mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-06 02:48:04 +00:00
fix: merge chat history to prevent message loss
This commit is contained in:
parent
1a97751e37
commit
7cba8d518a
5 changed files with 132 additions and 21 deletions
|
|
@ -134,6 +134,7 @@ class ChatFileModel(BaseModel):
|
|||
class ChatForm(BaseModel):
|
||||
chat: dict
|
||||
folder_id: str | None = None
|
||||
deleted_message_ids: list[str] | None = None
|
||||
|
||||
|
||||
class ChatImportForm(ChatForm):
|
||||
|
|
@ -497,6 +498,69 @@ class ChatTable:
|
|||
if msg.get('parentId') and msg['parentId'] not in messages_map
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _expand_deleted_ids(messages_map: dict, deleted_ids, protected_ids=()) -> set[str]:
|
||||
"""Expand a deletion set to include its orphaned descendants, keeping any
|
||||
node that reappears in ``protected_ids`` (reparented children survive).
|
||||
"""
|
||||
dead = set(deleted_ids)
|
||||
if not dead:
|
||||
return dead
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for message_id, message in messages_map.items():
|
||||
if message_id in dead or message_id in protected_ids:
|
||||
continue
|
||||
if message.get('parentId') in dead:
|
||||
dead.add(message_id)
|
||||
changed = True
|
||||
# Intersect so unknown/already-gone ids in deleted_ids don't leak out.
|
||||
return dead & set(messages_map)
|
||||
|
||||
@staticmethod
|
||||
def merge_history(existing_history: dict, incoming_history: dict, deleted_message_ids=None) -> dict:
|
||||
"""Merge an incoming history onto the stored one without inferring deletions.
|
||||
|
||||
Messages absent from the push are kept — only ``deleted_message_ids`` and
|
||||
their orphaned descendants are removed — so a stale client can't drop a
|
||||
message it never saw. Blob counterpart to ``reconcile_messages_by_chat_id``.
|
||||
"""
|
||||
existing = (existing_history or {}).get('messages') or {}
|
||||
incoming = (incoming_history or {}).get('messages') or {}
|
||||
merged = {**existing, **incoming}
|
||||
# Drop non-dict nodes so a corrupt stored entry can't crash the rebuild below.
|
||||
merged = {mid: msg for mid, msg in merged.items() if isinstance(msg, dict)}
|
||||
|
||||
for message_id in ChatTable._expand_deleted_ids(
|
||||
merged, deleted_message_ids or [], protected_ids=incoming
|
||||
):
|
||||
merged.pop(message_id, None)
|
||||
|
||||
# Rebuild childrenIds from parentId so concurrent branches stay reachable
|
||||
# (display only; the model context walks parentId, not childrenIds).
|
||||
for message in merged.values():
|
||||
message['childrenIds'] = [
|
||||
child_id for child_id in (message.get('childrenIds') or []) if child_id in merged
|
||||
]
|
||||
for message_id, message in merged.items():
|
||||
parent_id = message.get('parentId')
|
||||
if parent_id in merged and message_id not in merged[parent_id]['childrenIds']:
|
||||
merged[parent_id]['childrenIds'].append(message_id)
|
||||
|
||||
# Keep the push's currentId; fall back only if it was pruned.
|
||||
current_id = (incoming_history or {}).get('currentId')
|
||||
if current_id not in merged:
|
||||
fallback = (existing_history or {}).get('currentId')
|
||||
current_id = fallback if fallback in merged else None
|
||||
|
||||
# Preserve any other history keys from the push (fall back to existing).
|
||||
return {
|
||||
**(incoming_history or existing_history or {}),
|
||||
'messages': merged,
|
||||
'currentId': current_id,
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -514,21 +578,29 @@ class ChatTable:
|
|||
except Exception as e:
|
||||
log.warning('Backfill failed for message %s in chat %s: %s', message_id, chat_id, e)
|
||||
|
||||
async def reconcile_messages_by_chat_id(self, chat_id: str, user_id: str, messages: dict[str, dict]) -> None:
|
||||
async def reconcile_messages_by_chat_id(
|
||||
self, chat_id: str, user_id: str, messages: dict[str, dict], deleted_message_ids=None
|
||||
) -> 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 the pushed messages via ``backfill_messages_by_chat_id``, then
|
||||
prunes only the IDs the client explicitly reported in ``deleted_message_ids``
|
||||
plus their orphaned descendants. Rows merely missing from the push are never
|
||||
inferred as deletions — otherwise a stale/lost-update save would silently drop
|
||||
a still-valid message from the model context. 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)
|
||||
if deleted_message_ids:
|
||||
existing_map = await ChatMessages.get_messages_map_by_chat_id(chat_id)
|
||||
if existing_map is not None:
|
||||
dead_ids = self._expand_deleted_ids(
|
||||
existing_map, deleted_message_ids, protected_ids=messages
|
||||
)
|
||||
if dead_ids:
|
||||
await ChatMessages.delete_message_ids_by_chat_id(chat_id, dead_ids)
|
||||
except Exception as e:
|
||||
log.warning('Failed to reconcile chat_message rows for chat %s: %s', chat_id, e)
|
||||
|
||||
|
|
|
|||
|
|
@ -973,13 +973,22 @@ async def update_chat_by_id(
|
|||
if chat:
|
||||
updated_chat = {**chat.chat, **form_data.chat}
|
||||
|
||||
# Merge (don't replace) history so a stale save can't clobber unseen
|
||||
# messages; see Chats.merge_history for the full rationale.
|
||||
if 'history' in form_data.chat:
|
||||
updated_chat['history'] = Chats.merge_history(
|
||||
chat.chat.get('history'),
|
||||
form_data.chat.get('history'),
|
||||
deleted_message_ids=form_data.deleted_message_ids,
|
||||
)
|
||||
|
||||
# Re-derive content from output for assistant messages so that frontend
|
||||
# edits to output items are reflected in content. Only when output
|
||||
# actually changed — otherwise content set independently of output
|
||||
# (e.g. a `replace` event or an outlet filter footer) would be reverted.
|
||||
existing_messages = (chat.chat.get('history') or {}).get('messages') or {}
|
||||
for msg_id, msg in updated_chat.get('history', {}).get('messages', {}).items():
|
||||
if msg.get('role') == 'assistant' and msg.get('output'):
|
||||
if isinstance(msg, dict) and msg.get('role') == 'assistant' and msg.get('output'):
|
||||
if msg.get('output') != existing_messages.get(msg_id, {}).get('output'):
|
||||
msg['content'] = serialize_output(msg['output'])
|
||||
|
||||
|
|
@ -990,7 +999,9 @@ async def update_chat_by_id(
|
|||
# history with potential edits, deletions, or new branches.
|
||||
messages = (updated_chat.get('history') or {}).get('messages') or {}
|
||||
if messages:
|
||||
await Chats.reconcile_messages_by_chat_id(id, user.id, messages)
|
||||
await Chats.reconcile_messages_by_chat_id(
|
||||
id, user.id, messages, deleted_message_ids=form_data.deleted_message_ids
|
||||
)
|
||||
|
||||
return ChatResponse(**chat.model_dump())
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1039,7 +1039,12 @@ export const getChatAccessGrants = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const updateChatById = async (token: string, id: string, chat: object) => {
|
||||
export const updateChatById = async (
|
||||
token: string,
|
||||
id: string,
|
||||
chat: object,
|
||||
deletedMessageIds: string[] = []
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}`, {
|
||||
|
|
@ -1050,7 +1055,9 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
|
|||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chat: chat
|
||||
chat: chat,
|
||||
// Only sent when the client explicitly deleted messages; absent => upsert-only (no pruning).
|
||||
...(deletedMessageIds.length > 0 && { deleted_message_ids: deletedMessageIds })
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@
|
|||
}
|
||||
};
|
||||
|
||||
const showMessage = async (message, scroll = true) => {
|
||||
const showMessage = async (message, scroll = true, save = true) => {
|
||||
const _chatId = JSON.parse(JSON.stringify($chatId));
|
||||
let _messageId = JSON.parse(JSON.stringify(message.id));
|
||||
|
||||
|
|
@ -464,7 +464,10 @@
|
|||
await tick();
|
||||
await tick();
|
||||
|
||||
saveChatHandler(_chatId, history);
|
||||
// Callers that persist separately (e.g. deleteMessage's save that carries the deleted IDs) pass save=false.
|
||||
if (save) {
|
||||
saveChatHandler(_chatId, history);
|
||||
}
|
||||
};
|
||||
|
||||
const updateLastReadAt = (id) => {
|
||||
|
|
|
|||
|
|
@ -164,14 +164,26 @@
|
|||
}
|
||||
};
|
||||
|
||||
// IDs explicitly deleted by the user, sent with the next save so the backend prunes
|
||||
// the matching chat_message rows. Cleared only on a successful save (survives retry).
|
||||
let deletedMessageIds = new Set();
|
||||
|
||||
const updateChat = async () => {
|
||||
if (!$temporaryChatEnabled) {
|
||||
history = history;
|
||||
await tick();
|
||||
const res = await updateChatById(localStorage.token, chatId, {
|
||||
history: history,
|
||||
messages: messages
|
||||
});
|
||||
const res = await updateChatById(
|
||||
localStorage.token,
|
||||
chatId,
|
||||
{
|
||||
history: history,
|
||||
messages: messages
|
||||
},
|
||||
Array.from(deletedMessageIds)
|
||||
);
|
||||
|
||||
// Reached only when the save succeeded (updateChatById throws on failure).
|
||||
deletedMessageIds.clear();
|
||||
|
||||
// Refresh local message content from backend (e.g. re-derived via serialize_output)
|
||||
if (res?.chat?.history?.messages) {
|
||||
|
|
@ -458,12 +470,18 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Delete the message and its children
|
||||
// Delete the message and its children, buffering the IDs so the next save
|
||||
// tells the backend to prune the matching chat_message rows. Grandchildren
|
||||
// were reparented above and are intentionally not added to the deleted-IDs buffer.
|
||||
[messageId, ...childMessageIds].forEach((id) => {
|
||||
delete history.messages[id];
|
||||
deletedMessageIds.add(id);
|
||||
});
|
||||
|
||||
showMessage({ id: parentMessageId }, false);
|
||||
// Move currentId to the parent without saving, then persist once via
|
||||
// updateChat() so the deletion travels with its deleted_message_ids.
|
||||
showMessage({ id: parentMessageId }, false, false);
|
||||
await updateChat();
|
||||
};
|
||||
|
||||
const triggerScroll = () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue