phase: 122_image_documents
**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs
**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
**Next pending phase:** `123_chat_image_questions`.
This commit is contained in:
@@ -285,6 +285,21 @@
|
||||
* • the stat cards are UNTOUCHED — they keep their indexed_at
|
||||
* "last indexed" semantics (the owner asked for the column, not
|
||||
* the cards).
|
||||
*
|
||||
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
|
||||
* tree carries the image affordance (is_image / image_url / summary —
|
||||
* OMITTED on text nodes, the wire-additive rule) when it is an image
|
||||
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
|
||||
* box (object-fit: cover, loading="lazy", alt = the summary — the
|
||||
* vision description; a NULL summary falls back to the title) BEFORE
|
||||
* the path link (the .kb-doc-path flex wrapper — the link keeps its
|
||||
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
|
||||
* first paint) swaps in the document glyph INSIDE the same fixed box
|
||||
* (no layout shift beyond the box, no broken-image placeholder). Text
|
||||
* rows never get a box (the pre-phase bare-link cell, byte-identical).
|
||||
* The glyph is static SVG (aria-hidden — the alt text is the
|
||||
* accessible content); the box + img are properties only, never
|
||||
* innerHTML with document-derived data (the house rule).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -1391,12 +1406,78 @@ export async function mount(root) {
|
||||
chunks: f.chunks,
|
||||
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
|
||||
indexed_at: f.indexed_at,
|
||||
// Phase 122 (task 04): the image affordance — the tree's
|
||||
// file node carries is_image / image_url / summary on an
|
||||
// image doc (the omission rule: a TEXT node's wire shape
|
||||
// carries none, so these stay undefined there and makeRow
|
||||
// keeps the bare-link cell, byte-identical to pre-phase).
|
||||
is_image: f.is_image,
|
||||
image_url: f.image_url,
|
||||
summary: f.summary,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (tableWrap) tableWrap.hidden = files.length === 0;
|
||||
}
|
||||
|
||||
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
|
||||
* fixed thumbnail box (a failed fetch, or a node with no servable
|
||||
* image_url). Static SVG, aria-hidden (the img's alt is the
|
||||
* accessible content; this is decoration for the box). Built with
|
||||
* createElementNS — the module keeps its ONE innerHTML (the static
|
||||
* sync-modal skeleton, the test_kb_tree_ui pin). */
|
||||
function docThumbGlyph() {
|
||||
const span = document.createElement("span");
|
||||
span.className = "kb-doc-thumb-glyph";
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 48 48");
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("stroke-width", "2.4");
|
||||
svg.setAttribute("stroke-linecap", "round");
|
||||
svg.setAttribute("stroke-linejoin", "round");
|
||||
const sheet = document.createElementNS(NS, "path");
|
||||
sheet.setAttribute(
|
||||
"d",
|
||||
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
|
||||
);
|
||||
const fold = document.createElementNS(NS, "path");
|
||||
fold.setAttribute("d", "M28 4v8h8");
|
||||
svg.append(sheet, fold);
|
||||
span.appendChild(svg);
|
||||
return span;
|
||||
}
|
||||
|
||||
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
|
||||
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
|
||||
* the vision description; a NULL/blank summary falls back to the
|
||||
* title, then the path). A failed fetch swaps in the document
|
||||
* glyph in the SAME box (progressive enhancement — no layout shift
|
||||
* beyond the fixed box, no broken-image placeholder). Called only
|
||||
* for image rows (makeRow gates on d.is_image). */
|
||||
function docThumb(d) {
|
||||
const box = document.createElement("span");
|
||||
box.className = "kb-doc-thumb";
|
||||
const alt =
|
||||
typeof d.summary === "string" && d.summary.trim() !== ""
|
||||
? d.summary
|
||||
: d.title || d.path;
|
||||
if (d.image_url) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "kb-doc-thumb-img";
|
||||
img.loading = "lazy";
|
||||
img.src = d.image_url;
|
||||
img.alt = alt;
|
||||
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
|
||||
box.appendChild(img);
|
||||
} else {
|
||||
box.appendChild(docThumbGlyph());
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/* The no-data state (phase 97): zero sources (nothing registered,
|
||||
* nothing indexed) OR a failed tree fetch (the former showEmpty
|
||||
* failure behavior, unchanged in kind) — every catalog surface
|
||||
@@ -1440,7 +1521,18 @@ export async function mount(root) {
|
||||
});
|
||||
link.title = d.path; // full path as the link's hover/accessible name
|
||||
link.textContent = d.path;
|
||||
pathTd.appendChild(link);
|
||||
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
|
||||
// box before the path link (the .kb-doc-path flex wrapper — the
|
||||
// link keeps its ellipsis). Text rows keep the bare-link cell,
|
||||
// byte-identical to pre-phase (no box at all).
|
||||
if (d.is_image) {
|
||||
const pathWrap = document.createElement("div");
|
||||
pathWrap.className = "kb-doc-path";
|
||||
pathWrap.append(docThumb(d), link);
|
||||
pathTd.appendChild(pathWrap);
|
||||
} else {
|
||||
pathTd.appendChild(link);
|
||||
}
|
||||
tr.appendChild(pathTd);
|
||||
|
||||
// Phase 106 (task 08, D8): the cell order is [title, chunks,
|
||||
|
||||
Reference in New Issue
Block a user