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";
}