diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte index 0961b609d5..413a597408 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte @@ -210,7 +210,7 @@ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100) : 0} - {#if ms.isMultiModel} + {#if ms.isMultiModel || ms.switchingBackends} @@ -251,7 +251,7 @@ {/if} - {#if ms.updating || ms.isLoadingModel} + {#if ms.updating || ms.isLoadingModel || ms.switchingBackends} {:else} diff --git a/tools/ui/src/lib/services/backends.service.ts b/tools/ui/src/lib/services/backends.service.ts index 667f7ab34b..6529a92345 100644 --- a/tools/ui/src/lib/services/backends.service.ts +++ b/tools/ui/src/lib/services/backends.service.ts @@ -7,7 +7,7 @@ */ import { API_MODELS, LOCAL_BACKEND_ID } from '$lib/constants'; -import type { Backend, ModelOption } from '$lib/types'; +import type { ApiModelsListResponse, Backend, ModelOption } from '$lib/types'; import { isAbortError } from '$lib/utils/abort'; import { apiUrl } from '$lib/utils/api-base'; import { getAuthHeadersForBackend } from '$lib/utils/api-headers'; @@ -19,6 +19,8 @@ export interface BackendModelsResult { models: ModelOption[]; ok: boolean; status: number | null; + /** Untouched list payload of the local backend, kept so the router rows and their load statuses can be rebuilt without asking again. */ + raw?: ApiModelsListResponse; } /** Outcome of a backend connectivity check. */ @@ -64,8 +66,10 @@ export class BackendsService { const body = (await response.json()) as { data?: unknown }; const entries = Array.isArray(body?.data) ? body.data : []; const models = entries.flatMap((entry) => normalizeBackendModel(entry)); + // only the local server needs its raw rows: they carry the load status + const raw = backend.id === LOCAL_BACKEND_ID ? (body as ApiModelsListResponse) : undefined; - return { models, ok: true, status: response.status }; + return { models, ok: true, raw, status: response.status }; } catch (error) { if (isAbortError(error)) { return { models: [], ok: false, status: null }; diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts index 70b3d23208..b141551d1c 100644 --- a/tools/ui/src/lib/services/models.service.ts +++ b/tools/ui/src/lib/services/models.service.ts @@ -6,7 +6,13 @@ * modelsStore and its status manager. */ -import { API_MODELS, MODEL_ID, type ModelSidecar, SIDECAR_TOKENS } from '$lib/constants'; +import { + API_MODELS, + LOCAL_BACKEND_ID, + MODEL_ID, + type ModelSidecar, + SIDECAR_TOKENS +} from '$lib/constants'; import { ServerModelStatus } from '$lib/enums'; import type { ParsedModelId } from '$lib/types/models'; import { @@ -346,8 +352,10 @@ export class ModelsService { while (!signal.aborted) { try { - const response = await fetch(apiUrl(API_MODELS.SSE), { - headers: getAuthHeaders(), + // the status feed only exists on the local llama.cpp server; pin the + // request so an active external backend cannot redirect it + const response = await fetch(apiUrl(API_MODELS.SSE, LOCAL_BACKEND_ID), { + headers: getAuthHeaders(LOCAL_BACKEND_ID), signal }); diff --git a/tools/ui/src/lib/stores/backendsModels.svelte.ts b/tools/ui/src/lib/stores/backendsModels.svelte.ts index 2b645d0e95..b58c3eee77 100644 --- a/tools/ui/src/lib/stores/backendsModels.svelte.ts +++ b/tools/ui/src/lib/stores/backendsModels.svelte.ts @@ -9,6 +9,7 @@ import { BackendsService } from '$lib/services/backends.service'; import { backendsStore } from '$lib/stores/backends.svelte'; +import type { ApiModelsListResponse } from '$lib/types'; import type { ModelOption } from '$lib/types/models'; export interface BackendModelsState { @@ -16,9 +17,16 @@ export interface BackendModelsState { loaded: boolean; loading: boolean; models: ModelOption[]; + /** Untouched list payload, kept for the local backend so its router rows survive a tab switch. */ + raw?: ApiModelsListResponse; } -const EMPTY_STATE: BackendModelsState = { error: null, loaded: false, loading: false, models: [] }; +const EMPTY_STATE: BackendModelsState = { + error: null, + loaded: false, + loading: false, + models: [] +}; class BackendsModelsStore { private states = $state>({}); @@ -47,7 +55,8 @@ class BackendsModelsStore { error: result.error ?? null, loaded: result.ok, loading: false, - models: result.models + models: result.models, + raw: result.raw }; } diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts index 1e41e39267..b15d9f527b 100644 --- a/tools/ui/src/lib/stores/models/index.svelte.ts +++ b/tools/ui/src/lib/stores/models/index.svelte.ts @@ -404,46 +404,52 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost { } /** - * Drop per-backend state when the active backend changes. The model list, - * router rows and selection all belong to the previous backend, so they - * must not leak into the next one. Refetches for the new backend. + * 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. */ async switchBackend(): Promise { - this.status.unsubscribe(); this.clearSelection(); - this.routerModels = []; this.error = null; const backend = backendsStore.active; - // server props describe the local server; drop them only when the next - // backend is not the one they describe, refresh in place otherwise - if (backend.protocol !== 'llama.cpp') { + // local props describe the server the UI is served from; keep them while + // an external backend is active instead of dropping and refetching + if (backend.protocol === 'llama.cpp') { + serverStore.restoreLocalState(); + } else { + serverStore.cacheLocalState(); serverStore.clear(); } - // prefer the prefetched list so switching does not refetch const cached = backendsModelsStore.get(backend.id); - if (cached.loaded) { - this.activeModels = cached.models; - this.loading = false; - - await serverStore.fetch({ background: true }); - - // the cache carries names only; reload the router load status in place - if (serverStore.isRouterMode) { - await this.fetchRouterModels(); - } - - if (this.activeModels.length > 0) { - await this.ensureFirstModelSelected(); - } + if (!cached.loaded) { + // nothing prefetched for this backend (startup prefetch failed): load it once + await this.fetch(true); return; } - await this.fetch(true); + if (backend.protocol === 'llama.cpp' && !serverStore.props) { + // first visit to the local tab in this session + await serverStore.fetch({ background: true }); + } + + this.activeModels = cached.models; + this.loading = false; + + // the local router rows carry the load statuses; the startup prefetch + // already returned them, so a tab switch rebuilds the list from memory + if (backend.protocol === 'llama.cpp' && this.routerModels.length === 0 && cached.raw) { + this.routerModels = cached.raw.data; + this.activeModels = this.buildModelOptions(cached.raw); + } + + if (this.activeModels.length > 0) { + await this.ensureFirstModelSelected(); + } } toDisplayName(id: string): string { diff --git a/tools/ui/src/lib/stores/server.svelte.ts b/tools/ui/src/lib/stores/server.svelte.ts index 731f0c976b..4372d1b261 100644 --- a/tools/ui/src/lib/stores/server.svelte.ts +++ b/tools/ui/src/lib/stores/server.svelte.ts @@ -24,6 +24,9 @@ class ServerStore { status = $state(null); private fetchBackendId: string | undefined; private fetchPromise: Promise | null = null; + /** Local server state kept alive while an external backend is active. */ + private localState: { props: ApiLlamaCppServerProps | null; role: ServerRole | null } | null = + null; private retryTimer: ReturnType | null = null; /** Features of the active backend. Defaults to full llama.cpp support. */ @@ -55,6 +58,14 @@ class ServerStore { return this.props?.ui_settings ?? this.props?.webui_settings; } + /** + * Keep the local server state before switching to an external backend, so + * switching back restores it instead of asking the server again. + */ + cacheLocalState(): void { + this.localState = { props: this.props, role: this.role }; + } + clear(): void { this.clearRetryTimer(); this.props = null; @@ -145,6 +156,16 @@ class ServerStore { await promise; } + /** Restore the state kept by {@link cacheLocalState}; no request is made. */ + restoreLocalState(): void { + if (!this.localState) return; + + this.props = this.localState.props; + this.role = this.localState.role; + this.error = null; + this.status = null; + } + private clearRetryTimer(): void { if (this.retryTimer) { clearTimeout(this.retryTimer); diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index 2365e2f554..eb785a1a55 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -253,7 +253,9 @@ } }); - // Live model status and load progress via the /models/sse feed (router mode) + // 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. $effect(() => { if (!browser) return; @@ -262,10 +264,6 @@ untrack(() => { modelsStore.status.subscribe(); }); - - return () => { - modelsStore.status.unsubscribe(); - }; }); // Background MCP server health checks on app load.