"use client"; /* Forge canvas node - a chip on a circuit board, with IOType-colored typed handles. Ports come from the backend Node Type Registry (/v1/node-types) via context. */ import { Handle, Position, useUpdateNodeInternals, type NodeProps } from "@xyflow/react"; import { createContext, useContext, useEffect, useState } from "react"; import { Icon } from "../icons"; import { NODE_META, IO_COLOR, fmtUSD } from "@/lib/data"; import type { NodeType } from "@/lib/api"; export const NodeTypesContext = createContext>({}); const CAT_COLOR: Record = { control: "var(--io-control)", agent: "var(--accent)", json: "var(--io-json)", vector: "var(--io-vector)", human: "var(--warn)", signal: "var(--signal)", }; function summarize(type: string, c: Record): string[] { switch (type) { case "agent": case "deep_agent": // Show only the model on the card - tools / middleware / components / knowledge are // all partial here; the right-hand inspector shows the full config. return [String(c.model || "-")]; case "router": return [`expr · ${c.expression || "-"}`, Object.keys(c.cases || {}).concat(c.default ? ["default"] : []).join(" · ")]; case "llm": return [String(c.model || "-"), "single call"]; case "classifier": return [`→ ${c.output_key || "intent"}`, (c.labels || []).slice(0, 4).join(" · ") || "no labels"]; case "transform": return [`${c.engine || "jmespath"} → ${c.output_key || "data"}`]; case "tool_call": return [String(c.tool_id || "-")]; case "retrieval": { const lines: string[] = []; if (c.include_docs !== false) lines.push(`docs top_k ${c.top_k ?? 5}${c.hybrid ? " · hybrid" : ""}${c.rerank ? " · rerank" : ""}`); if (c.include_qa) lines.push(`Q&A top_k ${c.qa_top_k ?? 3}`); return lines.length ? lines : ["no sources"]; } case "human_input": return [(c.prompt || "").slice(0, 34), (c.allowed_decisions || ["approve", "reject"]).join(" · ")]; case "webhook_out": return [`${c.method || "POST"} ${String(c.url || "").slice(0, 26)}`]; case "emit_event": return [`channel · ${c.channel || ""}`]; default: return []; } } function debugPreview(value: any): string { if (value == null || value === "") return ""; if (typeof value === "string") return value; try { return JSON.stringify(value, null, 2); } catch { return String(value); } } function HandleStack({ ports, dir }: { ports: { id: string; io_type: string }[]; dir: "in" | "out" }) { const n = ports.length; return ( <> {ports.map((p, i) => ( 1 ? p.id : undefined} type={dir === "in" ? "target" : "source"} position={dir === "in" ? Position.Left : Position.Right} style={{ top: `${((i + 1) / (n + 1)) * 100}%`, width: 11, height: 11, border: "2px solid var(--bg-1)", background: IO_COLOR[p.io_type] || "var(--io-any)", }} /> ))} ); } export function ForgeNode({ id, data, selected }: NodeProps) { const registry = useContext(NodeTypesContext); const type = (data as any).nodeType as string; const isSubagent = !!(data as any).isSubagent; // wired as a deep_agent sub-agent (folded, not a flow node) const config = (data as any).config || {}; const status = (data as any).status as string | undefined; const debug = (data as any).debug || {}; const [showDebug, setShowDebug] = useState(false); const meta = NODE_META[type] || { icon: "n_agent", color: "var(--fg-2)", label: type }; const spec = registry[type]; const inPorts = spec?.input_ports || []; const outPorts = spec?.output_ports || []; const lines = summarize(type, config); const title = config.name || meta.label || type; const hasDebug = debug.output !== undefined || Number(debug.cost_usd || 0) > 0 || Number(debug.tokens || 0) > 0; const outputPreview = debugPreview(debug.output); // The port registry loads after the node first mounts, so the handles appear later. // Tell React Flow to re-measure this node's handle bounds when its port count changes - // without this, edges that connect to those handles never get drawn. Router case rows // each carry a handle, so case-count changes also need a re-measure. const caseKeys = type === "router" ? Object.keys(config.cases || {}).join("|") : ""; const updateNodeInternals = useUpdateNodeInternals(); const portKey = `${inPorts.length}:${outPorts.length}:${caseKeys}:${type === "deep_agent" ? "sub" : ""}`; useEffect(() => { updateNodeInternals(id); }, [id, portKey, updateNodeInternals]); const border = status === "running" ? "0 0 0 2px var(--ok), 0 0 0 6px var(--ok-bg)" : status === "done" ? "0 0 0 1px var(--ok)" : status === "error" ? "0 0 0 1px var(--err)" : selected ? "var(--glow-accent)" : "0 0 0 1px var(--line)"; return (
{/* A folded sub-agent isn't a flow node, so its in/out flow handles have no function - hide them for a clean "leaf" look (the sub-agent edge attaches to the node's top). */} {!isSubagent && } {/* header */}
{title} {type}
{/* body */} {type === "router" ? ( // OpenAI-style if/else: one labeled output row + connector per case, plus Else. // Dragging from a row's handle wires that case (see onConnect in workflows.tsx). // Geometry must stay in sync with ROUTER_GEOM in workflows.tsx (EdgeOverlay).
expr · {config.expression || "-"}
{[...Object.keys(config.cases || {}), "__default__"].map((k) => (
{k === "__default__" ? "Else" : k}
))}
) : lines.length > 0 && (
{lines.map((l, i) => (
{l}
))}
)} {type !== "router" && !isSubagent && } {/* Deep agent's third port: a bottom "subagents" handle. Drag from here to specialist agent nodes to make them sub-agents the supervisor calls (see onConnect / compiler). */} {type === "deep_agent" && ( )} {hasDebug && (
setShowDebug(true)} onMouseLeave={() => setShowDebug(false)} style={{ position: "absolute", right: 8, bottom: -13, zIndex: 25 }} > 0 ? "bolt" : "eye"} size={11} /> {Number(debug.cost_usd || 0) > 0 ? fmtUSD(Number(debug.cost_usd || 0)) : "output"} {showDebug && (
Node output
{(Number(debug.tokens || 0) > 0 || Number(debug.cost_usd || 0) > 0) && ( {Number(debug.tokens || 0)} tok · {fmtUSD(Number(debug.cost_usd || 0))} )}
                {(outputPreview || "(no state delta)").slice(0, 1400)}
              
)}
)}
); }