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 Ollama's loaded-model memory split: total, GPU VRAM, and normal RAM/offload
- View NVIDIA GPU telemetry when `nvidia-smi` is available - 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 ## Security limits
+36 -5
View File
@@ -7,6 +7,26 @@
var h = React.createElement; var h = React.createElement;
var fetchJSON = SDK.fetchJSON; var fetchJSON = SDK.fetchJSON;
var API = "/api/plugins/ollama-manager"; 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) { function fmtBytes(bytes) {
if (bytes === null || bytes === undefined || Number(bytes) === 0) return "0 B"; 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; } 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 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 Empty(props) { return h("div", { className: "ollama-empty" }, props.children); }
function CapabilityList(props) { function CapabilityList(props) {
@@ -113,17 +137,24 @@
function ChatPanel(props) { function ChatPanel(props) {
var models = props.models || []; 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 messageState = React.useState(""), message = messageState[0], setMessage = messageState[1];
var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1]; var urlState = React.useState(""), url = urlState[0], setUrl = urlState[1];
var attachState = React.useState([]), attachments = attachState[0], setAttachments = attachState[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 runtimeState = React.useState(null), runtime = runtimeState[0], setRuntime = runtimeState[1];
var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1]; var samplesState = React.useState([]), samples = samplesState[0], setSamples = samplesState[1];
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1]; var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[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 () { 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 () {}); } 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); }; }, []); 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(""); }); 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" }, 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), notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok),
h(RuntimePanel, { runtime: runtime, samples: samples }), h(RuntimePanel, { runtime: runtime, samples: samples }),
h("div", { className: "ollama-chat-layout" }, h("div", { className: "ollama-chat-layout" },
@@ -170,7 +201,7 @@
var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : []; var jobs = data && data.jobs ? data.jobs.filter(function (job) { return job.state === "running"; }) : [];
return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog"))), return h("main", { className: "ollama-page" }, h("header", { className: "ollama-hero" }, h("div", null, h("div", { className: "ollama-eyebrow" }, "LOCAL MODEL OPERATIONS"), h("h1", null, "Ollama Models"), h("p", null, "Inspect, chat with, download, update, and remove models from the local Ollama runtime.")), h("div", { className: "ollama-health" }, h(Badge, { tone: data && data.ollama && data.ollama.available ? "live" : "danger" }, data && data.ollama && data.ollama.available ? "Ollama online" : "Ollama unavailable"), data && data.ollama && h("span", null, "v" + (data.ollama.version || "unknown")), h(Button, { disabled: busy === "catalog", onClick: refreshCatalog }, busy === "catalog" ? "Refreshing…" : "Refresh catalog"))),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : "ok") }, notice.error || notice.ok), 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("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 === "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 === "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", "label": "Ollama Models",
"description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.", "description": "Inspect, manage, and chat with local Ollama models, including images, PDFs, URLs, and live memory telemetry.",
"icon": "Cpu", "icon": "Cpu",
"version": "1.3.1", "version": "1.3.2",
"tab": {"path": "/ollama-manager", "position": "after:models"}, "tab": {"path": "/ollama-manager", "position": "after:models"},
"entry": "dist/index.js", "entry": "dist/index.js",
"css": "dist/style.css", "css": "dist/style.css",
+1 -1
View File
@@ -1,5 +1,5 @@
name: ollama-manager 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. description: Native dashboard manager and chat interface for local Ollama models, attachments, URLs, and live runtime telemetry.
python_dependencies: python_dependencies:
- "pypdf>=6.0,<7.0" - "pypdf>=6.0,<7.0"