ui : keep model state in memory across backend switches

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 20:58:29 +02:00
parent 48e50e12ed
commit fa55902f79
7 changed files with 84 additions and 38 deletions
@@ -210,7 +210,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isMultiModel}
{#if ms.isMultiModel || ms.switchingBackends}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -251,7 +251,7 @@
{/if}
</span>
{#if ms.updating || ms.isLoadingModel}
{#if ms.updating || ms.isLoadingModel || ms.switchingBackends}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
@@ -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 };
+11 -3
View File
@@ -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
});
@@ -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<Record<string, BackendModelsState>>({});
@@ -47,7 +55,8 @@ class BackendsModelsStore {
error: result.error ?? null,
loaded: result.ok,
loading: false,
models: result.models
models: result.models,
raw: result.raw
};
}
+30 -24
View File
@@ -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<void> {
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 {
+21
View File
@@ -24,6 +24,9 @@ class ServerStore {
status = $state<number | null>(null);
private fetchBackendId: string | undefined;
private fetchPromise: Promise<void> | 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<typeof setTimeout> | 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);
+3 -5
View File
@@ -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.