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
This commit is contained in:
Aleksander Grygier
2026-09-01 00:22:57 +02:00
parent a07359ef93
commit 0d22938cd9
10 changed files with 246 additions and 2 deletions
+5
View File
@@ -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,
@@ -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',
+2
View File
@@ -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',
@@ -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=<hfRepoWithTag>`:
* - 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 `<repo>:<tag>`
* format returned by `buildDownloadTag`.
* @returns Server acknowledgement containing the success flag
*/
static async cancelDownload(hfRepoWithTag: string): Promise<ApiModelsDownloadResponse> {
return apiDelete<ApiModelsDownloadResponse>(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
* `:<tag>` (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<ApiModelsDownloadResponse> {
const payload: ApiModelsDownloadRequest = { model: hfRepoWithTag };
return apiPost<ApiModelsDownloadResponse>(API_MODELS.DOWNLOAD, payload);
}
/**
* Check if a model is loaded based on its metadata.
*
+3
View File
@@ -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';
@@ -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<string | null>(null);
models = $state<HfModelInfo[]>([]);
/** 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<void> {
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<void> {
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();
+28 -1
View File
@@ -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<string, { done: number; total: number }>;
}
/**
* 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 `:<tag>` 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 {
+3
View File
@@ -14,7 +14,10 @@ export type {
ApiModelLoadStage,
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
ApiModelsSseEvent,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,
ApiModelDetails,
ApiLlamaCppServerProps,
ApiChatCompletionRequest,
+20
View File
@@ -137,6 +137,26 @@ export async function apiPost<T, B = unknown>(
});
}
/**
* 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<T>(
path: string,
params?: Record<string, string>,
options: ApiFetchOptions = {}
): Promise<T> {
if (params && Object.keys(params).length > 0) {
return apiFetchWithParams<T>(path, params, { ...options, method: 'DELETE' });
}
return apiFetch<T>(path, { ...options, method: 'DELETE' });
}
/**
* Parse error message from a failed response.
* Tries to extract error message from JSON body, falls back to status text.
+1 -1
View File
@@ -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