From: Aleksander Grygier Date: Thu, 30 Jul 2026 08:10:37 +0000 (+0200) Subject: ui: IndexedDB and Conversations data fixes (#26278) X-Git-Tag: upstream/0.0.10438~251 X-Git-Url: https://git.djapps.eu/?a=commitdiff_plain;h=21a5f5b7f919834e37fd492158aa24dc2f5e3b26;p=pkg%2Fggml%2Fsources%2Fllama.cpp ui: IndexedDB and Conversations data fixes (#26278) * fix: single-flight conversations store init * refactor: remove unused legacy-migration util * fix: make createSystemMessage transactional * fix: delete message branches cascading on edit/regenerate * fix: stop stamping lastModified on conversation metadata updates * fix: count cascaded forks in bulk delete toast, bulkify deleteAll * refactor: drop redundant conversation list respreads * refactor: create conversation in a single write * fix: use table constant in toggleConversationPin * fix: keep the system message placeholder out of the edit form * fix: keep focus in the system message editor after opening it * fix: focus the main chat form after submitting a system message * fix: update timestamp of the correct conversation on stream completion --- diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte index 905c2fe6f..f81dcf09c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte @@ -48,6 +48,9 @@ }: Props = $props(); let dropdownOpen = $state(false); + // The system message action moves focus to the message editor, so the menu + // must not restore focus to the trigger on close + let suppressCloseAutoFocus = false; function handleMcpSettingsClick() { dropdownOpen = false; @@ -96,7 +99,16 @@ - + { + if (suppressCloseAutoFocus) { + suppressCloseAutoFocus = false; + e.preventDefault(); + } + }} + > @@ -148,7 +160,10 @@ { + suppressCloseAutoFocus = true; + onSystemPromptClick?.(); + }} > diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index 8e8a14ac3..b8068f790 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -2,6 +2,7 @@ import { goto } from '$app/navigation'; import { getChatActionsContext, setMessageEditContext } from '$lib/contexts'; import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte'; + import { isMobile } from '$lib/stores/viewport.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte'; import { DatabaseService } from '$lib/services/database.service'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants'; @@ -46,7 +47,14 @@ assistantMessages: number; messageTypes: string[]; } | null>(null); - let editedContent = $derived(message.content); + // The system message placeholder must never surface as editable content; keeping + // it in the derived (not just in handleEdit) guards against prop invalidation + // reverting the override while editing + let editedContent = $derived( + message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER + ? '' + : message.content + ); let rawEditContent = $derived.by(() => { if (message.role !== MessageRole.ASSISTANT) return undefined; @@ -265,6 +273,12 @@ chatActions.navigateToSibling(siblingId); } + // After the system message flow ends, hand focus to the main chat form + function focusMainChatForm() { + if (isMobile.current) return; + document.querySelector('.chat-screen-form-wrapper textarea')?.focus(); + } + async function handleSaveEdit() { if (message.role === MessageRole.SYSTEM) { // System messages: update in place without branching @@ -276,6 +290,8 @@ isEditing = false; if (conversationDeleted) { goto(ROUTES.START); + } else { + focusMainChatForm(); } return; } @@ -285,6 +301,7 @@ if (index !== -1) { conversationsStore.updateMessageAtIndex(index, { content: newContent }); } + focusMainChatForm(); } else if (message.role === MessageRole.USER) { const finalExtras = await getMergedExtras(); chatActions.editWithBranching(message, editedContent.trim(), finalExtras); diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte index 600180742..8eb17eeae 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte @@ -106,15 +106,23 @@ onFileRemove?.(fileId); } + // Auto-focus must not steal focus already claimed elsewhere (e.g. the system + // message editor opened just before a navigation) + function focusFormUnlessCaptured() { + const active = document.activeElement; + if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return; + chatFormRef?.focus(); + } + onMount(() => { if (!isMobile.current) { - setTimeout(() => chatFormRef?.focus(), 100); + setTimeout(focusFormUnlessCaptured, 100); } }); afterNavigate((navigation) => { if (navigation?.from != null && !isMobile.current) { - setTimeout(() => chatFormRef?.focus(), 100); + setTimeout(focusFormUnlessCaptured, 100); } }); @@ -127,7 +135,7 @@ $effect(() => { if (previousIsLoading && !isLoading) { - setTimeout(() => chatFormRef?.focus(), 10); + setTimeout(focusFormUnlessCaptured, 10); } previousIsLoading = isLoading; diff --git a/tools/ui/src/lib/services/database.service.ts b/tools/ui/src/lib/services/database.service.ts index bc65caaca..0a7c59b9c 100644 --- a/tools/ui/src/lib/services/database.service.ts +++ b/tools/ui/src/lib/services/database.service.ts @@ -31,14 +31,19 @@ export class DatabaseService { * Creates a new conversation. * * @param name - Name of the conversation + * @param fields - Optional extra fields (e.g. reasoningEffort) * @returns The created conversation */ - static async createConversation(name: string): Promise { + static async createConversation( + name: string, + fields?: Partial> + ): Promise { const conversation: DatabaseConversation = { id: uuid(), name, lastModified: Date.now(), - currNode: '' + currNode: '', + ...fields }; await db[IDXDB_TABLES.conversations].add(conversation); @@ -137,7 +142,7 @@ export class DatabaseService { * @param systemPrompt - The system prompt content (must be non-empty) * @param parentId - Parent message ID (typically the root message) * @returns The created system message - * @throws Error if systemPrompt is empty + * @throws Error if systemPrompt is empty or the parent message does not exist */ static async createSystemMessage( convId: string, @@ -149,27 +154,30 @@ export class DatabaseService { throw new Error('Cannot create system message with empty content'); } - const systemMessage: DatabaseMessage = { - id: uuid(), - convId, - type: MessageRole.SYSTEM, - timestamp: Date.now(), - role: MessageRole.SYSTEM, - content: trimmedPrompt, - parent: parentId, - children: [] - }; - - await db[IDXDB_TABLES.messages].add(systemMessage); + return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => { + const parentMessage = await db[IDXDB_TABLES.messages].get(parentId); + if (!parentMessage) { + throw new Error(`Parent message ${parentId} not found`); + } - const parentMessage = await db[IDXDB_TABLES.messages].get(parentId); - if (parentMessage) { + const systemMessage: DatabaseMessage = { + id: uuid(), + convId, + type: MessageRole.SYSTEM, + timestamp: Date.now(), + role: MessageRole.SYSTEM, + content: trimmedPrompt, + parent: parentId, + children: [] + }; + + await db[IDXDB_TABLES.messages].add(systemMessage); await db[IDXDB_TABLES.messages].update(parentId, { children: [...parentMessage.children, systemMessage.id] }); - } - return systemMessage; + return systemMessage; + }); } /** @@ -442,7 +450,8 @@ export class DatabaseService { } /** - * Updates a conversation. + * Updates a conversation. `lastModified` is never stamped implicitly; + * pass it in `updates` to bump the conversation in recency ordering. * * @param id - Conversation ID * @param updates - Partial updates to apply @@ -452,10 +461,7 @@ export class DatabaseService { id: string, updates: Partial> ): Promise { - await db[IDXDB_TABLES.conversations].update(id, { - ...updates, - lastModified: Date.now() - }); + await db[IDXDB_TABLES.conversations].update(id, updates); } /** @@ -473,7 +479,7 @@ export class DatabaseService { * @returns The new pinned status */ static async toggleConversationPin(id: string): Promise { - const conversation = await db.conversations.get(id); + const conversation = await db[IDXDB_TABLES.conversations].get(id); if (!conversation) { throw new Error(`Conversation ${id} not found`); } @@ -497,7 +503,6 @@ export class DatabaseService { const result = new Map(); if (cleanIds.length === 0) return result; - const now = Date.now(); await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => { const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds); const updates: DatabaseConversation[] = []; @@ -505,7 +510,7 @@ export class DatabaseService { const conv = convs[i]; if (!conv) continue; const newPinned = !conv.pinned; - updates.push({ ...conv, pinned: newPinned, lastModified: now }); + updates.push({ ...conv, pinned: newPinned }); result.set(cleanIds[i], newPinned); } if (updates.length === 0) return; diff --git a/tools/ui/src/lib/stores/chat.svelte.ts b/tools/ui/src/lib/stores/chat.svelte.ts index 222723ab1..935ed8e16 100644 --- a/tools/ui/src/lib/stores/chat.svelte.ts +++ b/tools/ui/src/lib/stores/chat.svelte.ts @@ -1658,7 +1658,8 @@ class ChatStore { generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine)) ); const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1); - for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + if (messagesToRemove.length > 0) + await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id); conversationsStore.sliceActiveMessages(messageIndex + 1); conversationsStore.updateConversationTimestamp(); this.setChatLoading(activeConv.id, true); @@ -1690,7 +1691,7 @@ class ChatStore { const { index: messageIndex } = result; try { const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex); - for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id); + await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id); conversationsStore.sliceActiveMessages(messageIndex); conversationsStore.updateConversationTimestamp(); this.setChatLoading(activeConv.id, true); @@ -2037,7 +2038,7 @@ class ChatStore { timings }); - conversationsStore.updateConversationTimestamp(); + conversationsStore.updateConversationTimestamp(msg.convId); this.setChatLoading(msg.convId, false); this.clearChatStreaming(msg.convId); diff --git a/tools/ui/src/lib/stores/conversations.svelte.ts b/tools/ui/src/lib/stores/conversations.svelte.ts index e467c8fad..1a1157c72 100644 --- a/tools/ui/src/lib/stores/conversations.svelte.ts +++ b/tools/ui/src/lib/stores/conversations.svelte.ts @@ -111,6 +111,9 @@ class ConversationsStore { | ((messageId: string, updates: Partial) => void) | null = null; + /** In-flight init run; shared by concurrent callers, reset on failure to allow retry */ + private initPromise: Promise | null = null; + /** * * @@ -121,19 +124,25 @@ class ConversationsStore { /** * Initialize the store by loading conversations from database. - * Must be called once after app startup. + * Safe to call multiple times: concurrent callers share a single run, + * and a failed run can be retried by calling again. */ - async init(): Promise { - if (!browser) return; - if (this.isInitialized) return; + init(): Promise { + if (!browser) return Promise.resolve(); + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + try { + await MigrationService.runAllMigrations(); + await this.loadConversations(); + this.isInitialized = true; + } catch (error) { + console.error('Failed to initialize conversations:', error); + this.initPromise = null; + } + })(); - try { - await MigrationService.runAllMigrations(); - await this.loadConversations(); - this.isInitialized = true; - } catch (error) { - console.error('Failed to initialize conversations:', error); - } + return this.initPromise; } /** @@ -237,15 +246,11 @@ class ConversationsStore { */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; - const conversation = await DatabaseService.createConversation(conversationName); // No MCP override list is seeded: getAllMcpServerOverrides resolves // servers without a per-conversation override to `mcpServers[i].enabled`, // and only explicit toggles are stored on the conversation. - - // Inherit the global reasoning default into the new conversation - conversation.reasoningEffort = this.pendingReasoningEffort; - await DatabaseService.updateConversation(conversation.id, { + const conversation = await DatabaseService.createConversation(conversationName, { reasoningEffort: this.pendingReasoningEffort }); @@ -358,10 +363,7 @@ class ConversationsStore { async deleteAll(): Promise { try { const allConversations = await DatabaseService.getAllConversations(); - - for (const conv of allConversations) { - await DatabaseService.deleteConversation(conv.id); - } + await DatabaseService.bulkDeleteConversations(allConversations.map((c) => c.id)); this.clearActiveConversation(); this.conversations = []; @@ -412,7 +414,9 @@ class ConversationsStore { } toast.success( - convIds.length === 1 ? 'Conversation deleted' : `${convIds.length} conversations deleted` + idsToRemove.size === 1 + ? 'Conversation deleted' + : `${idsToRemove.size} conversations deleted` ); } catch (error) { console.error('Failed to bulk delete conversations:', error); @@ -443,7 +447,6 @@ class ConversationsStore { const newPinned = updates.get(this.conversations[i].id); if (newPinned !== undefined) this.conversations[i].pinned = newPinned; } - this.conversations = [...this.conversations]; toast.success( convIds.length === 1 @@ -552,7 +555,6 @@ class ConversationsStore { if (convIndex !== -1) { this.conversations[convIndex].name = name; - this.conversations = [...this.conversations]; } if (this.activeConversation?.id === convId) { @@ -576,7 +578,6 @@ class ConversationsStore { if (convIndex !== -1) { this.conversations[convIndex].pinned = newPinnedState; - this.conversations = [...this.conversations]; } if (this.activeConversation?.id === convId) { @@ -591,18 +592,33 @@ class ConversationsStore { } /** - * Updates conversation lastModified timestamp and moves it to top of list + * Marks a conversation as recently active: stamps lastModified (persisted) + * and moves it to the top of the list. Only message-activity flows call + * this; metadata updates (rename, pin, settings) do not. + * + * @param convId - Conversation that produced the activity, defaults to the active one */ - updateConversationTimestamp(): void { - if (!this.activeConversation) return; + updateConversationTimestamp(convId?: string): void { + const targetId = convId ?? this.activeConversation?.id; + if (!targetId) return; - const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); + const now = Date.now(); + + const chatIndex = this.conversations.findIndex((c) => c.id === targetId); if (chatIndex !== -1) { - this.conversations[chatIndex].lastModified = Date.now(); + this.conversations[chatIndex].lastModified = now; const updatedConv = this.conversations.splice(chatIndex, 1)[0]; this.conversations = [updatedConv, ...this.conversations]; } + + if (this.activeConversation?.id === targetId) { + this.activeConversation = { ...this.activeConversation, lastModified: now }; + } + + DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) => + console.error('Failed to update conversation timestamp:', error) + ); } /** @@ -773,7 +789,6 @@ class ConversationsStore { if (convIndex !== -1) { this.conversations[convIndex].mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined; - this.conversations = [...this.conversations]; } } @@ -837,7 +852,6 @@ class ConversationsStore { const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); if (convIndex !== -1) { this.conversations[convIndex].reasoningEffort = effort; - this.conversations = [...this.conversations]; } } diff --git a/tools/ui/src/lib/utils/legacy-migration.ts b/tools/ui/src/lib/utils/legacy-migration.ts deleted file mode 100644 index 6b0890a36..000000000 --- a/tools/ui/src/lib/utils/legacy-migration.ts +++ /dev/null @@ -1,364 +0,0 @@ -/** - * @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format. - * - * Converts old marker-based agentic messages to the new structured format - * with separate messages per turn. - * - * Old format: Single assistant message with markers in content: - * <<>>...<<>> - * <<>>...<<>> - * - * New format: Separate messages per turn: - * - assistant (content + reasoningContent + toolCalls) - * - tool (toolCallId + content) - * - assistant (next turn) - * - ... - */ - -import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants'; -import { DatabaseService } from '$lib/services/database.service'; -import { MessageRole, MessageType } from '$lib/enums'; -import type { DatabaseMessage } from '$lib/types/database'; - -const MIGRATION_DONE_KEY = 'llama-ui-migration-v2-done'; -/** @deprecated Use {@link MIGRATION_DONE_KEY} instead */ -const DEPRECATED_MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done'; - -/** - * @deprecated Part of legacy migration — remove with the migration module. - * Check if migration has been performed. - */ -export function isMigrationNeeded(): boolean { - try { - // Check new key first, fall back to deprecated old key - if (localStorage.getItem(MIGRATION_DONE_KEY)) return false; - if (localStorage.getItem(DEPRECATED_MIGRATION_DONE_KEY)) { - // Migrate to new key - try { - localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now())); - localStorage.removeItem(DEPRECATED_MIGRATION_DONE_KEY); - } catch { - // Ignore storage errors - } - return false; - } - return true; - } catch { - return false; - } -} - -/** - * Mark migration as done. - */ -function markMigrationDone(): void { - try { - localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now())); - } catch { - // Ignore localStorage errors - } -} - -/** - * Check if a message has legacy markers in its content. - */ -function hasLegacyMarkers(message: DatabaseMessage): boolean { - if (!message.content) return false; - return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content); -} - -/** - * Extract reasoning content from legacy marker format. - */ -function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } { - let reasoning = ''; - let cleanContent = content; - - // Extract all reasoning blocks - const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g'); - let match; - while ((match = re.exec(content)) !== null) { - reasoning += match[1]; - } - - // Remove reasoning tags from content - cleanContent = cleanContent - .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '') - .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, ''); - - return { reasoning, cleanContent }; -} - -/** - * Parse legacy content with tool call markers into structured turns. - */ -interface ParsedTurn { - textBefore: string; - toolCalls: Array<{ - name: string; - args: string; - result: string; - }>; -} - -function parseLegacyToolCalls(content: string): ParsedTurn[] { - const turns: ParsedTurn[] = []; - const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); - - let lastIndex = 0; - let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] }; - let match; - - while ((match = regex.exec(content)) !== null) { - const textBefore = content.slice(lastIndex, match.index).trim(); - - // If there's text between tool calls and we already have tool calls, - // that means a new turn started (text after tool results = new LLM turn) - if (textBefore && currentTurn.toolCalls.length > 0) { - turns.push(currentTurn); - currentTurn = { textBefore, toolCalls: [] }; - } else if (textBefore && currentTurn.toolCalls.length === 0) { - currentTurn.textBefore = textBefore; - } - - currentTurn.toolCalls.push({ - name: match[1], - args: match[2], - result: match[3].replace(/^\n+|\n+$/g, '') - }); - - lastIndex = match.index + match[0].length; - } - - // Any remaining text after the last tool call - const remainingText = content.slice(lastIndex).trim(); - - if (currentTurn.toolCalls.length > 0) { - turns.push(currentTurn); - } - - // If there's text after all tool calls, it's the final assistant response - if (remainingText) { - // Remove any partial/open markers - const cleanRemaining = remainingText - .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '') - .trim(); - if (cleanRemaining) { - turns.push({ textBefore: cleanRemaining, toolCalls: [] }); - } - } - - // If no tool calls found at all, return the original content as a single turn - if (turns.length === 0) { - turns.push({ textBefore: content.trim(), toolCalls: [] }); - } - - return turns; -} - -/** - * Migrate a single conversation's messages from legacy format to new format. - */ -async function migrateConversation(convId: string): Promise { - const allMessages = await DatabaseService.getConversationMessages(convId); - let migratedCount = 0; - - for (const message of allMessages) { - if (message.role !== MessageRole.ASSISTANT) continue; - if (!hasLegacyMarkers(message)) { - // Still check for reasoning-only markers (no tool calls) - if (message.content?.includes(LEGACY_REASONING_TAGS.START)) { - const { reasoning, cleanContent } = extractLegacyReasoning(message.content); - await DatabaseService.updateMessage(message.id, { - content: cleanContent.trim(), - reasoningContent: reasoning || undefined - }); - migratedCount++; - } - continue; - } - - // Has agentic markers - full migration needed - const { reasoning, cleanContent } = extractLegacyReasoning(message.content); - const turns = parseLegacyToolCalls(cleanContent); - - // Parse existing toolCalls JSON to try to match IDs - let existingToolCalls: Array<{ - id: string; - function?: { name: string; arguments: string }; - }> = []; - if (message.toolCalls) { - try { - existingToolCalls = JSON.parse(message.toolCalls); - } catch { - // Ignore - } - } - - // First turn uses the existing message - const firstTurn = turns[0]; - if (!firstTurn) continue; - - // Match tool calls from the first turn to existing IDs - const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => { - const existing = - existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i]; - return { - id: existing?.id || `legacy_tool_${i}`, - type: 'function' as const, - function: { name: tc.name, arguments: tc.args } - }; - }); - - // Update the existing message for the first turn - await DatabaseService.updateMessage(message.id, { - content: firstTurn.textBefore, - reasoningContent: reasoning || undefined, - toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : '' - }); - - let currentParentId = message.id; - let toolCallIdCounter = existingToolCalls.length; - - // Create tool result messages for the first turn - for (let i = 0; i < firstTurn.toolCalls.length; i++) { - const tc = firstTurn.toolCalls[i]; - const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`; - - const toolMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.TOOL, - content: tc.result, - toolCallId, - timestamp: message.timestamp + i + 1, - toolCalls: '', - children: [] - }, - currentParentId - ); - currentParentId = toolMsg.id; - } - - // Create messages for subsequent turns - for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) { - const turn = turns[turnIdx]; - - const turnToolCalls = turn.toolCalls.map((tc, i) => { - const idx = toolCallIdCounter + i; - const existing = existingToolCalls[idx]; - return { - id: existing?.id || `legacy_tool_${idx}`, - type: 'function' as const, - function: { name: tc.name, arguments: tc.args } - }; - }); - toolCallIdCounter += turn.toolCalls.length; - - // Create assistant message for this turn - const assistantMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.ASSISTANT, - content: turn.textBefore, - timestamp: message.timestamp + turnIdx * 100, - toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '', - children: [], - model: message.model - }, - currentParentId - ); - currentParentId = assistantMsg.id; - - // Create tool result messages for this turn - for (let i = 0; i < turn.toolCalls.length; i++) { - const tc = turn.toolCalls[i]; - const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`; - - const toolMsg = await DatabaseService.createMessageBranch( - { - convId, - type: MessageType.TEXT, - role: MessageRole.TOOL, - content: tc.result, - toolCallId, - timestamp: message.timestamp + turnIdx * 100 + i + 1, - toolCalls: '', - children: [] - }, - currentParentId - ); - currentParentId = toolMsg.id; - } - } - - // Re-parent any children of the original message to the last created message - // (the original message's children list was the next user message or similar) - if (message.children.length > 0 && currentParentId !== message.id) { - for (const childId of message.children) { - // Skip children we just created (they were already properly parented) - const child = allMessages.find((m) => m.id === childId); - if (!child) continue; - // Only re-parent non-tool messages that were original children - if (child.role !== MessageRole.TOOL) { - await DatabaseService.updateMessage(childId, { parent: currentParentId }); - // Add to new parent's children - const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) => - msgs.find((m) => m.id === currentParentId) - ); - if (newParent && !newParent.children.includes(childId)) { - await DatabaseService.updateMessage(currentParentId, { - children: [...newParent.children, childId] - }); - } - } - } - // Clear re-parented children from the original message - await DatabaseService.updateMessage(message.id, { children: [] }); - } - - migratedCount++; - } - - return migratedCount; -} - -/** - * @deprecated Part of legacy migration — remove with the migration module. - * Run the full migration across all conversations. - * This should be called once at app startup if migration is needed. - */ -export async function runLegacyMigration(): Promise { - if (!isMigrationNeeded()) return; - - if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) - console.log('[Migration] Starting legacy message format migration...'); - - try { - const conversations = await DatabaseService.getAllConversations(); - let totalMigrated = 0; - - for (const conv of conversations) { - const count = await migrateConversation(conv.id); - totalMigrated += count; - } - - if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { - if (totalMigrated > 0) { - console.log( - `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations` - ); - } else { - console.log('[Migration] No legacy messages found, marking as done'); - } - } - - markMigrationDone(); - } catch (error) { - console.error('[Migration] Failed to migrate legacy messages:', error); - // Still mark as done to avoid infinite retry loops - markMigrationDone(); - } -}