grapheditor editor and template handling
This commit is contained in:
parent
3bf79e1ae5
commit
3a6f90a497
12 changed files with 911 additions and 130 deletions
|
|
@ -20,7 +20,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
publishVersion,
|
||||
unpublishVersion,
|
||||
archiveVersion,
|
||||
createTemplateFromWorkflow,
|
||||
copyTemplate,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
|
|
@ -27,12 +29,14 @@ import {
|
|||
type ExecuteGraphResponse,
|
||||
type WorkflowEntryPoint,
|
||||
type AutoVersion,
|
||||
type AutoTemplateScope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
import { NodeSidebar } from './NodeSidebar';
|
||||
import { CanvasHeader } from './CanvasHeader';
|
||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
||||
import { TemplatePicker } from './TemplatePicker';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
||||
import {
|
||||
|
|
@ -43,6 +47,7 @@ import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
|||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||
import { RunTracingPanel } from './RunTracingPanel';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
|
|
@ -56,12 +61,20 @@ interface Automation2FlowEditorProps {
|
|||
language?: string;
|
||||
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||
initialWorkflowId?: string | null;
|
||||
pendingFiles?: PendingFile[];
|
||||
onRemovePendingFile?: (fileId: string) => void;
|
||||
dataSources?: EditorDataSource[];
|
||||
featureDataSources?: EditorFeatureDataSource[];
|
||||
}
|
||||
|
||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||
instanceId,
|
||||
language = 'de',
|
||||
initialWorkflowId,
|
||||
pendingFiles,
|
||||
onRemovePendingFile,
|
||||
dataSources,
|
||||
featureDataSources,
|
||||
}) => {
|
||||
const { request } = useApiRequest();
|
||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||
|
|
@ -71,7 +84,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
|
||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||
);
|
||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||
|
|
@ -289,8 +302,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
const loadWorkflows = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const items = await fetchWorkflows(request, instanceId);
|
||||
setWorkflows(items);
|
||||
const result = await fetchWorkflows(request, instanceId);
|
||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadWorkflows failed`, e);
|
||||
}
|
||||
|
|
@ -452,6 +465,42 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
}
|
||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
||||
|
||||
// Template: save current workflow as template
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const handleSaveAsTemplate = useCallback(
|
||||
async (scope: AutoTemplateScope) => {
|
||||
if (!instanceId || !currentWorkflowId) return;
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
|
||||
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
setTemplateSaving(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, currentWorkflowId]
|
||||
);
|
||||
|
||||
// Template: new workflow from template
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
const handleNewFromTemplate = useCallback(
|
||||
async (templateId: string) => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const wf = await copyTemplate(request, instanceId, templateId);
|
||||
setWorkflows((prev) => [...prev, wf]);
|
||||
setCurrentWorkflowId(wf.id);
|
||||
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
||||
setTemplatePickerOpen(false);
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[request, instanceId, handleFromApiGraph]
|
||||
);
|
||||
|
||||
const renderSidebar = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -497,14 +546,54 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
|
||||
const configurableSelected =
|
||||
selectedNode &&
|
||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
|
||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
|
||||
selectedNode.type.startsWith(p)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{renderSidebar()}
|
||||
{/* Chat/Tracing panel - left side */}
|
||||
{(chatPanelOpen || tracingRunId) && (
|
||||
<div style={{ width: 340, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<button
|
||||
onClick={() => { 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setChatPanelOpen(false); setTracingRunId(null); }}
|
||||
style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{chatPanelOpen && currentWorkflowId ? (
|
||||
<EditorChatPanel
|
||||
instanceId={instanceId}
|
||||
workflowId={currentWorkflowId}
|
||||
onGraphUpdated={() => handleLoad(currentWorkflowId)}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemovePendingFile={onRemovePendingFile}
|
||||
dataSources={dataSources}
|
||||
featureDataSources={featureDataSources}
|
||||
/>
|
||||
) : tracingRunId ? (
|
||||
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas area - center */}
|
||||
<div className={styles.canvas}>
|
||||
<CanvasHeader
|
||||
workflows={workflows}
|
||||
|
|
@ -527,6 +616,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
onArchiveVersion={handleArchiveVersion}
|
||||
onCreateDraft={handleCreateDraft}
|
||||
versionLoading={versionLoading}
|
||||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
templateSaving={templateSaving}
|
||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
|
@ -565,37 +657,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(chatPanelOpen || tracingRunId) && (
|
||||
<div style={{ width: 340, borderLeft: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<button
|
||||
onClick={() => { 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setChatPanelOpen(false); setTracingRunId(null); }}
|
||||
style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{chatPanelOpen && currentWorkflowId ? (
|
||||
<EditorChatPanel instanceId={instanceId} workflowId={currentWorkflowId} />
|
||||
) : tracingRunId ? (
|
||||
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node sidebar - right side */}
|
||||
{renderSidebar()}
|
||||
<PromptDialog />
|
||||
<WorkflowConfigurationModal
|
||||
open={workflowSettingsOpen}
|
||||
|
|
@ -603,6 +667,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
|||
invocations={invocations}
|
||||
onApply={handleApplyWorkflowConfiguration}
|
||||
/>
|
||||
<TemplatePicker
|
||||
open={templatePickerOpen}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
onSelect={handleNewFromTemplate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; color: string }> = {
|
||||
|
|
@ -57,11 +60,31 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
|||
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<HTMLDivElement>(null);
|
||||
|
||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||
const templateMenuRef = useRef<HTMLDivElement>(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<string, string> = { user: 'Meine Vorlagen', instance: 'Instanz', mandate: 'Mandant' };
|
||||
|
||||
return (
|
||||
<div className={styles.canvasHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
|
|
@ -79,9 +102,45 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
|||
<FaCog />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className={styles.retryButton} onClick={onNew}>
|
||||
Neu
|
||||
</button>
|
||||
|
||||
{/* Split "Neu" button */}
|
||||
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
|
||||
Neu
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
||||
</button>
|
||||
</div>
|
||||
{newMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
Leerer Workflow
|
||||
</button>
|
||||
{onNewFromTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { 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...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
|
|
@ -90,6 +149,35 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
|||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||
</button>
|
||||
|
||||
{/* Save as template */}
|
||||
{currentWorkflowId && onSaveAsTemplate && (
|
||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
disabled={templateSaving}
|
||||
title="Als Vorlage speichern"
|
||||
>
|
||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />Als Vorlage</>}
|
||||
</button>
|
||||
{templateMenuOpen && (
|
||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => { 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]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
|
|
@ -124,8 +212,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
|||
)}
|
||||
</button>
|
||||
{onToggleChat && (
|
||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat öffnen">
|
||||
Chat
|
||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat & Tracing öffnen">
|
||||
<FaRobot style={{ marginRight: '0.4rem' }} />
|
||||
AI Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<EditorChatPanelProps> = ({
|
|||
instanceId,
|
||||
workflowId,
|
||||
onGraphUpdated,
|
||||
pendingFiles = [],
|
||||
onRemovePendingFile,
|
||||
dataSources = [],
|
||||
featureDataSources = [],
|
||||
}) => {
|
||||
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 _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<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: 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<EditorChatPanelProps> = ({
|
|||
},
|
||||
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<EditorChatPanelProps> = ({
|
|||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
||||
|
|
@ -134,12 +189,219 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
|
|||
isProcessing={loading}
|
||||
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
|
||||
/>
|
||||
<ChatInput
|
||||
onSend={_handleSend}
|
||||
isProcessing={loading}
|
||||
disabled={!workflowId}
|
||||
placeholder={workflowId ? 'Describe a change...' : 'Save workflow first'}
|
||||
/>
|
||||
|
||||
{/* 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 ? 'Describe a change...' : 'Save workflow first'}
|
||||
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="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' }}>
|
||||
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,
|
||||
}}>Send</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<NodeConfigPanelProps> = ({
|
||||
node,
|
||||
|
|
|
|||
150
src/components/FlowEditor/editor/TemplatePicker.tsx
Normal file
150
src/components/FlowEditor/editor/TemplatePicker.tsx
Normal file
|
|
@ -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<AutoTemplateScope | 'all', string> = {
|
||||
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<TemplatePickerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
|
||||
const [copying, setCopying] = useState<string | null>(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 (
|
||||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
|
||||
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
|
||||
Neu aus Vorlage
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={activeScope === s ? styles.workflowModalBtnPrimary : styles.workflowModalBtnSecondary}
|
||||
onClick={() => setActiveScope(s)}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
>
|
||||
{SCOPE_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 120 }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
|
||||
Keine Vorlagen gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
|
||||
<th style={{ padding: '6px 8px' }}>Name</th>
|
||||
<th style={{ padding: '6px 8px', width: 80 }}>Scope</th>
|
||||
<th style={{ padding: '6px 8px', width: 100 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templates.map((tpl) => (
|
||||
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
|
||||
<td style={{ padding: '8px' }}>{tpl.label}</td>
|
||||
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
|
||||
{SCOPE_LABELS[(tpl.templateScope as AutoTemplateScope) || 'user']}
|
||||
</td>
|
||||
<td style={{ padding: '8px', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.workflowModalBtnPrimary}
|
||||
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
|
||||
onClick={() => _handleSelect(tpl.id)}
|
||||
disabled={copying !== null}
|
||||
>
|
||||
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : 'Übernehmen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<NodeConfigRendererProps> = ({
|
||||
params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
nodeType = 'trustee.extractFromFiles',
|
||||
}) => {
|
||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<div>
|
||||
<label>Trustee Instance ID</label>
|
||||
<input
|
||||
value={(params.featureInstanceId as string) ?? ''}
|
||||
onChange={(e) => updateParam('featureInstanceId', e.target.value)}
|
||||
placeholder="Trustee Feature-Instanz-ID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExtract && (
|
||||
<>
|
||||
<div>
|
||||
<label>SharePoint Connection (optional)</label>
|
||||
<select
|
||||
value={(params.connectionId as string) ?? ''}
|
||||
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">{loading ? 'Laden...' : 'Keine (Dateien aus vorherigem Schritt)'}</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.externalUsername ?? c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>SharePoint Ordnerpfad (optional)</label>
|
||||
<input
|
||||
value={(params.sharepointFolder as string) ?? ''}
|
||||
onChange={(e) => updateParam('sharepointFolder', e.target.value)}
|
||||
placeholder="/sites/MySite/Documents/Expenses"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>AI Prompt (optional)</label>
|
||||
<textarea
|
||||
value={(params.prompt as string) ?? ''}
|
||||
onChange={(e) => updateParam('prompt', e.target.value)}
|
||||
placeholder="Zusätzliche Anweisungen für die AI-Extraktion"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isExtract && (
|
||||
<div>
|
||||
<label>Document List (Referenz)</label>
|
||||
<input
|
||||
value={(params.documentList as string) ?? ''}
|
||||
onChange={(e) => updateParam('documentList', e.target.value)}
|
||||
placeholder="Referenz auf vorherigen Schritt (automatisch verknüpft)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<NodeConfigRendererProps>;
|
||||
|
||||
|
|
@ -62,4 +63,7 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
|||
'flow.ifElse': IfElseNodeConfig,
|
||||
'flow.switch': SwitchNodeConfig,
|
||||
'flow.loop': LoopNodeConfig,
|
||||
'trustee.extractFromFiles': TrusteeNodeConfig,
|
||||
'trustee.processDocuments': TrusteeNodeConfig,
|
||||
'trustee.syncToAccounting': TrusteeNodeConfig,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface FileEntry {
|
|||
|
||||
interface FilesTabProps {
|
||||
context: UdbContext;
|
||||
onFileSelect?: (fileId: string) => void;
|
||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||
}
|
||||
|
||||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||
|
|
@ -297,7 +297,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ 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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GraphicalEditorPageProps> = ({
|
|||
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<UdbTab>('files');
|
||||
const [udbOpen, setUdbOpen] = useState(false);
|
||||
const [udbOpen, setUdbOpen] = useState(true);
|
||||
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||
const [dataSources, setDataSources] = useState<EditorDataSource[]>([]);
|
||||
const [featureDataSources, setFeatureDataSources] = useState<EditorFeatureDataSource[]>([]);
|
||||
|
||||
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<GraphicalEditorPageProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||
{udbOpen && (
|
||||
<div style={{ width: 280, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '13px' }}>Daten</span>
|
||||
<button onClick={() => setUdbOpen(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
|
||||
{/* UDB Sidebar */}
|
||||
{udbOpen ? (
|
||||
<div style={{
|
||||
width: 280, minWidth: 280,
|
||||
borderRight: '1px solid var(--border-color, #e0e0e0)',
|
||||
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
borderBottom: '1px solid var(--border-color, #e0e0e0)',
|
||||
background: 'var(--bg-secondary, #f8f9fa)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: '13px', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<FaDatabase style={{ fontSize: 12, opacity: 0.6 }} /> Daten
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setUdbOpen(false)}
|
||||
title="Sidebar schliessen"
|
||||
style={{
|
||||
border: 'none', background: 'transparent', cursor: 'pointer',
|
||||
fontSize: '14px', padding: '2px 4px', borderRadius: 4,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FaChevronLeft style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</div>
|
||||
<UnifiedDataBar
|
||||
context={udbContext}
|
||||
activeTab={udbTab}
|
||||
onTabChange={setUdbTab}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={_handleFileSelect}
|
||||
onSourcesChanged={_handleSourcesChanged}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => 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)',
|
||||
}}
|
||||
>
|
||||
<FaDatabase style={{ fontSize: 13, opacity: 0.5 }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* FlowEditor */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
{!udbOpen && (
|
||||
<button
|
||||
onClick={() => 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 ▸
|
||||
</button>
|
||||
)}
|
||||
<FlowEditor
|
||||
instanceId={instanceId}
|
||||
language={language}
|
||||
initialWorkflowId={workflowId}
|
||||
initialWorkflowId={initialWorkflowIdRef.current}
|
||||
pendingFiles={pendingFiles}
|
||||
onRemovePendingFile={_handleRemovePendingFile}
|
||||
dataSources={dataSources}
|
||||
featureDataSources={featureDataSources}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue