feat(web): 前端 P0 收尾(多路忙态收口 / 握手 history 落 store / 清理幽灵键)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 11:29:17 +08:00
1 parent e852e60af9
commit 012e9f3ad4
10 files changed
+365 -31

No files matched your search

+66
View File
@@ -28,6 +28,72 @@ export const BACKTHINK_SESSION_ID = "sess-backthink";
/** 一个会话的消息列表;键是会话 id。 */
export type SessionMessages = Record<string, ChatMessage[]>;
/** 握手 `history` 回放(契约 §3.2)的**整批**并入:一次最多 50 条**已落库**的权威行。
*
* 为什么不直接复用 `mergeMessage`:单条 `message_persisted` 允许用「同 role + 内容全等 /
* `createdAt` 最近」的启发式去认领本端乐观气泡;整批回放套这条启发式,会把本端
* **正在流式**的气泡错配到某条旧助手行上(正文被旧内容顶掉、流式态丢失)——
* 回放越大、越容易配错。因此这里认领本端气泡只用**精确键**:
* - 用户行:`clientMsgId`(本端生成、随 `chat.clientMsgId` 上行,回放 DTO 也带);
* - 助手行:`turnId`(`turn_begin` 已把这个 id 钉在本端气泡上)。
* 精确键对不上一律**追加**(猜错比多一条更糟),同 id 则 upsert。 */
export function mergeHistory(list: ChatMessage[], msgs: ChatMessage[]): ChatMessage[] {
let next = list;
for (const msg of msgs) {
const copy = next === list ? list.slice() : next;
const idx = copy.findIndex((m) => m.id === msg.id);
if (idx !== -1) {
copy[idx] = { ...copy[idx], ...msg };
next = copy;
continue;
}
const adopt = historyAdoptIndex(copy, msg);
if (adopt !== -1) {
const m = copy[adopt];
copy[adopt] = {
...m,
id: msg.id,
sessionId: msg.sessionId ?? m.sessionId,
content: msg.content || m.content,
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,
personaId: msg.personaId ?? m.personaId,
createdAt: msg.createdAt ?? m.createdAt,
// 回放里的**助手行** ⇒ 这一路已收尾落库:本端在飞气泡必须停止流式,
// 否则那一帧 `stream_end` 丢在断线窗口里会让它永远转圈。
...(msg.role === "assistant" ? { streaming: false } : {}),
// 用户行落库 ⇒ 不再是「发送中」,转「已送达」;已被某路接手(answering)
// 或已排队(queued)的不降级 —— 那些态来自比「落库」更晚的帧。
...(msg.role === "user" && m.delivery === "sending"
? { delivery: "sent" as const }
: {}),
};
next = copy;
continue;
}
copy.push(msg);
next = copy;
}
return next;
}
/** 回放行只按**精确键**认领本端乐观气泡:用户行认 `clientMsgId`、助手行认 `turnId`。
* 两者都没有、或本端没有对应气泡时返回 -1(调用方改为追加)。 */
function historyAdoptIndex(list: ChatMessage[], msg: ChatMessage): number {
if (!msg.clientMsgId && !msg.turnId) return -1;
for (let i = list.length - 1; i >= 0; i -= 1) {
const m = list[i];
if (m.sessionId || m.role !== msg.role) continue;
if (msg.role === "user" && msg.clientMsgId && m.clientMsgId === msg.clientMsgId) return i;
if (msg.role === "assistant" && msg.turnId && m.turnId === msg.turnId) return i;
}
return -1;
}
/** 把一条后端消息并入消息列表:按 id upsert;没有同 id 时采纳本端同 role 的本地
* 乐观气泡(本地 `uid` → 后端 `id`),使同一句话不会渲染两遍(design/16 §4.3 去重)。
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。