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

650 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WorkspacePage -- Unified AI Workspace
*
* 3-column layout:
* Left sidebar: UnifiedDataBar (Chats, Files, Sources)
* Center: ChatStream + WorkspaceInput
* Right sidebar: FilePreview, ToolActivityLog
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace';
import { ChatStream } from './ChatStream';
import { WorkspaceInput } from './WorkspaceInput';
import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab, AddToChat_FileItem, AddToChat_FeatureSource } from '../../../components/UnifiedDataBar';
import api from '../../../api';
import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { useBilling } from '../../../hooks/useBilling';
import { useLanguage } from '../../../providers/language/LanguageContext';
function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const _onMouseDown = useCallback((e: React.MouseEvent, direction: 1 | -1 = 1) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
const _onMouseMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const delta = (ev.clientX - startX.current) * direction;
setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta)));
};
const _onMouseUp = () => {
dragging.current = false;
document.removeEventListener('mousemove', _onMouseMove);
document.removeEventListener('mouseup', _onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', _onMouseMove);
document.addEventListener('mouseup', _onMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [width, minWidth, maxWidth]);
return { width, onMouseDown: _onMouseDown };
}
type RightTab = 'activity' | 'preview';
interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
interface WorkspacePageProps {
persistentInstanceId?: string;
}
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
const { t } = useLanguage();
const { instance } = useCurrentInstance();
const instanceId = persistentInstanceId || instance?.id || '';
const workspace = useWorkspace(instanceId);
const fileOps = useFileOperations();
const navigate = useNavigate();
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
mandateId: string; featureCode: string; instanceId: string;
}>();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const _leftResize = _useResizable(280, 200, 450);
const _rightResize = _useResizable(320, 200, 500);
const [rightTab, setRightTab] = useState<RightTab>('activity');
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection());
const { allowedProviders } = useBilling();
const [isDragOver, setIsDragOver] = useState(false);
const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
const autoStartHandled = useRef(false);
const [isMobile, setIsMobile] = useState<boolean>(() =>
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
);
const [mobileLeftOpen, setMobileLeftOpen] = useState(false);
const [mobileRightOpen, setMobileRightOpen] = useState(false);
useEffect(() => {
const _handleResize = () => {
setIsMobile(window.innerWidth <= 1024);
};
_handleResize();
window.addEventListener('resize', _handleResize);
return () => window.removeEventListener('resize', _handleResize);
}, []);
useEffect(() => {
if (!isMobile) {
setMobileLeftOpen(false);
setMobileRightOpen(false);
}
}, [isMobile]);
useEffect(() => {
if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
const prompt = searchParams.get('prompt');
const autoStart = searchParams.get('autoStart') === 'true';
if (prompt) {
autoStartHandled.current = true;
setSearchParams({}, { replace: true });
if (autoStart) {
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
workspace.sendMessage(prompt, [], [], resolvedProviders, []);
} else {
setDraftAppend(prompt);
}
}
}, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
const _uploadAndAttach = useCallback(async (file: File) => {
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
if (result.success && result.fileData) {
const data = result.fileData.file || result.fileData;
if (data?.id) {
setPendingFiles(prev => [...prev, { fileId: data.id, fileName: data.fileName || file.name }]);
}
workspace.refreshFiles();
}
}, [fileOps, workspace, instanceId]);
const _handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, []);
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDragOver(false);
const chatId = e.dataTransfer.getData('application/chat-id');
if (chatId) {
try {
const res = await api.post(`/api/workspace/${instanceId}/resolve-rag`, { chatId });
const body = res.data ?? {};
if (body.summary) {
setDraftAppend(body.summary);
}
} catch (err) {
console.error('RAG resolve failed for dropped chat:', err);
}
return;
}
const droppedFiles = e.dataTransfer.files;
if (droppedFiles.length > 0) {
for (const file of Array.from(droppedFiles)) {
await _uploadAndAttach(file);
}
}
}, [_uploadAndAttach, instanceId, workspace]);
const _handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
Array.from(e.target.files).forEach(file => _uploadAndAttach(file));
e.target.value = '';
}
}, [_uploadAndAttach]);
const _handleRemovePendingFile = useCallback((fileId: string) => {
setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
const _handleTreeItemsDrop = useCallback((items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
if (!instanceId) {
return (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
{t('Keine Workspace-Instanz ausgewählt.')}
</div>
);
}
const _handleFileSelect = (fileId: string) => {
setSelectedFileId(fileId);
setRightTab('preview');
setRightCollapsed(false);
if (isMobile) {
setMobileRightOpen(true);
}
};
const _handleConversationSelect = (wfId: string) => {
workspace.loadWorkflow(wfId);
};
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
flex: 1,
padding: '6px 0',
border: 'none',
borderBottom: active ? '2px solid var(--primary-color, #F25843)' : '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 11,
fontWeight: active ? 600 : 400,
color: active ? 'var(--primary-color, #F25843)' : 'var(--text-tertiary, #888)',
textTransform: 'uppercase' as const,
});
const _handleRenameChat = useCallback(async (chatId: string, newName: string) => {
try {
await api.patch(`/api/workspace/${instanceId}/workflows/${chatId}`, { name: newName });
} catch (err) {
console.error('Failed to rename chat:', err);
}
}, [instanceId]);
const _handleDeleteChat = useCallback(async (chatId: string) => {
try {
await api.delete(`/api/workspace/${instanceId}/workflows/${chatId}`);
if (workspace.workflowId === chatId) {
workspace.resetToNew();
}
} catch (err) {
console.error('Failed to delete chat:', err);
}
}, [instanceId, workspace]);
const _udbContext: UdbContext = {
instanceId: instanceId,
mandateId: mandateId,
featureInstanceId: instanceId,
};
const _handleSourcesChanged = useCallback(() => {
workspace.refreshDataSources();
workspace.refreshFeatureDataSources();
}, [workspace]);
const _handleSendToChat_Files = useCallback((items: AddToChat_FileItem[]) => {
setPendingFiles(prev => {
const existing = new Set(prev.map(f => f.fileId));
const toAdd: PendingFile[] = [];
for (const item of items) {
if (!existing.has(item.id)) {
toAdd.push({ fileId: item.id, fileName: item.name, itemType: item.type });
existing.add(item.id);
}
}
return [...prev, ...toAdd];
});
}, []);
const [pendingAttachFdsId, setPendingAttachFdsId] = useState<string>('');
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
try {
const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: params.featureInstanceId,
featureCode: params.featureCode,
tableName: params.tableName || '',
objectKey: params.objectKey,
label: params.label,
});
// Backend response shape parity with /datasources — accept either a flat
// ``id`` or a wrapped ``featureDataSource.id`` so a future API tweak
// doesn't silently break the chip again.
const newId =
res.data?.id ||
res.data?.featureDataSource?.id ||
res.data?.dataSource?.id ||
'';
workspace.refreshFeatureDataSources();
if (newId) {
setPendingAttachFdsId(newId);
}
} catch (err) {
console.error('Failed to add feature source to chat:', err);
}
}, [instanceId, workspace]);
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
const _handleAttachDataSource = useCallback((dsId: string) => {
setPendingAttachDsId(dsId);
}, []);
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
try {
const res = await api.post(`/api/workspace/${instanceId}/datasources`, {
connectionId: params.connectionId,
sourceType: params.sourceType,
path: params.path,
label: params.label,
displayPath: params.displayPath || params.label,
});
const newId = res.data?.id || res.data?.dataSource?.id;
if (newId) {
setPendingAttachDsId(newId);
workspace.refreshDataSources();
}
} catch (err) {
console.error('Failed to drop data source to chat:', err);
}
}, [instanceId, workspace]);
const _leftPanelBody = (
<UnifiedDataBar
context={_udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
onSelectChat={_handleConversationSelect}
activeWorkflowId={workspace.workflowId ?? undefined}
onCreateNewChat={workspace.resetToNew}
onRenameChat={_handleRenameChat}
onDeleteChat={_handleDeleteChat}
onFileSelect={_handleFileSelect}
onSourcesChanged={_handleSourcesChanged}
onSendToChat_Files={_handleSendToChat_Files}
onSendToChat_FeatureSource={_handleSendToChat_FeatureSource}
onAttachDataSource={_handleAttachDataSource}
/>
);
const _rightPanelBody = (
<>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8 }}>
<button style={tabButtonStyle(rightTab === 'activity')} onClick={() => setRightTab('activity')}>{t('Aktivität')}</button>
<button style={tabButtonStyle(rightTab === 'preview')} onClick={() => setRightTab('preview')}>{t('Vorschau')}</button>
</div>
{!isMobile && (
<button onClick={() => setRightCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rightTab === 'activity' && (
<ToolActivityLog activities={workspace.toolActivities} />
)}
{rightTab === 'preview' && (
<FilePreview
instanceId={instanceId}
fileId={selectedFileId}
files={workspace.files}
/>
)}
</div>
</>
);
return (
<div style={{ display: 'flex', flex: 1, minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* Left sidebar */}
{!isMobile && !leftCollapsed && (
<aside style={{
width: _leftResize.width,
minWidth: 200,
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
<div style={{ padding: '6px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<button onClick={() => setLeftCollapsed(true)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
{_leftPanelBody}
</aside>
)}
{/* Left resize handle */}
{!isMobile && !leftCollapsed && (
<div
onMouseDown={e => _leftResize.onMouseDown(e, 1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--primary-color, #F25843)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
{!isMobile && leftCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderRight: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setLeftCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
{/* Hidden file input */}
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={_handleFileInputChange} />
{/* Center - Chat + Input */}
<main
style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, minHeight: 0, position: 'relative' }}
onDragEnter={_handleDragEnter}
onDragLeave={_handleDragLeave}
onDragOver={_handleDragOver}
onDrop={_handleDrop}
>
{isMobile && (
<div style={{
display: 'flex',
gap: 8,
padding: '8px 12px',
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-primary, #fff)',
flexWrap: 'wrap',
}}>
<button
onClick={() => setMobileLeftOpen(true)}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
fontWeight: 600,
}}
>
{t('Workspace')}
</button>
<button
onClick={() => { setRightTab('activity'); setMobileRightOpen(true); }}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: rightTab === 'activity' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
>
{t('Aktivität')}
</button>
<button
onClick={() => { setRightTab('preview'); setMobileRightOpen(true); }}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: rightTab === 'preview' ? 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))' : '#f7f7f7',
cursor: 'pointer',
fontSize: 12,
}}
>
{t('Vorschau')}
</button>
</div>
)}
{isDragOver && (
<div style={{
position: 'absolute', inset: 0, zIndex: 100,
background: 'var(--primary-dark-bg, rgba(242, 88, 67, 0.1))',
border: '2px dashed var(--primary-color, #F25843)', borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16, fontWeight: 600, color: 'var(--primary-color, #F25843)',
pointerEvents: 'none',
}}>
Dateien hier ablegen
</div>
)}
<ChatStream
messages={workspace.messages}
agentProgress={workspace.agentProgress}
isProcessing={workspace.isProcessing}
pendingEdits={workspace.pendingEdits}
onAcceptEdit={workspace.acceptEdit}
onRejectEdit={workspace.rejectEdit}
onOpenEditor={() => navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)}
/>
<WorkspaceInput
instanceId={instanceId}
onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => {
const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])];
const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options);
setPendingFiles([]);
}}
isProcessing={workspace.isProcessing}
onStop={workspace.stopProcessing}
files={workspace.files}
dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources}
pendingFiles={pendingFiles}
onRemovePendingFile={_handleRemovePendingFile}
onFileUploadClick={() => fileInputRef.current?.click()}
uploading={fileOps.uploadingFile}
providerSelection={providerSelection}
onProviderSelectionChange={setProviderSelection}
isMobile={isMobile}
onTreeItemsDrop={_handleTreeItemsDrop}
onFeatureSourceDrop={_handleSendToChat_FeatureSource}
onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId}
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
pendingAttachFdsId={pendingAttachFdsId}
onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}
workflowId={workspace.workflowId}
loadedAttachedDataSourceIds={workspace.loadedAttachedDataSourceIds}
loadedAttachedFeatureDataSourceIds={workspace.loadedAttachedFeatureDataSourceIds}
loadedNonce={workspace.loadedNonce}
/>
</main>
{/* Right resize handle */}
{!isMobile && !rightCollapsed && (
<div
onMouseDown={e => _rightResize.onMouseDown(e, -1)}
style={{ width: 4, cursor: 'col-resize', background: 'transparent', flexShrink: 0 }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--primary-color, #F25843)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
/>
)}
{/* Right sidebar */}
{!isMobile && !rightCollapsed && (
<aside style={{
width: _rightResize.width,
minWidth: 200,
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
flexShrink: 0,
}}>
{_rightPanelBody}
</aside>
)}
{!isMobile && rightCollapsed && (
<div style={{ width: 32, display: 'flex', alignItems: 'start', justifyContent: 'center', paddingTop: 8, borderLeft: '1px solid var(--border-color, #e0e0e0)' }}>
<button onClick={() => setRightCollapsed(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: '#888' }}></button>
</div>
)}
{isMobile && mobileLeftOpen && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 120,
display: 'flex',
}}
onClick={() => setMobileLeftOpen(false)}
>
<aside
style={{
width: '100%',
maxWidth: 460,
height: '100%',
background: 'var(--bg-primary, #fff)',
borderRight: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('Workspace')}</span>
<button onClick={() => setMobileLeftOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div>
{_leftPanelBody}
</aside>
</div>
)}
{isMobile && mobileRightOpen && (
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.35)',
zIndex: 120,
display: 'flex',
justifyContent: 'flex-end',
}}
onClick={() => setMobileRightOpen(false)}
>
<aside
style={{
width: '100%',
maxWidth: 460,
height: '100%',
background: 'var(--bg-primary, #fff)',
borderLeft: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={e => e.stopPropagation()}
>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{rightTab === 'activity' ? t('Aktivität') : t('Vorschau')}</span>
<button onClick={() => setMobileRightOpen(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: '#666' }}>×</button>
</div>
{_rightPanelBody}
</aside>
</div>
)}
</div>
);
};
export default WorkspacePage;