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.
109 lines
5.4 KiB
TypeScript
109 lines
5.4 KiB
TypeScript
"use client";
|
|
/* Self-rendered canvas connections.
|
|
|
|
React Flow v12's own edge renderer doesn't draw loaded edges reliably in this dev
|
|
setup (handle-bounds race), so we draw the wires ourselves inside the ViewportPortal -
|
|
which lives in flow coordinate space, so it pans/zooms with the canvas. Native edges
|
|
are hidden via CSS to avoid double-draw. New connections (onConnect) land in the same
|
|
`edges` array, so hand-drawn wires show here too. */
|
|
import { ViewportPortal } from "@xyflow/react";
|
|
import { useMemo } from "react";
|
|
import { Icon } from "../icons";
|
|
import { type FlowEdge, type FlowNode } from "@/lib/graph";
|
|
|
|
const NODE_HEIGHT: Record<string, number> = {
|
|
start: 36, end: 36,
|
|
agent: 90, deep_agent: 90, llm: 80, human_input: 80, classifier: 80,
|
|
transform: 64, tool_call: 64, webhook_out: 64, emit_event: 64, retrieval: 64,
|
|
};
|
|
const NODE_WIDTH = 230;
|
|
// Router case-row geometry - must mirror ForgeNode's router body (header + expr line + rows).
|
|
const ROUTER_GEOM = { header: 37, exprLine: 22, rowH: 24, pad: 6 };
|
|
|
|
function routerHeight(cfg: any): number {
|
|
const rows = Object.keys(cfg?.cases || {}).length + 1; // + Else
|
|
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + rows * ROUTER_GEOM.rowH + ROUTER_GEOM.pad;
|
|
}
|
|
|
|
/** Y offset (within the router node) of the case row an edge leaves from. */
|
|
function routerCaseY(cfg: any, edge: FlowEdge): number {
|
|
const keys = [...Object.keys(cfg?.cases || {}), "__default__"];
|
|
let key: string | undefined = edge.sourceHandle?.startsWith("case:") ? edge.sourceHandle.slice(5) : undefined;
|
|
if (!key || !keys.includes(key)) {
|
|
// Infer from routing config: which case (or default) points at this edge's target?
|
|
key = Object.entries(cfg?.cases || {}).find(([, tgt]) => tgt === edge.target)?.[0]
|
|
?? (cfg?.default === edge.target ? "__default__" : undefined);
|
|
}
|
|
const idx = key ? keys.indexOf(key) : 0;
|
|
return ROUTER_GEOM.header + ROUTER_GEOM.exprLine + (idx < 0 ? 0 : idx) * ROUTER_GEOM.rowH + ROUTER_GEOM.rowH / 2;
|
|
}
|
|
|
|
export function EdgeOverlay({ nodes, edges, onRemove }: { nodes: FlowNode[]; edges: FlowEdge[]; onRemove: (edge: FlowEdge) => void }) {
|
|
const byId = useMemo(() => Object.fromEntries(nodes.map((n) => [n.id, n])), [nodes]);
|
|
const h = (n: any) => (n?.data?.nodeType === "router" ? routerHeight(n?.data?.config) : NODE_HEIGHT[n?.data?.nodeType] ?? 36);
|
|
const edgeGeometry = (e: FlowEdge) => {
|
|
const s = byId[e.source]; const t = byId[e.target];
|
|
if (!s || !t) return null;
|
|
const tx = t.position.x, ty = t.position.y + h(t) / 2;
|
|
// Sub-agent edge: an org-chart branch from the deep_agent's BOTTOM handle down into the
|
|
// specialist's TOP-center (agent-as-tool), drawn dashed/accent and distinct from flow edges.
|
|
if (e.sourceHandle === "subagents") {
|
|
const sx = s.position.x + NODE_WIDTH / 2;
|
|
const sy = s.position.y + h(s);
|
|
const ttx = t.position.x + NODE_WIDTH / 2;
|
|
const tty = t.position.y;
|
|
const dy = Math.max(28, Math.abs(tty - sy) / 2);
|
|
const d = `M ${sx},${sy} C ${sx},${sy + dy} ${ttx},${tty - dy} ${ttx},${tty}`;
|
|
return { d, mx: (sx + ttx) / 2, my: (sy + tty) / 2, sub: true };
|
|
}
|
|
const fromRouter = s.data?.nodeType === "router";
|
|
const sx = s.position.x + NODE_WIDTH;
|
|
const sy = s.position.y + (fromRouter ? routerCaseY(s.data?.config, e) : h(s) / 2);
|
|
const dx = Math.max(40, Math.abs(tx - sx) / 2);
|
|
const d = `M ${sx},${sy} C ${sx + dx},${sy} ${tx - dx},${ty} ${tx},${ty}`;
|
|
return { d, mx: (sx + tx) / 2, my: (sy + ty) / 2, sub: false };
|
|
};
|
|
return (
|
|
<ViewportPortal>
|
|
<svg style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, overflow: "visible", pointerEvents: "none", zIndex: -1 }}>
|
|
<defs>
|
|
<marker id="forge-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
|
<path d="M0,0 L7,3 L0,6 Z" fill="var(--line-strong)" />
|
|
</marker>
|
|
<marker id="forge-arrow-sub" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto">
|
|
<path d="M0,0 L7,3 L0,6 Z" fill="var(--accent)" />
|
|
</marker>
|
|
</defs>
|
|
{edges.map((e) => {
|
|
const geo = edgeGeometry(e);
|
|
if (!geo) return null;
|
|
return <path key={e.id} d={geo.d} fill="none" stroke={geo.sub ? "var(--accent)" : "var(--line-strong)"} strokeWidth={2} strokeDasharray={geo.sub ? "6 5" : undefined} markerEnd={geo.sub ? "url(#forge-arrow-sub)" : "url(#forge-arrow)"} />;
|
|
})}
|
|
</svg>
|
|
{edges.map((e) => {
|
|
const geo = edgeGeometry(e);
|
|
if (!geo) return null;
|
|
return (
|
|
<button
|
|
key={`${e.id}-detach`}
|
|
title="Detach edge"
|
|
aria-label={`Detach edge from ${e.source} to ${e.target}`}
|
|
onPointerDown={(ev) => ev.stopPropagation()}
|
|
onClick={(ev) => { ev.stopPropagation(); onRemove(e); }}
|
|
style={{
|
|
position: "absolute", left: geo.mx - 9, top: geo.my - 9,
|
|
width: 18, height: 18, borderRadius: 999,
|
|
border: "1px solid var(--line-strong)", background: "var(--bg-1)",
|
|
color: "var(--fg-2)", boxShadow: "var(--sh-1)",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
padding: 0, cursor: "pointer", pointerEvents: "auto", zIndex: 8,
|
|
}}
|
|
>
|
|
<Icon name="x" size={10} />
|
|
</button>
|
|
);
|
|
})}
|
|
</ViewportPortal>
|
|
);
|
|
}
|