feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端: trace/graph.go(RunGraph 图构建器,kind/status 映射,父子优先连线,脱敏白名单+截断);
       loop/flow.go(FlowPublisher 按会话增量发布 ws.graph_update,仅主人web会话); loop span
       start/finish 折入图; run.go StartRound/EndRound; wire.go 组装+注入.
前端: Flow.tsx(全链路流程页:FlowGraphView+会话/节点/开始/耗时卡片+状态计数+节点点开日志);
       FlowGraph 组件加 onNodeClick; types/wsParse 扩展; router/sections/AdminApp 注册 flow;
       api-client.md §3.5 graph_update 契约.
验证: 后端 build/vet/test 全绿+flow_test.go 集成(span→graph→ws.graph_update主干); 前端 build 绿.
红线: 单二进制零新依赖; 工具参数/结果脱敏; 仅主人web会话; 身份靠actor元数据; 只读不虚构.
P2(并行子代理分支/泳道/节点实时日志)后置.
This commit is contained in:
Aska committed 2026-09-07 19:10:57 +08:00
1 parent 7a81071ec8
commit 44e7a5c2c5
18 files changed
+1126 -11

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import type { AgentStatus, FlowGraph, FlowNode } from "../../lib/types";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Surface, Tag, type TagTone } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
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) → 工具 → 输出,节点可点开看实时日志/参数/结果。 */
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;
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="clock" size={18} />}
label="开始"
value={fmtStart(graph)}
sub="本轮开始时间"
tone="neutral"
/>
<StatCard
icon={<Icon name="trend" size={18} />}
label="耗时"
value={fmtDuration(graph)}
sub="本轮时长"
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>
{selected ? (
<NodeDetail node={selected} onClose={() => setSelectedId(null)} />
) : null}
</>
) : (
<Notice>
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;若某环节无对应 span,则诚实占位。
</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;
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)]"
>
<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>
);
}
/** 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;
}
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`;
}