import { MODES, type ChatMessage, type Mode } from "../../types"; import { parseAdminChannelMessage, parseHistoryMessages, parsePersistedMessage, toRecord, } from "../../wsParse"; import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared"; /** The fields of a `message_edited` payload the chat needs: the edited message's * id and its new content. `sessionId` is carried inside the broadcast DTO (and on * the frame envelope) so the store can confine the patch to that session's bucket; * `mode`/`mood` are optional — an edit never changes them, but the DTO carries them * for completeness. */ interface MessageEdited { id: string; content: string; sessionId?: string; mode?: Mode; mood?: string; } /** Narrow a `message_edited` payload (the updated message DTO), or null when it is * unusable. `content` is required: an edit always carries the new text, and a * frame without it must not blank out a rendered bubble. */ function parseMessageEdited(data: unknown): MessageEdited | null { const r = toRecord(data); if (!r || typeof r.id !== "string" || r.id === "" || typeof r.content !== "string") { return null; } return { id: r.id, content: r.content, sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined, mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined, mood: typeof r.mood === "string" ? r.mood : undefined, }; } /** 消息落库/编辑/删除帧处理组:message_persisted / message_edited / * message_deleted / channel_persisted。命中(含组内主动丢弃)返回 true, * 未命中返回 false 交给下一组。 */ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean { switch (ev.type) { case "history": { // 握手回放(契约 §3.2,仅 owner/admin,只回放主会话最近 50 条):此前这帧落到 // 未知帧兜底被丢掉 ⇒ WS 断线窗口内落库的消息永久缺失(要等切会话/刷新才补)。 // 并入时按 id 去重、只按精确键(clientMsgId / turnId)认领本端气泡(见 // sessionCache.mergeHistory)—— 整批回放绝不用内容/时间启发式,以免把正在 // 流式的气泡错配到某条旧行上。帧壳 sessionId("web")就是回放所属会话。 const msgs = parseHistoryMessages(r?.messages); if (msgs.length) { s.mergeHistoryPage(frameSessionId(ev, r) ?? msgs[0].sessionId ?? "", msgs); } return true; } case "message_persisted": { // design/16 §4.2 — a user or assistant message was persisted and broadcast // with its backend id. 裁决 16.4.5: the frame is written into the bucket its // own `sessionId` names (the store routes it), so a background conversation // keeps accumulating instead of being dropped. Within that bucket the store // dedups by id and adopts the local optimistic bubble (uid → backend id), so // both the originating and remote browsers converge on one message. const msg = parsePersistedMessage(r?.message); if (msg) { s.appendOrUpdateMessage(msg); // `turn_begin` 可能早于用户行落库到达(那时 messageIds 在列表里还没有落点, // 绑定只记在 turnBindings 里):这里补一次落地,让气泡及时进灰态。 s.rebindSessionTurns(msg.sessionId ?? s.activeSessionId); // 灰态的**兜底**收口(规范草案 §1.2):该 `turnId` 的助手回复落库同样收口, // 它与 `turn.end` 顺序不定,任一到即收。 if (msg.role === "assistant" && msg.turnId) { s.clearGrey(msg.turnId, msg.sessionId ?? s.activeSessionId); } } return true; } case "message_edited": { // design/16 §4.2 (D3 edit path) — the stored message's content was changed // and broadcast with the updated DTO. The backend row stays the single source // of truth; this only re-renders the bubble of the session the frame names // (the store confines the patch to that session's bucket). Patch by id — an // id we do not hold is another session's (or an already-gone) message, never // a new row. const edit = parseMessageEdited(ev.data); if (!edit) return true; const patch: Partial = { content: edit.content }; if (edit.mode) patch.mode = edit.mode; if (edit.mood) patch.mood = edit.mood; s.updateMessageById(edit.id, patch, edit.sessionId ?? ev.sessionId); return true; } case "message_deleted": { // design/16 §4.2 (D3 delete path) — the message was removed server-side, so // drop it by id instead of waiting for a manual re-read. The frame's session // scopes the removal; without one the store searches by id (globally unique). const id = r && typeof r.id === "string" ? r.id : ""; if (id) s.removeMessageById(id, ev.sessionId); return true; } case "channel_persisted": { // design/16 §4.6 §4 — an isolated (group/stranger) channel message was // persisted and broadcast for the IM adapter log page. The web chat has no // handler for this type, so it never reaches the active conversation; the // admin adapter log consumes it via `channelEvents` to append in realtime. const msg = parseAdminChannelMessage(r?.message); if (msg) s.pushChannelEvent(msg); return true; } default: return false; } }