Commit 1d8199278 for llama.cpp
commit 1d81992784448f66891d1bcde17852e920198f10
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Wed Nov 11 23:26:39 2026 +0100
ui: keep downloads in Discover during search and fix their progress with a draft (#31467)
* ui : keep downloads in Discover sidebar during search, sort base-org repos first
- Show the sticky Downloading section regardless of an active search query
- Order search results so repos published by their base model's org lead
Assisted-by: Claude (Opus 5.5 Medium)
* server : throttle download progress per file
Files of a download run in parallel threads, but progress updates were
throttled with one shared timer. A small draft sidecar reports far more
often than the main weights, so it took nearly every slot and the main
file's updates, including the first one carrying its total size, were
dropped until the draft finished. The UI then showed the draft's progress
first and restarted from 0% for the model.
Throttle each file on its own and always pass a file's first update.
Assisted-by: Claude (Opus 5.5 Medium)
* ui: list a downloading repo once and match base orgs case-insensitively
A search hit that is downloading already shows under Downloading, so the
results leave it out. Base model orgs compare without case, as the hub
treats them.
Assisted-by: Claude (Opus 5.5 Medium)
* server : tell clients a download stopped on request
When a client paused or cancelled a download, every other client following
/models/sse got a bare download_failed and could not tell it from an error,
so the web UI showed "Download failed" for a pause made in the Llama app.
A stop asked for through unload or delete now fails with {"stopped": true}.
Deleting a paused download, which has no instance left, now sends
model_remove as deleting a running one does, so other clients drop its row.
Assisted-by: Claude (Opus 5.5 Medium)
* ui : follow downloads other clients start, pause or cancel
A download another client started (e.g. the Llama app) already showed up
from the feed; it now also picks up the sidecars the list reports for it.
A stop another client asked for reads as a pause instead of a failure, and
a removal clears the download row even when the list does not carry it yet.
Assisted-by: Claude (Opus 5.5 Medium)
diff --git a/tools/server/README.md b/tools/server/README.md
index defc09c6b..3ca5d238e 100644
--- a/tools/server/README.md
+++ b/tools/server/README.md
@@ -2506,7 +2506,7 @@ To pause a download, send an event to `/models/unload`: it keeps its partial fil
Download procedure:
- Send POST request to `/models`
- Subscribe to `/models/sse` for updates
-- On downloading completed, you will receive either `download_finished` or `download_failed` event
+- On downloading completed, you will receive either `download_finished` or `download_failed` event. A download that stopped because a client paused or cancelled it fails with `{"stopped": true}` as its data
- Call GET `/models` to trigger model list update. If the download success, you should see the new model in the list
Payload:
diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp
index 93425fec0..5d052d62b 100644
--- a/tools/server/server-models.cpp
+++ b/tools/server/server-models.cpp
@@ -714,6 +714,8 @@ void server_models::unfinished_downloads_remove(const std::string & name) {
}
bool ok = common_download_remove(name);
SRV_INF("removing unfinished download name=%s (%s)\n", name.c_str(), ok ? "succeeded" : "nothing cached");
+ // other clients still list it as a paused download
+ notify_sse("model_remove", name, {});
}
bool server_models::remove_sidecar(const std::string & name, const std::string & kind) {
@@ -1361,6 +1363,7 @@ void server_models::unload(const std::string & name) {
if (it != mapping.end()) {
if (it->second.meta.status == SERVER_MODEL_STATUS_DOWNLOADING) {
SRV_INF("cancelling download for model name=%s\n", name.c_str());
+ it->second.download_stop_requested = true;
it->second.request_exit();
// for convenience, we wait the status change here
wait(lk, name, [](const server_model_meta & new_meta) {
@@ -1388,6 +1391,7 @@ void server_models::unload_all() {
for (auto & [name, inst] : mapping) {
if (inst.meta.status == SERVER_MODEL_STATUS_DOWNLOADING) {
SRV_INF("cancelling download for model name=%s\n", name.c_str());
+ inst.download_stop_requested = true;
inst.request_exit();
} else if (inst.meta.is_running()) {
SRV_INF("stopping model instance name=%s\n", name.c_str());
@@ -1483,6 +1487,7 @@ void server_models::update_status(const std::string & name, const update_status_
void server_models::update_download_progress(const std::string & name, const common_download_progress & progress, bool done, bool ok) {
json curr;
+ bool stopped = false;
{
std::lock_guard<std::mutex> lk(mutex);
auto it = mapping.find(name);
@@ -1491,6 +1496,7 @@ void server_models::update_download_progress(const std::string & name, const com
// mark the instance to be erased on next load_models() call
it->second.meta.status = SERVER_MODEL_STATUS_DOWNLOADED;
need_reload = true;
+ stopped = it->second.download_stop_requested;
// a download that failed keeps its tag: its partial files are still
// there and re-posting the tag resumes them
@@ -1512,7 +1518,13 @@ void server_models::update_download_progress(const std::string & name, const com
}
if (done) {
cv.notify_all(); // notify in case unload() is waiting for download to be cancelled
- notify_sse(ok ? "download_finished" : "download_failed", name, {});
+ // a stop a client asked for (pause or cancel) is no error: say so, so that every
+ // client, not only the one that asked, can tell it from a failed download
+ if (ok) {
+ notify_sse("download_finished", name, {});
+ } else {
+ notify_sse("download_failed", name, stopped ? json{{"stopped", true}} : json{});
+ }
} else {
notify_sse("download_progress", name, curr);
}
@@ -1535,6 +1547,7 @@ bool server_models::remove(const std::string & name) {
if (it->second.meta.status == SERVER_MODEL_STATUS_DOWNLOADING) {
// cancel in-flight download
SRV_INF("cancelling download for model name=%s\n", name.c_str());
+ it->second.download_stop_requested = true;
it->second.request_exit();
} else if (it->second.meta.is_running()) {
// stop running instance
diff --git a/tools/server/server-models.h b/tools/server/server-models.h
index 0db948773..855e147b3 100644
--- a/tools/server/server-models.h
+++ b/tools/server/server-models.h
@@ -170,6 +170,7 @@ private:
std::shared_ptr<server_subproc> subproc; // shared with the monitor thread
server_model_meta meta;
int req_count = 0; // number of active proxy requests
+ bool download_stop_requested = false; // a client asked the download to stop (pause or cancel)
// ask the child to exit (it handles the command on its stdin, see server_role::setup)
void request_exit() const;
diff --git a/tools/server/server.cpp b/tools/server/server.cpp
index 4e838cb10..a0640f12b 100644
--- a/tools/server/server.cpp
+++ b/tools/server/server.cpp
@@ -16,8 +16,10 @@
#include <atomic>
#include <clocale>
#include <exception>
+#include <mutex>
#include <signal.h>
#include <thread> // for std::thread::hardware_concurrency
+#include <unordered_map>
#if defined(_WIN32)
#include <windows.h>
@@ -354,7 +356,10 @@ static bool server_on_listening(server_http_context & ctx_http, const common_par
struct server_download_state : public common_download_callback {
server_role * role;
std::function<bool()> should_stop;
- std::atomic<int64_t> last_progress_time{0}; // multiple files downloading in different threads
+ // files download in parallel threads: throttle each file on its own, a shared
+ // timer lets a small, fast file (e.g. a draft) starve the updates of the others
+ std::mutex progress_mutex;
+ std::unordered_map<std::string, int64_t> last_progress_time;
bool is_ok = false;
server_download_state(server_role * r) : role(r) {}
@@ -384,11 +389,17 @@ struct server_download_state : public common_download_callback {
}
void on_update(const common_download_progress & p) override {
int64_t now = ggml_time_ms();
- // throttle progress updates to avoid flooding logs
- if (now - last_progress_time.load(std::memory_order_relaxed) >= 100) {
- on_progress(p);
- last_progress_time.store(now, std::memory_order_relaxed);
+ // throttle progress updates to avoid flooding logs; the first update of a file
+ // always passes, it carries the file's total size
+ {
+ std::lock_guard<std::mutex> lk(progress_mutex);
+ auto it = last_progress_time.find(p.url);
+ if (it != last_progress_time.end() && now - it->second < 100) {
+ return;
+ }
+ last_progress_time[p.url] = now;
}
+ on_progress(p);
}
void on_done(const common_download_progress & p, bool) override {
on_progress(p);
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
index 292938d59..4bc1c38eb 100644
--- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
+++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscover.svelte
@@ -18,12 +18,20 @@
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
- // running and paused downloads both lead the list; a search owns the pane, so the
- // section steps aside while a query is active
+ // running and paused downloads both lead the list, with or without a search query
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
let isSearching = $derived(searchQuery.trim().length > 0);
+ // a search hit that is downloading already sits under Downloading, so it is listed once
+ let listedModels = $derived.by(() => {
+ if (!isSearching) return modelsDiscoverStore.models;
+
+ const downloading = new Set(downloadEntries.map((entry) => repoOf(entry.repoWithTag)));
+
+ return modelsDiscoverStore.models.filter((model) => !downloading.has(model.id));
+ });
+
/** Hub record for a repo the list does not carry, so its row still renders. */
function hubRecord(id: string): HfModelInfo {
return {
@@ -119,7 +127,7 @@
<ModelsDiscoverList
activeId={selectedId}
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
- models={modelsDiscoverStore.models}
+ models={listedModels}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
@@ -130,7 +138,7 @@
>
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
- {#if downloadEntries.length > 0 && !isSearching}
+ {#if downloadEntries.length > 0}
<ModelsSection
count={downloadEntries.length}
label="Downloading"
diff --git a/tools/ui/src/lib/stores/models-discover/index.svelte.ts b/tools/ui/src/lib/stores/models-discover/index.svelte.ts
index c4658485a..9742ab649 100644
--- a/tools/ui/src/lib/stores/models-discover/index.svelte.ts
+++ b/tools/ui/src/lib/stores/models-discover/index.svelte.ts
@@ -14,7 +14,7 @@ import type {
HfModelInfo,
HfModelSibling
} from '$lib/types/huggingface';
-import { isAuxSidecar } from '$lib/utils';
+import { isAuxSidecar, orgOf } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
/** Min/max GGUF file size (bytes) across the quants of one repo. */
@@ -141,7 +141,7 @@ class ModelsDiscoverStore {
if (requestId !== this.searchRequestId) return;
- this.models = results;
+ this.models = this.baseOrgFirst(results);
this.error = null;
} catch (err) {
if (requestId !== this.searchRequestId) return;
@@ -181,6 +181,23 @@ class ModelsDiscoverStore {
return request;
}
+ /**
+ * Repos published by their base model's own org first (e.g. `Qwen/Qwen3-8B-GGUF`
+ * ahead of third-party quants of `Qwen/Qwen3-8B`); the hub's order holds otherwise.
+ */
+ private baseOrgFirst(models: HfModelInfo[]): HfModelInfo[] {
+ // org names are case-insensitive on the hub, and base_model tags are not always canonical
+ const isBaseOrg = (model: HfModelInfo) => {
+ const org = orgOf(model.id).toLowerCase();
+
+ return HuggingFaceService.getBaseModels(model).some(
+ (base) => orgOf(base).toLowerCase() === org
+ );
+ };
+
+ return [...models.filter(isBaseOrg), ...models.filter((model) => !isBaseOrg(model))];
+ }
+
/**
* Sizes the catalog lists under this repo. Never empty: an entry with no parsable
* size contributes the build's own.
diff --git a/tools/ui/src/lib/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts
index 3cc0954fb..a2691a1a8 100644
--- a/tools/ui/src/lib/stores/models/status.svelte.ts
+++ b/tools/ui/src/lib/stores/models/status.svelte.ts
@@ -26,6 +26,7 @@ import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
import type {
+ ApiModelsSseDownloadFailedData,
ApiModelsSseDownloadProgressData,
ModelDownloadEntry,
ModelDownloadProgress
@@ -656,6 +657,12 @@ export class ModelStatusManager {
if (event.event === ServerModelsSseEventType.DOWNLOAD_FAILED) {
request = this.stopRequests.get(event.model);
this.stopRequests.delete(event.model);
+
+ // a stop another client asked for (e.g. the Llama app) reads as a pause: a
+ // cancel follows with model_remove, which drops the row
+ if (!request && (event.data as ApiModelsSseDownloadFailedData | undefined)?.stopped) {
+ request = ModelDownloadStopRequest.PAUSE;
+ }
}
const progress = this.downloadProgress.get(event.model) ?? null;
@@ -718,6 +725,20 @@ export class ModelStatusManager {
total += file?.total ?? 0;
}
+ // a download another client started (e.g. the Llama app) pulls the sidecars the
+ // list reports for it; the first event of one the list does not know refreshes it
+ if (!this.downloadSidecars.has(event.model)) {
+ const listed = this.host.routerModels.find(
+ (m) => downloadIdKey(m.id) === downloadIdKey(event.model)
+ );
+
+ if (listed) {
+ this.downloadSidecars.set(event.model, listed.sidecars ?? []);
+ } else if (!this.downloadProgress.has(event.model)) {
+ void this.host.fetchRouterModels();
+ }
+ }
+
this.downloadProgress.set(event.model, {
downloadedBytes: downloaded,
files: progress,
@@ -809,14 +830,17 @@ export class ModelStatusManager {
private removeRouterModel(modelId: string): void {
const idx = this.host.routerModels.findIndex((m) => m.id === modelId);
+ // download state goes even for a row the list does not carry yet, e.g. a
+ // download another client started and then cancelled
+ this.downloadProgress.delete(modelId);
+ this.failedDownloads.delete(modelId);
+ this.deletePausedDownload(modelId);
+ this.downloadSidecars.delete(modelId);
+ this.stopRequests.delete(modelId);
+
if (idx !== -1) {
this.host.routerModels = this.host.routerModels.filter((m) => m.id !== modelId);
this.loadProgress.delete(modelId);
- this.downloadProgress.delete(modelId);
- this.failedDownloads.delete(modelId);
- this.deletePausedDownload(modelId);
- this.downloadSidecars.delete(modelId);
- this.stopRequests.delete(modelId);
this.rejectStatus(modelId, new Error(`Model removed: ${this.host.toDisplayName(modelId)}`));
}
diff --git a/tools/ui/src/lib/types/api.d.ts b/tools/ui/src/lib/types/api.d.ts
index d3bfbb184..dbca7ca82 100644
--- a/tools/ui/src/lib/types/api.d.ts
+++ b/tools/ui/src/lib/types/api.d.ts
@@ -156,6 +156,11 @@ export interface ApiModelsSseDownloadProgressData {
progress: Record<string, { done: number; total: number }>;
}
+/** download_failed data: `stopped` when a client paused or cancelled the download. */
+export interface ApiModelsSseDownloadFailedData {
+ stopped?: boolean;
+}
+
/**
* Event kind multiplexed on the /models/sse feed.
* Only the status_* events carry a status payload, models_reload signals a
@@ -168,7 +173,7 @@ export interface ApiModelsSseDownloadProgressData {
export interface ApiModelsSseEvent {
model: string;
event: ServerModelsSseEventType;
- data?: ApiModelsSseData | ApiModelsSseDownloadProgressData;
+ data?: ApiModelsSseData | ApiModelsSseDownloadProgressData | ApiModelsSseDownloadFailedData;
}
/**
diff --git a/tools/ui/src/lib/types/index.ts b/tools/ui/src/lib/types/index.ts
index 1a3b18a22..5c8c5e8b8 100644
--- a/tools/ui/src/lib/types/index.ts
+++ b/tools/ui/src/lib/types/index.ts
@@ -15,6 +15,7 @@ export type {
ApiModelsSseProgress,
ApiModelsSseData,
ApiModelsSseDownloadProgressData,
+ ApiModelsSseDownloadFailedData,
ApiModelsSseEvent,
ApiModelsDownloadRequest,
ApiModelsDownloadResponse,