refactor(frontend): 拆 types.ts(871) / ws.ts::dispatchFrame(266)(纯搬移 + barrel)

执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-D:
- lib/types.ts(871) → lib/types/ 11 个域文件 + 14 行 `export *` barrel(仿 lib/api.ts 先例)
  chat / ws / session / agent / flow / persona / api / admin(269) / adminWrite(176) / kook /
  notification
- lib/ws.ts(494) → 203 行入口 + lib/ws/handlers/ 8 文件;dispatchFrame(266 行) 改为按组
  分发链(7 个 handler 各持一组 case,命中 return true,未命中走原 default 兜底),
  33 个 case 标签一一守恒

机械证明:type 导出集 86 个守恒(拆后并集 diff 为空);去掉空行与 import/export 语句后
**784=784 行、排序 diff 为空**;dispatchFrame 的 case 标签集 33=33 守恒;ws.ts 的
scheduleReconnect 至文件尾与 HEAD 字节级一致。
65 个 lib/types 消费方 + 3 个 lib/ws 消费方**零改动**通过 —— tsc 0 错误正是这一点的强判据。

验证:npx tsc --noEmit 0 错误;npm run build 成功;9 个前端逻辑测试全绿(含真实驱动
拆后 dispatchFrame 的 messageSync 54 checks 与 personaSession 23 checks);
最大文件 admin.ts 269 行、最大函数 openSocket 72 行(均达标)。
This commit is contained in:
AskaEth committed 2026-10-04 10:54:06 +08:00
1 parent e9afca9b29
commit 6e0323b446
21 files changed
+1288 -1183

No files matched your search

@@ -0,0 +1,92 @@
import { messageEventApplies } from "../../../store/useAppStore";
import { MODES, type ChatMessage, type Mode } from "../../types";
import { parseAdminChannelMessage, parsePersistedMessage, toRecord } from "../../wsParse";
import type { FrameEvent, FramePayload, 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 frame can be filtered; `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 "message_persisted": {
// design/16 §4.2 — a user or assistant message was persisted and broadcast
// with its backend id. Only surface messages for the active session; 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 && msg.sessionId === s.activeSessionId) {
s.appendOrUpdateMessage(msg);
}
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, and only in the session it
// belongs to (same filter as message_persisted). 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;
if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) {
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);
}
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 id is globally
// unique; sessionId is advisory and, when present, must be the active one.
const id = r && typeof r.id === "string" ? r.id : "";
if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) {
s.removeMessageById(id);
}
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;
}
}