feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」:
- 落盘:迁移 0022_flow_graphs(session_id/turn_id/graph_json/时间戳 + 唯一键 upsert)+ internal/runtime/flowgraph(RecordRound/Graph/Latest/Turns/Turn + 保留期 GC)
- 写入:loop.EndRound **每轮一条**(锁外深拷贝后写;失败 WARN 且不打印图内容);只落发布会话白名单(复用 flowSessionAllowed)
- 读回:端点加 turnId + source(live/persisted/none) + turns[](最新在前,空为 []);**内存优先、miss 才落盘**(最新一轮永远最新)
- 前端:flowGraphs 纳入 persistOptions.partialize(**刷新即刻有图**,version 3→4 + migrate)+ 历史轮选择器 + 诚实文案;顺带收 L1 转交的 flow/NodeDetail.tsx 节点日志倒序
- 新 env 键:FLOW_GRAPH_RETENTION=168h / FLOW_GRAPH_GC_INTERVAL=1h
- 变异实测:每 span 一行 / 保留期守卫失效 / partialize 去掉 flowGraphs / 去掉内存优先 / 白名单失效 ⇒ 全部打红
- 3 个禁动面文件的纯追加改动(Flow.tsx 挂选择器、FlowSnapshotHint 纠已成假话的文案、其渲染断言随之更新)由主会话裁定认可
- 登记存疑:图桶持久化无体积上限;useFlowTurns 的 effect 无自动化证据(项目无 jsdom)
This commit is contained in:
AskaEth committed 2026-10-05 16:19:32 +08:00
1 parent 84c124f5a9
commit a6cb44b8a8
38 files changed
+3047 -91

No files matched your search

