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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-secondary, #f8f9fa);
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
publishVersion,
|
publishVersion,
|
||||||
unpublishVersion,
|
unpublishVersion,
|
||||||
archiveVersion,
|
archiveVersion,
|
||||||
|
createTemplateFromWorkflow,
|
||||||
|
copyTemplate,
|
||||||
type NodeType,
|
type NodeType,
|
||||||
type NodeTypeCategory,
|
type NodeTypeCategory,
|
||||||
type Automation2Graph,
|
type Automation2Graph,
|
||||||
|
|
@ -27,12 +29,14 @@ import {
|
||||||
type ExecuteGraphResponse,
|
type ExecuteGraphResponse,
|
||||||
type WorkflowEntryPoint,
|
type WorkflowEntryPoint,
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowApi';
|
||||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
||||||
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
||||||
import {
|
import {
|
||||||
|
|
@ -43,6 +47,7 @@ import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import { EditorChatPanel } from './EditorChatPanel';
|
import { EditorChatPanel } from './EditorChatPanel';
|
||||||
|
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||||
import { RunTracingPanel } from './RunTracingPanel';
|
import { RunTracingPanel } from './RunTracingPanel';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
@ -56,12 +61,20 @@ interface Automation2FlowEditorProps {
|
||||||
language?: string;
|
language?: string;
|
||||||
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||||
initialWorkflowId?: string | null;
|
initialWorkflowId?: string | null;
|
||||||
|
pendingFiles?: PendingFile[];
|
||||||
|
onRemovePendingFile?: (fileId: string) => void;
|
||||||
|
dataSources?: EditorDataSource[];
|
||||||
|
featureDataSources?: EditorFeatureDataSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
instanceId,
|
instanceId,
|
||||||
language = 'de',
|
language = 'de',
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
|
pendingFiles,
|
||||||
|
onRemovePendingFile,
|
||||||
|
dataSources,
|
||||||
|
featureDataSources,
|
||||||
}) => {
|
}) => {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { prompt: promptInput, PromptDialog } = usePrompt();
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
|
|
@ -71,7 +84,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
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 [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||||
|
|
@ -289,8 +302,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
const loadWorkflows = useCallback(async () => {
|
const loadWorkflows = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
try {
|
try {
|
||||||
const items = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request, instanceId);
|
||||||
setWorkflows(items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${LOG} loadWorkflows failed`, e);
|
console.error(`${LOG} loadWorkflows failed`, e);
|
||||||
}
|
}
|
||||||
|
|
@ -452,6 +465,42 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
}
|
}
|
||||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
}, [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 = () => {
|
const renderSidebar = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -497,14 +546,54 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
|
|
||||||
const configurableSelected =
|
const configurableSelected =
|
||||||
selectedNode &&
|
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)
|
selectedNode.type.startsWith(p)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<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}>
|
<div className={styles.canvas}>
|
||||||
<CanvasHeader
|
<CanvasHeader
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
|
|
@ -527,6 +616,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
onArchiveVersion={handleArchiveVersion}
|
onArchiveVersion={handleArchiveVersion}
|
||||||
onCreateDraft={handleCreateDraft}
|
onCreateDraft={handleCreateDraft}
|
||||||
versionLoading={versionLoading}
|
versionLoading={versionLoading}
|
||||||
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
|
templateSaving={templateSaving}
|
||||||
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
@ -565,37 +657,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(chatPanelOpen || tracingRunId) && (
|
|
||||||
<div style={{ width: 340, borderLeft: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
{/* Node sidebar - right side */}
|
||||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
{renderSidebar()}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<PromptDialog />
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
<WorkflowConfigurationModal
|
||||||
open={workflowSettingsOpen}
|
open={workflowSettingsOpen}
|
||||||
|
|
@ -603,6 +667,13 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
invocations={invocations}
|
invocations={invocations}
|
||||||
onApply={handleApplyWorkflowConfiguration}
|
onApply={handleApplyWorkflowConfiguration}
|
||||||
/>
|
/>
|
||||||
|
<TemplatePicker
|
||||||
|
open={templatePickerOpen}
|
||||||
|
onClose={() => setTemplatePickerOpen(false)}
|
||||||
|
onSelect={handleNewFromTemplate}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
|
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive } from 'react-icons/fa';
|
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaRobot, FaBookmark, FaCaretDown } from 'react-icons/fa';
|
||||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion } from '../../../api/workflowApi';
|
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
interface CanvasHeaderProps {
|
interface CanvasHeaderProps {
|
||||||
|
|
@ -28,6 +28,9 @@ interface CanvasHeaderProps {
|
||||||
onArchiveVersion?: (versionId: string) => void;
|
onArchiveVersion?: (versionId: string) => void;
|
||||||
onCreateDraft?: () => void;
|
onCreateDraft?: () => void;
|
||||||
versionLoading?: boolean;
|
versionLoading?: boolean;
|
||||||
|
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||||
|
templateSaving?: boolean;
|
||||||
|
onNewFromTemplate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
|
||||||
|
|
@ -57,11 +60,31 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
onArchiveVersion,
|
onArchiveVersion,
|
||||||
onCreateDraft,
|
onCreateDraft,
|
||||||
versionLoading,
|
versionLoading,
|
||||||
|
onSaveAsTemplate,
|
||||||
|
templateSaving,
|
||||||
|
onNewFromTemplate,
|
||||||
}) => {
|
}) => {
|
||||||
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
||||||
const currentStatus = currentVersion?.status || 'draft';
|
const currentStatus = currentVersion?.status || 'draft';
|
||||||
const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.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 (
|
return (
|
||||||
<div className={styles.canvasHeader}>
|
<div className={styles.canvasHeader}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||||
|
|
@ -79,9 +102,45 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
<FaCog />
|
<FaCog />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className={styles.retryButton} onClick={onNew}>
|
|
||||||
|
{/* 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
|
Neu
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
className={styles.retryButton}
|
||||||
|
|
@ -90,6 +149,35 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
>
|
>
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||||
</button>
|
</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
|
<select
|
||||||
value={currentWorkflowId ?? ''}
|
value={currentWorkflowId ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|
@ -124,8 +212,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{onToggleChat && (
|
{onToggleChat && (
|
||||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat öffnen">
|
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat & Tracing öffnen">
|
||||||
Chat
|
<FaRobot style={{ marginRight: '0.4rem' }} />
|
||||||
|
AI Chat
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,46 @@
|
||||||
* EditorChatPanel
|
* EditorChatPanel
|
||||||
*
|
*
|
||||||
* AI Chat sidebar for the GraphicalEditor.
|
* AI Chat sidebar for the GraphicalEditor.
|
||||||
* Uses shared ChatMessageList and ChatInput components.
|
|
||||||
* Streams responses via SSE (same pattern as Workspace chat).
|
* 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 React, { useState, useCallback, useRef } from 'react';
|
||||||
import { startSseStream } from '../../../utils/sseClient';
|
import { startSseStream } from '../../../utils/sseClient';
|
||||||
import { ChatMessageList, ChatInput } from '../../Chat';
|
import { ChatMessageList } from '../../Chat';
|
||||||
import type { ChatMessage } 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 {
|
interface EditorChatPanelProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
workflowId: string | null;
|
workflowId: string | null;
|
||||||
onGraphUpdated?: () => void;
|
onGraphUpdated?: () => void;
|
||||||
|
pendingFiles?: PendingFile[];
|
||||||
|
onRemovePendingFile?: (fileId: string) => void;
|
||||||
|
dataSources?: EditorDataSource[];
|
||||||
|
featureDataSources?: EditorFeatureDataSource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let _msgCounter = 0;
|
let _msgCounter = 0;
|
||||||
|
|
@ -22,62 +50,77 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
|
||||||
instanceId,
|
instanceId,
|
||||||
workflowId,
|
workflowId,
|
||||||
onGraphUpdated,
|
onGraphUpdated,
|
||||||
|
pendingFiles = [],
|
||||||
|
onRemovePendingFile,
|
||||||
|
dataSources = [],
|
||||||
|
featureDataSources = [],
|
||||||
}) => {
|
}) => {
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
|
||||||
|
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
|
||||||
|
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||||
|
const [treeDropOver, setTreeDropOver] = useState(false);
|
||||||
const abortRef = useRef<(() => void) | null>(null);
|
const abortRef = useRef<(() => void) | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const _handleSend = useCallback((text: string) => {
|
const _toggleDataSource = useCallback((dsId: string) => {
|
||||||
if (!workflowId || loading) return;
|
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 = {
|
const userMsg: ChatMessage = {
|
||||||
id: `user-${++_msgCounter}`,
|
id: `user-${++_msgCounter}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: text,
|
content: trimmed,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, userMsg]);
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setPrompt('');
|
||||||
|
setShowSourcePicker(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const assistantId = `asst-${++_msgCounter}`;
|
const assistantId = `asst-${++_msgCounter}`;
|
||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
|
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ id: assistantId, role: 'assistant', content: '', timestamp: Date.now() },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const conversationHistory = messages.map((m) => ({
|
|
||||||
role: m.role,
|
|
||||||
message: m.content,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const cleanup = startSseStream({
|
const cleanup = startSseStream({
|
||||||
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||||
body: {
|
body,
|
||||||
message: text,
|
|
||||||
conversationHistory,
|
|
||||||
userLanguage: navigator.language?.slice(0, 2) || 'de',
|
|
||||||
},
|
|
||||||
handlers: {
|
handlers: {
|
||||||
onChunk: (event) => {
|
onChunk: (event) => {
|
||||||
if (event.content) {
|
if (event.content) {
|
||||||
accumulated += event.content;
|
accumulated += event.content;
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId ? { ...m, content: accumulated } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRawEvent: (event) => {
|
onRawEvent: (event) => {
|
||||||
if (event.type === 'message' && event.content) {
|
if (event.type === 'message' && event.content) {
|
||||||
accumulated += event.content;
|
accumulated += event.content;
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId ? { ...m, content: accumulated } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (event.type === 'toolResult' || event.type === 'toolCall') {
|
if (event.type === 'toolResult' || event.type === 'toolCall') {
|
||||||
onGraphUpdated?.();
|
onGraphUpdated?.();
|
||||||
|
|
@ -85,11 +128,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
if (!accumulated) {
|
if (!accumulated) {
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: 'Done.' } : m));
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId ? { ...m, content: 'Done.' } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
onGraphUpdated?.();
|
onGraphUpdated?.();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -97,35 +136,51 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
|
||||||
onError: (event) => {
|
onError: (event) => {
|
||||||
const errText = event.content || 'Request failed';
|
const errText = event.content || 'Request failed';
|
||||||
if (!accumulated) {
|
if (!accumulated) {
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m));
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
onStopped: () => {
|
onStopped: () => setLoading(false),
|
||||||
setLoading(false);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
onConnectionError: (err) => {
|
onConnectionError: (err) => {
|
||||||
setMessages((prev) =>
|
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `Error: ${err.message}` } : m));
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantId
|
|
||||||
? { ...m, content: `Error: ${err.message}` }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
},
|
|
||||||
onStreamEnd: () => {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
|
onStreamEnd: () => setLoading(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
abortRef.current = cleanup;
|
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 (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
|
<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}
|
isProcessing={loading}
|
||||||
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
|
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
|
||||||
/>
|
/>
|
||||||
<ChatInput
|
|
||||||
onSend={_handleSend}
|
{/* Pending files (from UDB drag/click) */}
|
||||||
isProcessing={loading}
|
{pendingFiles.length > 0 && (
|
||||||
disabled={!workflowId}
|
<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'}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ interface NodeConfigPanelProps {
|
||||||
request?: ApiRequestFunction;
|
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> = ({
|
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||||
node,
|
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 { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||||
|
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||||
export { FlowCanvas } from './editor/FlowCanvas';
|
export { FlowCanvas } from './editor/FlowCanvas';
|
||||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
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 { FormStartNodeConfig } from '../start/FormStartNodeConfig';
|
||||||
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
|
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
|
||||||
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
|
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
|
||||||
|
import { TrusteeNodeConfig } from './TrusteeNodeConfig';
|
||||||
|
|
||||||
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||||
|
|
||||||
|
|
@ -62,4 +63,7 @@ export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
'flow.ifElse': IfElseNodeConfig,
|
'flow.ifElse': IfElseNodeConfig,
|
||||||
'flow.switch': SwitchNodeConfig,
|
'flow.switch': SwitchNodeConfig,
|
||||||
'flow.loop': LoopNodeConfig,
|
'flow.loop': LoopNodeConfig,
|
||||||
|
'trustee.extractFromFiles': TrusteeNodeConfig,
|
||||||
|
'trustee.processDocuments': TrusteeNodeConfig,
|
||||||
|
'trustee.syncToAccounting': TrusteeNodeConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ interface FileEntry {
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onFileSelect?: (fileId: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||||
|
|
@ -297,7 +297,10 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect }) => {
|
||||||
showFiles={true}
|
showFiles={true}
|
||||||
selectedFolderId={selectedFolderId}
|
selectedFolderId={selectedFolderId}
|
||||||
onSelect={setSelectedFolderId}
|
onSelect={setSelectedFolderId}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect ? (fileId: string) => {
|
||||||
|
const file = files.find(f => f.id === fileId);
|
||||||
|
onFileSelect(fileId, file?.fileName);
|
||||||
|
} : undefined}
|
||||||
expandedIds={expandedFolderIds}
|
expandedIds={expandedFolderIds}
|
||||||
onToggleExpand={toggleFolderExpanded}
|
onToggleExpand={toggleFolderExpanded}
|
||||||
onRefresh={_refreshAll}
|
onRefresh={_refreshAll}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ interface UnifiedDataBarProps {
|
||||||
onRenameChat?: (chatId: string, newName: string) => void;
|
onRenameChat?: (chatId: string, newName: string) => void;
|
||||||
onDeleteChat?: (chatId: string) => void;
|
onDeleteChat?: (chatId: string) => void;
|
||||||
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
onChatDragStart?: (chatId: string, event: React.DragEvent) => void;
|
||||||
onFileSelect?: (fileId: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
onSourcesChanged?: () => void;
|
onSourcesChanged?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
/**
|
/**
|
||||||
* GraphicalEditorPage
|
* 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 { useSearchParams } from 'react-router-dom';
|
||||||
|
import { FaDatabase, FaChevronLeft } from 'react-icons/fa';
|
||||||
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEditor';
|
import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEditor';
|
||||||
|
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from '../../../components/FlowEditor';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
|
import api from '../../../api';
|
||||||
import styles from '../../FeatureView.module.css';
|
import styles from '../../FeatureView.module.css';
|
||||||
|
|
||||||
interface GraphicalEditorPageProps {
|
interface GraphicalEditorPageProps {
|
||||||
|
|
@ -26,16 +35,76 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
const instanceId = persistentInstanceId || urlInstanceId;
|
const instanceId = persistentInstanceId || urlInstanceId;
|
||||||
const mandateId = persistentMandateId || urlMandateId;
|
const mandateId = persistentMandateId || urlMandateId;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const workflowId = searchParams.get('workflowId');
|
const initialWorkflowIdRef = useRef(searchParams.get('workflowId'));
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
||||||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
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(() => ({
|
const udbContext: UdbContext = useMemo(() => ({
|
||||||
|
instanceId: instanceId || '',
|
||||||
mandateId: mandateId || '',
|
mandateId: mandateId || '',
|
||||||
featureInstanceId: instanceId || '',
|
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) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,33 +116,73 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
<div style={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
|
||||||
{udbOpen && (
|
{/* UDB Sidebar */}
|
||||||
<div style={{ width: 280, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
{udbOpen ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
|
<div style={{
|
||||||
<span style={{ fontWeight: 600, fontSize: '13px' }}>Daten</span>
|
width: 280, minWidth: 280,
|
||||||
<button onClick={() => setUdbOpen(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
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>
|
</div>
|
||||||
<UnifiedDataBar
|
<UnifiedDataBar
|
||||||
context={udbContext}
|
context={udbContext}
|
||||||
activeTab={udbTab}
|
activeTab={udbTab}
|
||||||
onTabChange={setUdbTab}
|
onTabChange={setUdbTab}
|
||||||
|
hideTabs={['chats']}
|
||||||
|
onFileSelect={_handleFileSelect}
|
||||||
|
onSourcesChanged={_handleSourcesChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{!udbOpen && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setUdbOpen(true)}
|
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' }}
|
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)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Daten ▸
|
<FaDatabase style={{ fontSize: 13, opacity: 0.5 }} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* FlowEditor */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<FlowEditor
|
<FlowEditor
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
language={language}
|
language={language}
|
||||||
initialWorkflowId={workflowId}
|
initialWorkflowId={initialWorkflowIdRef.current}
|
||||||
|
pendingFiles={pendingFiles}
|
||||||
|
onRemovePendingFile={_handleRemovePendingFile}
|
||||||
|
dataSources={dataSources}
|
||||||
|
featureDataSources={featureDataSources}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue