* 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
1259 lines
37 KiB
TypeScript
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();
|
|
}
|