diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte deleted file mode 100644 index 6152de8ec0..0000000000 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
- {#if onSelectFavorites} - - {/if} - - {#each backends as backend (backend.id)} - {@const state = backendsModelsStore.get(backend.id)} - - {/each} - - {#if backends.length === 0} - No backends configured - {/if} - - -
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 96a300d284..192ebdb63b 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorDropdown.svelte @@ -5,10 +5,10 @@ DialogModelInformation, DropdownMenuSearchable, ModelId, - ModelsSelectorBackendSwitcher, ModelsSelectorList, ModelsSelectorOption, - ModelsSelectorReasoningPanel + ModelsSelectorReasoningPanel, + ModelsSelectorTabs } from '$lib/components/app'; import { DialogBackendForm } from '$lib/components/app/backends'; import type { ModelItem } from '$lib/components/app/navigation/utils'; @@ -94,7 +94,9 @@ for (const group of ms.groupedFilteredOptions.available) { for (const item of group.items) order.push(item.option.id); } - for (const item of ms.groupedFilteredOptions.external) order.push(item.option.id); + for (const provider of ms.groupedFilteredOptions.providers) { + for (const item of provider.items) order.push(item.option.id); + } return order; }); @@ -178,13 +180,13 @@
- {#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends} + {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends} + {:else if ms.options.length === 0 && ms.isMultiModel} {#if currentModel} @@ -251,7 +253,7 @@ {/if} - {#if ms.updating || ms.isLoadingModel || ms.switchingBackends} + {#if ms.updating || ms.isLoadingModel} {:else} @@ -273,11 +275,11 @@ event.preventDefault()} > ms.setSearchTerm(v)} onSearchKeyDown={handleSearchKeyDown} @@ -285,15 +287,14 @@ searchClass="bg-transparent" searchValue={ms.searchTerm} > - - void ms.handleBackendChange(backendId)} - onSelectFavorites={ms.showFavorites} - /> + + {#snippet subheader()} + + {/snippet} @@ -315,9 +316,7 @@ {/if} {#if ms.isEmpty} -

- {ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'} -

+

{ms.emptyMessage}

{/if} {#snippet modelOption(item: ModelItem, _hideOrgName: boolean)} @@ -351,6 +350,8 @@ favorites={ms.isFavoritesView ? ms.favoriteItems : []} groups={ms.groupedFilteredOptions} onInfoClick={ms.handleInfoClick} + onProviderBack={ms.isProviderView ? ms.closeProvider : undefined} + onProviderOpen={ms.openProvider} onSelect={ms.handleSelect} renderOption={modelOption} sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none" @@ -436,5 +437,5 @@ void ms.handleBackendChange(backend.id)} + onSaved={(backend) => void ms.showBackendModels(backend.id)} /> diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte index 0401f159f2..1d7b03e824 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte @@ -1,5 +1,6 @@
- {#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends} + {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends} + {:else if ms.options.length === 0 && ms.isMultiModel}

No models available.

{:else} {@const selectedOption = ms.getDisplayOption()} @@ -91,7 +91,7 @@ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100) : 0} - {#if ms.isMultiModel || ms.switchingBackends} + {#if ms.isMultiModel}
- - + void ms.handleBackendChange(backendId)} - onSelectFavorites={ms.showFavorites} + onSelect={ms.setView} />
@@ -185,9 +182,7 @@ {/if} {#if ms.isEmpty} -

- {ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'} -

+

{ms.emptyMessage}

{/if} @@ -245,5 +242,5 @@ void ms.handleBackendChange(backend.id)} + onSaved={(backend) => void ms.showBackendModels(backend.id)} /> diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTabs.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTabs.svelte new file mode 100644 index 0000000000..c3354b62fa --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTabs.svelte @@ -0,0 +1,67 @@ + + +
+ + + + + {#if hasRemoteBackends} + + {/if} + + +
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts index 97df9e231d..0115f6edef 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/index.ts @@ -46,7 +46,14 @@ */ export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte'; -export { default as ModelsSelectorBackendSwitcher } from './ModelsSelectorBackendSwitcher.svelte'; +/** + * **ModelsSelectorTabs** - View tabs for the selector + * + * Switches the rendered view: the favorites of every backend, the local server's + * models, or the remote backends'. Purely a display concern - the backend that + * serves requests follows the selected model. + */ +export { default as ModelsSelectorTabs } from './ModelsSelectorTabs.svelte'; /** * **ModelsSelectorList** - Grouped model options list diff --git a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte index 5a4474dc84..f69d11e1f7 100644 --- a/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte +++ b/tools/ui/src/lib/components/app/navigation/DropdownMenuSearchable.svelte @@ -21,6 +21,8 @@ * overflow-y-auto and a max-height) and must not be `overflow-hidden`. */ footer?: Snippet; + /** Extra sticky content under the search input, e.g. the view tabs. */ + subheader?: Snippet; } let { @@ -33,11 +35,16 @@ onSearchKeyDown, placeholder = 'Search...', searchClass = '', - searchValue = $bindable('') + searchValue = $bindable(''), + subheader }: Props = $props(); + + // the search and the subheader stick as one block; its height is published so + // list section headers can stick right below it + let stickyHeaderHeight = $state(0); -
+
+ + {#if subheader} + {@render subheader()} + {/if}
- {@render children()} + +
+ {@render children()} - {#if isEmpty} -
{emptyMessage}
- {/if} + {#if isEmpty} +
{emptyMessage}
+ {/if} +
{#if footer} diff --git a/tools/ui/src/lib/components/app/navigation/utils.ts b/tools/ui/src/lib/components/app/navigation/utils.ts index f7f351e698..c7e37313c6 100644 --- a/tools/ui/src/lib/components/app/navigation/utils.ts +++ b/tools/ui/src/lib/components/app/navigation/utils.ts @@ -12,11 +12,25 @@ export interface OrgGroup { items: ModelItem[]; } +/** One remote backend's section on the remote view. */ +export interface ProviderGroup { + backendId: string; + /** Models the provider lists without any search filtering. */ + catalog: number; + error: string | null; + /** Rows to render, capped by the display limit. */ + items: ModelItem[]; + loading: boolean; + /** Rows left after the search filter, before the cap. */ + matched: number; + name: string; +} + export interface GroupedModelOptions { - loaded: ModelItem[]; available: OrgGroup[]; - /** Models served by other backends; rendered flat, without categories. */ - external: ModelItem[]; + loaded: ModelItem[]; + /** Remote backends, one section each. */ + providers: ProviderGroup[]; } function matchesModality(option: ModelOption, term: string): boolean { @@ -72,8 +86,7 @@ export function groupFavoriteOptions( export function groupModelOptions( filteredOptions: ModelOption[], - isModelLoaded: (model: string) => boolean, - isLocalModel: (option: ModelOption) => boolean = () => true + isModelLoaded: (model: string) => boolean ): GroupedModelOptions { // Loaded models const loaded: ModelItem[] = []; @@ -89,8 +102,6 @@ export function groupModelOptions( const loadedModelIds = new Set(loaded.map((item) => item.option.model)); // Available models grouped by org (excluding loaded) const available: OrgGroup[] = []; - // models from other backends have no load state, so they stay flat - const external: ModelItem[] = []; const orgGroups = new SvelteMap(); for (let i = 0; i < filteredOptions.length; i++) { @@ -98,12 +109,6 @@ export function groupModelOptions( if (loadedModelIds.has(option.model)) continue; - if (!isLocalModel(option)) { - external.push({ flatIndex: i, option }); - - continue; - } - const key = option.parsedId?.orgName ?? ''; if (!orgGroups.has(key)) orgGroups.set(key, []); @@ -115,5 +120,39 @@ export function groupModelOptions( available.push({ items, orgName: orgName || null }); } - return { available, external, loaded }; + return { available, loaded, providers: [] }; +} + +/** + * Remote backends as sections, one per backend, in the given order. Each section + * keeps at most `limit` rows; `matched` carries the full count so the caller can + * offer the rest. + */ +export function groupProviderOptions( + options: ModelOption[], + providers: { + backendId: string; + catalog: number; + error: string | null; + loading: boolean; + name: string; + }[], + limit = Infinity +): ProviderGroup[] { + const byBackend = new SvelteMap(); + + for (let i = 0; i < options.length; i++) { + const option = options[i]; + const backendId = option.backendId ?? ''; + + if (!byBackend.has(backendId)) byBackend.set(backendId, []); + + byBackend.get(backendId)!.push({ flatIndex: i, option }); + } + + return providers.map((provider) => { + const items = byBackend.get(provider.backendId) ?? []; + + return { ...provider, items: items.slice(0, limit), matched: items.length }; + }); } diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts index b4beb9c242..2069a074a3 100644 --- a/tools/ui/src/lib/constants/ui.constants.ts +++ b/tools/ui/src/lib/constants/ui.constants.ts @@ -50,8 +50,13 @@ export const DARK_INVERT_AVATAR_ORGS = ['openai']; /** Icon used for the model selector and the `/model` slash command. */ export const MODEL_SELECTOR_ICON = Package; -/** Pseudo backend tab listing the favorites of every backend. */ -export const FAVORITES_TAB_ID = 'favorites'; +/** Models listed per remote provider before the "+ X more" line; search covers the rest. */ +export const REMOTE_PROVIDER_MODEL_LIMIT = 12; + +/** Model selector views: the favorites of every backend, the local server, the remote backends. */ +export const MODELS_VIEW_FAVORITES = 'favorites'; +export const MODELS_VIEW_LOCAL = 'local'; +export const MODELS_VIEW_REMOTE = 'remote'; export const ICON_STRIP_TRANSITION_DURATION = 150; export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50; diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts index a235820745..af1cfc69c8 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -2,17 +2,24 @@ import type { ModelItem } from '$lib/components/app/navigation/utils'; import { filterModelOptions, groupFavoriteOptions, - groupModelOptions + groupModelOptions, + groupProviderOptions } from '$lib/components/app/navigation/utils'; -import { CHAT_INPUT_FOCUS_SELECTOR, FAVORITES_TAB_ID, LOCAL_BACKEND_ID } from '$lib/constants'; +import { + CHAT_INPUT_FOCUS_SELECTOR, + LOCAL_BACKEND_ID, + MODELS_VIEW_FAVORITES, + MODELS_VIEW_LOCAL, + MODELS_VIEW_REMOTE, + REMOTE_PROVIDER_MODEL_LIMIT +} 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'; /** Groups of the favorites tab, which lists favorites only. */ -const EMPTY_GROUPS = { available: [], external: [], loaded: [] }; +const EMPTY_GROUPS = { available: [], loaded: [], providers: [] }; export interface UseModelsSelectorOptions { currentModel: () => string | null; @@ -28,8 +35,7 @@ export interface UseModelsSelectorReturn { readonly loading: boolean; readonly updating: boolean; readonly activeId: string | null; - readonly activeBackendId: string; - readonly backends: Backend[]; + readonly emptyMessage: string; readonly isMultiModel: boolean; readonly isRouter: boolean; readonly serverModel: string | null; @@ -39,18 +45,20 @@ export interface UseModelsSelectorReturn { readonly filteredOptions: ModelOption[]; readonly isFavoritesView: boolean; readonly isEmpty: boolean; + readonly isProviderView: boolean; readonly groupedFilteredOptions: ReturnType; readonly isLoadingModel: boolean; - readonly switchingBackends: boolean; readonly searchTerm: string; readonly showModelDialog: boolean; readonly infoModelId: string | null; readonly viewId: string; - showFavorites(): void; + closeProvider(): void; + openProvider(backendId: string): void; setSearchTerm(value: string): void; + setView(viewId: string): void; + showBackendModels(backendId: string): Promise; setShowModelDialog(value: boolean): void; handleInfoClick(modelName: string): void; - handleBackendChange(backendId: string): Promise; handleSelect(modelId: string): Promise; handleOpenChange(open: boolean): void; isFavorite(model: string): boolean; @@ -66,16 +74,15 @@ export interface UseModelsSelectorReturn { */ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn { /** - * Tab the selector shows: a backend id, or the favorites pseudo tab. Favorites - * are the default view while there is at least one. + * Current view: the favorites of every backend, the local server's models, or + * the remote backends'. Favorites are the default while there is at least one. */ let viewId = $state( - modelsStore.favoriteModelIds.size > 0 ? FAVORITES_TAB_ID : backendsStore.active.id + modelsStore.favoriteModelIds.size > 0 ? MODELS_VIEW_FAVORITES : MODELS_VIEW_LOCAL ); - const activeBackendId = $derived(backendsStore.active.id); - // every enabled backend's models stay selectable and resolvable, so a - // model never turns unavailable just because another tab is open + // every enabled backend's models stay selectable and resolvable, so a model + // never turns unavailable just because another view is open const allOptions = $derived( modelsStore.models.filter((option) => { const modelProps = modelsStore.props.getModelProps(option.model); @@ -83,16 +90,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return modelProps?.ui !== false; }) ); - // the switcher tabs scope the rendered list to one backend's models; the - // favorites tab is not a backend and lists every backend's favorites - const isFavoritesView = $derived(viewId === FAVORITES_TAB_ID); - const options = $derived( - isFavoritesView ? allOptions : allOptions.filter((option) => option.backendId === viewId) - ); + const isFavoritesView = $derived(viewId === MODELS_VIEW_FAVORITES); + const isRemoteView = $derived(viewId === MODELS_VIEW_REMOTE); + + /** Remote backend drilled into from the remote list; null while browsing sections. */ + let providerViewId = $state(null); + + const isProviderView = $derived(providerViewId !== null); + const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID; + const options = $derived.by(() => { + if (isFavoritesView) return allOptions; + + if (providerViewId) { + return allOptions.filter((option) => option.backendId === providerViewId); + } + + return allOptions.filter((option) => + isRemoteView ? !isLocalOption(option) : isLocalOption(option) + ); + }); const loading = $derived(modelsStore.loading); 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, and // configured backends always need the tabs; only a lone llama.cpp server // without a router has nothing to list. @@ -117,7 +136,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele }); let isLoadingModel = $state(false); - let switchingBackends = $state(false); let searchTerm = $state(''); let showModelDialog = $state(false); let infoModelId = $state(null); @@ -127,18 +145,54 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele const favoriteItems = $derived( groupFavoriteOptions(filterModelOptions(allOptions, searchTerm), modelsStore.favoriteModelIds) ); - const groupedFilteredOptions = $derived( - isFavoritesView - ? EMPTY_GROUPS - : groupModelOptions( - filteredOptions, - (m) => modelsStore.isModelLoaded(m), - (option) => option.backendId === LOCAL_BACKEND_ID - ) + const remoteProviders = $derived( + backendsStore.enabled + .filter((backend) => backend.id !== LOCAL_BACKEND_ID) + .map((backend) => { + const state = backendsModelsStore.get(backend.id); + + return { + backendId: backend.id, + catalog: state.models.length, + error: state.error, + loading: state.loading, + name: backend.name + }; + }) ); + const providerSections = $derived( + groupProviderOptions( + filteredOptions, + remoteProviders, + // a drill-in or a search reaches every model, the sections stay short + providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT + ) + ); + const groupedFilteredOptions = $derived.by(() => { + if (isFavoritesView) return EMPTY_GROUPS; + + if (isRemoteView || isProviderView) { + const sections = providerViewId + ? providerSections.filter((section) => section.backendId === providerViewId) + : providerSections; + + return { ...EMPTY_GROUPS, providers: sections }; + } + + return groupModelOptions(filteredOptions, (m) => modelsStore.isModelLoaded(m)); + }); const isEmpty = $derived( isFavoritesView ? favoriteItems.length === 0 : filteredOptions.length === 0 ); + const emptyMessage = $derived( + searchTerm + ? 'No models found.' + : isFavoritesView + ? 'No favorite models yet.' + : isRemoteView + ? 'No remote models.' + : 'No local models.' + ); function handleInfoClick(modelName: string) { infoModelId = modelName; @@ -163,6 +217,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele } searchTerm = ''; + providerViewId = null; if (open && isRouter) { modelsStore.props.fetchModalitiesForLoadedModels(); @@ -171,27 +226,32 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele opts.onOpenChange?.(open); } - async function handleBackendChange(backendId: string) { - viewId = backendId; + /** + * Switch the rendered view. Views are display only: the backend that serves + * requests follows the selected model, not the view. + */ + function setView(nextViewId: string) { + viewId = nextViewId; + providerViewId = null; searchTerm = ''; + } - if (backendId === backendsStore.active.id) return; + /** Drill into one remote backend's full model list. */ + function openProvider(backendId: string) { + providerViewId = backendId; + searchTerm = ''; + } - backendsStore.setActive(backendId); + function closeProvider() { + providerViewId = null; + searchTerm = ''; + } - // keep the multi-model selector mounted across the swap; a role flip - // (external MODEL mode -> local ROUTER mode) would otherwise unmount and - // remount the open dropdown - switchingBackends = true; + /** Show a backend's models, e.g. right after it was added. */ + async function showBackendModels(backendId: string): Promise { + setView(backendId === LOCAL_BACKEND_ID ? MODELS_VIEW_LOCAL : MODELS_VIEW_REMOTE); - try { - await backendsModelsStore.ensureLoaded(backendId); - await modelsStore.switchBackend(); - } catch (error) { - console.error('Failed to switch backend:', error); - } finally { - switchingBackends = false; - } + await backendsModelsStore.ensureLoaded(backendId); } async function handleSelect(modelId: string) { @@ -282,16 +342,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele } return { - get activeBackendId() { - return activeBackendId; - }, - get activeId() { return activeId; }, - get backends() { - return backends; + closeProvider, + + get emptyMessage() { + return emptyMessage; }, get favoriteItems() { @@ -308,8 +366,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return groupedFilteredOptions; }, - handleBackendChange, - handleInfoClick, handleOpenChange, @@ -348,6 +404,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return isMultiModel; }, + get isProviderView() { + return isProviderView; + }, + get isRouter() { return isRouter; }, @@ -356,6 +416,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return loading; }, + openProvider, + get options() { return options; }, @@ -376,19 +438,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele showModelDialog = value; }, - showFavorites() { - viewId = FAVORITES_TAB_ID; - searchTerm = ''; - }, + setView, + + showBackendModels, get showModelDialog() { return showModelDialog; }, - get switchingBackends() { - return switchingBackends; - }, - get updating() { return updating; }, diff --git a/tools/ui/src/lib/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts index 833f8b3f93..c2d03f8c2e 100644 --- a/tools/ui/src/lib/stores/models/status.svelte.ts +++ b/tools/ui/src/lib/stores/models/status.svelte.ts @@ -10,6 +10,7 @@ import { CLI_FLAGS, HF_UD_QUANT_PREFIX_REGEX, + LOCAL_BACKEND_ID, MODEL_ID, PATH_SEPARATOR, PAUSED_MODEL_DOWNLOADS_LOCALSTORAGE_KEY @@ -17,8 +18,10 @@ import { import { ModelDownloadStopRequest, ServerModelsSseEventType, ServerModelStatus } from '$lib/enums'; import { HuggingFaceService } from '$lib/services/huggingface.service'; import { ModelsService } from '$lib/services/models.service'; -import type { ModelPropsManager } from '$lib/stores/models/props.svelte'; +import { backendsStore } from '$lib/stores/backends.svelte'; // direct imports between stores, not via the barrel, to avoid circular deps +import { backendsModelsStore } from '$lib/stores/backendsModels.svelte'; +import type { ModelPropsManager } from '$lib/stores/models/props.svelte'; import { serverStore } from '$lib/stores/server.svelte'; // explicit type imports: the app.d.ts globals resolve to `any`, so import the real types import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types'; @@ -37,6 +40,7 @@ export interface ModelStatusHost { routerModels: ApiModelDataEntry[]; fetchRouterModels(): Promise; isModelLoaded(modelId: string): boolean; + switchBackend(): Promise; toDisplayName(id: string): string; } @@ -109,6 +113,8 @@ export class ModelStatusManager { * feed's model_remove event. */ async cancelDownload(repoWithTag: string): Promise { + await this.ensureLocalTarget(); + if (!serverStore.isRouterMode) { toast.error('Model downloads are only available in router mode'); @@ -154,6 +160,8 @@ export class ModelStatusManager { * waiter is registered here. */ async cancelLoad(modelId: string): Promise { + await this.ensureLocalTarget(); + if (!serverStore.isRouterMode) return; this.subscribe(); @@ -190,6 +198,8 @@ export class ModelStatusManager { * (same tag) continues from the partial files the pause kept on disk. */ async downloadModel(repoWithTag: string): Promise { + await this.ensureLocalTarget(); + if (!serverStore.isRouterMode) { toast.error('Model downloads are only available in router mode'); @@ -320,6 +330,8 @@ export class ModelStatusManager { } async load(modelId: string): Promise { + await this.ensureLocalTarget(); + if (this.host.isModelLoaded(modelId)) return; if (this.loadingStates.get(modelId)) return; @@ -356,6 +368,8 @@ export class ModelStatusManager { * reports the stop as download_failed; a 'pause' stop request marks it as such. */ async pauseDownload(repoWithTag: string): Promise { + await this.ensureLocalTarget(); + if (!serverStore.isRouterMode) { toast.error('Model downloads are only available in router mode'); @@ -374,10 +388,6 @@ export class ModelStatusManager { } } - /** - * Open the /models/sse feed and keep it live with auto reconnect. - * Idempotent and router mode only. - */ subscribe(): void { if (this.statusReaderActive) return; @@ -389,6 +399,8 @@ export class ModelStatusManager { } async unload(modelId: string): Promise { + await this.ensureLocalTarget(); + if (!this.host.isModelLoaded(modelId)) return; if (this.loadingStates.get(modelId)) return; @@ -586,6 +598,23 @@ export class ModelStatusManager { return true; } + /** + * Open the /models/sse feed and keep it live with auto reconnect. + * Idempotent and router mode only. + */ + /** + * Load, unload and download only exist on the local server. Make it the + * target first when the action comes from a row while a remote backend is + * the selected one. + */ + private async ensureLocalTarget(): Promise { + if (backendsStore.active.id === LOCAL_BACKEND_ID) return; + + backendsStore.setActive(LOCAL_BACKEND_ID); + await backendsModelsStore.ensureLoaded(LOCAL_BACKEND_ID); + await this.host.switchBackend(); + } + private persistPausedDownloads(): void { try { localStorage.setItem( diff --git a/tools/ui/tests/stories/ModelsSelector.stories.svelte b/tools/ui/tests/stories/ModelsSelector.stories.svelte index 0a22806ae0..800d185f53 100644 --- a/tools/ui/tests/stories/ModelsSelector.stories.svelte +++ b/tools/ui/tests/stories/ModelsSelector.stories.svelte @@ -103,8 +103,8 @@ orgName: 'intel' } ], - external: [], - loaded: loadedModels + loaded: loadedModels, + providers: [] }; function handleSelect(modelId: string) { @@ -139,8 +139,8 @@ currentModel={null} groups={{ available: [], - external: [], - loaded: [loadedModels[0]] + loaded: [loadedModels[0]], + providers: [] }} onInfoClick={(modelName) => console.log('Info clicked:', modelName)} onSelect={handleSelect} @@ -156,8 +156,8 @@ favorites={favoriteModels} groups={{ available: [], - external: [], - loaded: [] + loaded: [], + providers: [] }} onInfoClick={(modelName) => console.log('Info clicked:', modelName)} onSelect={handleSelect}