mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
ui : add download state tracking to the model status manager
Port the download lifecycle into ModelsStatusManager: track per-entry progress keyed by <repo>:<tag> from the /models/sse feed, record failed downloads for the delete-and-retry path, and expose the downloadModel / cancelDownload operations (POST/DELETE /models). Add the ServerModelStatus.DOWNLOADED/DOWNLOADING cases and the ModelDownloadProgress type. Assisted-by: pi
This commit is contained in:
@@ -17,6 +17,10 @@ export enum ServerModelStatus {
|
||||
LOADED = 'loaded',
|
||||
LOADING = 'loading',
|
||||
SLEEPING = 'sleeping',
|
||||
DOWNLOADED = 'downloaded',
|
||||
DOWNLOADING = 'downloading',
|
||||
DOWNLOAD_FAILED = 'download_failed',
|
||||
DOWNLOAD_FINISHED = 'download_finished',
|
||||
UNLOADED = 'unloaded'
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
*/
|
||||
|
||||
import { ServerModelsSseEventType, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services/huggingface.service';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
|
||||
// direct imports between stores, not via the barrel, to avoid circular deps
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
|
||||
import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
/**
|
||||
@@ -31,6 +34,33 @@ export interface ModelStatusHost {
|
||||
}
|
||||
|
||||
export class ModelStatusManager {
|
||||
/**
|
||||
* Draft sidecar files pulled by registered models, as `<repo>/<file>` keys.
|
||||
* Drafts are not separate /v1/models entries - the router pulls them as
|
||||
* sidecars of a main model and records them in its `--model-draft` arg.
|
||||
*/
|
||||
private downloadedDrafts = $derived.by(() => {
|
||||
const result = new SvelteSet<string>();
|
||||
|
||||
for (const m of this.host.routerModels) {
|
||||
const args = m.status?.args;
|
||||
|
||||
if (!args) continue;
|
||||
|
||||
for (let i = 0; i < args.length - 1; i++) {
|
||||
if (args[i] !== '--model-draft' && args[i] !== '-md') continue;
|
||||
|
||||
const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
|
||||
|
||||
if (parsed) result.add(`${parsed.repo}/${parsed.file}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
|
||||
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
|
||||
private failedDownloads = new SvelteSet<string>();
|
||||
private loadingStates = new SvelteMap<string, boolean>();
|
||||
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
|
||||
// /models/sse feed state, the single source of truth for status and load progress
|
||||
@@ -41,14 +71,105 @@ export class ModelStatusManager {
|
||||
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Cancel an in-flight download or remove a previously downloaded/failed model
|
||||
* from the server cache (ROUTER mode only). The cached row is dropped via the
|
||||
* feed's model_remove event.
|
||||
*/
|
||||
async cancelDownload(repoWithTag: string): Promise<boolean> {
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
const res = await ModelsService.cancelDownload(repoWithTag);
|
||||
const ok = res.success === true;
|
||||
|
||||
if (ok) {
|
||||
this.downloadProgress.delete(repoWithTag);
|
||||
this.failedDownloads.delete(repoWithTag);
|
||||
}
|
||||
|
||||
return ok;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel: ${error instanceof Error ? error.message : 'unknown error'}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
|
||||
* LOADING model on unload; the feed reports the settled status, so no
|
||||
* waiter is registered here.
|
||||
*/
|
||||
async cancelLoad(modelId: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) return;
|
||||
|
||||
this.subscribe();
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private host: ModelStatusHost) {}
|
||||
|
||||
/**
|
||||
* Trigger a model download from HuggingFace via POST /models
|
||||
* (ggml-org/llama.cpp#23976). The download runs in the background on the
|
||||
* server; the model appears in the list once the feed reports models_reload.
|
||||
*/
|
||||
async downloadModel(repoWithTag: string, displayName?: string): Promise<void> {
|
||||
if (!serverStore.isRouterMode) {
|
||||
toast.error('Model downloads are only available in router mode');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// the feed must be live so the resulting models_reload event refreshes the list
|
||||
this.subscribe();
|
||||
|
||||
const label = displayName ?? repoWithTag;
|
||||
|
||||
try {
|
||||
const res = await ModelsService.downloadModel(repoWithTag);
|
||||
|
||||
if (res.success) {
|
||||
toast.success(`Download started: ${label}`);
|
||||
} else {
|
||||
throw new Error(res.error?.message ?? 'Server rejected the download request');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Download failed: ${label}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoaded(modelId: string): Promise<void> {
|
||||
if (this.host.isModelLoaded(modelId)) return;
|
||||
|
||||
await this.load(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current download progress (bytes) for a `<repo>:<tag>` identifier, or null
|
||||
* when no download is being reported by the /models/sse feed.
|
||||
*/
|
||||
getDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
|
||||
return this.downloadProgress.get(repoWithTag) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current load progress for a model, or null when not loading.
|
||||
*/
|
||||
@@ -56,6 +177,35 @@ export class ModelStatusManager {
|
||||
return this.loadProgress.get(modelId) ?? null;
|
||||
}
|
||||
|
||||
/** Whether the most recent download attempt for the given entry failed. */
|
||||
hasFailedDownload(repoWithTag: string): boolean {
|
||||
return this.failedDownloads.has(repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the feed reports an active download for the given `<repo>:<tag>`.
|
||||
* Cleared on download_finished / download_failed.
|
||||
*/
|
||||
isDownloadInProgress(repoWithTag: string): boolean {
|
||||
return this.downloadProgress.has(repoWithTag);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given draft sidecar file (repo-relative path) has been pulled
|
||||
* as the `--model-draft` of some registered model.
|
||||
*/
|
||||
isDraftDownloaded(repoId: string, filePath: string): boolean {
|
||||
return this.downloadedDrafts.has(`${repoId}/${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the given `<repo>:<tag>` is already a fully downloaded model
|
||||
* registered with the server (i.e. it shows up in the /v1/models list).
|
||||
*/
|
||||
isModelDownloaded(repoWithTag: string): boolean {
|
||||
return this.host.routerModels.some((m) => m.id === repoWithTag);
|
||||
}
|
||||
|
||||
isOperationInProgress(modelId: string): boolean {
|
||||
return this.loadingStates.get(modelId) ?? false;
|
||||
}
|
||||
@@ -141,6 +291,48 @@ export class ModelStatusManager {
|
||||
this.statusAbort?.abort();
|
||||
this.statusAbort = null;
|
||||
this.loadProgress.clear();
|
||||
this.downloadProgress.clear();
|
||||
this.failedDownloads.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the stored progress for the model and toast the outcome.
|
||||
* Marks failed entries so the UI can offer a delete-and-retry path.
|
||||
*/
|
||||
private applyDownloadFinished(event: ApiModelsSseEvent): void {
|
||||
this.downloadProgress.delete(event.model);
|
||||
|
||||
const ok = event.event === ServerModelsSseEventType.DOWNLOAD_FINISHED;
|
||||
|
||||
if (ok) {
|
||||
this.failedDownloads.delete(event.model);
|
||||
toast.success(`Download finished: ${this.host.toDisplayName(event.model)}`);
|
||||
} else {
|
||||
this.failedDownloads.add(event.model);
|
||||
toast.error(`Download failed: ${this.host.toDisplayName(event.model)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket the per-file byte counts from a `download_progress` envelope.
|
||||
* Total = sum of `total` across files (plan size), downloaded sum of `done`.
|
||||
*/
|
||||
private applyDownloadProgress(event: ApiModelsSseEvent): void {
|
||||
const data = event.data;
|
||||
|
||||
if (!data || !('progress' in data)) return;
|
||||
|
||||
const progress = (data as ApiModelsSseDownloadProgressData).progress;
|
||||
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
|
||||
for (const file of Object.values(progress)) {
|
||||
downloaded += file?.done ?? 0;
|
||||
total += file?.total ?? 0;
|
||||
}
|
||||
|
||||
this.downloadProgress.set(event.model, { downloadedBytes: downloaded, totalBytes: total });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +343,7 @@ export class ModelStatusManager {
|
||||
const model = event.model;
|
||||
const data = event.data;
|
||||
|
||||
if (!model || !data?.status) return;
|
||||
if (!model || !data || !('status' in data) || !data.status) return;
|
||||
|
||||
const status = data.status;
|
||||
|
||||
@@ -202,6 +394,13 @@ export class ModelStatusManager {
|
||||
|
||||
break;
|
||||
case ServerModelsSseEventType.DOWNLOAD_PROGRESS:
|
||||
this.applyDownloadProgress(event);
|
||||
|
||||
break;
|
||||
case ServerModelsSseEventType.DOWNLOAD_FINISHED:
|
||||
case ServerModelsSseEventType.DOWNLOAD_FAILED:
|
||||
this.applyDownloadFinished(event);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export type {
|
||||
ModelCapabilities,
|
||||
ModelModalities,
|
||||
ModelOption,
|
||||
ModelDownloadProgress,
|
||||
ModelLoadProgress,
|
||||
ModalityCapabilities
|
||||
} from './models';
|
||||
|
||||
Vendored
+9
@@ -36,6 +36,15 @@ export interface ModelLoadProgress {
|
||||
current: ApiModelLoadStage;
|
||||
value: number;
|
||||
}
|
||||
/**
|
||||
* Per-byte download progress for one in-flight model download, driven by the
|
||||
* /models/sse feed. Lives only while a download runs.
|
||||
*/
|
||||
export interface ModelDownloadProgress {
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
|
||||
export interface ParsedModelId {
|
||||
raw: string;
|
||||
|
||||
Reference in New Issue
Block a user