ui : prepare every backend at startup so tabs only swap memory

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 21:26:09 +02:00
parent fa55902f79
commit b2c27a73b0
7 changed files with 69 additions and 46 deletions
@@ -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();
});
}
opts.onOpenChange?.(open);
+6 -2
View File
@@ -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<ApiLlamaCppServerProps> {
static async fetch(autoload = false, backendId?: string): Promise<ApiLlamaCppServerProps> {
const params: Record<string, string> = {};
if (!autoload) {
params.autoload = 'false';
}
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, { authOnly: true });
return apiFetchWithParams<ApiLlamaCppServerProps>('./props', params, {
authOnly: true,
backendId
});
}
/**
+8
View File
@@ -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<void> | null = null;
@@ -25,6 +27,12 @@ export function initStores(): Promise<void> {
// 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();
+15 -12
View File
@@ -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<void> {
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)
),
+24 -1
View File
@@ -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<void> {
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;
+6 -4
View File
@@ -32,6 +32,8 @@ export interface ApiFetchOptions extends Omit<RequestInit, 'headers'> {
* 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<RequestInit, 'headers'> {
* ```
*/
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
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<T>(
params: Record<string, string>,
options: ApiFetchOptions = {}
): Promise<T> {
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) {
-18
View File
@@ -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.