frontend_nyla/src/pages/views/workspace/useWorkspace.ts
2026-04-21 00:50:42 +02:00

754 lines
24 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, 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<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;
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<string, 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[], 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<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 [loadedAttachedDataSourceIds, setLoadedAttachedDataSourceIds] = useState<string[]>([]);
const [loadedAttachedFeatureDataSourceIds, setLoadedAttachedFeatureDataSourceIds] = useState<string[]>([]);
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<string, any> = {
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<string, unknown> | 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<string, unknown>, 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<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;
}
}