diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index f304a2f..3fdf5f7 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -11,7 +11,9 @@ import { FlowSnapshotHint } from "./flow/FlowSnapshotHint"; import { useFlowSnapshot, type FlowGraphSource } from "./flow/useFlowSnapshot"; import { useFlowTurns } from "./flow/useFlowTurns"; import { FlowHistoryHint, FlowTurnPicker } from "./flow/FlowTurnPicker"; +import { FlowSessionPicker } from "./flow/FlowSessionPicker"; import { LATEST_TURN, resolveShownGraph } from "./flow/flowHistory"; +import { flowPageData, type FlowSessionOption } from "./flow/flowSession"; import { groupFlowLanes } from "./flow/lanes"; import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone"; import { MAIN_SESSION_ID } from "../../store/sessionCache"; @@ -21,6 +23,11 @@ import type { AdminFlowTurn } from "../../lib/api"; * 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器), * 节点点开看参数 / 结果 / 实时日志。 * + * **看哪个会话**由页内的会话选择器决定(切片 FV1):选项只含发布会话白名单内的会话 + * (主会话 `web` / 自主思考 `sess-backthink` / `web:` 次级会话),默认主会话 `web`。 + * 取数与取图都只走 `flowPageData`(纯函数)—— 图取自 `flowGraphs[选中会话]`, + * **绝不读按 `activeSessionId` 取的兼容字段 `flowGraph`**(那样切了会话也白切)。 + * * 图有**两个来源**(契约 v0.71):本次页面存活期间收到的实时帧,以及打开页面时 * 只读拉取的「最近一轮快照」(`GET /api/v1/admin/flow/graph`)。后者修的是 * 「轮次结束后才打开页面 / 刷新过页面 ⇒ 图桶是空的」那个洞 —— 实时帧从不重放。 @@ -29,12 +36,14 @@ import type { AdminFlowTurn } from "../../lib/api"; * 本组件只做 store 接线;有图 / 无图两支的呈现交给 `FlowBody`(props 驱动), * 使那条判定能被真实渲染测试钉住(见 flowSnapshot.render.test.ts 头注释)。 */ export function FlowModule(): React.ReactNode { - // 激活会话(没有会话时即主会话,与 store 的 currentGraph 口径一致)。 - const activeSessionId = useAppStore((s) => s.activeSessionId); - const sessionId = activeSessionId || MAIN_SESSION_ID; + const sessions = useAppStore((s) => s.sessions); + const flowGraphs = useAppStore((s) => s.flowGraphs); + // 选择器选中值:默认主会话 `web`(后端固定 MainSessionID),不随激活会话漂移。 + const [pickedSessionId, setPickedSessionId] = useState(MAIN_SESSION_ID); + // 取数接线(纯函数,flowSession.ts):用哪个 sessionId 取数 / 画哪张图 / 有哪些选项。 + const { sessionId, graph, options } = flowPageData(sessions, flowGraphs, pickedSessionId); // 最新一轮:F1 的实时图 + 只读快照补拉(补拉会写进图桶 ⇒ 配合图桶持久化「刷新即刻有图」)。 const graphSource = useFlowSnapshot(sessionId); - const graph = useAppStore((s) => s.flowGraph); // 历史轮(契约 v0.73 多轮历史回放):已落盘轮次列表 + 选中轮的只读图。它**不写 store**, // 因此翻历史绝不顶掉当前会话的实时图(裁决 16.4.2)。 const history = useFlowTurns(sessionId); @@ -43,6 +52,8 @@ export function FlowModule(): React.ReactNode { graph={graph} sessionId={sessionId} source={graphSource} + sessionOptions={options} + onSelectSession={setPickedSessionId} turns={history.turns} selectedTurnId={history.selectedTurnId} onSelectTurn={history.selectTurn} @@ -57,11 +68,14 @@ export function FlowModule(): React.ReactNode { * * **来源与轮次**(契约 v0.73):省略 `selectedTurnId`(= `LATEST_TURN`)时画 * 实时/快照那张图(F1);选了某一轮时画 `historyGraph`(从本地库读回的落盘轮)。 - * 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。 */ + * 历史轮 UI 只在给了 `onSelectTurn` 时渲染 —— 渲染测试喂不到它,因此既有断言不受影响。 + * 同理,会话选择器只在给了 `onSelectSession` 时渲染。 */ export function FlowBody({ graph, sessionId, source, + sessionOptions, + onSelectSession, turns = [], selectedTurnId = LATEST_TURN, onSelectTurn, @@ -71,6 +85,10 @@ export function FlowBody({ graph: FlowGraph | null; sessionId: string; source: FlowGraphSource; + /** 会话选择器选项(只含白名单内会话)。 */ + sessionOptions?: readonly FlowSessionOption[]; + /** 选中会话的回调;不给则不渲染会话选择器(渲染测试因此不必造它)。 */ + onSelectSession?: (sessionId: string) => void; /** 该会话已落盘的轮次(最新在前)。 */ turns?: readonly AdminFlowTurn[]; /** 当前选择(`LATEST_TURN` = 最新一轮)。 */ @@ -89,6 +107,9 @@ export function FlowBody({ const selected = shown?.nodes.find((n) => n.id === selectedId) ?? null; const grouping = shown ? groupFlowLanes(shown) : null; const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined; + // 本回合并行子代理泳道数。0 时统计卡**不显示 `0`**:那张卡的标签是「泳道」,`0` 紧挨着 + // 标签会被读成「泳道 0」(主人报的正是这个),而事实是「本回合没有派发子代理」。 + const laneCount = grouping?.lanes.length ?? 0; return (
@@ -97,14 +118,23 @@ export function FlowBody({ subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史" /> - {onSelectTurn ? ( + {onSelectTurn || onSelectSession ? ( - + {onSelectSession ? ( + + ) : null} + {onSelectTurn ? ( + + ) : null} {showingHistory && shown ? : null} ) : null} @@ -129,8 +159,8 @@ export function FlowBody({ } label="泳道" - value={grouping?.lanes.length ?? 0} - sub="并行子代理分支" + value={laneCount > 0 ? laneCount : "—"} + sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"} tone="accent" /> void; +}): React.ReactNode { + return ( +
+
+ + + + 换会话后,本页的图 / 轮次列表都跟着切 + +
+

+ 只列发布会话白名单内的会话(主会话 web / 自主思考 sess-backthink / 你的次级会话 + web:*)。白名单之外的会话(群聊 / 陌生人的 sess-*)永远没有链路图,因此不在这个列表里 + —— 那不是缺陷。 +

+
+ ); +} diff --git a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx index 4f65e90..c02adc2 100644 --- a/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx +++ b/frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx @@ -1,12 +1,4 @@ -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; -} +import { flowSessionLabel } from "./flowSession"; /** 链路图快照的诚实说明条(契约 v0.71 §4.11(b)-1)。 * @@ -25,7 +17,7 @@ export function FlowSnapshotHint({ sessionId: string; source: "live" | "snapshot"; }): React.ReactNode { - const label = sessionLabel(sessionId); + const label = flowSessionLabel(sessionId); return (
{source === "snapshot" ? ( diff --git a/frontend/web/src/admin/modules/flow/flowSession.test.ts b/frontend/web/src/admin/modules/flow/flowSession.test.ts new file mode 100644 index 0000000..4935c12 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowSession.test.ts @@ -0,0 +1,215 @@ +// Flow 页**会话选择器**的逻辑测试(切片 FV1)。 +// +// 为什么需要它:后端端点本来就支持任意会话(白名单 `web` / `web:*` / `sess-backthink`, +// 见 `cmd/cyrene/wire_runtime.go#flowSessionAllowed`),缺口纯在前端 —— 页面固定看激活 +// 会话的图(兼容字段 `flowGraph`),于是 `sess-backthink` 只能在后台思考页看。 +// +// 本项目没有前端测试运行器、也没有 jsdom:store 驱动的组件渲染不出真实 state +// (zustand v5 的 SSR 快照取 `getInitialState()`),因此这里钉的是**取数接线**这个纯函数 +// 家族(`flowPageData` / `flowSessionOptions` / `resolveFlowSessionId`),并驱动**真的** +// api 客户端与**真的** store 写入路径,验证「选中的会话 id 真的传到了取数层」。 +// +// 跑法: +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSession.test.ts \ +// --bundle --platform=node --format=esm \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-flow-session.test.mjs \ +// && node /tmp/cyrene-flow-session.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, SessionMeta } from "../../../lib/types"; +import { + flowPageData, + flowSessionLabel, + flowSessionOptions, + isFlowSessionAllowed, + resolveFlowSessionId, +} from "./flowSession"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(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 并回一份假响应(同 useFlowSnapshot.test.ts 的手法)。 */ +interface FetchCall { + url: string; + method: string; +} +const calls: FetchCall[] = []; +const originalFetch = globalThis.fetch; +const originalLocalStorage = globalThis.localStorage; + +/** api 客户端的 `request` 会读 `localStorage` 取 token;node 下没有它。 */ +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; +} + +// ── ① 白名单与后端 flowSessionAllowed 逐条对齐 ─────────────────────────── +same(isFlowSessionAllowed(MAIN_SESSION_ID), true, "主会话 web 在白名单内"); +same(isFlowSessionAllowed(BACKTHINK_SESSION_ID), true, "后台思考会话 sess-backthink 在白名单内"); +same(isFlowSessionAllowed("web:7f3a"), true, "web: 前缀的次级会话在白名单内"); +same(isFlowSessionAllowed("web:"), false, "裸前缀 web: 不是会话(后端 sessionread 同样拒绝)"); +same(isFlowSessionAllowed(""), false, "空 id 不在白名单内"); +same(isFlowSessionAllowed("sess-group-1"), false, "群聊的 sess-* 不在白名单内(永远没有图)"); +same(isFlowSessionAllowed("sess-stranger-2"), false, "陌生人的 sess-* 不在白名单内"); + +// ── ② 选项只含白名单内会话;默认选中主会话 web ─────────────────────────── +const sessionList: SessionMeta[] = [ + { id: MAIN_SESSION_ID, type: "main", createdAt: 1 }, + { id: "web:b", type: "sub", createdAt: 200, title: "写周报" }, + { id: "web:a", type: "sub", createdAt: 100 }, + { id: "sess-group-1", type: "sub", createdAt: 900 }, + { id: "sess-backthink", type: "sub", createdAt: 950 }, +]; +useAppStore.getState().resetChat(); +useAppStore.getState().setSessions(sessionList); +useAppStore.getState().setActiveSession(MAIN_SESSION_ID); + +const options = flowSessionOptions(useAppStore.getState().sessions); +same( + options.map((o) => o.id).join(","), + "web,sess-backthink,web:b,web:a", + "选项 = 固定 web + 固定 sess-backthink + web: 次级会话(按创建时间倒序)" +); +ok( + !options.some((o) => o.id === "sess-group-1"), + "群聊会话绝不进选项(列出来就是「选了永远空态」的假承诺)" +); +same(options[0].label, "Web 主会话(web)", "主会话选项文案带人话名 + id"); +same(options[1].label, "后台思考会话(sess-backthink)", "后台思考会话选项文案带人话名 + id"); +same(options[2].label, "写周报(web:b)", "次级会话有标题时用标题 + id"); +same(options[3].label, "次级会话(web:a)", "次级会话无标题时用人话标签 + id"); + +same(resolveFlowSessionId(MAIN_SESSION_ID, options), "web", "默认选中主会话 web"); +same(resolveFlowSessionId("", options), "web", "还没有选中值时是主会话 web(= 默认行为)"); +same( + resolveFlowSessionId("sess-group-1", options), + "web", + "选中值不在选项里(白名单外 / 会话被删)⇒ 回落 web,绝不出现「显示 A 却取 B」" +); + +// ── ③ 切到 sess-backthink ⇒ 取数参数就是它,图来自它自己的桶 ───────────── +const webGraph = roundGraph("web-9", MAIN_SESSION_ID); +const btGraph = roundGraph("sess-backthink-3", BACKTHINK_SESSION_ID); +useAppStore.getState().setFlowGraph(webGraph, MAIN_SESSION_ID); +useAppStore.getState().setFlowGraph(btGraph, BACKTHINK_SESSION_ID); + +const btPage = flowPageData( + useAppStore.getState().sessions, + useAppStore.getState().flowGraphs, + BACKTHINK_SESSION_ID +); +same( + btPage.sessionId, + BACKTHINK_SESSION_ID, + "切到 sess-backthink ⇒ 传给取数层(useFlowSnapshot / useFlowTurns / REST)的 sessionId 就是它" +); +same(btPage.graph?.id, "sess-backthink-3", "图取自选中会话自己的图桶"); + +const compat = useAppStore.getState().flowGraph; +same( + compat?.id, + "web-9", + "兼容字段 flowGraph 仍是激活会话(web)的图 —— 正因如此,用它就会「切了也白切」" +); +ok( + btPage.graph?.id !== compat?.id, + "切会话后画的图必须与激活会话的图不同(这一条正是本切片要修的洞)" +); +same( + flowPageData(useAppStore.getState().sessions, useAppStore.getState().flowGraphs, MAIN_SESSION_ID) + .graph?.id, + "web-9", + "切回主会话 ⇒ 画的又是主会话的图" +); +same( + flowPageData(useAppStore.getState().sessions, useAppStore.getState().flowGraphs, "web:a").graph, + null, + "切到还没有图的次级会话 ⇒ graph 就是 null(空态由文案负责,绝不画假图)" +); + +// ── ④ 次级会话(web:)一样能取到自己的图 ──────────────────────────────── +useAppStore.getState().setFlowGraph(roundGraph("web:b-1", "web:b"), "web:b"); +const subPage = flowPageData( + useAppStore.getState().sessions, + useAppStore.getState().flowGraphs, + "web:b" +); +same(subPage.sessionId, "web:b", "次级会话的 sessionId 原样交给取数层"); +same(subPage.graph?.id, "web:b-1", "次级会话的图来自它自己的桶"); + +// ── ⑤ 取数层边界:解析出的 sessionId 真的进了请求,回包落进它自己的桶 ──── +stubFetch({ + data: { sessionId: BACKTHINK_SESSION_ID, graph: roundGraph("sess-backthink-9", BACKTHINK_SESSION_ID) }, +}); +const resp = await adminFlowGraph(btPage.sessionId); +ok( + calls[0].url.includes("/admin/flow/graph") && calls[0].url.includes("sessionId=sess-backthink"), + `取数请求必须带上选中的会话:${calls[0].url}` +); +same(calls[0].method, "GET", "取数是只读 GET"); +if (resp.graph) useAppStore.getState().setFlowGraph(resp.graph, btPage.sessionId); +same( + useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id, + "sess-backthink-9", + "回包按选中会话写进它的桶(hook 里就是这么写的)" +); +same( + useAppStore.getState().flowGraphs[MAIN_SESSION_ID]?.id, + "web-9", + "给后台思考取数绝不顶掉主会话的图" +); + +// ── ⑥ 标签:已知会话给人话名,未知会话原样显示 id ───────────────────────── +same(flowSessionLabel(MAIN_SESSION_ID), "Web 主会话", "web 的人话标签"); +same(flowSessionLabel(BACKTHINK_SESSION_ID), "后台思考会话", "sess-backthink 的人话标签"); +same(flowSessionLabel("web:7f3a"), "次级会话", "次级会话的人话标签"); +same( + flowSessionLabel("sess-group-1"), + "sess-group-1", + "未知会话原样显示 id,绝不猜一个更好听的名字" +); +same(flowSessionLabel(MAIN_SESSION_ID, "主会话"), "主会话", "有标题时用标题"); + +globalThis.fetch = originalFetch; +globalThis.localStorage = originalLocalStorage; + +console.log(`flow session logic test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/flow/flowSession.ts b/frontend/web/src/admin/modules/flow/flowSession.ts new file mode 100644 index 0000000..905b645 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowSession.ts @@ -0,0 +1,112 @@ +import type { FlowGraph, SessionMeta } from "../../../lib/types"; +import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache"; + +/** + * Flow 页**会话选择器**的纯逻辑(切片 FV1;design/19 §2 活动状态)。 + * + * 背景(只读调查 agent-283 第 1 问):后端端点本来就支持**任意会话** + * (`GET /api/v1/admin/flow/graph?sessionId=`,白名单见 + * `cmd/cyrene/wire_runtime.go#flowSessionAllowed`),缺口纯在前端 —— 页面固定看 + * 「激活会话」的图(兼容字段 `flowGraph`),于是 `sess-backthink` 只能在后台思考页看。 + * + * 三条口径: + * ① 选项**只列白名单内**的会话:固定 `web`(主会话)、`sess-backthink`(自主思考), + * 加上 `web:` 开头的次级会话。白名单外的会话(群聊 / 陌生人的 `sess-*`)永远没有图 + * ⇒ 不列,绝不用一份假承诺骗用户去选; + * ② 取数用的 sessionId 由**选择器的选中值**决定,图取自 `flowGraphs[选中会话]`。 + * 绝不回落到按 `activeSessionId` 取图的兼容字段 `flowGraph` —— 那样「切了也白切」; + * ③ 选中值不在选项里(会话被删 / 会话列表还没加载)⇒ 回落主会话 `web`, + * 绝不出现「选择器显示 A、图却取自 B」。 + * + * 从组件里抽成纯函数(同 `flowHistory.ts` 的手法):本项目没有 jsdom,store 驱动的组件 + * 渲染不出真实 state,而「用哪个会话取数」这条判定必须在逻辑测试里钉得死。 + */ + +/** 会话选择器的一项。 */ +export interface FlowSessionOption { + id: string; + label: string; +} + +/** Flow 页的取数接线:`sessionId` 给取数层(hook / REST)用,`graph` 是屏幕上要画的图。 */ +export interface FlowPageData { + /** 取数用的会话 id(`useFlowSnapshot` / `useFlowTurns` / REST 的 `sessionId`)。 */ + sessionId: string; + /** 该会话的图(取自**它自己的**图桶;没有就是没有,绝不编图)。 */ + graph: FlowGraph | null; + /** 会话选择器选项(只含白名单内会话)。 */ + options: FlowSessionOption[]; +} + +/** 会话 id 是否在发布会话白名单内 —— 与后端 `flowSessionAllowed` 逐条对齐 + * (`web` / `web:*` / `sess-backthink`)。两份白名单必须一致:前端多列一个会话 + * 就是「选了永远空态」,少列一个就是「有图却不让看」。 */ +export function isFlowSessionAllowed(sessionId: string): boolean { + if (!sessionId) return false; + if (sessionId === MAIN_SESSION_ID || sessionId === BACKTHINK_SESSION_ID) return true; + return isSubSessionId(sessionId); +} + +/** `web:` 前缀的**次级会话**。裸前缀 `web:`(没有后缀)不算会话 —— 后端读取层同样 + * 拒绝它(`internal/runtime/sessionread` 的用例 `{"web:", false}`)。 */ +function isSubSessionId(sessionId: string): boolean { + return ( + sessionId.startsWith(`${MAIN_SESSION_ID}:`) && + sessionId.length > MAIN_SESSION_ID.length + 1 + ); +} + +/** 会话 id → 人话标签(选择器 / 说明条上「这是哪个会话」必须看得懂)。有标题用标题, + * 否则用已知会话的人话名;未知会话原样显示 id,绝不猜一个更好听的名字。 */ +export function flowSessionLabel(sessionId: string, title?: string): string { + const named = title?.trim(); + if (named) return named; + if (sessionId === BACKTHINK_SESSION_ID) return "后台思考会话"; + if (sessionId === MAIN_SESSION_ID) return "Web 主会话"; + if (isSubSessionId(sessionId)) return "次级会话"; + return sessionId; +} + +/** 选择器选项:固定 `web` 与 `sess-backthink` 恒在(主会话是固定 id、后台思考会话由后端 + * 创建,都不依赖 `GET /sessions` 的返回),次级会话按创建时间**倒序**(最近用的先选到)。 + * 文案一律带 `(id)`:切会话的前提是用户认得出自己切到了哪个会话。 */ +export function flowSessionOptions(sessions: Record): FlowSessionOption[] { + const subs = Object.values(sessions) + .filter((s) => isSubSessionId(s.id)) + .sort((a, b) => b.createdAt - a.createdAt); + const options: FlowSessionOption[] = [ + optionOf(MAIN_SESSION_ID, sessions[MAIN_SESSION_ID]?.title), + optionOf(BACKTHINK_SESSION_ID, sessions[BACKTHINK_SESSION_ID]?.title), + ]; + for (const s of subs) options.push(optionOf(s.id, s.title)); + return options; +} + +/** 一项选项(人话标签 + id)。 */ +function optionOf(sessionId: string, title?: string): FlowSessionOption { + return { id: sessionId, label: `${flowSessionLabel(sessionId, title)}(${sessionId})` }; +} + +/** 选中值 → 真正用于取数的 sessionId。选中值不在选项里(会话被删 / 列表未加载 / + * 选中了白名单外的东西)⇒ 回落主会话 `web`(= 默认选中),绝不出现「显示 A 取 B」。 */ +export function resolveFlowSessionId( + picked: string, + options: readonly FlowSessionOption[], +): string { + return options.some((o) => o.id === picked) ? picked : MAIN_SESSION_ID; +} + +/** Flow 页的取数接线(纯函数):会话列表 + 图桶 + 选择器选中值 ⇒ + * 「用哪个 sessionId 取数 / 画哪张图 / 有哪些选项」。 + * + * `graph` **只看选中会话自己的图桶**,绝不回落到兼容字段 `flowGraph`(它按 + * `activeSessionId` 取 ⇒ 切了会话图也不变,本切片要修的就是这个洞)。 */ +export function flowPageData( + sessions: Record, + flowGraphs: Record, + picked: string, +): FlowPageData { + const options = flowSessionOptions(sessions); + const sessionId = resolveFlowSessionId(picked, options); + return { sessionId, graph: flowGraphs[sessionId] ?? null, options }; +} diff --git a/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts b/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts new file mode 100644 index 0000000..f532ea3 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/flowSessionPicker.render.test.ts @@ -0,0 +1,215 @@ +// Flow 页**会话选择器**与「泳道 0」文案的**真实渲染**测试(切片 FV1)。 +// +// 为什么需要它:① 选择器必须**只列白名单内**会话且默认选中主会话 web(列了白名单外的 +// 会话就是假承诺;默认选错会话就是换了主人的页面);② 统计卡「泳道」在 0 泳道时**不得** +// 显示 `0` —— 数字紧挨标签会被读成「泳道 0」(主人截图里的误读),事实是「本回合没有 +// 派发子代理」;③ 没有图时仍是诚实空态(绝不画假图)。 +// +// 逻辑测试(flowSession.test.ts)钉住「选哪个会话 ⇒ 用哪个 sessionId 取数」;这里钉住 +// 「界面真的按它渲染」。本项目没有 jsdom ⇒ 渲染的对象是 **props 驱动**的组件(同 +// flowHistory.render.test.ts)。 +// +// 跑法: +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/flowSessionPicker.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-flow-session-model.test.cjs \ +// && node /tmp/cyrene-flow-session-model.test.cjs + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { FlowGraph, SessionMeta } from "../../../lib/types"; +import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache"; +import { FlowBody } from "../Flow"; +import { FlowSessionPicker } from "./FlowSessionPicker"; +import { flowSessionOptions } from "./flowSession"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + checks += 1; + if (got !== want) throw new Error(`FAILED: ${what} — got ${String(got)}, want ${String(want)}`); +} + +function has(haystack: string, needle: string): boolean { + return haystack.includes(needle); +} + +/** SSR 渲染出的 `