299 lines
30 KiB
JavaScript
299 lines
30 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";
|
||
|
||
function readSavedChat() {
|
||
try {
|
||
var raw = window.localStorage.getItem(CHAT_STORAGE_KEY);
|
||
if (!raw) return { model: "", history: [] };
|
||
var value = JSON.parse(raw);
|
||
return {
|
||
model: typeof value.model === "string" ? value.model : "",
|
||
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: "", history: [] };
|
||
}
|
||
}
|
||
function saveChat(model, history) {
|
||
try {
|
||
window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify({ model: model || "", 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 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"));
|
||
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"))
|
||
),
|
||
h("div", { className: "ollama-card-meta" }, h("span", null, (model.quantization || "Unknown") + " · " + (model.format || "Unknown")), model.context_length && h("span", null, "Context " + Number(model.context_length).toLocaleString()), installed && model.modified_at && h("span", null, "Updated " + fmtDate(model.modified_at))),
|
||
h("div", { className: "ollama-strengths" }, h("strong", null, "Excels at: "), (model.strengths || []).join(" · ")),
|
||
installed && h(VariantTable, { model: model, action: action, busy: busy }),
|
||
h(Button, { className: "ollama-details-toggle", onClick: function () { setOpen(!open); } }, open ? "Hide capability breakdown" : "Show capability breakdown"),
|
||
open && h("div", { className: "ollama-details" },
|
||
h("h4", null, "Capabilities"), h(CapabilityList, { model: model }),
|
||
h("h4", null, "Runtime estimate"),
|
||
h("p", null, model.expected_ram_basis || "No estimate basis available.", " Actual memory varies with context length, KV cache, GPU offload, and concurrent requests."),
|
||
h("div", { className: "ollama-detail-grid" },
|
||
h("span", null, "Family: ", h("strong", null, model.family || "unknown")),
|
||
h("span", null, "Digest: ", h("strong", null, model.digest ? model.digest.slice(0, 16) + "…" : "unknown")),
|
||
h("span", null, "Embedding: ", h("strong", null, model.embedding_length || "unknown")),
|
||
h("span", null, "Loaded VRAM: ", h("strong", null, fmtBytes(model.loaded_vram_bytes)))
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
function RuntimePanel(props) {
|
||
var runtime = props.runtime || {}, total = Number(runtime.memory_total_bytes || 0), used = Number(runtime.memory_used_bytes || 0), pct = total ? Math.min(100, used * 100 / total) : 0;
|
||
var gpu = runtime.gpu || {}, models = runtime.model_memory || [];
|
||
return h("section", { className: "ollama-runtime-panel" },
|
||
h("div", { className: "ollama-runtime-heading" }, h("div", null, h("h3", null, "Live runtime memory"), h("p", null, "Updates every second while this panel is open.")), h(Badge, { tone: gpu.detected ? "live" : "muted" }, gpu.detected ? "GPU detected" : "CPU-only / no supported GPU telemetry")),
|
||
h("div", { className: "ollama-runtime-grid" },
|
||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "System RAM used"), h("strong", null, fmtBytes(used), " / ", fmtBytes(total)), h("div", { className: "ollama-meter" }, h("span", { style: { width: pct + "%" } })), h("small", null, fmtBytes(runtime.memory_available_bytes || 0), " available")),
|
||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "Swap used"), h("strong", null, fmtBytes(runtime.swap_used_bytes || 0), " / ", fmtBytes(runtime.swap_total_bytes || 0)), h("small", null, "Host-wide live statistic")),
|
||
h("div", { className: "ollama-runtime-stat" }, h("small", null, "GPU telemetry"), h("strong", null, gpu.telemetry_available ? (gpu.gpus || []).map(function (item) { return item.name + " · " + fmtBytes(item.used_bytes) + " / " + fmtBytes(item.total_bytes); }).join("; ") : "Unavailable"), h("small", null, gpu.detected ? "Ollama VRAM split is still shown below." : "No supported GPU was detected."))
|
||
),
|
||
h("div", { className: "ollama-memory-chart" }, (props.samples || []).map(function (sample, index) { var height = sample.total ? Math.max(3, Math.min(100, sample.used * 100 / sample.total)) : 3; return h("span", { key: index, title: fmtBytes(sample.used) + " used", style: { height: height + "%" } }); })),
|
||
h("div", { className: "ollama-loaded-memory" }, h("h4", null, "Loaded model placement"), models.length ? models.map(function (model) { return h("div", { className: "ollama-loaded-row", key: model.name }, h("strong", null, model.name), h("span", null, "Total ", fmtBytes(model.total_bytes)), h("span", null, "GPU VRAM ", fmtBytes(model.gpu_bytes)), h("span", null, "Normal RAM ", fmtBytes(model.ram_bytes)), h("span", null, model.gpu_offload_percent + "% GPU offload")); }) : h("p", null, "No model is currently loaded. Select a model and press Load model, or send a message."))
|
||
);
|
||
}
|
||
|
||
function 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 ChatPanel(props) {
|
||
var models = props.models || [];
|
||
var savedChatState = React.useState(function () { return readSavedChat(); })[0];
|
||
var modelState = React.useState(savedChatState.model || (models[0] ? models[0].name : "")), model = modelState[0], setModel = modelState[1];
|
||
var messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
|
||
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
|
||
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[1];
|
||
var historyState = React.useState(savedChatState.history), history = historyState[0], setHistory = historyState[1];
|
||
var runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
|
||
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
|
||
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
||
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 : "";
|
||
React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]);
|
||
React.useEffect(function () { saveChat(model, history); }, [model, 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, 500);
|
||
return function () { clearInterval(timer); };
|
||
}, [thinkingId]);
|
||
function clearChat() {
|
||
setHistory([]);
|
||
try { window.localStorage.removeItem(CHAT_STORAGE_KEY); } catch (_) {}
|
||
setNotice({ ok: "Chat history cleared from this browser." });
|
||
}
|
||
function pollRuntime() { fetchJSON(API + "/runtime").then(function (value) { setRuntime(value); setSamples(function (old) { return old.concat([{ used: Number(value.memory_used_bytes || 0), total: Number(value.memory_total_bytes || 0) }]).slice(-60); }); }).catch(function () {}); }
|
||
React.useEffect(function () { pollRuntime(); var timer = setInterval(pollRuntime, 1000); return function () { clearInterval(timer); }; }, []);
|
||
|
||
function loadModel() {
|
||
if (!model) return;
|
||
setBusy("load"); setNotice(null);
|
||
fetchJSON(API + "/chat/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model }) }).then(function () { setNotice({ ok: model + " loaded or refreshed in Ollama memory." }); pollRuntime(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); });
|
||
}
|
||
function addUrl() { if (!url.trim()) return; setAttachments(function (old) { return old.concat([{ name: url.trim(), url: url.trim(), mime_type: "" }]); }); setUrl(""); }
|
||
function 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 send() {
|
||
if (busy === "send" || busy === "stop" || !model || (!message.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: message.trim() || "[Attachments]" }, body = { model: model, message: message, history: history, attachments: attachments, request_id: requestId };
|
||
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) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: "Response complete. Live placement is shown below." }); pollRuntime(); }).catch(function (err) { 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 your selected model"), h("p", null, "Images are sent as Ollama vision inputs; PDFs and web pages are extracted as untrusted document text.")), h("div", { className: "ollama-chat-model" }, h("label", null, "Model", h("select", { value: model, onChange: function (event) { setModel(event.target.value); } }, models.map(function (item) { return h("option", { key: item.name, value: item.name }, item.name + (item.loaded ? " · loaded" : "")); }))), h(Button, { disabled: !model || busy === "load", onClick: loadModel }, busy === "load" ? "Loading…" : "Load model"), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat"))),
|
||
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
|
||
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 }),
|
||
h(RuntimePanel, { runtime: runtime, samples: samples }),
|
||
h("div", { className: "ollama-chat-layout" },
|
||
h("div", { className: "ollama-conversation" }, history.length ? history.map(function (item, index) { return h("div", { className: "ollama-message " + item.role, key: index }, h("small", null, item.role === "assistant" ? "Ollama" : "You"), h("div", null, item.content)); }) : h(Empty, null, "Start a conversation. The selected model will be loaded into Ollama memory when you load it or send the first message.")),
|
||
h("div", { className: "ollama-composer" + (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: "Ask the selected local model… Press Enter to send; Shift+Enter for a new line.", 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" || !model || (!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."))
|
||
)
|
||
);
|
||
}
|
||
|
||
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 busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
|
||
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
|
||
var loadingState = React.useState(true), loading = loadingState[0], setLoading = loadingState[1];
|
||
function load() { return fetchJSON(API + "/status").then(function (value) { setData(value); setLoading(false); return value; }).catch(function (err) { setNotice({ error: err.message || String(err) }); setLoading(false); }); }
|
||
React.useEffect(function () { load(); var timer = setInterval(load, 5000); return function () { clearInterval(timer); }; }, []);
|
||
function action(kind, name) { if (kind === "delete" && !window.confirm("Remove " + name + " from Ollama?")) return; var key = name + ":" + kind; setBusy(key); setNotice(null); 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 refreshCatalog() { setBusy("catalog"); setNotice(null); fetchJSON(API + "/catalog/refresh", { method: "POST" }).then(function (result) { setNotice({ ok: "Catalog refreshed: " + result.count + " models." }); load(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); }).finally(function () { setBusy(""); }); }
|
||
var models = data ? (tab === "installed" ? data.models || [] : tab === "popular" ? data.popular || [] : tab === "catalog" ? data.catalog || [] : []) : [];
|
||
var needle = query.toLowerCase().trim(); if (needle) models = models.filter(function (model) { return (model.name + " " + model.family + " " + (model.strengths || []).join(" ") + " " + (model.capabilities || []).join(" ")).toLowerCase().indexOf(needle) >= 0; });
|
||
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"))),
|
||
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 !== "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)")),
|
||
tab === "chat" && h(ChatPanel, { models: data && data.models ? data.models : [] }),
|
||
tab === "popular" && h("p", { className: "ollama-popular-note" }, "Popular is limited to models with known size and RAM estimates at or below 30 GiB. Oversized families are represented by a smaller fitting variant when available."), jobs.length > 0 && h("section", { className: "ollama-jobs" }, jobs.map(function (job) { return h("div", { key: job.id }, h("strong", null, job.action + " · " + job.name), h("span", null, job.percent == null ? job.status : job.percent + "%")); })),
|
||
tab !== "chat" && loading && h(Empty, null, "Loading local Ollama inventory…"), tab !== "chat" && !loading && !models.length && h(Empty, null, tab === "installed" ? "No local models found." : tab === "popular" ? "No popular catalog entries available." : "No catalog entries available. Try Refresh catalog."), tab !== "chat" && h("section", { className: "ollama-grid" }, models.map(function (model) { return h(ModelCard, { key: model.name, model: model, installed: tab === "installed" || !!model.installed, busy: busy, action: action }); }))
|
||
);
|
||
}
|
||
registry.register("ollama-manager", Page);
|
||
})();
|