- {renderSidebar()}
+ {/* Chat/Tracing panel - left side */}
+ {(chatPanelOpen || tracingRunId) && (
+
+
+ { setChatPanelOpen(true); setTracingRunId(null); }}
+ style={{ flex: 1, padding: '8px', border: 'none', background: chatPanelOpen ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
+ >
+ Chat
+
+ { setChatPanelOpen(false); setTracingRunId(tracingRunId || 'select'); }}
+ style={{ flex: 1, padding: '8px', border: 'none', background: !chatPanelOpen && tracingRunId ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
+ >
+ Tracing
+
+ { setChatPanelOpen(false); setTracingRunId(null); }}
+ style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
+ >
+ ×
+
+
+
+ {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) && (
-
-
- { setChatPanelOpen(true); setTracingRunId(null); }}
- style={{ flex: 1, padding: '8px', border: 'none', background: chatPanelOpen ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
- >
- Chat
-
- { setChatPanelOpen(false); setTracingRunId(tracingRunId || 'select'); }}
- style={{ flex: 1, padding: '8px', border: 'none', background: !chatPanelOpen && tracingRunId ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
- >
- Tracing
-
- { setChatPanelOpen(false); setTracingRunId(null); }}
- style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
- >
- ×
-
-
-
- {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
= ({
)}
-
- Neu
-
+
+ {/* Split "Neu" button */}
+
+
+
+ Neu
+
+ setNewMenuOpen((p) => !p)}
+ style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
+ title="Neu aus Vorlage"
+ >
+
+
+
+ {newMenuOpen && (
+
+ { onNew(); setNewMenuOpen(false); }}
+ style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
+ >
+ Leerer Workflow
+
+ {onNewFromTemplate && (
+ { onNewFromTemplate(); setNewMenuOpen(false); }}
+ style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
+ >
+ Aus Vorlage...
+
+ )}
+
+ )}
+
+
= ({
>
{saving ? : 'Speichern'}
+
+ {/* Save as template */}
+ {currentWorkflowId && onSaveAsTemplate && (
+
+
setTemplateMenuOpen((p) => !p)}
+ disabled={templateSaving}
+ title="Als Vorlage speichern"
+ >
+ {templateSaving ? : <> Als Vorlage>}
+
+ {templateMenuOpen && (
+
+ {(['user', 'instance', 'mandate'] as const).map((s) => (
+ { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
+ style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
+ >
+ {SCOPE_LABELS[s]}
+
+ ))}
+
+ )}
+
+ )}
{
@@ -124,8 +212,9 @@ export const CanvasHeader: React.FC = ({
)}
{onToggleChat && (
-
- Chat
+
+
+ AI Chat
)}
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 = ({
},
onComplete: () => {
if (!accumulated) {
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantId ? { ...m, content: 'Done.' } : m
- )
- );
+ setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: 'Done.' } : m));
}
onGraphUpdated?.();
setLoading(false);
@@ -97,35 +136,51 @@ export const EditorChatPanel: React.FC = ({
onError: (event) => {
const errText = event.content || 'Request failed';
if (!accumulated) {
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m
- )
- );
+ setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m));
}
setLoading(false);
},
- onStopped: () => {
- setLoading(false);
- },
+ onStopped: () => setLoading(false),
},
onConnectionError: (err) => {
- setMessages((prev) =>
- prev.map((m) =>
- m.id === assistantId
- ? { ...m, content: `Error: ${err.message}` }
- : m
- )
- );
- setLoading(false);
- },
- onStreamEnd: () => {
+ setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err.message}` } : m));
setLoading(false);
},
+ onStreamEnd: () => setLoading(false),
});
abortRef.current = cleanup;
- }, [loading, workflowId, instanceId, messages, onGraphUpdated]);
+ }, [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 (
@@ -134,12 +189,219 @@ export const EditorChatPanel: React.FC
= ({
isProcessing={loading}
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
/>
-
+
+ {/* 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 && (
+ onRemovePendingFile(pf.fileId)} style={{
+ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
+ }}>x
+ )}
+
+ ))}
+
+ )}
+
+ {/* 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}
+ _toggleDataSource(dsId)} style={{
+ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
+ }}>x
+
+ );
+ })}
+ {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}
+ _toggleFeatureDataSource(fdsId)} style={{
+ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
+ }}>x
+
+ );
+ })}
+
+ )}
+
+ {/* Input area */}
+
);
};
diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx
index 6aaea29..e834eef 100644
--- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx
+++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx
@@ -22,7 +22,7 @@ interface NodeConfigPanelProps {
request?: ApiRequestFunction;
}
-const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.'];
+const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.', 'trustee.'];
export const NodeConfigPanel: React.FC = ({
node,
diff --git a/src/components/FlowEditor/editor/TemplatePicker.tsx b/src/components/FlowEditor/editor/TemplatePicker.tsx
new file mode 100644
index 0000000..022031d
--- /dev/null
+++ b/src/components/FlowEditor/editor/TemplatePicker.tsx
@@ -0,0 +1,150 @@
+/**
+ * TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
+ */
+
+import React, { useState, useEffect, useCallback } from 'react';
+import { FaSpinner } from 'react-icons/fa';
+import {
+ fetchTemplates,
+ type AutoWorkflowTemplate,
+ type AutoTemplateScope,
+ type ApiRequestFunction,
+} from '../../../api/workflowApi';
+import styles from './Automation2FlowEditor.module.css';
+
+const SCOPE_LABELS: Record = {
+ all: 'Alle',
+ user: 'Meine',
+ instance: 'Instanz',
+ mandate: 'Mandant',
+ system: 'System',
+};
+
+interface TemplatePickerProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (templateId: string) => void;
+ instanceId: string;
+ request: ApiRequestFunction;
+}
+
+export const TemplatePicker: React.FC = ({
+ open,
+ onClose,
+ onSelect,
+ instanceId,
+ request,
+}) => {
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [activeScope, setActiveScope] = useState('all');
+ const [copying, setCopying] = useState(null);
+
+ const _load = useCallback(async () => {
+ if (!instanceId || !open) return;
+ setLoading(true);
+ try {
+ const scope = activeScope === 'all' ? undefined : activeScope;
+ const result = await fetchTemplates(request, instanceId, scope);
+ setTemplates(Array.isArray(result) ? result : result.items);
+ } catch {
+ setTemplates([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [instanceId, request, open, activeScope]);
+
+ useEffect(() => {
+ _load();
+ }, [_load]);
+
+ const _handleSelect = useCallback(
+ async (templateId: string) => {
+ setCopying(templateId);
+ try {
+ await onSelect(templateId);
+ } finally {
+ setCopying(null);
+ }
+ },
+ [onSelect]
+ );
+
+ if (!open) return null;
+
+ return (
+
+
+
+ Neu aus Vorlage
+
+
+ Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.
+
+
+
+ {(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
+ setActiveScope(s)}
+ style={{ fontSize: '0.8rem', padding: '4px 10px' }}
+ >
+ {SCOPE_LABELS[s]}
+
+ ))}
+
+
+
+ {loading ? (
+
+
+
+ ) : templates.length === 0 ? (
+
+ Keine Vorlagen gefunden.
+
+ ) : (
+
+
+
+ Name
+ Scope
+
+
+
+
+ {templates.map((tpl) => (
+
+ {tpl.label}
+
+ {SCOPE_LABELS[(tpl.templateScope as AutoTemplateScope) || 'user']}
+
+
+ _handleSelect(tpl.id)}
+ disabled={copying !== null}
+ >
+ {copying === tpl.id ? : 'Übernehmen'}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ Abbrechen
+
+
+
+
+ );
+};
diff --git a/src/components/FlowEditor/index.ts b/src/components/FlowEditor/index.ts
index b39cbd0..91f9e52 100644
--- a/src/components/FlowEditor/index.ts
+++ b/src/components/FlowEditor/index.ts
@@ -1,4 +1,5 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
+export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
diff --git a/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx
new file mode 100644
index 0000000..0ec339f
--- /dev/null
+++ b/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx
@@ -0,0 +1,92 @@
+/**
+ * Trustee node config — featureInstanceId, optional SharePoint connection + folder, prompt.
+ * Covers: trustee.extractFromFiles, trustee.processDocuments, trustee.syncToAccounting.
+ */
+
+import React, { useEffect, useState } from 'react';
+import type { NodeConfigRendererProps } from './types';
+import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
+
+export const TrusteeNodeConfig: React.FC = ({
+ params,
+ updateParam,
+ instanceId,
+ request,
+ nodeType = 'trustee.extractFromFiles',
+}) => {
+ const [connections, setConnections] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ const isExtract = nodeType === 'trustee.extractFromFiles';
+
+ useEffect(() => {
+ if (isExtract && instanceId && request) {
+ setLoading(true);
+ fetchConnections(request, instanceId)
+ .then(setConnections)
+ .catch(() => setConnections([]))
+ .finally(() => setLoading(false));
+ }
+ }, [isExtract, instanceId, request]);
+
+ return (
+ <>
+
+ Trustee Instance ID
+ updateParam('featureInstanceId', e.target.value)}
+ placeholder="Trustee Feature-Instanz-ID"
+ />
+
+
+ {isExtract && (
+ <>
+
+ SharePoint Connection (optional)
+ updateParam('connectionId', e.target.value)}
+ disabled={loading}
+ >
+ {loading ? 'Laden...' : 'Keine (Dateien aus vorherigem Schritt)'}
+ {connections.map((c) => (
+
+ {c.externalUsername ?? c.id}
+
+ ))}
+
+
+
+ SharePoint Ordnerpfad (optional)
+ updateParam('sharepointFolder', e.target.value)}
+ placeholder="/sites/MySite/Documents/Expenses"
+ />
+
+
+ AI Prompt (optional)
+
+ >
+ )}
+
+ {!isExtract && (
+
+ Document List (Referenz)
+ updateParam('documentList', e.target.value)}
+ placeholder="Referenz auf vorherigen Schritt (automatisch verknüpft)"
+ />
+
+ )}
+ >
+ );
+};
diff --git a/src/components/FlowEditor/nodes/configs/index.ts b/src/components/FlowEditor/nodes/configs/index.ts
index 0e2f6ad..10fe8a4 100644
--- a/src/components/FlowEditor/nodes/configs/index.ts
+++ b/src/components/FlowEditor/nodes/configs/index.ts
@@ -22,6 +22,7 @@ import { LoopNodeConfig } from '../loop/LoopNodeConfig';
import { FormStartNodeConfig } from '../start/FormStartNodeConfig';
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
+import { TrusteeNodeConfig } from './TrusteeNodeConfig';
export type NodeConfigComponent = ComponentType;
@@ -62,4 +63,7 @@ export const NODE_CONFIG_REGISTRY: Record = {
'flow.ifElse': IfElseNodeConfig,
'flow.switch': SwitchNodeConfig,
'flow.loop': LoopNodeConfig,
+ 'trustee.extractFromFiles': TrusteeNodeConfig,
+ 'trustee.processDocuments': TrusteeNodeConfig,
+ 'trustee.syncToAccounting': TrusteeNodeConfig,
};
diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx
index 93cbc21..b4d2046 100644
--- a/src/components/UnifiedDataBar/FilesTab.tsx
+++ b/src/components/UnifiedDataBar/FilesTab.tsx
@@ -19,7 +19,7 @@ interface FileEntry {
interface FilesTabProps {
context: UdbContext;
- onFileSelect?: (fileId: string) => void;
+ onFileSelect?: (fileId: string, fileName?: string) => void;
}
const FilesTab: React.FC = ({ context, onFileSelect }) => {
@@ -297,7 +297,10 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => {
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
- onFileSelect={onFileSelect}
+ onFileSelect={onFileSelect ? (fileId: string) => {
+ const file = files.find(f => f.id === fileId);
+ onFileSelect(fileId, file?.fileName);
+ } : undefined}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
index 75bf641..0517b55 100644
--- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx
+++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx
@@ -24,7 +24,7 @@ interface UnifiedDataBarProps {
onRenameChat?: (chatId: string, newName: string) => void;
onDeleteChat?: (chatId: string) => void;
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
- onFileSelect?: (fileId: string) => void;
+ onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void;
className?: string;
}
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
index 499a910..b1371a3 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
@@ -1,15 +1,24 @@
/**
* GraphicalEditorPage
*
- * n8n-style flow builder with backend-driven node list and UDB sidebar.
+ * Layout: [UDB sidebar (collapsible)] [FlowEditor (flex)] [Chat/Tracing (inside FlowEditor)]
+ * UDB provides access to Files & Sources while configuring nodes.
+ * AI Chat and Tracing panels are managed by the FlowEditor's CanvasHeader.
+ *
+ * File/Source attachment UX mirrors the Workspace:
+ * - Files: click in UDB FilesTab → added as pendingFile chip in chat input
+ * - Data Sources: 🔗 picker button in chat input (loaded from UDB API)
*/
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
+import { FaDatabase, FaChevronLeft } from 'react-icons/fa';
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEditor';
+import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from '../../../components/FlowEditor';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
+import api from '../../../api';
import styles from '../../FeatureView.module.css';
interface GraphicalEditorPageProps {
@@ -26,16 +35,76 @@ export const GraphicalEditorPage: React.FC = ({
const instanceId = persistentInstanceId || urlInstanceId;
const mandateId = persistentMandateId || urlMandateId;
const [searchParams] = useSearchParams();
- const workflowId = searchParams.get('workflowId');
+ const initialWorkflowIdRef = useRef(searchParams.get('workflowId'));
const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
const [udbTab, setUdbTab] = useState('files');
- const [udbOpen, setUdbOpen] = useState(false);
+ const [udbOpen, setUdbOpen] = useState(true);
+
+ const [pendingFiles, setPendingFiles] = useState([]);
+ const [dataSources, setDataSources] = useState([]);
+ const [featureDataSources, setFeatureDataSources] = useState([]);
const udbContext: UdbContext = useMemo(() => ({
+ instanceId: instanceId || '',
mandateId: mandateId || '',
featureInstanceId: instanceId || '',
- }), [mandateId, instanceId]);
+ }), [instanceId, mandateId]);
+
+ useEffect(() => {
+ if (!instanceId) return;
+ api.get(`/api/workspace/${instanceId}/datasources`)
+ .then(res => {
+ const list = (res.data.dataSources || res.data || []).map((d: any) => ({
+ id: d.id, label: d.label || d.path || d.id, path: d.path, sourceType: d.sourceType,
+ }));
+ setDataSources(list);
+ })
+ .catch(() => setDataSources([]));
+ }, [instanceId]);
+
+ useEffect(() => {
+ if (!instanceId) return;
+ api.get(`/api/workspace/${instanceId}/feature-datasources`)
+ .then(res => {
+ const list = (res.data.featureDataSources || res.data || []).map((d: any) => ({
+ id: d.id, featureInstanceId: d.featureInstanceId, featureCode: d.featureCode,
+ tableName: d.tableName, label: d.label || d.tableName,
+ }));
+ setFeatureDataSources(list);
+ })
+ .catch(() => setFeatureDataSources([]));
+ }, [instanceId]);
+
+ const _handleFileSelect = useCallback((fileId: string, fileName?: string) => {
+ setPendingFiles(prev => {
+ if (prev.some(f => f.fileId === fileId)) return prev;
+ return [...prev, { fileId, fileName: fileName || fileId.slice(0, 12) }];
+ });
+ }, []);
+
+ const _handleRemovePendingFile = useCallback((fileId: string) => {
+ setPendingFiles(prev => prev.filter(f => f.fileId !== fileId));
+ }, []);
+
+ const _handleSourcesChanged = useCallback(() => {
+ if (!instanceId) return;
+ api.get(`/api/workspace/${instanceId}/datasources`)
+ .then(res => {
+ setDataSources((res.data.dataSources || res.data || []).map((d: any) => ({
+ id: d.id, label: d.label || d.path || d.id, path: d.path, sourceType: d.sourceType,
+ })));
+ })
+ .catch(() => {});
+ api.get(`/api/workspace/${instanceId}/feature-datasources`)
+ .then(res => {
+ setFeatureDataSources((res.data.featureDataSources || res.data || []).map((d: any) => ({
+ id: d.id, featureInstanceId: d.featureInstanceId, featureCode: d.featureCode,
+ tableName: d.tableName, label: d.label || d.tableName,
+ })));
+ })
+ .catch(() => {});
+ }, [instanceId]);
if (!instanceId) {
return (
@@ -47,33 +116,73 @@ export const GraphicalEditorPage: React.FC = ({
}
return (
-
- {udbOpen && (
-
-
-
Daten
-
setUdbOpen(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px' }}>×
+
+ {/* UDB Sidebar */}
+ {udbOpen ? (
+
+
+
+ Daten
+
+ setUdbOpen(false)}
+ title="Sidebar schliessen"
+ style={{
+ border: 'none', background: 'transparent', cursor: 'pointer',
+ fontSize: '14px', padding: '2px 4px', borderRadius: 4,
+ display: 'flex', alignItems: 'center',
+ }}
+ >
+
+
+ ) : (
+
setUdbOpen(true)}
+ title="Daten-Sidebar öffnen"
+ style={{
+ position: 'absolute', left: 0, top: '50%', transform: 'translateY(-50%)',
+ zIndex: 20, width: 28, height: 80,
+ border: '1px solid var(--border-color, #ddd)', borderLeft: 'none',
+ borderRadius: '0 8px 8px 0',
+ background: 'var(--bg-primary, #fff)', cursor: 'pointer',
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ boxShadow: '2px 0 6px rgba(0,0,0,0.06)',
+ }}
+ >
+
+
)}
+
+ {/* FlowEditor */}
- {!udbOpen && (
- setUdbOpen(true)}
- style={{ position: 'absolute', left: 8, top: 8, zIndex: 10, padding: '4px 10px', border: '1px solid var(--border-color, #ddd)', borderRadius: '6px', background: 'var(--bg-primary, #fff)', cursor: 'pointer', fontSize: '12px' }}
- >
- Daten ▸
-
- )}