117 lines
5.5 KiB
TypeScript
117 lines
5.5 KiB
TypeScript
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;
|
||
}
|
||
}
|