feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return

This commit is contained in:
2026-08-29 21:22:25 -04:00
parent 6832957ab0
commit ece93a7c8f
29 changed files with 3099 additions and 20 deletions
+167 -1
View File
@@ -124,6 +124,37 @@
* while a turn is in flight. No banner, no scroll (phase 42): the fresh
* bubble lands where the old one was.
*
* Save the conversation (phase 50, owner-locked 2026-08-29, TODO.md L5):
* the "Save" pill (#save-chat-btn — admin-only, SHIPS HIDDEN, revealed at
* boot only for admin: absent, not hidden, for anonymous) stores the
* CURRENT conversation in Postgres (saved_chats, migration 0008) through
* the admin-only /api/chats CRUD. Upsert semantics keyed by
* `currentChatId` (module scope, string | null): a Save while unlinked
* POSTs /api/chats (the server auto-titles from the first question,
* 120-char cap) and links the conversation to the created row's id; a
* re-Save while linked PUTs the SAME row — the same conversation never
* spawns a second row; a 404 from that PUT (the row was deleted on the
* History page behind our back) unlinks and retries as a create, so a
* stale link can never leave the conversation unsaved. "New chat"
* unlinks (a fresh conversation is unlinked until saved again). Boot
* load: /?chat=<id> with a VALID uuid AND admin fetches the row and
* renders its messages through the SAME renderStoredMessage loop as the
* phase-14 local restore (sources / thinking / tools / stopped /
* deflection — pixel-identical), links currentChatId to the id, and
* mirrors the conversation to localStorage (a plain refresh returns to
* it the phase-14 way). The ?chat= param is a ONE-SHOT boot instruction:
* the success path normalizes the URL back to / (history.replaceState),
* so a later refresh — or a "New chat" + refresh — restores the LOCAL
* session (the mirror) instead of re-opening the saved row and evicting
* whatever the owner typed since. Invalid/absent param, anonymous (no
* fetch — the gate would 403), 404, or network failure: the normal local
* restore runs instead (404/network also raise the error banner). Save
* feedback
* is status text only ("Conversation saved." / "Nothing to save yet.")
* — the #send-status live region, never stale (PLAN §7.4); failures get
* the error banner. Phase 14's local persistence is untouched: saving is
* an additional, explicit action.
*
* All DOM ids match frontend/index.html.
*/
@@ -146,6 +177,7 @@ const sendStatus = document.querySelector("#send-status");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
const saveBtn = document.querySelector("#save-chat-btn"); // phase 50: admin-only Save pill (ships hidden)
/* Phase 39: the display name resolves from one place — window.BOR_BRAND
* (the classic assets/brand.js sets it at parse time; its /api/config
@@ -981,6 +1013,128 @@ function restoreConversation() {
markLastRetryable(); // phase 49: the restored last brain bubble is retryable
}
/* ---------- save & load saved chats (phase 50, owner-locked 2026-08-29) ----------
*
* `currentChatId` links the local conversation to a saved_chats row:
* set to the created row's id on a fresh Save, set to the opened id on a
* successful /?chat=<id> boot load, cleared by "New chat" and by the
* 404-PUT fallback (the row vanished — recreate, never lose the save).
* null = unlinked (a plain local session, phase 14).
*/
let currentChatId = null; // string | null — the linked saved_chats row id
/* A uuid — for the ?chat=<id> param. The API's path param is uuid.UUID,
* so anything else would 422; the client gate keeps the no-fetch rule
* (invalid/absent param → no request, plain local restore). */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Boot load (?chat=<id>, phase 50): when the URL carries a VALID uuid
* AND whoami says admin, GET the row and render it through the SAME
* renderStoredMessage loop as the local restore (pixel-identical), then
* link the conversation to the id and mirror it to localStorage (a plain
* refresh returns to it the phase-14 way). Returns true on success. Every
* other outcome — invalid or absent param, anonymous (no fetch: the gate
* would 403), 404 (deleted), network failure, or an unusable payload —
* returns false and the caller falls through to the normal local restore;
* the 404/network failures also raise the error banner. The ?chat= param
* is a one-shot boot instruction: on success the URL is normalized back
* to / (replaceState), so a later refresh or a "New chat" + refresh
* restores the LOCAL session (the mirror above) instead of re-opening
* the saved row. */
async function restoreSavedChatFromUrl() {
const chatId = new URLSearchParams(window.location.search).get("chat");
if (!chatId || !UUID_RE.test(chatId) || !isAdmin) return false;
const unavailable = () => {
showErrorBanner("That saved chat isn't available — it may have been deleted.");
return false;
};
let res;
try {
res = await fetch(`/api/chats/${chatId}`);
} catch {
return unavailable(); // network failure → banner + local restore
}
if (!res.ok) return unavailable(); // 404 (deleted) / 403 (signed out) / 5xx
let data = null;
try {
data = await res.json();
} catch {
return unavailable(); // malformed body — treat as unavailable
}
// The API schema guarantees the record shape; the same defensive filter
// as loadStoredConversation keeps a corrupted stored row from poisoning
// the restore (nothing HTML-shaped, ever).
const messages = (Array.isArray(data?.messages) ? data.messages : []).filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
if (!messages.length) return unavailable();
conversation = messages; // REPLACES the local conversation (owner-locked)
for (const m of conversation) renderStoredMessage(m);
markLastRetryable(); // parity with the local restore: Retry on the last brain bubble
currentChatId = chatId; // linked: a subsequent Save updates THIS row
saveConversation(); // mirror to localStorage — a plain refresh returns here
// The ?chat= param is a one-shot boot instruction: normalize the URL
// back to / so a later refresh / "New chat" + refresh restores the
// LOCAL session (the mirror above) instead of re-opening this row.
history.replaceState(null, "", "/");
return true;
}
/* Save the current conversation — the #save-chat-btn handler (phase 50).
* No-op with a live-region line when there is nothing to save. Upsert:
* linked → PUT /api/chats/<id> (re-Save updates the same row; no title in
* the body, so the row keeps its current one); unlinked → POST /api/chats
* (the server auto-titles) and link to the created id. A 404 from the PUT
* — the row was deleted on the History page — unlinks and retries as a
* create, then announces the outcome: the owner is never left with an
* unsaved conversation because of a stale link. 403/5xx/network → the
* error banner with an actionable line (the conversation is intact
* locally either way). Success is status text only — the #send-status
* live region, never stale (PLAN §7.4); no banner. */
async function saveCurrentChat() {
if (!conversation.length) {
sendStatus.textContent = "Nothing to save yet.";
return;
}
if (saveBtn.disabled) return; // one save at a time (double-click guard)
saveBtn.disabled = true;
const body = JSON.stringify({ messages: conversation });
const headers = { "Content-Type": "application/json" };
try {
let res;
if (currentChatId) {
res = await fetch(`/api/chats/${currentChatId}`, { method: "PUT", headers, body });
if (res.status === 404) {
// Stale link: the row is gone (deleted from History) — unlink and
// retry as a create, so the save never silently dies.
currentChatId = null;
res = await fetch("/api/chats", { method: "POST", headers, body });
}
} else {
res = await fetch("/api/chats", { method: "POST", headers, body });
}
if (!res.ok) {
showErrorBanner(
"Couldn't save the conversation — check you're still signed in and try again."
);
return;
}
if (res.status === 201) {
const created = await res.json();
currentChatId = String(created.id); // fresh Save: link to the new row
}
sendStatus.textContent = "Conversation saved.";
} catch {
showErrorBanner("Couldn't save the conversation — is the app reachable?");
} finally {
saveBtn.disabled = false; // released on EVERY outcome — never stale
}
}
/* Brain message save point (on `done`): raw accumulated text + metadata.
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
@@ -1031,6 +1185,7 @@ function applyAuthState() {
function startNewChat() {
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
conversation = [];
currentChatId = null; // phase 50: a new conversation is unlinked until saved
clearStoredConversation();
removeTyping();
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
@@ -1389,6 +1544,13 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
/* Phase 50 (owner-locked 2026-08-29, TODO.md L5): the Save pill stores
* the current conversation in Postgres (the upsert semantics live in
* saveCurrentChat). The button ships hidden in index.html; the boot
* IIFE below reveals it for admin (absent-not-hidden for anonymous,
* phase 16). Status-only feedback — the live region, never stale. */
saveBtn?.addEventListener("click", saveCurrentChat);
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
* leaving the chat mid-turn would otherwise drop the in-flight
* answer — the brain message persists only on `done`, and
@@ -1422,7 +1584,11 @@ window.addEventListener("pagehide", () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
restoreConversation();
if (saveBtn) saveBtn.hidden = !isAdmin; // phase 50: absent-not-hidden (phase 16)
// Phase 50: /?chat=<id> (valid uuid + admin) boots into the saved
// conversation; every other outcome falls through to the local restore.
const openedSaved = await restoreSavedChatFromUrl();
if (!openedSaved) restoreConversation();
loadSuggestions();
loadHealth();
})();