- 帧壳 `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 读端点
191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
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> = {}
|
||
): 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<string, TurnBinding>,
|
||
sessionId: string
|
||
): TurnBinding[] {
|
||
return Object.values(bindings).filter((b) => b.sessionId === sessionId);
|
||
}
|