import { useId } from "react"; import type { ReactNode } from "react"; import type { AgentStatus, FlowEdge, FlowGraph, FlowNode, } from "../../lib/types"; /** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps). * Left-to-right layered layout: a node's layer is its longest-path depth from * a root; within a layer nodes are stacked top-down. Edges are cubic beziers * with an arrowhead; node stroke/fill is coded by status. */ const NODE_W = 148; const NODE_H = 52; const GAP_X = 64; const GAP_Y = 30; const PAD = 20; interface FlowTone { stroke: string; fill: string; label: string; } const flowTone: Record = { idle: { stroke: "var(--mu-surface-5)", fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)", label: "空闲", }, running: { stroke: "var(--mu-accent)", fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)", label: "运行中", }, blocked: { stroke: "var(--mu-warn)", fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)", label: "阻塞", }, done: { stroke: "var(--mu-ok)", fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)", label: "完成", }, error: { stroke: "var(--mu-err)", fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)", label: "错误", }, }; const STATUS_ORDER: ReadonlyArray = [ "idle", "running", "blocked", "done", "error", ]; interface PlacedNode { node: FlowNode; x: number; y: number; tone: FlowTone; } function layout(graph: FlowGraph): { placed: PlacedNode[]; edges: FlowEdge[]; width: number; height: number; } { if (graph.nodes.length === 0) { return { placed: [], edges: [], width: PAD * 2, height: PAD * 2 }; } const byId = new Map(); graph.nodes.forEach((n) => byId.set(n.id, n)); const preds = new Map(); graph.nodes.forEach((n) => preds.set(n.id, [])); const validEdges: FlowEdge[] = []; graph.edges.forEach((e) => { if (e.from === e.to) return; if (!byId.has(e.from) || !byId.has(e.to)) return; preds.get(e.to)!.push(e.from); validEdges.push(e); }); const memo = new Map(); const visiting = new Set(); const depth = (id: string): number => { const cached = memo.get(id); if (cached !== undefined) return cached; if (visiting.has(id)) return 0; visiting.add(id); let d = 0; for (const p of preds.get(id)!) d = Math.max(d, depth(p) + 1); visiting.delete(id); memo.set(id, d); return d; }; const byLayer = new Map(); let maxRows = 0; graph.nodes.forEach((n) => { const l = depth(n.id); const arr = byLayer.get(l) ?? []; arr.push(n.id); byLayer.set(l, arr); maxRows = Math.max(maxRows, arr.length); }); const layers = Array.from(byLayer.keys()).sort((a, b) => a - b); const pos = new Map(); layers.forEach((l, li) => { const ids = byLayer.get(l)!; ids.forEach((id, ri) => pos.set(id, { x: PAD + li * (NODE_W + GAP_X), y: PAD + ri * (NODE_H + GAP_Y) }) ); }); const width = PAD * 2 + layers.length * NODE_W + (layers.length - 1) * GAP_X; const height = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * GAP_Y; const placed: PlacedNode[] = graph.nodes.map((n) => { const p = pos.get(n.id)!; return { node: n, x: p.x, y: p.y, tone: flowTone[n.status ?? "idle"] ?? flowTone.idle, }; }); return { placed, edges: validEdges, width, height }; } function clampLabel(label: string): string { return label.length > 20 ? `${label.slice(0, 19)}…` : label; } export function FlowGraphView({ graph, onNodeClick, }: { graph: FlowGraph; onNodeClick?: (node: FlowNode) => void; }): ReactNode { const markerId = useId().replace(/[^a-zA-Z0-9]/g, ""); const { placed, edges, width, height } = layout(graph); return (
{edges.map((e, i) => { const s = placed.find((p) => p.node.id === e.from); const t = placed.find((p) => p.node.id === e.to); if (!s || !t) return null; const sx = s.x + NODE_W; const sy = s.y + NODE_H / 2; const tx = t.x; const ty = t.y + NODE_H / 2; const dx = Math.max(24, Math.abs(tx - sx) / 2); return ( ); })} {placed.map((p) => ( onNodeClick(p.node) : undefined} className={onNodeClick ? "cursor-pointer" : undefined} > {p.node.kind ? ( {p.node.kind} ) : null} {clampLabel(p.node.label)} ))}
{STATUS_ORDER.map((s) => ( {flowTone[s].label} ))}
); }