+74 -20
View File
@@ -9,9 +9,13 @@ import { NodeDetail } from "./flow/NodeDetail";
import { LaneSummary } from "./flow/LaneSummary";
import { FlowSnapshotHint } from "./flow/FlowSnapshotHint";
import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot";
import { useFlowTurns } from "./flow/useFlowTurns";
import { FlowHistoryHint, FlowTurnPicker } from "./flow/FlowTurnPicker";
import { LATEST_TURN, resolveShownGraph } from "./flow/flowHistory";
import { groupFlowLanes } from "./flow/lanes";
import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone";
import { MAIN_SESSION_ID } from "../../store/sessionCache";
import type { AdminFlowTurn } from "../../lib/api";
/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 ——
* 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器),
@@ -27,55 +31,99 @@ import { MAIN_SESSION_ID } from "../../store/sessionCache";
export function FlowModule(): React.ReactNode {
// 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。
const activeSessionId = useAppStore((s) => s.activeSessionId);
const graphSource = useFlowSnapshot(activeSessionId || MAIN_SESSION_ID);
const sessionId = activeSessionId || MAIN_SESSION_ID;
// 最新一轮:F1 的实时图 + 只读快照补拉(补拉会写进图桶 ⇒ 配合图桶持久化「刷新即刻有图」)。
const graphSource = useFlowSnapshot(sessionId);
const graph = useAppStore((s) => s.flowGraph);
// 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**,
// 因此翻历史绝不顶掉当前会话的实时图(裁决 16.4.2)。
const history = useFlowTurns(sessionId);
return (
<FlowBody
graph={graph}
sessionId={activeSessionId || MAIN_SESSION_ID}
sessionId={sessionId}
source={graphSource}
turns={history.turns}
selectedTurnId={history.selectedTurnId}
onSelectTurn={history.selectTurn}
historyGraph={history.historyGraph}
historyLoading={history.historyLoading}
/>
);
}
/** 有图就画整页、没图就诚实空态。props 驱动(不读 store),因此可被
* 真实渲染测试直接喂两种输入。 */
* 真实渲染测试直接喂两种输入。
*
* **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画
* 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。
* 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。 */
export function FlowBody({
graph,
sessionId,
source,
turns = [],
selectedTurnId = LATEST_TURN,
onSelectTurn,
historyGraph = null,
historyLoading = false,
}: {
graph: FlowGraph | null;
sessionId: string;
source: FlowGraphSource;
/** 该会话已落盘的轮次(最新在前)。 */
turns?: readonly AdminFlowTurn[];
/** 当前选择(`LATEST_TURN` = 最新一轮)。 */
selectedTurnId?: string;
/** 选中某一轮的回调;不给则不渲染历史轮选择器(渲染测试因此不必造它)。 */
onSelectTurn?: (turnId: string) => void;
/** 选中历史轮时拉回的图(只读)。 */
historyGraph?: FlowGraph | null;
/** 正在拉取选中的历史轮。 */
historyLoading?: boolean;
}): React.ReactNode {
// 屏幕上该画哪张图(纯函数,见 flowHistory.ts):选历史轮画落盘那张,否则画实时/快照那张。
const shown = resolveShownGraph(selectedTurnId, graph, historyGraph);
const showingHistory = selectedTurnId !== LATEST_TURN;
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null;
const grouping = graph ? groupFlowLanes(graph) : null;
const selected = shown?.nodes.find((n) => n.id === selectedId) ?? null;
const grouping = shown ? groupFlowLanes(shown) : null;
const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined;
return (
<div className="space-y-5">
<PageHeader
title="全链路流程"
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志"
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史"
/>
{graph ? (
{onSelectTurn ? (
<Surface className="space-y-2">
<FlowTurnPicker
turns={turns}
selectedTurnId={selectedTurnId}
onSelect={onSelectTurn}
loading={historyLoading}
/>
{showingHistory && shown ? <FlowHistoryHint turnId={shown.id} /> : null}
</Surface>
) : null}
{shown ? (
<>
<CardGrid>
<StatCard
icon={<Icon name="chat" size={18} />}
label="会话"
value={<span className="text-base">{graph.sessionId ?? "web"}</span>}
sub={graph.id}
value={<span className="text-base">{shown.sessionId ?? "web"}</span>}
sub={shown.id}
tone="accent"
/>
<StatCard
icon={<Icon name="agent" size={18} />}
label="节点"
value={graph.nodes.length}
sub={`${graph.edges.length} 条连线`}
value={shown.nodes.length}
sub={`${shown.edges.length} 条连线`}
tone="neutral"
/>
<StatCard
@@ -88,24 +136,26 @@ export function FlowBody({
<StatCard
icon={<Icon name="clock" size={18} />}
label="耗时"
value={fmtDuration(graph)}
sub={`开始 ${fmtStart(graph)}`}
value={fmtDuration(shown)}
sub={`开始 ${fmtStart(shown)}`}
tone="ok"
/>
</CardGrid>
<Surface className="space-y-3">
<StatusStrip graph={graph} />
<StatusStrip graph={shown} />
<FlowGraphView
graph={graph}
graph={shown}
selectedId={selectedId}
onNodeClick={(n) => setSelectedId(n.id)}
/>
<LaneSummary lanes={grouping?.lanes ?? []} onSelect={setSelectedId} />
<FlowSnapshotHint
sessionId={graph.sessionId ?? sessionId}
source={source ?? "live"}
/>
{showingHistory ? null : (
<FlowSnapshotHint
sessionId={shown.sessionId ?? sessionId}
source={source ?? "live"}
/>
)}
</Surface>
{selected ? (
@@ -118,7 +168,11 @@ export function FlowBody({
</>
) : (
<Notice>
暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。
{showingHistory
? historyLoading
? `正在读取历史轮 ${selectedTurnId} 的链路图……`
: `历史轮 ${selectedTurnId} 没有图 —— 可能已过保留期,或该会话本就不在发布会话白名单内。`
: "暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。"}
</Notice>
)}
</div>
@@ -34,7 +34,8 @@ export function FlowSnapshotHint({
<code>{sessionId}</code>)的
<span className="text-[var(--mu-text-mid)]">最近一轮</span>
链路图快照 —— 打开本页时只读拉取(<code>GET /api/v1/admin/flow/graph</code>)。
它不一定是你要找的那一轮:后端每会话只保留最近一份图,多轮历史回放尚未实现(design/19 §4 P3)。
它不一定是你要找的那一轮 —— 这是**最新**的一份。要精确看某一轮,用上方的
「历史轮次」选择器(那些轮已落盘,刷新/重启后仍在;契约 v0.73)。
</>
) : (
<>
@@ -0,0 +1,66 @@
import type { AdminFlowTurn } from "../../../lib/api";
import { LATEST_TURN, turnOptionLabel } from "./flowHistory";
/**
* 历史轮次选择器(切片 F2;design/19 §4 P3;契约 v0.73)。props 驱动(不读 store),
* 因此可被真实渲染测试直接喂输入。
*
* 它把「图有本地数据」变成可点的东西:省略 turnId = 最新一轮(后端先内存、miss 才落盘),
* 选某一轮 = 只读拉该轮已落盘的图。没有已落盘轮次时的说明必须**诚实**:白名单外的会话
* (群聊 / 陌生人)永远没有图,这不是缺陷。
*/
export function FlowTurnPicker({
turns,
selectedTurnId,
onSelect,
loading,
}: {
turns: readonly AdminFlowTurn[];
selectedTurnId: string;
onSelect: (turnId: string) => void;
loading: boolean;
}): React.ReactNode {
return (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<label htmlFor="flow-turn-select" className="font-medium">
历史轮次
</label>
<select
id="flow-turn-select"
value={selectedTurnId}
onChange={(e) => onSelect(e.target.value)}
className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs text-[var(--mu-text-mid)]"
>
<option value={LATEST_TURN}>最新一轮(实时 / 快照)</option>
{turns.map((t) => (
<option key={t.turnId} value={t.turnId}>
{turnOptionLabel(t)}
</option>
))}
</select>
<span className="font-mono">共 {turns.length} 轮已落盘</span>
{loading ? <span className="mu-blink text-[var(--mu-accent)]">加载中…</span> : null}
</div>
{turns.length === 0 ? (
<p className="text-[11px] text-[var(--mu-text-lo)]">
还没有已落盘的轮次 —— 每轮收尾时自动落盘,保留期内可回看。
发布会话白名单之外的会话(群聊 / 陌生人)永远没有图,这不是缺陷。
</p>
) : null}
</div>
);
}
/**
* 选中历史轮时图上的**诚实说明条**:这一张是从**本地落盘**读回的那一轮,不是实时图。
* 与 `FlowSnapshotHint`(讲最新一轮的来源)互补 —— 两者不会同时出现。
*/
export function FlowHistoryHint({ turnId }: { turnId: string }): React.ReactNode {
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)]">
以上是 <code>{turnId}</code> 的链路图 —— 从<b>本地落盘</b>读回的历史轮(只读,契约 v0.73)。
它不是实时图;刷新或重启后它仍在(保留期内)。要回到正在跑 / 刚跑完的那一轮,把上方选回「最新一轮」。
</div>
);
}
@@ -2,7 +2,9 @@ import type { FlowNode } from "../../../lib/types";
import { Surface, Tag } from "../../../components/primitives";
import { Icon } from "../../../components/Icon";
import { Notice } from "../../components";
import { useTopAnchoredScroll } from "../../logScroll";
import { FLOW_TONE, statusOf } from "./tone";
import { headLogKey, newestFirstLog } from "./nodeLogOrder";
/** NodeDetail shows one selected node: its status, its lane (when it belongs to
* a sub-agent branch), the tool argument summary (design/19 §1.1), the
@@ -109,9 +111,10 @@ function toRecord(value: unknown): Record<string, unknown> {
return value as Record<string, unknown>;
}
/** LogList renders the node's accumulated span events, newest last, and is
* explicit about why an empty list is empty instead of pretending to have
* content. */
/** LogList 渲染该节点累积的 span 事件,**最新在上**(L1「日志倒序统一」;切片 F2 顺带
* 收口)。span 事件是 append-only 的,显示倒序后新事件落在顶部;配套 `useTopAnchoredScroll`
* 维持阅读位置(贴顶就跟最新,已下读则按新增高度补偿,新条目不会把用户顶跑)。
* 空态如实说明为什么空,而不是假装有内容。 */
function LogList({
node,
log,
@@ -120,6 +123,9 @@ function LogList({
log: NonNullable<FlowNode["log"]>;
}): React.ReactNode {
const running = statusOf(node) === "running";
// 顺序只在这里定一次(口径见 nodeLogOrder.ts);滚动锚定按「首条键 + 条数」触发。
const ordered = newestFirstLog(log);
const scrollRef = useTopAnchoredScroll(headLogKey(ordered), ordered.length);
if (log.length === 0) {
return (
<div className="rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] px-3 py-2 text-xs text-[var(--mu-text-lo)]">
@@ -133,10 +139,11 @@ function LogList({
<h4 className="flex items-center gap-2 text-xs font-medium text-[var(--mu-text-lo)]">
实时日志(span 事件累积)
<span className="font-mono">{log.length}</span>
<span>最新在上</span>
{running ? <span className="mu-blink text-[var(--mu-accent)]">● 运行中</span> : null}
</h4>
<div className="max-h-64 space-y-1.5 overflow-auto">
{log.map((ev, i) => (
<div ref={scrollRef} className="max-h-64 space-y-1.5 overflow-auto">
{ordered.map((ev, i) => (
<div
key={`${ev.time}-${ev.type}-${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"
@@ -0,0 +1,136 @@
// 多轮历史回放 UI 的**真实渲染**测试(切片 F2;契约 v0.73;design/19 §4 P3)。
//
// 逻辑测试(flowHistory.test.ts)钉住「选哪一轮 ⇒ 画哪张图」;这里钉住「界面真的把它画
// 出来」—— 选择器、诚实说明条、以及「选历史轮时画的是落盘那张、不混入实时图」。
//
// 本项目没有 jsdom,渲染测试对 **props 驱动**的组件用 react-dom/server(见
// flowSnapshot.render.test.ts 头注释的同一理由)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowHistory.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-flow-history-model.test.cjs \
// && node /tmp/cyrene-flow-history-model.test.cjs
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
import { FlowBody } from "../Flow";
import { FlowHistoryHint, FlowTurnPicker } from "./FlowTurnPicker";
import { LATEST_TURN } from "./flowHistory";
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);
}
const turns: AdminFlowTurn[] = [
{ turnId: "web-3", createdAt: "2026-10-05T11:00:00Z" },
{ turnId: "web-2", createdAt: "2026-10-05T10:00:00Z" },
];
const latestGraph: FlowGraph = {
id: "web-9",
sessionId: MAIN_SESSION_ID,
nodes: [{ id: "input", label: "实时哨兵", kind: "input", status: "done" }],
edges: [],
};
const historyGraph: FlowGraph = {
id: "web-3",
sessionId: MAIN_SESSION_ID,
nodes: [{ id: "input", label: "历史哨兵", kind: "input", status: "done" }],
edges: [],
};
// ── ① 选择器:默认「最新一轮」+ 每个落盘轮 + 计数 ─────────────────────────────
const picker = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns,
selectedTurnId: LATEST_TURN,
onSelect: () => {},
loading: false,
})
);
ok(has(picker, "最新一轮"), "选择器必须有「最新一轮」选项");
ok(has(picker, "web-3") && has(picker, "web-2"), "选择器必须列出每个已落盘轮次");
ok(has(picker, "共 2 轮已落盘"), "选择器必须如实给出已落盘轮数");
const pickerLoading = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns,
selectedTurnId: "web-3",
onSelect: () => {},
loading: true,
})
);
ok(has(pickerLoading, "加载中"), "拉取历史轮时选择器要显示加载态");
// ── ② 没有落盘轮次时的诚实说明(白名单外会话永远没有图) ──────────────────────
const pickerEmpty = renderToStaticMarkup(
createElement(FlowTurnPicker, {
turns: [],
selectedTurnId: LATEST_TURN,
onSelect: () => {},
loading: false,
})
);
ok(has(pickerEmpty, "还没有已落盘的轮次"), "无历史轮时必须如实说明,而不是空着");
ok(has(pickerEmpty, "群聊"), "无历史轮时必须点明白名单外会话(群聊/陌生人)永远没有图");
// ── ③ 诚实说明条:历史轮来自本地落盘 ────────────────────────────────────────
const hint = renderToStaticMarkup(createElement(FlowHistoryHint, { turnId: "web-3" }));
ok(has(hint, "本地落盘"), "历史轮说明条必须写明来自本地落盘");
ok(has(hint, "web-3"), "历史轮说明条必须露出轮次 id");
// ── ④ FlowBody:选历史轮 ⇒ 画落盘那张、不混入实时图、给历史说明条 ────────────
const withHistory = renderToStaticMarkup(
createElement(FlowBody, {
graph: latestGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
turns,
selectedTurnId: "web-3",
onSelectTurn: () => {},
historyGraph,
historyLoading: false,
})
);
ok(has(withHistory, "历史哨兵"), "选了历史轮 ⇒ 必须画出该轮落盘的图");
ok(!has(withHistory, "实时哨兵"), "选了历史轮时绝不把实时图混进来");
ok(has(withHistory, "本地落盘"), "选了历史轮时必须给出来源说明(本地落盘)");
ok(has(withHistory, "最新一轮"), "选择器仍在,可切回最新一轮");
// ── ⑤ 选了历史轮但还没拉到:是「正在读取」,不是「暂无流程」 ─────────────────
const loadingHistory = renderToStaticMarkup(
createElement(FlowBody, {
graph: latestGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
turns,
selectedTurnId: "web-3",
onSelectTurn: () => {},
historyGraph: null,
historyLoading: true,
})
);
ok(has(loadingHistory, "正在读取历史轮"), "加载中必须显示读取提示");
ok(!has(loadingHistory, "暂无流程"), "加载中不是空态");
// ── ⑥ 没给 onSelectTurn(渲染测试的旧调用形态)⇒ 不渲染选择器,画实时图 ──────
const plain = renderToStaticMarkup(
createElement(FlowBody, { graph: latestGraph, sessionId: MAIN_SESSION_ID, source: "live" })
);
ok(!has(plain, 'id="flow-turn-select"'), "没有 onSelectTurn 时不渲染历史轮选择器");
ok(has(plain, "实时哨兵"), "默认形态照常画实时/快照图(F1 行为不回退)");
console.log(`flow history render test: ${checks} checks passed`);
@@ -0,0 +1,84 @@
// 「多轮历史回放」的**纯逻辑**测试(切片 F2;契约 v0.73;design/19 §4 P3)。
//
// 本项目没有前端测试运行器、也没有 jsdom,因此这是无依赖的纯 node 检查:hook(取数)
// 在本项目无法执行,但「选哪一轮 ⇒ 画哪张图、标什么来源、列表怎么归一」全是纯函数,
// 直接钉住即可(与 useFlowSnapshot.test.ts 同一手法)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowHistory.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-flow-history.test.mjs \
// && node /tmp/cyrene-flow-history.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*。
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import {
LATEST_TURN,
normalizeTurns,
resolveShownGraph,
shownGraphSource,
turnOptionLabel,
} from "./flowHistory";
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 turn = (turnId: string, createdAt: string): AdminFlowTurn => ({ turnId, createdAt });
const graph = (id: string): FlowGraph => ({ id, nodes: [], edges: [] });
// ── ① 「最新一轮」的取值就是空串(= 不给后端 turnId) ───────────────────────
same(LATEST_TURN, "", "「最新一轮」必须用空串表示(与不传 turnId 同义)");
// ── ② 轮次列表归一:去重 + 最新在前(后端已排序,这里做防御性归一) ─────────
same(normalizeTurns(null).length, 0, "null 列表归一为空");
same(normalizeTurns(undefined).length, 0, "undefined 列表归一为空");
same(normalizeTurns([]).length, 0, "空列表归一为空");
const normalized = normalizeTurns([
turn("web-1", "2026-10-05T09:00:00Z"),
turn("web-3", "2026-10-05T11:00:00Z"),
turn("web-2", "2026-10-05T10:00:00Z"),
turn("web-3", "2026-10-05T11:05:00Z"), // 同 id 重复:只留一条
]);
same(normalized.length, 3, "同 turnId 去重后只剩三条");
same(normalized[0].turnId, "web-3", "最新在前");
same(normalized[1].turnId, "web-2", "次新第二");
same(normalized[2].turnId, "web-1", "最旧在最后");
// 时间不可解析的项排在最后(读数按 0 处理,不算 NaN、不伪造时间)。
const withBad = normalizeTurns([turn("web-x", "不是时间"), turn("web-9", "2026-10-05T10:00:00Z")]);
same(withBad[0].turnId, "web-9", "可解析时间的最新在前,不可解析的垫底");
// ── ③ 选择项文案:有时间的显示 id + 本地时间;没时间就只显示 id ─────────────
const labeled = turnOptionLabel(turn("web-7", "2026-10-05T10:00:00Z"));
ok(labeled.includes("web-7"), `文案必须含轮次 id:${labeled}`);
same(turnOptionLabel(turn("web-bad", "坏")).includes("web-bad"), true, "时间坏掉时仍给出 id");
same(turnOptionLabel(turn("web-bad", "坏")), "web-bad", "时间坏掉时不编造时间,只给 id");
// ── ④ 该画哪张图:最新 = 实时/快照;选历史轮 = 落盘那张 ─────────────────────
same(resolveShownGraph(LATEST_TURN, graph("web-9"), graph("web-1"))?.id, "web-9", "最新一轮画实时/快照那张");
same(resolveShownGraph("web-1", graph("web-9"), graph("web-1"))?.id, "web-1", "选历史轮画落盘那张");
same(resolveShownGraph("web-1", graph("web-9"), null), null, "选了历史轮但还没拉到 ⇒ 不拿实时图顶替");
same(resolveShownGraph(LATEST_TURN, null, graph("web-1")), null, "最新一轮没有图 ⇒ 不拿历史图顶替");
same(resolveShownGraph("web-1", undefined, undefined), null, "都没有图 ⇒ null");
// ── ⑤ 来源标签:选历史轮一定是落盘;否则沿用 F1 的 live/snapshot ────────────
same(shownGraphSource(LATEST_TURN, "live"), "live", "最新一轮沿用实时来源");
same(shownGraphSource(LATEST_TURN, "snapshot"), "snapshot", "最新一轮沿用快照来源");
same(shownGraphSource(LATEST_TURN, null), null, "没有图就没有来源");
same(shownGraphSource("web-1", "live"), "history", "选历史轮 ⇒ 来源一定是落盘的历史轮");
console.log(`flow history logic test: ${checks} checks passed`);
@@ -0,0 +1,67 @@
import type { AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
/**
* 多轮历史回放的**纯逻辑**(切片 F2;design/19 §4 P3;契约 v0.73)。
*
* 从组件里抽出来(AGENTS §10:本项目没有 jsdom,effect 跑不了)—— 选择器「选哪一轮 ⇒
* 画哪张图、标什么来源」的判定在这里是纯函数,逻辑测试可直接钉住;hook 只负责取数。
*
* 诚实边界(与后端 §4.11 逐条对齐):
* - 选了历史轮 ⇒ 画的是**落盘**的那一轮(不是实时图);
* - 「最新一轮」= 不给 turnId,走后端「先内存、miss 才落盘」那条路;
* - 没有图就是没有图(空态由文案负责,绝不给来源标签,绝不补画)。
*/
/** 历史轮选择器里「最新一轮」的取值(= 不给后端 turnId)。刻意是空串:它与「没选历史轮」
* 同义,也与 `adminFlowGraph` 对空 turnId 的行为一致(不传参)。 */
export const LATEST_TURN = "";
/** 归一历史轮列表:按 turnId 去重、按 createdAt 倒序(最新在前)。
*
* 后端已按 created_at DESC 返回,这里仍归一一遍,是为了让**选择器**在任何来源下都只有
* 一条同轮记录、且顺序稳定(渲染/将来合并都不必各自排序)。时间不可解析的按 0 处理
* (不伪造时间、也不让比较器算出 NaN)。 */
export function normalizeTurns(turns: readonly AdminFlowTurn[] | null | undefined): AdminFlowTurn[] {
if (!turns || turns.length === 0) return [];
const byId = new Map<string, AdminFlowTurn>();
for (const t of turns) {
if (!t || !t.turnId) continue;
if (!byId.has(t.turnId)) byId.set(t.turnId, t);
}
return Array.from(byId.values()).sort((a, b) => timeOf(b.createdAt) - timeOf(a.createdAt));
}
/** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。 */
function timeOf(iso: string): number {
const t = Date.parse(iso);
return Number.isFinite(t) ? t : 0;
}
/** 选择器某一项的文案:`轮次 id · 本地时间`。时间不可解析时只留 id(不编造时间)。 */
export function turnOptionLabel(turn: AdminFlowTurn): string {
const t = Date.parse(turn.createdAt);
if (!Number.isFinite(t)) return turn.turnId;
return `${turn.turnId} · ${new Date(t).toLocaleString()}`;
}
/** 屏幕上该画哪张图:选了历史轮 ⇒ 画拉回来的那一张(可能仍在加载 ⇒ null);
* 否则 ⇒ 画实时/快照那张(F1 的 `flowGraph`)。 */
export function resolveShownGraph(
selectedTurnId: string,
latestGraph: FlowGraph | null | undefined,
historyGraph: FlowGraph | null | undefined
): FlowGraph | null {
if (selectedTurnId !== LATEST_TURN) return historyGraph ?? null;
return latestGraph ?? null;
}
/** 来源标签(诚实来源)。选了历史轮 ⇒ 一定是**落盘**的那一轮;否则沿用 F1 的
* `live`(实时帧)/ `snapshot`(打开页面时拉回的最近一轮)。空图时返回 null。 */
export function shownGraphSource(
selectedTurnId: string,
baseSource: "live" | "snapshot" | null
): "live" | "snapshot" | "history" | null {
if (selectedTurnId !== LATEST_TURN) return "history";
return baseSource;
}
@@ -138,8 +138,8 @@ ok(
"快照来源必须写明它是只读拉取(GET /api/v1/admin/flow/graph)"
);
ok(
has(snapshotHint, "多轮历史回放尚未实现"),
"快照来源必须诚实标注「多轮历史回放尚未实现」,不得让用户以为每行都有自己的图"
has(snapshotHint, "历史轮次"),
"快照来源必须指向「历史轮次」选择器(v0.73 起多轮历史回放已实现),不得让用户以为这一张就是他要的那一轮"
);
const liveHint = renderToStaticMarkup(
@@ -147,8 +147,8 @@ const liveHint = renderToStaticMarkup(
);
ok(has(liveHint, "实时"), "实时来源必须标注为实时收到");
ok(
!has(liveHint, "多轮历史回放尚未实现"),
"实时图不是快照,不该出现快照专属的免责说明"
!has(liveHint, "历史轮次"),
"实时图不是快照,不该出现快照专属的历史轮说明"
);
// 后台思考会话的人话标签必须认得出(图上要写清是哪个会话的图)。
@@ -0,0 +1,51 @@
// 节点「实时日志」排序口径的**纯逻辑**测试(L1 顺带收口;切片 F2)。
//
// 本项目没有 jsdom、effect 不执行,因此渲染层只测纯函数(口径唯一出处见 nodeLogOrder.ts)。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/flow/nodeLogOrder.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-node-log-order.test.mjs \
// && node /tmp/cyrene-node-log-order.test.mjs
import type { TraceEvent } from "../../../lib/types";
import { headLogKey, newestFirstLog } from "./nodeLogOrder";
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 ev = (time: string, type: string): TraceEvent => ({ time, type });
const log: TraceEvent[] = [
ev("2026-10-05T10:00:00Z", "span.start"),
ev("2026-10-05T10:00:01Z", "span.progress"),
ev("2026-10-05T10:00:02Z", "span.done"),
];
// ── ① 最新在上:顺序必须是原顺序的镜像 ──────────────────────────────────────
const ordered = newestFirstLog(log);
same(ordered.length, 3, "倒序不丢条目");
same(ordered[0].type, "span.done", "首条是最新一条(最新在上)");
same(ordered[2].type, "span.start", "末条是最旧一条");
// ── ② 不改动入参(store 里的日志仍按发生顺序存) ────────────────────────────
same(log[0].type, "span.start", "原数组顺序未被改动(返回的是副本)");
ok(ordered !== log, "返回的是新数组而不是同一个引用");
// ── ③ 空列表:倒序仍为空,首条键为 null(滚动锚定据此判定「还没加载」) ──────
same(newestFirstLog([]).length, 0, "空列表倒序仍为空");
same(headLogKey([]), null, "空列表的首条键是 null");
same(headLogKey(ordered), "2026-10-05T10:00:02Z~span.done", "首条键 = 最新一条的 time~type");
console.log(`node log order test: ${checks} checks passed`);
@@ -0,0 +1,25 @@
import type { TraceEvent } from "../../../lib/types";
/**
* 节点「实时日志」的排序口径:**最新在上**(L1「日志倒序统一」的顺带收口;切片 F2)。
*
* 为什么需要它:span 事件是 append-only 的累积日志 —— 后端按发生顺序追加,旧的在前。
* 界面按「最新在上」显示才符合直觉(与渠道消息日志三面同一口径,见
* `admin/modules/channels/channelLogOrder.ts`),并与 `admin/logScroll.ts` 的
* `useTopAnchoredScroll` 配套:新条目落在顶部时,用户正在读的旧条目不被打断。
*
* 刻意**不倒序**的例外:用户端聊天页(读对话,正序 + 自动滚底才自然),与本口径无关。
*/
/** 返回一份「最新在上」的副本。**不改动入参**(store 里的日志仍是发生顺序)。 */
export function newestFirstLog(log: readonly TraceEvent[]): TraceEvent[] {
return [...log].reverse();
}
/** 列表首条(最新一条)的稳定键:`useTopAnchoredScroll` 靠它判断「顶部前插了」。
* 空列表返回 null(还没加载 / 没有事件)。 */
export function headLogKey(log: readonly TraceEvent[]): string | null {
const head = log[0];
if (!head) return null;
return `${head.time}~${head.type}`;
}
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useState } from "react";
import { adminFlowGraph, type AdminFlowTurn } from "../../../lib/api";
import type { FlowGraph } from "../../../lib/types";
import { LATEST_TURN, normalizeTurns } from "./flowHistory";
/**
* 历史轮的取数 hook(切片 F2;design/19 §4 P3;契约 v0.73)。
*
* 两件事:
* ① 会话确定后拉一次该会话的**已落盘轮次列表**(选择器用);
* ② 用户选了某一轮时,只读拉那一轮的图(`?turnId=`)。
*
* 与 `useFlowSnapshot`(F1)的分工:那个 hook 负责**最新一轮**的冷启动补拉并把图写进
* store(刷新即刻有图靠它 + `persistOptions` 持久化);本 hook 只负责**历史轮**,绝不写
* store —— 历史轮是「看过去」,把它塞进当前会话的图桶会把实时图顶掉(裁决 16.4.2)。
*
* 失败一律静默(历史回放是只读增强,失败不该改变界面):列表拉不到 → 空列表;某一轮
* 拉不到 → 该轮没有图(空态文案如实说明)。`sessionId` 为空时是空操作。
*/
export interface FlowTurnsState {
/** 该会话已落盘的轮次(最新在前)。 */
turns: AdminFlowTurn[];
/** 当前选择(`LATEST_TURN` = 最新一轮)。 */
selectedTurnId: string;
/** 选中历史轮时拉回的图(未选 / 加载中 / 拉不到时为 null)。 */
historyGraph: FlowGraph | null;
/** 正在拉取选中的历史轮。 */
historyLoading: boolean;
/** 选择某一轮(空串 = 回到「最新一轮」)。 */
selectTurn: (turnId: string) => void;
}
export function useFlowTurns(sessionId: string): FlowTurnsState {
const [turns, setTurns] = useState<AdminFlowTurn[]>([]);
const [selectedTurnId, setSelectedTurnId] = useState<string>(LATEST_TURN);
const [historyGraph, setHistoryGraph] = useState<FlowGraph | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
// 会话变了 ⇒ 复位选择并拉该会话的轮次列表。
useEffect(() => {
if (!sessionId) {
setTurns([]);
return;
}
let cancelled = false;
setSelectedTurnId(LATEST_TURN);
setHistoryGraph(null);
adminFlowGraph(sessionId)
.then((resp) => {
if (!cancelled) setTurns(normalizeTurns(resp.turns));
})
.catch(() => {
if (!cancelled) setTurns([]);
});
return () => {
cancelled = true;
};
}, [sessionId]);
// 选了历史轮 ⇒ 只读拉那一轮(最新一轮走 F1 的 useFlowSnapshot,不在这里重复拉)。
useEffect(() => {
if (!sessionId || selectedTurnId === LATEST_TURN) {
setHistoryGraph(null);
setHistoryLoading(false);
return;
}
let cancelled = false;
setHistoryLoading(true);
adminFlowGraph(sessionId, selectedTurnId)
.then((resp) => {
if (cancelled) return;
setHistoryGraph(resp.graph);
setHistoryLoading(false);
})
.catch(() => {
if (cancelled) return;
setHistoryGraph(null);
setHistoryLoading(false);
});
return () => {
cancelled = true;
};
}, [sessionId, selectedTurnId]);
const selectTurn = useCallback((turnId: string) => setSelectedTurnId(turnId || LATEST_TURN), []);
return { turns, selectedTurnId, historyGraph, historyLoading, selectTurn };
}
+1 -1
View File
@@ -129,4 +129,4 @@ export {
adminSystemRotateJWT,
adminSystemStorage,
} from "./api/adminSystem";
export type { AdminFlowGraphResponse } from "./api/adminSystem";
export type { AdminFlowGraphResponse, AdminFlowTurn } from "./api/adminSystem";
+30 -15
View File
@@ -47,28 +47,43 @@ export function adminBackthink(limit?: number): Promise<BackthinkItem[]> {
return request<BackthinkItem[]>(`/admin/backthink${limitQuery(limit)}`);
}
/** 最近一轮链路图快照的后端响应(契约 v0.71 §4.11(a))。`graph` **恒在**
* (`null` 而不是缺席):`null` = 该会话**确实**还没有图,客户端必须据此
* 渲染空态,绝不允许补画任何节点或假图。 */
export interface AdminFlowGraphResponse {
sessionId: string;
graph: FlowGraph | null;
/** 一条**已落盘轮次**的摘要(契约 v0.73 §4.11 的 `turns[]`)。面板用它渲染历史轮
* 选择器:把 `turnId` 回带给 `adminFlowGraph` 即可精确取回那一轮。 */
export interface AdminFlowTurn {
turnId: string;
createdAt: string;
}
/** GET /api/v1/admin/flow/graph?sessionId= — **最近一轮**链路图快照(契约
* v0.71 §4.11,只读、不审计,owner/admin 档)。
/** 链路图端点的后端响应(契约 v0.71 起;**v0.73 新增 `turnId`/`source`/`turns`**)。
*
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放,且前端图桶
* (`flowGraphs`)**不持久化** ⇒ 「轮次结束后才打开页面」或「刷新过页面」
* 此前只能看到空态。打开页面时拉一次即可补上最近一轮。
* `graph` **恒在**(`null` 而不是缺席):`null` = 该轮**确实**没有图,客户端必须据此
* 渲染空态,绝不允许补画任何节点或假图。
*
* ⚠️ 它只回**每会话最近一轮**:后台思考行上点开的图可能不是该行那一轮
* (多轮历史回放仍未实现,`design/19 §4` P3)⇒ 面板必须在图上说明。
* `source` 告诉客户端这张图是哪来的:`live` = 内存里最近一轮(实时图);`persisted` =
* 从本地库读回的历史轮;`none` = 没有图。`turns` 是该会话已落盘的轮次摘要(最新在前),
* 没有时为 `[]`(后端保证非 null 数组)。 */
export interface AdminFlowGraphResponse {
sessionId: string;
turnId: string;
source: "live" | "persisted" | "none";
graph: FlowGraph | null;
turns: AdminFlowTurn[];
}
/** GET /api/v1/admin/flow/graph?sessionId=&turnId= — **链路图快照 + 多轮历史回放**(契约
* v0.71 起,v0.73 增量;只读、不审计,owner/admin 档)。
*
* `sessionId` 省略时后端默认主会话 `web`。 */
export function adminFlowGraph(sessionId: string): Promise<AdminFlowGraphResponse> {
* 为什么需要它:`graph_update` 是**纯实时**帧、从不重放。v0.71 补了「打开页面拉最近一轮
* 快照」;v0.73 把图**每轮落盘**,于是可以:
* - **省略 `turnId`** ⇒ 最近一轮(后端**先查内存、miss 才落盘** ⇒ 最新一轮永远最新);
* - **给了 `turnId`** ⇒ 精确取该会话已落盘的某一轮(历史回放)。
*
* `turns`(同一响应里)就是给选择器用的历史轮列表。⚠️ 白名单之外的会话(群聊/陌生人)
* 从不建图、也从不落盘 ⇒ 它们**永远**是空态,这不是缺陷。 */
export function adminFlowGraph(sessionId: string, turnId?: string): Promise<AdminFlowGraphResponse> {
const qs = new URLSearchParams();
if (sessionId) qs.set("sessionId", sessionId);
if (turnId) qs.set("turnId", turnId);
const q = qs.toString();
return request<AdminFlowGraphResponse>(`/admin/flow/graph${q ? `?${q}` : ""}`);
}
@@ -0,0 +1,72 @@
// 前端 store 持久化的**纯逻辑**测试(切片 F2;主人要求「每次重载之后页面就没数据」)。
//
// 只钉一件事:`flowGraphs`(按会话分的链路图桶)**必须**在 zustand persist 的
// `partialize` 里 —— 否则刷新页面时图桶被抹掉,「刷新即刻有图」就不成立。同时钉住
// `migrate` 对旧版本(v3 之前没有图桶)给出空桶默认。
//
// 跑法:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/store/persistOptions.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-persist-options.test.mjs \
// && node /tmp/cyrene-persist-options.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它不 import node:*;只类型引入 AppStore
// (编译后被擦除,不触发 useAppStore 的运行时副作用)。
import type { AppStore } from "./useAppStore";
import type { FlowGraph } from "../lib/types";
import { persistOptions } from "./persistOptions";
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 graph: FlowGraph = {
id: "web-1",
sessionId: "web",
nodes: [{ id: "input", label: "你好", kind: "input", status: "done" }],
edges: [],
};
const state = {
activeSessionId: "web",
sessions: { web: { id: "web" } },
personas: [],
moods: { cyrene: "开心" },
context: {},
backthink: [],
flowGraphs: { web: graph },
} as unknown as AppStore;
// ── ① partialize 必须含 flowGraphs(刷新即刻有图的内核) ─────────────────────
const persisted = persistOptions.partialize(state) as unknown as Record<string, unknown>;
ok("flowGraphs" in persisted, "partialize 必须包含 flowGraphs —— 否则刷新后图桶被抹掉");
same(persisted.flowGraphs, state.flowGraphs, "持久化的图桶必须是当前 store 里的那份");
// ── ② 版本号已升到 4(v3 之前没有图桶,迁移时必须补空桶) ─────────────────────
same(persistOptions.version, 4, "引入 flowGraphs 持久化后版本应为 4");
// ── ③ migrate:旧版本(没有图桶)⇒ 补一个空桶;已存在的桶保留 ────────────────
const migratedOld = persistOptions.migrate({}, 3) as unknown as Record<string, unknown>;
same(JSON.stringify(migratedOld.flowGraphs), "{}", "v3 旧状态迁移后图桶是空对象(随后由快照补)");
const migratedKeep = persistOptions.migrate({ flowGraphs: { web: graph } }, 3) as unknown as Record<
string,
unknown
>;
ok(
(migratedKeep.flowGraphs as Record<string, FlowGraph>).web?.id === "web-1",
"迁移必须保留已有的图桶"
);
console.log(`persist options test: ${checks} checks passed`);
+10 -1
View File
@@ -10,7 +10,7 @@ import type { AppStore } from "./useAppStore";
*/
export const persistOptions = {
name: "cyrene.app",
version: 3,
version: 4,
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
// one resolved `currentPersona`, which can no longer describe the active role
// now that the role belongs to a *session*. Both shapes are dropped:
@@ -18,6 +18,8 @@ export const persistOptions = {
// the session list. `moods` is transient by nature (a restart resets a role's
// mood), so it is not carried across either. A v1 state was never trusted for
// the session metadata, so those users simply restart on the main session.
// v4 (切片 F2) added `flowGraphs` (the per-session flow-graph buckets): a v3
// state has none, so it starts empty and is refilled by the snapshot hook.
migrate: (persisted: unknown, version: number) => {
const p = (persisted ?? {}) as Partial<AppStore>;
const fromV1 = version < 2;
@@ -28,6 +30,7 @@ export const persistOptions = {
moods: {},
context: p.context ?? {},
backthink: p.backthink ?? [],
flowGraphs: p.flowGraphs ?? {},
};
},
partialize: (s: AppStore) => ({
@@ -43,5 +46,11 @@ export const persistOptions = {
moods: s.moods,
context: s.context,
backthink: s.backthink,
// v4 (切片 F2): the per-session flow-graph buckets are persisted too, so a
// refresh paints the last-seen graph immediately instead of flashing the
// empty state until the snapshot fetch returns (主人 2026-10-05「每次重载
// 之后页面就没数据了」). Bounded by `MAX_GRAPHS` (store/sessionCache.ts);
// the backend stays the truth and the snapshot/history endpoints refill it.
flowGraphs: s.flowGraphs,
}),
};