feat(gateway+web): D 消息编辑/删除 WS 实时广播 — message_edited/message_deleted
- 后端: 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:
1 parent
4ea68d17f3
commit
2720d77331
8 files changed
+485
-15
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Logic test for the D3 realtime-sync frames (`message_edited` / `message_deleted`).
|
||||
// The project has no frontend test runner and no jsdom, so this is a dependency-free
|
||||
// plain-node check: it drives the real frame decoder (`dispatchFrame` in lib/ws.ts)
|
||||
// and the real store, and throws on the first failure.
|
||||
//
|
||||
// Run it with the esbuild that already ships with vite (no new dependency, no
|
||||
// package.json change); the banner supplies the `window.localStorage` the store's
|
||||
// persist middleware probes, which is simply absent under node:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/store/messageSync.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-message-sync.test.mjs \
|
||||
// && node /tmp/cyrene-message-sync.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
||||
// imports nothing from node:*.
|
||||
|
||||
import { dispatchFrame } from "../lib/ws";
|
||||
import { messageEventApplies, useAppStore } from "./useAppStore";
|
||||
import type { ChatMessage } from "../lib/types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
const OTHER = "web:sub-1";
|
||||
|
||||
/** Reset the store to a focused session holding a fixed message list. */
|
||||
function seed(messages: ChatMessage[], activeSessionId: string): void {
|
||||
useAppStore.setState({ activeSessionId, messages });
|
||||
}
|
||||
|
||||
function messages(): ChatMessage[] {
|
||||
return useAppStore.getState().messages;
|
||||
}
|
||||
|
||||
function base(): ChatMessage[] {
|
||||
return [
|
||||
{ id: "m1", sessionId: MAIN, role: "user", content: "old", mode: "chat", createdAt: 1000 },
|
||||
{ id: "m2", sessionId: MAIN, role: "assistant", content: "reply", createdAt: 2000 },
|
||||
];
|
||||
}
|
||||
|
||||
// ① Session filter: a frame scoped to another conversation never touches this one.
|
||||
ok(messageEventApplies(MAIN, MAIN), "same session applies");
|
||||
ok(!messageEventApplies(OTHER, MAIN), "another session does not apply");
|
||||
ok(messageEventApplies(undefined, MAIN), "an unscoped (id-only) frame applies");
|
||||
|
||||
// ② message_edited patches the addressed bubble in place, by id, in the active
|
||||
// session — and leaves its siblings alone.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN, content: "new", mode: "chat", createdAt: 1000 } });
|
||||
same(
|
||||
messages().map((m) => m.content),
|
||||
["new", "reply"],
|
||||
"edit patches only the addressed message"
|
||||
);
|
||||
same(messages().find((m) => m.id === "m1")?.mode, "chat", "edit keeps the message mode");
|
||||
ok(messages().find((m) => m.id === "m1")?.createdAt === 1000, "edit keeps createdAt");
|
||||
|
||||
// ③ An edit frame for another session is ignored (no cross-session write) …
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_edited", sessionId: OTHER, data: { id: "m3", sessionId: OTHER, content: "elsewhere" } });
|
||||
same(messages().map((m) => m.content), ["old", "reply"], "edit for another session does not apply");
|
||||
|
||||
// … and an edit for an id we do not hold never appends a row.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m-ghost", sessionId: MAIN, content: "ghost" } });
|
||||
same(messages().length, 2, "edit for an unknown id does not append");
|
||||
|
||||
// A malformed frame (no content) must not blank out a rendered bubble.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN } });
|
||||
same(messages()[0].content, "old", "content-less edit frame is dropped");
|
||||
|
||||
// ④ message_deleted removes the addressed bubble in the active session only.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m1" } });
|
||||
same(messages().map((m) => m.id), ["m2"], "delete removes the addressed message");
|
||||
|
||||
// A delete frame for another session is ignored.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_deleted", sessionId: OTHER, data: { id: "m1" } });
|
||||
same(messages().map((m) => m.id), ["m1", "m2"], "delete for another session does not apply");
|
||||
|
||||
// An unscoped delete still applies (the id is globally unique, so it can only ever
|
||||
// match its own message).
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_deleted", data: { id: "m2" } });
|
||||
same(messages().map((m) => m.id), ["m1"], "unscoped delete applies by id");
|
||||
|
||||
// A delete for an id we do not hold is a no-op.
|
||||
seed(base(), MAIN);
|
||||
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m-ghost" } });
|
||||
same(messages().length, 2, "delete for an unknown id is a no-op");
|
||||
|
||||
// The store actions are also safe on their own (defensive: duplicate frames).
|
||||
seed(base(), MAIN);
|
||||
useAppStore.getState().updateMessageById("m-ghost", { content: "x" });
|
||||
useAppStore.getState().removeMessageById("m-ghost");
|
||||
same(messages().length, 2, "store id-actions no-op on an unknown id");
|
||||
|
||||
console.log(`message_edited/message_deleted logic test: ${checks} checks passed`);
|
||||
@@ -73,6 +73,13 @@ export interface AppStore {
|
||||
* it also adopts a same-role local optimistic bubble (rewriting its uid to the
|
||||
* backend id) so a message never renders twice (design/16 §4.3 dedup). */
|
||||
appendOrUpdateMessage: (msg: ChatMessage) => void;
|
||||
/** Patch one already-rendered message in place by id — the `message_edited` WS
|
||||
* path. A no-op when the id is not in the active session, so an edit broadcast
|
||||
* for another conversation can never inject a row here. */
|
||||
updateMessageById: (id: string, patch: Partial<ChatMessage>) => void;
|
||||
/** Drop one message by id — the `message_deleted` WS path. A no-op when the id
|
||||
* is absent (another session's message, or a duplicate frame). */
|
||||
removeMessageById: (id: string) => void;
|
||||
|
||||
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string) => void;
|
||||
beginAssistant: (mode?: Mode, deviceId?: string) => string;
|
||||
@@ -192,6 +199,21 @@ export const useAppStore = create<AppStore>()(
|
||||
return { messages: [...s.messages, msg] };
|
||||
}),
|
||||
|
||||
updateMessageById: (id, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.messages.findIndex((m) => m.id === id);
|
||||
if (idx === -1) return s;
|
||||
const messages = s.messages.slice();
|
||||
messages[idx] = { ...messages[idx], ...patch };
|
||||
return { messages };
|
||||
}),
|
||||
|
||||
removeMessageById: (id) =>
|
||||
set((s) => {
|
||||
const messages = s.messages.filter((m) => m.id !== id);
|
||||
return messages.length === s.messages.length ? s : { messages };
|
||||
}),
|
||||
|
||||
addUserMessage: (text, mode, image, deviceId) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
@@ -383,3 +405,20 @@ export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
|
||||
const last = s.messages[s.messages.length - 1];
|
||||
return last?.role === "assistant" ? last : undefined;
|
||||
}
|
||||
|
||||
/** Does an edit/delete frame scoped to `sessionId` belong to the conversation the
|
||||
* UI is showing? `message_edited` / `message_deleted` carry the owning session so
|
||||
* the rule matches `message_persisted`: only the active session's messages are
|
||||
* rendered, so another conversation's frame must not touch this list.
|
||||
*
|
||||
* A frame without a session id is id-scoped (the backend could not resolve the
|
||||
* conversation — see the gateway's optional message lookup). It still applies:
|
||||
* message ids are globally unique, so the by-id update/removal can only ever match
|
||||
* a message of its own session, and an id we do not hold is a no-op either way. */
|
||||
export function messageEventApplies(
|
||||
sessionId: string | undefined,
|
||||
activeSessionId: string
|
||||
): boolean {
|
||||
if (!sessionId) return true;
|
||||
return sessionId === activeSessionId;
|
||||
}
|
||||
Reference in new issue
Block a user