feat: show live Ollama model loading progress
This commit is contained in:
@@ -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.
|
||||
|
||||
Vendored
+7
-5
@@ -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."))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user