feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提 - 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` / `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡 - 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤 - `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标 - `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效) - 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变) - 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言) - 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result 等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
This commit is contained in:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -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<T>(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("<select"), "平台是下拉选择,不再是纯文本输入框(消灭拼错平台导致的静默失效)");
|
||||
ok(html.includes('value="qq-obv11"') && html.includes("QQ(qq-obv11)"), "下拉里有权威枚举项(含 id,抄不错)");
|
||||
ok(html.includes('value="discord"') === false, "存量里没有的平台不凭空塞进下拉");
|
||||
ok(html.includes("+ 添加账号 ID"), "每个平台下都有「+ 添加账号 ID」子按钮");
|
||||
ok(html.includes("从该平台已配置账号里选:"), "账号 ID 旁给出「从已配置账号里选」的候选");
|
||||
ok(html.includes("9616b27a"), "候选里出现该平台已配置的账号 id");
|
||||
ok(html.includes("owner.yaml"), "明写保存后仍是 owner.yaml 的既有格式");
|
||||
ok(html.includes("有 1 条空白账号 ID"), "空账号 ID 有显式提示(保存时会被忽略,不是静默丢弃)");
|
||||
ok(html.includes("该平台下的账号 ID(1 条)"), "分组标题报出该平台的账号条数");
|
||||
|
||||
// 未注册的平台(没有候选)给出诚实的解释,而不是假装有候选。
|
||||
const other = renderToStaticMarkup(
|
||||
createElement(OwnerAccountsEditor, {
|
||||
accounts: [{ platform: "telegram", accountId: "" }],
|
||||
disabled: true,
|
||||
platforms: PUBLISHED,
|
||||
channels,
|
||||
onChange: () => {},
|
||||
})
|
||||
);
|
||||
ok(other.includes("该平台未在渠道管理里注册"), "未注册平台说明「账号 ID 只能手填」");
|
||||
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
|
||||
}
|
||||
|
||||
console.log(`owner accounts editor test: ${checks} checks passed`);
|
||||
Reference in new issue
Block a user