feat(flow): 实时全链路流程可视化 P1——trace→graph_update→前端全链路流程页(design/19)
后端: 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:
1 parent
7a81071ec8
commit
44e7a5c2c5
18 files changed
+1126
-11
No files matched your search
@@ -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 />;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "治理" },
|
||||
|
||||
Reference in new issue
Block a user