404 lines
12 KiB
TypeScript
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;
|
|
}
|