/** * 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(null); const reconnectTimeout = useRef(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 & { 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 = (event) => { setConnected(false); console.log('AI UI Control WebSocket disconnected'); // Don't reconnect on 403 (forbidden) — user lacks permission if (event.code === 3003 || event.code === 1008) { console.warn('AI UI Control WebSocket closed with forbidden code — not reconnecting'); return; } 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; }