feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index
This commit is contained in:
+126
-2
@@ -177,6 +177,32 @@
|
||||
* in hint, like Save); a network failure → the "is the app reachable?"
|
||||
* banner.
|
||||
*
|
||||
* Stale saved chats (phase 53, TODO.md L4): every sync that changes the
|
||||
* knowledge base bumps the sources generation; a row saved against an
|
||||
* older one is STALE (server-computed `stale` on GET /api/chats/<id> —
|
||||
* the client never does staleness math). The /?chat=<id> boot load
|
||||
* reveals the #stale-banner (top of the column, directly below
|
||||
* #kb-banner) when the fetched payload reports `stale: true`. A stale
|
||||
* conversation with NO brain record is revealed text-only — the
|
||||
* #stale-regenerate button is removed (retryLastTurn is never called in
|
||||
* that state). Regenerate = the phase-49 redo-in-place of the LAST
|
||||
* brain bubble ONLY: retryLastTurn(lastBrainWrap) re-asks the last
|
||||
* question against the new index (full conversation context kept; earlier
|
||||
* answers are not re-run), and retryLastTurn now RETURNS the
|
||||
* runTurn promise so the handler can await the turn's completion
|
||||
* (behavior-neutral for the existing Retry click, which ignores it).
|
||||
* Only when the turn completes WITHOUT the error banner does the handler
|
||||
* persist the linked row through the SAME upsert as Save — PUT
|
||||
* /api/chats/<id> (the server re-stamps sources_version → stale: false);
|
||||
* a 404 (row deleted from History meanwhile) unlinks and recreates
|
||||
* (saveCurrentChat's stale-link rule). A regenerate that errors
|
||||
* mid-stream leaves the row untouched (stale stays true); a regenerate
|
||||
* STOPPED mid-stream (phase 48) persists the stopped partial. Success
|
||||
* hides the banner and announces in the #send-status live region
|
||||
* (PLAN §7.4 never-stale). The banner also clears on "New chat" and on
|
||||
* a successful manual re-Save (both make the row/conversation no longer
|
||||
* the one the banner describes).
|
||||
*
|
||||
* All DOM ids match frontend/index.html.
|
||||
*/
|
||||
|
||||
@@ -201,6 +227,8 @@ 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)
|
||||
const shareBtn = document.querySelector("#share-chat-btn"); // phase 51: admin-only Share pill (ships hidden)
|
||||
const staleBanner = document.querySelector("#stale-banner"); // phase 53: the stale banner (ships hidden)
|
||||
const staleRegenBtn = document.querySelector("#stale-regenerate"); // phase 53: the banner's Regenerate pill
|
||||
|
||||
/* 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
|
||||
@@ -1100,6 +1128,18 @@ async function restoreSavedChatFromUrl() {
|
||||
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
|
||||
// Phase 53 (task 05): the `stale` flag is server-computed (task 03 —
|
||||
// the row's sources stamp is behind the current generation; the
|
||||
// client never does staleness math). Reveal the banner; when the
|
||||
// conversation has NO brain record there is nothing to regenerate,
|
||||
// so the button is removed first (text-only — retryLastTurn is never
|
||||
// called in that state).
|
||||
if (data.stale === true) {
|
||||
if (!conversation.some((m) => m.who === "brain") && staleRegenBtn) {
|
||||
staleRegenBtn.remove(); // no brain answer — nothing to regenerate
|
||||
}
|
||||
if (staleBanner) staleBanner.hidden = false;
|
||||
}
|
||||
// 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.
|
||||
@@ -1151,6 +1191,9 @@ async function saveCurrentChat() {
|
||||
currentChatId = String(created.id); // fresh Save: link to the new row
|
||||
}
|
||||
sendStatus.textContent = "Conversation saved.";
|
||||
// Phase 53: a re-Save re-stamps the row to the current generation
|
||||
// (task 03) — the row is no longer stale, so the banner is done.
|
||||
if (staleBanner) staleBanner.hidden = true;
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the conversation — is the app reachable?");
|
||||
} finally {
|
||||
@@ -1268,6 +1311,75 @@ async function shareCurrentChat() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Regenerate a stale saved chat — the #stale-regenerate handler
|
||||
* (phase 53, task 05). The banner only ever shows on the /?chat=<id>
|
||||
* boot path (admin), so currentChatId is set whenever this runs. The
|
||||
* redo: retryLastTurn(lastBrainWrap) — the phase-49 redo-in-place of
|
||||
* the LAST brain bubble (its own guards — in-flight, wrap !==
|
||||
* lastBrainWrap, no preceding user record — make a stale or superseded
|
||||
* click a no-op that resolves nothing). The handler AWAITs the returned
|
||||
* turn promise, and only when the turn completed WITHOUT the error
|
||||
* banner persists the linked row through the SAME upsert as Save:
|
||||
* PUT /api/chats/<id> (the server re-stamps sources_version → the row
|
||||
* is fresh again); a 404 (the row was deleted from History meanwhile)
|
||||
* follows saveCurrentChat's stale-link rule — unlink + recreate, so the
|
||||
* owner is never left with an unsaved conversation. A regenerate that
|
||||
* errors mid-stream leaves the row untouched (stale stays true —
|
||||
* Regenerate stays available); a regenerate STOPPED mid-stream (phase
|
||||
* 48) persists the stopped partial (the owner engaged with the new
|
||||
* index). Success hides the banner and announces the outcome in the
|
||||
* #send-status live region (PLAN §7.4 never-stale). */
|
||||
async function regenerateStaleChat() {
|
||||
if (staleRegenBtn?.disabled) return; // one regenerate at a time (double-click guard)
|
||||
staleRegenBtn.disabled = true;
|
||||
try {
|
||||
// Phase-49 targeting: the LAST brain bubble's rendered wrap. When a
|
||||
// guard no-ops the redo (no brain bubble — the no-brain-record state
|
||||
// that removed the button at reveal; in-flight turn; superseded
|
||||
// wrap), retryLastTurn returns nothing and there is nothing to
|
||||
// await or persist.
|
||||
const turn = lastBrainWrap ? retryLastTurn(lastBrainWrap) : undefined;
|
||||
if (!turn) return;
|
||||
await turn; // the turn's completion — runTurn settles to idle always
|
||||
// A regenerate that errored mid-stream (the error banner is up) leaves
|
||||
// the linked row untouched — the row stays stale, the banner stays.
|
||||
if (banner.classList.contains("is-error")) return;
|
||||
// Persist the linked row through the SAME upsert as Save: PUT (the
|
||||
// server re-stamps sources_version — the row is fresh again); a 404
|
||||
// (deleted from History meanwhile) unlinks and recreates.
|
||||
const body = JSON.stringify({ messages: conversation });
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
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 regenerated answer — check you're still signed in and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (res.status === 201) {
|
||||
const created = await res.json();
|
||||
currentChatId = String(created.id); // the recreate: link the new row
|
||||
}
|
||||
staleBanner.hidden = true; // fresh row — the banner is done
|
||||
sendStatus.textContent = "Regenerated — the answer now reflects the current sources.";
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the regenerated answer — is the app reachable?");
|
||||
} finally {
|
||||
if (staleRegenBtn) staleRegenBtn.disabled = false; // released on EVERY outcome
|
||||
}
|
||||
}
|
||||
|
||||
/* 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
|
||||
@@ -1319,6 +1431,7 @@ function startNewChat() {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
conversation = [];
|
||||
currentChatId = null; // phase 50: a new conversation is unlinked until saved
|
||||
if (staleBanner) staleBanner.hidden = true; // phase 53: the banner described the cleared conversation
|
||||
clearStoredConversation();
|
||||
removeTyping();
|
||||
messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());
|
||||
@@ -1377,7 +1490,12 @@ function stopTurn() {
|
||||
* no scroll (phase 42: the fresh bubble lands where the old one was).
|
||||
* Guards: inert while a turn is in flight (one turn at a time), and the
|
||||
* click's wrap must still be the last brain bubble's rendered wrap — a
|
||||
* stale click on a superseded bubble is harmless by construction. */
|
||||
* stale click on a superseded bubble is harmless by construction.
|
||||
* Phase 53 (task 05): RETURNS the runTurn promise when the redo runs
|
||||
* (undefined when a guard no-ops it) — the stale banner's Regenerate
|
||||
* path awaits the turn's completion to know when to persist the linked
|
||||
* row. The existing Retry click handler ignores the return value, so
|
||||
* phase-49 behavior is unchanged. */
|
||||
function retryLastTurn(wrap) {
|
||||
if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;
|
||||
if (wrap !== lastBrainWrap) return; // stale click — the button moved on
|
||||
@@ -1403,7 +1521,9 @@ function retryLastTurn(wrap) {
|
||||
lastBrainWrap = null;
|
||||
// Re-ask without re-adding: the reask turn skips the user append and
|
||||
// persistence save point 1 (the question is already in both).
|
||||
void runTurn(text, { reask: true });
|
||||
// Phase 53: the promise is returned (the Regenerate await above);
|
||||
// runTurn never rejects — a failure surfaces as the error banner.
|
||||
return runTurn(text, { reask: true });
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
@@ -1686,6 +1806,10 @@ saveBtn?.addEventListener("click", saveCurrentChat);
|
||||
/* Phase 51 (owner-locked 2026-08-29, TODO.md L6): the Share pill —
|
||||
* same ship-hidden/reveal contract as Save (the boot IIFE below). */
|
||||
shareBtn?.addEventListener("click", shareCurrentChat);
|
||||
/* Phase 53 (task 05): the stale banner's Regenerate pill. The binding
|
||||
* is inert unless the banner is revealed — which only happens on the
|
||||
* /?chat=<id> boot path (admin, task-50 contract). */
|
||||
staleRegenBtn?.addEventListener("click", regenerateStaleChat);
|
||||
|
||||
/* Navigate-away save point (phase 20, owner choice 2026-08-24 A1):
|
||||
* leaving the chat mid-turn would otherwise drop the in-flight
|
||||
|
||||
Reference in New Issue
Block a user