Files
brain-of-reese/frontend/assets/shared.js
T
ducoterra bef24e05e2
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s
phase: 123_chat_image_questions
All gates green. Verification complete.

**Phase 123 — final verification pass (all 4 tasks already in `complete/`)**

- Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer.
- `uv run pytest` → **2796 passed**, exit 0 (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%).
- `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation.
- `uv run ruff check . && uv run pyright` → clean (0 errors).

**Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run).

No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
2026-09-25 05:19:18 -04:00

457 lines
21 KiB
JavaScript

/* Brain of Reese — the anonymous shared conversation page (phase 51,
* task 03).
*
* /shared/<token> (owner-locked 2026-08-29, TODO.md L6): anyone with
* the link sees the conversation READ-ONLY — zero interactive controls
* (no composer, no Save/Share/Tune/Retry, no document access). The
* page fetches GET /api/shared/<token> (public — the token IS the
* credential, no admin dependency) and renders the SAME record shape
* the chat page uses (phase 14 bor.chat.v1 — { who, text, sources?,
* deflected?, suggestions?, thinking?, tools?, stopped? }):
*
* • user → the .msg.user bubble (markdown, escape-first);
* • brain → the .msg.brain bubble: the optional thinking block
* restored COLLAPSED (the phase-17 restore convention — a guest
* can still expand it; reading is not mutating), the tool lines
* in saved order (phase 37), the is-deflected treatment + the
* "Maybe try" chips as PLAIN SPAN text (a guest tapping a chip
* has nowhere to go — owner-locked zero controls), the source
* chips as PLAIN TEXT spans (no href, no modal wiring — the
* documents API is admin-only, so a guest cannot open documents),
* and the stopped note (phase 48) when the turn was user-stopped.
*
* Per-page duplication house style (history.js keeps its own clipboard
* helper, the chat page's stays in app.js): this file carries its own
* small copies of the chat page's message-fragment builders — the
* thinking block, the tool lines, the stopped note, the chip rows.
* Nothing is imported from app.js (a page script is never imported by
* another page; header.js is the only cross-page module).
*
* Failure contract: a malformed or missing token in the URL → the
* invalid state shows immediately with NO fetch of any kind (not even
* whoami — the header ships in its guest state, which is already
* correct); a 404 (wrong or revoked token), a network failure, or a
* malformed body → the invalid state (the h1 keeps its fallback), no
* data is rendered, and there is no error banner on this page (zero
* controls — the muted invalid card is the whole failure UI).
*
* The header works for guests: initSharedHeader() runs the cached
* whoami (anonymous → the admin-only links stay hidden, the Sign in
* link is shown) and rewrites ?next= to the current pathname for a
* signed-in admin; the guest's static fallback is ?next=/ — a guest
* signing in from a shared page returns to the app root (see
* shared.html).
*
* All DOM ids match frontend/shared.html.
*/
import { bindSharedHeaderControls, initSharedHeader } from "./header.js";
// The shared header's control bindings (sign-out / mobile hamburger) —
// EXPLICIT init, once per document (header.js is bundle-inlined per
// entry; import-time side effects would double-bind — 2026-09-08 fix).
bindSharedHeaderControls();
const titleEl = document.querySelector("#shared-title");
const noteEl = document.querySelector(".shared-note");
const messagesEl = document.querySelector("#messages");
const invalidEl = document.querySelector("#shared-invalid");
/* 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
* fetch refreshes it). Read LAZILY (a function, not a const string):
* the note set after the fetch lands carries the configured name; the
* literal is only the no-config fallback. */
const brand = () => window.BOR_BRAND || "Brain of Reese";
/* ---------- the token (from the URL) ----------
* The last path segment of /shared/<token>. A malformed or missing
* token (a path without a final segment, or a non-uuid segment) →
* null: the boot shows the invalid state immediately and makes NO
* fetch of any kind (the server's page route 404s a hand-typed
* /shared/garbage anyway — the client gate keeps the no-fetch rule
* and renders the page's own invalid state instead of a JSON error). */
const TOKEN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function parseSharedToken() {
const segments = window.location.pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1] || "";
return TOKEN_RE.test(last) ? last : null;
}
/* ---------- the invalid / revoked state ----------
* The one failure UI on the page: the centered muted card (ship-
* hidden in the markup, revealed here). The h1 keeps its static
* fallback, the conversation section stays empty — no data rendered,
* no banner. */
export function showInvalid() {
if (invalidEl) invalidEl.hidden = false;
}
/* ---------- avatar glyphs (duplicated from app.js, phase 08) ----------
* Inline SVG as string constants so the message renderer shares the
* exact marks the chat page uses. currentColor lets the CSS theme the
* stroke (brand-ink for Brain, ink-soft for the user). */
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 (read-only) ----------
* The SAME .msg/.msg-body/.bubble structure the chat page renders, so
* the existing CSS applies unchanged. No scroll intent (a guest lands
* where the browser puts them — no composer to reveal), no empty
* state to hide (the section is empty by construction until records
* are appended). */
function addSharedMessage(who, html) {
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);
return wrap;
}
/* The thinking block (phase 17) — the local copy of the chat page's
* restore path: the model's reasoning ABOVE the answer bubble,
* restored COLLAPSED (the phase-17 restore convention — the live path
* opens it while streaming; a shared chat is a finished conversation,
* so it lands closed). Native details/summary — expanding is reading,
* not mutating. The reasoning text is stored RAW, so it goes through
* the same escape-first global renderMarkdown as the answer. */
function addThinkingBlock(wrap, thinking) {
const body = wrap.querySelector(".msg-body");
if (!body) return;
const block = document.createElement("details");
block.className = "thinking";
block.open = false; // restored COLLAPSED
const summary = document.createElement("summary");
summary.textContent = "Thinking";
const textEl = document.createElement("div");
textEl.className = "thinking-text";
textEl.innerHTML = renderMarkdown(thinking); // escape-first, XSS-safe
block.append(summary, textEl);
body.insertBefore(block, body.querySelector(".bubble"));
}
/* Tool-call lines (phase 37; phase 70 remapped the tool names to the
* harness surface ls / read(path) / grep(pattern, path?)) — the local
* copy of the chat page's appendToolLine: one visible "calling tool"
* row per saved {name, argument} record, in saved order, above the
* answer. Every argument (path / pattern / source scope) goes through
* textContent, so nothing HTML-shaped can come from storage. Lines
* are not interactive (no focus targets). Phase 70: the NEW names
* render (read → the Reading line, grep → the Searching-for line,
* ls → the Listing-documents line, scoped ls → the
* Listing-documents-in-<scope> line), and the pre-phase-70 names
* (read_document / search_documents / list_documents) still render
* exactly as before — a row saved before the remap keeps its exact
* line (no migration). The content marks are the exact app.js
* template strings — the frontend emoji guard (tests/integration/
* test_api.py) strips precisely those literals in this file, as in
* app.js.
*
* Phase 95 (task 02): the truncation marker rides the SAME stored
* record the chat page uses — a {name, argument, truncated,
* chars_shown, chars_total} entry (the live `tool_result` frame's stamp,
* persisted with the turn) re-renders the identical " (truncated —
* showing N of M chars)" span next to its Reading line, so a shared
* page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage.
*
* Phase 117 (D2/D3): the lines ride the SAME native <details>
* disclosure as the chat page (details.tool-calls-disclosure + a
* plain-text "Tool call(s) (N)" summary) — but a pure render has no
* live turn, so it is created CLOSED: the shared page shows one
* compact line, never an auto-expanded record. The line bytes, the
* four label literals, and the <code> textContent arguments are
* untouched (the parity pins stay green). */
function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
// Phase 117 (D3): the SAME disclosure as the chat page — created
// CLOSED here (pure render, always folded; no live turn to open it).
const disclosure = document.createElement("details");
disclosure.className = "tool-calls-disclosure";
disclosure.open = false; // D3: closed on the shared page (pure render)
const summary = document.createElement("summary");
summary.className = "tool-calls-summary";
disclosure.appendChild(summary);
const container = document.createElement("div");
container.className = "tool-calls";
container.setAttribute("role", "list");
container.setAttribute("aria-label", "Tool calls");
disclosure.appendChild(container);
for (const t of tools) {
if (!t || typeof t.name !== "string") continue;
const line = document.createElement("span");
line.className = "tool-call";
line.setAttribute("role", "listitem");
const argument =
typeof t.argument === "string" && t.argument ? t.argument : null;
// Phase 70: new names first, legacy names kept — a conversation
// saved before the remap renders byte-identical (no migration).
if ((t.name === "read" || t.name === "read_document") && argument) {
line.textContent = "📄 Reading ";
const code = document.createElement("code");
code.textContent = argument; // the path is data, never markup
line.appendChild(code);
} else if (
(t.name === "grep" || t.name === "search_documents") && argument
) {
line.textContent = "🔎 Searching for ";
const code = document.createElement("code");
code.textContent = argument; // the pattern is data, never markup
line.appendChild(code);
} else if (t.name === "ls" && argument) {
line.textContent = "🔎 Listing documents in ";
const code = document.createElement("code");
code.textContent = argument; // the source scope is data, never markup
line.appendChild(code);
} else {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
// Phase 95: the stored truncation record — the same marker the chat
// page's restore path renders (plain integers, no separators);
// only argument-bearing (Reading) lines can carry it.
if (t.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(t.chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)";
line.appendChild(note);
}
}
// Phase 117 (D5): the count once, after every line is in.
const n = container.children.length;
summary.textContent = `Tool call${n === 1 ? "" : "s"} (${n})`;
body.insertBefore(disclosure, body.querySelector(".bubble"));
}
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase
* 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a
* guest tapping a chip has nowhere to go — zero interactive
* controls). Same .suggestion-chip pill look as the chat page; the
* shared-page CSS kills the pointer (pointer-events: none, scoped to
* .shared-shell — the chat page's interactive chips are untouched). */
function addMaybeTry(wrap, suggestions) {
if (!Array.isArray(suggestions) || !suggestions.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
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);
for (const item of suggestions) {
const text = String(item || "").trim();
if (!text) continue;
const chip = document.createElement("span");
chip.className = "suggestion-chip";
chip.setAttribute("role", "listitem");
chip.textContent = text; // raw text — never markup
group.appendChild(chip);
}
body.appendChild(group);
}
/* Source chips (mono, source/path) under a brain bubble — PLAIN TEXT
* spans: no href, no modal wiring, no click handler (owner-locked:
* guests cannot open documents — the documents API is admin-only,
* phase 16). Same .source-chip pill look as the chat page; the
* shared-page CSS kills the pointer, scoped to .shared-shell. */
function addSources(wrap, sources) {
if (!Array.isArray(sources) || !sources.length) return;
const body = wrap.querySelector(".msg-body");
if (!body) return;
const meta = document.createElement("div");
meta.className = "msg-meta";
meta.setAttribute("role", "list");
meta.setAttribute("aria-label", "Sources");
for (const s of sources) {
if (!s || typeof s.source !== "string" || typeof s.path !== "string") continue;
const label = `${s.source}/${s.path}`;
const chip = document.createElement("span");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.textContent = label; // the path is data — never markup
chip.title = label;
meta.appendChild(chip);
}
body.appendChild(meta);
}
/* The "Stopped" note (phase 48) — the local copy of the chat page's
* appendStoppedNote: the non-interactive meta-row mark on a
* user-stopped brain bubble (the partial answer is what the owner
* shared). Reuses the .msg-meta row when one exists (the source
* chips' row) so the note joins it as a listitem, ARIA-valid. */
function addStoppedNote(wrap) {
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(".stopped-note")) return; // one per bubble
const note = document.createElement("span");
note.className = "stopped-note";
if (meta.getAttribute("role") === "list") note.setAttribute("role", "listitem");
note.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6.5" y="6.5" width="11" height="11" rx="2"/></svg>';
const label = document.createElement("span");
label.textContent = "Stopped";
note.appendChild(label);
meta.appendChild(note);
}
/* The question's attached image (phase 123, task 03, TODO L6) — the
* local copy of the chat page's attachBubbleImage (the per-page
* duplication house style: this file keeps its own small copies of
* the chat page's message-fragment builders). The SAME .msg-image
* treatment the chat page uses — styles.css is shared by both pages,
* so the rule needs no second copy: the img at the TOP of the user
* bubble (the attachment is part of the question), lazy, alt = the
* record's text or the fallback. The load failure degrades IDENTICALLY
* to the chat page: the img is replaced by the small "image
* unavailable" line (the stored file was deleted out-of-band — the
* record keeps its path, the render degrades; never a broken icon).
* The serve route is public (the token is the shared chat's
* credential, like saved-chat content), so the img loads for guests
* exactly as it does for the owner. */
function addBubbleImage(wrap, src, alt) {
const bubble = wrap?.querySelector?.(".bubble");
if (!bubble) return;
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
}
/* One stored record through the SAME .msg structure the chat page
* uses (pixel-parity with the chat page's restore path): user → the
* .msg.user bubble (with the question's attached image when the
* record carries the stored path — phase 123, task 03); brain → the
* .msg.brain bubble with the optional
* thinking block (restored COLLAPSED — phase 17), the tool lines,
* the deflection treatment + the plain-text "Maybe try" chips, the
* plain-text source chips, and the stopped note. NO interactive
* markup is ever created here — no buttons, no forms, no links, no
* click handlers (owner-locked: zero controls). Markdown goes
* through the global escape-first renderMarkdown (markdown.js): the
* stored payloads are raw text, so the renderer's XSS safety applies
* unchanged. */
function renderSharedMessage(m) {
if (m.who === "user") {
const wrap = addSharedMessage("user", renderMarkdown(m.text));
// Phase 123 (task 03, TODO L6): the question's attached image —
// the record's `m.image` carries the STORED PATH (A5: never
// base64; a pre-phase record has no key at all, so it renders
// byte-identically — no img). The public image route makes the
// shared view faithful: the SAME bubble treatment, alt, and
// load-failure degradation as the chat page's restore.
if (typeof m.image === "string" && m.image) {
addBubbleImage(wrap, m.image, m.text || "attached image");
}
return;
}
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
if (typeof m.thinking === "string" && m.thinking) {
addThinkingBlock(wrap, m.thinking);
}
addToolLines(wrap, m.tools);
if (m.deflected) {
wrap.classList.add("is-deflected");
addMaybeTry(wrap, m.suggestions);
}
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(wrap);
}
/* ---------- the public read ----------
* GET /api/shared/<token> — no admin dependency (the token IS the
* credential). Returns the SharedChatOut snapshot (title + messages)
* or null: a 404 (wrong or revoked token — one message server-side,
* no enumeration), a network failure, or a malformed body all
* collapse to null → the invalid state. */
async function fetchSharedChat(token) {
let res;
try {
res = await fetch(`/api/shared/${token}`);
} catch {
return null; // network failure
}
if (!res.ok) return null; // 404 (wrong/revoked) / 5xx
let data;
try {
data = await res.json();
} catch {
return null; // malformed body
}
return data && Array.isArray(data.messages) ? data : null;
}
/* The 200 path: the h1 gets the shared chat's title (the static
* fallback stays when the title is missing/blank) and every record
* renders through renderSharedMessage. The same defensive filter as
* the chat page's restore keeps a corrupted stored row from
* poisoning the render (nothing HTML-shaped, ever). */
function renderSharedChat(data) {
const title = typeof data.title === "string" ? data.title.trim() : "";
if (title) titleEl.textContent = title;
const messages = data.messages.filter(
(m) =>
m &&
(m.who === "user" || m.who === "brain") &&
typeof m.text === "string" &&
m.text.length > 0
);
for (const m of messages) renderSharedMessage(m);
}
/* Boot: the brand note first (call-time resolution — the classic
* brand.js already set the synchronous default), then the token. A
* malformed or missing token shows the invalid state immediately —
* NO fetch of any kind (not even whoami: the header ships in its
* guest state, which is already correct for a bad URL). A well-
* formed token runs the shared header init (guests: whoami
* anonymous, the admin-only links stay hidden) and then the public
* read; a null read (404 / network / malformed) shows the invalid
* state, and a 200 renders the conversation read-only. */
(async () => {
if (noteEl) noteEl.textContent = `Shared via ${brand()} — read-only.`;
const token = parseSharedToken();
if (!token) {
showInvalid();
return;
}
await initSharedHeader();
const data = await fetchSharedChat(token);
if (!data) {
showInvalid();
return;
}
renderSharedChat(data);
})();