/** * EditorChatPanel * * AI Chat sidebar for the GraphicalEditor. * Streams responses via SSE (same pattern as Workspace chat). * File & data-source attachment UX mirrors WorkspaceInput: * - Files: drag & drop from FolderTree onto input area, or click in UDB * - Data Sources: 🔗 picker button next to input (toggle-select from active sources) */ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { startSseStream } from '../../../utils/sseClient'; import { ChatMessageList } from '../../Chat'; import type { ChatMessage } from '../../Chat'; import { getPageIcon } from '../../../config/pageRegistry'; import api from '../../../api'; interface PersistedEditorChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content: string; timestamp?: number; sequenceNr?: number; } interface PersistedEditorChatResponse { chatWorkflowId: string | null; messages: PersistedEditorChatMessage[]; } import { useLanguage } from '../../../providers/language/LanguageContext'; export interface PendingFile { fileId: string; fileName: string; itemType?: 'file' | 'folder'; } export interface EditorDataSource { id: string; label: string; path?: string; sourceType?: string; } export interface EditorFeatureDataSource { id: string; featureInstanceId: string; featureCode: string; tableName: string; label: string; } interface EditorChatPanelProps { instanceId: string; workflowId: string | null; onGraphUpdated?: () => void; pendingFiles?: PendingFile[]; onRemovePendingFile?: (fileId: string) => void; dataSources?: EditorDataSource[]; featureDataSources?: EditorFeatureDataSource[]; } let _msgCounter = 0; export const EditorChatPanel: React.FC = ({ instanceId, workflowId, onGraphUpdated, pendingFiles = [], onRemovePendingFile, dataSources = [], featureDataSources = [], }) => { const { t } = useLanguage(); const [messages, setMessages] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); const [loading, setLoading] = useState(false); const [prompt, setPrompt] = useState(''); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); const [showSourcePicker, setShowSourcePicker] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false); const [stopping, setStopping] = useState(false); const abortRef = useRef<(() => void) | null>(null); const assistantIdRef = useRef(null); const textareaRef = useRef(null); const pickerRef = useRef(null); // Load persisted chat history from the backend whenever the workflow changes. // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is // returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. // For an unsaved workflow (workflowId == null) we just clear the panel. useEffect(() => { if (!workflowId) { setMessages([]); return; } let cancelled = false; const _loadHistory = async () => { setHistoryLoading(true); try { const res = await api.get( `/api/workflows/${instanceId}/${workflowId}/chat/messages`, ); if (cancelled) return; const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ id: m.id || `persisted-${++_msgCounter}`, role: m.role, content: m.content, timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(), })); setMessages(persisted); } catch (err) { if (cancelled) return; console.warn('EditorChatPanel: failed to load chat history', err); setMessages([]); } finally { if (!cancelled) setHistoryLoading(false); } }; _loadHistory(); return () => { cancelled = true; }; }, [instanceId, workflowId]); const _toggleDataSource = useCallback((dsId: string) => { setAttachedDataSourceIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId], ); }, []); const _toggleFeatureDataSource = useCallback((fdsId: string) => { setAttachedFeatureDataSourceIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId], ); }, []); const _handleSend = useCallback(() => { const trimmed = prompt.trim(); if (!workflowId || loading || !trimmed) return; const fileIds = pendingFiles.map(f => f.fileId); // Note: conversationHistory is no longer sent — the backend loads it // server-side from the persisted ChatWorkflow (linkedWorkflowId). const body: Record = { message: trimmed, userLanguage: navigator.language?.slice(0, 2) || 'de', }; if (fileIds.length > 0) body.fileIds = fileIds; if (attachedDataSourceIds.length > 0) body.dataSourceIds = attachedDataSourceIds; if (attachedFeatureDataSourceIds.length > 0) body.featureDataSourceIds = attachedFeatureDataSourceIds; const userMsg: ChatMessage = { id: `user-${++_msgCounter}`, role: 'user', content: trimmed, timestamp: Date.now(), }; setMessages(prev => [...prev, userMsg]); setPrompt(''); setShowSourcePicker(false); setLoading(true); const assistantId = `asst-${++_msgCounter}`; assistantIdRef.current = assistantId; let accumulated = ''; setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); const baseURL = api.defaults.baseURL || ''; const cleanup = startSseStream({ url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, body, handlers: { onChunk: (event) => { if (event.content) { accumulated += event.content; setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m)); } }, onRawEvent: (event) => { if (event.type === 'message' && event.content) { accumulated += event.content; setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m)); } if (event.type === 'toolResult' || event.type === 'toolCall') { onGraphUpdated?.(); } }, onComplete: () => { if (!accumulated) { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: t('Fertig.') } : m)); } onGraphUpdated?.(); setLoading(false); }, onError: (event) => { const errText = event.content || t('Anfrage fehlgeschlagen'); if (!accumulated) { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${errText}` } : m)); } setLoading(false); }, onStopped: () => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` } : m)); setLoading(false); setStopping(false); }, }, onConnectionError: (err) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m)); setLoading(false); setStopping(false); }, onStreamEnd: () => { setLoading(false); setStopping(false); }, }); abortRef.current = cleanup; }, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); const _handleStop = useCallback(async () => { if (!workflowId || stopping) return; setStopping(true); const assistantId = assistantIdRef.current; if (assistantId) { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` } : m)); } try { await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); } catch { } abortRef.current?.(); }, [workflowId, instanceId, stopping, t]); const _handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); _handleSend(); } }, [_handleSend]); const _handleDragOver = useCallback((e: React.DragEvent) => { if (e.dataTransfer.types.includes('application/tree-items')) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); } }, []); const _handleDragLeave = useCallback(() => setTreeDropOver(false), []); const _handleDrop = useCallback((e: React.DragEvent) => { setTreeDropOver(false); const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson) { e.preventDefault(); e.stopPropagation(); } }, []); const hasAttachments = pendingFiles.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0; const sourceCount = attachedDataSourceIds.length + attachedFeatureDataSourceIds.length; const hasSourceOptions = dataSources.length > 0 || featureDataSources.length > 0; return (
{/* Pending files (from UDB drag/click) */} {pendingFiles.length > 0 && (
{pendingFiles.map(pf => ( {pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} {onRemovePendingFile && ( )} ))}
)} {/* Attached data sources chips */} {(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
0 ? 'none' : '1px solid var(--border-color, #e0e0e0)', background: '#fafafa', }}> {attachedDataSourceIds.map(dsId => { const ds = dataSources.find(d => d.id === dsId); return ( \uD83D\uDD17 {ds?.label || dsId} ); })} {attachedFeatureDataSourceIds.map(fdsId => { const fds = featureDataSources.find(d => d.id === fdsId); const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null; return ( {fdsIcon || '\uD83D\uDDC3\uFE0F'} {fds?.label || fdsId} ); })}
)} {/* Input area */}