From d89251b5a8d997d7ca93fa63f4daa5fb544eb3d8 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 20:58:51 +1000 Subject: [PATCH] feat: configure and select Ollama endpoints --- README.md | 7 +- dashboard/dist/index.js | 42 +++++++++- dashboard/dist/style.css | 2 +- dashboard/manifest.json | 2 +- dashboard/plugin_api.py | 175 ++++++++++++++++++++++++++++++++++++--- plugin.yaml | 2 +- 6 files changed, 212 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bfc7c01..4c2dd5f 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,12 @@ https://gitea.beyondcloud.solutions/dennii/Hermes-Ollama_Models.git In Hermes Dashboard, open **Plugins**, choose **Install from repository**, enter the URL above, and install. The repository contains the root `plugin.yaml`, dashboard manifest, backend API, compiled frontend bundle, stylesheet, and an opt-in prerequisite declaration. On Linux, the Hermes installer will verify Ollama, install it with the official Ollama installer when missing, and install the plugin's `pypdf` dependency before committing the plugin into `~/.hermes/plugins/`. Ollama installation requires the Hermes container to run as root, which is the expected configuration for a privileged ZimaOS deployment. If the container is not running as root, the plugin install stops without enabling a partially configured plugin. After installation or an update, restart only the Hermes dashboard service if requested by the installer. -## Physical and Docker deployments +The dashboard now includes a connection panel in the header. Enter an Ollama base URL, choose **Local** or **Remote**, and use **Test** before **Save**. Saved URLs are stored in Hermes plugin data with file permissions restricted to the Hermes account. Credential-bearing URLs are rejected. + +The plugin probes the configured local endpoint and known Docker endpoints, and tests a configured remote endpoint. A successful connection reports the Ollama version and installed model count. The active local endpoint continues to drive chat, status, load, and runtime telemetry. + +When both local and remote endpoints are online, downloading or re-downloading a model opens a destination chooser. The selected target is recorded on the job and the pull is sent to that endpoint. Remote downloads do not alter the local installed-model inventory. + The plugin supports both deployment types: diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index af9a01b..cc7ffe8 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -52,6 +52,32 @@ } function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); } + function ConnectionPanel(props) { + var data = props.data || {}, ollama = data.ollama || {}, connectionState = React.useState(ollama.endpoint || data.active_url || ""), url = connectionState[0], setUrl = connectionState[1]; + var roleState = React.useState("local"), role = roleState[0], setRole = roleState[1]; + var resultState = React.useState(null), result = resultState[0], setResult = resultState[1]; + var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; + React.useEffect(function () { if (!url && (ollama.endpoint || data.active_url)) setUrl(ollama.endpoint || data.active_url); }, [ollama.endpoint, data.active_url]); + function test() { + if (!url.trim()) return; + setBusy("test"); setResult(null); + fetchJSON(API + "/connections/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), role: role }) }).then(function (value) { setResult({ ok: true, value: value }); }).catch(function (err) { setResult({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); + } + function save() { + if (!url.trim()) return; + setBusy("save"); setResult(null); + fetchJSON(API + "/connections/configure", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), role: role }) }).then(function (value) { setResult({ ok: true, value: value }); if (props.reload) props.reload(); }).catch(function (err) { setResult({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); + } + function useDetected(item) { setUrl(item.url || ""); setRole(item.kind || "local"); } + var rows = data.connections || []; + return h("section", { className: "ollama-connection-panel" }, + h("div", { className: "ollama-connection-heading" }, h("div", null, h("strong", null, "Ollama connection"), h("small", null, ollama.containerized ? "Docker runtime · local and remote endpoints supported" : "Physical runtime · local and remote endpoints supported"))), + h("div", { className: "ollama-connection-form" }, h("select", { className: "ollama-connection-role", value: role, onChange: function (event) { setRole(event.target.value); } }, h("option", { value: "local" }, "Local"), h("option", { value: "remote" }, "Remote")), h("input", { className: "ollama-connection-input", value: url, placeholder: "http://host-or-container:11434", onChange: function (event) { setUrl(event.target.value); } }), h(Button, { onClick: test, disabled: !url.trim() || !!busy }, busy === "test" ? "Testing…" : "Test"), h(Button, { onClick: save, disabled: !url.trim() || !!busy }, busy === "save" ? "Saving…" : "Save")), + result && h("div", { className: "ollama-connection-result " + (result.error ? "error" : "ok") }, result.error || ((result.value && result.value.version) ? "Online · v" + result.value.version + " · " + (result.value.models || 0) + " models" : "Connection accepted")), + h("div", { className: "ollama-connection-list" }, rows.length ? rows.map(function (item) { return h("button", { type: "button", className: "ollama-connection-row", key: item.kind + ":" + item.url, onClick: function () { useDetected(item); } }, h("span", { className: "ollama-connection-dot " + (item.available ? "online" : "offline") }), h("span", null, h("strong", null, (item.kind || "local").toUpperCase(), " · ", item.label || item.url), h("small", null, item.url, item.available ? " · v" + item.version + " · " + item.models + " models" : " · unavailable"))); }) : h("small", null, "No endpoints detected yet. Enter an Ollama URL and test it.")) + ); + } + function CapabilityList(props) { var model = props.model; return h("div", { className: "ollama-capabilities" }, (model.capabilities || []).map(function (cap) { @@ -342,10 +368,21 @@ var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1]; var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1]; + var targetDialogState = React.useState(null), targetDialog = targetDialogState[0], setTargetDialog = targetDialogState[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); fetchJSON(API + (kind === "delete" ? "/model" : "/" + kind), { method: kind === "delete" ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: name }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } + function action(kind, name, selectedTarget) { + if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; + if ((kind === "pull" || kind === "redownload") && !selectedTarget) { + var availableTargets = (data && data.connections ? data.connections : []).filter(function (item) { return item.available && (item.kind === "local" || item.kind === "remote"); }); + var uniqueTargets = availableTargets.filter(function (item, index, rows) { return rows.findIndex(function (other) { return other.kind === item.kind; }) === index; }); + if (uniqueTargets.length > 1) { setTargetDialog({ kind: kind, name: name, targets: uniqueTargets }); return; } + selectedTarget = uniqueTargets.length ? uniqueTargets[0].kind : "local"; + } + 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, target: selectedTarget || "local" }) }).then(function (result) { setNotice({ ok: result.message || "Action started." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); + } function refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); } var baseModels = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : []; var models = baseModels; @@ -364,7 +401,8 @@ 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 catalogCapabilities = data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : []; 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, 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")), h(ConnectionPanel, { data: data, reload: load })), + targetDialog && h("div", { className: "ollama-target-modal" }, h("div", { className: "ollama-target-card" }, h("h3", null, "Where should " + targetDialog.name + " be downloaded?"), h("p", null, "Both local and remote Ollama instances are online. Choose the destination for this model."), targetDialog.targets.map(function (item) { return h(Button, { key: item.kind, onClick: function () { var chosen = targetDialog; setTargetDialog(null); action(chosen.kind, chosen.name, item.kind); } }, (item.kind || "local").toUpperCase(), " · ", item.url, " · v", item.version, " · ", item.models, " models"); }), h(Button, { className: "secondary", onClick: function () { setTargetDialog(null); } }, "Cancel"))), 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 === "chat" ? "selected" : "", onClick: function () { setTab("chat"); } }, "Ollama Chat"), h(Button, { className: tab === "installed" ? "selected" : "", onClick: function () { setTab("installed"); } }, "Installed (" + ((data && data.models) || []).length + ")"), h(Button, { className: tab === "popular" ? "selected" : "", onClick: function () { setTab("popular"); } }, "Top 20 popular (" + ((data && data.popular) || []).length + ")"), h(Button, { className: tab === "catalog" ? "selected" : "", onClick: function () { setTab("catalog"); } }, "Available downloads (" + ((data && data.catalog) || []).length + ")")), tab !== "chat" && h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }), tab === "catalog" && h("div", { className: "ollama-catalog-controls" }, h("label", null, "Type", h("select", { className: "ollama-catalog-select", value: catalogType, onChange: function (event) { setCatalogType(event.target.value); } }, h("option", { value: "all" }, "All types"), h("option", { value: "moe" }, "MoE only"), h("option", { value: "dense" }, "Dense only"))), h("label", null, "Ability", h("select", { className: "ollama-catalog-select", value: catalogCapability, onChange: function (event) { setCatalogCapability(event.target.value); } }, h("option", { value: "all" }, "All abilities"), catalogCapabilities.map(function (capability) { return h("option", { key: capability, value: capability }, capability); }))), h("label", null, "Organize", h("select", { className: "ollama-catalog-select", value: catalogSort, onChange: function (event) { setCatalogSort(event.target.value); } }, h("option", { value: "popularity" }, "Popularity"), h("option", { value: "newest" }, "Newest"), h("option", { value: "size_asc" }, "Size: smallest first"), h("option", { value: "size_desc" }, "Size: largest first"), h("option", { value: "name" }, "Name"))))), 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)")), diff --git a/dashboard/dist/style.css b/dashboard/dist/style.css index ad0fb20..2cacdb4 100644 --- a/dashboard/dist/style.css +++ b/dashboard/dist/style.css @@ -5,6 +5,6 @@ .ollama-chat{margin-top:18px}.ollama-chat-header{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;padding:18px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-chat-header h2{margin:6px 0;color:#effcf8}.ollama-chat-header p{margin:0;color:#a5bfba;font-size:12px;max-width:720px}.ollama-chat-model{display:flex;align-items:flex-end;gap:10px;min-width:260px}.ollama-chat-model label{display:flex;flex-direction:column;gap:6px;color:#a5bfba;font-size:11px}.ollama-chat-model select,.ollama-url-input{border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:9px;font:inherit;font-size:12px}.ollama-runtime-panel{margin-top:14px;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-runtime-heading{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.ollama-runtime-heading h3{margin:0 0 3px}.ollama-runtime-heading p{margin:0;color:#8fa9a4;font-size:11px}.ollama-badge.muted{color:#a7b7b4;border-color:rgba(167,183,180,.25)}.ollama-runtime-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:14px}.ollama-runtime-stat{display:flex;flex-direction:column;gap:5px;min-height:70px;padding:11px;border-radius:8px;background:rgba(71,117,108,.11);color:#a5bfba}.ollama-runtime-stat strong{color:#effcf8;font-size:15px}.ollama-runtime-stat small{font-size:10px}.ollama-meter{height:5px;border-radius:99px;background:#173c36;overflow:hidden}.ollama-meter span{display:block;height:100%;border-radius:inherit;background:#75d2b7}.ollama-memory-chart{height:70px;display:flex;align-items:flex-end;gap:2px;margin-top:14px;padding:5px 0;border-bottom:1px solid rgba(164,211,199,.18)}.ollama-memory-chart span{flex:1;min-width:2px;background:#55a995;border-radius:2px 2px 0 0;opacity:.8}.ollama-loaded-memory h4{margin:15px 0 8px}.ollama-loaded-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:9px 0;border-top:1px solid rgba(164,211,199,.1);font-size:11px;color:#a5bfba}.ollama-loaded-row strong{color:#effcf8;min-width:180px}.ollama-chat-layout{display:flex;flex-direction:column;gap:14px;margin-top:14px}.ollama-conversation{min-height:360px;max-height:620px;overflow:auto;order:1}.ollama-composer{order:2}.ollama-conversation{min-height:320px;max-height:620px;overflow:auto;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,25,23,.62)}.ollama-message{margin:0 0 14px;padding:11px 13px;border-radius:9px;white-space:pre-wrap;line-height:1.5;font-size:13px}.ollama-message small{display:block;margin-bottom:5px;color:#8fc7ba;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.ollama-message.user{margin-left:12%;background:rgba(54,105,94,.3)}.ollama-message.assistant{margin-right:8%;background:rgba(34,63,62,.6)}.ollama-composer{display:flex;flex-direction:column;gap:10px;padding:14px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(23,52,48,.42)}.ollama-composer textarea{min-height:160px;resize:vertical;border:1px solid rgba(155,205,194,.25);border-radius:8px;background:#0d2522;color:#e8f2ef;padding:11px;font:inherit;font-size:13px}.ollama-attachment-actions{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.ollama-file-button{display:inline-flex;align-items:center;border:1px solid rgba(155,205,194,.24);border-radius:7px;background:rgba(70,117,108,.18);color:#dbebe7;padding:8px 11px;cursor:pointer;font-size:11px}.ollama-file-button input{display:none}.ollama-url-input{flex:1;min-width:160px}.ollama-attachments{display:flex;gap:6px;flex-wrap:wrap}.ollama-attachment{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border-radius:999px;background:rgba(80,125,115,.16);color:#c5dfd8;font-size:10px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.ollama-attachment button{border:0;background:none;color:#ffb3b3;cursor:pointer}.ollama-chat-footnote{margin:0;color:#819b96;font-size:10px;line-height:1.45} @media(max-width:900px){.ollama-chat-header,.ollama-chat-layout{display:block}.ollama-chat-model{margin-top:15px;align-items:center}.ollama-composer{margin-top:14px}.ollama-runtime-grid{grid-template-columns:1fr 1fr}} .ollama-persistence-panel{display:grid;gap:12px;margin:16px 0;padding:16px;border:1px solid rgba(164,211,199,.16);border-radius:12px;background:rgba(10,31,28,.7)}.ollama-persistence-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.ollama-persistence-heading h3{margin:0}.ollama-persistence-heading p{margin:4px 0 0;color:#8fa9a4;font-size:11px}.ollama-conversation-list{display:flex;flex-wrap:wrap;gap:8px}.ollama-conversation-list .ollama-button{font-size:11px}.ollama-conversation-list .selected{background:#3e8073;border-color:#8dd2c1}.ollama-metrics-summary,.ollama-metrics-detail{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#a5bfba}.ollama-metrics-summary strong{color:#effcf8}.ollama-metrics-detail{padding-top:8px;border-top:1px solid rgba(164,211,199,.14)} -@media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}} +@media(max-width:600px){.ollama-runtime-grid{grid-template-columns:1fr}.ollama-chat-model{display:block}.ollama-chat-model select{width:100%;margin-bottom:8px}.ollama-message.user{margin-left:0}.ollama-message.assistant{margin-right:0}}.ollama-connection-panel{min-width:340px;max-width:620px;margin-top:14px;padding:12px;border:1px solid rgba(164,211,199,.2);border-radius:10px;background:rgba(10,31,28,.72);box-shadow:0 8px 24px rgba(0,0,0,.12)}.ollama-connection-heading{display:flex;justify-content:space-between;gap:10px;color:#d7ebe5}.ollama-connection-heading strong{font-size:12px}.ollama-connection-heading small{display:block;margin-top:3px;color:#8fa9a4;font-size:10px}.ollama-connection-form{display:flex;gap:6px;align-items:center;margin-top:9px}.ollama-connection-role,.ollama-connection-input{border:1px solid rgba(155,205,194,.28);border-radius:6px;background:#102d29;color:#e8f2ef;padding:7px;font:inherit;font-size:11px}.ollama-connection-input{min-width:190px;flex:1}.ollama-connection-result{margin-top:7px;font-size:10px}.ollama-connection-result.ok{color:#9af1c7}.ollama-connection-result.error{color:#ffb1b1}.ollama-connection-list{display:grid;gap:5px;margin-top:8px}.ollama-connection-row{display:flex;align-items:center;gap:7px;width:100%;border:0;border-top:1px solid rgba(164,211,199,.1);padding:7px 0;background:none;color:#c5ddd7;text-align:left;cursor:pointer;font:inherit}.ollama-connection-row span:nth-child(2){display:flex;flex-direction:column;gap:2px;min-width:0}.ollama-connection-row strong{font-size:10px}.ollama-connection-row small{color:#8fa9a4;font-size:9px;overflow-wrap:anywhere}.ollama-connection-dot{width:7px;height:7px;border-radius:50%;background:#b36d6d;flex:0 0 auto}.ollama-connection-dot.online{background:#75d2b7;box-shadow:0 0 8px rgba(117,210,183,.55)}.ollama-target-modal{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(4,15,14,.72)}.ollama-target-card{display:grid;gap:10px;max-width:560px;width:100%;padding:20px;border:1px solid rgba(141,210,193,.38);border-radius:12px;background:#102d29;box-shadow:0 14px 50px rgba(0,0,0,.35)}.ollama-target-card h3{margin:0;color:#effcf8}.ollama-target-card p{margin:0;color:#a5bfba;font-size:12px}.ollama-target-card .ollama-button{text-align:left}@media(max-width:900px){.ollama-connection-panel{min-width:0;max-width:none}.ollama-connection-form{flex-wrap:wrap}.ollama-connection-input{min-width:160px}} .ollama-catalog-controls{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:12px;padding:10px;border:1px solid rgba(164,211,199,.16);border-radius:10px;background:rgba(10,31,28,.55)}.ollama-catalog-controls label{display:flex;flex-direction:column;gap:5px;color:#a5bfba;font-size:10px;text-transform:uppercase;letter-spacing:.06em}.ollama-catalog-select{min-width:145px;border:1px solid rgba(155,205,194,.28);border-radius:7px;background:#102d29;color:#e8f2ef;padding:8px;font:inherit;font-size:11px;text-transform:none;letter-spacing:normal} .ollama-thinking-status{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:14px;padding:14px 16px;border:1px solid rgba(117,210,183,.42);border-radius:10px;background:linear-gradient(90deg,rgba(46,111,96,.34),rgba(24,64,57,.5));box-shadow:0 0 20px rgba(74,190,158,.08)}.ollama-thinking-copy{flex:1;min-width:200px}.ollama-thinking-details{flex-basis:100%;padding:12px;border-top:1px solid rgba(164,211,199,.17);color:#a5bfba}.ollama-thinking-detail-grid{display:grid;grid-template-columns:repeat(5,minmax(100px,1fr));gap:8px;margin-bottom:8px}.ollama-thinking-detail-grid span{display:flex;flex-direction:column;gap:3px;padding:8px;border-radius:7px;background:rgba(71,117,108,.11);font-size:10px;color:#8fb5ac}.ollama-thinking-detail-grid strong{color:#e4f4ef;font-size:11px;overflow-wrap:anywhere}.ollama-thinking-details small{font-size:10px;color:#819b96}.ollama-thinking-status .thinking-stop{color:#ffb8b8;border-color:rgba(255,110,110,.45)}.ollama-composer.drop-active{border-color:rgba(117,210,183,.8);background:linear-gradient(135deg,rgba(33,92,79,.52),rgba(23,52,48,.62));box-shadow:0 0 24px rgba(117,210,183,.16)}.ollama-drop-hint{padding:9px;border:1px dashed rgba(117,210,183,.7);border-radius:7px;text-align:center;color:#b8ead9;font-size:11px;background:rgba(117,210,183,.08)}.ollama-thinking-spinner{display:flex;align-items:center;gap:4px;min-width:28px}.ollama-thinking-spinner span{width:7px;height:7px;border-radius:50%;background:#75d2b7;animation:ollama-thinking-pulse 1.1s ease-in-out infinite}.ollama-thinking-spinner span:nth-child(2){animation-delay:.18s}.ollama-thinking-spinner span:nth-child(3){animation-delay:.36s}.ollama-thinking-copy{display:flex;flex-direction:column;gap:3px}.ollama-thinking-copy strong{color:#effcf8;font-size:13px}.ollama-thinking-copy span{color:#b9d8d0;font-size:12px}.ollama-thinking-copy small{color:#8fb5ac;font-size:10px}@keyframes ollama-thinking-pulse{0%,80%,100%{opacity:.35;transform:scale(.8)}40%{opacity:1;transform:scale(1.2)}} \ No newline at end of file diff --git a/dashboard/manifest.json b/dashboard/manifest.json index c5a9759..01b5fe3 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "Ollama Models", "description": "Inspect, manage, and chat with local Ollama models, including shared persistent conversations, performance metrics, images, PDFs, URLs, and live memory telemetry.", "icon": "Cpu", - "version": "1.5.4", + "version": "1.5.5", "tab": {"path": "/ollama-manager", "position": "after:models"}, "entry": "dist/index.js", "css": "dist/style.css", diff --git a/dashboard/plugin_api.py b/dashboard/plugin_api.py index 1c79524..5cd7eb9 100644 --- a/dashboard/plugin_api.py +++ b/dashboard/plugin_api.py @@ -268,6 +268,104 @@ def _home() -> Path: return path +CONNECTIONS_FILE = "connections.json" + + +def _valid_ollama_url(value: str) -> str: + value = str(value or "").strip().rstrip("/") + parsed = urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password: + raise HTTPException(400, "Ollama URL must be an http(s) URL without credentials") + if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: + raise HTTPException(400, "Ollama URL must be a base URL without a path, query, or fragment") + try: + if parsed.port is not None and not 1 <= parsed.port <= 65535: + raise ValueError + except ValueError as exc: + raise HTTPException(400, "Ollama URL has an invalid port") from exc + return value + + +def _read_connections() -> dict[str, str]: + try: + value = json.loads((_home() / CONNECTIONS_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + value = {} + return {key: str(value.get(key) or "").strip().rstrip("/") for key in ("active_url", "local_url", "remote_url")} + + +def _write_connections(value: dict[str, str]) -> None: + path = _home() / CONNECTIONS_FILE + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + try: + path.chmod(0o600) + except OSError: + pass + + +def _apply_saved_connection() -> None: + global LOCAL_OLLAMA + if os.environ.get("OLLAMA_HOST", "").strip(): + return + saved = _read_connections().get("active_url") + if saved: + LOCAL_OLLAMA = saved + + +def _target_endpoint(target: str = "local") -> str: + target = str(target or "local").strip().lower() + if target not in {"local", "remote"}: + raise HTTPException(400, "Target must be local or remote") + saved = _read_connections() + if target == "remote": + endpoint = saved.get("remote_url") + if not endpoint: + raise HTTPException(400, "No remote Ollama URL is configured") + return _valid_ollama_url(endpoint) + _apply_saved_connection() + return _valid_ollama_url(saved.get("local_url") or LOCAL_OLLAMA) + + +def _probe_endpoint(url: str, timeout: int = 5) -> dict[str, Any]: + url = _valid_ollama_url(url) + version_payload = _json_request(url + "/api/version", timeout=timeout) + tags_payload = _json_request(url + "/api/tags", timeout=timeout) + models = tags_payload.get("models", []) + return {"available": True, "url": url, "version": str(version_payload.get("version") or "unknown"), "models": len(models) if isinstance(models, list) else 0} + + +def _connection_snapshot() -> list[dict[str, Any]]: + saved = _read_connections() + configured_local = os.environ.get("OLLAMA_HOST", "").strip() or saved.get("local_url") + candidates: list[tuple[str, str, str]] = [] + if configured_local: + candidates.append(("local", "Configured local endpoint", configured_local)) + elif _running_in_container(): + candidates.extend((("local", "Docker Ollama service", "http://ollama:11434"), ("local", "Docker host Ollama", "http://host.docker.internal:11434"))) + else: + candidates.append(("local", "Physical host Ollama", "http://localhost:11434")) + if saved.get("remote_url"): + candidates.append(("remote", "Configured remote endpoint", saved["remote_url"])) + results: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for kind, label, url in candidates: + try: + normalized = _valid_ollama_url(url) + except HTTPException: + continue + key = (kind, normalized) + if key in seen: + continue + seen.add(key) + try: + result = _probe_endpoint(normalized, timeout=3) + result.update({"kind": kind, "label": label}) + except Exception as exc: + result = {"available": False, "kind": kind, "label": label, "url": normalized, "error": str(exc)[:240]} + results.append(result) + return results + + 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"} @@ -288,6 +386,7 @@ def _valid_name(name: str) -> str: def _local_tags() -> list[dict[str, Any]]: + _apply_saved_connection() _discover_ollama_endpoint() try: payload = _json_request(LOCAL_OLLAMA + "/api/tags", timeout=15) @@ -902,10 +1001,11 @@ def _set_job(job_id: str, **values: Any) -> None: _jobs[job_id].update(values, updated_at=time.time()) -def _run_pull(job_id: str, name: str, action: str) -> None: +def _run_pull(job_id: str, name: str, action: str, target: str) -> None: try: + endpoint = _target_endpoint(target) 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") + request = Request(endpoint + "/api/pull", data=payload, headers={"Content-Type": "application/json"}, method="POST") with urlopen(request, timeout=3600) as response: for raw_line in response: try: @@ -924,26 +1024,36 @@ def _run_pull(job_id: str, name: str, action: str) -> None: _set_job(job_id, state="failed", status="error", error=str(exc)) -def _run_delete(job_id: str, name: str) -> None: +def _run_delete(job_id: str, name: str, target: str) -> None: try: - _json_request(LOCAL_OLLAMA + "/api/delete", method="DELETE", payload={"name": name}, timeout=120) + endpoint = _target_endpoint(target) + _json_request(endpoint + "/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: +def _new_job(name: str, action: str, target: str = "local") -> str: + target = str(target or "local").strip().lower() + if target not in {"local", "remote"}: + raise HTTPException(400, "Target must be local or remote") 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() + _jobs[job_id] = {"id": job_id, "name": name, "action": action, "target": target, "state": "running", "status": "starting", "percent": 0, "created_at": time.time(), "updated_at": time.time()} + target_fn = _run_delete if action == "delete" else _run_pull + args = (job_id, name, target) if action == "delete" else (job_id, name, action, target) + threading.Thread(target=target_fn, args=args, daemon=True, name=f"ollama-{action}-{job_id[:8]}").start() return job_id class ModelRequest(BaseModel): name: str + target: str = "local" + + +class ConnectionRequest(BaseModel): + url: str + role: str = "local" class ModelsRequest(BaseModel): @@ -1362,6 +1472,39 @@ def chat(body: ChatRequest) -> dict[str, Any]: return {"ok": True, "request_id": request_id, "conversation_id": conversation_id, "model": selected[0], "models": selected, "message": {"role": "assistant", "content": combined}, "model_responses": {name: str((results.get(name, {}).get("message") or {}).get("content") or "") for name in selected if name in results}, "metrics": [results[name].get("metrics") for name in selected if name in results], "errors": errors, "done": True, "runtime": _runtime_snapshot()} +@router.get("/connections") +def connections() -> dict[str, Any]: + saved = _read_connections() + active = os.environ.get("OLLAMA_HOST", "").strip() or saved.get("active_url") or LOCAL_OLLAMA + return {"active_url": active, "connections": _connection_snapshot(), "containerized": _running_in_container()} + + +@router.post("/connections/test") +def connections_test(body: ConnectionRequest) -> dict[str, Any]: + result = _probe_endpoint(body.url, timeout=8) + result["role"] = str(body.role or "local").strip().lower() + return result + + +@router.post("/connections/configure") +def connections_configure(body: ConnectionRequest) -> dict[str, Any]: + url = _valid_ollama_url(body.url) + role = str(body.role or "local").strip().lower() + if role not in {"local", "remote"}: + raise HTTPException(400, "Connection role must be local or remote") + saved = _read_connections() + saved[f"{role}_url"] = url + if role == "local" and not os.environ.get("OLLAMA_HOST", "").strip(): + saved["active_url"] = url + _write_connections(saved) + if role == "local" and not os.environ.get("OLLAMA_HOST", "").strip(): + global LOCAL_OLLAMA + LOCAL_OLLAMA = url + result = _probe_endpoint(url, timeout=8) + result.update({"ok": True, "role": role, "message": f"Saved {role} Ollama endpoint"}) + return result + + @router.get("/status") def status() -> dict[str, Any]: tags = _local_tags() @@ -1425,6 +1568,7 @@ def status() -> dict[str, Any]: row["variants"] = sorted(variants, key=lambda item: (item.get("size_bytes") or 0, item["name"])) ollama_version = _ollama_version() + connection_rows = _connection_snapshot() return { "ollama": { "available": bool(tags or ps_rows or ollama_version), @@ -1439,6 +1583,7 @@ def status() -> dict[str, Any]: else "" ), }, + "connections": connection_rows, "models": local, "popular": popular, "popular_filter": { @@ -1484,19 +1629,25 @@ def catalog_refresh() -> dict[str, Any]: @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}"} + target = str(body.target or "local").strip().lower() + _target_endpoint(target) + return {"ok": True, "job_id": _new_job(name, "download", target), "message": f"Downloading or updating {name} on {target}"} @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}"} + target = str(body.target or "local").strip().lower() + _target_endpoint(target) + return {"ok": True, "job_id": _new_job(name, "redownload", target), "message": f"Re-downloading or updating {name} on {target}"} @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}"} + target = str(body.target or "local").strip().lower() + _target_endpoint(target) + return {"ok": True, "job_id": _new_job(name, "delete", target), "message": f"Removing {name} from {target}"} def create_ollama_routes(app) -> None: diff --git a/plugin.yaml b/plugin.yaml index dd8a106..46632cf 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: ollama-manager -version: 1.5.4 +version: 1.5.5 description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, shared persistent conversations, performance metrics, and live runtime telemetry. auto_install_dependencies: true python_dependencies: