frontend_nyla/src/pages/views/workspace/useWorkspace.ts
2026-03-18 23:04:59 +01:00

588 lines
18 KiB
TypeScript

/**
* 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<string, any>;
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<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [files, setFiles] = useState<WorkspaceFile[]>([]);
const [folders, setFolders] = useState<WorkspaceFolder[]>([]);
const [dataSources, setDataSources] = useState<DataSource[]>([]);
const [featureDataSources, setFeatureDataSources] = useState<FeatureDataSource[]>([]);
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [workflowVersion, setWorkflowVersion] = useState(0);
const [dataSourceAccesses, setDataSourceAccesses] = useState<DataSourceAccessEvent[]>([]);
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<string, any> = {
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<React.SetStateAction<Message[]>>,
) {
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<React.SetStateAction<Message[]>>,
) {
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<React.SetStateAction<Message[]>>,
) {
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<React.SetStateAction<ToolActivity[]>>,
) {
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<React.SetStateAction<ToolActivity[]>>,
) {
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;
}
}