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:
@@ -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}
|
||||
|
||||
|
||||
+39
@@ -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} />
|
||||
|
||||
|
||||
+148
-61
@@ -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>
|
||||
|
||||
+58
-22
@@ -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.',
|
||||
|
||||
@@ -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?/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user