Safetensors
GGUF
Turkish
llama
Llama-3
instruct
finetune
chatml
gpt4
synthetic data
distillation
function calling
json mode
axolotl
roleplaying
chat
Instructions to use tda45/TdAI with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- llama-cpp-python
How to use tda45/TdAI with llama-cpp-python:
# !pip install llama-cpp-python from llama_cpp import Llama llm = Llama.from_pretrained( repo_id="tda45/TdAI", filename="llama.cpp/models/ggml-vocab-aquila.gguf", )
output = llm( "Once upon a time,", max_tokens=512, echo=True ) print(output)
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use tda45/TdAI with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf tda45/TdAI # Run inference directly in the terminal: llama cli -hf tda45/TdAI
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./llama-cli -hf tda45/TdAI
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf tda45/TdAI # Run inference directly in the terminal: ./build/bin/llama-cli -hf tda45/TdAI
Use Docker
docker model run hf.co/tda45/TdAI
- LM Studio
- Jan
- Ollama
How to use tda45/TdAI with Ollama:
ollama run hf.co/tda45/TdAI
- Unsloth Studio
How to use tda45/TdAI with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for tda45/TdAI to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for tda45/TdAI to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for tda45/TdAI to start chatting
- Atomic Chat new
- Docker Model Runner
How to use tda45/TdAI with Docker Model Runner:
docker model run hf.co/tda45/TdAI
- Lemonade
How to use tda45/TdAI with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull tda45/TdAI
Run and chat with the model
lemonade run user.TdAI-{{QUANT_TAG}}List all available models
lemonade list
| /** | |
| * 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 { goto } from '$app/navigation'; | |
| import { browser } from '$app/environment'; | |
| import { toast } from 'svelte-sonner'; | |
| import { DatabaseService } from '$lib/services/database.service'; | |
| import { MigrationService } from '$lib/services/migration.service'; | |
| import { config, settingsStore } from '$lib/stores/settings.svelte'; | |
| import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; | |
| import type { McpServerOverride } from '$lib/types/database'; | |
| import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; | |
| import { | |
| MessageRole, | |
| HtmlInputType, | |
| FileExtensionText, | |
| MimeTypeText, | |
| MimeTypeApplication, | |
| ReasoningEffort | |
| } from '$lib/enums'; | |
| import { | |
| ISO_DATE_TIME_SEPARATOR, | |
| ISO_DATE_TIME_SEPARATOR_REPLACEMENT, | |
| ISO_TIMESTAMP_SLICE_LENGTH, | |
| EXPORT_CONV_ID_TRIM_LENGTH, | |
| EXPORT_CONV_NONALNUM_REPLACEMENT, | |
| EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH, | |
| ISO_TIME_SEPARATOR, | |
| ISO_TIME_SEPARATOR_REPLACEMENT, | |
| NON_ALPHANUMERIC_REGEX, | |
| MULTIPLE_UNDERSCORE_REGEX, | |
| SETTINGS_KEYS, | |
| THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY, | |
| REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY | |
| } from '$lib/constants'; | |
| import { ROUTES } from '$lib/constants/routes'; | |
| import { RouterService } from '$lib/services/router.service'; | |
| import { SvelteMap, SvelteSet } from 'svelte/reactivity'; | |
| export interface ConversationTreeItem { | |
| conversation: DatabaseConversation; | |
| depth: number; | |
| } | |
| 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); | |
| /** Pending MCP server overrides for new conversations (before first message) */ | |
| pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults()); | |
| /** Global (non-conversation-specific) thinking toggle default */ | |
| pendingThinkingEnabled = $state(ConversationsStore.loadThinkingDefaults()); | |
| /** Global (non-conversation-specific) reasoning effort default */ | |
| pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault()); | |
| private static loadMcpDefaults(): McpServerOverride[] { | |
| const raw = config()[SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES]; | |
| if (typeof raw !== 'string' || raw.length === 0) return []; | |
| try { | |
| const parsed = JSON.parse(raw); | |
| if (!Array.isArray(parsed)) return []; | |
| return parsed.filter( | |
| (o: unknown) => typeof o === 'object' && o !== null && 'serverId' in o && 'enabled' in o | |
| ) as McpServerOverride[]; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| private saveMcpDefaults(): void { | |
| const plain = this.pendingMcpServerOverrides.map((o) => ({ | |
| serverId: o.serverId, | |
| enabled: o.enabled | |
| })); | |
| settingsStore.updateConfig(SETTINGS_KEYS.MCP_DEFAULT_SERVER_OVERRIDES, JSON.stringify(plain)); | |
| } | |
| /** Load thinking-enabled default from localStorage */ | |
| private static loadThinkingDefaults(): boolean { | |
| if (typeof globalThis.localStorage === 'undefined') return true; | |
| try { | |
| const raw = localStorage.getItem(THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY); | |
| if (!raw) return true; | |
| return raw === 'true'; | |
| } catch { | |
| return true; | |
| } | |
| } | |
| /** Persist thinking-enabled default to localStorage */ | |
| private saveThinkingDefaults(): void { | |
| if (typeof globalThis.localStorage === 'undefined') return; | |
| localStorage.setItem( | |
| THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY, | |
| this.pendingThinkingEnabled ? 'true' : 'false' | |
| ); | |
| } | |
| /** Load reasoning effort default from localStorage */ | |
| private static loadReasoningEffortDefault(): ReasoningEffort { | |
| if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.MEDIUM; | |
| try { | |
| const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY); | |
| return (raw as ReasoningEffort) || ReasoningEffort.MEDIUM; | |
| } catch { | |
| return ReasoningEffort.MEDIUM; | |
| } | |
| } | |
| /** 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 title update confirmation dialog */ | |
| titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>; | |
| /** | |
| * 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; | |
| /** | |
| * | |
| * | |
| * Lifecycle | |
| * | |
| * | |
| */ | |
| /** | |
| * Initialize the store by loading conversations from database. | |
| * Must be called once after app startup. | |
| */ | |
| async init(): Promise<void> { | |
| if (!browser) return; | |
| if (this.isInitialized) return; | |
| try { | |
| await MigrationService.runAllMigrations(); | |
| // Re-read defaults after migrations: a migration may have populated | |
| // the settings config (e.g. moved legacy MCP overrides into it). | |
| this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); | |
| await this.loadConversations(); | |
| this.isInitialized = true; | |
| } catch (error) { | |
| console.error('Failed to initialize conversations:', error); | |
| } | |
| } | |
| /** | |
| * 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 { | |
| if (index !== -1 && this.activeMessages[index]) { | |
| this.activeMessages[index] = { ...this.activeMessages[index], ...updates }; | |
| } | |
| } | |
| /** | |
| * 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; | |
| } | |
| /** | |
| * Sets the callback function for title update confirmations | |
| */ | |
| setTitleUpdateConfirmationCallback( | |
| callback: (currentTitle: string, newTitle: string) => Promise<boolean> | |
| ): void { | |
| this.titleUpdateConfirmationCallback = callback; | |
| } | |
| /** | |
| * | |
| * | |
| * 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()}`; | |
| const conversation = await DatabaseService.createConversation(conversationName); | |
| if (this.pendingMcpServerOverrides.length > 0) { | |
| // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) | |
| const plainOverrides = this.pendingMcpServerOverrides.map((o) => ({ | |
| serverId: o.serverId, | |
| enabled: o.enabled | |
| })); | |
| conversation.mcpServerOverrides = plainOverrides; | |
| await DatabaseService.updateConversation(conversation.id, { | |
| mcpServerOverrides: plainOverrides | |
| }); | |
| this.pendingMcpServerOverrides = []; | |
| } | |
| // Inherit global thinking default into the new conversation | |
| conversation.thinkingEnabled = this.pendingThinkingEnabled; | |
| await DatabaseService.updateConversation(conversation.id, { | |
| thinkingEnabled: this.pendingThinkingEnabled | |
| }); | |
| 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; | |
| } | |
| this.pendingMcpServerOverrides = []; | |
| this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults(); | |
| 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.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults(); | |
| this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults(); | |
| } | |
| /** | |
| * 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(); | |
| for (const conv of allConversations) { | |
| await DatabaseService.deleteConversation(conv.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'); | |
| } | |
| } | |
| /** | |
| * | |
| * | |
| * 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; | |
| this.conversations = [...this.conversations]; | |
| } | |
| 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; | |
| this.conversations = [...this.conversations]; | |
| } | |
| 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; | |
| } | |
| } | |
| /** | |
| * Updates conversation title with optional confirmation dialog based on settings | |
| * @param convId - The conversation ID to update | |
| * @param newTitle - The new title content | |
| * @returns True if title was updated, false if cancelled | |
| */ | |
| async updateConversationTitleWithConfirmation( | |
| convId: string, | |
| newTitle: string | |
| ): Promise<boolean> { | |
| try { | |
| const currentConfig = config(); | |
| if (currentConfig.askForTitleConfirmation && this.titleUpdateConfirmationCallback) { | |
| const conversation = await DatabaseService.getConversation(convId); | |
| if (!conversation) return false; | |
| const shouldUpdate = await this.titleUpdateConfirmationCallback( | |
| conversation.name, | |
| newTitle | |
| ); | |
| if (!shouldUpdate) return false; | |
| } | |
| await this.updateConversationName(convId, newTitle); | |
| return true; | |
| } catch (error) { | |
| console.error('Failed to update conversation title with confirmation:', error); | |
| return false; | |
| } | |
| } | |
| /** | |
| * Updates conversation lastModified timestamp and moves it to top of list | |
| */ | |
| updateConversationTimestamp(): void { | |
| if (!this.activeConversation) return; | |
| const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); | |
| if (chatIndex !== -1) { | |
| this.conversations[chatIndex].lastModified = Date.now(); | |
| const updatedConv = this.conversations.splice(chatIndex, 1)[0]; | |
| this.conversations = [updatedConv, ...this.conversations]; | |
| } | |
| } | |
| /** | |
| * 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.updateConversationTitleWithConfirmation( | |
| this.activeConversation.id, | |
| generateConversationTitle( | |
| newFirstUserMessage.content, | |
| Boolean(config().titleGenerationUseFirstLine) | |
| ) | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * | |
| * MCP Server Overrides | |
| * | |
| * | |
| */ | |
| /** | |
| * Gets MCP server override for a specific server in the active conversation. | |
| * Falls back to pending overrides if no active conversation exists. | |
| * @param serverId - The server ID to check | |
| * @returns The override if set, undefined if using global setting | |
| */ | |
| getMcpServerOverride(serverId: string): McpServerOverride | undefined { | |
| if (this.activeConversation) { | |
| return this.activeConversation.mcpServerOverrides?.find( | |
| (o: McpServerOverride) => o.serverId === serverId | |
| ); | |
| } | |
| return this.pendingMcpServerOverrides.find((o) => o.serverId === serverId); | |
| } | |
| /** | |
| * Get all MCP server overrides for the current conversation. | |
| * Returns pending overrides if no active conversation. | |
| */ | |
| getAllMcpServerOverrides(): McpServerOverride[] { | |
| if (this.activeConversation?.mcpServerOverrides) { | |
| return this.activeConversation.mcpServerOverrides; | |
| } | |
| return this.pendingMcpServerOverrides; | |
| } | |
| /** | |
| * 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, stores as pending override. | |
| * @param serverId - The server ID to override | |
| * @param enabled - The enabled state, or undefined to remove override | |
| */ | |
| async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> { | |
| if (!this.activeConversation) { | |
| this.setPendingMcpServerOverride(serverId, enabled); | |
| return; | |
| } | |
| // Clone to plain objects to avoid Proxy serialization issues with IndexedDB | |
| const currentOverrides = (this.activeConversation.mcpServerOverrides || []).map( | |
| (o: McpServerOverride) => ({ | |
| serverId: o.serverId, | |
| enabled: o.enabled | |
| }) | |
| ); | |
| 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] = { serverId, enabled }; | |
| } else { | |
| newOverrides = [...currentOverrides, { serverId, enabled }]; | |
| } | |
| } | |
| 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; | |
| this.conversations = [...this.conversations]; | |
| } | |
| } | |
| /** | |
| * Sets or removes a pending MCP server override (for new conversations). | |
| */ | |
| private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { | |
| if (enabled === undefined) { | |
| this.pendingMcpServerOverrides = this.pendingMcpServerOverrides.filter( | |
| (o) => o.serverId !== serverId | |
| ); | |
| } else { | |
| const existingIndex = this.pendingMcpServerOverrides.findIndex( | |
| (o) => o.serverId === serverId | |
| ); | |
| if (existingIndex >= 0) { | |
| const newOverrides = [...this.pendingMcpServerOverrides]; | |
| newOverrides[existingIndex] = { serverId, enabled }; | |
| this.pendingMcpServerOverrides = newOverrides; | |
| } else { | |
| this.pendingMcpServerOverrides = [...this.pendingMcpServerOverrides, { serverId, enabled }]; | |
| } | |
| } | |
| this.saveMcpDefaults(); | |
| } | |
| /** | |
| * 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); | |
| } | |
| /** | |
| * Clears all pending MCP server overrides. | |
| */ | |
| clearPendingMcpServerOverrides(): void { | |
| this.pendingMcpServerOverrides = []; | |
| this.saveMcpDefaults(); | |
| } | |
| /** | |
| * Gets the effective thinking-enabled state for the active conversation. | |
| * Returns the conversation override if set, otherwise the global default. | |
| */ | |
| getThinkingEnabled(): boolean { | |
| if (this.activeConversation) { | |
| return this.activeConversation.thinkingEnabled ?? this.pendingThinkingEnabled; | |
| } | |
| return this.pendingThinkingEnabled; | |
| } | |
| /** | |
| * Sets the thinking-enabled state for the active conversation. | |
| * If no conversation exists, stores the global default. | |
| * @param enabled - The enabled state | |
| */ | |
| async setThinkingEnabled(enabled: boolean): Promise<void> { | |
| if (!this.activeConversation) { | |
| this.pendingThinkingEnabled = enabled; | |
| this.saveThinkingDefaults(); | |
| return; | |
| } | |
| this.activeConversation = { | |
| ...this.activeConversation, | |
| thinkingEnabled: enabled | |
| }; | |
| await DatabaseService.updateConversation(this.activeConversation.id, { | |
| thinkingEnabled: enabled | |
| }); | |
| const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); | |
| if (convIndex !== -1) { | |
| this.conversations[convIndex].thinkingEnabled = enabled; | |
| this.conversations = [...this.conversations]; | |
| } | |
| } | |
| /** | |
| * Gets the effective reasoning effort for the active conversation. | |
| * Returns the conversation override if set, otherwise the global default. | |
| */ | |
| getReasoningEffort(): ReasoningEffort { | |
| if (this.activeConversation) { | |
| return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort; | |
| } | |
| return this.pendingReasoningEffort; | |
| } | |
| /** | |
| * Sets the reasoning effort for the active conversation. | |
| * If no conversation exists, stores the global default. | |
| * @param effort - The effort level ('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; | |
| this.conversations = [...this.conversations]; | |
| } | |
| } | |
| /** | |
| * 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(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT) | |
| .replace(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, ISO_TIMESTAMP_SLICE_LENGTH); | |
| const formattedDate = iso | |
| .replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT) | |
| .replaceAll(ISO_TIME_SEPARATOR, 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 `type: '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({ type: 'session', harness: 'llama.app', ...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({ type: 'message', message: normalized }); | |
| }); | |
| return [sessionLine, ...messageLines].join('\n'); | |
| } | |
| /** | |
| * Parses the JSONL session format produced by {@link serializeSessionToJsonl}. | |
| * A `type: 'session'` line starts a new session; following `type: '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('\n')) { | |
| const trimmed = line.trim(); | |
| if (!trimmed) continue; | |
| const record = JSON.parse(trimmed); | |
| if (record.type === '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 === '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; | |
| } | |
| /** | |
| * Parses an import file into conversations, accepting the current `.jsonl` and | |
| * `.zip` formats as well as the legacy `.json` format. | |
| * @param file - The user-selected file | |
| * @returns The parsed conversations with their messages | |
| */ | |
| async parseImportFile(file: File): Promise<ExportedConversation[]> { | |
| const name = file.name.toLowerCase(); | |
| if (name.endsWith(FileExtensionText.ZIP)) { | |
| const entries = unzipSync(new Uint8Array(await file.arrayBuffer())); | |
| const sessions: ExportedConversation[] = []; | |
| for (const [entryName, bytes] of Object.entries(entries)) { | |
| if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue; | |
| sessions.push(...this.parseSessionsJsonl(strFromU8(bytes))); | |
| } | |
| return sessions; | |
| } | |
| const text = await file.text(); | |
| if (name.endsWith(FileExtensionText.JSONL)) { | |
| 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(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 conversation as JSON file. | |
| * @param convId - The conversation ID to download | |
| */ | |
| async downloadConversation(convId: string): Promise<void> { | |
| let conversation: DatabaseConversation | null; | |
| let messages: DatabaseMessage[]; | |
| if (this.activeConversation?.id === convId) { | |
| conversation = this.activeConversation; | |
| messages = this.activeMessages; | |
| } else { | |
| conversation = await DatabaseService.getConversation(convId); | |
| if (!conversation) return; | |
| messages = await DatabaseService.getConversationMessages(convId); | |
| } | |
| this.downloadConversationFile({ conv: conversation, messages }); | |
| } | |
| /** | |
| * Imports conversations from a JSON file | |
| * Opens file picker and processes the selected file | |
| * @returns The list of imported conversations | |
| */ | |
| async importConversations(): Promise<DatabaseConversation[]> { | |
| return new Promise((resolve, reject) => { | |
| const input = document.createElement('input'); | |
| input.type = HtmlInputType.FILE; | |
| input.accept = FileExtensionText.JSON; | |
| input.onchange = async (e) => { | |
| const file = (e.target as HTMLInputElement)?.files?.[0]; | |
| if (!file) { | |
| reject(new Error('No file selected')); | |
| return; | |
| } | |
| try { | |
| const text = await file.text(); | |
| const parsedData = JSON.parse(text); | |
| let importedData: ExportedConversations; | |
| if (Array.isArray(parsedData)) { | |
| importedData = parsedData; | |
| } else if ( | |
| parsedData && | |
| typeof parsedData === 'object' && | |
| 'conv' in parsedData && | |
| 'messages' in parsedData | |
| ) { | |
| importedData = [parsedData]; | |
| } else { | |
| throw new Error('Invalid file format'); | |
| } | |
| const result = await DatabaseService.importConversations(importedData); | |
| toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); | |
| await this.loadConversations(); | |
| const importedConversations = ( | |
| Array.isArray(importedData) ? importedData : [importedData] | |
| ).map((item) => item.conv); | |
| resolve(importedConversations); | |
| } catch (err: unknown) { | |
| const message = err instanceof Error ? err.message : 'Unknown error'; | |
| console.error('Failed to import conversations:', err); | |
| toast.error('Import failed', { description: message }); | |
| reject(new Error(`Import failed: ${message}`)); | |
| } | |
| }; | |
| input.click(); | |
| }); | |
| } | |
| /** | |
| * Imports conversations from provided data (without file picker) | |
| * @param data - Array of conversation data with messages | |
| * @returns Import result with counts | |
| */ | |
| async importConversationsData( | |
| data: ExportedConversations | |
| ): Promise<{ imported: number; skipped: number }> { | |
| const result = await DatabaseService.importConversations(data); | |
| await this.loadConversations(); | |
| return result; | |
| } | |
| } | |
| export const conversationsStore = new ConversationsStore(); | |
| // Auto-initialize in browser | |
| if (browser) { | |
| conversationsStore.init(); | |
| } | |
| export const conversations = () => conversationsStore.conversations; | |
| export const activeConversation = () => conversationsStore.activeConversation; | |
| export const activeMessages = () => conversationsStore.activeMessages; | |
| export const isConversationsInitialized = () => conversationsStore.isInitialized; | |
| /** | |
| * Builds a flat tree of conversations with depth levels for nested forks. | |
| * Accepts a pre-filtered list so search filtering stays in the component. | |
| */ | |
| // Pinned conversations first, then by lastModified descending | |
| const comparePinnedThenRecent = (a: DatabaseConversation, b: DatabaseConversation) => { | |
| if (a.pinned && !b.pinned) return -1; | |
| if (!a.pinned && b.pinned) return 1; | |
| return b.lastModified - a.lastModified; | |
| }; | |
| export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] { | |
| const childrenByParent = new SvelteMap<string, DatabaseConversation[]>(); | |
| const forkIds = new SvelteSet<string>(); | |
| for (const conv of convs) { | |
| if (conv.forkedFromConversationId) { | |
| forkIds.add(conv.id); | |
| const siblings = childrenByParent.get(conv.forkedFromConversationId) || []; | |
| siblings.push(conv); | |
| childrenByParent.set(conv.forkedFromConversationId, siblings); | |
| } | |
| } | |
| const result: ConversationTreeItem[] = []; | |
| const visited = new SvelteSet<string>(); | |
| function walk(conv: DatabaseConversation, depth: number) { | |
| visited.add(conv.id); | |
| result.push({ conversation: conv, depth }); | |
| const children = childrenByParent.get(conv.id); | |
| if (children) { | |
| children.sort(comparePinnedThenRecent); | |
| for (const child of children) { | |
| walk(child, depth + 1); | |
| } | |
| } | |
| } | |
| const roots = convs.filter((c) => !forkIds.has(c.id)).sort(comparePinnedThenRecent); | |
| for (const root of roots) { | |
| walk(root, 0); | |
| } | |
| for (const conv of convs) { | |
| if (!visited.has(conv.id)) { | |
| walk(conv, 1); | |
| } | |
| } | |
| return result; | |
| } | |