feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
This commit is contained in:
1 parent
49d12d9a64
commit
2c100fe3d9
10 files changed
+1532
-338
No files matched your search
@@ -1,45 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import type { AgentStatus, FlowGraph, FlowNode } from "../../lib/types";
|
||||
import type { AgentStatus, FlowGraph } from "../../lib/types";
|
||||
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
|
||||
import { Surface, Tag, type TagTone } from "../../components/primitives";
|
||||
import { Surface } from "../../components/primitives";
|
||||
import { Icon } from "../../components/Icon";
|
||||
import { FlowGraphView } from "./FlowGraph";
|
||||
import { NodeDetail } from "./flow/NodeDetail";
|
||||
import { LaneSummary } from "./flow/LaneSummary";
|
||||
import { groupFlowLanes } from "./flow/lanes";
|
||||
import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone";
|
||||
|
||||
const STATUS_LABEL: Record<AgentStatus, string> = {
|
||||
idle: "空闲",
|
||||
running: "运行中",
|
||||
blocked: "阻塞",
|
||||
done: "完成",
|
||||
error: "错误",
|
||||
};
|
||||
const STATUS_TONE: Record<AgentStatus, TagTone> = {
|
||||
idle: "neutral",
|
||||
running: "accent",
|
||||
blocked: "warn",
|
||||
done: "ok",
|
||||
error: "err",
|
||||
};
|
||||
const STATUS_DOT: Record<AgentStatus, string> = {
|
||||
idle: "var(--mu-surface-5)",
|
||||
running: "var(--mu-accent)",
|
||||
blocked: "var(--mu-warn)",
|
||||
done: "var(--mu-ok)",
|
||||
error: "var(--mu-err)",
|
||||
};
|
||||
|
||||
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮主干图,
|
||||
* 输入 → 思考(LLM) → 工具 → 输出,节点可点开看实时日志/参数/结果。 */
|
||||
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 ——
|
||||
* 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器),
|
||||
* 节点点开看参数 / 结果 / 实时日志。 */
|
||||
export function FlowModule(): React.ReactNode {
|
||||
const graph = useAppStore((s) => s.flowGraph);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null;
|
||||
const grouping = graph ? groupFlowLanes(graph) : null;
|
||||
const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="全链路流程"
|
||||
subtitle="实时 输入 → 思考 → 工具 → 输出 主干图;点节点看参数 / 结果 / 实时日志"
|
||||
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志"
|
||||
/>
|
||||
|
||||
{graph ? (
|
||||
@@ -60,111 +45,87 @@ export function FlowModule(): React.ReactNode {
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Icon name="clock" size={18} />}
|
||||
label="开始"
|
||||
value={fmtStart(graph)}
|
||||
sub="本轮开始时间"
|
||||
tone="neutral"
|
||||
icon={<Icon name="trend" size={18} />}
|
||||
label="泳道"
|
||||
value={grouping?.lanes.length ?? 0}
|
||||
sub="并行子代理分支"
|
||||
tone="accent"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Icon name="trend" size={18} />}
|
||||
icon={<Icon name="clock" size={18} />}
|
||||
label="耗时"
|
||||
value={fmtDuration(graph)}
|
||||
sub="本轮时长"
|
||||
sub={`开始 ${fmtStart(graph)}`}
|
||||
tone="ok"
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{(["running", "done", "error", "idle"] as AgentStatus[]).map((s) => {
|
||||
const n = countStatus(graph, s);
|
||||
return (
|
||||
<span key={s} className="inline-flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: STATUS_DOT[s] }} />
|
||||
{STATUS_LABEL[s]} × {n}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FlowGraphView graph={graph} onNodeClick={(n) => setSelectedId(n.id)} />
|
||||
<Surface className="space-y-3">
|
||||
<StatusStrip graph={graph} />
|
||||
<FlowGraphView
|
||||
graph={graph}
|
||||
selectedId={selectedId}
|
||||
onNodeClick={(n) => setSelectedId(n.id)}
|
||||
/>
|
||||
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
|
||||
</Surface>
|
||||
|
||||
{selected ? (
|
||||
<NodeDetail node={selected} onClose={() => setSelectedId(null)} />
|
||||
<NodeDetail
|
||||
node={selected}
|
||||
laneLabel={selectedLane}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Notice>
|
||||
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;若某环节无对应 span,则诚实占位。
|
||||
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。
|
||||
</Notice>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** NodeDetail 展示单个节点的属性/结果与累积的 span 事件(实时日志)。 */
|
||||
function NodeDetail({ node, onClose }: { node: FlowNode; onClose: () => void }): React.ReactNode {
|
||||
const status = node.status ?? "idle";
|
||||
const hasProps = !!node.props && Object.keys(node.props).length > 0;
|
||||
const hasLog = !!node.log && node.log.length > 0;
|
||||
|
||||
/** StatusStrip shows the live status census of the round. */
|
||||
function StatusStrip({ graph }: { graph: FlowGraph }): React.ReactNode {
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{node.label}</span>
|
||||
{node.kind ? <Tag tone="neutral">{node.kind}</Tag> : null}
|
||||
<Tag tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Tag>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="关闭详情"
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-lo)] transition-colors hover:bg-[var(--mu-surface-4)]"
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{STATUS_ORDER.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]"
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-lo)]">
|
||||
{node.startedAt ? <span>开始 {new Date(node.startedAt).toLocaleTimeString()}</span> : null}
|
||||
{node.endedAt ? <span>结束 {new Date(node.endedAt).toLocaleTimeString()}</span> : null}
|
||||
<span className="font-mono">{node.id}</span>
|
||||
</div>
|
||||
|
||||
{hasProps ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-[var(--mu-text-lo)]">参数 / 结果</h4>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
|
||||
{JSON.stringify(node.props, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasLog ? (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium text-[var(--mu-text-lo)]">实时日志(span 事件累积)</h4>
|
||||
{node.log!.map((ev, i) => (
|
||||
<div key={i} className="flex flex-wrap items-start gap-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-xs">
|
||||
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">
|
||||
{ev.time ? new Date(ev.time).toLocaleTimeString() : ""}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--mu-text-mid)]">{ev.type}</span>
|
||||
{ev.props ? (
|
||||
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">{JSON.stringify(ev.props)}</code>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasProps && !hasLog ? <Notice>该节点暂无详细日志。</Notice> : null}
|
||||
</Surface>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full${s === "running" ? " mu-blink" : ""}`}
|
||||
style={{ backgroundColor: FLOW_TONE[s].stroke }}
|
||||
/>
|
||||
{FLOW_TONE[s].label} × {countStatus(graph, s)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** laneLabelFor returns the label of the lane a node belongs to (its sub-agent
|
||||
* root), or of the lane the node itself opens. */
|
||||
function laneLabelFor(
|
||||
nodeId: string | null,
|
||||
grouping: ReturnType<typeof groupFlowLanes> | null,
|
||||
): string | undefined {
|
||||
if (!nodeId || !grouping) return undefined;
|
||||
const band = grouping.bandOf.get(nodeId);
|
||||
const own = grouping.lanes.find((l) => l.id === nodeId);
|
||||
const laneId = band ?? own?.id;
|
||||
if (!laneId) return undefined;
|
||||
return grouping.lanes.find((l) => l.id === laneId)?.root.label;
|
||||
}
|
||||
|
||||
/** countStatus returns how many nodes in graph are in the given status. */
|
||||
function countStatus(graph: FlowGraph, status: AgentStatus): number {
|
||||
let n = 0;
|
||||
for (const node of graph.nodes) {
|
||||
if ((node.status ?? "idle") === status) n += 1;
|
||||
if (statusOf(node) === status) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -1,158 +1,35 @@
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
AgentStatus,
|
||||
FlowEdge,
|
||||
FlowGraph,
|
||||
FlowNode,
|
||||
} from "../../lib/types";
|
||||
import type { FlowGraph, FlowNode } from "../../lib/types";
|
||||
import {
|
||||
FLOW_TONE,
|
||||
STATUS_ORDER,
|
||||
statusOf,
|
||||
} from "./flow/tone";
|
||||
import { layoutFlow, NODE_W, NODE_H, type PlacedBand, type PlacedEdge, type PlacedNode } from "./flow/layout";
|
||||
|
||||
/** 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<AgentStatus, FlowTone> = {
|
||||
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<AgentStatus> = [
|
||||
"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<string, FlowNode>();
|
||||
graph.nodes.forEach((n) => byId.set(n.id, n));
|
||||
|
||||
const preds = new Map<string, string[]>();
|
||||
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<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
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<number, string[]>();
|
||||
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<string, { x: number; y: number }>();
|
||||
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;
|
||||
}
|
||||
* Left-to-right layered layout: a node's column is its longest-path depth from
|
||||
* a root, and the rows of one column stack top-down. Sub-agent branches are
|
||||
* drawn in their own framed lanes below the trunk (design/19 §2), so parallel
|
||||
* branches read as separate swimlanes instead of interleaving. Edges are cubic
|
||||
* beziers with an arrowhead; node stroke/fill is coded by status and a running
|
||||
* node pulses with the panel's existing `.mu-blink` (no new animation dep). */
|
||||
|
||||
export function FlowGraphView({
|
||||
graph,
|
||||
onNodeClick,
|
||||
selectedId,
|
||||
}: {
|
||||
graph: FlowGraph;
|
||||
onNodeClick?: (node: FlowNode) => void;
|
||||
selectedId?: string | null;
|
||||
}): ReactNode {
|
||||
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||
const { placed, edges, width, height } = layout(graph);
|
||||
const laneMarkerId = `${markerId}L`;
|
||||
const { nodes, edges, bands, lanes, width, height } = layoutFlow(graph);
|
||||
const placedOf = new Map(nodes.map((p) => [p.node.id, p]));
|
||||
const laneRoots = new Set(lanes.map((l) => l.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -170,82 +47,238 @@ export function FlowGraphView({
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--mu-text-lo)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{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 (
|
||||
<path
|
||||
key={i}
|
||||
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
|
||||
fill="none"
|
||||
stroke="var(--mu-text-lo)"
|
||||
strokeWidth="1.5"
|
||||
markerEnd={`url(#${markerId})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{placed.map((p) => (
|
||||
<g
|
||||
key={p.node.id}
|
||||
onClick={onNodeClick ? () => onNodeClick(p.node) : undefined}
|
||||
className={onNodeClick ? "cursor-pointer" : undefined}
|
||||
<marker
|
||||
id={laneMarkerId}
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<rect
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="12"
|
||||
fill={p.tone.fill}
|
||||
stroke={p.tone.stroke}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{p.node.kind ? (
|
||||
<text
|
||||
x={p.x + NODE_W / 2}
|
||||
y={p.y + 20}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="var(--mu-text-lo)"
|
||||
>
|
||||
{p.node.kind}
|
||||
</text>
|
||||
) : null}
|
||||
<text
|
||||
x={p.x + NODE_W / 2}
|
||||
y={p.y + (p.node.kind ? 41 : NODE_H / 2 + 5)}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
fill="var(--mu-text-hi)"
|
||||
>
|
||||
{clampLabel(p.node.label)}
|
||||
</text>
|
||||
</g>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--mu-accent)" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{bands.filter((b) => b.framed).map((band) => (
|
||||
<LaneFrame key={band.id} band={band} />
|
||||
))}
|
||||
|
||||
{edges.map((e, i) => (
|
||||
<EdgePath
|
||||
key={`${e.edge.from}-${e.edge.to}-${i}`}
|
||||
edge={e}
|
||||
from={placedOf.get(e.edge.from)}
|
||||
to={placedOf.get(e.edge.to)}
|
||||
markerId={markerId}
|
||||
laneMarkerId={laneMarkerId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{nodes.map((p) => (
|
||||
<NodeGroup
|
||||
key={p.node.id}
|
||||
node={p.node}
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
selected={selectedId === p.node.id}
|
||||
lane={laneRoots.has(p.node.id)}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{STATUS_ORDER.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: flowTone[s].stroke }}
|
||||
/>
|
||||
{flowTone[s].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Legend hasLanes={lanes.length > 0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** EdgePath draws one cubic bezier between two placed nodes; edges that run into
|
||||
* a lane are dashed and accent-colored so the trunk stays readable. */
|
||||
function EdgePath({
|
||||
edge,
|
||||
from,
|
||||
to,
|
||||
markerId,
|
||||
laneMarkerId,
|
||||
}: {
|
||||
edge: PlacedEdge;
|
||||
from?: PlacedNode;
|
||||
to?: PlacedNode;
|
||||
markerId: string;
|
||||
laneMarkerId: string;
|
||||
}): ReactNode {
|
||||
if (!from || !to) return null;
|
||||
const sx = from.x + NODE_W;
|
||||
const sy = from.y + NODE_H / 2;
|
||||
const tx = to.x;
|
||||
const ty = to.y + NODE_H / 2;
|
||||
const dx = Math.max(24, Math.abs(tx - sx) / 2);
|
||||
const inLane = edge.lane !== undefined;
|
||||
return (
|
||||
<path
|
||||
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
|
||||
fill="none"
|
||||
stroke={inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
||||
strokeOpacity={inLane ? 0.7 : 1}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={inLane ? "5 3" : undefined}
|
||||
markerEnd={`url(#${inLane ? laneMarkerId : markerId})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** LaneFrame draws one sub-agent swimlane container: a dashed Miuix surface with
|
||||
* a header carrying the sub-agent label and its live status. */
|
||||
function LaneFrame({ band }: { band: PlacedBand }): ReactNode {
|
||||
const tone = FLOW_TONE[band.status];
|
||||
const running = band.status === "running";
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={band.x}
|
||||
y={band.y}
|
||||
width={band.width}
|
||||
height={band.height}
|
||||
rx="16"
|
||||
fill="color-mix(in srgb, var(--mu-accent) 5%, transparent)"
|
||||
stroke="var(--mu-glass-border)"
|
||||
strokeDasharray="6 4"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={band.x + 12}
|
||||
y={band.y + 15}
|
||||
fontSize="10"
|
||||
fill="var(--mu-text-mid)"
|
||||
>
|
||||
泳道 · {clampLabel(band.label, 24)}
|
||||
</text>
|
||||
<circle
|
||||
cx={band.x + band.width - 14}
|
||||
cy={band.y + 11}
|
||||
r="4"
|
||||
fill={tone.stroke}
|
||||
className={running ? "mu-blink" : undefined}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** NodeGroup draws one node card: a status-colored rounded rect plus kind/label
|
||||
* text and a status dot. A running node carries a blinking halo so 运行中 is
|
||||
* visible at a glance without animating the text. */
|
||||
function NodeGroup({
|
||||
node,
|
||||
x,
|
||||
y,
|
||||
selected,
|
||||
lane,
|
||||
onNodeClick,
|
||||
}: {
|
||||
node: FlowNode;
|
||||
x: number;
|
||||
y: number;
|
||||
selected: boolean;
|
||||
lane: boolean;
|
||||
onNodeClick?: (node: FlowNode) => void;
|
||||
}): ReactNode {
|
||||
const status = statusOf(node);
|
||||
const tone = FLOW_TONE[status];
|
||||
return (
|
||||
<g
|
||||
onClick={onNodeClick ? () => onNodeClick(node) : undefined}
|
||||
className={onNodeClick ? "cursor-pointer" : undefined}
|
||||
>
|
||||
<title>{`${node.label} · ${tone.label}`}</title>
|
||||
{status === "running" ? (
|
||||
<rect
|
||||
x={x - 4}
|
||||
y={y - 4}
|
||||
width={NODE_W + 8}
|
||||
height={NODE_H + 8}
|
||||
rx="16"
|
||||
fill="none"
|
||||
stroke={tone.stroke}
|
||||
strokeWidth="2"
|
||||
className="mu-blink"
|
||||
/>
|
||||
) : null}
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="12"
|
||||
fill={tone.fill}
|
||||
stroke={tone.stroke}
|
||||
strokeWidth={selected ? 3 : 1.5}
|
||||
/>
|
||||
<circle cx={x + NODE_W - 12} cy={y + 11} r="3.5" fill={tone.stroke} />
|
||||
{lane ? (
|
||||
<text x={x + 10} y={y + 15} fontSize="9" fill="var(--mu-accent)">
|
||||
泳道根
|
||||
</text>
|
||||
) : null}
|
||||
{node.kind ? (
|
||||
<text
|
||||
x={x + NODE_W / 2}
|
||||
y={y + 20}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="var(--mu-text-lo)"
|
||||
>
|
||||
{node.kind}
|
||||
</text>
|
||||
) : null}
|
||||
<text
|
||||
x={x + NODE_W / 2}
|
||||
y={y + (node.kind ? 41 : NODE_H / 2 + 5)}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
fill="var(--mu-text-hi)"
|
||||
>
|
||||
{clampLabel(node.label, 20)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Legend explains the status colors and the lane edge style. */
|
||||
function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{STATUS_ORDER.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: FLOW_TONE[s].stroke }} />
|
||||
{FLOW_TONE[s].label}
|
||||
</span>
|
||||
))}
|
||||
{hasLanes ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
|
||||
<svg width="22" height="8" aria-hidden="true">
|
||||
<line
|
||||
x1="0"
|
||||
y1="4"
|
||||
x2="22"
|
||||
y2="4"
|
||||
stroke="var(--mu-accent)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="5 3"
|
||||
/>
|
||||
</svg>
|
||||
泳道内步骤
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** clampLabel shortens a node label to fit the fixed-width node card. */
|
||||
function clampLabel(label: string, max: number): string {
|
||||
return label.length > max ? `${label.slice(0, max - 1)}…` : label;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FlowLane } from "./lanes";
|
||||
import { FLOW_TONE } from "./tone";
|
||||
import { Tag } from "../../../components/primitives";
|
||||
|
||||
/** LaneSummary lists the parallel sub-agent branches of the current round
|
||||
* (design/19 §2 活动状态:每个分支的 running/ok/err + 进度). Each row shows the
|
||||
* branch's live status, its step progress and its start time; clicking a row
|
||||
* selects the branch root node so the detail panel opens on it. Nothing is
|
||||
* rendered when no sub-agent has reported — the trunk alone is not a lane. */
|
||||
export function LaneSummary({
|
||||
lanes,
|
||||
onSelect,
|
||||
}: {
|
||||
lanes: FlowLane[];
|
||||
onSelect: (nodeId: string) => void;
|
||||
}): React.ReactNode {
|
||||
if (lanes.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="px-1 text-xs font-medium text-[var(--mu-text-lo)]">
|
||||
并行子代理泳道({lanes.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{lanes.map((lane) => (
|
||||
<LaneRow key={lane.id} lane={lane} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** LaneRow renders one branch: status dot/tag, label, progress bar and counts. */
|
||||
function LaneRow({
|
||||
lane,
|
||||
onSelect,
|
||||
}: {
|
||||
lane: FlowLane;
|
||||
onSelect: (nodeId: string) => void;
|
||||
}): React.ReactNode {
|
||||
const tone = FLOW_TONE[lane.status];
|
||||
const settled = lane.done + lane.failed;
|
||||
const percent = lane.total > 0 ? Math.round((settled / lane.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(lane.id)}
|
||||
className="flex flex-col gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-left transition-colors hover:bg-[var(--mu-surface-4)]"
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full${lane.status === "running" ? " mu-blink" : ""}`}
|
||||
style={{ backgroundColor: tone.stroke }}
|
||||
/>
|
||||
<span className="text-xs font-medium text-[var(--mu-text-hi)]">{lane.root.label}</span>
|
||||
{lane.depth > 0 ? <Tag tone="neutral">嵌套 {lane.depth}</Tag> : null}
|
||||
<Tag tone={tone.tag} className="ml-auto">
|
||||
{tone.label}
|
||||
</Tag>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-[10px] text-[var(--mu-text-lo)]">
|
||||
<span className="h-1 flex-1 overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
|
||||
<span className="block h-full" style={{ width: `${percent}%`, backgroundColor: tone.stroke }} />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono">
|
||||
{settled}/{lane.total} 步
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-2 text-[10px] text-[var(--mu-text-lo)]">
|
||||
<span>完成 {lane.done}</span>
|
||||
<span>运行 {lane.running}</span>
|
||||
<span>失败 {lane.failed}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { FlowNode } from "../../../lib/types";
|
||||
import { Surface, Tag } from "../../../components/primitives";
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import { Notice } from "../../components";
|
||||
import { FLOW_TONE, statusOf } from "./tone";
|
||||
|
||||
/** NodeDetail shows one selected node: its status, its lane (when it belongs to
|
||||
* a sub-agent branch), the whitelisted span props and the span's accumulated
|
||||
* events (design/19 §2 节点实时日志). The panel is driven by the live graph, so
|
||||
* a newer `graph_update` frame replaces the props/log arrays in place and the
|
||||
* list keeps growing while the round runs — no local buffering, no invented
|
||||
* entries. */
|
||||
export function NodeDetail({
|
||||
node,
|
||||
laneLabel,
|
||||
onClose,
|
||||
}: {
|
||||
node: FlowNode;
|
||||
laneLabel?: string;
|
||||
onClose: () => void;
|
||||
}): React.ReactNode {
|
||||
const status = statusOf(node);
|
||||
const tone = FLOW_TONE[status];
|
||||
const props = node.props ?? {};
|
||||
const hasProps = Object.keys(props).length > 0;
|
||||
const log = node.log ?? [];
|
||||
|
||||
return (
|
||||
<Surface className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold">{node.label}</span>
|
||||
{node.kind ? <Tag tone="neutral">{node.kind}</Tag> : null}
|
||||
<Tag tone={tone.tag}>{tone.label}</Tag>
|
||||
{laneLabel ? <Tag tone="accent">泳道 · {laneLabel}</Tag> : null}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="关闭详情"
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-lo)] transition-colors hover:bg-[var(--mu-surface-4)]"
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[var(--mu-text-lo)]">
|
||||
{node.startedAt ? <span>开始 {fmtClock(node.startedAt)}</span> : null}
|
||||
{node.endedAt ? <span>结束 {fmtClock(node.endedAt)}</span> : null}
|
||||
<span className="font-mono">{node.id}</span>
|
||||
</div>
|
||||
|
||||
{hasProps ? (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium text-[var(--mu-text-lo)]">参数 / 结果</h4>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-3 py-2 font-mono text-xs text-[var(--mu-text-mid)]">
|
||||
{JSON.stringify(props, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<LogList node={node} log={log} />
|
||||
|
||||
{!hasProps && log.length === 0 ? <Notice>该节点暂无详细日志。</Notice> : null}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/** LogList renders the node's accumulated span events, newest last, and is
|
||||
* explicit about why an empty list is empty instead of pretending to have
|
||||
* content. */
|
||||
function LogList({
|
||||
node,
|
||||
log,
|
||||
}: {
|
||||
node: FlowNode;
|
||||
log: NonNullable<FlowNode["log"]>;
|
||||
}): React.ReactNode {
|
||||
const running = statusOf(node) === "running";
|
||||
if (log.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
|
||||
实时日志:暂无事件 ——{" "}
|
||||
{running ? "该 span 仍在运行,事件在 span 结束后随帧回填。" : "该 span 未记录事件。"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]">
|
||||
实时日志(span 事件累积)
|
||||
<span className="font-mono">{log.length}</span>
|
||||
{running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null}
|
||||
</h4>
|
||||
<div className="max-h-64 space-y-1.5 overflow-auto">
|
||||
{log.map((ev, i) => (
|
||||
<div
|
||||
key={`${ev.time}-${ev.type}-${i}`}
|
||||
className="flex flex-wrap items-start gap-2 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)]/60 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[var(--mu-text-lo)]">{fmtClock(ev.time)}</span>
|
||||
<span className="font-medium text-[var(--mu-text-mid)]">{ev.type || "event"}</span>
|
||||
{ev.props ? (
|
||||
<code className="min-w-0 break-all font-mono text-[var(--mu-text-lo)]">
|
||||
{JSON.stringify(ev.props)}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** fmtClock renders an RFC3339 stamp as a locale time, or "—" when absent/invalid. */
|
||||
function fmtClock(value?: string): string {
|
||||
if (!value) return "—";
|
||||
const t = Date.parse(value);
|
||||
return Number.isNaN(t) ? "—" : new Date(t).toLocaleTimeString();
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// Logic test for the P2 lane layer of the flow page (design/19 §2): the pure
|
||||
// swimlane grouping (`groupFlowLanes`) and the lane-banded layout (`layoutFlow`)
|
||||
// that the SVG renderer draws. The project has no frontend test runner and no
|
||||
// jsdom, so this is a dependency-free plain-node check that drives the real
|
||||
// functions and throws on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/flow/lanes.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --outfile=/tmp/cyrene-flow-lanes.test.mjs \
|
||||
// && node /tmp/cyrene-flow-lanes.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { groupFlowLanes } from "./lanes";
|
||||
import { GAP_X, NODE_W, PAD, layoutFlow } from "./layout";
|
||||
import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
const g = JSON.stringify(got);
|
||||
const w = JSON.stringify(want);
|
||||
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||
}
|
||||
|
||||
function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode {
|
||||
return { id, label: id, kind, status };
|
||||
}
|
||||
|
||||
function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph {
|
||||
return { id: "web-1", nodes, edges, sessionId: "web" };
|
||||
}
|
||||
|
||||
/** The P1 trunk: input → llm → tool → output, no sub-agent in flight. */
|
||||
function trunkOnly(): FlowGraph {
|
||||
return graph(
|
||||
[
|
||||
node("input", "input", "done"),
|
||||
node("llm1", "llm", "done"),
|
||||
node("tool1", "tool", "done"),
|
||||
node("output", "output", "done"),
|
||||
],
|
||||
[
|
||||
{ from: "input", to: "llm1" },
|
||||
{ from: "llm1", to: "tool1" },
|
||||
{ from: "tool1", to: "output" },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Two parallel sub-agent branches hanging off the same trunk node, matching the
|
||||
* edge shape the Go builder produces (graph_lane_test.go): each lane chains from
|
||||
* its own root, and the trunk resumes from the spawn node, not from a branch. */
|
||||
function parallel(): FlowGraph {
|
||||
return graph(
|
||||
[
|
||||
node("input", "input", "done"),
|
||||
node("t1", "tool", "done"),
|
||||
node("subA", "subagent", "running"),
|
||||
node("llmA", "llm", "running"),
|
||||
node("toolA", "tool", "done"),
|
||||
node("subB", "subagent", "done"),
|
||||
node("llmB", "llm", "done"),
|
||||
node("llm2", "llm", "done"),
|
||||
node("output", "output", "done"),
|
||||
],
|
||||
[
|
||||
{ from: "input", to: "t1" },
|
||||
{ from: "t1", to: "subA" },
|
||||
{ from: "t1", to: "subB" },
|
||||
{ from: "subA", to: "llmA" },
|
||||
{ from: "llmA", to: "toolA" },
|
||||
{ from: "subB", to: "llmB" },
|
||||
{ from: "t1", to: "llm2" },
|
||||
{ from: "llm2", to: "output" },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// --- trunk only: no lane, columns unchanged (no P1 regression) ---------------
|
||||
|
||||
{
|
||||
const g = trunkOnly();
|
||||
const grouping = groupFlowLanes(g);
|
||||
same(grouping.lanes.length, 0, "trunk graph has no lane");
|
||||
ok(grouping.bandOf.get("llm1") === undefined, "trunk node has no band");
|
||||
|
||||
const { nodes, bands } = layoutFlow(g);
|
||||
const at = new Map(nodes.map((p) => [p.node.id, p]));
|
||||
same(bands.length, 1, "trunk graph has exactly one band");
|
||||
same(bands[0].framed, false, "the trunk band is not framed");
|
||||
same(at.get("input")!.x, PAD, "input sits in the first column");
|
||||
ok(
|
||||
at.get("output")!.x > at.get("tool1")!.x &&
|
||||
at.get("tool1")!.x > at.get("llm1")!.x &&
|
||||
at.get("llm1")!.x > at.get("input")!.x,
|
||||
"trunk columns grow left to right",
|
||||
);
|
||||
same(
|
||||
[at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y],
|
||||
[PAD, PAD, PAD, PAD],
|
||||
"a single-row trunk keeps one row",
|
||||
);
|
||||
// P1 pixel parity: with no lane in play the geometry is exactly the old
|
||||
// longest-path column grid, so P2 cannot regress the trunk view.
|
||||
same(
|
||||
nodes.map((p) => [p.node.id, p.x, p.y]),
|
||||
[
|
||||
["input", PAD, PAD],
|
||||
["llm1", PAD + (NODE_W + GAP_X), PAD],
|
||||
["tool1", PAD + 2 * (NODE_W + GAP_X), PAD],
|
||||
["output", PAD + 3 * (NODE_W + GAP_X), PAD],
|
||||
],
|
||||
"trunk-only layout matches the P1 column grid",
|
||||
);
|
||||
}
|
||||
|
||||
// --- parallel branches: two lanes, each holding its own step chain -----------
|
||||
|
||||
{
|
||||
const g = parallel();
|
||||
const grouping = groupFlowLanes(g);
|
||||
same(
|
||||
grouping.lanes.map((l) => l.id),
|
||||
["subA", "subB"],
|
||||
"one lane per sub-agent node",
|
||||
);
|
||||
same(grouping.lanes.map((l) => l.depth), [0, 0], "both lanes are top-level");
|
||||
same(
|
||||
grouping.lanes.map((l) => l.members.map((m) => m.id)),
|
||||
[
|
||||
["llmA", "toolA"],
|
||||
["llmB"],
|
||||
],
|
||||
"lane members follow the branch, in graph order",
|
||||
);
|
||||
same(
|
||||
[
|
||||
grouping.bandOf.get("llmA"),
|
||||
grouping.bandOf.get("toolA"),
|
||||
grouping.bandOf.get("llmB"),
|
||||
grouping.bandOf.get("llm2"),
|
||||
grouping.bandOf.get("subA"),
|
||||
],
|
||||
["subA", "subA", "subB", undefined, undefined],
|
||||
"band membership: sub-agent roots stay on the trunk",
|
||||
);
|
||||
|
||||
const laneA = grouping.lanes[0];
|
||||
same(
|
||||
[laneA.status, laneA.total, laneA.running, laneA.done, laneA.failed],
|
||||
["running", 2, 1, 1, 0],
|
||||
"lane status comes from the root when reported",
|
||||
);
|
||||
same(laneA.root.status, "running", "lane A root keeps its own status");
|
||||
|
||||
// Aggregation only kicks in when the root reports nothing.
|
||||
const rootless = groupFlowLanes({
|
||||
...g,
|
||||
nodes: g.nodes.map((n) => (n.id === "subA" ? { ...n, status: undefined } : n)),
|
||||
}).lanes[0];
|
||||
same(rootless.status, "running", "rootless lane aggregates a running member");
|
||||
const failed = groupFlowLanes({
|
||||
...g,
|
||||
nodes: g.nodes.map((n) =>
|
||||
n.id === "subA"
|
||||
? { ...n, status: undefined }
|
||||
: n.id === "toolA"
|
||||
? { ...n, status: "error" as const }
|
||||
: n,
|
||||
),
|
||||
}).lanes[0];
|
||||
same(failed.status, "error", "any failed member turns the lane red");
|
||||
|
||||
const { nodes, bands, edges } = layoutFlow(g);
|
||||
const at = new Map(nodes.map((p) => [p.node.id, p]));
|
||||
const framed = bands.filter((b) => b.framed);
|
||||
same(framed.length, 2, "each lane gets its own framed band");
|
||||
|
||||
// Lane steps start one column right of their sub-agent root.
|
||||
same(
|
||||
at.get("llmA")!.x - at.get("subA")!.x,
|
||||
NODE_W + GAP_X,
|
||||
"lane A's first step starts one column right of its root",
|
||||
);
|
||||
same(
|
||||
at.get("toolA")!.x,
|
||||
at.get("llmA")!.x + NODE_W + GAP_X,
|
||||
"lane A chains toolA after llmA",
|
||||
);
|
||||
same(
|
||||
at.get("llmB")!.x - at.get("subB")!.x,
|
||||
NODE_W + GAP_X,
|
||||
"lane B's step starts one column right of its root",
|
||||
);
|
||||
|
||||
// Lanes are stacked below the trunk and never share rows with each other.
|
||||
const trunkBottom = PAD + 52;
|
||||
ok(framed[0].y >= trunkBottom, "lane A sits below the trunk");
|
||||
ok(framed[1].y >= framed[0].y + framed[0].height, "lane B sits below lane A");
|
||||
const rowA = at.get("llmA")!.y;
|
||||
const rowB = at.get("llmB")!.y;
|
||||
ok(rowA !== rowB, "parallel lanes do not share a row");
|
||||
ok(at.get("llm2")!.y < framed[0].y, "the trunk continuation stays in the trunk band");
|
||||
|
||||
// No two cards overlap.
|
||||
const seen = new Set(nodes.map((p) => `${p.x},${p.y}`));
|
||||
same(seen.size, nodes.length, "every node has its own slot");
|
||||
|
||||
// Edges into a lane are marked so the renderer can dash them.
|
||||
const byKey = new Map(edges.map((e) => [`${e.edge.from}>${e.edge.to}`, e.lane]));
|
||||
same(byKey.get("subA>llmA"), "subA", "lane entry edge is a lane edge");
|
||||
same(byKey.get("llmA>toolA"), "subA", "intra-lane edge is a lane edge");
|
||||
same(byKey.get("subB>llm2"), undefined, "trunk continuation is a trunk edge");
|
||||
same(byKey.get("t1>llm2"), undefined, "the trunk resumes from the spawn node");
|
||||
}
|
||||
|
||||
// --- nested sub-agent: a lane inside a lane ----------------------------------
|
||||
|
||||
{
|
||||
const nested = graph(
|
||||
[
|
||||
node("input", "input", "done"),
|
||||
node("subA", "subagent", "running"),
|
||||
node("llmA", "llm", "done"),
|
||||
node("subN", "subagent", "running"),
|
||||
node("llmN", "llm", "running"),
|
||||
],
|
||||
[
|
||||
{ from: "input", to: "subA" },
|
||||
{ from: "subA", to: "llmA" },
|
||||
{ from: "llmA", to: "subN" },
|
||||
{ from: "subN", to: "llmN" },
|
||||
],
|
||||
);
|
||||
const grouping = groupFlowLanes(nested);
|
||||
same(
|
||||
grouping.lanes.map((l) => [l.id, l.depth, l.parentLaneId]),
|
||||
[
|
||||
["subA", 0, undefined],
|
||||
["subN", 1, "subA"],
|
||||
],
|
||||
"a nested sub-agent is its own lane one level deeper",
|
||||
);
|
||||
same(grouping.bandOf.get("subN"), "subA", "the nested root is drawn in its parent lane");
|
||||
same(grouping.bandOf.get("llmN"), "subN", "the nested root's steps belong to the nested lane");
|
||||
|
||||
const { bands } = layoutFlow(nested);
|
||||
const framed = bands.filter((b) => b.framed);
|
||||
same(framed.length, 2, "nested lanes each get a frame");
|
||||
ok(framed[1].y > framed[0].y, "the nested lane is drawn below its parent lane");
|
||||
}
|
||||
|
||||
// --- degenerate input must not throw -----------------------------------------
|
||||
|
||||
{
|
||||
const empty: FlowGraph = { id: "g", nodes: [], edges: [] };
|
||||
const emptyLayout = layoutFlow(empty);
|
||||
same(
|
||||
[emptyLayout.nodes.length, emptyLayout.bands.length, emptyLayout.lanes.length],
|
||||
[0, 0, 0],
|
||||
"empty graph",
|
||||
);
|
||||
same(
|
||||
[emptyLayout.width, emptyLayout.height],
|
||||
[PAD * 2, PAD * 2],
|
||||
"empty graph keeps the padding box",
|
||||
);
|
||||
|
||||
const messy = graph(
|
||||
[node("a", "llm", "done"), node("b", "llm", "done")],
|
||||
[
|
||||
{ from: "a", to: "a" },
|
||||
{ from: "a", to: "ghost" },
|
||||
{ from: "a", to: "b" },
|
||||
{ from: "b", to: "a" },
|
||||
],
|
||||
);
|
||||
const grouping = groupFlowLanes(messy);
|
||||
same(grouping.edges.length, 2, "self-loops and dangling edges are dropped");
|
||||
const messyLayout = layoutFlow(messy);
|
||||
same(messyLayout.nodes.length, 2, "layout terminates on a cyclic graph");
|
||||
|
||||
// A wide layer must not push two cards onto the same slot.
|
||||
const wide = graph(
|
||||
[node("r", "input", "done"), node("c1", "llm"), node("c2", "llm"), node("c3", "llm")],
|
||||
[
|
||||
{ from: "r", to: "c1" },
|
||||
{ from: "r", to: "c2" },
|
||||
{ from: "r", to: "c3" },
|
||||
],
|
||||
);
|
||||
const wideLayout = layoutFlow(wide);
|
||||
const slots = new Set(wideLayout.nodes.map((p) => `${p.x},${p.y}`));
|
||||
same(slots.size, 4, "fan-out siblings get distinct rows");
|
||||
|
||||
// A malformed frame with a duplicated id must not draw the same card twice.
|
||||
const dup = graph([node("a", "llm", "done"), node("a", "llm", "done")], []);
|
||||
same(layoutFlow(dup).nodes.length, 1, "a duplicated node id is drawn once");
|
||||
}
|
||||
|
||||
console.log(`flow/lanes.test.ts OK — ${checks} checks`);
|
||||
@@ -0,0 +1,166 @@
|
||||
import type {
|
||||
AgentStatus,
|
||||
FlowEdge,
|
||||
FlowGraph,
|
||||
FlowNode,
|
||||
} from "../../../lib/types";
|
||||
import { statusOf } from "./tone";
|
||||
|
||||
/** Swimlane grouping for the live flow graph (design/19 §2): the main trunk
|
||||
* (输入→思考→工具→输出) stays one band, and every sub-agent node opens its own
|
||||
* band holding the step chain of that branch, so parallel branches never
|
||||
* interleave visually. Membership is derived from the edges alone: a node
|
||||
* belongs to the nearest sub-agent node upstream of it (the builder wires each
|
||||
* lane's steps behind its sub-agent root, see internal/runtime/trace/graph.go).
|
||||
*
|
||||
* Everything here is a pure function over a FlowGraph — no React, no store — so
|
||||
* it is unit-testable on a plain node run (flow/lanes.test.ts). */
|
||||
|
||||
/** The node kind that opens a lane. */
|
||||
export const SUBAGENT_KIND = "subagent";
|
||||
|
||||
export interface FlowLane {
|
||||
/** The sub-agent root node's id. */
|
||||
id: string;
|
||||
root: FlowNode;
|
||||
/** The nodes drawn in this lane's band, in graph order. A nested sub-agent
|
||||
* root is a member of its parent lane AND a lane of its own. */
|
||||
members: FlowNode[];
|
||||
/** The enclosing lane id when this sub-agent was spawned inside another. */
|
||||
parentLaneId?: string;
|
||||
/** Nesting depth: 0 = spawned by the trunk. */
|
||||
depth: number;
|
||||
/** The root's own outcome when reported, else aggregated from the members. */
|
||||
status: AgentStatus;
|
||||
total: number;
|
||||
running: number;
|
||||
done: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface FlowGrouping {
|
||||
byId: Map<string, FlowNode>;
|
||||
/** The graph's nodes, graph order, first occurrence per id (duplicated ids can
|
||||
* only come from a malformed frame; they must not draw twice). */
|
||||
nodes: FlowNode[];
|
||||
/** node id → the band it is drawn in (a lane id, or undefined = trunk). */
|
||||
bandOf: Map<string, string | undefined>;
|
||||
/** Every lane, ordered by nesting depth then graph order. */
|
||||
lanes: FlowLane[];
|
||||
/** Valid edges (self-loops and dangling endpoints dropped). */
|
||||
edges: FlowEdge[];
|
||||
}
|
||||
|
||||
/** groupFlowLanes partitions a graph into the trunk band plus one band per
|
||||
* sub-agent node. */
|
||||
export function groupFlowLanes(graph: FlowGraph): FlowGrouping {
|
||||
const byId = new Map<string, FlowNode>();
|
||||
const index = new Map<string, number>();
|
||||
const nodes: FlowNode[] = [];
|
||||
graph.nodes.forEach((n, i) => {
|
||||
if (!byId.has(n.id)) {
|
||||
byId.set(n.id, n);
|
||||
index.set(n.id, i);
|
||||
nodes.push(n);
|
||||
}
|
||||
});
|
||||
|
||||
const preds = new Map<string, string[]>();
|
||||
byId.forEach((_, id) => preds.set(id, []));
|
||||
const edges: FlowEdge[] = [];
|
||||
graph.edges.forEach((e) => {
|
||||
if (e.from === e.to) return;
|
||||
if (!byId.has(e.from) || !byId.has(e.to)) return;
|
||||
if (preds.get(e.to)!.includes(e.from)) return;
|
||||
preds.get(e.to)!.push(e.from);
|
||||
edges.push(e);
|
||||
});
|
||||
|
||||
const laneAncestor = (id: string): string | undefined =>
|
||||
nearestSubagent(id, preds, byId, index);
|
||||
|
||||
const bandOf = new Map<string, string | undefined>();
|
||||
byId.forEach((_, id) => bandOf.set(id, laneAncestor(id)));
|
||||
|
||||
const roots = nodes.filter((n) => n.kind === SUBAGENT_KIND);
|
||||
const depthMemo = new Map<string, number>();
|
||||
const laneDepth = (id: string): number => {
|
||||
const cached = depthMemo.get(id);
|
||||
if (cached !== undefined) return cached;
|
||||
const parent = bandOf.get(id);
|
||||
const d = parent === undefined || parent === id ? 0 : laneDepth(parent) + 1;
|
||||
depthMemo.set(id, d);
|
||||
return d;
|
||||
};
|
||||
|
||||
const lanes: FlowLane[] = roots
|
||||
.map((root) => {
|
||||
const members = nodes.filter((n) => n.id !== root.id && bandOf.get(n.id) === root.id);
|
||||
let running = 0;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
members.forEach((m) => {
|
||||
const s = statusOf(m);
|
||||
if (s === "running") running += 1;
|
||||
else if (s === "done") done += 1;
|
||||
else if (s === "error") failed += 1;
|
||||
});
|
||||
const parentLaneId = bandOf.get(root.id);
|
||||
return {
|
||||
id: root.id,
|
||||
root,
|
||||
members,
|
||||
parentLaneId,
|
||||
depth: laneDepth(root.id),
|
||||
status: laneStatus(root, members.length, running, failed),
|
||||
total: members.length,
|
||||
running,
|
||||
done,
|
||||
failed,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.depth - b.depth || index.get(a.id)! - index.get(b.id)!);
|
||||
|
||||
return { byId, nodes, bandOf, lanes, edges };
|
||||
}
|
||||
|
||||
/** nearestSubagent walks upstream from id and returns the closest sub-agent node
|
||||
* (BFS by hop count, ties broken by graph order so the result is stable). */
|
||||
function nearestSubagent(
|
||||
id: string,
|
||||
preds: Map<string, string[]>,
|
||||
byId: Map<string, FlowNode>,
|
||||
index: Map<string, number>,
|
||||
): string | undefined {
|
||||
const seen = new Set<string>([id]);
|
||||
let frontier = preds.get(id) ?? [];
|
||||
while (frontier.length > 0) {
|
||||
const layer = frontier.filter((p) => !seen.has(p));
|
||||
if (layer.length === 0) return undefined;
|
||||
layer.forEach((p) => seen.add(p));
|
||||
const hits = layer.filter((p) => byId.get(p)?.kind === SUBAGENT_KIND);
|
||||
if (hits.length > 0) {
|
||||
return hits.sort((a, b) => index.get(a)! - index.get(b)!)[0];
|
||||
}
|
||||
const next: string[] = [];
|
||||
layer.forEach((p) => (preds.get(p) ?? []).forEach((q) => next.push(q)));
|
||||
frontier = next;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** laneStatus prefers the sub-agent's own reported status; when the root has no
|
||||
* status yet it aggregates the members (any failure wins, then anything still
|
||||
* running, then all-done) — never inventing an outcome. */
|
||||
function laneStatus(
|
||||
root: FlowNode,
|
||||
total: number,
|
||||
running: number,
|
||||
failed: number,
|
||||
): AgentStatus {
|
||||
if (root.status) return root.status;
|
||||
if (failed > 0) return "error";
|
||||
if (running > 0) return "running";
|
||||
if (total > 0) return "done";
|
||||
return "idle";
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
||||
import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes";
|
||||
|
||||
/** Lane-aware left-to-right layout for the flow graph (design/19 §2). The trunk
|
||||
* keeps the P1 "longest path depth" columns; every sub-agent lane gets its own
|
||||
* horizontal band below the trunk, its steps starting one column right of the
|
||||
* sub-agent root and chaining rightwards. Bands never share rows, so parallel
|
||||
* branches cannot overlap — the canvas just grows (and scrolls) instead. */
|
||||
|
||||
export const NODE_W = 148;
|
||||
export const NODE_H = 52;
|
||||
export const GAP_X = 64;
|
||||
export const GAP_Y = 30;
|
||||
export const PAD = 20;
|
||||
/** Vertical gap between two bands. */
|
||||
export const BAND_GAP = 26;
|
||||
/** Padding between a lane frame and the nodes it holds. */
|
||||
export const FRAME_PAD = 10;
|
||||
/** Height reserved inside a lane frame for its label row. */
|
||||
export const LANE_HEADER = 22;
|
||||
/** Minimum columns a lane frame spans, so an idle lane is still readable. */
|
||||
const MIN_LANE_COLS = 1;
|
||||
|
||||
export interface PlacedNode {
|
||||
node: FlowNode;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PlacedBand {
|
||||
/** The lane id, or undefined for the trunk band. */
|
||||
id?: string;
|
||||
label: string;
|
||||
status: AgentStatus;
|
||||
/** Frame rect; for the trunk band this is the content area (not framed). */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
framed: boolean;
|
||||
}
|
||||
|
||||
export interface PlacedEdge {
|
||||
edge: FlowEdge;
|
||||
/** The lane the edge runs into/inside, when it is not a trunk edge. */
|
||||
lane?: string;
|
||||
}
|
||||
|
||||
export interface FlowLayout {
|
||||
nodes: PlacedNode[];
|
||||
edges: PlacedEdge[];
|
||||
bands: PlacedBand[];
|
||||
lanes: FlowLane[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BandPlan {
|
||||
id?: string;
|
||||
lane?: FlowLane;
|
||||
members: FlowNode[];
|
||||
/** node id → column offset inside the band (longest path over the band's own edges). */
|
||||
depth: Map<string, number>;
|
||||
/** Absolute first column of the band. */
|
||||
originCol: number;
|
||||
rows: number;
|
||||
fromCol: number;
|
||||
toCol: number;
|
||||
}
|
||||
|
||||
/** layoutFlow computes the lane-banded geometry of a graph. */
|
||||
export function layoutFlow(graph: FlowGraph): FlowLayout {
|
||||
const grouping = groupFlowLanes(graph);
|
||||
if (grouping.byId.size === 0) {
|
||||
return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2 };
|
||||
}
|
||||
|
||||
const index = orderIndex(grouping.nodes);
|
||||
const plans = planBands(grouping);
|
||||
const { nodes, bands, width, height } = placeBands(plans, index);
|
||||
const placed = new Set(nodes.map((p) => p.node.id));
|
||||
const edges: PlacedEdge[] = grouping.edges
|
||||
.filter((e) => placed.has(e.from) && placed.has(e.to))
|
||||
.map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) }));
|
||||
|
||||
return { nodes, edges, bands, lanes: grouping.lanes, width, height };
|
||||
}
|
||||
|
||||
/** orderIndex maps every node id to its graph-order position. */
|
||||
function orderIndex(nodes: FlowNode[]): Map<string, number> {
|
||||
const index = new Map<string, number>();
|
||||
nodes.forEach((n, i) => index.set(n.id, i));
|
||||
return index;
|
||||
}
|
||||
|
||||
/** planBands builds one plan per band, trunk first: a lane's origin column is
|
||||
* derived from its root's column, so the enclosing band must be planned before
|
||||
* it — lane order (by nesting depth) guarantees exactly that. */
|
||||
function planBands(grouping: FlowGrouping): BandPlan[] {
|
||||
const { bandOf, lanes, nodes } = grouping;
|
||||
const trunk: BandPlan = {
|
||||
members: nodes.filter((n) => bandOf.get(n.id) === undefined),
|
||||
depth: new Map(),
|
||||
originCol: 0,
|
||||
rows: 1,
|
||||
fromCol: 0,
|
||||
toCol: 0,
|
||||
};
|
||||
const plans: BandPlan[] = [
|
||||
trunk,
|
||||
...lanes.map((lane) => ({
|
||||
id: lane.id,
|
||||
lane,
|
||||
members: lane.members,
|
||||
depth: new Map<string, number>(),
|
||||
originCol: 0,
|
||||
rows: 1,
|
||||
fromCol: 0,
|
||||
toCol: 0,
|
||||
})),
|
||||
];
|
||||
|
||||
/** colOf is the absolute column a node is drawn in. */
|
||||
const colOf = (id: string): number => {
|
||||
const band = plans.find((p) => p.depth.has(id));
|
||||
return band ? band.originCol + band.depth.get(id)! : 0;
|
||||
};
|
||||
|
||||
plans.forEach((plan) => {
|
||||
if (plan.id !== undefined && plan.lane) {
|
||||
plan.originCol = colOf(plan.lane.root.id) + 1;
|
||||
}
|
||||
computeBandColumns(plan, grouping.edges);
|
||||
const cols = plan.members.map((n) => plan.originCol + plan.depth.get(n.id)!);
|
||||
plan.fromCol = cols.length > 0 ? Math.min(...cols) : plan.originCol;
|
||||
plan.toCol = cols.length > 0 ? Math.max(...cols) : plan.fromCol + MIN_LANE_COLS;
|
||||
});
|
||||
return plans;
|
||||
}
|
||||
|
||||
/** placeBands turns the plans into pixel geometry: bands stack top-down (lanes
|
||||
* framed, with a header row for their label) and every node gets its own slot. */
|
||||
function placeBands(
|
||||
plans: BandPlan[],
|
||||
index: Map<string, number>,
|
||||
): { nodes: PlacedNode[]; bands: PlacedBand[]; width: number; height: number } {
|
||||
const pos = new Map<string, PlacedNode>();
|
||||
const bands: PlacedBand[] = [];
|
||||
/** Top of the next band (its frame top, header included). */
|
||||
let limitY = PAD;
|
||||
let contentBottom = PAD;
|
||||
let maxCol = 0;
|
||||
|
||||
plans.forEach((plan) => {
|
||||
const framed = plan.id !== undefined;
|
||||
const frameY = limitY;
|
||||
const contentY = framed ? frameY + LANE_HEADER + FRAME_PAD : frameY;
|
||||
const bandHeight = rowsHeight(plan.rows);
|
||||
const frameHeight = framed ? bandHeight + LANE_HEADER + FRAME_PAD * 2 : bandHeight;
|
||||
|
||||
const byCol = new Map<number, FlowNode[]>();
|
||||
plan.members.forEach((n) => {
|
||||
const col = plan.originCol + plan.depth.get(n.id)!;
|
||||
const arr = byCol.get(col) ?? [];
|
||||
arr.push(n);
|
||||
byCol.set(col, arr);
|
||||
maxCol = Math.max(maxCol, col);
|
||||
});
|
||||
byCol.forEach((arr, col) => {
|
||||
arr
|
||||
.sort((a, b) => index.get(a.id)! - index.get(b.id)!)
|
||||
.forEach((n, row) => {
|
||||
pos.set(n.id, {
|
||||
node: n,
|
||||
x: PAD + col * (NODE_W + GAP_X),
|
||||
y: contentY + row * (NODE_H + GAP_Y),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
bands.push({
|
||||
id: plan.id,
|
||||
label: plan.lane ? plan.lane.root.label : "主干",
|
||||
status: plan.lane ? plan.lane.status : "idle",
|
||||
x: PAD + plan.fromCol * (NODE_W + GAP_X) - (framed ? FRAME_PAD : 0),
|
||||
y: frameY,
|
||||
width: (plan.toCol - plan.fromCol + 1) * (NODE_W + GAP_X) - GAP_X + (framed ? FRAME_PAD * 2 : 0),
|
||||
height: frameHeight,
|
||||
framed,
|
||||
});
|
||||
|
||||
contentBottom = frameY + frameHeight;
|
||||
limitY = contentBottom + BAND_GAP;
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: [...pos.values()].sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!),
|
||||
bands,
|
||||
width: PAD * 2 + (maxCol + 1) * NODE_W + maxCol * GAP_X,
|
||||
height: Math.max(contentBottom + PAD, PAD * 2),
|
||||
};
|
||||
}
|
||||
|
||||
/** computeBandColumns fills a band's per-node depth (longest path inside the
|
||||
* band) and row count. Edges leaving the band are ignored: a lane's entry edge
|
||||
* comes from its root, which is drawn in the enclosing band. */
|
||||
function computeBandColumns(plan: BandPlan, edges: FlowEdge[]): void {
|
||||
const memberIds = new Set(plan.members.map((n) => n.id));
|
||||
const preds = new Map<string, string[]>();
|
||||
memberIds.forEach((id) => preds.set(id, []));
|
||||
edges.forEach((e) => {
|
||||
if (memberIds.has(e.from) && memberIds.has(e.to)) preds.get(e.to)!.push(e.from);
|
||||
});
|
||||
|
||||
const memo = new Map<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
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 perCol = new Map<number, number>();
|
||||
plan.members.forEach((n) => {
|
||||
const d = depth(n.id);
|
||||
plan.depth.set(n.id, d);
|
||||
const col = plan.originCol + d;
|
||||
perCol.set(col, (perCol.get(col) ?? 0) + 1);
|
||||
});
|
||||
plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1;
|
||||
}
|
||||
|
||||
/** rowsHeight is the pixel height of rows stacked node cards. */
|
||||
function rowsHeight(rows: number): number {
|
||||
return rows * NODE_H + (rows - 1) * GAP_Y;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { AgentStatus, FlowNode } from "../../../lib/types";
|
||||
import type { TagTone } from "../../../components/primitives";
|
||||
|
||||
/** Miuix status presentation for flow nodes (design/19 §2: 节点闪烁=运行中、
|
||||
* ok=成功、err=失败). The stroke/fill colors are Miuix tokens so the graph
|
||||
* follows the panel theme (light/dark) without hard-coded colors. */
|
||||
export interface FlowTone {
|
||||
stroke: string;
|
||||
fill: string;
|
||||
label: string;
|
||||
tag: TagTone;
|
||||
}
|
||||
|
||||
export const FLOW_TONE: Record<AgentStatus, FlowTone> = {
|
||||
idle: {
|
||||
stroke: "var(--mu-surface-5)",
|
||||
fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)",
|
||||
label: "空闲",
|
||||
tag: "neutral",
|
||||
},
|
||||
running: {
|
||||
stroke: "var(--mu-accent)",
|
||||
fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)",
|
||||
label: "运行中",
|
||||
tag: "accent",
|
||||
},
|
||||
blocked: {
|
||||
stroke: "var(--mu-warn)",
|
||||
fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)",
|
||||
label: "阻塞",
|
||||
tag: "warn",
|
||||
},
|
||||
done: {
|
||||
stroke: "var(--mu-ok)",
|
||||
fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)",
|
||||
label: "完成",
|
||||
tag: "ok",
|
||||
},
|
||||
error: {
|
||||
stroke: "var(--mu-err)",
|
||||
fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)",
|
||||
label: "错误",
|
||||
tag: "err",
|
||||
},
|
||||
};
|
||||
|
||||
/** Legend / aggregate order, coldest to most alarming. */
|
||||
export const STATUS_ORDER: ReadonlyArray<AgentStatus> = [
|
||||
"idle",
|
||||
"running",
|
||||
"blocked",
|
||||
"done",
|
||||
"error",
|
||||
];
|
||||
|
||||
/** statusOf narrows a node's optional status to a concrete AgentStatus. */
|
||||
export function statusOf(node: FlowNode): AgentStatus {
|
||||
return node.status ?? "idle";
|
||||
}
|
||||
@@ -30,6 +30,28 @@ const (
|
||||
// collapsed to a safe, dashboard-friendly prefix.
|
||||
const maxSummaryRunes = 80
|
||||
|
||||
// Node kinds the builder emits. They are named constants because the builder
|
||||
// itself branches on the subagent kind when it resolves a node's lane
|
||||
// (design/19 §2: parallel sub-agent branches render as separate swimlanes).
|
||||
const (
|
||||
nodeKindLLM = "llm"
|
||||
nodeKindTool = "tool"
|
||||
nodeKindSubagent = "subagent"
|
||||
nodeKindMemory = "memory"
|
||||
nodeKindGuardrail = "guardrail"
|
||||
nodeKindOrchestrate = "orchestration"
|
||||
)
|
||||
|
||||
// maxLaneWalk bounds how far the builder walks a span's parent chain when it
|
||||
// resolves the lane (nearest enclosing subagent node) of a new node. Span
|
||||
// parents form a tree, so the bound only guards against malformed input — it
|
||||
// keeps the walk finite without a magic number in the loop (project rule §10).
|
||||
const maxLaneWalk = 64
|
||||
|
||||
// trunkLane is the lane key of the main trunk: the lane of every span that has no
|
||||
// sub-agent ancestor. It is a named constant so the map lookups read as intent.
|
||||
const trunkLane = ""
|
||||
|
||||
// FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier.
|
||||
type FlowNode struct {
|
||||
ID string `json:"id"`
|
||||
@@ -82,17 +104,27 @@ type RunGraph struct {
|
||||
order []string
|
||||
edges map[string]bool
|
||||
edgeOrder []FlowEdge
|
||||
last string
|
||||
started time.Time
|
||||
ended time.Time
|
||||
// parents remembers every span's parent link, including spans that are not
|
||||
// rendered as nodes (a state_transition step container), so a node's lane can
|
||||
// be resolved by walking through the skipped containers.
|
||||
parents map[string]string
|
||||
// laneLast is the per-lane cursor used to chain sequential steps: keyed by the
|
||||
// enclosing subagent node id, and by "" for the main trunk. One cursor per lane
|
||||
// (instead of one per graph) is what keeps two parallel sub-agent lanes from
|
||||
// cross-linking when their spans interleave.
|
||||
laneLast map[string]string
|
||||
started time.Time
|
||||
ended time.Time
|
||||
}
|
||||
|
||||
// NewRunGraph allocates an empty graph for one session round.
|
||||
func NewRunGraph(session string) *RunGraph {
|
||||
return &RunGraph{
|
||||
session: session,
|
||||
nodes: make(map[string]*FlowNode),
|
||||
edges: make(map[string]bool),
|
||||
session: session,
|
||||
nodes: make(map[string]*FlowNode),
|
||||
edges: make(map[string]bool),
|
||||
parents: make(map[string]string),
|
||||
laneLast: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +138,21 @@ func (g *RunGraph) Start(round int, now time.Time) {
|
||||
g.order = nil
|
||||
g.edges = make(map[string]bool)
|
||||
g.edgeOrder = nil
|
||||
g.last = ""
|
||||
g.parents = make(map[string]string)
|
||||
g.laneLast = make(map[string]string)
|
||||
}
|
||||
|
||||
// Upsert creates or merges a node and wires its incoming edge. The edge source
|
||||
// is the node's ParentID when that parent is already a node, otherwise the most
|
||||
// recently added node — so a round assembles as a left-to-right trunk even when
|
||||
// the parent container (a state_transition span) is omitted from the graph.
|
||||
// Upsert creates or merges a node and wires its incoming edge. The edge source is
|
||||
// the node's ParentID when that parent is already a node; otherwise the span
|
||||
// belongs to a lane (subagent branch) or the main trunk and is chained after the
|
||||
// last node of that same lane — so a lane stays a coherent step chain even while
|
||||
// several lanes are in flight, and the first node of a lane hangs off its
|
||||
// subagent root (design/19 §2: 泳道内展示该子代理的步骤链).
|
||||
func (g *RunGraph) Upsert(spec NodeSpec) {
|
||||
if spec.ID == "" {
|
||||
return
|
||||
}
|
||||
g.rememberParent(spec.ID, spec.ParentID)
|
||||
if existing := g.nodes[spec.ID]; existing != nil {
|
||||
g.merge(existing, spec)
|
||||
return
|
||||
@@ -136,16 +172,51 @@ func (g *RunGraph) Upsert(spec NodeSpec) {
|
||||
g.nodes[spec.ID] = n
|
||||
g.order = append(g.order, spec.ID)
|
||||
|
||||
from := g.last
|
||||
lane := g.laneOf(spec.ParentID)
|
||||
from := g.laneLast[lane]
|
||||
if spec.ParentID != "" {
|
||||
if _, ok := g.nodes[spec.ParentID]; ok {
|
||||
from = spec.ParentID
|
||||
}
|
||||
}
|
||||
if from == "" {
|
||||
from = lane
|
||||
}
|
||||
if from != "" && from != spec.ID {
|
||||
g.addEdge(from, spec.ID)
|
||||
}
|
||||
g.last = spec.ID
|
||||
// A sub-agent root is drawn on the trunk it hangs off, but it does not become
|
||||
// the trunk's continuation point: the main chain resumes where the branch left
|
||||
// it (the spawn node). Without this, every node after a spawn would look like a
|
||||
// step of the last branch (the frontend derives lanes from the edges). The
|
||||
// first trunk node still advances the cursor, so a graph built without an input
|
||||
// seed cannot orphan its later trunk nodes.
|
||||
if lane != "" || n.Kind != nodeKindSubagent || g.laneLast[trunkLane] == "" {
|
||||
g.laneLast[lane] = spec.ID
|
||||
}
|
||||
}
|
||||
|
||||
// rememberParent records a span's parent link even when the span is not rendered
|
||||
// as a node, so laneOf can walk through skipped containers.
|
||||
func (g *RunGraph) rememberParent(id, parent string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
g.parents[id] = parent
|
||||
}
|
||||
|
||||
// laneOf returns the id of the nearest enclosing subagent node for a span's
|
||||
// parent chain, or "" when the chain belongs to the main trunk. Only nodes
|
||||
// already in the graph can be a lane root, so a lane resolves as soon as its
|
||||
// first step is folded in.
|
||||
func (g *RunGraph) laneOf(parentID string) string {
|
||||
for id, hops := parentID, 0; id != "" && hops < maxLaneWalk; hops++ {
|
||||
if n, ok := g.nodes[id]; ok && n.Kind == nodeKindSubagent {
|
||||
return id
|
||||
}
|
||||
id = g.parents[id]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// merge updates an existing node in place (a running placeholder being finalized
|
||||
@@ -213,10 +284,12 @@ func (g *RunGraph) SeedOutput(text string) string {
|
||||
}
|
||||
|
||||
// AddSpan folds a finished span record into the graph as a node with its final
|
||||
// status. State-transition spans are the step container and are omitted.
|
||||
// status. State-transition spans are the step container and are omitted — but
|
||||
// their parent link is remembered so lane resolution can walk through them.
|
||||
func (g *RunGraph) AddSpan(rec SpanRecord) {
|
||||
kind := kindForSpan(rec.Kind)
|
||||
if kind == "" {
|
||||
g.rememberParent(rec.ID, rec.ParentID)
|
||||
return
|
||||
}
|
||||
g.Upsert(NodeSpec{
|
||||
@@ -234,10 +307,12 @@ func (g *RunGraph) AddSpan(rec SpanRecord) {
|
||||
|
||||
// AddSpanStart registers a running placeholder for a span that just began, so a
|
||||
// node appears as "running" before it finishes. State-transition spans are
|
||||
// omitted, matching AddSpan. It reports whether a node was created.
|
||||
// omitted (their parent link is still remembered for lane resolution), matching
|
||||
// AddSpan. It reports whether a node was created.
|
||||
func (g *RunGraph) AddSpanStart(id, name string, kind SpanKind, parent string, at time.Time) bool {
|
||||
nodeKind := kindForSpan(kind)
|
||||
if nodeKind == "" {
|
||||
g.rememberParent(id, parent)
|
||||
return false
|
||||
}
|
||||
g.Upsert(NodeSpec{
|
||||
@@ -272,17 +347,17 @@ func (g *RunGraph) Snapshot() *FlowGraph {
|
||||
func kindForSpan(k SpanKind) string {
|
||||
switch k {
|
||||
case SpanKindLLM:
|
||||
return "llm"
|
||||
return nodeKindLLM
|
||||
case SpanKindTool:
|
||||
return "tool"
|
||||
return nodeKindTool
|
||||
case SpanKindSubagent:
|
||||
return "subagent"
|
||||
return nodeKindSubagent
|
||||
case SpanKindMemory:
|
||||
return "memory"
|
||||
return nodeKindMemory
|
||||
case SpanKindGuardrail:
|
||||
return "guardrail"
|
||||
return nodeKindGuardrail
|
||||
case SpanKindOrchestration:
|
||||
return "orchestration"
|
||||
return nodeKindOrchestrate
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -290,7 +365,7 @@ func kindForSpan(k SpanKind) string {
|
||||
|
||||
// labelForSpan renders a node label from a finished span.
|
||||
func labelForSpan(rec SpanRecord) string {
|
||||
if kind := kindForSpan(rec.Kind); kind == "tool" {
|
||||
if kind := kindForSpan(rec.Kind); kind == nodeKindTool {
|
||||
return strings.TrimPrefix(rec.Name, "tool.")
|
||||
}
|
||||
if rec.Kind == SpanKindLLM {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// edgeSet renders a graph's edges as a sorted "from>to" set, so a test can assert
|
||||
// the exact wiring (an extra cross-lane edge fails even though the wanted edges
|
||||
// are present).
|
||||
func edgeSet(g *FlowGraph) []string {
|
||||
out := make([]string, 0, len(g.Edges))
|
||||
for _, e := range g.Edges {
|
||||
out = append(out, e.From+">"+e.To)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// assertEdges asserts the graph's edges match want exactly.
|
||||
func assertEdges(t *testing.T, g *FlowGraph, want ...string) {
|
||||
t.Helper()
|
||||
sort.Strings(want)
|
||||
got := edgeSet(g)
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("edges = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphParallelLanesStaySeparate folds two sub-agent lanes whose
|
||||
// spans interleave (A's second step arrives after B started) and asserts the
|
||||
// wiring stays per-lane: each lane chains from its own root through its own
|
||||
// steps, with no edge crossing lanes (design/19 §2 泳道).
|
||||
func TestBuildFlowGraphParallelLanesStaySeparate(t *testing.T) {
|
||||
spans := []SpanRecord{
|
||||
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
|
||||
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
|
||||
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
// Lane B starts before lane A's second step finishes.
|
||||
spanRec("subB", "t1", "subagent.b", SpanKindSubagent, SpanStatusOK, nil),
|
||||
spanRec("stB", "subB", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llmB", "stB", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
spanRec("stA2", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("toolA", "stA2", "tool.search", SpanKindTool, SpanStatusOK, nil),
|
||||
// The main loop continues on its own step after the spawn tool call.
|
||||
spanRec("st2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llm2", "st2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
}
|
||||
|
||||
g := graphFor(spans, "q", "a")
|
||||
|
||||
assertEdges(t, g,
|
||||
"input>t1",
|
||||
"t1>subA",
|
||||
"t1>subB",
|
||||
"subA>llmA",
|
||||
"llmA>toolA",
|
||||
"subB>llmB",
|
||||
// The trunk resumes from the spawn node, not from a sub-agent's branch.
|
||||
"t1>llm2",
|
||||
"llm2>output",
|
||||
)
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphNestedSubagentLane folds a sub-agent spawned inside another
|
||||
// sub-agent and asserts the nested lane chains off its own root while staying
|
||||
// chained inside the parent lane.
|
||||
func TestBuildFlowGraphNestedSubagentLane(t *testing.T) {
|
||||
spans := []SpanRecord{
|
||||
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil),
|
||||
spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil),
|
||||
spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
spanRec("stN", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("subN", "stN", "subagent.nested", SpanKindSubagent, SpanStatusOK, nil),
|
||||
spanRec("stN2", "subN", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llmN", "stN2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
}
|
||||
|
||||
g := graphFor(spans, "q", "a")
|
||||
|
||||
assertEdges(t, g,
|
||||
"input>t1",
|
||||
"t1>subA",
|
||||
"subA>llmA",
|
||||
// The nested sub-agent is a step of lane A...
|
||||
"llmA>subN",
|
||||
// ...and its own steps hang off it.
|
||||
"subN>llmN",
|
||||
// The trunk resumes from the spawn node even though the round ended inside
|
||||
// a nested branch.
|
||||
"t1>output",
|
||||
)
|
||||
if got := nodeByID(g, "subN").Kind; got != nodeKindSubagent {
|
||||
t.Fatalf("nested node kind = %q, want %q", got, nodeKindSubagent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildFlowGraphTrunkCursorUnchanged asserts the lane-aware cursor degrades to
|
||||
// the plain left-to-right trunk when no sub-agent is in flight (no P1 regression).
|
||||
func TestBuildFlowGraphTrunkCursorUnchanged(t *testing.T) {
|
||||
spans := []SpanRecord{
|
||||
spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llm1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
spanRec("step2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("llm2", "step2", "llm.call", SpanKindLLM, SpanStatusOK, nil),
|
||||
spanRec("step3", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil),
|
||||
spanRec("tool2", "step3", "tool.echo", SpanKindTool, SpanStatusOK, nil),
|
||||
}
|
||||
|
||||
g := graphFor(spans, "q", "a")
|
||||
|
||||
assertEdges(t, g,
|
||||
"input>llm1",
|
||||
"llm1>llm2",
|
||||
"llm2>tool2",
|
||||
"tool2>output",
|
||||
)
|
||||
}
|
||||
|
||||
// TestLaneOfWalkBound asserts a malformed (over-deep or cyclic) parent chain
|
||||
// cannot spin laneOf forever: past the walk bound the node simply lands on the
|
||||
// trunk, while a shallow chain still resolves to its sub-agent root.
|
||||
func TestLaneOfWalkBound(t *testing.T) {
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.AddSpan(spanRec("sub", "", "subagent.a", SpanKindSubagent, SpanStatusOK, nil))
|
||||
|
||||
g.rememberParent("near", "sub")
|
||||
if got := g.laneOf("near"); got != "sub" {
|
||||
t.Fatalf("laneOf near = %q, want sub", got)
|
||||
}
|
||||
|
||||
prev := "sub"
|
||||
for i := 0; i <= maxLaneWalk+5; i++ {
|
||||
id := fmt.Sprintf("st%d", i)
|
||||
g.rememberParent(id, prev)
|
||||
prev = id
|
||||
}
|
||||
if got := g.laneOf(prev); got != "" {
|
||||
t.Fatalf("laneOf over the walk bound = %q, want \"\"", got)
|
||||
}
|
||||
|
||||
g.rememberParent("cycA", "cycB")
|
||||
g.rememberParent("cycB", "cycA")
|
||||
if got := g.laneOf("cycA"); got != "" {
|
||||
t.Fatalf("laneOf cycle = %q, want \"\"", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user