ae67bff5a3
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
1300 lines
65 KiB
TypeScript
1300 lines
65 KiB
TypeScript
"use client";
|
||
/* Workflows list + the React Flow canvas editor (palette · typed nodes · inspector). */
|
||
import "@xyflow/react/dist/style.css";
|
||
import {
|
||
addEdge, applyEdgeChanges, applyNodeChanges, Background, BackgroundVariant, Controls, MiniMap, ReactFlow, ReactFlowProvider,
|
||
useEdgesState, useNodesState, useReactFlow, ViewportPortal, type Connection, type Edge, type EdgeChange, type NodeChange,
|
||
} from "@xyflow/react";
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||
import { Icon } from "../icons";
|
||
import { StatusPill, Tile } from "../primitives";
|
||
import { ForgeNode, NodeTypesContext } from "../canvas/ForgeNode";
|
||
import { AgentConfig, CollapsibleSection } from "../canvas/AgentConfig";
|
||
import { EdgeOverlay } from "../canvas/EdgeOverlay";
|
||
import { WorkflowTestPanel } from "../canvas/WorkflowTestPanel";
|
||
import { FieldsForm, ModelSelect, MultiSelectChips, type FieldSpec } from "../canvas/ConfigForm";
|
||
import { Field, Toggle } from "../primitives";
|
||
import { VersionHistory } from "../version-history";
|
||
import { ImportExport } from "../import-export";
|
||
import type { ComponentT } from "@/lib/api";
|
||
import { api, openSSE, Agent, McpClientT, NodeType, Tool, ToolSet, Workflow } from "@/lib/api";
|
||
import { NODE_META, NODE_HELP, IO_COLOR, fmtUSD } from "@/lib/data";
|
||
import { canvasToExecutable, canvasToFlow, ioCompatible, newNodeId, starterWorkflow, type FlowEdge, type FlowNode } from "@/lib/graph";
|
||
|
||
function stripRunData(nds: FlowNode[]): FlowNode[] {
|
||
return nds.map((n) => {
|
||
const { status, debug, ...data } = n.data;
|
||
return { ...n, data };
|
||
});
|
||
}
|
||
|
||
function EditableName({
|
||
value,
|
||
fallback,
|
||
className,
|
||
inputClassName = "input",
|
||
style,
|
||
inputStyle,
|
||
onCommit,
|
||
}: {
|
||
value?: string | null;
|
||
fallback: string;
|
||
className?: string;
|
||
inputClassName?: string;
|
||
style?: CSSProperties;
|
||
inputStyle?: CSSProperties;
|
||
onCommit: (name: string) => void | Promise<void>;
|
||
}) {
|
||
const display = (value || "").trim() || fallback;
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState(display);
|
||
|
||
useEffect(() => {
|
||
if (!editing) setDraft(display);
|
||
}, [display, editing]);
|
||
|
||
const finish = () => {
|
||
const next = draft.trim();
|
||
setEditing(false);
|
||
if (!next || next === display) {
|
||
setDraft(display);
|
||
return;
|
||
}
|
||
void onCommit(next);
|
||
};
|
||
|
||
if (editing) {
|
||
return (
|
||
<input
|
||
className={inputClassName}
|
||
autoFocus
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onBlur={finish}
|
||
onFocus={(e) => e.currentTarget.select()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") e.currentTarget.blur();
|
||
if (e.key === "Escape") {
|
||
setDraft(display);
|
||
setEditing(false);
|
||
}
|
||
}}
|
||
style={{ minWidth: 0, ...inputStyle }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={className}
|
||
title={display}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setDraft(display);
|
||
setEditing(true);
|
||
}}
|
||
onPointerDown={(e) => e.stopPropagation()}
|
||
style={{
|
||
display: "block",
|
||
maxWidth: "100%",
|
||
minWidth: 0,
|
||
border: 0,
|
||
background: "transparent",
|
||
color: "inherit",
|
||
padding: 0,
|
||
textAlign: "left",
|
||
cursor: "text",
|
||
...style,
|
||
}}
|
||
>
|
||
{display}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/* ============ WORKFLOWS LIST ============ */
|
||
export function WorkflowsScreen({ project, onOpen }: { project: any; onOpen: (w: Workflow) => void }) {
|
||
const [wfs, setWfs] = useState<Workflow[]>([]);
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const reload = useCallback(() => {
|
||
if (!project?.id) return;
|
||
api.listWorkflows(project.id).then((w) => { setWfs(w); setLoaded(true); }).catch(() => setLoaded(true));
|
||
}, [project?.id]);
|
||
useEffect(() => { reload(); }, [reload]);
|
||
|
||
async function create() {
|
||
setBusy(true);
|
||
try {
|
||
const s = starterWorkflow();
|
||
const executable = canvasToExecutable(s.nodes, s.edges, { id: "wf" });
|
||
const wf = await api.createWorkflow(project.id, { name: "Untitled workflow", canvas: s.canvas, executable });
|
||
onOpen(wf);
|
||
} finally { setBusy(false); }
|
||
}
|
||
|
||
const [deleting, setDeleting] = useState<string | null>(null);
|
||
async function del(e: React.MouseEvent, w: Workflow) {
|
||
e.stopPropagation();
|
||
if (!window.confirm(`Delete workflow "${w.name}"?\n\nThis also removes its run history and traces. This cannot be undone.`)) return;
|
||
setDeleting(w.id);
|
||
try {
|
||
setWfs((prev) => prev.filter((x) => x.id !== w.id)); // optimistic
|
||
await api.deleteWorkflow(project.id, w.id);
|
||
} catch {
|
||
reload(); // restore on failure
|
||
} finally {
|
||
setDeleting(null);
|
||
}
|
||
}
|
||
|
||
async function renameWorkflow(w: Workflow, name: string) {
|
||
if (!project?.id || name === w.name) return;
|
||
const previous = wfs;
|
||
setWfs((prev) => prev.map((x) => (x.id === w.id ? { ...x, name } : x)));
|
||
try {
|
||
const updated = await api.updateWorkflow(project.id, w.id, { name });
|
||
setWfs((prev) => prev.map((x) => (x.id === updated.id ? updated : x)));
|
||
} catch {
|
||
setWfs(previous);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="scroll-y" style={{ flex: 1, padding: "24px 28px" }}>
|
||
<div className="fade-up" style={{ maxWidth: 1600, margin: "0 auto" }}>
|
||
<div className="row spread" style={{ marginBottom: 18 }}>
|
||
<div>
|
||
<div className="t-display">Workflows</div>
|
||
<div className="fg-1" style={{ marginTop: 3 }}>Visual graphs compiled to LangGraph. Build agents, routers, tools, and HITL on the canvas.</div>
|
||
</div>
|
||
<div className="row gap2">
|
||
<ImportExport project={project} type="workflow" typeLabel="workflow" size="md" onImported={reload}
|
||
items={wfs.map((w) => ({ id: w.id, name: w.name || "Untitled workflow", sub: `${(w.executable?.nodes || []).length} nodes` }))} />
|
||
<button className="btn btn-primary" onClick={create} disabled={busy}><Icon name="plus" size={15} />{busy ? "Creating…" : "New workflow"}</button>
|
||
</div>
|
||
</div>
|
||
{loaded && wfs.length === 0 ? (
|
||
<div className="card col center" style={{ padding: 48, gap: 12, textAlign: "center" }}>
|
||
<Tile icon="workflows" color="var(--accent)" size={52} glow />
|
||
<div className="t-h1">No workflows yet</div>
|
||
<div className="fg-1" style={{ maxWidth: 360 }}>Create your first workflow and wire nodes on the canvas.</div>
|
||
<button className="btn btn-primary btn-lg" onClick={create} disabled={busy}><Icon name="plus" size={16} />New workflow</button>
|
||
</div>
|
||
) : (
|
||
<div className="col gap3">
|
||
{wfs.map((w) => (
|
||
<div key={w.id} className="card card-hover" style={{ padding: 14 }} onClick={() => onOpen(w)}>
|
||
<div className="row gap3">
|
||
<Tile icon="workflows" color="var(--accent)" size={38} />
|
||
<div className="grow" style={{ minWidth: 0 }}>
|
||
<EditableName value={w.name} fallback="Untitled workflow" className="t-h2 truncate" inputStyle={{ height: 28, maxWidth: 360 }} onCommit={(name) => renameWorkflow(w, name)} />
|
||
<div className="fg-2 t-caption">{(w.executable?.nodes || []).length} nodes · v{w.active_version}</div>
|
||
</div>
|
||
<StatusPill status={w.status === "active" ? "active" : "draft"} />
|
||
<button className="iconbtn" title="Delete workflow" disabled={deleting === w.id} onClick={(e) => del(e, w)}><Icon name="trash" size={15} /></button>
|
||
<Icon name="chevright" size={16} style={{ color: "var(--fg-2)" }} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ============ CANVAS ============ */
|
||
export function guardCanvasBeforeUnload(dirty: boolean, event: BeforeUnloadEvent): boolean {
|
||
if (!dirty) return false;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
return true;
|
||
}
|
||
|
||
export function WorkflowCanvas(props: { project: any; workflowId?: string; onWorkflowChange?: (workflow: Workflow) => void; onBack: () => void; onRun: () => void; onRegisterFlush?: (fn: (() => Promise<void>) | null) => void }) {
|
||
return (
|
||
<ReactFlowProvider>
|
||
<CanvasInner {...props} />
|
||
</ReactFlowProvider>
|
||
);
|
||
}
|
||
|
||
function CanvasInner({ project, workflowId, onWorkflowChange, onBack, onRun, onRegisterFlush }: { project: any; workflowId?: string; onWorkflowChange?: (workflow: Workflow) => void; onBack: () => void; onRun: () => void; onRegisterFlush?: (fn: (() => Promise<void>) | null) => void }) {
|
||
const [wf, setWf] = useState<Workflow | null>(null);
|
||
const [registry, setRegistry] = useState<Record<string, NodeType>>({});
|
||
const [tools, setTools] = useState<Tool[]>([]);
|
||
const [toolSets, setToolSets] = useState<ToolSet[]>([]);
|
||
// Saved agent presets (from the Agents tab) - loadable into an agent node.
|
||
const [agents, setAgents] = useState<Agent[]>([]);
|
||
const [mcpServers, setMcpServers] = useState<McpClientT[]>([]);
|
||
const [components, setComponents] = useState<ComponentT[]>([]);
|
||
// Live KB folders + Q&A kinds drive the dropdowns in the retrieval node config.
|
||
const [kbFolders, setKbFolders] = useState<string[]>([]);
|
||
const [qaKinds, setQaKinds] = useState<string[]>([]);
|
||
const [nodes, setNodes] = useNodesState<FlowNode>([]);
|
||
const [edges, setEdges] = useEdgesState<FlowEdge>([]);
|
||
const [selId, setSelId] = useState<string | null>(null);
|
||
const [problems, setProblems] = useState<{ pointer: string; message: string }[]>([]);
|
||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "invalid">("idle");
|
||
const [running, setRunning] = useState(false);
|
||
const [testOpen, setTestOpen] = useState(false);
|
||
const [showProblems, setShowProblems] = useState(false);
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
// Unsaved-changes tracking (drives the beforeunload guard + auto-save on navigate-away).
|
||
const [dirty, setDirty] = useState(false);
|
||
const dirtyRef = useRef(false);
|
||
// Undo/redo stacks of {nodes, edges} snapshots. Structural actions push; undo/redo pop.
|
||
const [past, setPast] = useState<{ nodes: FlowNode[]; edges: FlowEdge[] }[]>([]);
|
||
const [future, setFuture] = useState<{ nodes: FlowNode[]; edges: FlowEdge[] }[]>([]);
|
||
// Clipboard for copy/paste/duplicate of a single node.
|
||
const clipboardRef = useRef<{ nodeType: string; config: Record<string, any> } | null>(null);
|
||
// Transient, non-blocking connection-type mismatch hint (edges stay permissive).
|
||
const [connWarning, setConnWarning] = useState<string | null>(null);
|
||
const [paletteQuery, setPaletteQuery] = useState("");
|
||
// Hover help for palette items - rendered as a fixed-position card so the palette's
|
||
// own scroll container can't clip it. Positioned from the hovered item's rect (the
|
||
// palette's viewport offset varies with the rail/sidebar, so no hardcoded left).
|
||
const [paletteTip, setPaletteTip] = useState<{ type: string; top: number; left: number } | null>(null);
|
||
const { fitView } = useReactFlow();
|
||
const registryReady = Object.keys(registry).length > 0;
|
||
|
||
useEffect(() => {
|
||
api.listNodeTypes().then((nt) => setRegistry(Object.fromEntries(nt.map((n) => [n.type, n])))).catch(() => {});
|
||
if (project?.id) {
|
||
api.listTools(project.id).then(setTools).catch(() => {});
|
||
api.listToolSets(project.id).then(setToolSets).catch(() => {});
|
||
api.listAgents(project.id).then(setAgents).catch(() => {});
|
||
api.listMcpClients(project.id).then(setMcpServers).catch(() => {});
|
||
api.listComponents(project.id).then(setComponents).catch(() => {});
|
||
api.listFolders(project.id).then(setKbFolders).catch(() => {});
|
||
api.listQaKinds(project.id).then(setQaKinds).catch(() => {});
|
||
}
|
||
}, [project?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!project?.id || !workflowId) return;
|
||
api.getWorkflow(project.id, workflowId).then((w) => {
|
||
setWf(w);
|
||
const flow = w.canvas?.nodes?.length ? canvasToFlow(w.canvas) : starterWorkflow();
|
||
setNodes(flow.nodes);
|
||
setEdges(flow.edges as any);
|
||
setTimeout(() => fitView({ padding: 0.2, duration: 300 }), 90);
|
||
}).catch(() => {});
|
||
}, [project?.id, workflowId, reloadKey, setNodes, setEdges, fitView]);
|
||
|
||
const nodeTypes = useMemo(() => ({ forge: ForgeNode }), []);
|
||
const selected = nodes.find((n) => n.id === selId) || null;
|
||
|
||
// ---- Canvas UX: dirty tracking, undo/redo, copy/paste/duplicate ----
|
||
// Refs mirror the latest graph so the history/clipboard callbacks stay stable (no dep churn)
|
||
// yet always operate on current state; calling snapshot() inside an action captures the
|
||
// pre-action graph (refs update after render).
|
||
const nodesRef = useRef(nodes);
|
||
const edgesRef = useRef(edges);
|
||
useEffect(() => { nodesRef.current = nodes; }, [nodes]);
|
||
useEffect(() => { edgesRef.current = edges; }, [edges]);
|
||
useEffect(() => { dirtyRef.current = dirty; }, [dirty]);
|
||
|
||
// A node wired as a deep_agent sub-agent (target of a "subagents" edge) is a folded specialist,
|
||
// not a flow node - flag it so ForgeNode hides its now-meaningless in/out flow handles.
|
||
const subagentIds = useMemo(
|
||
() => new Set(edges.filter((e) => e.sourceHandle === "subagents").map((e) => e.target)),
|
||
[edges],
|
||
);
|
||
const flowNodes = useMemo(
|
||
() => (subagentIds.size === 0 ? nodes : nodes.map((n) => (subagentIds.has(n.id) ? { ...n, data: { ...n.data, isSubagent: true } } : n))),
|
||
[nodes, subagentIds],
|
||
);
|
||
|
||
// Warn before closing/reloading the tab with unsaved edits.
|
||
useEffect(() => {
|
||
const h = (e: BeforeUnloadEvent) => { guardCanvasBeforeUnload(dirtyRef.current, e); };
|
||
window.addEventListener("beforeunload", h);
|
||
return () => window.removeEventListener("beforeunload", h);
|
||
}, []);
|
||
|
||
const snapshot = useCallback(() => {
|
||
setPast((p) => [...p.slice(-49), { nodes: nodesRef.current, edges: edgesRef.current }]);
|
||
setFuture([]);
|
||
}, []);
|
||
|
||
const undo = useCallback(() => {
|
||
setPast((p) => {
|
||
if (!p.length) return p;
|
||
const prev = p[p.length - 1];
|
||
setFuture((f) => [{ nodes: nodesRef.current, edges: edgesRef.current }, ...f].slice(0, 50));
|
||
setNodes(prev.nodes);
|
||
setEdges(prev.edges as any);
|
||
setSelId(null);
|
||
setDirty(true);
|
||
return p.slice(0, -1);
|
||
});
|
||
}, [setNodes, setEdges]);
|
||
|
||
const redo = useCallback(() => {
|
||
setFuture((f) => {
|
||
if (!f.length) return f;
|
||
const next = f[0];
|
||
setPast((p) => [...p.slice(-49), { nodes: nodesRef.current, edges: edgesRef.current }]);
|
||
setNodes(next.nodes);
|
||
setEdges(next.edges as any);
|
||
setSelId(null);
|
||
setDirty(true);
|
||
return f.slice(1);
|
||
});
|
||
}, [setNodes, setEdges]);
|
||
|
||
const copyNode = useCallback(() => {
|
||
const n = nodesRef.current.find((x) => x.id === selId);
|
||
if (n) clipboardRef.current = { nodeType: n.data.nodeType, config: JSON.parse(JSON.stringify(n.data.config || {})) };
|
||
}, [selId]);
|
||
|
||
const pasteNode = useCallback(() => {
|
||
const clip = clipboardRef.current;
|
||
if (!clip) return;
|
||
snapshot();
|
||
const cur = nodesRef.current;
|
||
const id = newNodeId(clip.nodeType, cur.map((n) => n.id));
|
||
const base = cur.find((n) => n.id === selId);
|
||
const pos = base ? { x: base.position.x + 44, y: base.position.y + 44 } : { x: 360 + (cur.length % 4) * 36, y: 160 + (cur.length % 4) * 36 };
|
||
setNodes((nds) => [...nds, { id, type: "forge", position: pos, data: { nodeType: clip.nodeType, config: JSON.parse(JSON.stringify(clip.config)) } }]);
|
||
setSelId(id);
|
||
setDirty(true);
|
||
}, [selId, setNodes, snapshot]);
|
||
|
||
const duplicateNode = useCallback(() => {
|
||
const n = nodesRef.current.find((x) => x.id === selId);
|
||
if (!n) return;
|
||
clipboardRef.current = { nodeType: n.data.nodeType, config: JSON.parse(JSON.stringify(n.data.config || {})) };
|
||
pasteNode();
|
||
}, [selId, pasteNode]);
|
||
|
||
// Keyboard: Ctrl/Cmd+Z undo · Ctrl/Cmd+Shift+Z (or Ctrl+Y) redo · Ctrl+C/V copy/paste ·
|
||
// Ctrl+D duplicate. Ignored while typing in a form field so text editing keeps its own undo.
|
||
useEffect(() => {
|
||
const isEditable = (el: EventTarget | null) => {
|
||
const t = el as HTMLElement | null;
|
||
if (!t) return false;
|
||
const tag = t.tagName;
|
||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || !!t.isContentEditable;
|
||
};
|
||
const h = (e: KeyboardEvent) => {
|
||
if (!(e.metaKey || e.ctrlKey) || isEditable(e.target)) return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||
else if (k === "y") { e.preventDefault(); redo(); }
|
||
else if (k === "c") { copyNode(); }
|
||
else if (k === "v") { pasteNode(); }
|
||
else if (k === "d") { e.preventDefault(); duplicateNode(); }
|
||
};
|
||
window.addEventListener("keydown", h);
|
||
return () => window.removeEventListener("keydown", h);
|
||
}, [undo, redo, copyNode, pasteNode, duplicateNode]);
|
||
|
||
// LangGraph edges are control flow (data moves via shared state), so connections
|
||
// are permissive: any output -> any input, just not a node to itself. Handle colors
|
||
// remain as a visual hint of the dominant data type.
|
||
const isValidConnection = useCallback(
|
||
(c: Connection | Edge) => {
|
||
if (!c.source || !c.target || c.source === c.target) return false;
|
||
// A sub-agent edge (deep_agent's bottom handle) may only connect to an agent it can call.
|
||
if (c.sourceHandle === "subagents") {
|
||
const tgt = nodes.find((n) => n.id === c.target)?.data.nodeType;
|
||
return tgt === "agent" || tgt === "deep_agent";
|
||
}
|
||
return true;
|
||
},
|
||
[nodes],
|
||
);
|
||
|
||
const stripRouterTargets = useCallback((nds: FlowNode[], removedIds: Set<string>): FlowNode[] => {
|
||
if (!removedIds.size) return nds;
|
||
return nds.map((n) => {
|
||
if (n.data.nodeType !== "router") return n;
|
||
const cfg = { ...(n.data.config || {}) };
|
||
const cases = Object.fromEntries(
|
||
Object.entries(cfg.cases || {}).map(([key, target]) => [key, removedIds.has(String(target)) ? "" : target]),
|
||
);
|
||
if (removedIds.has(String(cfg.default))) cfg.default = "";
|
||
return { ...n, data: { ...n.data, config: { ...cfg, cases } } };
|
||
});
|
||
}, []);
|
||
|
||
const onNodesChangeSynced = useCallback((changes: NodeChange[]) => {
|
||
const removedIds = new Set(
|
||
changes.filter((c) => c.type === "remove").map((c) => c.id),
|
||
);
|
||
// Snapshot before a keyboard (Backspace/Delete) removal so it can be undone.
|
||
if (removedIds.size) snapshot();
|
||
// Ignore pure selection/measurement churn so the canvas doesn't read as dirty on load.
|
||
if (changes.some((c) => c.type !== "select" && c.type !== "dimensions")) setDirty(true);
|
||
setNodes((nds) => stripRouterTargets(applyNodeChanges(changes, nds) as FlowNode[], removedIds));
|
||
}, [setNodes, stripRouterTargets, snapshot]);
|
||
|
||
const syncRouterCaseEdge = useCallback((nodeId: string, key: string, target: string) => {
|
||
const handle = `case:${key}`;
|
||
setEdges((eds) => {
|
||
const rest = eds.filter((e) => !(e.source === nodeId && e.sourceHandle === handle));
|
||
if (!target) return rest;
|
||
return [
|
||
...rest,
|
||
{
|
||
id: `e-${nodeId}-${key}-${target}`,
|
||
source: nodeId,
|
||
target,
|
||
sourceHandle: handle,
|
||
style: { stroke: IO_COLOR.control || "var(--io-control)", strokeWidth: 2 },
|
||
} as FlowEdge,
|
||
];
|
||
});
|
||
}, [setEdges]);
|
||
|
||
const renameRouterCaseEdges = useCallback((nodeId: string, oldKey: string, newKey: string) => {
|
||
setEdges((eds) => eds.map((e) => (
|
||
e.source === nodeId && e.sourceHandle === `case:${oldKey}`
|
||
? { ...e, sourceHandle: `case:${newKey}`, id: `e-${nodeId}-${newKey}-${e.target}` }
|
||
: e
|
||
)));
|
||
}, [setEdges]);
|
||
|
||
const removeRouterCaseEdges = useCallback((nodeId: string, key: string) => {
|
||
setEdges((eds) => eds.filter((e) => !(e.source === nodeId && e.sourceHandle === `case:${key}`)));
|
||
}, [setEdges]);
|
||
|
||
const routerKeyForEdge = useCallback((cfg: Record<string, any>, edge: FlowEdge): string | undefined => {
|
||
if (edge.sourceHandle?.startsWith("case:")) return edge.sourceHandle.slice(5);
|
||
const match = Object.entries(cfg.cases || {}).find(([, target]) => target === edge.target);
|
||
if (match) return match[0];
|
||
if (cfg.default === edge.target) return "__default__";
|
||
return undefined;
|
||
}, []);
|
||
|
||
const clearRouterTargetsForRemovedEdges = useCallback((removed: FlowEdge[]) => {
|
||
if (!removed.length) return;
|
||
setNodes((nds) => nds.map((n) => {
|
||
if (n.data.nodeType !== "router") return n;
|
||
const relevant = removed.filter((e) => e.source === n.id);
|
||
if (!relevant.length) return n;
|
||
const cfg = { ...(n.data.config || {}) };
|
||
let cases = { ...(cfg.cases || {}) };
|
||
let changed = false;
|
||
for (const edge of relevant) {
|
||
const key = routerKeyForEdge(cfg, edge);
|
||
if (!key) continue;
|
||
if (key === "__default__") {
|
||
if (cfg.default) { cfg.default = ""; changed = true; }
|
||
} else if (Object.prototype.hasOwnProperty.call(cases, key) && cases[key]) {
|
||
cases = { ...cases, [key]: "" };
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? { ...n, data: { ...n.data, config: { ...cfg, cases } } } : n;
|
||
}));
|
||
}, [routerKeyForEdge, setNodes]);
|
||
|
||
const onEdgesChangeSynced = useCallback((changes: EdgeChange[]) => {
|
||
const removedIds = new Set(changes.filter((c) => c.type === "remove").map((c) => c.id));
|
||
if (removedIds.size) {
|
||
snapshot();
|
||
clearRouterTargetsForRemovedEdges(edges.filter((e) => removedIds.has(e.id)));
|
||
}
|
||
if (changes.some((c) => c.type !== "select")) setDirty(true);
|
||
setEdges((eds) => applyEdgeChanges(changes, eds) as FlowEdge[]);
|
||
}, [clearRouterTargetsForRemovedEdges, edges, setEdges, snapshot]);
|
||
|
||
const removeEdge = useCallback((edge: FlowEdge) => {
|
||
snapshot();
|
||
setDirty(true);
|
||
clearRouterTargetsForRemovedEdges([edge]);
|
||
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
|
||
}, [clearRouterTargetsForRemovedEdges, setEdges, snapshot]);
|
||
|
||
const onConnect = useCallback((params: Connection) => {
|
||
if (!isValidConnection(params)) return;
|
||
snapshot();
|
||
setDirty(true);
|
||
const sn = nodes.find((n) => n.id === params.source);
|
||
const fromRouterCase = !!params.sourceHandle?.startsWith("case:") && sn?.data.nodeType === "router";
|
||
// Dragging from a router's case row wires that case to the target node - the canvas
|
||
// edge is the picture, config.cases/default is what the compiler routes on.
|
||
if (params.sourceHandle?.startsWith("case:") && sn?.data.nodeType === "router") {
|
||
const key = params.sourceHandle.slice(5);
|
||
setNodes((nds) => nds.map((n) => {
|
||
if (n.id !== params.source) return n;
|
||
const cfg = { ...(n.data.config || {}) };
|
||
if (key === "__default__") cfg.default = params.target;
|
||
else cfg.cases = { ...(cfg.cases || {}), [key]: params.target };
|
||
return { ...n, data: { ...n.data, config: cfg } };
|
||
}));
|
||
}
|
||
const sp = registry[sn?.data.nodeType || ""]?.output_ports || [];
|
||
const io = (sp.find((p) => p.id === params.sourceHandle) || sp[0])?.io_type || "any";
|
||
// Non-blocking type hint: LangGraph edges are control flow (data moves via shared state),
|
||
// so a mismatch is allowed - but surface it so the user knows the port types differ.
|
||
const tn = nodes.find((n) => n.id === params.target);
|
||
const tp = registry[tn?.data.nodeType || ""]?.input_ports || [];
|
||
const tio = (tp.find((p) => p.id === params.targetHandle) || tp[0])?.io_type || "any";
|
||
if (!ioCompatible(io, tio)) {
|
||
setConnWarning(`Connected ${io} → ${tio}: the port types differ. It still works (data flows through shared state), just double-check this is intended.`);
|
||
window.setTimeout(() => setConnWarning(null), 6000);
|
||
}
|
||
setEdges((eds) => addEdge(
|
||
{ ...params, style: { stroke: IO_COLOR[io] || "var(--io-any)", strokeWidth: 2 } },
|
||
fromRouterCase ? eds.filter((e) => !(e.source === params.source && e.sourceHandle === params.sourceHandle)) : eds,
|
||
));
|
||
}, [isValidConnection, nodes, registry, setEdges, setNodes, snapshot]);
|
||
|
||
const addNode = useCallback((type: string) => {
|
||
snapshot();
|
||
setDirty(true);
|
||
const id = newNodeId(type, nodes.map((n) => n.id));
|
||
const n = nodes.length;
|
||
const defConfig: Record<string, any> =
|
||
type === "agent" || type === "deep_agent" ? { flavor: type === "deep_agent" ? "deep_agent" : "agent", model: "openai:gpt-4o-mini", middleware: [], tools: [] }
|
||
: type === "router" ? { expression: "intent", cases: {}, default: "" }
|
||
: type === "llm" ? { model: "openai:gpt-4o-mini", prompt: "" }
|
||
: type === "retrieval" ? { top_k: 4, include_docs: true, hybrid: false, rerank: false, include_qa: true, qa_threshold: 0.3, qa_top_k: 3, min_score: 0.18, announce_empty: true }
|
||
: type === "classifier" ? { labels: ["question", "request", "complaint"], output_key: "intent" }
|
||
: {};
|
||
setNodes((nds) => [...nds, { id, type: "forge", position: { x: 360 + (n % 4) * 36, y: 140 + (n % 4) * 36 }, data: { nodeType: type, config: defConfig } }]);
|
||
setSelId(id);
|
||
}, [nodes, setNodes, snapshot]);
|
||
|
||
const updateConfig = useCallback((cfg: Record<string, any>) => {
|
||
if (!selId) return;
|
||
setDirty(true);
|
||
setNodes((nds) => nds.map((n) => (n.id === selId ? { ...n, data: { ...n.data, config: cfg } } : n)));
|
||
}, [selId, setNodes]);
|
||
|
||
const deleteNode = useCallback((id: string) => {
|
||
snapshot();
|
||
setDirty(true);
|
||
setNodes((nds) => stripRouterTargets(nds.filter((n) => n.id !== id), new Set([id])));
|
||
setEdges((eds) => eds.filter((e) => e.source !== id && e.target !== id));
|
||
setSelId((cur) => (cur === id ? null : cur));
|
||
}, [setNodes, setEdges, stripRouterTargets, snapshot]);
|
||
|
||
const saveCanvasState = useCallback(async (nextNodes: FlowNode[], nextEdges: FlowEdge[]) => {
|
||
if (!wf) return;
|
||
setSaveState("saving");
|
||
const persistNodes = stripRunData(nextNodes);
|
||
const executable = canvasToExecutable(persistNodes, nextEdges, { id: wf.id, version: wf.active_version });
|
||
const canvas = { nodes: persistNodes, edges: nextEdges, viewport: { x: 0, y: 0, zoom: 1 } };
|
||
try {
|
||
const res = await api.saveCanvas(project.id, wf.id, canvas, executable);
|
||
// Errors block publish; warnings are wiring smells (e.g. a router whose expression
|
||
// nothing writes) - show both in the tray, tagged by level.
|
||
const warns = ((res as any).warnings || []).map((w: any) => ({ ...w, level: "warning" }));
|
||
setProblems([...res.errors, ...warns]);
|
||
setSaveState(res.valid ? "saved" : "invalid");
|
||
setDirty(false); // canvas is persisted (invalid only blocks publish, not the save)
|
||
if (!res.valid || warns.length) setShowProblems(true);
|
||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 1500);
|
||
} catch {
|
||
setSaveState("invalid");
|
||
}
|
||
}, [wf, project?.id]);
|
||
|
||
const save = useCallback(async () => {
|
||
await saveCanvasState(nodes, edges);
|
||
}, [saveCanvasState, nodes, edges]);
|
||
|
||
// Register the canvas flush with the parent so the top-bar Publish saves first.
|
||
useEffect(() => {
|
||
onRegisterFlush?.(save);
|
||
return () => onRegisterFlush?.(null);
|
||
}, [onRegisterFlush, save]);
|
||
|
||
const backGuarded = useCallback(async () => {
|
||
if (dirtyRef.current) { try { await save(); } catch { /* leave anyway */ } }
|
||
onBack();
|
||
}, [save, onBack]);
|
||
|
||
const renameWorkflowTitle = useCallback(async (name: string) => {
|
||
if (!wf || !project?.id || name === wf.name) return;
|
||
const previous = wf;
|
||
const optimistic = { ...wf, name };
|
||
setWf(optimistic);
|
||
onWorkflowChange?.(optimistic);
|
||
try {
|
||
const updated = await api.updateWorkflow(project.id, wf.id, { name });
|
||
setWf(updated);
|
||
onWorkflowChange?.(updated);
|
||
} catch {
|
||
setWf(previous);
|
||
onWorkflowChange?.(previous);
|
||
}
|
||
}, [wf, project?.id, onWorkflowChange]);
|
||
|
||
const renameNodeId = useCallback((nodeId: string, rawId: string) => {
|
||
const nextId = rawId.trim().replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
||
if (!nextId || nextId === nodeId || nodes.some((n) => n.id === nextId)) return;
|
||
const nextNodes = nodes.map((n) => {
|
||
const data = n.data.nodeType === "router"
|
||
? {
|
||
...n.data,
|
||
config: {
|
||
...(n.data.config || {}),
|
||
cases: Object.fromEntries(
|
||
Object.entries(n.data.config?.cases || {}).map(([key, target]) => [
|
||
key,
|
||
String(target) === nodeId ? nextId : target,
|
||
]),
|
||
),
|
||
default: String(n.data.config?.default || "") === nodeId ? nextId : n.data.config?.default,
|
||
},
|
||
}
|
||
: n.data;
|
||
return n.id === nodeId ? { ...n, id: nextId, data } : { ...n, data };
|
||
});
|
||
const nextEdges = edges.map((e) => ({
|
||
...e,
|
||
source: e.source === nodeId ? nextId : e.source,
|
||
target: e.target === nodeId ? nextId : e.target,
|
||
}));
|
||
setNodes(nextNodes);
|
||
setEdges(nextEdges);
|
||
setSelId((cur) => (cur === nodeId ? nextId : cur));
|
||
void saveCanvasState(nextNodes, nextEdges);
|
||
}, [nodes, edges, saveCanvasState, setNodes, setEdges]);
|
||
|
||
const clearRunDebug = useCallback(() => {
|
||
setNodes((nds) => stripRunData(nds));
|
||
setRunning(false);
|
||
}, [setNodes]);
|
||
|
||
const markRunNode = useCallback((nodeId: string, status?: "idle" | "running" | "done" | "error", output?: any) => {
|
||
setNodes((nds) => nds.map((n) => (
|
||
n.id === nodeId
|
||
? { ...n, data: { ...n.data, status, debug: { ...(n.data.debug || {}), ...(output !== undefined ? { output } : {}) } } }
|
||
: n
|
||
)));
|
||
}, [setNodes]);
|
||
|
||
const applyFinalDebug = useCallback((debugNodes: Record<string, any> = {}) => {
|
||
setNodes((nds) => nds.map((n) => {
|
||
const debug = debugNodes[n.id];
|
||
return debug ? { ...n, data: { ...n.data, debug: { ...(n.data.debug || {}), ...debug } } } : n;
|
||
}));
|
||
}, [setNodes]);
|
||
|
||
const closeTestPanel = useCallback(() => {
|
||
setTestOpen(false);
|
||
clearRunDebug();
|
||
}, [clearRunDebug]);
|
||
|
||
const palette = useMemo(() => {
|
||
const groups: Record<string, NodeType[]> = {};
|
||
Object.values(registry).forEach((nt) => { (groups[nt.category] = groups[nt.category] || []).push(nt); });
|
||
return groups;
|
||
}, [registry]);
|
||
|
||
return (
|
||
<div className="col" style={{ flex: 1, minHeight: 0 }}>
|
||
{/* toolbar */}
|
||
<div className="row spread" style={{ padding: "10px 16px", borderBottom: "1px solid var(--line)", background: "var(--bg-1)" }}>
|
||
<div className="row gap2">
|
||
<button className="iconbtn" onClick={backGuarded}><Icon name="chevleft" size={18} /></button>
|
||
<Tile icon="workflows" color="var(--accent)" size={28} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<EditableName value={wf?.name} fallback="Workflow" className="t-h2 truncate" inputStyle={{ height: 28, minWidth: 220 }} onCommit={renameWorkflowTitle} />
|
||
<div className="fg-2 t-caption mono">{nodes.length} nodes · {edges.length} edges</div>
|
||
</div>
|
||
</div>
|
||
<div className="row gap2">
|
||
<div className="row" style={{ gap: 1 }}>
|
||
<button className="iconbtn" title="Undo (Ctrl+Z)" onClick={undo} disabled={!past.length}><Icon name="undo" size={16} /></button>
|
||
<button className="iconbtn" title="Redo (Ctrl+Shift+Z)" onClick={redo} disabled={!future.length}><Icon name="redo" size={16} /></button>
|
||
</div>
|
||
{problems.length > 0 && (
|
||
<button className="btn btn-danger btn-sm" onClick={() => setShowProblems((s) => !s)}><Icon name="validate" size={14} />{problems.length} problem{problems.length === 1 ? "" : "s"}</button>
|
||
)}
|
||
<button className="btn btn-secondary btn-sm" onClick={() => fitView({ padding: 0.2, duration: 300 })}><Icon name="fit" size={14} />Fit</button>
|
||
<VersionHistory entityType="workflow" entityId={wf?.id} entityLabel={wf?.name} onRestored={() => setReloadKey((k) => k + 1)} />
|
||
<button className="btn btn-secondary btn-sm" onClick={save} disabled={saveState === "saving"}>
|
||
<Icon name={saveState === "saved" ? "check" : "save"} size={14} />
|
||
{saveState === "saving" ? "Saving…" : saveState === "saved" ? "Saved" : saveState === "invalid" ? "Saved (invalid)" : "Save"}
|
||
{dirty && saveState === "idle" && <span title="Unsaved changes" style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)", marginLeft: 4 }} />}
|
||
</button>
|
||
<button className="btn btn-ghost btn-sm" onClick={async () => { await save(); onRun(); }}><Icon name="playground" size={14} />Playground</button>
|
||
<button className="btn btn-primary btn-sm" onClick={() => setTestOpen(true)}><Icon name="play" size={14} />{running ? "Testing…" : "Test"}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", alignItems: "stretch", flex: 1, minHeight: 0 }}>
|
||
{/* palette */}
|
||
<div className="scroll-y" style={{ width: 200, flex: "none", borderRight: "1px solid var(--line)", background: "var(--bg-1)", padding: 10 }}>
|
||
<input className="input" placeholder="Search nodes…" value={paletteQuery} onChange={(e) => setPaletteQuery(e.target.value)} style={{ marginBottom: 10 }} />
|
||
{Object.entries(palette).map(([cat, items]) => {
|
||
const filtered = items.filter((it) => it.label.toLowerCase().includes(paletteQuery.toLowerCase()) || it.type.includes(paletteQuery.toLowerCase()));
|
||
if (!filtered.length) return null;
|
||
return (
|
||
<div key={cat} style={{ marginBottom: 12 }}>
|
||
<div className="t-micro" style={{ marginBottom: 6 }}>{cat.replace("_", " & ")}</div>
|
||
<div className="col gap1">
|
||
{filtered.map((it) => {
|
||
const meta = NODE_META[it.type] || { icon: "n_agent", color: "var(--fg-2)" };
|
||
return (
|
||
<button key={it.type} className="row gap2" onClick={() => addNode(it.type)}
|
||
style={{ padding: "7px 8px", borderRadius: 7, border: "1px solid var(--line)", background: "var(--bg-2)", cursor: "pointer", textAlign: "left" }}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.borderColor = meta.color;
|
||
const r = e.currentTarget.getBoundingClientRect();
|
||
setPaletteTip({ type: it.type, top: r.top, left: r.right + 10 });
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.borderColor = "var(--line)";
|
||
setPaletteTip(null);
|
||
}}>
|
||
<Icon name={meta.icon} size={15} style={{ color: meta.color, flexShrink: 0 }} />
|
||
<span className="t-body-sm truncate">{it.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* palette hover help card */}
|
||
{paletteTip && NODE_HELP[paletteTip.type] && (() => {
|
||
const help = NODE_HELP[paletteTip.type];
|
||
const meta = NODE_META[paletteTip.type] || { icon: "n_agent", color: "var(--fg-2)", label: paletteTip.type };
|
||
return (
|
||
<div className="card" style={{
|
||
position: "fixed", left: paletteTip.left, top: Math.max(60, Math.min(paletteTip.top, (typeof window !== "undefined" ? window.innerHeight : 800) - 210)),
|
||
width: 290, zIndex: 90, pointerEvents: "none", padding: 12, boxShadow: "var(--sh-pop)",
|
||
}}>
|
||
<div className="row gap2" style={{ marginBottom: 6 }}>
|
||
<Icon name={meta.icon} size={15} style={{ color: meta.color }} />
|
||
<span className="t-h3">{meta.label}</span>
|
||
<span className="typechip">{paletteTip.type}</span>
|
||
</div>
|
||
<div className="t-body-sm fg-1" style={{ lineHeight: "19px", marginBottom: 8 }}>{help.what}</div>
|
||
<div style={{ background: "var(--bg-3)", border: "1px solid var(--line)", borderRadius: 7, padding: "7px 9px" }}>
|
||
<div className="t-micro" style={{ marginBottom: 3 }}>Example</div>
|
||
<div className="mono-sm fg-1" style={{ fontSize: 11.5, lineHeight: "17px", whiteSpace: "pre-wrap" }}>{help.example}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* canvas */}
|
||
<div className="grow forge-canvas" style={{ position: "relative", minWidth: 0, height: "100%" }}>
|
||
{/* Hide React Flow's native edge layer - we draw connections via EdgeOverlay. */}
|
||
<style>{`.forge-canvas .react-flow__edge { display: none; }`}</style>
|
||
<NodeTypesContext.Provider value={registry}>
|
||
{/* Wait for the node-type registry before mounting React Flow so nodes render
|
||
WITH their handles on first paint - otherwise React Flow measures handle-less
|
||
nodes and loaded edges never get drawn (their handle bounds stay empty). */}
|
||
{registryReady ? (
|
||
<ReactFlow
|
||
style={{ width: "100%", height: "100%" }}
|
||
nodes={flowNodes} edges={edges} nodeTypes={nodeTypes}
|
||
onNodesChange={onNodesChangeSynced} onEdgesChange={onEdgesChangeSynced} onConnect={onConnect}
|
||
onNodeDragStart={() => snapshot()}
|
||
isValidConnection={isValidConnection as any}
|
||
onNodeClick={(_, n) => setSelId(n.id)} onPaneClick={() => setSelId(null)}
|
||
deleteKeyCode={["Backspace", "Delete"]}
|
||
fitView proOptions={{ hideAttribution: true }} defaultEdgeOptions={{ style: { strokeWidth: 2 } }}
|
||
>
|
||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--canvas-grid)" />
|
||
<Controls />
|
||
<MiniMap pannable zoomable style={{ background: "var(--bg-1)" }} nodeColor={(n) => NODE_META[(n.data as any)?.nodeType]?.color || "var(--fg-2)"} />
|
||
<EdgeOverlay nodes={flowNodes} edges={edges} onRemove={removeEdge} />
|
||
</ReactFlow>
|
||
) : (
|
||
<div className="col center" style={{ width: "100%", height: "100%", color: "var(--fg-2)" }}>Loading canvas…</div>
|
||
)}
|
||
</NodeTypesContext.Provider>
|
||
|
||
{connWarning && (
|
||
<div className="card fade-in" style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", maxWidth: 460, padding: "8px 12px", boxShadow: "var(--sh-2)", borderColor: "var(--warn)", zIndex: 5 }}>
|
||
<div className="row gap2" style={{ alignItems: "flex-start" }}>
|
||
<Icon name="validate" size={14} style={{ color: "var(--warn)", flexShrink: 0, marginTop: 2 }} />
|
||
<span className="t-caption fg-1" style={{ flex: 1 }}>{connWarning}</span>
|
||
<button className="iconbtn" style={{ width: 20, height: 20 }} onClick={() => setConnWarning(null)}><Icon name="x" size={12} /></button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{showProblems && problems.length > 0 && (
|
||
<div className="card" style={{ position: "absolute", left: 12, right: 12, bottom: 12, maxHeight: 160, overflow: "auto", boxShadow: "var(--sh-pop)" }}>
|
||
<div className="row spread" style={{ padding: "8px 12px", borderBottom: "1px solid var(--line)" }}>
|
||
<div className="t-h3" style={{ color: "var(--err)" }}>Problems</div>
|
||
<button className="iconbtn" onClick={() => setShowProblems(false)}><Icon name="x" size={15} /></button>
|
||
</div>
|
||
<div className="col" style={{ padding: 6 }}>
|
||
{problems.map((p: any, i) => (
|
||
<div key={i} className="row gap2" style={{ padding: "5px 8px", alignItems: "flex-start" }}>
|
||
<span className="mono-sm" style={{ color: p.level === "warning" ? "var(--warn)" : "var(--err)", flexShrink: 0 }}>
|
||
{p.level === "warning" ? "⚠ " : ""}{p.pointer}
|
||
</span>
|
||
<span className="t-caption fg-1">{p.message}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* inspector */}
|
||
<div className={testOpen ? "col" : "scroll-y"} style={{ width: testOpen ? 380 : 340, flex: "none", borderLeft: "1px solid var(--line)", background: "var(--bg-1)", padding: testOpen ? 0 : 16, minHeight: 0, overflow: testOpen ? "hidden" : undefined }}>
|
||
{testOpen && wf ? (
|
||
<WorkflowTestPanel
|
||
project={project}
|
||
workflow={wf}
|
||
running={running}
|
||
onRunningChange={setRunning}
|
||
onBeforeRun={save}
|
||
onClose={closeTestPanel}
|
||
onResetRun={clearRunDebug}
|
||
onNodeStep={markRunNode}
|
||
onFinalDebug={applyFinalDebug}
|
||
/>
|
||
) : selected ? (
|
||
<NodeInspector
|
||
node={selected}
|
||
nodes={nodes}
|
||
tools={tools}
|
||
toolSets={toolSets}
|
||
agents={agents}
|
||
mcpServers={mcpServers}
|
||
components={components}
|
||
dynamic={{ kb_folders: kbFolders, qa_kinds: qaKinds }}
|
||
onChange={updateConfig}
|
||
onRename={renameNodeId}
|
||
onDelete={deleteNode}
|
||
onRouterCaseTarget={syncRouterCaseEdge}
|
||
onRouterCaseRename={renameRouterCaseEdges}
|
||
onRouterCaseRemove={removeRouterCaseEdges}
|
||
/>
|
||
) : (
|
||
<div className="col gap3">
|
||
<div className="t-micro">Workflow</div>
|
||
<div className="fg-1 t-body-sm">Select a node to configure it, or add nodes from the palette.</div>
|
||
<div className="card" style={{ padding: 12 }}>
|
||
<div className="t-caption fg-2">State schema</div>
|
||
<div className="mono-sm" style={{ marginTop: 4 }}>messages · intent</div>
|
||
</div>
|
||
<div className="field-help">Routing: a Router node routes by its <b>cases</b> (set in its inspector); other edges define flow.</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeInspector({
|
||
node,
|
||
nodes,
|
||
tools,
|
||
toolSets,
|
||
agents,
|
||
mcpServers,
|
||
components,
|
||
dynamic,
|
||
onChange,
|
||
onRename,
|
||
onDelete,
|
||
onRouterCaseTarget,
|
||
onRouterCaseRename,
|
||
onRouterCaseRemove,
|
||
}: {
|
||
node: FlowNode;
|
||
nodes: FlowNode[];
|
||
tools: Tool[];
|
||
toolSets: ToolSet[];
|
||
agents: Agent[];
|
||
mcpServers: McpClientT[];
|
||
components: ComponentT[];
|
||
dynamic?: Record<string, string[]>;
|
||
onChange: (c: Record<string, any>) => void;
|
||
onRename: (nodeId: string, name: string) => void;
|
||
onDelete: (id: string) => void;
|
||
onRouterCaseTarget: (nodeId: string, key: string, target: string) => void;
|
||
onRouterCaseRename: (nodeId: string, oldKey: string, newKey: string) => void;
|
||
onRouterCaseRemove: (nodeId: string, key: string) => void;
|
||
}) {
|
||
const type = node.data.nodeType;
|
||
const c = node.data.config || {};
|
||
const set = (patch: Record<string, any>) => onChange({ ...c, ...patch });
|
||
const meta = NODE_META[type] || { label: type, color: "var(--fg-2)", icon: "n_agent" };
|
||
const nodeIds = nodes.map((n) => n.id).filter((id) => id !== node.id);
|
||
|
||
return (
|
||
<div className="col" style={{ gap: 14 }}>
|
||
<div className="row spread">
|
||
<div className="row gap2" style={{ minWidth: 0 }}>
|
||
<Tile icon={meta.icon} color={meta.color} size={28} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="t-h2 truncate">{meta.label}</div>
|
||
<EditableName value={node.id} fallback={node.id} className="mono-sm fg-2 truncate" inputClassName="input mono" inputStyle={{ height: 26, maxWidth: 190 }} onCommit={(name) => onRename(node.id, name)} />
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-danger btn-sm" onClick={() => onDelete(node.id)}><Icon name="trash" size={14} />Delete</button>
|
||
</div>
|
||
|
||
{(type === "agent" || type === "deep_agent") && <AgentConfig config={c} onChange={onChange} tools={tools} toolSets={toolSets} agents={agents} mcpServers={mcpServers} components={components} folders={dynamic?.kb_folders || []} kinds={dynamic?.qa_kinds || []} />}
|
||
|
||
{type === "retrieval" && <RetrievalForm c={c} set={set} folders={dynamic?.kb_folders || []} kinds={dynamic?.qa_kinds || []} />}
|
||
|
||
{type === "router" && (
|
||
<RouterForm
|
||
nodeId={node.id}
|
||
c={c}
|
||
set={set}
|
||
nodeIds={nodeIds}
|
||
nodes={nodes}
|
||
onCaseTarget={onRouterCaseTarget}
|
||
onCaseRename={onRouterCaseRename}
|
||
onCaseRemove={onRouterCaseRemove}
|
||
/>
|
||
)}
|
||
|
||
{type === "llm" && (
|
||
<div className="col gap3">
|
||
<Field label="Model"><ModelSelect value={c.model || ""} onChange={(v) => set({ model: v })} /></Field>
|
||
<Field label="Prompt" help="The instruction the model runs on the incoming message."><textarea className="textarea" rows={4} value={c.prompt || ""} onChange={(e) => set({ prompt: e.target.value })} /></Field>
|
||
</div>
|
||
)}
|
||
|
||
{type === "tool_call" && (
|
||
<div className="col gap3">
|
||
<Field label="Tool" help={tools.length ? "Which project tool to invoke." : "No tools in this project yet - add them on the Tools screen."}>
|
||
<select className="select" value={c.tool_id || ""} onChange={(e) => set({ tool_id: e.target.value || undefined })}>
|
||
<option value="">Select a tool…</option>
|
||
{tools.map((t) => <option key={t.id} value={t.id}>{t.name} · {t.kind}</option>)}
|
||
</select>
|
||
</Field>
|
||
<Field label="Output state key" help="Where the tool's result is written in state."><input className="input mono" value={c.output_key ?? ""} placeholder="result" onChange={(e) => set({ output_key: e.target.value || undefined })} /></Field>
|
||
</div>
|
||
)}
|
||
|
||
{NODE_FIELDS[type] && <FieldsForm specs={NODE_FIELDS[type]} config={c} onPatch={set} dynamic={dynamic} />}
|
||
|
||
{(type === "start" || type === "end") && <div className="fg-2 t-body-sm">No configuration for this node.</div>}
|
||
|
||
{!FORM_NODES.has(type) && (
|
||
<>
|
||
<div className="field-label">Config (JSON)</div>
|
||
<textarea className="textarea mono" rows={8} style={{ fontSize: 12 }} defaultValue={JSON.stringify(c, null, 2)}
|
||
onChange={(e) => { try { onChange(JSON.parse(e.target.value || "{}")); } catch { /* keep */ } }} />
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* Declarative field specs → friendly form widgets (no raw JSON for common nodes). The form
|
||
merges only its own keys, so any extra config is preserved. Mirrors the node config schemas.
|
||
(Widgets + FieldSpec live in canvas/ConfigForm.tsx, shared with the middleware stack.) */
|
||
const NODE_FIELDS: Record<string, FieldSpec[]> = {
|
||
transform: [
|
||
{ key: "input_key", label: "Input state key", widget: "text", placeholder: "messages", help: "Which state field to read." },
|
||
{ key: "output_key", label: "Output state key", widget: "text", placeholder: "result", help: "Which state field to write." },
|
||
{ key: "expression", label: "Expression", widget: "textarea", help: "Sandboxed expression over state (e.g. len(messages))." },
|
||
],
|
||
classifier: [
|
||
{ key: "labels", label: "Intent labels", widget: "csv", placeholder: "return_item, cancel_subscription, get_information", help: "Wire a Router after this with one case per label." },
|
||
{ key: "output_key", label: "Write label to state key", widget: "text", placeholder: "intent", help: "The Router's expression should read this key." },
|
||
{ key: "multi_label", label: "Multi-label (all that apply)", widget: "toggle", help: "Write EVERY applicable label (a list) instead of exactly one. Pair with a Router set to 'Route to every match' so a question with several intents reaches several specialists in parallel." },
|
||
{ key: "model", label: "Model", widget: "model", emptyLabel: "Auto (cheapest model)", help: "Left on Auto, the classifier uses your provider's cheapest model (e.g. gpt-4.1-nano) — not the project default — since classification is high-volume and low-stakes. Pick a model to override." },
|
||
{ key: "instructions", label: "Extra guidance (optional)", widget: "textarea", placeholder: "Domain hints, tie-breaking rules…" },
|
||
],
|
||
human_input: [
|
||
{ key: "prompt", label: "Prompt to reviewer", widget: "textarea", help: "Shown when the run pauses for approval." },
|
||
{ key: "allowed_decisions", label: "Allowed decisions", widget: "csv", placeholder: "approve, reject", help: "The buttons the reviewer sees in the Playground." },
|
||
{ key: "output_key", label: "Write decision to state key", widget: "text", placeholder: "decision", help: "Optional: use a key name like decision. The selected value is written there so a Router can branch approve vs reject." },
|
||
],
|
||
emit_event: [
|
||
{ key: "event", label: "Event name", widget: "text", placeholder: "order.created" },
|
||
],
|
||
webhook_out: [
|
||
{ key: "url", label: "Webhook URL", widget: "text", placeholder: "https://…" },
|
||
{ key: "method", label: "Method", widget: "select", options: ["POST", "PUT", "PATCH", "GET"].map((m) => ({ value: m, label: m })) },
|
||
],
|
||
handoff: [
|
||
{ key: "reason", label: "Reason", widget: "text", placeholder: "Escalated to a human agent.", help: "Why the conversation is handed off (shown in the agent inbox)." },
|
||
{ key: "ack_message", label: "Hold message (optional)", widget: "textarea", placeholder: "A team member will follow up shortly.", help: "Sent to the customer while they wait for a human." },
|
||
],
|
||
// --- flow ---
|
||
loop: [
|
||
{ key: "max_iter", label: "Max iterations", widget: "number", min: 1, step: 1, help: "Safety cap on how many times the body runs." },
|
||
{ key: "condition", label: "Continue while (expression)", widget: "textarea", placeholder: "keep_going == True", help: "Sandboxed expression over state; loops while truthy. Writes _loop = continue/done - wire a Router on _loop and point its body back to this node." },
|
||
],
|
||
parallel_fanout: [
|
||
{ key: "over", label: "List state key", widget: "text", placeholder: "items", help: "State field holding the list to map over." },
|
||
{ key: "child_node", label: "Child node id", widget: "text", placeholder: "worker", help: "The node run once per item, in parallel (Send)." },
|
||
{ key: "item_key", label: "Item state key", widget: "text", placeholder: "item", help: "Each item is written here for the child to read." },
|
||
],
|
||
join: [
|
||
{ key: "reducer", label: "Combine results", widget: "select", options: ["concat", "merge", "first", "last"].map((m) => ({ value: m, label: m })), help: "How parallel branches converge. Children should write to an add-reducer state key to aggregate." },
|
||
],
|
||
subworkflow: [
|
||
{ key: "workflow_id", label: "Workflow (id or name)", widget: "text", placeholder: "Verify identity", help: "Another workflow in THIS project, run as a reusable component (shares messages state)." },
|
||
],
|
||
// --- triggers ---
|
||
webhook_in: [
|
||
{ key: "message_path", label: "Message JMESPath (optional)", widget: "text", placeholder: "text", help: "Path into the POSTed JSON body used as the message (empty = the whole body). The webhook URL appears on the Triggers screen after you publish." },
|
||
{ key: "require_signature", label: "Require HMAC signature", widget: "toggle", help: "Verify the X-Forge-Signature (sha256) header using the secret below." },
|
||
{ key: "secret_ref", label: "Signing secret ref", widget: "text", placeholder: "secret://proj/webhook_secret", help: "Secret used to verify the signature." },
|
||
],
|
||
schedule: [
|
||
{ key: "every_minutes", label: "Every N minutes", widget: "number", min: 1, step: 1, help: "Run on a fixed interval. Use this OR cron." },
|
||
{ key: "cron", label: "Cron expression (optional)", widget: "text", placeholder: "0 9 * * 1-5", help: "Standard cron (needs the workers extra). Overrides the interval if set." },
|
||
{ key: "message", label: "Message", widget: "textarea", placeholder: "Summarize today's tickets.", help: "The input sent into the workflow on each run." },
|
||
],
|
||
email_in: [
|
||
{ key: "mailbox", label: "Mailbox", widget: "text", placeholder: "support@yourco.com", help: "The address this trigger handles. Connect an Email channel (Connect screen) to receive mail." },
|
||
{ key: "include_subject", label: "Include subject", widget: "toggle", help: "Prepend the email subject to the message." },
|
||
{ key: "reply", label: "Reply to sender", widget: "toggle", help: "Send the workflow's answer back as a threaded email." },
|
||
],
|
||
app_event: [
|
||
{ key: "poll_url", label: "Poll URL", widget: "text", placeholder: "https://api.example.com/events", help: "Polled on each interval; new items fire the workflow." },
|
||
{ key: "method", label: "Method", widget: "select", options: ["GET", "POST"].map((m) => ({ value: m, label: m })) },
|
||
{ key: "interval_minutes", label: "Poll every N minutes", widget: "number", min: 1, step: 1 },
|
||
{ key: "items_path", label: "Items JMESPath", widget: "text", placeholder: "data.items", help: "Path to the list of items in the response (empty = the whole body)." },
|
||
{ key: "dedupe_key", label: "Dedupe key JMESPath", widget: "text", placeholder: "id", help: "Unique id within each item so it fires only once." },
|
||
{ key: "message_path", label: "Message JMESPath (optional)", widget: "text", placeholder: "title", help: "Path within an item for the message text (empty = the whole item)." },
|
||
],
|
||
};
|
||
// Node types that render a real form (agent/router/llm/tool_call have bespoke forms; the rest come from NODE_FIELDS).
|
||
const FORM_NODES = new Set<string>(["agent", "deep_agent", "router", "llm", "tool_call", "retrieval", "start", "end", ...Object.keys(NODE_FIELDS)]);
|
||
|
||
/** State keys written by upstream-capable nodes, with the values they can take -
|
||
* shown in the router form so users branch on real values, not made-up labels. */
|
||
function stateWriters(nodes: FlowNode[]): { key: string; values: string[]; from: string }[] {
|
||
const out: { key: string; values: string[]; from: string }[] = [];
|
||
const valuesOf = (value: any, fallback: string[] = []) => (
|
||
Array.isArray(value)
|
||
? value
|
||
: typeof value === "string"
|
||
? value.split(",")
|
||
: fallback
|
||
).map((v) => String(v).trim()).filter(Boolean);
|
||
for (const n of nodes) {
|
||
const cfg = n.data.config || {};
|
||
const t = n.data.nodeType;
|
||
if (t === "classifier") out.push({ key: cfg.output_key || "intent", values: valuesOf(cfg.labels), from: n.id });
|
||
if (t === "retrieval" && cfg.route_key) out.push({ key: cfg.route_key, values: ["yes", "no"], from: n.id });
|
||
if (t === "human_input" && cfg.output_key) out.push({ key: cfg.output_key, values: valuesOf(cfg.allowed_decisions, ["approve", "reject"]), from: n.id });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/* Retrieval node config - toggleable Knowledge (RAG over documents) + FAQs / Q&A sections,
|
||
mirroring the agent panel's Knowledge sections (AgentConfig). Document search (include_docs)
|
||
and Q&A lookup (include_qa) are independent: use either or both. The flat retrieval config is
|
||
read/written directly. */
|
||
function RetrievalForm({ c, set, folders, kinds }: { c: Record<string, any>; set: (p: Record<string, any>) => void; folders: string[]; kinds: string[] }) {
|
||
const ragOn = c.include_docs !== false; // RAG on unless explicitly turned off
|
||
const qaOn = !!c.include_qa;
|
||
return (
|
||
<div className="col" style={{ gap: 18 }}>
|
||
<CollapsibleSection label="Knowledge" badge={ragOn ? "enabled" : undefined}
|
||
hint="Pull the most relevant document chunks from your knowledge base into context for a grounded agent.">
|
||
<div className="col gap2">
|
||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||
<Toggle on={ragOn} onChange={(on) => set({ include_docs: on })} />
|
||
<span className="t-body-sm">Search knowledge base (RAG over documents)</span>
|
||
</label>
|
||
{ragOn && (
|
||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||
<Field label="Folders" help="Limit document search to these folders (none selected = all).">
|
||
<MultiSelectChips value={c.folders || []} options={folders} onChange={(items) => set({ folders: items })} />
|
||
</Field>
|
||
<div className="row gap3 wrap">
|
||
<Field label="Documents (top K)" help="Chunks returned per search.">
|
||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||
value={c.top_k ?? 5} onChange={(e) => set({ top_k: Number(e.target.value) || 1 })} />
|
||
</Field>
|
||
<Field label="Min score" help="Drop chunks below this similarity (0–1).">
|
||
<input className="input" type="number" min={0} max={1} step={0.02} style={{ width: 92 }}
|
||
value={c.min_score ?? 0.18} onChange={(e) => set({ min_score: Number(e.target.value) })} />
|
||
</Field>
|
||
</div>
|
||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||
<Toggle on={!!c.hybrid} onChange={(on) => set({ hybrid: on })} />
|
||
<span className="t-body-sm">Hybrid search (BM25 + vector)</span>
|
||
</label>
|
||
<div className="field-help">Blend lexical keyword (BM25) ranking with semantic vectors so exact terms - codes, names, SKUs - aren’t missed.</div>
|
||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||
<Toggle on={!!c.rerank} onChange={(on) => set({ rerank: on })} />
|
||
<span className="t-body-sm">Rerank (cross-encoder)</span>
|
||
</label>
|
||
<div className="field-help">Two-stage retrieval: a local cross-encoder re-scores the shortlist and keeps only the best matches. Big accuracy boost; adds some latency. Runs offline on CPU (no extra cost). Min score is ignored while this is on (the reranker score is on a different scale).</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
<CollapsibleSection label="FAQs / Q&A" badge={qaOn ? "enabled" : undefined}
|
||
hint="Also pull curated FAQ / Q&A pairs into context alongside the documents.">
|
||
<div className="col gap2">
|
||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||
<Toggle on={qaOn} onChange={(on) => set({ include_qa: on })} />
|
||
<span className="t-body-sm">Include FAQ / Q&A answers</span>
|
||
</label>
|
||
{qaOn && (
|
||
<div className="col gap2" style={{ paddingLeft: 6 }}>
|
||
<Field label="Kinds" help="Limit Q&A to these kinds/categories (none selected = all).">
|
||
<MultiSelectChips value={c.qa_kinds || []} options={kinds} onChange={(items) => set({ qa_kinds: items })} />
|
||
</Field>
|
||
<div className="row gap3 wrap">
|
||
<Field label="Pairs (top K)" help="Q&A pairs returned per lookup.">
|
||
<input className="input" type="number" min={1} max={20} step={1} style={{ width: 92 }}
|
||
value={c.qa_top_k ?? 3} onChange={(e) => set({ qa_top_k: Number(e.target.value) || 1 })} />
|
||
</Field>
|
||
<Field label="Match threshold" help="Min similarity for a Q&A pair (0–1).">
|
||
<input className="input" type="number" min={0} max={1} step={0.05} style={{ width: 92 }}
|
||
value={c.qa_threshold ?? 0.3} onChange={(e) => set({ qa_threshold: Number(e.target.value) })} />
|
||
</Field>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
<div className="col gap2">
|
||
<div className="t-micro">When nothing is found</div>
|
||
<label className="row gap2" style={{ cursor: "pointer" }}>
|
||
<Toggle on={!!c.announce_empty} onChange={(on) => set({ announce_empty: on })} />
|
||
<span className="t-body-sm">Announce when empty</span>
|
||
</label>
|
||
<div className="field-help">Tells a grounded agent nothing relevant was found, so it says “I don’t know” instead of guessing.</div>
|
||
<Field label="Route flag (state key)" help="Optional: writes 'yes'/'no' (anything found?) to this state field so a Router can branch found vs not-found.">
|
||
<input className="input mono" value={c.route_key ?? ""} placeholder="data_found" onChange={(e) => set({ route_key: e.target.value || undefined })} />
|
||
</Field>
|
||
</div>
|
||
|
||
{!ragOn && !qaOn && (
|
||
<div className="field-help" style={{ color: "var(--warn)" }}>
|
||
⚠ Both sources are off - this node will retrieve nothing. Turn on Knowledge and/or Q&A above.
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RouterForm({
|
||
nodeId,
|
||
c,
|
||
set,
|
||
nodeIds,
|
||
nodes,
|
||
onCaseTarget,
|
||
onCaseRename,
|
||
onCaseRemove,
|
||
}: {
|
||
nodeId: string;
|
||
c: Record<string, any>;
|
||
set: (p: Record<string, any>) => void;
|
||
nodeIds: string[];
|
||
nodes: FlowNode[];
|
||
onCaseTarget: (nodeId: string, key: string, target: string) => void;
|
||
onCaseRename: (nodeId: string, oldKey: string, newKey: string) => void;
|
||
onCaseRemove: (nodeId: string, key: string) => void;
|
||
}) {
|
||
const cases: Record<string, string> = c.cases || {};
|
||
const rows = Object.entries(cases);
|
||
const setCase = (k: string, v: string) => {
|
||
set({ cases: { ...cases, [k]: v } });
|
||
onCaseTarget(nodeId, k, v);
|
||
};
|
||
const renameCase = (oldK: string, newK: string) => {
|
||
if (!newK || newK === oldK) return;
|
||
const n = { ...cases };
|
||
const val = n[oldK];
|
||
delete n[oldK];
|
||
n[newK] = val;
|
||
set({ cases: n });
|
||
onCaseRename(nodeId, oldK, newK);
|
||
};
|
||
const removeCase = (k: string) => {
|
||
const n = { ...cases };
|
||
delete n[k];
|
||
set({ cases: n });
|
||
onCaseRemove(nodeId, k);
|
||
};
|
||
const writers = stateWriters(nodes);
|
||
const activeWriter = writers.find((w) => w.key === (c.expression || "").trim());
|
||
const nextCaseKey = () => {
|
||
const nextKnown = activeWriter?.values?.find((value) => value && !Object.prototype.hasOwnProperty.call(cases, value));
|
||
if (nextKnown) return nextKnown;
|
||
let i = rows.length + 1;
|
||
while (Object.prototype.hasOwnProperty.call(cases, `case_${i}`)) i++;
|
||
return `case_${i}`;
|
||
};
|
||
const addCase = () => setCase(nextCaseKey(), "");
|
||
return (
|
||
<div className="col gap3">
|
||
<div>
|
||
<div className="field-label">Expression (over state)</div>
|
||
<input className="input mono" value={c.expression || ""} placeholder="intent" list="router-state-keys" onChange={(e) => set({ expression: e.target.value })} />
|
||
<datalist id="router-state-keys">{writers.map((w) => <option key={w.key} value={w.key} />)}</datalist>
|
||
{writers.length > 0 ? (
|
||
<div className="field-help">
|
||
Keys written upstream: {writers.map((w) => `${w.key} (${w.values.slice(0, 4).join("/") || "…"}, from ${w.from})`).join(" · ")}
|
||
</div>
|
||
) : (
|
||
<div className="field-help" style={{ color: "var(--warn)" }}>
|
||
⚠ No node in this workflow writes a routable state key yet. Add a Classifier (writes a label), or set a route/decision flag on Retrieval or Human Input - otherwise every run takes the Default path.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<div className="field-label">Cases - when the expression VALUE equals… → go to</div>
|
||
{activeWriter && (
|
||
<div className="field-help" style={{ marginBottom: 6 }}>
|
||
'{activeWriter.key}' takes the values: <b>{activeWriter.values.join(", ") || "(set by " + activeWriter.from + ")"}</b> - use those exact values as case keys.
|
||
</div>
|
||
)}
|
||
<div className="col gap2">
|
||
{rows.map(([k, v], i) => (
|
||
<div key={i} className="row gap2">
|
||
<input className="input mono" style={{ flex: 1 }} value={k} onChange={(e) => renameCase(k, e.target.value)} />
|
||
<Icon name="chevright" size={14} style={{ color: "var(--fg-2)" }} />
|
||
<select className="select" style={{ flex: 1 }} value={v} onChange={(e) => setCase(k, e.target.value)}>
|
||
<option value="">target…</option>
|
||
{nodeIds.map((id) => <option key={id} value={id}>{id}</option>)}
|
||
</select>
|
||
<button className="iconbtn" onClick={() => removeCase(k)}><Icon name="trash" size={14} /></button>
|
||
</div>
|
||
))}
|
||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: "flex-start" }} onClick={addCase}><Icon name="plus" size={13} />Add case</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="field-label">Default target</div>
|
||
<select className="select" value={c.default || ""} onChange={(e) => { set({ default: e.target.value }); onCaseTarget(nodeId, "__default__", e.target.value); }}>
|
||
<option value="">none</option>
|
||
{nodeIds.map((id) => <option key={id} value={id}>{id}</option>)}
|
||
</select>
|
||
{!c.default && (
|
||
<div className="field-help" style={{ color: "var(--warn)" }}>
|
||
⚠ No default: if the expression value matches no case, the run ends silently with no answer.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="row gap2" style={{ alignItems: "center", cursor: "pointer" }}>
|
||
<input type="checkbox" checked={!!c.multi} onChange={(e) => set({ multi: e.target.checked })} />
|
||
<span className="field-label" style={{ margin: 0 }}>Route to every match (multi)</span>
|
||
</label>
|
||
<div className="field-help">
|
||
For list-valued expressions (a multi-label Classifier): run EVERY matching case in parallel,
|
||
then converge the branches on one synthesizer agent before End.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|