/** * 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 = ({ 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('activity'); const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); const [draftAppend, setDraftAppend] = useState(''); const dragCounterRef = useRef(0); const fileInputRef = useRef(null); const [searchParams, setSearchParams] = useSearchParams(); const autoStartHandled = useRef(false); const [isMobile, setIsMobile] = useState(() => 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) => { 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 (
{t('Keine Workspace-Instanz ausgewählt.')}
); } 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(''); 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(''); 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 = ( ); const _rightPanelBody = ( <>
{!isMobile && ( )}
{rightTab === 'activity' && ( )} {rightTab === 'preview' && ( )}
); return (
{/* Left sidebar */} {!isMobile && !leftCollapsed && ( )} {/* Left resize handle */} {!isMobile && !leftCollapsed && (
_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 && (
)} {/* Hidden file input */} {/* Center - Chat + Input */}
{isMobile && (
)} {isDragOver && (
Dateien hier ablegen
)} navigate(`/mandates/${mandateId}/${featureCode}/${routeInstanceId}/editor`)} /> { 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} />
{/* Right resize handle */} {!isMobile && !rightCollapsed && (
_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 && ( )} {!isMobile && rightCollapsed && (
)} {isMobile && mobileLeftOpen && (
setMobileLeftOpen(false)} >
)} {isMobile && mobileRightOpen && (
setMobileRightOpen(false)} >
)}
); }; export default WorkspacePage;