feat(flow): 链路图本地落盘 + 多轮历史回放(design/19 §P3,契约 v0.73)
主人要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」: - 落盘:迁移 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:
1 parent
84c124f5a9
commit
a6cb44b8a8
38 files changed
+3047
-91
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user