mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-11 07:20:33 +02:00
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:
@@ -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)
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+8
-7
@@ -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"
|
||||
|
||||
+48
-3
@@ -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
|
||||
|
||||
+24
-3
@@ -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)}
|
||||
/>
|
||||
|
||||
+23
-4
@@ -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}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -83,6 +83,7 @@ export {
|
||||
ModelGroupKind,
|
||||
ModelRowDownloadState,
|
||||
ModelsTableGroupKind,
|
||||
ModelsTableProviderKind,
|
||||
ModelsTableSortKey
|
||||
} from './model.enums';
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Vendored
+4
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export {
|
||||
filterModelOptions,
|
||||
groupFavoriteOptions,
|
||||
groupModelOptions,
|
||||
groupProviderOptions,
|
||||
windowLocalGroups,
|
||||
type GroupedModelOptions,
|
||||
type ModelItem,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user