ui : rework the model selector around local and remote views

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 22:29:30 +02:00
parent ef01574d24
commit 3e2ae66a17
13 changed files with 415 additions and 213 deletions
@@ -1,70 +0,0 @@
<script lang="ts">
import { Loader2, Plus, Star } from '@lucide/svelte';
import { backendsModelsStore } from '$lib/stores';
import type { Backend } from '$lib/types';
interface Props {
activeId: string;
backends: Backend[];
favoritesActive?: boolean;
onAdd: () => void;
onSelect: (backendId: string) => void;
onSelectFavorites?: () => void;
}
let {
activeId,
backends,
favoritesActive = false,
onAdd,
onSelect,
onSelectFavorites
}: Props = $props();
const tabClass = (active: boolean) => [
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
];
</script>
<div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2">
{#if onSelectFavorites}
<button class={tabClass(favoritesActive)} onclick={onSelectFavorites} type="button">
<Star class="h-3 w-3" />
Favorites
</button>
{/if}
{#each backends as backend (backend.id)}
{@const state = backendsModelsStore.get(backend.id)}
<button
class={tabClass(activeId === backend.id)}
onclick={() => onSelect(backend.id)}
type="button"
>
{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>
{/each}
{#if backends.length === 0}
<span class="px-1 text-xs text-muted-foreground whitespace-nowrap">No backends configured</span>
{/if}
<button
aria-label="Add backend"
class="ml-auto shrink-0 rounded-full p-1 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={onAdd}
type="button"
>
<Plus class="h-3.5 w-3.5" />
</button>
</div>
@@ -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 @@
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
{:else if ms.options.length === 0 && ms.isMultiModel}
{#if currentModel}
<span
class={[
@@ -210,7 +212,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isMultiModel || ms.switchingBackends}
{#if ms.isMultiModel}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -251,7 +253,7 @@
{/if}
</span>
{#if ms.updating || ms.isLoadingModel || ms.switchingBackends}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
@@ -273,11 +275,11 @@
<DropdownMenu.Content
align="end"
class="w-full md:min-w-80 md:max-w-[26rem] max-w-[calc(100vw-2rem)] p-0!"
class="w-full md:min-w-80 md:w-96 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
emptyMessage={ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
emptyMessage={ms.emptyMessage}
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
@@ -285,15 +287,14 @@
searchClass="bg-transparent"
searchValue={ms.searchTerm}
>
<!-- Provider tabs (favorites first), above the option list. -->
<ModelsSelectorBackendSwitcher
activeId={ms.viewId}
backends={ms.backends}
favoritesActive={ms.isFavoritesView}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
onSelectFavorites={ms.showFavorites}
/>
<!-- View tabs (favorites first), sticky under the search input. -->
{#snippet subheader()}
<ModelsSelectorTabs
activeId={ms.viewId}
onAdd={handleAddBackend}
onSelect={ms.setView}
/>
{/snippet}
<!-- Option list; the search header sticks to the top and the actions
footer to the bottom of the content scrollport. -->
@@ -315,9 +316,7 @@
{/if}
{#if ms.isEmpty}
<p class="px-4 py-3 text-sm text-muted-foreground">
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
</p>
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{/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 @@
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { ModelsSelectorOption } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
@@ -16,6 +17,10 @@
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
/** Favorite models of every backend; shown on the favorites tab. */
favorites?: ModelItem[];
/** Open one provider's full list, offered when a section is cut short. */
onProviderOpen?: (backendId: string) => void;
/** Leave the drilled-in provider; enables the back affordance. */
onProviderBack?: () => void;
}
let {
@@ -24,11 +29,18 @@
favorites = [],
groups,
onInfoClick,
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
// section headers stick right below the search/tabs block of the dropdown
// scrollport; `--dropdown-sticky-height` is set by DropdownMenuSearchable
// and falls back to 0 in surfaces without one (the mobile sheet)
let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
/** In-flight / paused downloads, tracked by the status feed. */
let getDownloadEntries = $derived(modelsStore.status.getDownloadEntries());
@@ -70,7 +82,7 @@
{/each}
{#if getDownloadEntries.length > 0}
<p class={sectionHeaderClass}>Download in progress</p>
<p class={headerClass} style={headerStyle}>Download in progress</p>
{#each getDownloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
@@ -78,7 +90,7 @@
{/if}
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
<p class={headerClass} style={headerStyle}>Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, false)}
@@ -86,7 +98,7 @@
{/if}
{#if groups.available.length > 0}
<h2 class={sectionHeaderClass}>Downloaded models</h2>
<h2 class={headerClass} style={headerStyle}>Downloaded models</h2>
{#each groups.available as group (group.orgName)}
{#each group.items as item (item.option.id)}
@@ -95,11 +107,50 @@
{/each}
{/if}
{#if groups.external.length > 0}
{#each groups.external as item (`ext-${item.option.id}`)}
{@render render(item, true)}
{/each}
{/if}
<!-- Remote view: one section per backend. -->
{#each groups.providers as provider (provider.backendId)}
<p class="{headerClass} flex items-center gap-1.5" style={headerStyle}>
{#if onProviderBack}
<button
aria-label="Back to all providers"
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={onProviderBack}
type="button"
>
<ChevronLeft class="h-3.5 w-3.5" />
</button>
{/if}
{provider.name}
{#if provider.loading}
<Loader2 class="h-3 w-3 animate-spin" />
{:else if provider.error}
<CircleAlert class="h-3 w-3 text-destructive" />
{/if}
</p>
{#if provider.items.length > 0}
{#each provider.items as item (`${provider.backendId}-${item.option.id}`)}
{@render render(item, true)}
{/each}
{#if onProviderOpen && provider.matched > provider.items.length}
<!-- same box as a model row, it opens the provider's full list -->
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-foreground focus:outline-none"
onclick={() => onProviderOpen(provider.backendId)}
type="button"
>
+ {provider.matched - provider.items.length} more
</button>
{/if}
{:else if provider.catalog === 0}
<p class="px-4 pb-2 text-xs text-muted-foreground">
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
</p>
{/if}
{/each}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
@@ -48,6 +48,9 @@
showBaseModelAvatar = false
}: Props = $props();
// row actions follow the backend that serves the row, not the selected one
let rowBackend = $derived(getBackend(option.backendId));
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
@@ -196,7 +199,9 @@
{/if}
</div>
{#if isLoading}
{#if !canLoad}
<!-- remote rows have no load state, the column stays out of the way -->
{:else if isLoading}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
</div>
@@ -4,9 +4,9 @@
import {
DialogModelInformation,
ModelId,
ModelsSelectorBackendSwitcher,
ModelsSelectorList,
ModelsSelectorReasoningPanel,
ModelsSelectorTabs,
SearchInput
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
@@ -70,12 +70,12 @@
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isMultiModel && !ms.switchingBackends}
{:else if ms.options.length === 0 && ms.isMultiModel}
<p class="text-xs text-muted-foreground">No models available.</p>
{: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}
<button
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
@@ -127,7 +127,7 @@
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating || ms.isLoadingModel || ms.switchingBackends}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5 shrink-0" />
@@ -157,14 +157,11 @@
/>
</div>
<!-- Provider tabs (favorites first), above the option list. -->
<ModelsSelectorBackendSwitcher
<!-- View tabs (favorites first), above the option list. -->
<ModelsSelectorTabs
activeId={ms.viewId}
backends={ms.backends}
favoritesActive={ms.isFavoritesView}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
onSelectFavorites={ms.showFavorites}
onSelect={ms.setView}
/>
<div class="max-h-[60vh] overflow-y-auto px-2">
@@ -185,9 +182,7 @@
{/if}
{#if ms.isEmpty}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
</p>
<p class="px-3 py-3 text-center text-sm text-muted-foreground">{ms.emptyMessage}</p>
{/if}
<ModelsSelectorList
@@ -196,6 +191,8 @@
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
/>
@@ -245,5 +242,5 @@
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.handleBackendChange(backend.id)}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -0,0 +1,67 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import {
LOCAL_BACKEND_ID,
MODELS_VIEW_FAVORITES,
MODELS_VIEW_LOCAL,
MODELS_VIEW_REMOTE
} from '$lib/constants';
import { backendsStore } from '$lib/stores';
interface Props {
activeId: string;
onAdd: () => void;
onSelect: (viewId: string) => void;
}
let { activeId, onAdd, onSelect }: Props = $props();
const tabClass = (active: boolean) => [
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
];
// the remote view is only offered while a remote backend exists
const hasRemoteBackends = $derived(
backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
);
</script>
<div class="flex items-center gap-1 overflow-x-auto px-2 py-2">
<button
class={tabClass(activeId === MODELS_VIEW_FAVORITES)}
onclick={() => onSelect(MODELS_VIEW_FAVORITES)}
type="button"
>
Favorites
</button>
<button
class={tabClass(activeId === MODELS_VIEW_LOCAL)}
onclick={() => onSelect(MODELS_VIEW_LOCAL)}
type="button"
>
Local
</button>
{#if hasRemoteBackends}
<button
class={tabClass(activeId === MODELS_VIEW_REMOTE)}
onclick={() => onSelect(MODELS_VIEW_REMOTE)}
type="button"
>
Remote
</button>
{/if}
<button
aria-label="Add backend"
class="ml-auto shrink-0 rounded-full p-1 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={onAdd}
type="button"
>
<Plus class="h-3.5 w-3.5" />
</button>
</div>
@@ -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
@@ -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);
</script>
<div class="sticky top-0 z-20 p-1.5">
<div bind:clientHeight={stickyHeaderHeight} class="sticky top-0 z-20 bg-popover p-1.5">
<SearchInput
bind:value={searchValue}
class={searchClass}
@@ -45,14 +52,21 @@
onKeyDown={onSearchKeyDown}
{placeholder}
/>
{#if subheader}
{@render subheader()}
{/if}
</div>
<div class={contentClass}>
{@render children()}
<!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
<div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
{@render children()}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
</div>
{#if footer}
@@ -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<string, ModelItem[]>();
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<string, ModelItem[]>();
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 };
});
}
+7 -2
View File
@@ -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;
@@ -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<typeof groupModelOptions>;
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<void>;
setShowModelDialog(value: boolean): void;
handleInfoClick(modelName: string): void;
handleBackendChange(backendId: string): Promise<void>;
handleSelect(modelId: string): Promise<void>;
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<string>(
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<string | null>(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<string | null>(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<void> {
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;
},
@@ -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<void>;
isModelLoaded(modelId: string): boolean;
switchBackend(): Promise<void>;
toDisplayName(id: string): string;
}
@@ -109,6 +113,8 @@ export class ModelStatusManager {
* feed's model_remove event.
*/
async cancelDownload(repoWithTag: string): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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(
@@ -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}