feat: show live Ollama model loading progress

This commit is contained in:
Hermes Agent
2026-08-25 23:05:46 +10:00
parent 81245ecc5a
commit d00e1adebd
6 changed files with 61 additions and 10 deletions
+4 -1
View File
@@ -47,8 +47,11 @@ The Available downloads controls support:
Popularity and date ordering use upstream metadata only; the plugin does not invent popularity, dates, RAM requirements, or token metrics.
## Multi-model loading and resident state
## Live model-loading telemetry
While Ollama is starting a runner, the highlighted runtime chart displays an animated **Loading into Ollama memory** state with the selected model names, current stage, and elapsed time. The runtime panel separately reports Ollama resident model-weight bytes and the estimated target weight bytes. This is separate from host `MemAvailable`: CPU-mapped model files may appear as Linux file cache rather than ordinary process RAM usage.
## Multi-model loading and resident state
The model pool now verifies every load request against Ollama `/api/ps` before reporting success. The UI shows an in-progress loading message, then reports which models are actually resident and which Ollama evicted. Resident models are highlighted in the pool with a green loaded state. After a browser refresh, resident models repopulate the pool selection and the **Models for this answer** selector, allowing multiple loaded models to be selected for parallel chat.
Ollama still controls the physical resident-model limit. If it cannot keep all requested models at once because of its scheduler, GPU policy, context allocation, or available memory, the plugin reports the non-resident names instead of claiming they were permanently loaded. Increasing that limit requires changing the Ollama service configuration; the plugin does not silently alter or restart the Ollama service.
+7 -5
View File
@@ -156,16 +156,18 @@
function RuntimePanel(props) {
var runtime = props.runtime || {}, total = Number(runtime.memory_total_bytes || 0), used = Number(runtime.memory_used_bytes || 0), pct = total ? Math.min(100, used * 100 / total) : 0;
var gpu = runtime.gpu || {}, models = runtime.model_memory || [];
var gpu = runtime.gpu || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [];
var ollamaBytes = Number(runtime.ollama_model_bytes || 0), ollamaTargetBytes = Number(runtime.ollama_target_model_bytes || ollamaBytes), ollamaPct = total ? Math.min(100, ollamaTargetBytes * 100 / total) : 0;
return h("section", { className: "ollama-runtime-panel" },
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory"), h("p", null, "Updates every second while this panel is open.")), h(Badge, { tone: gpu.detected ? "live" : "muted" }, gpu.detected ? "GPU detected" : "CPU-only / no supported GPU telemetry")),
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory"), h("p", null, "Updates every second while this panel is open.")), h(Badge, { tone: loading.length ? "live" : (gpu.detected ? "live" : "muted") }, loading.length ? "MODEL LOADING" : (gpu.detected ? "GPU detected" : "CPU-only / no supported GPU telemetry"))),
h("div", { className: "ollama-runtime-grid" },
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
h("div", { className: "ollama-runtime-stat" }, h("small", null, "Swap used"), h("strong", null, fmtBytes(runtime.swap_used_bytes || 0), " / ", fmtBytes(runtime.swap_total_bytes || 0)), h("small", null, "Host-wide live statistic")),
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected."))
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected.")),
h("div", { className: "ollama-runtime-stat ollama-weight-stat" }, h("small", null, "Ollama model weights"), h("strong", null, fmtBytes(ollamaBytes), " resident"), h("div", { className: "ollama-meter" }, h("span", { style: { width: ollamaPct + "%" } })), h("small", null, loading.length ? "Loading target: " + fmtBytes(ollamaTargetBytes) : "Mapped weight bytes; Linux may report them as file cache"))
),
h("div", { className: "ollama-memory-chart" }, (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
h("div", { className: "ollama-memory-chart" + (loading.length ? " loading" : ""), role: loading.length ? "status" : undefined, "aria-live": loading.length ? "polite" : undefined }, loading.length ? h("div", { className: "ollama-loading-progress" }, h("strong", null, "Loading into Ollama memory"), h("span", null, loading.map(function (item) { return item.name; }).join(", ")), h("small", null, loading.map(function (item) { return item.stage + " · " + fmtElapsed(item.elapsed); }).join(" · ")), h("div", { className: "ollama-loading-track" }, h("span", null))) : (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : loading.length ? h("p", null, "Ollama is loading the selected model. Resident memory will appear here when the runner finishes starting.") : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.5.12",
"version": "1.5.13",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+47 -1
View File
@@ -71,6 +71,8 @@ CHAT_KEEP_ALIVE = -1
_jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock()
_model_loads: dict[str, dict[str, Any]] = {}
_model_loads_lock = threading.Lock()
_chat_requests: dict[str, dict[str, Any]] = {}
_chat_requests_lock = threading.Lock()
_catalog_lock = threading.Lock()
@@ -445,6 +447,21 @@ def _local_ps() -> list[dict[str, Any]]:
return []
def _model_load_update(name: str, **values: Any) -> None:
with _model_loads_lock:
current = _model_loads.setdefault(name, {"name": name, "state": "queued", "stage": "Queued for Ollama", "started_at": time.time()})
current.update(values, updated_at=time.time())
def _model_load_snapshot() -> list[dict[str, Any]]:
now = time.time()
with _model_loads_lock:
rows = [dict(value) for value in _model_loads.values()]
for row in rows:
row["elapsed"] = round(max(0.0, now - float(row.get("started_at") or now)), 1)
return sorted(rows, key=lambda row: row.get("started_at") or 0)
def _read_meminfo() -> dict[str, int]:
values: dict[str, int] = {}
try:
@@ -507,6 +524,8 @@ def _runtime_snapshot() -> dict[str, Any]:
swap_free = mem.get("SwapFree", 0)
ps_rows = _local_ps()
tag_rows = {str(row.get("name") or row.get("model")): row for row in _local_tags()}
load_rows = _model_load_snapshot()
active_loads = [row for row in load_rows if row.get("active")]
model_memory = []
for row in ps_rows:
name = str(row.get("name") or row.get("model") or "")
@@ -528,6 +547,18 @@ def _runtime_snapshot() -> dict[str, Any]:
"quantization": capability_view["quantization"],
"permanent": True,
})
gpu = _gpu_snapshot()
ollama_model_bytes = sum(int(row.get("total_bytes") or 0) for row in model_memory)
ollama_model_vram_bytes = sum(int(row.get("gpu_bytes") or 0) for row in model_memory)
model_loading = []
for row in active_loads:
tag = tag_rows.get(str(row.get("name")), {})
model_loading.append({
**row,
"estimated_bytes": int(tag.get("size") or 0),
"estimated_vram_bytes": 0,
})
ollama_target_model_bytes = ollama_model_bytes + sum(int(row.get("estimated_bytes") or 0) for row in model_loading)
return {
"captured_at": time.time(),
"memory_total_bytes": total,
@@ -536,7 +567,12 @@ def _runtime_snapshot() -> dict[str, Any]:
"swap_total_bytes": swap_total,
"swap_used_bytes": max(0, swap_total - swap_free),
"model_memory": model_memory,
"gpu": _gpu_snapshot(),
"model_loading": model_loading,
"model_loads": load_rows[-12:],
"ollama_model_bytes": ollama_model_bytes,
"ollama_model_vram_bytes": ollama_model_vram_bytes,
"ollama_target_model_bytes": ollama_target_model_bytes,
"gpu": gpu,
}
@@ -1394,15 +1430,25 @@ def models_load(body: ModelsRequest) -> dict[str, Any]:
raise HTTPException(400, "Select at least one model to load")
results = []
for name in names:
_model_load_update(name, active=True, state="queued", stage="Waiting for Ollama", started_at=time.time(), error="")
for name in names:
_model_load_update(name, state="loading", stage="Loading model into Ollama memory")
try:
results.append({"name": name, "ok": True, "result": _load_model(name)})
_model_load_update(name, state="checking", stage="Checking Ollama resident state")
except Exception as exc:
results.append({"name": name, "ok": False, "error": str(exc)})
_model_load_update(name, active=False, state="failed", stage="Ollama load failed", finished_at=time.time(), error=str(exc))
resident_rows = _local_ps()
resident_names = {str(row.get("name") or row.get("model")) for row in resident_rows}
for item in results:
item["resident"] = item["name"] in resident_names
not_resident = [name for name in names if name not in resident_names]
for item in results:
if item["name"] in resident_names:
_model_load_update(item["name"], active=False, state="resident", stage="Model is resident in Ollama", finished_at=time.time())
elif item["ok"]:
_model_load_update(item["name"], active=False, state="evicted", stage="Ollama did not retain this model", finished_at=time.time())
runtime = _runtime_snapshot()
return {
"ok": bool(results) and not not_resident and all(item["ok"] for item in results),
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.5.12
version: 1.5.13
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
auto_install_dependencies: true
python_dependencies: