490 lines
21 KiB
TypeScript
490 lines
21 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
};
|