diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f237ce58f7..81e11645f7 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -134,6 +134,7 @@ class ChatFileModel(BaseModel): class ChatForm(BaseModel): chat: dict folder_id: str | None = None + deleted_message_ids: list[str] | None = None class ChatImportForm(ChatForm): @@ -497,6 +498,69 @@ class ChatTable: if msg.get('parentId') and msg['parentId'] not in messages_map } + @staticmethod + def _expand_deleted_ids(messages_map: dict, deleted_ids, protected_ids=()) -> set[str]: + """Expand a deletion set to include its orphaned descendants, keeping any + node that reappears in ``protected_ids`` (reparented children survive). + """ + dead = set(deleted_ids) + if not dead: + return dead + changed = True + while changed: + changed = False + for message_id, message in messages_map.items(): + if message_id in dead or message_id in protected_ids: + continue + if message.get('parentId') in dead: + dead.add(message_id) + changed = True + # Intersect so unknown/already-gone ids in deleted_ids don't leak out. + return dead & set(messages_map) + + @staticmethod + def merge_history(existing_history: dict, incoming_history: dict, deleted_message_ids=None) -> dict: + """Merge an incoming history onto the stored one without inferring deletions. + + Messages absent from the push are kept — only ``deleted_message_ids`` and + their orphaned descendants are removed — so a stale client can't drop a + message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``. + """ + existing = (existing_history or {}).get('messages') or {} + incoming = (incoming_history or {}).get('messages') or {} + merged = {**existing, **incoming} + # Drop non-dict nodes so a corrupt stored entry can't crash the rebuild below. + merged = {mid: msg for mid, msg in merged.items() if isinstance(msg, dict)} + + for message_id in ChatTable._expand_deleted_ids( + merged, deleted_message_ids or [], protected_ids=incoming + ): + merged.pop(message_id, None) + + # Rebuild childrenIds from parentId so concurrent branches stay reachable + # (display only; the model context walks parentId, not childrenIds). + for message in merged.values(): + message['childrenIds'] = [ + child_id for child_id in (message.get('childrenIds') or []) if child_id in merged + ] + for message_id, message in merged.items(): + parent_id = message.get('parentId') + if parent_id in merged and message_id not in merged[parent_id]['childrenIds']: + merged[parent_id]['childrenIds'].append(message_id) + + # Keep the push's currentId; fall back only if it was pruned. + current_id = (incoming_history or {}).get('currentId') + if current_id not in merged: + fallback = (existing_history or {}).get('currentId') + current_id = fallback if fallback in merged else None + + # Preserve any other history keys from the push (fall back to existing). + return { + **(incoming_history or existing_history or {}), + 'messages': merged, + 'currentId': current_id, + } + async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: """Write messages to the ``chat_message`` table so future lookups use the fast path. Errors are logged but never raised. @@ -514,21 +578,29 @@ class ChatTable: except Exception as e: log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e) - async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None: + async def reconcile_messages_by_chat_id( + self, chat_id: str, user_id: str, messages: dict[str, dict], deleted_message_ids=None + ) -> None: """Sync ``chat_message`` rows with the committed JSON blob. - Upserts current messages via ``backfill_messages_by_chat_id`` - and deletes orphaned rows whose message_id no longer appears - in the blob. Best-effort: errors are logged but never raised. + Upserts the pushed messages via ``backfill_messages_by_chat_id``, then + prunes only the IDs the client explicitly reported in ``deleted_message_ids`` + plus their orphaned descendants. Rows merely missing from the push are never + inferred as deletions — otherwise a stale/lost-update save would silently drop + a still-valid message from the model context. Best-effort: errors are logged + but never raised. """ try: await self.backfill_messages_by_chat_id(chat_id, user_id, messages) - existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id) - if existing_map is not None: - orphaned_ids = set(existing_map.keys()) - set(messages.keys()) - if orphaned_ids: - await ChatMessages.delete_message_ids_by_chat_id(chat_id, orphaned_ids) + if deleted_message_ids: + existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id) + if existing_map is not None: + dead_ids = self._expand_deleted_ids( + existing_map, deleted_message_ids, protected_ids=messages + ) + if dead_ids: + await ChatMessages.delete_message_ids_by_chat_id(chat_id, dead_ids) except Exception as e: log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e) diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 2689aa6d2f..5d23520cbd 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -973,13 +973,22 @@ async def update_chat_by_id( if chat: updated_chat = {**chat.chat, **form_data.chat} + # Merge (don't replace) history so a stale save can't clobber unseen + # messages; see Chats.merge_history for the full rationale. + if 'history' in form_data.chat: + updated_chat['history'] = Chats.merge_history( + chat.chat.get('history'), + form_data.chat.get('history'), + deleted_message_ids=form_data.deleted_message_ids, + ) + # Re-derive content from output for assistant messages so that frontend # edits to output items are reflected in content. Only when output # actually changed — otherwise content set independently of output # (e.g. a `replace` event or an outlet filter footer) would be reverted. existing_messages = (chat.chat.get('history') or {}).get('messages') or {} for msg_id, msg in updated_chat.get('history', {}).get('messages', {}).items(): - if msg.get('role') == 'assistant' and msg.get('output'): + if isinstance(msg, dict) and msg.get('role') == 'assistant' and msg.get('output'): if msg.get('output') != existing_messages.get(msg_id, {}).get('output'): msg['content'] = serialize_output(msg['output']) @@ -990,7 +999,9 @@ async def update_chat_by_id( # history with potential edits, deletions, or new branches. messages = (updated_chat.get('history') or {}).get('messages') or {} if messages: - await Chats.reconcile_messages_by_chat_id(id, user.id, messages) + await Chats.reconcile_messages_by_chat_id( + id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids + ) return ChatResponse(**chat.model_dump()) else: diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 1916e35086..f0efee40d6 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -1039,7 +1039,12 @@ export const getChatAccessGrants = async (token: string, id: string) => { return res; }; -export const updateChatById = async (token: string, id: string, chat: object) => { +export const updateChatById = async ( + token: string, + id: string, + chat: object, + deletedMessageIds: string[] = [] +) => { let error = null; const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, { @@ -1050,7 +1055,9 @@ export const updateChatById = async (token: string, id: string, chat: object) => ...(token && { authorization: `Bearer ${token}` }) }, body: JSON.stringify({ - chat: chat + chat: chat, + // Only sent when the client explicitly deleted messages; absent => upsert-only (no pruning). + ...(deletedMessageIds.length > 0 && { deleted_message_ids: deletedMessageIds }) }) }) .then(async (res) => { diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 69652dbe41..582a377995 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -431,7 +431,7 @@ } }; - const showMessage = async (message, scroll = true) => { + const showMessage = async (message, scroll = true, save = true) => { const _chatId = JSON.parse(JSON.stringify($chatId)); let _messageId = JSON.parse(JSON.stringify(message.id)); @@ -464,7 +464,10 @@ await tick(); await tick(); - saveChatHandler(_chatId, history); + // Callers that persist separately (e.g. deleteMessage's save that carries the deleted IDs) pass save=false. + if (save) { + saveChatHandler(_chatId, history); + } }; const updateLastReadAt = (id) => { diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index d525e5a8a4..7a61f66302 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -164,14 +164,26 @@ } }; + // IDs explicitly deleted by the user, sent with the next save so the backend prunes + // the matching chat_message rows. Cleared only on a successful save (survives retry). + let deletedMessageIds = new Set(); + const updateChat = async () => { if (!$temporaryChatEnabled) { history = history; await tick(); - const res = await updateChatById(localStorage.token, chatId, { - history: history, - messages: messages - }); + const res = await updateChatById( + localStorage.token, + chatId, + { + history: history, + messages: messages + }, + Array.from(deletedMessageIds) + ); + + // Reached only when the save succeeded (updateChatById throws on failure). + deletedMessageIds.clear(); // Refresh local message content from backend (e.g. re-derived via serialize_output) if (res?.chat?.history?.messages) { @@ -458,12 +470,18 @@ } }); - // Delete the message and its children + // Delete the message and its children, buffering the IDs so the next save + // tells the backend to prune the matching chat_message rows. Grandchildren + // were reparented above and are intentionally not added to the deleted-IDs buffer. [messageId, ...childMessageIds].forEach((id) => { delete history.messages[id]; + deletedMessageIds.add(id); }); - showMessage({ id: parentMessageId }, false); + // Move currentId to the parent without saving, then persist once via + // updateChat() so the deletion travels with its deleted_message_ids. + showMessage({ id: parentMessageId }, false, false); + await updateChat(); }; const triggerScroll = () => {