/** * 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, MessageDocument } 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; scope: string; neutralize: boolean; } export interface WorkspaceFolder { id: string; name: string; parentId?: string; } export interface DataSource { id: string; connectionId: string; sourceType: string; path: string; label: string; /** Human-readable full path (service + folders); used for tooltips */ displayPath?: string; scope: string; neutralize: boolean; } export interface FeatureDataSource { id: string; featureInstanceId: string; featureCode: string; tableName: string; objectKey: string; label: string; mandateId: string; workspaceInstanceId: string; recordFilter?: Record; } 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[], options?: { requireNeutralization?: boolean }) => 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[]; /** * Hydrated chip-bar state for the WorkspaceInput. Set by ``loadWorkflow`` * to whatever the backend persisted for the chat (per-chat attachment * persistence). Sources that no longer exist are filtered out by the * WorkspaceInput before they're rendered. * * The `loadedNonce` increments on every load so the WorkspaceInput can * tell apart "same workflow, no change" from "user re-loaded the same * chat" and re-hydrate accordingly. */ loadedAttachedDataSourceIds: string[]; loadedAttachedFeatureDataSourceIds: string[]; loadedNonce: number; } 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 [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState([]); const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState([]); const [loadedNonce, setLoadedNonce] = useState(0); 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([]); setLoadedAttachedDataSourceIds([]); setLoadedAttachedFeatureDataSourceIds([]); api.get(`/api/workspace/${instanceId}/workflows/${wfId}/messages`) .then(res => { const msgs = (res.data.messages || []) .map((m: any) => _mapLoadedWorkspaceMessage(m, wfId)) .sort(_compareWorkspaceMessages); setMessages(msgs); const dsIds: string[] = Array.isArray(res.data.attachedDataSourceIds) ? res.data.attachedDataSourceIds.map((x: unknown) => String(x)) : []; const fdsIds: string[] = Array.isArray(res.data.attachedFeatureDataSourceIds) ? res.data.attachedFeatureDataSourceIds.map((x: unknown) => String(x)) : []; setLoadedAttachedDataSourceIds(dsIds); setLoadedAttachedFeatureDataSourceIds(fdsIds); setLoadedNonce(n => n + 1); }) .catch(() => {}); }, [instanceId]); const resetToNew = useCallback(() => { setWorkflowId(null); setMessages([]); setToolActivities([]); setPendingEdits([]); setAgentProgress(null); setDataSourceAccesses([]); setLoadedAttachedDataSourceIds([]); setLoadedAttachedFeatureDataSourceIds([]); setLoadedNonce(n => n + 1); }, []); const sendMessage = useCallback( (prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: string[] = [], featureDataSourceIds: string[] = [], options?: { requireNeutralization?: boolean }) => { 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, documents: _documentsFromFileIds(files, fileIds), documentsLabel: _attachmentLabelFromContext( dataSourceIds, featureDataSourceIds, dataSources, featureDataSources, ), }, ]); 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, }; if (workflowId) { body.workflowId = workflowId; } if (allowedProviders.length > 0) { body.allowedProviders = allowedProviders; } if (options?.requireNeutralization !== undefined) { body.requireNeutralization = options.requireNeutralization; } 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); const item = event.item as Record | undefined; let msg = event.content || 'Unknown error'; const subscriptionErrors = new Set([ 'SUBSCRIPTION_INACTIVE', 'SUBSCRIPTION_PAYMENT_REQUIRED', 'SUBSCRIPTION_PAYMENT_PENDING', 'SUBSCRIPTION_EXPIRED', ]); if (item && typeof item.error === 'string' && subscriptionErrors.has(item.error)) { msg = typeof item.message === 'string' ? item.message : msg; if (typeof item.subscriptionUiPath === 'string') { msg += `\n\n→ ${item.subscriptionUiPath}`; } } else if (item && item.error === 'INSUFFICIENT_BALANCE') { const preferDe = typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('de'); const de = typeof item.messageDe === 'string' ? item.messageDe : ''; const en = typeof item.messageEn === 'string' ? item.messageEn : ''; msg = preferDe ? de || en || msg : en || de || msg; if (item.userAction === 'TOP_UP_SELF' && typeof item.billingUiPath === 'string') { msg += `\n\n→ ${item.billingUiPath}`; } } else { msg = `Error: ${msg}`; } setMessages(prev => [ ...prev, { id: `error-${Date.now()}`, workflowId: '', role: 'system', message: msg, 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, files, dataSources, featureDataSources, ], ); 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, loadedAttachedDataSourceIds, loadedAttachedFeatureDataSourceIds, loadedNonce, }; } // --------------------------------------------------------------------------- // Internal: loaded message mapping & attachment display // --------------------------------------------------------------------------- function _mapLoadedWorkspaceMessage(m: Record, wfId: string): Message { const publishedAt = (typeof m.publishedAt === 'number' ? m.publishedAt : undefined) ?? (typeof m.createdAt === 'number' ? m.createdAt : undefined) ?? Date.now() / 1000; const docsRaw = Array.isArray(m.documents) ? m.documents : []; const documents: MessageDocument[] = docsRaw.map((d: any) => ({ id: String(d.id || `doc-${d.fileId}`), messageId: String(d.messageId || ''), fileId: String(d.fileId || ''), fileName: String(d.fileName || ''), mimeType: String(d.mimeType || 'application/octet-stream'), fileSize: Number(d.fileSize || 0), roundNumber: Number(d.roundNumber ?? 0), taskNumber: Number(d.taskNumber ?? 0), actionNumber: Number(d.actionNumber ?? 0), actionId: String(d.actionId || ''), })); return { id: String(m.id || `loaded-${Math.random()}`), workflowId: wfId, role: String(m.role || 'assistant'), message: String(m.content ?? m.message ?? ''), publishedAt, sequenceNr: typeof m.sequenceNr === 'number' ? m.sequenceNr : undefined, documents: documents.length ? documents : undefined, documentsLabel: typeof m.documentsLabel === 'string' ? m.documentsLabel : undefined, }; } function _compareWorkspaceMessages(a: Message, b: Message): number { const ta = (a.publishedAt || 0) - (b.publishedAt || 0); if (ta !== 0) return ta; const sa = (a.sequenceNr ?? 0) - (b.sequenceNr ?? 0); if (sa !== 0) return sa; return String(a.id).localeCompare(String(b.id)); } function _documentsFromFileIds(files: WorkspaceFile[], fileIds: string[]): MessageDocument[] | undefined { const out: MessageDocument[] = []; for (const fid of fileIds) { const f = files.find(x => x.id === fid); if (f) { out.push({ id: `local-${fid}-${Date.now()}`, messageId: '', fileId: f.id, fileName: f.fileName, mimeType: f.mimeType, fileSize: f.fileSize, roundNumber: 0, taskNumber: 0, actionNumber: 0, actionId: '', }); } } return out.length ? out : undefined; } function _attachmentLabelFromContext( dataSourceIds: string[], featureDataSourceIds: string[], dataSources: DataSource[], featureDataSources: FeatureDataSource[], ): string | undefined { const parts: string[] = []; const dsLabels = dataSourceIds .map(id => { const ds = dataSources.find(d => d.id === id); return ds?.label || ds?.path; }) .filter((x): x is string => Boolean(x)); if (dsLabels.length) parts.push(`Datenquellen: ${dsLabels.join(', ')}`); const fdsLabels = featureDataSourceIds .map(id => { const fds = featureDataSources.find(x => x.id === id); return fds ? `${fds.tableName} (${fds.label})` : ''; }) .filter(Boolean); if (fdsLabels.length) parts.push(`Feature-Daten: ${fdsLabels.join(', ')}`); return parts.length ? parts.join(' | ') : undefined; } // --------------------------------------------------------------------------- // 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; } }