- {renderSidebar()}
+ {/* Chat/Tracing panel - left side */}
+ {(chatPanelOpen || tracingRunId) && (
+
+
+
+
+
+
+
+ {chatPanelOpen && currentWorkflowId ? (
+ handleLoad(currentWorkflowId)}
+ pendingFiles={pendingFiles}
+ onRemovePendingFile={onRemovePendingFile}
+ dataSources={dataSources}
+ featureDataSources={featureDataSources}
+ />
+ ) : tracingRunId ? (
+
+ ) : null}
+
+
+ )}
+ {/* Canvas area - center */}
= ({
onArchiveVersion={handleArchiveVersion}
onCreateDraft={handleCreateDraft}
versionLoading={versionLoading}
+ onSaveAsTemplate={handleSaveAsTemplate}
+ templateSaving={templateSaving}
+ onNewFromTemplate={() => setTemplatePickerOpen(true)}
/>
@@ -565,37 +657,9 @@ export const Automation2FlowEditor: React.FC
= ({
)}
- {(chatPanelOpen || tracingRunId) && (
-
-
-
-
-
-
-
- {chatPanelOpen && currentWorkflowId ? (
-
- ) : tracingRunId ? (
-
- ) : null}
-
-
- )}
+
+ {/* Node sidebar - right side */}
+ {renderSidebar()}
= ({
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
+ setTemplatePickerOpen(false)}
+ onSelect={handleNewFromTemplate}
+ instanceId={instanceId}
+ request={request}
+ />
);
};
diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx
index 56e3dfb..735d419 100644
--- a/src/components/FlowEditor/editor/CanvasHeader.tsx
+++ b/src/components/FlowEditor/editor/CanvasHeader.tsx
@@ -2,9 +2,9 @@
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
-import React from 'react';
-import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive } from 'react-icons/fa';
-import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion } from '../../../api/workflowApi';
+import React, { useState, useRef, useEffect } from 'react';
+import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaRobot, FaBookmark, FaCaretDown } from 'react-icons/fa';
+import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
@@ -28,6 +28,9 @@ interface CanvasHeaderProps {
onArchiveVersion?: (versionId: string) => void;
onCreateDraft?: () => void;
versionLoading?: boolean;
+ onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
+ templateSaving?: boolean;
+ onNewFromTemplate?: () => void;
}
const STATUS_BADGE: Record
= {
@@ -57,11 +60,31 @@ export const CanvasHeader: React.FC = ({
onArchiveVersion,
onCreateDraft,
versionLoading,
+ onSaveAsTemplate,
+ templateSaving,
+ onNewFromTemplate,
}) => {
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.draft;
+ const [newMenuOpen, setNewMenuOpen] = useState(false);
+ const newMenuRef = useRef(null);
+
+ const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
+ const templateMenuRef = useRef(null);
+
+ useEffect(() => {
+ const _handleClickOutside = (e: MouseEvent) => {
+ if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
+ if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
+ };
+ document.addEventListener('mousedown', _handleClickOutside);
+ return () => document.removeEventListener('mousedown', _handleClickOutside);
+ }, []);
+
+ const SCOPE_LABELS: Record = { user: 'Meine Vorlagen', instance: 'Instanz', mandate: 'Mandant' };
+
return (
@@ -79,9 +102,45 @@ export const CanvasHeader: React.FC
= ({
)}
-
+
+ {/* Split "Neu" button */}
+
+
+
+
+
+ {newMenuOpen && (
+
+
+ {onNewFromTemplate && (
+
+ )}
+
+ )}
+
+
+
+ {/* Save as template */}
+ {currentWorkflowId && onSaveAsTemplate && (
+
+
+ {templateMenuOpen && (
+
+ {(['user', 'instance', 'mandate'] as const).map((s) => (
+
+ ))}
+
+ )}
+
+ )}
diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx
index b0a4c46..94b0f8f 100644
--- a/src/components/FlowEditor/editor/EditorChatPanel.tsx
+++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx
@@ -2,18 +2,46 @@
* EditorChatPanel
*
* AI Chat sidebar for the GraphicalEditor.
- * Uses shared ChatMessageList and ChatInput components.
* 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, ChatInput } from '../../Chat';
+import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat';
+import { getPageIcon } from '../../../config/pageRegistry';
+
+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;
@@ -22,62 +50,77 @@ export const EditorChatPanel: React.FC
= ({
instanceId,
workflowId,
onGraphUpdated,
+ pendingFiles = [],
+ onRemovePendingFile,
+ dataSources = [],
+ featureDataSources = [],
}) => {
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 _handleSend = useCallback((text: string) => {
- if (!workflowId || loading) return;
+ 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: text,
+ content: trimmed,
timestamp: Date.now(),
};
- setMessages((prev) => [...prev, userMsg]);
+ 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 conversationHistory = messages.map((m) => ({
- role: m.role,
- message: m.content,
- }));
+ setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
const cleanup = startSseStream({
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
- body: {
- message: text,
- conversationHistory,
- userLanguage: navigator.language?.slice(0, 2) || 'de',
- },
+ body,
handlers: {
onChunk: (event) => {
if (event.content) {
accumulated += event.content;
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantId ? { ...m, content: accumulated } : m
- )
- );
+ 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
- )
- );
+ setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
}
if (event.type === 'toolResult' || event.type === 'toolCall') {
onGraphUpdated?.();
@@ -85,11 +128,7 @@ export const EditorChatPanel: React.FC