mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-25 22:21:03 +02:00
feat: WIP
This commit is contained in:
+8
-1
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Info, LoaderCircle, PencilRuler } from '@lucide/svelte';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
PencilRuler
|
||||
} from '@lucide/svelte';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
|
||||
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
|
||||
import { mountSvgShadow } from '$lib/utils/svg-shadow';
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
@@ -77,6 +78,8 @@
|
||||
content: string;
|
||||
class?: string;
|
||||
disableMath?: boolean;
|
||||
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
|
||||
allowHtml?: boolean;
|
||||
}
|
||||
|
||||
interface MarkdownBlock {
|
||||
@@ -85,7 +88,13 @@
|
||||
contentHash?: string;
|
||||
}
|
||||
|
||||
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
|
||||
let {
|
||||
allowHtml = false,
|
||||
attachments,
|
||||
class: className = '',
|
||||
content,
|
||||
disableMath = false
|
||||
}: Props = $props();
|
||||
|
||||
let containerRef = $state<HTMLDivElement>();
|
||||
let renderedBlocks = $state<MarkdownBlock[]>([]);
|
||||
@@ -148,6 +157,7 @@
|
||||
|
||||
let processor = $derived(() => {
|
||||
void attachments;
|
||||
void allowHtml;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
@@ -155,10 +165,15 @@
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
|
||||
|
||||
if (!allowHtml) {
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
proc = proc.use(remarkLiteralHtml);
|
||||
}
|
||||
|
||||
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
|
||||
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
@@ -261,10 +276,11 @@
|
||||
const singleNodeRoot = { children: [node], type: 'root' };
|
||||
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
|
||||
const html = processorInstance.stringify(transformedRoot);
|
||||
const safeHtml = allowHtml ? (DOMPurify.sanitize(html) as unknown as string) : html;
|
||||
|
||||
transformCache.set(hash, html);
|
||||
transformCache.set(hash, safeHtml);
|
||||
|
||||
return { hash, html };
|
||||
return { hash, html: safeHtml };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -463,6 +479,10 @@
|
||||
)) as HastRoot;
|
||||
|
||||
unstableHtml = processorInstance.stringify(transformedRoot);
|
||||
|
||||
if (allowHtml) {
|
||||
unstableHtml = DOMPurify.sanitize(unstableHtml) as unknown as string;
|
||||
}
|
||||
}
|
||||
|
||||
renderedBlocks = nextBlocks;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Boxes } from '@lucide/svelte';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import { ModelsDiscoverList, ModelsDiscoverDetails } from '$lib/components/app/models/discover';
|
||||
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -57,42 +56,43 @@
|
||||
|
||||
<Dialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Content
|
||||
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!" style="grid-template-columns: auto 1fr;"
|
||||
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
|
||||
style="grid-template-columns: auto 1fr;"
|
||||
>
|
||||
<aside
|
||||
class="sticky top-0 w-100 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1 md:max-h-239.5!"
|
||||
>
|
||||
<div class="p-2 sticky top-0 z-99">
|
||||
<SearchInput
|
||||
class=""
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search models..."
|
||||
onInput={handleSearchInput}
|
||||
<aside
|
||||
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
|
||||
>
|
||||
<div class="p-2 sticky top-0 z-99">
|
||||
<SearchInput
|
||||
class=""
|
||||
bind:value={searchQuery}
|
||||
placeholder="Search models..."
|
||||
onInput={handleSearchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if modelsHubStore.loading}
|
||||
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
|
||||
{:else if modelsHubStore.error}
|
||||
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
|
||||
{:else if modelsHubStore.models.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No models found</p>
|
||||
{:else}
|
||||
<ModelsDiscoverList
|
||||
models={modelsHubStore.models}
|
||||
activeId={selectedId}
|
||||
showBaseModelAvatar
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if modelsHubStore.loading}
|
||||
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
|
||||
{:else if modelsHubStore.error}
|
||||
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
|
||||
{:else if modelsHubStore.models.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No models found</p>
|
||||
{:else}
|
||||
<ModelsDiscoverList
|
||||
models={modelsHubStore.models}
|
||||
activeId={selectedId}
|
||||
showBaseModelAvatar
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
{#if selectedId}
|
||||
<ModelsDiscoverDetails modelId={selectedId} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="overflow-y-auto">
|
||||
{#if selectedId}
|
||||
<ModelsDiscoverDetails modelId={selectedId} />
|
||||
{/if}
|
||||
</main>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { Database, Image, Lightbulb, Mic, ScrollText, Video, Wrench } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
hideName?: boolean;
|
||||
hideModalities?: boolean;
|
||||
hideReasoning?: boolean;
|
||||
hideParameters?: boolean;
|
||||
showRaw?: boolean;
|
||||
showRawTooltip?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
hideTags?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
/** Render the capability/modality/context icons on a second row. */
|
||||
iconsOnNewLine?: boolean;
|
||||
modalities?: ModelModalities;
|
||||
supportsThinking?: boolean;
|
||||
supportsToolUse?: boolean;
|
||||
/** Context length in tokens; renders a context icon when set. */
|
||||
contextLength?: number;
|
||||
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
|
||||
sizeRange?: { min: number; max: number } | null;
|
||||
draftVariants?: DraftVariant[];
|
||||
/** Allow badges to wrap onto new lines instead of truncating. */
|
||||
wrap?: boolean;
|
||||
@@ -27,15 +40,23 @@
|
||||
let {
|
||||
aliases,
|
||||
class: className = '',
|
||||
contextLength,
|
||||
draftVariants,
|
||||
hideModalities = false,
|
||||
hideName = false,
|
||||
hideOrgName = false,
|
||||
hideParameters = false,
|
||||
hideQuantization,
|
||||
hideReasoning = false,
|
||||
hideTags,
|
||||
iconsOnNewLine = false,
|
||||
modalities,
|
||||
modelId,
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
sizeRange,
|
||||
supportsThinking = false,
|
||||
supportsToolUse = false,
|
||||
tags,
|
||||
wrap = false,
|
||||
...rest
|
||||
@@ -60,9 +81,7 @@
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
let uniqueDraftVariants = $derived([...new Set(draftVariants ?? [])]);
|
||||
let hasModalityIcons = $derived(
|
||||
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
|
||||
);
|
||||
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -72,9 +91,11 @@
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
{#snippet nameAndBadges()}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
{#if !hideName}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
|
||||
{#if parsed.variant}
|
||||
@@ -83,18 +104,18 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.params && !hideParameters}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#each uniqueDraftVariants as variant (variant)}
|
||||
<span class={variantBadgeClass} title={`${variant.toUpperCase()} draft model available`}>
|
||||
{variant}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
@@ -119,71 +140,103 @@
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {className}" {...rest}>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
|
||||
? 'flex-col items-start'
|
||||
: ''} {className}"
|
||||
{...rest}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
|
||||
{#if hasModalityIcons}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if supportsThinking}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
{#if supportsToolUse}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Wrench class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Tool use</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if supportsThinking && !hideReasoning}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
{#if hasModalityIcons && !hideModalities}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
{#if contextLength}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<ScrollText class="h-3 w-3" />
|
||||
<span class="text-xs">{formatParameters(contextLength)}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if sizeRange}
|
||||
<span class="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Database class="h-3 w-3" />
|
||||
<span class="text-xs"
|
||||
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
|
||||
interface Props {
|
||||
/** Org whose avatar is shown (may differ from the repo's org for base models). */
|
||||
org: string;
|
||||
/** Repo's own org, shown as a small corner badge when provided. */
|
||||
quantOrg?: string;
|
||||
/** Tailwind size classes for the main avatar (default `h-9 w-9`). */
|
||||
size?: string;
|
||||
/** Extra classes appended to the base (main) image. */
|
||||
baseImageClass?: string;
|
||||
/** Size classes for the quant corner badge image (default `h-full w-full`). */
|
||||
quantImageClass?: string;
|
||||
/** Positioning classes for the quant corner badge (default `-bottom-0.75 -right-0.75`). */
|
||||
quantPositionClass?: string;
|
||||
}
|
||||
|
||||
let { org, quantOrg }: Props = $props();
|
||||
let {
|
||||
baseImageClass = '',
|
||||
org,
|
||||
quantImageClass = 'h-full w-full',
|
||||
quantOrg,
|
||||
quantPositionClass = '-bottom-0.75 -right-0.75',
|
||||
size = 'h-9 w-9'
|
||||
}: Props = $props();
|
||||
|
||||
let avatarError = $state(false);
|
||||
let quantError = $state(false);
|
||||
@@ -30,6 +45,7 @@
|
||||
|
||||
let quantHue = $derived.by(() => {
|
||||
const name = quantOrg ?? '';
|
||||
|
||||
let h = 0;
|
||||
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
@@ -41,28 +57,28 @@
|
||||
<span class="relative mt-0.5 inline-flex shrink-0">
|
||||
{#if avatarError}
|
||||
<span
|
||||
class="flex h-9 w-9 items-center justify-center rounded-md text-sm font-semibold text-white"
|
||||
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
|
||||
style="background-color: hsl({hue} 60% 45%)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{org.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="rounded-md">
|
||||
<img
|
||||
src={HuggingFaceService.getAvatarUrl(org)}
|
||||
onerror={() => (avatarError = true)}
|
||||
class="h-9 w-9 rounded-md {invertAvatar ? 'dark:invert' : ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="rounded-md">
|
||||
<img
|
||||
src={HuggingFaceService.getAvatarUrl(org)}
|
||||
onerror={() => (avatarError = true)}
|
||||
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if quantOrg && quantOrg !== org}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
class="absolute -bottom-0.75 -right-0.75 h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
|
||||
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
|
||||
>
|
||||
{#if quantError}
|
||||
<span
|
||||
@@ -76,7 +92,7 @@
|
||||
<img
|
||||
src={HuggingFaceService.getAvatarUrl(quantOrg)}
|
||||
onerror={() => (quantError = true)}
|
||||
class="h-full w-full rounded-full {invertQuant ? 'dark:invert' : ''}"
|
||||
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink } from '@lucide/svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
|
||||
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
|
||||
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
|
||||
import TerminalCommands from './TerminalCommands.svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
@@ -44,7 +43,7 @@
|
||||
// Draft sidecar variants (mtp, dflash, dspark, eagle3) present in the repo,
|
||||
// e.g. speculative-decoding drafts. mmproj is excluded: it is vision.
|
||||
let draftVariants = $derived.by<DraftVariant[]>(() => {
|
||||
const set = new Set<DraftVariant>();
|
||||
const set = new SvelteSet<DraftVariant>();
|
||||
|
||||
for (const file of files) {
|
||||
const variant = HuggingFaceService.extractQuantMeta(file.path)?.variant;
|
||||
|
||||
+15
-9
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Check, MessageSquareCode } from '@lucide/svelte';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import type { GgufVariantTagInput } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { HfModelSibling } from '$lib/types/huggingface';
|
||||
import DialogModelDownload from './DialogModelDownload.svelte';
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { Check, MessageSquareCode } from '@lucide/svelte';
|
||||
import type { GgufVariantTagInput } from '$lib/services';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { HfModelSibling } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
@@ -28,7 +28,9 @@
|
||||
|
||||
{#if bitDepthRows.length}
|
||||
<section class="space-y-3">
|
||||
<h2 class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
<h2
|
||||
class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
<MessageSquareCode class="h-3.5 w-3.5" />
|
||||
Download options
|
||||
</h2>
|
||||
@@ -62,8 +64,8 @@
|
||||
onclick={() =>
|
||||
(pendingDownload = {
|
||||
filePath: file.path,
|
||||
sizeBytes: file.size ?? null,
|
||||
quant: meta?.quant ?? null,
|
||||
sizeBytes: file.size ?? null,
|
||||
variant: meta?.variant ?? null
|
||||
})}
|
||||
title={isDownloading
|
||||
@@ -82,12 +84,16 @@
|
||||
<Check class="h-3 w-3 text-foreground/70" />
|
||||
{/if}
|
||||
{#if isFailed && !isDownloading && !isDownloaded}
|
||||
<span class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase">
|
||||
<span
|
||||
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
{/if}
|
||||
{#if meta?.variant}
|
||||
<span class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase">
|
||||
<span
|
||||
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
|
||||
>
|
||||
{meta.variant}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
+29
-21
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
|
||||
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
@@ -16,16 +17,8 @@
|
||||
hasReasoning: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
baseModels,
|
||||
details,
|
||||
gguf,
|
||||
hasReasoning,
|
||||
hasTools,
|
||||
hasVision,
|
||||
licenseTag,
|
||||
modelId
|
||||
}: Props = $props();
|
||||
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
|
||||
$props();
|
||||
|
||||
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
|
||||
// with the quant org as a corner badge when they differ.
|
||||
@@ -33,12 +26,17 @@
|
||||
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
|
||||
let avatarOrg = $derived(baseOrg || repoOrg);
|
||||
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
|
||||
|
||||
// Catalog family description when curated, else the HF card description.
|
||||
let description = $derived(
|
||||
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
|
||||
);
|
||||
</script>
|
||||
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={quantOrg} />
|
||||
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
|
||||
<ModelsDiscoverDetailsName modelId={details.id ?? modelId} {baseModels} />
|
||||
</div>
|
||||
<a
|
||||
@@ -70,8 +68,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if details.cardData?.description}
|
||||
<p class="text-sm text-muted-foreground">{details.cardData.description}</p>
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata chips -->
|
||||
@@ -82,7 +80,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if gguf?.architecture}
|
||||
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize">
|
||||
<span
|
||||
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize"
|
||||
>
|
||||
{gguf.architecture.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -97,7 +97,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
{#if details.gated === true}
|
||||
<span class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400">
|
||||
<span
|
||||
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
gated
|
||||
</span>
|
||||
{/if}
|
||||
@@ -107,19 +109,25 @@
|
||||
{#if hasVision || hasTools || hasReasoning}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if hasVision}
|
||||
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
<Image class="h-3 w-3" />
|
||||
Vision
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasTools}
|
||||
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
<Wrench class="h-3 w-3" />
|
||||
Tool use
|
||||
</span>
|
||||
{/if}
|
||||
{#if hasReasoning}
|
||||
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
<Lightbulb class="h-3 w-3" />
|
||||
Reasoning
|
||||
</span>
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
</script>
|
||||
|
||||
{#if readme}
|
||||
<section class="space-y-2">
|
||||
<section class="space-y-2 bg-muted/50 p-3 rounded-xl">
|
||||
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
|
||||
<article class="rounded-lg border bg-card p-4">
|
||||
<MarkdownContent content={readme} class="prose-sm max-w-none" />
|
||||
</article>
|
||||
<MarkdownContent content={readme} allowHtml class="prose-sm max-w-none" />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Download, Heart } from '@lucide/svelte';
|
||||
import ModelId from '../ModelId.svelte';
|
||||
import { type DraftVariant } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import ModelId from '../ModelId.svelte';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
import { detectToolUseSupport } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
@@ -20,15 +21,16 @@
|
||||
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(model.gguf.chat_template))
|
||||
);
|
||||
|
||||
// Tool use support from the chat template.
|
||||
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
|
||||
|
||||
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
|
||||
// multimodal pipeline tag, audio/video from their pipeline tags.
|
||||
let modalities = $derived.by<ModelModalities>(() => {
|
||||
const tag = model.pipeline_tag ?? '';
|
||||
|
||||
const vision =
|
||||
['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video'].includes(tag) ||
|
||||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')));
|
||||
|
||||
const audio = [
|
||||
'audio-classification',
|
||||
'audio-to-audio',
|
||||
@@ -36,17 +38,16 @@
|
||||
'text-to-speech',
|
||||
'voice-activity-detection'
|
||||
].includes(tag);
|
||||
|
||||
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
|
||||
|
||||
return { vision, audio, video };
|
||||
return { audio, video, vision };
|
||||
});
|
||||
|
||||
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
|
||||
// speculative-decoding drafts. mmproj is excluded: it is vision, already
|
||||
// conveyed by the modalities.
|
||||
let draftVariants = $derived.by<DraftVariant[]>(() => {
|
||||
const set = new Set<DraftVariant>();
|
||||
const set = new SvelteSet<DraftVariant>();
|
||||
|
||||
for (const sibling of model.siblings ?? []) {
|
||||
const variant = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.variant;
|
||||
@@ -56,6 +57,50 @@
|
||||
|
||||
return [...set];
|
||||
});
|
||||
|
||||
// Combined min/max size: the catalog gives main-model sizes per quant, and
|
||||
// the repo file tree carries draft sidecar sizes (the detail siblings do
|
||||
// not). Min = smallest main + smallest draft, max = largest main + largest
|
||||
// draft, so the stored model fits within the range.
|
||||
let sizeRange = $state<{ min: number; max: number } | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const base = modelsHubStore.sizeRangeFor(model.id);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
if (draftVariants.length === 0) {
|
||||
sizeRange = base ?? null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void HuggingFaceService.getTree(model.id).then((tree) => {
|
||||
if (cancelled) return;
|
||||
|
||||
const drafts = tree
|
||||
.filter((f) => {
|
||||
const variant = HuggingFaceService.extractQuantMeta(f.path)?.variant;
|
||||
|
||||
return variant && variant !== 'mmproj';
|
||||
})
|
||||
.map((f) => f.size ?? 0)
|
||||
.filter((size) => size > 0);
|
||||
|
||||
if (base && drafts.length > 0) {
|
||||
sizeRange = {
|
||||
max: base.max + Math.max(...drafts),
|
||||
min: base.min + Math.min(...drafts)
|
||||
};
|
||||
} else {
|
||||
sizeRange = base ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
@@ -64,24 +109,12 @@
|
||||
hideOrgName
|
||||
{modalities}
|
||||
{supportsThinking}
|
||||
{supportsToolUse}
|
||||
{contextLength}
|
||||
{sizeRange}
|
||||
{draftVariants}
|
||||
iconsOnNewLine
|
||||
wrap
|
||||
class="min-w-0"
|
||||
/>
|
||||
|
||||
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<Download class="h-3 w-3" />
|
||||
{HuggingFaceService.formatDownloads(model.downloads)}
|
||||
</span>
|
||||
<span class="ml-2.5 inline-flex items-center gap-1">
|
||||
<Heart class="h-3 w-3" />
|
||||
{HuggingFaceService.formatLikes(model.likes)}
|
||||
</span>
|
||||
{#if contextLength}
|
||||
<span class="ml-2.5 inline-flex items-center gap-1">
|
||||
{formatParameters(contextLength)} ctx
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverInfo from './ModelsDiscoverInfo.svelte';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
@@ -12,7 +12,7 @@
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let { model, active = false, showBaseModelAvatar = false, onSelect }: Props = $props();
|
||||
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
|
||||
let org = $derived(model.id.split('/')[0] ?? model.id);
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
|
||||
// llama.cpp --spec-type value for each draft variant.
|
||||
const SPEC_TYPE: Record<DraftVariant, string> = {
|
||||
mtp: 'draft-mtp',
|
||||
dflash: 'draft-dflash',
|
||||
dspark: 'draft-dspark',
|
||||
eagle3: 'eagle3',
|
||||
mmproj: ''
|
||||
mmproj: '',
|
||||
mtp: 'draft-mtp'
|
||||
};
|
||||
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
@@ -33,7 +33,6 @@
|
||||
// draft sidecar, or just the base command when none is present.
|
||||
let boxes = $derived.by(() => {
|
||||
const variants = draftVariants.filter((v) => v !== 'mmproj');
|
||||
|
||||
const build = (bin: string) => {
|
||||
const base = `llama ${bin} -hf ${modelId}`;
|
||||
|
||||
@@ -43,8 +42,8 @@
|
||||
};
|
||||
|
||||
return [
|
||||
{ title: 'Serve', icon: Server, commands: build('serve') },
|
||||
{ title: 'CLI', icon: SquareTerminal, commands: build('cli') }
|
||||
{ commands: build('serve'), icon: Server, title: 'Serve' },
|
||||
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
@@ -65,7 +64,9 @@
|
||||
|
||||
<div class="space-y-1 p-2">
|
||||
{#each box.commands as cmd, i (cmd)}
|
||||
<div class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs">
|
||||
<div
|
||||
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
<span class="truncate text-foreground/90">{cmd}</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from '../utils';
|
||||
import { Boxes, ChevronDown, Lightbulb, LoaderCircle, PackageSearch, Plus } from '@lucide/svelte';
|
||||
import { ChevronDown, Lightbulb, LoaderCircle, PackageSearch } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
@@ -98,9 +98,7 @@
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
for (const item of ms.groupedFilteredOptions.available) order.push(item.option.id);
|
||||
|
||||
return order;
|
||||
});
|
||||
@@ -214,7 +212,7 @@
|
||||
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'
|
||||
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
@@ -230,10 +228,10 @@
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideQuantization
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
@@ -266,7 +264,7 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-64 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
@@ -275,37 +273,37 @@
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenu.SubContent class="max-w-[calc(100vw-2rem)] md:max-w-108 pt-0">
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
@@ -315,38 +313,39 @@
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean, compact = false)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
hideOrgName={false}
|
||||
{compact}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
@@ -385,18 +384,18 @@
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -423,9 +422,9 @@
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
modelId={ms.infoModelId}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean, boolean?]>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,34 +19,33 @@
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
renderOption,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-xs font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean, compact = false)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
{onInfoClick}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
{isSelected}
|
||||
isHighlighted={false}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
{compact}
|
||||
{onSelect}
|
||||
{onInfoClick}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
@@ -55,7 +53,6 @@
|
||||
|
||||
{#if groups.favorites.length > 0}
|
||||
<p class={sectionHeaderClass}>Favorite models</p>
|
||||
|
||||
{#each groups.favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
@@ -63,14 +60,7 @@
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#if group.orgName}
|
||||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{#each groups.available as item (`avail-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
PowerOff,
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { ActionIcon, ModelId, ModelsDiscoverAvatar } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
@@ -23,6 +23,8 @@
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
/** Show only the quant/param badges, hiding the avatar and org/model name. */
|
||||
compact?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
@@ -30,6 +32,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
compact = false,
|
||||
hideOrgName = false,
|
||||
isFav,
|
||||
isHighlighted,
|
||||
@@ -60,10 +63,11 @@
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
|
||||
let quantOrg = $derived(option.parsedId?.orgName || option.model.split('/')[0] || option.model);
|
||||
let baseOrg = $derived(option.baseModel?.org || quantOrg);
|
||||
</script>
|
||||
|
||||
<div
|
||||
aria-selected={isSelected || isHighlighted}
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
@@ -74,22 +78,36 @@
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
onclick={() => onSelect(option.id)}
|
||||
onkeydown={onKeyDown}
|
||||
onmouseenter={onMouseEnter}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={isSelected || isHighlighted}
|
||||
title={loadTitle}
|
||||
tabindex="0"
|
||||
onclick={() => onSelect(option.id)}
|
||||
onmouseenter={onMouseEnter}
|
||||
onkeydown={onKeyDown}
|
||||
>
|
||||
{#if !compact}
|
||||
<ModelsDiscoverAvatar
|
||||
org={baseOrg}
|
||||
{quantOrg}
|
||||
size="size-5"
|
||||
quantImageClass="size-3.25"
|
||||
quantPositionClass="-right-1.25 -bottom-1.25"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="flex-1"
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
showRawTooltip
|
||||
{supportsThinking}
|
||||
{hideOrgName}
|
||||
hideName={compact}
|
||||
hideModalities={compact}
|
||||
hideParameters={compact}
|
||||
aliases={option.aliases}
|
||||
tags={option.tags}
|
||||
{modalities}
|
||||
{supportsThinking}
|
||||
showRawTooltip
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
@@ -101,30 +119,30 @@
|
||||
>
|
||||
{#if isFav}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={HeartOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
icon={HeartOff}
|
||||
tooltip="Remove from favorites"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
icon={Heart}
|
||||
tooltip="Add to favorites"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- info button: only shown when model is loaded and callback is provided -->
|
||||
{#if isLoaded && onInfoClick}
|
||||
<ActionIcon
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
icon={Info}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
onclick={() => onInfoClick(option.model)}
|
||||
icon={Info}
|
||||
tooltip="Model information"
|
||||
class="h-3 w-3 hover:text-foreground"
|
||||
onclick={() => onInfoClick(option.model)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -141,12 +159,12 @@
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-foreground"
|
||||
icon={RotateCw}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={RotateCw}
|
||||
tooltip="Retry loading model"
|
||||
class="h-3 w-3 text-red-500 hover:text-foreground"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Retry loading model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,14 +176,14 @@
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
|
||||
icon={PowerOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={PowerOff}
|
||||
tooltip="Unload model"
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
|
||||
onclick={(e) => {
|
||||
e?.stopPropagation();
|
||||
modelsStore.status.unload(option.model);
|
||||
}}
|
||||
tooltip="Unload model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,12 +195,12 @@
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
|
||||
icon={PowerOff}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={PowerOff}
|
||||
tooltip="Unload model"
|
||||
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
|
||||
onclick={() => modelsStore.status.unload(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Unload model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,12 +212,12 @@
|
||||
|
||||
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
|
||||
icon={Power}
|
||||
iconSize="h-2.5 w-2.5"
|
||||
icon={Power}
|
||||
tooltip="Load model"
|
||||
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Load model"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
|
||||
{#if ms.isRouter}
|
||||
<button
|
||||
type="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
|
||||
@@ -90,10 +91,9 @@
|
||||
: 'text-foreground',
|
||||
sheetOpen && 'text-foreground'
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
type="button"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -102,10 +102,10 @@
|
||||
{:else}
|
||||
<ModelId
|
||||
class="text-xs"
|
||||
hideOrgName
|
||||
modelId={selectedOption?.model || ''}
|
||||
hideQuantization
|
||||
hideTags
|
||||
modelId={selectedOption?.model || ''}
|
||||
hideOrgName
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
</button>
|
||||
|
||||
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
|
||||
<Sheet.Content class="max-h-[85vh] gap-1" side="bottom">
|
||||
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title>Select Model</Sheet.Title>
|
||||
|
||||
@@ -133,26 +133,24 @@
|
||||
<div class="flex flex-col gap-1 pb-4">
|
||||
<div class="mb-3 px-4">
|
||||
<SearchInput
|
||||
onInput={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
value={ms.searchTerm}
|
||||
onInput={(v) => ms.setSearchTerm(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto px-2">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
|
||||
disabled
|
||||
type="button"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{selectedOption?.name || currentModel}
|
||||
</span>
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
@@ -161,13 +159,12 @@
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,13 +182,13 @@
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
|
||||
<ModelId modelId={selectedOption?.model || ''} class="font-medium" hideQuantization />
|
||||
|
||||
{#if ms.updating}
|
||||
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
@@ -203,8 +200,8 @@
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
modelId={ms.infoModelId}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface ModelItem {
|
||||
option: ModelOption;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
export interface OrgGroup {
|
||||
orgName: string | null;
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
favorites: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
available: ModelItem[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
@@ -77,24 +71,15 @@ export function groupModelOptions(
|
||||
}
|
||||
}
|
||||
|
||||
// Available models grouped by org (excluding loaded and favorites)
|
||||
const available: OrgGroup[] = [];
|
||||
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||
// Available models (excluding loaded and favorites)
|
||||
const available: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
|
||||
orgGroups.get(key)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
available.push({ items, orgName: orgName || null });
|
||||
available.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
return { available, favorites, loaded };
|
||||
|
||||
@@ -11,13 +11,6 @@ export const MODEL_ID = {
|
||||
|
||||
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
|
||||
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
|
||||
/**
|
||||
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
|
||||
* same weight file (MTP) or a sidecar download entry, e.g.
|
||||
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
|
||||
* candidate model id; the caller decides whether it looks quantized.
|
||||
*/
|
||||
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
|
||||
/**
|
||||
* Sidecar prefix that wraps a model id with a draft/aux variant, e.g.
|
||||
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
|
||||
@@ -25,6 +18,13 @@ export const MODEL_ID = {
|
||||
* token for typed lookup.
|
||||
*/
|
||||
DRAFT_VARIANT_PREFIX_RE: /^(mtp|dflash|dspark|eagle3|mmproj)-(.*)$/i,
|
||||
/**
|
||||
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
|
||||
* same weight file (MTP) or a sidecar download entry, e.g.
|
||||
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
|
||||
* candidate model id; the caller decides whether it looks quantized.
|
||||
*/
|
||||
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
|
||||
|
||||
/** Container format segments to exclude from tags (every model uses these). */
|
||||
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
|
||||
|
||||
@@ -15,10 +15,10 @@ export const ROUTES = {
|
||||
MANAGE_MODEL: '#/models-hub/[modelId]',
|
||||
/** Model hub - browse and download HuggingFace GGUF models. */
|
||||
MANAGE_MODELS: '#/models-hub',
|
||||
/** Model manager - installed models from /v1/models. */
|
||||
MODEL_MANAGER: '#/model-manager',
|
||||
/** MCP servers. */
|
||||
MCP_SERVERS: '#/mcp-servers',
|
||||
/** Model manager - installed models from /v1/models. */
|
||||
MODEL_MANAGER: '#/model-manager',
|
||||
/** Search — mobile-only full-page conversation search. */
|
||||
SEARCH: '#/search',
|
||||
/** Root — start of the app. */
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface UseModelsSelectorOptions {
|
||||
currentModel: () => string | null;
|
||||
@@ -76,14 +78,47 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
let searchTerm = $state('');
|
||||
let showModelDialog = $state(false);
|
||||
let infoModelId = $state<string | null>(null);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// Base model org per base repo (e.g. `Qwen` for `ggml-org/Qwen3.8-27B-GGUF`),
|
||||
// resolved lazily from the HF card while the menu is open.
|
||||
const baseOrgs = new SvelteMap<string, string>();
|
||||
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
|
||||
// Augment each option with its base model; the org falls back to the quant
|
||||
// org until the HF lookup resolves.
|
||||
const optionsWithBaseModel = $derived(
|
||||
filteredOptions.map((option) => {
|
||||
const repo = option.model.split(':')[0];
|
||||
const org = baseOrgs.get(repo) ?? option.parsedId?.orgName ?? repo.split('/')[0] ?? '';
|
||||
const name = option.parsedId?.modelName ?? option.name ?? option.model;
|
||||
|
||||
return { ...option, baseModel: { name, org } };
|
||||
})
|
||||
);
|
||||
const groupedFilteredOptions = $derived(
|
||||
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
|
||||
groupModelOptions(optionsWithBaseModel, modelsStore.favoriteModelIds, (m) =>
|
||||
modelsStore.isModelLoaded(m)
|
||||
)
|
||||
);
|
||||
|
||||
// Fetch base model orgs for the visible repos while the menu is open. The
|
||||
// service caches per repo, so repeated opens never re-hit the HF API.
|
||||
$effect(() => {
|
||||
if (!menuOpen) return;
|
||||
|
||||
const repos = new SvelteSet<string>();
|
||||
|
||||
for (const option of options) repos.add(option.model.split(':')[0]);
|
||||
|
||||
for (const repo of repos) {
|
||||
if (baseOrgs.has(repo)) continue;
|
||||
|
||||
void HuggingFaceService.getBaseModel(repo).then((info) => {
|
||||
baseOrgs.set(repo, info?.org ?? repo.split('/')[0] ?? '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleInfoClick(modelName: string) {
|
||||
infoModelId = modelName;
|
||||
showModelDialog = true;
|
||||
@@ -98,6 +133,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (loading || updating) return;
|
||||
|
||||
menuOpen = open;
|
||||
|
||||
if (isRouter) {
|
||||
searchTerm = '';
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type DraftVariant,MODEL_ID } from '$lib/constants';
|
||||
import { type DraftVariant, MODEL_ID } from '$lib/constants';
|
||||
import type {
|
||||
HfCatalogEntry,
|
||||
HfModelDetailInfo,
|
||||
HfModelInfo,
|
||||
HfModelSearchParams,
|
||||
HfModelSibling,
|
||||
HfModelSort} from '$lib/types/huggingface';
|
||||
HfModelSort
|
||||
} from '$lib/types/huggingface';
|
||||
|
||||
/** Variant flag in a GGUF filename (e.g. draft-mtp, diffusion-flash, multimodal projector). */
|
||||
export type GgufVariant = DraftVariant;
|
||||
@@ -142,10 +144,21 @@ export class HuggingFaceService {
|
||||
|
||||
private static readonly BASE_URL = 'https://huggingface.co/api/models';
|
||||
|
||||
// Cached base model lookups keyed by repo id, so repeated selector opens
|
||||
// never re-hit the HF API for the same repo.
|
||||
private static baseModelCache = new Map<string, { org: string; name: string } | null>();
|
||||
|
||||
private static baseModelPending = new Map<
|
||||
string,
|
||||
Promise<{ org: string; name: string } | null>
|
||||
>();
|
||||
|
||||
private static readonly DEFAULT_LIMIT = 50;
|
||||
|
||||
private static readonly MAX_LIMIT = 100;
|
||||
|
||||
// GGUF Model Searching
|
||||
|
||||
/**
|
||||
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
|
||||
*/
|
||||
@@ -242,7 +255,7 @@ export class HuggingFaceService {
|
||||
return { quant, variant, variantForm };
|
||||
}
|
||||
|
||||
// GGUF Model Searching
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Filter raw siblings by file extension and sort by size descending.
|
||||
@@ -268,8 +281,6 @@ export class HuggingFaceService {
|
||||
return downloads.toString();
|
||||
}
|
||||
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human-readable string
|
||||
*/
|
||||
@@ -289,6 +300,19 @@ export class HuggingFaceService {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a min-max size range with a single shared unit and no spaces
|
||||
* around the dash, e.g. `19.0-28.6 GB`.
|
||||
*/
|
||||
static formatSizeRange(min: number, max: number): string {
|
||||
const unit = max >= 1_000_000_000 ? 'GB' : max >= 1_000_000 ? 'MB' : max >= 1_000 ? 'KB' : 'B';
|
||||
const div =
|
||||
unit === 'GB' ? 1_000_000_000 : unit === 'MB' ? 1_000_000 : unit === 'KB' ? 1_000 : 1;
|
||||
const fmt = (n: number) => (div === 1 ? `${n}` : `${(n / div).toFixed(1)}`);
|
||||
|
||||
return `${fmt(min)}-${fmt(max)} ${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format likes count with K suffix if applicable
|
||||
*/
|
||||
@@ -322,6 +346,66 @@ export class HuggingFaceService {
|
||||
return `${Math.floor(diffDays / 365)} years ago`;
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Avatar URL for an author (org or user). 404s when the author does not
|
||||
* exist, so callers should provide a fallback.
|
||||
*/
|
||||
static getAvatarUrl(author: string): string {
|
||||
return `https://huggingface.co/api/avatars/${author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
|
||||
* from its HF card (`cardData.base_model`). Returns null when the card has no
|
||||
* base model. Results are cached per repo.
|
||||
*/
|
||||
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
|
||||
const cached = this.baseModelCache.get(repoId);
|
||||
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const pending = this.baseModelPending.get(repoId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const details = await this.getDetails(repoId);
|
||||
const base = this.getBaseModels(details)[0];
|
||||
|
||||
if (!base) return null;
|
||||
|
||||
const [org, ...rest] = base.split('/');
|
||||
|
||||
return { name: rest.join('/'), org };
|
||||
})();
|
||||
|
||||
this.baseModelPending.set(repoId, promise);
|
||||
|
||||
promise
|
||||
.then((result) => this.baseModelCache.set(repoId, result))
|
||||
.finally(() => this.baseModelPending.delete(repoId));
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original (non-GGUF) base model ids for a repo, from
|
||||
* `cardData.base_model` (string or list) and the `base_model:` tags.
|
||||
*/
|
||||
static getBaseModels(model: HfModelDetailInfo | null): string[] {
|
||||
if (!model) return [];
|
||||
|
||||
const cardBase = model.cardData?.base_model;
|
||||
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
|
||||
const fromTags = (model.tags ?? [])
|
||||
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
return Array.from(new Set([...fromCard, ...fromTags]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the average bit-depth for a known GGUF quantization.
|
||||
* Returns `null` for unrecognized tokens.
|
||||
@@ -343,11 +427,29 @@ export class HuggingFaceService {
|
||||
});
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific GGUF model
|
||||
*/
|
||||
/**
|
||||
* Fetch the llama.app model catalog (https://llama.app/v1/catalog.json).
|
||||
* Returns an empty array on failure so callers can fall back gracefully.
|
||||
*/
|
||||
static async getCatalog(): Promise<HfCatalogEntry[]> {
|
||||
const url = 'https://llama.app/v1/catalog.json';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
|
||||
|
||||
return (await response.json()) as HfCatalogEntry[];
|
||||
} catch (error) {
|
||||
console.error('Error fetching catalog:', error);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name.
|
||||
// `full=true` includes cardData (description, base_model) and safetensors.
|
||||
@@ -370,6 +472,15 @@ export class HuggingFaceService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model URL on Hugging Face Hub
|
||||
*/
|
||||
static getModelUrl(modelId: string): string {
|
||||
return `https://huggingface.co/${modelId}`;
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get most liked GGUF models
|
||||
*/
|
||||
@@ -395,6 +506,28 @@ export class HuggingFaceService {
|
||||
return this.search({ limit, sort: 'downloads' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
|
||||
*/
|
||||
static async getReadme(modelId: string): Promise<string | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name
|
||||
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
|
||||
|
||||
return HuggingFaceService.stripFrontmatter(await response.text());
|
||||
} catch (error) {
|
||||
console.error(`Error fetching README for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository file tree to list available GGUF variants
|
||||
*/
|
||||
@@ -413,7 +546,6 @@ export class HuggingFaceService {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending GGUF models
|
||||
*/
|
||||
@@ -423,44 +555,6 @@ export class HuggingFaceService {
|
||||
return this.search({ limit, sort: 'trendingScore' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
|
||||
*/
|
||||
static async getReadme(modelId: string): Promise<string | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name
|
||||
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
|
||||
|
||||
return HuggingFaceService.stripFrontmatter(await response.text());
|
||||
} catch (error) {
|
||||
console.error(`Error fetching README for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get model URL on Hugging Face Hub
|
||||
*/
|
||||
static getModelUrl(modelId: string): string {
|
||||
return `https://huggingface.co/${modelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar URL for an author (org or user). 404s when the author does not
|
||||
* exist, so callers should provide a fallback.
|
||||
*/
|
||||
static getAvatarUrl(author: string): string {
|
||||
return `https://huggingface.co/api/avatars/${author}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a local HF cache file path
|
||||
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
|
||||
@@ -475,31 +569,20 @@ export class HuggingFaceService {
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
return { repo: `${parts[0]}/${parts.slice(1).join('--')}`, file: match[2] };
|
||||
return { file: match[2], repo: `${parts[0]}/${parts.slice(1).join('--')}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original (non-GGUF) base model ids for a repo, from
|
||||
* `cardData.base_model` (string or list) and the `base_model:` tags.
|
||||
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
|
||||
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
|
||||
* when no size token is present.
|
||||
*/
|
||||
static getBaseModels(model: HfModelDetailInfo | null): string[] {
|
||||
if (!model) return [];
|
||||
static parseParamCount(name: string): string | null {
|
||||
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
|
||||
|
||||
const cardBase = model.cardData?.base_model;
|
||||
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
|
||||
if (!match) return null;
|
||||
|
||||
const fromTags = (model.tags ?? [])
|
||||
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
|
||||
.filter((v): v is string => Boolean(v));
|
||||
|
||||
return Array.from(new Set([...fromCard, ...fromTags]));
|
||||
}
|
||||
|
||||
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
|
||||
private static stripFrontmatter(text: string): string {
|
||||
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
||||
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
return `${match[1]}${match[2].toUpperCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,19 +604,6 @@ export class HuggingFaceService {
|
||||
return { isGated, isGguf, isSafetensors, license, tasks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
|
||||
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
|
||||
* when no size token is present.
|
||||
*/
|
||||
static parseParamCount(name: string): string | null {
|
||||
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
return `${match[1]}${match[2].toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Resolve a pipeline_tag to a lucide icon name, or null when unknown. */
|
||||
static pipelineTagIcon(tag: string | null | undefined): string | null {
|
||||
if (!tag) return null;
|
||||
@@ -575,8 +645,6 @@ export class HuggingFaceService {
|
||||
});
|
||||
}
|
||||
|
||||
// Internal Methods
|
||||
|
||||
/**
|
||||
* Build API URL from search parameters
|
||||
*/
|
||||
@@ -596,6 +664,8 @@ export class HuggingFaceService {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Internal Methods
|
||||
|
||||
/**
|
||||
* Delay helper for retry logic
|
||||
*/
|
||||
@@ -648,4 +718,11 @@ export class HuggingFaceService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
|
||||
private static stripFrontmatter(text: string): string {
|
||||
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
||||
|
||||
return match ? text.slice(match[0].length) : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,27 +9,37 @@
|
||||
* Detail data is loaded by ModelsDiscoverDetails, not here.
|
||||
*/
|
||||
|
||||
import { CURATED_MODEL_IDS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
class ModelsHubStore {
|
||||
models = $state<HfModelInfo[]>([]);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
models = $state<HfModelInfo[]>([]);
|
||||
/** First model in the list - the hub auto-opens this one. */
|
||||
firstModel = $derived(this.models[0] ?? null);
|
||||
|
||||
private fetched = false;
|
||||
loading = $state(false);
|
||||
|
||||
private catalog: HfCatalogEntry[] = [];
|
||||
private defaultModels: HfModelInfo[] = [];
|
||||
private fetched = false;
|
||||
private searchRequestId = 0;
|
||||
|
||||
/**
|
||||
* Fetch the default list: the curated ggml--org models in display order.
|
||||
* Each curated model is fetched directly by ID, so the list is independent
|
||||
* of download ranking (curated models may fall outside the top-50 search
|
||||
* window). No-op when already loaded or in flight.
|
||||
* Catalog family description for a repo id, or undefined when the repo is
|
||||
* not part of the catalog (e.g. a search result outside the curated list).
|
||||
*/
|
||||
descriptionFor(modelId: string): string | undefined {
|
||||
return this.catalog.find((entry) =>
|
||||
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
|
||||
)?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the default list from the llama.app catalog, flattened to a flat
|
||||
* list of ggml-org repo ids in catalog order (one per size). Each repo is
|
||||
* fetched directly by ID, so the list is independent of download ranking.
|
||||
* No-op when already loaded or in flight.
|
||||
*/
|
||||
async fetch(): Promise<void> {
|
||||
if (this.loading || this.fetched) return;
|
||||
@@ -38,10 +48,15 @@ class ModelsHubStore {
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const catalog = await HuggingFaceService.getCatalog();
|
||||
|
||||
this.catalog = catalog;
|
||||
const ids = this.catalogModelIds(catalog);
|
||||
|
||||
// getDetails returns full metadata (downloads, likes, lastModified,
|
||||
// siblings, tags, gguf) for a single model.
|
||||
this.defaultModels = (
|
||||
await Promise.all(CURATED_MODEL_IDS.map((id) => HuggingFaceService.getDetails(id)))
|
||||
await Promise.all(ids.map((id) => HuggingFaceService.getDetails(id)))
|
||||
).filter((m): m is HfModelInfo => m !== null);
|
||||
this.models = this.defaultModels;
|
||||
this.fetched = true;
|
||||
@@ -65,13 +80,14 @@ class ModelsHubStore {
|
||||
if (!trimmed) {
|
||||
this.models = this.defaultModels;
|
||||
this.error = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = this.searchRequestId;
|
||||
|
||||
try {
|
||||
const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50, full: true });
|
||||
const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 });
|
||||
|
||||
if (requestId === this.searchRequestId) {
|
||||
this.models = results;
|
||||
@@ -83,6 +99,42 @@ class ModelsHubStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Min/max GGUF file size (bytes) across the available quants for a repo,
|
||||
* or undefined when the repo is not part of the catalog.
|
||||
*/
|
||||
sizeRangeFor(modelId: string): { min: number; max: number } | undefined {
|
||||
for (const entry of this.catalog) {
|
||||
for (const size of entry.sizes) {
|
||||
const builds = size.builds.filter((b) => b.repo === modelId);
|
||||
|
||||
if (builds.length === 0) continue;
|
||||
|
||||
const bytes = builds.map((b) => b.sizeBytes);
|
||||
|
||||
return { max: Math.max(...bytes), min: Math.min(...bytes) };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the catalog to a flat list of ggml-org repo ids, newest family
|
||||
* first (by release date). Returns an empty array when the catalog is empty.
|
||||
*/
|
||||
private catalogModelIds(catalog: HfCatalogEntry[]): string[] {
|
||||
return [...catalog]
|
||||
.sort((a, b) => b.released.localeCompare(a.released))
|
||||
.flatMap((entry) =>
|
||||
entry.sizes.flatMap((size) => {
|
||||
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/'));
|
||||
|
||||
return build ? [build.repo] : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const modelsHubStore = new ModelsHubStore();
|
||||
|
||||
@@ -34,11 +34,6 @@ export interface ModelStatusHost {
|
||||
}
|
||||
|
||||
export class ModelStatusManager {
|
||||
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
|
||||
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
|
||||
private failedDownloads = new SvelteSet<string>();
|
||||
private loadingStates = new SvelteMap<string, boolean>();
|
||||
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
|
||||
/**
|
||||
* Draft sidecar files pulled by registered models, as `<repo>/<file>` keys.
|
||||
* Drafts are not separate /v1/models entries - the router pulls them as
|
||||
@@ -63,6 +58,11 @@ export class ModelStatusManager {
|
||||
|
||||
return result;
|
||||
});
|
||||
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
|
||||
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
|
||||
private failedDownloads = new SvelteSet<string>();
|
||||
private loadingStates = new SvelteMap<string, boolean>();
|
||||
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
|
||||
// /models/sse feed state, the single source of truth for status and load progress
|
||||
private statusAbort: AbortController | null = null;
|
||||
private statusReaderActive = false;
|
||||
@@ -102,6 +102,26 @@ export class ModelStatusManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
|
||||
* LOADING model on unload; the feed reports the settled status, so no
|
||||
* waiter is registered here.
|
||||
*/
|
||||
async cancelLoad(modelId: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) return;
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private host: ModelStatusHost) {}
|
||||
|
||||
/**
|
||||
@@ -170,14 +190,6 @@ export class ModelStatusManager {
|
||||
return this.downloadProgress.has(repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given `<repo>:<tag>` is already a fully downloaded model
|
||||
* registered with the server (i.e. it shows up in the /v1/models list).
|
||||
*/
|
||||
isModelDownloaded(repoWithTag: string): boolean {
|
||||
return this.host.routerModels.some((m) => m.id === repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given draft sidecar file (repo-relative path) has been pulled
|
||||
* as the `--model-draft` of some registered model.
|
||||
@@ -186,6 +198,14 @@ export class ModelStatusManager {
|
||||
return this.downloadedDrafts.has(`${repoId}/${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given `<repo>:<tag>` is already a fully downloaded model
|
||||
* registered with the server (i.e. it shows up in the /v1/models list).
|
||||
*/
|
||||
isModelDownloaded(repoWithTag: string): boolean {
|
||||
return this.host.routerModels.some((m) => m.id === repoWithTag);
|
||||
}
|
||||
|
||||
isOperationInProgress(modelId: string): boolean {
|
||||
return this.loadingStates.get(modelId) ?? false;
|
||||
}
|
||||
@@ -263,26 +283,6 @@ export class ModelStatusManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
|
||||
* LOADING model on unload; the feed reports the settled status, so no
|
||||
* waiter is registered here.
|
||||
*/
|
||||
async cancelLoad(modelId: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) return;
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the /models/sse feed and drop transient progress.
|
||||
*/
|
||||
|
||||
+30
@@ -189,3 +189,33 @@ export interface HfModelApiResponse {
|
||||
/** Total count (if available) */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
// llama.app model catalog (https://llama.app/v1/catalog.json)
|
||||
|
||||
/** A single GGUF build/repo within a catalog size. */
|
||||
export interface HfCatalogBuild {
|
||||
quant: string;
|
||||
size: string;
|
||||
sizeBytes: number;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/** A size variant (e.g. `GPT-OSS 20B`) within a catalog entry. */
|
||||
export interface HfCatalogSize {
|
||||
name: string;
|
||||
params: string;
|
||||
builds: HfCatalogBuild[];
|
||||
}
|
||||
|
||||
/** A single model family in the catalog. `featured` marks the staff picks. */
|
||||
export interface HfCatalogEntry {
|
||||
name: string;
|
||||
brand: string;
|
||||
description: string;
|
||||
details: string;
|
||||
released: string;
|
||||
license: string;
|
||||
featured?: boolean;
|
||||
maxMemGb?: number;
|
||||
sizes: HfCatalogSize[];
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
import type { ModelOption } from './models';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* A draft sidecar model attached to a quant (speculative decoding).
|
||||
|
||||
Vendored
+2
@@ -19,6 +19,8 @@ export interface ModelOption {
|
||||
parsedId?: ParsedModelId;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
/** Original (non-GGUF) base model, resolved from the HF card. */
|
||||
baseModel?: { org: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Detects whether a model's chat template supports tool calling.
|
||||
*
|
||||
* There is no server flag for tool support, so we infer it from the chat
|
||||
* template. A template that accepts a `tools` array or emits tool-call tokens
|
||||
* is treated as tool-capable.
|
||||
*/
|
||||
|
||||
/** Tool-call tokens emitted by the template for assistant tool calls. */
|
||||
const TOOL_CALL_TOKENS = [
|
||||
'tool_call',
|
||||
'tool_calls',
|
||||
'function_call',
|
||||
'tool_use',
|
||||
'<tool',
|
||||
'<|tool',
|
||||
'TOOL_CALL'
|
||||
];
|
||||
/** Jinja reference to the `tools` array passed in by the caller. */
|
||||
const JINJA_TOOLS_VAR = /\{\{[^{}]*\btools\b[^{}]*\}\}|\{%[^{}]*\btools\b[^{}]*%\}/i;
|
||||
|
||||
export function detectToolUseSupport(t: string): boolean {
|
||||
if (!t) return false;
|
||||
|
||||
if (JINJA_TOOLS_VAR.test(t)) return true;
|
||||
|
||||
return TOOL_CALL_TOKENS.some((token) => t.includes(token));
|
||||
}
|
||||
@@ -263,6 +263,8 @@ export {
|
||||
detectThinkingSupportWithReason
|
||||
} from './chat-template-thinking-detector';
|
||||
|
||||
export { detectToolUseSupport } from './chat-template-tool-detector';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type {
|
||||
ModelManagerParent,
|
||||
ModelManagerQuant,
|
||||
ModelManagerQuantOrg
|
||||
} from '$lib/types';
|
||||
import type { ModelManagerParent, ModelManagerQuant, ModelManagerQuantOrg } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
/** Strip the `:quant` / `:quant-VARIANT` tag from a model id to get the repo id. */
|
||||
@@ -50,7 +46,9 @@ export async function resolveBaseModel(repoId: string): Promise<string | null> {
|
||||
resolved = first.trim();
|
||||
} else {
|
||||
// tag fallback, e.g. `base_model:Qwen/Qwen3-8B`
|
||||
const tag = details?.tags?.find((t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:'));
|
||||
const tag = details?.tags?.find(
|
||||
(t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:')
|
||||
);
|
||||
const fromTag = tag?.slice('base_model:'.length).trim();
|
||||
|
||||
if (fromTag) resolved = fromTag;
|
||||
@@ -90,17 +88,18 @@ export function buildModelManagerTree(
|
||||
}
|
||||
|
||||
const key = quant ?? '';
|
||||
|
||||
let entry = quantMap.get(key);
|
||||
|
||||
if (!entry) {
|
||||
entry = { quant, main: option, drafts: [], mmproj: null };
|
||||
entry = { drafts: [], main: option, mmproj: null, quant };
|
||||
quantMap.set(key, entry);
|
||||
}
|
||||
|
||||
if (variant === 'mmproj') {
|
||||
entry.mmproj = option;
|
||||
} else if (variant) {
|
||||
entry.drafts.push({ variant, option });
|
||||
entry.drafts.push({ option, variant });
|
||||
} else {
|
||||
entry.main = option;
|
||||
}
|
||||
@@ -119,7 +118,7 @@ export function buildModelManagerTree(
|
||||
const slashIdx = repoId.indexOf('/');
|
||||
const orgName = slashIdx !== -1 ? repoId.slice(0, slashIdx) : repoId;
|
||||
|
||||
quantOrgs.push({ repoId, orgName, quants });
|
||||
quantOrgs.push({ orgName, quants, repoId });
|
||||
}
|
||||
|
||||
// Group quant orgs under parents.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
let modelsHubOpen = $state(false);
|
||||
let baseModels = $state(new SvelteMap<string, string | null>());
|
||||
let baseModels = new SvelteMap<string, string | null>();
|
||||
|
||||
let isRouter = $derived(serverStore.isRouterMode);
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
{@const entry = getRouterEntry(option.model)}
|
||||
{@const status = entry?.status}
|
||||
{@const statusValue = status?.value}
|
||||
{@const isLoaded = statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
|
||||
{@const isLoaded =
|
||||
statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
|
||||
{@const isLoading = statusValue === ServerModelStatus.LOADING}
|
||||
{@const isDownloading = statusValue === ServerModelStatus.DOWNLOADING}
|
||||
{@const isFailed = statusValue === ServerModelStatus.FAILED || status?.failed === true}
|
||||
@@ -78,7 +79,7 @@
|
||||
tags={option.tags}
|
||||
modalities={option.modalities}
|
||||
supportsThinking={modelsStore.props.checkModelSupportsThinking(option.model)}
|
||||
hideQuantization={hideQuantization}
|
||||
{hideQuantization}
|
||||
showRawTooltip
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
|
||||
import { DialogConfirmation } from '$lib/components/app';
|
||||
import DownloadToast from './DownloadToast.svelte';
|
||||
import { Download, LoaderCircle, TriangleAlert } from '@lucide/svelte';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import type { DraftVariant } from '$lib/constants';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -30,9 +30,8 @@
|
||||
variant
|
||||
}: Props = $props();
|
||||
|
||||
type Phase = 'pending' | 'starting' | 'downloading' | 'finished';
|
||||
type Phase = 'pending' | 'starting';
|
||||
let phase = $state<Phase>('pending');
|
||||
let hasSeenProgress = $state(false);
|
||||
let lastError: string | null = $state(null);
|
||||
|
||||
let tagInput = $derived<GgufVariantTagInput | null>(
|
||||
@@ -49,36 +48,10 @@
|
||||
return 'default';
|
||||
});
|
||||
|
||||
let inFlight = $derived(phase === 'starting' || phase === 'downloading');
|
||||
// True when a previous SSE `download_failed` left a recorded failure for the
|
||||
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
|
||||
// because POST /models rejects already-existing partial entries.
|
||||
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
|
||||
let cancelling = $state(false);
|
||||
let lastCancelError: string | null = $state(null);
|
||||
|
||||
// Only offer Delete when the model is registered with the server (a fully
|
||||
// downloaded entry in /v1/models). For an in-flight download the partial
|
||||
// files are cleaned up by the Retry path, so Delete would be redundant.
|
||||
let canDelete = $derived(
|
||||
phase === 'finished' && modelsStore.status.isModelDownloaded(hfRepoWithTag)
|
||||
);
|
||||
let showDeleteConfirm = $state(false);
|
||||
async function handleConfirmDelete() {
|
||||
showDeleteConfirm = false;
|
||||
await modelsStore.status.cancelDownload(hfRepoWithTag);
|
||||
// Close the download dialog too - removing the entry makes the wizard moot.
|
||||
onCancel();
|
||||
}
|
||||
|
||||
// Reactive: while the SSE feed reports progress for our download, surface it.
|
||||
// The downloadProgress map is deleted on download_finished/download_failed.
|
||||
let progress = $derived(modelsStore.status.getDownloadProgress(hfRepoWithTag));
|
||||
let progressPercent = $derived.by(() => {
|
||||
if (!progress || progress.totalBytes <= 0) return 0;
|
||||
|
||||
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
|
||||
@@ -87,28 +60,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// The dialog is always closable - downloads run in the background and are
|
||||
// tracked by a toast, so closing mid-flight never aborts the download.
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (newOpen) {
|
||||
lastError = null;
|
||||
lastCancelError = null;
|
||||
showDeleteConfirm = false;
|
||||
phase = 'pending';
|
||||
hasSeenProgress = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inFlight) onCancel();
|
||||
onCancel();
|
||||
}
|
||||
|
||||
async function trigger() {
|
||||
if (inFlight) return;
|
||||
if (phase === 'starting') return;
|
||||
|
||||
phase = 'starting';
|
||||
hasSeenProgress = false;
|
||||
lastError = null;
|
||||
lastCancelError = null;
|
||||
showDeleteConfirm = false;
|
||||
|
||||
// A recorded failure for the same <repo>:<tag> means the server still holds
|
||||
// a partial entry that POST /models would reject; remove it before retrying.
|
||||
@@ -118,54 +87,26 @@
|
||||
|
||||
try {
|
||||
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
|
||||
phase = 'downloading';
|
||||
// Download runs on the server; hand progress off to a toast and close.
|
||||
showDownloadToast();
|
||||
onConfirm();
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'Failed to start download';
|
||||
phase = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (cancelling) return;
|
||||
|
||||
cancelling = true;
|
||||
lastCancelError = null;
|
||||
try {
|
||||
const ok = await modelsStore.status.cancelDownload(hfRepoWithTag);
|
||||
|
||||
if (!ok) {
|
||||
lastCancelError = 'Cancel request failed. Try again in a moment.';
|
||||
}
|
||||
} finally {
|
||||
cancelling = false;
|
||||
}
|
||||
function showDownloadToast() {
|
||||
toast.custom(DownloadToast, {
|
||||
componentProps: {
|
||||
displayName: filePath,
|
||||
repoId,
|
||||
repoWithTag: hfRepoWithTag
|
||||
},
|
||||
dismissible: true,
|
||||
duration: Infinity
|
||||
});
|
||||
}
|
||||
|
||||
// Latch once we've seen real progress so we know what 'no longer in flight'
|
||||
// actually means. Without this the dialog auto-closed shortly after the POST
|
||||
// resolved because no SSE event had landed yet.
|
||||
$effect(() => {
|
||||
if (phase !== 'downloading') return;
|
||||
|
||||
if (progress) hasSeenProgress = true;
|
||||
});
|
||||
|
||||
// Promote to 'finished' only after progress was observed and the feed then
|
||||
// drops our entry. Auto-close after a short pause.
|
||||
$effect(() => {
|
||||
if (phase !== 'downloading') return;
|
||||
|
||||
if (!hasSeenProgress) return;
|
||||
|
||||
const stillInFlight = modelsStore.status.isDownloadInProgress(hfRepoWithTag);
|
||||
|
||||
if (!stillInFlight) {
|
||||
phase = 'finished';
|
||||
const timer = setTimeout(() => onConfirm(), 600);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
|
||||
@@ -173,19 +114,12 @@
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title class="flex items-center gap-2">
|
||||
<Download class="h-5 w-5 text-primary" />
|
||||
{#if phase === 'pending'}
|
||||
Download this model?
|
||||
{:else}
|
||||
Downloading {tagDisplay}
|
||||
{/if}
|
||||
Download this model?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
{#if phase === 'pending'}
|
||||
llama-server will download this file (and related sidecar weights such as multimodal
|
||||
projectors or draft models) from Hugging Face into your local model cache.
|
||||
{:else}
|
||||
Download runs in the background; this dialog tracks live progress.
|
||||
{/if}
|
||||
llama-server will download this file (and related sidecar weights such as multimodal
|
||||
projectors or draft models) from Hugging Face into your local model cache. The download runs
|
||||
in the background and its progress is shown in a notification.
|
||||
</AlertDialog.Description>
|
||||
|
||||
{#if previousFailure && phase === 'pending'}
|
||||
@@ -203,20 +137,6 @@
|
||||
{/if}
|
||||
</AlertDialog.Header>
|
||||
|
||||
{#if canDelete}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
|
||||
aria-label="Delete model from cache"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
Delete from cache
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Request</span>
|
||||
@@ -243,74 +163,25 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if phase === 'downloading' || phase === 'finished'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between text-muted-foreground">
|
||||
<span>
|
||||
{#if phase === 'finished'}
|
||||
Complete
|
||||
{:else if progress && progress.totalBytes > 0}
|
||||
Downloading
|
||||
{:else}
|
||||
Preparing download
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono tabular-nums">{progressPercent}%</span>
|
||||
</div>
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={progress?.downloadedBytes ?? 0}
|
||||
totalBytes={progress?.totalBytes ?? 0}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
<p class="text-xs text-destructive">{lastError}</p>
|
||||
{/if}
|
||||
{#if lastCancelError}
|
||||
<p class="text-xs text-destructive">{lastCancelError}</p>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Footer>
|
||||
{#if phase === 'downloading'}
|
||||
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
|
||||
{#if cancelling}
|
||||
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
Cancelling...
|
||||
{:else}
|
||||
Cancel download
|
||||
{/if}
|
||||
</AlertDialog.Action>
|
||||
{:else}
|
||||
<AlertDialog.Cancel disabled={inFlight} onclick={onCancel}>
|
||||
{#if phase === 'finished'}Close{:else}Cancel{/if}
|
||||
</AlertDialog.Cancel>
|
||||
{/if}
|
||||
{#if phase === 'pending'}
|
||||
<AlertDialog.Action disabled={inFlight} onclick={trigger}>
|
||||
<Download class="mr-1.5 h-4 w-4" />
|
||||
{previousFailure ? 'Retry download' : 'Download'}
|
||||
</AlertDialog.Action>
|
||||
{:else if phase === 'starting'}
|
||||
<AlertDialog.Action disabled>
|
||||
<AlertDialog.Cancel disabled={phase === 'starting'} onclick={onCancel}>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action disabled={phase === 'starting'} onclick={trigger}>
|
||||
{#if phase === 'starting'}
|
||||
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
Starting...
|
||||
</AlertDialog.Action>
|
||||
{/if}
|
||||
{:else}
|
||||
<Download class="mr-1.5 h-4 w-4" />
|
||||
{previousFailure ? 'Retry download' : 'Download'}
|
||||
{/if}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<DialogConfirmation
|
||||
bind:open={showDeleteConfirm}
|
||||
title="Delete model"
|
||||
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="destructive"
|
||||
icon={Trash2}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => (showDeleteConfirm = false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import DownloadProgressBar from './DownloadProgressBar.svelte';
|
||||
import { ArrowUpRight, Check, TriangleAlert } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
/** `<repo>:<tag>` identifier the server reports progress under. */
|
||||
repoWithTag: string;
|
||||
/** Repo id (no tag) used by the "Open details" CTA. */
|
||||
repoId: string;
|
||||
/** Short label for the file being downloaded. */
|
||||
displayName: string;
|
||||
/** Injected by sonner; dismisses this toast. */
|
||||
closeToast?: () => void;
|
||||
}
|
||||
|
||||
let { closeToast, displayName, repoId, repoWithTag }: Props = $props();
|
||||
|
||||
// Live progress from the /models/sse feed. The map entry is deleted on
|
||||
// download_finished / download_failed, so we latch on first sight to tell
|
||||
// "not started yet" apart from "finished".
|
||||
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
|
||||
let hasSeenProgress = $state(false);
|
||||
let percent = $derived.by(() => {
|
||||
if (!progress || progress.totalBytes <= 0) return 0;
|
||||
|
||||
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (progress) hasSeenProgress = true;
|
||||
});
|
||||
|
||||
// A download is done when the feed drops our entry after we saw progress, or
|
||||
// when the model landed in /v1/models / a failure was recorded (covers fast
|
||||
// downloads that settle before the first progress event reaches this toast).
|
||||
let failed = $derived(modelsStore.status.hasFailedDownload(repoWithTag));
|
||||
let modelReady = $derived(modelsStore.status.isModelDownloaded(repoWithTag));
|
||||
let done = $derived(
|
||||
(hasSeenProgress && !modelsStore.status.isDownloadInProgress(repoWithTag)) ||
|
||||
failed ||
|
||||
modelReady
|
||||
);
|
||||
|
||||
// Auto-dismiss once the download settles; keep the failure visible a bit longer.
|
||||
$effect(() => {
|
||||
if (!done) return;
|
||||
|
||||
const timer = setTimeout(() => closeToast?.(), failed ? 4000 : 2500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function openDetails() {
|
||||
closeToast?.();
|
||||
goto(`/temp/models-hub/${repoId}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium">
|
||||
{#if done && failed}
|
||||
<TriangleAlert class="h-4 w-4 text-destructive" />
|
||||
Download failed
|
||||
{:else if done}
|
||||
<Check class="h-4 w-4 text-emerald-500" />
|
||||
Download complete
|
||||
{:else}
|
||||
<span class="h-4 w-4 animate-pulse rounded-full bg-primary/30"></span>
|
||||
Downloading
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
|
||||
</div>
|
||||
<p class="truncate text-xs text-muted-foreground">{displayName}</p>
|
||||
<DownloadProgressBar
|
||||
downloadedBytes={progress?.downloadedBytes ?? 0}
|
||||
totalBytes={progress?.totalBytes ?? 0}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openDetails}
|
||||
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
Open details
|
||||
<ArrowUpRight class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -82,26 +82,11 @@
|
||||
|
||||
const groupedOptions: GroupedModelOptions = {
|
||||
available: [
|
||||
{
|
||||
items: [availableModels[0]],
|
||||
orgName: 'deepseek'
|
||||
},
|
||||
{
|
||||
items: [availableModels[1]],
|
||||
orgName: 'google'
|
||||
},
|
||||
{
|
||||
items: [availableModels[2]],
|
||||
orgName: 'microsoft'
|
||||
},
|
||||
{
|
||||
items: [availableModels[3]],
|
||||
orgName: 'codellama'
|
||||
},
|
||||
{
|
||||
items: [availableModels[4]],
|
||||
orgName: 'intel'
|
||||
}
|
||||
availableModels[0],
|
||||
availableModels[1],
|
||||
availableModels[2],
|
||||
availableModels[3],
|
||||
availableModels[4]
|
||||
],
|
||||
favorites: favoriteModels,
|
||||
loaded: loadedModels
|
||||
|
||||
Reference in New Issue
Block a user