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 343b240fa0..bb4d81bcb9 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -68,9 +68,12 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele const updating = $derived(modelsStore.updating); const activeId = $derived(modelsStore.selectedModelId); const backends = $derived(backendsStore.enabled); - // Router mode and external backends both expose a selectable model list; - // a single-model llama.cpp server does not. - const isMultiModel = $derived(serverStore.isRouterMode || !serverStore.capabilities.props); + // Router mode and external backends both expose a selectable model list, and + // configured backends always need the tabs; only a lone llama.cpp server + // without a router has nothing to list. + const isMultiModel = $derived( + serverStore.isRouterMode || !serverStore.capabilities.props || backendsStore.enabled.length > 1 + ); const isRouter = $derived(serverStore.isRouterMode); const serverModel = $derived(modelsStore.singleModelName); const currentModel = $derived(opts.currentModel()); @@ -118,9 +121,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele function handleOpenChange(open: boolean) { if (loading || updating) return; - // a single-model llama.cpp server has no list to show, so the trigger - // opens the model info dialog instead; external backends have a menu - if (!isRouter && serverStore.capabilities.props) { + // a single-model llama.cpp server with no other backend has no list to + // show, so the trigger opens the model info dialog instead + if (!isMultiModel) { showModelDialog = open; return; @@ -129,9 +132,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele searchTerm = ''; if (open && isRouter) { - modelsStore.fetchRouterModels().then(() => { - modelsStore.props.fetchModalitiesForLoadedModels(); - }); + modelsStore.props.fetchModalitiesForLoadedModels(); } opts.onOpenChange?.(open); diff --git a/tools/ui/src/lib/services/props.service.ts b/tools/ui/src/lib/services/props.service.ts index 488a67b641..74d5bf4bff 100644 --- a/tools/ui/src/lib/services/props.service.ts +++ b/tools/ui/src/lib/services/props.service.ts @@ -15,17 +15,21 @@ export class PropsService { * In ROUTER mode, returns server-wide settings without model-specific modalities. * * @param autoload - If false, prevents automatic model loading (default: false) + * @param backendId - Backend to ask; defaults to the active one. * @returns Server properties including default generation settings and capabilities * @throws {Error} If the request fails or returns invalid data */ - static async fetch(autoload = false): Promise { + static async fetch(autoload = false, backendId?: string): Promise { const params: Record = {}; if (!autoload) { params.autoload = 'false'; } - return apiFetchWithParams('./props', params, { authOnly: true }); + return apiFetchWithParams('./props', params, { + authOnly: true, + backendId + }); } /** diff --git a/tools/ui/src/lib/stores/init.ts b/tools/ui/src/lib/stores/init.ts index c39f1b9993..0f28f88480 100644 --- a/tools/ui/src/lib/stores/init.ts +++ b/tools/ui/src/lib/stores/init.ts @@ -3,11 +3,13 @@ import { backendsStore } from './backends.svelte'; import { backendsModelsStore } from './backendsModels.svelte'; import { conversationsStore } from './conversations/index.svelte'; import { permissionsStore } from './permissions.svelte'; +import { serverStore } from './server.svelte'; import { settingsStore } from './settings/index.svelte'; import { tabsStore } from './tabs.svelte'; import { toolsStore } from './tools.svelte'; import { versionStore } from './version.svelte'; import { browser } from '$app/environment'; +import { LOCAL_BACKEND_ID } from '$lib/constants'; import { MigrationService } from '$lib/services/migration.service'; let startup: Promise | null = null; @@ -25,6 +27,12 @@ export function initStores(): Promise { // per-backend and never block startup void backendsModelsStore.loadAll(); + // the local server state is needed once its tab is opened; loading it here + // keeps the tab switch free of /props requests + if (backendsStore.local.enabled && backendsStore.active.id !== LOCAL_BACKEND_ID) { + void serverStore.prefetchLocalState(); + } + permissionsStore.initialize(); toolsStore.initialize(); void versionStore.initialize(); diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts index b15d9f527b..352bb00899 100644 --- a/tools/ui/src/lib/stores/models/index.svelte.ts +++ b/tools/ui/src/lib/stores/models/index.svelte.ts @@ -98,10 +98,14 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { const merged: ModelOption[] = []; for (const option of this.activeModels) { + // keep the backend an option was built for: rows from the previous + // backend must not be relabelled while a switch is in flight + const backendId = option.backendId ?? activeBackendId; + merged.push({ ...option, - backendId: activeBackendId, - id: qualifyModelId(activeBackendId, option.id) + backendId, + id: qualifyModelId(backendId, rawModelId(option.id)) }); } @@ -111,8 +115,8 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { for (const option of backendsModelsStore.get(backend.id).models) { merged.push({ ...option, - backendId: backend.id, - id: qualifyModelId(backend.id, option.id) + backendId: option.backendId ?? backend.id, + id: qualifyModelId(option.backendId ?? backend.id, rawModelId(option.id)) }); } } @@ -404,12 +408,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { } /** - * Swap the active backend's state in memory. Every backend is prefetched at - * startup, so switching tabs restores the cached list and the local server - * state without a request. + * Activate a backend for the selector tabs. Everything comes from memory: + * the model list and router rows are prefetched at startup and the local + * server state is kept while an external backend is active. The selection + * is left alone, switching tabs must not pick a model. */ async switchBackend(): Promise { - this.clearSelection(); this.error = null; const backend = backendsStore.active; @@ -446,10 +450,6 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { this.routerModels = cached.raw.data; this.activeModels = this.buildModelOptions(cached.raw); } - - if (this.activeModels.length > 0) { - await this.ensureFirstModelSelected(); - } } toDisplayName(id: string): string { @@ -506,6 +506,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { return { aliases: item.aliases ?? [], + // stamp the backend here so the option keeps its origin even + // after another backend becomes active + backendId: backendsStore.active.id, capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value) ), diff --git a/tools/ui/src/lib/stores/server.svelte.ts b/tools/ui/src/lib/stores/server.svelte.ts index 4372d1b261..b305be6783 100644 --- a/tools/ui/src/lib/stores/server.svelte.ts +++ b/tools/ui/src/lib/stores/server.svelte.ts @@ -6,7 +6,7 @@ * PropsService for the /props fetch. */ -import { BACKEND_CAPABILITIES } from '$lib/constants'; +import { BACKEND_CAPABILITIES, LOCAL_BACKEND_ID } from '$lib/constants'; import { ServerRole } from '$lib/enums'; import { PropsService } from '$lib/services/props.service'; import type { BackendCapabilities } from '$lib/types'; @@ -63,6 +63,10 @@ class ServerStore { * switching back restores it instead of asking the server again. */ cacheLocalState(): void { + // props only exist while a llama.cpp server is active; an external to + // external switch must not overwrite the kept local state with blanks + if (!this.props) return; + this.localState = { props: this.props, role: this.role }; } @@ -156,6 +160,25 @@ class ServerStore { await promise; } + /** + * Load the local server state in the background at startup. The local tab + * then opens from memory instead of asking for props on the first click. + */ + async prefetchLocalState(): Promise { + if (this.localState) return; + + try { + const props = await PropsService.fetch(false, LOCAL_BACKEND_ID); + + this.localState = { + props, + role: props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL + }; + } catch { + // the local tab falls back to fetching when it is opened + } + } + /** Restore the state kept by {@link cacheLocalState}; no request is made. */ restoreLocalState(): void { if (!this.localState) return; diff --git a/tools/ui/src/lib/utils/api-fetch.ts b/tools/ui/src/lib/utils/api-fetch.ts index b942ec6723..d5fce0fd49 100644 --- a/tools/ui/src/lib/utils/api-fetch.ts +++ b/tools/ui/src/lib/utils/api-fetch.ts @@ -32,6 +32,8 @@ export interface ApiFetchOptions extends Omit { * Default: false (uses JSON headers with Content-Type: application/json) */ authOnly?: boolean; + /** Backend to target; defaults to the active one. */ + backendId?: string; /** * Additional headers to merge with default headers. */ @@ -59,10 +61,10 @@ export interface ApiFetchOptions extends Omit { * ``` */ export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { - const { authOnly = false, headers: customHeaders, ...fetchOptions } = options; - const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders(); + const { authOnly = false, backendId, headers: customHeaders, ...fetchOptions } = options; + const baseHeaders = authOnly ? getAuthHeaders(backendId) : getJsonHeaders(backendId); const headers = { ...baseHeaders, ...customHeaders }; - const url = apiUrl(path); + const url = apiUrl(path, backendId); let response; @@ -105,7 +107,7 @@ export async function apiFetchWithParams( params: Record, options: ApiFetchOptions = {} ): Promise { - const url = new URL(apiUrl(basePath), window.location.href); + const url = new URL(apiUrl(basePath, options.backendId), window.location.href); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index 0618a9ac5d..b90c1bdc39 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -235,24 +235,6 @@ }); } - // Fetch router models when in router mode (for status and modalities) - // Wait for models to be loaded first, run only once - let routerModelsFetched = false; - - $effect(() => { - const isRouter = serverStore.isRouterMode; - const modelsCount = modelsStore.models.length; - - // Only fetch router models once when we have models loaded and in router mode - if (isRouter && modelsCount > 0 && !routerModelsFetched) { - routerModelsFetched = true; - - untrack(() => { - modelsStore.fetchRouterModels(); - }); - } - }); - // Live model status and load progress via the /models/sse feed (router mode). // The feed is kept for the session: switching to an external backend and back // must not tear it down and reconnect on every tab switch.