From aaf4790591ff03c16afdd4a1e61fdb37200851f1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 18 Aug 2026 18:25:33 +1000 Subject: [PATCH] Persist Ollama chat history across navigation --- README.md | 2 +- dashboard/dist/index.js | 41 ++++++++++++++++++++++++++++++++++++----- dashboard/manifest.json | 2 +- plugin.yaml | 2 +- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 593acbd..50390fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dashboard/dist/index.js b/dashboard/dist/index.js index 9d17823..56276d1 100644 --- a/dashboard/dist/index.js +++ b/dashboard/dist/index.js @@ -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 + "%")); })), diff --git a/dashboard/manifest.json b/dashboard/manifest.json index e6e9a6d..7b299e4 100644 --- a/dashboard/manifest.json +++ b/dashboard/manifest.json @@ -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", diff --git a/plugin.yaml b/plugin.yaml index bbb717c..40c71b9 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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"