fix: verify and highlight resident Ollama models
This commit is contained in:
@@ -47,6 +47,12 @@ 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.
|
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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Hermes native model selector
|
## Hermes native model selector
|
||||||
|
|
||||||
During plugin load after install/update, the plugin registers the active Ollama endpoint(s) in Hermes' native `providers:` configuration as **Ollama Models (Local)** and, when configured, **Ollama Models (Remote)**. Hermes discovers the installed IDs through each endpoint's OpenAI-compatible `/v1/models` route, so models such as `gemma4:latest` become selectable with their exact Ollama tags.
|
During plugin load after install/update, the plugin registers the active Ollama endpoint(s) in Hermes' native `providers:` configuration as **Ollama Models (Local)** and, when configured, **Ollama Models (Remote)**. Hermes discovers the installed IDs through each endpoint's OpenAI-compatible `/v1/models` route, so models such as `gemma4:latest` become selectable with their exact Ollama tags.
|
||||||
|
|||||||
Vendored
+32
-9
@@ -208,9 +208,9 @@
|
|||||||
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; });
|
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; });
|
||||||
return h("section", { className: "ollama-model-pool" },
|
return h("section", { className: "ollama-model-pool" },
|
||||||
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
|
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
|
||||||
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { return h("label", { className: "ollama-pool-item", key: item.name }, h("input", { type: "checkbox", checked: props.poolSelection.indexOf(item.name) >= 0, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, item.loaded ? "Loaded permanently" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"))); })),
|
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { var loaded = !!item.loaded, selected = props.poolSelection.indexOf(item.name) >= 0; return h("label", { className: "ollama-pool-item" + (loaded ? " loaded" : "") + (selected ? " selected" : ""), key: item.name, title: loaded ? "Loaded and resident in Ollama" : "Installed but not resident" }, h("input", { type: "checkbox", checked: selected, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, loaded ? "Loaded and resident · keep-alive active" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"))); })),
|
||||||
h("div", { className: "ollama-pool-actions" }, h(Button, { disabled: !props.poolSelection.length || !!props.busy, onClick: props.onLoad }, props.busy === "/models/load" ? "Loading…" : "Load selected permanently"), h(Button, { className: "secondary", disabled: !props.poolSelection.length || !!props.busy, onClick: props.onUnload }, props.busy === "/models/unload" ? "Unloading…" : "Unload selected")),
|
h("div", { className: "ollama-pool-actions" }, h(Button, { disabled: !props.poolSelection.length || !!props.busy, onClick: props.onLoad }, props.busy === "/models/load" ? "Loading " + props.poolSelection.length + " model" + (props.poolSelection.length === 1 ? "" : "s") + "…" : "Load selected permanently"), h(Button, { className: "secondary", disabled: !props.poolSelection.length || !!props.busy, onClick: props.onUnload }, props.busy === "/models/unload" ? "Unloading…" : "Unload selected")),
|
||||||
h("div", { className: "ollama-chat-model-selection" }, h("strong", null, "Models for this answer"), h("small", null, "Select two or more loaded models for parallel perspectives."), loaded.length ? loaded.map(function (item) { return h("label", { key: item.name }, h("input", { type: "checkbox", checked: props.selectedModels.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); }) : h("span", null, "Load one or more models above first."))
|
h("div", { className: "ollama-chat-model-selection" }, h("strong", null, "Models for this answer"), h("small", null, "Select one or more loaded models for parallel perspectives."), loaded.length ? loaded.map(function (item) { return h("label", { className: "loaded", key: item.name }, h("input", { type: "checkbox", checked: props.selectedModels.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); }) : h("span", null, "Load one or more models above first."))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +221,8 @@
|
|||||||
var modelState = React.useState(savedChatState.model || (loadedModels[0] ? loadedModels[0].name : (models[0] ? models[0].name : ""))), model = modelState[0], setModel = modelState[1];
|
var modelState = React.useState(savedChatState.model || (loadedModels[0] ? loadedModels[0].name : (models[0] ? models[0].name : ""))), model = modelState[0], setModel = modelState[1];
|
||||||
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
|
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
|
||||||
var poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
|
var poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
|
||||||
|
var poolLoadedSignature = React.useRef("");
|
||||||
|
var chatLoadedSignature = React.useRef("");
|
||||||
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
||||||
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
||||||
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
||||||
@@ -266,10 +268,21 @@
|
|||||||
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
|
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
|
||||||
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
|
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
|
||||||
React.useEffect(function () {
|
React.useEffect(function () {
|
||||||
var validLoaded = selectedModels.filter(function (name) { return loadedModels.some(function (item) { return item.name === name; }); });
|
var loadedNames = loadedModels.map(function (item) { return item.name; });
|
||||||
if (validLoaded.length !== selectedModels.length || !validLoaded.length) setSelectedModels(validLoaded.length ? validLoaded : (loadedModels[0] ? [loadedModels[0].name] : []));
|
var loadedSignature = loadedNames.slice().sort().join("\u001f");
|
||||||
var validPool = poolSelection.filter(function (name) { return models.some(function (item) { return item.name === name; }); });
|
var loadedChanged = loadedSignature !== poolLoadedSignature.current;
|
||||||
if (validPool.length !== poolSelection.length) setPoolSelection(validPool);
|
if (loadedChanged) {
|
||||||
|
poolLoadedSignature.current = loadedSignature;
|
||||||
|
setPoolSelection(function (old) { return Array.from(new Set(old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }).concat(loadedNames))); });
|
||||||
|
} else {
|
||||||
|
setPoolSelection(function (old) { return old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }); });
|
||||||
|
}
|
||||||
|
if (loadedSignature !== chatLoadedSignature.current) {
|
||||||
|
chatLoadedSignature.current = loadedSignature;
|
||||||
|
setSelectedModels(function (old) { var valid = old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); return Array.from(new Set(valid.concat(loadedNames))); });
|
||||||
|
} else {
|
||||||
|
setSelectedModels(function (old) { return old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); });
|
||||||
|
}
|
||||||
}, [models]);
|
}, [models]);
|
||||||
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
|
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
|
||||||
React.useEffect(function () {
|
React.useEffect(function () {
|
||||||
@@ -300,8 +313,18 @@
|
|||||||
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
|
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
|
||||||
function manageModels(endpoint, label) {
|
function manageModels(endpoint, label) {
|
||||||
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
|
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
|
||||||
setBusy(endpoint); setNotice(null);
|
var requested = poolSelection.slice();
|
||||||
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: poolSelection }) }).then(function (result) { setNotice({ ok: label + ": " + poolSelection.join(", ") }); pollRuntime(); if (props.refresh) props.refresh(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
setBusy(endpoint); setNotice({ ok: label + " in progress for " + requested.length + " model" + (requested.length === 1 ? "" : "s") + "…" });
|
||||||
|
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: requested }) }).then(function (result) {
|
||||||
|
if (endpoint === "/models/load") {
|
||||||
|
var resident = Array.isArray(result.resident) ? result.resident : ((result.runtime && result.runtime.model_memory) || []).map(function (item) { return item.name; });
|
||||||
|
var missing = Array.isArray(result.not_resident) ? result.not_resident : requested.filter(function (name) { return resident.indexOf(name) < 0; });
|
||||||
|
setNotice(missing.length ? { error: "Ollama resident check: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". Ollama may have evicted models because of its scheduler or available memory." } : { ok: "Loaded and resident: " + resident.join(", ") });
|
||||||
|
} else {
|
||||||
|
setNotice({ ok: label + ": " + requested.join(", ") });
|
||||||
|
}
|
||||||
|
pollRuntime(); if (props.refresh) props.refresh();
|
||||||
|
}).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||||||
}
|
}
|
||||||
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
|
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
|
||||||
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
|
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
|||||||
"label": "Ollama Models",
|
"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.",
|
"description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.",
|
||||||
"icon": "Cpu",
|
"icon": "Cpu",
|
||||||
"version": "1.5.11",
|
"version": "1.5.12",
|
||||||
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
"tab": {"path": "/ollama-manager", "position": "after:models"},
|
||||||
"entry": "dist/index.js",
|
"entry": "dist/index.js",
|
||||||
"css": "dist/style.css",
|
"css": "dist/style.css",
|
||||||
|
|||||||
+20
-1
@@ -1398,7 +1398,26 @@ def models_load(body: ModelsRequest) -> dict[str, Any]:
|
|||||||
results.append({"name": name, "ok": True, "result": _load_model(name)})
|
results.append({"name": name, "ok": True, "result": _load_model(name)})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
results.append({"name": name, "ok": False, "error": str(exc)})
|
results.append({"name": name, "ok": False, "error": str(exc)})
|
||||||
return {"ok": all(item["ok"] for item in results), "results": results, "runtime": _runtime_snapshot(), "keep_alive": "permanent"}
|
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]
|
||||||
|
runtime = _runtime_snapshot()
|
||||||
|
return {
|
||||||
|
"ok": bool(results) and not not_resident and all(item["ok"] for item in results),
|
||||||
|
"requested": names,
|
||||||
|
"resident": sorted(resident_names),
|
||||||
|
"not_resident": not_resident,
|
||||||
|
"results": results,
|
||||||
|
"runtime": runtime,
|
||||||
|
"keep_alive": "permanent",
|
||||||
|
"message": (
|
||||||
|
"All selected models are resident."
|
||||||
|
if not not_resident
|
||||||
|
else "Ollama did not keep every selected model resident; see not_resident."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/models/unload")
|
@router.post("/models/unload")
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
name: ollama-manager
|
name: ollama-manager
|
||||||
version: 1.5.11
|
version: 1.5.12
|
||||||
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry.
|
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
|
auto_install_dependencies: true
|
||||||
python_dependencies:
|
python_dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user