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

+2
View File
@@ -16,6 +16,7 @@ import { RagModule } from "./modules/Rag";
import { LlmModule } from "./modules/Llm";
import { AutomationsModule } from "./modules/Automations";
import { AgentsModule } from "./modules/Agents";
import { FlowModule } from "./modules/Flow";
import { BackthinkModule } from "./modules/Backthink";
import { ObservabilityModule } from "./modules/Observability";
import { UsersModule } from "./modules/Users";
@@ -34,6 +35,7 @@ function renderSection(section: AdminSectionId): React.ReactNode {
case "llm": return <LlmModule />;
case "automations": return <AutomationsModule />;
case "agents": return <AgentsModule />;
case "flow": return <FlowModule />;
case "backthink": return <BackthinkModule />;
case "observability": return <ObservabilityModule />;
case "users": return <UsersModule />;
+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`;
}
+12 -2
View File
@@ -144,7 +144,13 @@ function clampLabel(label: string): string {
return label.length > 20 ? `${label.slice(0, 19)}…` : label;
}
export function FlowGraphView({ graph }: { graph: FlowGraph }): ReactNode {
export function FlowGraphView({
graph,
onNodeClick,
}: {
graph: FlowGraph;
onNodeClick?: (node: FlowNode) => void;
}): ReactNode {
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
const { placed, edges, width, height } = layout(graph);
@@ -186,7 +192,11 @@ export function FlowGraphView({ graph }: { graph: FlowGraph }): ReactNode {
);
})}
{placed.map((p) => (
<g key={p.node.id}>
<g
key={p.node.id}
onClick={onNodeClick ? () => onNodeClick(p.node) : undefined}
className={onNodeClick ? "cursor-pointer" : undefined}
>
<rect
x={p.x}
y={p.y}
+2
View File
@@ -18,6 +18,7 @@ export type AdminSectionId =
| "llm"
| "automations"
| "agents"
| "flow"
| "backthink"
| "observability"
| "system"
@@ -40,6 +41,7 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"llm",
"automations",
"agents",
"flow",
"backthink",
"observability",
"system",
+2 -1
View File
@@ -10,7 +10,7 @@ export interface AdminSectionMeta {
}
/**
* The 15-module registry (design/10 §3). Order drives the sidebar; group drives
* The 16-module registry (design/10 §3). Order drives the sidebar; group drives
* the section headers. All are clickable — several are shells until M10+ backend.
*/
export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
@@ -25,6 +25,7 @@ export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
{ id: "llm", label: "LLM 模型", icon: "robot", group: "能力与连接" },
{ id: "automations", label: "自动化与提醒", icon: "clock", group: "能力与连接" },
{ id: "agents", label: "Agent 监控", icon: "agent", group: "运行时" },
{ id: "flow", label: "全链路流程", icon: "chart", group: "运行时" },
{ id: "backthink", label: "后台思考", icon: "night", group: "运行时" },
{ id: "observability", label: "可观测性", icon: "trend", group: "运行时" },
{ id: "users", label: "用户与权限", icon: "users", group: "治理" },