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.
233 lines
11 KiB
TypeScript
233 lines
11 KiB
TypeScript
"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<Record<string, NodeType>>({});
|
|
|
|
const CAT_COLOR: Record<string, string> = {
|
|
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, any>): 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) => (
|
|
<Handle
|
|
key={p.id}
|
|
// With a single port per side (every Forge node today), omit the id so this is
|
|
// React Flow's *default* handle - then loaded edges (which carry no handle id)
|
|
// always attach, with no id-matching that can silently drop the edge.
|
|
id={n > 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 (
|
|
<div
|
|
style={{
|
|
width: 230, background: "var(--bg-2)", borderRadius: "var(--r-lg)",
|
|
boxShadow: `${border}, var(--node-shadow)`, position: "relative",
|
|
}}
|
|
>
|
|
{/* 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 && <HandleStack ports={inPorts} dir="in" />}
|
|
{/* header */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", borderBottom: "1px solid var(--line)", background: "var(--bg-3)", borderRadius: "var(--r-lg) var(--r-lg) 0 0", borderLeft: `3px solid ${meta.color}` }}>
|
|
<Icon name={meta.icon} size={16} style={{ color: meta.color, flexShrink: 0 }} />
|
|
<span className="t-h3 truncate" style={{ flex: 1, fontFamily: "var(--font-display)" }}>{title}</span>
|
|
<span className="typechip">{type}</span>
|
|
</div>
|
|
{/* 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).
|
|
<div className="col" style={{ paddingBottom: 6 }}>
|
|
<div className="t-caption fg-2 truncate" style={{ padding: "4px 11px", height: 22 }}>expr · {config.expression || "-"}</div>
|
|
{[...Object.keys(config.cases || {}), "__default__"].map((k) => (
|
|
<div key={k} style={{ position: "relative", height: 24, display: "flex", alignItems: "center", justifyContent: "flex-end", padding: "0 12px", borderTop: "1px solid var(--line)", background: "var(--bg-3)" }}>
|
|
<span className="mono-sm truncate" style={{ color: k === "__default__" ? "var(--fg-2)" : "var(--fg-1)" }}>{k === "__default__" ? "Else" : k}</span>
|
|
<Handle
|
|
id={`case:${k}`} type="source" position={Position.Right}
|
|
style={{ top: "50%", right: -6, transform: "translateY(-50%)", width: 10, height: 10, border: "2px solid var(--bg-1)", background: k === "__default__" ? "var(--fg-2)" : "var(--io-control)", position: "absolute" }}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : lines.length > 0 && (
|
|
<div className="col" style={{ padding: "9px 11px", gap: 3 }}>
|
|
{lines.map((l, i) => (
|
|
<div key={i} className={i === 0 ? "mono-sm truncate" : "t-caption fg-2 truncate"} style={{ color: i === 0 ? "var(--fg-1)" : undefined }}>{l}</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{type !== "router" && !isSubagent && <HandleStack ports={outPorts} dir="out" />}
|
|
{/* 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" && (
|
|
<Handle
|
|
id="subagents"
|
|
type="source"
|
|
position={Position.Bottom}
|
|
title="Sub-agents — wire to specialist agents this supervisor can call"
|
|
style={{ left: "50%", bottom: -6, transform: "translateX(-50%)", width: 12, height: 12, border: "2px solid var(--bg-1)", background: "var(--accent)", position: "absolute" }}
|
|
/>
|
|
)}
|
|
{hasDebug && (
|
|
<div
|
|
onMouseEnter={() => setShowDebug(true)}
|
|
onMouseLeave={() => setShowDebug(false)}
|
|
style={{ position: "absolute", right: 8, bottom: -13, zIndex: 25 }}
|
|
>
|
|
<span
|
|
className="chip chip-mono"
|
|
style={{
|
|
height: 24,
|
|
borderColor: "var(--accent)",
|
|
background: "var(--bg-1)",
|
|
boxShadow: "var(--sh-1)",
|
|
color: "var(--fg-0)",
|
|
}}
|
|
>
|
|
<Icon name={Number(debug.cost_usd || 0) > 0 ? "bolt" : "eye"} size={11} />
|
|
{Number(debug.cost_usd || 0) > 0 ? fmtUSD(Number(debug.cost_usd || 0)) : "output"}
|
|
</span>
|
|
{showDebug && (
|
|
<div
|
|
className="card"
|
|
style={{
|
|
position: "absolute",
|
|
right: 0,
|
|
top: 28,
|
|
width: 290,
|
|
padding: 11,
|
|
boxShadow: "var(--sh-pop)",
|
|
zIndex: 80,
|
|
}}
|
|
>
|
|
<div className="row spread" style={{ marginBottom: 8 }}>
|
|
<div className="t-micro">Node output</div>
|
|
{(Number(debug.tokens || 0) > 0 || Number(debug.cost_usd || 0) > 0) && (
|
|
<span className="chip chip-mono">
|
|
<Icon name="bolt" size={11} />
|
|
{Number(debug.tokens || 0)} tok · {fmtUSD(Number(debug.cost_usd || 0))}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<pre
|
|
className="mono-sm"
|
|
style={{
|
|
margin: 0,
|
|
maxHeight: 150,
|
|
overflow: "auto",
|
|
whiteSpace: "pre-wrap",
|
|
overflowWrap: "anywhere",
|
|
color: "var(--fg-1)",
|
|
background: "var(--bg-3)",
|
|
border: "1px solid var(--line)",
|
|
borderRadius: 7,
|
|
padding: 8,
|
|
}}
|
|
>
|
|
{(outputPreview || "(no state delta)").slice(0, 1400)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|