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