513 lines
15 KiB
TypeScript
513 lines
15 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;
|
|
}
|
|
|
|
export interface WorkspaceFolder {
|
|
id: string;
|
|
name: string;
|
|
parentId?: string;
|
|
}
|
|
|
|
export interface DataSource {
|
|
id: string;
|
|
connectionId: string;
|
|
sourceType: string;
|
|
path: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface FileEditProposal {
|
|
id: string;
|
|
fileId: string;
|
|
fileName: string;
|
|
newContent: string;
|
|
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[]) => void;
|
|
stopProcessing: () => void;
|
|
loadWorkflow: (workflowId: string) => void;
|
|
files: WorkspaceFile[];
|
|
folders: WorkspaceFolder[];
|
|
dataSources: DataSource[];
|
|
agentProgress: AgentProgress | null;
|
|
toolActivities: ToolActivity[];
|
|
pendingEdits: FileEditProposal[];
|
|
acceptEdit: (editId: string) => void;
|
|
rejectEdit: (editId: string) => void;
|
|
workflowId: string | null;
|
|
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 [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
|
const [toolActivities, setToolActivities] = useState<ToolActivity[]>([]);
|
|
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
|
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
refreshFiles();
|
|
refreshFolders();
|
|
refreshDataSources();
|
|
}, [instanceId, refreshFiles, refreshFolders, refreshDataSources]);
|
|
|
|
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 sendMessage = useCallback(
|
|
(prompt: string, fileIds: string[] = [], dataSourceIds: string[] = [], allowedProviders: 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,
|
|
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) => {
|
|
if (event.item) {
|
|
setPendingEdits(prev => [...prev, event.item]);
|
|
}
|
|
},
|
|
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,
|
|
]);
|
|
}
|
|
},
|
|
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) => {
|
|
const edit = pendingEdits.find(e => e.id === editId);
|
|
if (!edit || !instanceId || !workflowId) return;
|
|
setPendingEdits(prev =>
|
|
prev.map(e => (e.id === editId ? { ...e, status: 'accepted' as const } : e)),
|
|
);
|
|
refreshFiles();
|
|
},
|
|
[pendingEdits, instanceId, workflowId, refreshFiles],
|
|
);
|
|
|
|
const rejectEdit = useCallback((editId: string) => {
|
|
setPendingEdits(prev =>
|
|
prev.map(e => (e.id === editId ? { ...e, status: 'rejected' as const } : e)),
|
|
);
|
|
}, []);
|
|
|
|
return {
|
|
messages,
|
|
isProcessing,
|
|
sendMessage,
|
|
stopProcessing,
|
|
loadWorkflow,
|
|
files,
|
|
folders,
|
|
dataSources,
|
|
agentProgress,
|
|
toolActivities,
|
|
pendingEdits,
|
|
acceptEdit,
|
|
rejectEdit,
|
|
workflowId,
|
|
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;
|
|
}
|
|
}
|