frontend_nyla/src/components/FlowEditor/editor/EditorChatPanel.tsx
2026-04-09 00:11:35 +02:00

409 lines
18 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, 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<EditorChatPanelProps> = ({ instanceId,
workflowId,
onGraphUpdated,
pendingFiles = [],
onRemovePendingFile,
dataSources = [],
featureDataSources = [],
}) => {
const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 abortRef = useRef<(() => void) | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(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<string, unknown> = {
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList
messages={messages}
isProcessing={loading}
emptyMessage={t('editorChatPanel.describeWhatYouWantTo')}
/>
{/* 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('editorChatPanel.describeAChange') : t('editorChatPanel.saveWorkflowFirst')}
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('editorChatPanel.datenquellenAnhaengen')}
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' }}>
Active Sources 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' }}>
Feature Data Sources
</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={() => abortRef.current?.()} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
}}>Stop</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('editorChatPanel.send')}</button>
)}
</div>
</div>
);
};