ui : add the model configuration pane

The pane shows a model's information, load and inference settings side by
side: what its provider reports, the Hub records it falls back to, the
load controls gated by what the serving backend can do, and the
per-model overrides the load form writes. The slider control arrives
with it.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 17:12:27 +02:00
parent 8bc6867ecf
commit 01d8fa0053
7 changed files with 1094 additions and 0 deletions
@@ -0,0 +1,141 @@
<script lang="ts">
import { LOAD_DEFAULTS, type ModelOverride } from '../utils';
import ModelsManagerModelConfigurationHeader from './ModelsManagerModelConfigurationHeader.svelte';
import ModelsManagerModelConfigurationInference from './ModelsManagerModelConfigurationInference.svelte';
import ModelsManagerModelConfigurationInformation from './ModelsManagerModelConfigurationInformation.svelte';
import ModelsManagerModelConfigurationLoad from './ModelsManagerModelConfigurationLoad.svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import { modelsStore, settingsStore } from '$lib/stores';
import type { HfModelDetailInfo } from '$lib/types/huggingface';
import type { ModelOption } from '$lib/types/models';
interface Props {
isCustomized: boolean;
onClose: () => void;
onSave: (override: ModelOverride) => void;
onToggleLoad: () => void;
onUseInNewChat: () => void;
option: ModelOption;
override?: ModelOverride;
}
let { isCustomized, onClose, onSave, onToggleLoad, onUseInNewChat, option, override }: Props =
$props();
let tab = $state('information');
// edits in this pane, cleared on reset; the stored override is the baseline
let edits = $state<ModelOverride | null>(null);
let draft = $derived(edits ?? override ?? {});
// the props cache is a plain Map, so the read has to name its version to stay reactive
let serverProps = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getModelProps(option.model);
});
let status = $derived.by(() => modelsStore.getModelStatus(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isLoaded = $derived(
(status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) &&
!isOperationInProgress
);
let loadProgress = $derived(
isOperationInProgress ? modelsStore.status.getLoadProgress(option.model) : null
);
// The server only reports the full metadata once a model is loaded, which loads
// it. When discovery is on, the Hub fills those gaps instead.
let hubDetails = $state<HfModelDetailInfo | null>(null);
// the window the model can take, from the listing or the Hub; a loaded model's runtime
// context only bounds it when nothing else says otherwise
let contextMax = $derived(
option.contextLength ??
hubDetails?.gguf?.context_length ??
serverProps?.default_generation_settings?.n_ctx ??
LOAD_DEFAULTS.contextLength
);
$effect(() => {
const repo = option.model.split(':')[0] ?? option.model;
let cancelled = false;
hubDetails = null;
if (!settingsStore.config[SETTINGS_KEYS.ENABLE_DISCOVER_MODELS] || !repo.includes('/')) {
return;
}
void HuggingFaceService.getDetails(repo)
.then((details) => {
if (!cancelled) hubDetails = details;
})
.catch(() => {});
return () => {
cancelled = true;
};
});
function resetDraft(): void {
edits = null;
}
</script>
<div class="flex h-full min-h-0 flex-col">
<ModelsManagerModelConfigurationHeader
{isCustomized}
{isLoaded}
{onClose}
{onToggleLoad}
{onUseInNewChat}
{option}
{status}
/>
<Tabs.Root class="mt-3 min-h-0 flex-1 gap-0" onValueChange={(value) => (tab = value)} value={tab}>
<div class="pl-4">
<Tabs.List class="w-full">
<Tabs.Trigger value="information">Information</Tabs.Trigger>
<Tabs.Trigger value="load">Load</Tabs.Trigger>
<Tabs.Trigger value="inference">Inference</Tabs.Trigger>
</Tabs.List>
</div>
<div class="min-h-0 flex-1 overflow-y-auto py-4 pl-4">
<Tabs.Content value="information">
<ModelsManagerModelConfigurationInformation
draftSetting={override?.load?.speculativeDecoding ?? null}
hub={hubDetails}
{option}
{serverProps}
/>
</Tabs.Content>
<Tabs.Content value="load">
<ModelsManagerModelConfigurationLoad
{contextMax}
{draft}
{loadProgress}
onChange={(next) => (edits = next)}
onReset={resetDraft}
onSave={() => onSave(draft)}
/>
</Tabs.Content>
<Tabs.Content value="inference">
<ModelsManagerModelConfigurationInference
{draft}
onChange={(next) => (edits = next)}
onReset={resetDraft}
onSave={() => onSave(draft)}
/>
</Tabs.Content>
</div>
</Tabs.Root>
</div>
@@ -0,0 +1,172 @@
<script lang="ts">
import { Eject, Loader2, Power, Server, SquarePen, X } from '@lucide/svelte';
import { Logo, ModelAvatar, ModelId } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
interface Props {
isCustomized: boolean;
isLoaded: boolean;
onClose: () => void;
onToggleLoad: () => void;
onUseInNewChat: () => void;
option: ModelOption;
/** Load state reported by the server, null when it does not report one. */
status: ServerModelStatus | null;
}
let { isCustomized, isLoaded, onClose, onToggleLoad, onUseInNewChat, option, status }: Props =
$props();
let supportsToolUse = $derived(option.capabilities.includes(ModelCapability.TOOL_USE));
let supportsThinking = $derived(option.capabilities.includes(ModelCapability.REASONING));
let backend = $derived(getBackend(option.backendId));
let backendName = $derived(backend?.name ?? null);
let capabilities = $derived(getBackendCapabilities(backend));
// what the provider speaks decides which server endpoints the pane can read
let compatTitle = $derived(
capabilities.props
? 'llama.cpp server: reads /props and /slots'
: 'OpenAI-compatible: no /props, /slots, load or unload'
);
let isLoading = $derived(status === ServerModelStatus.LOADING);
let statusLabel = $derived.by(() => {
// a provider that cannot load or unload reports no load state, so its
// models are simply on offer
if (!capabilities.loadUnload) return 'Available';
if (status === ServerModelStatus.LOADING) return 'Loading';
if (status === ServerModelStatus.FAILED) return 'Failed to load';
if (status === ServerModelStatus.SLEEPING) return 'Sleeping';
if (isLoaded) return 'Loaded';
return 'Not loaded';
});
let statusDot = $derived.by(() => {
if (!capabilities.loadUnload) return 'bg-green-500';
if (status === ServerModelStatus.FAILED) return 'bg-red-500';
if (status === ServerModelStatus.LOADING) return 'bg-muted-foreground/50 animate-pulse';
if (status === ServerModelStatus.SLEEPING) return 'bg-orange-400';
if (isLoaded) return 'bg-green-500';
return 'bg-muted-foreground/50';
});
</script>
<header class="space-y-2.5 pt-3 pl-4">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<!-- same geometry as the discover details header: base org, quant org badge -->
<ModelAvatar
{option}
quantPositionClass="-bottom-1.5 -right-1.5"
quantSize="h-6 w-6"
showBaseModelAvatar
size="h-12 w-12"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<ModelId
aliases={option.aliases}
class="min-w-0"
modalities={option.modalities}
modelId={option.model}
{supportsThinking}
{supportsToolUse}
tags={option.tags}
title={option.model}
/>
{#if isCustomized}
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">CUSTOMIZED</Badge>
{/if}
</div>
<!-- one line, one voice: state, protocol, owner -->
<p class="mt-1 flex items-center gap-x-2 text-xs text-muted-foreground">
{#if backend && backendName}
<span class="flex min-w-0 items-center gap-1" title={compatTitle}>
<BackendIcon {backend} class="size-4">
{#snippet fallback()}
{#if backend.id === LOCAL_BACKEND_ID}
<Logo class="shrink-0" style="--size: 0.75rem" />
{:else}
<Server class="size-4 shrink-0 opacity-70" />
{/if}
{/snippet}
</BackendIcon>
<!-- a long provider name shortens rather than growing the header -->
<span class="truncate">{backendName}</span>
</span>
{/if}
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 shrink-0 rounded-full {statusDot}"></span>
{statusLabel}
</span>
</p>
</div>
</div>
<Button
aria-label="Close details"
class="h-7 w-7"
onclick={onClose}
size="icon"
variant="ghost"
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button class="flex-1 gap-1.5" onclick={onUseInNewChat} size="sm" variant="outline">
<SquarePen class="h-3.5 w-3.5" />
Start a new chat
</Button>
{#if capabilities.loadUnload}
<Button
class="flex-1 gap-1.5"
disabled={isLoading}
onclick={onToggleLoad}
size="sm"
variant="outline"
>
{#if isLoading}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading...
{:else if isLoaded}
<Eject class="h-3.5 w-3.5" />
Unload model
{:else}
<Power class="h-3.5 w-3.5" />
Load model
{/if}
</Button>
{/if}
</div>
</header>
@@ -0,0 +1,281 @@
<script lang="ts">
import { type ModelOverride, SAMPLING_DEFAULTS } from '../utils';
import { Braces, CircleDot, SlidersHorizontal, X } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { Slider } from '$lib/components/ui/slider';
import { Switch } from '$lib/components/ui/switch';
import { Textarea } from '$lib/components/ui/textarea';
interface Props {
draft: ModelOverride;
onChange: (draft: ModelOverride) => void;
onReset: () => void;
onSave: () => void;
}
let { draft, onChange, onReset, onSave }: Props = $props();
let stopDraft = $state('');
let stopStrings = $derived(draft.stopStrings ?? []);
function patchSampling(patch: Partial<NonNullable<ModelOverride['sampling']>>): void {
onChange({ ...draft, sampling: { ...draft.sampling, ...patch } });
}
function addStopString(): void {
const value = stopDraft.trim();
if (!value) return;
onChange({ ...draft, stopStrings: [...stopStrings, value] });
stopDraft = '';
}
function removeStopString(value: string): void {
onChange({ ...draft, stopStrings: stopStrings.filter((s) => s !== value) });
}
const rowClass = 'flex items-center gap-3 py-2';
const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
</script>
{#snippet numberInput(value: number, onInput: (value: number) => void)}
<Input
class="h-8 w-24 text-right text-sm"
min="0"
oninput={(event) => onInput(Number(event.currentTarget.value) || 0)}
type="number"
{value}
/>
{/snippet}
{#snippet samplingRow(
label: string,
value: number | null | undefined,
enabled: boolean,
onToggle: (enabled: boolean) => void,
onValue: (value: number) => void,
max: number,
step: number
)}
<div class="space-y-1.5 py-2">
<div class="flex items-center gap-2">
<span class="text-sm">{label}</span>
<Checkbox checked={enabled} onCheckedChange={(checked) => onToggle(checked === true)} />
<span class="ml-auto">
{@render numberInput(value ?? 0, onValue)}
</span>
</div>
<Slider
disabled={!enabled}
{max}
onValueChange={(next: number) => onValue(next)}
{step}
type="single"
value={value ?? 0}
/>
</div>
{/snippet}
<p class="pb-2 text-sm font-medium text-muted-foreground">System prompt</p>
<Textarea
class="min-h-24 text-sm"
oninput={(event) => onChange({ ...draft, systemPrompt: event.currentTarget.value })}
placeholder="Example, &quot;Only answer in rhymes&quot;"
value={draft.systemPrompt ?? ''}
/>
<p class="pt-1 text-right text-xs text-muted-foreground">Token count: N/A</p>
<div class="mt-4">
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<SlidersHorizontal class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm font-medium">Sampling</span>
{/snippet}
<div class="pt-1">
{@render samplingRow(
'Temperature',
draft.sampling?.temperature,
draft.sampling?.temperature !== null && draft.sampling?.temperature !== undefined,
(enabled) => patchSampling({ temperature: enabled ? SAMPLING_DEFAULTS.temperature : null }),
(value) => patchSampling({ temperature: value }),
2,
0.05
)}
{@render samplingRow(
'Top K',
draft.sampling?.topK,
draft.sampling?.topK !== null && draft.sampling?.topK !== undefined,
(enabled) => patchSampling({ topK: enabled ? SAMPLING_DEFAULTS.topK : null }),
(value) => patchSampling({ topK: value }),
200,
1
)}
{@render samplingRow(
'Top P',
draft.sampling?.topP,
draft.sampling?.topP !== null && draft.sampling?.topP !== undefined,
(enabled) => patchSampling({ topP: enabled ? SAMPLING_DEFAULTS.topP : null }),
(value) => patchSampling({ topP: value }),
1,
0.01
)}
{@render samplingRow(
'Min P',
draft.sampling?.minP,
draft.sampling?.minP !== null && draft.sampling?.minP !== undefined,
(enabled) => patchSampling({ minP: enabled ? SAMPLING_DEFAULTS.minP : null }),
(value) => patchSampling({ minP: value }),
1,
0.01
)}
{@render samplingRow(
'Repeat Penalty',
draft.sampling?.repeatPenalty,
draft.sampling?.repeatPenalty !== null && draft.sampling?.repeatPenalty !== undefined,
(enabled) =>
patchSampling({ repeatPenalty: enabled ? SAMPLING_DEFAULTS.repeatPenalty : null }),
(value) => patchSampling({ repeatPenalty: value }),
2,
0.01
)}
</div>
</CollapsibleSection>
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<span class="text-sm font-medium">Stop strings</span>
{/snippet}
<div class="space-y-2 pt-1">
{#if stopStrings.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each stopStrings as value (value)}
<button
class="inline-flex cursor-pointer items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs"
onclick={() => removeStopString(value)}
type="button"
>
{value}
<X class="h-3 w-3" />
</button>
{/each}
</div>
{/if}
<Input
bind:value={stopDraft}
class="h-9 text-sm"
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addStopString();
}
}}
placeholder="Enter a string and press Enter"
/>
</div>
</CollapsibleSection>
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<Braces class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm font-medium">Structured output</span>
{/snippet}
<div class="space-y-2 pt-1">
<div class={rowClass}>
<span class="text-sm">Enabled</span>
<Switch
checked={draft.structuredOutput?.enabled ?? false}
class="ml-auto"
onCheckedChange={(checked) =>
onChange({
...draft,
structuredOutput: {
enabled: checked === true,
schema: draft.structuredOutput?.schema ?? ''
}
})}
/>
</div>
<Textarea
class="min-h-20 font-mono text-xs"
disabled={!draft.structuredOutput?.enabled}
oninput={(event) =>
onChange({
...draft,
structuredOutput: {
enabled: draft.structuredOutput?.enabled ?? false,
schema: event.currentTarget.value
}
})}
placeholder={'{ }'}
value={draft.structuredOutput?.schema ?? ''}
/>
</div>
</CollapsibleSection>
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<CircleDot class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm font-medium">Reasoning</span>
{/snippet}
<div class="space-y-2 pt-1">
<div class={rowClass}>
<span class="text-sm">Enable Thinking</span>
<Switch
checked={draft.reasoning?.enabled ?? false}
class="ml-auto"
onCheckedChange={(checked) =>
onChange({
...draft,
reasoning: {
budget: draft.reasoning?.budget ?? 'Unrestricted',
enabled: checked === true
}
})}
/>
</div>
<p class="text-xs text-muted-foreground">
Controls whether the model will think before replying
</p>
<div class={rowClass}>
<span class="text-sm">Reasoning Budget</span>
<span class="ml-auto text-sm text-muted-foreground">
{draft.reasoning?.budget ?? 'Unrestricted'}
</span>
</div>
</div>
</CollapsibleSection>
</div>
<div class="flex justify-end gap-2 pt-4">
<Button onclick={onReset} variant="ghost">Reset</Button>
<Button onclick={onSave}>Save</Button>
</div>
@@ -0,0 +1,245 @@
<script lang="ts">
import { modelDraftsFor, modelQuantLabel, modelSizeLabel, resolveModelSize } from '../utils';
import {
ActionIconCopyToClipboard,
BadgesModality,
CollapsibleSection
} from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { HuggingFaceService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ApiLlamaCppServerProps } from '$lib/types/api';
import type { HfModelDetailInfo } from '$lib/types/huggingface';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils/formatters';
interface Props {
/** Draft the load settings name, when one is set. */
draftSetting?: string | null;
/** Hugging Face details, filled in when discovery is on and the server has none. */
hub?: HfModelDetailInfo | null;
option: ModelOption;
serverProps: ApiLlamaCppServerProps | null | undefined;
}
let { draftSetting = null, hub = null, option, serverProps }: Props = $props();
let quant = $derived(modelQuantLabel(option));
let size = $derived(modelSizeLabel(option));
// a GGUF carries the count in its metadata, a transformers repo in its SafeTensors index
let hubParameters = $derived(HuggingFaceService.parameterCount(hub));
let resolvedSize = $state<string | null>(null);
// the router rarely reports a size, the repo tree does
$effect(() => {
const target = option;
let cancelled = false;
resolvedSize = null;
void resolveModelSize(target)
.then((value) => {
if (!cancelled) resolvedSize = value;
})
.catch(() => {});
return () => {
cancelled = true;
};
});
// the window a model can take, from the listing or from the Hub
let contextLabel = $derived(
option.contextLength
? `${formatParameters(option.contextLength)} tokens`
: hub?.gguf?.context_length
? `${formatNumber(hub.gguf.context_length)} tokens`
: null
);
// the draft a load would speculate with, and any other sidecar on disk
let drafts = $derived(modelDraftsFor(option, draftSetting));
let activeDraft = $derived(drafts.find((draft) => draft.active) ?? null);
let idleDrafts = $derived(drafts.filter((draft) => !draft.active));
let meta = $derived(option.meta);
// a plain OpenAI-compatible endpoint has no /props or /slots to read
let reportsServerInfo = $derived(getBackendCapabilities(getBackend(option.backendId)).props);
// the server reports these once the model is loaded; the Hub knows them anyway
let gguf = $derived(hub?.gguf ?? null);
let modalities = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getModelModalitiesArray(option.id);
});
let rows = $derived([
...(reportsServerInfo
? [
{
isCopyable: true,
isMono: true,
label: 'File Path',
value: serverProps?.model_path ?? null
}
]
: []),
{
label: 'Context Size',
// the server reports the context it runs with once the model is loaded; until then
// the listing, or the Hub, still says what the model can take
value: serverProps
? `${formatNumber(serverProps.default_generation_settings?.n_ctx ?? 0)} tokens`
: (contextLabel ?? null)
},
{
label: 'Model Size',
value: meta?.size ? formatFileSize(meta.size) : (resolvedSize ?? size)
},
{
label: 'Parameters',
value: meta?.n_params
? formatParameters(meta.n_params)
: (option.parsedId?.params ?? (hubParameters ? formatParameters(hubParameters) : null))
},
{ label: 'Embedding Size', value: meta?.n_embd ? formatNumber(meta.n_embd) : null },
{
label: 'Vocabulary Size',
value: meta?.n_vocab ? `${formatNumber(meta.n_vocab)} tokens` : null
},
{ isBadge: true, label: 'Quantization', value: quant },
{
isBadge: true,
label: 'Architecture',
value: (option.meta?.architecture as string) ?? gguf?.architecture ?? null
},
...(activeDraft
? [
{ isBadge: true, label: 'Draft sidecar', value: activeDraft.kind },
{
isCopyable: true,
label: 'Draft model',
value: activeDraft.model ?? option.model.split(':')[0]
},
{ isBadge: true, label: 'Draft quant', value: activeDraft.quant }
]
: []),
{
isBadge: true,
label: 'Other sidecars',
value: idleDrafts.length > 0 ? idleDrafts.map((draft) => draft.kind).join(', ') : null
},
...(reportsServerInfo
? [
{
label: 'Parallel Slots',
value: serverProps?.total_slots != null ? String(serverProps.total_slots) : null
},
{ isMono: true, label: 'Build Info', value: serverProps?.build_info ?? null }
]
: [])
] satisfies Array<{
isBadge?: boolean;
isCopyable?: boolean;
isMono?: boolean;
label: string;
value: string | null;
}>);
// a provider that reports little gets a short list: rows without a value say nothing,
// and a llama-compat server fills the rest in from /props once the model is loaded
let visibleRows = $derived(rows.filter((row) => row.value !== null));
const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
</script>
<div class="space-y-0">
<div class="flex items-center gap-3 border-b border-border/30 py-2.5">
<span class="text-sm text-muted-foreground">Model</span>
<span class="ml-auto flex min-w-0 flex-1 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto font-mono text-xs whitespace-nowrap">
{option.model}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy
text={option.model}
/>
</span>
</div>
{#each visibleRows as row (row.label)}
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
<span class="text-sm text-muted-foreground">{row.label}</span>
<span class="ml-auto flex min-w-0 items-center gap-2 {row.isCopyable ? 'flex-1' : ''}">
{#if row.value === null}
<span class="text-muted-foreground">—</span>
{:else}
<span
class={[
'min-w-0',
// a copyable value scrolls instead of clipping, the icon stays put
row.isCopyable ? 'flex-1 overflow-x-auto whitespace-nowrap' : 'truncate',
row.isBadge ? '' : 'text-sm',
row.isMono ? 'font-mono text-xs' : ''
]}
>
{#if row.isBadge}
<Badge class="h-5 px-1.5 text-[10px]" variant="secondary">{row.value}</Badge>
{:else}
{row.value}
{/if}
</span>
{#if row.isCopyable}
<ActionIconCopyToClipboard
ariaLabel="Copy value to clipboard"
canCopy
text={row.value}
/>
{/if}
{/if}
</span>
</div>
{/each}
{#if visibleRows.length === 0}
<p class="py-2.5 text-sm text-muted-foreground">This provider reports no model metadata.</p>
{/if}
{#if modalities.length > 0}
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
<span class="text-sm text-muted-foreground">Modalities</span>
<span class="ml-auto flex flex-wrap gap-1"><BadgesModality {modalities} /></span>
</div>
{/if}
</div>
{#if !reportsServerInfo}
<p class="pt-2 text-xs text-muted-foreground">
OpenAI-compatible provider: there is no /props or /slots, so only what the listing and the
model's repo report is shown.
</p>
{:else if !serverProps}
<p class="pt-2 text-xs text-muted-foreground">
/props values - file path, slots, build info - appear once the model is loaded. Reading this
page never loads a model.
</p>
{/if}
<div class="mt-4">
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<span class="text-sm font-medium">Chat Template</span>
{/snippet}
<pre
class="mt-1 max-h-48 overflow-auto rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap">{serverProps?.chat_template ??
gguf?.chat_template ??
'Not reported by the server.'}</pre>
</CollapsibleSection>
</div>
@@ -0,0 +1,195 @@
<script lang="ts">
import {
LOAD_DEFAULTS,
type ModelLoadProgress,
type ModelOverride,
SPECULATIVE_OPTIONS
} from '../utils';
import { Info, ListOrdered } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Slider } from '$lib/components/ui/slider';
import { Switch } from '$lib/components/ui/switch';
import { formatParameters } from '$lib/utils/formatters';
interface Props {
contextMax: number;
draft: ModelOverride;
loadProgress: ModelLoadProgress | null;
onChange: (draft: ModelOverride) => void;
onReset: () => void;
onSave: () => void;
}
let { contextMax, draft, loadProgress, onChange, onReset, onSave }: Props = $props();
let load = $derived({ ...LOAD_DEFAULTS, ...draft.load });
function patchLoad(patch: Partial<typeof load>): void {
onChange({ ...draft, load: { ...draft.load, ...patch } });
}
const rowClass = 'flex items-center gap-3 py-2';
const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
</script>
{#snippet numberInput(value: number, onInput: (value: number) => void)}
<Input
class="h-8 w-24 text-right text-sm"
min="0"
oninput={(event) => onInput(Number(event.currentTarget.value) || 0)}
type="number"
{value}
/>
{/snippet}
<p class="pb-2 text-sm font-medium text-muted-foreground">Context and offload</p>
<div class="space-y-4">
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-sm">Context Length</span>
<span class="ml-auto">
{@render numberInput(load.contextLength, (value) => patchLoad({ contextLength: value }))}
</span>
</div>
<p class="text-xs text-muted-foreground">
Model supports up to
<Badge class="h-5 px-1.5 text-[10px]" variant="secondary">
{formatParameters(contextMax)}
</Badge>
tokens
</p>
<Slider
max={contextMax}
onValueChange={(next: number) => patchLoad({ contextLength: next })}
step={512}
type="single"
value={load.contextLength}
/>
</div>
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-sm">
GPU Offload <span class="text-muted-foreground">(layers)</span>
</span>
<span class="ml-auto">
{@render numberInput(load.gpuOffload, (value) => patchLoad({ gpuOffload: value }))}
</span>
</div>
<Slider
max={200}
onValueChange={(next: number) => patchLoad({ gpuOffload: next })}
step={1}
type="single"
value={load.gpuOffload}
/>
</div>
</div>
<div class="mt-4">
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<ListOrdered class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-sm font-medium">Advanced load params</span>
{/snippet}
<div class="pt-1">
<div class={rowClass}>
<span class="text-sm">CPU Thread Pool Size</span>
<span class="ml-auto">
{@render numberInput(load.cpuThreads, (value) => patchLoad({ cpuThreads: value }))}
</span>
</div>
<div class={rowClass}>
<span class="text-sm">Evaluation Batch Size</span>
<span class="ml-auto">
{@render numberInput(load.batchSize, (value) => patchLoad({ batchSize: value }))}
</span>
</div>
<div class={rowClass}>
<span class="text-sm">Physical Batch Size</span>
<span class="ml-auto">
{@render numberInput(load.ubatchSize, (value) => patchLoad({ ubatchSize: value }))}
</span>
</div>
<div class={rowClass}>
<span class="text-sm">Flash Attention</span>
<Switch
checked={load.flashAttention}
class="ml-auto"
onCheckedChange={(checked) => patchLoad({ flashAttention: checked === true })}
/>
</div>
<div class={rowClass}>
<span class="text-sm">Keep Model in Memory</span>
<Switch
checked={load.keepInMemory}
class="ml-auto"
onCheckedChange={(checked) => patchLoad({ keepInMemory: checked === true })}
/>
</div>
<div class={rowClass}>
<span class="text-sm">Try mmap()</span>
<Switch
checked={load.useMmap}
class="ml-auto"
onCheckedChange={(checked) => patchLoad({ useMmap: checked === true })}
/>
</div>
<div class={rowClass}>
<span class="text-sm">Speculative Decoding</span>
<select
class="ml-auto h-8 cursor-pointer rounded-md border border-input bg-transparent px-2 text-sm capitalize"
onchange={(event) => patchLoad({ speculativeDecoding: event.currentTarget.value })}
value={load.speculativeDecoding}
>
{#each SPECULATIVE_OPTIONS as value (value)}
<option {value}>{value.replace('-', ' ')}</option>
{/each}
</select>
</div>
</div>
</CollapsibleSection>
</div>
<div class="mt-4 flex items-start gap-2 rounded-lg bg-muted/40 p-3 text-xs text-muted-foreground">
<Info class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p>Changes apply on next load - eject and re-load to pick them up.</p>
</div>
{#if loadProgress}
<p class="pt-2 text-xs text-muted-foreground">
Loading: {loadProgress.current}
{Math.round(loadProgress.value * 100)}%
</p>
{/if}
<div class="flex justify-end gap-2 pt-4">
<Button onclick={onReset} variant="ghost">Reset</Button>
<Button onclick={onSave}>Save</Button>
</div>
@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};
@@ -0,0 +1,53 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils';
import { Slider as SliderPrimitive } from 'bits-ui';
let {
class: className,
orientation = 'horizontal',
ref = $bindable(null),
value = $bindable(),
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
class={cn(
'data-[orientation=vertical]:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className
)}
data-slot="slider"
{orientation}
{...restProps}
>
{#snippet children({ thumbItems })}
<span
class={cn(
'bg-muted rounded-full data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1 relative grow overflow-hidden data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full'
)}
data-orientation={orientation}
data-slot="slider-track"
>
<SliderPrimitive.Range
class={cn(
'bg-primary absolute select-none data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full'
)}
data-slot="slider-range"
/>
</span>
{#each thumbItems as thumb (thumb.index)}
<SliderPrimitive.Thumb
class="border-ring ring-ring/50 relative size-3 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
data-slot="slider-thumb"
index={thumb.index}
/>
{/each}
{/snippet}
</SliderPrimitive.Root>