]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Refactor models store, MCP service, and gate logs behind VITE_DEBUG (#23236)
authorAleksander Grygier <redacted>
Mon, 18 May 2026 14:09:40 +0000 (16:09 +0200)
committerGitHub <redacted>
Mon, 18 May 2026 14:09:40 +0000 (16:09 +0200)
* refactor: Scope console logs to `DEV` + `VITE_DEBUG` env vars

* refactor: skip MCP proxy probe when no server requires it

* refactor: suppress expected disconnect errors during MCP client shutdown

* refactor: Deduplicate requests

* refactor: deduplicate model fetching across ROUTER and MODEL modes

* refactor: Clean up models logic

* chore: Add `.env.example` file

* refactor: replace client-side CORS proxy probe with server status flag

* refactor: Post-review fixes

* test: add vitest client setup with API fetch mocks

17 files changed:
tools/server/server-context.cpp
tools/server/server-models.cpp
tools/ui/.env.example [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte
tools/ui/src/lib/hooks/use-models-selector.svelte.ts
tools/ui/src/lib/services/mcp.service.ts
tools/ui/src/lib/services/migration.service.ts
tools/ui/src/lib/stores/mcp.svelte.ts
tools/ui/src/lib/stores/models.svelte.ts
tools/ui/src/lib/types/api.d.ts
tools/ui/src/lib/utils/api-key-validation.ts
tools/ui/src/lib/utils/legacy-migration.ts
tools/ui/src/routes/(chat)/+page.svelte
tools/ui/src/routes/+layout.svelte
tools/ui/tests/client/page.svelte.test.ts
tools/ui/vitest-setup-client.ts

index 0f3fb9efa3cacca52c4a0970b3626edac8c77d49..6b16c6b496275ca7c9987a9c7baf84a1ee1ba84a 100644 (file)
@@ -3885,6 +3885,7 @@ void server_routes::init_routes() {
             { "eos_token",                   meta->eos_token_str },
             { "build_info",                  meta->build_info },
             { "is_sleeping",                 queue_tasks.is_sleeping() },
+            { "cors_proxy_enabled",          params.ui_mcp_proxy || params.webui_mcp_proxy },
         };
         if (params.use_jinja) {
             if (!tmpl_tools.empty()) {
index 6c6fed52d58e94757678532f1ff82a5e37c014a2..ccf42320f77a0daeb0375727ca0711999c44fb8c 100644 (file)
@@ -1165,6 +1165,7 @@ void server_models_routes::init_routes() {
                 // Deprecated: use ui_settings instead (kept for backward compat)
                 {"webui_settings",  webui_settings},
                 {"build_info",     std::string(llama_build_info())},
+                {"cors_proxy_enabled", params.ui_mcp_proxy || params.webui_mcp_proxy},
             });
             return res;
         }
diff --git a/tools/ui/.env.example b/tools/ui/.env.example
new file mode 100644 (file)
index 0000000..9a995b7
--- /dev/null
@@ -0,0 +1,2 @@
+VITE_PUBLIC_APP_NAME='llama-ui'
+# VITE_DEBUG='true'
index 2f9471e0ddda4d9d732f61288c9611bb180d3849..2970206056523c8242a641cb4da3dfc0a7a61e04 100644 (file)
@@ -7,7 +7,6 @@
        import { activeMessages } from '$lib/stores/conversations.svelte';
 
        interface Props {
-               currentModel?: string;
                disabled?: boolean;
                forceForegroundText?: boolean;
                hasAudioModality?: boolean;
@@ -20,7 +19,6 @@
        }
 
        let {
-               currentModel,
                disabled = false,
                forceForegroundText = false,
                hasAudioModality = $bindable(false),
 
        let lastSyncedConversationModel: string | null = null;
 
+       let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
+
        $effect(() => {
                if (conversationModel && conversationModel !== lastSyncedConversationModel) {
-                       lastSyncedConversationModel = conversationModel;
+                       if (modelOptions().some((m) => m.model === conversationModel)) {
+                               modelsStore.selectedModelName = conversationModel;
+                               modelsStore.selectModelByName(conversationModel);
+                       } else {
+                               modelsStore.selectedModelName = null;
+                               modelsStore.clearSelection();
+                       }
 
-                       modelsStore.selectModelByName(conversationModel);
-               } else if (isRouter && !modelsStore.selectedModelId && modelsStore.loadedModelIds.length > 0) {
+                       lastSyncedConversationModel = conversationModel;
+               } else if (
+                       isRouter &&
+                       !modelsStore.selectedModelId &&
+                       modelsStore.loadedModelIds.length > 0 &&
+                       activeMessages().length > 0 &&
+                       !conversationModel
+               ) {
                        lastSyncedConversationModel = null;
-                       // auto-select the first loaded model only when nothing is selected yet
+
                        const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
 
                        if (first) modelsStore.selectModelById(first.id);
        <ModelsSelectorSheet
                disabled={disabled || isOffline}
                bind:this={selectorModelRef}
-               {currentModel}
+               currentModel={selectorModel}
                {forceForegroundText}
                {useGlobalSelection}
        />
        <ModelsSelectorDropdown
                disabled={disabled || isOffline}
                bind:this={selectorModelRef}
-               {currentModel}
+               currentModel={selectorModel}
                {forceForegroundText}
                {useGlobalSelection}
        />
index 567fdac4704f31a316be1978dbff7f6b282e4e63..ff734ac88fbc60fac6e8644d34add6e598477d26 100644 (file)
                        return;
                }
 
-               if (import.meta.env.DEV) {
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                        console.log('[ChatFormPickerMcpPrompts] Fetching completions for:', {
                                serverName: selectedPrompt.serverName,
                                promptName: selectedPrompt.name,
                                value
                        );
 
-                       if (import.meta.env.DEV) {
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                                console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', {
                                        argName,
                                        value,
index 537a2af1841e6436f9835d82c6dc13086bf24fb2..098cb2c27aaf2e071987e6014a4122cb84d8341a 100644 (file)
@@ -66,7 +66,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
        const serverModel = $derived(singleModelName());
 
        const currentModel = $derived(opts.currentModel());
-       const useGlobalSelection = $derived(opts.useGlobalSelection?.() ?? false);
        const onModelChange = $derived(opts.onModelChange?.());
 
        const isHighlightedCurrentModelActive = $derived.by(() => {
@@ -128,6 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
 
                if (onModelChange) {
                        const result = await onModelChange(option.id, option.model);
+
                        if (result === false) {
                                shouldCloseMenu = false;
                        }
@@ -142,12 +142,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                                const textarea = document.querySelector<HTMLTextAreaElement>(
                                        '[data-slot="chat-form"] textarea'
                                );
+
                                textarea?.focus();
                        });
                }
 
                if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
                        isLoadingModel = true;
+
                        modelsStore
                                .loadModel(option.model)
                                .catch((error) => console.error('Failed to load model:', error))
@@ -158,6 +160,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
        function getDisplayOption(): ModelOption | undefined {
                if (!isRouter) {
                        const displayModel = serverModel || currentModel;
+
                        if (displayModel) {
                                return {
                                        id: serverModel ? 'current' : 'offline-current',
@@ -166,12 +169,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                                        capabilities: []
                                };
                        }
-                       return undefined;
-               }
 
-               if (useGlobalSelection && activeId) {
-                       const selected = options.find((option) => option.id === activeId);
-                       if (selected) return selected;
+                       return undefined;
                }
 
                if (currentModel) {
@@ -183,6 +182,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                                        capabilities: []
                                };
                        }
+
                        return options.find((option) => option.model === currentModel);
                }
 
@@ -197,57 +197,77 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
                get options() {
                        return options;
                },
+
                get loading() {
                        return loading;
                },
+
                get updating() {
                        return updating;
                },
+
                get activeId() {
                        return activeId;
                },
+
                get isRouter() {
                        return isRouter;
                },
+
                get serverModel() {
                        return serverModel;
                },
+
                get isHighlightedCurrentModelActive() {
                        return isHighlightedCurrentModelActive;
                },
+
                get isCurrentModelInCache() {
                        return isCurrentModelInCache;
                },
+
                get filteredOptions() {
                        return filteredOptions;
                },
+
                get groupedFilteredOptions() {
                        return groupedFilteredOptions;
                },
+
                get isLoadingModel() {
                        return isLoadingModel;
                },
+
                get searchTerm() {
                        return searchTerm;
                },
+
                get showModelDialog() {
                        return showModelDialog;
                },
+
                get infoModelId() {
                        return infoModelId;
                },
+
                setSearchTerm(value: string) {
                        searchTerm = value;
                },
+
                setShowModelDialog(value: boolean) {
                        showModelDialog = value;
                },
+
                handleInfoClick,
+
                handleSelect,
+
                handleOpenChange,
+
                isFavorite(model: string) {
                        return modelsStore.favoriteModelIds.has(model);
                },
+
                getDisplayOption
        };
 }
index 44cbd4a8aaf23e1b7040a0df6350547613916363..d596381aa055c3e53256a85a2a950d64f0d1f845 100644 (file)
@@ -392,7 +392,7 @@ export class MCPService {
 
                        const url = new URL(config.url);
 
-                       if (import.meta.env.DEV) {
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                                console.log(`[MCPService] Creating WebSocket transport for ${url.href}`);
                        }
 
@@ -413,12 +413,12 @@ export class MCPService {
                        onLog
                );
 
-               if (useProxy && import.meta.env.DEV) {
+               if (useProxy && import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                        console.log(`[MCPService] Using CORS proxy for ${config.url} -> ${url.href}`);
                }
 
                try {
-                       if (import.meta.env.DEV) {
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                                console.log(`[MCPService] Creating StreamableHTTP transport for ${url.href}`);
                        }
 
@@ -520,7 +520,7 @@ export class MCPService {
                        )
                );
 
-               if (import.meta.env.DEV) {
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
                        console.log(`[MCPService][${serverName}] Creating transport...`);
                }
 
@@ -560,6 +560,22 @@ export class MCPService {
                );
 
                const runtimeErrorHandler = (error: Error) => {
+                       // Ignore errors that are expected when the SDK's transport is closed,
+                       // or when connecting to servers that don't support SSE (stateless-only
+                       // endpoints returning 405). The SDK wraps the original AbortError in
+                       // a new Error with the message "SSE stream disconnected: AbortError",
+                       // and also produces "Cannot cancel a stream locked by a reader".
+                       // DOMException is thrown by the browser when aborting fetch requests.
+                       const msg = error.message || String(error);
+                       if (
+                               error.name === 'AbortError' ||
+                               error instanceof DOMException ||
+                               msg.includes('SSE stream disconnected') ||
+                               msg.includes('stream locked by a reader') ||
+                               msg.includes('The operation was aborted')
+                       ) {
+                               return;
+                       }
                        console.error(`[MCPService][${serverName}] Protocol error after initialize:`, error);
                };
 
@@ -658,7 +674,10 @@ export class MCPService {
                        this.createLog(MCPConnectionPhase.LISTING_TOOLS, 'Listing available tools...')
                );
 
-               console.log(`[MCPService][${serverName}] Connected, listing tools...`);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                       console.log(`[MCPService][${serverName}] Connected, listing tools...`);
+               }
+
                const tools = await this.listTools({
                        client,
                        transport,
@@ -680,10 +699,11 @@ export class MCPService {
                                `Connection established with ${tools.length} tools (${connectionTimeMs}ms)`
                        )
                );
-
-               console.log(
-                       `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
-               );
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                       console.log(
+                               `[MCPService][${serverName}] Initialization complete with ${tools.length} tools in ${connectionTimeMs}ms`
+                       );
+               }
 
                return {
                        client,
@@ -709,9 +729,22 @@ export class MCPService {
         * @param connection - The active MCP connection to close
         */
        static async disconnect(connection: MCPConnection): Promise<void> {
-               console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                       console.log(`[MCPService][${connection.serverName}] Disconnecting...`);
+               }
+
                try {
-                       // Prevent reconnection on voluntary disconnect
+                       // Terminate the session first for streamable-http transports to cleanly
+                       // close streams, matching the inspector's disconnect flow.
+                       if (connection.transport instanceof StreamableHTTPClientTransport) {
+                               await connection.transport.terminateSession();
+                       }
+
+                       // Clear error handlers before closing to prevent noise from expected
+                       // abort errors during shutdown. The inspector avoids this entirely
+                       // by not setting onerror, but since we use it for protocol logging,
+                       // we must clear it before disconnect.
+                       connection.client.onerror = undefined;
                        if (connection.transport.onclose) {
                                connection.transport.onclose = undefined;
                        }
@@ -1078,7 +1111,9 @@ export class MCPService {
                try {
                        await connection.client.unsubscribeResource({ uri });
 
-                       console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                               console.log(`[MCPService][${connection.serverName}] Unsubscribed from resource: ${uri}`);
+                       }
                } catch (error) {
                        console.error(
                                `[MCPService][${connection.serverName}] Failed to unsubscribe from resource:`,
index 5ed24c00d9267a47e2a88c3b46d776c3d0230e8a..35d47070acfb4b515625119bfd9ffc75f11dfa5d 100644 (file)
@@ -119,7 +119,8 @@ const localStorageMigration: Migration = {
                        // Only migrate if new key doesn't already exist
                        const newValue = localStorage.getItem(newKey);
                        if (newValue !== null) {
-                               console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
+                               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                                       console.log(`[Migration] localStorage: ${newKey} already exists, skipping`);
                                continue;
                        }
 
@@ -127,9 +128,11 @@ const localStorageMigration: Migration = {
                        if (oldValue !== null) {
                                localStorage.setItem(newKey, oldValue);
                                // Keep old key for downgrade compatibility - DO NOT DELETE
-                               console.log(
-                                       `[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
-                               );
+                               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                                       console.log(
+                                               `[Migration] localStorage: copied ${deprecatedKey} → ${newKey} (preserved old)`
+                                       );
+                               }
                        }
                }
        }
@@ -146,7 +149,8 @@ const idxdbMigration: Migration = {
        async run(): Promise<void> {
                const oldDbNames = await Dexie.getDatabaseNames();
                if (!oldDbNames.includes(DB_APP_NAME_DEPRECATED)) {
-                       console.log('[Migration] IndexedDB: no old database found, skipping');
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] IndexedDB: no old database found, skipping');
                        return;
                }
 
@@ -155,11 +159,13 @@ const idxdbMigration: Migration = {
                newDb.version(1).stores(IDXDB_STORES);
                const existingConvs = await newDb.table(IDXDB_TABLES.conversations).count();
                if (existingConvs > 0) {
-                       console.log('[Migration] IndexedDB: new database already has data, skipping');
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] IndexedDB: new database already has data, skipping');
                        return;
                }
 
-               console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] IndexedDB: copying from', DB_APP_NAME_DEPRECATED);
 
                const oldDb = new Dexie(DB_APP_NAME_DEPRECATED);
                oldDb.version(1).stores(IDXDB_STORES);
@@ -169,15 +175,18 @@ const idxdbMigration: Migration = {
 
                if (conversations.length > 0) {
                        await newDb.table(IDXDB_TABLES.conversations).bulkAdd(conversations);
-                       console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log(`[Migration] IndexedDB: copied ${conversations.length} conversations`);
                }
                if (messages.length > 0) {
                        await newDb.table(IDXDB_TABLES.messages).bulkAdd(messages);
-                       console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log(`[Migration] IndexedDB: copied ${messages.length} messages`);
                }
 
                // Non-destructive: DO NOT delete old database - keep for downgrade compatibility
-               console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] IndexedDB: preserved old database for downgrade compatibility');
        }
 };
 
@@ -419,7 +428,8 @@ const legacyMessageMigration: Migration = {
                        }
                }
 
-               console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log(`[Migration] Legacy messages: migrated ${migratedCount} messages`);
        }
 };
 
