feat: render discover rows from expanded HF list data

Search results now request the fields the rows render (gguf, siblings,
pipeline_tag, ...) via repeated expand params, so a search row shows the
same badges as a catalog row. The catalog path fetches each repo's tree
and derives a per-repo min/max size range (quants plus draft sidecars)
through the models hub store, with search rows falling back to one lazy
tree fetch per repo. Sizes from llama.app catalog size strings parse via
HuggingFaceService.parseSizeBytes when sizeBytes is absent.

Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-09-01 12:15:06 +02:00
parent 7599e2c602
commit 6775c200e2
5 changed files with 238 additions and 67 deletions
@@ -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<number | null>(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<ModelsHubSizeRange | null>(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 () => {
@@ -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<Record<string, number>> = {
b: BYTE,
g: GIGABYTE,
k: KILOBYTE,
m: MEGABYTE,
t: TERABYTE
};
export const BYTE_LABEL = 'B';
export const KILOBYTE_LABEL = 'KB';
@@ -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<HfModelInfo[]> {
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)
});
@@ -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<string | null>(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<string, ModelsHubSizeRange>();
private defaultModels: HfModelInfo[] = [];
private fetched = false;
private searchRequestId = 0;
/** In-flight `sizeRange()` lookups, keyed by repo id. */
private sizeRangePending = new Map<string, Promise<ModelsHubSizeRange | undefined>>();
/**
* 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<void> {
@@ -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<ModelsHubSizeRange | undefined> {
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();
+6
View File
@@ -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=<field>`).
* 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 */