Persist Ollama chat history across navigation

This commit is contained in:
Hermes Agent
2026-08-18 18:25:33 +10:00
parent d8cf8dd80a
commit aaf4790591
4 changed files with 39 additions and 8 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Native-like Hermes dashboard plugin for local Ollama model management and chat.
- View Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
- View NVIDIA GPU telemetry when `nvidia-smi` is available
The chat UI polls runtime memory once per second and keeps a short in-browser history for the current page session.
The chat transcript and selected model are persisted in this browser, so navigating away from the plugin or reloading the dashboard does not clear the conversation. Use **Clear chat** to remove the saved transcript. Uploaded files remain temporary and are not stored in browser persistence.
## Security limits
+36 -5
View File
@@ -7,6 +7,26 @@
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";
@@ -19,7 +39,11 @@
try { return new Date(value * 1000 || value).toLocaleString(); } catch (_) { return value; }
}
function Badge(props) { return h("span", { className: "ollama-badge " + (props.tone || "") }, props.children); }
function Button(props) { return h("button", Object.assign({ className: "ollama-button" }, props), props.children); }
function 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) {
@@ -113,17 +137,24 @@
function ChatPanel(props) {
var models = props.models || [];
var modelState = React.useState(models[0] ? models[0].name : ""), model = modelState[0], setModel = modelState[1];
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([]), history = historyState[0], setHistory = historyState[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];
React.useEffect(function () { if (!model && models[0]) setModel(models[0].name); }, [models, model]);
React.useEffect(function () { saveChat(model, history); }, [model, history]);
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); }; }, []);
@@ -141,7 +172,7 @@
fetchJSON(API + "/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).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) { setNotice({ error: err.message || String(err) }); }).finally(function () { 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("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),
h(RuntimePanel, { runtime: runtime, samples: samples }),
h("div", { className: "ollama-chat-layout" },
@@ -170,7 +201,7 @@
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"); } }, "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); } })),
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 + "%")); })),
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu",
"version": "1.3.1",
"version": "1.3.2",
"tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js",
"css": "dist/style.css",
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager
version: 1.3.1
version: 1.3.2
description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry.
python_dependencies:
- "pypdf>=6.0,<7.0"