feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `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:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

@@ -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<string, OwnerAccountGroup>();
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<string>();
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);
}