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,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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user