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