Skip to content

Commit e3e3879

Browse files
committed
fix: merge chat history to prevent message loss
1 parent 21f6d44 commit e3e3879

5 files changed

Lines changed: 128 additions & 20 deletions

File tree

backend/open_webui/models/chats.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class ChatFileModel(BaseModel):
133133
class ChatForm(BaseModel):
134134
chat: dict
135135
folder_id: str | None = None
136+
deleted_message_ids: list[str] | None = None
136137

137138

138139
class ChatImportForm(ChatForm):
@@ -478,6 +479,68 @@ def get_unresolved_parent_ids(messages_map: dict) -> set[str]:
478479
if msg.get('parentId') and msg['parentId'] not in messages_map
479480
}
480481

482+
@staticmethod
483+
def _expand_deleted_ids(messages_map: dict, deleted_ids, protected_ids=()) -> set[str]:
484+
"""Grow an explicit deletion set to its orphaned descendants, keeping any
485+
node that reappears in ``protected_ids`` (reparented children survive).
486+
"""
487+
dead = set(deleted_ids)
488+
if not dead:
489+
return dead
490+
changed = True
491+
while changed:
492+
changed = False
493+
for message_id, message in messages_map.items():
494+
if message_id in dead or message_id in protected_ids:
495+
continue
496+
if message.get('parentId') in dead:
497+
dead.add(message_id)
498+
changed = True
499+
# Intersect so unknown/already-gone ids in deleted_ids don't leak out.
500+
return dead & set(messages_map)
501+
502+
@staticmethod
503+
def merge_history(existing_history: dict, incoming_history: dict, deleted_message_ids=None) -> dict:
504+
"""Merge an incoming history onto the stored one without inferring deletions.
505+
506+
Messages absent from the push are kept — only ``deleted_message_ids`` and
507+
their orphaned descendants are removed — so a stale client can't drop a
508+
message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``.
509+
"""
510+
existing = (existing_history or {}).get('messages') or {}
511+
incoming = (incoming_history or {}).get('messages') or {}
512+
merged = {**existing, **incoming}
513+
514+
for message_id in ChatTable._expand_deleted_ids(
515+
merged, deleted_message_ids or [], protected_ids=incoming
516+
):
517+
merged.pop(message_id, None)
518+
519+
# Rebuild childrenIds from parentId so concurrent branches stay reachable
520+
# (display only; the model context walks parentId, not childrenIds).
521+
for message in merged.values():
522+
message['childrenIds'] = [
523+
child_id for child_id in (message.get('childrenIds') or []) if child_id in merged
524+
]
525+
for message_id, message in merged.items():
526+
parent_id = message.get('parentId')
527+
if parent_id in merged and message_id not in merged[parent_id]['childrenIds']:
528+
merged[parent_id]['childrenIds'].append(message_id)
529+
530+
# Keep the push's currentId; fall back only if it was pruned.
531+
current_id = (incoming_history or {}).get('currentId')
532+
if current_id not in merged:
533+
fallback = (existing_history or {}).get('currentId')
534+
current_id = fallback if fallback in merged else None
535+
536+
# Preserve any other history keys from the push (fall back to existing),
537+
# then override messages/currentId with the merged result.
538+
return {
539+
**(incoming_history or existing_history or {}),
540+
'messages': merged,
541+
'currentId': current_id,
542+
}
543+
481544
async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
482545
"""Write messages to the ``chat_message`` table so future lookups
483546
use the fast path. Errors are logged but never raised.
@@ -496,22 +559,28 @@ async def backfill_messages_by_chat_id(self, chat_id: str, user_id: str, message
496559
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
497560

498561
async def reconcile_messages_by_chat_id(
499-
self, chat_id: str, user_id: str, messages: dict[str, dict]
562+
self, chat_id: str, user_id: str, messages: dict[str, dict], deleted_message_ids=None
500563
) -> None:
501564
"""Sync ``chat_message`` rows with the committed JSON blob.
502565
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.
566+
Upserts the pushed messages via ``backfill_messages_by_chat_id``, then
567+
prunes only the IDs the client explicitly reported in ``deleted_message_ids``
568+
plus their orphaned descendants. Rows merely missing from the push are never
569+
inferred as deletions — otherwise a stale/lost-update save would silently drop
570+
a still-valid message from the model context. Best-effort: errors are logged
571+
but never raised.
506572
"""
507573
try:
508574
await self.backfill_messages_by_chat_id(chat_id, user_id, messages)
509575

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)
576+
if deleted_message_ids:
577+
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id)
578+
if existing_map is not None:
579+
dead_ids = self._expand_deleted_ids(
580+
existing_map, deleted_message_ids, protected_ids=messages
581+
)
582+
if dead_ids:
583+
await ChatMessages.delete_message_ids_by_chat_id(chat_id, dead_ids)
515584
except Exception as e:
516585
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
517586

backend/open_webui/routers/chats.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,15 @@ async def update_chat_by_id(
968968
if chat:
969969
updated_chat = {**chat.chat, **form_data.chat}
970970

971+
# Merge (don't replace) history so a stale save can't clobber unseen
972+
# messages; see Chats.merge_history for the full rationale.
973+
if 'history' in form_data.chat:
974+
updated_chat['history'] = Chats.merge_history(
975+
chat.chat.get('history'),
976+
form_data.chat.get('history'),
977+
deleted_message_ids=form_data.deleted_message_ids,
978+
)
979+
971980
# Re-derive content from output for assistant messages so that
972981
# frontend edits to output items are always reflected in content.
973982
# serialize_output() is the single source of truth for this conversion.
@@ -982,7 +991,9 @@ async def update_chat_by_id(
982991
# history with potential edits, deletions, or new branches.
983992
messages = (updated_chat.get('history') or {}).get('messages') or {}
984993
if messages:
985-
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
994+
await Chats.reconcile_messages_by_chat_id(
995+
id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids
996+
)
986997

987998
return ChatResponse(**chat.model_dump())
988999
else:

src/lib/apis/chats/index.ts

Lines changed: 9 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[] = []
1047+
) => {
10431048
let error = null;
10441049

10451050
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
@@ -1050,7 +1055,9 @@ 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+
// Only sent when the client explicitly deleted messages; absent => upsert-only (no pruning).
1060+
...(deletedMessageIds.length > 0 && { deleted_message_ids: deletedMessageIds })
10541061
})
10551062
})
10561063
.then(async (res) => {

src/lib/components/chat/Chat.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@
409409
}
410410
};
411411
412-
const showMessage = async (message, scroll = true) => {
412+
const showMessage = async (message, scroll = true, save = true) => {
413413
const _chatId = JSON.parse(JSON.stringify($chatId));
414414
let _messageId = JSON.parse(JSON.stringify(message.id));
415415
@@ -442,7 +442,10 @@
442442
await tick();
443443
await tick();
444444
445-
saveChatHandler(_chatId, history);
445+
// Callers that persist separately (e.g. deleteMessage's tombstone-carrying save) pass save=false.
446+
if (save) {
447+
saveChatHandler(_chatId, history);
448+
}
446449
};
447450
448451
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 tombstoned.
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)