Files
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
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.
2026-07-28 01:49:19 +05:30

1300 lines
65 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (01).">
<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 - arent 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&amp;A" badge={qaOn ? "enabled" : undefined}
hint="Also pull curated FAQ / Q&amp;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&amp;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 (01).">
<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 dont 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&amp;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>
);
}