From 0f3ca5bb05b687b4aa44d7d257b5feef0f49286f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 18 Aug 2026 18:03:31 +1000 Subject: [PATCH] feat: add Ollama chat and live memory telemetry --- README.md | 42 ++--- dashboard/dist/index.js | 219 +++++++++++++------------- dashboard/dist/style.css | 3 + dashboard/manifest.json | 4 +- dashboard/plugin_api.py | 321 ++++++++++++++++++++++++++++++++++++++- plugin.yaml | 4 +- requirements.txt | 2 + 7 files changed, 457 insertions(+), 138 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 9377153..593acbd 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,34 @@ # 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 -- Model size, loaded memory, estimated RAM, quantization, context, capabilities, and strengths -- Dense versus MoE classification -- Search, popular models, family variants, downloads, updates, and removal actions -- MLX model exclusion -- RAM-aware Popular view for the current 30 GiB host +- Select an installed Ollama model and load it into memory +- Chat through Ollama's native `/api/chat` endpoint +- Attach screenshots and JPEG/PNG/WebP images for vision-capable models +- Attach text PDFs; PDF text is extracted with `pypdf` +- Add public HTTP/HTTPS URLs for HTML/text, images, or PDFs +- 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 -- `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 +Install the plugin's Python dependency in the Hermes runtime environment: -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. diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 5d7e894..9d17823 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -9,73 +9,59 @@ var API = "/api/plugins/ollama-manager"; function fmtBytes(bytes) { - if (!bytes) return "Unknown"; - var units = ["B", "GiB", "TiB"], value = Number(bytes), index = 0; + if (bytes === null || bytes === undefined || Number(bytes) === 0) return "0 B"; + var units = ["B", "KiB", "MiB", "GiB", "TiB"], value = Number(bytes), index = 0; while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; } return value.toFixed(index ? 2 : 0) + " " + units[index]; } function fmtDate(value) { 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 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) { var model = props.model; - return h("div", { className: "ollama-capabilities" }, - (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-capabilities" }, (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")); + })); } + function VariantTable(props) { var model = props.model, variants = model.variants || [], action = props.action, busy = props.busy; if (!variants.length) return null; return h("div", { className: "ollama-variants" }, - h("div", { className: "ollama-variants-heading" }, - h("h4", null, "Available ", model.name.split(":")[0], " sizes"), - h("span", null, "MLX variants excluded") - ), - h("div", { className: "ollama-variant-table-wrap" }, - h("table", { className: "ollama-variant-table" }, - 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) { - var current = variant.name === model.name; - 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")) - ); - })) - ) - ) + h("div", { className: "ollama-variants-heading" }, h("h4", null, "Available ", model.name.split(":")[0], " sizes"), h("span", null, "MLX variants excluded")), + h("div", { className: "ollama-variant-table-wrap" }, h("table", { className: "ollama-variant-table" }, + 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) { + var current = variant.name === model.name, 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) { - 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 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" }, h("div", { className: "ollama-card-top" }, - h("div", { className: "ollama-model-title" }, - 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-model-title" }, h("h3", null, model.name), h("div", { className: "ollama-badge-row" }, badges)), 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, 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, "Parameters"), h("strong", null, model.parameter_size || "Unknown")) ), - 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("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("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")), 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"), @@ -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() { 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 noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[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); }); - } - 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; }); + 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); }); } + 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); 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(""); }); } + 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 || [] : 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; }); 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, 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, 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"))), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok), - h("section", { className: "ollama-toolbar" }, - h("div", { className: "ollama-tabs" }, - 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 + ")") - ), - 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 }); })) + 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); } })), + 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)")), + tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [] }), + 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 + "%")); })), + 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 }); })) ); } registry.register("ollama-manager", Page); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index 2392c22..0ddbd59 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -1,3 +1,6 @@ .ollama-page{min-height:100%;padding:28px 32px 56px;color:#e8f2ef;background:linear-gradient(135deg,rgba(17,42,39,.92),rgba(10,24,23,.98));font-family:inherit}.ollama-hero{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;border-bottom:1px solid rgba(164,211,199,.16);padding-bottom:22px}.ollama-eyebrow{font-size:10px;letter-spacing:.18em;color:#8ec8bb;font-weight:700}.ollama-hero h1{margin:7px 0 6px;font-size:30px;letter-spacing:.02em}.ollama-hero p{margin:0;color:#a5bfba;max-width:680px}.ollama-health{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;color:#9bb6b0;font-size:12px}.ollama-badge-row{display:flex;gap:6px;flex-wrap:wrap;margin-top:7px}.ollama-badge{display:inline-flex;align-items:center;border:1px solid rgba(152,204,192,.24);border-radius:999px;padding:4px 8px;color:#b4d1ca;font-size:10px;letter-spacing:.06em;text-transform:uppercase;background:rgba(80,125,115,.12)}.ollama-badge.live{color:#9af1c7;border-color:rgba(72,218,147,.4);background:rgba(35,137,91,.2)}.ollama-badge.installed{color:#bce1ff;border-color:rgba(89,168,231,.35);background:rgba(30,96,145,.2)}.ollama-badge.download{color:#f6d18f;border-color:rgba(244,182,80,.35);background:rgba(142,90,25,.18)}.ollama-badge.moe{color:#e2b4ff;border-color:rgba(197,107,255,.35);background:rgba(105,44,139,.2)}.ollama-badge.danger{color:#ffb1b1;border-color:rgba(255,100,100,.45);background:rgba(160,40,40,.18)}.ollama-button{border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font:inherit;font-size:11px;transition:background .15s,border-color .15s}.ollama-button:hover:not(:disabled){background:rgba(91,161,144,.3);border-color:rgba(155,230,210,.55)}.ollama-button:disabled{opacity:.45;cursor:not-allowed}.ollama-button.selected{background:#3e8073;border-color:#8dd2c1}.ollama-button.danger{color:#ffb8b8;border-color:rgba(255,110,110,.3)}.ollama-toolbar{display:flex;justify-content:space-between;gap:16px;margin:22px 0 12px;align-items:center}.ollama-tabs{display:flex;gap:8px;flex-wrap:wrap}.ollama-search{min-width:280px;max-width:410px;width:100%;background:rgba(7,20,19,.65);border:1px solid rgba(155,205,194,.24);border-radius:7px;color:#e8f2ef;padding:10px 12px;font:inherit;font-size:12px}.ollama-search::placeholder{color:#78958e}.ollama-info-strip{display:flex;gap:18px;flex-wrap:wrap;color:#88aaa2;font-size:11px;padding:10px 0 18px}.ollama-notice{padding:10px 12px;margin-top:15px;border-radius:7px;font-size:12px}.ollama-notice.ok{background:rgba(42,141,98,.18);border:1px solid rgba(84,222,159,.32);color:#a9f1cf}.ollama-notice.error{background:rgba(156,45,45,.18);border:1px solid rgba(255,116,116,.35);color:#ffc1c1}.ollama-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(390px,1fr));gap:15px}.ollama-model-card{border:1px solid rgba(155,205,194,.18);border-radius:11px;background:rgba(12,31,29,.78);padding:17px;box-shadow:0 12px 30px rgba(0,0,0,.12)}.ollama-card-top{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-model-title h3{margin:0;font-size:17px;word-break:break-word}.ollama-card-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.ollama-model-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:17px 0 10px}.ollama-model-summary div{padding:9px;background:rgba(87,137,126,.09);border-radius:7px;min-width:0}.ollama-model-summary small{display:block;color:#779d95;font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.ollama-model-summary strong{display:block;color:#d8ebe6;font-size:12px;overflow-wrap:anywhere}.ollama-card-meta{display:flex;gap:12px;flex-wrap:wrap;color:#8eada6;font-size:10px}.ollama-strengths{margin:13px 0;color:#b1c9c3;font-size:12px;line-height:1.5}.ollama-strengths strong{color:#dfede9}.ollama-details-toggle{padding:6px 0;border:0;background:transparent;color:#8fd1bf}.ollama-details{border-top:1px solid rgba(155,205,194,.14);margin-top:10px;padding-top:13px;color:#a9c4bd;font-size:11px}.ollama-details h4{margin:0 0 8px;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-details p{line-height:1.5}.ollama-capabilities{display:grid;gap:7px}.ollama-capability{display:flex;align-items:center;gap:8px}.ollama-capability span{color:#9cbab3}.ollama-detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:7px;color:#8eada6}.ollama-detail-grid strong{color:#d2e7e1}.ollama-jobs{display:grid;gap:7px;margin:0 0 15px}.ollama-jobs>div{display:flex;justify-content:space-between;padding:10px 12px;border-radius:7px;background:rgba(50,113,99,.2);border:1px solid rgba(112,213,187,.23);font-size:11px;color:#b6dcd2}.ollama-empty{padding:48px 14px;text-align:center;color:#8aa8a1;font-size:13px}.ollama-jobs span{color:#a7e7d0}.ollama-badge.current{color:#a9f0d1;border-color:rgba(84,222,159,.42);background:rgba(35,137,91,.22)}.ollama-badge.popular{color:#f5d18c;border-color:rgba(244,182,80,.4);background:rgba(142,90,25,.2)}.ollama-variants{margin:15px 0 5px;border:1px solid rgba(155,205,194,.17);border-radius:8px;background:rgba(5,18,17,.34);overflow:hidden}.ollama-variants-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:11px 12px;border-bottom:1px solid rgba(155,205,194,.14)}.ollama-variants-heading h4{margin:0;color:#d8ebe6;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.ollama-variants-heading span{color:#7fa69d;font-size:10px}.ollama-variant-table-wrap{overflow-x:auto}.ollama-variant-table{border-collapse:collapse;width:100%;font-size:11px;min-width:650px}.ollama-variant-table th{padding:9px 10px;text-align:left;color:#7fa69d;background:rgba(87,137,126,.08);font-size:9px;text-transform:uppercase;letter-spacing:.08em;font-weight:600}.ollama-variant-table td{padding:10px;border-top:1px solid rgba(155,205,194,.1);color:#a9c4bd;vertical-align:middle;white-space:nowrap}.ollama-variant-table tr.current{background:rgba(42,141,98,.12)}.ollama-variant-table td strong{color:#dcece8;display:block}.ollama-variant-table td small{display:block;color:#7fa69d;font-size:9px;margin-top:3px}.ollama-variant-table td .ollama-badge{margin-left:7px;vertical-align:middle}.ollama-current-label{color:#8fe1bf;font-size:10px}.ollama-variant-table .ollama-button{padding:6px 9px;font-size:10px} @media(max-width:760px){.ollama-page{padding:20px 16px 40px}.ollama-hero,.ollama-toolbar{display:block}.ollama-health{justify-content:flex-start;margin-top:15px}.ollama-search{margin-top:12px;max-width:none}.ollama-grid{grid-template-columns:1fr}.ollama-card-top{display:block}.ollama-card-actions{justify-content:flex-start;margin-top:12px}.ollama-model-summary{grid-template-columns:repeat(2,1fr)}} .ollama-popular-note{margin:14px 0;color:#a5bfba;font-size:12px;line-height:1.5} +.ollama-chat{margin-top:18px}.ollama-chat-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:18px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-chat-header h2{margin:6px 0;color:#effcf8}.ollama-chat-header p{margin:0;color:#a5bfba;font-size:12px;max-width:720px}.ollama-chat-model{display:flex;align-items:flex-end;gap:10px;min-width:260px}.ollama-chat-model label{display:flex;flex-direction:column;gap:6px;color:#a5bfba;font-size:11px}.ollama-chat-model select,.ollama-url-input{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:9px;font:inherit;font-size:12px}.ollama-runtime-panel{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-runtime-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-runtime-heading h3{margin:0 0 3px}.ollama-runtime-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-badge.muted{color:#a7b7b4;border-color:rgba(167,183,180,.25)}.ollama-runtime-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.ollama-runtime-stat{display:flex;flex-direction:column;gap:5px;min-height:70px;padding:11px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba}.ollama-runtime-stat strong{color:#effcf8;font-size:15px}.ollama-runtime-stat small{font-size:10px}.ollama-meter{height:5px;border-radius:99px;background:#173c36;overflow:hidden}.ollama-meter span{display:block;height:100%;border-radius:inherit;background:#75d2b7}.ollama-memory-chart{height:70px;display:flex;align-items:flex-end;gap:2px;margin-top:14px;padding:5px 0;border-bottom:1px solid rgba(164,211,199,.18)}.ollama-memory-chart span{flex:1;min-width:2px;background:#55a995;border-radius:2px 2px 0 0;opacity:.8}.ollama-loaded-memory h4{margin:15px 0 8px}.ollama-loaded-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:9px 0;border-top:1px solid rgba(164,211,199,.1);font-size:11px;color:#a5bfba}.ollama-loaded-row strong{color:#effcf8;min-width:180px}.ollama-chat-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(300px,420px);gap:14px;margin-top:14px}.ollama-conversation{min-height:320px;max-height:620px;overflow:auto;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,25,23,.62)}.ollama-message{margin:0 0 14px;padding:11px 13px;border-radius:9px;white-space:pre-wrap;line-height:1.5;font-size:13px}.ollama-message small{display:block;margin-bottom:5px;color:#8fc7ba;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.ollama-message.user{margin-left:12%;background:rgba(54,105,94,.3)}.ollama-message.assistant{margin-right:8%;background:rgba(34,63,62,.6)}.ollama-composer{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-composer textarea{min-height:160px;resize:vertical;border:1px solid rgba(155,205,194,.25);border-radius:8px;background:#0d2522;color:#e8f2ef;padding:11px;font:inherit;font-size:13px}.ollama-attachment-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.ollama-file-button{display:inline-flex;align-items:center;border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font-size:11px}.ollama-file-button input{display:none}.ollama-url-input{flex:1;min-width:160px}.ollama-attachments{display:flex;gap:6px;flex-wrap:wrap}.ollama-attachment{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:rgba(80,125,115,.16);color:#c5dfd8;font-size:10px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.ollama-attachment button{border:0;background:none;color:#ffb3b3;cursor:pointer}.ollama-chat-footnote{margin:0;color:#819b96;font-size:10px;line-height:1.45} +@media(max-width:900px){.ollama-chat-header,.ollama-chat-layout{display:block}.ollama-chat-model{margin-top:15px;align-items:center}.ollama-composer{margin-top:14px}.ollama-runtime-grid{grid-template-columns:1fr 1fr}} +@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}} diff --git a/dashboard/manifest.json b/dashboard/manifest.json index 99a8bfa..b6b798c 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -1,9 +1,9 @@ { "name": "ollama-manager", "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", - "version": "1.2.0", + "version": "1.3.0", "tab": {"path": "/ollama-manager", "position": "after:models"}, "entry": "dist/index.js", "css": "dist/style.css", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 1682527..c0f4418 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -1,8 +1,16 @@ """Native Hermes dashboard API for managing a local Ollama instance.""" from __future__ import annotations +import base64 +import binascii +import io +import ipaddress import json +import mimetypes +import os import re +import socket +import subprocess import threading import time import uuid @@ -12,12 +20,12 @@ from html.parser import HTMLParser from pathlib import Path from typing import Any from urllib.error import HTTPError, URLError -from urllib.parse import unquote, urlencode -from urllib.request import Request, urlopen +from urllib.parse import unquote, urlencode, urlparse +from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, Field from hermes_constants import get_hermes_home 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}$") MELBOURNE = ZoneInfo("Australia/Melbourne") 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_lock = threading.Lock() @@ -99,6 +111,204 @@ def _local_ps() -> list[dict[str, Any]]: 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" 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: text = str(raw if isinstance(raw, str) else { "name": raw.get("name") or raw.get("model"), @@ -523,6 +733,111 @@ class ModelRequest(BaseModel): 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") def status() -> dict[str, Any]: tags = _local_tags() diff --git a/plugin.yaml b/plugin.yaml index a6fffd4..824561d 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,3 +1,3 @@ name: ollama-manager -version: 1.2.0 -description: Native dashboard manager for local Ollama models and catalog discovery. +version: 1.3.0 +description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f77f95d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# PDF text extraction for Ollama Chat attachments +pypdf>=6.0,<7.0