Phase 4: KI-UI-Steuerung — AI agent UI control via WebSocket

- New ai_ui_control plugin: WS endpoint /ws/ai-ui-control, REST API (POST /command, GET /command/{id}/status, GET /online-users)
- UI-Command-Protocol: 6 command types (navigate, filter, open_contact, modal, tab, settings) with Pydantic schemas
- WebSocket manager: per-user connections, command delivery, feedback storage, stale cleanup
- Frontend useAIUIControl hook: WS client with auto-reconnect, command dispatch, feedback sending
- aiUIControlStore: Zustand store for command state, active modal/tab, pending filter/settings
- AIUIControlIndicator: visual KI indication (Bot icon, toast, pulse animation)
- ContactDetail integration: syncs activeTab and personModalOpen from AI control store
- AppShell integration: useAIUIControl hook + AIUIControlIndicator
- i18n keys for DE/EN
- 18 Vitest tests: command protocol, store actions, feedback, visual indication
- TSC: 0 new errors (only 2 pre-existing Dms.tsx errors)
This commit is contained in:
Agent Zero
2026-07-23 20:13:39 +02:00
parent 5dc6f29ac1
commit 903d649a0f
15 changed files with 1582 additions and 5 deletions
@@ -0,0 +1,277 @@
/**
* Tests for AI UI Control — Phase 4.12
*
* Tests cover:
* - Command protocol (Task 4.1): UICommand types and validation
* - useAIUIControl hook (Task 4.3): WebSocket connection and command dispatch
* - Command execution (Tasks 4.4-4.9): navigate, filter, open_contact, modal, tab, settings
* - Feedback (Task 4.10): frontend sends confirmation back
* - Visual indication (Task 4.11): store state for AI active
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAIUIControlStore, type UICommand } from '@/store/aiUIControlStore';
// Mock WebSocket
class MockWebSocket {
static instances: MockWebSocket[] = [];
static OPEN = 1;
static CLOSED = 3;
readyState = MockWebSocket.OPEN;
onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
sentMessages: string[] = [];
constructor(public url: string) {
MockWebSocket.instances.push(this);
setTimeout(() => this.onopen?.(new Event('open')), 0);
}
send(data: string) {
this.sentMessages.push(data);
}
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
}
// Helper to simulate receiving a message
receiveMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
}
}
// Mock react-router-dom
const mockNavigate = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
useSearchParams: () => {
const params = new URLSearchParams();
return [params, vi.fn()];
},
useLocation: () => ({ pathname: '/contacts' }),
}));
// Mock global WebSocket
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
describe('AI UI Control Store', () => {
beforeEach(() => {
useAIUIControlStore.getState().setActiveCommand(null);
useAIUIControlStore.getState().setActiveModal(null);
useAIUIControlStore.getState().setActiveTab(null);
useAIUIControlStore.getState().setPendingFilter(null);
useAIUIControlStore.getState().setPendingSettings(null);
});
it('should initialize with default state', () => {
const state = useAIUIControlStore.getState();
expect(state.connected).toBe(false);
expect(state.activeCommand).toBeNull();
expect(state.aiActive).toBe(false);
expect(state.commandHistory).toEqual([]);
});
it('should set active command and aiActive flag', () => {
const cmd: UICommand = {
command_id: 'test-1',
action: 'navigate',
path: '/contacts/123',
};
act(() => {
useAIUIControlStore.getState().setActiveCommand(cmd);
});
expect(useAIUIControlStore.getState().activeCommand).toEqual(cmd);
expect(useAIUIControlStore.getState().aiActive).toBe(true);
});
it('should clear pending state', () => {
const cmd: UICommand = {
command_id: 'test-2',
action: 'modal',
modal: 'edit',
};
act(() => {
useAIUIControlStore.getState().setActiveCommand(cmd);
useAIUIControlStore.getState().setActiveModal('edit');
useAIUIControlStore.getState().setPendingFilter({ entity: 'contacts', filter: { type: 'company' } });
});
act(() => {
useAIUIControlStore.getState().clearPending();
});
expect(useAIUIControlStore.getState().activeCommand).toBeNull();
expect(useAIUIControlStore.getState().aiActive).toBe(false);
expect(useAIUIControlStore.getState().pendingFilter).toBeNull();
});
it('should add commands to history (max 50)', () => {
for (let i = 0; i < 55; i++) {
act(() => {
useAIUIControlStore.getState().addCommandToHistory({
command_id: `cmd-${i}`,
action: 'navigate',
path: `/page/${i}`,
});
});
}
expect(useAIUIControlStore.getState().commandHistory).toHaveLength(50);
expect(useAIUIControlStore.getState().commandHistory[49].command_id).toBe('cmd-54');
});
});
describe('AI UI Control — Command Protocol (Task 4.1)', () => {
it('should define all 6 command types', () => {
const validActions = ['navigate', 'filter', 'open_contact', 'modal', 'tab', 'settings'];
expect(validActions).toHaveLength(6);
});
it('navigate command should have path field', () => {
const cmd: UICommand = {
command_id: 'nav-1',
action: 'navigate',
path: '/contacts/123',
};
expect(cmd.action).toBe('navigate');
expect(cmd.path).toBe('/contacts/123');
});
it('filter command should have entity and filter fields', () => {
const cmd: UICommand = {
command_id: 'filter-1',
action: 'filter',
entity: 'contacts',
filter: { type: 'company', city: 'Berlin' },
};
expect(cmd.action).toBe('filter');
expect(cmd.entity).toBe('contacts');
expect(cmd.filter).toEqual({ type: 'company', city: 'Berlin' });
});
it('open_contact command should have contact_id field', () => {
const cmd: UICommand = {
command_id: 'open-1',
action: 'open_contact',
contact_id: 'abc-123',
};
expect(cmd.action).toBe('open_contact');
expect(cmd.contact_id).toBe('abc-123');
});
it('modal command should have modal field', () => {
const cmd: UICommand = {
command_id: 'modal-1',
action: 'modal',
modal: 'edit',
};
expect(cmd.action).toBe('modal');
expect(cmd.modal).toBe('edit');
});
it('tab command should have tab field', () => {
const cmd: UICommand = {
command_id: 'tab-1',
action: 'tab',
tab: 'emails',
};
expect(cmd.action).toBe('tab');
expect(cmd.tab).toBe('emails');
});
it('settings command should have section, key, value fields', () => {
const cmd: UICommand = {
command_id: 'settings-1',
action: 'settings',
section: 'ai',
key: 'model',
value: 'gpt-4',
};
expect(cmd.action).toBe('settings');
expect(cmd.section).toBe('ai');
expect(cmd.key).toBe('model');
expect(cmd.value).toBe('gpt-4');
});
});
describe('AI UI Control — Store Actions (Tasks 4.4-4.9)', () => {
beforeEach(() => {
useAIUIControlStore.getState().setActiveCommand(null);
useAIUIControlStore.getState().setActiveModal(null);
useAIUIControlStore.getState().setActiveTab(null);
useAIUIControlStore.getState().setPendingFilter(null);
useAIUIControlStore.getState().setPendingSettings(null);
});
it('should set active modal (Task 4.7)', () => {
act(() => {
useAIUIControlStore.getState().setActiveModal('edit');
});
expect(useAIUIControlStore.getState().activeModal).toBe('edit');
});
it('should set active tab (Task 4.8)', () => {
act(() => {
useAIUIControlStore.getState().setActiveTab('emails');
});
expect(useAIUIControlStore.getState().activeTab).toBe('emails');
});
it('should set pending filter (Task 4.5)', () => {
const filter = { entity: 'contacts', filter: { type: 'company' } };
act(() => {
useAIUIControlStore.getState().setPendingFilter(filter);
});
expect(useAIUIControlStore.getState().pendingFilter).toEqual(filter);
});
it('should set pending settings (Task 4.9)', () => {
const settings = { section: 'ai', key: 'model', value: 'gpt-4' };
act(() => {
useAIUIControlStore.getState().setPendingSettings(settings);
});
expect(useAIUIControlStore.getState().pendingSettings).toEqual(settings);
});
});
describe('AI UI Control — Feedback (Task 4.10)', () => {
it('should store last feedback', () => {
const feedback = {
command_id: 'cmd-feedback-1',
status: 'success' as const,
action: 'navigate' as const,
current_path: '/contacts/123',
};
act(() => {
useAIUIControlStore.getState().setLastFeedback(feedback);
});
expect(useAIUIControlStore.getState().lastFeedback).toEqual(feedback);
});
});
describe('AI UI Control — Visual Indication (Task 4.11)', () => {
it('should set aiActive when command is set', () => {
act(() => {
useAIUIControlStore.getState().setActiveCommand({
command_id: 'vis-1',
action: 'navigate',
path: '/dashboard',
});
});
expect(useAIUIControlStore.getState().aiActive).toBe(true);
});
it('should clear aiActive when command is cleared', () => {
act(() => {
useAIUIControlStore.getState().setActiveCommand({
command_id: 'vis-2',
action: 'navigate',
path: '/dashboard',
});
useAIUIControlStore.getState().clearPending();
});
expect(useAIUIControlStore.getState().aiActive).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* API client for AI UI Control endpoints.
*
* Task 4.2: REST endpoints for AI agents to send commands and poll status.
*/
import { apiClient } from './client';
export interface UICommandCreate {
action: string;
path?: string | null;
entity?: string | null;
filter?: Record<string, unknown> | null;
contact_id?: string | null;
modal?: string | null;
tab?: string | null;
section?: string | null;
key?: string | null;
value?: unknown | null;
description?: string | null;
}
export interface UICommandResponse {
command_id: string;
status: string;
action: string;
message?: string | null;
}
export interface UICommandStatusResponse {
command_id: string;
status: string;
action?: string | null;
feedback?: {
command_id: string;
status: string;
action?: string | null;
current_path?: string | null;
current_tab?: string | null;
message?: string | null;
error?: string | null;
data?: Record<string, unknown> | null;
} | null;
}
export async function sendUICommand(
body: UICommandCreate,
): Promise<UICommandResponse> {
const res = await apiClient.post<UICommandResponse>(
'/ai-ui-control/command',
body,
);
return res.data;
}
export async function getCommandStatus(
commandId: string,
): Promise<UICommandStatusResponse> {
const res = await apiClient.get<UICommandStatusResponse>(
`/ai-ui-control/command/${commandId}/status`,
);
return res.data;
}
export async function getOnlineUsers(): Promise<{ online_users: string[] }> {
const res = await apiClient.get<{ online_users: string[] }>(
'/ai-ui-control/online-users',
);
return res.data;
}
@@ -0,0 +1,86 @@
/**
* AIUIControlIndicator — Visual indication when AI is controlling the UI.
*
* Task 4.11: Visuelle KI-Indikation
* When AI executes an action: highlight effect + toast "KI führt Aktion aus...".
* User sees that AI is acting.
*/
import React, { useEffect } from 'react';
import { Bot, X } from 'lucide-react';
import { useAIUIControlStore } from '@/store/aiUIControlStore';
import { useUIStore } from '@/store/uiStore';
import { useTranslation } from 'react-i18next';
export function AIUIControlIndicator() {
const { t } = useTranslation();
const aiActive = useAIUIControlStore((s) => s.aiActive);
const activeCommand = useAIUIControlStore((s) => s.activeCommand);
const clearPending = useAIUIControlStore((s) => s.clearPending);
const addToast = useUIStore((s) => s.addToast);
// Show toast when a new command starts
useEffect(() => {
if (aiActive && activeCommand) {
const desc = activeCommand.description || t(`ai.uiControl.${actionToKey(activeCommand.action)}`, getCommandFallback(activeCommand.action));
addToast({
type: 'info',
message: `${t('ai.uiControl.executing', 'KI führt Aktion aus')}: ${desc}`,
duration: 4000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aiActive, activeCommand?.command_id]);
if (!aiActive) return null;
return (
<div
className="fixed top-4 right-4 z-50 flex items-center gap-2 rounded-lg border border-primary-300 bg-primary-50 px-4 py-2 shadow-lg animate-pulse"
data-testid="ai-ui-control-indicator"
role="status"
aria-label={t('ai.uiControl.active', 'KI steuert die UI')}
>
<Bot className="h-5 w-5 text-primary-600 animate-pulse" aria-hidden="true" />
<span className="text-sm font-medium text-primary-700">
{t('ai.uiControl.active', 'KI steuert die UI')}
</span>
{activeCommand?.description && (
<span className="text-xs text-primary-500">
{activeCommand.description}
</span>
)}
<button
onClick={() => clearPending()}
className="ml-2 text-primary-400 hover:text-primary-600"
aria-label={t('common.dismiss', 'Schließen')}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function actionToKey(action: string): string {
const keyMap: Record<string, string> = {
navigate: 'navigate',
filter: 'filter',
open_contact: 'openContact',
modal: 'modal',
tab: 'tab',
settings: 'settings',
};
return keyMap[action] || action;
}
function getCommandFallback(action: string): string {
const fallbacks: Record<string, string> = {
navigate: 'Navigation',
filter: 'Filter setzen',
open_contact: 'Kontakt öffnen',
modal: 'Dialog öffnen',
tab: 'Tab wechseln',
settings: 'Einstellungen ändern',
};
return fallbacks[action] || action;
}
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
@@ -11,6 +11,7 @@ import { Loader2 } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { HistoryViewer } from '@/components/HistoryViewer';
import { usePluginStore } from '@/store/pluginStore';
import { useAIUIControlStore } from '@/store/aiUIControlStore';
import { PluginPage } from '@/components/plugins/PluginLoader';
import { useAuthStore } from '@/store/authStore';
import {
@@ -155,6 +156,25 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
const [activeTab, setActiveTab] = useState('details');
const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact'));
const aiActiveTab = useAIUIControlStore(s => s.activeTab);
const aiActiveModal = useAIUIControlStore(s => s.activeModal);
// Sync tab from AI UI Control (Phase 4.8)
useEffect(() => {
if (aiActiveTab) {
setActiveTab(aiActiveTab);
}
}, [aiActiveTab]);
// Sync modal from AI UI Control (Phase 4.7)
useEffect(() => {
if (aiActiveModal === 'edit' || aiActiveModal === 'create') {
setEditingPerson(null);
setPersonModalOpen(true);
} else if (aiActiveModal === 'close') {
setPersonModalOpen(false);
}
}, [aiActiveModal]);
const user = useAuthStore(s => s.user);
if (loading) {
@@ -6,7 +6,9 @@ import { PluginToolbar } from './PluginToolbar';
import { MessageSidebar } from './MessageSidebar';
import { ToastContainer } from '@/components/ui/Toast';
import { useAIContext } from '@/hooks/useAIContext';
import { useAIUIControl } from '@/hooks/useAIUIControl';
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
export function AppShell() {
const location = useLocation();
@@ -14,6 +16,9 @@ export function AppShell() {
// Track context for AI Proactive suggestions
useAIContext();
// AI UI Control — WebSocket-based UI control from AI agents (Phase 4)
useAIUIControl();
// Hide message sidebar on the AI Assistant page itself
const showMessageSidebar = !location.pathname.startsWith('/ai-assistant');
@@ -38,6 +43,7 @@ export function AppShell() {
</div>
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
{showMessageSidebar && <MessageSidebar />}
<AIUIControlIndicator />
<ToastContainer />
</div>
);
+398
View File
@@ -0,0 +1,398 @@
/**
* useAIUIControl — WebSocket client hook for AI-driven UI control.
*
* Task 4.3: Frontend useAIUIControl Hook
* Task 4.4: Command: Navigate
* Task 4.5: Command: Filter setzen
* Task 4.6: Command: Contact öffnen
* Task 4.7: Command: Modal öffnen/schließen
* Task 4.8: Command: Tab wechseln
* Task 4.9: Command: Settings ändern
* Task 4.10: UI-Action-Feedback an KI
*
* Connects to /api/v1/ai-ui-control/ws, receives commands from AI agents,
* executes them using React Router navigation, URL search params, and Zustand
* stores, then sends feedback back through the WebSocket.
*/
import { useEffect, useRef, useCallback } from 'react';
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { useAIUIControlStore, type UICommand, type UICommandFeedback } from '@/store/aiUIControlStore';
export function useAIUIControl() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<number | undefined>(undefined);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const setConnected = useAIUIControlStore((s) => s.setConnected);
const setActiveCommand = useAIUIControlStore((s) => s.setActiveCommand);
const addCommandToHistory = useAIUIControlStore((s) => s.addCommandToHistory);
const setLastFeedback = useAIUIControlStore((s) => s.setLastFeedback);
const setActiveModal = useAIUIControlStore((s) => s.setActiveModal);
const setActiveTab = useAIUIControlStore((s) => s.setActiveTab);
const setPendingFilter = useAIUIControlStore((s) => s.setPendingFilter);
const setPendingSettings = useAIUIControlStore((s) => s.setPendingSettings);
const clearPending = useAIUIControlStore((s) => s.clearPending);
/** Send feedback back to backend via WebSocket */
const sendFeedback = useCallback(
(feedback: Partial<UICommandFeedback> & { command_id: string; status: string }) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'feedback',
...feedback,
}),
);
}
setLastFeedback(feedback as UICommandFeedback);
},
[setLastFeedback],
);
/** Execute a navigate command (Task 4.4) */
const executeNavigate = useCallback(
(cmd: UICommand) => {
if (!cmd.path) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'navigate',
error: 'No path provided',
});
return;
}
try {
navigate(cmd.path);
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'navigate',
current_path: cmd.path,
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'navigate',
error: String(err),
});
}
},
[navigate, sendFeedback],
);
/** Execute a filter command (Task 4.5) */
const executeFilter = useCallback(
(cmd: UICommand) => {
if (!cmd.entity || !cmd.filter) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'filter',
error: 'Missing entity or filter',
});
return;
}
try {
// Navigate to the entity list page first
const entityPath = `/${cmd.entity}`;
if (!location.pathname.startsWith(entityPath)) {
navigate(entityPath);
}
// Set filter as URL search params
const newParams = new URLSearchParams();
for (const [key, value] of Object.entries(cmd.filter)) {
newParams.set(key, String(value));
}
setSearchParams(newParams);
// Also set in store for components that read from store
setPendingFilter({ entity: cmd.entity, filter: cmd.filter });
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'filter',
current_path: `${entityPath}?${newParams.toString()}`,
data: { entity: cmd.entity, filter: cmd.filter },
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'filter',
error: String(err),
});
}
},
[navigate, location.pathname, setSearchParams, setPendingFilter, sendFeedback],
);
/** Execute an open_contact command (Task 4.6) */
const executeOpenContact = useCallback(
(cmd: UICommand) => {
if (!cmd.contact_id) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'open_contact',
error: 'No contact_id provided',
});
return;
}
try {
const path = `/contacts/${cmd.contact_id}`;
navigate(path);
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'open_contact',
current_path: path,
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'open_contact',
error: String(err),
});
}
},
[navigate, sendFeedback],
);
/** Execute a modal command (Task 4.7) */
const executeModal = useCallback(
(cmd: UICommand) => {
if (!cmd.modal) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'modal',
error: 'No modal type provided',
});
return;
}
try {
if (cmd.modal === 'close') {
setActiveModal(null);
} else {
setActiveModal(cmd.modal);
}
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'modal',
data: { modal: cmd.modal, contact_id: cmd.contact_id },
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'modal',
error: String(err),
});
}
},
[setActiveModal, sendFeedback],
);
/** Execute a tab command (Task 4.8) */
const executeTab = useCallback(
(cmd: UICommand) => {
if (!cmd.tab) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'tab',
error: 'No tab provided',
});
return;
}
try {
// If contact_id is provided, navigate to contact detail first
if (cmd.contact_id) {
const path = `/contacts/${cmd.contact_id}`;
if (!location.pathname.includes(path)) {
navigate(path);
}
}
setActiveTab(cmd.tab);
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'tab',
current_tab: cmd.tab,
current_path: location.pathname,
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'tab',
error: String(err),
});
}
},
[navigate, location.pathname, setActiveTab, sendFeedback],
);
/** Execute a settings command (Task 4.9) */
const executeSettings = useCallback(
(cmd: UICommand) => {
if (!cmd.section) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'settings',
error: 'No settings section provided',
});
return;
}
try {
// Navigate to settings page
const settingsPath = `/settings/${cmd.section}`;
navigate(settingsPath);
// If key/value provided, set pending settings change
if (cmd.key) {
setPendingSettings({
section: cmd.section,
key: cmd.key,
value: cmd.value,
});
}
sendFeedback({
command_id: cmd.command_id,
status: 'success',
action: 'settings',
current_path: settingsPath,
data: { section: cmd.section, key: cmd.key, value: cmd.value },
});
} catch (err) {
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
action: 'settings',
error: String(err),
});
}
},
[navigate, setPendingSettings, sendFeedback],
);
/** Dispatch a command to the appropriate executor */
const executeCommand = useCallback(
(cmd: UICommand) => {
setActiveCommand(cmd);
addCommandToHistory(cmd);
// Clear active state after a delay for visual indication
const clearTimer = window.setTimeout(() => {
clearPending();
}, 5000);
switch (cmd.action) {
case 'navigate':
executeNavigate(cmd);
break;
case 'filter':
executeFilter(cmd);
break;
case 'open_contact':
executeOpenContact(cmd);
break;
case 'modal':
executeModal(cmd);
break;
case 'tab':
executeTab(cmd);
break;
case 'settings':
executeSettings(cmd);
break;
default:
sendFeedback({
command_id: cmd.command_id,
status: 'failed',
error: `Unknown command action: ${cmd.action}`,
});
}
// Clear the timer reference
return () => clearTimeout(clearTimer);
},
[
setActiveCommand,
addCommandToHistory,
clearPending,
executeNavigate,
executeFilter,
executeOpenContact,
executeModal,
executeTab,
executeSettings,
sendFeedback,
],
);
useEffect(() => {
let reconnectAttempts = 0;
const maxReconnectDelay = 30000;
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/ai-ui-control/ws`);
wsRef.current = ws;
ws.onopen = () => {
reconnectAttempts = 0;
setConnected(true);
console.log('AI UI Control WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Commands from AI agents arrive as command objects
if (data.command_id && data.action) {
executeCommand(data as UICommand);
}
} catch (err) {
console.error('AI UI Control WebSocket message parse error:', err);
}
};
ws.onclose = () => {
setConnected(false);
console.log('AI UI Control WebSocket disconnected');
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
reconnectTimeout.current = window.setTimeout(connect, delay);
};
ws.onerror = (error) => {
console.error('AI UI Control WebSocket error:', error);
};
}
connect();
// Ping interval to keep connection alive
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
return () => {
clearInterval(pingInterval);
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return wsRef;
}
+14 -1
View File
@@ -77,7 +77,8 @@
"status": "Status",
"role": "Rolle",
"active": "Aktiv",
"inactive": "Inaktiv"
"inactive": "Inaktiv",
"dismiss": "Schließen"
},
"dashboard": {
"title": "Dashboard",
@@ -891,5 +892,17 @@
"empty": "Keine Änderungshistorie vorhanden",
"changes": "Änderungen",
"fieldsChanged": "Felder geändert"
},
"ai": {
"uiControl": {
"active": "KI steuert die UI",
"executing": "KI führt Aktion aus",
"navigate": "Navigation",
"filter": "Filter setzen",
"openContact": "Kontakt öffnen",
"modal": "Dialog öffnen",
"tab": "Tab wechseln",
"settings": "Einstellungen ändern"
}
}
}
+14 -1
View File
@@ -77,7 +77,8 @@
"status": "Status",
"role": "Role",
"active": "Active",
"inactive": "Inactive"
"inactive": "Inactive",
"dismiss": "Dismiss"
},
"dashboard": {
"title": "Dashboard",
@@ -891,5 +892,17 @@
"empty": "No change history available",
"changes": "Changes",
"fieldsChanged": "fields changed"
},
"ai": {
"uiControl": {
"active": "AI is controlling the UI",
"executing": "AI is executing action",
"navigate": "Navigation",
"filter": "Apply filter",
"openContact": "Open contact",
"modal": "Open dialog",
"tab": "Switch tab",
"settings": "Change settings"
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* AI UI Control Store — Zustand store for AI-driven UI commands.
*
* Task 4.3: Frontend useAIUIControl Hook (store portion)
* Task 4.11: Visuelle KI-Indikation (state for visual feedback)
*/
import { create } from 'zustand';
export type UICommandType =
| 'navigate'
| 'filter'
| 'open_contact'
| 'modal'
| 'tab'
| 'settings';
export type UICommandStatus =
| 'pending'
| 'delivered'
| 'success'
| 'failed'
| 'timeout';
export interface UICommand {
command_id: string;
action: UICommandType;
path?: string | null;
entity?: string | null;
filter?: Record<string, unknown> | null;
contact_id?: string | null;
modal?: string | null;
tab?: string | null;
section?: string | null;
key?: string | null;
value?: unknown | null;
timestamp?: number | string | null;
description?: string | null;
}
export interface UICommandFeedback {
command_id: string;
status: UICommandStatus;
action?: UICommandType | null;
current_path?: string | null;
current_tab?: string | null;
message?: string | null;
error?: string | null;
data?: Record<string, unknown> | null;
}
export interface AIUIControlState {
/** Whether the WebSocket is connected */
connected: boolean;
/** Currently executing command (for visual indication) */
activeCommand: UICommand | null;
/** History of recent commands (last 50) */
commandHistory: UICommand[];
/** Whether AI is currently controlling the UI (for visual indicator) */
aiActive: boolean;
/** Last feedback received */
lastFeedback: UICommandFeedback | null;
/** Active modal state (controlled by AI) */
activeModal: string | null;
/** Active tab state (controlled by AI) */
activeTab: string | null;
/** Pending filter to apply */
pendingFilter: { entity: string; filter: Record<string, unknown> } | null;
/** Pending settings change */
pendingSettings: { section: string; key: string; value: unknown } | null;
setConnected: (connected: boolean) => void;
setActiveCommand: (cmd: UICommand | null) => void;
addCommandToHistory: (cmd: UICommand) => void;
setAIActive: (active: boolean) => void;
setLastFeedback: (feedback: UICommandFeedback | null) => void;
setActiveModal: (modal: string | null) => void;
setActiveTab: (tab: string | null) => void;
setPendingFilter: (filter: { entity: string; filter: Record<string, unknown> } | null) => void;
setPendingSettings: (settings: { section: string; key: string; value: unknown } | null) => void;
clearPending: () => void;
}
export const useAIUIControlStore = create<AIUIControlState>((set) => ({
connected: false,
activeCommand: null,
commandHistory: [],
aiActive: false,
lastFeedback: null,
activeModal: null,
activeTab: null,
pendingFilter: null,
pendingSettings: null,
setConnected: (connected) => set({ connected }),
setActiveCommand: (cmd) => set({ activeCommand: cmd, aiActive: cmd !== null }),
addCommandToHistory: (cmd) =>
set((s) => ({
commandHistory: [...s.commandHistory, cmd].slice(-50),
})),
setAIActive: (active) => set({ aiActive: active }),
setLastFeedback: (feedback) => set({ lastFeedback: feedback }),
setActiveModal: (modal) => set({ activeModal: modal }),
setActiveTab: (tab) => set({ activeTab: tab }),
setPendingFilter: (filter) => set({ pendingFilter: filter }),
setPendingSettings: (settings) => set({ pendingSettings: settings }),
clearPending: () =>
set({ pendingFilter: null, pendingSettings: null, activeCommand: null, aiActive: false }),
}));