ui: Restructure repo to use tools/ui folder and ui / UI / llama-ui / LLAMA_UI naming (#23064)

* webui: Move static build output from `tools/server/public` to `build/ui` directory

* refactor: Move to `tools/ui`

* refactor: rename CMake variables and preprocessor defines

- Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated)
- Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated)
- Backward compat: old vars auto-forward to new ones with DEPRECATION warning
- Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc.
- Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET
- Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines
- Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED

* refactor: rename CLI flags (--webui -> --ui) with backward compat

- Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases)
- Add --ui-config (old --webui-config kept as deprecated alias)
- Add --ui-config-file (old --webui-config-file kept as deprecated alias)
- Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated)
- Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY
- C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields
- Backward compat: old fields synced to new ones in g_params_to_internals

* refactor: update C++ server internals with backward compat

- Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta)
- Rename params.webui usage -> params.ui (both synced, old still works)
- JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys
- Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy
- Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI)

* refactor: rename CI/CD workflows, artifacts, and build script

- Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build
- Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT
- Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks
- Update server.yml: job/artifact refs webui-build -> ui-build
- Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT
- Update server-self-hosted.yml: webui-build -> ui-build
- Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION
- Rename webui-download.cmake -> ui-download.cmake (internal refs updated)
- Update labeler.yml: server/webui -> server/ui path label

* docs: update CODEOWNERS and server README docs

- Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/
- Update server README.md: CLI tables show --ui flags with deprecated --webui aliases
- Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/

* fix: Small fixes for UI build

* fix: CMake.txt syntax

* chore: Formatting

* fix: `.editorconfig` for llama-ui

* chore: Formatting

* refactor: Use `APP_NAME` in Error route

* refactor: Cleanup

* refactor: Single migration service

* make llama-ui a linkable target

* fix: UI Build output

* fix: Missing change

* fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI

