Files
Cyrene_v2/frontend/web/src/lib/ws/handlers/message.ts
T
AskaEth 012e9f3ad4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 前端 P0 收尾(多路忙态收口 / 握手 history 落 store / 清理幽灵键)
2026-10-05 11:29:17 +08:00

117 lines
5.5 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 { 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<ChatMessage> = { 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;
}
}