feat: add durable chat storage and disconnect recovery

This commit is contained in:
Hermes Agent
2026-08-27 20:16:33 +10:00
parent 934a718c90
commit 1c5167b6e6
9 changed files with 709 additions and 184 deletions
+42 -2
View File
@@ -225,6 +225,22 @@
);
}
function StoragePanel(props) {
var storage = props.storage || {};
var postgres = storage.postgres || {};
var backend = storage.backend || "sqlite";
return h("section", { className: "ollama-storage-panel" },
h("div", { className: "ollama-pool-heading" }, h("div", null, h("h3", null, "Chat storage"), h("p", null, "SQLite is the default. PostgreSQL is optional and remains local-only.")), h(Badge, { tone: backend === "postgres" ? "live" : "muted" }, backend === "postgres" ? "PostgreSQL" : "SQLite")),
h("div", { className: "ollama-storage-copy" }, h("strong", null, backend === "postgres" ? "Using PostgreSQL chat storage" : "Using SQLite chat storage"), h("small", null, postgres.available ? "Native PostgreSQL detected: " + (postgres.version || "version available") : "PostgreSQL is not currently linked. Switching storage does not delete existing conversations.")),
h("div", { className: "ollama-storage-actions" },
backend === "postgres" ? h(Button, { className: "secondary", onClick: function () { props.onConfigure("sqlite"); } }, "Use SQLite") : h(Button, { disabled: !postgres.available, onClick: function () { props.onConfigure("postgres"); } }, "Link PostgreSQL"),
!postgres.available && h(Button, { className: "secondary", onClick: props.onInstall }, "Install native PostgreSQL"),
h(Button, { className: "secondary", onClick: props.onRefresh }, "Refresh storage status")
),
h("small", { className: "ollama-storage-note" }, "PostgreSQL installation is an explicit host change. Chat data is not moved until you choose Link PostgreSQL.")
);
}
function ModelPoolPanel(props) {
var models = props.models || [], loaded = models.filter(function (item) { return item.loaded; }), primary = props.primaryModel || "", validators = props.validatorModels || [];
return h("section", { className: "ollama-model-pool" },
@@ -262,6 +278,7 @@
var busyState = React.useState(""), busy = busyState[0], setBusy = busyState[1];
var noticeState = React.useState(null), notice = noticeState[0], setNotice = noticeState[1];
var validationState = React.useState(null), validationReports = validationState[0], setValidationReports = validationState[1];
var storageState = React.useState(null), storage = storageState[0], setStorage = storageState[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];
@@ -281,6 +298,17 @@
if (Array.isArray(item.models) && item.models.length) setSelectedModels(item.models);
}).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function refreshStorage() { fetchJSON(API + "/storage").then(setStorage).catch(function (err) { setNotice({ error: "Storage status unavailable: " + (err.message || String(err)) }); }); }
function configureStorage(backend) {
var confirmation = backend === "postgres" ? "enable-postgresql" : "use-sqlite";
if (!window.confirm(backend === "postgres" ? "Link PostgreSQL chat storage now? Existing SQLite conversations will be migrated." : "Switch new chat writes back to SQLite?")) return;
fetchJSON(API + "/storage/configure", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: backend, confirm: confirmation }) }).then(function (value) { setNotice({ ok: value.message || "Chat storage updated." }); refreshStorage(); refreshConversations(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function installPostgres() {
if (!window.confirm("Install native PostgreSQL on this host? This changes host packages and services.")) return;
setNotice({ ok: "Native PostgreSQL installation started…" });
fetchJSON(API + "/storage/postgresql/install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: "postgres", confirm: "install-postgresql", install: true }) }).then(function (value) { setNotice({ ok: value.message || "PostgreSQL installation completed." }); refreshStorage(); }).catch(function (err) { setNotice({ error: err.message || String(err) }); });
}
function refreshConversations() {
fetchJSON(API + "/metrics?limit=100").then(function (value) { setAggregate(value.aggregate || null); }).catch(function () {});
return fetchJSON(API + "/conversations").then(function (value) {
@@ -291,7 +319,7 @@
}).catch(function () { return []; });
}
function newConversation() { setConversationId(""); setHistory([]); setMetrics([]); setValidationReports(null); setNotice({ ok: "New conversation ready." }); }
React.useEffect(function () { refreshConversations(); }, []);
React.useEffect(function () { refreshConversations(); refreshStorage(); }, []);
React.useEffect(function () { if (conversationId) saveChat(model, selectedModels, history); }, [model, selectedModels, history, conversationId]);
React.useEffect(function () { if (!model && (loadedModels[0] || models[0])) setModel((loadedModels[0] || models[0]).name); }, [models, loadedModels, model]);
React.useEffect(function () {
@@ -390,6 +418,17 @@
setThinking(null); setActiveRequest(null); setBusy(""); setNotice({ ok: "Generation stopped." });
});
}
function pollChatJob(requestId, current) {
return fetchJSON(API + "/chat/status/" + encodeURIComponent(requestId)).then(function (status) {
setThinkingDetails(status);
if (current.stopped) throw new Error("Chat stopped by user");
if (status.done) {
if (status.status !== "completed") throw new Error(status.error || "Server-side chat job did not complete");
return status;
}
return new Promise(function (resolve) { setTimeout(resolve, 1200); }).then(function () { return pollChatJob(requestId, current); });
});
}
function send() {
if (busy === "send" || busy === "stop" || !selectedModels.length || (!message.trim() && !attachments.length)) return;
var requestId = makeRequestId();
@@ -399,7 +438,7 @@
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.)"; setConversationId(result.conversation_id || conversationId); setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setMetrics(result.metrics || []); setValidationReports(result.mode === "harness" ? (result.validation_reports || []) : null); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: result.mode === "harness" ? "One final answer compiled by " + result.primary_model + " after validation by " + (result.validator_models || []).join(", ") + "." : "Response complete. Shared conversation and performance metrics saved." }); pollRuntime(); refreshConversations(); }).catch(function (err) { if (!current.stopped) setNotice({ error: err.message || String(err) }); }).finally(function () { if (!current.stopped) { setThinking(null); setActiveRequest(null); } setBusy(""); });
fetchJSON(API + "/chat", requestOptions).then(function (result) { return result.done ? result : pollChatJob(requestId, current); }).then(function (result) { var answer = result.message && result.message.content ? result.message.content : "(No response text returned.)"; setConversationId(result.conversation_id || conversationId); setHistory(function (old) { return old.concat([{ role: "assistant", content: answer }]); }); setMetrics(result.metrics || []); setValidationReports(result.mode === "harness" ? (result.validation_reports || []) : null); setAttachments([]); setRuntime(result.runtime || runtime); setNotice({ ok: result.mode === "harness" ? "One final answer compiled by " + result.primary_model + " after validation by " + (result.validator_models || []).join(", ") + "." : "Response complete. Shared conversation and performance metrics saved." }); pollRuntime(); refreshConversations(); }).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 a validated model harness"), h("p", null, "Choose one primary model and at least one loaded validator model. The primary produces one final answer after reviewing the independent validation reports.")), h(Button, { className: "secondary", disabled: !history.length || busy === "send", onClick: clearChat }, "Clear chat")),
@@ -409,6 +448,7 @@
aggregate && h("div", { className: "ollama-metrics-summary" }, h("strong", null, "Model performance · ", aggregate.sample_count || 0, " samples"), h("span", null, "TTFT avg: ", aggregate.avg_time_to_first_token_ms == null ? "n/a" : aggregate.avg_time_to_first_token_ms + " ms"), h("span", null, "Output: ", aggregate.avg_eval_tokens_per_second == null ? "n/a" : aggregate.avg_eval_tokens_per_second + " tok/s"), h("span", null, "Latency: ", aggregate.avg_total_latency_ms == null ? "n/a" : aggregate.avg_total_latency_ms + " ms"), h("span", null, "Errors: ", aggregate.error_count || 0)),
metrics && metrics.length > 0 && h("div", { className: "ollama-metrics-detail" }, (metrics.slice(-3)).map(function (item, index) { return h("span", { key: index }, item.model || "model", " · TTFT ", item.time_to_first_token_ms == null ? "n/a" : item.time_to_first_token_ms + " ms", " · ", item.eval_count == null ? "n/a" : item.eval_count + " output tokens", " · ", item.eval_tokens_per_second == null ? "n/a" : item.eval_tokens_per_second + " tok/s"); }))
),
h(StoragePanel, { storage: storage, onConfigure: configureStorage, onInstall: installPostgres, onRefresh: refreshStorage }),
h(ModelPoolPanel, { models: models, loadedModels: loadedModels, selectedModels: selectedModels, primaryModel: selectedModels[0] || "", validatorModels: selectedModels.slice(1), poolSelection: poolSelection, placements: placements, busy: busy, onTogglePool: togglePoolModel, onPlacementChange: setPlacement, onToggleChat: toggleChatModel, onPrimaryChange: setPrimaryModel, onLoad: loadModel, onUnload: unloadModels }),
notice && h("div", { className: "ollama-notice " + (notice.error ? "error" : notice.warning ? "warning" : "ok") }, notice.error || notice.warning || notice.ok),
validationReports && validationReports.length > 0 && h("details", { className: "ollama-validation-evidence" }, h("summary", null, "Validation evidence · ", validationReports.length, " independent reports"), validationReports.map(function (item) { return h("div", { className: "ollama-validation-report", key: item.model }, h("strong", null, item.model), h("p", null, item.report || "No report text returned.")); })),