Skip to content

Commit 745a4fc

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: reconcile chat_message via explicit deletions
1 parent d7e5d8e commit 745a4fc

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
@@ -133,6 +133,8 @@ class ChatFileModel(BaseModel):
133133
class ChatForm(BaseModel):
134134
chat: dict
135135
folder_id: str | None = None
136+
# Explicit deletions to prune; absent => upsert-only.
137+
deleted_message_ids: list[str] | None = None
136138

137139

138140
class ChatImportForm(ChatForm):
@@ -496,22 +498,42 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
496498
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
497499

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

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

backend/open_webui/routers/chats.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,9 @@ async def update_chat_by_id(
982982
# history with potential edits, deletions, or new branches.
983983
messages = (updated_chat.get('history') or {}).get('messages') or {}
984984
if messages:
985-
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
985+
await Chats.reconcile_messages_by_chat_id(
986+
id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids
987+
)
986988

987989
return ChatResponse(**chat.model_dump())
988990
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)