From ef9847771be59c77b30e27ab0652c86e7c035a3a Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 18 Aug 2026 17:39:37 +1000 Subject: [PATCH] Add Hermes Ollama Models dashboard plugin --- README.md | 28 ++ dashboard/dist/index.js | 188 ++++++++++++ dashboard/dist/style.css | 3 + dashboard/manifest.json | 11 + dashboard/plugin_api.py | 624 +++++++++++++++++++++++++++++++++++++++ plugin.yaml | 3 + 6 files changed, 857 insertions(+) create mode 100644 README.md create mode 100644 dashboard/dist/index.js create mode 100644 dashboard/dist/style.css create mode 100644 dashboard/manifest.json create mode 100644 dashboard/plugin_api.py create mode 100644 plugin.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..9377153 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Hermes Ollama Models + +Native-like Hermes dashboard plugin for inspecting and managing a local Ollama installation. + +## Included + +- 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 + +## RAM-aware Popular policy + +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. + +The estimate is a baseline and actual usage varies with context length, KV cache, GPU offload, batching, and runtime overhead. + +## Layout + +- `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 + +Runtime catalog data is intentionally stored in Hermes state rather than committed here. diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js new file mode 100644 index 0000000..5d7e894 --- /dev/null +++ b/dashboard/dist/index.js @@ -0,0 +1,188 @@ +(function () { + "use strict"; + var SDK = window.__HERMES_PLUGIN_SDK__; + var registry = window.__HERMES_PLUGINS__; + if (!SDK || !registry) return; + var React = SDK.React; + var h = React.createElement; + var fetchJSON = SDK.fetchJSON; + var API = "/api/plugins/ollama-manager"; + + function fmtBytes(bytes) { + if (!bytes) return "Unknown"; + var units = ["B", "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; } + } + 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 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") + ); + }) + ); + } + 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")) + ); + })) + ) + ) + ); + } + + function ModelCard(props) { + var model = props.model, installed = props.installed, busy = props.busy; + var openState = React.useState(false), open = openState[0], setOpen = openState[1]; + var action = props.action; + 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-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"), + !installed && h(Button, { disabled: !!busy, onClick: function () { action("pull", model.name); } }, busy === model.name + ":pull" ? "Downloading…" : "Download") + ) + ), + h("div", { className: "ollama-model-summary" }, + h("div", null, h("small", null, "Size"), h("strong", null, model.size_gb ? model.size_gb + " GiB" : "Unknown")), + h("div", null, h("small", null, "Expected RAM"), h("strong", null, model.expected_ram_label || "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", { 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"), + open && h("div", { className: "ollama-details" }, + h("h4", null, "Capabilities"), h(CapabilityList, { model: model }), + h("h4", null, "Runtime estimate"), + h("p", null, model.expected_ram_basis || "No estimate basis available.", " Actual memory varies with context length, KV cache, GPU offload, and concurrent requests."), + h("div", { className: "ollama-detail-grid" }, + h("span", null, "Family: ", h("strong", null, model.family || "unknown")), + h("span", null, "Digest: ", h("strong", null, model.digest ? model.digest.slice(0, 16) + "…" : "unknown")), + h("span", null, "Embedding: ", h("strong", null, model.embedding_length || "unknown")), + h("span", null, "Loaded VRAM: ", h("strong", null, fmtBytes(model.loaded_vram_bytes))) + ) + ) + ); + } + function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); } + + function Page() { + var dataState = React.useState(null), data = dataState[0], setData = dataState[1]; + var tabState = React.useState("installed"), tab = tabState[0], setTab = tabState[1]; + var queryState = React.useState(""), query = queryState[0], setQuery = queryState[1]; + 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; }); + 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") + ) + ), + 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 }); })) + ); + } + registry.register("ollama-manager", Page); +})(); diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css new file mode 100644 index 0000000..2392c22 --- /dev/null +++ b/dashboard/dist/style.css @@ -0,0 +1,3 @@ +.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} diff --git a/dashboard/manifest.json b/dashboard/manifest.json new file mode 100644 index 0000000..99a8bfa --- /dev/null +++ b/dashboard/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "ollama-manager", + "label": "Ollama Models", + "description": "Inspect, download, update, and remove local Ollama models.", + "icon": "Cpu", + "version": "1.2.0", + "tab": {"path": "/ollama-manager", "position": "after:models"}, + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py new file mode 100644 index 0000000..1682527 --- /dev/null +++ b/dashboard/plugin_api.py @@ -0,0 +1,624 @@ +"""Native Hermes dashboard API for managing a local Ollama instance.""" +from __future__ import annotations + +import json +import re +import threading +import time +import uuid +from datetime import datetime, timedelta, timezone +from html import unescape +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 zoneinfo import ZoneInfo + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from hermes_constants import get_hermes_home + +router = APIRouter() +LOCAL_OLLAMA = "http://127.0.0.1:11434" +REMOTE_OLLAMA = "https://ollama.com" +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 + +_jobs: dict[str, dict[str, Any]] = {} +_jobs_lock = threading.Lock() +_catalog_lock = threading.Lock() + +CAPABILITY_INFO = { + "completion": "Text generation and chat completion.", + "tools": "Tool/function calling for agent workflows.", + "thinking": "Explicit reasoning/thinking output support.", + "vision": "Image input and visual understanding.", + "audio": "Audio input or audio-aware inference.", + "video": "Video input or video-aware inference.", +} + +FAMILY_STRENGTHS = { + "qwen": ["general reasoning", "coding", "multilingual work", "tool use"], + "qwen35": ["general reasoning", "coding", "long-context work", "tool use"], + "gemma": ["general assistance", "reasoning", "tool use", "efficient local inference"], + "nemotron": ["reasoning", "agent workflows", "long-context work", "technical tasks"], + "deepseek": ["coding", "mathematical reasoning", "technical analysis"], + "gpt-oss": ["general reasoning", "coding", "agent workflows"], + "mistral": ["general assistance", "multilingual work", "coding"], + "kimi": ["long-context work", "reasoning", "coding"], + "minimax": ["agent workflows", "reasoning", "long-context work"], + "glm": ["reasoning", "coding", "multilingual work"], + "lfm2": ["fast local assistants", "low-resource inference", "general chat"], +} + + +def _home() -> Path: + path = get_hermes_home() / "ollama-manager" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _json_request(url: str, method: str = "GET", payload: Any = None, timeout: int = 30) -> dict[str, Any]: + data = None if payload is None else json.dumps(payload).encode("utf-8") + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + with urlopen(request, timeout=timeout) as response: + raw = response.read() + value = json.loads(raw.decode("utf-8")) if raw else {} + return value if isinstance(value, dict) else {} + + +def _valid_name(name: str) -> str: + name = str(name or "").strip() + if not MODEL_RE.fullmatch(name): + raise HTTPException(400, "Invalid Ollama model name") + return name + + +def _local_tags() -> list[dict[str, Any]]: + try: + payload = _json_request(LOCAL_OLLAMA + "/api/tags", timeout=15) + models = payload.get("models", []) + return [item for item in models if isinstance(item, dict)] + except (HTTPError, URLError, OSError, ValueError): + 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 _is_mlx(raw: dict[str, Any] | str) -> bool: + text = str(raw if isinstance(raw, str) else { + "name": raw.get("name") or raw.get("model"), + "format": (raw.get("details") or {}).get("format"), + "capabilities": raw.get("capabilities"), + }).lower() + return "mlx" in text or bool(re.search(r"(?:^|[-:])mlx(?:$|[-:])", text)) + + +def _family_key(name: str) -> str: + return str(name or "").split(":", 1)[0].strip() + + +def _known_ram_fit(model: dict[str, Any]) -> bool: + """Return True only when both size and RAM are known and fit this host.""" + size_gb = model.get("size_gb") + ram_gb = model.get("expected_ram_gb") + return ( + isinstance(size_gb, (int, float)) + and isinstance(ram_gb, (int, float)) + and float(ram_gb) <= POPULAR_RAM_LIMIT_GIB + ) + + +def _popular_fit_models( + raw_rows: list[dict[str, Any]], + family_rows: dict[str, list[dict[str, Any]]], + catalog_view, + installed_names: set[str], + loaded: dict[str, Any], +) -> list[dict[str, Any]]: + """Select popular models that are usable within this host's RAM budget. + + Oversized popular entries may be represented by the largest known smaller + family variant that fits. Unknown entries are never shown. + """ + result: list[dict[str, Any]] = [] + seen: set[str] = set() + seen_footprints: set[tuple[str, float, float]] = set() + seen_families: set[str] = set() + for raw in raw_rows: + if _is_mlx(raw): + continue + original = catalog_view(raw, "popular") + if _known_ram_fit(original): + candidate = original + else: + original_size = original.get("size_gb") + if not isinstance(original_size, (int, float)): + continue + family = _family_key(original["name"]) + variants: list[dict[str, Any]] = [] + for variant_raw in family_rows.get(family, []): + if _is_mlx(variant_raw): + continue + variant = _model_view(variant_raw, loaded.get(str(variant_raw.get("name") or variant_raw.get("model"))), source="popular") + variant["installed"] = variant["name"] in installed_names + variant["loaded"] = variant["name"] in loaded + if _known_ram_fit(variant) and float(variant.get("size_gb", 0)) < float(original_size): + variants.append(variant) + if not variants: + continue + candidate = max(variants, key=lambda item: (float(item.get("size_gb", 0)), item["name"])) + candidate["popular_origin"] = original["name"] + candidate["popular_note"] = f"Smaller fit variant for {original['name']}" + if candidate["name"] in seen: + continue + candidate_family = _family_key(candidate["name"]) + if candidate.get("popular_origin") and candidate_family in seen_families: + continue + footprint = ( + candidate_family, + round(float(candidate.get("size_gb", 0)), 2), + round(float(candidate.get("expected_ram_gb", 0)), 1), + ) + if footprint in seen_footprints: + continue + seen.add(candidate["name"]) + seen_families.add(candidate_family) + seen_footprints.add(footprint) + result.append(candidate) + for rank, row in enumerate(result, 1): + row["popularity_rank"] = rank + return result + + +def _parse_number(text: Any) -> float | None: + match = re.search(r"([0-9]+(?:\.[0-9]+)?)", str(text or "")) + return float(match.group(1)) if match else None + + +def _ram_estimate(size_bytes: Any, parameter_size: Any, quantization: Any, context_length: Any) -> tuple[float | None, str]: + size = float(size_bytes or 0) + if size > 0: + base = size / (1024 ** 3) + # Ollama's runtime needs allocator/graph overhead beyond the GGUF blob. + estimate = base * 1.15 + 0.5 + basis = "disk size × 1.15 + 0.5 GiB runtime overhead" + else: + params = _parse_number(parameter_size) + if params is None: + return None, "Unavailable: source did not publish a model size" + bits = 4.5 if "Q4" in str(quantization).upper() else 8.0 + estimate = params * (bits / 8.0) + 0.8 + basis = "parameter/quantization estimate; source size unavailable" + context = int(context_length or 0) + if context >= 524288: + estimate += 2.0 + basis += "; includes large-context allowance" + elif context >= 131072: + estimate += 1.0 + basis += "; includes long-context allowance" + return round(estimate, 1), basis + + +def _infer_capabilities(name: str, family: str, details: dict[str, Any], advertised: Any) -> list[str]: + caps = [str(item).lower() for item in advertised or [] if item] + text = f"{name} {family}".lower() + if not caps or "completion" not in caps: + caps.append("completion") + if any(token in text for token in ("vision", "vl", "gemma4", "muse-glimmer", "qwen3.8")) and "vision" not in caps: + caps.append("vision") + if any(token in text for token in ("audio", "omni")) and "audio" not in caps: + caps.append("audio") + if "video" in text and "video" not in caps: + caps.append("video") + if any(token in text for token in ("thinking", "reasoning", "nemotron", "deepseek", "qwen", "kimi")) and "thinking" not in caps: + caps.append("thinking") + if any(token in text for token in ("tool", "agent", "qwen", "gemma", "nemotron", "deepseek", "gpt-oss")) and "tools" not in caps: + caps.append("tools") + order = ["completion", "tools", "thinking", "vision", "audio", "video"] + return [cap for cap in order if cap in set(caps)] + + +def _architecture(name: str, family: str, details: dict[str, Any]) -> tuple[str, bool]: + text = f"{name} {family} {details.get('parent_model', '')}".lower() + moe = any(token in text for token in ("moe", "mixture", "_h_", "a3b")) + label = "Mixture of Experts (MoE)" if moe else "Dense / single-expert" + if family: + label += f" · {family}" + return label, moe + + +def _strengths(name: str, family: str, capabilities: list[str], context_length: Any) -> list[str]: + text = f"{name} {family}".lower() + result: list[str] = [] + for key, values in FAMILY_STRENGTHS.items(): + if key in text: + result.extend(values) + break + if "tools" in capabilities: + result.append("tool-enabled automation") + if "vision" in capabilities: + result.append("image-aware tasks") + if int(context_length or 0) >= 131072: + result.append("long documents and large codebases") + if not result: + result = ["general local inference"] + return list(dict.fromkeys(result)) + + +def _model_view(raw: dict[str, Any], loaded: dict[str, Any] | None = None, source: str = "local") -> dict[str, Any]: + details = raw.get("details") if isinstance(raw.get("details"), dict) else {} + name = str(raw.get("name") or raw.get("model") or "") + family = str(details.get("family") or (details.get("families") or [""])[0] or "") + capabilities = _infer_capabilities(name, family, details, raw.get("capabilities")) + context_length = details.get("context_length") or raw.get("context_length") + architecture, is_moe = _architecture(name, family, details) + size_bytes = raw.get("size") or 0 + ram_gb, ram_basis = _ram_estimate(size_bytes, details.get("parameter_size"), details.get("quantization_level"), context_length) + loaded = loaded or {} + return { + "name": name, + "source": source, + "downloadable": source == "catalog", + "installed": source == "local", + "loaded": bool(loaded), + "size_bytes": int(size_bytes or 0), + "size_gb": round(float(size_bytes or 0) / (1024 ** 3), 2) if size_bytes else None, + "size_label": raw.get("size_label") or (f"{round(float(size_bytes or 0) / (1024 ** 3), 2)} GiB" if size_bytes else "Unknown"), + "loaded_bytes": int(loaded.get("size") or 0), + "loaded_vram_bytes": int(loaded.get("size_vram") or 0), + "digest": raw.get("digest", ""), + "modified_at": raw.get("modified_at"), + "family": family or "unknown", + "architecture": architecture, + "is_moe": is_moe, + "parameter_size": details.get("parameter_size") or "unknown", + "quantization": details.get("quantization_level") or "unknown", + "format": details.get("format") or "unknown", + "context_length": context_length, + "input_modalities": raw.get("input_modalities") or (["Text", "Image"] if "vision" in capabilities else ["Text"]), + "embedding_length": details.get("embedding_length"), + "capabilities": capabilities, + "capability_breakdown": {cap: CAPABILITY_INFO[cap] for cap in capabilities if cap in CAPABILITY_INFO}, + "strengths": _strengths(name, family, capabilities, context_length), + "expected_ram_gb": ram_gb, + "expected_ram_label": f"{ram_gb:.1f} GiB baseline" if ram_gb is not None else "Unknown", + "expected_ram_basis": ram_basis, + } + + +class _VariantPageParser(HTMLParser): + """Extract the public Ollama tag rows without depending on third-party HTML packages.""" + + def __init__(self) -> None: + super().__init__() + self._depth = 0 + self._parts: list[str] = [] + self._href = "" + self.rows: list[dict[str, Any]] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attrs_map = dict(attrs) + classes = attrs_map.get("class") or "" + if tag == "div" and "group" in classes.split() and "px-4" in classes.split(): + self._depth = 1 + self._parts = [] + self._href = "" + return + if self._depth: + if tag == "div": + self._depth += 1 + if tag == "a" and (attrs_map.get("href") or "").startswith("/library/") and not self._href: + self._href = attrs_map["href"] or "" + + def handle_data(self, data: str) -> None: + if self._depth: + self._parts.append(data) + + def handle_endtag(self, tag: str) -> None: + if not self._depth or tag != "div": + return + self._depth -= 1 + if self._depth == 0 and self._href: + self.rows.append({"href": self._href, "text": " ".join("".join(self._parts).split())}) + self._parts = [] + self._href = "" + + +def _text_request(url: str, timeout: int = 30) -> str: + request = Request(url, headers={"Accept": "text/html"}) + with urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + + +def _size_bytes(label: str) -> int: + match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*(KB|MB|GB|TB)", label.upper()) + if not match: + return 0 + multipliers = {"KB": 10**3, "MB": 10**6, "GB": 10**9, "TB": 10**12} + return int(float(match.group(1)) * multipliers[match.group(2)]) + + +def _parse_variant_page(html: str, family: str) -> list[dict[str, Any]]: + parser = _VariantPageParser() + parser.feed(html) + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in parser.rows: + href = unescape(item["href"]) + name = unquote(href.rsplit("/", 1)[-1]) + if not name or name in seen or not name.startswith(family + ":"): + continue + seen.add(name) + text = item["text"] + size_match = re.search(r"•\s*([0-9]+(?:\.[0-9]+)?(?:KB|MB|GB|TB))\s*•", text, re.I) + context_match = re.search(r"([0-9]+)K\s+context window", text, re.I) + input_match = re.search(r"([^•]+?)\s+input\s+•", text, re.I) + size_label = size_match.group(1).upper() if size_match else ("cloud" if ("-cloud" in name or ":cloud" in name) else "Unknown") + modalities = [part.strip() for part in (input_match.group(1).split(",") if input_match else []) if part.strip()] + caps = ["vision"] if any(part.lower() == "image" for part in modalities) else [] + rows.append({ + "name": name, + "model": name, + "size": _size_bytes(size_label), + "size_label": size_label, + "modified_at": None, + "digest": "", + "details": {"family": family, "context_length": int(context_match.group(1)) * 1024 if context_match else None, "format": "gguf"}, + "capabilities": caps, + "input_modalities": modalities or ["Text"], + "is_mlx": _is_mlx(name) or bool(re.search(r"\bMLX\b", text, re.I)), + }) + return [row for row in rows if not row["is_mlx"]] + + +def _fetch_family_variants(family: str) -> list[dict[str, Any]]: + try: + html = _text_request(f"{REMOTE_OLLAMA}/library/{family}/tags", timeout=30) + return _parse_variant_page(html, family) + except (HTTPError, URLError, OSError, ValueError): + return [] + + +def _catalog_path() -> Path: + return _home() / CATALOG_FILE + + +def _read_catalog() -> dict[str, Any]: + try: + value = json.loads(_catalog_path().read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def _write_catalog(value: dict[str, Any]) -> None: + path = _catalog_path() + temp = path.with_suffix(".tmp") + temp.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temp.replace(path) + + +def _refresh_family_variants(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + families = sorted({_family_key(str(row.get("name") or row.get("model"))) for row in rows if not _is_mlx(row)}) + return {family: _fetch_family_variants(family) for family in families if family} + + +def refresh_catalog(force: bool = True) -> dict[str, Any]: + with _catalog_lock: + try: + query = urlencode({"limit": 100, "sort": "popular"}) + payload = _json_request(f"{REMOTE_OLLAMA}/api/tags?{query}", timeout=30) + rows = [item for item in payload.get("models", []) if isinstance(item, dict) and not _is_mlx(item)] + catalog = { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": f"{REMOTE_OLLAMA}/api/tags", + "models": rows, + "families": _refresh_family_variants(_local_tags()), + } + _write_catalog(catalog) + return catalog + except (HTTPError, URLError, OSError, ValueError) as exc: + cached = _read_catalog() + if cached: + cached["last_error"] = str(exc) + return cached + return {"fetched_at": None, "source": f"{REMOTE_OLLAMA}/api/tags", "models": [], "last_error": str(exc)} + + +def _catalog_stale(catalog: dict[str, Any]) -> bool: + raw = catalog.get("fetched_at") + if not raw: + return True + try: + fetched = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + return datetime.now(timezone.utc) - fetched > timedelta(hours=20) + except ValueError: + return True + + +def _ensure_catalog() -> dict[str, Any]: + catalog = _read_catalog() + if not catalog.get("models"): + return refresh_catalog() + if _catalog_stale(catalog) and not _catalog_lock.locked(): + threading.Thread(target=refresh_catalog, kwargs={"force": True}, daemon=True, name="ollama-catalog-refresh").start() + return catalog + + +def _next_refresh() -> str: + now = datetime.now(MELBOURNE) + target = now.replace(hour=1, minute=0, second=0, microsecond=0) + if now >= target: + target += timedelta(days=1) + return target.isoformat() + + +def _job_snapshot() -> list[dict[str, Any]]: + with _jobs_lock: + return [dict(item) for item in _jobs.values()] + + +def _set_job(job_id: str, **values: Any) -> None: + with _jobs_lock: + if job_id in _jobs: + _jobs[job_id].update(values, updated_at=time.time()) + + +def _run_pull(job_id: str, name: str, action: str) -> None: + try: + payload = json.dumps({"name": name, "stream": True}).encode("utf-8") + request = Request(LOCAL_OLLAMA + "/api/pull", data=payload, headers={"Content-Type": "application/json"}, method="POST") + with urlopen(request, timeout=3600) as response: + for raw_line in response: + try: + event = json.loads(raw_line.decode("utf-8")) + except ValueError: + continue + status = str(event.get("status") or "working") + completed = int(event.get("completed") or 0) + total = int(event.get("total") or 0) + percent = round(completed * 100 / total, 1) if total else None + _set_job(job_id, status=status, completed=completed, total=total, percent=percent, digest=event.get("digest")) + if event.get("error"): + raise RuntimeError(str(event["error"])) + _set_job(job_id, state="completed", status="success", percent=100) + except Exception as exc: + _set_job(job_id, state="failed", status="error", error=str(exc)) + + +def _run_delete(job_id: str, name: str) -> None: + try: + _json_request(LOCAL_OLLAMA + "/api/delete", method="DELETE", payload={"name": name}, timeout=120) + _set_job(job_id, state="completed", status="deleted", percent=100) + except Exception as exc: + _set_job(job_id, state="failed", status="error", error=str(exc)) + + +def _new_job(name: str, action: str) -> str: + job_id = uuid.uuid4().hex + with _jobs_lock: + _jobs[job_id] = {"id": job_id, "name": name, "action": action, "state": "running", "status": "starting", "percent": 0, "created_at": time.time(), "updated_at": time.time()} + target = _run_delete if action == "delete" else _run_pull + args = (job_id, name) if action == "delete" else (job_id, name, action) + threading.Thread(target=target, args=args, daemon=True, name=f"ollama-{action}-{job_id[:8]}").start() + return job_id + + +class ModelRequest(BaseModel): + name: str + + +@router.get("/status") +def status() -> dict[str, Any]: + tags = _local_tags() + ps_rows = _local_ps() + loaded = {str(row.get("name") or row.get("model")): row for row in ps_rows} + local = [ + _model_view(row, loaded.get(str(row.get("name") or row.get("model")))) + for row in tags + if not _is_mlx(row) + ] + catalog = _ensure_catalog() + installed_names = {row["name"] for row in local} + + def catalog_view(raw: dict[str, Any], source: str) -> dict[str, Any]: + name = str(raw.get("name") or raw.get("model")) + view = _model_view(raw, loaded.get(name), source=source) + view["installed"] = name in installed_names + view["loaded"] = name in loaded + return view + + downloadable = [ + catalog_view(row, "catalog") + for row in catalog.get("models", []) + if not _is_mlx(row) and str(row.get("name") or row.get("model")) not in installed_names + ] + + family_rows = catalog.get("families") if isinstance(catalog.get("families"), dict) else {} + popular = _popular_fit_models( + [row for row in catalog.get("models", []) if not _is_mlx(row)], + family_rows, + catalog_view, + installed_names, + loaded, + ) + for row in local: + family = _family_key(row["name"]) + variants = [] + for raw in family_rows.get(family, []): + if _is_mlx(raw): + continue + variant = _model_view(raw, loaded.get(raw["name"]), source="variant") + variant["installed"] = variant["name"] in installed_names + variant["current"] = variant["name"] == row["name"] + variants.append(variant) + row["variants"] = sorted(variants, key=lambda item: (item.get("size_bytes") or 0, item["name"])) + + return { + "ollama": {"available": bool(tags or ps_rows), "version": _ollama_version(), "endpoint": LOCAL_OLLAMA}, + "models": local, + "popular": popular, + "popular_filter": { + "max_expected_ram_gib": POPULAR_RAM_LIMIT_GIB, + "requires_known_size": True, + "requires_known_ram": True, + "smaller_fit_variants_substituted": True, + }, + "catalog": downloadable, + "catalog_updated_at": catalog.get("fetched_at"), + "catalog_source": catalog.get("source"), + "catalog_error": catalog.get("last_error"), + "next_catalog_refresh": _next_refresh(), + "jobs": _job_snapshot(), + "generated_at": time.time(), + } + + +def _ollama_version() -> str | None: + try: + return str(_json_request(LOCAL_OLLAMA + "/api/version", timeout=5).get("version") or "unknown") + except Exception: + return None + + +@router.post("/catalog/refresh") +def catalog_refresh() -> dict[str, Any]: + catalog = refresh_catalog() + return {"ok": bool(catalog.get("models")), "updated_at": catalog.get("fetched_at"), "count": len(catalog.get("models", [])), "error": catalog.get("last_error")} + + +@router.post("/pull") +def pull_model(body: ModelRequest) -> dict[str, Any]: + name = _valid_name(body.name) + return {"ok": True, "job_id": _new_job(name, "download"), "message": f"Downloading or updating {name}"} + + +@router.post("/redownload") +def redownload_model(body: ModelRequest) -> dict[str, Any]: + name = _valid_name(body.name) + return {"ok": True, "job_id": _new_job(name, "redownload"), "message": f"Re-downloading or updating {name}"} + + +@router.delete("/model") +def delete_model(body: ModelRequest) -> dict[str, Any]: + name = _valid_name(body.name) + return {"ok": True, "job_id": _new_job(name, "delete"), "message": f"Removing {name}"} + + +def create_ollama_routes(app) -> None: + app.include_router(router, prefix="/api/plugins/ollama-manager") diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..a6fffd4 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,3 @@ +name: ollama-manager +version: 1.2.0 +description: Native dashboard manager for local Ollama models and catalog discovery.