@@ -434,7 +444,8 @@ const themeMigration: Migration = {
        async run(): Promise<void> {
                const legacyTheme = localStorage.getItem('theme');
                if (legacyTheme === null) {
-                       console.log('[Migration] Theme: no legacy theme key found, skipping');
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] Theme: no legacy theme key found, skipping');
                        return;
                }
 
@@ -443,7 +454,8 @@ const themeMigration: Migration = {
                const config = configRaw ? JSON.parse(configRaw) : {};
 
                if (SETTINGS_KEYS.THEME in config) {
-                       console.log('[Migration] Theme: config already has theme, skipping');
+                       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                               console.log('[Migration] Theme: config already has theme, skipping');
                        return;
                }
 
@@ -451,7 +463,8 @@ const themeMigration: Migration = {
                localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
 
                // Non-destructive: DO NOT delete legacy theme key - keep for downgrade compatibility
-               console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log(`[Migration] Theme: copied standalone theme to config (preserved old key)`);
        }
 };
 
@@ -491,7 +504,8 @@ export const MigrationService = {
         */
        resetState(): void {
                localStorage.removeItem(MIGRATION_STATE_KEY);
-               console.log('[Migration] State reset - all migrations will run again');
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] State reset - all migrations will run again');
        },
 
        /**
@@ -500,25 +514,30 @@ export const MigrationService = {
         */
        async runAllMigrations(): Promise<void> {
                const state = getMigrationState();
-               console.log('[Migration] Starting migration run, state:', state);
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] Starting migration run, state:', state);
 
                for (const migration of migrations) {
                        if (isMigrationCompleted(migration.id)) {
-                               console.log(`[Migration] ${migration.id}: already completed, skipping`);
+                               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                                       console.log(`[Migration] ${migration.id}: already completed, skipping`);
                                continue;
                        }
 
                        try {
-                               console.log(`[Migration] ${migration.id}: running...`);
+                               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                                       console.log(`[Migration] ${migration.id}: running...`);
                                await migration.run();
                                markMigrationCompleted(migration.id);
-                               console.log(`[Migration] ${migration.id}: completed successfully`);
+                               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                                       console.log(`[Migration] ${migration.id}: completed successfully`);
                        } catch (error) {
                                console.error(`[Migration] ${migration.id}: failed`, error);
                                markMigrationFailed(migration.id);
                        }
                }
 
-               console.log('[Migration] All migrations complete');
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+                       console.log('[Migration] All migrations complete');
        }
 };
