feat(gateway): 链路图冷启动快照(v0.71)+ 面板来源说明条 —— 修「轮次结束后打开页面一片空白」
- 断点已证 = **时序层**: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:
1 parent
a3482ed06e
commit
1bf85a955b
23 files changed
+1427
-36
No files matched your search
@@ -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)] ${
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user