feat(admin): Flow 页会话选择器 + 修「泳道 0」文案(纯前端)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 新增 flow/flowSession.ts(白名单判定与后端 flowSessionAllowed 逐条对齐 + 选项构造 + 选中回落 web + 取数接线 flowPageData)+ FlowSessionPicker.tsx
- Flow.tsx:图不再读兼容字段 s.flowGraph / activeSessionId,改由 flowPageData 从 flowGraphs[选中会话] 取;选择器只在给了 onSelectSession 时渲染(既有调用形态不变)
- 修文案:0 泳道时统计卡显示「—」+「本回合未派发子代理」(原先 0 紧挨「泳道」会被读成「泳道 0」)
- 标签单一出处:FlowSnapshotHint 改用 flowSessionLabel(避免与选择器漂移)
- 测试 33 + 21 checks;变异:取图改回主会话桶 / 选项去掉 sess-backthink / 泳道卡还原 / 放行白名单外会话 ⇒ 全红
- 如实登记覆盖缺口:组件调用点那一行无法自动化(无 jsdom,zustand SSR 快照取不到真实 state)⇒ 有一条变异打不红,靠「取图路径唯一 + 注释」缓解
- ⚠️ 行为变化(任务书要求):默认选中固定为 web,不再随 activeSessionId 漂移(原先跟着激活会话走)——一行可改
This commit is contained in:
AskaEth committed 2026-10-05 20:00:01 +08:00
1 parent e792fe655b
commit 8e56163418
6 files changed
+637 -24

No files matched your search

