ui : aggregate models across backends

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 ad967c6165
commit a4d6bf249f
8 changed files with 103 additions and 26 deletions
@@ -24,6 +24,7 @@
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
import { rawModelId } from '$lib/utils/model-option-id';
interface Props {
class?: string;
@@ -141,12 +142,13 @@
return;
}
const model = modelsStore.routerModels.find((m) => m.id === modelId);
const rawId = rawModelId(modelId);
const model = modelsStore.routerModels.find((m) => m.id === rawId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.status.unload(modelId);
await modelsStore.status.unload(rawId);
}
export function open() {
@@ -3,6 +3,7 @@ import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { backendsModelsStore, backendsStore, modelsStore, serverStore } from '$lib/stores';
import type { Backend } from '$lib/types';
import type { ModelOption } from '$lib/types/models';
import { rawModelId } from '$lib/utils/model-option-id';
import { onMount } from 'svelte';
export interface UseModelsSelectorOptions {
@@ -145,7 +146,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
const result = await onModelChange(rawModelId(option.id), option.model);
if (result === false) {
shouldCloseMenu = false;
@@ -56,7 +56,7 @@ class BackendsModelsStore {
return this.states[backendId] ?? EMPTY_STATE;
}
/** Prefetch every enabled external backend, skipping the active one. */
/** Prefetch every enabled backend's model list. */
async loadAll(): Promise<void> {
const enabled = backendsStore.enabled;
const ids = new Set(enabled.map((backend) => backend.id));
@@ -67,11 +67,7 @@ class BackendsModelsStore {
}
}
await Promise.all(
enabled
.filter((backend) => backend.id !== backendsStore.active.id)
.map((backend) => this.ensureLoaded(backend.id))
);
await Promise.all(enabled.map((backend) => this.ensureLoaded(backend.id)));
}
}
+58 -13
View File
@@ -18,14 +18,15 @@ import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { backendIdFromModelId, qualifyModelId, rawModelId } from '$lib/utils/model-option-id';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
class ModelsStore implements ModelPropsHost, ModelStatusHost {
activeModels = $state<ModelOption[]>([]);
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
loading = $state(false);
models = $state<ModelOption[]>([]);
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
@@ -86,6 +87,39 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
.map((m) => m.id);
}
/**
* Every selectable model across enabled backends. The active backend's
* models come from {@link activeModels}; the rest come from the background
* prefetch cache. Ids are backend-qualified so the same model name on two
* backends stays distinct.
*/
get models(): ModelOption[] {
const activeBackendId = backendsStore.active.id;
const merged: ModelOption[] = [];
for (const option of this.activeModels) {
merged.push({
...option,
backendId: activeBackendId,
id: qualifyModelId(activeBackendId, option.id)
});
}
for (const backend of backendsStore.enabled) {
if (backend.id === activeBackendId) continue;
for (const option of backendsModelsStore.get(backend.id).models) {
merged.push({
...option,
backendId: backend.id,
id: qualifyModelId(backend.id, option.id)
});
}
}
return merged;
}
get props() {
return this._props;
}
@@ -195,7 +229,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
async fetch(force = false): Promise<void> {
if (this.inflightFetch) return this.inflightFetch;
if (this.models.length > 0 && !force) return;
if (this.activeModels.length > 0 && !force) return;
this.inflightFetch = this.runFetch();
try {
@@ -219,7 +253,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.routerModels = response.data;
// keep the selector options in sync: a downloaded / deleted model shows
// up here too, not only in the router model rows
this.models = this.buildModelOptions(response);
this.activeModels = this.buildModelOptions(response);
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
@@ -290,9 +324,19 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
async selectModelById(modelId: string): Promise<void> {
if (!modelId || this.updating) return;
const backendId = backendIdFromModelId(modelId) ?? backendsStore.active.id;
const rawId = rawModelId(modelId);
// a model from another backend makes that backend active first
if (backendId !== backendsStore.active.id) {
backendsStore.setActive(backendId);
await backendsModelsStore.ensureLoaded(backendId);
await this.switchBackend();
}
if (this.selectedModelId === modelId) return;
const option = this.models.find((model) => model.id === modelId);
const option = this.activeModels.find((model) => model.id === rawId);
if (!option) throw new Error('Selected model is not available');
@@ -300,7 +344,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.error = null;
try {
this.selectedModelId = option.id;
this.selectedModelId = modelId;
this.selectedModelName = option.model;
} finally {
this.updating = false;
@@ -314,8 +358,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
void this.selectModelById(option.id);
}
}
@@ -361,12 +404,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const cached = backend.baseUrl.trim() ? backendsModelsStore.get(backend.id) : null;
if (cached?.loaded) {
this.models = cached.models;
this.activeModels = cached.models;
this.loading = false;
await serverStore.fetch();
if (this.models.length > 0) {
if (this.activeModels.length > 0) {
await this.ensureFirstModelSelected();
}
@@ -457,7 +500,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
* Filter to models visible in the UI (ui !== false).
*/
private getVisibleModels(): ModelOption[] {
return this.models.filter((option) => this.props.getModelProps(option.model)?.ui !== false);
return this.activeModels.filter(
(option) => this.props.getModelProps(option.model)?.ui !== false
);
}
private loadFavoritesFromStorage(): Set<string> {
@@ -487,7 +532,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const response = await ModelsService.list();
this.routerModels = response.data;
this.models = this.buildModelOptions(response);
this.activeModels = this.buildModelOptions(response);
await this.props.fetchModalitiesForLoadedModels();
@@ -497,7 +542,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.selectModelById(visible[0].id);
}
} else {
this.models = await this.fetchModelModeInternal();
this.activeModels = await this.fetchModelModeInternal();
// external backends expose a selectable list; pick a default so the
// first send and title generation have a model to target
@@ -506,7 +551,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
} catch (error) {
this.models = [];
this.activeModels = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
@@ -28,7 +28,7 @@ import { SvelteSet } from 'svelte/reactivity';
*/
export interface ModelPropsHost {
/** Model rows the manager mirrors fetched modalities onto. */
models: ModelOption[];
activeModels: ModelOption[];
readonly selectedModelName: string | null;
readonly loadedModelIds: string[];
isModelLoaded(modelId: string): boolean;
@@ -123,7 +123,7 @@ export class ModelPropsManager {
try {
const results = await Promise.all(propsPromises);
this.host.models = this.host.models.map((model) => {
this.host.activeModels = this.host.activeModels.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
@@ -195,7 +195,7 @@ export class ModelPropsManager {
return this.buildModalities(serverStore.props.modalities);
}
const model = this.host.models.find((m) => m.model === modelId || m.id === modelId);
const model = this.host.activeModels.find((m) => m.model === modelId || m.id === modelId);
if (model?.modalities) {
return model.modalities;
@@ -255,7 +255,7 @@ export class ModelPropsManager {
if (!props?.modalities) return;
this.host.models = this.host.models.map((model) =>
this.host.activeModels = this.host.activeModels.map((model) =>
model.model === modelId
? { ...model, modalities: this.buildModalities(props.modalities!) }
: model
+2
View File
@@ -16,6 +16,8 @@ export interface ModelOption {
id: string;
name: string;
model: string;
/** Backend that serves this model; set on the aggregated option list. */
backendId?: string;
description?: string;
capabilities: string[];
modalities?: ModelModalities;
+3
View File
@@ -127,6 +127,9 @@ export {
// Model name utilities
export { isValidModelName, normalizeModelName, orgOf } from './model-names';
// Backend-qualified model option ids
export { backendIdFromModelId, qualifyModelId, rawModelId } from './model-option-id';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
+28
View File
@@ -0,0 +1,28 @@
/**
* Backend-qualified model option ids.
*
* The selector lists models from every enabled backend, so a bare model id can
* collide. Option ids are qualified as `<backendId>::<rawModelId>`; selection
* strips the prefix to find the row in the active backend's list.
*/
const MODEL_OPTION_ID_SEPARATOR = '::';
/** Prefix a raw model id with the backend that serves it. */
export function qualifyModelId(backendId: string, modelId: string): string {
return `${backendId}${MODEL_OPTION_ID_SEPARATOR}${modelId}`;
}
/** Backend id of a qualified model option id, or null when unqualified. */
export function backendIdFromModelId(qualifiedId: string): string | null {
const index = qualifiedId.indexOf(MODEL_OPTION_ID_SEPARATOR);
return index === -1 ? null : qualifiedId.slice(0, index);
}
/** Strip the backend prefix from a qualified model option id. */
export function rawModelId(qualifiedId: string): string {
const index = qualifiedId.indexOf(MODEL_OPTION_ID_SEPARATOR);
return index === -1 ? qualifiedId : qualifiedId.slice(index + MODEL_OPTION_ID_SEPARATOR.length);
}