734 lines
77 KiB
JavaScript
734 lines
77 KiB
JavaScript
(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";
|
||
var CHAT_STORAGE_KEY = "hermes.ollama-manager.chat.v1";
|
||
var PLACEMENT_STORAGE_KEY = "hermes.ollama-manager.placement.v1";
|
||
var PERFORMANCE_STORAGE_KEY = "hermes.ollama-manager.performance.v1";
|
||
|
||
function readSavedPlacements() {
|
||
try {
|
||
var raw = window.localStorage.getItem(PLACEMENT_STORAGE_KEY);
|
||
var value = raw ? JSON.parse(raw) : {};
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||
} catch (_) { return {}; }
|
||
}
|
||
function savePlacements(value) {
|
||
try { window.localStorage.setItem(PLACEMENT_STORAGE_KEY, JSON.stringify(value || {})); } catch (_) {}
|
||
}
|
||
function readSavedPerformanceSamples() {
|
||
try {
|
||
var raw = window.localStorage.getItem(PERFORMANCE_STORAGE_KEY);
|
||
var value = raw ? JSON.parse(raw) : [];
|
||
return Array.isArray(value) ? value.filter(function (sample) { return sample && typeof sample.captured_at === "number"; }).slice(-120) : [];
|
||
} catch (_) { return []; }
|
||
}
|
||
function savePerformanceSamples(value) {
|
||
try { window.localStorage.setItem(PERFORMANCE_STORAGE_KEY, JSON.stringify((value || []).slice(-120))); } catch (_) {}
|
||
}
|
||
function readSavedChat() {
|
||
try {
|
||
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
||
if (!raw) return { model: "", models: [], history: [] };
|
||
var value = JSON.parse(raw);
|
||
return {
|
||
model: typeof value.model === "string" ? value.model : "",
|
||
primaryModel: typeof value.primaryModel === "string" ? value.primaryModel : (typeof value.model === "string" ? value.model : ""),
|
||
models: Array.isArray(value.models) ? value.models.filter(function (item) { return typeof item === "string"; }).slice(0, 12) : [],
|
||
validatorModels: Array.isArray(value.validatorModels) ? value.validatorModels.filter(function (item) { return typeof item === "string"; }).slice(0, 11) : [],
|
||
history: Array.isArray(value.history) ? value.history.filter(function (item) { return item && (item.role === "user" || item.role === "assistant") && typeof item.content === "string"; }).slice(-100) : []
|
||
};
|
||
} catch (_) {
|
||
return { model: "", models: [], history: [] };
|
||
}
|
||
}
|
||
function saveChat(model, models, history) {
|
||
try {
|
||
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", primaryModel: model || "", models: (models || []).slice(0, 12), validatorModels: (models || []).slice(1, 12), history: history.slice(-100) }));
|
||
} catch (_) {}
|
||
}
|
||
|
||
function fmtBytes(bytes) {
|
||
if (bytes === null || bytes === undefined || Number(bytes) === 0) return "0 B";
|
||
var units = ["B", "KiB", "MiB", "GiB", "TiB"], value = Number(bytes), index = 0;
|
||
while (value >= 1024 && index < units.length - 1) { value /= 1024; index += 1; }
|
||
return value.toFixed(index ? 2 : 0) + " " + units[index];
|
||
}
|
||
function fmtDate(value) {
|
||
if (!value) return "Unknown";
|
||
try { return new Date(value * 1000 || value).toLocaleString(); } catch (_) { return value; }
|
||
}
|
||
function fmtElapsed(seconds) {
|
||
var total = Math.max(0, Number(seconds) || 0);
|
||
if (total < 60) return total + "s";
|
||
return Math.floor(total / 60) + "m " + String(total % 60).padStart(2, "0") + "s";
|
||
}
|
||
function Badge(props) { return h("span", { className: "ollama-badge " + (props.tone || "") }, props.children); }
|
||
function Button(props) {
|
||
var buttonProps = Object.assign({}, props);
|
||
buttonProps.className = "ollama-button" + (props.className ? " " + props.className : "");
|
||
return h("button", buttonProps, props.children);
|
||
}
|
||
function Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
|
||
|
||
function renderInline(text, keyPrefix) {
|
||
var tokens = String(text || "").split(/(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*)/g).filter(function (token) { return token !== ""; });
|
||
return tokens.map(function (token, index) {
|
||
var key = keyPrefix + "-" + index;
|
||
if (token.charAt(0) === "`" && token.charAt(token.length - 1) === "`") return h("code", { key: key, className: "ollama-inline-code" }, token.slice(1, -1));
|
||
if (token.indexOf("**") === 0 && token.lastIndexOf("**") === token.length - 2) return h("strong", { key: key }, token.slice(2, -2));
|
||
if (token.charAt(0) === "*" && token.charAt(token.length - 1) === "*") return h("em", { key: key }, token.slice(1, -1));
|
||
return token;
|
||
});
|
||
}
|
||
|
||
function RichText(props) {
|
||
var lines = String(props.content || "").split("\\n"), nodes = [], code = [], language = "", inCode = false;
|
||
lines.forEach(function (line, index) {
|
||
var fence = line.match(/^```(.*)$/);
|
||
if (fence) {
|
||
if (inCode) {
|
||
nodes.push(h("pre", { key: "code-" + index, className: "ollama-code-block" }, h("code", { className: language ? "language-" + language : "" }, code.join("\\n"))));
|
||
code = []; language = ""; inCode = false;
|
||
} else {
|
||
language = String(fence[1] || "").trim().replace(/[^A-Za-z0-9_-]/g, ""); inCode = true;
|
||
}
|
||
return;
|
||
}
|
||
if (inCode) { code.push(line); return; }
|
||
if (!line.trim()) { nodes.push(h("br", { key: "break-" + index })); return; }
|
||
nodes.push(h("div", { key: "line-" + index }, renderInline(line, "line-" + index)));
|
||
});
|
||
if (inCode) nodes.push(h("pre", { key: "code-final", className: "ollama-code-block" }, h("code", null, code.join("\\n"))));
|
||
return h("div", { className: "ollama-rich-text" }, nodes);
|
||
}
|
||
|
||
function copyText(value, onCopied) {
|
||
if (!navigator.clipboard || !navigator.clipboard.writeText) return;
|
||
navigator.clipboard.writeText(String(value || "")).then(function () { if (onCopied) onCopied(); }).catch(function () {});
|
||
}
|
||
|
||
function MessageBubble(props) {
|
||
var item = props.item || {}, assistant = item.role === "assistant";
|
||
return h("div", { className: "ollama-message " + item.role, key: props.messageKey },
|
||
h("div", { className: "ollama-message-meta" }, h("small", null, assistant ? (item.model || "Ollama") : "You"), item.created_at && h("small", null, fmtDate(item.created_at))),
|
||
h(RichText, { content: item.content }),
|
||
h("div", { className: "ollama-message-actions" },
|
||
h("button", { type: "button", onClick: function () { copyText(item.content, props.onCopied); } }, "Copy"),
|
||
assistant && props.onRetry && h("button", { type: "button", onClick: props.onRetry }, "Retry")
|
||
)
|
||
);
|
||
}
|
||
|
||
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 removeSaved(item) {
|
||
if (!item || !item.saved || !item.kind) return;
|
||
if (!window.confirm("Remove the saved " + item.kind + " Ollama configuration?")) return;
|
||
setBusy("remove:" + item.kind); setResult(null);
|
||
fetchJSON(API + "/connections/" + encodeURIComponent(item.kind), { method: "DELETE" }).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("div", { className: "ollama-connection-row", key: item.kind + ":" + item.url }, h("button", { type: "button", className: "ollama-connection-row-main", 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" + (item.model_names && item.model_names.length ? " · " + item.model_names.join(", ") : "") + (item.same_endpoint ? " · same endpoint as local" : "") : " · unavailable"))), item.saved && h(Button, { className: "ollama-connection-remove", disabled: !!busy, onClick: function () { removeSaved(item); } }, busy === "remove:" + item.kind ? "Removing…" : "Remove saved")); }) : 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) {
|
||
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, 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, action = props.action;
|
||
var openState = React.useState(false), open = openState[0], setOpen = openState[1];
|
||
var badges = [];
|
||
if (installed && model.loaded) badges.push(h(Badge, { key: "loaded", tone: "live" }, "loaded"));
|
||
if (installed) badges.push(h(Badge, { key: "installed", tone: "installed" }, "installed"));
|
||
if (!installed) badges.push(h(Badge, { key: "available", tone: "download" }, "available"));
|
||
if (model.popularity_rank) badges.push(h(Badge, { key: "popular", tone: "popular" }, "#" + model.popularity_rank + " popular"));
|
||
if (model.is_moe) badges.push(h(Badge, { key: "moe", tone: "moe" }, "MoE"));
|
||
if (model.memory_fit === false) badges.push(h(Badge, { key: "memory", tone: "danger" }, "RAM estimate exceeds host"));
|
||
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" }, badges)),
|
||
h("div", { className: "ollama-card-actions" },
|
||
installed && h(Button, { disabled: !!busy, onClick: function () { action("redownload", model.name); } }, busy === model.name + ":redownload" ? "Updating…" : "Update / re-download"),
|
||
installed && h(Button, { disabled: !!busy, className: "ollama-button danger", onClick: function () { action("delete", model.name); } }, busy === model.name + ":delete" ? "Removing…" : "Remove"),
|
||
!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"), model.activated_parameter_size && h("small", { className: "ollama-activated-parameters" }, model.activated_parameter_size, " activated"))
|
||
),
|
||
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()), model.modified_at ? h("span", null, "Last updated " + fmtDate(model.modified_at)) : h("span", { className: "ollama-date-unavailable" }, "Last updated unavailable")),
|
||
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 graphNumber(sample, key) {
|
||
if (!sample || sample[key] === null || sample[key] === undefined) return null;
|
||
var value = Number(sample[key]);
|
||
return isFinite(value) ? value : null;
|
||
}
|
||
function PerformanceGraph(props) {
|
||
var samples = props.samples || [], values = samples.map(function (sample) { return graphNumber(sample, props.valueKey); });
|
||
var max = Number(props.max || 0), observed = values.reduce(function (result, value) { return value == null ? result : Math.max(result, value); }, 0);
|
||
if (!max) max = observed > 0 ? observed * 1.15 : 1;
|
||
var width = 240, height = 84, pad = 5, points = [], latest = null;
|
||
values.forEach(function (value, index) {
|
||
if (value == null) return;
|
||
latest = value;
|
||
var x = pad + (values.length > 1 ? index * (width - pad * 2) / (values.length - 1) : (width / 2));
|
||
var y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
|
||
points.push(x.toFixed(1) + "," + y.toFixed(1));
|
||
});
|
||
var line = points.join(" "), area = points.length ? pad + "," + (height - pad) + " " + line + " " + (width - pad) + "," + (height - pad) : "";
|
||
var formatted = latest == null ? "No samples yet" : (props.format ? props.format(latest) : Number(latest).toFixed(1));
|
||
return h("article", { className: "ollama-performance-graph" },
|
||
h("div", { className: "ollama-performance-graph-heading" }, h("div", null, h("strong", null, props.title), h("small", null, props.subtitle)), h("b", null, formatted)),
|
||
h("svg", { className: "ollama-performance-svg", viewBox: "0 0 240 84", role: "img", "aria-label": props.title + " history" },
|
||
h("line", { x1: pad, y1: height - pad, x2: width - pad, y2: height - pad, className: "ollama-graph-grid" }),
|
||
h("line", { x1: pad, y1: height / 2, x2: width - pad, y2: height / 2, className: "ollama-graph-grid" }),
|
||
h("line", { x1: pad, y1: pad, x2: width - pad, y2: pad, className: "ollama-graph-grid" }),
|
||
area && h("polygon", { points: area, className: "ollama-graph-area" }),
|
||
line && h("polyline", { points: line, className: "ollama-graph-line" })
|
||
),
|
||
h("div", { className: "ollama-performance-graph-scale" }, h("span", null, "0"), h("span", null, props.range || "dynamic"))
|
||
);
|
||
}
|
||
function PerformanceGraphs(props) {
|
||
var samples = props.samples || [];
|
||
function pct(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
|
||
function gib(value) { return value == null ? "n/a" : Number(value).toFixed(2) + " GiB"; }
|
||
function count(value) { return value == null ? "n/a" : String(Math.round(value)); }
|
||
return h("section", { className: "ollama-performance-graphs" },
|
||
h("div", { className: "ollama-performance-graphs-heading" }, h("div", null, h("h3", null, "Performance history"), h("p", null, "CPU, GPU, memory, storage, swap, and Ollama residency over the last ", samples.length, " samples.")), h("span", null, samples.length ? "Live · 1 second" : "Waiting for telemetry")),
|
||
h("div", { className: "ollama-performance-grid" },
|
||
h(PerformanceGraph, { title: "CPU usage", subtitle: "Total processor utilization", valueKey: "cpu_usage_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "CPU load", subtitle: "1-minute load average", valueKey: "cpu_load_1m", range: "dynamic", format: function (value) { return Number(value).toFixed(2); }, samples: samples }),
|
||
h(PerformanceGraph, { title: "System memory", subtitle: "Used RAM", valueKey: "memory_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "GPU usage", subtitle: "Aggregate GPU utilization", valueKey: "gpu_usage_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "GPU VRAM", subtitle: "Used video memory", valueKey: "gpu_vram_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "Disk usage", subtitle: "Root filesystem", valueKey: "disk_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "Swap usage", subtitle: "Used swap memory", valueKey: "swap_used_percent", max: 100, range: "100%", format: pct, samples: samples }),
|
||
h(PerformanceGraph, { title: "Ollama model weights", subtitle: "Resident model bytes", valueKey: "ollama_model_gib", range: "dynamic", format: gib, samples: samples }),
|
||
h(PerformanceGraph, { title: "Resident models", subtitle: "Loaded Ollama model count", valueKey: "resident_model_count", range: "dynamic", format: count, samples: samples })
|
||
)
|
||
);
|
||
}
|
||
|
||
function RuntimePanel(props) {
|
||
var runtime = props.runtime || {}, total = Number(runtime.memory_total_bytes || 0), used = Number(runtime.memory_used_bytes || 0), pct = total ? Math.min(100, used * 100 / total) : 0;
|
||
var gpu = runtime.gpu || {}, cpu = runtime.cpu || {}, disk = runtime.disk || {}, models = runtime.model_memory || [], loading = runtime.model_loading || [], cores = cpu.cores || [], gpus = gpu.gpus || [];
|
||
var ollamaBytes = Number(runtime.ollama_model_bytes || 0), ollamaTargetBytes = Number(runtime.ollama_target_model_bytes || ollamaBytes), ollamaPct = total ? Math.min(100, ollamaTargetBytes * 100 / total) : 0;
|
||
function percent(value) { return value == null ? "n/a" : Number(value).toFixed(1) + "%"; }
|
||
function meter(value) { return value == null ? 0 : Math.max(0, Math.min(100, Number(value))); }
|
||
return h("section", { className: "ollama-runtime-panel" },
|
||
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory and hardware"), h("p", null, "Updates every second while this panel is open. Multiple CPUs and GPUs expand into individual cards.")), h(Badge, { tone: loading.length ? "live" : (gpu.detected ? "live" : "muted") }, loading.length ? "MODEL LOADING" : (gpu.detected ? "GPU detected" : "CPU telemetry"))),
|
||
h("div", { className: "ollama-runtime-grid" },
|
||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
|
||
h("div", { className: "ollama-runtime-stat ollama-cpu-stat" }, h("small", null, "CPU usage"), h("strong", null, percent(cpu.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(cpu.usage_percent) + "%" } })), h("small", null, cpu.count ? cpu.count + " logical CPUs · load " + (cpu.load_average || []).map(function (value) { return Number(value).toFixed(2); }).join(" / ") : "Unavailable")),
|
||
h("div", { className: "ollama-runtime-stat ollama-disk-stat" }, h("small", null, "Disk usage"), h("strong", null, percent(disk.used_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(disk.used_percent) + "%" } })), h("small", null, disk.available ? fmtBytes(disk.used_bytes) + " used · " + fmtBytes(disk.free_bytes) + " free" : "Unavailable")),
|
||
h("div", { className: "ollama-runtime-stat ollama-gpu-stat" }, h("small", null, "GPU usage"), h("strong", null, percent(gpu.utilization_percent), " · ", gpu.count || 0, " GPU", (gpu.count || 0) === 1 ? "" : "s"), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(gpu.utilization_percent) + "%" } })), h("small", null, gpu.telemetry_available && gpus.length ? gpus.map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable")),
|
||
h("div", { className: "ollama-runtime-stat ollama-weight-stat" }, h("small", null, "Ollama model weights"), h("strong", null, fmtBytes(ollamaBytes), " resident"), h("div", { className: "ollama-meter" }, h("span", { style: { width: ollamaPct + "%" } })), h("small", null, loading.length ? "Loading target: " + fmtBytes(ollamaTargetBytes) : "Mapped weight bytes; Linux may report them as file cache"))
|
||
),
|
||
cores.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "CPU cores · ", cores.length), h("small", null, "Per-core usage")), h("div", { className: "ollama-device-grid" }, cores.map(function (core) { return h("div", { className: "ollama-device-card", key: core.name }, h("strong", null, core.name.toUpperCase()), h("span", null, percent(core.usage_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(core.usage_percent) + "%" } }))); }))),
|
||
gpus.length > 1 && h("div", { className: "ollama-device-section" }, h("div", { className: "ollama-device-heading" }, h("h4", null, "GPUs · ", gpus.length), h("small", null, "Per-GPU telemetry")), h("div", { className: "ollama-device-grid" }, gpus.map(function (item) { return h("div", { className: "ollama-device-card ollama-gpu-device-card", key: item.index }, h("strong", null, "GPU ", item.index, " · ", item.name), h("span", null, "Usage ", percent(item.utilization_percent)), h("div", { className: "ollama-meter" }, h("span", { style: { width: meter(item.utilization_percent) + "%" } })), h("small", null, "VRAM ", fmtBytes(item.used_bytes), " / ", fmtBytes(item.total_bytes), " · Free ", fmtBytes(item.free_bytes)), h("small", null, item.temperature_c == null ? "Temperature n/a" : "Temperature " + item.temperature_c.toFixed(0) + "°C", " · ", item.power_watts == null ? "Power n/a" : "Power " + item.power_watts.toFixed(0) + " W")); }))),
|
||
h("div", { className: "ollama-memory-chart" + (loading.length ? " loading" : ""), role: loading.length ? "status" : undefined, "aria-live": loading.length ? "polite" : undefined }, loading.length ? h("div", { className: "ollama-loading-progress" }, h("strong", null, "Loading into Ollama memory"), h("span", null, loading.map(function (item) { return item.name; }).join(", ")), h("small", null, loading.map(function (item) { return item.stage + " · " + fmtElapsed(item.elapsed); }).join(" · ")), h("div", { className: "ollama-loading-track" }, h("span", null))) : (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
|
||
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded models and capabilities"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload"), h("span", { className: "ollama-loaded-capabilities" }, "Capabilities: ", (model.capabilities || []).join(", ") || "Unknown", " · Input: ", (model.input_modalities || []).join(", ") || "Text", " · ", model.parameter_size || "unknown", " · ", model.quantization || "unknown", " · Context ", model.context_length || "unknown"), h("span", { className: "ollama-permanent-label" }, model.permanent ? "Permanent keep-alive" : "Runtime-loaded")); }) : loading.length ? h("p", null, "Ollama is loading the selected model. Resident memory will appear here when the runner finishes starting.") : h("p", null, "No model is currently loaded. Use the model pool below to load one or more permanently."))
|
||
);
|
||
}
|
||
|
||
function ThinkingStatus(props) {
|
||
return h("div", { className: "ollama-thinking-status", role: "status", "aria-live": "polite" },
|
||
h("div", { className: "ollama-thinking-spinner", "aria-hidden": "true" }, h("span", null), h("span", null), h("span", null)),
|
||
h("div", { className: "ollama-thinking-copy" },
|
||
h("strong", null, "Ollama is thinking"),
|
||
h("span", null, props.stage),
|
||
h("small", null, "Elapsed ", fmtElapsed(props.elapsed), " · RAM telemetry is updating live")
|
||
),
|
||
h(Button, { className: "thinking-toggle", onClick: props.onToggle }, props.expanded ? "Hide details" : "Show details"),
|
||
h(Button, { className: "danger thinking-stop", onClick: props.onStop }, "Stop"),
|
||
props.expanded && h(ThinkingDetails, { details: props.details, stage: props.stage })
|
||
);
|
||
}
|
||
|
||
function readFileAsDataURL(file) {
|
||
return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onload = function () { resolve({ name: file.name, mime_type: file.type || "application/octet-stream", data_url: reader.result }); }; reader.onerror = reject; reader.readAsDataURL(file); });
|
||
}
|
||
function makeRequestId() {
|
||
if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID();
|
||
return "chat-" + Date.now() + "-" + Math.random().toString(36).slice(2);
|
||
}
|
||
function ThinkingDetails(props) {
|
||
var details = props.details || {};
|
||
return h("div", { className: "ollama-thinking-details" },
|
||
h("div", { className: "ollama-thinking-detail-grid" },
|
||
h("span", null, "State", h("strong", null, details.state || "working")),
|
||
h("span", null, "Stage", h("strong", null, details.stage || props.stage)),
|
||
h("span", null, "Events", h("strong", null, String(details.chunks || 0))),
|
||
h("span", null, "Response characters", h("strong", null, String(details.response_chars || 0))),
|
||
h("span", null, "Model-thinking characters", h("strong", null, String(details.thinking_chars || 0)))
|
||
),
|
||
h("small", null, "This window shows operational progress and telemetry, not private chain-of-thought.")
|
||
);
|
||
}
|
||
|
||
function StoragePanel(props) {
|
||
var storage = props.storage || {};
|
||
var postgres = storage.postgres || {};
|
||
var backend = storage.backend || "sqlite";
|
||
return h("section", { className: "ollama-storage-panel" },
|
||
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Chat storage"), h("p", null, "SQLite is the default. PostgreSQL is optional and remains local-only.")), h(Badge, { tone: backend === "postgres" ? "live" : "muted" }, backend === "postgres" ? "PostgreSQL" : "SQLite")),
|
||
h("div", { className: "ollama-storage-copy" }, h("strong", null, backend === "postgres" ? "Using PostgreSQL chat storage" : "Using SQLite chat storage"), h("small", null, postgres.available ? "Native PostgreSQL detected: " + (postgres.version || "version available") : "PostgreSQL is not currently linked. Switching storage does not delete existing conversations.")),
|
||
h("div", { className: "ollama-storage-actions" },
|
||
backend === "postgres" ? h(Button, { className: "secondary", onClick: function () { props.onConfigure("sqlite"); } }, "Use SQLite") : h(Button, { disabled: !postgres.available, onClick: function () { props.onConfigure("postgres"); } }, "Link PostgreSQL"),
|
||
!postgres.available && h(Button, { className: "secondary", onClick: props.onInstall }, "Install native PostgreSQL"),
|
||
h(Button, { className: "secondary", onClick: props.onRefresh }, "Refresh storage status")
|
||
),
|
||
h("small", { className: "ollama-storage-note" }, "PostgreSQL installation is an explicit host change. Chat data is not moved until you choose Link PostgreSQL.")
|
||
);
|
||
}
|
||
|
||
function ModelPoolPanel(props) {
|
||
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; }), primary = props.primaryModel || "", validators = props.validatorModels || [];
|
||
return h("section", { className: "ollama-model-pool" },
|
||
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Model pool"), h("p", null, "Choose installed models to keep permanently loaded. Loaded models remain available to Hermes Agent through Local Ollama.")), h(Badge, { tone: loaded.length ? "live" : "muted" }, loaded.length + " loaded")),
|
||
h("div", { className: "ollama-pool-grid" }, models.map(function (item) { var loaded = !!item.loaded, selected = props.poolSelection.indexOf(item.name) >= 0, placement = props.placements[item.name] || "gpu_ram"; return h("label", { className: "ollama-pool-item" + (loaded ? " loaded" : "") + (selected ? " selected" : ""), key: item.name, title: loaded ? "Loaded and resident in Ollama" : "Installed but not resident" }, h("input", { type: "checkbox", checked: selected, onChange: function () { props.onTogglePool(item.name); } }), h("span", null, h("strong", null, item.name), h("small", null, loaded ? "Loaded and resident · keep-alive active" : "Installed · not loaded", " · ", (item.capabilities || []).join(", ") || "capabilities unknown"), h("span", { className: "ollama-placement-control" }, h("small", null, "Placement"), h("select", { value: placement, onClick: function (event) { event.stopPropagation(); }, onChange: function (event) { event.stopPropagation(); props.onPlacementChange(item.name, event.target.value); } }, h("option", { value: "gpu_ram" }, "GPU + RAM (automatic offload)"), h("option", { value: "ram_only" }, "RAM only (CPU)"))))); })),
|
||
h("div", { className: "ollama-pool-actions" }, h(Button, { disabled: !props.poolSelection.length || !!props.busy, onClick: props.onLoad }, props.busy === "/models/load" ? "Loading " + props.poolSelection.length + " model" + (props.poolSelection.length === 1 ? "" : "s") + "…" : "Load selected permanently"), h(Button, { className: "secondary", disabled: !props.poolSelection.length || !!props.busy, onClick: props.onUnload }, props.busy === "/models/unload" ? "Unloading…" : "Unload selected")),
|
||
h("div", { className: "ollama-chat-model-selection" },
|
||
h("div", null, h("strong", null, "Answer harness"), h("small", null, "Choose one primary model. Add one or more validators to review its draft before the primary compiles the final answer.")),
|
||
loaded.length ? h("div", { className: "ollama-harness-primary" }, h("label", null, "Primary model", h("select", { value: primary, onChange: function (event) { props.onPrimaryChange(event.target.value); } }, loaded.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name); })))) : h("span", null, "Load one or more models above first."),
|
||
loaded.length > 1 && h("div", { className: "ollama-harness-validators" }, h("strong", null, "Validator models"), loaded.filter(function (item) { return item.name !== primary; }).map(function (item) { return h("label", { className: "loaded", key: item.name }, h("input", { type: "checkbox", checked: validators.indexOf(item.name) >= 0, onChange: function () { props.onToggleChat(item.name); } }), item.name, " · ", (item.capabilities || []).join(", ")); })),
|
||
loaded.length > 1 && h("small", { className: validators.length >= 1 ? "ollama-harness-ready" : "ollama-harness-warning" }, validators.length >= 1 ? "Validation harness ready: the primary will compile one final answer after independent checks." : "Select at least one validator model to enable the validation harness."))
|
||
);
|
||
}
|
||
|
||
function ChatPanel(props) {
|
||
var models = props.models || [];
|
||
var loadedModels = models.filter(function (item) { return item.loaded; });
|
||
var savedChatState = React.useState(function () { return readSavedChat(); })[0];
|
||
var modelState = React.useState(savedChatState.model || (loadedModels[0] ? loadedModels[0].name : (models[0] ? models[0].name : ""))), model = modelState[0], setModel = modelState[1];
|
||
var selectedModelsState = React.useState(savedChatState.models && savedChatState.models.length ? savedChatState.models : (loadedModels[0] ? [loadedModels[0].name] : [])), selectedModels = selectedModelsState[0], setSelectedModels = selectedModelsState[1];
|
||
var poolState = React.useState(loadedModels.map(function (item) { return item.name; })), poolSelection = poolState[0], setPoolSelection = poolState[1];
|
||
var poolLoadedSignature = React.useRef("");
|
||
var chatLoadedSignature = React.useRef("");
|
||
var placementState = React.useState(readSavedPlacements()), placements = placementState[0], setPlacements = placementState[1];
|
||
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
||
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
||
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
||
var historyState = React.useState(savedChatState.history), history = historyState[0], setHistory = historyState[1];
|
||
var conversationIdState = React.useState(""), conversationId = conversationIdState[0], setConversationId = conversationIdState[1];
|
||
var conversationsState = React.useState([]), conversations = conversationsState[0], setConversations = conversationsState[1];
|
||
var metricsState = React.useState(null), metrics = metricsState[0], setMetrics = metricsState[1];
|
||
var aggregateState = React.useState(null), aggregate = aggregateState[0], setAggregate = aggregateState[1];
|
||
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
|
||
var samplesState = React.useState(readSavedPerformanceSamples()), samples = samplesState[0], setSamples = samplesState[1];
|
||
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
||
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
|
||
var storageState = React.useState(null), storage = storageState[0], setStorage = storageState[1];
|
||
var thinkingState = React.useState(null), thinking = thinkingState[0], setThinking = thinkingState[1];
|
||
var thinkingElapsedState = React.useState(0), thinkingElapsed = thinkingElapsedState[0], setThinkingElapsed = thinkingElapsedState[1];
|
||
var thinkingDetailsState = React.useState(null), thinkingDetails = thinkingDetailsState[0], setThinkingDetails = thinkingDetailsState[1];
|
||
var thinkingOpenState = React.useState(false), thinkingOpen = thinkingOpenState[0], setThinkingOpen = thinkingOpenState[1];
|
||
var draggingState = React.useState(false), dragging = draggingState[0], setDragging = draggingState[1];
|
||
var activeRequestState = React.useState(null), activeRequest = activeRequestState[0], setActiveRequest = activeRequestState[1];
|
||
|
||
var thinkingId = thinking ? thinking.request_id : "";
|
||
function openConversation(id) {
|
||
if (!id) return;
|
||
fetchJSON(API + "/conversations/" + encodeURIComponent(id)).then(function (value) {
|
||
var item = value.conversation || {};
|
||
setConversationId(item.id || id);
|
||
setHistory((value.messages || []).filter(function (message) { return message.role === "user" || message.role === "assistant"; }).map(function (message) { return { id: message.id, request_id: message.request_id, role: message.role, content: message.content, created_at: message.created_at, model: message.model, attachments: message.attachments || [] }; }));
|
||
setMetrics(value.metrics || []);
|
||
if (item.model) setModel(item.model);
|
||
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models);
|
||
}).catch(function (err) { setNotice({ error: err.message || String(err) }); });
|
||
}
|
||
function refreshStorage() { fetchJSON(API + "/storage").then(setStorage).catch(function (err) { setNotice({ error: "Storage status unavailable: " + (err.message || String(err)) }); }); }
|
||
function configureStorage(backend) {
|
||
var confirmation = backend === "postgres" ? "enable-postgresql" : "use-sqlite";
|
||
if (!window.confirm(backend === "postgres" ? "Link PostgreSQL chat storage now? Existing SQLite conversations will be migrated." : "Switch new chat writes back to SQLite?")) return;
|
||
fetchJSON(API + "/storage/configure", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: backend, confirm: confirmation }) }).then(function (value) { setNotice({ ok: value.message || "Chat storage updated." }); refreshStorage(); refreshConversations(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
|
||
}
|
||
function installPostgres() {
|
||
if (!window.confirm("Install native PostgreSQL on this host? This changes host packages and services.")) return;
|
||
setNotice({ ok: "Native PostgreSQL installation started…" });
|
||
fetchJSON(API + "/storage/postgresql/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: "postgres", confirm: "install-postgresql", install: true }) }).then(function (value) { setNotice({ ok: value.message || "PostgreSQL installation completed." }); refreshStorage(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
|
||
}
|
||
function refreshConversations() {
|
||
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
|
||
return fetchJSON(API + "/conversations").then(function (value) {
|
||
var rows = value.conversations || [];
|
||
setConversations(rows);
|
||
if (!conversationId && rows.length) openConversation(rows[0].id);
|
||
return rows;
|
||
}).catch(function () { return []; });
|
||
}
|
||
function newConversation() { setConversationId(""); setHistory([]); setMetrics([]); setValidationReports(null); setNotice({ ok: "New conversation ready." }); }
|
||
React.useEffect(function () { refreshConversations(); refreshStorage(); resumeActiveJobs(); }, []);
|
||
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
|
||
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
|
||
React.useEffect(function () {
|
||
var loadedNames = loadedModels.map(function (item) { return item.name; });
|
||
var loadedSignature = loadedNames.slice().sort().join("\u001f");
|
||
var loadedChanged = loadedSignature !== poolLoadedSignature.current;
|
||
if (loadedChanged) {
|
||
poolLoadedSignature.current = loadedSignature;
|
||
setPoolSelection(function (old) { return Array.from(new Set(old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }).concat(loadedNames))); });
|
||
} else {
|
||
setPoolSelection(function (old) { return old.filter(function (name) { return models.some(function (item) { return item.name === name; }); }); });
|
||
}
|
||
if (loadedSignature !== chatLoadedSignature.current) {
|
||
chatLoadedSignature.current = loadedSignature;
|
||
setSelectedModels(function (old) { var valid = old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); return valid.length ? Array.from(new Set(valid)) : (loadedNames.length ? [loadedNames[0]] : []); });
|
||
} else {
|
||
setSelectedModels(function (old) { return old.filter(function (name) { return loadedNames.indexOf(name) >= 0; }); });
|
||
}
|
||
}, [models]);
|
||
React.useEffect(function () { saveChat(model, selectedModels, history); }, [model, selectedModels, history]);
|
||
React.useEffect(function () {
|
||
if (!thinking) { setThinkingElapsed(0); return; }
|
||
function tick() { setThinkingElapsed(Math.floor((Date.now() - thinking.startedAt) / 1000)); }
|
||
tick();
|
||
var timer = setInterval(tick, 1000);
|
||
return function () { clearInterval(timer); };
|
||
}, [thinking]);
|
||
React.useEffect(function () {
|
||
if (!thinkingId) return;
|
||
function pollThinking() { fetchJSON(API + "/chat/status/" + encodeURIComponent(thinkingId)).then(setThinkingDetails).catch(function () {}); }
|
||
pollThinking();
|
||
var timer = setInterval(pollThinking, 1000);
|
||
return function () { clearInterval(timer); };
|
||
}, [thinkingId]);
|
||
function clearChat() {
|
||
var id = conversationId;
|
||
setHistory([]); setMetrics([]); setValidationReports(null); setConversationId("");
|
||
if (id) fetchJSON(API + "/conversations/" + encodeURIComponent(id), { method: "DELETE" }).catch(function () {});
|
||
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
|
||
refreshConversations();
|
||
setNotice({ ok: "Conversation deleted from shared storage." });
|
||
}
|
||
function pollRuntime() {
|
||
fetchJSON(API + "/runtime").then(function (value) {
|
||
var total = Number(value.memory_total_bytes || 0), used = Number(value.memory_used_bytes || 0), gpu = value.gpu || {}, gpus = gpu.gpus || [], gpuTotal = gpus.reduce(function (sum, item) { return sum + Number(item.total_bytes || 0); }, 0), gpuUsed = gpus.reduce(function (sum, item) { return sum + Number(item.used_bytes || 0); }, 0), swapTotal = Number(value.swap_total_bytes || 0), swapUsed = Number(value.swap_used_bytes || 0);
|
||
setRuntime(value);
|
||
setSamples(function (old) { return old.concat([{
|
||
captured_at: Number(value.captured_at || Date.now() / 1000),
|
||
cpu_usage_percent: value.cpu && value.cpu.usage_percent,
|
||
cpu_load_1m: value.cpu && value.cpu.load_average && value.cpu.load_average[0],
|
||
memory_used_percent: total ? used * 100 / total : null,
|
||
gpu_usage_percent: gpu.utilization_percent,
|
||
gpu_vram_used_percent: gpuTotal ? gpuUsed * 100 / gpuTotal : null,
|
||
disk_used_percent: value.disk && value.disk.used_percent,
|
||
swap_used_percent: swapTotal ? swapUsed * 100 / swapTotal : null,
|
||
ollama_model_gib: Number(value.ollama_model_bytes || 0) / (1024 * 1024 * 1024),
|
||
resident_model_count: Array.isArray(value.model_memory) ? value.model_memory.length : 0
|
||
}]).slice(-120); });
|
||
}).catch(function () {});
|
||
}
|
||
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
|
||
React.useEffect(function () { savePerformanceSamples(samples); }, [samples]);
|
||
|
||
React.useEffect(function () { savePlacements(placements); }, [placements]);
|
||
function toggleIn(setter, name) { setter(function (old) { return old.indexOf(name) >= 0 ? old.filter(function (item) { return item !== name; }) : old.concat([name]); }); }
|
||
function setPlacement(name, value) { setPlacements(function (old) { var next = Object.assign({}, old); next[name] = value === "ram_only" ? "ram_only" : "gpu_ram"; return next; }); }
|
||
function manageModels(endpoint, label) {
|
||
if (!poolSelection.length) { setNotice({ error: "Select one or more installed models first." }); return; }
|
||
var requested = poolSelection.slice();
|
||
var requestedPlacements = requested.reduce(function (result, name) { result[name] = placements[name] || "gpu_ram"; return result; }, {});
|
||
setBusy(endpoint); setNotice({ ok: label + " in progress for " + requested.length + " model" + (requested.length === 1 ? "" : "s") + "…" });
|
||
fetchJSON(API + endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names: requested, placements: requestedPlacements }) }).then(function (result) {
|
||
if (endpoint === "/models/load") {
|
||
var resident = Array.isArray(result.resident) ? result.resident : ((result.runtime && result.runtime.model_memory) || []).map(function (item) { return item.name; });
|
||
var missing = Array.isArray(result.not_resident) ? result.not_resident : requested.filter(function (name) { return resident.indexOf(name) < 0; });
|
||
if (result.memory_safety && result.memory_safety.triggered) {
|
||
setNotice({ warning: result.message + " Observed RAM: " + (result.memory_safety.usage_percent == null ? "unknown" : result.memory_safety.usage_percent + "%") + "." });
|
||
} else {
|
||
setNotice(missing.length ? { warning: "Ollama kept resident: " + resident.join(", ") + ". Not resident: " + missing.join(", ") + ". This is an Ollama scheduler/capacity warning, not a plugin error." } : { ok: result.message || ("Loaded and resident: " + resident.join(", ")) });
|
||
}
|
||
} else {
|
||
setNotice({ ok: label + ": " + requested.join(", ") });
|
||
}
|
||
pollRuntime(); if (props.refresh) props.refresh();
|
||
}).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||
}
|
||
function loadModel() { manageModels("/models/load", "Permanently loaded"); }
|
||
function unloadModels() { manageModels("/models/unload", "Unloaded"); }
|
||
function setPrimaryModel(name) { if (!loadedModels.some(function (item) { return item.name === name; })) return; setModel(name); setSelectedModels(function (old) { return [name].concat(old.filter(function (item) { return item !== name; })); }); }
|
||
function toggleChatModel(name) { if (name === selectedModels[0] || !loadedModels.some(function (item) { return item.name === name; })) return; toggleIn(setSelectedModels, name); }
|
||
function togglePoolModel(name) { toggleIn(setPoolSelection, name); }
|
||
function addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
|
||
function addFiles(files) {
|
||
var selected = Array.prototype.slice.call(files || []).filter(function (file) { return file && file.size <= 20 * 1024 * 1024; }).slice(0, 12);
|
||
if (!selected.length) { setNotice({ error: "No supported files were added, or a file exceeded the 20 MiB limit." }); return; }
|
||
Promise.all(selected.map(readFileAsDataURL)).then(function (items) { setAttachments(function (old) { return old.concat(items); }); setNotice({ ok: selected.length + " file" + (selected.length === 1 ? "" : "s") + " attached." }); }).catch(function (err) { setNotice({ error: err.message || "Could not read the selected files." }); });
|
||
}
|
||
function onFiles(event) { addFiles(event.target.files || []); event.target.value = ""; }
|
||
function onPaste(event) {
|
||
var files = [];
|
||
Array.prototype.slice.call((event.clipboardData && event.clipboardData.items) || []).forEach(function (item) { if (item.kind === "file") { var file = item.getAsFile(); if (file) files.push(file); } });
|
||
if (files.length) { event.preventDefault(); addFiles(files); }
|
||
}
|
||
function onDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDragging(true); }
|
||
function onDragLeave(event) { if (!event.currentTarget.contains(event.relatedTarget)) setDragging(false); }
|
||
function onDrop(event) { event.preventDefault(); setDragging(false); addFiles(event.dataTransfer.files || []); }
|
||
function stop() {
|
||
var current = activeRequest;
|
||
if (!current || !thinking) return;
|
||
current.stopped = true;
|
||
setBusy("stop");
|
||
fetchJSON(API + "/chat/stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: current.id }) }).catch(function () {}).finally(function () {
|
||
if (current.controller) current.controller.abort();
|
||
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
|
||
});
|
||
}
|
||
function pollChatJob(requestId, current) {
|
||
return fetchJSON(API + "/chat/status/" + encodeURIComponent(requestId)).then(function (status) {
|
||
setThinkingDetails(status);
|
||
if (current.stopped) throw new Error("Chat stopped by user");
|
||
if (status.done) {
|
||
if (status.status !== "completed") throw new Error(status.error || "Server-side chat job did not complete");
|
||
return status;
|
||
}
|
||
return new Promise(function (resolve) { setTimeout(resolve, 1200); }).then(function () { return pollChatJob(requestId, current); });
|
||
});
|
||
}
|
||
function applyChatResult(result) {
|
||
var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)";
|
||
var resolvedConversationId = result.conversation_id || conversationId;
|
||
setConversationId(resolvedConversationId);
|
||
setHistory(function (old) {
|
||
var last = old.length ? old[old.length - 1] : null;
|
||
return last && last.role === "assistant" && last.content === answer ? old : old.concat([{ role: "assistant", content: answer }]);
|
||
});
|
||
setMetrics(result.metrics || []);
|
||
setValidationReports(result.mode === "harness" ? (result.validation_reports || []) : null);
|
||
setAttachments([]);
|
||
setRuntime(result.runtime || runtime);
|
||
setNotice({ ok: result.mode === "harness" ? "One final answer compiled by " + result.primary_model + " after validation by " + (result.validator_models || []).join(", ") + "." : "Response complete. Shared conversation and performance metrics saved." });
|
||
pollRuntime(); refreshConversations();
|
||
return result;
|
||
}
|
||
function resumeActiveJobs() {
|
||
return fetchJSON(API + "/chat/jobs?active=true&limit=20").then(function (value) {
|
||
var jobs = value.jobs || [];
|
||
var active = jobs.find(function (job) { return !job.done && (job.status === "queued" || job.status === "running" || job.status === "stopping"); });
|
||
if (!active) return null;
|
||
var current = { id: active.request_id, controller: null, stopped: false };
|
||
setConversationId(active.conversation_id || "");
|
||
setActiveRequest(current);
|
||
setBusy("send");
|
||
setThinking({ request_id: active.request_id, startedAt: (Number(active.started_at || active.updated_at || Date.now() / 1000) * 1000), stage: active.stage || "Resuming server-side chat job" });
|
||
setThinkingDetails(active);
|
||
if (active.conversation_id) openConversation(active.conversation_id);
|
||
return pollChatJob(active.request_id, current).then(applyChatResult).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
|
||
}).catch(function () { return null; });
|
||
}
|
||
function retryMessage(index) {
|
||
if (busy === "send" || busy === "stop") return;
|
||
var prior = history.slice(0, index).reverse().find(function (item) { return item.role === "user"; });
|
||
if (prior && prior.content) send(prior.content);
|
||
}
|
||
function send(messageOverride) {
|
||
var outgoingMessage = messageOverride == null ? message : String(messageOverride);
|
||
if (busy === "send" || busy === "stop" || !selectedModels.length || (!outgoingMessage.trim() && !attachments.length)) return;
|
||
var requestId = makeRequestId();
|
||
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
||
var current = { id: requestId, controller: controller, stopped: false };
|
||
var outgoing = { role: "user", content: outgoingMessage.trim() || "[Attachments]" }, body = { model: selectedModels[0], models: selectedModels, primary_model: selectedModels[0], validator_models: selectedModels.slice(1), harness: selectedModels.length > 1, placements: placements, message: outgoingMessage, history: [], attachments: attachments, request_id: requestId, conversation_id: conversationId };
|
||
setHistory(function (old) { return old.concat([outgoing]); }); setMessage(""); setBusy("send"); setActiveRequest(current); setThinking({ request_id: requestId, startedAt: Date.now(), stage: attachments.length ? "Preparing attachments and sending request to Ollama" : "Sending request to Ollama" }); setThinkingDetails(null); setThinkingOpen(false); setNotice(null);
|
||
var requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
||
if (controller) requestOptions.signal = controller.signal;
|
||
fetchJSON(API + "/chat", requestOptions).then(function (result) { return result.done ? result : pollChatJob(requestId, current); }).then(applyChatResult).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
|
||
}
|
||
return h("section", { className: "ollama-chat" },
|
||
h("div", { className: "ollama-chat-header" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL OLLAMA CHAT"), h("h2", null, "Chat with Ollama"), h("p", null, "Direct chat is the default. Enable quality review in the controls when you want independent validator checks.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
|
||
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
|
||
h("div", { className: "ollama-chat-shell" },
|
||
h("aside", { className: "ollama-conversation-rail" },
|
||
h("div", { className: "ollama-rail-heading" }, h("div", null, h("h3", null, "Conversations"), h("small", null, "Shared on this Hermes server")), h(Button, { className: "secondary", onClick: newConversation }, "New")),
|
||
h("div", { className: "ollama-conversation-list" }, conversations.length ? conversations.map(function (item) { return h(Button, { key: item.id, className: item.id === conversationId ? "selected" : "", onClick: function () { openConversation(item.id); } }, h("span", null, (item.title || "New conversation").slice(0, 52)), h("small", null, item.message_count || 0, " messages")); }) : h("small", null, "No saved conversations yet.")),
|
||
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, aggregate.sample_count || 0, " samples"), h("span", null, "TTFT ", aggregate.avg_time_to_first_token_ms == null ? "n/a" : aggregate.avg_time_to_first_token_ms + " ms"), h("span", null, "Output ", aggregate.avg_eval_tokens_per_second == null ? "n/a" : aggregate.avg_eval_tokens_per_second + " tok/s"), h("span", null, "Errors ", aggregate.error_count || 0))
|
||
),
|
||
h("div", { className: "ollama-chat-main" },
|
||
thinking && h(ThinkingStatus, { stage: thinkingDetails && thinkingDetails.stage ? thinkingDetails.stage : (thinkingElapsed < 1 ? thinking.stage : "Ollama is generating the response"), elapsed: thinkingDetails && thinkingDetails.elapsed != null ? thinkingDetails.elapsed : thinkingElapsed, details: thinkingDetails, expanded: thinkingOpen, onToggle: function () { setThinkingOpen(!thinkingOpen); }, onStop: stop }),
|
||
validationReports && validationReports.length > 0 && h("details", { className: "ollama-validation-evidence" }, h("summary", null, "Validation evidence · ", validationReports.length, " independent reports"), validationReports.map(function (item) { return h("div", { className: "ollama-validation-report", key: item.model }, h("strong", null, item.model), h("p", null, item.report || "No report text returned.")); })),
|
||
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h(MessageBubble, { item: item, messageKey: item.id || item.request_id || index, key: item.id || item.request_id || index, onCopied: function () { setNotice({ ok: "Message copied." }); }, onRetry: item.role === "assistant" ? function () { retryMessage(index); } : null }); }) : h(Empty, null, "Start a conversation. Select a loaded model in the controls, then send a message.")),
|
||
h("div", { className: "ollama-composer" + (dragging ? " drop-active" : ""), onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop }, dragging && h("div", { className: "ollama-drop-hint" }, "Drop files here to attach"), h("textarea", { value: message, placeholder: selectedModels.length ? "Ask " + selectedModels.length + " loaded model" + (selectedModels.length === 1 ? "" : "s") + "… Press Enter to send; Shift+Enter for a new line." : "Load and select at least one model in Chat controls…", onPaste: onPaste, onChange: function (event) { setMessage(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); send(); } } }),
|
||
h("div", { className: "ollama-attachment-actions" }, h("label", { className: "ollama-file-button" }, "Attach image / PDF / file", h("input", { type: "file", multiple: true, accept: "image/*,application/pdf,text/*,.txt,.md,.csv,.json,.log,.xml,.yaml,.yml", onChange: onFiles })), h("input", { className: "ollama-url-input", value: url, placeholder: "https://example.com/document", onChange: function (event) { setUrl(event.target.value); }, onKeyDown: function (event) { if (event.key === "Enter") addUrl(); } }), h(Button, { onClick: addUrl, disabled: !url.trim() }, "Add URL"), h(Button, { onClick: send, disabled: busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length) }, busy === "send" ? "Sending…" : "Send (Enter)")),
|
||
attachments.length > 0 && h("div", { className: "ollama-attachments" }, attachments.map(function (item, index) { return h("span", { className: "ollama-attachment", key: index }, item.name || item.url, h("button", { type: "button", onClick: function () { setAttachments(function (old) { return old.filter(function (_, i) { return i !== index; }); }); } }, "×")); })),
|
||
h("p", { className: "ollama-chat-footnote" }, "Limits: 20 MiB per uploaded file, 15 MiB per fetched URL. Private/local URL targets are blocked. Remote content is treated as untrusted text.")
|
||
)
|
||
),
|
||
h("aside", { className: "ollama-chat-controls" },
|
||
h("div", { className: "ollama-controls-heading" }, h("h3", null, "Chat controls"), h("small", null, "Model, quality, and runtime")),
|
||
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
|
||
h("details", { className: "ollama-advanced-control" }, h("summary", null, "Chat storage"), h(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage })),
|
||
h("div", { className: "ollama-advanced-note" }, "Runtime cards and historical performance graphs are shown in Operations / Performance below.")
|
||
)
|
||
),
|
||
h("section", { className: "ollama-operations-section" },
|
||
h("div", { className: "ollama-operations-heading" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "OPERATIONS / PERFORMANCE"), h("h3", null, "Runtime telemetry and historical graphs"), h("p", null, "The complete operational view is preserved below the chat so it remains available without competing with the conversation.")), h("span", null, "CPU · GPU · RAM · disk · swap")),
|
||
h(RuntimePanel, { runtime: runtime, samples: samples }),
|
||
h(PerformanceGraphs, { samples: samples })
|
||
)
|
||
);
|
||
}
|
||
|
||
function Page() {
|
||
var dataState = React.useState(null), data = dataState[0], setData = dataState[1];
|
||
var tabState = React.useState("chat"), tab = tabState[0], setTab = tabState[1];
|
||
var queryState = React.useState(""), query = queryState[0], setQuery = queryState[1];
|
||
var catalogTypeState = React.useState("all"), catalogType = catalogTypeState[0], setCatalogType = catalogTypeState[1];
|
||
var catalogCapabilityState = React.useState("all"), catalogCapability = catalogCapabilityState[0], setCatalogCapability = catalogCapabilityState[1];
|
||
var catalogSortState = React.useState("popularity"), catalogSort = catalogSortState[0], setCatalogSort = catalogSortState[1];
|
||
var recentOnlyState = React.useState(true), recentOnly = recentOnlyState[0], setRecentOnly = recentOnlyState[1];
|
||
var showOversizedState = React.useState(false), showOversized = showOversizedState[0], setShowOversized = showOversizedState[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];
|
||
var catalogState = React.useState({ rows: [], total: 0, page: 1, has_more: false, capabilities: [] }), catalogData = catalogState[0], setCatalogData = catalogState[1];
|
||
var catalogLoadingState = React.useState(false), catalogLoading = catalogLoadingState[0], setCatalogLoading = catalogLoadingState[1];
|
||
var loadSequence = React.useRef(0);
|
||
var catalogSequence = React.useRef(0);
|
||
function load() {
|
||
var sequence = ++loadSequence.current;
|
||
return fetchJSON(API + "/status").then(function (value) { if (sequence !== loadSequence.current) return value; setData(value); setLoading(false); return value; }).catch(function (err) { if (sequence === loadSequence.current) { setNotice({ error: err.message || String(err) }); setLoading(false); } });
|
||
}
|
||
function loadCatalog(nextPage) {
|
||
var page = nextPage || 1;
|
||
var sequence = ++catalogSequence.current;
|
||
var params = new URLSearchParams({ q: query, page: String(page), page_size: "60", model_type: catalogType, capability: catalogCapability, sort: catalogSort, recent_only: recentOnly ? "true" : "false", show_oversized: showOversized ? "true" : "false" });
|
||
setCatalogLoading(true);
|
||
return fetchJSON(API + "/catalog?" + params.toString()).then(function (value) {
|
||
if (sequence !== catalogSequence.current) return value;
|
||
setCatalogData(function (old) { return page > 1 ? Object.assign({}, value, { rows: (old.rows || []).concat(value.catalog || []), capabilities: old.capabilities || ((value.catalog_filter_options || {}).capabilities || []) }) : Object.assign({}, value, { rows: value.catalog || [], capabilities: (value.catalog_filter_options || {}).capabilities || [] }); });
|
||
return value;
|
||
}).catch(function (err) { if (sequence === catalogSequence.current) setNotice({ error: err.message || String(err) }); }).finally(function () { if (sequence === catalogSequence.current) setCatalogLoading(false); });
|
||
}
|
||
React.useEffect(function () { load(); var timer = setInterval(load, 10000); return function () { clearInterval(timer); }; }, []);
|
||
React.useEffect(function () { if (tab !== "catalog") return; var timer = setTimeout(function () { loadCatalog(1); }, 250); return function () { clearTimeout(timer); }; }, [tab, query, catalogType, catalogCapability, catalogSort, recentOnly, showOversized]);
|
||
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 || other.url === item.url; }) === 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" ? catalogData.rows || [] : []) : [];
|
||
var models = baseModels;
|
||
if (tab !== "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 catalogCapabilities = catalogData.capabilities && catalogData.capabilities.length ? catalogData.capabilities : (data && data.catalog_filter_options ? data.catalog_filter_options.capabilities || [] : []);
|
||
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
|
||
var disk = data && data.disk ? data.disk : {};
|
||
var navTabs = h("nav", { className: "ollama-tabs", "aria-label": "Ollama views" },
|
||
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 (" + (tab === "catalog" ? (catalogData.total || 0) : ((data && data.catalog_count) || 0)) + ")")
|
||
);
|
||
var catalogControls = 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")
|
||
)),
|
||
h("label", { className: "ollama-catalog-checkbox", title: "Models with no published source date remain visible." }, h("input", { type: "checkbox", checked: recentOnly, onChange: function (event) { setRecentOnly(event.target.checked); } }), h("span", null, "Hide models older than 12 months")),
|
||
h("label", { className: "ollama-catalog-checkbox ollama-catalog-memory-bypass", title: "This only bypasses the catalog display filter; loading remains protected by the 95% RAM safety guard." }, h("input", { type: "checkbox", checked: showOversized, onChange: function (event) { setShowOversized(event.target.checked); } }), h("span", null, "Show models above estimated RAM"))
|
||
);
|
||
var browseToolbar = tab !== "chat" && h("div", { className: "ollama-browse-row" },
|
||
h("input", { className: "ollama-search", value: query, placeholder: "Search models, capabilities, or strengths…", onChange: function (event) { setQuery(event.target.value); } }),
|
||
catalogControls
|
||
);
|
||
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" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
|
||
h("section", { className: "ollama-toolbar" }, h("div", { className: "ollama-nav-row" }, navTabs, h("div", { className: "ollama-toolbar-disk" }, h("span", null, "Disk"), h("strong", null, disk.used_percent == null ? "n/a" : Number(disk.used_percent).toFixed(1) + "%"), h("small", null, disk.available ? fmtBytes(disk.free_bytes) + " free" : "Unavailable"))), browseToolbar),
|
||
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, tab === "catalog" ? (catalogData.total || 0) + " matching downloads · showing " + models.length : "Next daily check " + (data && data.next_catalog_refresh ? fmtDate(data.next_catalog_refresh) : "01:00 Melbourne time") + " (1:00 AM Melbourne time)")),
|
||
tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [], refresh: load }),
|
||
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below the detected system RAM (" + (data && data.popular_filter && data.popular_filter.max_expected_ram_gib ? data.popular_filter.max_expected_ram_gib + " GiB" : "detecting…") + "). 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 + " · " + (job.target || "local") + (job.endpoint ? " · " + job.endpoint : "")), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
|
||
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab === "catalog" && catalogLoading && !models.length && h(Empty, null, "Searching the Ollama catalog…"), tab !== "chat" && !loading && !catalogLoading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries match these filters."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); })), tab === "catalog" && catalogData.has_more && h("div", { className: "ollama-catalog-more" }, h(Button, { className: "secondary", disabled: catalogLoading, onClick: function () { loadCatalog((catalogData.page || 1) + 1); } }, catalogLoading ? "Loading more…" : "Load more models"))
|
||
);
|
||
}
|
||
registry.register("ollama-manager", Page);
|
||
})();
|