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
@@ -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