feat: add Ollama chat and live memory telemetry

This commit is contained in:
Hermes Agent
2026-08-18 18:03:31 +10:00
parent ef9847771b
commit 0f3ca5bb05
7 changed files with 457 additions and 138 deletions
+24 -18
View File
@@ -1,28 +1,34 @@
# Hermes Ollama Models # Hermes Ollama Models
Native-like Hermes dashboard plugin for inspecting and managing a local Ollama installation. Native-like Hermes dashboard plugin for local Ollama model management and chat.
## Included ## Chat capabilities
- Installed and currently loaded Ollama model inventory - Select an installed Ollama model and load it into memory
- Model size, loaded memory, estimated RAM, quantization, context, capabilities, and strengths - Chat through Ollama's native `/api/chat` endpoint
- Dense versus MoE classification - Attach screenshots and JPEG/PNG/WebP images for vision-capable models
- Search, popular models, family variants, downloads, updates, and removal actions - Attach text PDFs; PDF text is extracted with `pypdf`
- MLX model exclusion - Add public HTTP/HTTPS URLs for HTML/text, images, or PDFs
- RAM-aware Popular view for the current 30 GiB host - View live host RAM and swap statistics
- View Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
- View NVIDIA GPU telemetry when `nvidia-smi` is available
## RAM-aware Popular policy The chat UI polls runtime memory once per second and keeps a short in-browser history for the current page session.
The Popular view only displays models with known size and known estimated baseline RAM at or below 30 GiB. When an oversized popular family has a known smaller fitting variant, the smaller variant is shown instead. Equivalent model footprints are deduplicated. ## Security limits
The estimate is a baseline and actual usage varies with context length, KV cache, GPU offload, batching, and runtime overhead. - Uploaded files are limited to 20 MiB each
- Fetched URLs are limited to 15 MiB and a 30-second timeout
- Private, loopback, link-local, reserved, multicast, and unspecified URL targets are blocked, including redirect destinations
- Remote documents are inserted as untrusted content, not system instructions
- Only locally installed models can be selected or loaded; chat does not download models
## Layout ## Dependency
- `plugin.yaml` — Hermes plugin metadata Install the plugin's Python dependency in the Hermes runtime environment:
- `dashboard/manifest.json` — native dashboard registration
- `dashboard/plugin_api.py` — Ollama API and catalog backend
- `dashboard/dist/index.js` — dashboard UI bundle
- `dashboard/dist/style.css` — dashboard styles
Runtime catalog data is intentionally stored in Hermes state rather than committed here. ```bash
pip install -r requirements.txt
```
`pypdf` is required for text-based PDF extraction. Scanned/image-only PDFs need OCR and are not converted to text by this plugin.
+106 -113
View File
@@ -9,73 +9,59 @@
var API = "/api/plugins/ollama-manager"; var API = "/api/plugins/ollama-manager";
function fmtBytes(bytes) { function fmtBytes(bytes) {
if (!bytes) return "Unknown"; if (bytes === null || bytes === undefined || Number(bytes) === 0) return "0 B";
var units = ["B", "GiB", "TiB"], value = Number(bytes), index = 0; var units = ["B", "KiB", "MiB", "GiB", "TiB"], value = Number(bytes), index = 0;
while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; } while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }
return value.toFixed(index ? 2 : 0) + " " + units[index]; return value.toFixed(index ? 2 : 0) + " " + units[index];
} }
function fmtDate(value) { function fmtDate(value) {
if (!value) return "Unknown"; if (!value) return "Unknown";
try { return new Date(value).toLocaleString(); } catch (_) { return value; } try { return new Date(value * 1000 || value).toLocaleString(); } catch (_) { return value; }
} }
function Badge(props) { return h("span", { className: "ollama-badge " + (props.tone || "") }, props.children); } function Badge(props) { return h("span", { className: "ollama-badge " + (props.tone || "") }, props.children); }
function Button(props) { return h("button", Object.assign({ className: "ollama-button" }, props), props.children); } function Button(props) { return h("button", Object.assign({ className: "ollama-button" }, props), props.children); }
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
function CapabilityList(props) { function CapabilityList(props) {
var model = props.model; var model = props.model;
return h("div", { className: "ollama-capabilities" }, return h("div", { className: "ollama-capabilities" }, (model.capabilities || []).map(function (cap) {
(model.capabilities || []).map(function (cap) { return h("div", { className: "ollama-capability", key: cap }, h(Badge, null, cap), h("span", null, (model.capability_breakdown || {})[cap] || "Advertised by model metadata"));
return h("div", { className: "ollama-capability", key: cap }, }));
h(Badge, null, cap),
h("span", null, (model.capability_breakdown || {})[cap] || "Advertised by model metadata")
);
})
);
} }
function VariantTable(props) { function VariantTable(props) {
var model = props.model, variants = model.variants || [], action = props.action, busy = props.busy; var model = props.model, variants = model.variants || [], action = props.action, busy = props.busy;
if (!variants.length) return null; if (!variants.length) return null;
return h("div", { className: "ollama-variants" }, return h("div", { className: "ollama-variants" },
h("div", { className: "ollama-variants-heading" }, h("div", { className: "ollama-variants-heading" }, h("h4", null, "Available ", model.name.split(":")[0], " sizes"), h("span", null, "MLX variants excluded")),
h("h4", null, "Available ", model.name.split(":")[0], " sizes"), h("div", { className: "ollama-variant-table-wrap" }, h("table", { className: "ollama-variant-table" },
h("span", null, "MLX variants excluded") h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Size / RAM"), h("th", null, "Context"), h("th", null, "Input"), h("th", null, "Action"))),
), h("tbody", null, variants.map(function (variant) {
h("div", { className: "ollama-variant-table-wrap" }, var current = variant.name === model.name, installed = variant.installed;
h("table", { className: "ollama-variant-table" }, return h("tr", { key: variant.name, className: current ? "current" : "" },
h("thead", null, h("tr", null, h("td", null, h("strong", null, variant.name), current && h(Badge, { tone: "current" }, "current"), installed && !current && h(Badge, { tone: "installed" }, "installed")),
h("th", null, "Name"), h("th", null, "Size / RAM"), h("th", null, "Context"), h("th", null, "Input"), h("th", null, "Action") h("td", null, h("strong", null, variant.size_label || "Unknown"), h("small", null, variant.expected_ram_label || "RAM unknown")),
)), h("td", null, variant.context_length ? Math.round(Number(variant.context_length) / 1024) + "K" : "Unknown"),
h("tbody", null, variants.map(function (variant) { h("td", null, (variant.input_modalities || ["Text"]).join(", ")),
var current = variant.name === model.name; h("td", null, current ? h("span", { className: "ollama-current-label" }, "Current") : installed ? h("span", { className: "ollama-current-label" }, "Installed") : h(Button, { disabled: !!busy, onClick: function () { action("pull", variant.name); } }, busy === variant.name + ":pull" ? "Downloading…" : "Download"))
var installed = variant.installed; );
return h("tr", { key: variant.name, className: current ? "current" : "" }, }))
h("td", null, h("strong", null, variant.name), current && h(Badge, { tone: "current" }, "current"), installed && !current && h(Badge, { tone: "installed" }, "installed")), ))
h("td", null, h("strong", null, variant.size_label || "Unknown"), h("small", null, variant.expected_ram_label || "RAM unknown")),
h("td", null, variant.context_length ? Math.round(Number(variant.context_length) / 1024) + "K" : "Unknown"),
h("td", null, (variant.input_modalities || ["Text"]).join(", ")),
h("td", null, current ? h("span", { className: "ollama-current-label" }, "Current") : installed ? h("span", { className: "ollama-current-label" }, "Installed") : h(Button, { disabled: !!busy, onClick: function () { action("pull", variant.name); } }, busy === variant.name + ":pull" ? "Downloading…" : "Download"))
);
}))
)
)
); );
} }
function ModelCard(props) { function ModelCard(props) {
var model = props.model, installed = props.installed, busy = props.busy; var model = props.model, installed = props.installed, busy = props.busy, action = props.action;
var openState = React.useState(false), open = openState[0], setOpen = openState[1]; var openState = React.useState(false), open = openState[0], setOpen = openState[1];
var action = props.action; var badges = [];
if (installed && model.loaded) badges.push(h(Badge, { key: "loaded", tone: "live" }, "loaded"));
if (installed) badges.push(h(Badge, { key: "installed", tone: "installed" }, "installed"));
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.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE"));
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("div", { className: "ollama-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)),
h("h3", null, model.name),
h("div", { className: "ollama-badge-row" },
installed && model.loaded && h(Badge, { tone: "live" }, "loaded"),
installed && h(Badge, { tone: "installed" }, "installed"),
!installed && h(Badge, { tone: "download" }, "available"),
model.popularity_rank && h(Badge, { tone: "popular" }, "#" + model.popularity_rank + " popular"),
model.is_moe && h(Badge, { tone: "moe" }, "MoE")
)
),
h("div", { className: "ollama-card-actions" }, h("div", { className: "ollama-card-actions" },
installed && h(Button, { disabled: !!busy, onClick: function () { action("redownload", model.name); } }, busy === model.name + ":redownload" ? "Updating…" : "Update / re-download"), installed && h(Button, { disabled: !!busy, onClick: function () { action("redownload", model.name); } }, busy === model.name + ":redownload" ? "Updating…" : "Update / re-download"),
installed && h(Button, { disabled: !!busy, className: "ollama-button danger", onClick: function () { action("delete", model.name); } }, busy === model.name + ":delete" ? "Removing…" : "Remove"), installed && h(Button, { disabled: !!busy, className: "ollama-button danger", onClick: function () { action("delete", model.name); } }, busy === model.name + ":delete" ? "Removing…" : "Remove"),
@@ -88,11 +74,7 @@
h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")), h("div", null, h("small", null, "Type"), h("strong", null, model.architecture || "Unknown")),
h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown")) h("div", null, h("small", null, "Parameters"), h("strong", null, model.parameter_size || "Unknown"))
), ),
h("div", { className: "ollama-card-meta" }, h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), installed && model.modified_at && h("span", null, "Updated " + fmtDate(model.modified_at))),
h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")),
model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()),
installed && model.modified_at && h("span", null, "Updated " + fmtDate(model.modified_at))
),
h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")), h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")),
installed && h(VariantTable, { model: model, action: action, busy: busy }), installed && h(VariantTable, { model: model, action: action, busy: busy }),
h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"), h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"),
@@ -109,7 +91,68 @@
) )
); );
} }
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
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 || [];
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-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-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 model placement"), 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("p", null, "No model is currently loaded. Select a model and press Load model, or send a message."))
);
}
function readFileAsDataURL(file) {
return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve({ name: file.name, mime_type: file.type || "application/octet-stream", data_url: reader.result }); }; reader.onerror = reject; reader.readAsDataURL(file); });
}
function ChatPanel(props) {
var models = props.models || [];
var modelState = React.useState(models[0] ? models[0].name : ""), model = modelState[0], setModel = modelState[1];
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
var historyState = React.useState([]), history = historyState[0], setHistory = historyState[1];
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]);
function pollRuntime() { fetchJSON(API + "/runtime").then(function (value) { setRuntime(value); setSamples(function (old) { return old.concat([{ used: Number(value.memory_used_bytes || 0), total: Number(value.memory_total_bytes || 0) }]).slice(-60); }); }).catch(function () {}); }
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
function loadModel() {
if (!model) return;
setBusy("load"); setNotice(null);
fetchJSON(API + "/chat/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model }) }).then(function () { setNotice({ ok: model + " loaded or refreshed in Ollama memory." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
}
function addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
function onFiles(event) { var files = Array.prototype.slice.call(event.target.files || []); var valid = files.filter(function (file) { return file.size <= 20 * 1024 * 1024 && (file.type === "application/pdf" || file.type.indexOf("image/") === 0); }); Promise.all(valid.map(readFileAsDataURL)).then(function (items) { setAttachments(function (old) { return old.concat(items); }); }); event.target.value = ""; }
function send() {
if (busy === "send" || !model || (!message.trim() && !attachments.length)) return;
var outgoing = { role: "user", content: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments };
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setNotice(null);
fetchJSON(API + "/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Live placement is shown below." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
}
return h("section", { className: "ollama-chat" },
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with your selected model"), h("p", null, "Images are sent as Ollama vision inputs; PDFs and web pages are extracted as untrusted document text.")), h("div", { className: "ollama-chat-model" }, h("label", null, "Model", h("select", { value: model, onChange: function (event) { setModel(event.target.value); } }, models.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name + (item.loaded ? " · loaded" : "")); }))), h(Button, { disabled: !model || busy === "load", onClick: loadModel }, busy === "load" ? "Loading…" : "Load model"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h("div", { className: "ollama-chat-layout" },
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h("div", { className: "ollama-message " + item.role, key: index }, h("small", null, item.role === "assistant" ? "Ollama" : "You"), h("div", null, item.content)); }) : h(Empty, null, "Start a conversation. The selected model will be loaded into Ollama memory when you load it or send the first message.")),
h("div", { className: "ollama-composer" }, h("textarea", { value: message, placeholder: "Ask the selected local model…", onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if ((event.ctrlKey || event.metaKey) && event.key === "Enter") send(); } }),
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Add screenshot / image / PDF", h("input", { type: "file", multiple: true, accept: "image/png,image/jpeg,image/webp,application/pdf,.pdf", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || !model || (!message.trim() && !attachments.length) }, busy === "send" ? "Thinking…" : "Send (Ctrl+Enter)")),
attachments.length > 0 && h("div", { className: "ollama-attachments" }, attachments.map(function (item, index) { return h("span", { className: "ollama-attachment", key: index }, item.name || item.url, h("button", { type: "button", onClick: function () { setAttachments(function (old) { return old.filter(function (_, i) { return i !== index; }); }); } }, "×")); })),
h("p", { className: "ollama-chat-footnote" }, "Limits: 20 MiB per uploaded file, 15 MiB per fetched URL. Private/local URL targets are blocked. Remote content is treated as untrusted text."))
)
);
}
function Page() { function Page() {
var dataState = React.useState(null), data = dataState[0], setData = dataState[1]; var dataState = React.useState(null), data = dataState[0], setData = dataState[1];
@@ -118,70 +161,20 @@
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 loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1]; var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
function load() { return fetchJSON(API + "/status").then(function (value) { setData(value); setLoading(false); return value; }).catch(function (err) { setNotice({ error: err.message || String(err) }); setLoading(false); }); }
function load() { React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
return fetchJSON(API + "/status").then(function (value) { function action(kind, name) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; var key = name + ":" + kind; setBusy(key); setNotice(null); fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); }
setData(value); setLoading(false); return value; 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(""); }); }
}).catch(function (err) { setNotice({ error: err.message || String(err) }); setLoading(false); }); var models = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : [];
} var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
React.useEffect(function () {
load();
var timer = setInterval(load, 5000);
return function () { clearInterval(timer); };
}, []);
function action(kind, name) {
if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return;
var key = name + ":" + kind;
setBusy(key); setNotice(null);
var method = kind === "delete" ? "DELETE" : "POST";
var path = kind === "delete" ? "/model" : "/" + kind;
fetchJSON(API + path, { method: method, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) })
.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(""); });
}
var models = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : data.catalog || []) : [];
var needle = query.toLowerCase().trim();
if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : []; var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog"))),
return h("main", { className: "ollama-page" },
h("header", { className: "ollama-hero" },
h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, download, update, and remove models from the local Ollama runtime.")),
h("div", { className: "ollama-health" },
h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"),
data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")),
h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog")
)
),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
h("section", { className: "ollama-toolbar" }, h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-tabs" }, h(Button, { className: tab === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Chat"), h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"), h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"), h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + ((data && data.catalog) || []).length + ")")), tab !== "chat" && h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } })),
h("div", { className: "ollama-tabs" }, tab !== "chat" && h("div", { className: "ollama-info-strip" }, h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"), h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")), h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"), tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [] }),
h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"), tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below 30 GiB. Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + ((data && data.catalog) || []).length + ")") tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
),
h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } })
),
h("div", { className: "ollama-info-strip" },
h("span", null, data && data.models ? data.models.filter(function (m) { return m.loaded; }).length + " currently loaded" : "Loading runtime state…"),
h("span", null, "Catalog checked " + (data && data.catalog_updated_at ? fmtDate(data.catalog_updated_at) : "not yet")),
h("span", null, "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")
),
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below 30 GiB. Oversized families are represented by a smaller fitting variant when available."),
jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
loading && h(Empty, null, "Loading local Ollama inventory…"),
!loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."),
h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
); );
} }
registry.register("ollama-manager", Page); registry.register("ollama-manager", Page);
+3
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "ollama-manager", "name": "ollama-manager",
"label": "Ollama Models", "label": "Ollama Models",
"description": "Inspect, download, update, and remove local Ollama models.", "description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu", "icon": "Cpu",
"version": "1.2.0", "version": "1.3.0",
"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",
+318 -3
View File
@@ -1,8 +1,16 @@
"""Native Hermes dashboard API for managing a local Ollama instance.""" """Native Hermes dashboard API for managing a local Ollama instance."""
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import io
import ipaddress
import json import json
import mimetypes
import os
import re import re
import socket
import subprocess
import threading import threading
import time import time
import uuid import uuid
@@ -12,12 +20,12 @@ from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import unquote, urlencode from urllib.parse import unquote, urlencode, urlparse
from urllib.request import Request, urlopen from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
router = APIRouter() router = APIRouter()
@@ -27,6 +35,10 @@ CATALOG_FILE = "catalog.json"
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$") MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,190}$")
MELBOURNE = ZoneInfo("Australia/Melbourne") MELBOURNE = ZoneInfo("Australia/Melbourne")
POPULAR_RAM_LIMIT_GIB = 30.0 POPULAR_RAM_LIMIT_GIB = 30.0
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
MAX_ATTACHMENT_TEXT = 80_000
MAX_URL_BYTES = 15 * 1024 * 1024
CHAT_KEEP_ALIVE = "10m"
_jobs: dict[str, dict[str, Any]] = {} _jobs: dict[str, dict[str, Any]] = {}
_jobs_lock = threading.Lock() _jobs_lock = threading.Lock()
@@ -99,6 +111,204 @@ def _local_ps() -> list[dict[str, Any]]:
return [] return []
def _local_ps() -> list[dict[str, Any]]:
try:
payload = _json_request(LOCAL_OLLAMA + "/api/ps", timeout=10)
models = payload.get("models", [])
return [item for item in models if isinstance(item, dict)]
except (HTTPError, URLError, OSError, ValueError):
return []
def _read_meminfo() -> dict[str, int]:
values: dict[str, int] = {}
try:
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
key, _, raw = line.partition(":")
match = re.search(r"([0-9]+)", raw)
if match:
values[key] = int(match.group(1)) * 1024
except OSError:
return {}
return values
def _gpu_snapshot() -> dict[str, Any]:
"""Return NVIDIA GPU telemetry when available, without requiring CUDA."""
query = "name,memory.total,memory.used,memory.free"
try:
result = subprocess.run(
["nvidia-smi", f"--query-gpu={query}", "--format=csv,noheader,nounits"],
capture_output=True,
text=True,
timeout=4,
check=False,
)
except (OSError, subprocess.SubprocessError):
result = None
if result and result.returncode == 0:
gpus = []
for line in result.stdout.splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) != 4:
continue
try:
total, used, free = (int(float(value)) * 1024 * 1024 for value in parts[1:])
except ValueError:
continue
gpus.append({"name": parts[0], "total_bytes": total, "used_bytes": used, "free_bytes": free})
if gpus:
return {"detected": True, "telemetry_available": True, "gpus": gpus}
nvidia_present = False
for vendor in Path("/sys/class/drm").glob("card*/device/vendor"):
try:
nvidia_present = nvidia_present or vendor.read_text().strip().lower() == "0x10de"
except OSError:
continue
return {"detected": nvidia_present, "telemetry_available": False, "gpus": []}
def _runtime_snapshot() -> dict[str, Any]:
mem = _read_meminfo()
total = mem.get("MemTotal", 0)
available = mem.get("MemAvailable", mem.get("MemFree", 0))
swap_total = mem.get("SwapTotal", 0)
swap_free = mem.get("SwapFree", 0)
ps_rows = _local_ps()
model_memory = []
for row in ps_rows:
name = str(row.get("name") or row.get("model") or "")
total_bytes = int(row.get("size") or 0)
gpu_bytes = int(row.get("size_vram") or 0)
model_memory.append({
"name": name,
"total_bytes": total_bytes,
"gpu_bytes": gpu_bytes,
"ram_bytes": max(0, total_bytes - gpu_bytes),
"gpu_offload_percent": round(gpu_bytes * 100 / total_bytes, 1) if total_bytes else 0,
})
return {
"captured_at": time.time(),
"memory_total_bytes": total,
"memory_used_bytes": max(0, total - available),
"memory_available_bytes": available,
"swap_total_bytes": swap_total,
"swap_used_bytes": max(0, swap_total - swap_free),
"model_memory": model_memory,
"gpu": _gpu_snapshot(),
}
def _validate_public_url(value: str) -> str:
parsed = urlparse(value.strip())
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(400, "URL attachments must use http:// or https://")
host = parsed.hostname
try:
addresses = {info[4][0] for info in socket.getaddrinfo(host, parsed.port or 443, type=socket.SOCK_STREAM)}
except (OSError, ValueError) as exc:
raise HTTPException(400, f"Could not resolve URL host: {exc}") from exc
for address in addresses:
ip = ipaddress.ip_address(address)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified:
raise HTTPException(400, "Private or local URL targets are not allowed")
return value.strip()
class _SafeRedirectHandler(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
_validate_public_url(newurl)
return super().redirect_request(req, fp, code, msg, headers, newurl)
_SAFE_URL_OPENER = build_opener(_SafeRedirectHandler)
def _fetch_attachment_url(value: str) -> tuple[bytes, str, str]:
value = _validate_public_url(value)
request = Request(value, headers={"Accept": "text/html, text/plain, application/pdf, image/*", "User-Agent": "Hermes-Ollama-Manager/1.3"})
try:
with _SAFE_URL_OPENER.open(request, timeout=30) as response:
final_url = _validate_public_url(response.geturl())
content_type = response.headers.get_content_type() if response.headers else "application/octet-stream"
data = response.read(MAX_URL_BYTES + 1)
except (HTTPError, URLError, OSError, ValueError) as exc:
raise HTTPException(400, f"Could not fetch URL: {exc}") from exc
if len(data) > MAX_URL_BYTES:
raise HTTPException(413, "URL attachment is larger than 15 MiB")
return data, content_type, final_url
def _extract_pdf_text(data: bytes, label: str) -> str:
try:
from pypdf import PdfReader
except ImportError as exc:
raise HTTPException(500, "PDF support requires the pypdf package") from exc
try:
reader = PdfReader(io.BytesIO(data))
text = "\n\n".join(page.extract_text() or "" for page in reader.pages)
except Exception as exc:
raise HTTPException(400, f"Could not extract text from PDF {label}: {exc}") from exc
return text[:MAX_ATTACHMENT_TEXT]
class _PageTextParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
self._skip = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag.lower() in {"script", "style", "noscript", "svg"}:
self._skip += 1
def handle_endtag(self, tag: str) -> None:
if tag.lower() in {"script", "style", "noscript", "svg"} and self._skip:
self._skip -= 1
def handle_data(self, data: str) -> None:
if not self._skip and data.strip():
self.parts.append(data.strip())
def _extract_page_text(data: bytes, content_type: str) -> str:
decoded = data.decode("utf-8", errors="replace")
if "html" in content_type.lower() or re.search(r"<html|<body|<article", decoded, re.I):
parser = _PageTextParser()
parser.feed(decoded)
decoded = "\n".join(parser.parts)
return unescape(decoded)[:MAX_ATTACHMENT_TEXT]
def _decode_data_url(data_url: str, fallback_mime: str, label: str) -> tuple[bytes, str]:
match = re.match(r"data:([^;,]+)?;base64,(.*)", data_url or "", re.S)
if not match:
raise HTTPException(400, f"Attachment {label} is not a valid base64 data URL")
mime = (match.group(1) or fallback_mime or "application/octet-stream").lower()
try:
data = base64.b64decode(match.group(2), validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(400, f"Attachment {label} has invalid base64 data") from exc
if len(data) > MAX_ATTACHMENT_BYTES:
raise HTTPException(413, f"Attachment {label} is larger than 20 MiB")
return data, mime
def _attachment_parts(attachment: "ChatAttachment") -> tuple[str | None, str | None]:
label = attachment.name or attachment.url or "attachment"
if attachment.url:
data, mime, _ = _fetch_attachment_url(attachment.url)
elif attachment.data_url:
data, mime = _decode_data_url(attachment.data_url, attachment.mime_type, label)
else:
raise HTTPException(400, f"Attachment {label} has no data or URL")
if mime == "application/pdf" or label.lower().endswith(".pdf"):
return f"[PDF: {label}]\n{_extract_pdf_text(data, label)}", None
if mime.startswith("image/"):
return f"[Image attached: {label}]", base64.b64encode(data).decode("ascii")
return f"[Text attachment: {label}]\n{_extract_page_text(data, mime)}", None
def _is_mlx(raw: dict[str, Any] | str) -> bool: def _is_mlx(raw: dict[str, Any] | str) -> bool:
text = str(raw if isinstance(raw, str) else { text = str(raw if isinstance(raw, str) else {
"name": raw.get("name") or raw.get("model"), "name": raw.get("name") or raw.get("model"),
@@ -523,6 +733,111 @@ class ModelRequest(BaseModel):
name: str name: str
class ChatAttachment(BaseModel):
name: str = ""
mime_type: str = ""
data_url: str | None = None
url: str | None = None
class ChatRequest(BaseModel):
model: str
message: str = ""
history: list[dict[str, Any]] = Field(default_factory=list)
attachments: list[ChatAttachment] = Field(default_factory=list)
def _installed_model_names() -> set[str]:
return {
str(row.get("name") or row.get("model"))
for row in _local_tags()
if not _is_mlx(row)
}
def _require_installed_model(name: str) -> str:
name = _valid_name(name)
if name not in _installed_model_names():
raise HTTPException(400, f"Model '{name}' is not installed locally")
return name
def _ollama_error(exc: HTTPError) -> HTTPException:
try:
detail = exc.read().decode("utf-8", errors="replace")[:1000]
payload = json.loads(detail)
detail = str(payload.get("error") or detail)
except (OSError, ValueError):
detail = str(exc)
return HTTPException(502, f"Ollama request failed: {detail}")
def _load_model(name: str) -> dict[str, Any]:
name = _require_installed_model(name)
try:
result = _json_request(
LOCAL_OLLAMA + "/api/generate",
method="POST",
payload={"model": name, "prompt": "", "stream": False, "keep_alive": CHAT_KEEP_ALIVE, "options": {"num_predict": 1}},
timeout=900,
)
except HTTPError as exc:
raise _ollama_error(exc) from exc
return {"ok": True, "model": name, "response": result.get("response", ""), "runtime": _runtime_snapshot()}
def _chat_payload(body: ChatRequest) -> dict[str, Any]:
model = _require_installed_model(body.model)
messages: list[dict[str, Any]] = []
for item in body.history[-24:]:
role = str(item.get("role") or "")
content = str(item.get("content") or "").strip()
if role in {"user", "assistant"} and content:
messages.append({"role": role, "content": content[:MAX_ATTACHMENT_TEXT]})
text_parts = [body.message.strip()] if body.message.strip() else []
images: list[str] = []
for attachment in body.attachments[:12]:
text, image = _attachment_parts(attachment)
if text:
text_parts.append(text)
if image:
images.append(image)
if not text_parts and not images:
raise HTTPException(400, "Enter a message or attach a file/URL")
user_message: dict[str, Any] = {"role": "user", "content": "\n\n".join(text_parts)[:MAX_ATTACHMENT_TEXT]}
if images:
user_message["images"] = images
messages.append(user_message)
return {"model": model, "messages": messages, "stream": False, "keep_alive": CHAT_KEEP_ALIVE}
@router.get("/runtime")
def runtime() -> dict[str, Any]:
return _runtime_snapshot()
@router.post("/chat/load")
def chat_load(body: ModelRequest) -> dict[str, Any]:
return _load_model(body.name)
@router.post("/chat")
def chat(body: ChatRequest) -> dict[str, Any]:
payload = _chat_payload(body)
try:
result = _json_request(LOCAL_OLLAMA + "/api/chat", method="POST", payload=payload, timeout=1800)
except HTTPError as exc:
raise _ollama_error(exc) from exc
message = result.get("message") if isinstance(result.get("message"), dict) else {}
return {
"ok": True,
"model": payload["model"],
"message": {"role": "assistant", "content": str(message.get("content") or "")},
"done": bool(result.get("done", True)),
"runtime": _runtime_snapshot(),
}
@router.get("/status") @router.get("/status")
def status() -> dict[str, Any]: def status() -> dict[str, Any]:
tags = _local_tags() tags = _local_tags()
+2 -2
View File
@@ -1,3 +1,3 @@
name: ollama-manager name: ollama-manager
version: 1.2.0 version: 1.3.0
description: Native dashboard manager for local Ollama models and catalog discovery. description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry.
+2
View File
@@ -0,0 +1,2 @@
# PDF text extraction for Ollama Chat attachments
pypdf>=6.0,<7.0