Files
llama.cpp/tools/ui/src/lib/stores/conversations.svelte.ts
T
Aleksander Grygier 094e53db1c ui: Stores architecture improvements (#26910)
* refactor: Stores barrel imports + SSR gates

* refactor: Drop agenticStore wrapper exports

* refactor: Drop chatStore wrapper exports

* refactor: Drop modelsStore wrapper exports

* refactor: Drop serverStore wrapper exports

* refactor: Drop unused mcpStore wrapper exports

* refactor: Drop mcpResourceStore wrapper exports

* refactor: Drop conversationsStore wrapper exports + move buildConversationTree to utils

* refactor: Drop settingsStore wrapper exports

* refactor: Drop unused toolsStore wrapper exports

* refactor: Fix lint errors from store wrapper removal

* fix: Missing change

* refactor: Cleanup

* refactor: Context Stats store
2026-08-13 08:11:30 +02:00

1259 lines
37 KiB
TypeScript

/**
* 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<DatabaseConversation[]>([]);
/** Currently active conversation */
activeConversation = $state<DatabaseConversation | null>(null);
/** Messages in the active conversation (filtered by currNode path) */
activeMessages = $state<DatabaseMessage[]>([]);
/** Whether the store has been initialized */
isInitialized = $state(false);
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(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<string | null>(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<DatabaseMessage>) => void)
| null = null;
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
private initPromise: Promise<void> | 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<void> {
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<void> {
return this.init();
}
/**
* Register a callback for message updates from other stores.
* Called by chatStore during initialization.
*/
registerMessageUpdateCallback(
callback: (messageId: string, updates: Partial<DatabaseMessage>) => 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<DatabaseMessage>): 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<string, unknown>;
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<void> {
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<string> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DatabaseMessage[]> {
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | null> {
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<ExportedConversation[]> {
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<string>();
const files: Record<string, Uint8Array> = {};
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<void> {
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();
}