mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-02 10:57:33 -05: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)
|
||||
|
||||
+29
-6
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import {
|
||||
backendsModelsStore,
|
||||
backendsStore,
|
||||
conversationsStore,
|
||||
deviceStore,
|
||||
modelsStore,
|
||||
serverStore
|
||||
} from '$lib/stores';
|
||||
import { getConversationModel } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -28,7 +36,14 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
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 conversationModel = $derived(
|
||||
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
|
||||
@@ -52,10 +67,16 @@
|
||||
|
||||
$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. sends resolve their
|
||||
// backend explicitly via ensureModelBackend
|
||||
if (option && option.backendId === backendsStore.active.id) {
|
||||
modelsStore.selectedModelName = conversationModel;
|
||||
modelsStore.selectModelByName(conversationModel);
|
||||
} else {
|
||||
} else if (!option) {
|
||||
modelsStore.selectedModelName = null;
|
||||
modelsStore.clearSelection();
|
||||
}
|
||||
@@ -155,7 +176,8 @@
|
||||
<ModelsSelectorSheet
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{disabled}
|
||||
error={selectorError}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
@@ -163,7 +185,8 @@
|
||||
<ModelsSelectorDropdown
|
||||
bind:this={selectorModelRef}
|
||||
currentModel={selectorModel}
|
||||
disabled={disabled || isOffline}
|
||||
{disabled}
|
||||
error={selectorError}
|
||||
{forceForegroundText}
|
||||
{useGlobalSelection}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -87,10 +89,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"
|
||||
>
|
||||
@@ -99,7 +101,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'}"
|
||||
>
|
||||
@@ -107,21 +109,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'}"
|
||||
/>
|
||||
@@ -132,12 +134,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"
|
||||
>
|
||||
@@ -148,7 +150,7 @@
|
||||
|
||||
<Button
|
||||
class="gap-1.5"
|
||||
onclick={() => (view = 'providers')}
|
||||
onclick={() => (view = MODELS_DIALOG_VIEW.PROVIDERS)}
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
@@ -158,7 +160,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,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ModelOrgAvatar from './ModelOrgAvatar.svelte';
|
||||
import { HF_BASE_MODEL_TAG_REGEX } 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 } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
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 {
|
||||
@@ -38,6 +42,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);
|
||||
// Avatars come from the Hub, so with the metadata setting off they are
|
||||
// hidden entirely and no base-model lookup runs.
|
||||
let hubEnabled = $derived(HuggingFaceService.isEnabled());
|
||||
@@ -59,6 +69,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)
|
||||
@@ -74,7 +90,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';
|
||||
@@ -21,7 +22,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 = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? '';
|
||||
|
||||
@@ -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,22 +4,31 @@
|
||||
import {
|
||||
downloadGroups,
|
||||
groupModelQuants,
|
||||
loadExtraArgs,
|
||||
loadOverrides,
|
||||
modelContextLength,
|
||||
modelDraftBadges,
|
||||
type ModelOverride,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup,
|
||||
modelSupports
|
||||
modelSupports,
|
||||
saveOverrides
|
||||
} from './utils';
|
||||
import {
|
||||
LOCAL_BACKEND_ID,
|
||||
type ModalityKey,
|
||||
MODELS_TABLE_GROUP_LABELS,
|
||||
ModelsTableGroupKind
|
||||
ModelsTableGroupKind,
|
||||
ModelsTableProviderKind
|
||||
} from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { conversationsStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import { backendsStore, conversationsStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
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;
|
||||
@@ -30,10 +39,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);
|
||||
|
||||
@@ -101,6 +113,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(() => {
|
||||
const term = filter.trim().toLowerCase();
|
||||
|
||||
@@ -116,6 +130,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;
|
||||
@@ -124,6 +145,24 @@
|
||||
return contextLimit === 0 || modelContextLength(option) >= 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(() => {
|
||||
@@ -138,15 +177,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) ||
|
||||
@@ -177,24 +220,59 @@
|
||||
(entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id))
|
||||
);
|
||||
const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key));
|
||||
const local = rest.filter((entry) => !claimed.has(entry.key) && !hiddenKeys.has(entry.key));
|
||||
const byBackend = new SvelteMap<string, ModelQuantGroup[]>();
|
||||
|
||||
for (const entry of rest) {
|
||||
if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue;
|
||||
|
||||
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;
|
||||
@@ -219,7 +297,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await modelsStore.status.load(option.model);
|
||||
await modelsStore.status.load(option.model, loadExtraArgs(overrides[option.id]));
|
||||
}
|
||||
|
||||
async function useInNewChat(option: ModelOption): Promise<void> {
|
||||
@@ -229,6 +307,24 @@
|
||||
uiStore.manageModelsOpen = false;
|
||||
uiStore.requestComposerFocus();
|
||||
}
|
||||
|
||||
/** 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={['flex min-h-0 flex-1', className]}>
|
||||
@@ -236,11 +332,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,12 +100,83 @@
|
||||
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}
|
||||
|
||||
<ScrollCarousel alwaysShowArrows class="min-w-0 flex-1" 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"
|
||||
@@ -107,6 +195,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_ID, MODEL_ROW_GRID_CLASS, ModelRowDownloadState } from '$lib/constants';
|
||||
@@ -15,16 +21,31 @@
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
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));
|
||||
@@ -38,6 +59,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 {
|
||||
@@ -84,7 +108,7 @@
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1"
|
||||
draftSidecars={option.draftSidecars}
|
||||
draftSidecars={draftBadges}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
modalities={option.modalities}
|
||||
@@ -97,7 +121,11 @@
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
|
||||
<ModelContext
|
||||
class="justify-self-end"
|
||||
configured={configuredContext(option, overrides)}
|
||||
{option}
|
||||
/>
|
||||
|
||||
<ModelsManagerStatusCell {download} {option} />
|
||||
|
||||
@@ -111,7 +139,11 @@
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center">
|
||||
<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"
|
||||
|
||||
+52
-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 {
|
||||
isModelRunning,
|
||||
modelContextLength,
|
||||
type ModelOverride,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup
|
||||
} from './utils';
|
||||
@@ -35,10 +37,11 @@
|
||||
MODEL_ROW_GRID_CLASS,
|
||||
MODEL_ROW_WINDOW,
|
||||
ModelsTableGroupKind,
|
||||
ModelsTableProviderKind,
|
||||
ModelsTableSortKey
|
||||
} from '$lib/constants';
|
||||
import { KeyboardKey, ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
|
||||
import { 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';
|
||||
@@ -49,12 +52,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;
|
||||
@@ -63,17 +76,35 @@
|
||||
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(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
|
||||
let hasFilters = $derived(
|
||||
providers.length > 0 ||
|
||||
contextLimit > 0 ||
|
||||
modalities.length > 0 ||
|
||||
capabilities.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> = {
|
||||
@@ -218,27 +249,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}
|
||||
@@ -328,8 +367,11 @@
|
||||
<ModelsManagerTableToolbar
|
||||
bind:capabilities
|
||||
bind:contextLimit
|
||||
bind:draft
|
||||
bind:filter
|
||||
bind:modalities
|
||||
bind:providers
|
||||
{providerCounts}
|
||||
{toolbarEnd}
|
||||
/>
|
||||
|
||||
@@ -366,12 +408,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
|
||||
sticky
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
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';
|
||||
@@ -11,18 +11,37 @@
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
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));
|
||||
@@ -48,20 +67,30 @@
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
|
||||
<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>
|
||||
</span>
|
||||
|
||||
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
|
||||
<ModelContext
|
||||
class="justify-self-end"
|
||||
configured={configuredContext(option, overrides)}
|
||||
{option}
|
||||
/>
|
||||
|
||||
<ModelsManagerStatusCell {option} />
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center">
|
||||
<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, isModelRunning } from './utils';
|
||||
import { configuredContext, isModelRunning, type ModelOverride } from './utils';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
|
||||
import { KeyboardKey } 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 === 'variants'
|
||||
? `${entry.quants.length} variants`
|
||||
: `${entry.quants.length} quants available`
|
||||
entry.kind === 'providers'
|
||||
? `${providerCount} provider${providerCount === 1 ? '' : 's'}`
|
||||
: entry.kind === 'variants'
|
||||
? `${entry.quants.length} variants`
|
||||
: `${entry.quants.length} quants available`
|
||||
);
|
||||
let anyLoaded = $derived(entry.quants.some((quant) => isModelRunning(quant)));
|
||||
// a repo row stands for its quants, so it reports what they agree on
|
||||
@@ -78,7 +83,7 @@
|
||||
|
||||
<ModelContext
|
||||
class="justify-self-end"
|
||||
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/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
@@ -27,11 +28,13 @@
|
||||
<ModelsManagerDownloadControl class="justify-self-center {className}" {option} state={download} />
|
||||
{:else}
|
||||
<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
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+28
-3
@@ -5,7 +5,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { type ModalityKey } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { uiStore } from '$lib/stores';
|
||||
import { backendsStore, uiStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -13,9 +13,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;
|
||||
}
|
||||
@@ -23,12 +29,21 @@
|
||||
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(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
|
||||
let hasFilters = $derived(
|
||||
providers.length > 0 ||
|
||||
contextLimit > 0 ||
|
||||
modalities.length > 0 ||
|
||||
capabilities.length > 0 ||
|
||||
draft
|
||||
);
|
||||
let filterInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// the dialog hands focus to its first control, so the filter takes it instead
|
||||
@@ -59,15 +74,25 @@
|
||||
size="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,17 +1,34 @@
|
||||
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, MODEL_ID, ModelRowDownloadState } from '$lib/constants';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
/** 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',
|
||||
@@ -39,7 +56,7 @@ export function modelRowActions(
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete from disk',
|
||||
onclick: () => onDelete(option),
|
||||
onclick: () => handlers.onDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
},
|
||||
@@ -48,18 +65,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',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ModelGroupKind,
|
||||
type ModelSidecar,
|
||||
ModelsTableGroupKind,
|
||||
ModelsTableProviderKind,
|
||||
SETTINGS_KEYS,
|
||||
SPEC_TYPE
|
||||
} from '$lib/constants';
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
ModelDownloadProgress,
|
||||
ModelLoadProgress,
|
||||
ModelOption,
|
||||
ModelSidecarBadge,
|
||||
ModelSidecarFile
|
||||
} from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
|
||||
@@ -115,30 +117,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;
|
||||
@@ -305,6 +287,31 @@ export function modelDrafts(
|
||||
return drafts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a model has a capability. A listing that declares nothing is not a listing
|
||||
* that lacks it: the chat template the Hub carries says whether tools or reasoning work.
|
||||
@@ -320,10 +327,25 @@ export function isModelRunning(option: ModelOption): boolean {
|
||||
}
|
||||
|
||||
/** Context the model runs with: what a loaded model reports. */
|
||||
export function configuredContext(option: ModelOption): number | null {
|
||||
/** 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 isModelRunning(option) ? 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 modelSupports(option: ModelOption, capability: ModelCapability): boolean {
|
||||
if (option.capabilities.includes(capability)) return true;
|
||||
|
||||
@@ -442,7 +464,7 @@ export function groupModelQuants(models: ModelOption[], mergeProviders = false):
|
||||
// the very same id twice is not a quant set; keep those rows apart, unless
|
||||
// the group exists to list the providers that serve it
|
||||
if (
|
||||
kind !== 'providers' &&
|
||||
kind !== ModelGroupKind.PROVIDERS &&
|
||||
modelIds.length > 1 &&
|
||||
modelIds.every((model) => model === modelIds[0])
|
||||
) {
|
||||
@@ -463,7 +485,7 @@ export function groupModelQuants(models: ModelOption[], mergeProviders = false):
|
||||
function groupKind(quants: ModelOption[]): ModelGroupKind {
|
||||
const backends = new Set(quants.map((option) => option.backendId ?? ''));
|
||||
|
||||
if (backends.size > 1) return 'providers';
|
||||
if (backends.size > 1) return ModelGroupKind.PROVIDERS;
|
||||
|
||||
const isQuant = quants.every(
|
||||
(option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
|
||||
|
||||
@@ -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>
|
||||
|
||||
+46
-10
@@ -8,6 +8,7 @@
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorTriggerIcon
|
||||
} from '$lib/components/app';
|
||||
import { DialogBackendForm } from '$lib/components/app/backends';
|
||||
import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -23,13 +24,20 @@
|
||||
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
import { rawModelId } from '$lib/utils/model-option-id';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
disabled?: boolean;
|
||||
/** The provider behind this selector is unreachable. */
|
||||
error?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
onModelChange?: (
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
backendId?: string
|
||||
) => Promise<boolean> | boolean | void;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
@@ -37,6 +45,7 @@
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
error = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
@@ -44,6 +53,7 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
let showAddBackend = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -117,6 +127,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;
|
||||
});
|
||||
@@ -150,6 +163,13 @@
|
||||
setTimeout(() => uiStore.openModelsManager(), 0);
|
||||
}
|
||||
|
||||
function handleAddBackend() {
|
||||
isOpen = false;
|
||||
|
||||
// let the menu finish closing before the dialog takes focus
|
||||
setTimeout(() => (showAddBackend = true), 0);
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
@@ -158,12 +178,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const model = modelsStore.routerModels.find((m) => m.id === modelId);
|
||||
const rawId = rawModelId(modelId);
|
||||
const model = modelsStore.routerModels.find((m) => m.id === rawId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.status.unload(modelId);
|
||||
await modelsStore.status.unload(rawId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
@@ -210,7 +231,13 @@
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">No models yet.</span>
|
||||
<button
|
||||
class="cursor-pointer text-xs 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}
|
||||
{:else}
|
||||
{@const triggerStatus = triggerModel
|
||||
@@ -234,13 +261,15 @@
|
||||
{...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 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'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground',
|
||||
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
||||
]}
|
||||
@@ -357,6 +386,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"
|
||||
@@ -430,3 +461,8 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,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 {
|
||||
@@ -36,6 +40,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',
|
||||
@@ -222,6 +228,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;
|
||||
@@ -41,6 +44,9 @@
|
||||
showRepoOrgAvatar = false
|
||||
}: Props = $props();
|
||||
|
||||
// row actions follow the backend that serves the row, not the selected one
|
||||
let rowBackend = $derived(getBackend(option.backendId));
|
||||
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
|
||||
let currentRouterModels = $derived(modelsStore.routerModels);
|
||||
let serverStatus = $derived.by(() => {
|
||||
const model = currentRouterModels.find((m) => m.id === option.model);
|
||||
@@ -92,7 +98,7 @@
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1"
|
||||
draftSidecars={option.draftSidecars}
|
||||
draftSidecars={modelDraftBadges(option)}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
{hideOrgName}
|
||||
@@ -109,7 +115,15 @@
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<ModelRowActions {isFav} {option} />
|
||||
|
||||
<ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
|
||||
<ModelLoadControl
|
||||
{canLoad}
|
||||
{isFailed}
|
||||
{isLoaded}
|
||||
{isLoading}
|
||||
{isSleeping}
|
||||
{option}
|
||||
showBackendMark
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
ModelsSelectorTriggerIcon,
|
||||
SearchInput
|
||||
} from '$lib/components/app';
|
||||
import { DialogBackendForm } from '$lib/components/app/backends';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
@@ -18,8 +19,14 @@
|
||||
class?: string;
|
||||
currentModel?: string | null;
|
||||
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
|
||||
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
||||
onModelChange?: (
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
backendId?: string
|
||||
) => Promise<boolean> | boolean | void;
|
||||
disabled?: boolean;
|
||||
/** The provider behind this selector is unreachable. */
|
||||
error?: boolean;
|
||||
forceForegroundText?: boolean;
|
||||
/** When true, user's global selection takes priority over currentModel (for form selector) */
|
||||
useGlobalSelection?: boolean;
|
||||
@@ -29,12 +36,14 @@
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
error = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
let showAddBackend = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -63,6 +72,13 @@
|
||||
// let the sheet finish closing before the dialog takes focus
|
||||
setTimeout(() => uiStore.openModelsManager(), 0);
|
||||
}
|
||||
|
||||
function handleAddBackend() {
|
||||
sheetOpen = false;
|
||||
|
||||
// let the sheet finish closing before the dialog takes focus
|
||||
setTimeout(() => (showAddBackend = true), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
@@ -72,7 +88,13 @@
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel}
|
||||
<span class="text-xs text-muted-foreground">No models yet.</span>
|
||||
<button
|
||||
class="cursor-pointer text-xs 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>
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
@@ -91,13 +113,15 @@
|
||||
<button
|
||||
class={[
|
||||
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
sheetOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
@@ -176,6 +200,8 @@
|
||||
favorites={ms.favoriteItems}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
loaded={ms.loadedItems}
|
||||
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
|
||||
onProviderOpen={ms.openProvider}
|
||||
onSelect={ms.handleSelect}
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
{showOrgName}
|
||||
@@ -200,13 +226,15 @@
|
||||
<button
|
||||
class={[
|
||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 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'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
@@ -228,3 +256,8 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
|
||||
@@ -12,9 +12,25 @@ export interface OrgGroup {
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
/** One remote backend's section on the remote view. */
|
||||
export interface ProviderGroup {
|
||||
backendId: string;
|
||||
/** Models the provider lists without any search filtering. */
|
||||
catalog: number;
|
||||
error: string | null;
|
||||
/** Rows to render, capped by the display limit. */
|
||||
items: ModelItem[];
|
||||
loading: boolean;
|
||||
/** Rows left after the search filter, before the cap. */
|
||||
matched: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
available: OrgGroup[];
|
||||
loaded: ModelItem[];
|
||||
/** Remote backends, one section each. */
|
||||
providers: ProviderGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
@@ -133,5 +149,48 @@ export function groupModelOptions(
|
||||
available.push({ items, orgName: orgName || null });
|
||||
}
|
||||
|
||||
return { available, loaded };
|
||||
return { available, loaded, providers: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote backends as sections, one per backend, in the given order. Each section
|
||||
* keeps at most `limit` rows; `matched` carries the full count so the caller can
|
||||
* offer the rest.
|
||||
*/
|
||||
export function groupProviderOptions(
|
||||
options: ModelOption[],
|
||||
providers: {
|
||||
backendId: string;
|
||||
catalog: number;
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
name: string;
|
||||
}[],
|
||||
limit = Infinity,
|
||||
recentIds: readonly string[] = []
|
||||
): ProviderGroup[] {
|
||||
const byBackend = new SvelteMap<string, ModelItem[]>();
|
||||
const rank = new SvelteMap<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 (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
const backendId = option.backendId ?? '';
|
||||
|
||||
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
|
||||
|
||||
byBackend.get(backendId)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
return providers.map((provider) => {
|
||||
const items = byRecency(byBackend.get(provider.backendId) ?? []);
|
||||
|
||||
return { ...provider, items: items.slice(0, limit), matched: items.length };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
/** What a repo group of the table folds, which decides its label. */
|
||||
export const ModelGroupKind = {
|
||||
PROVIDERS: 'providers',
|
||||
QUANTS: 'quants',
|
||||
VARIANTS: 'variants'
|
||||
} as const;
|
||||
@@ -19,6 +20,15 @@ export const ModelsTableGroupKind = {
|
||||
|
||||
export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
|
||||
|
||||
/** Kinds a provider block carries on top of the manager's own sections. */
|
||||
export const ModelsTableProviderKind = {
|
||||
COMPAT: 'compat',
|
||||
PROVIDER: 'provider'
|
||||
} as const;
|
||||
|
||||
export type ModelsTableProviderKind =
|
||||
(typeof ModelsTableProviderKind)[keyof typeof ModelsTableProviderKind];
|
||||
|
||||
/** Header label of each manager section. */
|
||||
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
|
||||
[ModelsTableGroupKind.DOWNLOADING]: 'Downloading',
|
||||
@@ -45,3 +55,12 @@ export const ModelsTableSortKey = {
|
||||
} as const;
|
||||
|
||||
export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
|
||||
|
||||
/** 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];
|
||||
|
||||
@@ -2,19 +2,34 @@ import type { ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import {
|
||||
filterModelOptions,
|
||||
groupFavoriteOptions,
|
||||
groupModelOptions
|
||||
groupModelOptions,
|
||||
groupProviderOptions
|
||||
} from '$lib/components/app/navigation/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { modelsStore, serverStore, uiStore } from '$lib/stores';
|
||||
import {
|
||||
CHAT_INPUT_FOCUS_SELECTOR,
|
||||
LOCAL_BACKEND_ID,
|
||||
REMOTE_PROVIDER_MODEL_LIMIT
|
||||
} from '$lib/constants';
|
||||
import { backendsModelsStore, backendsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import { getBackendCapabilities } from '$lib/utils/backend';
|
||||
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 = { available: [], loaded: [], providers: [] };
|
||||
|
||||
export interface UseModelsSelectorOptions {
|
||||
currentModel: () => string | null;
|
||||
useGlobalSelection?: () => 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;
|
||||
}
|
||||
@@ -34,10 +49,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;
|
||||
@@ -52,21 +71,36 @@ export interface UseModelsSelectorReturn {
|
||||
* duplicating store derivations, 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
|
||||
const isMultiModel = $derived(serverStore.isRouterMode);
|
||||
// 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());
|
||||
@@ -74,28 +108,54 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
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);
|
||||
});
|
||||
const visibleOptions = $derived(options.filter((option) => !modelsStore.isHidden(option.id)));
|
||||
|
||||
let isLoadingModel = $state(false);
|
||||
let searchTerm = $state('');
|
||||
|
||||
const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
|
||||
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||
// favorites span every backend, so they come from the full option list
|
||||
const favoriteItems = $derived(
|
||||
groupFavoriteOptions(
|
||||
filterModelOptions(visibleOptions, searchTerm),
|
||||
modelsStore.favoriteModelIds
|
||||
)
|
||||
);
|
||||
const loadedItems = $derived(
|
||||
filterModelOptions(visibleOptions, searchTerm)
|
||||
.map((option, flatIndex) => ({ flatIndex, option }))
|
||||
.filter(({ option }) => modelsStore.isModelLoaded(option.model))
|
||||
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 filterModelOptions(visibleOptions, searchTerm)
|
||||
.map((option, flatIndex) => ({ flatIndex, option }))
|
||||
.filter(({ option }) => isLoadedLlamaCompat(option));
|
||||
});
|
||||
const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
|
||||
// loaded models and favorites are listed once, at the top: the sections skip both
|
||||
const sectionOptions = $derived(
|
||||
@@ -103,9 +163,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
(option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
|
||||
)
|
||||
);
|
||||
const groupedFilteredOptions = $derived(
|
||||
groupModelOptions(sectionOptions, (model) => modelsStore.isModelLoaded(model))
|
||||
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), (m) =>
|
||||
modelsStore.isModelLoaded(m)
|
||||
);
|
||||
|
||||
return { ...local, providers: providerSections };
|
||||
});
|
||||
const isEmpty = $derived(
|
||||
filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
|
||||
);
|
||||
@@ -135,6 +214,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
}
|
||||
|
||||
searchTerm = '';
|
||||
providerViewId = null;
|
||||
|
||||
if (open && isRouter) {
|
||||
modelsStore.props.fetchModalitiesForLoadedModels();
|
||||
@@ -143,15 +223,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;
|
||||
@@ -171,7 +273,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
|
||||
@@ -183,6 +287,18 @@ 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) {
|
||||
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) {
|
||||
@@ -207,11 +323,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;
|
||||
@@ -222,6 +338,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return activeId;
|
||||
},
|
||||
|
||||
closeProvider,
|
||||
|
||||
get emptyMessage() {
|
||||
return emptyMessage;
|
||||
},
|
||||
@@ -267,6 +385,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return isMultiModel;
|
||||
},
|
||||
|
||||
get isProviderView() {
|
||||
return isProviderView;
|
||||
},
|
||||
|
||||
get isRouter() {
|
||||
return isRouter;
|
||||
},
|
||||
@@ -279,6 +401,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
return loading;
|
||||
},
|
||||
|
||||
openProvider,
|
||||
|
||||
get options() {
|
||||
return options;
|
||||
},
|
||||
@@ -295,6 +419,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,
|
||||
@@ -652,7 +653,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();
|
||||
@@ -668,7 +669,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 });
|
||||
}
|
||||
@@ -678,7 +679,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
orgName: 'intel'
|
||||
}
|
||||
],
|
||||
loaded: loadedModels
|
||||
loaded: loadedModels,
|
||||
providers: []
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
@@ -137,7 +138,8 @@
|
||||
currentModel={null}
|
||||
groups={{
|
||||
available: [],
|
||||
loaded: [loadedModels[0]]
|
||||
loaded: [loadedModels[0]],
|
||||
providers: []
|
||||
}}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
@@ -152,7 +154,8 @@
|
||||
favorites={favoriteModels}
|
||||
groups={{
|
||||
available: [],
|
||||
loaded: []
|
||||
loaded: [],
|
||||
providers: []
|
||||
}}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user