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:
co-authored by
Xuan Son Nguyen
parent
49d1701bd2
commit
59778f0196
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
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user