ui : resume model downloads from the pause point

A stopped download keeps its partial file instead of deleting it, so
re-posting the tag resumes from disk. The first progress record now
carries the resume offset, the paused snapshot survives into the
resumed entry, and a pause settles as soon as it is requested.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-10-02 22:52:28 +02:00
parent 6e8843938d
commit 9df4d1f4ed
3 changed files with 40 additions and 19 deletions
+7 -10
View File
@@ -346,10 +346,6 @@ static int common_download_file_single_online(const std::string & url,
const std::string path_temporary = path + ".downloadInProgress";
int delay = retry_delay_seconds;
if (opts.callback) {
opts.callback->on_start(p);
}
for (int i = 0; i < max_attempts; ++i) {
if (opts.callback && opts.callback->is_cancelled()) {
break;
@@ -373,6 +369,12 @@ static int common_download_file_single_online(const std::string & url,
p.downloaded = existing_size;
if (i == 0 && opts.callback) {
// report after the resume offset is known, so a resumed file does not
// start at 0 in the progress UI
opts.callback->on_start(p);
}
LOG_DBG("%s: downloading from %s to %s (etag:%s)...\n",
__func__, common_http_show_masked_url(parts).c_str(),
path_temporary.c_str(), etag.c_str());
@@ -393,12 +395,7 @@ static int common_download_file_single_online(const std::string & url,
if (opts.callback) {
opts.callback->on_done(p, success);
}
if (opts.callback && opts.callback->is_cancelled() &&
std::filesystem::exists(path_temporary)) {
if (remove(path_temporary.c_str()) != 0) {
LOG_ERR("%s: unable to delete temporary file: %s\n", __func__, path_temporary.c_str());
}
}
// a stopped download keeps path_temporary, so the next attempt resumes from it
if (!success) {
LOG_ERR("%s: download failed after %d attempts\n", __func__, max_attempts);
return -1; // max attempts reached
@@ -219,8 +219,8 @@
{@render tooltipTrigger(tooltipText, deleteChip)}
{:else if entry.isDownloading || entry.isPaused}
<!-- in-flight / paused chips: the chip body pauses / resumes on click, the X
inside the chip cancels (stops and discards the partial files). The X slot
is reserved, so the chip never reflows when the affordance fades in -->
inside the chip cancels the download. The X slot is reserved, so the chip
never reflows when the affordance fades in -->
<div
class="group relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-sm transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
{entry.isPaused
@@ -59,6 +59,11 @@ function downloadIdKey(repoWithTag: string): string {
return `${repo.toUpperCase()}:${tag.toUpperCase().replace(HF_UD_QUANT_PREFIX_REGEX, '')}`;
}
/** Zeroed progress, used until the first feed record reports real counts. */
function emptyDownloadProgress(): ModelDownloadProgress {
return { downloadedBytes: 0, files: {}, totalBytes: 0 };
}
export class ModelStatusManager {
/**
* Sidecar files pulled by registered models, as `<repo>/<file>` keys.
@@ -250,8 +255,11 @@ export class ModelStatusManager {
// the feed must be live so the resulting models_reload event refreshes the list
this.subscribe();
// resuming a paused download: drop the paused state, and let the server
// discard its stale DOWNLOADED entry (via the list fetch) before re-posting
// resuming a paused download: keep the last reported progress so the chip does
// not fall back to 0%, and let the server discard its stale DOWNLOADED entry
// (via the list fetch) before re-posting
const snapshot = this.pausedDownloads.get(repoWithTag) ?? null;
if (this.deletePausedDownload(repoWithTag) || this.stopRequests.delete(repoWithTag)) {
await this.host.fetchRouterModels();
}
@@ -264,7 +272,7 @@ export class ModelStatusManager {
}
// flip the chip to "downloading" right away; the feed refines it with real progress
this.downloadProgress.set(repoWithTag, { downloadedBytes: 0, files: {}, totalBytes: 0 });
this.downloadProgress.set(repoWithTag, snapshot ?? emptyDownloadProgress());
toast.success(`Download started: ${this.host.toDisplayName(repoWithTag)}`);
} catch (error) {
@@ -378,9 +386,10 @@ export class ModelStatusManager {
}
/**
* The server stops the download child but keeps the partial files, so
* re-posting the tag resumes where it stopped. The feed reports the stop
* as download_failed; the 'pause' stop request marks it as intentional.
* The server stops the download child but keeps the partial file, so re-posting
* the tag resumes where it stopped. The feed reports the stop as download_failed;
* the 'pause' stop request marks it as intentional, and the paused state is stored
* before the request: an unconfirmed pause would leave the chip spinning.
*/
async pauseDownload(repoWithTag: string): Promise<void> {
if (!serverStore.isRouterMode) {
@@ -393,10 +402,21 @@ export class ModelStatusManager {
this.stopRequests.set(repoWithTag, ModelDownloadStopRequest.PAUSE);
const snapshot = this.downloadProgress.get(repoWithTag) ?? null;
const wasInFlight = this.downloadProgress.delete(repoWithTag);
this.setPausedDownload(repoWithTag, snapshot);
try {
await ModelsService.unload(repoWithTag);
} catch {
this.stopRequests.delete(repoWithTag);
this.deletePausedDownload(repoWithTag);
if (wasInFlight) {
this.downloadProgress.set(repoWithTag, snapshot ?? emptyDownloadProgress());
}
toast.error(`Failed to pause: ${repoWithTag}`);
}
}
@@ -476,7 +496,11 @@ export class ModelStatusManager {
}
if (request === ModelDownloadStopRequest.PAUSE) {
this.setPausedDownload(event.model, progress);
// a pause already stored its snapshot when the user asked for it
if (!this.pausedDownloads.has(event.model)) {
this.setPausedDownload(event.model, progress);
}
this.failedDownloads.delete(event.model);
return;