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();
|
||||
|
||||
@@ -174,11 +174,13 @@ body::after {
|
||||
rgb(34 211 238 / 0.05) 90%
|
||||
);
|
||||
}
|
||||
/* margin-left:auto on the nav (not justify-content:space-between) so the
|
||||
phase-14 "New chat" pill clusters with the nav on the right while the
|
||||
two-child Sources header keeps the exact same look. */
|
||||
.header-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.brand {
|
||||
@@ -198,7 +200,7 @@ body::after {
|
||||
.brand-mark { width: 22px; height: 22px; flex: 0 0 auto; display: block; }
|
||||
.brand-text strong { color: var(--brand-ink); font-weight: 700; }
|
||||
|
||||
.app-nav { display: flex; gap: 0.25rem; }
|
||||
.app-nav { display: flex; gap: 0.25rem; margin-left: auto; }
|
||||
.nav-link {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
@@ -213,6 +215,32 @@ body::after {
|
||||
.nav-link:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.nav-link.is-active { background: var(--brand); color: var(--bg); }
|
||||
|
||||
/* "New chat" reset (phase 14): ghost pill in the chat header, hover like
|
||||
a nav link. ink-soft on surface ≈6.9:1; hover pair brand-ink/brand-soft
|
||||
≈6.9:1 — both WCAG AA. Icon-only below 640px (aria-label keeps the
|
||||
accessible name); ≥44px touch target at every width. */
|
||||
.new-chat-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.new-chat-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
/* The plus mark is hidden on desktop (label carries the pill); it is the
|
||||
whole control below 640px. */
|
||||
.new-chat-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* ---------- Main frame ---------- */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
@@ -754,8 +782,23 @@ body::after {
|
||||
@media (max-width: 640px) {
|
||||
:root { --header-h: 58px; }
|
||||
.container { padding-inline: 0.9rem; }
|
||||
.brand-text { font-size: 0.88rem; }
|
||||
.nav-link { padding: 0.45rem 0.7rem; font-size: 0.9rem; }
|
||||
/* Phase 14: the New chat pill joins the header — tighten the bar so
|
||||
brand + nav + pill fit at 360px without horizontal overflow (the
|
||||
brand text may ellipsize as the designated squeeze target). */
|
||||
.header-inner { gap: 0.6rem; }
|
||||
.brand { min-width: 0; }
|
||||
.brand-text {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.04em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-link { padding: 0.4rem 0.55rem; font-size: 0.88rem; }
|
||||
.new-chat-btn { padding: 0.4rem 0.55rem; }
|
||||
.new-chat-label { display: none; }
|
||||
.new-chat-btn svg { display: block; }
|
||||
.msg-body { max-width: 92%; }
|
||||
.empty-state { padding: 1.75rem 1.1rem; margin-top: 0.25rem; }
|
||||
.empty-state-title { font-size: 1.25rem; }
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
<a href="/" class="nav-link is-active" aria-current="page">Chat</a>
|
||||
<a href="/sources.html" class="nav-link">Sources</a>
|
||||
</nav>
|
||||
<!-- Phase 14: reset the local (localStorage) conversation — chat page only. -->
|
||||
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
<span class="new-chat-label">New chat</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user