- 后端 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 已诚实显示空态)
151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { useState } from "react";
|
||
import { useAppStore } from "../../store/useAppStore";
|
||
import type { AgentStatus, FlowGraph } from "../../lib/types";
|
||
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
|
||
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";
|
||
|
||
/** 全链路流程页(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="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志"
|
||
/>
|
||
|
||
{graph ? (
|
||
<>
|
||
<CardGrid>
|
||
<StatCard
|
||
icon={<Icon name="chat" size={18} />}
|
||
label="会话"
|
||
value={<span className="text-base">{graph.sessionId ?? "web"}</span>}
|
||
sub={graph.id}
|
||
tone="accent"
|
||
/>
|
||
<StatCard
|
||
icon={<Icon name="agent" size={18} />}
|
||
label="节点"
|
||
value={graph.nodes.length}
|
||
sub={`${graph.edges.length} 条连线`}
|
||
tone="neutral"
|
||
/>
|
||
<StatCard
|
||
icon={<Icon name="trend" size={18} />}
|
||
label="泳道"
|
||
value={grouping?.lanes.length ?? 0}
|
||
sub="并行子代理分支"
|
||
tone="accent"
|
||
/>
|
||
<StatCard
|
||
icon={<Icon name="clock" size={18} />}
|
||
label="耗时"
|
||
value={fmtDuration(graph)}
|
||
sub={`开始 ${fmtStart(graph)}`}
|
||
tone="ok"
|
||
/>
|
||
</CardGrid>
|
||
|
||
<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}
|
||
laneLabel={selectedLane}
|
||
onClose={() => setSelectedId(null)}
|
||
/>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<Notice>
|
||
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。
|
||
</Notice>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** StatusStrip shows the live status census of the round. */
|
||
function StatusStrip({ graph }: { graph: FlowGraph }): React.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-xs text-[var(--mu-text-lo)]"
|
||
>
|
||
<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 (statusOf(node) === status) n += 1;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** fmtStart renders the round start time as a locale time, or "—" when absent. */
|
||
function fmtStart(graph: FlowGraph): string {
|
||
if (!graph.startedAt) return "—";
|
||
const t = Date.parse(graph.startedAt);
|
||
return Number.isNaN(t) ? "—" : new Date(t).toLocaleTimeString();
|
||
}
|
||
|
||
/** fmtDuration renders the round duration from startedAt to endedAt (or now). */
|
||
function fmtDuration(graph: FlowGraph): string {
|
||
if (!graph.startedAt) return "—";
|
||
const start = Date.parse(graph.startedAt);
|
||
if (Number.isNaN(start)) return "—";
|
||
const end = graph.endedAt ? Date.parse(graph.endedAt) : Date.now();
|
||
const ms = Math.max(0, (Number.isNaN(end) ? Date.now() : end) - start);
|
||
if (ms >= 60000) return `${(ms / 60000).toFixed(1)} min`;
|
||
if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s`;
|
||
return `${ms} ms`;
|
||
}
|