diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 4be82d5b4b..36a1b160ba 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -551,6 +551,68 @@ class ChatTable: if msg.get('parentId') and msg['parentId'] not in messages_map } + @staticmethod + def merge_history(existing_history: dict | None, incoming_history: dict | None) -> dict: + existing = (existing_history or {}).get('messages') or {} + incoming = (incoming_history or {}).get('messages') or {} + merged = {**existing, **incoming} + merged = {message_id: message for message_id, message in merged.items() if isinstance(message, dict)} + + for message in merged.values(): + message['childrenIds'] = [] + for message_id, message in merged.items(): + parent_id = message.get('parentId') + if parent_id in merged: + merged[parent_id]['childrenIds'].append(message_id) + + current_id = (incoming_history or {}).get('currentId') + if current_id not in merged: + current_id = (existing_history or {}).get('currentId') + if current_id not in merged: + current_id = None + + return {**(existing_history or {}), **(incoming_history or {}), 'messages': merged, 'currentId': current_id} + + @staticmethod + def delete_message_from_history(history: dict, message_id: str) -> set[str]: + messages = history.get('messages') or {} + message = messages.get(message_id) + if not isinstance(message, dict): + return set() + + parent_id = message.get('parentId') + child_ids = [child_id for child_id in (message.get('childrenIds') or []) if child_id in messages] + grandchild_ids = [ + grandchild_id + for child_id in child_ids + for grandchild_id in (messages.get(child_id, {}).get('childrenIds') or []) + if grandchild_id in messages + ] + + if parent_id in messages: + messages[parent_id]['childrenIds'] = [ + child_id for child_id in (messages[parent_id].get('childrenIds') or []) if child_id != message_id + ] + grandchild_ids + + for grandchild_id in grandchild_ids: + messages[grandchild_id]['parentId'] = parent_id + + deleted_ids = {message_id, *child_ids} + for deleted_id in deleted_ids: + messages.pop(deleted_id, None) + + current_id = parent_id + child_ids = ( + [child_id for child_id, child in messages.items() if child.get('parentId') is None] + if current_id is None + else messages.get(current_id, {}).get('childrenIds', []) + ) + while child_ids: + current_id = child_ids[-1] + child_ids = messages.get(current_id, {}).get('childrenIds', []) + history['currentId'] = current_id if current_id in messages else None + return deleted_ids + 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. @@ -571,18 +633,11 @@ class ChatTable: 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. + Upserts current messages via ``backfill_messages_by_chat_id``. + 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) @@ -718,6 +773,26 @@ class ChatTable: return await self.update_chat_by_id(id, chat) + async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None: + chat_model = await self.get_chat_by_id(id) + if chat_model is None: + return None + + chat = chat_model.chat + history = chat.get('history', {}) + deleted_ids = self.delete_message_from_history(history, message_id) + if not deleted_ids: + return chat_model + + messages = history.get('messages') or {} + chat['history'] = history + updated_chat = await self.update_chat_by_id(id, chat) + + await self.backfill_messages_by_chat_id(id, chat_model.user_id, messages) + await ChatMessages.delete_message_ids_by_chat_id(id, deleted_ids) + + return updated_chat + async def add_message_status_to_chat_by_id_and_message_id( self, id: str, message_id: str, status: dict ) -> ChatModel | None: diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 08d3b1ccb2..7fbdfa125a 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1219,7 +1219,6 @@ async def update_chat_by_id( updated_chat['history'] = Chats.merge_history( chat.chat.get('history'), form_data.chat.get('history'), - form_data.deleted_message_ids, ) # Re-derive content from output for assistant messages so that frontend @@ -1234,12 +1233,11 @@ async def update_chat_by_id( 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. + # Reconcile chat_message rows without inferring deletes from missing IDs. + # Message deletion has its own endpoint below. messages = (updated_chat.get('history') or {}).get('messages') or {} if messages: - await Chats.reconcile_messages_by_chat_id(id, user.id, messages, form_data.deleted_message_ids) + await Chats.reconcile_messages_by_chat_id(id, user.id, messages) await publish_event( request, @@ -1325,6 +1323,45 @@ async def update_chat_message_by_id( return ChatResponse(**chat.model_dump()) +@router.delete('/{id}/messages/{message_id}', response_model=ChatResponse | None) +async def delete_chat_message_by_id( + request: Request, + id: str, + message_id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + chat = await Chats.get_chat_by_id(id, db=db) + + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + if chat.user_id != user.id and user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + chat = await Chats.delete_message_from_chat_by_id_and_message_id(id, message_id) + if not chat: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + + await publish_event( + request, + EVENTS.MESSAGE_DELETED, + actor=user, + subject_id=message_id, + data={'chat_id': id}, + ) + return ChatResponse(**chat.model_dump()) + + ############################ # SendChatMessageEventById ############################