Skip to content

Commit d7281cf

Browse files
committed
fix: reconcile chat_message via explicit deletions
1 parent 2eec1e7 commit d7281cf

4 files changed

Lines changed: 62 additions & 17 deletions

File tree

backend/open_webui/models/chats.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class ChatFileModel(BaseModel):
139139
class ChatForm(BaseModel):
140140
chat: dict
141141
folder_id: str | None = None
142+
# Explicit deletions to prune; absent => upsert-only.
143+
deleted_message_ids: list[str] | None = None
142144

143145

144146
class ChatImportForm(ChatForm):
@@ -499,22 +501,42 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
499501
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
500502

501503
async def reconcile_messages_by_chat_id(
502-
self, chat_id: str, user_id: str, messages: dict[str, dict]
504+
self,
505+
chat_id: str,
506+
user_id: str,
507+
messages: dict[str, dict],
508+
deleted_message_ids: list[str] | None = None,
503509
) -> None:
504-
"""Sync ``chat_message`` rows with the committed JSON blob.
510+
"""Upsert blob messages, then prune only the IDs the client explicitly
511+
deleted (plus their now-orphaned descendants).
505512
506-
Upserts current messages via ``backfill_messages_by_chat_id``
507-
and deletes orphaned rows whose message_id no longer appears
508-
in the blob. Best-effort: errors are logged but never raised.
513+
Rows merely missing from the blob are never inferred as deletions — a
514+
lost write race would otherwise drop a still-valid message permanently.
515+
Best-effort: errors are logged, not raised.
509516
"""
510517
try:
511518
await self.backfill_messages_by_chat_id(chat_id, user_id, messages)
512519

513-
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id)
514-
if existing_map is not None:
515-
orphaned_ids = set(existing_map.keys()) - set(messages.keys())
516-
if orphaned_ids:
517-
await ChatMessages.delete_message_ids_by_chat_id(chat_id, orphaned_ids)
520+
dead_ids = set(deleted_message_ids or [])
521+
if not dead_ids:
522+
return
523+
524+
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id) or {}
525+
526+
# Reparented children reappear in the blob and survive; truly orphaned ones go with the parent.
527+
changed = True
528+
while changed:
529+
changed = False
530+
for message_id, message in existing_map.items():
531+
if message_id in dead_ids or message_id in messages:
532+
continue
533+
if message.get('parentId') in dead_ids:
534+
dead_ids.add(message_id)
535+
changed = True
536+
537+
dead_ids &= set(existing_map.keys())
538+
if dead_ids:
539+
await ChatMessages.delete_message_ids_by_chat_id(chat_id, dead_ids)
518540
except Exception as e:
519541
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
520542

backend/open_webui/routers/chats.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,9 @@ async def update_chat_by_id(
993993
# history with potential edits, deletions, or new branches.
994994
messages = (updated_chat.get('history') or {}).get('messages') or {}
995995
if messages:
996-
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
996+
await Chats.reconcile_messages_by_chat_id(
997+
id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids
998+
)
997999

9981000
return ChatResponse(**chat.model_dump())
9991001
else:

src/lib/apis/chats/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,7 +1039,12 @@ export const getChatAccessGrants = async (token: string, id: string) => {
10391039
return res;
10401040
};
10411041

1042-
export const updateChatById = async (token: string, id: string, chat: object) => {
1042+
export const updateChatById = async (
1043+
token: string,
1044+
id: string,
1045+
chat: object,
1046+
deletedMessageIds: string[] | null = null
1047+
) => {
10431048
let error = null;
10441049

10451050
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
@@ -1050,7 +1055,11 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
10501055
...(token && { authorization: `Bearer ${token}` })
10511056
},
10521057
body: JSON.stringify({
1053-
chat: chat
1058+
chat: chat,
1059+
// Omit when empty so ordinary saves stay upsert-only.
1060+
...(deletedMessageIds && deletedMessageIds.length
1061+
? { deleted_message_ids: deletedMessageIds }
1062+
: {})
10541063
})
10551064
})
10561065
.then(async (res) => {

src/lib/components/chat/Messages.svelte

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,24 @@
164164
}
165165
};
166166
167+
// Deletions buffered since the last save; the backend prunes exactly these.
168+
let deletedMessageIds = new Set();
169+
167170
const updateChat = async () => {
168171
if (!$temporaryChatEnabled) {
169172
history = history;
170173
await tick();
171-
const res = await updateChatById(localStorage.token, chatId, {
172-
history: history,
173-
messages: messages
174-
});
174+
const res = await updateChatById(
175+
localStorage.token,
176+
chatId,
177+
{
178+
history: history,
179+
messages: messages
180+
},
181+
Array.from(deletedMessageIds)
182+
);
183+
// Reached only on success (updateChatById throws otherwise); keep IDs for retry on failure.
184+
deletedMessageIds.clear();
175185
176186
// Refresh local message content from backend (e.g. re-derived via serialize_output)
177187
if (res?.chat?.history?.messages) {
@@ -461,9 +471,11 @@
461471
// Delete the message and its children
462472
[messageId, ...childMessageIds].forEach((id) => {
463473
delete history.messages[id];
474+
deletedMessageIds.add(id);
464475
});
465476
466477
showMessage({ id: parentMessageId }, false);
478+
await updateChat();
467479
};
468480
469481
const triggerScroll = () => {

0 commit comments

Comments
 (0)