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

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