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[] | 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, sessionId: string ): TurnBinding[] { return Object.values(bindings).filter((b) => b.sessionId === sessionId); }