mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-29 01:17:36 -05:00
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:
+141
@@ -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>
|
||||
+172
@@ -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>
|
||||
+281
@@ -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, "Only answer in rhymes""
|
||||
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>
|
||||
+245
@@ -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>
|
||||
+195
@@ -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>
|
||||
Reference in New Issue
Block a user