feat: add RAM bypass and load safety guard

This commit is contained in:
Hermes Agent
2026-08-26 01:27:11 +10:00
parent b961b3986e
commit 6348b23881
6 changed files with 110 additions and 18 deletions
+6 -1
View File
@@ -47,7 +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.
## Available-download age filter ## Memory-filter bypass and permanent-load safety
Available downloads retain the RAM-fit filter by default. The **Show models above estimated RAM** checkbox exposes oversized catalog entries when explicitly requested and marks them with **RAM estimate exceeds host**. This bypass changes catalog visibility only; it does not bypass permanent-load safety.
Permanent multi-model loading now has a hard host-RAM safety limit of **95%** based on Linux `MemAvailable`. The check runs before and after each model load. If the threshold is reached, no further models are started and models started by the current load action are unloaded as a safety rollback. Models resident before that action are preserved. The response and dashboard warning identify the 95% trigger, observed usage, and rollback behavior.
Available downloads now include a checkbox selected by default: **Hide models older than 12 months**. The filter uses each model's published `modified_at` source timestamp and recalculates against the current browser date. It applies only to Available downloads; Installed and Top 20 popular are unchanged. Models with no published date remain visible because their age cannot be verified. The active filtered count is shown in the Available downloads tab label. Available downloads now include a checkbox selected by default: **Hide models older than 12 months**. The filter uses each model's published `modified_at` source timestamp and recalculates against the current browser date. It applies only to Available downloads; Installed and Top 20 popular are unchanged. Models with no published date remain visible because their age cannot be verified. The active filtered count is shown in the Available downloads tab label.
+9 -2
View File
@@ -132,6 +132,7 @@
if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available")); if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available"));
if (model.popularity_rank) badges.push(h(Badge, { key: "popular", tone: "popular" }, "#" + model.popularity_rank + " popular")); if (model.popularity_rank) badges.push(h(Badge, { key: "popular", tone: "popular" }, "#" + model.popularity_rank + " popular"));
if (model.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE")); if (model.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE"));
if (model.memory_fit === false) badges.push(h(Badge, { key: "memory", tone: "danger" }, "RAM estimate exceeds host"));
return h("article", { className: "ollama-model-card" }, return h("article", { className: "ollama-model-card" },
h("div", { className: "ollama-card-top" }, h("div", { className: "ollama-card-top" },
h("div", { className: "ollama-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)), h("div", { className: "ollama-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)),
@@ -341,7 +342,11 @@
if (endpoint === "/models/load") { 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 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; }); var missing = Array.isArray(result.not_resident) ? result.not_resident : requested.filter(function (name) { return resident.indexOf(name) < 0; });
if (result.memory_safety && result.memory_safety.triggered) {
setNotice({ warning: result.message + " Observed RAM: " + (result.memory_safety.usage_percent == null ? "unknown" : result.memory_safety.usage_percent + "%") + "." });
} else {
setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: result.message || ("Loaded and resident: " + resident.join(", ")) }); setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: result.message || ("Loaded and resident: " + resident.join(", ")) });
}
} else { } else {
setNotice({ ok: label + ": " + requested.join(", ") }); setNotice({ ok: label + ": " + requested.join(", ") });
} }
@@ -418,6 +423,7 @@
var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1]; var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1];
var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1]; var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1];
var recentOnlyState = React.useState(true), recentOnly = recentOnlyState[0], setRecentOnly = recentOnlyState[1]; var recentOnlyState = React.useState(true), recentOnly = recentOnlyState[0], setRecentOnly = recentOnlyState[1];
var showOversizedState = React.useState(false), showOversized = showOversizedState[0], setShowOversized = showOversizedState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1]; var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[1];
@@ -440,7 +446,7 @@
fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
} }
function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); }
var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : []; var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? (showOversized ? data.catalog_all || data.catalog || [] : data.catalog || []) : []) : [];
var models = baseModels; var models = baseModels;
if (tab === "catalog") { if (tab === "catalog") {
if (catalogType === "moe") models = models.filter(function (model) { return model.is_moe; }); if (catalogType === "moe") models = models.filter(function (model) { return model.is_moe; });
@@ -478,7 +484,8 @@
h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } }, h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } },
h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name") h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name")
)), )),
h("label", { className: "ollama-catalog-checkbox", title: "Models with no published source date remain visible." }, h("input", { type: "checkbox", checked: recentOnly, onChange: function (event) { setRecentOnly(event.target.checked); } }), h("span", null, "Hide models older than 12 months")) h("label", { className: "ollama-catalog-checkbox", title: "Models with no published source date remain visible." }, h("input", { type: "checkbox", checked: recentOnly, onChange: function (event) { setRecentOnly(event.target.checked); } }), h("span", null, "Hide models older than 12 months")),
h("label", { className: "ollama-catalog-checkbox ollama-catalog-memory-bypass", title: "This only bypasses the catalog display filter; loading remains protected by the 95% RAM safety guard." }, h("input", { type: "checkbox", checked: showOversized, onChange: function (event) { setShowOversized(event.target.checked); } }), h("span", null, "Show models above estimated RAM"))
); );
var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" }, var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" },
h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }), h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }),
+1 -1
View File
@@ -7,7 +7,7 @@
.ollama-persistence-panel{display:grid;gap:12px;margin:16px 0;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-persistence-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.ollama-persistence-heading h3{margin:0}.ollama-persistence-heading p{margin:4px 0 0;color:#8fa9a4;font-size:11px}.ollama-conversation-list{display:flex;flex-wrap:wrap;gap:8px}.ollama-conversation-list .ollama-button{font-size:11px}.ollama-conversation-list .selected{background:#3e8073;border-color:#8dd2c1}.ollama-metrics-summary,.ollama-metrics-detail{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#a5bfba}.ollama-metrics-summary strong{color:#effcf8}.ollama-metrics-detail{padding-top:8px;border-top:1px solid rgba(164,211,199,.14)} .ollama-persistence-panel{display:grid;gap:12px;margin:16px 0;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-persistence-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.ollama-persistence-heading h3{margin:0}.ollama-persistence-heading p{margin:4px 0 0;color:#8fa9a4;font-size:11px}.ollama-conversation-list{display:flex;flex-wrap:wrap;gap:8px}.ollama-conversation-list .ollama-button{font-size:11px}.ollama-conversation-list .selected{background:#3e8073;border-color:#8dd2c1}.ollama-metrics-summary,.ollama-metrics-detail{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#a5bfba}.ollama-metrics-summary strong{color:#effcf8}.ollama-metrics-detail{padding-top:8px;border-top:1px solid rgba(164,211,199,.14)}
@media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:340px;max-width:620px;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:flex;gap:6px;align-items:center;margin-top:9px}.ollama-connection-role,.ollama-connection-input{border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{min-width:190px;flex:1}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere} @media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:340px;max-width:620px;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:flex;gap:6px;align-items:center;margin-top:9px}.ollama-connection-role,.ollama-connection-input{border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{min-width:190px;flex:1}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-connection-row-main{display:flex;align-items:center;gap:7px;flex:1;min-width:0;border:0;padding:0;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}.ollama-connection-remove{flex:0 0 auto;padding:5px 7px;font-size:9px}.ollama-connection-row-main>span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row-main strong{font-size:10px}.ollama-connection-row-main small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}
.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} .ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}}
.ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal} .ollama-catalog-controls{display:grid;grid-template-columns:repeat(3,minmax(130px,1fr));gap:8px;align-items:end;margin-top:0;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-checkbox{display:flex!important;flex-direction:row!important;align-items:center;gap:8px;grid-column:1 / -1;padding:8px 4px;color:#b8ead9!important;text-transform:none!important;letter-spacing:normal!important;cursor:pointer}.ollama-catalog-checkbox input{width:15px;height:15px;margin:0;accent-color:#75d2b7}.ollama-catalog-checkbox span{font-size:11px}.ollama-catalog-memory-bypass{color:#ffd89a!important;background:rgba(142,90,25,.12);border-radius:7px}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal}
@media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}} @media(max-width:1000px){.ollama-nav-row{display:grid;grid-template-columns:1fr}.ollama-toolbar-disk{justify-self:end}.ollama-browse-row{grid-template-columns:1fr}.ollama-catalog-controls{margin-top:0}}
@media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}} @media(max-width:760px){.ollama-tabs{grid-template-columns:repeat(2,minmax(0,1fr))}.ollama-nav-row{gap:8px}.ollama-toolbar-disk{justify-self:stretch;grid-template-columns:auto auto;min-width:0}.ollama-browse-row{gap:8px}.ollama-catalog-controls{grid-template-columns:1fr;align-items:stretch}.ollama-catalog-select{width:100%}}
.ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}} .ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}}
+1 -1
View File
@@ -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.22", "version": "1.5.23",
"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",
+91 -11
View File
@@ -1369,6 +1369,41 @@ def _model_placement(value: str | None) -> str:
return placement return placement
PERMANENT_LOAD_RAM_LIMIT_PERCENT = 95.0
def _memory_usage_percent() -> float | None:
mem = _read_meminfo()
total = int(mem.get("MemTotal") or 0)
available = int(mem.get("MemAvailable") or mem.get("MemFree") or 0)
if not total:
return None
return round(max(0.0, min(100.0, (total - available) * 100 / total)), 1)
def _memory_safety_check() -> dict[str, Any]:
usage_percent = _memory_usage_percent()
return {
"threshold_percent": PERMANENT_LOAD_RAM_LIMIT_PERCENT,
"usage_percent": usage_percent,
"triggered": usage_percent is not None and usage_percent >= PERMANENT_LOAD_RAM_LIMIT_PERCENT,
}
def _unload_model(name: str) -> dict[str, Any]:
name = _require_installed_model(name)
try:
_json_request(
LOCAL_OLLAMA + "/api/generate",
method="POST",
payload={"model": name, "prompt": "", "stream": False, "keep_alive": 0},
timeout=120,
)
return {"name": name, "ok": True}
except Exception as exc:
return {"name": name, "ok": False, "error": str(exc)}
def _load_model(name: str, placement: str = "gpu_ram") -> dict[str, Any]: def _load_model(name: str, placement: str = "gpu_ram") -> dict[str, Any]:
name = _require_installed_model(name) name = _require_installed_model(name)
placement = _model_placement(placement) placement = _model_placement(placement)
@@ -1633,50 +1668,91 @@ def models_load(body: ModelsRequest) -> dict[str, Any]:
if not names: if not names:
raise HTTPException(400, "Select at least one model to load") raise HTTPException(400, "Select at least one model to load")
placements = {name: _model_placement(body.placements.get(name)) for name in names} placements = {name: _model_placement(body.placements.get(name)) for name in names}
# Load RAM-only models first, then GPU+RAM models. This avoids asking the
# GPU scheduler to rearrange an already GPU-resident runner unnecessarily.
ordered_names = sorted(names, key=lambda name: 0 if placements[name] == "ram_only" else 1) ordered_names = sorted(names, key=lambda name: 0 if placements[name] == "ram_only" else 1)
initial_resident = {str(row.get("name") or row.get("model")) for row in _local_ps()}
started_by_action: list[str] = []
unloaded_by_safety: list[dict[str, Any]] = []
results_by_name: dict[str, dict[str, Any]] = {} results_by_name: dict[str, dict[str, Any]] = {}
safety = _memory_safety_check()
safety_triggered = bool(safety["triggered"])
for pass_index in range(2): for pass_index in range(2):
if safety_triggered:
break
resident_now = {str(row.get("name") or row.get("model")) for row in _local_ps()} resident_now = {str(row.get("name") or row.get("model")) for row in _local_ps()}
missing_now = [name for name in ordered_names if name not in resident_now] missing_now = [name for name in ordered_names if name not in resident_now]
if not missing_now: if not missing_now:
break break
for name in missing_now: for name in missing_now:
safety = _memory_safety_check()
if safety["triggered"]:
safety_triggered = True
break
placement = placements[name] placement = placements[name]
stage = "Loading into GPU + RAM" if placement == "gpu_ram" else "Loading into system RAM only" stage = "Loading into GPU + RAM" if placement == "gpu_ram" else "Loading into system RAM only"
_model_load_update(name, active=True, state="loading", stage=stage + " · pass " + str(pass_index + 1), attempt=pass_index + 1) _model_load_update(name, active=True, state="loading", stage=stage + " · pass " + str(pass_index + 1), attempt=pass_index + 1)
try: try:
result = _load_model(name, placement) result = _load_model(name, placement)
results_by_name[name] = {"name": name, "placement": placement, "ok": True, "result": result, "attempts": pass_index + 1} results_by_name[name] = {"name": name, "placement": placement, "ok": True, "result": result, "attempts": pass_index + 1}
if name not in initial_resident and name not in started_by_action:
started_by_action.append(name)
_model_load_update(name, state="checking", stage="Checking Ollama resident state", attempt=pass_index + 1) _model_load_update(name, state="checking", stage="Checking Ollama resident state", attempt=pass_index + 1)
safety = _memory_safety_check()
if safety["triggered"]:
safety_triggered = True
break
except Exception as exc: except Exception as exc:
results_by_name[name] = {"name": name, "placement": placement, "ok": False, "error": str(exc), "attempts": pass_index + 1} results_by_name[name] = {"name": name, "placement": placement, "ok": False, "error": str(exc), "attempts": pass_index + 1}
_model_load_update(name, active=False, state="failed", stage="Ollama load failed", finished_at=time.time(), error=str(exc), attempt=pass_index + 1) _model_load_update(name, active=False, state="failed", stage="Ollama load failed", finished_at=time.time(), error=str(exc), attempt=pass_index + 1)
if safety_triggered:
for name in reversed(started_by_action):
unloaded = _unload_model(name)
unloaded_by_safety.append(unloaded)
_model_load_update(name, active=False, state="safety_rollback", stage="Unloaded after 95% RAM safety stop", finished_at=time.time())
resident_rows = _local_ps() resident_rows = _local_ps()
resident_names = {str(row.get("name") or row.get("model")) for row in resident_rows} resident_names = {str(row.get("name") or row.get("model")) for row in resident_rows}
results = [results_by_name.get(name, {"name": name, "placement": placements[name], "ok": name in resident_names, "attempts": 0}) for name in names] results = []
for item in results: for name in names:
item["resident"] = item["name"] in resident_names item = results_by_name.get(name, {"name": name, "placement": placements[name], "ok": name in resident_names, "attempts": 0})
item["resident"] = name in resident_names
if safety_triggered and name not in results_by_name:
item["ok"] = False
item["error"] = "Loading stopped by 95% RAM safety limit"
results.append(item)
not_resident = [name for name in names if name not in resident_names] not_resident = [name for name in names if name not in resident_names]
for item in results: for item in results:
if item["name"] in resident_names: 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()) _model_load_update(item["name"], active=False, state="resident", stage="Model is resident in Ollama", finished_at=time.time())
elif item["ok"]: elif item.get("ok"):
_model_load_update(item["name"], active=False, state="evicted", stage="Ollama did not retain this model", finished_at=time.time()) _model_load_update(item["name"], active=False, state="evicted", stage="Ollama did not retain this model", finished_at=time.time())
runtime = _runtime_snapshot() runtime = _runtime_snapshot()
retry_count = sum(max(0, int(item.get("attempts") or 0) - 1) for item in results) retry_count = sum(max(0, int(item.get("attempts") or 0) - 1) for item in results)
memory_safety = {
**safety,
"triggered": safety_triggered,
"unloaded_by_safety": unloaded_by_safety,
"message": (
"Permanent loading stopped because host RAM reached the 95% safety limit. Models started by this action were unloaded; models resident before this action were preserved."
if safety_triggered
else "RAM remained below the 95% permanent-load safety limit."
),
}
return { return {
"ok": bool(results) and not not_resident and all(item["ok"] for item in results), "ok": bool(results) and not not_resident and all(item["ok"] for item in results) and not safety_triggered,
"requested": names, "requested": names,
"resident": sorted(resident_names), "resident": sorted(resident_names),
"not_resident": not_resident, "not_resident": not_resident,
"results": results, "results": results,
"retries": retry_count, "retries": retry_count,
"runtime": runtime, "runtime": runtime,
"memory_safety": memory_safety,
"keep_alive": "permanent", "keep_alive": "permanent",
"message": ( "message": (
"All selected models are resident. Automatic Ollama eviction recovery completed." memory_safety["message"]
if safety_triggered
else "All selected models are resident. Automatic Ollama eviction recovery completed."
if not not_resident and retry_count if not not_resident and retry_count
else "All selected models are resident." else "All selected models are resident."
if not not_resident if not not_resident
@@ -1876,18 +1952,21 @@ def status() -> dict[str, Any]:
if str(variant.get("name") or variant.get("model") or "") not in catalog_names if str(variant.get("name") or variant.get("model") or "") not in catalog_names
] ]
downloadable = [] downloadable = []
all_downloadable = []
seen_downloads: set[str] = set() seen_downloads: set[str] = set()
for rank, row in enumerate(candidate_rows, 1): for rank, row in enumerate(candidate_rows, 1):
name = str(row.get("name") or row.get("model") or "") name = str(row.get("name") or row.get("model") or "")
if _is_mlx(row) or not name or name in installed_names or name in seen_downloads: if _is_mlx(row) or not name or name in installed_names or name in seen_downloads:
continue continue
view = catalog_view(row, "catalog") view = catalog_view(row, "catalog")
if not _known_ram_fit(view): view["memory_fit"] = _known_ram_fit(view)
continue view["memory_warning"] = "Estimated runtime RAM exceeds detected host RAM" if view["memory_fit"] is False else ""
if name in catalog_names: if name in catalog_names:
view["popularity_rank"] = rank view["popularity_rank"] = rank
seen_downloads.add(name) seen_downloads.add(name)
downloadable.append(view) all_downloadable.append(view)
if view["memory_fit"]:
downloadable.append(dict(view))
popular = _popular_fit_models( popular = _popular_fit_models(
[row for row in catalog.get("models", []) if not _is_mlx(row)], [row for row in catalog.get("models", []) if not _is_mlx(row)],
@@ -1936,6 +2015,7 @@ def status() -> dict[str, Any]:
"smaller_fit_variants_substituted": True, "smaller_fit_variants_substituted": True,
}, },
"catalog": downloadable, "catalog": downloadable,
"catalog_all": all_downloadable,
"catalog_filter": { "catalog_filter": {
"max_expected_ram_gib": _host_ram_gib(), "max_expected_ram_gib": _host_ram_gib(),
"basis": "detected MemTotal from the running host", "basis": "detected MemTotal from the running host",
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager name: ollama-manager
version: 1.5.22 version: 1.5.23
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: