ui : move HF constants to constants and enums modules

Address review on the HF data layer:

- replace the HfModelSort / SidecarForm / sibling entry type string
  unions with enums (HfModelSort, SidecarForm, HfEntryType)
- move URLs, query params, regexes, limits, retry settings, shard
  file conventions, tag tokens and formatting units into a dedicated
  huggingface.constants.ts; reuse the existing PATH_SEPARATOR
- drop the task label / pipeline icon / library display maps: the
  discover UI only presents GGUF models, so keep the task tags for
  logic use only (parseTags)
- drop the hardcoded curated model list; the discover dialog gets its
  default list from the llama.app /v1/catalog.json endpoint, which is
  an acceptable online-only source since the feature requires internet
  access anyway

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-29 12:57:08 +02:00
parent 8d496326f7
commit da017bcb53
9 changed files with 377 additions and 266 deletions
@@ -0,0 +1,165 @@
/**
* HuggingFace Hub constants.
*
* URLs, parsing regexes and formatting units for the HuggingFaceService.
* Reference: https://huggingface.co/docs/huggingface_hub/package_reference/hf_api
*/
// API endpoints
export const HF_BASE_URL = 'https://huggingface.co';
export const HF_API_MODELS_URL = `${HF_BASE_URL}/api/models`;
export const HF_AVATARS_URL = `${HF_BASE_URL}/api/avatars`;
export const HF_CATALOG_URL = 'https://llama.app/v1/catalog.json';
// Query params
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';
// Repo file conventions
export const HF_MAIN_BRANCH = 'main';
export const HF_README_FILENAME = 'README.md';
export const HF_RAW_PATH = 'raw';
export const HF_TREE_PATH = 'tree';
// Pagination
export const HF_LINK_NEXT_REGEX = /<([^>]+)>;\s*rel="next"/;
/** `Link` response header carrying the next page URL for cursor pagination. */
export const HF_LINK_HEADER = 'Link';
// Fetch retry
export const HF_RETRY_ATTEMPTS = 3;
export const HF_RETRY_DELAY_MS = 1000;
export const HF_HTTP_NOT_FOUND = 404;
export const HF_HTTP_SERVER_ERROR_MIN = 500;
// Search limits
export const HF_DEFAULT_LIMIT = 50;
export const HF_MAX_LIMIT = 100;
// GGUF shard files
/** Matches a split-shard GGUF file name, e.g. `Model-00001-of-00015.gguf`. */
export const HF_SHARD_REGEX = /-(\d{5})-of-(\d{5})\.gguf$/i;
/** Index (1-based) of the first shard in a split-shard set. */
export const HF_FIRST_SHARD = 1;
/** Zero-padded width of the shard index in a split-shard file name. */
export const HF_SHARD_PAD_WIDTH = 5;
// Quantization tokens
/** `UD-` (Unsloth Dynamic) custom quantization prefix, e.g. `UD-Q4_K_XL`. */
export const HF_UD_QUANT_PREFIX = 'UD';
export const HF_UD_QUANT_PREFIX_REGEX = /^UD-/i;
/**
* Extracts the leading precision digits from a quant token, e.g.
* `Q4_K_XL` -> 4, `IQ2_XXS` -> 2, `TQ1_0` -> 1, `BF16` -> 16.
*/
export const HF_QUANT_PRECISION_REGEX = /^(?:I?Q|TQ|BF|F|MXFP)?(\d+)/i;
// Model card tags
/** Matches the `base_model:` tag (plain or `quantized:`), capturing the repo id. */
export const HF_BASE_MODEL_TAG_REGEX = /^base_model:(?:quantized:)?(.+)$/;
export const HF_LICENSE_TAG_PREFIX = 'license:';
export const HF_GATED_TAG = 'gated';
export const HF_GGUF_TAG = 'gguf';
export const HF_SAFETENSORS_TAG = 'safetensors';
// Pipeline tasks (logic use only - matching `pipeline_tag` values against tags)
export const HF_TASK_TAGS: readonly string[] = [
'audio-classification',
'audio-to-audio',
'automatic-speech-recognition',
'conversational',
'depth-estimation',
'feature-extraction',
'fill-mask',
'image-classification',
'image-feature-extraction',
'image-segmentation',
'image-text-to-text',
'image-to-text',
'image-to-video',
'object-detection',
'question-answering',
'reinforcement-learning',
'robotics',
'sentence-similarity',
'summarization',
'text2text-generation',
'text-classification',
'text-generation',
'text-to-image',
'text-to-speech',
'text-to-video',
'token-classification',
'translation',
'video-to-video',
'voice-activity-detection',
'zero-shot-classification'
];
// Formatting
export const BYTE = 1;
export const KILOBYTE = 1_000;
export const MEGABYTE = 1_000_000;
export const GIGABYTE = 1_000_000_000;
export const BYTE_LABEL = 'B';
export const KILOBYTE_LABEL = 'KB';
export const MEGABYTE_LABEL = 'MB';
export const GIGABYTE_LABEL = 'GB';
/** Count suffixes for compact number formatting, e.g. `1.5K`, `2.0M`. */
export const KILO_LABEL = 'K';
export const MEGA_LABEL = 'M';
export const GIGA_LABEL = 'B';
// Relative time
export const MS_PER_DAY = 1000 * 60 * 60 * 24;
export const DAYS_PER_WEEK = 7;
/** Rough month length in days, used to bucket relative timestamps. */
export const DAYS_PER_MONTH = 30;
export const DAYS_PER_YEAR = 365;
export const TODAY_LABEL = 'Today';
export const YESTERDAY_LABEL = 'Yesterday';
export const DAYS_AGO_LABEL = 'days ago';
export const WEEKS_AGO_LABEL = 'weeks ago';
export const MONTHS_AGO_LABEL = 'months ago';
export const YEARS_AGO_LABEL = 'years ago';
// Cache paths
/**
* Matches a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`), capturing the repo
* directory name and the repo-relative file path.
*/
export const HF_CACHE_PATH_REGEX = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/;
/** Separator between org and name segments in an HF cache directory name. */
export const HF_CACHE_DIR_SEPARATOR = '--';
// README
/** Matches a leading YAML frontmatter block (--- ... ---) in a markdown document. */
export const HF_FRONTMATTER_REGEX = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/;
// Param counts
/**
* Best-effort parameter count token in a model id/name, e.g. `27B` from
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`.
*/
export const HF_PARAM_COUNT_REGEX = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i;
+1 -1
View File
@@ -44,7 +44,7 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
export * from './huggingface.constants';
export * from './precision.constants';
export * from './pwa.constants';
export * from './routes.constants';
@@ -1,18 +0,0 @@
/**
* Curated default models shown in the Discover Models sidebar, in display order.
*/
export const CURATED_MODEL_IDS = [
'ggml-org/Qwen3.8-27B-GGUF',
'ggml-org/DeepSeek-V4-Flash-0731-GGUF',
'ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF',
'ggml-org/Qwen3.6-35B-A3B-GGUF',
'ggml-org/Laguna-S-2.1-GGUF',
'ggml-org/gemma-4-31B-it-GGUF',
'ggml-org/gemma-4-26B-A4B-it-GGUF',
'ggml-org/gemma-4-12B-it-GGUF',
'ggml-org/Qwen3.5-0.8B-GGUF',
'ggml-org/gemma-4-E2B-it-GGUF',
'ggml-org/gemma-4-E4B-it-GGUF',
'ggml-org/gpt-oss-120b-GGUF',
'ggml-org/gpt-oss-20b-GGUF'
];
@@ -0,0 +1,33 @@
/**
* HuggingFace Hub enums.
*
* Values mirror the strings used by the HF REST API
* (https://huggingface.co/docs/huggingface_hub/package_reference/hf_api)
* so they can be sent and compared directly.
*/
/** Sort field for /api/models search queries. */
export enum HfModelSort {
CREATED_AT = 'createdAt',
DOWNLOADS = 'downloads',
LAST_MODIFIED = 'lastModified',
LIKES = 'likes',
TRENDING_SCORE = 'trendingScore'
}
/**
* Where the sidecar token (`mtp` / `dflash` / `mmproj` / ...) sits in the
* filename.
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
*/
export enum SidecarForm {
PREFIX = 'prefix',
SUFFIX = 'suffix'
}
/** Entry type in a model repository file tree (`/tree` responses). */
export enum HfEntryType {
DIRECTORY = 'directory',
FILE = 'file'
}
+2
View File
@@ -57,6 +57,8 @@ export {
SpecialFileType
} from './files.enums';
export { HfEntryType, HfModelSort, SidecarForm } from './huggingface.enums';
export {
MCPConnectionPhase,
MCPLogLevel,
+172 -242
View File
@@ -1,145 +1,76 @@
import { PATH_SEPARATOR } from '$lib/constants';
import {
BYTE,
BYTE_LABEL,
DAYS_AGO_LABEL,
DAYS_PER_MONTH,
DAYS_PER_WEEK,
DAYS_PER_YEAR,
GIGABYTE,
GIGABYTE_LABEL,
HF_API_MODELS_URL,
HF_AVATARS_URL,
HF_BASE_MODEL_TAG_REGEX,
HF_BASE_URL,
HF_CACHE_DIR_SEPARATOR,
HF_CACHE_PATH_REGEX,
HF_CATALOG_URL,
HF_DEFAULT_LIMIT,
HF_FIRST_SHARD,
HF_FRONTMATTER_REGEX,
HF_FULL_DETAIL_PARAM,
HF_GATED_TAG,
HF_GGUF_FILTER,
HF_GGUF_TAG,
HF_HTTP_NOT_FOUND,
HF_HTTP_SERVER_ERROR_MIN,
HF_LICENSE_TAG_PREFIX,
HF_LINK_HEADER,
HF_LINK_NEXT_REGEX,
HF_MAIN_BRANCH,
HF_MAX_LIMIT,
HF_PARAM_COUNT_REGEX,
HF_QUANT_PRECISION_REGEX,
HF_RAW_PATH,
HF_README_FILENAME,
HF_RECURSIVE_TREE_PARAM,
HF_RETRY_ATTEMPTS,
HF_RETRY_DELAY_MS,
HF_SAFETENSORS_TAG,
HF_SHARD_PAD_WIDTH,
HF_SHARD_REGEX,
HF_TASK_TAGS,
HF_TREE_PATH,
HF_UD_QUANT_PREFIX,
HF_UD_QUANT_PREFIX_REGEX,
KILO_LABEL,
KILOBYTE,
KILOBYTE_LABEL,
MEGA_LABEL,
MEGABYTE,
MEGABYTE_LABEL,
MONTHS_AGO_LABEL,
MS_PER_DAY,
TODAY_LABEL,
WEEKS_AGO_LABEL,
YEARS_AGO_LABEL,
YESTERDAY_LABEL
} from '$lib/constants';
import { MODEL_ID, type ModelSidecar, sidecarFromFileToken } from '$lib/constants';
import { HfEntryType, HfModelSort, SidecarForm } from '$lib/enums';
import type {
HfCatalogEntry,
HfModelDetailInfo,
HfModelInfo,
HfModelSearchParams,
HfModelSibling,
HfModelSort
HfModelSibling
} from '$lib/types/huggingface';
/**
* Where the sidecar token (`mtp` / `dflash` / `mmproj` / ...) sits in the filename.
* - `prefix` sidecar file that lives next to the main weights, e.g. `mtp-Q4_0.gguf`
* - `suffix` embedded draft baked into the main weights, e.g. `Hy3-IQ1_M-mtp.gguf`
*/
export type SidecarForm = 'prefix' | 'suffix';
// Constants
export const HF_TASKS: Record<string, string> = {
'audio-classification': 'Audio Classification',
'audio-to-audio': 'Audio-to-Audio',
'automatic-speech-recognition': 'Speech Recognition',
conversational: 'Conversational',
'depth-estimation': 'Depth Estimation',
'feature-extraction': 'Feature Extraction',
'fill-mask': 'Fill Mask',
'image-classification': 'Image Classification',
'image-feature-extraction': 'Image Feature Extraction',
'image-segmentation': 'Image Segmentation',
'image-text-to-text': 'Image-Text-to-Text',
'image-to-text': 'Image-to-Text',
'image-to-video': 'Image-to-Video',
'object-detection': 'Object Detection',
'question-answering': 'Question Answering',
'reinforcement-learning': 'Reinforcement Learning',
robotics: 'Robotics',
'sentence-similarity': 'Sentence Similarity',
summarization: 'Summarization',
'text2text-generation': 'Text2Text Generation',
'text-classification': 'Text Classification',
'text-generation': 'Text Generation',
'text-to-image': 'Text-to-Image',
'text-to-speech': 'Text to Speech',
'text-to-video': 'Text-to-Video',
'token-classification': 'Token Classification',
translation: 'Translation',
'video-to-video': 'Video-to-Video',
'voice-activity-detection': 'Voice Activity Detection',
'zero-shot-classification': 'Zero-Shot Classification'
};
/**
* Best-effort readable label for an HF pipeline tag. Falls back to a
* title-cased version of the kebab-case `pipeline_tag` (e.g. `image-text-to-text`
* becomes `Image-Text-to-Text`) when we don't have an explicit entry above.
*/
function pipelineTagLabel(tag: string): string {
if (HF_TASKS[tag]) return HF_TASKS[tag];
return tag
.split('-')
.map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part))
.join('-');
}
/**
* Lucide icon name (string identifier, used to lazy-import the Svelte component)
* matching the HF pipeline_tag. Used for the filter chips on the model browser.
* Returns `null` for unknown tags so the consumer can render a generic icon.
*/
const HF_PIPELINE_ICONS: Record<string, string> = {
'audio-classification': 'mic',
'audio-to-audio': 'audio-lines',
'automatic-speech-recognition': 'mic',
conversational: 'message-circle',
'depth-estimation': 'layers',
'feature-extraction': 'hash',
'fill-mask': 'replace',
'image-classification': 'image',
'image-feature-extraction': 'image',
'image-segmentation': 'image',
'image-text-to-text': 'image-plus',
'image-to-text': 'image',
'image-to-video': 'video',
'object-detection': 'scan',
'question-answering': 'help-circle',
'sentence-similarity': 'equal',
summarization: 'list-collapse',
'text2text-generation': 'message-square-more',
'text-generation': 'message-square',
'text-to-image': 'image',
'text-to-speech': 'volume-2',
'text-to-video': 'video',
translation: 'languages',
'video-to-video': 'video',
'voice-activity-detection': 'mic'
};
function pipelineTagIcon(tag: string): string | null {
return HF_PIPELINE_ICONS[tag] ?? null;
}
export const HF_LIBRARIES: Record<string, string> = {
gguf: 'GGUF',
mlx: 'MLX',
onnx: 'ONNX',
safetensors: 'Safetensors',
transformers: 'Transformers',
vllm: 'vLLM'
};
/**
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
*/
export class HuggingFaceService {
// Configuration
/** Available library names with display labels */
static readonly LIBRARIES: Record<string, string> = HF_LIBRARIES;
/** Sort option display labels */
static readonly SORT_LABELS: Record<HfModelSort, string> = {
createdAt: 'Newest',
downloads: 'Most Downloads',
lastModified: 'Recently Updated',
likes: 'Most Likes',
trendingScore: 'Trending'
};
/** Available sort options */
static readonly SORT_OPTIONS: HfModelSort[] = [
'downloads',
'likes',
'trendingScore',
'createdAt'
];
// Available options for filtering
/** Available pipeline tasks with display labels */
static readonly TASKS: Record<string, string> = HF_TASKS;
private static readonly BASE_URL = 'https://huggingface.co/api/models';
private static readonly BASE_URL = HF_API_MODELS_URL;
// Cached base model lookups keyed by repo id, so repeated selector opens
// never re-hit the HF API for the same repo.
@@ -150,12 +81,6 @@ export class HuggingFaceService {
Promise<{ org: string; name: string } | null>
>();
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
// GGUF Model Searching
/**
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
*/
@@ -206,7 +131,7 @@ export class HuggingFaceService {
const result: HfModelSibling[] = [];
for (const file of siblings) {
const match = /-(\d{5})-of-(\d{5})\.gguf$/i.exec(file.path);
const match = HF_SHARD_REGEX.exec(file.path);
if (!match) {
result.push(file);
@@ -215,15 +140,15 @@ export class HuggingFaceService {
}
// Keep only the first shard; its size becomes the whole shard set's.
if (match[1] !== '00001') continue;
if (parseInt(match[1], 10) !== HF_FIRST_SHARD) 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`;
for (let i = HF_FIRST_SHARD; i <= total; i++) {
const shard = HuggingFaceService.shardPath(stem, i, total);
size += sizeByPath.get(shard) ?? 0;
}
@@ -265,7 +190,7 @@ export class HuggingFaceService {
if (prefixMatch) {
sidecar = sidecarFromFileToken(prefixMatch[1].toLowerCase());
sidecarForm = 'prefix';
sidecarForm = SidecarForm.PREFIX;
source = prefixMatch[2];
} else {
const suffixMatch = source.match(MODEL_ID.SIDECAR_SUFFIX_REGEX);
@@ -276,7 +201,7 @@ export class HuggingFaceService {
if (headSeg && MODEL_ID.QUANTIZATION_SEGMENT_REGEX.test(headSeg)) {
sidecar = sidecarFromFileToken(suffixMatch[2].toLowerCase());
sidecarForm = 'suffix';
sidecarForm = SidecarForm.SUFFIX;
source = candidate;
}
}
@@ -292,8 +217,8 @@ export class HuggingFaceService {
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}`;
if (quant && quantIdx > 0 && segments[quantIdx - 1].toUpperCase() === HF_UD_QUANT_PREFIX) {
quant = `${HF_UD_QUANT_PREFIX}-${quant}`;
}
return { quant, sidecar, sidecarForm };
@@ -312,12 +237,12 @@ export class HuggingFaceService {
* Format model downloads count with K/M/B suffix
*/
static formatDownloads(downloads: number): string {
if (downloads >= 1_000_000) {
return `${(downloads / 1_000_000).toFixed(1)}M`;
if (downloads >= MEGABYTE) {
return `${(downloads / MEGABYTE).toFixed(1)}${MEGA_LABEL}`;
}
if (downloads >= 1_000) {
return `${(downloads / 1_000).toFixed(1)}K`;
if (downloads >= KILOBYTE) {
return `${(downloads / KILOBYTE).toFixed(1)}${KILO_LABEL}`;
}
return downloads.toString();
@@ -327,27 +252,27 @@ export class HuggingFaceService {
* Format file size in bytes to human-readable string
*/
static formatFileSize(bytes: number): string {
if (bytes >= 1_000_000_000) {
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
if (bytes >= GIGABYTE) {
return `${(bytes / GIGABYTE).toFixed(1)} ${GIGABYTE_LABEL}`;
}
if (bytes >= 1_000_000) {
return `${(bytes / 1_000_000).toFixed(1)} MB`;
if (bytes >= MEGABYTE) {
return `${(bytes / MEGABYTE).toFixed(1)} ${MEGABYTE_LABEL}`;
}
if (bytes >= 1_000) {
return `${(bytes / 1_000).toFixed(1)} KB`;
if (bytes >= KILOBYTE) {
return `${(bytes / KILOBYTE).toFixed(1)} ${KILOBYTE_LABEL}`;
}
return `${bytes} B`;
return `${bytes} ${BYTE_LABEL}`;
}
/**
* Format likes count with K suffix if applicable
*/
static formatLikes(likes: number): string {
if (likes >= 1_000) {
return `${(likes / 1_000).toFixed(1)}K`;
if (likes >= KILOBYTE) {
return `${(likes / KILOBYTE).toFixed(1)}${KILO_LABEL}`;
}
return likes.toString();
@@ -360,19 +285,23 @@ export class HuggingFaceService {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const diffDays = Math.floor(diffMs / MS_PER_DAY);
if (diffDays === 0) return 'Today';
if (diffDays === 0) return TODAY_LABEL;
if (diffDays === 1) return 'Yesterday';
if (diffDays === 1) return YESTERDAY_LABEL;
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < DAYS_PER_WEEK) return `${diffDays} ${DAYS_AGO_LABEL}`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < DAYS_PER_MONTH) {
return `${Math.floor(diffDays / DAYS_PER_WEEK)} ${WEEKS_AGO_LABEL}`;
}
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
if (diffDays < DAYS_PER_YEAR) {
return `${Math.floor(diffDays / DAYS_PER_MONTH)} ${MONTHS_AGO_LABEL}`;
}
return `${Math.floor(diffDays / 365)} years ago`;
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
}
/**
@@ -380,10 +309,23 @@ export class HuggingFaceService {
* 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 unit =
max >= GIGABYTE
? GIGABYTE_LABEL
: max >= MEGABYTE
? MEGABYTE_LABEL
: max >= KILOBYTE
? KILOBYTE_LABEL
: BYTE_LABEL;
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)}`);
unit === GIGABYTE_LABEL
? GIGABYTE
: unit === MEGABYTE_LABEL
? MEGABYTE
: unit === KILOBYTE_LABEL
? KILOBYTE
: BYTE;
const fmt = (n: number) => (div === BYTE ? `${n}` : `${(n / div).toFixed(1)}`);
return `${fmt(min)}-${fmt(max)} ${unit}`;
}
@@ -395,7 +337,7 @@ export class HuggingFaceService {
* exist, so callers should provide a fallback.
*/
static getAvatarUrl(author: string): string {
return `https://huggingface.co/api/avatars/${author}`;
return `${HF_AVATARS_URL}${PATH_SEPARATOR}${author}`;
}
/**
@@ -418,9 +360,9 @@ export class HuggingFaceService {
if (!base) return null;
const [org, ...rest] = base.split('/');
const [org, ...rest] = base.split(PATH_SEPARATOR);
return { name: rest.join('/'), org };
return { name: rest.join(PATH_SEPARATOR), org };
})();
this.baseModelPending.set(repoId, promise);
@@ -442,7 +384,7 @@ export class HuggingFaceService {
const cardBase = model.cardData?.base_model;
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
const fromTags = (model.tags ?? [])
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
.map((t) => HF_BASE_MODEL_TAG_REGEX.exec(t)?.[1])
.filter((v): v is string => Boolean(v));
return Array.from(new Set([...fromCard, ...fromTags]));
@@ -454,14 +396,14 @@ export class HuggingFaceService {
*/
static getBitDepth(quant: string): number | null {
// Strip a leading `UD-` (Unsloth Dynamic) prefix before lookup.
const base = quant.replace(/^UD-/i, '');
const base = quant.replace(HF_UD_QUANT_PREFIX_REGEX, '');
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);
const match = HF_QUANT_PRECISION_REGEX.exec(base);
return match ? parseInt(match[1], 10) : null;
}
@@ -480,17 +422,12 @@ export class HuggingFaceService {
}
/**
* Get detailed information about a specific GGUF model
*/
/**
* Fetch the llama.app model catalog (https://llama.app/v1/catalog.json).
* Returns an empty array on failure so callers can fall back gracefully.
* Fetch the llama.app model catalog. Returns an empty array on failure so
* callers can fall back gracefully.
*/
static async getCatalog(): Promise<HfCatalogEntry[]> {
const url = 'https://llama.app/v1/catalog.json';
try {
const response = await fetch(url);
const response = await fetch(HF_CATALOG_URL);
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
@@ -505,12 +442,12 @@ export class HuggingFaceService {
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
// Do not encode the modelId, it contains slashes for author/name.
// `full=true` includes cardData (description, base_model) and safetensors.
const url = `https://huggingface.co/api/models/${modelId}?full=true`;
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (response.status === HF_HTTP_NOT_FOUND) return null;
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
@@ -528,7 +465,7 @@ export class HuggingFaceService {
* Get model URL on Hugging Face Hub
*/
static getModelUrl(modelId: string): string {
return `https://huggingface.co/${modelId}`;
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
}
// Utility Methods
@@ -536,26 +473,22 @@ export class HuggingFaceService {
/**
* Get most liked GGUF models
*/
static async getMostLiked(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'likes' });
static async getMostLiked(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.LIKES });
}
/**
* Get newly released GGUF models
*/
static async getNew(limit: number = HuggingFaceService.DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'createdAt' });
static async getNew(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.CREATED_AT });
}
/**
* Get most popular GGUF models by downloads
*/
static async getPopular(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'downloads' });
static async getPopular(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.DOWNLOADS });
}
/**
@@ -563,12 +496,12 @@ export class HuggingFaceService {
*/
static async getReadme(modelId: string): Promise<string | null> {
// Do not encode the modelId, it contains slashes for author/name
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
const url = `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_RAW_PATH}${PATH_SEPARATOR}${HF_MAIN_BRANCH}${PATH_SEPARATOR}${HF_README_FILENAME}`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (response.status === HF_HTTP_NOT_FOUND) return null;
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
@@ -587,9 +520,11 @@ export class HuggingFaceService {
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
let url: string | null =
`https://huggingface.co/api/models/${modelId}/tree/main?recursive=true`;
let url: string | null = firstUrl;
try {
while (url) {
@@ -599,9 +534,9 @@ export class HuggingFaceService {
const data = (await response.json()) as HfModelSibling[];
files.push(...data.filter((f) => f.type !== 'directory'));
files.push(...data.filter((f) => f.type !== HfEntryType.DIRECTORY));
url = HuggingFaceService.parseNextPageUrl(response.headers.get('Link'));
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
}
} catch {
// Return whatever was fetched before the failure.
@@ -613,26 +548,28 @@ export class HuggingFaceService {
/**
* Get trending GGUF models
*/
static async getTrending(
limit: number = HuggingFaceService.DEFAULT_LIMIT
): Promise<HfModelInfo[]> {
return this.search({ limit, sort: 'trendingScore' });
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
}
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
* repo-relative file path. Returns null when the path is not an HF cache path.
*/
static parseCachePath(path: string): { repo: string; file: string } | null {
const match = /models--(.+?)\/snapshots\/[^/]+\/(.+)$/.exec(path);
const match = HF_CACHE_PATH_REGEX.exec(path);
if (!match) return null;
const parts = match[1].split('--');
const parts = match[1].split(HF_CACHE_DIR_SEPARATOR);
if (parts.length < 2) return null;
return { file: match[2], repo: `${parts[0]}/${parts.slice(1).join('--')}` };
return {
file: match[2],
repo: `${parts[0]}${PATH_SEPARATOR}${parts.slice(1).join(HF_CACHE_DIR_SEPARATOR)}`
};
}
/**
@@ -641,7 +578,7 @@ export class HuggingFaceService {
* when no size token is present.
*/
static parseParamCount(name: string): string | null {
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
const match = HF_PARAM_COUNT_REGEX.exec(name);
if (!match) return null;
@@ -658,38 +595,27 @@ export class HuggingFaceService {
isSafetensors: boolean;
tasks: string[];
} {
const license = tags.find((tag) => tag.startsWith('license:'))?.replace('license:', '') || null;
const isGated = tags.includes('gated');
const isGguf = tags.includes('gguf');
const isSafetensors = tags.includes('safetensors');
const tasks = tags.filter((tag) => Object.keys(HuggingFaceService.TASKS).includes(tag));
const license =
tags
.find((tag) => tag.startsWith(HF_LICENSE_TAG_PREFIX))
?.replace(HF_LICENSE_TAG_PREFIX, '') || null;
const isGated = tags.includes(HF_GATED_TAG);
const isGguf = tags.includes(HF_GGUF_TAG);
const isSafetensors = tags.includes(HF_SAFETENSORS_TAG);
const tasks = tags.filter((tag) => HF_TASK_TAGS.includes(tag));
return { isGated, isGguf, isSafetensors, license, tasks };
}
/** Resolve a pipeline_tag to a lucide icon name, or null when unknown. */
static pipelineTagIcon(tag: string | null | undefined): string | null {
if (!tag) return null;
return pipelineTagIcon(tag);
}
/** Resolve a pipeline_tag to a human-readable label. */
static pipelineTagLabel(tag: string | null | undefined): string | null {
if (!tag) return null;
return pipelineTagLabel(tag);
}
/**
* Search GGUF models with various filters and options
*/
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
const { limit = HuggingFaceService.DEFAULT_LIMIT, ...restParams } = params;
const { limit = HF_DEFAULT_LIMIT, ...restParams } = params;
const url = this.buildUrl({
...restParams,
filter: 'gguf',
limit: Math.min(limit, HuggingFaceService.MAX_LIMIT)
filter: HF_GGUF_FILTER,
limit: Math.min(limit, HF_MAX_LIMIT)
});
return this.fetchWithRetry(url);
@@ -734,25 +660,20 @@ export class HuggingFaceService {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Internal Methods
/**
* Fetch data with retry logic for resilience
*/
private static async fetchWithRetry(url: string, attempt: number = 1): Promise<HfModelInfo[]> {
const RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1000;
try {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
if (response.status === HF_HTTP_NOT_FOUND) {
return [];
}
if (response.status >= 500 && attempt < RETRY_ATTEMPTS) {
await this.delay(RETRY_DELAY_MS * attempt);
if (response.status >= HF_HTTP_SERVER_ERROR_MIN && attempt < HF_RETRY_ATTEMPTS) {
await this.delay(HF_RETRY_DELAY_MS * attempt);
return this.fetchWithRetry(url, attempt + 1);
}
@@ -772,8 +693,8 @@ export class HuggingFaceService {
throw new Error('Unexpected API response format');
} catch (error) {
if (attempt < RETRY_ATTEMPTS) {
await this.delay(RETRY_DELAY_MS * attempt);
if (attempt < HF_RETRY_ATTEMPTS) {
await this.delay(HF_RETRY_DELAY_MS * attempt);
return this.fetchWithRetry(url, attempt + 1);
}
@@ -782,18 +703,27 @@ export class HuggingFaceService {
}
}
// Internal Methods
/** 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);
const match = HF_LINK_NEXT_REGEX.exec(linkHeader);
return match ? match[1] : null;
}
/** Full path of one shard in a split-shard GGUF set. */
private static shardPath(stem: string, index: number, total: number): string {
const pad = (n: number) => String(n).padStart(HF_SHARD_PAD_WIDTH, '0');
return `${stem}-${pad(index)}-of-${pad(total)}.gguf`;
}
/** 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?/);
const match = text.match(HF_FRONTMATTER_REGEX);
return match ? text.slice(match[0].length) : text;
}
+1 -1
View File
@@ -155,7 +155,7 @@ export { ModelsService } from './models.service';
* (quant metadata, shard collapsing, size formatting) used by the models
* discover UI.
*/
export { HuggingFaceService, type SidecarForm } from './huggingface.service';
export { HuggingFaceService } from './huggingface.service';
/**
* **PropsService** - Server properties and capabilities retrieval
+2 -2
View File
@@ -34,7 +34,7 @@ export interface HfModelSearchParams {
gated?: boolean;
}
export type HfModelSort = 'downloads' | 'likes' | 'createdAt' | 'lastModified' | 'trendingScore';
import type { HfEntryType, HfModelSort } from '$lib/enums';
// Model Info (from /api/models)
@@ -165,7 +165,7 @@ export interface HfModelSibling {
/** Size in bytes (omitted for directories) */
size?: number;
/** Whether this entry is a directory */
type?: 'file' | 'directory';
type?: HfEntryType;
/** OID/hash for the blob */
oid?: string;
[key: string]: unknown;
+1 -2
View File
@@ -49,8 +49,7 @@ export type {
HfModelInfo,
HfModelSearchParams,
HfModelSibling,
HfModelSiblingRef,
HfModelSort
HfModelSiblingRef
} from './huggingface';
// Chat types