diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 751c49bed9..44dabafeb2 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -58,6 +58,7 @@ copyToClipboard, getMessageContentParts, createMessagesList, + sanitizeHistory, getPromptVariables, processDetails, removeAllDetails, @@ -1379,29 +1380,9 @@ ? chatContent.history : convertMessagesToHistory(chatContent.messages); - // Sanitize history: repair orphaned references from failed regenerations (#24424) - for (const message of Object.values(history.messages)) { - if (message.childrenIds) { - message.childrenIds = message.childrenIds.filter( - (childId) => history.messages[childId] - ); - } - } - if (history.currentId && !history.messages[history.currentId]) { - const messageIds = Object.keys(history.messages); - let lastMessageId = null; - for (const messageId of messageIds) { - const message = history.messages[messageId]; - if ( - (message.childrenIds ?? []).length === 0 && - (!lastMessageId || - (message.timestamp ?? 0) > (history.messages[lastMessageId].timestamp ?? 0)) - ) { - lastMessageId = messageId; - } - } - history.currentId = lastMessageId ?? messageIds[0] ?? null; - } + // Sanitize history: repair orphaned references and structurally-malformed + // nodes from failed regenerations (#24424, #24157, #20474) + sanitizeHistory(history); chatTitle.set(chatContent.title); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 91cfa2b688..b4009f198a 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -225,6 +225,81 @@ export const convertMessagesToHistory = (messages) => { return history; }; +// Repair structurally-malformed history nodes from failed regenerations. +// A lost assistant placeholder may exist under its map key with only +// completion fields (content/done/error), missing id/role/parentId. +// Reconstruct graph fields so already-corrupted chats recover on open. +export const sanitizeHistory = (history) => { + if (!history?.messages || typeof history.messages !== 'object') return; + + // Purge entries that aren't usable objects + for (const [id, message] of Object.entries(history.messages)) { + if (!message || typeof message !== 'object') { + delete history.messages[id]; + } + } + + // Ensure every surviving node has its canonical id and a childrenIds array + for (const [id, message] of Object.entries(history.messages)) { + if (message.id !== id) message.id = id; + if (!Array.isArray(message.childrenIds)) message.childrenIds = []; + } + + // Build reverse lookup: parent, indexed by child id + const parentByChildId = {}; + for (const [id, message] of Object.entries(history.messages)) { + for (const childId of message.childrenIds) { + parentByChildId[childId] = id; + } + } + + // Reconstruct missing parentId and role + for (const [id, message] of Object.entries(history.messages)) { + // Well-formed: has role and explicit parentId (null is valid for root) + if (message.role && message.parentId !== undefined) continue; + + if (message.parentId === undefined) { + message.parentId = parentByChildId[id] ?? null; + } + + if (!message.role) { + const parent = message.parentId ? history.messages[message.parentId] : null; + message.role = + parent?.role === 'user' + ? 'assistant' + : parent?.role === 'assistant' + ? 'user' + : message.model || message.usage || message.done !== undefined + ? 'assistant' + : 'user'; + } + } + + // Prune childrenIds referencing deleted/missing nodes + for (const message of Object.values(history.messages)) { + message.childrenIds = message.childrenIds.filter( + (childId) => history.messages[childId] + ); + } + + // Recover currentId if it points to a missing or incomplete node + const currentMessage = history.messages?.[history.currentId]; + if (!currentMessage?.id || !currentMessage?.role) { + let latestLeafId = null; + let latestTimestamp = -1; + for (const [id, message] of Object.entries(history.messages)) { + if ( + message.childrenIds.length === 0 && + (message.timestamp ?? 0) > latestTimestamp + ) { + latestLeafId = id; + latestTimestamp = message.timestamp ?? 0; + } + } + history.currentId = latestLeafId ?? Object.keys(history.messages)[0] ?? null; + } +}; + export const getGravatarURL = (email) => { // Trim leading and trailing whitespace from // an email address and force all characters