diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte index 77a35061de..691f320bba 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte @@ -4,6 +4,7 @@ import { isAuxSidecar, type ModelSidecar } from '$lib/constants'; import { HuggingFaceService, ModelsService } from '$lib/services'; import { modelsHubStore } from '$lib/stores'; + import type { ModelsHubSizeRange } from '$lib/stores/models-hub/index.svelte'; import type { HfModelInfo } from '$lib/types/huggingface'; import type { ModelModalities } from '$lib/types/models'; import { detectThinkingSupport, detectToolUseSupport, formatParameters } from '$lib/utils'; @@ -35,8 +36,8 @@ // 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. + // (`gguf.total`), fetched lazily only when neither the response nor the name + // has it. let fetchedParams = $state(null); $effect(() => { @@ -102,43 +103,21 @@ 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); + // Min/max size across the repo's quants, draft sidecars included. The store + // has catalog rows covered already; any other row (a search hit) measures + // its repo once here and the result is cached per repo. + let measuredSize = $state(null); + let sizeRange = $derived(modelsHubStore.cachedSizeRangeFor(model.id) ?? measuredSize); $effect(() => { - const base = modelsHubStore.sizeRangeFor(model.id); + const id = model.id; + + if (modelsHubStore.cachedSizeRangeFor(id)) return; let cancelled = false; - if (draftSidecars.length === 0) { - sizeRange = base ?? null; - - return; - } - - void HuggingFaceService.getTree(model.id).then((tree) => { - if (cancelled) return; - - const drafts = tree - .filter((f) => { - const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar; - - return sidecar && !isAuxSidecar(sidecar); - }) - .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; - } + void modelsHubStore.sizeRange(id).then((range) => { + if (!cancelled) measuredSize = range ?? null; }); return () => { diff --git a/tools/ui/src/lib/constants/huggingface.constants.ts b/tools/ui/src/lib/constants/huggingface.constants.ts index 7ac956ee9e..f3b97e5cc1 100644 --- a/tools/ui/src/lib/constants/huggingface.constants.ts +++ b/tools/ui/src/lib/constants/huggingface.constants.ts @@ -18,6 +18,23 @@ export const HF_FULL_DETAIL_PARAM = 'full=true'; export const HF_RECURSIVE_TREE_PARAM = 'recursive=true'; /** Search filter that restricts results to repos containing GGUF files. */ export const HF_GGUF_FILTER = 'gguf'; +/** Repeatable `expand` query param selecting fields on the list endpoint. */ +export const HF_EXPAND_PARAM = 'expand'; +/** + * Fields the model list endpoint omits by default but the discover list rows + * render: `gguf` (chat template, context length, param count) drives the + * reasoning / tool-use icons and the context badge, `siblings` the vision and + * draft-sidecar badges. Without them those parts of a row stay empty. + */ +export const HF_MODEL_LIST_EXPAND: readonly string[] = [ + 'author', + 'downloads', + 'gguf', + 'lastModified', + 'likes', + 'pipeline_tag', + 'siblings' +]; // Repo file conventions @@ -114,6 +131,19 @@ export const BYTE = 1; export const KILOBYTE = 1_000; export const MEGABYTE = 1_000_000; export const GIGABYTE = 1_000_000_000; +export const TERABYTE = 1_000_000_000_000; + +/** + * Byte multiplier for a size suffix (`k` kilobyte, `m` megabyte, ...) as used by + * the llama.app catalog `size` strings, whose suffix is lowercase. + */ +export const HF_SIZE_SUFFIX_BYTES: Readonly> = { + b: BYTE, + g: GIGABYTE, + k: KILOBYTE, + m: MEGABYTE, + t: TERABYTE +}; export const BYTE_LABEL = 'B'; export const KILOBYTE_LABEL = 'KB'; diff --git a/tools/ui/src/lib/services/huggingface.service.ts b/tools/ui/src/lib/services/huggingface.service.ts index e2f3ad1d27..0550ecd64d 100644 --- a/tools/ui/src/lib/services/huggingface.service.ts +++ b/tools/ui/src/lib/services/huggingface.service.ts @@ -28,6 +28,7 @@ import { HF_LINK_NEXT_REGEX, HF_MAIN_BRANCH, HF_MAX_LIMIT, + HF_MODEL_LIST_EXPAND, HF_PARAM_COUNT_REGEX, HF_QUANT_PRECISION_REGEX, HF_RAW_PATH, @@ -38,6 +39,7 @@ import { HF_SAFETENSORS_TAG, HF_SHARD_PAD_WIDTH, HF_SHARD_REGEX, + HF_SIZE_SUFFIX_BYTES, HF_TASK_TAGS, HF_TREE_PATH, HF_UD_QUANT_PREFIX, @@ -609,6 +611,25 @@ export class HuggingFaceService { return `${match[1]}${match[2].toUpperCase()}`; } + /** + * Parse a human size string (`177GB`, `1.2 TB`, `500MB`) to bytes. Returns + * null when it carries no number or no known suffix, so callers can fall + * back to another source instead of showing a wrong size. + */ + // LLAMA-APP-REUSE: catalog size string parsing + static parseSizeBytes(size: string): number | null { + const match = /^\s*([\d.]+)\s*([a-z]+)\s*$/i.exec(size); + + if (!match) return null; + + const value = parseFloat(match[1]); + const multiplier = HF_SIZE_SUFFIX_BYTES[match[2].toLowerCase()]; + + if (!Number.isFinite(value) || multiplier === undefined) return null; + + return value * multiplier; + } + /** * Parse model tags to extract useful information */ @@ -633,12 +654,17 @@ export class HuggingFaceService { } /** - * Search GGUF models with various filters and options + * Search GGUF models with various filters and options. + * + * Always expands the fields the discover rows render (chat template, context + * length, siblings, …) so a search result carries the same badges as a + * catalog entry; caller-provided `expand` entries are merged in. */ static async search(params: HfModelSearchParams = {}): Promise { - const { limit = HF_DEFAULT_LIMIT, ...restParams } = params; + const { expand, limit = HF_DEFAULT_LIMIT, ...restParams } = params; const url = this.buildUrl({ ...restParams, + expand: [...new Set([...HF_MODEL_LIST_EXPAND, ...(expand ?? [])])], filter: HF_GGUF_FILTER, limit: Math.min(limit, HF_MAX_LIMIT) }); diff --git a/tools/ui/src/lib/stores/models-hub/index.svelte.ts b/tools/ui/src/lib/stores/models-hub/index.svelte.ts index 981d78ea75..0c8753553a 100644 --- a/tools/ui/src/lib/stores/models-hub/index.svelte.ts +++ b/tools/ui/src/lib/stores/models-hub/index.svelte.ts @@ -4,13 +4,28 @@ * Owns the HuggingFace GGUF model list shown in the hub sidebar * (DialogModelsDiscover). The hub has no "nothing selected" screen: it always opens * a model, so `firstModel` drives the initial selection. By default the list - * shows a curated set of official ggml-org GGUF models in a fixed display order; - * search replaces the list with matching models across all of HuggingFace. + * shows a curated set of GGUF models in catalog order; search replaces the list + * with matching models across all of HuggingFace. Both paths fetch the same + * fields (chat template, context length, repo files), so a row renders the same + * badges and sizes whether it came from the catalog or from a query. * Detail data is loaded by ModelsDiscoverModelDetails, not here. */ +import { isAuxSidecar } from '$lib/constants'; import { HuggingFaceService } from '$lib/services'; -import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface'; +import type { + HfCatalogBuild, + HfCatalogEntry, + HfModelInfo, + HfModelSibling +} from '$lib/types/huggingface'; +import { SvelteMap } from 'svelte/reactivity'; + +/** Min/max GGUF file size (bytes) across the quants of one repo. */ +export interface ModelsHubSizeRange { + max: number; + min: number; +} class ModelsHubStore { error = $state(null); @@ -27,9 +42,21 @@ class ModelsHubStore { searching = $state(false); private catalog: HfCatalogEntry[] = []; + /** Repo id -> size range, for catalog rows and lazily measured search rows. */ + private catalogSizeRanges = new SvelteMap(); private defaultModels: HfModelInfo[] = []; private fetched = false; private searchRequestId = 0; + /** In-flight `sizeRange()` lookups, keyed by repo id. */ + private sizeRangePending = new Map>(); + + /** + * Cached size range for a repo, without measuring: the synchronous part of + * `sizeRange()`, for rendering a row before its measurement resolves. + */ + cachedSizeRangeFor(modelId: string): ModelsHubSizeRange | undefined { + return this.catalogSizeRanges.get(modelId); + } /** * Catalog family description for a repo id, or undefined when the repo is @@ -42,9 +69,10 @@ class ModelsHubStore { } /** - * 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. + * Fetch the default list from the llama.app catalog, one repo per catalog + * size in display order (newest family first). Every repo is fetched by ID + * with its file tree, so the rows carry chat-template capabilities, context + * length and a real size range - the same data a search result gets. * No-op when already loaded or in flight. */ async fetch(): Promise { @@ -57,14 +85,32 @@ class ModelsHubStore { 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(ids.map((id) => HuggingFaceService.getDetails(id))) - ).filter((m): m is HfModelInfo => m !== null); - this.models = this.defaultModels; + const builds = this.catalogBuilds(catalog); + const fetched = await Promise.all( + builds.map(async (build) => { + const [info, tree] = await Promise.all([ + HuggingFaceService.getDetails(build.repo), + HuggingFaceService.getTree(build.repo) + ]); + + return { build, info, tree }; + }) + ); + const models: HfModelInfo[] = []; + + for (const { build, info, tree } of fetched) { + if (!info) continue; + + this.catalogSizeRanges.set(build.repo, this.sizeRangeFor(build, tree)); + + // The catalog row, not the repo, defines the id shown and used for + // selection: the HF response `id` is not always the catalog repo. + models.push({ ...info, id: build.repo, modelId: build.repo } as HfModelInfo); + } + + this.defaultModels = models; + this.models = models; this.fetched = true; } catch (err) { this.error = err instanceof Error ? err.message : 'Failed to fetch models'; @@ -98,7 +144,7 @@ class ModelsHubStore { this.searching = true; try { - const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 }); + const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50 }); if (requestId !== this.searchRequestId) return; @@ -114,40 +160,124 @@ 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. + * Size range for a repo not measured yet - a search result, or a catalog + * repo whose tree came back empty. Fetches the file tree once per repo and + * caches it, so remounting a row (scrolling, searching back) is free. */ - 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); + sizeRange(modelId: string): Promise { + const cached = this.catalogSizeRanges.get(modelId); - if (builds.length === 0) continue; + if (cached) return Promise.resolve(cached); - const bytes = builds.map((b) => b.sizeBytes); + const pending = this.sizeRangePending.get(modelId); - return { max: Math.max(...bytes), min: Math.min(...bytes) }; - } - } + if (pending) return pending; - return undefined; + const request = (async () => { + const tree = await HuggingFaceService.getTree(modelId); + const range = this.sizeRangeOfMainQuants(tree); + + if (range) this.catalogSizeRanges.set(modelId, range); + + return range; + })() + .catch(() => undefined) + .finally(() => this.sizeRangePending.delete(modelId)); + + this.sizeRangePending.set(modelId, request); + + return request; } /** - * 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. + * Bytes of every quant the catalog lists under this repo, parsed from the + * `size` strings when a build carries no `sizeBytes`. Never empty: an + * unparsable entry contributes the build's own size. */ - private catalogModelIds(catalog: HfCatalogEntry[]): string[] { + private buildSizeBytes(build: HfCatalogBuild): number[] { + const sizes = this.catalog + .flatMap((entry) => entry.sizes) + .flatMap((size) => size.builds.filter((b) => b.repo === build.repo)) + .map((b) => b.sizeBytes ?? HuggingFaceService.parseSizeBytes(b.size)) + .filter((bytes): bytes is number => Boolean(bytes) && bytes > 0); + + return sizes.length > 0 ? sizes : [build.sizeBytes ?? 0]; + } + + /** + * One build per catalog size, newest family first (by release date). + * Prefers the official ggml-org repo, falling back to the first build so + * families published only by other orgs (mistralai, unsloth) still show up. + * Returns an empty array when the catalog is empty. + */ + private catalogBuilds(catalog: HfCatalogEntry[]): HfCatalogBuild[] { 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/')); + const build = size.builds.find((b) => b.repo.startsWith('ggml-org/')) ?? size.builds[0]; - return build ? [build.repo] : []; + return build ? [build] : []; }) ); } + + /** Byte sizes of every non-sidecar quant file in a tree, shards collapsed. */ + private quantSizesOf(tree: HfModelSibling[]): number[] { + return HuggingFaceService.collapseGgufShards( + HuggingFaceService.filterByExtension(tree, '.gguf') + ) + .filter((f) => { + const { quant, sidecar } = HuggingFaceService.extractQuantMeta(f.path) ?? {}; + + return Boolean(quant) && (sidecar === null || sidecar === undefined); + }) + .map((f) => f.size ?? 0) + .filter((size) => size > 0); + } + + /** + * Size range of one catalog build: every quant in the repo's file tree, plus + * the draft sidecars those files carry (mtp, dflash, ...) so the downloaded + * model fits within the range. Falls back to the catalog `size` / `sizeBytes` + * strings when the tree yielded nothing (partial fetch, sharded-only repo). + */ + private sizeRangeFor(build: HfCatalogBuild, tree: HfModelSibling[]): ModelsHubSizeRange { + const quantSizes = this.quantSizesOf(tree); + + if (quantSizes.length === 0) { + const listed = this.buildSizeBytes(build); + + return { max: Math.max(...listed), min: Math.min(...listed) }; + } + + const draftSizes = tree + .filter((f) => { + const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar; + + return sidecar !== null && sidecar !== undefined && !isAuxSidecar(sidecar); + }) + .map((f) => f.size ?? 0) + .filter((size) => size > 0); + const extra = draftSizes.length > 0 ? Math.max(...draftSizes) : 0; + + return { + max: Math.max(...quantSizes) + extra, + min: Math.min(...quantSizes) + (draftSizes.length > 0 ? Math.min(...draftSizes) : 0) + }; + } + + /** + * Size range across the main-model quants of a file tree, draft sidecars + * excluded (they only widen the range when a row advertises them). + */ + private sizeRangeOfMainQuants(tree: HfModelSibling[]): ModelsHubSizeRange | undefined { + const sizes = this.quantSizesOf(tree); + + if (sizes.length === 0) return undefined; + + return { max: Math.max(...sizes), min: Math.min(...sizes) }; + } } export const modelsHubStore = new ModelsHubStore(); diff --git a/tools/ui/src/lib/types/huggingface.d.ts b/tools/ui/src/lib/types/huggingface.d.ts index 0d6a9f7f75..b9a9d73eb3 100644 --- a/tools/ui/src/lib/types/huggingface.d.ts +++ b/tools/ui/src/lib/types/huggingface.d.ts @@ -29,6 +29,12 @@ export interface HfModelSearchParams { config?: string; /** Return full model info */ full?: boolean; + /** + * Fields to include beyond the default set (repeated as `expand=`). + * The list endpoint returns only `_id`, `id`, `modelId` and the sort field + * unless this is given, so callers rendering badges must ask for them. + */ + expand?: string[]; /** Filter by visibility */ private?: boolean; /** Filter by gated status */