* refactor: UI workflows cleanup

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
Aleksander Grygier
2026-05-16 02:02:40 +02:00
committed by GitHub
co-authored by Xuan Son Nguyen
parent 49d1701bd2
commit 59778f0196
565 changed files with 1610 additions and 694 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,971 @@
/**
* 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 } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { MessageRole, HtmlInputType, FileExtensionText } 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,
MCP_DEFAULT_ENABLED_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());
/** Load MCP default overrides from localStorage */
private static loadMcpDefaults(): McpServerOverride[] {
if (typeof globalThis.localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
if (!raw) return [];
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 [];
}
}
/** Persist MCP default overrides to localStorage */
private saveMcpDefaults(): void {
if (typeof globalThis.localStorage === 'undefined') return;
const plain = this.pendingMcpServerOverrides.map((o) => ({
serverId: o.serverId,
enabled: o.enabled
}));
if (plain.length > 0) {
localStorage.setItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY, JSON.stringify(plain));
} else {
localStorage.removeItem(MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY);
}
}
/** 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();
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 = [];
}
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.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 MCP defaults so new chats inherit persisted state
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
}
/**
* 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);
}
}
/**
* 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();
}
/**
* 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}.json`;
}
/**
* Triggers a browser download of the provided exported conversation data
* @param data - The exported conversation payload (either a single conversation or array of them)
* @param filename - Filename; if omitted, a deterministic name is generated
*/
downloadConversationFile(data: ExportedConversations, filename?: string): void {
// Choose the first conversation or message
const conversation =
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
const msgs =
'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined;
if (!conversation) {
console.error('Invalid data: missing conversation');
return;
}
let downloadFilename: string;
if (filename) {
downloadFilename = filename;
} else if (Array.isArray(data) && data.length > 1) {
downloadFilename = `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`;
} else {
downloadFilename = this.generateConversationFilename(conversation, msgs);
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = downloadFilename;
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.
*/
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((a, b) => b.lastModified - a.lastModified);
for (const child of children) {
walk(child, depth + 1);
}
}
}
const roots = convs.filter((c) => !forkIds.has(c.id));
for (const root of roots) {
walk(root, 0);
}
for (const conv of convs) {
if (!visited.has(conv.id)) {
walk(conv, 1);
}
}
return result;
}
@@ -0,0 +1,31 @@
import { NEW_CHAT_DRAFT_KEY } from '$lib/constants';
interface DraftMessage {
message: string;
files: ChatUploadedFile[];
}
class DraftMessagesStore {
private drafts = new Map<string, DraftMessage>();
getDraftMessage(chatId: string | undefined): DraftMessage {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
return this.drafts.get(key) ?? { message: '', files: [] };
}
saveDraftMessage(chatId: string | undefined, message: string, files: ChatUploadedFile[]): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
if (message || files.length > 0) {
this.drafts.set(key, { message, files: [...files] });
} else {
this.drafts.delete(key);
}
}
clearDraftMessage(chatId: string | undefined): void {
const key = chatId ?? NEW_CHAT_DRAFT_KEY;
this.drafts.delete(key);
}
}
export const draftMessagesStore = new DraftMessagesStore();
@@ -0,0 +1,608 @@
/**
* mcpResourceStore - Reactive State Store for MCP Resources
*
* Manages MCP protocol resources:
* - Resource discovery and listing per server
* - Resource content caching
* - Resource subscriptions
* - Resource attachments for chat context
*
* @see MCP Protocol Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
*/
import { SvelteMap } from 'svelte/reactivity';
import { AttachmentType } from '$lib/enums';
import {
MCP_RESOURCE_ATTACHMENT_ID_PREFIX,
MCP_RESOURCE_CACHE_MAX_ENTRIES,
MCP_RESOURCE_CACHE_TTL_MS,
NEWLINE_SEPARATOR,
RESOURCE_UNKNOWN_TYPE,
BINARY_CONTENT_LABEL
} from '$lib/constants';
import { normalizeResourceUri } from '$lib/utils';
import type {
MCPResource,
MCPResourceTemplate,
MCPResourceContent,
MCPResourceInfo,
MCPResourceTemplateInfo,
MCPCachedResource,
MCPResourceAttachment,
MCPResourceSubscription,
MCPServerResources,
DatabaseMessageExtraMcpResource
} from '$lib/types';
function generateAttachmentId(): string {
return `${MCP_RESOURCE_ATTACHMENT_ID_PREFIX}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
class MCPResourceStore {
private _serverResources = $state<SvelteMap<string, MCPServerResources>>(new SvelteMap());
private _cachedResources = $state<SvelteMap<string, MCPCachedResource>>(new SvelteMap());
private _subscriptions = $state<SvelteMap<string, MCPResourceSubscription>>(new SvelteMap());
private _attachments = $state<MCPResourceAttachment[]>([]);
private _isLoading = $state(false);
get serverResources(): Map<string, MCPServerResources> {
return this._serverResources;
}
get cachedResources(): Map<string, MCPCachedResource> {
return this._cachedResources;
}
get subscriptions(): Map<string, MCPResourceSubscription> {
return this._subscriptions;
}
get attachments(): MCPResourceAttachment[] {
return this._attachments;
}
get isLoading(): boolean {
return this._isLoading;
}
get totalResourceCount(): number {
let count = 0;
for (const serverRes of this._serverResources.values()) {
count += serverRes.resources.length;
}
return count;
}
get totalTemplateCount(): number {
let count = 0;
for (const serverRes of this._serverResources.values()) {
count += serverRes.templates.length;
}
return count;
}
get attachmentCount(): number {
return this._attachments.length;
}
get hasAttachments(): boolean {
return this._attachments.length > 0;
}
/**
*
*
* Server Resources Management
*
*
*/
/**
* Set resources for a server (called after listResources)
*/
setServerResources(
serverName: string,
resources: MCPResource[],
templates: MCPResourceTemplate[]
): void {
this._serverResources.set(serverName, {
serverName,
resources,
templates,
lastFetched: new Date(),
loading: false,
error: undefined
});
console.log(
`[MCPResources][${serverName}] Set ${resources.length} resources, ${templates.length} templates`
);
}
/**
* Set loading state for a server's resources
*/
setServerLoading(serverName: string, loading: boolean): void {
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, { ...existing, loading });
} else {
this._serverResources.set(serverName, {
serverName,
resources: [],
templates: [],
loading,
error: undefined
});
}
}
/**
* Set error state for a server's resources
*/
setServerError(serverName: string, error: string): void {
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, { ...existing, loading: false, error });
} else {
this._serverResources.set(serverName, {
serverName,
resources: [],
templates: [],
loading: false,
error
});
}
}
/**
* Get resources for a specific server
*/
getServerResources(serverName: string): MCPServerResources | undefined {
return this._serverResources.get(serverName);
}
/**
* Get all resources as MCPResourceInfo array (flattened with server names)
*/
getAllResourceInfos(): MCPResourceInfo[] {
const result: MCPResourceInfo[] = [];
for (const [serverName, serverRes] of this._serverResources) {
for (const resource of serverRes.resources) {
result.push({
uri: resource.uri,
name: resource.name,
title: resource.title,
description: resource.description,
mimeType: resource.mimeType,
serverName,
annotations: resource.annotations,
icons: resource.icons
});
}
}
return result;
}
/**
* Get all templates as MCPResourceTemplateInfo array (flattened with server names)
*/
getAllTemplateInfos(): MCPResourceTemplateInfo[] {
const result: MCPResourceTemplateInfo[] = [];
for (const [serverName, serverRes] of this._serverResources) {
for (const template of serverRes.templates) {
result.push({
uriTemplate: template.uriTemplate,
name: template.name,
title: template.title,
description: template.description,
mimeType: template.mimeType,
serverName,
annotations: template.annotations,
icons: template.icons
});
}
}
return result;
}
/**
* Clear resources for a server (e.g., when disconnected)
*/
clearServerResources(serverName: string): void {
this._serverResources.delete(serverName);
// Also clear cached content for this server's resources
for (const [uri, cached] of this._cachedResources) {
if (cached.resource.serverName === serverName) {
this._cachedResources.delete(uri);
}
}
// Clear subscriptions for this server
for (const [uri, sub] of this._subscriptions) {
if (sub.serverName === serverName) {
this._subscriptions.delete(uri);
}
}
console.log(`[MCPResources][${serverName}] Cleared all resources`);
}
/**
*
*
* Resource Content Caching
*
*
*/
/**
* Cache resource content after reading
*/
cacheResourceContent(resource: MCPResourceInfo, content: MCPResourceContent[]): void {
// Enforce cache size limit
if (this._cachedResources.size >= MCP_RESOURCE_CACHE_MAX_ENTRIES) {
// Remove oldest entry
const oldestKey = this._cachedResources.keys().next().value;
if (oldestKey) {
this._cachedResources.delete(oldestKey);
}
}
this._cachedResources.set(resource.uri, {
resource,
content,
fetchedAt: new Date(),
subscribed: this._subscriptions.has(resource.uri)
});
console.log(`[MCPResources] Cached content for: ${resource.uri}`);
}
/**
* Get cached content for a resource
*/
getCachedContent(uri: string): MCPCachedResource | undefined {
const cached = this._cachedResources.get(uri);
if (!cached) return undefined;
// Check if cache is still valid
const age = Date.now() - cached.fetchedAt.getTime();
if (age > MCP_RESOURCE_CACHE_TTL_MS && !cached.subscribed) {
// Cache expired and not subscribed, remove it
this._cachedResources.delete(uri);
return undefined;
}
return cached;
}
/**
* Invalidate cached content for a resource (e.g., on update notification)
*/
invalidateCache(uri: string): void {
this._cachedResources.delete(uri);
console.log(`[MCPResources] Invalidated cache for: ${uri}`);
}
/**
* Clear all cached content
*/
clearCache(): void {
this._cachedResources.clear();
console.log(`[MCPResources] Cleared all cached content`);
}
/**
*
*
* Subscriptions
*
*
*/
/**
* Register a subscription for a resource
*/
addSubscription(uri: string, serverName: string): void {
this._subscriptions.set(uri, {
uri,
serverName,
subscribedAt: new Date()
});
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: true });
}
console.log(`[MCPResources] Added subscription: ${uri}`);
}
/**
* Remove a subscription for a resource
*/
removeSubscription(uri: string): void {
this._subscriptions.delete(uri);
// Update cached resource if exists
const cached = this._cachedResources.get(uri);
if (cached) {
this._cachedResources.set(uri, { ...cached, subscribed: false });
}
console.log(`[MCPResources] Removed subscription: ${uri}`);
}
/**
* Check if a resource is subscribed
*/
isSubscribed(uri: string): boolean {
return this._subscriptions.has(uri);
}
/**
* Handle resource update notification
*/
handleResourceUpdate(uri: string): void {
// Invalidate cache so next read gets fresh content
this.invalidateCache(uri);
// Update subscription last update time
const sub = this._subscriptions.get(uri);
if (sub) {
this._subscriptions.set(uri, { ...sub, lastUpdate: new Date() });
}
console.log(`[MCPResources] Resource updated: ${uri}`);
}
/**
* Handle resources list changed notification
*/
handleResourcesListChanged(serverName: string): void {
// Mark server resources as needing refresh
const existing = this._serverResources.get(serverName);
if (existing) {
this._serverResources.set(serverName, {
...existing,
lastFetched: undefined // Mark as stale
});
}
console.log(`[MCPResources][${serverName}] Resources list changed, needs refresh`);
}
/**
*
*
* Attachments (for chat context)
*
*
*/
/**
* Add a resource attachment to the current chat context
*/
addAttachment(resource: MCPResourceInfo): MCPResourceAttachment {
const attachment: MCPResourceAttachment = {
id: generateAttachmentId(),
resource,
loading: true
};
this._attachments = [...this._attachments, attachment];
console.log(`[MCPResources] Added attachment: ${resource.uri}`);
return attachment;
}
/**
* Update attachment with fetched content
*/
updateAttachmentContent(attachmentId: string, content: MCPResourceContent[]): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, content, loading: false, error: undefined } : att
);
}
/**
* Update attachment with error
*/
updateAttachmentError(attachmentId: string, error: string): void {
this._attachments = this._attachments.map((att) =>
att.id === attachmentId ? { ...att, loading: false, error } : att
);
}
/**
* Remove an attachment
*/
removeAttachment(attachmentId: string): void {
this._attachments = this._attachments.filter((att) => att.id !== attachmentId);
console.log(`[MCPResources] Removed attachment: ${attachmentId}`);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this._attachments = [];
console.log(`[MCPResources] Cleared all attachments`);
}
/**
* Get attachment by ID
*/
getAttachment(attachmentId: string): MCPResourceAttachment | undefined {
return this._attachments.find((att) => att.id === attachmentId);
}
/**
* Check if a resource is already attached
*/
isAttached(uri: string): boolean {
const normalizedUri = normalizeResourceUri(uri);
return this._attachments.some(
(att) => att.resource.uri === uri || normalizeResourceUri(att.resource.uri) === normalizedUri
);
}
/**
*
*
* Utility Methods
*
*
*/
/**
* Set global loading state
*/
setLoading(loading: boolean): void {
this._isLoading = loading;
}
/**
* Find resource info by URI across all servers
*/
findResourceByUri(uri: string): MCPResourceInfo | undefined {
const normalizedUri = normalizeResourceUri(uri);
for (const [serverName, serverRes] of this._serverResources) {
const resource =
serverRes.resources.find((r) => r.uri === uri) ??
serverRes.resources.find((r) => normalizeResourceUri(r.uri) === normalizedUri);
if (resource) {
return {
uri: resource.uri,
name: resource.name,
title: resource.title,
description: resource.description,
mimeType: resource.mimeType,
serverName,
annotations: resource.annotations,
icons: resource.icons
};
}
}
return undefined;
}
/**
* Find server name for a resource URI
*/
findServerForUri(uri: string): string | undefined {
for (const [serverName, serverRes] of this._serverResources) {
if (serverRes.resources.some((r) => r.uri === uri)) {
return serverName;
}
}
return undefined;
}
/**
* Clear all state (e.g., on full reset)
*/
clear(): void {
this._serverResources.clear();
this._cachedResources.clear();
this._subscriptions.clear();
this._attachments = [];
this._isLoading = false;
console.log(`[MCPResources] Cleared all state`);
}
/**
* Get resource content as text for chat context
* Formats content for inclusion in LLM prompts
*/
formatAttachmentsForContext(): string {
if (this._attachments.length === 0) return '';
const parts: string[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const serverName = attachment.resource.serverName;
for (const content of attachment.content) {
if ('text' in content && content.text) {
parts.push(`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n${content.text}`);
} else if ('blob' in content && content.blob) {
// For binary content, just note it exists
parts.push(
`\n\n--- Resource: ${resourceName} (from ${serverName}) ---\n[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
}
return parts.join('');
}
/**
* Convert current resource attachments to DatabaseMessageExtra[] for persisting with a message.
* Each attachment becomes a DatabaseMessageExtraMcpResource stored on the user message.
*/
toMessageExtras(): DatabaseMessageExtraMcpResource[] {
const extras: DatabaseMessageExtraMcpResource[] = [];
for (const attachment of this._attachments) {
if (attachment.error) continue;
if (!attachment.content || attachment.content.length === 0) continue;
const resourceName = attachment.resource.title || attachment.resource.name;
const contentParts: string[] = [];
for (const content of attachment.content) {
if ('text' in content && content.text) {
contentParts.push(content.text);
} else if ('blob' in content && content.blob) {
contentParts.push(
`[${BINARY_CONTENT_LABEL}: ${content.mimeType || RESOURCE_UNKNOWN_TYPE}]`
);
}
}
if (contentParts.length > 0) {
extras.push({
type: AttachmentType.MCP_RESOURCE,
name: resourceName,
uri: attachment.resource.uri,
serverName: attachment.resource.serverName,
content: contentParts.join(NEWLINE_SEPARATOR),
mimeType: attachment.resource.mimeType
});
}
}
return extras;
}
}
export const mcpResourceStore = new MCPResourceStore();
// Export convenience functions
export const mcpResources = () => mcpResourceStore.serverResources;
export const mcpResourceAttachments = () => mcpResourceStore.attachments;
export const mcpResourceAttachmentCount = () => mcpResourceStore.attachmentCount;
export const mcpHasResourceAttachments = () => mcpResourceStore.hasAttachments;
export const mcpTotalResourceCount = () => mcpResourceStore.totalResourceCount;
export const mcpResourcesLoading = () => mcpResourceStore.isLoading;
File diff suppressed because it is too large Load Diff
+832
View File
@@ -0,0 +1,832 @@
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
import { serverStore } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils';
import {
MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVORITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { conversationsStore } from '$lib/stores/conversations.svelte';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
*
* This store manages:
* - Available models list
* - Selected model for new conversations
* - Loaded models tracking (ROUTER mode)
* - Model usage tracking per conversation
* - Automatic unloading of unused models
*
* **Architecture & Relationships:**
* - **ModelsService**: Stateless service for model API communication
* - **PropsService**: Stateless service for props/modalities fetching
* - **modelsStore** (this class): Reactive store for model state
* - **conversationsStore**: Tracks which conversations use which models
*
* **API Inconsistency Workaround:**
* In MODEL mode, `/props` returns modalities for the single model.
* In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
* This store normalizes this behavior so consumers don't need to know the server mode.
*
* **Key Features:**
* - **MODEL mode**: Single model, always loaded
* - **ROUTER mode**: Multi-model with load/unload capability
* - **Auto-unload**: Automatically unloads models not used by any conversation
* - **Lazy loading**: ensureModelLoaded() loads models on demand
*/
class ModelsStore {
/**
*
*
* State
*
*
*/
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
loading = $state(false);
updating = $state(false);
error = $state<string | null>(null);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
// dedup concurrent fetch() callers, all awaiters share the same inflight promise
// without this, ?model=<name> URL handler raced an in-progress fetch and saw an empty list
private inflightFetch: Promise<void> | null = null;
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = new SvelteMap<string, boolean>();
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
/**
* Model-specific props cache with TTL
* Key: modelId, Value: props data including modalities
* TTL: 10 minutes - props don't change frequently
*/
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
maxEntries: MODEL_PROPS_CACHE_MAX_ENTRIES
});
private modelPropsFetching = $state<Set<string>>(new Set());
/**
* Version counter for props cache - used to trigger reactivity when props are updated
*/
propsCacheVersion = $state(0);
/**
*
*
* Computed Getters
*
*
*/
get selectedModel(): ModelOption | null {
if (!this.selectedModelId) return null;
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
}
get loadedModelIds(): string[] {
return this.routerModels
.filter(
(m) =>
m.status.value === ServerModelStatus.LOADED ||
m.status.value === ServerModelStatus.SLEEPING
)
.map((m) => m.id);
}
get loadingModelIds(): string[] {
return Array.from(this.modelLoadingStates.entries())
.filter(([, loading]) => loading)
.map(([id]) => id);
}
/**
* Get model name in MODEL mode (single model).
* Extracts from model_path or model_alias from server props.
* In ROUTER mode, returns null (model is per-conversation).
*/
get singleModelName(): string | null {
if (serverStore.isRouterMode) return null;
const props = serverStore.props;
if (props?.model_alias) return props.model_alias;
if (!props?.model_path) return null;
return props.model_path.split(/(\\|\/)/).pop() || null;
}
/**
*
*
* Modalities
*
*
*/
/**
* Get modalities for a specific model
* Returns cached modalities from model props
*/
getModelModalities(modelId: string): ModelModalities | null {
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
}
const props = this.modelPropsCache.get(modelId);
if (props?.modalities) {
return {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
}
return null;
}
/**
* Check if a model supports vision modality
*/
modelSupportsVision(modelId: string): boolean {
return this.getModelModalities(modelId)?.vision ?? false;
}
/**
* Check if a model supports audio modality
*/
modelSupportsAudio(modelId: string): boolean {
return this.getModelModalities(modelId)?.audio ?? false;
}
/**
* Get model modalities as an array of ModelModality enum values
*/
getModelModalitiesArray(modelId: string): ModelModality[] {
const modalities = this.getModelModalities(modelId);
if (!modalities) return [];
const result: ModelModality[] = [];
if (modalities.vision) result.push(ModelModality.VISION);
if (modalities.audio) result.push(ModelModality.AUDIO);
return result;
}
/**
* Get props for a specific model (from cache)
*/
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this.modelPropsCache.get(modelId);
}
/**
* Get context size (n_ctx) for a specific model from cached props
*/
getModelContextSize(modelId: string): number | null {
const props = this.getModelProps(modelId);
const nCtx = props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
/**
* Get context size for the currently selected model or null if no model is selected
*/
get selectedModelContextSize(): number | null {
if (!this.selectedModelName) return null;
return this.getModelContextSize(this.selectedModelName);
}
/**
* Check if props are being fetched for a model
*/
isModelPropsFetching(modelId: string): boolean {
return this.modelPropsFetching.has(modelId);
}
/**
*
*
* Status Queries
*
*
*/
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING ||
false
);
}
isModelOperationInProgress(modelId: string): boolean {
return this.modelLoadingStates.get(modelId) ?? false;
}
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
}
getModelUsage(modelId: string): SvelteSet<string> {
return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
}
isModelInUse(modelId: string): boolean {
const usage = this.modelUsage.get(modelId);
return usage !== undefined && usage.size > 0;
}
/**
*
*
* Data Fetching
*
*
*/
/**
* Fetch list of models from server and detect server role
* Also fetches modalities for MODEL mode (single model)
*/
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
if (this.models.length > 0 && !force) return;
this.inflightFetch = this.runFetch();
try {
await this.inflightFetch;
} finally {
this.inflightFetch = null;
}
}
private async runFetch(): Promise<void> {
this.loading = true;
this.error = null;
try {
if (!serverStore.props) {
await serverStore.fetch();
}
const response = await ModelsService.list();
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
const details = response.models?.[index];
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const displayName = this.toDisplayName(displayNameSource);
const modelId = details?.model || item.id;
return {
id: item.id,
name: displayName,
model: modelId,
description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
} satisfies ModelOption;
});
this.models = models;
// WORKAROUND: In MODEL mode, /props returns modalities for the single model,
// but /v1/models doesn't include modalities. We bridge this gap here.
const serverProps = serverStore.props;
if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
const modalities: ModelModalities = {
vision: serverProps.modalities.vision ?? false,
audio: serverProps.modalities.audio ?? false
};
this.modelPropsCache.set(this.models[0].model, serverProps);
this.models = this.models.map((model, index) =>
index === 0 ? { ...model, modalities } : model
);
}
} catch (error) {
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this.loading = false;
}
}
/**
* Fetch router models with full metadata (ROUTER mode only)
* This fetches the /models endpoint which returns status info for each model
*/
async fetchRouterModels(): Promise<void> {
try {
const response = await ModelsService.listRouter();
this.routerModels = response.data;
await this.fetchModalitiesForLoadedModels();
const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
if (o.length === 1 && this.isModelLoaded(o[0].model)) {
this.selectModelById(o[0].id);
}
} catch (error) {
console.warn('Failed to fetch router models:', error);
this.routerModels = [];
}
}
/**
* Fetch props for a specific model from /props endpoint
* Uses caching to avoid redundant requests
*
* In ROUTER mode, this will only fetch props if the model is loaded,
* since unloaded models return 400 from /props endpoint.
*
* @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed or model not loaded
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
return null;
}
if (this.modelPropsFetching.has(modelId)) return null;
this.modelPropsFetching.add(modelId);
try {
const props = await PropsService.fetchForModel(modelId);
this.modelPropsCache.set(modelId, props);
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
return null;
} finally {
this.modelPropsFetching.delete(modelId);
}
}
/**
* Fetch modalities for all loaded models from /props endpoint
* This updates the modalities field in models array
*/
async fetchModalitiesForLoadedModels(): Promise<void> {
const loadedModelIds = this.loadedModelIds;
if (loadedModelIds.length === 0) return;
const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
try {
const results = await Promise.all(propsPromises);
// Update models with modalities
this.models = this.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
const props = results[modelIndex];
if (!props?.modalities) return model;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
return { ...model, modalities };
});
this.propsCacheVersion++;
} catch (error) {
console.warn('Failed to fetch modalities for loaded models:', error);
}
}
/**
* Gets the model name from the last assistant message in the active conversation.
* Iterates backward through messages to find the most recent message with a model.
* Used by both the chat page and settings page to maintain model consistency.
* @returns The model name or null if not found
*/
getModelFromLastAssistantResponse(): string | null {
const messages = conversationsStore.activeMessages;
if (!messages || messages.length === 0) return null;
// Iterate backward to find the last message with a model
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].model) {
return messages[i].model;
}
}
return null;
}
/**
* Auto-selects the model from the last assistant response if available and loaded.
* Returns true if a model was selected, false otherwise.
* This is used by the chat page to maintain model consistency across page navigation.
*/
async selectModelFromLastAssistantResponse(): Promise<boolean> {
const lastModel = this.getModelFromLastAssistantResponse();
if (!lastModel) return false;
// Skip if already selected
if (this.selectedModelName === lastModel) return false;
const matchingModel = this.models.find((option) => option.model === lastModel);
if (!matchingModel) return false;
if (!this.isModelLoaded(lastModel)) {
console.log('[modelsStore] last assistant model not loaded:', lastModel);
return false;
}
try {
await this.selectModelById(matchingModel.id);
console.log(`[modelsStore] Automatically selected model: ${lastModel} from last message`);
return true;
} catch (error) {
console.warn('[modelsStore] Failed to automatically select model from last message:', error);
return false;
}
}
/**
* Auto-selects the first available model if none is selected, and fetches its props.
* Prioritizes:
* 1. Model from active conversation's last assistant response (if loaded)
* 2. Model from active conversation's last assistant response (if not loaded)
* 3. First loaded model (not from active conversation)
* 4. First available model
* This is used to ensure default values are populated in settings pages.
*/
async ensureFirstModelSelected(): Promise<void> {
if (this.selectedModelName) return;
// Filter models that are visible in the UI
const availableModels = this.models.filter(
(option) => this.getModelProps(option.model)?.ui !== false
);
if (availableModels.length === 0) return;
// Try to select model from last assistant response first
const lastModel = this.getModelFromLastAssistantResponse();
if (lastModel) {
const lastModelOption = availableModels.find((m) => m.model === lastModel);
if (lastModelOption) {
await this.selectModelById(lastModelOption.id);
if (this.isModelLoaded(lastModel)) {
await this.fetchModelProps(lastModel);
}
return;
}
}
// Try to find a loaded model first
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
if (loadedModel) {
await this.selectModelById(loadedModel.id);
await this.fetchModelProps(loadedModel.model);
return;
}
// Fall back to the first available model
const firstModel = availableModels[0];
await this.selectModelById(firstModel.id);
// Don't fetch props for unloaded models (will fail in ROUTER mode)
}
/**
* Update modalities for a specific model
* Called when a model is loaded or when we need fresh modality data
*/
async updateModelModalities(modelId: string): Promise<void> {
try {
const props = await this.fetchModelProps(modelId);
if (!props?.modalities) return;
const modalities: ModelModalities = {
vision: props.modalities.vision ?? false,
audio: props.modalities.audio ?? false
};
this.models = this.models.map((model) =>
model.model === modelId ? { ...model, modalities } : model
);
this.propsCacheVersion++;
} catch (error) {
console.warn(`Failed to update modalities for model ${modelId}:`, error);
}
}
/**
*
*
* Model Selection
*
*
*/
/**
* Select a model for new conversations
*/
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
if (!option) throw new Error('Selected model is not available');
this.updating = true;
this.error = null;
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
} finally {
this.updating = false;
}
}
/**
* Select a model by its model name (used for syncing with conversation model)
* @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF")
*/
selectModelByName(modelName: string): void {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
}
}
clearSelection(): void {
this.selectedModelId = null;
this.selectedModelName = null;
}
findModelByName(modelName: string): ModelOption | null {
return this.models.find((model) => model.model === modelName) ?? null;
}
findModelById(modelId: string): ModelOption | null {
return this.models.find((model) => model.id === modelId) ?? null;
}
hasModel(modelName: string): boolean {
return this.models.some((model) => model.model === modelName);
}
/**
*
*
* Loading/Unloading Models
*
*
*/
/**
* WORKAROUND: Polling for model status after load/unload operations.
*
* Currently, the `/models/load` and `/models/unload` endpoints return success
* before the operation actually completes on the server. This means an immediate
* request to `/models` returns stale status (e.g., "loading" after load request,
* "loaded" after unload request).
*
* TODO: Remove this polling once llama-server properly waits for the operation
* to complete before returning success from `/load` and `/unload` endpoints.
* At that point, a single `fetchRouterModels()` call after the operation will
* be sufficient to get the correct status.
*/
/** Polling interval in ms for checking model status */
private static readonly STATUS_POLL_INTERVAL = 500;
/**
* Poll for expected model status after load/unload operation.
* Keeps polling indefinitely until the model reaches the expected status or fails.
*
* @param modelId - Model identifier to check
* @param expectedStatus - Expected status to wait for
* @throws Error if model reaches FAILED status
*/
private async pollForModelStatus(
modelId: string,
expectedStatus: ServerModelStatus
): Promise<void> {
let attempt = 0;
while (true) {
await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId);
if (currentStatus === expectedStatus) {
return;
}
if (currentStatus === ServerModelStatus.FAILED) {
throw new Error(
`Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
);
}
if (
expectedStatus === ServerModelStatus.LOADED &&
currentStatus === ServerModelStatus.UNLOADED &&
attempt > 2
) {
throw new Error('Model was unloaded unexpectedly during loading');
}
attempt++;
await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
}
}
/**
* Load a model (ROUTER mode)
* @param modelId - Model identifier to load
*/
async loadModel(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.load(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
}
}
/**
* Unload a model (ROUTER mode)
* @param modelId - Model identifier to unload
*/
async unloadModel(modelId: string): Promise<void> {
if (!this.isModelLoaded(modelId)) {
return;
}
if (this.modelLoadingStates.get(modelId)) return;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
}
}
/**
* Ensure a model is loaded before use
* @param modelId - Model identifier to ensure is loaded
*/
async ensureModelLoaded(modelId: string): Promise<void> {
if (this.isModelLoaded(modelId)) {
return;
}
await this.loadModel(modelId);
}
/**
*
*
* Favorites
*
*
*/
isFavorite(modelId: string): boolean {
return this.favoriteModelIds.has(modelId);
}
toggleFavorite(modelId: string): void {
const next = new SvelteSet(this.favoriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favoriteModelIds = next;
try {
localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favorite models to local storage');
}
}
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favorite models from local storage');
return new Set();
}
}
/**
*
*
* Utilities
*
*
*/
private toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
clear(): void {
this.models = [];
this.routerModels = [];
this.loading = false;
this.updating = false;
this.error = null;
this.selectedModelId = null;
this.selectedModelName = null;
this.modelUsage.clear();
this.modelLoadingStates.clear();
this.modelPropsCache.clear();
this.modelPropsFetching.clear();
}
/**
* Prune expired entries from caches.
* Call periodically for proactive memory cleanup.
*/
pruneExpiredCache(): number {
return this.modelPropsCache.prune();
}
}
export const modelsStore = new ModelsStore();
export const modelOptions = () => modelsStore.models;
export const routerModels = () => modelsStore.routerModels;
export const modelsLoading = () => modelsStore.loading;
export const modelsUpdating = () => modelsStore.updating;
export const modelsError = () => modelsStore.error;
export const selectedModelId = () => modelsStore.selectedModelId;
export const selectedModelName = () => modelsStore.selectedModelName;
export const selectedModelOption = () => modelsStore.selectedModel;
export const loadedModelIds = () => modelsStore.loadedModelIds;
export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favoriteModelIds = () => modelsStore.favoriteModelIds;
@@ -0,0 +1,59 @@
import { ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
class PermissionsStore {
private _tools = $state(new SvelteSet<string>());
constructor() {
try {
const stored = localStorage.getItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY);
if (stored) {
for (const name of JSON.parse(stored) as string[]) {
if (typeof name === 'string') this._tools.add(name);
}
}
} catch (err) {
console.error(
`Failed to load permissions from localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`,
err
);
}
}
get tools(): ReadonlySet<string> {
return this._tools;
}
hasTool(key: string): boolean {
return this._tools.has(key);
}
allowTool(key: string): void {
this._tools.add(key);
this._persist();
}
allowTools(keys: string[]): void {
for (const key of keys) this._tools.add(key);
this._persist();
}
revokeTool(key: string): void {
this._tools.delete(key);
this._persist();
}
private _persist(): void {
try {
localStorage.setItem(ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY, JSON.stringify([...this._tools]));
} catch (err) {
console.error(
`Failed to persist to localStorage ("${ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY}"):`,
err
);
}
}
}
export const permissionsStore = new PermissionsStore();
@@ -0,0 +1,50 @@
import { browser } from '$app/environment';
type PersistedValue<T> = {
get value(): T;
set value(newValue: T);
};
export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
let value = initialValue;
if (browser) {
try {
const stored = localStorage.getItem(key);
if (stored !== null) {
value = JSON.parse(stored) as T;
}
} catch (error) {
console.warn(`Failed to load ${key}:`, error);
}
}
const persist = (next: T) => {
if (!browser) {
return;
}
try {
if (next === null || next === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify(next));
} catch (error) {
console.warn(`Failed to persist ${key}:`, error);
}
};
return {
get value() {
return value;
},
set value(newValue: T) {
value = newValue;
persist(newValue);
}
};
}
+158
View File
@@ -0,0 +1,158 @@
import { PropsService } from '$lib/services/props.service';
import { ServerRole } from '$lib/enums';
/**
* serverStore - Server connection state, configuration, and role detection
*
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration and role detection.
*
* **Architecture & Relationships:**
* - **PropsService**: Stateless service for fetching `/props` data
* - **serverStore** (this class): Reactive store for server state
* - **modelsStore**: Independent store for model management (uses PropsService directly)
*
* **Key Features:**
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
/**
*
*
* State
*
*
*/
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
role = $state<ServerRole | null>(null);
private fetchPromise: Promise<void> | null = null;
/**
*
*
* Getters
*
*
*/
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this.props?.default_generation_settings?.params || null;
}
get contextSize(): number | null {
const nCtx = this.props?.default_generation_settings?.n_ctx;
return typeof nCtx === 'number' ? nCtx : null;
}
get uiSettings(): Record<string, string | number | boolean> | undefined {
return this.props?.ui_settings ?? this.props?.webui_settings;
}
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
}
get isModelMode(): boolean {
return this.role === ServerRole.MODEL;
}
/**
*
*
* Data Handling
*
*
*/
async fetch(): Promise<void> {
if (this.fetchPromise) return this.fetchPromise;
this.loading = true;
this.error = null;
const fetchPromise = (async () => {
try {
const props = await PropsService.fetch();
this.props = props;
this.error = null;
this.detectRole(props);
} catch (error) {
this.error = this.getErrorMessage(error);
console.error('Error fetching server properties:', error);
} finally {
this.loading = false;
this.fetchPromise = null;
}
})();
this.fetchPromise = fetchPromise;
await fetchPromise;
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message || '';
if (error.name === 'TypeError' && message.includes('fetch')) {
return 'Server is not running or unreachable';
} else if (message.includes('ECONNREFUSED')) {
return 'Connection refused - server may be offline';
} else if (message.includes('ENOTFOUND')) {
return 'Server not found - check server address';
} else if (message.includes('ETIMEDOUT')) {
return 'Request timed out';
} else if (message.includes('503')) {
return 'Server temporarily unavailable';
} else if (message.includes('500')) {
return 'Server error - check server logs';
} else if (message.includes('404')) {
return 'Server endpoint not found';
} else if (message.includes('403') || message.includes('401')) {
return 'Access denied';
}
}
return 'Failed to connect to server';
}
clear(): void {
this.props = null;
this.error = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
}
/**
*
*
* Utilities
*
*
*/
private detectRole(props: ApiLlamaCppServerProps): void {
const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
if (this.role !== newRole) {
this.role = newRole;
console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
}
}
}
export const serverStore = new ServerStore();
export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const serverRole = () => serverStore.role;
export const defaultParams = () => serverStore.defaultParams;
export const contextSize = () => serverStore.contextSize;
export const isRouterMode = () => serverStore.isRouterMode;
export const isModelMode = () => serverStore.isModelMode;
@@ -0,0 +1,12 @@
import { SETTINGS_FALLBACK_EXIT_ROUTE } from '$lib/constants';
let _url = $state<string>(SETTINGS_FALLBACK_EXIT_ROUTE);
export const settingsReferrer = {
get url() {
return _url;
},
set url(value: string) {
_url = value;
}
};
+547
View File
@@ -0,0 +1,547 @@
/**
* settingsStore - Application configuration and theme management
*
* This store manages all application settings including AI model parameters, UI preferences,
* and theme configuration. It provides persistent storage through localStorage with reactive
* state management using Svelte 5 runes.
*
* **Architecture & Relationships:**
* - **settingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
* - Offers reactive configuration access
*
* - **ChatService**: Reads model parameters for API requests
* - **UI Components**: Subscribe to theme and configuration changes
*
* **Key Features:**
* - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
* - **Theme Management**: Auto, light, dark theme switching
* - **Persistence**: Automatic localStorage synchronization
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **Default Handling**: Graceful fallback to defaults for missing settings
* - **Batch Updates**: Efficient multi-setting updates
* - **Reset Functionality**: Restore defaults for individual or all settings
*
* **Configuration Categories:**
* - Generation parameters (temperature, tokens, sampling)
* - UI preferences (theme, display options)
* - System settings (model selection, prompts)
* - Advanced options (seed, penalties, context handling)
*/
import { browser } from '$app/environment';
import { ColorMode } from '$lib/enums';
import type { SettingsExportType } from '$lib/types';
import { setMode } from 'mode-watcher';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
SETTINGS_KEYS,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
import {
configToParameterRecord,
normalizeFloatingPoint,
getConfigValue,
setConfigValue
} from '$lib/utils';
class SettingsStore {
/**
*
*
* State
*
*
*/
config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
isInitialized = $state(false);
userOverrides = $state<Set<string>>(new Set());
/**
*
*
* Utilities (private helpers)
*
*
*/
/**
* Helper method to get server defaults with null safety
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
const serverParams = serverStore.defaultParams;
const uiSettings = serverStore.uiSettings;
return ParameterSyncService.extractServerDefaults(serverParams, uiSettings);
}
constructor() {
if (browser) {
this.initialize();
}
}
/**
*
*
* Lifecycle
*
*
*/
/**
* Initialize the settings store by loading from localStorage
*/
initialize() {
try {
this.loadConfig();
this.migrateLegacyTheme();
// Apply the persisted theme from config on initial load
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
this.isInitialized = true;
} catch (error) {
console.error('Failed to initialize settings store:', error);
}
}
/**
* Load configuration from localStorage
* Returns default values for missing keys to prevent breaking changes
*/
private loadConfig() {
if (!browser) return;
try {
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
this.config = {
...SETTING_CONFIG_DEFAULT,
...savedVal
};
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (new IsMobile().current) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
}
}
// Load user overrides
const savedOverrides = JSON.parse(
localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
);
this.userOverrides = new Set(savedOverrides);
} catch (error) {
console.warn('Failed to parse config from localStorage, using defaults:', error);
this.config = { ...SETTING_CONFIG_DEFAULT };
this.userOverrides = new Set();
}
}
/**
* Migrate the legacy un-namespaced "theme" localStorage key into config.
* Previously theme was stored separately in localStorage("theme") — now it lives
* inside the config object alongside all other settings.
* After migration the legacy key is removed.
*/
private migrateLegacyTheme() {
if (!browser) return;
const legacyTheme = localStorage.getItem('theme');
if (legacyTheme) {
this.config[SETTINGS_KEYS.THEME] = legacyTheme;
localStorage.removeItem('theme');
this.saveConfig();
setMode(legacyTheme as ColorMode);
}
}
/**
*
*
* Config Updates
*
*
*/
/**
* Update a specific configuration setting
* @param key - The configuration key to update
* @param value - The new value for the configuration key
*/
updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
this.config[key] = value;
if (ParameterSyncService.canSyncParameter(key as string)) {
const propsDefaults = this.getServerDefaults();
const propsDefault = propsDefaults[key as string];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key as string);
} else {
this.userOverrides.add(key as string);
}
}
}
this.saveConfig();
}
/**
* Update multiple configuration settings at once
* @param updates - Object containing the configuration updates
*/
updateMultipleConfig(updates: Partial<SettingsConfigType>) {
Object.assign(this.config, updates);
const propsDefaults = this.getServerDefaults();
for (const [key, value] of Object.entries(updates)) {
if (ParameterSyncService.canSyncParameter(key)) {
const propsDefault = propsDefaults[key];
if (propsDefault !== undefined) {
const normalizedValue = normalizeFloatingPoint(value);
const normalizedDefault = normalizeFloatingPoint(propsDefault);
if (normalizedValue === normalizedDefault) {
this.userOverrides.delete(key);
} else {
this.userOverrides.add(key);
}
}
}
}
this.saveConfig();
}
/**
* Save the current configuration to localStorage
*/
private saveConfig() {
if (!browser) return;
try {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
}
/**
* Update the theme setting.
* @param newTheme - The new theme value
*/
updateTheme(newTheme: string) {
this.updateConfig(SETTINGS_KEYS.THEME, newTheme);
setMode(newTheme as ColorMode);
}
/**
*
*
* Reset
*
*
*/
/**
* Reset configuration to defaults
*/
resetConfig() {
this.config = { ...SETTING_CONFIG_DEFAULT };
this.saveConfig();
}
/**
* Reset theme to default value.
* Theme is now stored inside the config object.
*/
resetTheme() {
this.updateConfig(SETTINGS_KEYS.THEME, SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME]);
setMode(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.THEME] as ColorMode);
}
/**
* Reset all settings to defaults.
*/
resetAll() {
this.resetConfig();
this.resetTheme();
}
/**
* Reset a parameter to Server default (or UI default if no Server default)
*/
resetParameterToServerDefault(key: string): void {
const serverDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (serverDefaults[key] !== undefined) {
// sampling param known by server: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
this.saveConfig();
}
/**
*
*
* Server Sync
*
*
*/
/**
* Initialize settings with props defaults when server properties are first loaded
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
if (Object.keys(propsDefaults).length === 0) return;
const uiSettings = serverStore.uiSettings;
const uiSettingsKeys = new Set(uiSettings ? Object.keys(uiSettings) : []);
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
const normalizedCurrent = normalizeFloatingPoint(currentValue);
const normalizedDefault = normalizeFloatingPoint(propsValue);
// if user value matches server, it's not a real override
if (normalizedCurrent === normalizedDefault) {
this.userOverrides.delete(key);
if (!uiSettingsKeys.has(key) && getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined) {
setConfigValue(this.config, key, undefined);
}
}
}
// UI settings need actual values in config (no placeholder mechanism),
// so write them for non-overridden keys
if (uiSettings) {
for (const [key, value] of Object.entries(uiSettings)) {
if (!this.userOverrides.has(key) && value !== undefined) {
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}
}
this.saveConfig();
console.log('User overrides after sync:', Array.from(this.userOverrides));
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
* Prioritizes Server defaults from /props, falls back to UI defaults
*/
forceSyncWithServerDefaults(): void {
const propsDefaults = this.getServerDefaults();
const uiSettings = serverStore.uiSettings;
for (const key of ParameterSyncService.getSyncableParameterKeys()) {
if (uiSettings && key in uiSettings) {
// UI setting from admin config: write actual value
setConfigValue(this.config, key, uiSettings[key]);
} else if (propsDefaults[key] !== undefined) {
// sampling param: clear it, let server decide
setConfigValue(this.config, key, '');
} else if (key in SETTING_CONFIG_DEFAULT) {
setConfigValue(this.config, key, getConfigValue(SETTING_CONFIG_DEFAULT, key));
}
this.userOverrides.delete(key);
}
this.saveConfig();
}
/**
*
*
* Utilities
*
*
*/
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
canSyncParameter(key: string): boolean {
return ParameterSyncService.canSyncParameter(key);
}
/**
* Get parameter information including source for a specific parameter
*/
getParameterInfo(key: string) {
const propsDefaults = this.getServerDefaults();
const currentValue = getConfigValue(this.config, key);
return ParameterSyncService.getParameterInfo(
key,
currentValue ?? '',
propsDefaults,
this.userOverrides
);
}
/**
* Get diff between current settings and server defaults
*/
getParameterDiff() {
const serverDefaults = this.getServerDefaults();
if (Object.keys(serverDefaults).length === 0) return {};
const configAsRecord = configToParameterRecord(
this.config,
ParameterSyncService.getSyncableParameterKeys()
);
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
*
*
* Import / Export
*
*
*/
/**
* Export all settings as a versioned JSON-compatible object.
* The export captures the full config (excluding sensitive values like API key)
* and user overrides. Sensitive fields are filtered out for security by default.
* @param includeSensitiveData - If true, include sensitive fields (apiKey, MCP server headers) in export
*/
exportSettings(includeSensitiveData: boolean = false): SettingsExportType {
// Build config excluding sensitive data unless user opts in
const configToExport: Record<string, string | number | boolean | undefined> =
includeSensitiveData
? { ...this.config }
: Object.fromEntries(Object.entries(this.config).filter(([key]) => key !== 'apiKey'));
// Handle MCP servers: exclude custom headers unless user opts in
if ('mcpServers' in configToExport && !includeSensitiveData) {
try {
const mcpServers = JSON.parse(configToExport.mcpServers as string) as Array<
Record<string, unknown>
>;
const safeServers = mcpServers.map((server) => {
delete server.headers;
return server;
});
configToExport.mcpServers = JSON.stringify(safeServers);
} catch {
// If parsing fails, just exclude the entire mcpServers field
delete (configToExport as Record<string, unknown>).mcpServers;
}
}
return {
version: 1,
timestamp: Date.now(),
config: configToExport,
userOverrides: Array.from(this.userOverrides)
};
}
/**
* Import settings from a previously exported object.
* Restores config (including theme) and user overrides.
* @param data - The exported settings object
*/
importSettings(data: SettingsExportType): void {
if (!browser) return;
if (!data || !data.config) {
throw new Error('Invalid settings data: missing config');
}
// Restore config (theme is included in config)
this.config = {
...SETTING_CONFIG_DEFAULT,
...data.config
};
// Restore user overrides (derived state — may be stale if server defaults differ)
this.userOverrides = new Set(data.userOverrides ?? []);
// Persist to localStorage
this.saveConfig();
// Apply theme for immediate visual feedback
setMode(this.config[SETTINGS_KEYS.THEME] as ColorMode);
console.log('Settings imported successfully');
}
}
export const settingsStore = new SettingsStore();
export const config = () => settingsStore.config;
export const theme = () => settingsStore.config[SETTINGS_KEYS.THEME];
export const isInitialized = () => settingsStore.isInitialized;
+427
View File
@@ -0,0 +1,427 @@
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { ToolsService } from '$lib/services/tools.service';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import {
DISABLED_TOOLS_LOCALSTORAGE_KEY,
TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS
} from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
constructor() {
try {
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const name of parsed) {
if (typeof name === 'string') this._disabledTools.add(name);
}
}
}
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
// Initialize builtin tools on startup
this.fetchBuiltinTools();
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOLS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
get builtinTools(): OpenAIToolDefinition[] {
return this._builtinTools;
}
get mcpTools(): OpenAIToolDefinition[] {
return mcpStore.getToolDefinitionsForLLM();
}
get customTools(): OpenAIToolDefinition[] {
const raw = config().custom;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
/** Flat list of all tool entries with source metadata */
get allTools(): ToolEntry[] {
const entries: ToolEntry[] = [];
for (const def of this._builtinTools) {
entries.push({ source: ToolSource.BUILTIN, definition: def });
}
// Use live connections when available (full schema), fall back to health check data
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: JsonSchemaType.OBJECT,
properties: {},
required: []
};
entries.push({
source: ToolSource.MCP,
serverName,
serverId,
definition: {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
}
});
}
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
entries.push({
source: ToolSource.MCP,
serverName,
serverId,
definition: {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: {
type: JsonSchemaType.OBJECT,
properties: {},
required: []
}
}
}
});
}
}
}
for (const def of this.customTools) {
entries.push({ source: ToolSource.CUSTOM, definition: def });
}
return entries;
}
/** Tools grouped by category for tree display */
get toolGroups(): ToolGroup[] {
const groups: ToolGroup[] = [];
if (this._builtinTools.length > 0) {
groups.push({
source: ToolSource.BUILTIN,
label: TOOL_GROUP_LABELS[ToolSource.BUILTIN],
tools: this._builtinTools
});
}
// Use live connections when available, fall back to health check data
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
if (connection.tools.length === 0) continue;
const label = mcpStore.getServerDisplayName(serverId);
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: JsonSchemaType.OBJECT,
properties: {},
required: []
};
return {
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
};
});
groups.push({ source: ToolSource.MCP, label, serverId, tools });
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
if (tools.length === 0) continue;
const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
type: ToolCallType.FUNCTION,
function: {
name: tool.name,
description: tool.description,
parameters: { type: JsonSchemaType.OBJECT, properties: {}, required: [] }
}
}));
groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
}
}
const custom = this.customTools;
if (custom.length > 0) {
groups.push({
source: ToolSource.CUSTOM,
label: TOOL_GROUP_LABELS[ToolSource.CUSTOM],
tools: custom
});
}
return groups;
}
/** Only enabled tool definitions (for sending to the API) */
get enabledToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools
.filter((t) => !this._disabledTools.has(t.definition.function.name))
.map((t) => t.definition);
}
/**
* Returns enabled tool definitions for sending to the LLM.
* MCP tools use properly normalized schemas from mcpStore.
* Filters out tools disabled via the UI checkboxes.
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
const disabled = this._disabledTools;
const result: OpenAIToolDefinition[] = [];
for (const tool of this._builtinTools) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
// MCP tools with properly normalized schemas
for (const tool of mcpStore.getToolDefinitionsForLLM()) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
for (const tool of this.customTools) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
return result;
}
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
isToolEnabled(toolName: string): boolean {
return !this._disabledTools.has(toolName);
}
toggleTool(toolName: string): void {
if (this._disabledTools.has(toolName)) {
this._disabledTools.delete(toolName);
} else {
this._disabledTools.add(toolName);
}
this.persistDisabledTools();
}
setToolEnabled(toolName: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(toolName);
} else {
this._disabledTools.add(toolName);
}
}
/**
* Enable all tools belonging to a specific MCP server.
* Called when a server is enabled for a conversation.
*/
enableAllToolsForServer(serverId: string): void {
const connection = mcpStore.getConnections().get(serverId);
if (!connection) return;
for (const tool of connection.tools) {
this._disabledTools.delete(tool.name);
}
this.persistDisabledTools();
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
for (const tool of group.tools) {
this.setToolEnabled(tool.function.name, !allEnabled);
}
this.persistDisabledTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
}
isGroupPartiallyEnabled(group: ToolGroup): boolean {
const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
return enabledCount > 0 && enabledCount < group.tools.length;
}
/**
* Get MCP tools from health check data (reactive).
* Used when live connections aren't established yet.
*/
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) {
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
/** Determine the source of a tool by its name. */
getToolSource(toolName: string): ToolSource | null {
if (this._builtinTools.some((t) => t.function.name === toolName)) {
return ToolSource.BUILTIN;
}
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
return entry.source;
}
}
return null;
}
/** Get the display label for the server that owns a given tool. */
getToolServerLabel(toolName: string): string {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
if (entry.serverName) {
return mcpStore.getServerDisplayName(entry.serverName);
}
if (entry.source === ToolSource.BUILTIN) {
return TOOL_SERVER_LABELS[ToolSource.BUILTIN];
}
if (entry.source === ToolSource.CUSTOM) {
return TOOL_SERVER_LABELS[ToolSource.CUSTOM];
}
}
}
return '';
}
/** Build a permission key with category prefix, e.g. "mcp-<serverId>:tool_name" */
getPermissionKey(toolName: string): string | null {
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
switch (entry.source) {
case ToolSource.BUILTIN:
return `builtin:${toolName}`;
case ToolSource.CUSTOM:
return `custom:${toolName}`;
case ToolSource.MCP:
if (entry.serverId) {
return `mcp-${entry.serverId}:${toolName}`;
}
return `mcp:${toolName}`;
default:
return null;
}
}
}
return null;
}
/** Check if there are any enabled tools available (builtin, MCP, or custom). */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
async fetchBuiltinTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
this._toolsEndpointUnreachable = false;
try {
const toolInfos = await ToolsService.list();
this._builtinTools = toolInfos.map((info) => info.definition);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this._error = errorMessage;
// 404 from /tools means the server was started without --tools
if (errorMessage.includes('404') || errorMessage.toLowerCase().includes('not found')) {
this._toolsEndpointUnreachable = true;
}
console.error('[ToolsStore] Failed to fetch built-in tools:', err);
} finally {
this._loading = false;
}
}
}
export const toolsStore = new ToolsStore();
export const allTools = () => toolsStore.allTools;
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
export const toolGroups = () => toolsStore.toolGroups;