Files
Cyrene_v2/frontend/web/src/store/turnCache.ts
T
AskaEth eaada3af5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 读端点
2026-10-05 11:09:24 +08:00

191 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}