feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-25 10:43:40 +02:00
parent 21c61123a9
commit caef7df650
16 changed files with 735 additions and 127 deletions
+2
View File
@@ -26,6 +26,7 @@
--border: oklch(0.875 0 0);
--input: oklch(0.92 0 0);
--ring: oklch(0.708 0 0);
--brand: #f65e00;
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -115,6 +116,7 @@
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-brand: var(--brand);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
@@ -31,6 +31,8 @@
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
/** Params badge fallback (formatted) when the model id carries no params token. */
params?: string;
draftVariants?: DraftVariant[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
@@ -52,6 +54,7 @@
iconsOnNewLine = false,
modalities,
modelId,
params,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
@@ -104,9 +107,9 @@
</span>
{/if}
{#if parsed.params && !hideParameters}
{#if (parsed.params || params) && !hideParameters}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
{parsed.params ?? params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { copyToClipboard } from '$lib/utils';
interface Props {
open?: boolean;
chatTemplate: string;
onOpenChange?: (open: boolean) => void;
}
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
>
<Dialog.Header class="flex-row items-center justify-between border-b border-border/40 p-4">
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
<button
type="button"
onclick={() => copyToClipboard(chatTemplate)}
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
>
<Copy class="h-3.5 w-3.5" />
Copy
</button>
</Dialog.Header>
<pre
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
</Dialog.Content>
</Dialog.Root>
@@ -95,7 +95,10 @@
}
details = info;
files = HuggingFaceService.filterByExtension(tree, '.gguf');
files = HuggingFaceService.filterByExtension(
HuggingFaceService.collapseGgufShards(tree),
'.gguf'
);
readme = readmeText;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load model';
@@ -131,7 +134,12 @@
{hasReasoning}
/>
<ModelsDiscoverDetailsDownloadOptions {modelId} {bitDepthRows} />
<ModelsDiscoverDetailsDownloadOptions
{modelId}
{files}
{bitDepthRows}
nativeCtxTokens={gguf?.context_length ?? 0}
/>
<TerminalCommands {modelId} {draftVariants} />
@@ -1,15 +1,20 @@
<script lang="ts">
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, MessageSquareCode } from '@lucide/svelte';
import { Check, Cpu, MessageSquareCode, TriangleAlert, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { GgufVariantTagInput } from '$lib/services';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import { modelsStore, settingsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import { computeFileCompatibilityTiers, detectOs, resolveDeviceMemoryGb } from '$lib/utils';
interface Props {
modelId: string;
files: HfModelSibling[];
bitDepthRows: BitDepthRow[];
nativeCtxTokens: number;
}
interface PendingDownload {
@@ -21,19 +26,64 @@
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let { bitDepthRows, modelId }: Props = $props();
let { bitDepthRows, files, modelId, nativeCtxTokens }: Props = $props();
let pendingDownload = $state<PendingDownload | null>(null);
let deviceMemoryGb = $derived(
resolveDeviceMemoryGb(Number(settingsStore.config.deviceMemoryGb) || 0)
);
let osLabel = $derived(browser ? detectOs(navigator.userAgent) : 'unknown');
let tiers = $derived(computeFileCompatibilityTiers(files, nativeCtxTokens, deviceMemoryGb));
function buttonClass(parts: {
isDownloaded: boolean;
isFailed: boolean;
isUnavailable: boolean;
}): string {
const { isDownloaded, isFailed, isUnavailable } = parts;
const classes = [
'relative inline-flex items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors'
];
// Buttons stay neutral; only the leading compatibility badge carries
// color (green/yellow/red). Unavailable quants are greyed + disabled.
if (isUnavailable) {
classes.push('cursor-not-allowed opacity-50');
} else {
classes.push('cursor-pointer hover:border-primary/60 hover:bg-primary/5');
}
if (isDownloaded && !isFailed) {
classes.push('border-foreground bg-muted');
} else if (isFailed) {
classes.push('border-destructive');
}
return classes.join(' ');
}
</script>
{#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"
>
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
</h2>
<div class="flex flex-wrap items-center justify-between gap-2">
<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>
<span
class="inline-flex items-center gap-1.5 rounded-full border bg-background px-2.5 py-1 text-xs font-medium"
>
<Cpu class="h-3 w-3 text-muted-foreground" />
{osLabel}
{#if deviceMemoryGb > 0}
<span class="text-muted-foreground">({deviceMemoryGb} GB)</span>
{/if}
</span>
</div>
<div class="space-y-2">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
@@ -59,60 +109,97 @@
? modelsStore.status.isDraftDownloaded(modelId, file.path)
: modelsStore.status.isModelDownloaded(hfRepoWithTag)}
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
<button
type="button"
onclick={() =>
(pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
})}
title={isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
{@const tier = tiers.get(file.path)}
{@const isUnavailable =
tier === 'none' && !isDownloaded && !isDownloading && !isFailed}
{@const isAvailable = tier === 'full' && !isDownloaded && !isDownloading && !isFailed}
{@const isLimited =
tier === 'limited' && !isDownloaded && !isDownloading && !isFailed}
{@const tooltipText = isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: isUnavailable
? `Does not fit this device: ${file.path}`
: `Download ${file.path}`}
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
class:border-foreground={isDownloaded && !isDownloading && !isFailed}
class:bg-muted={isDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
>
{#if isDownloaded && !isDownloading}
<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"
>
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"
>
{meta.variant}
</span>
{/if}
<span class="font-medium">{label}</span>
<span class="text-muted-foreground">
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
<Tooltip.Root>
<Tooltip.Trigger
type="button"
onclick={() => {
if (isUnavailable) return;
pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
};
}}
aria-disabled={isUnavailable}
class={buttonClass({
isDownloaded,
isFailed,
isUnavailable
})}
>
{#if isAvailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-green-600"
>
<Check class="h-2.5 w-2.5 text-white" />
</span>
{:else if isLimited}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-yellow-500"
>
<TriangleAlert class="h-2.5 w-2.5 text-white" />
</span>
{:else if isUnavailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-red-600"
>
<X class="h-2.5 w-2.5 text-white" />
</span>
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
overlay
downloadedBytes={progress.downloadedBytes}
totalBytes={progress.totalBytes}
/>
{/if}
</button>
{#if isFailed && !isDownloading && !isDownloaded}
<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"
>
{meta.variant}
</span>
{/if}
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
overlay
downloadedBytes={progress.downloadedBytes}
totalBytes={progress.totalBytes}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
@@ -1,10 +1,21 @@
<script lang="ts">
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
import {
Download,
ExternalLink,
Heart,
Image,
Lightbulb,
MessageSquareCode,
Wrench
} from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
@@ -31,6 +42,8 @@
let description = $derived(
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
);
let chatTemplateOpen = $state(false);
</script>
<header class="space-y-3">
@@ -76,7 +89,7 @@
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B params
{formatParameters(gguf.total)} params
</span>
{/if}
{#if gguf?.architecture}
@@ -91,6 +104,16 @@
{gguf.context_length.toLocaleString()} ctx
</span>
{/if}
{#if gguf?.chat_template}
<button
type="button"
onclick={() => (chatTemplateOpen = true)}
class="inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-secondary/70"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
@@ -105,33 +128,46 @@
{/if}
</div>
<!-- Capability badges -->
<!-- Capability / modality icons, matching the list item's icon style -->
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex flex-wrap items-center gap-2.5 text-muted-foreground">
{#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"
>
<Image class="h-3 w-3" />
Vision
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/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"
>
<Wrench class="h-3 w-3" />
Tool use
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/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"
>
<Lightbulb class="h-3 w-3" />
Reasoning
</span>
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</header>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -1,11 +1,11 @@
<script lang="ts">
import ModelId from '../ModelId.svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { ModelModalities } from '$lib/types/models';
import { detectToolUseSupport } from '$lib/utils';
import { detectToolUseSupport, formatParameters } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
@@ -16,6 +16,35 @@
let contextLength = $derived(model.gguf?.context_length);
// Params badge fallback: the id usually carries the count (`Qwen3.8-27B`),
// but ids like `Kimi-K3` do not. Fall back to the HF param count
// (`gguf.total`); search results omit `gguf`, so fetch details lazily only
// when the name has no params token.
let fetchedParams = $state<number | null>(null);
$effect(() => {
fetchedParams = null;
if (model.gguf?.total || ModelsService.parseModelId(model.id).params) return;
let cancelled = false;
void HuggingFaceService.getDetails(model.id).then((info) => {
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
});
return () => {
cancelled = true;
};
});
let hfParams = $derived(model.gguf?.total ?? fetchedParams);
let paramsFallback = $derived(
hfParams && !ModelsService.parseModelId(model.id).params
? formatParameters(hfParams, 0)
: undefined
);
// Reasoning support from the chat template, matching the details view.
let supportsThinking = $derived(
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(model.gguf.chat_template))
@@ -113,6 +142,7 @@
{contextLength}
{sizeRange}
{draftVariants}
params={paramsFallback}
iconsOnNewLine
wrap
class="min-w-0"
@@ -70,6 +70,13 @@ export { default as ModelsDiscoverDetailsName } from './ModelsDiscoverDetailsNam
*/
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
/**
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
*
* Shows the model's chat template in a scrollable dialog with a copy button.
*/
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
/**
* **ModelsDiscoverDetailsReadme** - Detail view README
*
@@ -43,10 +43,11 @@ export const MODEL_ID = {
PARAMS_RE: /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/,
/**
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `F16`, `BF16`, `MXFP4`.
* Case-insensitive to handle both uppercase and lowercase inputs.
* Matches a quantization/precision segment, e.g. `Q4_K_M`, `IQ4_XS`, `TQ1_0`,
* `F16`, `BF16`, `MXFP4`. Case-insensitive to handle both cases.
*/
QUANTIZATION_SEGMENT_RE: /^(I?Q\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
QUANTIZATION_SEGMENT_RE:
/^(I?Q\d+(_[A-Z0-9]+)*|TQ\d+(_[A-Z0-9]+)*|F\d+|BF\d+|MXFP\d+(_[A-Z0-9]+)*)$/i,
/** Separates the model path from the quantization tag, e.g. `model:Q4_K_M`. */
QUANTIZATION_SEPARATOR: ':',
@@ -16,6 +16,7 @@ export const SETTINGS_KEYS = {
CUSTOM_CSS: 'customCss',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
CUSTOM_JSON: 'customJson',
DEVICE_MEMORY_GB: 'deviceMemoryGb',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
@@ -117,6 +117,14 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: 0,
help: 'Total device memory (RAM) in GB, used to estimate which models can run. Set to 0 to auto-detect from the browser when possible.',
isPositiveInteger: true,
key: SETTINGS_KEYS.DEVICE_MEMORY_GB,
label: 'Device memory (GB)',
type: SettingsFieldType.INPUT
},
{
defaultValue: false,
help: 'Enable "Continue" button for assistant messages, including reasoning models.',
+103 -28
View File
@@ -198,6 +198,47 @@ export class HuggingFaceService {
Q8_0: 8
};
/**
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
* shard, summing every shard's size so the kept entry reflects the whole
* quant. Non-sharded files pass through unchanged. Downloads are tag-based
* (`repo:quant`), so the first shard is enough to represent the set.
*/
static collapseGgufShards(siblings: HfModelSibling[]): HfModelSibling[] {
const sizeByPath = new Map(siblings.map((f) => [f.path, f.size ?? 0]));
const result: HfModelSibling[] = [];
for (const file of siblings) {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(file.path);
if (!match) {
result.push(file);
continue;
}
// Keep only the first shard; its size becomes the whole shard set's.
if (match[1] !== '00001') continue;
const total = parseInt(match[2], 10);
const stem = file.path.slice(0, file.path.length - match[0].length);
let size = 0;
for (let i = 1; i <= total; i++) {
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
size += sizeByPath.get(shard) ?? 0;
}
result.push({ ...file, size });
}
return result;
}
// GGUF Model Browsing
/**
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any draft/aux variant
* (`mtp`, `dflash`, `mmproj`) from a `.gguf` filename. The variant shows up
@@ -249,14 +290,18 @@ export class HuggingFaceService {
// - For embedded MTP like `Hy3-IQ1_M-mtp.gguf` we have `Hy3-IQ1_M` and `IQ1_M` matches.
// - For main files like `Llama-3-8B-Q4_K_M.gguf` we land on the trailing quant.
const segments = source.split(MODEL_ID.SEGMENT_SEPARATOR);
const quantSeg = segments.find((seg) => MODEL_ID.QUANTIZATION_SEGMENT_RE.test(seg));
const quant = quantSeg ? quantSeg.toUpperCase() : null;
const quantIdx = segments.findIndex((seg) => MODEL_ID.QUANTIZATION_SEGMENT_RE.test(seg));
let quant = quantIdx >= 0 ? segments[quantIdx].toUpperCase() : null;
// Recombine a `UD-` (Unsloth Dynamic) prefix, e.g. `...-UD-Q4_K_XL.gguf`.
if (quant && quantIdx > 0 && segments[quantIdx - 1].toUpperCase() === 'UD') {
quant = `UD-${quant}`;
}
return { quant, variant, variantForm };
}
// GGUF Model Browsing
/**
* Filter raw siblings by file extension and sort by size descending.
*/
@@ -300,19 +345,6 @@ 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
*/
@@ -346,6 +378,19 @@ export class HuggingFaceService {
return `${Math.floor(diffDays / 365)} years ago`;
}
/**
* 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}`;
}
// Model Details & Files
/**
@@ -411,7 +456,17 @@ export class HuggingFaceService {
* Returns `null` for unrecognized tokens.
*/
static getBitDepth(quant: string): number | null {
return HuggingFaceService.QUANT_BIT_DEPTH[quant] ?? null;
// Strip a leading `UD-` (Unsloth Dynamic) prefix before lookup.
const base = quant.replace(/^UD-/i, '');
const direct = HuggingFaceService.QUANT_BIT_DEPTH[base];
if (direct !== undefined) return direct;
// Fall back to the leading precision digits for variants missing from the
// map, e.g. `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
const match = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i.exec(base);
return match ? parseInt(match[1], 10) : null;
}
/**
@@ -529,23 +584,35 @@ export class HuggingFaceService {
}
/**
* Get repository file tree to list available GGUF variants
* Get repository file tree to list available GGUF variants. Recursive so
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
* are included; follows cursor pagination for repos over one page.
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
const url = `https://huggingface.co/api/models/${modelId}/tree/main`;
const files: HfModelSibling[] = [];
let url: string | null =
`https://huggingface.co/api/models/${modelId}/tree/main?recursive=true`;
try {
const response = await fetch(url);
while (url) {
const response: Response = await fetch(url);
if (!response.ok) return [];
if (!response.ok) return files;
const data = (await response.json()) as HfModelSibling[];
const data = (await response.json()) as HfModelSibling[];
return data.filter((f) => f.type !== 'directory');
files.push(...data.filter((f) => f.type !== 'directory'));
url = HuggingFaceService.parseNextPageUrl(response.headers.get('Link'));
}
} catch {
return [];
// Return whatever was fetched before the failure.
}
return files;
}
/**
* Get trending GGUF models
*/
@@ -554,7 +621,6 @@ export class HuggingFaceService {
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'trendingScore' });
}
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
@@ -664,8 +730,6 @@ export class HuggingFaceService {
return url.toString();
}
// Internal Methods
/**
* Delay helper for retry logic
*/
@@ -673,6 +737,8 @@ export class HuggingFaceService {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Internal Methods
/**
* Fetch data with retry logic for resilience
*/
@@ -719,6 +785,15 @@ export class HuggingFaceService {
}
}
/** Extract the `rel="next"` URL from an RFC 5988 `Link` header, if present. */
private static parseNextPageUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null;
const match = /<([^>]+)>;\s*rel="next"/.exec(linkHeader);
return match ? match[1] : null;
}
/** 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?/);
+1 -1
View File
@@ -16,7 +16,7 @@ import {
} from '$lib/constants';
import type { ToolExecutionResult } from '$lib/types';
function detectOs(userAgent: string): string {
export function detectOs(userAgent: string): string {
for (const [pattern, os] of BROWSER_INFO_OS_UA_PATTERNS) {
if (pattern.test(userAgent)) return os;
}
+10 -5
View File
@@ -26,24 +26,29 @@ export function formatFileSize(bytes: number | unknown): string {
}
/**
* Format parameter count to human-readable format (B, M, K)
* Format parameter count to human-readable format (T, B, M, K)
*
* @param params - Parameter count
* @param decimals - Decimal places to keep (0 rounds to a full number)
* @returns Human-readable parameter count
*/
export function formatParameters(params: number | unknown): string {
export function formatParameters(params: number | unknown, decimals = 2): string {
if (typeof params !== 'number') return 'Unknown';
if (params >= 1e12) {
return `${(params / 1e12).toFixed(decimals)}T`;
}
if (params >= 1e9) {
return `${(params / 1e9).toFixed(2)}B`;
return `${(params / 1e9).toFixed(decimals)}B`;
}
if (params >= 1e6) {
return `${(params / 1e6).toFixed(2)}M`;
return `${(params / 1e6).toFixed(decimals)}M`;
}
if (params >= 1e3) {
return `${(params / 1e3).toFixed(2)}K`;
return `${(params / 1e3).toFixed(decimals)}K`;
}
return params.toString();
+9 -1
View File
@@ -116,6 +116,14 @@ export {
resolveBaseModel
} from './model-manager';
// Model hardware-compatibility estimation
export {
computeFileCompatibilityTiers,
deviceMemoryBudgetMb,
resolveDeviceMemoryGb
} from './model-compatibility';
export type { CompatibilityTier } from './model-compatibility';
// Portal utilities
export { portalToBody } from './portal-to-body';
@@ -350,7 +358,7 @@ export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-t
export { executeGetDatetimeTool } from './get-datetime';
// Browser fallback for the server's get_info tool
export { executeBrowserInfoTool } from './browser-info';
export { detectOs, executeBrowserInfoTool } from './browser-info';
// Cryptography utilities
@@ -0,0 +1,298 @@
/**
* Model hardware-compatibility estimation, ported from ggml-org/llama-macos
* (`Model+Compatibility.swift`, `HFRepoResolver.swift`, `SidecarPicker.swift`).
*
* A quant "fits" when its estimated runtime memory stays within the device
* budget. The budget mirrors llama.cpp's own fit target: the GPU working set
* (approximated from RAM) minus a fit slack, clamped by an OS floor so the
* desktop keeps enough to run. We cannot read Metal's working set from a
* browser, so the working set is approximated as 75% of RAM (Apple's ratio on
* the machines llama-macos targets).
*
* Compatibility tiers:
* - `full` -> fits at the model's native max context (green)
* - `limited` -> fits only at a reduced context (yellow)
* - `none` -> does not fit even at the minimum context (red)
*/
import { browser } from '$app/environment';
import { HuggingFaceService } from '$lib/services';
import type { HfModelSibling } from '$lib/types/huggingface';
/** llama.cpp's default `--fit-target` margin, in MB. */
const FIT_SLACK_MB = 1024;
/** Physical RAM kept out of a model's reach for the OS and other apps, in MB. */
const OS_FLOOR_MB = 4096;
/** Overhead multiplier applied to the file size when estimating weight memory. */
const WEIGHT_OVERHEAD_MULTIPLIER = 1.05;
/** Fraction of RAM the GPU working set is approximated as (Apple's ~75%). */
const WORKING_SET_FRACTION = 0.75;
/** Minimum context a model must support to launch, matching llama.cpp's default. */
const MIN_CTX_TOKENS = 4096;
/** Standard context tiers, ascending, used to find the largest fitting one. */
const CTX_TIERS = [4096, 8192, 16384, 32768, 65536, 131072, 262144] as const;
export type CompatibilityTier = 'full' | 'limited' | 'none';
const MB = 1024 * 1024;
/** Hardcoded device RAM (GB) for testing the compatibility UI; 0 disables it. */
const TEST_DEVICE_MEMORY_GB = 128;
/**
* Resolve the device memory in GB: the user's settings override when set,
* else the browser's `navigator.deviceMemory` (Chrome/Edge only, capped at 8).
* Returns 0 when neither is available, which callers treat as "unknown".
*/
export function resolveDeviceMemoryGb(configuredGb: number): number {
// Hardcoded device RAM for testing the compatibility UI; 0 disables the
// override. TODO: remove once the device-memory source is trusted.
if (TEST_DEVICE_MEMORY_GB > 0) return TEST_DEVICE_MEMORY_GB;
if (configuredGb > 0) return configuredGb;
if (!browser) return 0;
const nav = navigator as Navigator & { deviceMemory?: number };
return typeof nav.deviceMemory === 'number' && nav.deviceMemory > 0 ? nav.deviceMemory : 0;
}
/**
* Memory a model may use, in MB: whichever of the GPU working set (less the
* fit slack) or the RAM-less-OS-floor binds first. Returns 0 when unknown.
*/
export function deviceMemoryBudgetMb(deviceMemoryGb: number): number {
if (deviceMemoryGb <= 0) return 0;
const physicalMb = deviceMemoryGb * 1024;
const gpuLimbMb = physicalMb * WORKING_SET_FRACTION - FIT_SLACK_MB;
const ramLimbMb = physicalMb - OS_FLOOR_MB;
return Math.max(Math.min(gpuLimbMb, ramLimbMb), 0);
}
/**
* Map every GGUF file in the repo to a compatibility tier. Main quants get
* their own tier; their shards and sidecars (mmproj + draft head) inherit the
* matched main quant's tier so the whole group reads consistently.
*/
export function computeFileCompatibilityTiers(
files: HfModelSibling[],
nativeCtxTokens: number,
deviceMemoryGb: number
): Map<string, CompatibilityTier> {
const tiers = new Map<string, CompatibilityTier>();
// Unknown device memory: leave every file untiered (neutral) rather than
// guessing a fit we cannot back up.
if (deviceMemoryGb <= 0) return tiers;
const allPaths = new Set(files.map((f) => f.path));
const sizeByPath = new Map(files.map((f) => [f.path, f.size ?? 0]));
const budgetMb = deviceMemoryBudgetMb(deviceMemoryGb);
// Candidate mains: skip draft heads, mmproj, imatrix, and non-first shards.
const mains = files.filter((f) => {
const meta = HuggingFaceService.extractQuantMeta(f.path);
if (!meta || meta.variant !== null) return false;
if ((f.path.split('/').pop() ?? f.path).toLowerCase().includes('imatrix')) return false;
return !isNonFirstShard(f.path);
});
// Track each main's quant bits + directory so draft sidecars can be matched
// to their closest-quant main for a tier below.
const mainInfos: MainQuantInfo[] = [];
for (const main of mains) {
// Aggregate size: main + shards + mmproj + quant-matched draft.
const picked = expandShards(main.path, allPaths);
const mmproj = pickSidecar(main.path, files, (v) => v === 'mmproj');
const draft = pickSidecar(main.path, files, (v) => v !== null && v !== 'mmproj');
if (mmproj) picked.push(mmproj.path);
const mainBytes = picked.reduce((sum, p) => sum + (sizeByPath.get(p) ?? 0), 0);
const draftBytes = draft ? (sizeByPath.get(draft.path) ?? 0) : 0;
const tier = compatibilityTier(mainBytes + draftBytes, nativeCtxTokens, budgetMb);
for (const p of picked) tiers.set(p, tier);
if (draft) tiers.set(draft.path, tier);
const meta = HuggingFaceService.extractQuantMeta(main.path);
const bits = meta?.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
mainInfos.push({ bits, dirs: dirComponents(main.path), tier });
}
// Assign every remaining draft sidecar (mtp, dflash, ...) the tier of its
// closest-quant main, so all variant badges show a fit icon - not just the
// single draft counted toward a main's size.
for (const file of files) {
if (tiers.has(file.path)) continue;
const meta = HuggingFaceService.extractQuantMeta(file.path);
if (!meta?.variant || meta.variant === 'mmproj') continue;
const main = bestMainForSidecar(file.path, meta.quant, mainInfos);
if (main) tiers.set(file.path, main.tier);
}
return tiers;
}
interface MainQuantInfo {
bits: number;
dirs: string[];
tier: CompatibilityTier;
}
/**
* Find the main quant a draft sidecar pairs with: among mains in the
* sidecar's directory or a descendant of it, the one with the closest quant
* bit depth.
*/
function bestMainForSidecar(
sidecarPath: string,
sidecarQuant: string | null,
mains: MainQuantInfo[]
): MainQuantInfo | null {
const sidecarDirs = dirComponents(sidecarPath);
const sidecarBits = sidecarQuant ? (HuggingFaceService.getBitDepth(sidecarQuant) ?? 0) : 0;
let best: { diff: number; main: MainQuantInfo } | null = null;
for (const main of mains) {
// The sidecar's directory must be the main's directory or an ancestor.
if (sidecarDirs.length > main.dirs.length) continue;
if (!sidecarDirs.every((d, i) => main.dirs[i] === d)) continue;
const diff = Math.abs(main.bits - sidecarBits);
if (!best || diff < best.diff) best = { diff, main };
}
return best?.main ?? null;
}
/**
* Weight memory (MB) is the file size with overhead; context memory scales
* with the requested window. A quant is `full` when it fits at the native max
* context, `limited` when it only fits at a smaller standard tier, and `none`
* when it does not fit even at the minimum context.
*/
function compatibilityTier(
totalBytes: number,
nativeCtxTokens: number,
budgetMb: number
): CompatibilityTier {
// A known-but-tiny budget (<= 0) means nothing fits; unknown memory is
// handled by the caller returning no tiers at all.
const weightMb = (totalBytes / MB) * WEIGHT_OVERHEAD_MULTIPLIER;
const ctxBytesPer1k = ctxBytesPer1kTokens(nativeCtxTokens);
const fits = (ctxTokens: number) =>
weightMb + (ctxBytesPer1k * (ctxTokens / 1000)) / MB <= budgetMb;
if (nativeCtxTokens < MIN_CTX_TOKENS) return 'none';
if (fits(nativeCtxTokens)) return 'full';
// Find the largest standard tier that still fits within the native window.
const largestFitting = [...CTX_TIERS]
.filter((t) => t <= nativeCtxTokens)
.reverse()
.find((t) => fits(t));
return largestFitting !== undefined ? 'limited' : 'none';
}
/**
* Approximate KV-cache bytes per 1k tokens. Without a MemProfile probe (which
* only exists post-launch in llama-macos) we estimate from the native context
* window; ~0.1 MB per 1k tokens is a conservative mid-range for modern models.
*/
function ctxBytesPer1kTokens(_nativeCtxTokens: number): number {
return 0.1 * MB;
}
/** Directory components of a repo-relative path (`Q4_K_M/a.gguf` -> `["Q4_K_M"]`). */
function dirComponents(path: string): string[] {
return path.split('/').slice(0, -1);
}
/** True for a split-shard continuation (`-00002-of-00003.gguf`), not the first shard. */
function isNonFirstShard(path: string): boolean {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(path);
return match !== null && match[1] !== '00001';
}
/** Expand a main GGUF to its full shard set; non-sharded files return `[main]`. */
function expandShards(main: string, allPaths: Set<string>): string[] {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(main);
if (!match) return [main];
const total = parseInt(match[2], 10);
const stem = main.slice(0, main.length - match[0].length);
const shards: string[] = [];
for (let i = 1; i <= total; i++) {
const shard = `${stem}-${String(i).padStart(5, '0')}-of-${String(total).padStart(5, '0')}.gguf`;
if (allPaths.has(shard)) shards.push(shard);
}
return shards.length > 0 ? shards : [main];
}
/**
* Pick the best sidecar (mmproj or draft head) for a main file, mirroring
* llama.cpp's `find_best_sibling`: the candidate's directory must be the main
* file's directory or an ancestor; candidates rank by deepest directory, then
* exact quant-tag match, then closest quant-bit distance.
*/
function pickSidecar(
main: string,
files: HfModelSibling[],
isCandidate: (variant: string | null) => boolean
): HfModelSibling | null {
const mainDirs = dirComponents(main);
const mainMeta = HuggingFaceService.extractQuantMeta(main);
const mainBits = mainMeta?.quant ? (HuggingFaceService.getBitDepth(mainMeta.quant) ?? 0) : 0;
const mainTag = mainMeta?.quant?.toUpperCase();
let best: { depth: number; diff: number; exact: boolean; file: HfModelSibling } | null = null;
for (const file of files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
if (!meta || !isCandidate(meta.variant)) continue;
const dirs = dirComponents(file.path);
if (dirs.length > mainDirs.length || !dirs.every((d, i) => mainDirs[i] === d)) continue;
const depth = dirs.length;
const bits = meta.quant ? (HuggingFaceService.getBitDepth(meta.quant) ?? 0) : 0;
const diff = Math.abs(bits - mainBits);
const exact = mainTag ? file.path.toUpperCase().includes(`-${mainTag}.`) : false;
if (best) {
const better =
depth > best.depth ||
(depth === best.depth && exact && !best.exact) ||
(depth === best.depth && exact === best.exact && diff < best.diff);
if (!better) continue;
}
best = { depth, diff, exact, file };
}
return best?.file ?? null;
}