ui : show every provider's models in the manager

The table grows a section per enabled provider, each with its own mark,
error and loading state, and a provider filter with live repo counts.
Rows gain their capability gates back, the draft column with its
use-as-draft action, and the quant badge names the provider on its rows.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-10-09 11:09:40 +02:00
committed by GitHub
parent 48e87a76cb
commit 2f5ea674d3
48 changed files with 1114 additions and 227 deletions
@@ -7,12 +7,13 @@
import * as Card from '$lib/components/ui/card';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { BackendProtocol } from '$lib/constants';
import { backendsModelsStore } from '$lib/stores/backendsModels.svelte';
import type { Backend, BackendProtocol } from '$lib/types';
import type { Backend } from '$lib/types';
const PROTOCOL_LABELS: Record<BackendProtocol, string> = {
'llama.cpp': 'Llama-compatible',
openai: 'OpenAI-compatible'
[BackendProtocol.COMPAT]: 'Llama-compatible',
[BackendProtocol.OPENAI]: 'OpenAI-compatible'
};
const CARD_ICON_CLASS = 'h-5 w-5';
@@ -3,12 +3,16 @@
import * as Collapsible from '$lib/components/ui/collapsible';
import { Input } from '$lib/components/ui/input';
import * as Select from '$lib/components/ui/select';
import { DEFAULT_BACKEND_CHAT_PATH, DEFAULT_BACKEND_MODELS_PATH } from '$lib/constants';
import type { Backend, BackendProtocol } from '$lib/types';
import {
BackendProtocol,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH
} from '$lib/constants';
import type { Backend } from '$lib/types';
const PROTOCOL_OPTIONS: Array<{ label: string; value: BackendProtocol }> = [
{ label: 'OpenAI-compatible', value: 'openai' },
{ label: 'Llama-compatible (llama-server)', value: 'llama.cpp' }
{ label: 'OpenAI-compatible', value: BackendProtocol.OPENAI },
{ label: 'Llama-compatible (llama-server)', value: BackendProtocol.COMPAT }
];
interface Props {
@@ -41,13 +45,13 @@
let detectionLabel = $derived.by(() => {
if (detecting) return 'Checking what the endpoint speaks...';
if (detected === 'llama.cpp') {
if (detected === BackendProtocol.COMPAT) {
return apiKeyRequired
? 'llama.cpp server detected. It asks for an API key.'
: 'llama.cpp server detected.';
}
if (detected === 'openai') return 'OpenAI-compatible endpoint detected.';
if (detected === BackendProtocol.OPENAI) return 'OpenAI-compatible endpoint detected.';
return null;
});
@@ -8,13 +8,14 @@
import {
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
BackendProtocol,
DISMISSED_RECOMMENDED_BACKENDS_LOCALSTORAGE_KEY
} from '$lib/constants';
import { BooleanString } from '$lib/enums';
import { BackendsService } from '$lib/services';
import type { BackendTestResult } from '$lib/services/backends.service';
import { backendsStore } from '$lib/stores';
import type { Backend, BackendPreset, BackendProtocol } from '$lib/types';
import type { Backend, BackendPreset } from '$lib/types';
import { findBackendPreset, uuid } from '$lib/utils';
import { untrack } from 'svelte';
@@ -28,7 +29,7 @@
let {
backend = null,
defaultProtocol = 'openai',
defaultProtocol = BackendProtocol.OPENAI,
onOpenChange,
onSaved,
open = $bindable(false)
@@ -166,6 +166,7 @@
<ChatMessageAssistantModel
{displayedModel}
isLoading={chatStore.isLoading}
{isRouter}
{onRegenerate}
/>
@@ -2,10 +2,17 @@
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { serverStore } from '$lib/stores';
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { backendsStore, serverStore } from '$lib/stores';
const availability = useBackendAvailability();
let hasError = $derived(!!serverStore.error);
let isLoadingModel = $derived(serverStore.status === 503);
// A single provider leaves nowhere to switch to, so the banner is the only
// place the failure can be reported. With another provider available the
// selector carries it instead, and stays usable.
let isOnlyProvider = $derived(backendsStore.enabled.length <= 1);
let hasError = $derived(availability.isOffline && isOnlyProvider);
</script>
{#if hasError || isLoadingModel}
@@ -5,7 +5,7 @@
import ModelsManagerModelProviders from '$lib/components/app/models/ModelsManager/ModelsManagerModelProviders.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { MODEL_ICON } from '$lib/constants';
import { MODEL_ICON, MODELS_DIALOG_VIEW, type ModelsDialogView } from '$lib/constants';
import { uiStore } from '$lib/stores';
import { untrack } from 'svelte';
@@ -16,15 +16,13 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
type View = 'discover' | 'manage' | 'providers';
/** How long a view takes to fade out before the next one fades in. */
const VIEW_FADE_MS = 150;
// what the user asked for, and what is actually rendered: a view change fades the
// current one out, swaps, then fades the next one in
let view = $state<View>('manage');
let shownView = $state<View>('manage');
let view = $state<ModelsDialogView>(MODELS_DIALOG_VIEW.MANAGE);
let shownView = $state<ModelsDialogView>(MODELS_DIALOG_VIEW.MANAGE);
let isSwapping = $state(false);
$effect(() => {
@@ -52,7 +50,11 @@
});
let title = $derived(
view === 'discover' ? 'Discover' : view === 'providers' ? 'Providers' : 'Models'
view === MODELS_DIALOG_VIEW.DISCOVER
? 'Discover'
: view === MODELS_DIALOG_VIEW.PROVIDERS
? 'Providers'
: 'Models'
);
// the sidebar's Discover entry opens this dialog on its Discover view
@@ -61,7 +63,7 @@
untrack(() => {
uiStore.discoverModelsOpen = false;
view = 'discover';
view = MODELS_DIALOG_VIEW.DISCOVER;
handleOpenChange(true);
});
});
@@ -86,10 +88,10 @@
<Button
aria-label="Back to models"
class="absolute top-1/2 left-1 h-7 w-7 -translate-y-1/2 transition-[opacity,visibility] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'invisible opacity-0'
: 'visible opacity-100'}"
onclick={() => (view = 'manage')}
onclick={() => (view = MODELS_DIALOG_VIEW.MANAGE)}
size="icon"
variant="ghost"
>
@@ -98,7 +100,7 @@
<Dialog.Title
class="flex min-h-7 items-center gap-2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'translate-x-0'
: 'translate-x-8'}"
>
@@ -106,21 +108,21 @@
<span class="grid h-5 w-5 shrink-0 place-items-center">
<MODEL_ICON
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'manage'
MODELS_DIALOG_VIEW.MANAGE
? 'opacity-100'
: 'opacity-0'}"
/>
<Compass
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'discover'
MODELS_DIALOG_VIEW.DISCOVER
? 'opacity-100'
: 'opacity-0'}"
/>
<Server
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'providers'
MODELS_DIALOG_VIEW.PROVIDERS
? 'opacity-100'
: 'opacity-0'}"
/>
@@ -135,12 +137,12 @@
</Dialog.Header>
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
{#if shownView === 'manage'}
{#if shownView === MODELS_DIALOG_VIEW.MANAGE}
<ModelsManager class="h-full">
{#snippet toolbarEnd()}
<Button
class="gap-1.5"
onclick={() => (view = 'discover')}
onclick={() => (view = MODELS_DIALOG_VIEW.DISCOVER)}
size="sm"
variant="tertiary"
>
@@ -151,7 +153,7 @@
<Button
class="gap-1.5"
onclick={() => (view = 'providers')}
onclick={() => (view = MODELS_DIALOG_VIEW.PROVIDERS)}
size="sm"
variant="tertiary"
>
@@ -161,7 +163,7 @@
</Button>
{/snippet}
</ModelsManager>
{:else if shownView === 'discover'}
{:else if shownView === MODELS_DIALOG_VIEW.DISCOVER}
<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
<ModelsDiscover />
</div>
@@ -1,10 +1,19 @@
<script lang="ts">
import ModelOrgAvatar from './ModelOrgAvatar.svelte';
import { HF_BASE_MODEL_TAG_REGEX, SETTINGS_KEYS } from '$lib/constants';
import { BackendIcon } from '$lib/components/app/backends';
import { Logo } from '$lib/components/app/misc';
import {
HF_BASE_MODEL_TAG_REGEX,
LOCAL_BACKEND_ID,
MODEL_ICON,
SETTINGS_KEYS
} from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { nearViewport, orgOf } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import type { Snippet } from 'svelte';
interface Props {
@@ -15,8 +24,7 @@
quantPositionClass?: string;
quantSize?: string;
/** Show the base model's org as the main image, the repo (quantizer) org as the
* corner badge. The base org costs one Hub request per repo. Left unset with
* {@link showRepoOrgAvatar}, the row follows the family grouping. */
* corner badge. The base org costs one Hub request per repo. */
showBaseModelAvatar?: boolean;
/** Show the repo's own org as the main image, skipping the base model.
* Used inside a heading that already carries the base org. */
@@ -40,6 +48,12 @@
let parsedId = $derived(ModelsService.parseModelId(option.model));
let orgName = $derived(parsedId.orgName);
// a llama-compat model whose id carries no `org/name` is not a Hugging Face repo,
// so the provider's own mark identifies it better than an initial
let isLlamaCompat = $derived(getBackendCapabilities(getBackend(option.backendId)).props);
let useProviderIcon = $derived(isLlamaCompat && !orgName);
// the bundled server has no favicon to resolve, its mark is the llama.cpp logo
let isLocal = $derived(getBackend(option.backendId)?.id === LOCAL_BACKEND_ID);
// A row names neither flag: a family heading already carries the base org, so a
// grouped list shows the repo's own org, and an ungrouped one the base org with the
// quantizer badge. A caller that names one of the two keeps control of the avatar.
@@ -72,6 +86,12 @@
if (!hubEnabled) return;
// external provider ids (`~openai/gpt-...`, `deepseek/deepseek-chat`) are
// not HF repos; their org is already the provider slug
const backend = getBackend(option.backendId);
if (backend && !getBackendCapabilities(backend).props) return;
let cancelled = false;
void HuggingFaceService.getBaseModel(option.model)
@@ -87,7 +107,19 @@
});
</script>
{#if orgName && hubEnabled}
{#if useProviderIcon}
<span class={['inline-flex shrink-0', className]}>
<BackendIcon backend={getBackend(option.backendId)} class={size}>
{#snippet fallback()}
{#if isLocal}
<Logo class={size} style="--size: 100%" />
{:else}
<MODEL_ICON class={size} />
{/if}
{/snippet}
</BackendIcon>
</span>
{:else if orgName && hubEnabled}
<span
use:nearViewport={() => (isNearViewport = true)}
class={['inline-flex shrink-0', className]}
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
@@ -23,7 +24,7 @@
$effect(() => {
template = '';
if (!isNearViewport) return;
if (!isNearViewport || !isLocalOption(option)) return;
// only the chat template says whether tools and reasoning work, and the Hub
// carries it for a local GGUF whether the model is loaded or not
@@ -1,4 +1,5 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -22,7 +23,7 @@
$effect(() => {
fetched = null;
if (reported || !isNearViewport) return;
if (reported || !isNearViewport || !isLocalOption(option)) return;
// a local GGUF carries its trained context in the model metadata
const repo = repoOf(option.model);
@@ -38,7 +38,7 @@
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
/** Draft sidecars available for the model, badged with their own quant. */
/** Draft sidecars available for the model, badged as `+ [KIND] [QUANT]`. */
draftSidecars?: ModelSidecarBadge[];
/** Draft sidecar kinds the repo offers, one badge per kind and no quant. */
draftKinds?: ModelDraftSidecar[];
@@ -1,10 +1,14 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { ICON_CLASS_DEFAULT, MODEL_LOAD_ICONS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import { backendsModelsStore, modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
interface Props {
/** Backend can load and unload models, llama-compat servers only. */
canLoad: boolean;
class?: string;
isFailed?: boolean;
isLoaded: boolean;
@@ -15,9 +19,14 @@
revealOnHover?: boolean;
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
showAction?: boolean;
/** Non-loadable rows show the provider mark, which identifies them in a flat list. */
showBackendMark?: boolean;
/** Table rows mark a remote provider, which this UI cannot load or unload. */
showRemoteMark?: boolean;
}
let {
canLoad,
class: className = '',
isFailed = false,
isLoaded,
@@ -25,12 +34,35 @@
isSleeping = false,
option,
revealOnHover = true,
showAction = true
showAction = true,
showBackendMark = false,
showRemoteMark = false
}: Props = $props();
let backendName = $derived(getBackend(option.backendId)?.name ?? 'Remote provider');
/** The provider's listing failed, so nothing it serves is selectable right now. */
let isBackendFailed = $derived(
Boolean(option.backendId && backendsModelsStore.get(option.backendId).error)
);
</script>
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
{#if isLoading}
{#if !canLoad}
{#if showBackendMark}
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
{:else if showRemoteMark}
<span
class="flex items-center gap-1 {isBackendFailed ? 'opacity-50 grayscale' : ''}"
title={isBackendFailed ? `${backendName} is unavailable` : backendName}
>
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
{#if isBackendFailed}
<MODEL_LOAD_ICONS.failed class="h-3 w-3 text-destructive" />
{/if}
</span>
{/if}
{:else if isLoading}
<MODEL_LOAD_ICONS.loading class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
{:else}
<!-- the state dot is what the row shows at rest; the action takes its place on hover -->
@@ -4,19 +4,33 @@
import {
downloadGroups,
groupModelQuants,
loadExtraArgs,
loadOverrides,
modelCapability,
modelContextLength,
modelDraftBadges,
type ModelOverride,
type ModelQuantGroup,
type ModelsTableGroup,
saveOverrides,
splitHiddenQuants
} from './utils';
import { LOCAL_BACKEND_ID, type ModalityKey, MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
import { ModelCapability, ModelsTableGroupKind } from '$lib/enums';
import { conversationsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import { ModelCapability, ModelsTableGroupKind, ModelsTableProviderKind } from '$lib/enums';
import {
backendsStore,
conversationsStore,
modelsStore,
serverStore,
uiStore
} from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { filterModelOptions } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { type Snippet, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
interface Props {
class?: string;
@@ -27,10 +41,13 @@
let { class: className, toolbarEnd }: Props = $props();
let filter = $state('');
let providerFilter = $state<string[]>([]);
let contextLimit = $state(0);
let modalityFilter = $state<ModalityKey[]>([]);
let capabilityFilter = $state<ModelCapability[]>([]);
let draftFilter = $state(false);
let selectedId = $state<string | null>(null);
let overrides = $state<Record<string, ModelOverride>>(loadOverrides());
let allModels = $derived(modelsStore.models);
@@ -99,6 +116,8 @@
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
// every filter but the provider one, so a provider count does not fall to zero
// the moment that provider is the one being looked at
let matching = $derived.by(() => {
// the term matches what the selector search matches: name, model, aliases and tags
const searched = filterModelOptions(allModels, filter);
@@ -114,6 +133,13 @@
return false;
}
if (
draftFilter &&
modelDraftBadges(option, overrides[option.id]?.load?.speculativeDecoding).length === 0
) {
return false;
}
// a model whose modalities are unknown cannot be shown to match
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
return false;
@@ -128,6 +154,24 @@
return context === null || context >= contextLimit;
});
});
let visible = $derived.by(() =>
providerFilter.length === 0
? matching
: matching.filter((option) => providerFilter.includes(option.backendId ?? LOCAL_BACKEND_ID))
);
// the rail counts follow the active view and filter, so it always says how many
// repos each provider contributes to what the table is showing
let providerCounts = $derived.by(() => {
const counts: Record<string, number> = {};
for (const entry of groupModelQuants(matching)) {
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
counts[backendId] = (counts[backendId] ?? 0) + 1;
}
return counts;
});
// recently used models lead their section, the rest keep the server's order
let rank = $derived.by(() => {
@@ -142,15 +186,19 @@
Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
const byRecency = (list: ModelQuantGroup[]) =>
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants takes a single table row
let entries = $derived(byRecency(groupModelQuants(matching)));
// one entry per repo, so a model with several quants takes a single table row;
// loaded models lead the table, then favorites, then one block per provider
let entries = $derived(byRecency(groupModelQuants(visible)));
// tracked downloads get their own section, listed like any other model
let downloads = $derived(
downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models)
);
let groups = $derived.by(() => {
// a loaded quant is a model of its own: its repo keeps the quants left behind
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
// a loaded quant is a model of its own: its repo keeps the quants left behind.
// Only llama-compat servers report a load state.
const isLoaded = (option: ModelOption) =>
getBackendCapabilities(getBackend(option.backendId)).loadUnload &&
modelsStore.isModelLoaded(option.model);
// a tracked download stands in its own section, so it is not listed twice
const isDownload = (option: ModelOption) =>
modelsStore.status.isDownloadInProgress(option.model) ||
@@ -181,23 +229,57 @@
rest.filter((entry) => !claimed.has(entry.key)),
(option) => modelsStore.isHidden(option.id)
);
const byBackend = new SvelteMap<string, ModelQuantGroup[]>();
for (const entry of local) {
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push(entry);
}
const ordered: ModelsTableGroup[] = [];
// loaded models lead the table, then favorites, then the local block
const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
// loaded models lead the table, then favorites, then one block per backend
const pushSection = (
kind: ModelsTableGroup['kind'],
items: ModelQuantGroup[],
provider?: { id: string; label: string }
): void => {
if (items.length === 0) return;
const isLocal = kind === ModelsTableGroupKind.LOCAL;
ordered.push({
backendId: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : null),
isLocal,
items,
key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
key: provider?.id ?? (isLocal ? LOCAL_BACKEND_ID : kind),
kind,
label: MODELS_TABLE_GROUP_LABELS[kind]
label: provider?.label ?? MODELS_TABLE_GROUP_LABELS[kind as ModelsTableGroupKind]
});
};
pushSection(ModelsTableGroupKind.LOADED, loaded);
pushSection(ModelsTableGroupKind.DOWNLOADING, downloads);
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
pushSection(ModelsTableGroupKind.LOCAL, local);
const localItems = byBackend.get(LOCAL_BACKEND_ID);
if (localItems?.length) pushSection(ModelsTableGroupKind.LOCAL, localItems);
for (const backend of backendsStore.enabled) {
if (backend.id === LOCAL_BACKEND_ID) continue;
const items = byBackend.get(backend.id);
if (items?.length)
pushSection(ModelsTableProviderKind.PROVIDER, items, {
id: backend.id,
label: backend.name
});
}
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
return ordered;
@@ -222,7 +304,7 @@
return;
}
await modelsStore.status.load(option.model);
await modelsStore.status.load(option.model, loadExtraArgs(overrides[option.id]));
}
/** The chat sits behind the dialog, so the dialog closes and the composer takes focus. */
@@ -261,6 +343,24 @@
returnToChat();
}
/** Point the selected model's load settings at another model as its draft. */
function useAsDraft(draft: ModelOption, targetId: string): void {
const target = modelsStore.models.find((option) => option.id === targetId);
if (!target) return;
saveOverride(target, {
...overrides[target.id],
load: { ...overrides[target.id]?.load, speculativeDecoding: draft.id }
});
}
function saveOverride(option: ModelOption, override: ModelOverride): void {
overrides = { ...overrides, [option.id]: override };
saveOverrides(overrides);
toast.success(`Saved settings for ${option.name}`);
}
</script>
<div class={['relative flex min-h-0 flex-1', className]}>
@@ -268,11 +368,16 @@
<ModelsManagerModelsTable
bind:capabilities={capabilityFilter}
bind:contextLimit
bind:draft={draftFilter}
bind:filter
bind:modalities={modalityFilter}
bind:providers={providerFilter}
{groups}
{isFavorite}
onSelect={(option) => (selectedId = option.id)}
onUseAsDraft={useAsDraft}
{overrides}
{providerCounts}
{selectedId}
{toolbarEnd}
/>
@@ -1,12 +1,17 @@
<script lang="ts">
import { ScrollCarousel } from '$lib/components/app';
import { Check, ChevronDown, Server } from '@lucide/svelte';
import { Logo, ScrollCarousel } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Select from '$lib/components/ui/select';
import { Toggle } from '$lib/components/ui/toggle';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import {
CAPABILITY_ICONS,
CAPABILITY_LABELS,
FILTER_TOGGLE_ITEM_CLASS,
FILTER_TRIGGER_CLASS,
LOCAL_BACKEND_ID,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_KEYS,
@@ -16,21 +21,33 @@
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { Backend } from '$lib/types/backend';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
backends: Backend[];
/** Capabilities a model must have every one of. */
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Repos each provider contributes to the current search, shown in the menu. */
providerCounts?: Record<string, number>;
}
let {
backends,
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
modalities = $bindable<ModalityKey[]>([])
draft = $bindable(false),
modalities = $bindable<ModalityKey[]>([]),
providerCounts = {},
providers = $bindable<string[]>([])
}: Props = $props();
const CONTEXT_STEPS: { label: string; value: number }[] = [
@@ -83,11 +100,35 @@
modalities = values.filter((value): value is ModalityKey => MODALITY_VALUES.has(value));
}
// none selected means every provider, so the label names the selection
let providerLabel = $derived(
providers.length === 0
? 'All providers'
: providers.length === 1
? (backends.find((backend) => backend.id === providers[0])?.name ?? '1 provider')
: `${providers.length} providers`
);
let contextLabel = $derived(
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
);
function toggleProvider(id: string, checked: boolean | 'indeterminate'): void {
providers = checked === true ? [...providers, id] : providers.filter((entry) => entry !== id);
}
</script>
{#snippet providerMark(backend: Backend)}
{#if backend.id === LOCAL_BACKEND_ID}
<BackendIcon {backend} class="h-3.5 w-3.5">
{#snippet fallback()}
<Logo class="shrink-0" style="--size: 0.875rem" />
{/snippet}
</BackendIcon>
{:else}
<BackendIcon {backend} class="h-3.5 w-3.5" />
{/if}
{/snippet}
<!-- below md the carousel keeps a row of its own: a toolbar that also holds a call to
action would otherwise squeeze the filters out of sight -->
<ScrollCarousel
@@ -96,6 +137,53 @@
gapSize="2"
innerClass="items-center"
>
{#if backends.length > 1}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<button
{...props}
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS}"
type="button"
>
<Server class="h-3.5 w-3.5" />
{providerLabel}
<ChevronDown class="h-3.5 w-3.5 opacity-60" />
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-48">
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>Providers</DropdownMenu.GroupHeading>
{#each backends as backend (backend.id)}
<DropdownMenu.CheckboxItem
checked={providers.includes(backend.id)}
onCheckedChange={(checked) => toggleProvider(backend.id, checked)}
>
{@render providerMark(backend)}
{backend.name}
<DropdownMenu.Shortcut>{providerCounts[backend.id] ?? 0}</DropdownMenu.Shortcut>
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Group>
{#if providers.length > 0}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => (providers = [])}
>Show every provider</DropdownMenu.Item
>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<Select.Root
onValueChange={(value) => (contextLimit = Number(value))}
type="single"
@@ -114,6 +202,26 @@
</Select.Content>
</Select.Root>
<!-- a checkbox chip: the whole pill is the control, the box is its indicator -->
<Toggle
bind:pressed={draft}
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS} px-2!"
variant="outline"
>
<span
aria-hidden="true"
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-shadow {draft
? 'border-border/30 bg-muted-foreground/15 text-foreground dark:border-border/20 dark:bg-muted-foreground/25'
: 'border-input bg-background dark:bg-input/30'}"
>
{#if draft}
<Check class="size-3" />
{/if}
</span>
Has draft sidecar
</Toggle>
<ToggleGroup.Root
class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
onValueChange={setToggles}
@@ -2,8 +2,9 @@
import { Plus } from '@lucide/svelte';
import { BackendCard, DialogBackendForm } from '$lib/components/app/backends';
import { Button } from '$lib/components/ui/button';
import { BackendProtocol } from '$lib/constants';
import { backendsModelsStore, backendsStore, serverStore } from '$lib/stores';
import type { Backend, BackendProtocol } from '$lib/types';
import type { Backend } from '$lib/types';
import { fade } from 'svelte/transition';
interface Props {
@@ -20,18 +21,18 @@
: backendsStore.external
);
// the bundled server belongs to the list whenever llama.cpp providers are in it
let showsLocal = $derived(protocol === undefined || protocol === 'llama.cpp');
let showsLocal = $derived(protocol === undefined || protocol === BackendProtocol.COMPAT);
let addTitle = $derived(
protocol === 'openai'
protocol === BackendProtocol.OPENAI
? 'Add a backend'
: protocol === 'llama.cpp'
: protocol === BackendProtocol.COMPAT
? 'Add a Llama-compatible backend'
: 'Add new provider'
);
let addDescription = $derived(
protocol === 'openai'
protocol === BackendProtocol.OPENAI
? 'Connect an OpenAI-compatible endpoint.'
: protocol === 'llama.cpp'
: protocol === BackendProtocol.COMPAT
? 'Point at another llama-server.'
: 'External llama-server or connect an OpenAI-compatible API.'
);
@@ -66,7 +67,7 @@
<DialogBackendForm
bind:open={isAdding}
backend={editing}
defaultProtocol={protocol ?? 'llama.cpp'}
defaultProtocol={protocol ?? BackendProtocol.COMPAT}
onOpenChange={handleOpenChange}
onSaved={() => void backendsModelsStore.loadAll()}
/>
@@ -5,8 +5,14 @@
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
import ModelId from '../ModelId.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext, downloadProgressFor } from './utils';
import { modelRowActions, type ModelRowDraftTarget } from './row-actions';
import {
canLoadOption,
configuredContext,
downloadProgressFor,
modelDraftBadges,
type ModelOverride
} from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
@@ -16,16 +22,31 @@
import { repoOf } from '$lib/utils';
interface Props {
/** Model the pane has open, when this row can be set as its draft. */
draftTarget?: ModelRowDraftTarget | null;
isFavorite: (option: ModelOption) => boolean;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let {
draftTarget = null,
indent = 0,
isFavorite,
onDelete,
onSelect,
onUseAsDraft,
option,
overrides,
selected
}: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
@@ -39,6 +60,9 @@
? ModelRowDownloadState.DOWNLOADING
: null
);
let draftBadges = $derived(
modelDraftBadges(option, overrides?.[option.id]?.load?.speculativeDecoding)
);
/** Repo of the row's model, which is what the Discover details are keyed by. */
function openInDiscover(): void {
@@ -76,7 +100,7 @@
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1 max-md:text-sm"
draftSidecars={option.draftSidecars}
draftSidecars={draftBadges}
hideCapabilities
hideModalities
modalities={option.modalities}
@@ -92,7 +116,7 @@
<ModelContext
class="justify-self-end max-md:hidden"
configured={configuredContext(option)}
configured={configuredContext(option, overrides)}
{option}
/>
@@ -108,7 +132,11 @@
<div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete, download)}
actions={modelRowActions(
option,
{ canLoad: canLoadOption(option), download, draftTarget, favorite, isHidden },
{ onDelete, onUseAsDraft }
)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
@@ -3,9 +3,11 @@
import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
import { type ModelRowDraftTarget } from './row-actions';
import {
hasActiveFilters,
modelContextLength,
type ModelOverride,
type ModelQuantGroup,
type ModelsTableGroup,
statusRank
@@ -44,9 +46,10 @@
ModelCapability,
ModelDownloadConfirmAction,
ModelsTableGroupKind,
ModelsTableProviderKind,
ModelsTableSortKey
} from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import { backendsModelsStore, modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
import type { Snippet } from 'svelte';
@@ -57,12 +60,22 @@
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
filter?: string;
groups: ModelsTableGroup[];
isFavorite: (option: ModelOption) => boolean;
onSelect: (option: ModelOption) => void;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Per-model load and inference overrides, keyed by backend-qualified id. */
overrides: Record<string, ModelOverride>;
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Repos each provider contributes to the current search, for the filter menu. */
providerCounts?: Record<string, number>;
/** Called when a row is set as the draft of the selected model. */
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selectedId: string | null;
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
@@ -71,17 +84,31 @@
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
draft = $bindable(false),
filter = $bindable(''),
groups,
isFavorite,
modalities = $bindable<ModalityKey[]>([]),
onSelect,
onUseAsDraft,
overrides,
providerCounts = {},
providers = $bindable<string[]>([]),
selectedId,
toolbarEnd
}: Props = $props();
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
let hasFilters = $derived(
hasActiveFilters(contextLimit, modalities, capabilities) || providers.length > 0 || draft
);
/** Model the configuration pane has open, when a row can be set as its draft. */
let draftTarget = $derived.by<ModelRowDraftTarget | null>(() => {
const selected = modelsStore.models.find((option) => option.id === selectedId);
return selected ? { id: selected.id, label: selected.name } : null;
});
/** Noun the show-more row counts in, per unit of the grouped list. */
const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
@@ -231,27 +258,35 @@
expanded={!collapsedQuants.has(entry.key)}
{indent}
onToggle={() => toggleQuants(entry.key)}
{overrides}
/>
<CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
{#each entry.quants as quant (quant.id)}
<ModelsManagerQuantRow
{draftTarget}
indent={indent + 24}
{isFavorite}
onDelete={requestDelete}
{onSelect}
{onUseAsDraft}
option={quant}
{overrides}
selected={selectedId === quant.id}
showProvider={entry.kind === 'providers'}
/>
{/each}
</CollapsibleRegion>
{:else}
<ModelsManagerModelRow
{draftTarget}
{indent}
{isFavorite}
onDelete={requestDelete}
{onSelect}
{onUseAsDraft}
option={entry.base}
{overrides}
selected={selectedId === entry.base.id}
/>
{/if}
@@ -343,8 +378,11 @@
<ModelsManagerTableToolbar
bind:capabilities
bind:contextLimit
bind:draft
bind:filter
bind:modalities
bind:providers
{providerCounts}
{toolbarEnd}
/>
@@ -381,12 +419,19 @@
{/if}
{/snippet}
{@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
<ModelsSection
backendId={group.kind === ModelsTableProviderKind.PROVIDER
? (group.backendId ?? undefined)
: undefined}
chevronClass="mr-7"
count={group.items.length}
defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
icon={groupIcon}
error={Boolean(backendState?.error)}
icon={group.kind === ModelsTableProviderKind.PROVIDER ? undefined : groupIcon}
label={group.label}
loading={Boolean(backendState?.loading)}
persistKey={group.key}
revealChevronOnHover
sectionHeaderClass="m-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none max-md:px-3"
@@ -2,26 +2,45 @@
import ModelContext from '../ModelContext.svelte';
import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { modelRowActions, type ModelRowDraftTarget } from './row-actions';
import { canLoadOption, configuredContext, modelDraftBadges, type ModelOverride } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
interface Props {
/** Model the pane has open, when this row can be set as its draft. */
draftTarget?: ModelRowDraftTarget | null;
isFavorite: (option: ModelOption) => boolean;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
selected: boolean;
/** Badge the backend name instead of the quant, for a provider group. */
showProvider?: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let {
draftTarget = null,
indent = 0,
isFavorite,
onDelete,
onSelect,
onUseAsDraft,
option,
overrides,
selected,
showProvider = false
}: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
@@ -45,16 +64,18 @@
style="padding-left: {indent}px"
type="button"
>
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">
{showProvider ? (getBackend(option.backendId)?.name ?? quant) : quant}
</Badge>
<ModelDraftSidecars draftSidecars={option.draftSidecars} />
<ModelDraftSidecars draftSidecars={modelDraftBadges(option)} />
<span class="truncate text-sm text-muted-foreground">{option.model}</span>
</button>
<ModelContext
class="justify-self-end max-md:hidden"
configured={configuredContext(option)}
configured={configuredContext(option, overrides)}
{option}
/>
@@ -62,7 +83,11 @@
<div class="flex items-center justify-center justify-self-center {MODEL_ROW_TRAILING_CELL_CLASS}">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
actions={modelRowActions(
option,
{ canLoad: canLoadOption(option), draftTarget, favorite, isHidden },
{ onDelete, onUseAsDraft }
)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
@@ -4,7 +4,7 @@
import ModelContext from '../ModelContext.svelte';
import ModelId from '../ModelId.svelte';
import type { ModelQuantGroup } from './utils';
import { configuredContext } from './utils';
import { configuredContext, type ModelOverride } from './utils';
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
import { KeyboardKey, ModelGroupKind } from '$lib/enums';
@@ -14,16 +14,21 @@
entry: ModelQuantGroup;
expanded: boolean;
onToggle: () => void;
/** Stored per-model overrides, for the drafts and context the row reports. */
overrides?: Record<string, ModelOverride>;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { entry, expanded, indent = 0, onToggle }: Props = $props();
let { entry, expanded, indent = 0, onToggle, overrides }: Props = $props();
let providerCount = $derived(new Set(entry.quants.map((option) => option.backendId ?? '')).size);
let groupLabel = $derived(
entry.kind === ModelGroupKind.VARIANTS
? `${entry.quants.length} variants`
: `${entry.quants.length} quants available`
entry.kind === ModelGroupKind.PROVIDERS
? `${providerCount} provider${providerCount === 1 ? '' : 's'}`
: entry.kind === ModelGroupKind.VARIANTS
? `${entry.quants.length} variants`
: `${entry.quants.length} quants available`
);
let anyLoaded = $derived(entry.quants.some((quant) => modelsStore.isModelRunning(quant.model)));
// a repo row stands for its quants, so it reports what they agree on
@@ -73,7 +78,7 @@
<ModelContext
class="justify-self-end max-md:hidden"
configured={configuredContext(contextSource)}
configured={configuredContext(contextSource, overrides)}
option={contextSource}
/>
@@ -1,6 +1,7 @@
<script lang="ts">
import ModelLoadControl from '../ModelLoadControl.svelte';
import ModelsManagerDownloadControl from './ModelsManagerDownloadControl.svelte';
import { canLoadOption } from './utils';
import type { ModelRowDownloadState } from '$lib/enums';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, serverStore } from '$lib/stores';
@@ -26,12 +27,14 @@
<ModelsManagerDownloadControl class="justify-self-center {className}" {option} state={download} />
{:else if isRouter}
<ModelLoadControl
canLoad={canLoadOption(option)}
class="justify-self-center {className}"
isFailed={status === ServerModelStatus.FAILED}
{isLoaded}
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
isSleeping={status === ServerModelStatus.SLEEPING}
{option}
showRemoteMark
/>
{:else}
<span
@@ -6,7 +6,7 @@
import { Button } from '$lib/components/ui/button';
import { type ModalityKey } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { deviceStore, uiStore } from '$lib/stores';
import { backendsStore, deviceStore, uiStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
@@ -14,9 +14,15 @@
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Keep only models that have a draft sidecar to speculate with. */
draft?: boolean;
filter?: string;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Repos each provider contributes to the current search, for the filter menu. */
providerCounts?: Record<string, number>;
/** Backend ids to keep; empty keeps every provider. */
providers?: string[];
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
}
@@ -24,12 +30,17 @@
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
draft = $bindable(false),
filter = $bindable(''),
modalities = $bindable<ModalityKey[]>([]),
providerCounts = {},
providers = $bindable<string[]>([]),
toolbarEnd
}: Props = $props();
let hasFilters = $derived(hasActiveFilters(contextLimit, modalities, capabilities));
let hasFilters = $derived(
hasActiveFilters(contextLimit, modalities, capabilities) || providers.length > 0 || draft
);
let filterInput = $state<HTMLInputElement | null>(null);
// The search takes the focus the dialog would give its first control: the input
@@ -63,15 +74,25 @@
size={deviceStore.isMobile ? 'default' : 'sm'}
/>
<ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
<ModelsManagerFilters
bind:capabilities
bind:contextLimit
bind:draft
bind:modalities
bind:providers
backends={backendsStore.enabled}
{providerCounts}
/>
{#if hasFilters}
<Button
class="gap-1.5 text-muted-foreground"
onclick={() => {
providers = [];
contextLimit = 0;
modalities = [];
capabilities = [];
draft = false;
}}
size="sm"
variant="ghost"
@@ -1,19 +1,36 @@
import { Compass, Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { Compass, Eye, EyeOff, Heart, HeartOff, Trash2, Zap } from '@lucide/svelte';
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
import { ModelRowDownloadState } from '$lib/enums';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { repoOf } from '$lib/utils';
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
export function modelRowActions(
option: ModelOption,
favorite: boolean,
isHidden: boolean,
onDelete: (option: ModelOption) => void,
/** Model the configuration pane has open, which a row can be set as the draft of. */
export interface ModelRowDraftTarget {
id: string;
label: string;
}
interface RowState {
/** Backend can load and unload the model. */
canLoad: boolean;
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null
) {
download?: ModelRowDownloadState | null;
/** Model the pane has open, when one is selected. */
draftTarget?: ModelRowDraftTarget | null;
favorite: boolean;
isHidden: boolean;
}
interface RowHandlers {
onDelete: (option: ModelOption) => void;
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
}
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
export function modelRowActions(option: ModelOption, state: RowState, handlers: RowHandlers) {
const { canLoad, download, draftTarget, favorite, isHidden } = state;
const canBeDraft = canLoad && !!draftTarget && draftTarget.id !== option.id;
const viewInDiscover = {
icon: Compass,
label: 'View in Discover',
@@ -40,7 +57,7 @@ export function modelRowActions(
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => onDelete(option),
onclick: () => handlers.onDelete(option),
separator: true,
variant: 'destructive' as const
},
@@ -49,18 +66,32 @@ export function modelRowActions(
}
return [
...(canBeDraft
? [
{
icon: Zap,
label: `Use as draft for ${draftTarget.label}`,
onclick: () => handlers.onUseAsDraft?.(option, draftTarget.id),
separator: true
}
]
: []),
{
icon: favorite ? HeartOff : Heart,
label: favorite ? 'Remove from favorites' : 'Add to favorites',
onclick: () => modelsStore.toggleFavorite(option.model)
},
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => onDelete(option),
separator: true,
variant: 'destructive' as const
},
...(canLoad
? [
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => handlers.onDelete(option),
separator: true,
variant: 'destructive' as const
}
]
: []),
{
icon: isHidden ? Eye : EyeOff,
label: isHidden ? 'Unhide model' : 'Hide model',
@@ -10,6 +10,7 @@ import {
ModelCapability,
ModelGroupKind,
ModelsTableGroupKind,
ModelsTableProviderKind,
ServerModelStatus
} from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
@@ -19,6 +20,7 @@ import type {
ModelDownloadProgress,
ModelLoadProgress,
ModelOption,
ModelSidecarBadge,
ModelSidecarFile
} from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport, repoOf } from '$lib/utils';
@@ -138,30 +140,10 @@ export interface ModelsTableGroup {
isLocal?: boolean;
key: string;
/** Manager sections use the kind constants, provider blocks their own kinds. */
kind: ModelsTableGroupKind | 'compat' | 'provider';
kind: ModelsTableGroupKind | ModelsTableProviderKind;
label: string;
}
/** Values the load form falls back to when the server reports nothing. */
export const LOAD_DEFAULTS = {
batchSize: 2048,
contextLength: 8192,
cpuThreads: 13,
gpuOffload: 42,
speculativeDecoding: 'off',
ubatchSize: 512
};
export const SAMPLING_DEFAULTS = {
minP: 0.05,
repeatPenalty: 1.1,
temperature: 1,
topK: 64,
topP: 0.95
};
export const SPECULATIVE_OPTIONS = ['off', 'draft-model'];
/** True when the user saved anything for this model. */
export function isCustomized(override?: ModelOverride): boolean {
return override !== undefined && Object.keys(override).length > 0;
@@ -326,14 +308,31 @@ export function modelDrafts(
return drafts;
}
/** Context the model runs with: what a loaded model reports. */
export function configuredContext(option: ModelOption): number | null {
return modelsStore.isModelRunning(option.model)
? modelsStore.props.getModelContextSize(option.model)
: null;
/**
* Draft sidecars of a model, as ModelId badges them: what the store's listing reports
* plus the drafts a load would use, one kind/quant pair per sidecar.
*/
export function modelDraftBadges(
option: ModelOption,
settingValue?: string | null
): ModelSidecarBadge[] {
const badges = [...(option.draftSidecars ?? [])];
for (const draft of modelDraftsFor(option, settingValue)) {
if (!draft.kind) continue;
if (badges.some((badge) => badge.kind === draft.kind && badge.quant === draft.quant)) continue;
badges.push({
kind: draft.kind,
quant: draft.quant,
repo: draft.model ?? repoOf(option.model) ?? ''
});
}
return badges;
}
/**
/**
* Capability a model reports: true or false once its listing or its chat template
* answers, null while the Hub record that carries the template is not read yet. A
@@ -354,6 +353,27 @@ export function modelCapability(option: ModelOption, capability: ModelCapability
: detectThinkingSupport(template);
}
/** Context the model runs with: the stored override, else what a loaded local model reports. */
export function configuredContext(
option: ModelOption,
overrides?: Record<string, ModelOverride>
): number | null {
const override = overrides?.[option.id]?.load?.contextLength;
if (override) return override;
if (!isLocalOption(option)) return null;
return modelsStore.isModelRunning(option.model)
? modelsStore.props.getModelContextSize(option.model)
: null;
}
/** True when the backend that serves the model can load and unload it. */
export function canLoadOption(option: ModelOption): boolean {
return getBackendCapabilities(getBackend(option.backendId)).loadUnload;
}
export function servedByLabel(option: ModelOption): string {
const backend = getBackend(option.backendId);
@@ -1,11 +1,15 @@
<script lang="ts">
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { modelsStore } from '$lib/stores';
import { getBackend } from '$lib/utils/api-base';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/** Renders the backend's logo in the header. */
backendId?: string;
/** Extra classes for the chevron, to line it up with a row's own control. */
chevronClass?: string;
/** Number shown next to the label, omitted when undefined. */
@@ -29,6 +33,7 @@
}
let {
backendId,
chevronClass = '',
children,
count,
@@ -78,6 +83,8 @@
{#if icon}
{@render icon()}
{:else if backendId}
<BackendIcon backend={getBackend(backendId)} class="h-3.5 w-3.5" />
{/if}
<span class="truncate">{label}</span>
@@ -9,13 +9,27 @@
ModelsSelectorOption,
ModelsSelectorTriggerIcon
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import * as Tooltip from '$lib/components/ui/tooltip';
import { DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR, MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
import {
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
LOCAL_BACKEND_ID,
MODEL_ICON,
SETTINGS_KEYS
} from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useChatFormModel } from '$lib/hooks/use-chat-form-model.svelte';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { deviceStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
import {
backendsModelsStore,
backendsStore,
deviceStore,
modelsStore,
serverStore,
settingsStore
} from '$lib/stores';
import { type ModelItem, modelLoadFraction } from '$lib/utils';
import { rawModelId } from '$lib/utils/model-option-id';
interface Props {
/** Model to show, when the caller owns it, e.g. the model of one message. */
@@ -39,10 +53,18 @@
// a phone opens the picker in a drawer, a desktop keeps the anchored dropdown
let isMobile = $derived(deviceStore.isMobile);
let isOffline = $derived(!!serverStore.error);
// the provider this selector is pointed at, which is the one its colours report
let selectorError = $derived.by(() => {
const backendId = backendsStore.active.id;
return backendId === LOCAL_BACKEND_ID
? Boolean(serverStore.error)
: backendsModelsStore.get(backendId).error !== null;
});
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let showAddBackend = $state(false);
const formModel = useChatFormModel();
@@ -104,6 +126,9 @@
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) 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;
});
@@ -138,11 +163,20 @@
return;
}
const status = modelsStore.getModelStatus(modelId);
// an option id is backend-qualified, the router lists the raw model id
const rawId = rawModelId(modelId);
const status = modelsStore.getModelStatus(rawId);
if (status === ServerModelStatus.LOADING) return;
await modelsStore.status.unload(modelId);
await modelsStore.status.unload(rawId);
}
function handleAddBackend() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
function handleSearchKeyDown(event: KeyboardEvent) {
@@ -268,7 +302,17 @@
{/if}
{#if ms.isEmpty}
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{#if ms.searchTerm}
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{:else}
<button
class="cursor-pointer px-4 py-3 text-left text-sm text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
{/if}
{/if}
<ModelsSelectorList
@@ -277,6 +321,8 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-2 mb-1 px-2 py-2.5 text-sm font-semibold text-foreground/80 select-none"
@@ -329,9 +375,11 @@
<ModelsSelectorDropdown
bind:open={isOpen}
currentModel={displayModel}
disabled={disabled || isOffline}
{disabled}
error={selectorError}
{highlightedId}
{ms}
onAddBackend={handleAddBackend}
onHighlight={(id) => (highlightedId = id)}
onModelKeyAction={(id, unload) => void handleModelKeyAction(id, unload)}
onSearchKeyDown={handleSearchKeyDown}
@@ -403,3 +451,8 @@
</Tooltip.Trigger>
</Tooltip.Root>
{/if}
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -24,11 +24,14 @@
ms: UseModelsSelectorReturn;
currentModel?: string | null;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
/** Model id the list highlights from the keyboard; the owner tracks it. */
highlightedId?: string | null;
/** Bind the menu's open state, so the owner sees the close too. */
open?: boolean;
showOrgName?: boolean;
onAddBackend?: () => void;
onHighlight?: (id: string | null) => void;
onManageModels?: () => void;
onModelKeyAction?: (modelId: string, unload: boolean) => void;
@@ -38,8 +41,10 @@
let {
currentModel = null,
disabled = false,
error = false,
highlightedId = null,
ms,
onAddBackend,
onHighlight,
onManageModels,
onModelKeyAction,
@@ -155,9 +160,11 @@
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 max-md:h-8 max-md:px-2.25 max-md:py-1.25 max-md:text-[13px] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: 'text-foreground',
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: 'text-foreground',
open && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
@@ -230,7 +237,17 @@
{/if}
{#if ms.isEmpty}
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{#if ms.searchTerm}
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{:else}
<button
class="cursor-pointer px-4 py-3 text-left text-sm text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={() => onAddBackend?.()}
type="button"
>
No models yet. Add a backend to get started.
</button>
{/if}
{/if}
<ModelsSelectorList
@@ -239,6 +256,8 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
@@ -38,6 +38,10 @@
loaded?: ModelItem[];
/** Show the organization name in every model id of the list. */
showOrgName?: boolean;
/** 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 {
@@ -46,6 +50,8 @@
favorites = [],
groups,
loaded = [],
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
@@ -227,6 +233,39 @@
</ModelsSection>
{/if}
<!-- One section per remote provider. -->
{#each groups.providers as provider (provider.backendId)}
<ModelsSection
backendId={provider.backendId}
error={Boolean(provider.error)}
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
persistKey={provider.backendId}
revealChevronOnHover
sticky
>
{#if provider.items.length > 0}
{@render listRows(provider.items, provider.backendId)}
{#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}
</ModelsSection>
{/each}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { modelDraftBadges } from '../ModelsManager/utils';
import {
ModelAvatar,
ModelCapabilities,
@@ -12,6 +13,8 @@
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
interface Props {
option: ModelOption;
@@ -35,6 +38,9 @@
option
}: 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 serverStatus = $derived(modelsStore.getModelStatus(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
@@ -79,7 +85,7 @@
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
draftSidecars={modelDraftBadges(option)}
hideCapabilities
hideModalities
{hideOrgName}
@@ -96,7 +102,15 @@
<div class="flex shrink-0 items-center gap-1 max-md:gap-2.5">
<ModelRowActions {isFav} {option} />
<ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
<ModelLoadControl
{canLoad}
{isFailed}
{isLoaded}
{isLoading}
{isSleeping}
{option}
showBackendMark
/>
</div>
{#if isLoading}
+32 -12
View File
@@ -1,15 +1,29 @@
import type {
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
import type { BackendCapabilities, BackendCompat, BackendPreset } from '$lib/types';
/** Request/response shape a backend speaks. */
export const BackendProtocol = {
COMPAT: 'llama.cpp',
OPENAI: 'openai'
} as const;
export type BackendProtocol = (typeof BackendProtocol)[keyof typeof BackendProtocol];
/** Field carrying the output token cap, per OpenAI dialect. */
export const MaxTokensField = {
CHAT_COMPLETION: 'max_completion_tokens',
COMPLETION: 'max_tokens'
} as const;
export type MaxTokensField = (typeof MaxTokensField)[keyof typeof MaxTokensField];
/** Prefix for generated ids of user-added backends. */
export const BACKEND_ID_PREFIX = 'backend';
/** Protocols a configured backend can speak, in display order. */
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = ['llama.cpp', 'openai'];
export const BACKEND_PROTOCOLS: readonly BackendProtocol[] = [
BackendProtocol.COMPAT,
BackendProtocol.OPENAI
];
/** Chat completions path used when a backend does not override it. */
export const DEFAULT_BACKEND_CHAT_PATH = '/v1/chat/completions';
@@ -45,15 +59,21 @@ const COMPATIBLE_CAPABILITIES: BackendCapabilities = {
/** Capabilities per backend protocol. */
export const BACKEND_CAPABILITIES: Record<BackendProtocol, BackendCapabilities> = {
'llama.cpp': LLAMA_CPP_CAPABILITIES,
openai: COMPATIBLE_CAPABILITIES
[BackendProtocol.COMPAT]: LLAMA_CPP_CAPABILITIES,
[BackendProtocol.OPENAI]: COMPATIBLE_CAPABILITIES
};
/** Default wire quirks per protocol. */
export const BACKEND_COMPAT: Record<BackendProtocol, BackendCompat> = {
// llama-server reports its own timings, so it needs no usage chunk
'llama.cpp': { maxTokensField: 'max_tokens', supportsUsageInStreaming: false },
openai: { maxTokensField: 'max_tokens', supportsUsageInStreaming: true }
[BackendProtocol.COMPAT]: {
maxTokensField: MaxTokensField.COMPLETION,
supportsUsageInStreaming: false
},
[BackendProtocol.OPENAI]: {
maxTokensField: MaxTokensField.COMPLETION,
supportsUsageInStreaming: true
}
};
/**
@@ -84,7 +104,7 @@ export const BACKEND_PRESETS: readonly BackendPreset[] = [
iconUrl: '/backend-presets/huggingface.svg',
id: 'huggingface',
name: 'Hugging Face',
protocol: 'openai'
protocol: BackendProtocol.OPENAI
},
{
baseUrl: 'https://openrouter.ai/api',
@@ -16,3 +16,12 @@ export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
* so the two stacked sticky rows do not show a gap or overlap by a pixel.
*/
export const MODELS_TABLE_GROUP_STICKY_OFFSET = 'top: calc(2.25rem - 1px)';
/** Panel the models dialog shows. */
export const MODELS_DIALOG_VIEW = {
DISCOVER: 'discover',
MANAGE: 'manage',
PROVIDERS: 'providers'
} as const;
export type ModelsDialogView = (typeof MODELS_DIALOG_VIEW)[keyof typeof MODELS_DIALOG_VIEW];
+1
View File
@@ -83,6 +83,7 @@ export {
ModelGroupKind,
ModelRowDownloadState,
ModelsTableGroupKind,
ModelsTableProviderKind,
ModelsTableSortKey
} from './model.enums';
+6
View File
@@ -94,3 +94,9 @@ export enum ModelsTableSortKey {
NAME = 'name',
STATUS = 'status'
}
/** Kinds a provider block carries on top of the manager's own sections. */
export enum ModelsTableProviderKind {
COMPAT = 'compat',
PROVIDER = 'provider'
}
@@ -8,7 +8,7 @@
* from the server on demand if they are not cached yet.
*/
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { backendsStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import type { DatabaseMessage } from '$lib/types/database';
import { getConversationModel } from '$lib/utils';
@@ -24,10 +24,15 @@ export function useChatFormModel() {
// points the selection at the model that produced it
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
if (modelsStore.models.some((m) => m.model === conversationModel)) {
const option = modelsStore.models.find((m) => m.model === conversationModel);
// only sync models served by the active backend; a model from another
// backend must not yank the active tab (and trigger a full backend
// switch) just because the conversation used it
if (option && option.backendId === backendsStore.active.id) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else {
} else if (!option) {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
@@ -1,21 +1,46 @@
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { deviceStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import {
CHAT_INPUT_FOCUS_SELECTOR,
LOCAL_BACKEND_ID,
REMOTE_PROVIDER_MODEL_LIMIT
} from '$lib/constants';
import {
backendsModelsStore,
backendsStore,
deviceStore,
modelsStore,
serverStore,
uiStore
} from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import {
filterModelOptions,
type GroupedModelOptions,
groupFavoriteOptions,
groupModelOptions,
groupProviderOptions,
type ModelItem
} from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { nameOf } from '$lib/utils/model-names';
import { rawModelId } from '$lib/utils/model-option-id';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/** Groups of the favorites tab, which lists favorites only. */
const EMPTY_GROUPS: GroupedModelOptions = { available: [], providers: [] };
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
useGlobalSelection?: () => boolean;
/** The caller's model outranks the active backend's selection, e.g. a message's own. */
preferCurrentModel?: () => boolean;
onModelChange?: () =>
| ((modelId: string, modelName: string) => Promise<boolean> | boolean | void)
| ((
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void)
| undefined;
onOpenChange?: (open: boolean) => void;
}
@@ -35,10 +60,14 @@ export interface UseModelsSelectorReturn {
readonly loadedItems: ModelItem[];
readonly filteredOptions: ModelOption[];
readonly isEmpty: boolean;
readonly isProviderView: boolean;
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
closeProvider(): void;
openProvider(backendId: string): void;
setSearchTerm(value: string): void;
showBackendModels(backendId: string): Promise<void>;
handleSelect(modelId: string): Promise<void>;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
@@ -53,52 +82,97 @@ export interface UseModelsSelectorReturn {
* selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
let isLoadingModel = $state(false);
let searchTerm = $state('');
/**
* 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.
*/
/** Remote backend drilled into from its section; null while browsing. */
let providerViewId = $state<string | null>(null);
const options = $derived(
const isProviderView = $derived(providerViewId !== null);
const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID;
// every enabled backend's models are one list: favorites, then the local
// server, then one section per remote provider
const allOptions = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
const options = $derived(
providerViewId ? allOptions.filter((option) => option.backendId === providerViewId) : allOptions
);
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
// a lone llama.cpp server without a router has nothing to choose from
// Router mode and external backends both expose a selectable model list; only
// a lone llama.cpp server without a router has nothing to choose from.
const isMultiModel = $derived(
serverStore.isRouterMode ||
backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
);
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
const preferCurrentModel = $derived(opts.preferCurrentModel?.() ?? false);
const onModelChange = $derived(opts.onModelChange?.());
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
const currentOption = options.find((option) => option.model === currentModel);
const currentOption = allOptions.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
return allOptions.some((option) => option.model === currentModel);
});
// the search, the rows and the sections all read the visible set; only the current
// model resolves against `options`, since it can be hidden and still selected
let isLoadingModel = $state(false);
let searchTerm = $state('');
// the search, the rows and the sections all read the visible set, while the
// favorites and loaded blocks span every backend and read the full one
const allVisible = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
const visibleOptions = $derived(options.filter((option) => !modelsStore.isHidden(option.id)));
// one filter pass feeds the favorites, the loaded rows and the sections alike
// the sections read the view's own set; the favorites and the loaded rows span
// every backend and read the full visible one
const filteredOptions = $derived(filterModelOptions(visibleOptions, searchTerm));
const loadedItems = $derived(
filteredOptions
.filter((option) => modelsStore.isModelLoaded(option.model))
.map((option) => ({ option }))
const filteredAllOptions = $derived(filterModelOptions(allVisible, searchTerm));
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
};
})
);
// loaded models lead the list, from any llama-compat backend
const isLoadedLlamaCompat = (option: ModelOption) =>
modelsStore.isModelLoaded(option.model) &&
getBackendCapabilities(getBackend(option.backendId)).loadUnload;
const loadedItems = $derived.by(() => {
if (isProviderView) return [];
return filteredAllOptions
.filter((option) => isLoadedLlamaCompat(option))
.map((option) => ({ option }));
});
const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
// loaded models lead the list: their own sections list them once, so a
// loaded favorite shows there and not twice
const favoriteItems = $derived(
groupFavoriteOptions(
filteredOptions.filter((option) => !loadedIds.has(option.id)),
filteredAllOptions.filter((option) => !loadedIds.has(option.id)),
modelsStore.favoriteModelIds
)
);
@@ -108,7 +182,26 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
(option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
)
);
const groupedFilteredOptions = $derived(groupModelOptions(sectionOptions));
const providerSections = $derived(
groupProviderOptions(
sectionOptions,
remoteProviders,
// a drill-in or a search reaches every model, the sections stay short
providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT,
modelsStore.recentModelIds
)
);
const groupedFilteredOptions = $derived.by(() => {
if (isProviderView) {
const sections = providerSections.filter((section) => section.backendId === providerViewId);
return { ...EMPTY_GROUPS, providers: sections };
}
const local = groupModelOptions(sectionOptions.filter(isLocalOption));
return { ...local, providers: providerSections };
});
const isEmpty = $derived(
filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
);
@@ -139,6 +232,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
searchTerm = '';
providerViewId = null;
if (open && isRouter) {
modelsStore.props.fetchModalitiesForLoadedModels();
@@ -147,15 +241,37 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
opts.onOpenChange?.(open);
}
/**
* Switch the rendered view. Views are display only: the backend that serves
* requests follows the selected model, not the view.
*/
/** Drill into one remote backend's full model list. */
function openProvider(backendId: string) {
providerViewId = backendId;
searchTerm = '';
}
function closeProvider() {
providerViewId = null;
searchTerm = '';
}
/** Refresh a backend's models, e.g. right after it was added. */
async function showBackendModels(backendId: string): Promise<void> {
await backendsModelsStore.ensureLoaded(backendId);
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
// favorites live above the tabs and may belong to another backend, so the
// lookup spans every enabled backend
const option = allOptions.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
const result = await onModelChange(rawModelId(option.id), option.model, option.backendId);
if (result === false) {
shouldCloseMenu = false;
@@ -175,7 +291,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
// only the built-in server loads on request, and only in router mode
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
const canLoadHere = option.backendId === LOCAL_BACKEND_ID && isRouter;
if (!onModelChange && canLoadHere && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore.status
@@ -187,6 +305,33 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
// External backend: the selection is backend-scoped, so it wins over
// the conversation's model, which may belong to another backend.
if (!serverStore.capabilities.props) {
// the caller's model leads when it says so: a message's own model must
// not follow the active backend's selection
if (currentModel && preferCurrentModel) {
const match = allOptions.find((option) => option.model === currentModel);
return (
match ?? {
capabilities: [],
id: 'message-model',
model: currentModel,
name: currentModel.split('/').pop() || currentModel
}
);
}
const selected = activeId ? allOptions.find((option) => option.id === activeId) : undefined;
if (selected) return selected;
return currentModel
? allOptions.find((option) => option.model === currentModel)
: undefined;
}
const displayModel = serverModel || currentModel;
if (displayModel) {
@@ -211,11 +356,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
};
}
return options.find((option) => option.model === currentModel);
return allOptions.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
return allOptions.find((option) => option.id === activeId);
}
return undefined;
@@ -226,6 +371,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
closeProvider,
get emptyMessage() {
return emptyMessage;
},
@@ -268,7 +415,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
},
get isMultiModel() {
return isRouter;
return isMultiModel;
},
get isProviderView() {
return isProviderView;
},
get isRouter() {
@@ -283,6 +434,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return loading;
},
openProvider,
get options() {
return options;
},
@@ -299,6 +452,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
showBackendModels,
get updating() {
return updating;
}
@@ -6,9 +6,9 @@
* consumed by the backends settings UI and the per-backend model cache.
*/
import { API_MODELS, LOCAL_BACKEND_ID } from '$lib/constants';
import { API_MODELS, BackendProtocol, LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelsService } from '$lib/services/models.service';
import type { ApiModelsListResponse, Backend, BackendProtocol, ModelOption } from '$lib/types';
import type { ApiModelsListResponse, Backend, ModelOption } from '$lib/types';
import { isAbortError } from '$lib/utils/abort';
import { apiUrl } from '$lib/utils/api-base';
import { getAuthHeadersForBackend } from '$lib/utils/api-headers';
@@ -49,7 +49,7 @@ export class BackendsService {
static async detectProtocol(backend: Backend): Promise<BackendProbe> {
const base = backend.baseUrl.trim().replace(/\/+$/, '');
if (!base) return { authRequired: false, protocol: 'openai' };
if (!base) return { authRequired: false, protocol: BackendProtocol.OPENAI };
try {
// llama-server answers /props with its build and generation defaults; a
@@ -62,10 +62,10 @@ export class BackendsService {
// a llama-server behind a key refuses before it says anything else, while
// an OpenAI-compatible endpoint has no /props to guard in the first place
if (response.status === 401) {
return { authRequired: true, protocol: 'llama.cpp' };
return { authRequired: true, protocol: BackendProtocol.COMPAT };
}
if (!response.ok) return { authRequired: false, protocol: 'openai' };
if (!response.ok) return { authRequired: false, protocol: BackendProtocol.OPENAI };
const body = (await response.json()) as Record<string, unknown>;
const isLlamaCpp =
@@ -73,10 +73,10 @@ export class BackendsService {
return {
authRequired: false,
protocol: isLlamaCpp ? 'llama.cpp' : 'openai'
protocol: isLlamaCpp ? BackendProtocol.COMPAT : BackendProtocol.OPENAI
};
} catch {
return { authRequired: false, protocol: 'openai' };
return { authRequired: false, protocol: BackendProtocol.OPENAI };
}
}
+4 -3
View File
@@ -8,11 +8,12 @@
import { openaiAdapter } from './openai';
import type { ChatProtocolAdapter } from './types';
import type { Backend, BackendProtocol } from '$lib/types';
import { BackendProtocol } from '$lib/constants';
import type { Backend } from '$lib/types';
const ADAPTERS: Record<BackendProtocol, ChatProtocolAdapter> = {
'llama.cpp': openaiAdapter,
openai: openaiAdapter
[BackendProtocol.COMPAT]: openaiAdapter,
[BackendProtocol.OPENAI]: openaiAdapter
};
export function getProtocolAdapter(backend?: Backend): ChatProtocolAdapter {
@@ -5,7 +5,7 @@
*/
import type { ChatProtocolAdapter, ChatStreamEvent, ChatStreamReader } from './types';
import { HEADERS } from '$lib/constants';
import { BackendProtocol, HEADERS, MaxTokensField } from '$lib/constants';
import type { Backend } from '$lib/types';
import type { ApiChatCompletionStreamChunk } from '$lib/types/api';
import { getBackendCompat } from '$lib/utils/backend';
@@ -60,7 +60,7 @@ function buildChatRequest(
body: Record<string, unknown>,
backend: Backend
): Record<string, unknown> {
if (backend.protocol === 'llama.cpp') return body;
if (backend.protocol === BackendProtocol.COMPAT) return body;
const compat = getBackendCompat(backend);
const request: Record<string, unknown> = { ...body };
@@ -83,7 +83,10 @@ function buildChatRequest(
// newer OpenAI models require max_completion_tokens, most compatible
// endpoints only understand max_tokens
if (compat.maxTokensField === 'max_completion_tokens' && request.max_tokens !== undefined) {
if (
compat.maxTokensField === MaxTokensField.CHAT_COMPLETION &&
request.max_tokens !== undefined
) {
request.max_completion_tokens = request.max_tokens;
delete request.max_tokens;
}
@@ -9,6 +9,7 @@
import { browser } from '$app/environment';
import {
BackendProtocol,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
HIDDEN_MODELS_LOCALSTORAGE_KEY,
LOCAL_BACKEND_ID,
@@ -648,7 +649,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// local props describe the server the UI is served from; keep them while
// an external backend is active instead of dropping and refetching
if (backend.protocol === 'llama.cpp') {
if (backend.protocol === BackendProtocol.COMPAT) {
serverStore.restoreLocalState();
} else {
serverStore.cacheLocalState();
@@ -664,7 +665,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return;
}
if (backend.protocol === 'llama.cpp' && !serverStore.props) {
if (backend.protocol === BackendProtocol.COMPAT && !serverStore.props) {
// first visit to the local tab in this session
await serverStore.fetch({ background: true });
}
@@ -674,7 +675,11 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// the local router rows carry the load statuses; the startup prefetch
// already returned them, so a tab switch rebuilds the list from memory
if (backend.protocol === 'llama.cpp' && this.routerModels.length === 0 && cached.raw) {
if (
backend.protocol === BackendProtocol.COMPAT &&
this.routerModels.length === 0 &&
cached.raw
) {
this.routerModels = cached.raw.data;
this.activeModels = this.buildModelOptions(cached.raw);
}
+2 -2
View File
@@ -6,7 +6,7 @@
* PropsService for the /props fetch.
*/
import { BACKEND_CAPABILITIES, LOCAL_BACKEND_ID } from '$lib/constants';
import { BACKEND_CAPABILITIES, BackendProtocol, LOCAL_BACKEND_ID } from '$lib/constants';
import { ServerRole } from '$lib/enums';
import { PropsService } from '$lib/services/props.service';
import type { BackendCapabilities } from '$lib/types';
@@ -38,7 +38,7 @@ class ServerStore {
get capabilities(): BackendCapabilities {
const backend = getBackend();
return backend ? getBackendCapabilities(backend) : BACKEND_CAPABILITIES['llama.cpp'];
return backend ? getBackendCapabilities(backend) : BACKEND_CAPABILITIES[BackendProtocol.COMPAT];
}
get contextSize(): number | null {
+4 -3
View File
@@ -7,8 +7,9 @@
* protocol.
*/
/** Request/response shape a backend speaks. */
export type BackendProtocol = 'llama.cpp' | 'openai';
import type { BackendProtocol, MaxTokensField } from '$lib/constants';
export type { BackendProtocol };
/**
* Wire-level quirks of a backend's protocol. Capabilities gate llama.cpp
@@ -16,7 +17,7 @@ export type BackendProtocol = 'llama.cpp' | 'openai';
*/
export interface BackendCompat {
/** Field carrying the output token cap. */
maxTokensField: 'max_completion_tokens' | 'max_tokens';
maxTokensField: MaxTokensField;
/** Whether the endpoint accepts stream_options.include_usage. */
supportsUsageInStreaming: boolean;
}
+17 -12
View File
@@ -12,19 +12,15 @@ import {
BACKEND_ID_PREFIX,
BACKEND_PRESETS,
BACKEND_PROTOCOLS,
BackendProtocol,
DEFAULT_BACKEND_CHAT_PATH,
DEFAULT_BACKEND_MODELS_PATH,
FAVICON_SERVICE_URL,
LOCAL_BACKEND_ID,
MaxTokensField,
MODEL_CONTEXT_LENGTH_FIELDS
} from '$lib/constants';
import type {
Backend,
BackendCapabilities,
BackendCompat,
BackendPreset,
BackendProtocol
} from '$lib/types';
import type { Backend, BackendCapabilities, BackendCompat, BackendPreset } from '$lib/types';
/** Absolute chat completions URL for a backend. */
export function backendChatUrl(backend: Backend): string {
@@ -84,12 +80,18 @@ function normalizeBaseUrl(url: string): string | null {
* (unknown model, early startup) gets the plain compatible defaults.
*/
export function getBackendCapabilities(backend?: Backend): BackendCapabilities {
return BACKEND_CAPABILITIES[backend?.protocol ?? 'openai'] ?? BACKEND_CAPABILITIES.openai;
return (
BACKEND_CAPABILITIES[backend?.protocol ?? BackendProtocol.OPENAI] ??
BACKEND_CAPABILITIES[BackendProtocol.OPENAI]
);
}
/** Wire quirks for a backend: protocol defaults overridden by the backend. */
export function getBackendCompat(backend: Backend): BackendCompat {
return { ...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT.openai), ...backend.compat };
return {
...(BACKEND_COMPAT[backend.protocol] ?? BACKEND_COMPAT[BackendProtocol.OPENAI]),
...backend.compat
};
}
/** The built-in backend pointing at the server that serves this UI. */
@@ -100,7 +102,7 @@ export function createLocalBackend(apiKey?: string, enabled = true): Backend {
enabled,
id: LOCAL_BACKEND_ID,
name: 'Local',
protocol: 'llama.cpp'
protocol: BackendProtocol.COMPAT
};
}
@@ -155,7 +157,7 @@ function parseBackendEntry(entry: unknown, index: number): Backend | null {
const protocol = BACKEND_PROTOCOLS.includes(raw.protocol as BackendProtocol)
? (raw.protocol as BackendProtocol)
: 'openai';
: BackendProtocol.OPENAI;
const id =
typeof raw.id === 'string' && raw.id.trim()
? raw.id.trim()
@@ -190,7 +192,10 @@ function parseBackendCompat(
const defaults = BACKEND_COMPAT[protocol] ?? BACKEND_COMPAT.openai;
const overrides: Partial<BackendCompat> = {};
if (entry.maxTokensField === 'max_tokens' || entry.maxTokensField === 'max_completion_tokens') {
if (
entry.maxTokensField === MaxTokensField.COMPLETION ||
entry.maxTokensField === MaxTokensField.CHAT_COMPLETION
) {
overrides.maxTokensField = entry.maxTokensField;
}
+1
View File
@@ -74,6 +74,7 @@ export {
filterModelOptions,
groupFavoriteOptions,
groupModelOptions,
groupProviderOptions,
windowLocalGroups,
type GroupedModelOptions,
type ModelItem,
+59 -1
View File
@@ -12,8 +12,24 @@ 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 {
available: OrgGroup[];
/** Remote backends, one section each. */
providers: ProviderGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
@@ -103,5 +119,47 @@ export function groupModelOptions(filteredOptions: ModelOption[]): GroupedModelO
available.push({ items, orgName: orgName || null });
}
return { available };
return { available, 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,
recentIds: readonly string[] = []
): ProviderGroup[] {
const byBackend = new Map<string, ModelItem[]>();
const rank = new Map<string, number>();
recentIds.forEach((id, index) => rank.set(id, index));
const rankOf = (id: string) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
// recently used models lead their section, the rest keep the backend's order
const byRecency = (items: ModelItem[]) =>
rank.size === 0 ? items : [...items].sort((a, b) => rankOf(a.option.id) - rankOf(b.option.id));
for (const option of options) {
const backendId = option.backendId ?? '';
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push({ option });
}
return providers.map((provider) => {
const items = byRecency(byBackend.get(provider.backendId) ?? []);
return { ...provider, items: items.slice(0, limit), matched: items.length };
});
}
@@ -4,7 +4,9 @@
import ModelsManagerRowWrapper from './components/ModelsManagerRowWrapper.svelte';
import ModelsManagerRepoRow from '$lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte';
import type { ModelQuantGroup } from '$lib/components/app/models/ModelsManager/utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelGroupKind, ServerRole } from '$lib/enums';
import { backendsStore } from '$lib/stores/backends.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import type { ModelOption } from '$lib/types/models';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -12,6 +14,7 @@ import { userEvent } from 'vitest/browser';
import { render } from 'vitest-browser-svelte';
const option: ModelOption = {
backendId: LOCAL_BACKEND_ID,
capabilities: [],
id: 'org/Qwen3-8B:Q4_K_M',
model: 'org/Qwen3-8B:Q4_K_M',
@@ -34,7 +37,9 @@ describe('manager model row', () => {
beforeEach(() => {
selected = [];
// the row only offers a load control on a router server
// the row resolves the backend that serves it, and only offers a load control on
// a router server
backendsStore.initialize();
serverStore.role = ServerRole.ROUTER;
});
@@ -15,7 +15,7 @@ import type { ApiModelDataEntry, ModelOption } from '$lib/types';
import { beforeEach, describe, expect, it } from 'vitest';
function option(model: string, id = model): ModelOption {
return { capabilities: [], id, model, name: model };
return { backendId: LOCAL_BACKEND_ID, capabilities: [], id, model, name: model };
}
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
@@ -43,8 +43,8 @@ describe('groupModelQuants', () => {
]);
expect(groups.map((group) => [group.key, group.kind, group.quants.length])).toEqual([
['org/Qwen3-8B', ModelGroupKind.QUANTS, 2],
['org/Llama-3-8B', ModelGroupKind.QUANTS, 1]
[`${LOCAL_BACKEND_ID}::org/Qwen3-8B`, ModelGroupKind.QUANTS, 2],
[`${LOCAL_BACKEND_ID}::org/Llama-3-8B`, ModelGroupKind.QUANTS, 1]
]);
});
@@ -61,8 +61,8 @@ describe('groupModelQuants', () => {
]);
expect(groups.map((group) => [group.key, group.kind])).toEqual([
['org/Qwen3-8B::first', ModelGroupKind.VARIANTS],
['org/Qwen3-8B::second', ModelGroupKind.VARIANTS]
[`${LOCAL_BACKEND_ID}::org/Qwen3-8B::first`, ModelGroupKind.VARIANTS],
[`${LOCAL_BACKEND_ID}::org/Qwen3-8B::second`, ModelGroupKind.VARIANTS]
]);
});
});
@@ -3,6 +3,7 @@
// can be hidden and selected at the same time.
import ModelsSelectorHookHarness from './components/ModelsSelectorHookHarness.svelte';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ServerModelStatus, ServerRole } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
import { serverStore } from '$lib/stores/server.svelte';
@@ -11,7 +12,7 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
function option(model: string): ModelOption {
return { capabilities: [], id: model, model, name: model };
return { backendId: LOCAL_BACKEND_ID, capabilities: [], id: model, model, name: model };
}
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
@@ -82,6 +82,7 @@
]}
{isFavorite}
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
overrides={{}}
{selectedId}
/>
</div>
@@ -93,6 +94,7 @@
groups={[section(ModelsTableGroupKind.LOCAL, [repo(qwen, [qwen, qwenQ8])])]}
{isFavorite}
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
overrides={{}}
{selectedId}
/>
</div>
@@ -104,6 +106,7 @@
groups={[]}
{isFavorite}
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
overrides={{}}
{selectedId}
/>
</div>
@@ -101,7 +101,8 @@
items: [availableModels[4]],
orgName: 'intel'
}
]
],
providers: []
};
function handleSelect(modelId: string) {
@@ -133,7 +134,7 @@
<ModelsSelectorList
activeId={null}
currentModel={null}
groups={{ available: [] }}
groups={{ available: [], providers: [] }}
loaded={[loadedModels[0]]}
onSelect={handleSelect}
/>
@@ -146,7 +147,7 @@
activeId={null}
currentModel={null}
favorites={favoriteModels}
groups={{ available: [] }}
groups={{ available: [], providers: [] }}
onSelect={handleSelect}
/>
</div>
+2 -1
View File
@@ -33,7 +33,8 @@ function groups(): GroupedModelOptions {
available: [
{ items: [row('org/a'), row('org/b'), row('org/c')], orgName: 'org' },
{ items: [row('other/d')], orgName: 'other' }
]
],
providers: []
};
}