/** * conversationsStore - Reactive State Store for Conversations * * Manages conversation lifecycle, persistence, navigation, and MCP server overrides. * * **Architecture & Relationships:** * - **DatabaseService**: Stateless IndexedDB layer * - **conversationsStore** (this): Reactive state + business logic * - **chatStore**: Chat-specific state (streaming, loading) * * **Key Responsibilities:** * - Conversation CRUD (create, load, delete) * - Message management and tree navigation * - MCP server per-chat overrides * - Import/Export functionality * - Title management with confirmation * * @see DatabaseService in services/database.ts for IndexedDB operations */ import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { EXPORT_CONV, NEWLINE, REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, ROUTES, ZIP_MAGIC } from '$lib/constants'; import { FileExtensionText, MessageRole, MimeTypeApplication, MimeTypeText, ReasoningEffort, SessionRecordType } from '$lib/enums'; import { DatabaseService } from '$lib/services/database.service'; import { MigrationService } from '$lib/services/migration.service'; import { RouterService } from '$lib/services/router.service'; // direct imports between stores, not via the barrel, to avoid circular deps import { mcpStore } from '$lib/stores/mcp.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import type { McpServerOverride } from '$lib/types/database'; import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; class ConversationsStore { /** * * * State * * */ /** List of all conversations */ conversations = $state([]); /** Currently active conversation */ activeConversation = $state(null); /** Messages in the active conversation (filtered by currNode path) */ activeMessages = $state([]); /** Whether the store has been initialized */ isInitialized = $state(false); /** Global (non-conversation-specific) reasoning effort default */ pendingReasoningEffort = $state(ConversationsStore.loadReasoningEffortDefault()); /** * Working directory picked on the empty new-chat screen, before any * conversation exists. Consumed by `chatStore.sendMessage()`, which * records it into chat history as a synthetic message on first send. * Cleared by `loadConversation` and `clearActiveConversation` so a * stale pick can't bleed onto an unrelated chat. */ pendingCwd = $state(null); /** Load reasoning effort default from localStorage, DEFAULT defers to the server */ private static loadReasoningEffortDefault(): ReasoningEffort { if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT; try { const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY); return (raw as ReasoningEffort) || ReasoningEffort.DEFAULT; } catch { return ReasoningEffort.DEFAULT; } } /** Persist reasoning effort default to localStorage */ private saveReasoningEffortDefaults(): void { if (typeof globalThis.localStorage === 'undefined') return; localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort); } /** * Callback for updating message content in chatStore. * Registered by chatStore to enable cross-store updates without circular dependency. */ private messageUpdateCallback: | ((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; /** * * * Lifecycle * * */ /** * Initialize the store by loading conversations from database. * Safe to call multiple times: concurrent callers share a single run, * and a failed run can be retried by calling again. */ 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; } })(); return this.initPromise; } /** * Alias for init() for backward compatibility. */ async initialize(): Promise { return this.init(); } /** * Register a callback for message updates from other stores. * Called by chatStore during initialization. */ registerMessageUpdateCallback( callback: (messageId: string, updates: Partial) => void ): void { this.messageUpdateCallback = callback; } /** * * * Message Array Operations * * */ /** * Adds a message to the active messages array */ addMessageToActive(message: DatabaseMessage): void { this.activeMessages.push(message); } /** * Updates a message at a specific index in active messages */ updateMessageAtIndex(index: number, updates: Partial): void { const message = index === -1 ? undefined : this.activeMessages[index]; if (!message) return; // Assign field by field rather than replacing the object. Replacing it // changes the array slot, which invalidates every consumer that merely // walks the list - notably ChatMessages.displayMessages, which rebuilds // entries for every message in the conversation. Deep $state proxies make // per-field writes fine-grained, so only readers of the changed field wake. const target = message as unknown as Record; for (const [key, value] of Object.entries(updates)) { if (target[key] !== value) { target[key] = value; } } } /** * Finds the index of a message in active messages */ findMessageIndex(messageId: string): number { return this.activeMessages.findIndex((m) => m.id === messageId); } /** * Removes messages from active messages starting at an index */ sliceActiveMessages(startIndex: number): void { this.activeMessages = this.activeMessages.slice(0, startIndex); } /** * Removes a message from active messages by index */ removeMessageAtIndex(index: number): DatabaseMessage | undefined { if (index !== -1) { return this.activeMessages.splice(index, 1)[0]; } return undefined; } /** * * * Conversation CRUD * * */ /** * Loads all conversations from the database */ async loadConversations(): Promise { const conversations = await DatabaseService.getAllConversations(); this.conversations = conversations; } /** * Creates a new conversation and navigates to it * @param name - Optional name for the conversation * @returns The ID of the created conversation */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; // 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. // Working directory picked on the new-chat screen gets threaded in // here too, then cleared so it doesn't bleed onto subsequent new chats. const conversation = await DatabaseService.createConversation(conversationName, { cwd: this.pendingCwd ?? undefined, reasoningEffort: this.pendingReasoningEffort }); this.pendingCwd = null; this.conversations = [conversation, ...this.conversations]; this.activeConversation = conversation; this.activeMessages = []; await goto(RouterService.chat(conversation.id)); return conversation.id; } /** * Loads a specific conversation and its messages * @param convId - The conversation ID to load * @returns True if conversation was loaded successfully */ async loadConversation(convId: string): Promise { try { const conversation = await DatabaseService.getConversation(convId); if (!conversation) { return false; } // Drop any cwd the user drafted on the empty new-chat screen - // it doesn't belong to this conversation. this.pendingCwd = null; this.activeConversation = conversation; if (conversation.currNode) { const allMessages = await DatabaseService.getConversationMessages(convId); const filteredMessages = filterByLeafNodeId( allMessages, conversation.currNode, false ) as DatabaseMessage[]; this.activeMessages = filteredMessages; } else { const messages = await DatabaseService.getConversationMessages(convId); this.activeMessages = messages; } return true; } catch (error) { console.error('Failed to load conversation:', error); return false; } } /** * Clears the active conversation and messages. */ clearActiveConversation(): void { this.activeConversation = null; this.activeMessages = []; // reload defaults so new chats inherit persisted state this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault(); this.pendingCwd = null; } /** * Deletes a conversation and all its messages * @param convId - The conversation ID to delete */ async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise { try { await DatabaseService.deleteConversation(convId, options); if (options?.deleteWithForks) { // Collect all descendants recursively const idsToRemove = new SvelteSet([convId]); const queue = [convId]; while (queue.length > 0) { const parentId = queue.pop()!; for (const c of this.conversations) { if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { idsToRemove.add(c.id); queue.push(c.id); } } } this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) { this.clearActiveConversation(); await goto(ROUTES.NEW_CHAT); } } else { // Reparent direct children to deleted conv's parent (or promote to top-level) const deletedConv = this.conversations.find((c) => c.id === convId); const newParent = deletedConv?.forkedFromConversationId; this.conversations = this.conversations .filter((c) => c.id !== convId) .map((c) => c.forkedFromConversationId === convId ? { ...c, forkedFromConversationId: newParent } : c ); if (this.activeConversation?.id === convId) { this.clearActiveConversation(); await goto(ROUTES.NEW_CHAT); } } } catch (error) { console.error('Failed to delete conversation:', error); } } /** * Deletes all conversations and their messages */ async deleteAll(): Promise { try { const allConversations = await DatabaseService.getAllConversations(); await DatabaseService.bulkDeleteConversations(allConversations.map((c) => c.id)); this.clearActiveConversation(); this.conversations = []; toast.success('All conversations deleted'); await goto(ROUTES.NEW_CHAT); } catch (error) { console.error('Failed to delete all conversations:', error); toast.error('Failed to delete conversations'); } } /** * Deletes multiple conversations in sequence. * Mirrors deleteConversation() per-id; navigates to NEW_CHAT only if the * currently-open chat was among the deleted ones. * @param convIds - Conversation IDs to delete */ async bulkDeleteConversations(convIds: string[]): Promise { if (convIds.length === 0) return; try { const idsToRemove = new SvelteSet(convIds); // Collect all descendants recursively so the local cache stays consistent // even when deleteWithForks is omitted. const queue = [...convIds]; while (queue.length > 0) { const parentId = queue.pop()!; for (const c of this.conversations) { if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) { idsToRemove.add(c.id); queue.push(c.id); } } } const activeWasDeleted = this.activeConversation !== null && idsToRemove.has(this.activeConversation.id); await DatabaseService.bulkDeleteConversations([...idsToRemove]); this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); if (activeWasDeleted) { this.clearActiveConversation(); await goto(ROUTES.NEW_CHAT); } toast.success( idsToRemove.size === 1 ? 'Conversation deleted' : `${idsToRemove.size} conversations deleted` ); } catch (error) { console.error('Failed to bulk delete conversations:', error); toast.error('Failed to delete conversations'); } } /** * Toggles the pinned state of each conversation individually. * Mixed-pin selections are intentionally not normalised here; the bulk * action UI surfaces them as a disabled mixed-state instead. * @param convIds - Conversation IDs to toggle */ async bulkToggleConversationPin(convIds: string[]): Promise { if (convIds.length === 0) return; try { const updates = await DatabaseService.bulkToggleConversationPins(convIds); const activeId = this.activeConversation?.id; if (activeId && updates.has(activeId)) { this.activeConversation = { ...this.activeConversation!, pinned: updates.get(activeId)! }; } for (let i = 0; i < this.conversations.length; i++) { const newPinned = updates.get(this.conversations[i].id); if (newPinned !== undefined) this.conversations[i].pinned = newPinned; } toast.success( convIds.length === 1 ? 'Conversation pin toggled' : `Updated pin state for ${convIds.length} conversations` ); } catch (error) { console.error('Failed to bulk toggle pin:', error); toast.error('Failed to update pin state'); } } /** * Bundles the given conversations into a single zip archive and triggers a * browser download (one JSONL file per conversation). * @param convIds - Conversation IDs to export */ async bulkExportConversations(convIds: string[]): Promise { if (convIds.length === 0) return; try { const fetched = await DatabaseService.getConversationsWithMessages(convIds); const activeId = this.activeConversation?.id; const overridden = fetched.get(activeId ?? ''); if (overridden && activeId) { overridden.conv = { ...this.activeConversation! }; } const exported = [...fetched.values()]; if (exported.length === 0) { toast.error('No conversations to export'); return; } this.downloadConversationsArchive(exported); toast.success( exported.length === 1 ? 'Conversation exported' : `${exported.length} conversations exported` ); } catch (error) { console.error('Failed to bulk export conversations:', error); toast.error('Failed to export conversations'); } } /** * * * Message Management * * */ /** * Refreshes active messages based on currNode after branch navigation. */ async refreshActiveMessages(): Promise { if (!this.activeConversation) return; const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); if (allMessages.length === 0) { this.activeMessages = []; return; } const leafNodeId = this.activeConversation.currNode || allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; this.activeMessages = currentPath; } /** * Gets all messages for a specific conversation * @param convId - The conversation ID * @returns Array of messages */ async getConversationMessages(convId: string): Promise { return await DatabaseService.getConversationMessages(convId); } /** * * * Title Management * * */ /** * Updates the name of a conversation. * @param convId - The conversation ID to update * @param name - The new name for the conversation */ async updateConversationName(convId: string, name: string): Promise { try { await DatabaseService.updateConversation(convId, { name }); const convIndex = this.conversations.findIndex((c) => c.id === convId); if (convIndex !== -1) { this.conversations[convIndex].name = name; } if (this.activeConversation?.id === convId) { this.activeConversation = { ...this.activeConversation, name }; } } catch (error) { console.error('Failed to update conversation name:', error); } } /** * Toggles the pinned status of a conversation. * @param convId - The conversation ID to toggle * @returns The new pinned status */ async toggleConversationPin(convId: string): Promise { try { const newPinnedState = await DatabaseService.toggleConversationPin(convId); const convIndex = this.conversations.findIndex((c) => c.id === convId); if (convIndex !== -1) { this.conversations[convIndex].pinned = newPinnedState; } if (this.activeConversation?.id === convId) { this.activeConversation = { ...this.activeConversation, pinned: newPinnedState }; } return newPinnedState; } catch (error) { console.error('Failed to toggle conversation pin:', error); return false; } } /** * 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(convId?: string): void { const targetId = convId ?? this.activeConversation?.id; if (!targetId) return; const now = Date.now(); const chatIndex = this.conversations.findIndex((c) => c.id === targetId); if (chatIndex !== -1) { 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) ); } /** * Updates the current node of the active conversation * @param nodeId - The new current node ID */ async updateCurrentNode(nodeId: string): Promise { if (!this.activeConversation) return; await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId); this.activeConversation = { ...this.activeConversation, currNode: nodeId }; } /** * * * Branch Navigation * * */ /** * Navigates to a specific sibling branch by updating currNode and refreshing messages. * @param siblingId - The sibling message ID to navigate to */ async navigateToSibling(siblingId: string): Promise { if (!this.activeConversation) return; const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const currentFirstUserMessage = this.activeMessages.find( (m) => m.role === MessageRole.USER && m.parent === rootMessage?.id ); const currentLeafNodeId = findLeafNode(allMessages, siblingId); await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId }; await this.refreshActiveMessages(); if (rootMessage && this.activeMessages.length > 0) { const newFirstUserMessage = this.activeMessages.find( (m) => m.role === MessageRole.USER && m.parent === rootMessage.id ); if ( newFirstUserMessage && newFirstUserMessage.content.trim() && (!currentFirstUserMessage || newFirstUserMessage.id !== currentFirstUserMessage.id || newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) ) { await this.updateConversationName( this.activeConversation.id, generateConversationTitle( newFirstUserMessage.content, Boolean(settingsStore.config.titleGenerationUseFirstLine) ) ); } } } /** * * * MCP Server Overrides * * */ /** * Resolve the default enabled value for a server: its own `enabled` * flag in `mcpServers`, so the global on/off state lives in one place. */ #getDefaultOverride(serverId: string): McpServerOverride | undefined { const server = mcpStore.getServers().find((s) => s.id === serverId); if (!server) return undefined; return { enabled: server.enabled, serverId }; } /** * Gets the effective MCP server override for a specific server. * A per-conversation override wins when present; a server without one * resolves to its `mcpServers[i].enabled` default. * @param serverId - The server ID to check * @returns The effective override, undefined if no matching server */ getMcpServerOverride(serverId: string): McpServerOverride | undefined { const override = this.activeConversation?.mcpServerOverrides?.find( (o: McpServerOverride) => o.serverId === serverId ); if (override) return override; return this.#getDefaultOverride(serverId); } /** * Gets the effective override list for the current conversation: * one entry per configured server, resolved per server. The stored * per-conversation list is sparse and only holds explicit toggles. */ getAllMcpServerOverrides(): McpServerOverride[] { const overrides = this.activeConversation?.mcpServerOverrides; return mcpStore.getServers().map((s) => { const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id); return { enabled: override?.enabled ?? s.enabled, serverId: s.id }; }); } /** * Checks if an MCP server is enabled for the active conversation. * @param serverId - The server ID to check * @returns True if server is enabled for this conversation */ isMcpServerEnabledForChat(serverId: string): boolean { const override = this.getMcpServerOverride(serverId); return override?.enabled ?? false; } /** * Sets or removes MCP server override for the active conversation. * If no conversation exists, persists `enabled` onto `mcpServers[i].enabled` * (the single source of truth for new-chat defaults). * @param serverId - The server ID to override * @param enabled - The enabled state, or undefined to remove per-conversation override */ async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { if (!this.activeConversation) { if (enabled !== undefined) { mcpStore.updateServer(serverId, { enabled }); } return; } // Clone to plain objects to avoid Proxy serialization issues with IndexedDB const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map( (o: McpServerOverride) => ({ enabled: o.enabled, serverId: o.serverId }) ); let newOverrides: McpServerOverride[]; if (enabled === undefined) { newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); } else { const existingIndex = currentOverrides.findIndex( (o: McpServerOverride) => o.serverId === serverId ); if (existingIndex >= 0) { newOverrides = [...currentOverrides]; newOverrides[existingIndex] = { enabled, serverId }; } else { newOverrides = [...currentOverrides, { enabled, serverId }]; } } await DatabaseService.updateConversation(this.activeConversation.id, { mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined }); this.activeConversation = { ...this.activeConversation, mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined }; const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); if (convIndex !== -1) { this.conversations[convIndex].mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined; } } /** * Toggles MCP server enabled state for the active conversation. * @param serverId - The server ID to toggle */ async toggleMcpServerForChat(serverId: string): Promise { const currentEnabled = this.isMcpServerEnabledForChat(serverId); await this.setMcpServerOverride(serverId, !currentEnabled); } /** * Removes MCP server override for the active conversation. * @param serverId - The server ID to remove override for */ async removeMcpServerOverride(serverId: string): Promise { await this.setMcpServerOverride(serverId, undefined); } /** * Gets the effective reasoning effort for the active conversation. * Returns the conversation override if set, otherwise the global default. * DEFAULT means no override is sent and the server decides. */ getReasoningEffort(): ReasoningEffort { if (this.activeConversation) { if (this.activeConversation.reasoningEffort !== undefined) { return this.activeConversation.reasoningEffort; } // conversations created before the tri-state store an explicit // opt-out only as thinkingEnabled = false if (this.activeConversation.thinkingEnabled === false) { return ReasoningEffort.OFF; } } return this.pendingReasoningEffort; } /** * Sets the reasoning effort for the active conversation. * If no conversation exists, stores the global default. * @param effort - The effort level ('default' | 'off' | 'low' | 'medium' | 'high' | 'max') */ async setReasoningEffort(effort: ReasoningEffort): Promise { if (!this.activeConversation) { this.pendingReasoningEffort = effort; this.saveReasoningEffortDefaults(); return; } this.activeConversation = { ...this.activeConversation, reasoningEffort: effort }; await DatabaseService.updateConversation(this.activeConversation.id, { reasoningEffort: effort }); const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); if (convIndex !== -1) { this.conversations[convIndex].reasoningEffort = effort; } } /** * Sets the working directory for the active conversation. Pass `null` or * an empty string to clear it, which restores the picker's empty state. * * On the empty new-chat screen (no active conversation yet), the value * is buffered into `pendingCwd` so the user can pick before * sending the first message; `createConversation()` consumes it. * * @param value - Absolute server-side path to the working directory, or null to clear */ async setCwd(value: string | null): Promise { const trimmed = value?.trim() || undefined; // No chat yet - buffer for the first chat the user creates. if (!this.activeConversation) { this.pendingCwd = trimmed ?? null; return; } this.activeConversation = { ...this.activeConversation, cwd: trimmed }; await DatabaseService.updateConversation(this.activeConversation.id, { cwd: trimmed }); const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); if (convIndex !== -1) { this.conversations[convIndex].cwd = trimmed; this.conversations = [...this.conversations]; } this.pendingCwd = null; } /** * Forks a conversation at a specific message, creating a new conversation * containing messages from root up to the target message, then navigates to it. * * @param messageId - The message ID to fork at * @param options - Fork options (name and whether to include attachments) * @returns The new conversation ID, or null if fork failed */ async forkConversation( messageId: string, options: { name: string; includeAttachments: boolean } ): Promise { if (!this.activeConversation) return null; try { const newConv = await DatabaseService.forkConversation( this.activeConversation.id, messageId, options ); this.conversations = [newConv, ...this.conversations]; await goto(RouterService.chat(newConv.id)); toast.success('Conversation forked'); return newConv.id; } catch (error) { console.error('Failed to fork conversation:', error); toast.error('Failed to fork conversation'); return null; } } /** * * * Import & Export * * */ /** * Generates a sanitized filename for a conversation export * @param conversation - The conversation metadata * @param msgs - Optional array of messages belonging to the conversation * @returns The generated filename string */ generateConversationFilename( conversation: { id?: string; name?: string }, msgs?: DatabaseMessage[] ): string { const conversationName = (conversation.name ?? '').trim().toLowerCase(); const sanitizedName = conversationName .replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT) .replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_') .substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH); // If we have messages, use the timestamp of the newest message const referenceDate = msgs?.length ? new Date(Math.max(...msgs.map((m) => m.timestamp))) : new Date(); const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE); const formattedDate = iso .replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT) .replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT); const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? ''; return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`; } /** * Serializes a session (a conversation with its messages) as JSONL. * The first line is the session header (a `SessionRecordType.SESSION` record * carrying the conversation properties); each subsequent line is a single message. * @param data - The exported conversation payload * @returns The JSONL string (one record per line) */ serializeSessionToJsonl(data: ExportedConversation): string { const { conv, messages } = data; const sessionLine = JSON.stringify({ harness: EXPORT_CONV.HARNESS, type: SessionRecordType.SESSION, ...conv }); const messageLines = messages.map((message: DatabaseMessage) => { // `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it. const { toolCalls, ...rest } = message; const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest; return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE }); }); return [sessionLine, ...messageLines].join(NEWLINE); } /** * Parses the JSONL session format produced by {@link serializeSessionToJsonl}. * A `SessionRecordType.SESSION` line starts a new session; following * `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple * sessions in a single file. * @param text - The JSONL file contents * @returns The parsed conversations with their messages */ parseSessionsJsonl(text: string): ExportedConversation[] { const sessions: ExportedConversation[] = []; let current: ExportedConversation | null = null; for (const line of text.split(NEWLINE)) { const trimmed = line.trim(); if (!trimmed) continue; const record = JSON.parse(trimmed); if (record.type === SessionRecordType.SESSION) { // Drop the discriminator and harness marker; the rest is the conversation. const conv = { ...record }; delete conv.type; delete conv.harness; current = { conv: conv as DatabaseConversation, messages: [] }; sessions.push(current); } else if (record.type === SessionRecordType.MESSAGE) { if (!current) { throw new Error('Invalid JSONL: message record before any session record'); } const message = record.message as DatabaseMessage; // `toolCalls` is parsed to an array on export; the DB stores it as a string. if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') { message.toolCalls = JSON.stringify(message.toolCalls); } current.messages.push(message); } // Ignore unknown record types for forward compatibility. } return sessions; } /** * Reports whether the text is the JSONL session format, whose first non-empty * line is a `SessionRecordType.SESSION` record. A legacy JSON export starts * with an array or an object that has no such discriminator. * @param text - The file contents */ private isSessionsJsonl(text: string): boolean { const trimmed = text.trimStart(); const lineEnd = trimmed.indexOf(NEWLINE); const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd); try { return JSON.parse(firstLine).type === SessionRecordType.SESSION; } catch { // Not a standalone JSON record, so not the JSONL format. return false; } } /** * Parses an import file into conversations, accepting the current JSONL and * ZIP formats as well as the legacy JSON format. The format comes from the * contents, so an import works whatever the file is named. * @param file - The user-selected file * @returns The parsed conversations with their messages */ async parseImportFile(file: File): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) { const entries = unzipSync(bytes); const sessions: ExportedConversation[] = []; for (const [entryName, entryBytes] of Object.entries(entries)) { if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue; sessions.push(...this.parseSessionsJsonl(strFromU8(entryBytes))); } return sessions; } const text = strFromU8(bytes); if (this.isSessionsJsonl(text)) { return this.parseSessionsJsonl(text); } // Legacy JSON format: an array of conversations or a single conversation object. const parsed = JSON.parse(text); if (Array.isArray(parsed)) { return parsed; } if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) { return [parsed]; } throw new Error( 'Invalid file format: expected array of conversations or single conversation object' ); } /** * Triggers a browser download of the provided exported conversation data * @param data - The exported conversation payload (a single conversation with its messages) * @param filename - Filename; if omitted, a deterministic name is generated */ downloadConversationFile(data: ExportedConversation, filename?: string): void { const { conv: conversation, messages: msgs } = data; if (!conversation) { console.error('Invalid data: missing conversation'); return; } const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs); const jsonl = this.serializeSessionToJsonl(data); const blob = new Blob([jsonl], { type: MimeTypeText.JSONL }); this.triggerDownload(blob, downloadFilename); } /** * Triggers a browser download of multiple conversations as a `.zip`, one * `.jsonl` file per conversation. * @param data - The conversations to export */ downloadConversationsArchive(data: ExportedConversation[]): void { if (data.length === 0) { console.error('Invalid data: no conversations to export'); return; } const usedNames = new SvelteSet(); const files: Record = {}; for (const session of data) { const baseName = this.generateConversationFilename(session.conv, session.messages); // Disambiguate any duplicate filenames within the archive. let entryName = baseName; let suffix = 1; while (usedNames.has(entryName)) { entryName = baseName.replace( new RegExp(`${FileExtensionText.JSONL}$`), `_${suffix++}${FileExtensionText.JSONL}` ); } usedNames.add(entryName); files[entryName] = strToU8(this.serializeSessionToJsonl(session)); } const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`; const zipped = zipSync(files); const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP }); this.triggerDownload(blob, archiveName); } /** * Triggers a browser download of a blob under the given filename. */ private triggerDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Downloads a single conversation as a JSONL file, serializing the full message tree. * @param convId - The conversation ID to download */ async downloadConversation(convId: string): Promise { const conversation = this.activeConversation?.id === convId ? this.activeConversation : await DatabaseService.getConversation(convId); if (!conversation) return; const messages = await DatabaseService.getConversationMessages(convId); this.downloadConversationFile({ conv: conversation, messages }); } /** * Imports conversations from provided data (without file picker) * @param data - Array of conversation data with messages * @returns The conversations written to the database and the ones skipped */ async importConversationsData( data: ExportedConversations ): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> { const result = await DatabaseService.importConversations(data); await this.loadConversations(); return result; } } export const conversationsStore = new ConversationsStore(); // Auto-initialize in browser if (browser) { conversationsStore.init(); }