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,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<OwnerAccountGroup>): 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 (
<div className="space-y-3">
<WhitelistHeader disabled={disabled} onAddGroup={addGroup} />
{groups.length === 0 ? (
<p className="text-xs text-[var(--mu-text-lo)]">
尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。
</p>
) : null}
{groups.map((g, i) => (
<PlatformGroupRow
key={`platform-${i}`}
group={g}
index={i}
options={options}
candidates={accountCandidates(channels, g.platform)}
known={platformIsConfigured(channels, g.platform)}
disabled={disabled}
onPlatform={(v) => patchGroup(i, { platform: v })}
onAccount={(j, v) => patchAccount(i, j, v)}
onAddAccount={() => addAccount(i)}
onRemoveAccount={(j) => removeAccount(i, j)}
onRemoveGroup={() => removeGroup(i)}
/>
))}
</div>
);
}
/** 头部:标题 + 说明(写盘格式不变)+「+ 添加平台」,以及「一组都没有」的诚实提示。 */
function WhitelistHeader({
disabled,
onAddGroup,
}: {
disabled: boolean;
onAddGroup: () => void;
}): React.ReactNode {
return (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">
渠道账号白名单(命中即走 owner 车道)· 一个平台一条,可加多个账号 ID
</span>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAddGroup}>
+ 添加平台
</Button>
</div>
<p className="text-xs text-[var(--mu-text-lo)]">
保存时仍是扁平数组{' '}
<span className="font-mono">{'accounts: [{platform, accountId}, …]'}</span>
(<span className="font-mono">owner.yaml</span> 格式不变);平台与账号 ID 都填了的条目才会写盘。
</p>
</>
);
}
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 (
<div
className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/50 p-3"
data-platform={group.platform}
data-group-index={index}
>
<PlatformPicker
group={group}
options={options}
disabled={disabled}
onPlatform={props.onPlatform}
onRemoveGroup={props.onRemoveGroup}
/>
{group.accountIds.length === 0 ? (
<p className="text-xs text-[var(--mu-warn)]">
该平台还没有账号 ID:保存时这一组会被忽略(不会写入 owner.yaml)。
</p>
) : null}
{group.accountIds.map((accountId, j) => (
<AccountIdRow
key={`acct-${index}-${j}`}
listId={`acct-${index}-${j}-candidates`}
accountId={accountId}
candidates={candidates}
platform={group.platform}
known={known}
disabled={disabled}
onValue={(v) => props.onAccount(j, v)}
onRemove={() => props.onRemoveAccount(j)}
/>
))}
<div className="flex items-center gap-2">
<Button size="sm" variant="soft" disabled={disabled} onClick={props.onAddAccount}>
+ 添加账号 ID
</Button>
{emptyCount > 0 ? (
<span className="text-xs text-[var(--mu-text-lo)]">
有 {emptyCount} 条空白账号 ID:保存时会被忽略。
</span>
) : null}
</div>
</div>
);
}
/** 分组头:平台下拉(权威枚举)+ 该平台的账号条数 + 「删除该平台」。 */
function PlatformPicker({
group,
options,
disabled,
onPlatform,
onRemoveGroup,
}: {
group: OwnerAccountGroup;
options: EnumItem[];
disabled: boolean;
onPlatform: (v: string) => void;
onRemoveGroup: () => void;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-end gap-2">
<label className="block w-48 space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">平台</span>
<select
value={group.platform}
onChange={(e) => onPlatform(e.target.value)}
disabled={disabled}
className={inputCls}
>
<option value="">请选择平台…</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.label}({p.id})
</option>
))}
</select>
</label>
<span className="text-xs text-[var(--mu-text-lo)]">
{group.platform === ""
? "未选平台:这组不会被写盘"
: `该平台下的账号 ID(${group.accountIds.length} 条)`}
</span>
<Button size="sm" variant="ghost" className="ml-auto" disabled={disabled} onClick={onRemoveGroup}>
删除该平台
</Button>
</div>
);
}
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 (
<div className="space-y-1">
<div className="flex flex-wrap items-end gap-2">
<label className="block min-w-56 flex-1 space-y-1">
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
<input
value={accountId}
onChange={(e) => onValue(e.target.value)}
disabled={disabled}
list={candidates.length ? listId : undefined}
className={`${inputCls} font-mono`}
placeholder={candidates.length ? "可手填,或从下方候选里选" : "如 9616b27a…"}
/>
</label>
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
删除
</Button>
</div>
{candidates.length ? (
<AccountCandidates
listId={listId}
candidates={candidates}
disabled={disabled}
onPick={onValue}
/>
) : (
<CandidateHint platform={platform} known={known} />
)}
</div>
);
}
/** 候选账号:`<datalist>`(键盘可筛)+ 可点的标签(看得见、点一下就填)。 */
function AccountCandidates({
listId,
candidates,
disabled,
onPick,
}: {
listId: string;
candidates: string[];
disabled: boolean;
onPick: (v: string) => void;
}): React.ReactNode {
return (
<>
<datalist id={listId}>
{candidates.map((c) => (
<option key={c} value={c} />
))}
</datalist>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">从该平台已配置账号里选:</span>
{candidates.map((c) => (
<button
key={c}
type="button"
disabled={disabled}
onClick={() => onPick(c)}
className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-2 py-0.5 font-mono text-xs text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] disabled:opacity-60"
title={`填入已配置账号 ${c}`}
>
{c}
</button>
))}
</div>
</>
);
}
/** 没有候选时给一句诚实的解释(而不是假装有候选或静静空着)。 */
function CandidateHint({ platform, known }: { platform: string; known: boolean }): React.ReactNode {
if (!platform) return null;
return (
<p className="text-xs text-[var(--mu-text-lo)]">
{known
? "该平台暂无已配置账号:先在「渠道管理」里配好账号,这里就能直接选。"
: "该平台未在渠道管理里注册:账号 ID 只能手填。"}
</p>
);
}