phase: 124_image_chips_and_answers
All green. Final report: **Phase 124 final verification pass — all criteria verified, no defects found (nothing to fix)** - Verified the implemented phase (all 7 tasks done, changes in working tree): `BOR_ANSWER_IMAGES` toggle, `show_image` tool + refusals + `holder.shown_images`, toggle-gated prompt clause, `answer_image` frame field (None-omitting serializer) + `ChatMessage` persistence, `merge_cited_docs` auto-cite bug fix, answer-figure UI (live/restore/shared/CSS), mock triggers + E2E suite. Also confirmed the bytes route → public change matches the phase-123 shared-chat precedent (task 06 design). - `uv run pytest --cov=app --cov-report=term-missing` → **2848 passed**, TOTAL **99%** (>90% gate) - `bash .agents/validate.sh` → **validation OK** (tests + coverage + ruff + pyright) - `uv run ruff check . && uv run pyright` → clean, 0 errors - `uv run pytest tests/e2e/test_answer_images.py -v --no-cov` (isolated) → **5/5 passed** - Mock-inert spot checks: `tests/e2e/test_image_documents.py` 4/4, `tests/e2e/test_agent_document_tools.py` 4/4 **Completion criteria:** ① zero-read suggested-tier image doc chips with figure — verified (unit matrix, integration `test_zero_read_grounded_turn_auto_cites_suggested_image_doc`, E2E bug-fix flows); ② show_image → answer figure below text/above chips, second call refused, persists + restore + shared — verified (unit handler matrix, integration round-trip, E2E feature flow); ③ default-off byte-identical (3 tools, no clause, no key) — verified (unit + integration + E2E toggle-off); ④ full suite green / >90% / lint clean — verified above; ⑤ commit + phase move — harness responsibility (changes left uncommitted in working tree, no live-infrastructure changes made). **Next pending phase:** none — `todo/` holds only this phase; the pipeline is clear once the harness moves it.
This commit is contained in:
@@ -1726,6 +1726,81 @@ function fetchContentSummary(source, path) {
|
||||
.then((doc) => (doc && typeof doc.summary === "string" ? doc.summary : null));
|
||||
}
|
||||
|
||||
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||
* answer's first-class figure (the `done` frame's `answer_image` ref,
|
||||
* task 04). A4: it sits in the .msg-body BELOW the answer text and
|
||||
* ABOVE the source-chip row — the callers append it right BEFORE
|
||||
* appendSources creates the chip row, so it lands in between. The
|
||||
* text bubble is untouched: the figure is ADDITIVE ("summaries
|
||||
* should still be provided").
|
||||
*
|
||||
* The visual sibling of appendSourceImageFigure (the compact chip
|
||||
* figure) but a different surface: the answer figure is the answer's
|
||||
* own image, not a chip accessory — .answer-image scales it up (the
|
||||
* .source-image language at answer size, styles.css). Same WCAG alt
|
||||
* contract as the chip figure: alt + the VISIBLE caption = the
|
||||
* document's SUMMARY (the vision description); the frame carries no
|
||||
* summary (the ref is source/path/title/image_url), so the figure
|
||||
* fetches the content endpoint the chip's modal already uses and
|
||||
* swaps the summary in; until it settles — and when it fails — the
|
||||
* title stands in (a caption is ALWAYS visible). A FAILED IMAGE LOAD
|
||||
* REMOVES the whole figure (the collapse rule — the answer figure has
|
||||
* no chip of its own to fall back to; the chip row below still cites
|
||||
* the doc, the phase-124 auto-cite): an answer whose image bytes are
|
||||
* gone renders as a normal answer, never a broken-image icon.
|
||||
* Navigation reuses the chip affordance: the same documentUrl href
|
||||
* (the /document.html escape hatch) + left-click → same-page modal
|
||||
* (phase 26, the appendSources/appendSourceImageFigure pattern). */
|
||||
function appendAnswerImage(wrap, ref) {
|
||||
if (
|
||||
!ref ||
|
||||
typeof ref.source !== "string" ||
|
||||
typeof ref.path !== "string" ||
|
||||
typeof ref.image_url !== "string" ||
|
||||
!ref.image_url
|
||||
)
|
||||
return; // no ref (or a malformed one) → no figure DOM at all
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
const label = `${ref.source}/${ref.path}`;
|
||||
const fig = document.createElement("a");
|
||||
fig.className = "answer-image";
|
||||
fig.href = documentUrl(ref.source, ref.path, "/"); // back → the chat page
|
||||
fig.title = label; // full path as the native tooltip (chip pattern)
|
||||
fig.setAttribute("aria-label", label); // the accessible name is the full path
|
||||
fig.addEventListener("click", (e) => {
|
||||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||||
e.stopPropagation();
|
||||
openDocumentModal(ref.source, ref.path, fig);
|
||||
});
|
||||
const img = document.createElement("img");
|
||||
img.className = "answer-image-img";
|
||||
img.src = ref.image_url; // the public bytes route (task 04's ref)
|
||||
img.loading = "lazy"; // A4: lazy-loaded
|
||||
img.alt = ref.title || label; // the summary arrives via the fetch below
|
||||
img.addEventListener("error", () => {
|
||||
// Collapse to NOTHING (there is no chip inside the figure to fall
|
||||
// back to) — the answer renders normally, the chip row below
|
||||
// still cites the doc.
|
||||
fig.remove();
|
||||
});
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "answer-image-caption";
|
||||
caption.textContent = ref.title || label; // visible caption: the title first…
|
||||
fig.append(img, caption);
|
||||
body.appendChild(fig);
|
||||
// …and the document's summary once the content fetch settles (the
|
||||
// chip figure's SAME pattern — fetchContentSummary fails soft to
|
||||
// null: the title stays).
|
||||
fetchContentSummary(ref.source, ref.path).then((summary) => {
|
||||
if (!fig.isConnected) return; // collapsed (img error) or bubble cleared
|
||||
if (summary && summary.trim() !== "") {
|
||||
img.alt = summary;
|
||||
caption.textContent = summary;
|
||||
caption.title = summary; // full text (the caption is line-clamped)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* "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. */
|
||||
@@ -1906,6 +1981,11 @@ function renderStoredMessage(m) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the answer image restores with the
|
||||
// bubble — the SAME helper + the SAME A4 position as the live done
|
||||
// path (below the text, above the chip row). A record without the
|
||||
// key renders byte-identically (pre-phase chats restore unchanged).
|
||||
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
|
||||
appendSources(wrap, m.sources);
|
||||
// Phase 120: a failed turn (m.failed) is a note, not an answer — no
|
||||
// Tune (the "note, not an answer" scope the m.stopped exclusion
|
||||
@@ -2985,6 +3065,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
wrap.classList.add("is-deflected");
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the LLM's answer image — the
|
||||
// answer's first-class figure (A4): appended to .msg-body
|
||||
// right BEFORE the chip row, so it lands below the answer
|
||||
// text and above the sources. The text is untouched (the
|
||||
// figure is additive — "summaries should still be provided");
|
||||
// a frame without it renders byte-identically (no figure DOM).
|
||||
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
|
||||
appendSources(wrap, ev.sources);
|
||||
// Phase 113 (task 02): the cited tier stays the citation
|
||||
// surface (the chips above); the related tier (scored docs
|
||||
@@ -3019,6 +3106,12 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
tools: toolAcc.length ? toolAcc : undefined,
|
||||
deflected: !!ev.deflected,
|
||||
sources: ev.sources,
|
||||
// Phase 124 (task 06, TODO L3): the answer image persists
|
||||
// with the turn (undefined drops the key from the JSON — the
|
||||
// house optional-meta pattern, the `related` line below is
|
||||
// the precedent): an image-less / pre-phase turn stores a
|
||||
// byte-identical record.
|
||||
answer_image: ev.answer_image || undefined,
|
||||
// Phase 113 (task 02): the related tier persists with the
|
||||
// turn (undefined drops the key from the JSON — the house
|
||||
// optional-meta pattern), so the restore path re-renders the
|
||||
|
||||
@@ -349,6 +349,50 @@ function addBubbleImage(wrap, src, alt) {
|
||||
bubble.prepend(img);
|
||||
}
|
||||
|
||||
/* The brain answer's image (phase 124, task 06, TODO L3) — the local
|
||||
* copy of the chat page's appendAnswerImage (the per-page duplication
|
||||
* house style: this file keeps its own small copies of the chat
|
||||
* page's message-fragment builders). NON-INTERACTIVE like every other
|
||||
* shared-page fragment (owner-locked zero controls: no link, no
|
||||
* modal, no click handler — a plain <figure>, unlike the chat page's
|
||||
* anchor). The bytes route is PUBLIC (the token is the shared chat's
|
||||
* credential, like the phase-123 user-image contract), so the img
|
||||
* loads for guests exactly as it does for the owner. The visible
|
||||
* caption keeps the document's TITLE: the content endpoint the chat
|
||||
* page fetches for the summary is user-gated (a guest fetch would
|
||||
* just 401), and a caption is ALWAYS visible either way (the WCAG
|
||||
* alt contract holds with the title standing in). A failed load
|
||||
* REMOVES the figure (the collapse rule — never a broken-image
|
||||
* icon). */
|
||||
function addAnswerImage(wrap, ref) {
|
||||
if (
|
||||
!ref ||
|
||||
typeof ref.source !== "string" ||
|
||||
typeof ref.path !== "string" ||
|
||||
typeof ref.image_url !== "string" ||
|
||||
!ref.image_url
|
||||
) {
|
||||
return; // no ref (or a malformed one) → no figure DOM at all
|
||||
}
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
const title =
|
||||
typeof ref.title === "string" && ref.title ? ref.title : "image";
|
||||
const fig = document.createElement("figure");
|
||||
fig.className = "answer-image";
|
||||
const img = document.createElement("img");
|
||||
img.className = "answer-image-img";
|
||||
img.src = ref.image_url; // the public bytes route
|
||||
img.loading = "lazy";
|
||||
img.alt = title;
|
||||
img.addEventListener("error", () => fig.remove()); // collapse: never a broken icon
|
||||
const caption = document.createElement("span");
|
||||
caption.className = "answer-image-caption";
|
||||
caption.textContent = title; // the visible caption (always)
|
||||
fig.append(img, caption);
|
||||
body.appendChild(fig);
|
||||
}
|
||||
|
||||
/* 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
|
||||
@@ -356,9 +400,11 @@ function addBubbleImage(wrap, src, alt) {
|
||||
* .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
|
||||
* brain answer's image figure (phase 124, task 06 — a plain
|
||||
* non-interactive <figure>), 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. */
|
||||
@@ -385,6 +431,11 @@ function renderSharedMessage(m) {
|
||||
wrap.classList.add("is-deflected");
|
||||
addMaybeTry(wrap, m.suggestions);
|
||||
}
|
||||
// Phase 124 (task 06, TODO L3): the answer image renders with the
|
||||
// brain bubble — the SAME A4 position as the chat page's render
|
||||
// (below the text, above the chip row). A record without the key
|
||||
// renders byte-identically (no figure DOM).
|
||||
addAnswerImage(wrap, m.answer_image);
|
||||
addSources(wrap, m.sources);
|
||||
if (m.stopped) addStoppedNote(wrap);
|
||||
}
|
||||
|
||||
@@ -814,6 +814,53 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
.source-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||
|
||||
/* Phase 124 (task 06, TODO L3): the LLM's ANSWER IMAGE — the brain
|
||||
answer's first-class figure (A4: below the text, above the chips).
|
||||
The .source-image language at ANSWER scale — the figure is the
|
||||
answer's own image, not a chip accessory: max-width 100% of the
|
||||
.msg-body column, a 320px height cap (the 46rem chat column — a
|
||||
full-height image would blow the turn's height), contain on the
|
||||
theme's surface with the bordered-image treatment
|
||||
(.source-image-img's border/background/radius), the visible
|
||||
caption in the AA-safe muted ink (the same --ink-soft the chip
|
||||
caption uses — the row's page background, 8.6:1). Token-only (the
|
||||
zero-literal invariant — the monochrome themes carry it). The
|
||||
hover underline applies to the chat page's ANCHOR figure only (the
|
||||
shared page's figure is a plain <figure> — zero controls, the
|
||||
element-type scope keeps it out). */
|
||||
.answer-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.answer-image-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 320px; /* the one free number (task 06): the answer scale */
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.answer-image-caption {
|
||||
max-width: 100%;
|
||||
color: var(--ink-soft); /* 8.6:1 on --bg (AA) — the row's page background */
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; /* the summary can be long; the alt + title carry the full text */
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
a.answer-image { text-decoration: none; }
|
||||
a.answer-image:hover { text-decoration: underline; } /* flat — the caption underlines, the chip pattern */
|
||||
|
||||
/* Phase 113 (task 02): the related-docs row — the SECONDARY tier of
|
||||
scored docs (phase 113 task 01's usefulness bar demotes the
|
||||
sub-floor hits out of the citation surface; on a deflected turn the
|
||||
|
||||
Reference in New Issue
Block a user