ui : make the manager rail pick providers, not models

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:11 +02:00
parent 5cd229fcff
commit 681c160749
5 changed files with 112 additions and 201 deletions
@@ -9,7 +9,7 @@
loadOverrides,
type ModelOverride,
type ModelsListFilter,
type ModelsListGroup,
type ModelsProviderGroup,
saveOverrides
} from './utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
@@ -28,15 +28,17 @@
let filter = $state('');
let view = $state<ModelsListFilter>('all');
let selectedBackendId = $state<string | null>(null);
let selectedId = $state<string | null>(null);
let overrides = $state<Record<string, ModelOverride>>(loadOverrides());
let allModels = $derived(modelsStore.models);
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
let favorites = $derived(
allModels.filter((option) => modelsStore.favoriteModelIds.has(option.model))
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
let matches = $derived.by(() => {
// the rail counts follow the active view and filter, so it always says how many
// models each provider contributes to what the table is showing
let visible = $derived.by(() => {
const term = filter.trim().toLowerCase();
return allModels.filter((option) => {
@@ -49,25 +51,23 @@
return true;
});
});
let groups = $derived.by(() => {
const byBackend = new SvelteMap<string, ModelOption[]>();
let providers = $derived.by(() => {
const counts = new SvelteMap<string, number>();
for (const option of matches) {
for (const option of visible) {
const backendId = option.backendId ?? LOCAL_BACKEND_ID;
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push(option);
counts.set(backendId, (counts.get(backendId) ?? 0) + 1);
}
const ordered: ModelsListGroup[] = [];
const localItems = byBackend.get(LOCAL_BACKEND_ID);
const ordered: ModelsProviderGroup[] = [];
const localCount = counts.get(LOCAL_BACKEND_ID);
if (localItems?.length) {
if (localCount) {
ordered.push({
backendId: LOCAL_BACKEND_ID,
count: localCount,
isLocal: true,
items: localItems,
label: 'This server'
});
}
@@ -75,15 +75,26 @@
for (const backend of backendsStore.enabled) {
if (backend.id === LOCAL_BACKEND_ID) continue;
const items = byBackend.get(backend.id);
const count = counts.get(backend.id);
if (items?.length) {
ordered.push({ backendId: backend.id, isLocal: false, items, label: backend.name });
if (count) {
ordered.push({
backendId: backend.id,
count,
isLocal: false,
label: backend.name
});
}
}
return ordered;
});
let matches = $derived(
selectedBackendId === null
? visible
: visible.filter((option) => (option.backendId ?? LOCAL_BACKEND_ID) === selectedBackendId)
);
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
let summary = $derived.by(() => {
const local = matches.filter(isLocalOption);
const bytes = local.reduce((total, option) => {
@@ -102,7 +113,12 @@
.join(' · ');
});
let title = $derived(
view === 'favorites' ? 'Favorites' : view === 'loaded' ? 'Loaded models' : 'All models'
view === 'favorites'
? 'Favorites'
: view === 'loaded'
? 'Loaded models'
: (providers.find((provider) => provider.backendId === selectedBackendId)?.label ??
'All models')
);
function toggleFavorite(option: ModelOption): void {
@@ -138,24 +154,22 @@
<div
class="grid min-h-0 flex-1"
style="grid-template-columns: 20rem minmax(0, 1fr){selected ? ' 30rem' : ''};"
style="grid-template-columns: 16rem minmax(0, 1fr){selected ? ' 30rem' : ''};"
>
<div class="min-h-0 border-r border-border/40 px-3 py-3">
<div class="min-h-0 border-r border-border/40 px-2 py-3">
<ModelsManagerModelsList
bind:filter
bind:view
{favorites}
{groups}
isFavorite={(option) => modelsStore.favoriteModelIds.has(option.model)}
onSelect={(option) => (selectedId = option.id)}
onToggleFavorite={toggleFavorite}
{selectedId}
onSelect={(backendId) => (selectedBackendId = backendId)}
{providers}
{selectedBackendId}
totalCount={visible.length}
/>
</div>
<div class="min-h-0">
<ModelsManagerModelsTable
isFavorite={(option) => modelsStore.favoriteModelIds.has(option.model)}
bind:filter
bind:view
{isFavorite}
models={matches}
onCopyId={copyId}
onGetModels={() => onOpenDiscover?.()}
@@ -1,83 +1,39 @@
<script lang="ts">
import type { ModelsListFilter, ModelsListGroup } from '../utils';
import ModelsManagerModelsListControls from './ModelsManagerModelsListControls.svelte';
import type { ModelsProviderGroup } from '../utils';
import ModelsManagerModelsListItem from './ModelsManagerModelsListItem.svelte';
import { Heart } from '@lucide/svelte';
import { Logo, ModelsSection } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
favorites: ModelOption[];
filter?: string;
groups: ModelsListGroup[];
isFavorite: (option: ModelOption) => boolean;
onSelect: (option: ModelOption) => void;
onToggleFavorite: (option: ModelOption) => void;
selectedId: string | null;
view?: ModelsListFilter;
onSelect: (backendId: string | null) => void;
providers: ModelsProviderGroup[];
selectedBackendId: string | null;
totalCount: number;
}
let {
favorites,
filter = $bindable(''),
groups,
isFavorite,
onSelect,
onToggleFavorite,
selectedId,
view = $bindable<ModelsListFilter>('all')
}: Props = $props();
let { onSelect, providers, selectedBackendId, totalCount }: Props = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-3">
<ModelsManagerModelsListControls bind:filter bind:view />
<div class="flex h-full min-h-0 flex-col gap-1">
<p class="px-2 py-1 text-[13px] font-semibold text-muted-foreground/70 select-none">Providers</p>
<div class="min-h-0 flex-1 space-y-1 overflow-y-auto">
{#if favorites.length > 0}
<ModelsSection count={favorites.length} label="Favorites" sticky>
{#snippet icon()}
<Heart class="h-3.5 w-3.5 shrink-0" />
{/snippet}
<div class="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
<ModelsManagerModelsListItem
backendId={null}
count={totalCount}
isActive={selectedBackendId === null}
isLocal={false}
label="All providers"
onSelect={() => onSelect(null)}
/>
{#each favorites as option (option.id)}
<ModelsManagerModelsListItem
isFavorite={isFavorite(option)}
isSelected={selectedId === option.id}
onSelect={() => onSelect(option)}
onToggleFavorite={() => onToggleFavorite(option)}
{option}
/>
{/each}
</ModelsSection>
{/if}
{#each groups as group (group.backendId)}
<ModelsSection
backendId={group.isLocal ? undefined : group.backendId}
count={group.items.length}
label={group.label}
sticky
>
{#snippet icon()}
{#if group.isLocal}
<Logo class="shrink-0" style="--size: 0.875rem" />
{/if}
{/snippet}
{#each group.items as option (option.id)}
<ModelsManagerModelsListItem
isFavorite={isFavorite(option)}
isSelected={selectedId === option.id}
onSelect={() => onSelect(option)}
onToggleFavorite={() => onToggleFavorite(option)}
{option}
/>
{/each}
</ModelsSection>
{#each providers as provider (provider.backendId)}
<ModelsManagerModelsListItem
backendId={provider.backendId}
count={provider.count}
isActive={selectedBackendId === provider.backendId}
isLocal={provider.isLocal}
label={provider.label}
onSelect={() => onSelect(provider.backendId)}
/>
{/each}
{#if groups.length === 0 && favorites.length === 0}
<p class="px-2 py-6 text-center text-sm text-muted-foreground">No models found.</p>
{/if}
</div>
</div>
@@ -1,109 +1,40 @@
<script lang="ts">
import { modelQuantLabel, modelSizeLabel } from '../utils';
import { ChevronDown, ChevronUp, Heart } from '@lucide/svelte';
import { ModelCapabilityIcons } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { Boxes } from '@lucide/svelte';
import { Logo } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { getBackend } from '$lib/utils/api-base';
interface Props {
isFavorite: boolean;
isSelected: boolean;
/** Null renders the aggregate entry, which lists every provider's models. */
backendId: string | null;
count: number;
isActive: boolean;
isLocal: boolean;
label: string;
onSelect: () => void;
onToggleFavorite: () => void;
option: ModelOption;
}
let { isFavorite, isSelected, onSelect, onToggleFavorite, option }: Props = $props();
let expanded = $state(true);
let serverStatus = $derived.by(() => {
const model = modelsStore.routerModels.find((m) => m.id === option.model);
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
let quant = $derived(modelQuantLabel(option));
let size = $derived(modelSizeLabel(option));
let supportsToolUse = $derived(option.capabilities.includes(ModelCapability.TOOL_USE));
let supportsThinking = $derived(option.capabilities.includes(ModelCapability.REASONING));
let { backendId, count, isActive, isLocal, label, onSelect }: Props = $props();
</script>
<div
<button
aria-pressed={isActive}
class={[
'group rounded-md transition',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/50'
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/50'
]}
onclick={onSelect}
type="button"
>
<div class="flex items-center gap-1.5 px-2 py-1.5">
<button
aria-label="Select model"
class="shrink-0 cursor-pointer"
onclick={onSelect}
type="button"
>
{#if isLoading}
<span class="block h-2 w-2 animate-pulse rounded-full bg-amber-500"></span>
{:else if isFailed}
<span class="block h-2 w-2 rounded-full bg-destructive"></span>
{:else}
<span
class="block h-2 w-2 rounded-full {isLoaded
? 'bg-emerald-500'
: 'border border-muted-foreground/50'}"
></span>
{/if}
</button>
{#if isFavorite}
<button
aria-label="Remove from favorites"
class="shrink-0 cursor-pointer text-muted-foreground transition hover:text-foreground"
onclick={onToggleFavorite}
type="button"
>
<Heart class="h-3.5 w-3.5 fill-current" />
</button>
{/if}
<button class="min-w-0 flex-1 cursor-pointer text-left" onclick={onSelect} type="button">
<span class="block truncate text-sm">{option.name}</span>
</button>
<button
aria-label={expanded ? 'Hide model details' : 'Show model details'}
class="shrink-0 cursor-pointer text-muted-foreground transition hover:text-foreground"
onclick={() => (expanded = !expanded)}
type="button"
>
{#if expanded}
<ChevronUp class="h-3 w-3" />
{:else}
<ChevronDown class="h-3 w-3" />
{/if}
</button>
</div>
{#if expanded}
<div class="flex flex-wrap items-center gap-1.5 px-2 pb-2 pl-5.5">
{#if quant}
<Badge class="h-5 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
{/if}
{#if size}
<span class="text-xs text-muted-foreground">{size}</span>
{/if}
<ModelCapabilityIcons modalities={option.modalities} {supportsThinking} {supportsToolUse} />
</div>
{#if isLocal}
<Logo class="shrink-0" style="--size: 1rem" />
{:else if backendId}
<BackendIcon backend={getBackend(backendId)} class="h-4 w-4" />
{:else}
<Boxes class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
</div>
<span class="truncate">{label}</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground/70">{count}</span>
</button>
@@ -1,4 +1,6 @@
<script lang="ts">
import ModelsManagerModelsListControls from './ModelsManagerModelsList/ModelsManagerModelsListControls.svelte';
import type { ModelsListFilter } from './utils';
import { modelParamsLabel, modelSizeLabel, servedByLabel } from './utils';
import { ArrowUpDown, ChevronDown, Heart, Loader2, MoreHorizontal, Plus } from '@lucide/svelte';
import { ModelCapabilityIcons } from '$lib/components/app';
@@ -10,6 +12,7 @@
import type { ModelOption } from '$lib/types/models';
interface Props {
filter?: string;
isFavorite: (option: ModelOption) => boolean;
models: ModelOption[];
onCopyId: (option: ModelOption) => void;
@@ -21,9 +24,11 @@
selectedId: string | null;
summary: string;
title: string;
view?: ModelsListFilter;
}
let {
filter = $bindable(''),
isFavorite,
models,
onCopyId,
@@ -34,7 +39,8 @@
onUseInNewChat,
selectedId,
summary,
title
title,
view = $bindable<ModelsListFilter>('all')
}: Props = $props();
let descending = $state(false);
@@ -93,6 +99,10 @@
<span class="text-right">Actions</span>
</div>
<div class="shrink-0 border-b border-border/40 px-4 py-3">
<ModelsManagerModelsListControls bind:filter bind:view />
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#each sorted as option (option.id)}
{@const status = stateOf(option)}
@@ -38,11 +38,11 @@ export type ModelOverrideMap = Record<string, ModelOverride>;
export type ModelsListFilter = 'all' | 'favorites' | 'loaded';
/** One backend's worth of models, as listed in the manager's rail. */
export interface ModelsListGroup {
/** One backend, as listed in the manager's provider rail. */
export interface ModelsProviderGroup {
backendId: string;
count: number;
isLocal: boolean;
items: ModelOption[];
label: string;
}