feat(gateway+web): D 消息编辑/删除 WS 实时广播 — message_edited/message_deleted
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端: ws_hub.go broadcastMessageEdited(完整DTO,thinking剥离)/broadcastMessageDeleted(全广播+sessionId,前端过滤); history_write.go PATCH/DELETE成功后触发(404/400/501不广播); history/message_ops.go GetMessage(导出壳,取删除前sessionId)
- 前端: useAppStore.updateMessageById/removeMessageById + messageEventApplies(同会话过滤); ws.ts 消费两事件(缺content丢弃); dispatchFrame导出(可测)
- 真机e2e: PATCH/DELETE → WS收到 message_edited/message_deleted, 二次删404无帧
- Docs/api-client v0.34 §3.8; 测试 history_write_broadcast_test + messageSync.test(14 checks)
- 遗留: 前端编辑/删除UI入口+api客户端函数未接(lib/api 无 PATCH/DELETE messages); ws_hub.go 589行>500(既有)
This commit is contained in:
AskaEth committed 2026-09-30 11:58:49 +08:00
1 parent 4ea68d17f3
commit 2720d77331
8 files changed
+485 -15

No files matched your search

+72 -4
View File
@@ -1,4 +1,4 @@
import { useAppStore } from "../store/useAppStore";
import { messageEventApplies, useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
import {
@@ -13,7 +13,7 @@ import {
playVoiceAudio,
toRecord,
} from "./wsParse";
import { MODES, type AwakenState, type Mode, type WsUpEvent } from "./types";
import { MODES, type AwakenState, type ChatMessage, type Mode, type WsUpEvent } from "./types";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
@@ -41,7 +41,43 @@ function wsUrl(): string {
return `${proto}://${window.location.host}/ws/chat${qs}`;
}
function dispatch(ev: { type: string; data: unknown }): void {
/** 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,
};
}
/** Route one decoded downstream frame into the store. Exported (with a `frame`
* shaped first argument) so the frame→store mapping can be exercised as a pure
* logic test without a browser: this project has no jsdom/test runner, so the
* socket lifecycle around it stays untested while the mapping does not have to. */
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
const s = useAppStore.getState();
const r = toRecord(ev.data);
@@ -87,6 +123,32 @@ function dispatch(ev: { type: string; data: unknown }): void {
}
break;
}
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) break;
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);
}
break;
}
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);
}
break;
}
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
@@ -305,7 +367,13 @@ function openSocket(): void {
const parsed = JSON.parse(String(msg.data)) as unknown;
const r = toRecord(parsed);
if (!r) return;
dispatch({ type: typeof r.type === "string" ? r.type : "", data: r.data });
dispatchFrame({
type: typeof r.type === "string" ? r.type : "",
// The frame envelope's sessionId is what scopes a payload that does not
// carry its own (see the `message_deleted` case in dispatch).
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
data: r.data,
});
} catch {
// ignore malformed frames
}