feat(ui): persist the chat conversation in localStorage — survives refresh and navigation, with a New chat reset
This commit is contained in:
+152
-3
@@ -20,6 +20,14 @@
|
||||
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
|
||||
* streams, so the button can never sit zombified.
|
||||
*
|
||||
* Conversation persistence (phase 14) makes the chat a durable LOCAL
|
||||
* session: the message list (raw text + turn metadata) lives in
|
||||
* localStorage under the versioned key `bor.chat.v1` and is re-rendered on
|
||||
* load — refresh, tab close, and a trip to Sources never lose it. A10 is
|
||||
* untouched: the API stays stateless, nothing is stored server-side.
|
||||
* "New chat" (#new-chat-btn) clears the key + the list back to the empty
|
||||
* state.
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -94,7 +102,7 @@ const USER_AVATAR =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="3.6"/><path d="M4.8 20.2c.9-3.9 3.8-6 7.2-6s6.3 2.1 7.2 6"/></svg>';
|
||||
|
||||
/* ---------- messages ---------- */
|
||||
function addMessage(who, html) {
|
||||
function addMessage(who, html, scrollBehavior = SCROLL) {
|
||||
if (emptyState) emptyState.hidden = true;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `msg ${who}`;
|
||||
@@ -104,7 +112,7 @@ function addMessage(who, html) {
|
||||
<div class="bubble">${html}</div>
|
||||
</div>`;
|
||||
messagesEl.appendChild(wrap);
|
||||
wrap.scrollIntoView({ behavior: SCROLL, block: "end" });
|
||||
wrap.scrollIntoView({ behavior: scrollBehavior, block: "end" });
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -330,6 +338,133 @@ function appendMaybeTry(wrap, suggestions) {
|
||||
body.appendChild(group);
|
||||
}
|
||||
|
||||
/* ---------- conversation persistence (phase 14) ----------
|
||||
*
|
||||
* A durable LOCAL session (A10 unchanged: the API stays stateless — the
|
||||
* server stores nothing about the conversation). The whole conversation
|
||||
* lives in localStorage under a versioned key; a format bump = clean start:
|
||||
*
|
||||
* bor.chat.v1 → { v: 1, messages: [{ who: "user"|"brain", text,
|
||||
* sources?, deflected?, suggestions? }] }
|
||||
*
|
||||
* Only RAW TEXT is stored — restore re-renders it through the escape-first
|
||||
* markdown renderer, so no HTML is ever persisted. Save points: the user
|
||||
* message on send (a failed turn keeps the question) and the brain message
|
||||
* on `done` (with sources/deflected/suggestions). Every localStorage access
|
||||
* is try/catch'd — private mode or quota exhaustion degrades silently to
|
||||
* in-memory-only chat. If the serialized state outgrows the budget (~700k
|
||||
* chars, far under the ~5MB quota) the oldest messages are dropped first.
|
||||
*/
|
||||
const STORAGE_KEY = "bor.chat.v1";
|
||||
export const STORAGE_VERSION = 1;
|
||||
export const STORAGE_BUDGET_CHARS = 700_000;
|
||||
|
||||
let conversation = []; // in-memory copy of the persisted messages
|
||||
|
||||
function loadStoredConversation() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || data.v !== STORAGE_VERSION || !Array.isArray(data.messages)) return [];
|
||||
// Legacy/corrupt shape → clean start; keep only well-formed raw-text
|
||||
// messages (nothing HTML-shaped can survive this filter).
|
||||
return data.messages.filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.who === "user" || m.who === "brain") &&
|
||||
typeof m.text === "string" &&
|
||||
m.text.length > 0
|
||||
);
|
||||
} catch {
|
||||
return []; // unreadable storage: start clean, never throw
|
||||
}
|
||||
}
|
||||
|
||||
function trimToBudget(messages) {
|
||||
let out = messages.slice();
|
||||
for (;;) {
|
||||
let size = Infinity;
|
||||
try {
|
||||
size = JSON.stringify({ v: STORAGE_VERSION, messages: out }).length;
|
||||
} catch {
|
||||
break; // even one message cannot serialize — keep it in memory only
|
||||
}
|
||||
if (size <= STORAGE_BUDGET_CHARS || out.length <= 1) return out;
|
||||
out = out.slice(1); // drop the oldest until it fits
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function saveConversation() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ v: STORAGE_VERSION, messages: trimToBudget(conversation) })
|
||||
);
|
||||
} catch {
|
||||
/* quota/private mode: chat keeps working with in-memory state only */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredConversation() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* nothing was stored */
|
||||
}
|
||||
}
|
||||
|
||||
function renderStoredMessage(m) {
|
||||
if (m.who === "user") {
|
||||
addMessage("user", renderMarkdown(m.text), "auto");
|
||||
return;
|
||||
}
|
||||
const wrap = addMessage("brain", renderMarkdown(m.text), "auto");
|
||||
if (m.deflected) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
appendSources(wrap, m.sources);
|
||||
}
|
||||
|
||||
/* On load: re-render the stored conversation (markdown, source chips,
|
||||
deflected styling, maybe-try chips). addMessage hides the empty state,
|
||||
so a restored conversation starts right where it was left. */
|
||||
function restoreConversation() {
|
||||
conversation = loadStoredConversation();
|
||||
for (const m of conversation) renderStoredMessage(m);
|
||||
}
|
||||
|
||||
/* Brain message save point (on `done`): raw accumulated text only. An
|
||||
empty stream keeps the "…" placeholder that was actually rendered. */
|
||||
function rememberBrainTurn(rawText, meta) {
|
||||
conversation.push({ who: "brain", text: rawText || "…", ...meta });
|
||||
saveConversation();
|
||||
}
|
||||
|
||||
/* ---------- new chat (phase 14) ----------
|
||||
* Clears the stored conversation + the rendered list and returns to the
|
||||
* empty state (suggestions included). Ignored while a turn is in flight —
|
||||
* a live stream must not be hijacked. Confirmation reuses the existing
|
||||
* #send-status live region (aria-live=polite). */
|
||||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||||
function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
if (emptyState) emptyState.hidden = false;
|
||||
clearErrorBanner();
|
||||
setUiState(UI_STATE.idle);
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
input.focus();
|
||||
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
||||
}
|
||||
if (newChatBtn) newChatBtn.addEventListener("click", startNewChat);
|
||||
|
||||
function showErrorBanner(detail) {
|
||||
banner.hidden = false;
|
||||
banner.classList.add("is-error");
|
||||
@@ -352,6 +487,10 @@ async function handleSend(e) {
|
||||
if (!text || sendBtn.disabled) return;
|
||||
|
||||
addMessage("user", renderMarkdown(text));
|
||||
// Persistence save point 1: the question is stored the moment it is
|
||||
// sent, so a failed/interrupted turn never loses it.
|
||||
conversation.push({ who: "user", text });
|
||||
saveConversation();
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
clearErrorBanner();
|
||||
@@ -406,12 +545,21 @@ async function handleSend(e) {
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
appendSources(wrap, ev.sources);
|
||||
// Persistence save point 2: the answer lands only when the turn is
|
||||
// complete (raw text + the done metadata).
|
||||
rememberBrainTurn(acc, {
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
suggestions: ev.suggestions,
|
||||
});
|
||||
} else if (ev.type === "error") {
|
||||
throw new Error(ev.detail || "Something went wrong on my side.");
|
||||
}
|
||||
});
|
||||
if (!aborted && !wrap) {
|
||||
addMessage("brain", "Hmm — that came back empty. Ask me again?");
|
||||
const fallback = "Hmm — that came back empty. Ask me again?";
|
||||
addMessage("brain", fallback);
|
||||
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
||||
}
|
||||
} catch (err) {
|
||||
if (!aborted) {
|
||||
@@ -441,5 +589,6 @@ input.addEventListener("keydown", (e) => {
|
||||
});
|
||||
composer.addEventListener("submit", handleSend);
|
||||
|
||||
restoreConversation(); // phase 14: the conversation comes back as left
|
||||
loadSuggestions();
|
||||
loadHealth();
|
||||
|
||||
Reference in New Issue
Block a user