feat(flow,web): 全链路可视化 P2 — 泳道/并行分支 + 节点活动状态 + 节点实时日志
- 后端 trace/graph.go(395→470):泳道感知游标 —— parents 记住被跳过容器(state_transition)的父链、laneLast 按泳道分别的末节点游标;泳道首节点挂 subagent 根、后续步骤在本泳道内串链;**子代理根不再推进主干游标**(否则 spawn 之后的主干节点会被算成最后一条分支的步骤);node kind 抽具名常量。+ graph_lane_test.go(并行双泳道断言精确边集/嵌套泳道/主干游标不回归/laneOf 环保护)
- 前端新增 flow/{tone,lanes,layout,NodeDetail,LaneSummary}:分组=沿入边反向 BFS 取最近 kind=subagent 祖先(纯 edges,故无需新增 wire 字段);布局=主干沿用 P1 最长路径分层 + 每泳道一条独立横带(带内最长路径深度、逐带下推 y 不重叠、横向滚动);Flow.tsx/FlowGraph.tsx 加泳道框/虚线泳道边/运行闪烁(既有 mu-blink)/选中高亮/图例
- 实时日志:选中态按 node id 保存,随每帧 graph_update 重新 find → 自动显示追加后的 log[];空时区分「运行中(事件随 span 结束回填)」与「未记录事件」,不合成事件
- 未新增 wire 字段 → 无契约变化(因此未动 lib/types.ts / wsParse.ts);<FlowGraphView> 旧签名兼容,Agents/Backthink 页不受影响
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译 + npm run build(177 模块) + 逻辑测试 lanes 40 / channelStatus 45 / enums 47 / messageSync 54
- ⚠️ 已核实、未伪造的缺口:生产路径当前产不出子代理数据 —— Scheduler.Spawn 全仓仅测试调用方(0 生产)、编排包无 bus 订阅入口、无「派子代理」工具/capability → 泳道要真的亮需先接线;Span.Record 同样无生产调用方 → log[] 恒空(UI 已诚实显示空态)
This commit is contained in:
1 parent
49d12d9a64
commit
2c100fe3d9
10 files changed
+1532
-338
No files matched your search
@@ -1,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;
|
||||
}
|
||||
Reference in new issue
Block a user