phase: 123_chat_image_questions
Build and Push Containers / build-and-push-app (push) Successful in 1m54s
Build and Push Containers / build-and-push-db (push) Failing after 13s

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/`.
This commit is contained in:
2026-09-25 05:19:18 -04:00
parent a19d78d284
commit bef24e05e2
48 changed files with 3910 additions and 71 deletions
+262 -10
View File
@@ -306,6 +306,14 @@ const sendBtn = document.querySelector("#send-btn");
const sendLabel = document.querySelector("#send-label");
const sendStatus = document.querySelector("#send-status");
const turnLoader = document.querySelector("#turn-loader"); // phase 109 (D16): the persistent in-turn loader — ships hidden; setUiState is its sole visibility owner
// Phase 123 (task 02, TODO L6): the attach control family — the
// paperclip button (hidden until the boot /api/config says
// `images: true`), its hidden file-input backend, the preview strip
// (hidden until a pick), and the strip's remove button.
const attachBtn = document.querySelector("#attach-btn");
const attachFile = document.querySelector("#attach-file");
const attachPreview = document.querySelector("#attach-preview");
const attachRemove = document.querySelector("#attach-remove");
const banner = document.querySelector("#kb-banner");
const bannerText = document.querySelector("#kb-banner-text");
const versionEl = document.querySelector("#app-version");
@@ -927,8 +935,17 @@ const USER_AVATAR =
* scrolls only when the caller passes `scroll = true` — the user submit
* (reveal my message) and the phase-14 restore landing. The streaming
* path (thinking / tool / delta) creates bubbles with the default
* (scroll = false): the page never follows a turn. */
function addMessage(who, html, scroll = false) {
* (scroll = false): the page never follows a turn.
*
* Phase 123 (task 02, TODO L6): the optional `image` argument —
* { src, alt } for a USER bubble carrying the question's attached
* image (attachedImage → the live data URL; task 03's restore → the
* stored path). The attachment is part of the question, so the img
* lands at the TOP of the bubble (above the text) through
* attachBubbleImage — ONE renderer for live + restore + shared. A
* null image (every text-only message, every brain message) leaves
* the bubble byte-identical to pre-phase. */
function addMessage(who, html, scroll = false, image = null) {
if (emptyState) emptyState.hidden = true;
const wrap = document.createElement("div");
wrap.className = `msg ${who}`;
@@ -937,11 +954,45 @@ function addMessage(who, html, scroll = false) {
<div class="msg-body">
<div class="bubble">${html}</div>
</div>`;
if (who === "user" && image) {
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
}
messagesEl.appendChild(wrap);
if (scroll) scrollReveal(wrap);
return wrap;
}
/* Phase 123 (task 02, TODO L6): the question's image in a user bubble
* — the ONE renderer (task 03 reuses it for the restore and the shared
* page): the img is built createElement-style (no HTML strings, the
* house rule), capped height + full-width safe (a tall portrait must
* not blow the chat column — .msg-image in styles.css), lazy-loaded
* (the restore's stored paths re-fetch on demand), alt = the
* accessible name (the filename live, task 03's restore choice). The
* image PREPENDS the text: the attachment is part of the question. */
function attachBubbleImage(bubble, src, alt) {
const img = document.createElement("img");
img.className = "msg-image";
img.src = src;
img.alt = alt || "attached image";
img.loading = "lazy";
// Phase 123 (task 03, TODO L6): the load failure — the STORED file
// was deleted out-of-band (the record keeps its path, the render
// degrades): the img is replaced IN PLACE by the small "image
// unavailable" line (never a broken-image icon). In practice only
// the restore's stored path can 404 (a live data URL is inline); the
// shared page carries its own copy of the same degradation (the
// per-page duplication house style).
img.onerror = () => {
const note = document.createElement("span");
note.className = "msg-image-unavailable";
note.textContent = "image unavailable";
img.replaceWith(note);
};
bubble.prepend(img);
return img;
}
function addTyping() {
removeTyping(); // idempotent: at most one indicator at a time
if (emptyState) emptyState.hidden = true;
@@ -1268,6 +1319,26 @@ let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
let turnAbort = null; // AbortController of the in-flight turn (null idle)
let stoppedByUser = false; // the Stop button took this turn (not the guard)
/* Phase 123 (task 02, TODO L6; locked A5): the composer's ATTACHED
* image — the { file, name, dataUrl } triple, held until send. The
* bytes NEVER touch the chat payload: the send flow uploads the File
* to POST /api/chat-images and the record + the request body carry the
* returned STORED PATH (A5: never base64). The data URL feeds two
* local things only — the preview thumbnail and the LIVE user bubble
* (no fetch needed); the restore (task 03) re-renders from the stored
* path instead. null = no attachment — the text-only path, byte-
* identical to pre-phase (request body, record, bubble). */
let attachedImage = null;
let attachUpload = false; // phase 123: one upload at a time (double-fire guard — the upload is the first await in handleSend; a second submit mid-upload is a no-op, the first owns the send)
/* The six extensions the upload endpoint accepts (phase 122's image
* set, one list — the server re-validates on the upload; this pre-check
* only keeps a bad pick from opening a state change + a wasted
* round-trip, and it checks the file NAME's extension: the accept
* attribute is advisory, and a drag-pasted or renamed file can carry
* any extension the server will 422 anyway). */
const ATTACHABLE_IMAGE_EXTENSIONS = ["bmp", "gif", "jpeg", "jpg", "png", "webp"];
function stopThinkingClock() {
if (thinkingClock) {
clearInterval(thinkingClock);
@@ -1788,7 +1859,22 @@ function renderStoredMessage(m) {
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
// instead of the old forced "auto" — noted per the phase-42 task.
if (m.who === "user") {
addMessage("user", renderMarkdown(m.text), true);
const wrap = addMessage("user", renderMarkdown(m.text), true);
// Phase 123 (task 03, TODO L6): the restored record may carry the
// question's attached image — `m.image`, the STORED PATH (A5:
// never base64; a pre-phase / text-only record has no key at all,
// so it renders byte-identically — no img). It lands through the
// SAME one bubble-image renderer the live send uses
// (attachBubbleImage — the live bubble passed the data URL, the
// restore passes the stored path; the helper takes any src): the
// img at the top of the user bubble, above the text. A load
// failure degrades inside the helper (deleted out-of-band file →
// the small "image unavailable" line, never a broken icon). A
// re-ask (retryLastTurn) re-sends prev.text only (locked A7) —
// this bubble's restored attachment is untouched by the redo.
if (typeof m.image === "string" && m.image) {
attachBubbleImage(wrap.querySelector(".bubble"), m.image, m.text || "attached image");
}
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), true);
@@ -2355,6 +2441,7 @@ function startNewChat() {
input.value = "";
autoGrow();
updateCharCount(); // phase 104: the cleared composer hides the counter again
clearAttachedImage(); // phase 123: the attachment is composer draft state — it resets with the conversation
input.focus();
sendStatus.textContent = "New chat started — previous conversation cleared.";
}
@@ -2460,6 +2547,70 @@ function retryLastTurn(wrap) {
return runTurn(text, { reask: true });
}
/* Phase 123 (task 02, TODO L6): the send flow's UPLOAD STEP (locked
* A8) — the attached File goes to POST /api/chat-images as multipart
* (the session cookie rides the browser; the endpoint is user-gated
* like the turn it feeds) and the returned STORED path is what the
* record + the /api/chat body carry (A5: never base64). ANY failure —
* 413 over the cap, 422 a bad extension (a renamed file the client
* pre-check missed), 5xx, or a network drop — settles the
* phase-114-style OUT-OF-TURN banner with the server's detail and
* returns null: the send is BLOCKED (the question is never sent without
* the image the user attached — the typed text stays, the attachment
* stays for the retry). */
async function uploadAttachedImage(file) {
let res;
try {
const form = new FormData();
form.append("file", file);
res = await fetch("/api/chat-images", { method: "POST", body: form });
} catch {
// Network drop before the server answered — no detail to show.
showErrorBanner("Couldn't attach the image — try again.");
return null;
}
let detail = "";
let path = null;
try {
const body = await res.json();
if (typeof body?.detail === "string") detail = body.detail;
if (typeof body?.path === "string") path = body.path;
} catch { /* non-JSON error body — the status line stands in */ }
if (!res.ok || !path) {
showErrorBanner(
detail
? `Couldn't attach the image — ${detail}.`
: "Couldn't attach the image — try again."
);
return null;
}
return path;
}
/* Phase 123 (task 02, TODO L6): the preview strip — revealed with the
* attached image's data-URL thumbnail + filename (the thumbnail is
* decorative, alt="" in the static markup — the filename beside it is
* the readable label), and cleared with the attachment (the remove button,
* the send, or a New chat). The strip's markup is static (hidden by
* default); only the thumbnail's src + the name's textContent move
* here (the createElement/textContent house rule — no HTML strings).
* Idempotent: a fresh pick re-renders the same strip in place. */
function showAttachPreview() {
if (!attachedImage || !attachPreview) return;
attachPreview.querySelector("img").src = attachedImage.dataUrl;
attachPreview.querySelector(".attach-preview-name").textContent = attachedImage.name;
attachPreview.hidden = false;
}
/* Phase 123 (task 02, TODO L6): clear the attachment + hide the strip
* (idempotent — calling it with nothing attached is a no-op). The
* strip must not linger into a turn, and a New chat resets the
* composer's draft (the question text + its attachment) together. */
function clearAttachedImage() {
attachedImage = null;
if (attachPreview) attachPreview.hidden = true;
}
async function handleSend(e) {
e.preventDefault();
// Phase 48: while a turn is in flight the Send button IS the Stop
@@ -2479,6 +2630,26 @@ async function handleSend(e) {
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
return;
}
// Phase 123 (task 02, TODO L6; locked A8): an attached image uploads
// FIRST — BEFORE the input is cleared, so a failed upload BLOCKS the
// send and the typed question stays exactly where the user left it
// (the question is never sent without the image the user attached;
// the banner says what failed, the attachment stays for the retry).
// The returned STORED path (never the bytes, A5) then rides runTurn
// into the record + the request body.
let image = null; // { path, src, alt } | null — null = text-only send
if (attachedImage) {
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
attachUpload = true;
const path = await uploadAttachedImage(attachedImage.file);
attachUpload = false; // the helper never throws (every failure path is a banner + null)
if (path === null) return; // A8: the send is blocked — the question stays
image = {
path, // the record + the /api/chat body (A5: the path, never base64)
src: attachedImage.dataUrl, // the live bubble (no fetch); restore uses the path
alt: attachedImage.name, // the filename (task 03's restore picks its own alt)
};
}
// Phase 49: the user append + persistence save point 1 moved into
// runTurn with the rest of the turn — the `reask` flag skips them on
// the redo-in-place retry path (the question is already in the DOM +
@@ -2487,7 +2658,7 @@ async function handleSend(e) {
autoGrow();
updateCharCount(); // phase 104: the sent question clears the counter with the input
clearErrorBanner();
await runTurn(text, { reask: false });
await runTurn(text, { reask: false, image });
}
/* Phase 120 (TODO.md L3–4, locked A1): the single funnel for every
@@ -2561,17 +2732,38 @@ function finalizeFailedTurn(detail, { acc, thinking, tools, wrap, leavePartialIn
* finally settle moved here verbatim, and the turn-local resets (acc,
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
* stay turn-scoped exactly as phase 48 left them. */
async function runTurn(text, { reask = false } = {}) {
async function runTurn(text, { reask = false, image = null } = {}) {
if (!reask) {
addMessage("user", renderMarkdown(text), true); // reveal my message (owner-kept)
// Phase 123 (task 02, TODO L6): the attached image rides the user
// bubble (the data URL live — the stored path is the fallback, so
// any future caller passing only a path still renders) and the
// STORED RECORD (the path, A5: never base64). A null image (every
// text-only send, every re-ask — A7: a redo re-sends the text
// only) leaves the bubble and the record byte-identical to
// pre-phase.
addMessage(
"user",
renderMarkdown(text),
true, // reveal my message (owner-kept)
image ? { src: image.src || image.path, alt: image.alt } : null
);
// 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 });
// sent, so a failed/interrupted turn never loses it. The `image`
// key (the stored path) joins the `bor.chat.v1` record only when an
// attachment exists (A5 — the phase-14 shape gains one optional key;
// a text-only record is byte-identical to pre-phase).
conversation.push(
image ? { who: "user", text, image: image.path } : { who: "user", text }
);
saveConversation();
// Phase 55 (A2): the auto-save rides the save point — an unlinked
// conversation creates its row here (auto-title, server-side), a
// linked one refreshes. Fire-and-forget: it never blocks the turn.
persistConversation();
// Phase 123 (task 02): the strip must not linger into the turn —
// cleared AFTER the bubble is rendered (the bubble already holds
// the image; the record holds the path; a failed turn keeps both).
clearAttachedImage();
}
let wrap = null;
@@ -2632,10 +2824,19 @@ async function runTurn(text, { reask = false } = {}) {
text: m.text,
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
}));
// Phase 123 (task 02, TODO L6): the attached image's STORED path
// rides the body top-level (A5: the path, never base64; the server
// builds the multimodal content from the stored bytes). Added only
// when present — a text-only body omits the `image` key entirely
// (byte-identical to pre-phase). HISTORY entries stay {who, text,
// thinking}: prior turns' images are never replayed (locked A7 —
// the image is turn-local to the original send).
const payload = { message: text, history };
if (image) payload.image = image.path;
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, history }),
body: JSON.stringify(payload),
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
});
if (!res.ok || !res.body) {
@@ -3008,6 +3209,48 @@ input.addEventListener("keydown", (e) => {
});
composer.addEventListener("submit", handleSend);
/* Phase 123 (task 02, TODO L6): the attach flow. The button (revealed
* at boot ONLY when the config flag says images on) opens the hidden
* file input; a pick is validated CLIENT-side against the six
* extensions (the server re-validates on upload — a bad pick gets the
* out-of-turn banner and NO state change: a previous attachment, if
* any, survives), then the { file, name, dataUrl } triple lives in
* attachedImage until the send flow uploads it. The remove button clears
* the state + hides the strip; a fresh pick replaces the triple in
* place (the strip re-renders through showAttachPreview). */
attachBtn?.addEventListener("click", () => attachFile?.click());
attachRemove?.addEventListener("click", () => {
clearAttachedImage();
attachBtn?.focus(); // back to the trigger (reachable only while the strip is visible — which means the button is too)
});
attachFile?.addEventListener("change", () => {
const file = attachFile.files && attachFile.files[0];
attachFile.value = ""; // the same file re-picked must fire change again
if (!file) return;
// The file NAME's extension (lowercased) is the client pre-check —
// the accept attribute is advisory (a renamed file can carry any
// extension); the upload endpoint is the authority (422).
const dot = file.name.lastIndexOf(".");
const ext = dot >= 0 ? file.name.slice(dot + 1).toLowerCase() : "";
if (!ATTACHABLE_IMAGE_EXTENSIONS.includes(ext)) {
showErrorBanner(
"Only PNG, JPEG, WebP, GIF, and BMP images can be attached."
);
return; // no state change — a previous attachment survives
}
const reader = new FileReader();
reader.onload = () => {
attachedImage = { file, name: file.name, dataUrl: String(reader.result) };
showAttachPreview();
};
reader.onerror = () => {
// The bytes never became readable (the file evicted mid-pick) —
// the same copy as an upload failure; no state change.
showErrorBanner("Couldn't attach the image — try again.");
};
reader.readAsDataURL(file);
});
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
* GONE with the pill — there is no Save control; persistConversation()
* auto-saves headless at the save points (fire-and-forget, quiet on
@@ -3086,8 +3329,17 @@ window.addEventListener("pagehide", () => {
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
const bootConfig = await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
// Phase 123 (task 02, TODO L6): the attach control reveals ONLY when
// the SAME settled config says images: true (the brand boot's ONE
// /api/config request — no second round-trip). Off (the default) or
// an unanswered fetch → the button stays hidden for good: the
// flag-off DOM is byte-identical to pre-phase (A5's default-off
// contract), and a degraded boot degrades quietly (the loadHealth
// house style — the page never breaks, the affordance is simply
// absent; the API contract still enforces the toggle server-side).
if (attachBtn) attachBtn.hidden = bootConfig?.images !== true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).