+44 -14
View File
@@ -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 (
<div className="space-y-5">
@@ -97,14 +118,23 @@ export function FlowBody({
subtitle="实时 输入 → 思考 → 工具 → 输出 主干;并行子代理独立泳道;点节点看参数 / 结果 / 实时日志;可按轮回放历史"
/>
{onSelectTurn ? (
{onSelectTurn || onSelectSession ? (
<Surface className="space-y-2">
<FlowTurnPicker
turns={turns}
selectedTurnId={selectedTurnId}
onSelect={onSelectTurn}
loading={historyLoading}
/>
{onSelectSession ? (
<FlowSessionPicker
options={sessionOptions ?? []}
value={sessionId}
onSelect={onSelectSession}
/>
) : null}
{onSelectTurn ? (
<FlowTurnPicker
turns={turns}
selectedTurnId={selectedTurnId}
onSelect={onSelectTurn}
loading={historyLoading}
/>
) : null}
{showingHistory && shown ? <FlowHistoryHint turnId={shown.id} /> : null}
</Surface>
) : null}
@@ -129,8 +159,8 @@ export function FlowBody({
<StatCard
icon={<Icon name="trend" size={18} />}
label="泳道"
value={grouping?.lanes.length ?? 0}
sub="并行子代理分支"
value={laneCount > 0 ? laneCount : "—"}
sub={laneCount > 0 ? "并行子代理分支" : "本回合未派发子代理"}
tone="accent"
/>
<StatCard
@@ -0,0 +1,49 @@
import type { FlowSessionOption } from "./flowSession";
/**
* 会话选择器(切片 FV1;design/19 §2)—— 「看哪个会话的链路图」变成可切的。
* props 驱动(不读 store),因此可被真实渲染测试直接喂输入(同 `FlowTurnPicker`)。
*
* 选项**只含发布会话白名单内**的会话(web / sess-backthink / web: 次级会话)。白名单外的
* 会话(群聊 / 陌生人的 `sess-*`)永远没有链路图,列出来只会让用户点了看到空态 ——
* 假承诺不如不列,诚实说明写在下面那行小字里。
*/
export function FlowSessionPicker({
options,
value,
onSelect,
}: {
options: readonly FlowSessionOption[];
value: string;
onSelect: (sessionId: string) => void;
}): 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-session-select" className="font-medium">
会话
</label>
<select
id="flow-session-select"
value={value}
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)]"
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
<span className="text-[11px] text-[var(--mu-text-lo)]">
换会话后,本页的图 / 轮次列表都跟着切
</span>
</div>
<p className="text-[11px] text-[var(--mu-text-lo)]">
只列发布会话白名单内的会话(主会话 web / 自主思考 sess-backthink / 你的次级会话
web:*)。白名单之外的会话(群聊 / 陌生人的 sess-*)永远没有链路图,因此不在这个列表里
—— 那不是缺陷。
</p>
</div>
);
}
@@ -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 (
<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" ? (
@@ -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<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 并回一份假响应(同 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`);
@@ -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<string, SessionMeta>): 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<string, SessionMeta>,
flowGraphs: Record<string, FlowGraph>,
picked: string,
): FlowPageData {
const options = flowSessionOptions(sessions);
const sessionId = resolveFlowSessionId(picked, options);
return { sessionId, graph: flowGraphs[sessionId] ?? null, options };
}
@@ -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<T>(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 渲染出的 `<select>` 里被标为选中的那个 option 的 value(React 只把 `selected`
* 标在与 select 的 value 匹配的那一项上)。 */
function selectedOptionValue(markup: string): string {
for (const tag of markup.match(/<option[^>]*>/g) ?? []) {
if (!tag.includes("selected")) continue;
const m = /value="([^"]*)"/.exec(tag);
return m ? m[1] : "";
}
return "";
}
/** 统计卡「泳道」那一格的 value(该卡在标签之后第一个 `text-2xl` 的 div)。 */
function laneStatValue(markup: string): string {
const at = markup.indexOf(">泳道<");
if (at === -1) return "";
const m = /text-2xl font-semibold leading-tight">([^<]*)</.exec(markup.slice(at));
return m ? m[1] : "";
}
const sessions: Record<string, SessionMeta> = {
[MAIN_SESSION_ID]: { id: MAIN_SESSION_ID, type: "main", createdAt: 1 },
"web:sub-1": { id: "web:sub-1", type: "sub", createdAt: 200, title: "写周报" },
"sess-backthink": { id: BACKTHINK_SESSION_ID, type: "sub", createdAt: 300 },
"sess-group-1": { id: "sess-group-1", type: "sub", createdAt: 400 },
};
const options = flowSessionOptions(sessions);
/** 主干三拍、**没有**并行子代理 ⇒ 0 泳道。 */
const trunkGraph: FlowGraph = {
id: "web-21",
sessionId: MAIN_SESSION_ID,
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" },
],
};
/** 主干 + 一条并行子代理泳道(子代理根 + 它的一步)⇒ 1 泳道。 */
const laneGraph: FlowGraph = {
id: "web-22",
sessionId: MAIN_SESSION_ID,
nodes: [
{ id: "input", label: "哨兵输入", kind: "input", status: "done" },
{ id: "llm-1", label: "哨兵思考", kind: "llm", status: "done" },
{ id: "sub-1", label: "哨兵子代理", kind: "subagent", status: "running" },
{ id: "tool-1", label: "哨兵子代理工具", kind: "tool", status: "running" },
{ id: "output", label: "哨兵输出", kind: "output", status: "running" },
],
edges: [
{ from: "input", to: "llm-1" },
{ from: "llm-1", to: "sub-1" },
{ from: "sub-1", to: "tool-1" },
{ from: "tool-1", to: "output" },
],
};
/** 后台思考会话的一张图(哨兵节点,用来验证「传谁的图就画谁」)。 */
const btGraph: FlowGraph = {
id: "sess-backthink-4",
sessionId: BACKTHINK_SESSION_ID,
nodes: [{ id: "input", label: "后台思考哨兵", kind: "input", status: "done" }],
edges: [],
};
// ── ① 选择器:默认选中主会话 web ─────────────────────────────────────────
const picker = renderToStaticMarkup(
createElement(FlowSessionPicker, { options, value: MAIN_SESSION_ID, onSelect: () => {} })
);
same(selectedOptionValue(picker), MAIN_SESSION_ID, "默认选中主会话 web(不得默认选别的会话)");
ok(has(picker, "Web 主会话(web)"), "主会话选项要能认出来");
ok(
has(picker, "后台思考会话(sess-backthink)"),
"选项必须含 sess-backthink(否则自主思考的图永远看不到,正是本切片要修的)"
);
ok(has(picker, "写周报(web:sub-1)"), "web: 次级会话要列出(带标题 + id)");
// ── ② 选项里不含白名单外会话 ────────────────────────────────────────────
ok(!has(picker, "sess-group-1"), "群聊会话绝不进选项(白名单外永远没有图)");
same((picker.match(/<option/g) ?? []).length, options.length, "渲染出的选项数与选项列表一致");
const btPicker = renderToStaticMarkup(
createElement(FlowSessionPicker, { options, value: BACKTHINK_SESSION_ID, onSelect: () => {} })
);
same(selectedOptionValue(btPicker), BACKTHINK_SESSION_ID, "选中值跟着状态走(可切到后台思考)");
// ── ③ FlowBody:给了 onSelectSession 才渲染会话选择器,画对应会话的图 ────
const body = renderToStaticMarkup(
createElement(FlowBody, {
graph: trunkGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
sessionOptions: options,
onSelectSession: () => {},
})
);
ok(has(body, 'id="flow-session-select"'), "Flow 页必须真的渲染会话选择器");
same(selectedOptionValue(body), MAIN_SESSION_ID, "Flow 页默认看主会话的图");
const bodyBt = renderToStaticMarkup(
createElement(FlowBody, {
graph: btGraph,
sessionId: BACKTHINK_SESSION_ID,
source: "snapshot",
sessionOptions: options,
onSelectSession: () => {},
})
);
same(selectedOptionValue(bodyBt), BACKTHINK_SESSION_ID, "切到后台思考后选择器如实反映");
ok(has(bodyBt, "后台思考哨兵"), "传哪张图就画哪张(图不由选择器现算,而是取数层给的)");
const legacy = renderToStaticMarkup(
createElement(FlowBody, { graph: trunkGraph, sessionId: MAIN_SESSION_ID, source: "live" })
);
ok(
!has(legacy, 'id="flow-session-select"'),
"没有 onSelectSession 时不渲染会话选择器(既有调用形态/断言不受影响)"
);
// ── ④ 0 泳道时统计卡不再显示 `0`(B 修复) ──────────────────────────────
const zeroLanes = renderToStaticMarkup(
createElement(FlowBody, {
graph: trunkGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
sessionOptions: options,
onSelectSession: () => {},
})
);
same(
laneStatValue(zeroLanes),
"—",
"0 泳道时统计卡显示「—」而不是 `0`(数字紧挨「泳道」会被读成「泳道 0」)"
);
ok(has(zeroLanes, "本回合未派发子代理"), "0 泳道时如实说明本回合没有派发子代理");
ok(
!has(zeroLanes, "并行子代理分支"),
"0 泳道时不得再显示「并行子代理分支」(那正是被误读成「泳道 0」的那句)"
);
const oneLane = renderToStaticMarkup(
createElement(FlowBody, {
graph: laneGraph,
sessionId: MAIN_SESSION_ID,
source: "live",
sessionOptions: options,
onSelectSession: () => {},
})
);
same(laneStatValue(oneLane), "1", "有泳道时统计卡照旧显示数字");
ok(has(oneLane, "并行子代理分支"), "有泳道时保留原说明(数据与渲染逻辑未动)");
ok(has(oneLane, "哨兵子代理"), "泳道根节点仍画在图里(只改文案,不动 lanes.ts 的渲染)");
// ── ⑤ 没有图 ⇒ 诚实空态(不画假图) ─────────────────────────────────────
const empty = renderToStaticMarkup(
createElement(FlowBody, {
graph: null,
sessionId: BACKTHINK_SESSION_ID,
source: null,
sessionOptions: options,
onSelectSession: () => {},
})
);
ok(has(empty, "暂无流程"), "该会话没有图 ⇒ 空态文案(选择器仍在,可切回有图的会话)");
ok(!has(empty, "哨兵"), "空态绝不画出任何节点(不画假图)");
ok(has(empty, 'id="flow-session-select"'), "空态下也要留着选择器,用户能切走");
console.log(`flow session render test: ${checks} checks passed`);