feat: configure and select Ollama endpoints
This commit is contained in:
Vendored
+40
-2
@@ -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)")),
|
||||
|
||||
Vendored
+1
-1
@@ -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)}}
|
||||
@@ -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",
|
||||
|
||||
+163
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user