feat: add Ollama chat and live memory telemetry
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
Vendored
+97
-104
@@ -9,44 +9,35 @@
|
|||||||
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("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) {
|
h("tbody", null, variants.map(function (variant) {
|
||||||
var current = variant.name === model.name;
|
var current = variant.name === model.name, installed = variant.installed;
|
||||||
var installed = variant.installed;
|
|
||||||
return h("tr", { key: variant.name, className: current ? "current" : "" },
|
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.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, h("strong", null, variant.size_label || "Unknown"), h("small", null, variant.expected_ram_label || "RAM unknown")),
|
||||||
@@ -55,27 +46,22 @@
|
|||||||
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("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);
|
||||||
|
|||||||
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# PDF text extraction for Ollama Chat attachments
|
||||||
|
pypdf>=6.0,<7.0
|
||||||
Reference in New Issue
Block a user