mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat(workflows): add sub-workflow inspector, breadcrumb navigation, and reference UI
Phase 3 frontend: sub-workflow node inspector with reference/inline mode toggle, breadcrumb drill-down navigation for nested workflows, read-only view for referenced workflows, inline workflow editing with stack propagation, sub-workflow label badges on canvas nodes, and referential integrity error display on delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -205,8 +205,16 @@ export function SettingsPanel({
|
|||||||
onChange={setEditingWorkflow}
|
onChange={setEditingWorkflow}
|
||||||
onDelete={
|
onDelete={
|
||||||
async () => {
|
async () => {
|
||||||
await onDeleteWorkflow(editingWorkflow.id);
|
try {
|
||||||
setEditingWorkflow(null);
|
await onDeleteWorkflow(editingWorkflow.id);
|
||||||
|
setEditingWorkflow(null);
|
||||||
|
} catch (err) {
|
||||||
|
window.alert(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Cannot delete this workflow. It may be referenced by other workflows.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
@@ -214,6 +222,7 @@ export function SettingsPanel({
|
|||||||
setEditingWorkflow(null);
|
setEditingWorkflow(null);
|
||||||
}}
|
}}
|
||||||
workflow={editingWorkflow}
|
workflow={editingWorkflow}
|
||||||
|
workflows={workflows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { Fragment, useCallback, useMemo, useState } from 'react';
|
||||||
import { AlertCircle, CheckCircle, ChevronLeft, Trash2 } from 'lucide-react';
|
import { AlertCircle, CheckCircle, ChevronLeft, ChevronRight, Info, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { ModelDefinition } from '@shared/domain/models';
|
import type { ModelDefinition } from '@shared/domain/models';
|
||||||
import type {
|
import type {
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
WorkflowNodeKind,
|
WorkflowNodeKind,
|
||||||
WorkflowEdge,
|
WorkflowEdge,
|
||||||
AgentNodeConfig,
|
AgentNodeConfig,
|
||||||
|
SubWorkflowConfig,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createId } from '@shared/utils/ids';
|
import { createId } from '@shared/utils/ids';
|
||||||
@@ -22,12 +23,21 @@ import { WorkflowNodePalette } from './workflow/WorkflowNodePalette';
|
|||||||
interface WorkflowEditorProps {
|
interface WorkflowEditorProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
|
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||||
onChange: (workflow: WorkflowDefinition) => void;
|
onChange: (workflow: WorkflowDefinition) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkflowBreadcrumb {
|
||||||
|
workflow: WorkflowDefinition;
|
||||||
|
nodeId: string;
|
||||||
|
nodeLabel: string;
|
||||||
|
/** true when drilling into a referenced (not inline) workflow — view-only */
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function InputField({
|
function InputField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -113,11 +123,36 @@ function defaultLabelForKind(kind: WorkflowNodeKind): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Minimal inline workflow factory ────────────────────────── */
|
||||||
|
|
||||||
|
function createMinimalInlineWorkflow(): WorkflowDefinition {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: createId('inline-wf'),
|
||||||
|
name: 'Inline Workflow',
|
||||||
|
description: '',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
{ id: createId('wf-start'), kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
|
{ id: createId('wf-end'), kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main editor ───────────────────────────────────────────── */
|
/* ── Main editor ───────────────────────────────────────────── */
|
||||||
|
|
||||||
export function WorkflowEditor({
|
export function WorkflowEditor({
|
||||||
availableModels,
|
availableModels,
|
||||||
workflow,
|
workflow,
|
||||||
|
workflows,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -125,14 +160,65 @@ export function WorkflowEditor({
|
|||||||
}: WorkflowEditorProps) {
|
}: WorkflowEditorProps) {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const issues = validateWorkflowDefinition(workflow);
|
const [breadcrumbs, setBreadcrumbs] = useState<WorkflowBreadcrumb[]>([]);
|
||||||
|
|
||||||
|
/* ── Active workflow resolution ──────────────────────────── */
|
||||||
|
|
||||||
|
const activeWorkflow = useMemo(() => {
|
||||||
|
if (breadcrumbs.length === 0) return workflow;
|
||||||
|
return breadcrumbs[breadcrumbs.length - 1].workflow;
|
||||||
|
}, [workflow, breadcrumbs]);
|
||||||
|
|
||||||
|
const isReadOnly = breadcrumbs.length > 0 && breadcrumbs[breadcrumbs.length - 1].readOnly;
|
||||||
|
|
||||||
|
const issues = validateWorkflowDefinition(activeWorkflow);
|
||||||
|
|
||||||
|
/* ── Change propagation ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function propagateActiveChange(updatedActive: WorkflowDefinition) {
|
||||||
|
if (breadcrumbs.length === 0) {
|
||||||
|
onChange(updatedActive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk back up the breadcrumb stack, embedding each level's workflow
|
||||||
|
// into the parent's sub-workflow node config.
|
||||||
|
let child = updatedActive;
|
||||||
|
const newCrumbs = [...breadcrumbs];
|
||||||
|
newCrumbs[newCrumbs.length - 1] = { ...newCrumbs[newCrumbs.length - 1], workflow: child };
|
||||||
|
|
||||||
|
for (let i = newCrumbs.length - 1; i >= 0; i--) {
|
||||||
|
const crumb = newCrumbs[i];
|
||||||
|
const parent = i === 0 ? workflow : newCrumbs[i - 1].workflow;
|
||||||
|
const updatedParent: WorkflowDefinition = {
|
||||||
|
...parent,
|
||||||
|
graph: {
|
||||||
|
...parent.graph,
|
||||||
|
nodes: parent.graph.nodes.map((n) => {
|
||||||
|
if (n.id !== crumb.nodeId) return n;
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
config: { kind: 'sub-workflow' as const, inlineWorkflow: child } satisfies SubWorkflowConfig,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
child = updatedParent;
|
||||||
|
if (i > 0) {
|
||||||
|
newCrumbs[i - 1] = { ...newCrumbs[i - 1], workflow: updatedParent };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBreadcrumbs(newCrumbs);
|
||||||
|
onChange(child);
|
||||||
|
}
|
||||||
|
|
||||||
function emitChange(next: WorkflowDefinition) {
|
function emitChange(next: WorkflowDefinition) {
|
||||||
onChange(next);
|
propagateActiveChange(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitGraphChange(graph: WorkflowGraph) {
|
function emitGraphChange(graph: WorkflowGraph) {
|
||||||
onChange({ ...workflow, graph });
|
propagateActiveChange({ ...activeWorkflow, graph });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddNode(kind: WorkflowNodeKind) {
|
function handleAddNode(kind: WorkflowNodeKind) {
|
||||||
@@ -145,8 +231,8 @@ export function WorkflowEditor({
|
|||||||
config: defaultConfigForKind(kind),
|
config: defaultConfigForKind(kind),
|
||||||
};
|
};
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
...workflow.graph,
|
...activeWorkflow.graph,
|
||||||
nodes: [...workflow.graph.nodes, newNode],
|
nodes: [...activeWorkflow.graph.nodes, newNode],
|
||||||
});
|
});
|
||||||
setSelectedNodeId(nodeId);
|
setSelectedNodeId(nodeId);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
@@ -154,46 +240,116 @@ export function WorkflowEditor({
|
|||||||
|
|
||||||
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
|
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
...workflow.graph,
|
...activeWorkflow.graph,
|
||||||
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
|
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, ...patch } : n)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
|
function handleNodeConfigChange(nodeId: string, config: WorkflowNodeConfig) {
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
...workflow.graph,
|
...activeWorkflow.graph,
|
||||||
nodes: workflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
|
nodes: activeWorkflow.graph.nodes.map((n) => (n.id === nodeId ? { ...n, config } : n)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNodeRemove(nodeId: string) {
|
function handleNodeRemove(nodeId: string) {
|
||||||
const node = workflow.graph.nodes.find((n) => n.id === nodeId);
|
const node = activeWorkflow.graph.nodes.find((n) => n.id === nodeId);
|
||||||
if (!node || node.kind === 'start' || node.kind === 'end') {
|
if (!node || node.kind === 'start' || node.kind === 'end') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
nodes: workflow.graph.nodes.filter((n) => n.id !== nodeId),
|
nodes: activeWorkflow.graph.nodes.filter((n) => n.id !== nodeId),
|
||||||
edges: workflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
edges: activeWorkflow.graph.edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||||
});
|
});
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
|
function handleEdgeChange(edgeId: string, patch: Partial<WorkflowEdge>) {
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
...workflow.graph,
|
...activeWorkflow.graph,
|
||||||
edges: workflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
|
edges: activeWorkflow.graph.edges.map((e) => (e.id === edgeId ? { ...e, ...patch } : e)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdgeRemove(edgeId: string) {
|
function handleEdgeRemove(edgeId: string) {
|
||||||
emitGraphChange({
|
emitGraphChange({
|
||||||
...workflow.graph,
|
...activeWorkflow.graph,
|
||||||
edges: workflow.graph.edges.filter((e) => e.id !== edgeId),
|
edges: activeWorkflow.graph.edges.filter((e) => e.id !== edgeId),
|
||||||
});
|
});
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Drill-down ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const handleDrillIntoSubWorkflow = useCallback(
|
||||||
|
(node: WorkflowNode) => {
|
||||||
|
if (node.config.kind !== 'sub-workflow') return;
|
||||||
|
|
||||||
|
const config = node.config as SubWorkflowConfig;
|
||||||
|
|
||||||
|
if (config.workflowId) {
|
||||||
|
// Reference mode — find the referenced workflow and show it read-only
|
||||||
|
const ref = workflows.find((wf) => wf.id === config.workflowId);
|
||||||
|
if (!ref) return;
|
||||||
|
setBreadcrumbs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ workflow: ref, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: true },
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Inline mode — create a minimal workflow if needed, then drill in
|
||||||
|
let inlineWf = config.inlineWorkflow;
|
||||||
|
if (!inlineWf) {
|
||||||
|
inlineWf = createMinimalInlineWorkflow();
|
||||||
|
// Persist the new inline workflow into the node
|
||||||
|
handleNodeConfigChange(node.id, { kind: 'sub-workflow', inlineWorkflow: inlineWf });
|
||||||
|
}
|
||||||
|
setBreadcrumbs((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ workflow: inlineWf, nodeId: node.id, nodeLabel: node.label || 'Sub-Workflow', readOnly: false },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
},
|
||||||
|
[workflows, handleNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ── Breadcrumb sync: when the top-level workflow changes externally,
|
||||||
|
re-resolve the breadcrumb stack from the current workflow to keep
|
||||||
|
inline sub-workflows in sync. ──────────────────────────────────── */
|
||||||
|
|
||||||
|
// Kept simple: if breadcrumbs exist and the innermost is inline,
|
||||||
|
// re-resolve the active workflow from the current top-level workflow
|
||||||
|
// so external saves don't desync. Referenced (read-only) crumbs
|
||||||
|
// already point to a stable workflow object.
|
||||||
|
useMemo(() => {
|
||||||
|
if (breadcrumbs.length === 0) return;
|
||||||
|
let current: WorkflowDefinition = workflow;
|
||||||
|
const synced: WorkflowBreadcrumb[] = [];
|
||||||
|
for (const crumb of breadcrumbs) {
|
||||||
|
if (crumb.readOnly) {
|
||||||
|
synced.push(crumb);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parentNode = current.graph.nodes.find((n) => n.id === crumb.nodeId);
|
||||||
|
if (
|
||||||
|
!parentNode ||
|
||||||
|
parentNode.config.kind !== 'sub-workflow' ||
|
||||||
|
!(parentNode.config as SubWorkflowConfig).inlineWorkflow
|
||||||
|
) {
|
||||||
|
// Breadcrumb target no longer exists — pop remaining crumbs
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const resolved = (parentNode.config as SubWorkflowConfig).inlineWorkflow!;
|
||||||
|
synced.push({ ...crumb, workflow: resolved });
|
||||||
|
current = resolved;
|
||||||
|
}
|
||||||
|
if (synced.length !== breadcrumbs.length || synced.some((s, i) => s.workflow !== breadcrumbs[i].workflow)) {
|
||||||
|
setBreadcrumbs(synced);
|
||||||
|
}
|
||||||
|
}, [workflow]); // intentionally excluding breadcrumbs to avoid loops
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -234,6 +390,52 @@ export function WorkflowEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb bar */}
|
||||||
|
{breadcrumbs.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
|
||||||
|
<button
|
||||||
|
className="text-[12px] text-[var(--color-accent)] hover:underline"
|
||||||
|
onClick={() => { setBreadcrumbs([]); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{workflow.name || 'Untitled'}
|
||||||
|
</button>
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<Fragment key={`${crumb.nodeId}-${index}`}>
|
||||||
|
<ChevronRight className="size-3 text-[var(--color-text-muted)]" />
|
||||||
|
<button
|
||||||
|
className={`text-[12px] ${
|
||||||
|
index === breadcrumbs.length - 1
|
||||||
|
? 'font-medium text-[var(--color-text-primary)]'
|
||||||
|
: 'text-[var(--color-accent)] hover:underline'
|
||||||
|
}`}
|
||||||
|
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, index + 1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{crumb.nodeLabel}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Read-only banner for referenced sub-workflows */}
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-1)] px-4 py-2">
|
||||||
|
<Info className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
<span className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
This is a referenced workflow. Open it separately to edit.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="ml-auto text-[12px] text-[var(--color-accent)] hover:underline"
|
||||||
|
onClick={() => { setBreadcrumbs(breadcrumbs.slice(0, -1)); setSelectedNodeId(null); setSelectedEdgeId(null); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Go back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Body — palette + canvas + inspector */}
|
{/* Body — palette + canvas + inspector */}
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
{/* Left palette */}
|
{/* Left palette */}
|
||||||
@@ -277,18 +479,22 @@ export function WorkflowEditor({
|
|||||||
onGraphChange={emitGraphChange}
|
onGraphChange={emitGraphChange}
|
||||||
onNodeSelect={setSelectedNodeId}
|
onNodeSelect={setSelectedNodeId}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
workflow={workflow}
|
workflow={activeWorkflow}
|
||||||
|
workflows={workflows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings below canvas */}
|
{/* Settings below canvas */}
|
||||||
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
|
{breadcrumbs.length === 0 && (
|
||||||
|
<WorkflowSettingsPanel workflow={workflow} onChange={emitChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right inspector */}
|
{/* Right inspector */}
|
||||||
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
<div className="w-80 shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
<WorkflowGraphInspector
|
<WorkflowGraphInspector
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
|
onDrillIntoSubWorkflow={handleDrillIntoSubWorkflow}
|
||||||
onEdgeChange={handleEdgeChange}
|
onEdgeChange={handleEdgeChange}
|
||||||
onEdgeRemove={handleEdgeRemove}
|
onEdgeRemove={handleEdgeRemove}
|
||||||
onNodeChange={handleNodeChange}
|
onNodeChange={handleNodeChange}
|
||||||
@@ -297,7 +503,8 @@ export function WorkflowEditor({
|
|||||||
selectedEdgeId={selectedEdgeId}
|
selectedEdgeId={selectedEdgeId}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
validationIssues={issues}
|
validationIssues={issues}
|
||||||
workflow={workflow}
|
workflow={activeWorkflow}
|
||||||
|
workflows={workflows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { ExternalLink, GitBranch, Link, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SubWorkflowConfig,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowNodeConfig,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
|
interface SubWorkflowInspectorProps {
|
||||||
|
node: WorkflowNode;
|
||||||
|
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||||
|
currentWorkflowId: string;
|
||||||
|
onNodeChange: (nodeId: string, patch: Partial<WorkflowNode>) => void;
|
||||||
|
onNodeConfigChange: (nodeId: string, config: WorkflowNodeConfig) => void;
|
||||||
|
onNodeRemove: (nodeId: string) => void;
|
||||||
|
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceMode = 'reference' | 'inline';
|
||||||
|
|
||||||
|
function resolveSourceMode(config: SubWorkflowConfig): SourceMode {
|
||||||
|
if (config.workflowId) return 'reference';
|
||||||
|
if (config.inlineWorkflow) return 'inline';
|
||||||
|
return 'reference';
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">{label}</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubWorkflowInspector({
|
||||||
|
node,
|
||||||
|
workflows,
|
||||||
|
currentWorkflowId,
|
||||||
|
onNodeChange,
|
||||||
|
onNodeConfigChange,
|
||||||
|
onNodeRemove,
|
||||||
|
onDrillIntoSubWorkflow,
|
||||||
|
}: SubWorkflowInspectorProps) {
|
||||||
|
const config = node.config as SubWorkflowConfig;
|
||||||
|
const sourceMode = resolveSourceMode(config);
|
||||||
|
|
||||||
|
const availableWorkflows = workflows.filter((wf) => wf.id !== currentWorkflowId);
|
||||||
|
const selectedWorkflow = config.workflowId
|
||||||
|
? workflows.find((wf) => wf.id === config.workflowId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleModeChange = useCallback(
|
||||||
|
(mode: SourceMode) => {
|
||||||
|
if (mode === 'reference') {
|
||||||
|
onNodeConfigChange(node.id, { kind: 'sub-workflow', workflowId: config.workflowId });
|
||||||
|
} else {
|
||||||
|
onNodeConfigChange(node.id, {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
inlineWorkflow: config.inlineWorkflow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node.id, config.workflowId, config.inlineWorkflow, onNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkflowSelect = useCallback(
|
||||||
|
(workflowId: string) => {
|
||||||
|
onNodeConfigChange(node.id, {
|
||||||
|
kind: 'sub-workflow',
|
||||||
|
workflowId: workflowId || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[node.id, onNodeConfigChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrillIn = useCallback(() => {
|
||||||
|
onDrillIntoSubWorkflow(node);
|
||||||
|
}, [node, onDrillIntoSubWorkflow]);
|
||||||
|
|
||||||
|
const inlineNodeCount = config.inlineWorkflow?.graph?.nodes?.length ?? 0;
|
||||||
|
const inlineEdgeCount = config.inlineWorkflow?.graph?.edges?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-lg bg-amber-500/10">
|
||||||
|
<GitBranch className="size-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-[13px] font-semibold text-[var(--color-text-primary)]">
|
||||||
|
{node.label || 'Sub-Workflow'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex size-7 items-center justify-center rounded-lg text-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
|
||||||
|
onClick={() => onNodeRemove(node.id)}
|
||||||
|
title="Remove node"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<InputField
|
||||||
|
label="Label"
|
||||||
|
onChange={(v) => onNodeChange(node.id, { label: v })}
|
||||||
|
placeholder="Display label"
|
||||||
|
value={node.label}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Source mode selector */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">Source</span>
|
||||||
|
<div className="flex rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] p-0.5">
|
||||||
|
<button
|
||||||
|
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||||
|
sourceMode === 'reference'
|
||||||
|
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModeChange('reference')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Reference
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 rounded-md px-3 py-1.5 text-[12px] font-medium transition-all duration-200 ${
|
||||||
|
sourceMode === 'inline'
|
||||||
|
? 'bg-[var(--color-accent)]/15 text-[var(--color-accent)]'
|
||||||
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleModeChange('inline')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Inline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference mode */}
|
||||||
|
{sourceMode === 'reference' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
|
Workflow
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none transition focus:border-[var(--color-accent)]/50"
|
||||||
|
onChange={(e) => handleWorkflowSelect(e.target.value)}
|
||||||
|
value={config.workflowId ?? ''}
|
||||||
|
>
|
||||||
|
<option value="">Select a workflow…</option>
|
||||||
|
{availableWorkflows.map((wf) => (
|
||||||
|
<option key={wf.id} value={wf.id}>
|
||||||
|
{wf.name || 'Untitled'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedWorkflow?.description && (
|
||||||
|
<p className="text-[11px] leading-relaxed text-[var(--color-text-muted)]">
|
||||||
|
{selectedWorkflow.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!config.workflowId || !selectedWorkflow}
|
||||||
|
onClick={handleDrillIn}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
Open Sub-Workflow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline mode */}
|
||||||
|
{sourceMode === 'inline' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{config.inlineWorkflow ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-[12px] text-amber-400">
|
||||||
|
<Link className="size-3 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{inlineNodeCount} node{inlineNodeCount !== 1 ? 's' : ''},{' '}
|
||||||
|
{inlineEdgeCount} edge{inlineEdgeCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[12px] text-[var(--color-text-muted)]">
|
||||||
|
No inline workflow configured yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-[12px] font-medium text-[var(--color-accent)] transition-all duration-200 hover:bg-[var(--color-accent)]/10"
|
||||||
|
onClick={handleDrillIn}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
Edit Inline Workflow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { workflowEdgeTypes } from './WorkflowGraphEdges';
|
|||||||
interface WorkflowGraphCanvasProps {
|
interface WorkflowGraphCanvasProps {
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
availableModels?: ReadonlyArray<ModelDefinition>;
|
availableModels?: ReadonlyArray<ModelDefinition>;
|
||||||
|
workflows?: ReadonlyArray<WorkflowDefinition>;
|
||||||
onGraphChange: (graph: WorkflowGraph) => void;
|
onGraphChange: (graph: WorkflowGraph) => void;
|
||||||
onNodeSelect: (nodeId: string | null) => void;
|
onNodeSelect: (nodeId: string | null) => void;
|
||||||
onEdgeSelect: (edgeId: string | null) => void;
|
onEdgeSelect: (edgeId: string | null) => void;
|
||||||
@@ -47,6 +48,7 @@ interface WorkflowGraphCanvasProps {
|
|||||||
function WorkflowGraphCanvasInner({
|
function WorkflowGraphCanvasInner({
|
||||||
workflow,
|
workflow,
|
||||||
availableModels,
|
availableModels,
|
||||||
|
workflows,
|
||||||
onGraphChange,
|
onGraphChange,
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onEdgeSelect,
|
onEdgeSelect,
|
||||||
@@ -57,16 +59,16 @@ function WorkflowGraphCanvasInner({
|
|||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
const [nodes, setNodes, onNodesChangeBase] = useNodesState(
|
||||||
toCanvasNodes(graph, availableModels),
|
toCanvasNodes(graph, availableModels, workflows),
|
||||||
);
|
);
|
||||||
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
|
const [edges, setEdges, onEdgesChangeBase] = useEdgesState(
|
||||||
toCanvasEdges(graph),
|
toCanvasEdges(graph),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodes(toCanvasNodes(graph, availableModels));
|
setNodes(toCanvasNodes(graph, availableModels, workflows));
|
||||||
setEdges(toCanvasEdges(graph));
|
setEdges(toCanvasEdges(graph));
|
||||||
}, [graph, availableModels, setNodes, setEdges]);
|
}, [graph, availableModels, workflows, setNodes, setEdges]);
|
||||||
|
|
||||||
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
|
const handleNodesChange: OnNodesChange<Node<WorkflowGraphNodeData>> = useCallback(
|
||||||
(changes) => {
|
(changes) => {
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import type {
|
|||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentConfigFields';
|
||||||
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
import { ConditionEditor } from '@renderer/components/workflow/ConditionEditor';
|
||||||
|
import { SubWorkflowInspector } from '@renderer/components/workflow/SubWorkflowInspector';
|
||||||
|
|
||||||
interface WorkflowGraphInspectorProps {
|
interface WorkflowGraphInspectorProps {
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
|
workflows: ReadonlyArray<WorkflowDefinition>;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
selectedEdgeId: string | null;
|
selectedEdgeId: string | null;
|
||||||
validationIssues?: WorkflowValidationIssue[];
|
validationIssues?: WorkflowValidationIssue[];
|
||||||
@@ -30,6 +32,7 @@ interface WorkflowGraphInspectorProps {
|
|||||||
onNodeRemove: (nodeId: string) => void;
|
onNodeRemove: (nodeId: string) => void;
|
||||||
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
onEdgeChange: (edgeId: string, patch: Partial<WorkflowEdge>) => void;
|
||||||
onEdgeRemove: (edgeId: string) => void;
|
onEdgeRemove: (edgeId: string) => void;
|
||||||
|
onDrillIntoSubWorkflow: (node: WorkflowNode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputField({
|
function InputField({
|
||||||
@@ -375,6 +378,7 @@ function EdgeInspector({
|
|||||||
export function WorkflowGraphInspector({
|
export function WorkflowGraphInspector({
|
||||||
availableModels,
|
availableModels,
|
||||||
workflow,
|
workflow,
|
||||||
|
workflows,
|
||||||
selectedNodeId,
|
selectedNodeId,
|
||||||
selectedEdgeId,
|
selectedEdgeId,
|
||||||
validationIssues,
|
validationIssues,
|
||||||
@@ -383,6 +387,7 @@ export function WorkflowGraphInspector({
|
|||||||
onNodeRemove,
|
onNodeRemove,
|
||||||
onEdgeChange,
|
onEdgeChange,
|
||||||
onEdgeRemove,
|
onEdgeRemove,
|
||||||
|
onDrillIntoSubWorkflow,
|
||||||
}: WorkflowGraphInspectorProps) {
|
}: WorkflowGraphInspectorProps) {
|
||||||
const selectedNode = selectedNodeId
|
const selectedNode = selectedNodeId
|
||||||
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
|
? workflow.graph.nodes.find((n) => n.id === selectedNodeId)
|
||||||
@@ -436,6 +441,22 @@ export function WorkflowGraphInspector({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedNode.kind === 'sub-workflow') {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<SubWorkflowInspector
|
||||||
|
currentWorkflowId={workflow.id}
|
||||||
|
node={selectedNode}
|
||||||
|
onDrillIntoSubWorkflow={onDrillIntoSubWorkflow}
|
||||||
|
onNodeChange={onNodeChange}
|
||||||
|
onNodeConfigChange={onNodeConfigChange}
|
||||||
|
onNodeRemove={onNodeRemove}
|
||||||
|
workflows={workflows}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<PlaceholderNodeInspector
|
<PlaceholderNodeInspector
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Bot, Circle, Code, FunctionSquare, GitBranch, Radio, Play, Square } from 'lucide-react';
|
import { Bot, Circle, Code, FunctionSquare, GitBranch, Link2, Radio, Play, Square } from 'lucide-react';
|
||||||
|
|
||||||
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
|
import type { WorkflowGraphNodeData } from '@renderer/lib/workflowGraph';
|
||||||
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
import type { WorkflowNodeKind } from '@shared/domain/workflow';
|
||||||
@@ -39,6 +39,7 @@ const handleStyles = {
|
|||||||
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
|
function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) {
|
||||||
const colors = kindColors[data.kind] ?? kindColors.agent;
|
const colors = kindColors[data.kind] ?? kindColors.agent;
|
||||||
const isAgent = data.kind === 'agent';
|
const isAgent = data.kind === 'agent';
|
||||||
|
const isSubWorkflow = data.kind === 'sub-workflow';
|
||||||
|
|
||||||
const renderIcon = () => {
|
const renderIcon = () => {
|
||||||
if (isAgent && data.provider) {
|
if (isAgent && data.provider) {
|
||||||
@@ -62,6 +63,18 @@ function WorkflowNodeContent({ data, selected }: { data: WorkflowGraphNodeData;
|
|||||||
{isAgent && data.modelLabel && (
|
{isAgent && data.modelLabel && (
|
||||||
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{data.modelLabel}</div>
|
||||||
)}
|
)}
|
||||||
|
{isSubWorkflow && data.subWorkflowLabel && (
|
||||||
|
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{data.subWorkflowLabel === 'Inline' ? (
|
||||||
|
<span className="rounded bg-amber-500/15 px-1 py-0.5 text-amber-400">Inline</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<Link2 className="inline size-2.5" />
|
||||||
|
{data.subWorkflowLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
WorkflowGraph,
|
WorkflowGraph,
|
||||||
WorkflowNode,
|
WorkflowNode,
|
||||||
WorkflowNodeKind,
|
WorkflowNodeKind,
|
||||||
|
SubWorkflowConfig,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
import type { ModelProvider } from '@shared/domain/models';
|
import type { ModelProvider } from '@shared/domain/models';
|
||||||
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
import { inferProvider, findModel, type ModelDefinition } from '@shared/domain/models';
|
||||||
@@ -21,6 +22,8 @@ export interface WorkflowGraphNodeData extends Record<string, unknown> {
|
|||||||
provider?: ModelProvider;
|
provider?: ModelProvider;
|
||||||
/** Short display name for the agent's model (agent nodes only). */
|
/** Short display name for the agent's model (agent nodes only). */
|
||||||
modelLabel?: string;
|
modelLabel?: string;
|
||||||
|
/** Sub-workflow label: referenced workflow name or "Inline" (sub-workflow nodes only). */
|
||||||
|
subWorkflowLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── View-model projection ─────────────────────────────────── */
|
/* ── View-model projection ─────────────────────────────────── */
|
||||||
@@ -47,10 +50,12 @@ function resolveNodeType(kind: WorkflowNodeKind): string {
|
|||||||
export function toCanvasNodes(
|
export function toCanvasNodes(
|
||||||
graph: WorkflowGraph,
|
graph: WorkflowGraph,
|
||||||
models?: ReadonlyArray<ModelDefinition>,
|
models?: ReadonlyArray<ModelDefinition>,
|
||||||
|
workflows?: ReadonlyArray<WorkflowDefinition>,
|
||||||
): Node<WorkflowGraphNodeData>[] {
|
): Node<WorkflowGraphNodeData>[] {
|
||||||
return graph.nodes.map((node) => {
|
return graph.nodes.map((node) => {
|
||||||
let provider: ModelProvider | undefined;
|
let provider: ModelProvider | undefined;
|
||||||
let modelLabel: string | undefined;
|
let modelLabel: string | undefined;
|
||||||
|
let subWorkflowLabel: string | undefined;
|
||||||
|
|
||||||
if (node.kind === 'agent' && node.config.kind === 'agent') {
|
if (node.kind === 'agent' && node.config.kind === 'agent') {
|
||||||
const modelId = node.config.model;
|
const modelId = node.config.model;
|
||||||
@@ -61,6 +66,16 @@ export function toCanvasNodes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.kind === 'sub-workflow' && node.config.kind === 'sub-workflow') {
|
||||||
|
const swConfig = node.config as SubWorkflowConfig;
|
||||||
|
if (swConfig.workflowId && workflows) {
|
||||||
|
const ref = workflows.find((wf) => wf.id === swConfig.workflowId);
|
||||||
|
subWorkflowLabel = ref?.name ?? swConfig.workflowId;
|
||||||
|
} else if (swConfig.inlineWorkflow) {
|
||||||
|
subWorkflowLabel = 'Inline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isSystemNode = node.kind === 'start' || node.kind === 'end';
|
const isSystemNode = node.kind === 'start' || node.kind === 'end';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +87,7 @@ export function toCanvasNodes(
|
|||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
provider,
|
provider,
|
||||||
modelLabel,
|
modelLabel,
|
||||||
|
subWorkflowLabel,
|
||||||
},
|
},
|
||||||
draggable: true,
|
draggable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user