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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user