ui : prefetch backend model lists on first selector open

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 19:22:47 +02:00
parent 5beb29294b
commit 088030d384
5 changed files with 118 additions and 4 deletions
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Plus } from '@lucide/svelte'; import { Loader2, Plus } from '@lucide/svelte';
import { backendsModelsStore } from '$lib/stores';
import type { Backend } from '$lib/types'; import type { Backend } from '$lib/types';
interface Props { interface Props {
@@ -14,9 +15,10 @@
<div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2"> <div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2">
{#each backends as backend (backend.id)} {#each backends as backend (backend.id)}
{@const state = backendsModelsStore.get(backend.id)}
<button <button
class={[ class={[
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition', 'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
activeId === backend.id activeId === backend.id
? 'bg-muted text-foreground' ? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
@@ -25,6 +27,12 @@
type="button" type="button"
> >
{backend.name} {backend.name}
{#if state.loading}
<Loader2 class="h-3 w-3 animate-spin" />
{:else if state.error}
<span class="h-1.5 w-1.5 rounded-full bg-destructive" title={state.error}></span>
{/if}
</button> </button>
{/each} {/each}
@@ -1,6 +1,6 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils'; import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants'; import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { backendsStore, modelsStore, serverStore } from '$lib/stores'; import { backendsModelsStore, backendsStore, modelsStore, serverStore } from '$lib/stores';
import type { Backend } from '$lib/types'; import type { Backend } from '$lib/types';
import type { ModelOption } from '$lib/types/models'; import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -86,6 +86,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let searchTerm = $state(''); let searchTerm = $state('');
let showModelDialog = $state(false); let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null); let infoModelId = $state<string | null>(null);
let backendsPrefetched = false;
const filteredOptions = $derived(filterModelOptions(options, searchTerm)); const filteredOptions = $derived(filterModelOptions(options, searchTerm));
const groupedFilteredOptions = $derived( const groupedFilteredOptions = $derived(
@@ -106,6 +107,12 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}); });
function handleOpenChange(open: boolean) { function handleOpenChange(open: boolean) {
// first open: prefetch every backend's model list so switching is instant
if (open && !backendsPrefetched) {
backendsPrefetched = true;
void backendsModelsStore.loadAll();
}
if (loading || updating) return; if (loading || updating) return;
if (isRouter) { if (isRouter) {
@@ -130,6 +137,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = ''; searchTerm = '';
try { try {
await backendsModelsStore.ensureLoaded(backendId);
await modelsStore.switchBackend(); await modelsStore.switchBackend();
} catch (error) { } catch (error) {
console.error('Failed to switch backend:', error); console.error('Failed to switch backend:', error);
@@ -0,0 +1,78 @@
/**
* backendsModelsStore - Per-backend model catalog cache.
*
* The models selector prefetches every enabled backend's model list the first
* time it opens, so switching backends is instant and the switcher can show
* load state. The active backend's list still lives in modelsStore, which owns
* selection and chat wiring; this cache is the prefetch layer for the others.
*/
import { BackendsService } from '$lib/services/backends.service';
import { backendsStore } from '$lib/stores/backends.svelte';
import type { ModelOption } from '$lib/types/models';
export interface BackendModelsState {
error: string | null;
loaded: boolean;
loading: boolean;
models: ModelOption[];
}
const EMPTY_STATE: BackendModelsState = { error: null, loaded: false, loading: false, models: [] };
class BackendsModelsStore {
private states = $state<Record<string, BackendModelsState>>({});
clear(backendId: string): void {
delete this.states[backendId];
}
/**
* Load a backend's models once. Local backends are skipped: their list is
* owned by modelsStore.
*/
async ensureLoaded(backendId: string): Promise<void> {
const backend = backendsStore.enabled.find((candidate) => candidate.id === backendId);
if (!backend || !backend.baseUrl.trim()) return;
const state = this.states[backendId];
if (state?.loaded || state?.loading) return;
this.states[backendId] = { error: null, loaded: false, loading: true, models: [] };
const result = await BackendsService.listModels(backend);
this.states[backendId] = {
error: result.error ?? null,
loaded: result.ok,
loading: false,
models: result.models
};
}
get(backendId: string): BackendModelsState {
return this.states[backendId] ?? EMPTY_STATE;
}
/** Prefetch every enabled external backend, skipping the active one. */
async loadAll(): Promise<void> {
const enabled = backendsStore.enabled;
const ids = new Set(enabled.map((backend) => backend.id));
for (const id of Object.keys(this.states)) {
if (!ids.has(id)) {
delete this.states[id];
}
}
await Promise.all(
enabled
.filter((backend) => backend.id !== backendsStore.active.id)
.map((backend) => this.ensureLoaded(backend.id))
);
}
}
export const backendsModelsStore = new BackendsModelsStore();
+2
View File
@@ -40,6 +40,8 @@ export { mcpStore } from './mcp/index.svelte';
// BACKENDS // BACKENDS
export { backendsStore } from './backends.svelte'; export { backendsStore } from './backends.svelte';
export { backendsModelsStore } from './backendsModels.svelte';
// MODELS // MODELS
export { modelsStore } from './models/index.svelte'; export { modelsStore } from './models/index.svelte';
+19 -1
View File
@@ -11,6 +11,8 @@ import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service'; import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps // direct imports between stores, not via the barrel, to avoid circular deps
import { backendsStore } from '$lib/stores/backends.svelte';
import { backendsModelsStore } from '$lib/stores/backendsModels.svelte';
import { conversationsStore } from '$lib/stores/conversations/index.svelte'; import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte'; import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte'; import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
@@ -350,11 +352,27 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
async switchBackend(): Promise<void> { async switchBackend(): Promise<void> {
this.status.unsubscribe(); this.status.unsubscribe();
this.clearSelection(); this.clearSelection();
this.models = [];
this.routerModels = []; this.routerModels = [];
this.error = null; this.error = null;
serverStore.clear(); serverStore.clear();
// prefer the prefetched list so switching does not refetch
const backend = backendsStore.active;
const cached = backend.baseUrl.trim() ? backendsModelsStore.get(backend.id) : null;
if (cached?.loaded) {
this.models = cached.models;
this.loading = false;
await serverStore.fetch();
if (this.models.length > 0) {
await this.ensureFirstModelSelected();
}
return;
}
await this.fetch(true); await this.fetch(true);
} }