diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp index 0f3fb9efa..6b16c6b49 100644 --- a/tools/server/server-context.cpp +++ b/tools/server/server-context.cpp @@ -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()) { diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp index 6c6fed52d..ccf42320f 100644 --- a/tools/server/server-models.cpp +++ b/tools/server/server-models.cpp @@ -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 index 000000000..9a995b746 --- /dev/null +++ b/tools/ui/.env.example @@ -0,0 +1,2 @@ +VITE_PUBLIC_APP_NAME='llama-ui' +# VITE_DEBUG='true' diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte index 2f9471e0d..297020605 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte @@ -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), @@ -41,14 +39,28 @@ 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); @@ -151,7 +163,7 @@ @@ -159,7 +171,7 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte index 567fdac47..ff734ac88 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -162,7 +162,7 @@ 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, @@ -181,7 +181,7 @@ value ); - if (import.meta.env.DEV) { + if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) { console.log('[ChatFormPickerMcpPrompts] Autocomplete result:', { argName, value, diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts index 537a2af18..098cb2c27 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -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( '[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 }; } diff --git a/tools/ui/src/lib/services/mcp.service.ts b/tools/ui/src/lib/services/mcp.service.ts index 44cbd4a8a..d596381aa 100644 --- a/tools/ui/src/lib/services/mcp.service.ts +++ b/tools/ui/src/lib/services/mcp.service.ts @@ -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 { - 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:`, diff --git a/tools/ui/src/lib/services/migration.service.ts b/tools/ui/src/lib/services/migration.service.ts index 5ed24c00d..35d47070a 100644 --- a/tools/ui/src/lib/services/migration.service.ts +++ b/tools/ui/src/lib/services/migration.service.ts @@ -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 { 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 { 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 { 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'); } }; diff --git a/tools/ui/src/lib/stores/mcp.svelte.ts b/tools/ui/src/lib/stores/mcp.svelte.ts index 8fb306da8..effb78e33 100644 --- a/tools/ui/src/lib/stores/mcp.svelte.ts +++ b/tools/ui/src/lib/stores/mcp.svelte.ts @@ -20,11 +20,11 @@ */ 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([]); private _healthChecks = $state>({}); - private _proxyAvailable = $state(false); private connections = new Map(); private toolsIndex = new Map(); @@ -96,27 +94,8 @@ class MCPStore { private initPromise: Promise | 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 { - 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; } /** diff --git a/tools/ui/src/lib/stores/models.svelte.ts b/tools/ui/src/lib/stores/models.svelte.ts index 45981b38f..bc99d7412 100644 --- a/tools/ui/src/lib/stores/models.svelte.ts +++ b/tools/ui/src/lib/stores/models.svelte.ts @@ -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=` per model. + * In ROUTER mode, `/props` has no modalities — must use `/props?model=` 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(null); selectedModelName = $state(null); - // dedup concurrent fetch() callers, all awaiters share the same inflight promise - // without this, ?model= 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= URL handler races an in-progress fetch and sees an empty list. private inflightFetch: Promise | null = null; private modelUsage = $state>>(new Map()); @@ -67,9 +54,9 @@ class ModelsStore { favoriteModelIds = $state>(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({ ttlMs: MODEL_PROPS_CACHE_TTL_MS, @@ -78,7 +65,7 @@ class ModelsStore { private modelPropsFetching = $state>(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 { if (this.inflightFetch) return this.inflightFetch; @@ -293,69 +249,87 @@ class ModelsStore { await serverStore.fetch(); } - const response = await ModelsService.list(); + const router = isRouterMode(); - 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; + if (router) { + const response = await ModelsService.listRouter(); - 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.routerModels = response.data; + this.models = this.buildModelOptions(response); - this.models = models; + await this.fetchModalitiesForLoadedModels(); - // 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 - ); + 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 { + const response = await ModelsService.list(); + + return this.buildModelOptions(response); + } + /** - * Fetch router models with full metadata (ROUTER mode only) - * This fetches the /models endpoint which returns status info for each model + * 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). + * No-op in router mode — fetch() already calls listRouter() internally. + * Kept for API compatibility (e.g. handleOpenChange dropdown open handler). */ async fetchRouterModels(): Promise { + 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 { 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 { + 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 { 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 { 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 { - 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 { 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 { - 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 { - 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 { - if (this.isModelLoaded(modelId)) { - return; - } - + if (this.isModelLoaded(modelId)) return; await this.loadModel(modelId); } @@ -779,11 +685,9 @@ class ModelsStore { private loadFavoritesFromStorage(): Set { 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 + ): ModelModalities { + return { + vision: modalities.vision ?? false, + audio: modalities.audio ?? false, + video: modalities.video ?? false + }; + } + clear(): void { this.models = []; this.routerModels = []; diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts index 316ad5528..5f0a38dd3 100644 --- a/tools/ui/src/lib/types/api.d.ts +++ b/tools/ui/src/lib/types/api.d.ts @@ -203,6 +203,7 @@ export interface ApiLlamaCppServerProps { /** @deprecated Use {@link ui_settings} instead */ webui_settings?: Record; ui_settings?: Record; + cors_proxy_enabled?: boolean; } export interface ApiChatCompletionRequest { diff --git a/tools/ui/src/lib/utils/api-key-validation.ts b/tools/ui/src/lib/utils/api-key-validation.ts index 948b7d7b6..dbbf9a09b 100644 --- a/tools/ui/src/lib/utils/api-key-validation.ts +++ b/tools/ui/src/lib/utils/api-key-validation.ts @@ -12,17 +12,21 @@ export async function validateApiKey(fetch: typeof globalThis.fetch): Promise = { - '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) { diff --git a/tools/ui/src/lib/utils/legacy-migration.ts b/tools/ui/src/lib/utils/legacy-migration.ts index 19755f6ee..6b0890a36 100644 --- a/tools/ui/src/lib/utils/legacy-migration.ts +++ b/tools/ui/src/lib/utils/legacy-migration.ts @@ -333,7 +333,8 @@ async function migrateConversation(convId: string): Promise { export async function runLegacyMigration(): Promise { 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 { 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(); diff --git a/tools/ui/src/routes/(chat)/+page.svelte b/tools/ui/src/routes/(chat)/+page.svelte index c272b438e..9db1d445f 100644 --- a/tools/ui/src/routes/(chat)/+page.svelte +++ b/tools/ui/src/routes/(chat)/+page.svelte @@ -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'; @@ -72,23 +71,13 @@ conversationsStore.clearActiveConversation(); chatStore.clearUIState(); - if ( - isRouterMode() && - modelsStore.selectedModelName && - !modelsStore.isModelLoaded(modelsStore.selectedModelName) - ) { - modelsStore.clearSelection(); + await modelsStore.fetch(); - const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model)); - if (first) { - await modelsStore.selectModelById(first.id); - } - } - - // 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(); }); diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index e03d13fef..b35d20a5c 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -84,29 +84,34 @@ 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 = { - '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 = { + '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() { diff --git a/tools/ui/tests/client/page.svelte.test.ts b/tools/ui/tests/client/page.svelte.test.ts index 6849beb27..32e333d7f 100644 --- a/tools/ui/tests/client/page.svelte.test.ts +++ b/tools/ui/tests/client/page.svelte.test.ts @@ -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); }); }); diff --git a/tools/ui/vitest-setup-client.ts b/tools/ui/vitest-setup-client.ts index 570b9f0e1..0b753db02 100644 --- a/tools/ui/vitest-setup-client.ts +++ b/tools/ui/vitest-setup-client.ts @@ -1,2 +1,78 @@ /// /// + +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); + }); +});