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)] ${
+55 -12
View File
@@ -3,12 +3,14 @@ import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import type { BackthinkItem } from "../../lib/types";
import type { BackthinkItem, FlowGraph, WsStatus } from "../../lib/types";
import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
import { adminBackthink } from "../../lib/api";
import { useAsyncData } from "../../lib/useAsyncData";
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { BackthinkUrgencyTags } from "./BackthinkUrgencyTags";
import { QuietHoursFactsCard } from "./ConfigFactsCards";
@@ -28,7 +30,13 @@ function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
return "neutral";
}
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
function BackthinkRow({
item,
graphSource,
}: {
item: BackthinkItem;
graphSource: FlowGraphSource;
}): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
// 裁决 16.4.2:这条思考的链路图取自**后台思考会话**(`sess-backthink`,design/19 §1
// 的发布会话范围)的快照,而不是管理端 Flow 页那张「当前会话」图 —— 后者属于用户端
@@ -68,23 +76,58 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
{open ? "收起链路" : "查看完整链路导图"}
</button>
{open ? (
graph ? (
<FlowGraphView graph={graph} />
) : (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
{wsStatus === "connected"
? "后台思考会话尚未产生链路图 —— 该会话还没有 graph_update 快照(本轮思考未跑 span,或后端未发布)。"
: `实时通道未连接(${wsStatus}),链路图无法更新 —— 这不等于「本轮思考没有链路」。`}
</div>
)
<BackthinkGraphBlock
graph={graph}
sessionId={BACKTHINK_SESSION_ID}
source={graphSource}
wsStatus={wsStatus}
/>
) : null}
</Surface>
);
}
/** 「查看完整链路导图」展开的那一块(有图就画、没图就诚实空态)。
* props 驱动(不读 store),因此可被真实渲染测试直接喂两种输入。
*
* 契约 v0.71 §4.11(b)-1:后端每会话只保留**最近一轮**的图 ⇒ 这一行展开的图
* 可能**不是这一行那一轮**的图;说明条必须讲明,不得让用户以为「每行有自己那轮的图」。
* 空态文案同理必须说清两条来源(实时帧 + 最近一轮快照)都为空。 */
export function BackthinkGraphBlock({
graph,
sessionId,
source,
wsStatus,
}: {
graph: FlowGraph | null;
sessionId: string;
source: FlowGraphSource;
wsStatus: WsStatus;
}): React.ReactNode {
if (!graph) {
return (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
{wsStatus === "connected"
? "后台思考会话尚未产生链路图 —— 实时帧与「最近一轮快照」(GET /api/v1/admin/flow/graph)都为空,即 sess-backthink 至今没有跑过带 span 的轮次。"
: `实时通道未连接(${wsStatus}),链路图无法更新 —— 这不等于「本轮思考没有链路」。`}
</div>
);
}
return (
<>
<FlowGraphView graph={graph} />
<FlowSnapshotHint sessionId={sessionId} source={source ?? "live"} />
</>
);
}
export function BackthinkModule(): React.ReactNode {
const backthink = useAppStore((s) => s.backthink);
const log = useAuditStore((s) => s.log);
// 契约 v0.71:`graph_update` 只走实时帧、从不重放,图桶也不持久化 ⇒ 打开本页时
// 先只读拉一次 sess-backthink 的**最近一轮**图(否则每一行都只显示「尚未产生链路图」)。
// 一次拉取在模块级做(不是每行一个 hook)—— 行数多时不会打出 N 个重复请求。
const graphSource = useFlowSnapshot(BACKTHINK_SESSION_ID);
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
const [interveneHint, setInterveneHint] = useState(false);
// Cold-load persisted history once (design/12 §4); real-time updates come from the
@@ -138,7 +181,7 @@ export function BackthinkModule(): React.ReactNode {
) : (
<div className="space-y-3">
{items.map((b) => (
<BackthinkRow key={b.id} item={b} />
<BackthinkRow key={b.id} item={b} graphSource={graphSource} />
))}
</div>
)}
+39 -1
View File
@@ -7,14 +7,48 @@ import { Icon } from "../../components/Icon";
import { FlowGraphView } from "./FlowGraph";
import { NodeDetail } from "./flow/NodeDetail";
import { LaneSummary } from "./flow/LaneSummary";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { groupFlowLanes } from "./flow/lanes";
import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone";
import { MAIN_SESSION_ID } from "../../store/sessionCache";
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 ——
* 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器),
* 节点点开看参数 / 结果 / 实时日志。 */
* 节点点开看参数 / 结果 / 实时日志。
*
* 图有**两个来源**(契约 v0.71):本次页面存活期间收到的实时帧,以及打开页面时
* 只读拉取的「最近一轮快照」(`GET /api/v1/admin/flow/graph`)。后者修的是
* 「轮次结束后才打开页面 / 刷新过页面 ⇒ 图桶是空的」那个洞 —— 实时帧从不重放。
* 说明条(FlowSnapshotHint)把当前这一张的来源讲明,不让用户误以为它是实时图。
*
* 本组件只做 store 接线;有图 / 无图两支的呈现交给 `FlowBody`(props 驱动),
* 使那条判定能被真实渲染测试钉住(见 flowSnapshot.render.test.ts 头注释)。 */
export function FlowModule(): React.ReactNode {
// 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。
const activeSessionId = useAppStore((s) => s.activeSessionId);
const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID);
const graph = useAppStore((s) => s.flowGraph);
return (
<FlowBody
graph={graph}
sessionId={activeSessionId || MAIN_SESSION_ID}
source={graphSource}
/>
);
}
/** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被
* 真实渲染测试直接喂两种输入。 */
export function FlowBody({
graph,
sessionId,
source,
}: {
graph: FlowGraph | null;
sessionId: string;
source: FlowGraphSource;
}): React.ReactNode {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null;
const grouping = graph ? groupFlowLanes(graph) : null;
@@ -68,6 +102,10 @@ export function FlowModule(): React.ReactNode {
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
<FlowSnapshotHint
sessionId={graph.sessionId ?? sessionId}
source={source ?? "live"}
/>
</Surface>
{selected ? (
@@ -0,0 +1,49 @@
import { BACKTHINK_SESSION_ID } from "../../../store/sessionCache";
/** 会话 id → 人话标签(图上「这是哪个会话的图」必须看得懂)。未知会话
* 原样显示 id,绝不猜一个更好听的名字。 */
function sessionLabel(sessionId: string): string {
if (sessionId === BACKTHINK_SESSION_ID) return "后台思考会话";
if (sessionId === "web") return "Web 主会话";
return sessionId;
}
/** 链路图快照的诚实说明条(契约 v0.71 §4.11(b)-1)。
*
* 为什么必须有它:后端只保留**每个会话最近一轮**的图,而补拉通路的用途正是
* 「轮次结束后才打开页面」。于是图上画的到底是哪一种来源必须讲清:
* - `snapshot` = 打开页面时**只读拉取**的那一份最近一轮(不是实时帧,也**不一定
* 是你眼前这一行那一轮** —— 后台思考行尤其如此);
* - `live` = 这次页面存活期间**实时收到**的图(正在跑 / 刚跑完的那一轮)。
*
* 不写这一条就等于让用户以为「这行思考有自己的图」,那是把推导当事实:
* 多轮历史回放仍未实现(`design/19 §4` P3)。 */
export function FlowSnapshotHint({
sessionId,
source,
}: {
sessionId: string;
source: "live" | "snapshot";
}): React.ReactNode {
const label = sessionLabel(sessionId);
return (
<div className="rounded-[var(--mu-radius)] border border-dashed border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
{source === "snapshot" ? (
<>
以上是 <span className="text-[var(--mu-text-mid)]">{label}</span>(
<code>{sessionId}</code>)的
<span className="text-[var(--mu-text-mid)]">最近一轮</span>
链路图快照 —— 打开本页时只读拉取(<code>GET /api/v1/admin/flow/graph</code>)。
它不一定是你要找的那一轮:后端每会话只保留最近一份图,多轮历史回放尚未实现(design/19 §4 P3)。
</>
) : (
<>
以上是 <span className="text-[var(--mu-text-mid)]">{label}</span>(
<code>{sessionId}</code>)在本次页面存活期间
<span className="text-[var(--mu-text-mid)]">实时收到</span>
的链路图(正在跑或最近跑完的那一轮)。刷新页面后它由「最近一轮快照」补上。
</>
)}
</div>
);
}
@@ -0,0 +1,161 @@
// 「最近一轮链路图快照」的**真实渲染**测试(契约 v0.71 §4.11)。
//
// 为什么需要它:主人第二次报的两条都是**看得见**的症状 ——
// ① `/#/admin/flow` 停在「暂无流程图数据」;② 后台思考行说「该会话还没有 graph_update
// 快照」。逻辑测试(useFlowSnapshot.test.ts)钉住「图真的进了桶」,这里钉住
// 「桶里有图 ⇒ 界面真的把它画出来(而不是照样空态)」,以及**来源必须如实标注** ——
// 后端只保留每会话最近一轮,把快照画成「这一行的图」就是把推导当事实。
//
// 渲染的对象是三个 **props 驱动**的呈现组件(FlowBody / AgentsFlowBlock /
// BackthinkGraphBlock):本项目没有 jsdom,且 zustand v5 在 SSR 下用
// `getInitialState()` 作 server snapshot(`node_modules/zustand/esm/react.mjs`),
// store 驱动的组件在测试里只能看到初始 state ⇒ 真实渲染测试必须喂 props。
//
// 跑法(同 BackthinkUrgencyTags.render.test.ts):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSnapshot.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-flow-snapshot-model.test.cjs \
// && node /tmp/cyrene-flow-snapshot-model.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { FlowGraph } from "../../../lib/types";
import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
import { AgentsFlowBlock } from "../Agents";
import { BackthinkGraphBlock } from "../Backthink";
import { FlowBody } from "../Flow";
import { FlowSnapshotHint } from "./FlowSnapshotHint";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** 一轮「输入 → 思考 → 输出」的最小图;节点的 label 是断言用的哨兵。 */
const roundGraph: FlowGraph = {
id: "web-11",
sessionId: MAIN_SESSION_ID,
startedAt: "2026-10-05T15:17:08+08:00",
endedAt: "2026-10-05T15:17:10+08:00",
nodes: [
{ id: "input", label: "哨兵输入", kind: "input", status: "done" },
{ id: "llm-1", label: "哨兵思考", kind: "llm", status: "done" },
{ id: "output", label: "哨兵回复", kind: "output", status: "done" },
],
edges: [
{ from: "input", to: "llm-1" },
{ from: "llm-1", to: "output" },
],
};
// ── ① 空态:确实没有图时是诚实的空态文案(且不画任何节点) ────────────────
const emptyFlow = renderToStaticMarkup(
createElement(FlowBody, { graph: null, sessionId: MAIN_SESSION_ID, source: null })
);
ok(has(emptyFlow, "暂无流程"), "没有图时 Flow 页显示空态");
ok(!has(emptyFlow, "哨兵"), "空态绝不能画出任何节点(不画假图)");
const emptyAgents = renderToStaticMarkup(
createElement(AgentsFlowBlock, { graph: null, sessionId: MAIN_SESSION_ID, source: null })
);
ok(has(emptyAgents, "暂无流程图数据"), "没有图时 Agents 页显示空态");
ok(
has(emptyAgents, "最近一轮快照"),
"Agents 页的空态必须说明它也查过「最近一轮快照」(否则用户分不清是没跑过还是没接线)"
);
const emptyBackthink = renderToStaticMarkup(
createElement(BackthinkGraphBlock, {
graph: null,
sessionId: BACKTHINK_SESSION_ID,
source: null,
wsStatus: "connected",
})
);
ok(
has(emptyBackthink, "最近一轮快照"),
"后台思考行的空态必须说明它也查过「最近一轮快照」(主人报的就是这句)"
);
ok(
has(emptyBackthink, "/api/v1/admin/flow/graph"),
"后台思考行的空态必须说清查的是哪个端点,便于对照"
);
// ── ② 有快照 ⇒ 三处都真的画出该轮的图(不再是空态) ──────────────────────
const withGraph = renderToStaticMarkup(
createElement(FlowBody, {
graph: roundGraph,
sessionId: MAIN_SESSION_ID,
source: "snapshot",
})
);
ok(!has(withGraph, "暂无流程"), "桶里有图时 Flow 页不该再显示空态");
ok(has(withGraph, "哨兵输入"), "Flow 页必须画出 input 节点(主人报的就是这里空着)");
ok(has(withGraph, "哨兵思考"), "Flow 页必须画出 llm 节点");
ok(has(withGraph, "哨兵回复"), "Flow 页必须画出 output 节点");
const agentsWithGraph = renderToStaticMarkup(
createElement(AgentsFlowBlock, {
graph: roundGraph,
sessionId: MAIN_SESSION_ID,
source: "snapshot",
})
);
ok(!has(agentsWithGraph, "暂无流程图数据"), "桶里有图时 Agents 页不该再显示空态");
ok(has(agentsWithGraph, "哨兵输入"), "Agents 页必须画出该轮的图");
const btWithGraph = renderToStaticMarkup(
createElement(BackthinkGraphBlock, {
graph: roundGraph,
sessionId: BACKTHINK_SESSION_ID,
source: "snapshot",
wsStatus: "connected",
})
);
ok(!has(btWithGraph, "尚未产生链路图"), "桶里有图时后台思考行不该再提示没有快照");
ok(has(btWithGraph, "哨兵输入"), "后台思考行展开后必须画出该轮的图");
// ── ③ 来源必须如实标注:快照 ≠ 实时 ──────────────────────────────────────
const snapshotHint = renderToStaticMarkup(
createElement(FlowSnapshotHint, { sessionId: MAIN_SESSION_ID, source: "snapshot" })
);
ok(
has(snapshotHint, "最近一轮"),
"快照来源必须写明「最近一轮」(后端每会话只留一份,它不一定是你眼前那行那轮)"
);
ok(
has(snapshotHint, "/api/v1/admin/flow/graph"),
"快照来源必须写明它是只读拉取(GET /api/v1/admin/flow/graph)"
);
ok(
has(snapshotHint, "多轮历史回放尚未实现"),
"快照来源必须诚实标注「多轮历史回放尚未实现」,不得让用户以为每行都有自己的图"
);
const liveHint = renderToStaticMarkup(
createElement(FlowSnapshotHint, { sessionId: MAIN_SESSION_ID, source: "live" })
);
ok(has(liveHint, "实时"), "实时来源必须标注为实时收到");
ok(
!has(liveHint, "多轮历史回放尚未实现"),
"实时图不是快照,不该出现快照专属的免责说明"
);
// 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。
const btHint = renderToStaticMarkup(
createElement(FlowSnapshotHint, { sessionId: BACKTHINK_SESSION_ID, source: "snapshot" })
);
ok(has(btHint, "后台思考会话"), "sess-backthink 必须显示为「后台思考会话」而不是裸 id");
ok(has(btHint, BACKTHINK_SESSION_ID), "会话 id 也必须如实露出,便于对照");
console.log(`flow snapshot render test: ${checks} checks passed`);
@@ -0,0 +1,189 @@
// 「最近一轮链路图快照」逻辑测试(契约 v0.71 §4.11)。
//
// 为什么需要它:主人第二次报 `/#/admin/flow` 停在「暂无流程图数据」、后台思考行说
// 「该会话还没有 graph_update 快照」。根因是**图只走实时帧**(从不重放)+ 前端图桶
// 不持久化 ⇒ 轮次结束后打开的页面永远空着。后端补了只读快照端点,前端必须真的**拉**
// 并**写进图桶**,否则洞还在。
//
// 本项目没有前端测试运行器、也没有 jsdom,因此这是无依赖的纯 node 检查:它驱动
// **真的** api 客户端(`lib/api` 的 request → fetch)、**真的** store 写路径
// (`setFlowGraph` → `putGraph`),只把 hook 的两条判定(要不要拉 / 这张图是哪来的)
// 当纯函数直接钉住 —— effect 本身在本项目无法执行。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/useFlowSnapshot.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-flow-snapshot.test.mjs \
// && node /tmp/cyrene-flow-snapshot.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*(用 globalThis 取 fetch)。
import { adminFlowGraph } from "../../../lib/api";
import { useAppStore } from "../../../store/useAppStore";
import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
import type { FlowGraph } from "../../../lib/types";
import {
flowGraphSourceOf,
needsFlowSnapshot,
} from "./useFlowSnapshot";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`);
}
const roundGraph = (id: string, sessionId: string): FlowGraph => ({
id,
sessionId,
nodes: [
{ id: "input", label: "你好", kind: "input", status: "done" },
{ id: "llm-1", label: "思考", kind: "llm", status: "done" },
],
edges: [{ from: "input", to: "llm-1" }],
});
/** 记下每个请求的 URL 并回一份假的快照响应。 */
interface FetchCall {
url: string;
method: string;
}
const calls: FetchCall[] = [];
const originalFetch = globalThis.fetch;
const originalLocalStorage = globalThis.localStorage;
/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它,
* 因此给一个只读空壳(与 messageSync.test.ts 同一手法)。 */
interface StorageLike {
getItem: () => string | null;
setItem: () => void;
removeItem: () => void;
}
globalThis.localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
} as unknown as Storage;
function stubFetch(body: unknown, status = 200): void {
globalThis.fetch = ((url: string, init?: { method?: string }) => {
calls.push({ url: String(url), method: init?.method ?? "GET" });
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
headers: { get: () => "application/json" },
json: () => Promise.resolve(body),
});
}) as unknown as typeof globalThis.fetch;
}
// ── ① 「轮次结束后才打开页面」的前端半边(主人的原话场景) ────────────────
// 图桶是空的(刷新/晚开)⇒ 必须补拉;拉回来的图必须真的进桶,且
// Flow / Agents 页读的兼容字段 `flowGraph` 立刻可用(不再是空态)。
useAppStore.getState().resetChat();
useAppStore.setState({ activeSessionId: MAIN_SESSION_ID });
same(
needsFlowSnapshot(MAIN_SESSION_ID, useAppStore.getState().flowGraphs[MAIN_SESSION_ID]),
true,
"图桶为空时必须补拉(这正是「轮次结束后才打开页面」的形态)"
);
const snapshot = roundGraph("web-7", MAIN_SESSION_ID);
stubFetch({ data: { sessionId: MAIN_SESSION_ID, graph: snapshot } });
const resp = await adminFlowGraph(MAIN_SESSION_ID);
same(calls.length, 1, "一次补拉恰好一个请求");
ok(
calls[0].url.includes("/admin/flow/graph") && calls[0].url.includes("sessionId=web"),
`请求 URL 必须带上会话 id:${calls[0].url}`
);
same(calls[0].method, "GET", "补拉是只读 GET");
ok(resp.graph !== null, "后端回了图 ⇒ 解析后 graph 非空");
// hook 里写的这一步(`setFlowGraph(resp.graph, sessionId)`)在这里原样执行:
useAppStore.getState().setFlowGraph(resp.graph!, MAIN_SESSION_ID);
same(
useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id,
"web-7",
"快照按会话写进图桶"
);
same(
useAppStore.getState().flowGraph?.id,
"web-7",
"激活会话的兼容字段 flowGraph 立刻可用 ⇒ Flow / Agents 页不再显示空态"
);
same(
flowGraphSourceOf(useAppStore.getState().flowGraphs[MAIN_SESSION_ID], resp.graph),
"snapshot",
"这张图是补拉来的 ⇒ 来源必须标 snapshot(面板据实说明「最近一轮」)"
);
same(
needsFlowSnapshot(MAIN_SESSION_ID, useAppStore.getState().flowGraphs[MAIN_SESSION_ID]),
false,
"已经有图了 ⇒ 不再补拉(绝不覆盖实时图 / 不重复请求)"
);
// ── ② 实时帧到达后来源翻成 live ──────────────────────────────────────────
useAppStore.getState().setFlowGraph(roundGraph("web-8", MAIN_SESSION_ID), MAIN_SESSION_ID);
same(
flowGraphSourceOf(useAppStore.getState().flowGraphs[MAIN_SESSION_ID], resp.graph),
"live",
"实时帧换掉了快照对象 ⇒ 来源必须是 live(zutand 每次 set 都换引用)"
);
// ── ③ 后台思考会话:按它自己的会话补拉、落进它自己的桶 ────────────────────
stubFetch({
data: { sessionId: BACKTHINK_SESSION_ID, graph: roundGraph("sess-backthink-3", BACKTHINK_SESSION_ID) },
});
const bt = await adminFlowGraph(BACKTHINK_SESSION_ID);
ok(bt.graph !== null, "后台思考会话的快照非空");
useAppStore.getState().setFlowGraph(bt.graph!, BACKTHINK_SESSION_ID);
same(
useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id,
"sess-backthink-3",
"后台思考的图落进它自己的桶(裁决 16.4.2:不顶掉当前会话的图)"
);
same(
useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id,
"web-8",
"补拉后台思考的图绝不覆盖主会话的图"
);
// ── ④ 后端如实回 null:不造图、不写桶、空态保持诚实 ─────────────────────
useAppStore.getState().resetChat();
same(
needsFlowSnapshot(BACKTHINK_SESSION_ID, useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]),
true,
"清空后仍需要补拉"
);
stubFetch({ data: { sessionId: BACKTHINK_SESSION_ID, graph: null } });
const empty = await adminFlowGraph(BACKTHINK_SESSION_ID);
same(empty.graph, null, "后端回 null ⇒ 客户端拿到的就是 null(不编图)");
// hook 的写法(`if (!cancelled && resp.graph)`)保证 null 不写桶:
if (empty.graph) useAppStore.getState().setFlowGraph(empty.graph, BACKTHINK_SESSION_ID);
same(
useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID],
undefined,
"graph:null 绝不写进图桶(否则面板就会画出一张假图)"
);
same(
flowGraphSourceOf(useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID], null),
null,
"没有图 ⇒ 来源是 null(空态)"
);
// ── ⑤ 会话 id 为空时是空操作 ────────────────────────────────────────────
same(needsFlowSnapshot("", undefined), false, "没有会话 id 时不补拉");
globalThis.fetch = originalFetch;
globalThis.localStorage = originalLocalStorage;
console.log(`flow snapshot logic test: ${checks} checks passed`);
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { useAppStore } from "../../../store/useAppStore";
import { adminFlowGraph } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
/** 当前屏幕上的那张图是哪来的(契约 v0.71 §4.11(b)-1)。
* - `live` = 本次页面存活期间**实时收到**的 `graph_update` 帧;
* - `snapshot` = 打开页面时**只读拉取**的「最近一轮」快照;
* - `null` = 该会话还没有任何图(诚实空态)。 */
export type FlowGraphSource = "live" | "snapshot" | null;
/** 要不要为这个会话补拉「最近一轮快照」——**只有本会话还没有任何图时**才拉。
*
* 已有图(实时帧先到了)时**绝不**补拉:实时图更新、且是「正在跑的那一轮」的
* 唯一来源;用快照覆盖它就是把旧一轮顶掉正在跑的一轮。抽成纯函数是为了让这条
* 判定能被逻辑测试直接钉住(组件/hook 的 effect 在本项目无 jsdom,跑不了)。 */
export function needsFlowSnapshot(sessionId: string, graph: FlowGraph | null | undefined): boolean {
return Boolean(sessionId) && !graph;
}
/** 把一个会话「最近一轮」的链路图快照补进 store,并返回当前屏幕上这张图的来源。
*
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,而 store 的图桶
* (`flowGraphs`)**不在 persist 的 partialize 里** ⇒ 轮次结束后才打开的页面、
* 或刷新过的页面,桶里是空的,管理端 Flow / Agents 页与后台思考行因此永远显示
* 空态(主人第二次报的就是这个)。本 hook 在页面挂载时**只读拉一次**最近一轮。
*
* 四条刻意的口径:
* 1. **只在「本会话还没有任何图」时拉**(`graph` 为空)。已有实时图时**绝不**
* 用快照覆盖它 —— 实时帧更新、而且是「正在跑的那一轮」的唯一来源;补拉只补
* 冷启动的洞。
* 2. **拉回 `null` 不重试、不造图**:后端保证 `graph` 键恒在(没有图时是 null)
* ⇒ 只有真拿到图才写桶;空态因此是稳定的诚实空态。
* 3. **失败静默**:网络/401/`500 flow_unavailable` 一律不动界面。
* 4. **来源可分辨**:本 hook 记住**自己写进去的那个对象引用**,屏幕上还是同一个
* 引用 ⇒ 它是快照;被后来的实时帧换掉 ⇒ 它是实时图(zustand 每次 set 都换对象)。
* 没有这个区分,面板就只能假装快照是实时的 —— 而两者是不同的事实。
*
* `sessionId` 为空时是空操作(调用方还没解析出会话)。 */
export function useFlowSnapshot(sessionId: string): FlowGraphSource {
const setFlowGraph = useAppStore((s) => s.setFlowGraph);
const graph = useAppStore((s) => (sessionId ? s.flowGraphs[sessionId] : undefined));
const [pulled, setPulled] = useState<FlowGraph | null>(null);
useEffect(() => {
if (!needsFlowSnapshot(sessionId, graph)) return;
let cancelled = false;
adminFlowGraph(sessionId)
.then((resp) => {
if (cancelled || !resp.graph) return;
setFlowGraph(resp.graph, sessionId);
setPulled(resp.graph);
})
.catch(() => {
/* 只读补拉失败不改变界面:空态/实时图照旧 */
});
return () => {
cancelled = true;
};
}, [sessionId, graph, setFlowGraph]);
if (!graph) return null;
return flowGraphSourceOf(graph, pulled);
}
/** 屏幕上这张图的来源(纯函数,供逻辑测试直接钉住「有快照 ≠ 空态」)。
* `pulled` 是**本 hook 自己写进去的那个对象引用**:屏幕上还是同一个引用 ⇒
* 它来自快照;被后来的实时帧换掉(zustand 每次 set 都换对象)⇒ 它是实时图。 */
export function flowGraphSourceOf(
graph: FlowGraph | null | undefined,
pulled: FlowGraph | null
): FlowGraphSource {
if (!graph) return null;
return pulled === graph ? "snapshot" : "live";
}