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
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user