Skip to content

Commit 4d6ec92

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
fix: merge chat history to prevent message loss
1 parent 3d100ca commit 4d6ec92

5 files changed

Lines changed: 132 additions & 21 deletions

File tree

backend/open_webui/models/chats.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ class ChatFileModel(BaseModel):
134134
class ChatForm(BaseModel):
135135
chat: dict
136136
folder_id: str | None = None
137+
deleted_message_ids: list[str] | None = None
137138

138139

139140
class ChatImportForm(ChatForm):
@@ -497,6 +498,69 @@ def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
497498
if msg.get('parentId') and msg['parentId'] not in messages_map
498499
}
499500

501+
@staticmethod
502+
def _expand_deleted_ids(messages_map: dict, deleted_ids, protected_ids=()) -> set[str]:
503+
"""Expand a deletion set to include its orphaned descendants, keeping any
504+
node that reappears in ``protected_ids`` (reparented children survive).
505+
"""
506+
dead = set(deleted_ids)
507+
if not dead:
508+
return dead
509+
changed = True
510+
while changed:
511+
changed = False
512+
for message_id, message in messages_map.items():
513+
if message_id in dead or message_id in protected_ids:
514+
continue
515+
if message.get('parentId') in dead:
516+
dead.add(message_id)
517+
changed = True
518+
# Intersect so unknown/already-gone ids in deleted_ids don't leak out.
519+
return dead & set(messages_map)
520+
521+
@staticmethod
522+
def merge_history(existing_history: dict, incoming_history: dict, deleted_message_ids=None) -> dict:
523+
"""Merge an incoming history onto the stored one without inferring deletions.
524+
525+
Messages absent from the push are kept — only ``deleted_message_ids`` and
526+
their orphaned descendants are removed — so a stale client can't drop a
527+
message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``.
528+
"""
529+
existing = (existing_history or {}).get('messages') or {}
530+
incoming = (incoming_history or {}).get('messages') or {}
531+
merged = {**existing, **incoming}
532+
# Drop non-dict nodes so a corrupt stored entry can't crash the rebuild below.
533+
merged = {mid: msg for mid, msg in merged.items() if isinstance(msg, dict)}
534+
535+
for message_id in ChatTable._expand_deleted_ids(
536+
merged, deleted_message_ids or [], protected_ids=incoming
537+
):
538+
merged.pop(message_id, None)
539+
540+
# Rebuild childrenIds from parentId so concurrent branches stay reachable
541+
# (display only; the model context walks parentId, not childrenIds).
542+
for message in merged.values():
543+
message['childrenIds'] = [
544+
child_id for child_id in (message.get('childrenIds') or []) if child_id in merged
545+
]
546+
for message_id, message in merged.items():
547+
parent_id = message.get('parentId')
548+
if parent_id in merged and message_id not in merged[parent_id]['childrenIds']:
549+
merged[parent_id]['childrenIds'].append(message_id)
550+
551+
# Keep the push's currentId; fall back only if it was pruned.
552+
current_id = (incoming_history or {}).get('currentId')
553+
if current_id not in merged:
554+
fallback = (existing_history or {}).get('currentId')
555+
current_id = fallback if fallback in merged else None
556+
557+
# Preserve any other history keys from the push (fall back to existing).
558+
return {
559+
**(incoming_history or existing_history or {}),
560+
'messages': merged,
561+
'currentId': current_id,
562+
}
563+
500564
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
501565
"""Write messages to the ``chat_message`` table so future lookups
502566
use the fast path. Errors are logged but never raised.
@@ -514,21 +578,29 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
514578
except Exception as e:
515579
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
516580

517-
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
581+
async def reconcile_messages_by_chat_id(
582+
self, chat_id: str, user_id: str, messages: dict[str, dict], deleted_message_ids=None
583+
) -> None:
518584
"""Sync ``chat_message`` rows with the committed JSON blob.
519585
520-
Upserts current messages via ``backfill_messages_by_chat_id``
521-
and deletes orphaned rows whose message_id no longer appears
522-
in the blob. Best-effort: errors are logged but never raised.
586+
Upserts the pushed messages via ``backfill_messages_by_chat_id``, then
587+
prunes only the IDs the client explicitly reported in ``deleted_message_ids``
588+
plus their orphaned descendants. Rows merely missing from the push are never
589+
inferred as deletions — otherwise a stale/lost-update save would silently drop
590+
a still-valid message from the model context. Best-effort: errors are logged
591+
but never raised.
523592
"""
524593
try:
525594
await self.backfill_messages_by_chat_id(chat_id, user_id, messages)
526595

527-
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id)
528-
if existing_map is not None:
529-
orphaned_ids = set(existing_map.keys()) - set(messages.keys())
530-
if orphaned_ids:
531-
await ChatMessages.delete_message_ids_by_chat_id(chat_id, orphaned_ids)
596+
if deleted_message_ids:
597+
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id)
598+
if existing_map is not None:
599+
dead_ids = self._expand_deleted_ids(
600+
existing_map, deleted_message_ids, protected_ids=messages
601+
)
602+
if dead_ids:
603+
await ChatMessages.delete_message_ids_by_chat_id(chat_id, dead_ids)
532604
except Exception as e:
533605
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
534606

backend/open_webui/routers/chats.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,13 +1177,22 @@ async def update_chat_by_id(
11771177
if chat:
11781178
updated_chat = {**chat.chat, **form_data.chat}
11791179

1180+
# Merge (don't replace) history so a stale save can't clobber unseen
1181+
# messages; see Chats.merge_history for the full rationale.
1182+
if 'history' in form_data.chat:
1183+
updated_chat['history'] = Chats.merge_history(
1184+
chat.chat.get('history'),
1185+
form_data.chat.get('history'),
1186+
deleted_message_ids=form_data.deleted_message_ids,
1187+
)
1188+
11801189
# Re-derive content from output for assistant messages so that frontend
11811190
# edits to output items are reflected in content. Only when output
11821191
# actually changed — otherwise content set independently of output
11831192
# (e.g. a `replace` event or an outlet filter footer) would be reverted.
11841193
existing_messages = (chat.chat.get('history') or {}).get('messages') or {}
11851194
for msg_id, msg in updated_chat.get('history', {}).get('messages', {}).items():
1186-
if msg.get('role') == 'assistant' and msg.get('output'):
1195+
if isinstance(msg, dict) and msg.get('role') == 'assistant' and msg.get('output'):
11871196
if msg.get('output') != existing_messages.get(msg_id, {}).get('output'):
11881197
msg['content'] = serialize_output(msg['output'])
11891198

@@ -1194,7 +1203,9 @@ async def update_chat_by_id(
11941203
# history with potential edits, deletions, or new branches.
11951204
messages = (updated_chat.get('history') or {}).get('messages') or {}
11961205
if messages:
1197-
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
1206+
await Chats.reconcile_messages_by_chat_id(
1207+
id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids
1208+
)
11981209

11991210
await publish_event(
12001211
request,

src/lib/apis/chats/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,12 @@ export const getChatAccessGrants = async (token: string, id: string) => {
11561156
return res;
11571157
};
11581158

1159-
export const updateChatById = async (token: string, id: string, chat: object) => {
1159+
export const updateChatById = async (
1160+
token: string,
1161+
id: string,
1162+
chat: object,
1163+
deletedMessageIds: string[] = []
1164+
) => {
11601165
let error = null;
11611166

11621167
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
@@ -1167,7 +1172,9 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
11671172
...(token && { authorization: `Bearer ${token}` })
11681173
},
11691174
body: JSON.stringify({
1170-
chat: chat
1175+
chat: chat,
1176+
// Only sent when the client explicitly deleted messages; absent => upsert-only (no pruning).
1177+
...(deletedMessageIds.length > 0 && { deleted_message_ids: deletedMessageIds })
11711178
})
11721179
})
11731180
.then(async (res) => {

src/lib/components/chat/Chat.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@
444444
}
445445
};
446446
447-
const showMessage = async (message, scroll = true) => {
447+
const showMessage = async (message, scroll = true, save = true) => {
448448
const _chatId = JSON.parse(JSON.stringify($chatId));
449449
let _messageId = JSON.parse(JSON.stringify(message.id));
450450
@@ -477,7 +477,10 @@
477477
await tick();
478478
await tick();
479479
480-
saveChatHandler(_chatId, history);
480+
// Callers that persist separately (e.g. deleteMessage's save that carries the deleted IDs) pass save=false.
481+
if (save) {
482+
saveChatHandler(_chatId, history);
483+
}
481484
};
482485
483486
const updateLastReadAt = (id) => {

src/lib/components/chat/Messages.svelte

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,26 @@
164164
}
165165
};
166166
167+
// IDs explicitly deleted by the user, sent with the next save so the backend prunes
168+
// the matching chat_message rows. Cleared only on a successful save (survives retry).
169+
let deletedMessageIds = new Set();
170+
167171
const updateChat = async () => {
168172
if (!$temporaryChatEnabled) {
169173
history = history;
170174
await tick();
171-
const res = await updateChatById(localStorage.token, chatId, {
172-
history: history,
173-
messages: messages
174-
});
175+
const res = await updateChatById(
176+
localStorage.token,
177+
chatId,
178+
{
179+
history: history,
180+
messages: messages
181+
},
182+
Array.from(deletedMessageIds)
183+
);
184+
185+
// Reached only when the save succeeded (updateChatById throws on failure).
186+
deletedMessageIds.clear();
175187
176188
// Refresh local message content from backend (e.g. re-derived via serialize_output)
177189
if (res?.chat?.history?.messages) {
@@ -458,12 +470,18 @@
458470
}
459471
});
460472
461-
// Delete the message and its children
473+
// Delete the message and its children, buffering the IDs so the next save
474+
// tells the backend to prune the matching chat_message rows. Grandchildren
475+
// were reparented above and are intentionally not added to the deleted-IDs buffer.
462476
[messageId, ...childMessageIds].forEach((id) => {
463477
delete history.messages[id];
478+
deletedMessageIds.add(id);
464479
});
465480
466-
showMessage({ id: parentMessageId }, false);
481+
// Move currentId to the parent without saving, then persist once via
482+
// updateChat() so the deletion travels with its deleted_message_ids.
483+
showMessage({ id: parentMessageId }, false, false);
484+
await updateChat();
467485
};
468486
469487
const triggerScroll = () => {

0 commit comments

Comments
 (0)