Files
Cyrene_v2/frontend/web/src/admin/modules/Flow.tsx
T
AskaEth 2c100fe3d9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 已诚实显示空态)
2026-10-03 19:21:10 +08:00

151 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;
}