/** * 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, useRef } from 'react'; import { startSseStream } from '../../../utils/sseClient'; import { ChatMessageList } from '../../Chat'; import type { ChatMessage } from '../../Chat'; import { getPageIcon } from '../../../config/pageRegistry'; 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 [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 abortRef = useRef<(() => void) | null>(null); const textareaRef = useRef(null); const pickerRef = useRef(null); 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); const body: Record = { message: trimmed, conversationHistory: messages.map(m => ({ role: m.role, message: m.content })), 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}`; let accumulated = ''; setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); const cleanup = startSseStream({ url: `/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: 'Done.' } : m)); } onGraphUpdated?.(); setLoading(false); }, onError: (event) => { const errText = event.content || 'Request failed'; if (!accumulated) { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m)); } setLoading(false); }, onStopped: () => setLoading(false), }, onConnectionError: (err) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err.message}` } : m)); setLoading(false); }, onStreamEnd: () => setLoading(false), }); abortRef.current = cleanup; }, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds]); 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 */}