From 0d22938cd9c39bf20add2bfaae06367f72aebedf Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Sat, 29 Aug 2026 12:39:17 +0200 Subject: [PATCH] ui : add model download pipeline Wire the model download flow: ModelsService.downloadModel (POST /models) and cancelDownload (DELETE /models), the apiDelete helper, ApiModelsDownloadRequest/Response types, the download_progress SSE payload, and the download_finished/download_failed SSE event kinds matching the server feed. Add modelsHubStore owning the HuggingFace GGUF model list for the discover dialog: curated catalog defaults on open, search replaces the list across all of HuggingFace. Assisted-by: pi --- tools/ui/src/app.d.ts | 5 + .../lib/constants/api-endpoints.constants.ts | 3 + tools/ui/src/lib/enums/server.enums.ts | 2 + tools/ui/src/lib/services/models.service.ts | 41 +++++ tools/ui/src/lib/stores/index.ts | 3 + .../src/lib/stores/models-hub/index.svelte.ts | 140 ++++++++++++++++++ tools/ui/src/lib/types/api.d.ts | 29 +++- tools/ui/src/lib/types/index.ts | 3 + tools/ui/src/lib/utils/api-fetch.ts | 20 +++ tools/ui/src/lib/utils/index.ts | 2 +- 10 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tools/ui/src/lib/stores/models-hub/index.svelte.ts diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts index 2c896ed448..e5bb4a3877 100644 --- a/tools/ui/src/app.d.ts +++ b/tools/ui/src/app.d.ts @@ -19,6 +19,8 @@ import type { ApiLlamaCppServerProps, ApiModelDataEntry, ApiModelLoadStage, + ApiModelsDownloadRequest, + ApiModelsDownloadResponse, ApiModelsListResponse, ApiModelsLoadResponse, ApiModelsSseData, @@ -83,9 +85,12 @@ declare global { ApiModelLoadStage, ApiModelsSseProgress, ApiModelsSseData, + ApiModelsSseDownloadProgressData, ApiModelsSseEvent, ApiModelsListResponse, ApiModelsLoadResponse, + ApiModelsDownloadRequest, + ApiModelsDownloadResponse, ApiModelsStatusResponse, ApiModelsUnloadResponse, ApiProcessingState, diff --git a/tools/ui/src/lib/constants/api-endpoints.constants.ts b/tools/ui/src/lib/constants/api-endpoints.constants.ts index 8611d49fbb..2f6efb94cb 100644 --- a/tools/ui/src/lib/constants/api-endpoints.constants.ts +++ b/tools/ui/src/lib/constants/api-endpoints.constants.ts @@ -1,4 +1,7 @@ export const API_MODELS = { + /** Download a model from HuggingFace (ROUTER mode, POST) or cancel/remove it (DELETE) */ + DELETE: '/models', + DOWNLOAD: '/models', LIST: '/v1/models', LOAD: '/models/load', SSE: '/models/sse', diff --git a/tools/ui/src/lib/enums/server.enums.ts b/tools/ui/src/lib/enums/server.enums.ts index b7e80433c6..5a81104b75 100644 --- a/tools/ui/src/lib/enums/server.enums.ts +++ b/tools/ui/src/lib/enums/server.enums.ts @@ -26,6 +26,8 @@ export enum ServerModelStatus { * tools/server/server-models.cpp from the C++ server. */ export enum ServerModelsSseEventType { + DOWNLOAD_FAILED = 'download_failed', + DOWNLOAD_FINISHED = 'download_finished', DOWNLOAD_PROGRESS = 'download_progress', MODEL_REMOVE = 'model_remove', MODEL_STATUS = 'model_status', diff --git a/tools/ui/src/lib/services/models.service.ts b/tools/ui/src/lib/services/models.service.ts index 8d81d3f49a..0f1e34f800 100644 --- a/tools/ui/src/lib/services/models.service.ts +++ b/tools/ui/src/lib/services/models.service.ts @@ -11,6 +11,7 @@ import { API_MODELS, MODEL_ID, type ModelSidecar, sidecarFromFileToken } from '$ import { ServerModelStatus } from '$lib/enums'; import type { ParsedModelId } from '$lib/types/models'; import { + apiDelete, apiFetch, apiPost, extractSseDataPayload, @@ -46,6 +47,46 @@ export class ModelsService { return `${repoId}:${tag}`; } + /** + * Cancel an in-flight download or remove a previously downloaded/failed + * entry from the server's model cache (ROUTER mode only). + * + * Sends DELETE `/models?model=`: + * - while a download is running, the child subprocess is asked to exit + * and any partial `.tmp` files are removed; + * - once the entry has finished downloading or has failed, the cached + * files are removed from disk. + * + * @param hfRepoWithTag - HuggingFace repo id in the same `:` + * format returned by `buildDownloadTag`. + * @returns Server acknowledgement containing the success flag + */ + static async cancelDownload(hfRepoWithTag: string): Promise { + return apiDelete(API_MODELS.DELETE, { + model: hfRepoWithTag + }); + } + + /** + * Trigger a model download from HuggingFace (ROUTER mode only). + * + * Sends a POST request to `/models`. The response returns immediately; the + * actual download runs in the background and tracks progress through + * `/models/sse`. The server picks the file that matches the supplied tag + * (when present) and additionally pulls mmproj / draft sidecar weights as + * appropriate for the model. + * + * @param hfRepoWithTag - HuggingFace repo id, optionally suffixed with + * `:` (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M` + * or `:IQ1_M-mtp` for an embedded-draft GGUF). + * @returns Server acknowledgement containing the success flag + */ + static async downloadModel(hfRepoWithTag: string): Promise { + const payload: ApiModelsDownloadRequest = { model: hfRepoWithTag }; + + return apiPost(API_MODELS.DOWNLOAD, payload); + } + /** * Check if a model is loaded based on its metadata. * diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts index b571699072..a94a5d51ae 100644 --- a/tools/ui/src/lib/stores/index.ts +++ b/tools/ui/src/lib/stores/index.ts @@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte'; // MODELS export { modelsStore } from './models/index.svelte'; +// MODELS HUB (HuggingFace browse state for the discover dialog) +export { modelsHubStore } from './models-hub/index.svelte'; + // SERVER export { serverStore } from './server.svelte'; diff --git a/tools/ui/src/lib/stores/models-hub/index.svelte.ts b/tools/ui/src/lib/stores/models-hub/index.svelte.ts new file mode 100644 index 0000000000..e769553377 --- /dev/null +++ b/tools/ui/src/lib/stores/models-hub/index.svelte.ts @@ -0,0 +1,140 @@ +/** + * modelsHubStore - Model Hub browse state + * + * 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. + * Detail data is loaded by ModelsDiscoverDetails, not here. + */ + +import { HuggingFaceService } from '$lib/services'; +import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface'; + +class ModelsHubStore { + error = $state(null); + models = $state([]); + /** First model in the list - the hub auto-opens this one. */ + firstModel = $derived(this.models[0] ?? null); + + loading = $state(false); + + private catalog: HfCatalogEntry[] = []; + private defaultModels: HfModelInfo[] = []; + private fetched = false; + private searchRequestId = 0; + + /** + * Catalog family description for a repo id, or undefined when the repo is + * not part of the catalog (e.g. a search result outside the curated list). + */ + descriptionFor(modelId: string): string | undefined { + return this.catalog.find((entry) => + entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId)) + )?.description; + } + + /** + * 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. + * No-op when already loaded or in flight. + */ + async fetch(): Promise { + if (this.loading || this.fetched) return; + + this.loading = true; + this.error = null; + + try { + 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; + this.fetched = true; + } catch (err) { + this.error = err instanceof Error ? err.message : 'Failed to fetch models'; + } finally { + this.loading = false; + } + } + + /** + * Replace the list with GGUF search results. An empty query restores the + * default list. The current list stays visible while a search is in + * flight; stale responses are dropped when a newer search starts. + */ + async search(query: string): Promise { + const trimmed = query.trim(); + + this.searchRequestId++; + + if (!trimmed) { + this.models = this.defaultModels; + this.error = null; + + return; + } + + const requestId = this.searchRequestId; + + try { + const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 }); + + if (requestId === this.searchRequestId) { + this.models = results; + this.error = null; + } + } catch (err) { + if (requestId === this.searchRequestId) { + this.error = err instanceof Error ? err.message : 'Search failed'; + } + } + } + + /** + * Min/max GGUF file size (bytes) across the available quants for a repo, + * or undefined when the repo is not part of the catalog. + */ + 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); + + if (builds.length === 0) continue; + + const bytes = builds.map((b) => b.sizeBytes); + + return { max: Math.max(...bytes), min: Math.min(...bytes) }; + } + } + + return undefined; + } + + /** + * 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. + */ + private catalogModelIds(catalog: HfCatalogEntry[]): string[] { + 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/')); + + return build ? [build.repo] : []; + }) + ); + } +} + +export const modelsHubStore = new ModelsHubStore(); diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts index 6abe2f3781..39dff55b70 100644 --- a/tools/ui/src/lib/types/api.d.ts +++ b/tools/ui/src/lib/types/api.d.ts @@ -138,6 +138,14 @@ export interface ApiModelsSseData { exit_code?: number; } +/** + * Per-file size snapshot reported by the download_progress SSE envelope. + * Keys are file URLs, values are byte counters (done <= total). + */ +export interface ApiModelsSseDownloadProgressData { + progress: Record; +} + /** * Event kind multiplexed on the /models/sse feed. * Only the status_* events carry a status payload, models_reload signals a @@ -150,7 +158,26 @@ export interface ApiModelsSseData { export interface ApiModelsSseEvent { model: string; event: ServerModelsSseEventType; - data: ApiModelsSseData; + data?: ApiModelsSseData | ApiModelsSseDownloadProgressData; +} + +/** + * Request body for POST /models (model download). + * `model` is a HuggingFace repo id, optionally suffixed with `:` to + * pin a quantization or sidecar file (e.g. `ggml-org/gemma-3-4b-it-GGUF:Q4_K_M`). + */ +export interface ApiModelsDownloadRequest { + model: string; +} + +/** + * Response from POST /models and DELETE /models. The POST endpoint returns + * immediately; the download itself runs in the background and emits events + * on /models/sse. + */ +export interface ApiModelsDownloadResponse { + success: boolean; + error?: { code: number; message: string; type: string }; } export interface ApiModelDetails { diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts index 8bfab67961..60fec29289 100644 --- a/tools/ui/src/lib/types/index.ts +++ b/tools/ui/src/lib/types/index.ts @@ -14,7 +14,10 @@ export type { ApiModelLoadStage, ApiModelsSseProgress, ApiModelsSseData, + ApiModelsSseDownloadProgressData, ApiModelsSseEvent, + ApiModelsDownloadRequest, + ApiModelsDownloadResponse, ApiModelDetails, ApiLlamaCppServerProps, ApiChatCompletionRequest, diff --git a/tools/ui/src/lib/utils/api-fetch.ts b/tools/ui/src/lib/utils/api-fetch.ts index 9aa3a85799..7d4a159450 100644 --- a/tools/ui/src/lib/utils/api-fetch.ts +++ b/tools/ui/src/lib/utils/api-fetch.ts @@ -137,6 +137,26 @@ export async function apiPost( }); } +/** + * Send a DELETE request to an API endpoint, optionally with query parameters. + * + * @param path - API path (query string is appended if `params` is provided) + * @param params - Optional record of query parameters + * @param options - Additional fetch options + * @returns Parsed JSON response + */ +export async function apiDelete( + path: string, + params?: Record, + options: ApiFetchOptions = {} +): Promise { + if (params && Object.keys(params).length > 0) { + return apiFetchWithParams(path, params, { ...options, method: 'DELETE' }); + } + + return apiFetch(path, { ...options, method: 'DELETE' }); +} + /** * Parse error message from a failed response. * Tries to extract error message from JSON body, falls back to status text. diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 7a2a9713f8..fd1e2d1373 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -9,7 +9,7 @@ // API utilities export { getAuthHeaders, getJsonHeaders, sanitizeHeaders } from './api-headers'; -export { ApiError, apiFetch, apiFetchWithParams, apiPost } from './api-fetch'; +export { ApiError, apiDelete, apiFetch, apiFetchWithParams, apiPost } from './api-fetch'; export { validateApiKey } from './api-key-validation'; // Attachment utilities