diff --git a/backend/open_webui/models/chat_messages.py b/backend/open_webui/models/chat_messages.py index b33be1f7eb..cc160997ef 100644 --- a/backend/open_webui/models/chat_messages.py +++ b/backend/open_webui/models/chat_messages.py @@ -394,6 +394,24 @@ class ChatMessageTable: await db.commit() return True + async def delete_message_ids_by_chat_id( + self, + chat_id: str, + message_ids: set[str], + db: Optional[AsyncSession] = None, + ) -> bool: + """Delete specific ``chat_message`` rows by their original message IDs.""" + if not message_ids: + return True + async with get_async_db_context(db) as db: + await db.execute( + delete(ChatMessage) + .where(ChatMessage.chat_id == chat_id) + .where(ChatMessage.id.in_({f'{chat_id}-{mid}' for mid in message_ids})) + ) + await db.commit() + return True + # Analytics methods async def get_message_count_by_model( self, diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 954d8640f0..a19cdf1a6b 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -394,6 +394,9 @@ class ChatTable: try: async with get_async_db_context(db) as db: chat_item = await db.get(Chat, id) + if chat_item is None: + return None + chat_item.chat = self._clean_null_bytes(chat) chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat' @@ -495,6 +498,26 @@ 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: + """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. + """ + 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) + except Exception as e: + log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e) + async def get_messages_map_by_chat_id(self, id: str) -> dict | None: """Message map for walking history (see ``get_message_list``). diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 9344173807..7c6f3dea9d 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -987,6 +987,14 @@ async def update_chat_by_id( msg['content'] = serialize_output(msg['output']) chat = await Chats.update_chat_by_id(id, updated_chat, db=db) + + # Reconcile chat_message rows with the committed blob. + # This is the only caller where the frontend pushes a full + # 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) + return ChatResponse(**chat.model_dump()) else: raise HTTPException(