Persist Ollama chat history across navigation
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Vendored
+36
-5
@@ -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 + "%")); })),
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user