feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 断点已证 = **时序层**:graph_update 只走实时帧从不重放、flowGraphs 不持久化、补拉通路此前根本不存在(EndRound 注释承诺过「刷新能重放」,代码里从来没有)
  铁证:15:16:38 页面重载抹掉 15:09 那轮图,而下一轮心跳要等 ~15:24 ⇒ 打开后台思考页时桶必然为空
- 新增只读端点 GET /api/v1/admin/flow/graph?sessionId=(owner|admin;省略=主会话;graph 与 graph_update 帧的 data 逐字同形)
  无图 ⇒ 200 + graph:null(不画假图);图源未接线 ⇒ 500 flow_unavailable(不伪装成「没有图」);不审计、不发布
- loop.FlowPublisher.LastGraph:只读快照(不发布/不改图/不动轮次计数),FlowGraph.Clone 深拷贝避免锁外编码撕裂
- 前端 useFlowSnapshot:只在「本会话还没有图」时补拉(避免每行一个请求);Flow/Agents/Backthink 三处接入 + 来源说明条(明确「最近一轮」与「多轮历史回放尚未实现」)
- 变异实测:LastGraph 恒 nil ⇒ 4 条测试红;白名单去掉 sess-backthink ⇒ 对应测试红(首次变异因未使用 import 编译失败 ⇒ 已作废重做)
- ⚠️ 诚实边界:每会话只回**最近一轮**(多轮回放属 F2);白名单外会话(IM 群聊/陌生人)永远没有图;**后端重启后未跑新轮次前端点如实返 null(图是内存资产,不落库)**—— 主人要的「本地数据」正是 F2 要补的
This commit is contained in:
AskaEth committed 2026-10-05 15:38:53 +08:00
1 parent a3482ed06e
commit 1bf85a955b
23 files changed
+1427 -36

No files matched your search

+40 -9
View File
@@ -1,12 +1,15 @@
import { useState } from "react";
import { useAppStore } from "../../store/useAppStore";
import type { AgentInfo, AgentStatus } from "../../lib/types";
import type { AgentInfo, AgentStatus, FlowGraph } from "../../lib/types";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { MAIN_SESSION_ID } from "../../store/sessionCache";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
@@ -68,6 +71,10 @@ function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
export function AgentsModule(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const activeSessionId = useAppStore((s) => s.activeSessionId);
// 与 Flow 页同一条补拉(契约 v0.71):本页读的也是「激活会话」的图,
// 刷新过页面后实时帧已经错过,不补拉就永远停在空态。
const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID);
const flowGraph = useAppStore((s) => s.flowGraph);
const taskLogs = useAppStore((s) => s.taskLogs);
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
@@ -105,13 +112,11 @@ export function AgentsModule(): React.ReactNode {
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
</div>
{flowGraph ? (
<Surface gap="sm">
<FlowGraphView graph={flowGraph} />
</Surface>
) : (
<Notice>暂无流程图数据 —— 连接 /ws/chat 后按 graph_update 事件实时渲染。</Notice>
)}
<AgentsFlowBlock
graph={flowGraph}
sessionId={activeSessionId || MAIN_SESSION_ID}
source={graphSource}
/>
{list.length === 0 ? (
<EmptyState icon={<Icon name="agent" size={32} />} title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
@@ -146,8 +151,34 @@ export function AgentsModule(): React.ReactNode {
);
}
function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode {
/** 流程图这一块(有图就画、没图就诚实空态)。props 驱动(不读 store),
* 因此可被真实渲染测试直接喂两种输入(见 flowSnapshot.render.test.ts 头注释)。
*
* 空态文案里必须说明**两条**来源都查过(实时帧 + 最近一轮快照,契约 v0.71):
* 否则用户分不清「本会话没跑过带 span 的轮次」与「图源没接线」。 */
export function AgentsFlowBlock({
graph,
sessionId,
source,
}: {
graph: FlowGraph | null;
sessionId: string;
source: FlowGraphSource;
}): React.ReactNode {
if (!graph) {
return (
<Notice>暂无流程图数据 —— 本会话还没有跑过带 span 的轮次(实时帧与「最近一轮快照」都为空)。发起一次会话后此处会显示链路图。</Notice>
);
}
return (
<Surface gap="sm">
<FlowGraphView graph={graph} />
<FlowSnapshotHint sessionId={graph.sessionId ?? sessionId} source={source ?? "live"} />
</Surface>
);
}
function TabPill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.ReactNode { return (
<button
onClick={onClick}
className={`rounded-[var(--mu-radius)] px-3 py-1.5 text-xs font-medium transition-all duration-200 ease-[var(--mu-spring)] ${