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:
Aleksander Grygier
2026-09-04 20:07:36 +02:00
parent fb3e6277d1
commit 6629357df4
4 changed files with 215 additions and 2 deletions
+4
View File
@@ -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'
}
+201 -2
View File
@@ -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;
}
}
+1
View File
@@ -109,6 +109,7 @@ export type {
ModelCapabilities,
ModelModalities,
ModelOption,
ModelDownloadProgress,
ModelLoadProgress,
ModalityCapabilities
} from './models';
+9
View File
@@ -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;