index 8fb306da881c0f2b2e7c19286333e2021bee1e26..effb78e339b07304e5e1758b59167525f33d6304 100644 (file)
  */
 
 import { browser } from '$app/environment';
-import { base } from '$app/paths';
 import { SETTINGS_KEYS } from '$lib/constants';
 import { MCPService } from '$lib/services/mcp.service';
 import { config, settingsStore } from '$lib/stores/settings.svelte';
 import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
+import { serverStore } from '$lib/stores/server.svelte';
 import { mode } from 'mode-watcher';
 import {
        parseMcpServerSettings,
@@ -43,7 +43,6 @@ import {
        ToolCallType
 } from '$lib/enums';
 import {
-       CORS_PROXY_ENDPOINT,
        DEFAULT_CACHE_TTL_MS,
        DEFAULT_MCP_CONFIG,
        EXPECTED_THEMED_ICON_PAIR_COUNT,
@@ -86,7 +85,6 @@ class MCPStore {
        private _toolCount = $state(0);
        private _connectedServers = $state<string[]>([]);
        private _healthChecks = $state<Record<string, HealthCheckState>>({});
-       private _proxyAvailable = $state(false);
 
        private connections = new Map<string, MCPConnection>();
        private toolsIndex = new Map<string, string>();
@@ -96,27 +94,8 @@ class MCPStore {
        private initPromise: Promise<boolean> | null = null;
        private activeFlowCount = 0;
 
-       constructor() {
-               if (browser) {
-                       this.probeProxy();
-               }
-       }
-
-       /**
-        * Probes the CORS proxy endpoint to determine availability.
-        * The endpoint is only registered when llama-server runs with --ui-mcp-proxy.
-        */
-       async probeProxy(): Promise<void> {
-               try {
-                       const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
-                       this._proxyAvailable = response.status !== 404;
-               } catch {
-                       this._proxyAvailable = false;
-               }
-       }
-
        get isProxyAvailable(): boolean {
-               return this._proxyAvailable;
+               return serverStore.props?.cors_proxy_enabled ?? false;
        }
 
        /**
index 45981b38fa07de97c91bc60dc07b659b7cdb2223..bc99d7412cd37805be71584337ad947c88cba952 100644 (file)
@@ -3,7 +3,7 @@ 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 { serverStore, isRouterMode } from '$lib/stores/server.svelte';
 import { TTLCache } from '$lib/utils';
 import {
        MODEL_PROPS_CACHE_TTL_MS,
@@ -14,14 +14,7 @@ import {
 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
+ * modelsStore - Reactive store for model management in both MODEL and ROUTER modes.
  *
  * **Architecture & Relationships:**
  * - **ModelsService**: Stateless service for model API communication
@@ -31,14 +24,8 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
  *
  * **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.
+ * 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 {
        /**
@@ -57,8 +44,8 @@ class ModelsStore {
        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
+       // Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
+       // Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
        private inflightFetch: Promise<void> | null = null;
 
        private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
@@ -67,9 +54,9 @@ class ModelsStore {
        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
+        * 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,
@@ -78,7 +65,7 @@ class ModelsStore {
        private modelPropsFetching = $state<Set<string>>(new Set());
 
        /**
-        * Version counter for props cache - used to trigger reactivity when props are updated
+        * Version counter for props cache — used to trigger reactivity when props are updated.
         */
        propsCacheVersion = $state(0);
 
@@ -92,7 +79,7 @@ class ModelsStore {
 
        get selectedModel(): ModelOption | null {
                if (!this.selectedModelId) return null;
-               return this.models.find((model) => model.id === this.selectedModelId) ?? null;
+               return this.models.find((m) => m.id === this.selectedModelId) ?? null;
        }
 
        get loadedModelIds(): string[] {
@@ -117,7 +104,7 @@ class ModelsStore {
         * In ROUTER mode, returns null (model is per-conversation).
         */
        get singleModelName(): string | null {
-               if (serverStore.isRouterMode) return null;
+               if (isRouterMode()) return null;
 
                const props = serverStore.props;
                if (props?.model_alias) return props.model_alias;
@@ -126,6 +113,11 @@ class ModelsStore {
                return props.model_path.split(/(\\|\/)/).pop() || null;
        }
 
+       get selectedModelContextSize(): number | null {
+               if (!this.selectedModelName) return null;
+               return this.getModelContextSize(this.selectedModelName);
+       }
+
        /**
         *
         *
@@ -134,10 +126,6 @@ class ModelsStore {
         *
         */
 
-       /**
-        * 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) {
@@ -146,46 +134,29 @@ class ModelsStore {
 
                const props = this.modelPropsCache.get(modelId);
                if (props?.modalities) {
-                       return {
-                               vision: props.modalities.vision ?? false,
-                               audio: props.modalities.audio ?? false,
-                               video: props.modalities.video ?? false
-                       };
+                       return this.buildModalities(props.modalities);
                }
 
                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;
        }
 
-       /**
-        * Check if a model supports video modality
-        */
        modelSupportsVideo(modelId: string): boolean {
                return this.getModelModalities(modelId)?.video ?? 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);
                if (modalities.video) result.push(ModelModality.VIDEO);
@@ -193,16 +164,10 @@ class ModelsStore {
                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;
@@ -210,17 +175,6 @@ class ModelsStore {
                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);
        }
@@ -235,10 +189,10 @@ class ModelsStore {
 
        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
+                       model?.status.value === ServerModelStatus.SLEEPING
                );
        }
 
@@ -248,6 +202,7 @@ class ModelsStore {
 
        getModelStatus(modelId: string): ServerModelStatus | null {
                const model = this.routerModels.find((m) => m.id === modelId);
+
                return model?.status.value ?? null;
        }
 
@@ -257,6 +212,7 @@ class ModelsStore {
 
        isModelInUse(modelId: string): boolean {
                const usage = this.modelUsage.get(modelId);
+
                return usage !== undefined && usage.size > 0;
        }
 
@@ -269,8 +225,8 @@ class ModelsStore {
         */
 
        /**
-        * Fetch list of models from server and detect server role
-        * Also fetches modalities for MODEL mode (single model)
+        * 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;
@@ -293,69 +249,87 @@ class ModelsStore {
                                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;
-                       });
+                       const router = isRouterMode();
 
-                       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,
-                                       video: serverProps.modalities.video ?? false
-                               };
-                               this.modelPropsCache.set(this.models[0].model, serverProps);
-                               this.models = this.models.map((model, index) =>
-                                       index === 0 ? { ...model, modalities } : model
-                               );
+                       if (router) {
+                               const response = await ModelsService.listRouter();
+
+                               this.routerModels = response.data;
+                               this.models = this.buildModelOptions(response);
+
+                               await this.fetchModalitiesForLoadedModels();
+
+                               const visible = this.getVisibleModels();
+
+                               if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
+                                       this.selectModelById(visible[0].id);
+                               }
+                       } else {
+                               this.models = await this.fetchModelModeInternal();
                        }
                } catch (error) {
                        this.models = [];
                        this.error = error instanceof Error ? error.message : 'Failed to load models';
+
                        throw error;
                } finally {
                        this.loading = false;
                }
        }
 
+       /** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
+       private async fetchModelModeInternal(): Promise<ModelOption[]> {
+               const response = await ModelsService.list();
+
+               return this.buildModelOptions(response);
+       }
+
+       /**
+        * Build ModelOption[] from an API response.
+        * Both MODEL and ROUTER modes share the same mapping logic;
+        * they differ only in which endpoint is called.
+        */
+       private buildModelOptions(
+               response: ApiModelListResponse | ApiRouterModelsListResponse
+       ): ModelOption[] {
+               return 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 modelId = details?.model || item.id;
+
+                       return {
+                               id: item.id,
+                               name: this.toDisplayName(displayNameSource),
+                               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 ?? []
+                       };
+               });
+       }
+
        /**
-        * Fetch router models with full metadata (ROUTER mode only)
-        * This fetches the /models endpoint which returns status info for each model
+        * Fetch router models with full metadata (ROUTER mode only).
+        * No-op in router mode — fetch() already calls listRouter() internally.
+        * Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
         */
        async fetchRouterModels(): Promise<void> {
+               if (!isRouterMode()) return;
+
                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);
+                       const visible = this.getVisibleModels();
+                       if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
+                               this.selectModelById(visible[0].id);
                        }
                } catch (error) {
                        console.warn('Failed to fetch router models:', error);
@@ -364,10 +338,10 @@ class ModelsStore {
        }
 
        /**
-        * Fetch props for a specific model from /props endpoint
-        * Uses caching to avoid redundant requests
+        * 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,
+        * In ROUTER mode, this only fetches props if the model is loaded,
         * since unloaded models return 400 from /props endpoint.
         *
         * @param modelId - Model identifier to fetch props for
@@ -397,10 +371,7 @@ class ModelsStore {
                }
        }
 
-       /**
-        * Fetch modalities for all loaded models from /props endpoint
-        * This updates the modalities field in models array
-        */
+       /** Fetch modalities for all loaded models from /props endpoint. */
        async fetchModalitiesForLoadedModels(): Promise<void> {
                const loadedModelIds = this.loadedModelIds;
                if (loadedModelIds.length === 0) return;
@@ -410,7 +381,6 @@ class ModelsStore {
                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;
@@ -418,13 +388,7 @@ class ModelsStore {
                                const props = results[modelIndex];
                                if (!props?.modalities) return model;
 
-                               const modalities: ModelModalities = {
-                                       vision: props.modalities.vision ?? false,
-                                       audio: props.modalities.audio ?? false,
-                                       video: props.modalities.video ?? false
-                               };
-
-                               return { ...model, modalities };
+                               return { ...model, modalities: this.buildModalities(props.modalities) };
                        });
 
                        this.propsCacheVersion++;
@@ -433,17 +397,38 @@ class ModelsStore {
                }
        }
 
+       /**
+        * 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> {
+               const props = await this.fetchModelProps(modelId);
+               if (!props?.modalities) return;
+
+               this.models = this.models.map((model) =>
+                       model.model === modelId
+                               ? { ...model, modalities: this.buildModalities(props.modalities!) }
+                               : model
+               );
+
+               this.propsCacheVersion++;
+       }
+
+       /**
+        * Filter to models visible in the UI (ui !== false).
+        */
+       private getVisibleModels(): ModelOption[] {
+               return this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
+       }
+
        /**
         * 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;
@@ -456,22 +441,13 @@ class ModelsStore {
        /**
         * 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;
+               if (!lastModel || 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;
-               }
+               if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
 
                try {
                        await this.selectModelById(matchingModel.id);
@@ -484,22 +460,17 @@ class ModelsStore {
        }
 
        /**
-        * Auto-selects the first available model if none is selected, and fetches its props.
+        * Auto-selects the first available model if none is selected.
         * 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
-               );
-
+               const availableModels = this.getVisibleModels();
                if (availableModels.length === 0) return;
 
                // Try to select model from last assistant response first
@@ -515,7 +486,7 @@ class ModelsStore {
                        }
                }
 
-               // Try to find a loaded model first
+               // Try a loaded model first
                const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
                if (loadedModel) {
                        await this.selectModelById(loadedModel.id);
@@ -524,34 +495,7 @@ class ModelsStore {
                }
 
                // 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,
-                               video: props.modalities.video ?? 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);
-               }
+               await this.selectModelById(availableModels[0].id);
        }
 
        /**
@@ -562,9 +506,6 @@ class ModelsStore {
         *
         */
 
-       /**
-        * Select a model for new conversations
-        */
        async selectModelById(modelId: string): Promise<void> {
                if (!modelId || this.updating) return;
                if (this.selectedModelId === modelId) return;
@@ -584,8 +525,7 @@ class ModelsStore {
        }
 
        /**
-        * 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")
+        * Select a model by its model name (used for syncing with conversation model).
         */
        selectModelByName(modelName: string): void {
                const option = this.models.find((model) => model.model === modelName);
@@ -615,7 +555,7 @@ class ModelsStore {
        /**
         *
         *
-        * Loading/Unloading Models
+        * Loading / Unloading Models
         *
         *
         */
@@ -623,27 +563,18 @@ class ModelsStore {
        /**
         * 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).
+        * Currently, `/models/load` and `/models/unload` return success before
+        * the operation actually completes on the server.
         *
-        * 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.
+        * TODO: Remove polling once llama-server properly waits for the operation
+        * to complete before returning success.
         */
 
-       /** 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
+        * Keeps polling until the model reaches the expected status or fails.
         */
        private async pollForModelStatus(
                modelId: string,
@@ -654,9 +585,7 @@ class ModelsStore {
                        await this.fetchRouterModels();
 
                        const currentStatus = this.getModelStatus(modelId);
-                       if (currentStatus === expectedStatus) {
-                               return;
-                       }
+                       if (currentStatus === expectedStatus) return;
 
                        if (currentStatus === ServerModelStatus.FAILED) {
                                throw new Error(
@@ -677,15 +606,8 @@ class ModelsStore {
                }
        }
 
-       /**
-        * Load a model (ROUTER mode)
-        * @param modelId - Model identifier to load
-        */
        async loadModel(modelId: string): Promise<void> {
-               if (this.isModelLoaded(modelId)) {
-                       return;
-               }
-
+               if (this.isModelLoaded(modelId)) return;
                if (this.modelLoadingStates.get(modelId)) return;
 
                this.modelLoadingStates.set(modelId, true);
@@ -694,7 +616,6 @@ class ModelsStore {
                try {
                        await ModelsService.load(modelId);
                        await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
-
                        await this.updateModelModalities(modelId);
                        toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
@@ -706,15 +627,8 @@ class ModelsStore {
                }
        }
 
-       /**
-        * Unload a model (ROUTER mode)
-        * @param modelId - Model identifier to unload
-        */
        async unloadModel(modelId: string): Promise<void> {
-               if (!this.isModelLoaded(modelId)) {
-                       return;
-               }
-
+               if (!this.isModelLoaded(modelId)) return;
                if (this.modelLoadingStates.get(modelId)) return;
 
                this.modelLoadingStates.set(modelId, true);
@@ -722,7 +636,6 @@ class ModelsStore {
 
                try {
                        await ModelsService.unload(modelId);
-
                        await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
                        toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
                } catch (error) {
@@ -734,15 +647,8 @@ class ModelsStore {
                }
        }
 
-       /**
-        * 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;
-               }
-
+               if (this.isModelLoaded(modelId)) return;
                await this.loadModel(modelId);
        }
 
@@ -779,11 +685,9 @@ class ModelsStore {
        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();
                }
        }
@@ -799,10 +703,19 @@ class ModelsStore {
        private toDisplayName(id: string): string {
                const segments = id.split(/\\|\//);
                const candidate = segments.pop();
-
                return candidate && candidate.trim().length > 0 ? candidate : id;
        }
 
+       private buildModalities(
+               modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
+       ): ModelModalities {
+               return {
+                       vision: modalities.vision ?? false,
+                       audio: modalities.audio ?? false,
+                       video: modalities.video ?? false
+               };
+       }
+
        clear(): void {
                this.models = [];
                this.routerModels = [];
index 316ad552842c2f0ed075285c0ca619ae08aa75c7..5f0a38dd3c3a2e8b3ff60c1c25b15e55517be67d 100644 (file)
@@ -203,6 +203,7 @@ export interface ApiLlamaCppServerProps {
        /** @deprecated Use {@link ui_settings} instead */
        webui_settings?: Record<string, string | number | boolean>;
        ui_settings?: Record<string, string | number | boolean>;
+       cors_proxy_enabled?: boolean;
 }
 
 export interface ApiChatCompletionRequest {
index 948b7d7b6bb2a16526d80ef995392c4138646584..dbbf9a09b2b243adad44ac01b42db5c62b25dd67 100644 (file)
@@ -12,17 +12,21 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise<vo
                return;
        }
 
-       try {
-               const apiKey = config().apiKey;
+       const apiKey = config().apiKey;
+
+       // No API key configured — server doesn't require auth, skip the request entirely.
+       // The /props endpoint is only protected when the server has API keys configured,
+       // and in that case the client always has one set (from settings).
+       if (!apiKey) {
+               return;
+       }
 
+       try {
                const headers: Record<string, string> = {
-                       'Content-Type': 'application/json'
+                       'Content-Type': 'application/json',
+                       Authorization: `Bearer ${apiKey}`
                };
 
-               if (apiKey) {
-                       headers.Authorization = `Bearer ${apiKey}`;
-               }
-
                const response = await fetch(`${base}/props`, { headers });
 
                if (!response.ok) {
index 19755f6eee9460e213c69d0c119303ab6f38be8b..6b0890a363142828d1521b65a65987444bde7a20 100644 (file)
@@ -333,7 +333,8 @@ async function migrateConversation(convId: string): Promise<number> {
 export async function runLegacyMigration(): Promise<void> {
        if (!isMigrationNeeded()) return;
 
-       console.log('[Migration] Starting legacy message format migration...');
+       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
+               console.log('[Migration] Starting legacy message format migration...');
 
        try {
                const conversations = await DatabaseService.getAllConversations();
@@ -344,12 +345,14 @@ export async function runLegacyMigration(): Promise<void> {
                        totalMigrated += count;
                }
 
-               if (totalMigrated > 0) {
-                       console.log(
-                               `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
-                       );
-               } else {
-                       console.log('[Migration] No legacy messages found, marking as done');
+               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
+                       if (totalMigrated > 0) {
+                               console.log(
+                                       `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
+                               );
+                       } else {
+                               console.log('[Migration] No legacy messages found, marking as done');
+                       }
                }
 
                markMigrationDone();
index c272b438ef1abc16d53934c1c7ef9aac7344e93f..9db1d445fe8429cef1e44e0b3a7d6189d1f2df8b 100644 (file)
@@ -3,7 +3,6 @@
        import { chatStore } from '$lib/stores/chat.svelte';
        import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
        import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
-       import { isRouterMode } from '$lib/stores/server.svelte';
        import { onMount } from 'svelte';
        import { page } from '$app/state';
        import { replaceState } from '$app/navigation';
                conversationsStore.clearActiveConversation();
                chatStore.clearUIState();
 
-               if (
-                       isRouterMode() &&
-                       modelsStore.selectedModelName &&
-                       !modelsStore.isModelLoaded(modelsStore.selectedModelName)
-               ) {
-                       modelsStore.clearSelection();
-
-                       const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
-                       if (first) {
-                               await modelsStore.selectModelById(first.id);
-                       }
-               }
+               await modelsStore.fetch();
 
-               // Handle URL params only if we have ?q= or ?model= or ?new_chat=true
                if (qParam !== null || modelParam !== null || newChatParam === 'true') {
                        await handleUrlParams();
                }
+
+               await modelsStore.ensureFirstModelSelected();
        });
 </script>
 
index e03d13fefda6c4785323e95173a5b4a87848f093..b35d20a5cd5027ea6c716c54ab5b7470161fbf8b 100644 (file)
        function checkApiKey() {
                const apiKey = config().apiKey;
 
-               if (
-                       (page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
-                       page.status !== 401 &&
-                       page.status !== 403
-               ) {
-                       const headers: Record<string, string> = {
-                               'Content-Type': 'application/json'
-                       };
-
-                       if (apiKey && apiKey.trim() !== '') {
-                               headers.Authorization = `Bearer ${apiKey.trim()}`;
-                       }
-
-                       fetch(`${base}/props`, { headers })
-                               .then((response) => {
-                                       if (response.status === 401 || response.status === 403) {
-                                               window.location.reload();
-                                       }
-                               })
-                               .catch((e) => {
-                                       console.error('Error checking API key:', e);
-                               });
+               // No API key configured — server doesn't require auth, no need to validate.
+               // This mirrors the early return in validateApiKey() to avoid redundant /props requests.
+               if (!apiKey || apiKey.trim() === '') {
+                       return;
                }
+
+               untrack(() => {
+                       if (
+                               (page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
+                               page.status !== 401 &&
+                               page.status !== 403
+                       ) {
+                               const headers: Record<string, string> = {
+                                       'Content-Type': 'application/json',
+                                       Authorization: `Bearer ${apiKey.trim()}`
+                               };
+
+                               fetch(`${base}/props`, { headers })
+                                       .then((response) => {
+                                               if (response.status === 401 || response.status === 403) {
+                                                       window.location.reload();
+                                               }
+                                       })
+                                       .catch((e) => {
+                                               console.error('Error checking API key:', e);
+                                       });
+                       }
+               });
        }
 
        function handleTitleUpdateCancel() {
index 6849beb27b21bd629fedb65b251a6960c7875371..32e333d7f5194487443b74a3b298493a21d57c37 100644 (file)
@@ -4,8 +4,9 @@ import TestWrapper from './components/TestWrapper.svelte';
 
 describe('/+page.svelte', () => {
        it('should render page without throwing', async () => {
-               // Basic smoke test - page should render without throwing errors
-               // API calls will fail in test environment but component should still mount
-               expect(() => render(TestWrapper)).not.toThrow();
+               // Basic smoke test - page should render without throwing errors.
+               // API calls are mocked in vitest-setup-client.ts.
+               await render(TestWrapper);
+               expect(true).toBe(true);
        });
 });
index 570b9f0e1a0c23365129352e3ae87211a659e21f..0b753db02b1e4ba70ab84571cbf4fd4ee883ed79 100644 (file)
@@ -1,2 +1,78 @@
 /// <reference types="@vitest/browser/matchers" />
 /// <reference types="@vitest/browser/providers/playwright" />
+
+import { beforeEach, vi } from 'vitest';
+
+// Mock fetch for API calls during client tests.
+// In test environment there is no backend server, so we intercept
+// the specific endpoints the app uses and return valid mock data.
+beforeEach(() => {
+       const originalFetch = globalThis.fetch;
+
+       vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
+               const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
+
+               // Mock server props endpoint
+               if (url.includes('/server')) {
+                       return new Response(
+                               JSON.stringify({
+                                       mode: 'router',
+                                       version: 'test',
+                                       git_commit: 'test',
+                                       git_branch: 'test'
+                               }),
+                               { status: 200, headers: { 'Content-Type': 'application/json' } }
+                       );
+               }
+
+               // Mock models list endpoint
+               if (/\/v1\/models|\/models\b/.test(url)) {
+                       return new Response(
+                               JSON.stringify({
+                                       object: 'list',
+                                       data: [
+                                               {
+                                                       id: 'test-model.gguf',
+                                                       object: 'model',
+                                                       owned_by: 'llamacpp',
+                                                       created: 0,
+                                                       in_cache: false,
+                                                       path: 'models/test-model.gguf',
+                                                       status: { value: 'unloaded' },
+                                                       meta: {}
+                                               }
+                                       ],
+                                       models: [
+                                               {
+                                                       model: 'test-model.gguf',
+                                                       name: 'Test Model',
+                                                       details: {}
+                                               }
+                                       ]
+                               }),
+                               { status: 200, headers: { 'Content-Type': 'application/json' } }
+                       );
+               }
+
+               // Mock /props endpoint (used for modalities)
+               if (url.includes('/props')) {
+                       return new Response(
+                               JSON.stringify({
+                                       default_generation_settings: { n_ctx: 2048 }
+                               }),
+                               { status: 200, headers: { 'Content-Type': 'application/json' } }
+                       );
+               }
+
+               // Mock /tools endpoint (used for built-in tools list)
+               if (url.includes('/tools')) {
+                       return new Response(JSON.stringify([]), {
+                               status: 200,
+                               headers: { 'Content-Type': 'application/json' }
+                       });
+               }
+
+               // Default: use real fetch
+               return originalFetch(input, init);
+       });
+});