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

+25 -3
View File
@@ -30,7 +30,13 @@ export type SessionMessages = Record<string, ChatMessage[]>;
/** 把一条后端消息并入消息列表:按 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,