Files
leocrm/frontend/src/hooks/useAIUIControl.ts
T

404 lines
12 KiB
TypeScript

/**
* 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 = (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;
}