mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 20:47:30 +02:00
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:
Vendored
+5
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
Vendored
+28
-1
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,10 @@ export type {
|
||||
ApiModelLoadStage,
|
||||
ApiModelsSseProgress,
|
||||
ApiModelsSseData,
|
||||
ApiModelsSseDownloadProgressData,
|
||||
ApiModelsSseEvent,
|
||||
ApiModelsDownloadRequest,
|
||||
ApiModelsDownloadResponse,
|
||||
ApiModelDetails,
|
||||
ApiLlamaCppServerProps,
|
||||
ApiChatCompletionRequest,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user