990 lines
40 KiB
JavaScript
990 lines
40 KiB
JavaScript
/* Brain of Reese — chat shell.
|
|
*
|
|
* Renders suggestions (onboarding chips + "Maybe try" deflection chips —
|
|
* one shared .suggestion-chip component, renderChips below), shows KB
|
|
* health, and runs chat turns against POST /api/chat (SSE, PLAN §4).
|
|
*
|
|
* Loading feedback (PLAN §7.4 "never stale" contract, loading-feedback
|
|
* story) is one explicit state machine with a single entry point —
|
|
* setUiState(state) — driving the typing indicator, the send button
|
|
* (disabled/spinner/label), and the #send-status live region:
|
|
*
|
|
* idle → thinking → streaming → done | error → idle
|
|
*
|
|
* • thinking — pre-token: typing dots + disabled "Thinking…" button;
|
|
* after 10s the indicator's aria-label shows elapsed
|
|
* seconds so screen-reader users are never left guessing.
|
|
* Phase 17: while the model streams reasoning (`thinking`
|
|
* SSE events), the live collapsible Thinking block IS the
|
|
* visible feedback (it replaces the typing dots; the UI
|
|
* state stays "thinking" — button still disabled,
|
|
* "Thinking…") and the 120s guard clears on the first
|
|
* thinking *or* delta event.
|
|
* • streaming — the first delta removes the dots and appends live into
|
|
* the answer bubble (auto-collapsing the Thinking block,
|
|
* phase 17); the button stays busy until `done`.
|
|
* • error — red banner (role="alert") with an actionable retry hint;
|
|
* the 120s guard (TURN_TIMEOUT_MS) catches hung pre-token
|
|
* streams and the sawDone guard (phase 17) catches a
|
|
* stream that dies after frames but before `done`, 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. Phase
|
|
* 17: a brain record may carry an optional `thinking` field — the
|
|
* collapsed Thinking block is restored with it; records without it (old
|
|
* sessions) restore exactly as before, so no version bump. 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.
|
|
*
|
|
* Steering notes (phase 15) let the owner tune how Brain answers: a
|
|
* "Tune" button under every completed brain bubble (deflected included)
|
|
* opens an inline form → POST /api/steering → the note is stored in
|
|
* Postgres and injected into the system prompt of every subsequent turn
|
|
* (the <tuning> section). Notes are listed newest-first in the header
|
|
* "Tuning" panel (#steering-panel), where each can be deleted. Note text
|
|
* is always rendered with textContent (XSS-safe), save/delete are
|
|
* announced through a polite live region (#steering-announcer), and the
|
|
* panel + count badge update on every change.
|
|
*
|
|
* Scroll (phase 18, owner choice 2026-08-23): the page auto-scrolls only
|
|
* while the user is pinned to the bottom. NEAR_BOTTOM_PX (200px) covers
|
|
* the composer zone — the textarea auto-grows to 192px plus the button
|
|
* row — so "the composer is in view" counts as pinned: submitting from
|
|
* the composer reveals your own message, and the answer follows token by
|
|
* token while you stay pinned. Once you scroll up to read earlier
|
|
* content, nothing drags the viewport back down for the rest of the turn
|
|
* (thinking or answer). scrollReveal(wrap) is the single scroll gate;
|
|
* `force` is reserved for the one-shot phase-14 restore landing.
|
|
*
|
|
* All DOM ids match frontend/index.html.
|
|
*/
|
|
|
|
import { fetchIsAdmin, initSharedHeader } from "/assets/header.js";
|
|
|
|
const messagesEl = document.querySelector("#messages");
|
|
const emptyState = document.querySelector("#empty-state");
|
|
const suggestionsEl = document.querySelector("#suggestions");
|
|
const composer = document.querySelector("#composer");
|
|
const input = document.querySelector("#message-input");
|
|
const sendBtn = document.querySelector("#send-btn");
|
|
const sendLabel = document.querySelector("#send-label");
|
|
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");
|
|
|
|
/* ---------- loading-feedback contract (PLAN §7.4) ----------
|
|
* Client-side guard: a pre-token stream that produces no delta within
|
|
* TURN_TIMEOUT_MS is treated as hung → error state + banner. It is
|
|
* cleared on the first delta (entering "streaming") and on every
|
|
* terminal transition. Exported so the constant is testable (tests/unit/
|
|
* test_frontend_feedback.py). */
|
|
export const TURN_TIMEOUT_MS = 120_000;
|
|
|
|
const UI_STATE = Object.freeze({
|
|
idle: "idle",
|
|
thinking: "thinking",
|
|
streaming: "streaming",
|
|
error: "error",
|
|
});
|
|
|
|
const SEND_STATUS = Object.freeze({
|
|
[UI_STATE.idle]: "",
|
|
[UI_STATE.thinking]: "Brain of Reese is thinking",
|
|
[UI_STATE.streaming]: "Brain of Reese is answering",
|
|
[UI_STATE.error]: "The last question failed — try again",
|
|
});
|
|
|
|
const TYPING_LABEL = "Brain of Reese is thinking";
|
|
const ERROR_HINT = "Try again — if this persists, check the LLM is reachable.";
|
|
/* A turn with no answer content (an empty stream, or reasoning that
|
|
exhausted max_tokens — phase 17) still renders a bubble, and this exact
|
|
text is what gets persisted: what the user saw is what is stored. */
|
|
const EMPTY_ANSWER_FALLBACK = "Hmm — that came back empty. Ask me again?";
|
|
|
|
/* Calm, don't remove: smooth scrolling is the one motion JS controls. */
|
|
const reducedMotion =
|
|
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
const SCROLL = reducedMotion ? "auto" : "smooth";
|
|
|
|
/* Follow-the-bottom scroll contract (phase 18, owner choice
|
|
* 2026-08-23): the page auto-scrolls only while the user is pinned
|
|
* at the bottom — the 200px band covers the composer zone (the
|
|
* textarea auto-grows to 192px + the button row), i.e. "the
|
|
* composer is in view". Exported so the band is unit-pinned (same
|
|
* pattern as TURN_TIMEOUT_MS). */
|
|
export const NEAR_BOTTOM_PX = 200;
|
|
|
|
function isNearBottom() {
|
|
const bottom =
|
|
document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
|
|
return bottom <= NEAR_BOTTOM_PX;
|
|
}
|
|
|
|
/* The ONE scroll call site in this file. `force` is used only by
|
|
* the phase-14 restore landing (one-shot, load-time). */
|
|
function scrollReveal(wrap, behavior = SCROLL, force = false) {
|
|
if (force || isNearBottom()) {
|
|
wrap.scrollIntoView({ behavior, block: "end" });
|
|
}
|
|
}
|
|
|
|
/* ---------- document viewer link (phase 10; phase 13 adds `back`) ----------
|
|
* Every cited document opens in the viewer, in a NEW tab. All query
|
|
* values are percent-encoded: real paths contain slashes and sometimes
|
|
* spaces, which would otherwise corrupt the query string. `back` tells the
|
|
* viewer which page to return to when its back button is clicked — the
|
|
* chips live in the chat, so chat passes "/" (the viewer validates it:
|
|
* only same-origin relative URLs are honored; Sources links omit it and
|
|
* get the viewer's /sources.html default). (The renderer
|
|
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
|
|
* script loaded by index.html and document.html before these modules.) */
|
|
export function documentUrl(source, path, back = "/") {
|
|
let url =
|
|
"/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
|
if (back) url += "&back=" + encodeURIComponent(back);
|
|
return url;
|
|
}
|
|
|
|
/* ---------- steering notes (phase 15) ----------
|
|
*
|
|
* The owner's tuning notes steer every future answer: they live in
|
|
* Postgres (stateless API, A10) and the chat turn reads them into the
|
|
* system prompt. UI contract: Tune button → inline form → save →
|
|
* confirmation (or inline error, form kept); the header panel lists the
|
|
* notes (newest first) with per-note delete.
|
|
*/
|
|
const steeringToggle = document.querySelector("#steering-toggle");
|
|
const steeringCount = document.querySelector("#steering-count");
|
|
const steeringPanel = document.querySelector("#steering-panel");
|
|
const steeringList = document.querySelector("#steering-list");
|
|
const steeringEmpty = document.querySelector("#steering-empty");
|
|
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
|
|
|
const TUNE_ICON =
|
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>';
|
|
|
|
let tuneSeq = 0; // unique ids for one open tune form's inputs
|
|
|
|
function announceSteering(message) {
|
|
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
|
|
}
|
|
|
|
/* "Tune" button in the meta row of a completed brain bubble. Reuses the
|
|
sources' .msg-meta row when it exists (role=list → the button joins as
|
|
a listitem so ARIA stays valid); otherwise creates a plain meta row.
|
|
Phase 16: anonymous visitors never get the button — this single guard
|
|
covers both fresh turns and the phase-14 restore path. */
|
|
function appendTuneButton(wrap) {
|
|
if (!isAdmin) return; // phase 16: tuning is admin-only
|
|
const body = wrap.querySelector(".msg-body");
|
|
if (!body) return;
|
|
let meta = body.querySelector(".msg-meta");
|
|
if (!meta) {
|
|
meta = document.createElement("div");
|
|
meta.className = "msg-meta";
|
|
body.appendChild(meta);
|
|
}
|
|
if (meta.querySelector(".tune-btn")) return; // one per bubble
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "tune-btn";
|
|
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
|
btn.innerHTML = TUNE_ICON + "<span>Tune</span>";
|
|
btn.addEventListener("click", () => openTuneForm(wrap, btn));
|
|
meta.appendChild(btn);
|
|
}
|
|
|
|
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
|
|
+ Save / Cancel. Success replaces the form with the .tune-saved status
|
|
(role=status); failure keeps the form and shows an inline error
|
|
(role=alert) — the note is never lost on a failed save. */
|
|
function openTuneForm(wrap, toggleBtn) {
|
|
document.querySelectorAll(".tune-form").forEach((f) => f.remove()); // one at a time
|
|
const body = wrap.querySelector(".msg-body");
|
|
if (!body) return;
|
|
tuneSeq += 1;
|
|
const inputId = `tune-input-${tuneSeq}`;
|
|
const form = document.createElement("form");
|
|
form.className = "tune-form";
|
|
form.noValidate = true;
|
|
form.innerHTML =
|
|
`<label for="${inputId}">Tuning note — how should Brain answer from now on?</label>` +
|
|
`<textarea id="${inputId}" name="note" rows="2" maxlength="2000"
|
|
placeholder="e.g. be more concise — or: assume I'm on NixOS"></textarea>`;
|
|
const actions = document.createElement("div");
|
|
actions.className = "tune-form-actions";
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.type = "submit";
|
|
saveBtn.className = "tune-save";
|
|
saveBtn.textContent = "Save";
|
|
const cancelBtn = document.createElement("button");
|
|
cancelBtn.type = "button";
|
|
cancelBtn.className = "tune-cancel";
|
|
cancelBtn.textContent = "Cancel";
|
|
actions.append(saveBtn, cancelBtn);
|
|
form.appendChild(actions);
|
|
const status = document.createElement("p");
|
|
status.className = "tune-error";
|
|
status.setAttribute("role", "alert");
|
|
status.hidden = true;
|
|
form.appendChild(status);
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
saveBtn.disabled = true;
|
|
status.hidden = true;
|
|
try {
|
|
const r = await fetch("/api/steering", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ note: form.querySelector("textarea").value }),
|
|
});
|
|
if (!r.ok) {
|
|
let detail = "Could not save the note — try again.";
|
|
try {
|
|
const data = await r.json();
|
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
|
detail = String(data.detail[0].msg);
|
|
} else if (typeof data.detail === "string" && data.detail) {
|
|
detail = data.detail;
|
|
}
|
|
} catch { /* non-JSON error body */ }
|
|
status.textContent = detail;
|
|
status.hidden = false;
|
|
saveBtn.disabled = false;
|
|
return; // form kept on failure — the instruction survives
|
|
}
|
|
const saved = document.createElement("p");
|
|
saved.className = "tune-saved";
|
|
saved.setAttribute("role", "status");
|
|
saved.textContent = "Saved — future answers will follow this.";
|
|
form.replaceWith(saved);
|
|
announceSteering("Tuning note saved. Future answers will follow it.");
|
|
await loadSteering(); // panel + count badge update
|
|
} catch {
|
|
status.textContent = "Could not save the note — is the app reachable?";
|
|
status.hidden = false;
|
|
saveBtn.disabled = false;
|
|
}
|
|
});
|
|
cancelBtn.addEventListener("click", () => {
|
|
form.remove();
|
|
toggleBtn.focus();
|
|
});
|
|
body.appendChild(form);
|
|
form.querySelector("textarea").focus();
|
|
}
|
|
|
|
/* Panel: newest-first list (textContent — XSS-safe), per-note delete,
|
|
empty text, and the header count badge. */
|
|
async function loadSteering() {
|
|
let notes = [];
|
|
try {
|
|
const r = await fetch("/api/steering");
|
|
if (r.ok) notes = (await r.json()).notes || [];
|
|
} catch { /* API unreachable: keep the last rendered list */ }
|
|
renderSteeringPanel(notes);
|
|
return notes;
|
|
}
|
|
|
|
function renderSteeringPanel(notes) {
|
|
if (!steeringList) return;
|
|
steeringList.textContent = "";
|
|
for (const n of notes) {
|
|
const li = document.createElement("li");
|
|
li.className = "steering-note";
|
|
const text = document.createElement("span");
|
|
text.className = "steering-note-text";
|
|
text.textContent = n.note; // rendered as text, never as HTML
|
|
li.appendChild(text);
|
|
const del = document.createElement("button");
|
|
del.type = "button";
|
|
del.className = "steering-delete";
|
|
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
|
del.innerHTML =
|
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
|
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
|
li.appendChild(del);
|
|
steeringList.appendChild(li);
|
|
}
|
|
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
|
if (steeringCount) steeringCount.textContent = String(notes.length);
|
|
}
|
|
|
|
async function deleteSteeringNote(id, btn) {
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
if (r.status === 404) {
|
|
announceSteering("That note was already removed.");
|
|
await loadSteering();
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
announceSteering("Could not delete the note — try again.");
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
await loadSteering();
|
|
announceSteering("Tuning note deleted.");
|
|
} catch {
|
|
announceSteering("Could not delete the note — is the app reachable?");
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function setSteeringPanel(open) {
|
|
if (!steeringPanel || !steeringToggle) return;
|
|
steeringPanel.hidden = !open;
|
|
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
}
|
|
if (steeringToggle && steeringPanel) {
|
|
steeringToggle.addEventListener("click", () => {
|
|
setSteeringPanel(steeringPanel.hidden);
|
|
if (!steeringPanel.hidden) loadSteering(); // refresh when (re)opened
|
|
});
|
|
}
|
|
|
|
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
|
|
* Inline SVG as string constants so the message renderer and the typing
|
|
* indicator share exactly the same marks. currentColor lets the CSS theme
|
|
* the stroke (brand-ink for Brain, ink-soft for the user — see styles.css).
|
|
*/
|
|
const BRAIN_AVATAR =
|
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><circle cx="12" cy="12" r="1.9" fill="currentColor" stroke="none"/><path d="M9.5 6.5V3.8M14.5 6.5V3.8M9.5 20.2v-2.7M14.5 20.2v-2.7M6.5 9.5H3.8M6.5 14.5H3.8M20.2 9.5h-2.7M20.2 14.5h-2.7"/></svg>';
|
|
|
|
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 ----------
|
|
* Scroll is conditional (phase 18): addMessage reveals through
|
|
* scrollReveal — only when the user is pinned to the bottom, or when
|
|
* forced (the one-shot phase-14 restore landing). */
|
|
function addMessage(who, html, scrollBehavior = SCROLL, force = false) {
|
|
if (emptyState) emptyState.hidden = true;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = `msg ${who}`;
|
|
wrap.innerHTML = `
|
|
<span class="avatar" aria-hidden="true">${who === "brain" ? BRAIN_AVATAR : USER_AVATAR}</span>
|
|
<div class="msg-body">
|
|
<div class="bubble">${html}</div>
|
|
</div>`;
|
|
messagesEl.appendChild(wrap);
|
|
scrollReveal(wrap, scrollBehavior, force);
|
|
return wrap;
|
|
}
|
|
|
|
function addTyping() {
|
|
removeTyping(); // idempotent: at most one indicator at a time
|
|
if (emptyState) emptyState.hidden = true;
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "msg brain";
|
|
wrap.id = "typing-indicator";
|
|
wrap.innerHTML = `
|
|
<span class="avatar" aria-hidden="true">${BRAIN_AVATAR}</span>
|
|
<div class="msg-body">
|
|
<div class="bubble typing" role="status" aria-label="${TYPING_LABEL}">
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
</div>`;
|
|
messagesEl.appendChild(wrap);
|
|
scrollReveal(wrap);
|
|
}
|
|
|
|
function removeTyping() {
|
|
document.querySelector("#typing-indicator")?.remove();
|
|
}
|
|
|
|
/* ---------- thinking block (phase 17) ----------
|
|
* The model's reasoning streams into a collapsible <details> block ABOVE
|
|
* the answer bubble: created OPEN on the first `thinking` event,
|
|
* auto-collapsed when the first answer token lands, and user-toggleable
|
|
* afterwards (native <details>/<summary> — a real focusable control).
|
|
* ensureThinkingBlock is idempotent (returns the existing block if any);
|
|
* closeThinkingBlock never reopens a block once the answer has started,
|
|
* so a late/interleaved `thinking` event only appends to the closed text. */
|
|
function ensureThinkingBlock(wrap) {
|
|
let block = wrap.querySelector(".thinking");
|
|
if (!block) {
|
|
block = document.createElement("details");
|
|
block.className = "thinking";
|
|
block.open = true;
|
|
block.innerHTML =
|
|
`<summary>Thinking</summary><div class="thinking-text"></div>`;
|
|
const body = wrap.querySelector(".msg-body");
|
|
body.insertBefore(block, body.querySelector(".bubble"));
|
|
}
|
|
return block;
|
|
}
|
|
|
|
function closeThinkingBlock(wrap) {
|
|
const block = wrap?.querySelector?.(".thinking");
|
|
if (block) block.open = false; // idempotent; no-op without a block
|
|
}
|
|
|
|
/* ---------- suggestions (shared chip component, phase 05) ----------
|
|
*
|
|
* One component, two homes: the onboarding row in the empty state and the
|
|
* "Maybe try" row under a deflected answer. The container must be
|
|
* role="list" with an accessible name ("Suggested questions" / "Maybe
|
|
* try"); each chip is a real <button type="button"> with role="listitem".
|
|
* Clicking a chip is one tap → question: it fills the composer, focuses it,
|
|
* and submits — the same behavior everywhere (submitSuggestion).
|
|
*/
|
|
function submitSuggestion(text) {
|
|
input.value = text;
|
|
autoGrow();
|
|
input.focus();
|
|
composer.requestSubmit();
|
|
}
|
|
|
|
function renderChips(container, items, { onSelect } = {}) {
|
|
// Replace only previous chips; keep any other children (e.g. the
|
|
// visually-hidden group label inside a "maybe-try" row).
|
|
container.querySelectorAll(".suggestion-chip").forEach((c) => c.remove());
|
|
for (const item of items || []) {
|
|
const text = String(item || "").trim();
|
|
if (!text) continue;
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "suggestion-chip";
|
|
btn.setAttribute("role", "listitem");
|
|
btn.textContent = text;
|
|
btn.addEventListener("click", () => {
|
|
submitSuggestion(text);
|
|
if (onSelect) onSelect(text, btn);
|
|
});
|
|
container.appendChild(btn);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
async function loadSuggestions() {
|
|
try {
|
|
const r = await fetch("/api/suggestions");
|
|
if (!r.ok) return;
|
|
const { suggestions } = await r.json();
|
|
renderChips(suggestionsEl, suggestions);
|
|
} catch {
|
|
/* suggestions are progressive enhancement: no chips, no error spam */
|
|
}
|
|
}
|
|
|
|
/* ---------- health / version ---------- */
|
|
async function loadHealth() {
|
|
try {
|
|
const r = await fetch("/api/health");
|
|
const body = await r.json();
|
|
versionEl.textContent = `v${body.version}`;
|
|
if (body.db === "down") {
|
|
bannerText.textContent =
|
|
"Knowledge base is offline — start Postgres with `podman compose up -d db`.";
|
|
banner.hidden = false;
|
|
}
|
|
} catch {
|
|
/* API unreachable: page still renders, composer will explain on send */
|
|
}
|
|
}
|
|
|
|
/* ---------- chat feedback state machine (PLAN §7.4) ----------
|
|
*
|
|
* Timers belong to the state machine, not to the turn handler: every
|
|
* transition stops/clears them, which is what makes a stuck button
|
|
* impossible.
|
|
*/
|
|
let uiState = UI_STATE.idle;
|
|
let thinkingClock = 0; // setInterval id — elapsed-seconds hint
|
|
let thinkingStart = 0; // Date.now() when "thinking" began
|
|
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
|
|
|
|
function stopThinkingClock() {
|
|
if (thinkingClock) {
|
|
clearInterval(thinkingClock);
|
|
thinkingClock = 0;
|
|
}
|
|
}
|
|
|
|
function startThinkingClock() {
|
|
thinkingStart = Date.now();
|
|
thinkingClock = setInterval(() => {
|
|
const secs = Math.round((Date.now() - thinkingStart) / 1000);
|
|
if (secs < 10) return; // hint only after 10s of pre-token silence
|
|
const bubble = document.querySelector("#typing-indicator .bubble");
|
|
if (bubble) {
|
|
bubble.setAttribute("aria-label", `Brain of Reese is still thinking (${secs}s)`);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function armTurnTimeout(onTimeout) {
|
|
clearTurnTimeout();
|
|
turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS);
|
|
}
|
|
|
|
function clearTurnTimeout() {
|
|
if (turnTimeout) {
|
|
clearTimeout(turnTimeout);
|
|
turnTimeout = 0;
|
|
}
|
|
}
|
|
|
|
/* The single entry point for chat feedback. Every in-flight state has a
|
|
* visible indicator; every terminal state re-enables the button. */
|
|
export function setUiState(state, errorDetail = "") {
|
|
uiState = state;
|
|
stopThinkingClock();
|
|
clearTurnTimeout(); // the guard only owns the pre-token window
|
|
|
|
const inFlight = state === UI_STATE.thinking || state === UI_STATE.streaming;
|
|
sendBtn.disabled = inFlight;
|
|
sendBtn.querySelector(".spinner").hidden = !inFlight;
|
|
sendLabel.textContent = inFlight ? "Thinking…" : "Send";
|
|
sendStatus.textContent = SEND_STATUS[state] ?? "";
|
|
|
|
if (state === UI_STATE.thinking) {
|
|
addTyping();
|
|
startThinkingClock();
|
|
} else {
|
|
removeTyping();
|
|
}
|
|
if (state === UI_STATE.error) showErrorBanner(errorDetail);
|
|
}
|
|
|
|
function autoGrow() {
|
|
input.style.height = "auto";
|
|
input.style.height = `${Math.min(input.scrollHeight, 192)}px`;
|
|
}
|
|
|
|
/* ---------- chat turn (SSE streaming, PLAN §4) ---------- */
|
|
|
|
/* Parse an SSE response body into JSON events. */
|
|
async function readSSE(response, onEvent) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buf = "";
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += decoder.decode(value, { stream: true });
|
|
let sep;
|
|
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
|
const frame = buf.slice(0, sep).trim();
|
|
buf = buf.slice(sep + 2);
|
|
if (!frame.startsWith("data:")) continue;
|
|
const payload = frame.slice(5).trim();
|
|
if (!payload || payload === "[DONE]") continue;
|
|
onEvent(JSON.parse(payload));
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Source chips (mono, source/path) under a Brain bubble. */
|
|
function appendSources(wrap, sources) {
|
|
if (!sources || !sources.length) return;
|
|
const body = wrap.querySelector(".msg-body");
|
|
const meta = document.createElement("div");
|
|
meta.className = "msg-meta";
|
|
meta.setAttribute("role", "list");
|
|
meta.setAttribute("aria-label", "Sources");
|
|
for (const s of sources) {
|
|
const label = `${s.source}/${s.path}`;
|
|
const chip = document.createElement("a");
|
|
chip.className = "source-chip";
|
|
chip.setAttribute("role", "listitem");
|
|
chip.href = documentUrl(s.source, s.path, "/"); // back → the chat page
|
|
chip.target = "_blank"; // open the full document in a new tab
|
|
chip.rel = "noopener";
|
|
chip.textContent = label;
|
|
chip.title = label;
|
|
meta.appendChild(chip);
|
|
}
|
|
body.appendChild(meta);
|
|
// Accessible full path whenever the pill visually truncates.
|
|
for (const chip of meta.children) {
|
|
if (chip.scrollWidth > chip.clientWidth) chip.setAttribute("aria-label", chip.title);
|
|
}
|
|
}
|
|
|
|
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase 04,
|
|
shared component + one-tap submit, phase 05). The group is accessible
|
|
(role=list + aria-label) and wraps cleanly at every width. */
|
|
function appendMaybeTry(wrap, suggestions) {
|
|
if (!suggestions || !suggestions.length) return;
|
|
const body = wrap.querySelector(".msg-body");
|
|
const group = document.createElement("div");
|
|
group.className = "maybe-try";
|
|
group.setAttribute("role", "list");
|
|
group.setAttribute("aria-label", "Maybe try");
|
|
const label = document.createElement("span");
|
|
label.className = "visually-hidden";
|
|
label.textContent = "Maybe try:";
|
|
group.appendChild(label);
|
|
renderChips(group, 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?,
|
|
* thinking? }] }
|
|
*
|
|
* 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) {
|
|
// Phase 18: the restore landing is the only `force`d scroll — one-shot,
|
|
// non-smooth, so a restored conversation lands on its latest message
|
|
// (phase-14 behavior preserved) without smooth-scrolling through it.
|
|
if (m.who === "user") {
|
|
addMessage("user", renderMarkdown(m.text), "auto", true);
|
|
return;
|
|
}
|
|
const wrap = addMessage("brain", renderMarkdown(m.text), "auto", true);
|
|
if (m.thinking) {
|
|
// Phase 17: restore the thinking block COLLAPSED above the bubble.
|
|
const block = ensureThinkingBlock(wrap);
|
|
block.open = false;
|
|
block.querySelector(".thinking-text").innerHTML = renderMarkdown(m.thinking);
|
|
}
|
|
if (m.deflected) {
|
|
wrap.classList.add("is-deflected");
|
|
appendMaybeTry(wrap, m.suggestions);
|
|
}
|
|
appendSources(wrap, m.sources);
|
|
appendTuneButton(wrap); // restored brain answers are tunable too
|
|
}
|
|
|
|
/* 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 + metadata.
|
|
Phase 17: meta.thinking is optional — `undefined` drops the key from
|
|
the JSON, so turns without thinking persist exactly as before. An empty
|
|
answer keeps the fallback/"…" text that was actually rendered — what
|
|
the user saw is what is stored. */
|
|
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). */
|
|
/* ---------- single-admin auth (phase 16, A10 revised) ----------
|
|
*
|
|
* /api/whoami decides the header: anonymous → the Sign in link and NO
|
|
* tuning surface at all — the Tuning toggle + panel are removed from the
|
|
* DOM (the story says "absent", not just hidden), /api/steering is never
|
|
* fetched, and appendTuneButton injects nothing (new or restored
|
|
* messages). Admin → Sign out + the full phase-15 UI. Whoami is awaited
|
|
* BEFORE the phase-14 restore, so restored brain bubbles never flash a
|
|
* Tune button that should not be there.
|
|
*
|
|
* Phase 19: the whoami fetch, the Sign in / Sign out / Sources-nav
|
|
* toggling, and the #sign-out-btn click binding (POST /api/logout +
|
|
* reload) all moved to the shared header module (assets/header.js) —
|
|
* initSharedHeader() does the header toggling on every page, and
|
|
* fetchIsAdmin() is the single cached whoami, so this page still makes
|
|
* exactly one request per load. applyAuthState keeps only the
|
|
* chat-page-specific work (removing the tuning surface for anonymous
|
|
* visitors) — idempotent alongside the header module's own link/button
|
|
* toggling.
|
|
*/
|
|
const signInLink = document.querySelector("#sign-in-link");
|
|
const signOutBtn = document.querySelector("#sign-out-btn");
|
|
let isAdmin = false;
|
|
|
|
function applyAuthState() {
|
|
if (signInLink) signInLink.hidden = isAdmin;
|
|
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
|
if (!isAdmin && steeringToggle) {
|
|
steeringToggle.remove();
|
|
steeringPanel?.remove();
|
|
}
|
|
}
|
|
|
|
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");
|
|
banner.setAttribute("role", "alert");
|
|
bannerText.textContent = detail ? `${detail} ${ERROR_HINT}` : ERROR_HINT;
|
|
}
|
|
|
|
function clearErrorBanner() {
|
|
if (banner.classList.contains("is-error")) {
|
|
banner.classList.remove("is-error");
|
|
banner.setAttribute("role", "status");
|
|
bannerText.textContent = "";
|
|
banner.hidden = true;
|
|
}
|
|
}
|
|
|
|
async function handleSend(e) {
|
|
e.preventDefault();
|
|
const text = input.value.trim();
|
|
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();
|
|
|
|
let wrap = null;
|
|
let acc = "";
|
|
let res = null;
|
|
let aborted = false; // the 120s guard already took the turn to error
|
|
// Phase 17 (thinking display): turn-local reasoning state.
|
|
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
|
|
let sawThinking = false; // did any `thinking` frame arrive this turn?
|
|
let sawDone = false; // did the stream end with a `done` event?
|
|
|
|
try {
|
|
// thinking = pre-token: dots + busy button. The guard is armed so a
|
|
// hung stream can never leave the button zombified; it clears on the
|
|
// first thinking OR delta event (phase 17) and on every terminal
|
|
// transition.
|
|
setUiState(UI_STATE.thinking);
|
|
armTurnTimeout(() => {
|
|
aborted = true;
|
|
try { res?.body?.cancel(); } catch { /* already closed */ }
|
|
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
|
|
});
|
|
|
|
res = await fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ message: text }),
|
|
});
|
|
if (!res.ok || !res.body) {
|
|
let detail = `Brain's API answered with HTTP ${res.status}.`;
|
|
try {
|
|
const body = await res.json();
|
|
if (body.detail) detail = body.detail;
|
|
} catch { /* non-JSON error body */ }
|
|
throw new Error(detail);
|
|
}
|
|
await readSSE(res, (ev) => {
|
|
if (aborted) return;
|
|
if (ev.type === "thinking") {
|
|
// Phase 17: model reasoning — stream it live into the collapsible
|
|
// Thinking block. No setUiState here: the UI state stays
|
|
// "thinking" (button still disabled with "Thinking…", #send-status
|
|
// unchanged) — the live block simply replaces the typing dots as
|
|
// the visible feedback.
|
|
thinkingAcc += ev.text || "";
|
|
sawThinking = true;
|
|
clearTurnTimeout(); // the stream is alive — as the first delta says
|
|
if (!wrap) wrap = addMessage("brain", "");
|
|
removeTyping(); // the live block replaces the dots as feedback
|
|
const block = ensureThinkingBlock(wrap);
|
|
const textEl = block.querySelector(".thinking-text");
|
|
textEl.innerHTML = renderMarkdown(thinkingAcc); // escape-first, XSS-safe
|
|
if (block.open) {
|
|
textEl.scrollTop = textEl.scrollHeight; // pin the stream to the bottom
|
|
scrollReveal(wrap); // page follows only while pinned (phase 18)
|
|
}
|
|
} else if (ev.type === "delta") {
|
|
acc += ev.text || "";
|
|
if (uiState === UI_STATE.thinking) setUiState(UI_STATE.streaming);
|
|
if (!wrap) wrap = addMessage("brain", ""); // first token: live bubble in
|
|
closeThinkingBlock(wrap); // auto-collapse; idempotent, never reopens
|
|
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
|
|
scrollReveal(wrap); // page follows only while pinned (phase 18)
|
|
} else if (ev.type === "done") {
|
|
sawDone = true;
|
|
closeThinkingBlock(wrap); // the turn is over: settle the block closed
|
|
if (!wrap) {
|
|
setUiState(UI_STATE.streaming);
|
|
wrap = addMessage("brain", "…");
|
|
}
|
|
if (ev.deflected) {
|
|
wrap.classList.add("is-deflected");
|
|
appendMaybeTry(wrap, ev.suggestions);
|
|
}
|
|
appendSources(wrap, ev.sources);
|
|
appendTuneButton(wrap); // every completed brain bubble is tunable
|
|
// Thinking-without-answer (reasoning can exhaust max_tokens): the
|
|
// bubble gets the empty-answer fallback — what the user saw is
|
|
// what gets persisted.
|
|
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
|
if (!acc && sawThinking) {
|
|
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
|
}
|
|
// Persistence save point 2: the answer lands only when the turn is
|
|
// complete (raw text + the done metadata; phase 17: + optional
|
|
// thinking — `undefined` drops the key from the JSON).
|
|
rememberBrainTurn(finalText || acc, {
|
|
thinking: thinkingAcc || undefined,
|
|
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.");
|
|
}
|
|
});
|
|
// Stream-drop guard (phase 17): frames arrived but no `done` event —
|
|
// the connection died mid-turn. Say so; never settle silently into
|
|
// idle with a half bubble. The zero-frame case falls through to the
|
|
// existing empty-answer fallback below.
|
|
if (!sawDone && !aborted && (acc || thinkingAcc)) {
|
|
setUiState(
|
|
UI_STATE.error,
|
|
"The stream ended before my answer finished — try again?"
|
|
);
|
|
}
|
|
if (!aborted && !wrap) {
|
|
const fallback = EMPTY_ANSWER_FALLBACK;
|
|
const fwrap = addMessage("brain", fallback);
|
|
appendTuneButton(fwrap);
|
|
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
|
}
|
|
} catch (err) {
|
|
if (!aborted) {
|
|
const detail =
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: "Something went wrong on my side.";
|
|
setUiState(UI_STATE.error, detail);
|
|
}
|
|
} finally {
|
|
// done | error → idle: always settle, always focus back. State is
|
|
// turn-local, so a page reload mid-stream leaves a usable composer.
|
|
clearTurnTimeout();
|
|
stopThinkingClock();
|
|
try { res?.body?.cancel(); } catch { /* stream already closed */ }
|
|
if (uiState !== UI_STATE.idle) setUiState(UI_STATE.idle);
|
|
// Phase 18: focus back for the next question, but never move the
|
|
// viewport — a user reading earlier content stays where they are.
|
|
input.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
input.addEventListener("input", autoGrow);
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
composer.requestSubmit();
|
|
}
|
|
});
|
|
composer.addEventListener("submit", handleSend);
|
|
|
|
/* Boot: auth state FIRST — it decides whether the restored conversation
|
|
gets Tune buttons and whether the steering UI exists at all (phase 16).
|
|
Phase 14: the conversation then comes back exactly as left. Phase 19:
|
|
the shared header module runs the whoami (cached — exactly one
|
|
request per page load) and toggles the Sign in/out pair + the Sources
|
|
nav link; applyAuthState() then applies the chat-page-only gating. */
|
|
(async () => {
|
|
await initSharedHeader(); // header.js: whoami + Sign in/out + #nav-sources
|
|
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
|
applyAuthState(); // chat page: the admin-only tuning surface
|
|
restoreConversation();
|
|
loadSuggestions();
|
|
loadHealth();
|
|
if (isAdmin) loadSteering(); // phase 15: panel + count badge (admin only)
|
|
})();
|