feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端 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:
AskaEth committed 2026-10-03 19:21:10 +08:00
1 parent 49d12d9a64
commit 2c100fe3d9
10 files changed
+1532 -338

No files matched your search

+63 -102
View File
@@ -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;
}
+247 -214
View File
@@ -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";
}
+97 -22
View File
@@ -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 {
+155
View File
@@ -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)
}
}