diff --git a/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx b/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx new file mode 100644 index 0000000..2ed6ba9 --- /dev/null +++ b/frontend/web/src/admin/modules/OwnerAccountsEditor.tsx @@ -0,0 +1,330 @@ +import { useMemo } from "react"; +import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; +import { Button } from "../../components/primitives"; +import { + accountCandidates, + flattenAccounts, + groupAccounts, + platformIsConfigured, + platformOptions, + type OwnerAccountGroup, +} from "./ownerAccounts"; + +/** + * 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用 + * 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置 + * 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为 + * `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。 + * + * 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的 + * `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。 + */ +export function OwnerAccountsEditor({ + accounts, + disabled, + platforms, + channels, + onChange, +}: { + accounts: OwnerAccount[]; + disabled: boolean; + /** 权威平台枚举(`GET /api/v1/meta/enums` 的 `platforms`);读取失败可为空。 */ + platforms: EnumItem[] | undefined; + /** 已配置渠道账号(`GET /api/v1/admin/channels`),用于给出账号 id 候选。 */ + channels: AdminChannelRecord[] | null; + onChange: (next: OwnerAccount[]) => void; +}): React.ReactNode { + const groups = useMemo(() => groupAccounts(accounts), [accounts]); + const options = useMemo( + () => platformOptions(platforms, groups.map((g) => g.platform)), + [platforms, groups] + ); + + // 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态, + // 界面上的分组只是它的投影,两者不会各说各话。 + const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next)); + + const patchGroup = (i: number, patch: Partial): void => + emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g))); + const addAccount = (i: number): void => + patchGroup(i, { accountIds: [...groups[i].accountIds, ""] }); + const removeAccount = (i: number, j: number): void => + patchGroup(i, { accountIds: groups[i].accountIds.filter((_, k) => k !== j) }); + const patchAccount = (i: number, j: number, value: string): void => + patchGroup(i, { + accountIds: groups[i].accountIds.map((id, k) => (k === j ? value : id)), + }); + const removeGroup = (i: number): void => emit(groups.filter((_, idx) => idx !== i)); + const addGroup = (): void => emit([...groups, { platform: "", accountIds: [""] }]); + + return ( +
+ + {groups.length === 0 ? ( +

+ 尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。 +

+ ) : null} + {groups.map((g, i) => ( + patchGroup(i, { platform: v })} + onAccount={(j, v) => patchAccount(i, j, v)} + onAddAccount={() => addAccount(i)} + onRemoveAccount={(j) => removeAccount(i, j)} + onRemoveGroup={() => removeGroup(i)} + /> + ))} +
+ ); +} + +/** 头部:标题 + 说明(写盘格式不变)+「+ 添加平台」,以及「一组都没有」的诚实提示。 */ +function WhitelistHeader({ + disabled, + onAddGroup, +}: { + disabled: boolean; + onAddGroup: () => void; +}): React.ReactNode { + return ( + <> +
+ + 渠道账号白名单(命中即走 owner 车道)· 一个平台一条,可加多个账号 ID + + +
+

+ 保存时仍是扁平数组{' '} + {'accounts: [{platform, accountId}, …]'} + (owner.yaml 格式不变);平台与账号 ID 都填了的条目才会写盘。 +

+ + ); +} + +const inputCls = + "w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"; + +interface PlatformGroupRowProps { + group: OwnerAccountGroup; + index: number; + options: EnumItem[]; + candidates: string[]; + known: boolean; + disabled: boolean; + onPlatform: (v: string) => void; + onAccount: (j: number, v: string) => void; + onAddAccount: () => void; + onRemoveAccount: (j: number) => void; + onRemoveGroup: () => void; +} + +/** 一个平台分组:平台下拉 + 该平台下的账号 ID 多行(每行带候选,可删)。 */ +function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode { + const { group, index, options, candidates, known, disabled } = props; + const emptyCount = group.accountIds.filter((id) => id.trim() === "").length; + return ( +
+ + + {group.accountIds.length === 0 ? ( +

+ 该平台还没有账号 ID:保存时这一组会被忽略(不会写入 owner.yaml)。 +

+ ) : null} + + {group.accountIds.map((accountId, j) => ( + props.onAccount(j, v)} + onRemove={() => props.onRemoveAccount(j)} + /> + ))} + +
+ + {emptyCount > 0 ? ( + + 有 {emptyCount} 条空白账号 ID:保存时会被忽略。 + + ) : null} +
+
+ ); +} + +/** 分组头:平台下拉(权威枚举)+ 该平台的账号条数 + 「删除该平台」。 */ +function PlatformPicker({ + group, + options, + disabled, + onPlatform, + onRemoveGroup, +}: { + group: OwnerAccountGroup; + options: EnumItem[]; + disabled: boolean; + onPlatform: (v: string) => void; + onRemoveGroup: () => void; +}): React.ReactNode { + return ( +
+ + + {group.platform === "" + ? "未选平台:这组不会被写盘" + : `该平台下的账号 ID(${group.accountIds.length} 条)`} + + +
+ ); +} + +interface AccountIdRowProps { + listId: string; + accountId: string; + candidates: string[]; + platform: string; + known: boolean; + disabled: boolean; + onValue: (v: string) => void; + onRemove: () => void; +} + +/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */ +function AccountIdRow({ + listId, + accountId, + candidates, + platform, + known, + disabled, + onValue, + onRemove, +}: AccountIdRowProps): React.ReactNode { + return ( +
+
+ + +
+ {candidates.length ? ( + + ) : ( + + )} +
+ ); +} + +/** 候选账号:``(键盘可筛)+ 可点的标签(看得见、点一下就填)。 */ +function AccountCandidates({ + listId, + candidates, + disabled, + onPick, +}: { + listId: string; + candidates: string[]; + disabled: boolean; + onPick: (v: string) => void; +}): React.ReactNode { + return ( + <> + + {candidates.map((c) => ( + +
+ 从该平台已配置账号里选: + {candidates.map((c) => ( + + ))} +
+ + ); +} + +/** 没有候选时给一句诚实的解释(而不是假装有候选或静静空着)。 */ +function CandidateHint({ platform, known }: { platform: string; known: boolean }): React.ReactNode { + if (!platform) return null; + return ( +

+ {known + ? "该平台暂无已配置账号:先在「渠道管理」里配好账号,这里就能直接选。" + : "该平台未在渠道管理里注册:账号 ID 只能手填。"} +

+ ); +} diff --git a/frontend/web/src/admin/modules/System.tsx b/frontend/web/src/admin/modules/System.tsx index 317ca8d..adc3feb 100644 --- a/frontend/web/src/admin/modules/System.tsx +++ b/frontend/web/src/admin/modules/System.tsx @@ -157,9 +157,17 @@ export function SystemModule(): React.ReactNode { } title="环境变量" /> - - - + {/* 键名必须与 `.env` / `.env.example` **逐字一致**:面板是只读事实卡, + 用户会照着这里抄键名去改 .env —— 写错一个字母就是静默不生效(AGENTS §10 E6)。 + 真键:HOST / PORT / ADMIN_USERNAME / ADMIN_PASSWORD(见 .env.example)。 */} + + + + +

+ 以上为 .env.example 的默认值(面板无 env 读取端点,不是运行实例的实时值); + 改值请编辑 .env 后重启服务。 +

diff --git a/frontend/web/src/admin/modules/Users.tsx b/frontend/web/src/admin/modules/Users.tsx index 6db35df..2de176b 100644 --- a/frontend/web/src/admin/modules/Users.tsx +++ b/frontend/web/src/admin/modules/Users.tsx @@ -3,15 +3,23 @@ import { useAsyncData } from "../../lib/useAsyncData"; import { adminCreateUser, adminGetOwnerProfile, + adminListChannels, adminListUsers, adminIssueApiKey, adminRevokeApiKey, adminSaveOwnerProfile, + fetchEnums, getStoredRole, } from "../../lib/api"; -import type { AdminUserRecord, OwnerAccount, OwnerProfile } from "../../lib/types"; +import type { + AdminChannelRecord, + AdminUserRecord, + EnumItem, + OwnerProfile, +} from "../../lib/types"; import { useAuditStore } from "../audit"; import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components"; +import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; import { Button, Surface, Tag } from "../../components/primitives"; const roleTone: Record = { @@ -138,6 +146,10 @@ function UsersTableSection({ * 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode { const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []); + // 平台下拉取权威枚举(未接线/失败时回落本仓镜像的标签表,取值仍原样呈现); + // 账号 id 候选取该平台已配置的渠道账号,避免手打错一位就静默失效。 + const enums = useAsyncData(fetchEnums, []); + const channels = useAsyncData(adminListChannels, []); const log = useAuditStore((s) => s.log); const [profile, setProfile] = useState(null); const [notice, setNotice] = useState(null); @@ -181,7 +193,15 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {fail ? {fail} : null} {notice ? {notice} : null} {profile ? ( - + ) : (

{loading ? "读取中…" : "未加载到主人档案"}

)} @@ -193,17 +213,23 @@ interface OwnerProfileFormProps { profile: OwnerProfile; disabled: boolean; busy: boolean; + platforms: EnumItem[] | undefined; + channels: AdminChannelRecord[] | null; onChange: (next: OwnerProfile) => void; onSave: () => void; } /** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */ -function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerProfileFormProps): React.ReactNode { +function OwnerProfileForm({ + profile, + disabled, + busy, + platforms, + channels, + onChange, + onSave, +}: OwnerProfileFormProps): React.ReactNode { const patch = (p: Partial): void => onChange({ ...profile, ...p }); - const patchAccount = (i: number, p: Partial): void => - patch({ accounts: profile.accounts.map((a, idx) => (idx === i ? { ...a, ...p } : a)) }); - const removeAccount = (i: number): void => patch({ accounts: profile.accounts.filter((_, idx) => idx !== i) }); - const appendAccount = (): void => patch({ accounts: [...profile.accounts, { platform: "", accountId: "" }] }); return ( <> @@ -213,12 +239,12 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr patch({ defaultPersona: v })} disabled={disabled} /> patch({ identityFile: v })} disabled={disabled} /> - patch({ accounts })} />
关系备注 @@ -242,46 +268,6 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr ); } -interface AccountWhitelistEditorProps { - accounts: OwnerAccount[]; - disabled: boolean; - onPatch: (index: number, patch: Partial) => void; - onRemove: (index: number) => void; - onAppend: () => void; -} - -/** 渠道账号白名单行编辑器:命中此列表的账号走 owner 车道(元数据判定,非提示词)。 */ -function AccountWhitelistEditor({ - accounts, - disabled, - onPatch, - onRemove, - onAppend, -}: AccountWhitelistEditorProps): React.ReactNode { - return ( -
-
- 渠道账号白名单(命中即走 owner 车道) - -
- {accounts.length === 0 ? ( -

尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。

- ) : null} - {accounts.map((a, i) => ( -
- onPatch(i, { platform: v })} disabled={disabled} /> - onPatch(i, { accountId: v })} disabled={disabled} /> - -
- ))} -
- ); -} - /** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */ function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode { const log = useAuditStore((s) => s.log); diff --git a/frontend/web/src/admin/modules/ownerAccounts.test.ts b/frontend/web/src/admin/modules/ownerAccounts.test.ts new file mode 100644 index 0000000..6d016db --- /dev/null +++ b/frontend/web/src/admin/modules/ownerAccounts.test.ts @@ -0,0 +1,174 @@ +// 主人档案「渠道账号白名单」编辑器的测试(主人 2026-10-05 要求)。 +// +// 为什么需要它:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**, +// 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**(主人被当陌生人, +// 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威 +// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。 +// +// 锁三件事: +// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变); +// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉); +// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。 +// +// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/ownerAccounts.test.ts \ +// --bundle --platform=node --format=cjs \ +// --outfile=/tmp/cyrene-owner-accounts.test.cjs \ +// && node /tmp/cyrene-owner-accounts.test.cjs +// +// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 +// 不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; +import { OwnerAccountsEditor } from "./OwnerAccountsEditor"; +import { + accountCandidates, + flattenAccounts, + groupAccounts, + platformIsConfigured, + platformOptions, +} from "./ownerAccounts"; + +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 { + ok( + JSON.stringify(got) === JSON.stringify(want), + `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}` + ); +} + +const PUBLISHED: EnumItem[] = [ + { id: "web", label: "网页" }, + { id: "kook", label: "KOOK" }, + { id: "qq-obv11", label: "QQ" }, + { id: "weixin-ilink", label: "微信" }, + { id: "telegram", label: "Telegram" }, + { id: "webhook", label: "Webhook" }, +]; + +// ── ① 分组 / 展平(纯函数) ───────────────────────────────────────────────────── +{ + const flat: OwnerAccount[] = [ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "web", accountId: "web-user" }, + { platform: "qq-obv11", accountId: "aaaa1111" }, + ]; + const groups = groupAccounts(flat); + same(groups.length, 2, "两个平台 ⇒ 两块(同一平台的多个账号不重复出现平台)"); + same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块"); + same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序"); + + // 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的 + // 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以 + // 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。 + const sorted = (list: OwnerAccount[]): OwnerAccount[] => + [...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`)); + same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目"); + const shape = Object.keys(flattenAccounts(groups)[0]).sort(); + same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)"); + + // 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。 + same( + flattenAccounts([ + { platform: "qq-obv11", accountIds: ["9616b27a", " ", ""] }, + { platform: "", accountIds: ["orphan"] }, + { platform: "kook", accountIds: [] }, + ]), + [{ platform: "qq-obv11", accountId: "9616b27a" }], + "空白账号 / 空白平台 / 空平台组都不写盘" + ); + same( + flattenAccounts([{ platform: " kook ", accountIds: [" 42 "] }]), + [{ platform: "kook", accountId: "42" }], + "两侧空白被去掉(避免写出匹配不上的键)" + ); +} + +// ── ② 下拉选项:权威枚举 + 存量旧值(display-raw) ─────────────────────────────── +{ + const options = platformOptions(PUBLISHED, ["qq-obv11", "discord"]); + ok(options.length === 7, "权威 6 项 + 1 个存量里的未知平台(不重复)"); + same(options[0], { id: "web", label: "网页" }, "权威枚举在最前"); + same(options[6], { id: "discord", label: "discord" }, "未列出的旧值原样保留(display-raw,不被下拉吃掉)"); + ok( + platformOptions(undefined, ["qq-obv11"]).length === 1, + "取不到枚举(未接线/失败)时仍能列出存量平台,不至于把下拉掏空" + ); + ok( + platformOptions(PUBLISHED, []).some((o) => o.id === "qq-obv11" && o.label === "QQ"), + "枚举项用后端给的标签" + ); +} + +// ── ③ 候选账号:来自该平台已配置的渠道账号 ────────────────────────────────────── +{ + const channels: AdminChannelRecord[] = [ + { name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }, { id: "aaaa1111", config: {} }] }, + { name: "kook", connected: false, accounts: [{ id: "", config: {} }] }, + ]; + same(accountCandidates(channels, "qq-obv11"), ["9616b27a", "aaaa1111"], "候选取该平台已配置账号的 id"); + same(accountCandidates(channels, "kook"), [], "空 id 的历史条目不作候选(它没有可用 id)"); + same(accountCandidates(channels, "telegram"), [], "未配置的平台没有候选"); + same(accountCandidates(null, "qq-obv11"), [], "渠道列表没读到时不编候选"); + ok(platformIsConfigured(channels, "qq-obv11"), "已注册平台"); + ok(!platformIsConfigured(channels, "telegram"), "未注册平台"); +} + +// ── ④ 渲染:一个平台一条 + 子按钮 + 候选 ──────────────────────────────────────── +{ + const accounts: OwnerAccount[] = [ + { platform: "qq-obv11", accountId: "9616b27a" }, + { platform: "qq-obv11", accountId: "aaaa1111" }, + { platform: "kook", accountId: "" }, + ]; + const channels: AdminChannelRecord[] = [ + { name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }] }, + ]; + const html = renderToStaticMarkup( + createElement(OwnerAccountsEditor, { + accounts, + disabled: false, + platforms: PUBLISHED, + channels, + onChange: () => {}, + }) + ); + + const groupCount = html.split("data-platform=").length - 1; + same(groupCount, 2, "界面「一个平台一条」:两个平台 ⇒ 两块(同一平台的账号合在一块)"); + ok(html.includes('data-platform="qq-obv11"'), "分组带平台标记,便于核对与测试定位"); + ok(html.includes(" {}, + }) + ); + ok(other.includes("该平台未在渠道管理里注册"), "未注册平台说明「账号 ID 只能手填」"); + ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)"); +} + +console.log(`owner accounts editor test: ${checks} checks passed`); diff --git a/frontend/web/src/admin/modules/ownerAccounts.ts b/frontend/web/src/admin/modules/ownerAccounts.ts new file mode 100644 index 0000000..da1b90f --- /dev/null +++ b/frontend/web/src/admin/modules/ownerAccounts.ts @@ -0,0 +1,97 @@ +import { labelFor } from "../../lib/enums"; +import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types"; + +/** + * 主人档案「渠道账号白名单」的呈现层(主人 2026-10-05 要求):界面**按平台分组** + * (一个平台一条 + 该平台下「+ 添加账号 ID」),**写盘时展平成扁平数组** + * `accounts:[{platform,accountId}…]` —— 后端契约与 `configs/users/owner.yaml` 的 + * 文件格式**一个字都不改**,这里纯粹是呈现层。 + * + * 为什么值得改:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**, + * 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效** + * (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。 + * 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。 + * + * 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。 + */ + +/** 一个平台分组:界面上的一条。 */ +export interface OwnerAccountGroup { + platform: string; + accountIds: string[]; +} + +/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */ +export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] { + const groups: OwnerAccountGroup[] = []; + const byPlatform = new Map(); + for (const a of accounts) { + const platform = a?.platform ?? ""; + let g = byPlatform.get(platform); + if (!g) { + g = { platform, accountIds: [] }; + byPlatform.set(platform, g); + groups.push(g); + } + g.accountIds.push(a?.accountId ?? ""); + } + return groups; +} + +/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何 + * actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略, + * 界面上有对应提示,不是静默丢弃。 */ +export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] { + const out: OwnerAccount[] = []; + for (const g of groups) { + const platform = g.platform.trim(); + for (const raw of g.accountIds) { + const accountId = raw.trim(); + if (!platform || !accountId) continue; + out.push({ platform, accountId }); + } + } + return out; +} + +/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上 + * 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它, + * 否则一次保存就把主人已经生效的配置删了)。 */ +export function platformOptions( + published: readonly EnumItem[] | undefined, + used: readonly string[] +): EnumItem[] { + const out: EnumItem[] = []; + const seen = new Set(); + const push = (id: string, label?: string) => { + if (!id || seen.has(id)) return; + seen.add(id); + out.push({ id, label: label || labelFor("platform", id) }); + }; + for (const p of published ?? []) push(p.id, p.label); + for (const id of used) push(id); + return out; +} + +/** 该平台**已配置账号**的候选(数据来自 `GET /api/v1/admin/channels` 的 accounts): + * 让主人从真凭据里选,不再手打 id(手打错一个字符同样是静默失效)。 */ +export function accountCandidates( + channels: AdminChannelRecord[] | null | undefined, + platform: string +): string[] { + if (!platform) return []; + const record = (channels ?? []).find((c) => c.name === platform); + const ids = (record?.accounts ?? []) + .map((a) => a?.id ?? "") + .filter((id) => id !== ""); + return [...new Set(ids)]; +} + +/** 该平台是否在「已配置账号」里出现过(用于给「候选为空」一句诚实的解释)。 */ +export function platformIsConfigured( + channels: AdminChannelRecord[] | null | undefined, + platform: string +): boolean { + if (!platform) return false; + return (channels ?? []).some((c) => c.name === platform); +} diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index 7f201c5..37e49f7 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -8,6 +8,8 @@ import { Tag } from "./primitives"; import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; import { modeBody } from "./messages"; +import { DocumentList } from "./messages/Documents"; +import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta"; import { UserMessageActions } from "./messages/MessageActions"; import { renderContent } from "../lib/renderContent"; import { splitBubble, type ContentPart } from "../lib/parseContent"; @@ -169,6 +171,22 @@ function PersonaLabel({ name }: { name?: string }): React.ReactNode { ); } +/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段) + * 共用一处,避免「四份渲染各写一遍、日后漂移」。 */ +function UserMeta({ message }: { message: ChatMessage }): React.ReactNode { + return ( +
+ + + +
+ ); +} + /** A single message bubble. Per design/07 §1 the bubble shape is driven by the * message's own `mode`: the same role renders differently under different modes, * and a single session may mix shapes. Falls back to `chat` when mode is absent @@ -288,6 +306,7 @@ function AssistantParaBubble({ ) : null} {full ? : null} {full && switched ? : null} + {full ? : null} {full ? : null} {full ? : null} {full ? message.tools?.map((t, i) => ) : null} @@ -309,7 +328,9 @@ function UserParaBubble({ }): React.ReactNode { return (
-
+
{full && message.image ? ( ) : null} + {full ? : null} {renderContent(text)}
- {full ? : null} + {full ? : null}
); } @@ -391,7 +413,9 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re ) : null; return (
-
+
{message.image ? ( ) : null} + {body}
- +
@@ -430,6 +455,7 @@ function AssistantBubble({ ) : null} {switched ? : null} + {message.tools?.map((t, i) => )} diff --git a/frontend/web/src/components/messages/Documents.tsx b/frontend/web/src/components/messages/Documents.tsx new file mode 100644 index 0000000..5cc8bc9 --- /dev/null +++ b/frontend/web/src/components/messages/Documents.tsx @@ -0,0 +1,84 @@ +import type { MessageDocument } from "../../lib/types"; +import { Icon } from "../Icon"; +import { Tag } from "../primitives"; + +/** + * 一条消息携带的文档附件条(契约 §4.4,design/16)。放在用户气泡里(附件是**用户** + * 这一回合带进来的),逐条显示:文件名、状态、注入侧统计,以及 `status:"unreadable"` + * 时后端给的那句**逐字**中文原因。 + * + * 两条红线: + * - `chars`/`truncated` 是**注入侧**语义(本回合注入模型上下文的字符数 / 是否被预算 + * 截断),不是文件字数与文件截断,所以文案写「注入 N 字」而不是「N 字」; + * - `status` 允许后端新增取值:未知值**原样显示**(display-raw),绝不丢附件、也绝不 + * 把未知当错误。`reason` 只由后端产出,前端不自己拼一句。 + */ + +const STATUS_LABEL: Record = { + extracted: "已读入", + listed: "仅清单", + unreadable: "读不到正文", +}; + +const STATUS_TITLE: Record = { + extracted: "正文已注入本轮上下文", + listed: "只给了模型附件清单,正文未进上下文", + unreadable: "读不到正文:原因见下方(服务端原话)", +}; + +export function DocumentList({ + documents, +}: { + documents?: MessageDocument[]; +}): React.ReactNode { + if (!documents || documents.length === 0) return null; + return ( +
+ {documents.map((doc, i) => ( + + ))} +
+ ); +} + +/** 状态标签色:读不到正文用告警色(用户需要知道这条附件没被读到),其余中性/强调。 */ +function statusTone(status: string): "accent" | "warn" | "neutral" { + if (status === "unreadable") return "warn"; + if (status === "extracted") return "accent"; + return "neutral"; +} + +function DocumentRow({ doc }: { doc: MessageDocument }): React.ReactNode { + const status = doc.status ?? ""; + const label = STATUS_LABEL[status] ?? (status || "附件"); + return ( +
+
+ + + {doc.name} + + + {label} + + {typeof doc.chars === "number" ? ( + 注入 {doc.chars} 字 + ) : null} + {doc.truncated ? 注入已截断 : null} + {typeof doc.size === "number" ? ( + {formatSize(doc.size)} + ) : null} +
+ {doc.reason ? ( +
{doc.reason}
+ ) : null} +
+ ); +} + +/** 文件大小的紧凑展示(后端给的是字节)。 */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} diff --git a/frontend/web/src/components/messages/TurnMeta.tsx b/frontend/web/src/components/messages/TurnMeta.tsx new file mode 100644 index 0000000..5057606 --- /dev/null +++ b/frontend/web/src/components/messages/TurnMeta.tsx @@ -0,0 +1,83 @@ +import type { MessageDelivery } from "../../lib/types"; +import { useAppStore } from "../../store/useAppStore"; +import { Icon } from "../Icon"; +import { Tag } from "../primitives"; + +/** + * 六态投递态与回合分组小标(忙时并行规范草案 §1.1 / §1.4)。纯展示、无副作用: + * `delivery` 缺席(老后端 / REST 历史行)时不渲染任何角标,旧行为逐字不变。 + * + * 为什么要有它:忙时一条消息可能处在「排队中」或「已受理·回答中(灰态)」——两者 + * 语义不同(③ 她还没开始理你这条;④ 这条已经在被处理的那一轮里了),用户必须能 + * 一眼分清;被拒时还要明确说「未送达」而不是让气泡静静躺着。 + */ +export function DeliveryChip({ + delivery, + steerStep, + rejectReason, +}: { + delivery?: MessageDelivery; + steerStep?: number; + rejectReason?: string; +}): React.ReactNode { + switch (delivery) { + case "sending": + return ( + + + 发送中 + + ); + case "queued": + return ( + + 排队中 + + ); + case "answering": + // 灰态:「已读、未答」。执行到第几步由 steer_ack 的 atStep 给出(后端没给就不写)。 + return ( + + {typeof steerStep === "number" + ? `已读 · 回答中(第 ${steerStep} 步)` + : "已读 · 回答中"} + + ); + case "rejected": + // ⑥ 被拒:文案优先用后端给的(如「排队已满(上限 N 条)」),没给就说清后果。 + return {rejectReason ?? "未送达 · 会话正忙"}; + default: + // sent / answered / 缺省:正常气泡,不加角标(不制造噪声)。 + return null; + } +} + +/** 灰态时用户气泡的底色:正文区转灰表示「已读、未答」,收口后回到正常的强调色。 */ +export function userBubbleClass(delivery?: MessageDelivery): string { + return delivery === "answering" + ? "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] ring-1 ring-[var(--mu-glass-border)]" + : "liquid-glass-accent text-white"; +} + +/** 回合分组小标:显示这一行属于哪个回合(有绑定记录时补上「并行路 N」)。 + * 它让「回复 ↔ 提问」在真并行下可核对,也是重连后按 `turnId` 分组的落点 + * (规范草案 §1.4 呈现 A / §1.6)。 */ +export function TurnTag({ + turnId, + align = "left", +}: { + turnId?: string; + align?: "left" | "right"; +}): React.ReactNode { + // 该路仍在绑定表里(还在飞)时能拿到会话内路序号;已收口的回合退化成只显示 id。 + const leg = useAppStore((s) => (turnId ? s.turnBindings[turnId]?.leg : undefined)); + if (!turnId) return null; + const short = turnId.length > 12 ? `${turnId.slice(0, 12)}…` : turnId; + return ( +
+ {leg !== undefined && leg > 1 ? `并行路 ${leg} · ` : ""}回合 {short} +
+ ); +} diff --git a/frontend/web/src/components/messages/bubble.render.test.ts b/frontend/web/src/components/messages/bubble.render.test.ts new file mode 100644 index 0000000..bbef105 --- /dev/null +++ b/frontend/web/src/components/messages/bubble.render.test.ts @@ -0,0 +1,133 @@ +// 气泡渲染测试(契约 §4.4 `documents` + 忙时并行规范草案 §1.1 六态 / §1.4 分组小标)。 +// +// 为什么需要它:`documents` 此前在**全链**缺席(DTO → parser → ChatMessage → 气泡), +// IM 附件在 Web 上完全看不见;而「已受理·回答中」的灰态与「排队中」「未送达」的区分 +// 是画出来才算数的事 —— 纯逻辑测试证明不了它们真的出现在气泡上。 +// +// 锁三件事: +// ① `documents` 渲染:文件名、状态、**注入侧**语义(`chars`=注入字符数、`truncated`= +// 注入被预算截断,不能写成「文件 N 字」)、以及 `status:"unreadable"` 时后端给的 +// 那句**逐字**中文原因; +// ② 六态角标:发送中 / 排队中 / 已读·回答中(含 atStep)/ 未送达(含原因);缺省与 +// 已答不加角标,灰态时气泡底色不再是强调色; +// ③ 回合小标:历史行显示所属 `turnId`(有绑定记录时补「并行路 N」)。 +// +// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/components/messages/bubble.render.test.ts \ +// --bundle --platform=node --format=cjs \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-bubble.test.cjs \ +// && node /tmp/cyrene-bubble.test.cjs +// +// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 +// 不 import node:*。 + +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { ChatMessage } from "../../lib/types"; +import { SplitChatBubble } from "../chat"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function has(html: string, needle: string): boolean { + return html.includes(needle); +} + +function render(m: ChatMessage): string { + return renderToStaticMarkup(createElement(SplitChatBubble, { message: m })); +} + +const T = "turn-9f3c1a2b3c4d5e6f"; + +function user(extra: Partial = {}): ChatMessage { + return { id: "m1", role: "user", content: "看看这个附件", createdAt: 1, ...extra }; +} + +// ── ① documents 全链渲染 ──────────────────────────────────────────────────────── +{ + const html = render( + user({ + documents: [ + { name: "年度报告.pdf", mime: "application/pdf", size: 2 * 1024 * 1024, chars: 8000, truncated: true, status: "extracted" }, + { name: "扫描件.pdf", status: "unreadable", reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" }, + { name: "只进了清单.docx", status: "listed" }, + ], + }) + ); + ok(has(html, "年度报告.pdf"), "渲染附件文件名"); + ok(has(html, "扫描件.pdf"), "渲染第二个附件(≤5 条按发送顺序)"); + ok(has(html, "已读入"), "extracted 显示「已读入」"); + ok(has(html, "仅清单"), "listed 显示「仅清单」(正文没进上下文)"); + ok(has(html, "读不到正文"), "unreadable 显示「读不到正文」"); + ok(has(html, "注入 8000 字"), "chars 按**注入侧**语义呈现(不是文件字数)"); + ok(has(html, "注入已截断"), "truncated 按**注入**口径呈现(不是文件被截断)"); + ok(has(html, "2.0 MiB"), "size 渲染成可读体积"); + ok( + has(html, "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"), + "unreadable 的 reason **逐字**渲染(后端给的文案,前端不自己拼)" + ); +} + +// 未知 status 原样呈现(display-raw:后端新增取值时客户端不丢附件、不报错)。 +{ + const html = render(user({ documents: [{ name: "x.bin", status: "quarantined" }] })); + ok(has(html, "x.bin"), "未知 status 的附件仍然渲染"); + ok(has(html, "quarantined"), "未知 status 原样显示"); +} + +// 没有附件时不出现附件条(0 个附件时契约里键就缺席)。 +{ + const html = render(user()); + ok(!has(html, "注入 "), "无附件时不渲染附件条"); +} + +// ── ② 六态角标(缺省 = 已送达/已答,不加角标) ────────────────────────────────── +{ + const plain = render(user()); + ok(!has(plain, "发送中") && !has(plain, "排队中") && !has(plain, "已读"), "缺省投递态不加任何角标"); + ok(has(plain, "liquid-glass-accent"), "正常用户气泡仍是强调色"); + + ok(has(render(user({ delivery: "sending" })), "发送中"), "① sending 显示「发送中」"); + ok(has(render(user({ delivery: "queued" })), "排队中"), "③ queued 显示「排队中」(不是灰态)"); + + const answering = render(user({ delivery: "answering" })); + ok(has(answering, "已读 · 回答中"), "④ answering 显示「已读 · 回答中」"); + ok(!has(answering, "liquid-glass-accent"), "灰态时气泡不再是强调色(正文区转灰)"); + + ok( + has(render(user({ delivery: "answering", steerStep: 2 })), "已读 · 回答中(第 2 步)"), + "steer_ack 的 atStep 作为灰态小字" + ); + + const rejected = render(user({ delivery: "rejected", rejectReason: "会话正忙:排队已满(上限 5 条),这条未送达" })); + ok(has(rejected, "未送达"), "⑥ rejected 说清「未送达」"); + ok(has(rejected, "排队已满(上限 5 条)"), "⑥ 被拒原因用后端给的文案"); + + ok(has(render(user({ delivery: "answered" })), "看看这个附件"), "⑤ answered 是正常气泡(内容照常)"); + ok(!has(render(user({ delivery: "answered" })), "回答中"), "⑤ answered 不再显示「回答中」"); +} + +// ── ③ 回合小标(turnId)+ 并行路序号 ──────────────────────────────────────────── +{ + const msg = user({ turnId: T }); + const html = render(msg); + ok(has(html, "回合 turn-9f3c1a2…"), "用户气泡带回合小标(可与 turn_begin 配对)"); + + const assistant = render({ id: "a1", role: "assistant", content: "回答", createdAt: 2, turnId: T }); + ok(has(assistant, "回合 turn-9f3c1a2…"), "助手气泡同样带回合小标"); + ok(!has(assistant, "并行路"), "无绑定记录时退回只显示回合 id"); + ok(!has(render(user()), "回合 "), "没有 turnId 的历史行不渲染小标(旧行为不变)"); + + // ⚠️ 诚实边界:「并行路 N」取自 store 的 turnBindings,而 `renderToStaticMarkup` 用的是 + // zustand 的 **server snapshot(初始状态)**,所以这里读不到绑定,无法在静态渲染里断言。 + // 绑定与路序号本身由 parallel.test.ts 驱动真实 store 覆盖(turn_begin 的 leg 落表)。 +} + +console.log(`bubble render test: ${checks} checks passed`); diff --git a/frontend/web/src/components/status.tsx b/frontend/web/src/components/status.tsx index fbec7e2..856c590 100644 --- a/frontend/web/src/components/status.tsx +++ b/frontend/web/src/components/status.tsx @@ -109,16 +109,21 @@ export function WakePath({ } /** 会话编排 busy/pending 轻状态提醒(design/15 §5)。她忙时显示在聊天区顶部; - * **不阻塞输入框**(输入框可继续打字),挂起消息数可见。 */ + * **不阻塞输入框**(输入框可继续打字),挂起消息数与在途路数可见。 + * 忙时并行(design/15 §2.5)后:`activeTurns > 1` 时显式写「并行 N 路」, + * 排队/额度已满这类后端给的原因(`queueNotice`)优先显示 —— 用户才知道「为什么还在等」。 */ export function SessionStatusHint(): React.ReactNode | null { const busy = useAppStore((s) => s.sessionBusy); const pendingCount = useAppStore((s) => s.pendingCount); + const activeTurns = useAppStore((s) => s.activeTurns); + const queueNotice = useAppStore((s) => s.queueNotice); if (!busy) return null; return (
- 她正在忙,有新消息稍后回 + {queueNotice ?? "她正在忙,有新消息稍后回"} + {activeTurns > 1 ? 并行 {activeTurns} 路 : null} {pendingCount > 0 ? {pendingCount} 条待回 : null}
); diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts index 60bbdea..7d7cb41 100644 --- a/frontend/web/src/lib/api.ts +++ b/frontend/web/src/lib/api.ts @@ -32,7 +32,11 @@ export { export { health, login } from "./api/auth"; +export { fetchEnums } from "./api/meta"; +export type { MetaEnums } from "./api/meta"; + export { chatStream, chatSync } from "./api/chat"; +export type { ChatTriggerResult } from "./api/chat"; export { createSubSession, diff --git a/frontend/web/src/lib/api/chat.ts b/frontend/web/src/lib/api/chat.ts index 7f088c4..3daa307 100644 --- a/frontend/web/src/lib/api/chat.ts +++ b/frontend/web/src/lib/api/chat.ts @@ -15,19 +15,42 @@ export function chatSync( }); } +/** `POST /api/v1/chat`(`stream:true`)的响应面(契约 §4.6)。202/200 都属成功, + * 载荷必须**如实读出**:`queued` 表示被排队(不是流式),`parallel`/`turnId` 给出 + * 这一路的权威回合标识(`turn_begin` 帧也会带同一个值)。 */ +export interface ChatTriggerResult { + stream?: true; + /** 显式声明 `parallel:true` 且服务端照准 ⇒ 独立一路并行回合。 */ + parallel?: boolean; + /** 会话忙且落回排队(HTTP 202):`queueDepth` = 仍在 pending 的条数。 */ + queued?: boolean; + queueDepth?: number; + /** 本回合的权威标识(`turn_begin` 帧的帧壳 `turnId` 同值)。 */ + turnId?: string; + /** `clientMsgId` 判重命中(HTTP 200):本条没有新回合,`messageId` 是既有消息的 + * 权威 id(与 WS 的 `chat_duplicate` 帧同一条事实)。 */ + duplicate?: boolean; + messageId?: string; +} + /** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. * `image` (a base64 data: URI or URL) is attached for the vision path; the * gateway's chat body currently has no image field, so an unconfigured backend * simply ignores it. `deviceId` mirrors the client's per-browser id onto the * persisted message (design/16 §4.5). `mode` is the behavior mode this turn is - * sent in (design/01 §3); omitted when empty so the backend falls back to chat. */ + * sent in (design/01 §3); omitted when empty so the backend falls back to chat. + * `clientMsgId` 是本条的幂等键(契约 §4.3):服务端判重命中时回 + * `chat_duplicate{clientMsgId,messageId}`,本端据此把乐观气泡对齐到既有 id。 + * `parallel` 显式请求独立一路(忙时并行规范草案 §2.2);省略 = 行为与今天逐字相同。 */ export function chatStream( sessionId: string, text: string, image?: string, - mode?: Mode -): Promise<{ stream: true }> { - return request<{ stream: true }>("/chat", { + mode?: Mode, + clientMsgId?: string, + parallel?: boolean +): Promise { + return request("/chat", { method: "POST", body: JSON.stringify({ sessionId, @@ -36,6 +59,8 @@ export function chatStream( stream: true, deviceId: getClientId(), ...(mode ? { mode } : {}), + ...(clientMsgId ? { clientMsgId } : {}), + ...(parallel ? { parallel: true } : {}), }), }); } diff --git a/frontend/web/src/lib/api/meta.ts b/frontend/web/src/lib/api/meta.ts new file mode 100644 index 0000000..f4c69c5 --- /dev/null +++ b/frontend/web/src/lib/api/meta.ts @@ -0,0 +1,19 @@ +import type { EnumItem } from "../types"; +import { request } from "./core"; + +/** GET /api/v1/meta/enums 的载荷(design/07 §6,Android 协作 B4):后端发布的权威 + * 枚举表 + 未列出取值的回退策略(`unknown: "display-raw"`)。 + * + * 客户端优先读这里而不是硬编码映射:表由后端一处维护,来源徽标/情绪标签才不会逐 + * 客户端漂移。取不到(未接线 / 离线)时调用方回落到本仓镜像的 `lib/enums.ts` + * 标签表 —— 回落也只影响标签文字,不影响取值的原样呈现。 */ +export interface MetaEnums { + platforms?: EnumItem[]; + moods?: EnumItem[]; + modes?: EnumItem[]; + unknown?: string; +} + +export function fetchEnums(): Promise { + return request("/meta/enums"); +} diff --git a/frontend/web/src/lib/api/sessions.ts b/frontend/web/src/lib/api/sessions.ts index c76b62d..4a687a5 100644 --- a/frontend/web/src/lib/api/sessions.ts +++ b/frontend/web/src/lib/api/sessions.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, Mode, SessionMeta } from "../types"; +import type { ChatMessage, MessageDocument, Mode, SessionMeta } from "../types"; import { request } from "./core"; /** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors @@ -11,6 +11,11 @@ export interface MessageDto { mode?: Mode; mood?: string; image?: string; + /** 文档附件(契约 §4.4):`{name,mime,size,chars,truncated,status,reason?}`, + * ≤5 条/回合,0 个附件时键缺席。**不含正文**;`status:"unreadable"` 时带逐字 + * 中文 `reason`。`chars`/`truncated` 是**注入侧**语义(注入上下文的字符数与是否 + * 被预算截断),不是文件字数/截断。 */ + documents?: MessageDocument[]; thinking?: string; /** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */ platform?: string; @@ -18,6 +23,13 @@ export interface MessageDto { deviceId?: string; /** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */ senderName?: string; + /** IM 来源适配器(`platform:account`);Web 来源为空。 */ + adapterId?: string; + /** 本回合的权威回合标识(`chat_messages.turn_id`):用户行 = 吃进/起它的那一路, + * 助手行 = 产出它的那一路。旧行为空缺席。 */ + turnId?: string; + /** 幂等键(契约 §4.3);空则省略。 */ + clientMsgId?: string; /** The role that spoke this message (design/22 §7-5, backing column * `chat_messages.persona_id`); `''` means "inherit the current default role". */ personaId?: string; @@ -34,10 +46,13 @@ function toChatMessage(m: MessageDto): ChatMessage { mode: m.mode, mood: m.mood, image: m.image, + documents: m.documents, thinking: m.thinking, platform: m.platform, deviceId: m.deviceId, senderName: m.senderName, + turnId: m.turnId, + clientMsgId: m.clientMsgId, personaId: m.personaId, createdAt: m.createdAt, }; diff --git a/frontend/web/src/lib/types/api.ts b/frontend/web/src/lib/types/api.ts index dcdf0b7..7517e12 100644 --- a/frontend/web/src/lib/types/api.ts +++ b/frontend/web/src/lib/types/api.ts @@ -14,3 +14,12 @@ export interface ApiErrorShape { code?: string; message?: string; } + +/** 一条客户端枚举项(`GET /api/v1/meta/enums` 的 `platforms`/`moods`/`modes` 元素): + * `id` 是客户端拿来比对的机器可读值,`label` 是可直接显示的中文默认标签 + * (有 i18n 的客户端可覆盖)。契约保证「表外取值原样展示」,所以消费方绝不能把 + * 未列出的 id 当错误丢掉。 */ +export interface EnumItem { + id: string; + label: string; +} diff --git a/frontend/web/src/lib/types/chat.ts b/frontend/web/src/lib/types/chat.ts index e11994a..ca955fd 100644 --- a/frontend/web/src/lib/types/chat.ts +++ b/frontend/web/src/lib/types/chat.ts @@ -30,6 +30,40 @@ export interface ToolUsed { at: number; } +/** 一条用户消息的投递态(忙时并行规范草案 §1.1 的六态)。缺省 = 旧行为 + * (已送达 / 已答),因此老后端下渲染逐字不变。 + * + * sending ①本地乐观气泡 → sent ②已落库 → queued ③排队中(pending_ack)→ + * answering ④已受理·回答中(灰态:turn_begin / steer_ack)→ answered ⑤已答 + * (该 turnId 的 turn.end,兜底 = 该 turnId 的助手 message_persisted); + * rejected ⑥被拒(chat_busy / pending_full)。 */ +export type MessageDelivery = + | "sending" + | "sent" + | "queued" + | "answering" + | "answered" + | "rejected"; + +/** 一条消息携带的文档附件(契约 §4.4)。**不含正文**:只给名字与「注入侧」的 + * 统计。`chars`/`truncated` 讲的是**本回合注入模型上下文的字符数 / 是否被预算 + * 截断**,不是文件的真实字数与截断 —— 渲染时不能写成「文件 N 字」。 */ +export interface MessageDocument { + name: string; + mime?: string; + size?: number; + /** 本回合注入上下文的字符数(截断时为截断后的值)。 */ + chars?: number; + /** 注入是否被预算截断。 */ + truncated?: boolean; + /** extracted(正文已进上下文)/ listed(只给了清单)/ unreadable(读不到正文)。 + * 契约允许后端新增取值,未知值按 display-raw 原样呈现,绝不当作错误。 */ + status?: string; + /** **只在 `status:"unreadable"` 时下发**的逐字中文原因(服务端常量),客户端 + * 原样渲染、不自己拼。 */ + reason?: string; +} + export interface ChatMessage { id: string; /** The owning session (backend `session_id`). Set when loaded via the sessions @@ -49,6 +83,23 @@ export interface ChatMessage { * as an image card. Absent for legacy persisted messages, so renders fall back * to plain text. */ image?: string; + /** 文档附件列表(契约 §4.4;0 个附件时键缺席,不是 `[]`)。渲染在用户气泡里; + * `status:"unreadable"` 时同屏显示后端给的逐字中文 `reason`。 */ + documents?: MessageDocument[]; + /** 这条消息所属的回合(`chat_messages.turn_id` / 帧壳 `turnId`)。用户行 = 吃进 + * 或起它的那一路;助手行 = 产出它的那一路。忙时并行下靠它把「回复 ↔ 提问」配对、 + * 把两条交错到达的回复分进两个气泡(规范草案 §1.4/§1.7)。旧行为空缺席。 */ + turnId?: string; + /** 本端乐观气泡的幂等键(契约 §4.3;随 `chat.clientMsgId` 上行)。服务端判重命中 + * 时回 `chat_duplicate{clientMsgId,messageId}`,本端据此把气泡对齐到既有 id。 */ + clientMsgId?: string; + /** 六态投递态(规范草案 §1.1);缺省 = 已送达/已答。 */ + delivery?: MessageDelivery; + /** steer_ack 的 `atStep`:这条消息被在飞回合吃进时的模型步序号(1 基)。 + * 只作灰态小字用,不参与判定。 */ + steerStep?: number; + /** ⑥ 被拒的原因小字(队列满 / `chat_busy`),后端文案或码,原样展示。 */ + rejectReason?: string; /** Source-platform tag of the message (design/16 §4.4): "web" for messages that * originate on the Web main session, or an IM channel (e.g. "telegram", * "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The diff --git a/frontend/web/src/lib/types/ws.ts b/frontend/web/src/lib/types/ws.ts index 99c6fdb..889428f 100644 --- a/frontend/web/src/lib/types/ws.ts +++ b/frontend/web/src/lib/types/ws.ts @@ -17,10 +17,64 @@ export interface AwakenPayload { tool?: string | { name: string }; } -/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */ +/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 + * + * 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1)后这些帧还带 `activeTurns`(在途路数)、 + * `mode`(忙碌通道)、`queueDepth` / `reason` / `dropped` / `cancelledTurns` 等计数键。 + * 全部可选:老后端不发则逐字沿用旧行为。`reason` 目前唯一取值 `"parallel_limit"` + * (声明并行却落回排队)。 */ export interface SessionStatePayload { busy?: boolean; pendingCount?: number; + /** 该帧发出时的在途路数(含正在收尾的路)。忙 = activeTurns > 0。 */ + activeTurns?: number; + /** 忙碌通道:thinking / executing / subagent;`session_idle` 为 "idle"。 */ + mode?: string; + /** 仍在 pending、尚未被任何一路接手的条数(已 steer / 已升并行路的不计)。 */ + queueDepth?: number; + /** `pending_ack` 的落回原因,目前仅 `"parallel_limit"`。其它情况键缺席。 */ + reason?: string; + /** `session_interrupted`:scope:"all" 时清空的 pending 数;scope:"current" 恒 0。 */ + dropped?: number; + /** `session_interrupted`:本次真的取消了几路。 */ + cancelledTurns?: number; + /** `pending_full`:队列上限(队列满 ⇒ 深度 = max)。 */ + max?: number; + /** `pending_full`:被拒消息此前已落库/已认领时的权威 id(服务端绝不在此新铸 id)。 */ + messageId?: string; + /** `pending_full`:被拒消息的幂等键(未携带则键缺席)。 */ + clientMsgId?: string; +} + +/** `turn_begin` 下行帧 payload(契约 §3.4.3 / 规范草案 §2.4):每起一路(含续跑路) + * 恰好一帧,把「服务端刚起的这一路 ↔ 它归属的用户消息 id」钉死。帧壳带 `turnId`。 */ +export interface TurnBeginPayload { + /** 服务端**实际**如何起这条路:新回合/续跑 = "turn";声明并行且照准 = "parallel"。 + * 仅供展示,客户端状态机一律用 `messageIds` 是否含本气泡的 id 判定。 */ + kind?: string; + /** 本次启动归属本路的用户消息 id 列表(可为空数组,恒为数组不为 null)。 */ + messageIds?: string[]; + /** 该路的会话内序号(1 基;1 = 唯一/第一路)。 */ + leg?: number; + /** 本次启动后该会话在途路数。 */ + activeTurns?: number; +} + +/** 一路在途回合的客户端绑定记录(由 `turn_begin` 建立,`turn.end` 收口时丢弃)。 + * 它是「消息 ↔ 回合」的唯一真源:灰态靠它把 `messageIds` 置 answering,待落库的 + * 用户行靠它在 `message_persisted` 到达时补上绑定。只存内存,不持久化。 */ +export interface TurnBinding { + turnId: string; + sessionId: string; + /** 该路归属的用户消息 id(`turn_begin.messageIds`,可为空数组)。 */ + messageIds: string[]; + /** "turn"(新回合/续跑)或 "parallel"(并行照准);仅供标注。 */ + kind: string; + /** 会话内路序号(1 基)。 */ + leg: number; + /** 建立时的在途路数。 */ + activeTurns: number; + at: number; } /** approval_notice 下行事件 payload(design/11 §4 主代理代审,裁决 13.4.3): diff --git a/frontend/web/src/lib/useChat.ts b/frontend/web/src/lib/useChat.ts index c74e3c5..80fc4f5 100644 --- a/frontend/web/src/lib/useChat.ts +++ b/frontend/web/src/lib/useChat.ts @@ -1,10 +1,10 @@ import { useCallback, useState } from "react"; -import { chatStream } from "./api"; +import { ApiError, chatStream } from "./api"; import { connectWs, isWsOpen } from "./ws"; import { useAppStore } from "../store/useAppStore"; import { useModeStore } from "../store/useModeStore"; import { getClientId } from "./client"; -import { now } from "./util"; +import { now, uid } from "./util"; /** Drives one user turn: push the user bubble, blanket a streaming assistant * bubble, trigger the backend loop over REST (`stream:true`), and let the WS @@ -16,7 +16,7 @@ import { now } from "./util"; * renders per that message's mode. We read the current UI mode here and pass it * on the turn so the backend persists it alongside the message (A3). */ export function useSendChat(): { - send: (text: string, image?: string) => Promise; + send: (text: string, image?: string, opts?: { parallel?: boolean }) => Promise; sending: boolean; } { const [sending, setSending] = useState(false); @@ -27,10 +27,12 @@ export function useSendChat(): { const appendChunk = useAppStore((s) => s.appendChunk); const addTool = useAppStore((s) => s.addTool); const appendTaskLog = useAppStore((s) => s.appendTaskLog); + const markLocalDelivery = useAppStore((s) => s.markLocalDelivery); + const alignDuplicateMessage = useAppStore((s) => s.alignDuplicateMessage); const mode = useModeStore((s) => s.mode); const send = useCallback( - async (text: string, image?: string) => { + async (text: string, image?: string, opts: { parallel?: boolean } = {}) => { const trimmed = text.trim(); if ((!trimmed && !image) || sending) return; setSending(true); @@ -42,15 +44,41 @@ export function useSendChat(): { // Tag this browser's persistent client id onto the optimistic bubbles so the // source label shows immediately and matches the persisted turn (design/16 §4.5). const clientId = getClientId(); - addUserMessage(trimmed, undefined, image, clientId); + // 幂等键(契约 §4.3):随上行一起发,服务端判重命中会回 `chat_duplicate` 帧或 + // `{duplicate:true,messageId}`(REST 面),本端据此把本地气泡对齐到既有 id —— + // 重发/双发不会多出一条气泡。 + const clientMsgId = uid(); + addUserMessage(trimmed, undefined, image, clientId, clientMsgId); beginAssistant(mode, clientId); try { - await chatStream(sessionId, prompt, image, mode); + const res = await chatStream( + sessionId, + prompt, + image, + mode, + clientMsgId, + opts.parallel + ); + // 响应面得如实消费(契约 §4.6):202 排队不是错误。 + // `pending_ack` 帧是队列深度的真源,这里只做「万一那条帧没到」的兜底标注; + // `duplicate` 同理(同一条事实的 REST 面)。 + if (res.duplicate && res.messageId) { + alignDuplicateMessage(clientMsgId, res.messageId, sessionId); + } else if (res.queued) { + markLocalDelivery("queued"); + } } catch (err: unknown) { + endStream(); + // ⑥ 被拒:`409 chat_busy`(队列满)—— 这不是「触发失败」,气泡要明确说 + // 「未送达」,让人知道这句话没进去(而不是只看到一行错误日志)。 + if (err instanceof ApiError && err.code === "chat_busy") { + markLocalDelivery("rejected", { rejectReason: "会话正忙:排队已满,这条未送达" }); + appendTaskLog("main", "[chat_busy] 排队已满,本条未送达\n"); + return; + } // Backend loop not wired or gateway unreachable: show the failure inline // rather than leaving a permanently-streaming bubble. - endStream(); const msg = err instanceof Error ? err.message : "触发失败"; appendChunk(`[stream 触发失败] ${msg}`); appendTaskLog("main", `[error] ${msg}\n`); @@ -59,7 +87,7 @@ export function useSendChat(): { setSending(false); } }, - [sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog] + [sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog, markLocalDelivery, alignDuplicateMessage] ); return { send, sending }; diff --git a/frontend/web/src/lib/ws.ts b/frontend/web/src/lib/ws.ts index 761de24..66bf72e 100644 --- a/frontend/web/src/lib/ws.ts +++ b/frontend/web/src/lib/ws.ts @@ -9,6 +9,8 @@ import { handleToolEvent } from "./ws/handlers/tool"; import { handleMessageEvent } from "./ws/handlers/message"; import { handleAgentEvent } from "./ws/handlers/agent"; import { handleSessionEvent } from "./ws/handlers/session"; +import { handlePersonaEvent } from "./ws/handlers/persona"; +import { handleParallelEvent } from "./ws/handlers/parallel"; import { handleDeviceEvent } from "./ws/handlers/device"; import { handleApprovalEvent } from "./ws/handlers/approval"; @@ -72,8 +74,18 @@ function notifyObservability(type: string, data: unknown): void { * logic test without a browser: this project has no jsdom/test runner, so the * socket lifecycle around it stays untested while the mapping does not have to. * 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束, - * 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */ -export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void { + * 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 + * + * `turnId`/`seq` 是帧壳字段,**必须原样传进 handler**:忙时并行下同一会话的两条 + * 回复靠 `(sessionId, turnId)` 两级分桶区分,此前签名只收 `{type,sessionId,data}`, + * `turnId` 在 socket 层就被丢掉 ⇒ 所有按路分桶都不可能实现。 */ +export function dispatchFrame(ev: { + type: string; + sessionId?: string; + turnId?: string; + seq?: number; + data: unknown; +}): void { const s = useAppStore.getState(); const r = toRecord(ev.data); @@ -91,6 +103,10 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn if (handleMessageEvent(ev, r, s)) return; if (handleAgentEvent(ev, r, s)) return; if (handleSessionEvent(ev, r, s)) return; + if (handlePersonaEvent(ev, r, s)) return; + // 忙时并行帧组在 device 组之前:`error` 除 `chat_busy` 外一律放行,仍由 device.ts + // 记一行日志,所以顺序只影响谁先处理 chat_busy,不影响原有日志行为。 + if (handleParallelEvent(ev, r, s)) return; if (handleDeviceEvent(ev, r, s)) return; if (handleApprovalEvent(ev, r, s)) return; @@ -161,6 +177,9 @@ function openSocket(): void { // The frame envelope's sessionId is what scopes a payload that does not // carry its own (see the `message_deleted` case in dispatch). sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined, + // 帧壳的 turnId/seq(契约 §3.4):分桶键必须传下去,不能在这里丢。 + turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined, + seq: typeof r.seq === "number" ? r.seq : undefined, data: r.data, }); } catch { diff --git a/frontend/web/src/lib/ws/handlers/message.ts b/frontend/web/src/lib/ws/handlers/message.ts index fecc9ff..b412fb0 100644 --- a/frontend/web/src/lib/ws/handlers/message.ts +++ b/frontend/web/src/lib/ws/handlers/message.ts @@ -48,7 +48,17 @@ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreStat // dedups by id and adopts the local optimistic bubble (uid → backend id), so // both the originating and remote browsers converge on one message. const msg = parsePersistedMessage(r?.message); - if (msg) s.appendOrUpdateMessage(msg); + if (msg) { + s.appendOrUpdateMessage(msg); + // `turn_begin` 可能早于用户行落库到达(那时 messageIds 在列表里还没有落点, + // 绑定只记在 turnBindings 里):这里补一次落地,让气泡及时进灰态。 + s.rebindSessionTurns(msg.sessionId ?? s.activeSessionId); + // 灰态的**兜底**收口(规范草案 §1.2):该 `turnId` 的助手回复落库同样收口, + // 它与 `turn.end` 顺序不定,任一到即收。 + if (msg.role === "assistant" && msg.turnId) { + s.clearGrey(msg.turnId, msg.sessionId ?? s.activeSessionId); + } + } return true; } case "message_edited": { diff --git a/frontend/web/src/lib/ws/handlers/parallel.test.ts b/frontend/web/src/lib/ws/handlers/parallel.test.ts new file mode 100644 index 0000000..b2e925a --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/parallel.test.ts @@ -0,0 +1,300 @@ +// 忙时并行帧组的逻辑测试(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`;契约 v0.65 §3.4.1–§3.4.3)。 +// +// 为什么需要它:这组帧此前在前端 **grep 命中 0**(全部落进未知帧兜底),更根本的是 +// 帧壳 `turnId` 在 socket 层就被丢掉 ⇒ 任何「按路分桶」都不可能实现。本测试锁六件事: +// ① `turnId` 从帧壳一路传进 handler 与按路气泡(分桶键不能在中途丢); +// ② `(sessionId, turnId)` 两级分桶:两路增量**不会糊进同一个气泡**; +// ③ 灰态(已读·未答)入口 = `turn_begin`/`steer_ack`,出口 = 该 `turnId` 的 `turn.end`, +// 兜底 = 该 `turnId` 的助手 `message_persisted`; +// ④ 六态里的 ③ 排队 / ⑥ 被拒:`pending_ack` 不是灰态,`pending_full`/`chat_busy` 说清后果; +// ⑤ 多路忙态:`session_interrupted`(scope:"current")不能当空闲;`stream_end` 不能把 +// 另一路还在跑的徽标关掉; +// ⑥ `chat_duplicate` 把本地乐观气泡对齐到既有 id(不重复渲染); +// ⑦ `tool_execution.start/end` 的 `{tool, phase}` 真的落成工具卡(此前用错解析器 ⇒ 卡片永不出现)。 +// +// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的帧解码器 +// (lib/ws.ts 的 dispatchFrame)与真实的 store,并在第一个失败处抛出。 +// +// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上 store 的 +// persist 中间件在 node 下探测的 window.localStorage): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/lib/ws/handlers/parallel.test.ts \ +// --bundle --platform=node --format=esm \ +// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \ +// --outfile=/tmp/cyrene-parallel.test.mjs \ +// && node /tmp/cyrene-parallel.test.mjs +// +// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、 +// 不 import node:*。 + +import { dispatchFrame } from "../../ws"; +import { useAppStore } from "../../../store/useAppStore"; +import type { ChatMessage } from "../../types"; + +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 { + ok( + JSON.stringify(got) === JSON.stringify(want), + `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}` + ); +} + +const MAIN = "web"; +const T1 = "turn-aaaa"; +const T2 = "turn-bbbb"; + +/** 每个场景前把 store 复位成「新连接、无在途路」。 */ +function reset(): void { + useAppStore.setState({ + activeSessionId: MAIN, + messages: [], + sessionMessages: {}, + turnBindings: {}, + sessionBusy: false, + pendingCount: 0, + activeTurns: 0, + queueNotice: null, + deviceUpdates: [], + }); +} + +function msgs(): ChatMessage[] { + return useAppStore.getState().messages; +} + +/** 下行帧(帧壳 turnId/seq 与真实 socket 层一致:onmessage 就是这么填的)。 */ +function down(type: string, turnId: string | undefined, data: unknown, sessionId = MAIN): void { + dispatchFrame({ type, sessionId, turnId, data }); +} + +/** 落库一条消息(`message_persisted`,帧里带 DTO)。 */ +function persist(m: Record): void { + down("message_persisted", undefined, { message: { sessionId: MAIN, createdAt: 1, ...m } }); +} + +/** 起一路(`turn_begin` 的帧壳带 turnId)。 */ +function turnBegin(turnId: string, kind: string, messageIds: string[], leg: number, activeTurns: number): void { + down("turn_begin", turnId, { kind, messageIds, leg, activeTurns }); +} + +// ── ① 帧壳 turnId 真的传进 handler(此前在 socket 层被丢) ──────────────────────── +{ + reset(); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + const bindings = useAppStore.getState().turnBindings; + same(Object.keys(bindings), [T1], "turn_begin 建立了以帧壳 turnId 为键的绑定"); + same(bindings[T1].messageIds, ["msg-A"], "绑定记住了本路归属的用户消息 id"); + same(bindings[T1].leg, 1, "绑定记住了路序号"); +} + +// ── ② 两级分桶:两条回复交错到达,各写自己的气泡(不糊成一条) ────────────────── +{ + reset(); + persist({ id: "msg-A", role: "user", content: "问题一" }); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + persist({ id: "msg-B", role: "user", content: "问题二" }); + turnBegin(T2, "parallel", ["msg-B"], 2, 2); + + same(msgs().length, 4, "两路各自有自己的用户行与助手气泡"); + same( + msgs().map((m) => [m.id, m.role, m.turnId]), + [ + ["msg-A", "user", T1], + [msgs()[1].id, "assistant", T1], + ["msg-B", "user", T2], + [msgs()[3].id, "assistant", T2], + ], + "用户行与助手气泡各自绑到自己的 turnId(顺序 = 历史顺序)" + ); + + // 交错到达:T1 → T2 → T1 → T2,若按「列表尾气泡」写,四个片段会全落进最后一条。 + down("message.delta", T1, { index: 0, delta: "甲一" }); + down("message.delta", T2, { index: 0, delta: "乙一" }); + down("message.delta", T1, { index: 0, delta: "甲二" }); + down("message.delta", T2, { index: 0, delta: "乙二" }); + same(msgs()[1].content, "甲一甲二", "T1 的增量只进 T1 的气泡"); + same(msgs()[3].content, "乙一乙二", "T2 的增量只进 T2 的气泡(没被糊在一起)"); +} + +// ── ③ 灰态:入口 turn_begin/steer_ack,出口 turn.end,兜底 = 助手 message_persisted ── +{ + reset(); + persist({ id: "msg-A", role: "user", content: "问题" }); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + same(msgs()[0].delivery, "answering", "turn_begin 把归属本路的用户气泡置灰态(已受理·回答中)"); + + down("message.delta", T1, { index: 0, delta: "答" }); + down("turn.end", T1, undefined); + same(msgs()[0].delivery, "answered", "该 turnId 的 turn.end 收口灰态"); + same(msgs()[1].streaming, false, "该路助手气泡停止流式"); + same(useAppStore.getState().turnBindings, {}, "turn.end 丢弃该路绑定"); + + // 兜底口径:助手 message_persisted 也收口(两个事件顺序不定)。 + reset(); + persist({ id: "msg-C", role: "user", content: "问题" }); + turnBegin(T1, "turn", ["msg-C"], 1, 1); + persist({ id: "msg-reply", role: "assistant", content: "回答", turnId: T1 }); + same( + msgs().find((m) => m.id === "msg-C")?.delivery, + "answered", + "该 turnId 的助手回复落库同样收口灰态(兜底)" + ); + + // 两路并行时:T2 收口不得把 T1 的灰态一起收掉。 + reset(); + persist({ id: "msg-A", role: "user", content: "一" }); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + persist({ id: "msg-B", role: "user", content: "二" }); + turnBegin(T2, "parallel", ["msg-B"], 2, 2); + down("turn.end", T2, undefined); + same(msgs()[0].delivery, "answering", "T2 收口不影响 T1 的灰态"); + same(msgs()[2].delivery, "answered", "T2 的灰态按自己的 turnId 收口"); + + // steer_ack:被在飞回合吃进 ⇒ 灰态 + 步序号(帧壳 turnId 决定与哪一路配对)。 + reset(); + persist({ id: "msg-S", role: "user", content: "忙时发的" }); + down("steer_ack", T1, { messageId: "msg-S", atStep: 2 }); + same(msgs()[0].delivery, "answering", "steer_ack 把该气泡置灰态"); + same(msgs()[0].turnId, T1, "steer_ack 的帧壳 turnId 绑到该气泡(并行下必需)"); + same(msgs()[0].steerStep, 2, "steer_ack 的 atStep 作为进度小字"); + down("turn.end", T1, undefined); + same(msgs()[0].delivery, "answered", "steer_ack 的灰态由该 turnId 的 turn.end 收口"); +} + +// ── ④ 六态:③ 排队(pending_ack 不是灰态)/ ④ 已受理 / ⑥ 被拒 ───────────────────── +{ + reset(); + useAppStore.setState({ messages: [{ id: "local-1", role: "user", content: "忙时发的", delivery: "sending", createdAt: 1 }] }); + down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 2 }); + same(useAppStore.getState().pendingCount, 2, "pending_ack 的 queueDepth 是队列深度的真源"); + same(useAppStore.getState().sessionBusy, true, "排队 ⇒ 会话忙"); + same(msgs()[0].delivery, "queued", "本端刚发的那条标为「排队中」"); + same(typeof useAppStore.getState().queueNotice, "string", "排队给出可见的解释文案"); + + down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 3, reason: "parallel_limit" }); + ok( + (useAppStore.getState().queueNotice ?? "").includes("并行额度已满"), + "reason:parallel_limit 明说「声明并行但额度已满」" + ); + + // 排队的消息被弹出 ⇒ 刷新深度与在途路数(随后会有该路的 turn_begin)。 + down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1, activeTurns: 2 }); + same(useAppStore.getState().pendingCount, 1, "pending_drain 刷新队列深度"); + same(useAppStore.getState().activeTurns, 2, "pending_drain 刷新在途路数"); + + // 队列满:定向 chat_busy 与广播 pending_full 都算 ⑥ 被拒。 + reset(); + useAppStore.setState({ messages: [{ id: "local-2", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] }); + down("pending_full", undefined, { sessionId: MAIN, mode: "executing", max: 5 }); + same(msgs()[0].delivery, "rejected", "pending_full 把本端待处理的那条标为未送达"); + ok((msgs()[0].rejectReason ?? "").includes("排队已满"), "被拒原因写清「排队已满(上限 N 条)」"); + + reset(); + useAppStore.setState({ messages: [{ id: "local-3", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] }); + down("error", undefined, { code: "chat_busy", message: "会话正忙", sessionId: MAIN, messageId: "local-3" }); + same(msgs()[0].delivery, "rejected", "定向 error{code:chat_busy} 同样标为被拒"); + + // 其它 error 码仍交给 device 组记一行日志(不被本组吞掉)。 + reset(); + down("error", undefined, { message: "voice unavailable" }); + same(useAppStore.getState().deviceUpdates.length, 1, "非 chat_busy 的 error 仍落设备日志"); +} + +// ── ⑤ 多路忙态:interrupted(scope:current) 不能当空闲;stream_end 不能关掉别的路 ── +{ + reset(); + down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 }); + same(useAppStore.getState().sessionBusy, true, "session_busy{activeTurns:2} 置忙"); + same(useAppStore.getState().activeTurns, 2, "在途路数被读出来(状态栏可显示「并行 2 路」)"); + + // scope:"current":只取消了当时忙碌的那条通道,另一路仍在答 ⇒ 不能显示「她闲了」。 + down("session_interrupted", undefined, { sessionId: MAIN, mode: "executing", dropped: 0, cancelledTurns: 1, activeTurns: 1 }); + same(useAppStore.getState().sessionBusy, true, "scope:current 被打断后仍忙(mode 非 idle)"); + same(useAppStore.getState().activeTurns, 1, "被打断后刷新在途路数"); + + // scope:"all":mode:"idle" ⇒ 确实空闲。 + down("session_interrupted", undefined, { sessionId: MAIN, mode: "idle", dropped: 2, cancelledTurns: 2, activeTurns: 0 }); + same(useAppStore.getState().sessionBusy, false, "scope:all(mode idle)收口为空闲"); + same(useAppStore.getState().pendingCount, 0, "scope:all 清空挂起数"); + + // stream_end:并行另一路还在跑时,先收尾的那一路不能把徽标关掉。 + reset(); + persist({ id: "msg-A", role: "user", content: "一" }); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + persist({ id: "msg-B", role: "user", content: "二" }); + turnBegin(T2, "parallel", ["msg-B"], 2, 2); + down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 }); + down("stream_end", T1, { stopReason: "end_turn" }); + same(useAppStore.getState().sessionBusy, true, "T1 收尾时 T2 仍在跑 ⇒ 不回空闲"); + same(msgs()[1].streaming, false, "T1 的气泡确实停止流式"); + same(msgs()[3].streaming, true, "T2 的气泡继续生长"); + + down("turn.end", T1, undefined); + same(useAppStore.getState().sessionBusy, true, "T1 的 turn.end 之后(T2 仍在途)仍忙"); + down("turn.end", T2, undefined); + same(useAppStore.getState().sessionBusy, false, "全部在途路收尾且队列空 ⇒ 忙态置闲"); + same(useAppStore.getState().activeTurns, 0, "全部收尾后在途路数为 0"); + + // 单路(无 turnId 的老帧)逐字沿用旧行为:stream_end 直接收口。 + reset(); + down("session_busy", undefined, { sessionId: MAIN, mode: "thinking" }); + down("stream_end", undefined, { stopReason: "end_turn" }); + same(useAppStore.getState().sessionBusy, false, "无 turnId 的 stream_end 照旧收口忙态"); +} + +// ── ⑥ chat_duplicate:本地乐观气泡对齐到既有 id(不重复渲染) ───────────────────── +{ + reset(); + useAppStore.setState({ + messages: [ + { id: "local-9", role: "user", content: "重发的话", delivery: "sending", clientMsgId: "c-1", createdAt: 1 }, + ], + }); + down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-1", messageId: "msg-server-1" }); + same(msgs().length, 1, "判重命中不新增气泡"); + same(msgs()[0].id, "msg-server-1", "气泡对齐到服务端既有 messageId"); + same(msgs()[0].delivery, "sent", "对齐后投递态回到「已送达」"); + + // 没有匹配的 clientMsgId 时不改任何东西(不误改别的气泡)。 + down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-unknown", messageId: "msg-x" }); + same(msgs()[0].id, "msg-server-1", "认不出的幂等键不动状态"); +} + +// ── ⑦ 工具的**执行阶段**帧(契约 §3.4.2;键名与 tool_call.* 不同) ──────────────── +// 此前只接了 `tool_execution.update`(后端暂无生产者),真正的 start/end 落到未知帧兜底; +// 而用 `parseToolCall` 读 `{tool, phase}` 只会得到 null(键名不同)⇒ 卡片永远不出现。 +{ + reset(); + persist({ id: "msg-A", role: "user", content: "跑个命令" }); + down("message_persisted", undefined, { message: { id: "a-T1", sessionId: MAIN, role: "assistant", content: "", turnId: T1, createdAt: 2 } }); + down("turn_begin", T1, { kind: "turn", messageIds: ["msg-A"], leg: 1, activeTurns: 1 }); + down("tool_execution.start", T1, { tool: "terminal", phase: "running" }); + const running = msgs().find((m) => m.role === "assistant")?.tools ?? []; + same(running.map((t) => [t.name, t.ok]), [["terminal", undefined]], "tool_execution.start 落成「运行中」工具卡"); + down("tool_execution.end", T1, { tool: "terminal", phase: "error" }); + const failed = msgs().find((m) => m.role === "assistant")?.tools ?? []; + same(failed.map((t) => [t.name, t.ok]), [["terminal", false]], "tool_execution.end 的 phase:error 落成失败(不是一次新调用)"); + same(useAppStore.getState().deviceUpdates.length, 0, "tool_execution.* 不进未知帧兜底"); +} + +// ── ⑧ 这些帧必须被消费(不得落进未知帧兜底,往 deviceUpdates 里堆垃圾) ────────── +{ + reset(); + turnBegin(T1, "turn", ["msg-A"], 1, 1); + down("turn.start", T1, undefined); + down("pending_ack", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1 }); + down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 0, activeTurns: 1 }); + down("steer_ack", T1, { messageId: "msg-A", atStep: 1 }); + down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-x", messageId: "m-x" }); + down("turn.end", T1, undefined); + same(useAppStore.getState().deviceUpdates.length, 0, "并行帧组全部被消费,不进未知帧兜底"); +} + +console.log(`parallel frame logic test: ${checks} checks passed`); diff --git a/frontend/web/src/lib/ws/handlers/parallel.ts b/frontend/web/src/lib/ws/handlers/parallel.ts new file mode 100644 index 0000000..ba59310 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/parallel.ts @@ -0,0 +1,181 @@ +import { now } from "../../util"; +import type { TurnBinding } from "../../types"; +import { + frameIsActive, + frameNumber, + frameSessionId, + frameText, + frameTurnId, + type FrameEvent, + type FramePayload, + type StoreState, +} from "./shared"; + +/** + * 忙时并行(多路)帧处理组(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`): + * `turn_begin` / `turn.end` / `turn.start` / `steer_ack` / `pending_ack` / + * `pending_drain` / `pending_full` / `chat_duplicate`,以及 `error{code:"chat_busy"}`。 + * + * 接入这组的三个必须(规范草案 §1.7): + * ① 结构化流帧按 `(sessionId, turnId)` 两级分桶 —— 分桶写入在 store 的 + * `appendChunk`/`setThinking`/`addTool` 里,本组负责把 `turnId` 送进去并把 + * `turn_begin` 的「消息 ↔ 回合」绑定建起来; + * ② 灰态(已读·未答)入口只有 `turn_begin` / `steer_ack`,出口只有该 `turnId` 的 + * `turn.end`(兜底 = 该 `turnId` 的助手 `message_persisted`,见 message.ts); + * ③ `queued`(排队)**不是**灰态:它由 `pending_ack` 标注,收到 `turn_begin` 时 + * 直接换态,绝不把排队渲染成灰态。 + * + * 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 + */ +export function handleParallelEvent( + ev: FrameEvent, + r: FramePayload, + s: StoreState +): boolean { + switch (ev.type) { + case "turn_begin": + return onTurnBegin(ev, r, s); + case "turn.end": + return onTurnEnd(ev, r, s); + case "turn.start": + // 回合起始的边界帧:气泡与灰态已由 `turn_begin` 处理(它带 messageIds), + // 这里消费掉即可,不再堆进未知帧兜底。 + return true; + case "steer_ack": + return onSteerAck(ev, r, s); + case "pending_ack": + return onPendingAck(ev, r, s); + case "pending_drain": + return onPendingDrain(ev, r, s); + case "pending_full": + return onPendingFull(ev, r, s); + case "chat_duplicate": + return onDuplicate(ev, r, s); + case "error": + return onUpstreamError(r, s); + default: + return false; + } +} + +/** `turn_begin`:每起一路恰好一帧,是「消息 ↔ 回合」的唯一权威来源。 + * 帧壳 `turnId` 缺了就无处分桶 —— 契约要求它必带,所以这种帧只消费不猜。 */ +function onTurnBegin(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + const turnId = frameTurnId(ev, r); + if (!turnId) return true; + const raw = r && Array.isArray(r.messageIds) ? r.messageIds : []; + const binding: TurnBinding = { + turnId, + sessionId: frameSessionId(ev, r) ?? s.activeSessionId, + messageIds: raw.filter((x): x is string => typeof x === "string" && x !== ""), + kind: frameText(r?.kind) ?? "turn", + leg: frameNumber(r?.leg) ?? 1, + activeTurns: frameNumber(r?.activeTurns) ?? s.activeTurns, + at: now(), + }; + s.beginTurn(binding); + return true; +} + +/** `turn.end`:整轮结束(覆盖最终答案 / 工具循环终止 / 预算耗尽 / 出错四种出口), + * 是灰态的**唯一**收口事件。缺 `turnId` 时无从知道收的是哪一路,交给 + * `session_idle` 收尾,绝不错关别人的灰态。 */ +function onTurnEnd(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + const turnId = frameTurnId(ev, r); + if (turnId) s.endTurn(turnId, frameSessionId(ev, r)); + return true; +} + +/** `steer_ack`:忙时新消息被在飞回合吃进同一回合 ⇒ 该气泡进灰态;`turnId` 是把灰态 + * 与正确 `turn.end` 配对的键(并行下必需)。`atStep` 只是进度小字。 */ +function onSteerAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + const messageId = frameText(r?.messageId); + if (!messageId) return true; + const turnId = frameTurnId(ev, r); + const atStep = frameNumber(r?.atStep); + s.markDelivery( + messageId, + { + delivery: "answering", + ...(turnId ? { turnId } : {}), + ...(atStep === undefined ? {} : { steerStep: atStep }), + }, + frameSessionId(ev, r) + ); + return true; +} + +/** `pending_ack`:忙时消息入队(唯一真源是这里的 `queueDepth`)。 + * ③ 排队 ≠ 灰态:只把**本端刚发的那条**(还没被任何一路接手)标成 queued。 + * `reason:"parallel_limit"` 时明说「声明并行但额度已满,落回排队」。 */ +function onPendingAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true; + const depth = frameNumber(r?.queueDepth); + s.setSessionBusy(true, depth ?? s.pendingCount); + if (depth !== undefined && depth > 0) s.markLocalDelivery("queued"); + if (frameText(r?.reason) === "parallel_limit") { + s.setQueueNotice( + depth === undefined + ? "并行额度已满:这条已转入排队" + : `并行额度已满:这条已转入排队(前面还有 ${depth} 条)` + ); + } else if (depth !== undefined && depth > 0) { + s.setQueueNotice(`已排队:前面还有 ${depth} 条`); + } else { + s.setQueueNotice(null); + } + return true; +} + +/** `pending_drain`:续跑弹出队首 ⇒ 深度与在途路数都刷新(随后会有该路的 `turn_begin`)。 */ +function onPendingDrain(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true; + const depth = frameNumber(r?.queueDepth); + s.setSessionBusy(true, depth, frameNumber(r?.activeTurns)); + if (depth === 0) s.setQueueNotice(null); + return true; +} + +/** `pending_full`:队列满(⑥ 被拒)。`messageId` 只在服务端已认领该消息时给出, + * 没有就落到本端刚发的那条气泡上 —— 不新铸 id、不假装送达。 */ +function onPendingFull(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + const sid = frameSessionId(ev, r); + if (!frameIsActive(sid, s.activeSessionId)) return true; + const max = frameNumber(r?.max); + const reason = + max === undefined + ? "会话正忙,这条未送达" + : `会话正忙:排队已满(上限 ${max} 条),这条未送达`; + s.setSessionBusy(true, max ?? s.pendingCount); + s.setQueueNotice(reason); + const messageId = frameText(r?.messageId); + if (messageId) s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, sid); + else s.markLocalDelivery("rejected", { rejectReason: reason }); + return true; +} + +/** `chat_duplicate`:`clientMsgId` 判重命中 —— 没有新回合、没有新内容帧,只把本地 + * 乐观气泡对齐到既有 `messageId`(否则同一句话会渲染两遍)。 */ +function onDuplicate(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + const clientMsgId = frameText(r?.clientMsgId); + const messageId = frameText(r?.messageId); + if (clientMsgId && messageId) { + s.alignDuplicateMessage(clientMsgId, messageId, frameSessionId(ev, r)); + } + return true; +} + +/** 上行 `error` 里的 `chat_busy`(定向回发送者,同时可能广播 `pending_full`): + * 把这条气泡标成 ⑥ 被拒。**其它 error 码一律放行**给 device.ts 记一行日志。 */ +function onUpstreamError(r: FramePayload, s: StoreState): boolean { + if (frameText(r?.code) !== "chat_busy") return false; + const reason = frameText(r?.message) ?? "会话正忙,这条未送达"; + s.setQueueNotice(reason); + const messageId = frameText(r?.messageId); + if (messageId) { + s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, frameText(r?.sessionId)); + } else { + s.markLocalDelivery("rejected", { rejectReason: reason }); + } + return true; +} diff --git a/frontend/web/src/lib/ws/handlers/persona.ts b/frontend/web/src/lib/ws/handlers/persona.ts new file mode 100644 index 0000000..e766606 --- /dev/null +++ b/frontend/web/src/lib/ws/handlers/persona.ts @@ -0,0 +1,50 @@ +import { useModeStore } from "../../../store/useModeStore"; +import { MODES, type AwakenState, type Mode } from "../../types"; +import { parseAwakenTool } from "../../wsParse"; +import type { FrameEvent, FramePayload, StoreState } from "./shared"; + +/** 角色 / 呈现状态帧处理组:capacity_awaken / mode_switch / mood。命中返回 true, + * 未命中返回 false 交给下一组(与拆分前的单个 switch 等价)。 + * + * 为什么从 session.ts 拆出来(AGENTS §10 函数 ≤80 行):这三帧讲的是**角色与呈现** + * (唤醒三态 design/14 §5、呈现模式 design/01 §3、角色情绪 design/22 §4.6), + * 而 session.ts 讲的是**会话编排**(忙闲/在途路/已读游标)。两件事的改动理由不同。 */ +export function handlePersonaEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { + switch (ev.type) { + case "capacity_awaken": { + // design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。 + const fromState = + typeof r?.fromState === "string" + ? (r.fromState as AwakenState) + : undefined; + const toState = + (typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ?? + "quiet"; + s.setAwaken(fromState, toState, parseAwakenTool(r?.tool)); + return true; + } + case "mode_switch": { + // The agent drives the behavior mode (design/01 §3). Apply it to the shell + // so newly begun message bubbles render per that mode. + if (r && typeof r.mode === "string") { + const m = r.mode as Mode; + if (MODES.includes(m)) useModeStore.getState().setMode(m); + } + return true; + } + case "mood": { + // Role-scoped (design/22 §4.6): the frame names the persona the change + // belongs to and carries no sessionId, so the bucket key is personaId — + // each role's mood stays with its own conversations. A legacy string frame + // has nowhere to put it, so it lands in the unscoped "" bucket. + if (typeof ev.data === "string") { + s.setMood(ev.data); + } else if (r && typeof r.mood === "string") { + s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : ""); + } + return true; + } + default: + return false; + } +} diff --git a/frontend/web/src/lib/ws/handlers/session.ts b/frontend/web/src/lib/ws/handlers/session.ts index 564103d..dad1647 100644 --- a/frontend/web/src/lib/ws/handlers/session.ts +++ b/frontend/web/src/lib/ws/handlers/session.ts @@ -1,48 +1,71 @@ -import { useModeStore } from "../../../store/useModeStore"; import { sessionReadTarget } from "../../sessionRead"; -import { MODES, type AwakenState, type Mode } from "../../types"; -import { parseAwakenTool } from "../../wsParse"; -import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; +import { + frameIsActive, + frameNumber, + frameSessionId, + frameText, + type FrameEvent, + type FramePayload, + type StoreState, +} from "./shared"; -/** 会话/角色状态帧处理组:capacity_awaken / 会话编排忙闲(session_busy / - * session_pending / session_idle / session_interrupted)/ session_read / - * mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */ +/** 会话编排忙闲帧处理组:session_busy / session_pending / session_idle / + * session_interrupted / session_read。命中返回 true,未命中返回 false 交给下一组。 + * + * 角色与呈现类帧(capacity_awaken / mode_switch / mood)在 `handlers/persona.ts` + * —— 那三帧的改动理由与本组不同(AGENTS §10 函数 ≤80 行)。 + * + * 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1):这些帧现在还带 `activeTurns` 等计数, + * 判定必须据实读,尤其 `session_interrupted` **不能无条件当空闲**(`scope:"current"` + * 只取消了当时忙碌的那条通道,并行的其它路仍在跑)。 */ export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { switch (ev.type) { - case "capacity_awaken": { - // design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。 - const fromState = - typeof r?.fromState === "string" - ? (r.fromState as AwakenState) - : undefined; - const toState = - (typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ?? - "quiet"; - s.setAwaken(fromState, toState, parseAwakenTool(r?.tool)); - return true; - } case "session_busy": case "session_pending": { - // design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。 + // design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。 // 忙态是**单槽全局**状态,只认屏幕上这个会话(见下方 session_idle 的说明)。 + // v0.65 §3.4.1 起帧里还带 `activeTurns`(在途路数):忙 = activeTurns > 0, + // 比 `busy` 更能表达「并行 N 路」;老后端不发时回落 `busy`(缺省按忙处理)。 if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true; - const busy = typeof r?.busy === "boolean" ? r.busy : true; + const turns = frameNumber(r?.activeTurns); + const busy = turns !== undefined ? turns > 0 : typeof r?.busy === "boolean" ? r.busy : true; const pendingCount = - typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount; - s.setSessionBusy(busy, pendingCount); + typeof r?.pendingCount === "number" ? r.pendingCount : undefined; + s.setSessionBusy(busy, pendingCount, turns); return true; } - case "session_idle": - case "session_interrupted": { - // design/15 §3/§4/§5 — 会话回到空闲:`session_idle`(队列清空)与 - // `session_interrupted`(用户 interrupt:当前任务取消、pending 丢弃)都自带 - // `{sessionId, mode}`、**没有 busy 字段**,语义就是「这个会话不再忙」。 - // 缺这两个 case 时忙态只能靠 `stream_end` 回落:回合卡住/被中断时 - // `useAppStore.sessionBusy` 一直为 true,裁决 16.4.1 的「预约切换」永不落地。 + case "session_idle": { + // design/15 §3/§5 — 会话回到空闲:`session_idle` 只在**全部在途路收尾且 pending + // 空**时才发(v0.65 时点收紧),所以它是可以放心置闲的权威信号。 const sid = frameSessionId(ev, r); // 单槽忙态跨会话会串(design/16 §4.2 已登记的债):置忙与置闲必须用同一把尺子, // 否则后台会话的 bus 帧把徽标点亮、它自己的 idle 帧又被过滤掉 ⇒ 永久卡忙。 - if (frameIsActive(sid, s.activeSessionId)) s.setSessionBusy(false, 0); + if (!frameIsActive(sid, s.activeSessionId)) return true; + s.setSessionBusy(false, 0, 0); + s.setQueueNotice(null); + // 空闲 ⇒ 该会话不可能还有在途路:把(可能漏收 turn.end 的)绑定清掉, + // 否则下一轮的「还有别的路在跑」判定会拿旧绑定当真。 + if (sid) s.dropSessionTurns(sid); + return true; + } + case "session_interrupted": { + // 打断结果(v0.65 §3.4.1):**不能无条件当空闲**。 + // scope:"all" ⇒ mode:"idle"、dropped=N、activeTurns:0 —— 这个会话不再忙; + // scope:"current" ⇒ mode 是当时忙碌的通道(**非 idle**)、dropped:0、 + // activeTurns 可能 > 0(并行的其它路仍在跑)——置闲就会 + // 显示「她闲了」而实际还在答。 + const sid = frameSessionId(ev, r); + if (!frameIsActive(sid, s.activeSessionId)) return true; + const mode = frameText(r?.mode); + const turns = frameNumber(r?.activeTurns); + if (mode !== undefined && mode !== "idle") { + s.setSessionBusy(turns === undefined ? true : turns > 0, undefined, turns); + return true; + } + s.setSessionBusy(false, 0, 0); + s.setQueueNotice(null); + // 取消的路不会再发 `turn.end`,绑定必须清掉(否则永远认为「还有路在跑」)。 + if (sid) s.dropSessionTurns(sid); return true; } case "session_read": { @@ -54,27 +77,6 @@ export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreStat if (id) s.clearUnread(id); return true; } - case "mode_switch": { - // The agent drives the behavior mode (design/01 §3). Apply it to the shell - // so newly begun message bubbles render per that mode. - if (r && typeof r.mode === "string") { - const m = r.mode as Mode; - if (MODES.includes(m)) useModeStore.getState().setMode(m); - } - return true; - } - case "mood": { - // Role-scoped (design/22 §4.6): the frame names the persona the change - // belongs to and carries no sessionId, so the bucket key is personaId — - // each role's mood stays with its own conversations. A legacy string frame - // has nowhere to put it, so it lands in the unscoped "" bucket. - if (typeof ev.data === "string") { - s.setMood(ev.data); - } else if (r && typeof r.mood === "string") { - s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : ""); - } - return true; - } default: return false; } diff --git a/frontend/web/src/lib/ws/handlers/shared.ts b/frontend/web/src/lib/ws/handlers/shared.ts index c5ab66f..e0ff986 100644 --- a/frontend/web/src/lib/ws/handlers/shared.ts +++ b/frontend/web/src/lib/ws/handlers/shared.ts @@ -4,6 +4,11 @@ import { useAppStore } from "../../../store/useAppStore"; export interface FrameEvent { type: string; sessionId?: string; + /** 帧壳的回合标识(契约 §3.4 帧壳 `{type,turnId?,seq?,sessionId?,data}`)。 + * 忙时并行下它是**分桶键**:同一会话两条回复靠它区分,缺了它两条会糊成一个气泡。 */ + turnId?: string; + /** 帧壳的帧序号(同回合内的到达序;当前只透传,未参与判定)。 */ + seq?: number; data: unknown; } @@ -34,3 +39,22 @@ export function frameIsActive( if (!sessionId || !activeSessionId) return true; return sessionId === activeSessionId; } + +/** 一帧所属的回合 id。帧壳 `turnId` 是契约里的权威位置(结构化流帧、`turn_begin`、 + * `steer_ack` 都带);payload 里若也给了同名键(历史 DTO / graph 等)以 payload 为准 + * —— 它离内容更近。都没有时返回 undefined,调用方回落既有的「列表尾气泡」行为, + * 因此老后端的帧逐字不变。 */ +export function frameTurnId(ev: FrameEvent, r: FramePayload): string | undefined { + if (r && typeof r.turnId === "string" && r.turnId) return r.turnId; + return ev.turnId || undefined; +} + +/** 取一个数字字段(帧字段是 `unknown`,各 handler 里重复收窄三次以上 ⇒ 抽出来)。 */ +export function frameNumber(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} + +/** 取一个非空字符串字段(同上;空串按「没给」处理,避免用空值覆盖真实状态)。 */ +export function frameText(v: unknown): string | undefined { + return typeof v === "string" && v ? v : undefined; +} diff --git a/frontend/web/src/lib/ws/handlers/stream.ts b/frontend/web/src/lib/ws/handlers/stream.ts index c827296..6066367 100644 --- a/frontend/web/src/lib/ws/handlers/stream.ts +++ b/frontend/web/src/lib/ws/handlers/stream.ts @@ -1,23 +1,38 @@ -import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; +import { turnsInSession } from "../../../store/turnCache"; +import { + frameIsActive, + frameSessionId, + frameTurnId, + type FrameEvent, + type FramePayload, + type StoreState, +} from "./shared"; /** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta / * message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 * * 裁决 16.4.5:帧按自己的 `sessionId` 落进对应会话的消息桶,**不再**因为不属于 - * 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。 */ + * 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。 + * + * 忙时并行(design/15 §2.5):帧壳 `turnId` 决定它属于**哪一路**,增量按 + * `(sessionId, turnId)` 二级分桶写进那一路的助手气泡;两条回复交错到达时不会糊成 + * 一条(此前只按「列表尾气泡」写,正是把两条回复糊在一起的成因)。 */ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { switch (ev.type) { case "stream_chunk": { - if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r)); + if (r && typeof r.delta === "string") { + s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r)); + } return true; } case "stream_end": { const sid = frameSessionId(ev, r); - s.endStream(sid); + const turnId = frameTurnId(ev, r); + s.endStream(sid, turnId); // 忙态 / 上下文是**单槽全局**状态(design/15 §5),所以只有屏幕上这个会话的 // 回合结束才收口;别的会话结束只能收它自己气泡的流,不能清掉这里的徽标。 - if (frameIsActive(sid, s.activeSessionId)) { - s.setSessionBusy(false, 0); + if (frameIsActive(sid, s.activeSessionId) && !otherLegsRunning(s, sid, turnId)) { + s.setSessionBusy(false, 0, turnId ? 0 : undefined); if (r && typeof r.stopReason === "string") { s.setContext({ lastAgentMode: s.context.lastAgentMode }); } @@ -31,7 +46,9 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState return true; } case "message.delta": { - if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r)); + if (r && typeof r.delta === "string") { + s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r)); + } return true; } case "message.end": { @@ -43,3 +60,12 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState return false; } } + +/** 除了刚收尾的这一路,该会话还有别的路在跑吗?有就**不能**把忙态置闲 —— 并行时 + * 先答完的那一路会把「她正在忙」徽标误关掉(v0.65 §4.6 的 `session_idle` 时点收紧 + * 就是为这个)。没有 `turnId` 的旧帧(单路老后端)逐字沿用旧行为:照样清忙。 */ +function otherLegsRunning(s: StoreState, sessionId: string | undefined, turnId: string | undefined): boolean { + if (!turnId) return false; + const sid = sessionId ?? s.activeSessionId; + return turnsInSession(s.turnBindings, sid).some((b) => b.turnId !== turnId); +} diff --git a/frontend/web/src/lib/ws/handlers/thinking.ts b/frontend/web/src/lib/ws/handlers/thinking.ts index a477b7a..dd431b4 100644 --- a/frontend/web/src/lib/ws/handlers/thinking.ts +++ b/frontend/web/src/lib/ws/handlers/thinking.ts @@ -1,4 +1,4 @@ -import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; +import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; /** Accumulator for a streamed thinking segment (thinking.* events), **per session**: * a background turn's reasoning must not be spliced into the segment being built @@ -6,9 +6,12 @@ import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } f * mid-turn reconnect can rebuild the collapsed block without relying on prior state. */ const pendingThinking: Record = {}; -/** 累积器与写入用的会话键:帧带的 sessionId 优先,缺席时归当前激活会话。 */ +/** 累积器与写入用的键:`sessionId|turnId` 两级 —— 忙时并行下同一会话两条回复的思维链 + * 会交错到达,只按会话累积会把两路的思考文本拼成一段(`turnId` 缺席时回落会话键, + * 与旧行为一致)。 */ function thinkKey(ev: FrameEvent, r: FramePayload, s: StoreState): string { - return frameSessionId(ev, r) ?? s.activeSessionId; + const sid = frameSessionId(ev, r) ?? s.activeSessionId; + return `${sid}|${frameTurnId(ev, r) ?? ""}`; } /** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。 @@ -24,22 +27,22 @@ export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreSta const key = thinkKey(ev, r, s); const text = (pendingThinking[key] ?? "") + r.delta; pendingThinking[key] = text; - s.setThinking(text, frameSessionId(ev, r)); + s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r)); return true; } case "thinking.end": { const key = thinkKey(ev, r, s); const text = pendingThinking[key] ?? ""; - if (text) s.setThinking(text, frameSessionId(ev, r)); + if (text) s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r)); delete pendingThinking[key]; return true; } case "thinking": { // Legacy single-shot CoT frame — renders into the same bubble. if (typeof ev.data === "string") { - s.setThinking(ev.data, frameSessionId(ev, r)); + s.setThinking(ev.data, frameSessionId(ev, r), frameTurnId(ev, r)); } else if (r && typeof r.text === "string") { - s.setThinking(r.text, frameSessionId(ev, r)); + s.setThinking(r.text, frameSessionId(ev, r), frameTurnId(ev, r)); } return true; } diff --git a/frontend/web/src/lib/ws/handlers/tool.ts b/frontend/web/src/lib/ws/handlers/tool.ts index fc3a2f2..dcbf5d4 100644 --- a/frontend/web/src/lib/ws/handlers/tool.ts +++ b/frontend/web/src/lib/ws/handlers/tool.ts @@ -1,18 +1,19 @@ -import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; -import { parseTool, parseToolCall } from "../../wsParse"; +import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; +import { parseTool, parseToolCall, parseToolExecution } from "../../wsParse"; /** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta / - * tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true, - * 未命中返回 false 交给下一组。 + * tool_execution.start / tool_execution.end / tool_execution.update + 遗留 tool_used。 + * 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 * * 裁决 16.4.5:工具卡按帧的 `sessionId` 落在该会话的助手气泡上(其它会话的调用 - * 卡不会跑到屏幕上的这一轮里)。 */ + * 卡不会跑到屏幕上的这一轮里)。忙时并行(design/15 §2.5):帧壳 `turnId` 决定它 + * 落在**哪一路**的气泡上 —— 两条回复的工具卡不会串到对方那里。 */ export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { switch (ev.type) { case "tool_call.start": case "tool_call.end": { const tool = parseToolCall(ev.data); - if (tool) s.addTool(tool, frameSessionId(ev, r)); + if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r)); return true; } case "tool_call.delta": { @@ -20,16 +21,20 @@ export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): // boundary so mid-stream reconnects do not drop it. return true; } + case "tool_execution.start": + case "tool_execution.end": case "tool_execution.update": { - // Same card as tool_call.* — same routing. - const tool = parseToolCall(ev.data); - if (tool) s.addTool(tool, frameSessionId(ev, r)); + // 执行阶段帧(契约 §3.4.2):`{tool, phase, id?, delta?}` —— 键名与 tool_call.* + // 不同,必须用 parseToolExecution 读(用 parseToolCall 会一律 null ⇒ 卡片永不出现)。 + // `update` 目前后端暂无生产者(契约已注明),保留分支:一旦开报即同路。 + const tool = parseToolExecution(ev.data); + if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r)); return true; } case "tool_used": { // Legacy tool echo — same card as tool_call.*. const tool = parseTool(ev.data); - if (tool) s.addTool(tool, frameSessionId(ev, r)); + if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r)); return true; } default: diff --git a/frontend/web/src/lib/wsParse.ts b/frontend/web/src/lib/wsParse.ts index c5af4c0..aeed53a 100644 --- a/frontend/web/src/lib/wsParse.ts +++ b/frontend/web/src/lib/wsParse.ts @@ -1,4 +1,4 @@ -import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types"; +import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type MessageDocument, type Mode, type ToolUsed } from "./types"; import { now, uid } from "./util"; /** @@ -83,6 +83,24 @@ export function parseToolCall(v: unknown): ToolUsed | null { }; } +/** Build a tool card from a `tool_execution.*` payload (契约 §3.4.2): + * `{tool, phase, id?, delta?}`,`phase` ∈ `running/done/error`(**phase 绝不塞进 + * delta**)。注意这里的键名与 `tool_call.*` 不同:工具名在 `tool` 上(不是 `name`), + * 成败由 `phase` 决定 —— 用 `parseToolCall` 读它只会得到 null,卡片就永远不会出现。 */ +export function parseToolExecution(v: unknown): ToolUsed | null { + const r = toRecord(v); + if (!r) return null; + const name = typeof r.tool === "string" ? r.tool : ""; + if (!name) return null; + const phase = typeof r.phase === "string" ? r.phase : ""; + return { + name, + summary: typeof r.detail === "string" ? r.detail : undefined, + ok: phase === "done" ? true : phase === "error" ? false : undefined, + at: now(), + }; +} + export function parseAgent(payload: unknown): AgentInfo | null { const r = toRecord(payload); if (!r) return null; @@ -159,6 +177,28 @@ export function parseGraph(payload: unknown): FlowGraph | null { }; } +/** 解析消息 DTO 的 `documents` 数组(契约 §4.4)。**不含正文**,只在有附件时出现 + * (0 个附件时键缺席)。`status` 允许后端新增取值(display-raw:未知值原样带下去, + * 渲染层按未知处理,绝不丢掉附件、也绝不把未知当错误)。`reason` 只在 + * `status:"unreadable"` 时下发,是服务端产出的逐字中文原因,客户端原样展示。 */ +export function parseDocuments(v: unknown): MessageDocument[] | undefined { + if (!Array.isArray(v)) return undefined; + const docs = v + .map((d) => toRecord(d)) + .filter((d): d is Record => d !== null) + .map((d) => ({ + name: typeof d.name === "string" ? d.name : "", + mime: typeof d.mime === "string" && d.mime ? d.mime : undefined, + size: typeof d.size === "number" ? d.size : undefined, + chars: typeof d.chars === "number" ? d.chars : undefined, + truncated: typeof d.truncated === "boolean" ? d.truncated : undefined, + status: typeof d.status === "string" && d.status ? d.status : undefined, + reason: typeof d.reason === "string" && d.reason ? d.reason : undefined, + })) + .filter((d) => d.name !== ""); + return docs.length ? docs : undefined; +} + /** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2): * the backend broadcast a fully-persisted message carrying the backend-generated * id. mode is validated against the known modes so a bad value never reaches the @@ -177,6 +217,11 @@ export function parsePersistedMessage(v: unknown): ChatMessage | null { mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined, mood: typeof r.mood === "string" ? r.mood : undefined, image: typeof r.image === "string" ? r.image : undefined, + documents: parseDocuments(r.documents), + // `chat_messages.turn_id`(契约 §4.4):忙时并行下靠它把回复与提问配对、把两条 + // 交错到达的回复分进两个气泡;旧行该键省略 ⇒ 回落按顺序渲染。 + turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined, + clientMsgId: typeof r.clientMsgId === "string" && r.clientMsgId ? r.clientMsgId : undefined, platform: typeof r.platform === "string" && r.platform ? r.platform : undefined, deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined, senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined, diff --git a/frontend/web/src/store/persistOptions.ts b/frontend/web/src/store/persistOptions.ts new file mode 100644 index 0000000..ed72b2a --- /dev/null +++ b/frontend/web/src/store/persistOptions.ts @@ -0,0 +1,47 @@ +import type { AppStore } from "./useAppStore"; + +/** + * zustand `persist` 中间件的配置(从 `useAppStore.ts` 拆出,AGENTS §10 文件 ≤500 行)。 + * + * 只持久化「小且可重建」的东西:会话元数据 + 聚焦的会话 id + 角色目录 + 情绪 + 上下文 + + * 后台思考条目。消息**不持久化**(后端是消息真源,design/16 §1)。v1 的形状(整份聊天 + + * 单个会话 id)与 v2(一个全局 `currentPersona`)都无法描述「角色属于会话」的现状, + * 统一丢弃并由接口重新取回。 + */ +export const persistOptions = { + name: "cyrene.app", + version: 3, + // 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: + // `personas` is refilled from `GET /personas` and the role is re-derived from + // 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. + migrate: (persisted: unknown, version: number) => { + const p = (persisted ?? {}) as Partial; + const fromV1 = version < 2; + return { + activeSessionId: fromV1 ? "" : p.activeSessionId ?? "", + sessions: fromV1 ? {} : p.sessions ?? {}, + personas: p.personas ?? [], + moods: {}, + context: p.context ?? {}, + backthink: p.backthink ?? [], + }; + }, + partialize: (s: AppStore) => ({ + // messages deliberately excluded: the backend is the message truth + // (design/16 §1), so we never persist the whole chat into localStorage — + // and neither is `sessionMessages` (the per-session buckets of 裁决 16.4.5). + // We keep the (small) session metadata + the focused session id so a + // refresh lands on the same conversation, plus the role catalog so the + // avatar/name/wallpaper resolve before the first fetch returns. + activeSessionId: s.activeSessionId, + sessions: s.sessions, + personas: s.personas, + moods: s.moods, + context: s.context, + backthink: s.backthink, + }), +}; diff --git a/frontend/web/src/store/sessionCache.ts b/frontend/web/src/store/sessionCache.ts index 8c12a44..5b7e28c 100644 --- a/frontend/web/src/store/sessionCache.ts +++ b/frontend/web/src/store/sessionCache.ts @@ -30,7 +30,13 @@ export type SessionMessages = Record; /** 把一条后端消息并入消息列表:按 id upsert;没有同 id 时采纳本端同 role 的本地 * 乐观气泡(本地 `uid` → 后端 `id`),使同一句话不会渲染两遍(design/16 §4.3 去重)。 - * 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。 */ + * 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。 + * + * 采纳顺序(忙时并行后很重要,规范草案 §1.7): + * ① 同 `turnId` 且尚未落库的气泡 —— `turn_begin` 已把本端气泡绑到这一路,turnId + * 才是权威配对(并行时两条回复内容可能相似,靠内容判等会**配错路**); + * ② 内容完全相同的那条(旧启发式,保持老后端行为); + * ③ createdAt 最近的那条。 */ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage[] { const idx = list.findIndex((m) => m.id === msg.id); if (idx !== -1) { @@ -38,6 +44,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage next[idx] = { ...next[idx], ...msg }; return next; } + if (msg.turnId) { + for (let i = list.length - 1; i >= 0; i -= 1) { + const m = list[i]; + if (!m.sessionId && m.role === msg.role && m.turnId === msg.turnId) { + return adopt(list, i, msg); + } + } + } // 没有同 id:找一个本端尚未落库的同 role 气泡(无 sessionId)来归一化。 // 优先内容完全相同的那条,否则取 createdAt 最近的;从新到旧扫描。 let best = -1; @@ -52,9 +66,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage } } if (best === -1) return [...list, msg]; - const m = list[best]; + return adopt(list, best, msg); +} + +/** 把本端乐观气泡归一为后端消息(id 换成服务端的,其余保留本端状态)。 */ +function adopt(list: ChatMessage[], at: number, msg: ChatMessage): ChatMessage[] { + const m = list[at]; const next = list.slice(); - next[best] = { + next[at] = { ...m, id: msg.id, sessionId: msg.sessionId ?? m.sessionId, @@ -62,6 +81,9 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage mode: msg.mode ?? m.mode, mood: msg.mood ?? m.mood, image: msg.image ?? m.image, + documents: msg.documents ?? m.documents, + turnId: msg.turnId ?? m.turnId, + clientMsgId: msg.clientMsgId ?? m.clientMsgId, platform: msg.platform ?? m.platform, deviceId: msg.deviceId ?? m.deviceId, personaId: msg.personaId ?? m.personaId, diff --git a/frontend/web/src/store/turnCache.ts b/frontend/web/src/store/turnCache.ts new file mode 100644 index 0000000..4d6afb9 --- /dev/null +++ b/frontend/web/src/store/turnCache.ts @@ -0,0 +1,190 @@ +import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types"; +import { now, uid } from "../lib/util"; + +/** + * 回合级(按 `(sessionId, turnId)` 二级分桶)的纯函数(忙时并行规范草案 + * design/15 §2.5)。与 `sessionCache.ts` 的分工: + * + * sessionCache 管「哪一份会话列表」(按 `sessionId` 一级分桶); + * 本模块管「列表里哪一条气泡」(按 `turnId` 二级分桶 + 六态投递态)。 + * + * 为什么必须分开:忙时并行下同一会话的两条回复会**交错**到达(同 sessionId、不同 + * turnId),任何「改列表尾那条助手气泡」的写法都会把两条回复糊成一条。所有按路写入 + * 都从这里走,规则只有一处,才不会在几条帧路径上走样。 + * + * 全部是纯函数(只读 list,返回新 list 或 null 表示「帧与我们持有的气泡对不上,不改」), + * 因此可以在 node 下逐条跑逻辑测试,不需要 jsdom(项目无浏览器测试环境)。 + */ + +/** 找这条路(`turnId`)的助手气泡并就地变换。顺序: + * ① 已绑定同 turnId 的气泡 —— 直接命中(唯一正确的分桶); + * ② 还没有绑定:采纳**列表尾**那条本端刚开、既无 turnId 又是流式的手气泡 + * (`turn_begin` 可能比首个 `message.delta` 晚到一帧); + * ③ 都不匹配返回 `null`:已绑到别的 turnId 的气泡**绝不复用** —— 那正是「两条回复 + * 糊成一条」的成因。丢一帧增量,好过把另一路的正文写进这一路。 */ +export function patchAssistantForTurn( + list: ChatMessage[], + turnId: string, + fn: (m: ChatMessage) => ChatMessage +): ChatMessage[] | null { + for (let i = list.length - 1; i >= 0; i -= 1) { + const m = list[i]; + if (m.role === "assistant" && m.turnId === turnId) { + const next = list.slice(); + next[i] = fn(m); + return next; + } + } + const tail = list[list.length - 1]; + if (tail && tail.role === "assistant" && !tail.turnId && tail.streaming) { + const next = list.slice(); + next[next.length - 1] = fn({ ...tail, turnId }); + return next; + } + return null; +} + +/** 把一条 `turn_begin` 绑定落到该会话的消息列表上(规范草案 §2.4): + * ① 归属本路的用户消息 → 灰态 `answering` + 绑定 turnId; + * ② 本路需要一个**生长中的助手气泡**:优先采纳该用户消息之后最近的「未绑定且流式」 + * 助手气泡(本端 `beginAssistant` 开的那条),否则在列表尾新开一条 —— 远端发起的 + * 并行路在本端没有开过气泡,不新开就没有落点。 + * 返回 `null` 表示这条绑定在这个列表里还没有落点(用户消息尚未落库/尚未并入), + * 调用方保留绑定,等 `message_persisted` 到达时再落(规范草案 §1.7)。 */ +export function applyTurnBinding(list: ChatMessage[], b: TurnBinding): ChatMessage[] | null { + let next = list; + let hit = false; + for (const id of b.messageIds) { + const i = next.findIndex((m) => m.id === id); + if (i === -1) continue; + const m = next[i]; + if (m.role !== "user") continue; + hit = true; + if (m.turnId !== b.turnId || m.delivery !== "answering") { + if (next === list) next = list.slice(); + next[i] = { + ...m, + turnId: b.turnId, + delivery: "answering", + rejectReason: undefined, + }; + } + } + if (!hit) return null; + if (next.some((m) => m.role === "assistant" && m.turnId === b.turnId)) return next; + const anchor = next.findIndex((m) => b.messageIds.includes(m.id)); + for (let i = anchor + 1; i < next.length; i += 1) { + const m = next[i]; + if (m.role === "assistant" && !m.turnId && m.streaming) { + if (next === list) next = list.slice(); + next[i] = { ...m, turnId: b.turnId }; + return next; + } + } + return [ + ...next, + { + id: uid(), + role: "assistant", + content: "", + streaming: true, + turnId: b.turnId, + sessionId: b.sessionId || undefined, + createdAt: now(), + }, + ]; +} + +/** 灰态收口(规范草案 §1.2 的**唯一**口径):把该 `turnId` 的用户消息从 + * 「已受理·回答中」改为「已答」。由该路的 `turn.end` 或该路的助手 + * `message_persisted`(兜底)触发;两个事件顺序不定,任一到即收口。 + * 返回 `null` 表示没有需要收口的气泡(不改状态,避免无谓重渲染)。 */ +export function clearGreyForTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null { + let next: ChatMessage[] | null = null; + for (let i = 0; i < list.length; i += 1) { + const m = list[i]; + if (m.role !== "user" || m.turnId !== turnId || m.delivery !== "answering") continue; + if (next === null) next = list.slice(); + next[i] = { ...m, delivery: "answered" }; + } + return next; +} + +/** 整路收口(`turn.end` / 该路的 `stream_end`):灰态收口 + 该路助手气泡停止流式。 + * 只动本 `turnId` 的气泡,另一路继续生长(这正是多路下必须按路收口的原因)。 */ +export function closeTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null { + const grey = clearGreyForTurn(list, turnId); + let next = grey ?? list; + let changed = grey !== null; + for (let i = 0; i < next.length; i += 1) { + const m = next[i]; + if (m.role !== "assistant" || m.turnId !== turnId || m.streaming !== true) continue; + if (next === list) next = list.slice(); + next[i] = { ...m, streaming: false }; + changed = true; + } + return changed ? next : null; +} + +/** 把一条消息改成某个投递态(本地待落库/排队/被拒的乐观气泡用)。 */ +export function setDelivery( + list: ChatMessage[], + index: number, + delivery: MessageDelivery, + patch: Partial = {} +): ChatMessage[] | null { + if (index < 0 || index >= list.length) return null; + const next = list.slice(); + next[index] = { ...next[index], delivery, ...patch }; + return next; +} + +/** 找到「本端刚发、还没被任何一路接手」的那条用户消息的下标:`pending_ack` / + * `pending_full` 只带会话与计数、不带消息 id,本端能确定的只有这个 —— 队列深度是 + * 权威的,气泡标注是尽力而为。已绑定 turnId 的气泡绝不会被改(它已被接手)。 */ +export function localPendingIndex(list: ChatMessage[]): number { + for (let i = list.length - 1; i >= 0; i -= 1) { + const m = list[i]; + if (m.role !== "user" || m.turnId) continue; + if (m.delivery === "queued" || m.delivery === "sending" || m.delivery === undefined || m.delivery === "sent") { + return i; + } + } + return -1; +} + +/** 按幂等键找一个气泡(`chat_duplicate` 用来把本地乐观气泡对齐到既有 id)。 */ +export function indexByClientMsgId(list: ChatMessage[], clientMsgId: string): number { + if (!clientMsgId) return -1; + return list.findIndex((m) => m.clientMsgId === clientMsgId); +} + +/** `chat_duplicate` 命中:没有新回合、没有新内容帧,只把本地气泡的 id 换成服务端 + * 既有 id(design/16 §4.3 去重),并把投递态落回「已送达」。 */ +export function alignDuplicate( + list: ChatMessage[], + clientMsgId: string, + messageId: string, + sessionId?: string +): ChatMessage[] | null { + const i = indexByClientMsgId(list, clientMsgId); + if (i === -1) return null; + const next = list.slice(); + next[i] = { + ...next[i], + id: messageId, + sessionId: sessionId ?? next[i].sessionId, + delivery: "sent", + }; + return next; +} + +/** 某会话在途路数(绑定表里属于该会话的条数)。`session_busy`/`session_idle` 帧自带的 + * `activeTurns` 是权威;这里用于帧没带计数时的多路判定(如 `stream_end` 收尾时 + * 「是不是还有别的路在跑」)。 */ +export function turnsInSession( + bindings: Record, + sessionId: string +): TurnBinding[] { + return Object.values(bindings).filter((b) => b.sessionId === sessionId); +} diff --git a/frontend/web/src/store/turnSlice.ts b/frontend/web/src/store/turnSlice.ts new file mode 100644 index 0000000..93869c0 --- /dev/null +++ b/frontend/web/src/store/turnSlice.ts @@ -0,0 +1,187 @@ +import type { AppStore } from "./useAppStore"; +import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types"; +import { writeSession, writeSessionById, patchMessage } from "./sessionCache"; +import { + alignDuplicate, + applyTurnBinding, + clearGreyForTurn, + closeTurn, + localPendingIndex, + setDelivery, + turnsInSession, +} from "./turnCache"; + +/** + * 忙时并行的**回合切片**(design/15 §2.5 / 规范草案 §1.1 §2):忙态与在途路数、 + * `turn_begin` 绑定表、六态投递态的写入动作。 + * + * 从 `useAppStore.ts` 拆出来(AGENTS §10 文件 ≤500 行):这些都是「按路」的状态, + * 与消息桶(`sessionCache`)、与其余界面状态不是一回事,放一处才好对照规范逐条核对。 + * 由于每个动作都要「写进对的那个会话桶」,这里统一走 `sessionCache` 的写入路径, + * 规则不复制第二份。 + */ + +/** 本切片贡献给 AppStore 的状态与动作。 */ +export interface TurnSlice extends TurnState, TurnActions, DeliveryActions {} + +/** 忙时并行的**回合状态**(design/15 §2.5)。 */ +export interface TurnState { + /** 会话在途路数(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted` + * 帧的 `activeTurns`,权威)。忙 = `activeTurns > 0`;收到前恒 0(旧后端行为不变)。 */ + activeTurns: number; + /** 在途回合的绑定表,键 = 帧壳 `turnId`(`turn_begin` 建立、`turn.end` 丢弃)。 + * 不持久化:它的唯一来源是这次连接收到的帧,刷新后由 REST 历史里的 `turnId` 接管。 */ + turnBindings: Record; + /** 队列/并行额度的一句话提示(`pending_ack.reason` / `pending_full` 时后端给的 + * 计数与原因)。null = 无提示;文案由前端按计数拼,但**不编造后端没给的事实**。 */ + queueNotice: string | null; +} + +/** 在途回合的生命周期动作(起路 / 收口 / 绑定表维护)。 */ +export interface TurnActions { + /** 会话忙闲 + 挂起数 + 在途路数。三个参数都可选:缺省沿用旧值,因此老后端的 + * `session_busy{busy}` / `session_idle` 逐字不变。 */ + setSessionBusy: (busy: boolean, pendingCount?: number, activeTurns?: number) => void; + /** 记录一条 `turn_begin` 绑定并立刻尝试落到消息列表(消息未落库则留在绑定表里, + * 等 `message_persisted` 到达再落)。 */ + beginTurn: (binding: TurnBinding) => void; + /** 该路收口(`turn.end`):灰态转「已答」、该路助手气泡停止流式、丢弃绑定; + * 若该会话已无在途路且队列空,顺手把忙态置闲(`session_idle` 是权威,这里是 + * 兜底,条件与它逐字一致,不会提前置闲)。 */ + endTurn: (turnId: string, sessionId?: string) => void; + /** 只收灰态、不动流式气泡(助手 `message_persisted` 的兜底收口,规范草案 §1.2)。 */ + clearGrey: (turnId: string, sessionId?: string) => void; + /** 把该会话的**全部**绑定重新落到消息列表:`turn_begin` 早于用户行落库到达时, + * 绑定只记在表里;用户行一到就用它把气泡补进灰态。无绑定/无落点时不改状态。 */ + rebindSessionTurns: (sessionId: string) => void; + /** 该会话的全部在途路作废(`session_interrupted` scope:"all"):取消的路不会再发 + * `turn.end`,绑定必须清掉,否则忙态判定会永远认为「还有路在跑」。 */ + dropSessionTurns: (sessionId: string) => void; + setQueueNotice: (text: string | null) => void; +} + +/** 六态投递态的写入动作(规范草案 §1.1)。 */ +export interface DeliveryActions { + /** 按 id 打投递态补丁(`pending_full.messageId` / `chat_busy.messageId` 等)。 */ + markDelivery: (id: string, patch: Partial, sessionId?: string) => void; + /** 把「本端刚发、尚未被任何一路接手」的那条气泡改为某个投递态(队列深度是权威的, + * 气泡标注是尽力而为:`pending_ack`/`pending_full` 不带消息 id)。 */ + markLocalDelivery: (delivery: MessageDelivery, patch?: Partial) => void; + /** `chat_duplicate` 命中:把本地乐观气泡对齐到服务端既有 id。 */ + alignDuplicateMessage: (clientMsgId: string, messageId: string, sessionId?: string) => void; +} + +/** store 的 set/get 结构类型(只取本切片用到的形态,避免与 zustand 的完整 + * 重载签名纠缠)。`AppStore` 只作类型引入,编译后被擦除。 */ +type SetState = ( + partial: Partial | ((s: AppStore) => Partial | AppStore) +) => void; +type GetState = () => AppStore; + +/** 切片的初始状态(与动作分开,便于 useAppStore 各自 spread 且每个函数守住 ≤80 行)。 */ +export function turnState(): TurnState { + return { activeTurns: 0, turnBindings: {}, queueNotice: null }; +} + +/** 在途回合的生命周期动作。 */ +export function turnActions(set: SetState, get: GetState): TurnActions { + return { + setSessionBusy: (busy, pendingCount, activeTurns) => + set((s) => ({ + sessionBusy: busy, + pendingCount: pendingCount ?? s.pendingCount, + activeTurns: activeTurns ?? s.activeTurns, + })), + + beginTurn: (b) => + set((s) => { + const sessionId = b.sessionId || s.activeSessionId; + const turnBindings = { ...s.turnBindings, [b.turnId]: { ...b, sessionId } }; + const patch = writeSession(s, sessionId, (list) => applyTurnBinding(list, b)); + // `writeSession` 在「这一帧与持有的消息对不上」时原样返回 state(表示不改), + // 那种情况下只更新绑定表,别把整份 state 当成补丁重放。 + return { + ...(patch === s ? {} : patch), + turnBindings, + activeTurns: b.activeTurns || s.activeTurns, + }; + }), + + endTurn: (turnId, sessionId) => + set((s) => { + const binding = s.turnBindings[turnId]; + const sid = binding?.sessionId ?? sessionId ?? s.activeSessionId; + const turnBindings = { ...s.turnBindings }; + delete turnBindings[turnId]; + const patch = writeSession(s, sid, (list) => closeTurn(list, turnId)); + const remaining = turnsInSession(turnBindings, sid).length; + const quiescent = remaining === 0 && s.pendingCount === 0; + return { + ...(patch === s ? {} : patch), + turnBindings, + ...(quiescent ? { sessionBusy: false, activeTurns: 0 } : {}), + }; + }), + + clearGrey: (turnId, sessionId) => + set((s) => { + const sid = s.turnBindings[turnId]?.sessionId ?? sessionId ?? s.activeSessionId; + const patch = writeSession(s, sid, (list) => clearGreyForTurn(list, turnId)); + return patch === s ? {} : patch; + }), + + rebindSessionTurns: (sessionId) => + set((s) => { + const bindings = turnsInSession(s.turnBindings, sessionId); + if (bindings.length === 0) return s; + return writeSession(s, sessionId, (list) => { + let next = list; + let changed = false; + for (const b of bindings) { + const out = applyTurnBinding(next, b); + if (out) { + next = out; + changed = true; + } + } + return changed ? next : null; + }); + }), + + dropSessionTurns: (sessionId) => + set((s) => { + const turnBindings: Record = {}; + for (const [k, v] of Object.entries(s.turnBindings)) { + if (v.sessionId !== sessionId) turnBindings[k] = v; + } + return { turnBindings }; + }), + + setQueueNotice: (text) => set({ queueNotice: text }), + }; +} + +/** 六态投递态的写入动作。 */ +export function deliveryActions(set: SetState, get: GetState): DeliveryActions { + return { + markDelivery: (id, patch, sessionId) => + set((s) => + writeSessionById(s, sessionId, (list) => patchMessage(list, id, patch)) + ), + + markLocalDelivery: (delivery, patch = {}) => + set((s) => { + const idx = localPendingIndex(s.messages); + return writeSession(s, s.activeSessionId, (list) => + setDelivery(list, idx, delivery, patch) + ); + }), + + alignDuplicateMessage: (clientMsgId, messageId, sessionId) => + set((s) => + writeSessionById(s, sessionId, (list) => + alignDuplicate(list, clientMsgId, messageId, sessionId) + ) + ), + }; +} diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts index b46e6d6..dbaa549 100644 --- a/frontend/web/src/store/useAppStore.ts +++ b/frontend/web/src/store/useAppStore.ts @@ -28,6 +28,9 @@ import { writeSessionById, type SessionMessages, } from "./sessionCache"; +import { deliveryActions, turnActions, turnState, type TurnSlice } from "./turnSlice"; +import { patchAssistantForTurn } from "./turnCache"; +import { persistOptions } from "./persistOptions"; interface ChatContext { topic?: string; @@ -35,7 +38,7 @@ interface ChatContext { notes?: string[]; } -export interface AppStore { +export interface AppStore extends TurnSlice { /** Session metadata (main + secondary) keyed by session id. The message truth * lives server-side, so this is a small list — safe to persist. */ sessions: Record; @@ -80,7 +83,9 @@ export interface AppStore { awakenState: AwakenState; awakenFrom: AwakenState | null; awakenTool: string | null; - /** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */ + /** 会话编排(design/15 §5)——她是否在忙(不阻塞输入框)。挂起数 `pendingCount`、 + * 在途路数 `activeTurns`、在途回合绑定 `turnBindings` 与六态写入动作由 + * `store/turnSlice.ts` 提供(AGENTS §10 文件 ≤500 行)。 */ sessionBusy: boolean; pendingCount: number; /** 最近一次主代理代审小字(design/11 §4;`approval_notice` 帧,裁决 13.4.3): @@ -149,14 +154,16 @@ export interface AppStore { * duplicate frame). */ removeMessageById: (id: string, sessionId?: string) => void; - addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string) => void; + addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string, clientMsgId?: string) => void; beginAssistant: (mode?: Mode, deviceId?: string) => string; /** Append a streamed delta to the assistant bubble of `sessionId` (default: the - * active session) — a background turn keeps streaming into its own bucket. */ - appendChunk: (delta: string, sessionId?: string) => void; - endStream: (sessionId?: string) => void; - setThinking: (text: string, sessionId?: string) => void; - addTool: (tool: ToolUsed, sessionId?: string) => void; + * active session) — a background turn keeps streaming into its own bucket. + * 给了 `turnId` 就按**二级分桶**写进那一路的气泡(忙时并行下两条回复交错到达, + * 按列表尾写会把它们糊成一条);没给则沿用旧的「列表尾气泡」行为。 */ + appendChunk: (delta: string, sessionId?: string, turnId?: string) => void; + endStream: (sessionId?: string, turnId?: string) => void; + setThinking: (text: string, sessionId?: string, turnId?: string) => void; + addTool: (tool: ToolUsed, sessionId?: string, turnId?: string) => void; setAgents: (agents: AgentInfo[]) => void; upsertAgent: (agent: AgentInfo) => void; @@ -172,7 +179,6 @@ export interface AppStore { to: AwakenState, tool?: string | null ) => void; - setSessionBusy: (busy: boolean, pendingCount?: number) => void; /** 记录一条主代理代审小字(`approval_notice` WS 帧)。null 表示清除。 */ setApprovalNotice: (notice: ApprovalNotice | null) => void; @@ -210,6 +216,11 @@ export const useAppStore = create()( taskLogs: {}, channelEvents: [], + // 忙时并行切片(在途路数 / 绑定表 / 队列提示 + 六态与回合生命周期动作)。 + ...turnState(), + ...turnActions(set, get), + ...deliveryActions(set, get), + setWsStatus: (status) => set({ wsStatus: status }), setPersonas: (list) => set({ personas: list }), setContext: (patch) => @@ -277,7 +288,7 @@ export const useAppStore = create()( removeMessageById: (id, sessionId) => set((s) => writeSessionById(s, sessionId, (list) => dropMessage(list, id))), - addUserMessage: (text, mode, image, deviceId) => + addUserMessage: (text, mode, image, deviceId, clientMsgId) => set((s) => ({ messages: [ ...s.messages, @@ -288,6 +299,10 @@ export const useAppStore = create()( mode, image, deviceId, + clientMsgId, + /** ① 发送中:本地乐观气泡,落库(`message_persisted`)或幂等命中 + * (`chat_duplicate`)后归一为服务端 id 并转「已送达」。 */ + delivery: "sending", createdAt: now(), }, ], @@ -313,34 +328,46 @@ export const useAppStore = create()( return id; }, - appendChunk: (delta, sessionId) => + appendChunk: (delta, sessionId, turnId) => set((s) => writeSession(s, sessionId ?? s.activeSessionId, (list) => - patchAssistantTail(list, (last) => ({ - ...last, - content: last.content + delta, - })) + turnId + ? patchAssistantForTurn(list, turnId, (m) => ({ + ...m, + content: m.content + delta, + })) + : patchAssistantTail(list, (last) => ({ + ...last, + content: last.content + delta, + })) ) ), - endStream: (sessionId) => + endStream: (sessionId, turnId) => set((s) => writeSession(s, sessionId ?? s.activeSessionId, (list) => - patchAssistantTail(list, (last) => ({ ...last, streaming: false })) + turnId + ? patchAssistantForTurn(list, turnId, (m) => ({ + ...m, + streaming: false, + })) + : patchAssistantTail(list, (last) => ({ ...last, streaming: false })) ) ), - setThinking: (text, sessionId) => + setThinking: (text, sessionId, turnId) => set((s) => writeSession(s, sessionId ?? s.activeSessionId, (list) => - patchAssistantTail(list, (last) => ({ ...last, thinking: text })) + turnId + ? patchAssistantForTurn(list, turnId, (m) => ({ ...m, thinking: text })) + : patchAssistantTail(list, (last) => ({ ...last, thinking: text })) ) ), - addTool: (tool, sessionId) => + addTool: (tool, sessionId, turnId) => set((s) => - writeSession(s, sessionId ?? s.activeSessionId, (list) => - patchAssistantTail(list, (last) => { + writeSession(s, sessionId ?? s.activeSessionId, (list) => { + const add = (last: ChatMessage): ChatMessage => { const existing = last.tools ?? []; const idx = existing.findIndex((t) => t.name === tool.name); const tools = @@ -348,8 +375,11 @@ export const useAppStore = create()( ? [...existing, tool] : existing.map((t, i) => (i === idx ? { ...t, ...tool } : t)); return { ...last, tools }; - }) - ) + }; + return turnId + ? patchAssistantForTurn(list, turnId, add) + : patchAssistantTail(list, add); + }) ), setAgents: (agents) => set({ agents }), @@ -393,11 +423,6 @@ export const useAppStore = create()( awakenFrom: from ?? s.awakenFrom, awakenTool: tool ?? s.awakenTool, })), - setSessionBusy: (busy, pendingCount) => - set((s) => ({ - sessionBusy: busy, - pendingCount: pendingCount ?? s.pendingCount, - })), setApprovalNotice: (notice) => set({ approvalNotice: notice }), appendTaskLog: (taskId, text) => @@ -434,45 +459,12 @@ export const useAppStore = create()( awakenTool: null, sessionBusy: false, pendingCount: 0, + activeTurns: 0, + turnBindings: {}, + queueNotice: null, approvalNotice: null, })), }), - { - name: "cyrene.app", - version: 3, - // 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: - // `personas` is refilled from `GET /personas` and the role is re-derived from - // 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. - migrate: (persisted, version) => { - const p = (persisted ?? {}) as Partial; - const fromV1 = version < 2; - return { - activeSessionId: fromV1 ? "" : p.activeSessionId ?? "", - sessions: fromV1 ? {} : p.sessions ?? {}, - personas: p.personas ?? [], - moods: {}, - context: p.context ?? {}, - backthink: p.backthink ?? [], - }; - }, - partialize: (s) => ({ - // messages deliberately excluded: the backend is the message truth - // (design/16 §1), so we never persist the whole chat into localStorage — - // and neither is `sessionMessages` (the per-session buckets of 裁决 16.4.5). - // We keep the (small) session metadata + the focused session id so a - // refresh lands on the same conversation, plus the role catalog so the - // avatar/name/wallpaper resolve before the first fetch returns. - activeSessionId: s.activeSessionId, - sessions: s.sessions, - personas: s.personas, - moods: s.moods, - context: s.context, - backthink: s.backthink, - }), - } + persistOptions ) );