frontend_nyla/src/components/FlowEditor/editor/EditorChatPanel.tsx
2026-04-19 01:22:34 +02:00

490 lines
21 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.

/**
* 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<EditorChatPanelProps> = ({ instanceId,
workflowId,
onGraphUpdated,
pendingFiles = [],
onRemovePendingFile,
dataSources = [],
featureDataSources = [],
}) => {
const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false);
const [stopping, setStopping] = useState(false);
const abortRef = useRef<(() => void) | null>(null);
const assistantIdRef = useRef<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(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<PersistedEditorChatResponse>(
`/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<string, unknown> = {
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList
messages={messages}
isProcessing={loading || historyLoading}
emptyMessage={historyLoading ? t('Lade Verlauf…') : t('Beschreiben Sie, was Sie tun möchten')}
/>
{/* Pending files (from UDB drag/click) */}
{pendingFiles.length > 0 && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
}}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
}}>x</button>
)}
</span>
))}
</div>
)}
{/* Attached data sources chips */}
{(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: pendingFiles.length > 0 ? 'none' : '1px solid var(--border-color, #e0e0e0)',
background: '#fafafa',
}}>
{attachedDataSourceIds.map(dsId => {
const ds = dataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
\uD83D\uDD17 {ds?.label || dsId}
<button onClick={() => _toggleDataSource(dsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
{attachedFeatureDataSourceIds.map(fdsId => {
const fds = featureDataSources.find(d => d.id === fdsId);
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 11 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFeatureDataSource(fdsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
</div>
)}
{/* Input area */}
<div
style={{
borderTop: hasAttachments ? 'none' : '1px solid var(--border-color, #e0e0e0)',
padding: '8px 12px',
display: 'flex', gap: 6, alignItems: 'flex-end',
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
background: treeDropOver ? 'rgba(242, 88, 67, 0.08)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
<textarea
ref={textareaRef}
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={workflowId ? t('Beschreiben Sie eine Änderung') : t('Speichern Sie zuerst den Workflow')}
disabled={!workflowId || loading}
style={{
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--border-color, #ccc)',
fontSize: 13, fontFamily: 'inherit', outline: 'none',
}}
rows={1}
/>
{/* Source picker button */}
{hasSourceOptions && (
<div style={{ position: 'relative' }} ref={pickerRef}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={loading || !workflowId}
title={t('Datenquellen anhängen')}
style={{
width: 36, height: 36, borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: sourceCount > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: sourceCount > 0 ? '#2e7d32' : '#666',
cursor: loading || !workflowId ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: loading ? 0.5 : 1, position: 'relative',
}}
>
{'\uD83D\uDD17'}
{sourceCount > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{sourceCount}</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
{t('Aktive Quellen auswählen')}
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDataSource(ds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#e8f5e9' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
{t('Feature-Datenquellen')}
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFeatureDataSource(fds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#f3e5f5' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{loading ? (
<button onClick={_handleStop} disabled={stopping} title={stopping ? t('Stoppen…') : t('Anfrage stoppen')} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: stopping ? '#9e9e9e' : '#f44336', color: '#fff',
cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12,
opacity: stopping ? 0.7 : 1,
}}>{stopping ? t('Stoppen…') : t('Stopp')}</button>
) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
fontWeight: 600, fontSize: 12,
}}>{t('Senden')}</button>
)}
</div>
</div>
);
};