/** * useWorkspace Hook * * Central state management for the Unified AI Workspace. * Manages SSE streaming, messages, files, folders, data sources, * tool activity, voice, and file previews via the shared sseClient. */ import { useState, useCallback, useRef, useEffect } from 'react'; import api from '../../../api'; import { startSseStream, SseEvent } from '../../../utils/sseClient'; import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes'; export interface AgentProgress { round: number; maxRounds?: number; totalAiCalls: number; totalToolCalls: number; costCHF: number; } export interface ToolActivity { id: string; toolName: string; status: 'calling' | 'success' | 'error'; args?: Record; result?: string; error?: string; timestamp: number; } export interface WorkspaceFile { id: string; fileName: string; mimeType: string; fileSize: number; tags?: string[]; folderId?: string; status?: string; description?: string; featureInstanceId?: string; featureInstanceLabel?: string; } export interface WorkspaceFolder { id: string; name: string; parentId?: string; } export interface DataSource { id: string; connectionId: string; sourceType: string; path: string; label: string; } export interface FeatureDataSource { id: string; featureInstanceId: string; featureCode: string; tableName: string; objectKey: string; label: string; mandateId: string; workspaceInstanceId: string; } export interface FileEditProposal { id: string; fileId: string; fileName: string; mimeType?: string; oldContent?: string; newContent?: string; oldSize?: number; newSize?: number; status: 'pending' | 'accepted' | 'rejected'; } export interface DataSourceAccessEvent { sourceType: string; label: string; path: string; action: string; } interface UseWorkspaceReturn { messages: Message[]; isProcessing: boolean; sendMessage: (prompt: string, fileIds?: string[], dataSourceIds?: string[], allowedProviders?: string[], featureDataSourceIds?: string[]) => void; stopProcessing: () => void; loadWorkflow: (workflowId: string) => void; resetToNew: () => void; files: WorkspaceFile[]; folders: WorkspaceFolder[]; dataSources: DataSource[]; featureDataSources: FeatureDataSource[]; refreshFeatureDataSources: () => void; agentProgress: AgentProgress | null; toolActivities: ToolActivity[]; pendingEdits: FileEditProposal[]; acceptEdit: (editId: string) => void; rejectEdit: (editId: string) => void; workflowId: string | null; workflowVersion: number; refreshFiles: () => void; refreshFolders: () => void; refreshDataSources: () => void; dataSourceAccesses: DataSourceAccessEvent[]; } export function useWorkspace(instanceId: string): UseWorkspaceReturn { const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [files, setFiles] = useState([]); const [folders, setFolders] = useState([]); const [dataSources, setDataSources] = useState([]); const [featureDataSources, setFeatureDataSources] = useState([]); const [agentProgress, setAgentProgress] = useState(null); const [toolActivities, setToolActivities] = useState([]); const [pendingEdits, setPendingEdits] = useState([]); const [workflowId, setWorkflowId] = useState(null); const [workflowVersion, setWorkflowVersion] = useState(0); const [dataSourceAccesses, setDataSourceAccesses] = useState([]); const cleanupRef = useRef<(() => void) | null>(null); const refreshFiles = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/files`) .then(res => setFiles(res.data.files || [])) .catch(err => console.error('Failed to load workspace files:', err)); }, [instanceId]); const refreshFolders = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/folders`) .then(res => setFolders(res.data.folders || [])) .catch(err => console.error('Failed to load workspace folders:', err)); }, [instanceId]); const refreshDataSources = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/datasources`) .then(res => setDataSources(res.data.dataSources || [])) .catch(() => {}); }, [instanceId]); const refreshFeatureDataSources = useCallback(() => { if (!instanceId) return; api.get(`/api/workspace/${instanceId}/feature-datasources`) .then(res => setFeatureDataSources(res.data.featureDataSources || [])) .catch(() => {}); }, [instanceId]); useEffect(() => { if (!instanceId) return; refreshFiles(); refreshFolders(); refreshDataSources(); refreshFeatureDataSources(); }, [instanceId, refreshFiles, refreshFolders, refreshDataSources, refreshFeatureDataSources]); const loadWorkflow = useCallback((wfId: string) => { if (!instanceId || !wfId) return; setWorkflowId(wfId); setMessages([]); setToolActivities([]); setPendingEdits([]); setAgentProgress(null); setDataSourceAccesses([]); api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`) .then(res => { const msgs = (res.data.messages || []).map((m: any) => ({ id: m.id || `loaded-${Math.random()}`, workflowId: wfId, role: m.role || 'assistant', message: m.content || m.message || '', publishedAt: m.createdAt || Date.now() / 1000, })); setMessages(msgs); }) .catch(() => {}); }, [instanceId]); const resetToNew = useCallback(() => { setWorkflowId(null); setMessages([]); setToolActivities([]); setPendingEdits([]); setAgentProgress(null); setDataSourceAccesses([]); }, []); const sendMessage = useCallback( (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = []) => { if (!instanceId || isProcessing) return; setIsProcessing(true); setAgentProgress(null); setToolActivities([]); setDataSourceAccesses([]); setMessages(prev => [ ...prev, { id: `user-${Date.now()}`, workflowId: workflowId || '', role: 'user', message: prompt, publishedAt: Date.now() / 1000, }, ]); if (cleanupRef.current) { cleanupRef.current(); } const baseURL = api.defaults.baseURL || ''; const url = `${baseURL}/api/workspace/${instanceId}/start/stream`; const body: Record = { prompt, fileIds, dataSourceIds, featureDataSourceIds, userLanguage: navigator.language?.slice(0, 2) || 'en', }; if (workflowId) { body.workflowId = workflowId; } if (allowedProviders.length > 0) { body.allowedProviders = allowedProviders; } cleanupRef.current = startSseStream({ url, body, handlers: { onMessage: (event) => _handleMessage(event, setMessages), onChunk: (event) => _handleChunk(event, setMessages), onStatus: (event) => _handleStatus(event, setMessages), onToolCall: (event) => _handleToolCall(event, setToolActivities), onToolResult: (event) => _handleToolResult(event, setToolActivities), onAgentProgress: (event) => setAgentProgress(event.item || event.data || null), onAgentSummary: (event) => { const s = event.item || event.data || {}; setMessages(prev => [ ...prev, { id: `summary-${Date.now()}`, workflowId: '', role: 'system', message: `Agent completed: ${s.rounds || '?'} rounds, ${s.totalToolCalls || 0} tool calls, ${s.costCHF || '?'} CHF`, publishedAt: Date.now() / 1000, }, ]); setAgentProgress(null); }, onFileEditProposal: (event) => { const data = event.item || event.data || {}; if (data.id) { setPendingEdits(prev => [...prev, { id: data.id, fileId: data.fileId || '', fileName: data.fileName || '', mimeType: data.mimeType || '', oldSize: data.oldSize || 0, newSize: data.newSize || 0, status: 'pending' as const, }]); } }, onFileVersion: (event) => { const data = event.item || event.data || {}; if (data.fileId) { setPendingEdits(prev => prev.map(e => e.fileId === data.fileId ? { ...e, status: 'accepted' as const } : e, ), ); refreshFiles(); } }, onFileCreated: (event) => { refreshFiles(); const data = event.item || event.data || {}; if (data.fileId && data.fileName) { setMessages(prev => [ ...prev, { id: `file-${data.fileId}-${Date.now()}`, workflowId: '', role: 'assistant', message: '', publishedAt: Date.now() / 1000, documents: [{ id: data.fileId, messageId: '', fileId: data.fileId, fileName: data.fileName, mimeType: data.mimeType || 'application/octet-stream', fileSize: data.fileSize || 0, roundNumber: 0, taskNumber: 0, actionNumber: 0, actionId: '', }], }, ]); } }, onDataSourceAccess: (event) => { const data = event.item || event.data || {}; setDataSourceAccesses(prev => [...prev, { sourceType: data.sourceType || '', label: data.label || '', path: data.path || '', action: data.action || 'access', }]); }, onVoiceResponse: (event) => { const audioUrl = _buildAudioUrl(event); if (audioUrl) { const lang = event.item?.language || event.data?.language || ''; const charCount = event.item?.charCount || event.data?.charCount || 0; setMessages(prev => [ ...prev, { id: `voice-${Date.now()}`, role: 'assistant', message: '', _audioUrl: audioUrl, _audioLang: lang, _audioCharCount: charCount, } as any, ]); } }, onWorkflowUpdated: (event) => { if (event.workflowId) setWorkflowId(event.workflowId); setWorkflowVersion(v => v + 1); }, onComplete: (event) => { setIsProcessing(false); if (event.workflowId) setWorkflowId(event.workflowId); }, onStopped: () => setIsProcessing(false), onError: (event) => { setIsProcessing(false); setMessages(prev => [ ...prev, { id: `error-${Date.now()}`, workflowId: '', role: 'system', message: `Error: ${event.content || 'Unknown error'}`, publishedAt: Date.now() / 1000, }, ]); }, }, onConnectionError: (err) => { setIsProcessing(false); setMessages(prev => [ ...prev, { id: `error-${Date.now()}`, workflowId: '', role: 'system', message: `Connection error: ${err.message}`, publishedAt: Date.now() / 1000, }, ]); }, onStreamEnd: () => setIsProcessing(false), }); }, [instanceId, isProcessing, workflowId, refreshFiles], ); const stopProcessing = useCallback(() => { if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; } if (instanceId && workflowId) { api.post(`/api/workspace/${instanceId}/${workflowId}/stop`).catch(console.error); } setIsProcessing(false); }, [instanceId, workflowId]); const acceptEdit = useCallback( (editId: string) => { if (!instanceId) return; setPendingEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)), ); api.post(`/api/workspace/${instanceId}/edit/${editId}/accept`) .then(() => refreshFiles()) .catch(err => { console.error('Failed to accept edit:', err); setPendingEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)), ); }); }, [instanceId, refreshFiles], ); const rejectEdit = useCallback( (editId: string) => { if (!instanceId) return; setPendingEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)), ); api.post(`/api/workspace/${instanceId}/edit/${editId}/reject`) .catch(err => { console.error('Failed to reject edit:', err); setPendingEdits(prev => prev.map(e => (e.id === editId ? { ...e, status: 'pending' as const } : e)), ); }); }, [instanceId], ); return { messages, isProcessing, sendMessage, stopProcessing, loadWorkflow, resetToNew, files, folders, dataSources, featureDataSources, refreshFeatureDataSources, agentProgress, toolActivities, pendingEdits, acceptEdit, rejectEdit, workflowId, workflowVersion, refreshFiles, refreshFolders, refreshDataSources, dataSourceAccesses, }; } // --------------------------------------------------------------------------- // Internal event handlers // --------------------------------------------------------------------------- function _handleMessage( event: SseEvent, setMessages: React.Dispatch>, ) { const item = event.item || {}; setMessages(prev => [ ...prev, { id: item.id || `msg-${Date.now()}-${Math.random()}`, workflowId: item.workflowId || '', role: item.role || 'assistant', message: item.content || event.content || '', publishedAt: item.createdAt || Date.now() / 1000, documents: item.documents, }, ]); } function _handleChunk( event: SseEvent, setMessages: React.Dispatch>, ) { const chunkText = event.content || ''; if (!chunkText) return; setMessages(prev => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === 'assistant' && lastMsg.id?.startsWith('stream-')) { return [ ...prev.slice(0, -1), { ...lastMsg, message: lastMsg.message + chunkText }, ]; } return [ ...prev, { id: `stream-${Date.now()}`, workflowId: '', role: 'assistant', message: chunkText, publishedAt: Date.now() / 1000, }, ]; }); } function _handleStatus( event: SseEvent, setMessages: React.Dispatch>, ) { setMessages(prev => { const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status'; const statusMsg: Message = { id: `status-${Date.now()}`, workflowId: '', role: 'status', message: event.label || event.content || '', publishedAt: Date.now() / 1000, }; return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg]; }); } function _handleToolCall( event: SseEvent, setToolActivities: React.Dispatch>, ) { const data = event.item || event.data || {}; setToolActivities(prev => [ ...prev, { id: `tc-${Date.now()}-${Math.random()}`, toolName: data.toolName || 'unknown', status: 'calling', args: data.args, timestamp: Date.now(), }, ]); } function _handleToolResult( event: SseEvent, setToolActivities: React.Dispatch>, ) { const data = event.item || event.data || {}; setToolActivities(prev => { const idx = [...prev].reverse().findIndex(t => t.toolName === data.toolName && t.status === 'calling'); if (idx >= 0) { const realIdx = prev.length - 1 - idx; const updated = [...prev]; updated[realIdx] = { ...updated[realIdx], status: data.success ? 'success' : 'error', result: data.data, error: data.error, }; return updated; } return [ ...prev, { id: `tr-${Date.now()}-${Math.random()}`, toolName: data.toolName || 'unknown', status: data.success ? 'success' : 'error', result: data.data, error: data.error, timestamp: Date.now(), }, ]; }); } function _buildAudioUrl(event: SseEvent): string | null { const audioData = event.item?.audio || event.data?.audio; if (!audioData) return null; try { const byteChars = atob(audioData); const byteArray = new Uint8Array(byteChars.length); for (let i = 0; i < byteChars.length; i++) { byteArray[i] = byteChars.charCodeAt(i); } const blob = new Blob([byteArray], { type: 'audio/mp3' }); return URL.createObjectURL(blob); } catch (err) { console.error('Failed to decode voice response:', err); return null; } }