feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return
This commit is contained in:
+167
-1
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user