Protocol B append: failed-turn retry (L3–4), git source tokens (L5), image documents (L6 ingest), chat image questions (L6 chat side). TODO.md items now live in .agents/phases/todo/ and the file is cleared. LLM-Generated: true
12 KiB
Phase 123 — Chat image questions: attach an image to a question
Source: TODO.md L6 — "…users should be able to submit images as part of their question in brain of reese."
Story: n/a (feature request; completes the phase-122 image capability on the question side).
Context: Phase 122 (todo, this pipeline) — BOR_IMAGES toggle + GET /api/config images flag (task 01), the ext→mime map, image_dir storage convention. app/schemas.py:60 — ChatRequest (message min 1/max 4000, history ≤100 — HistoryTurn is text-only), ChatMessage (L742, extra="forbid", phase-83 value bounds). app/api/chat.py — the turn pipeline: the user message is built at L645 ({"role": "user", "content": request.message}; a grounded turn runs run_agent, a deflected turn a direct chat_stream on the same messages), and app/rag/agent.py:1370/1448 — run_agent(..., user_message: str) builds its own [system, user] (verify the data flow — if run_agent receives the already-built messages, the single edit site is chat.py). The phase-114 SSE error-frame-with-hint pattern (the "question too long" frame — ChatErrorEvent.detail + optional hint, consumed by the banner at app.js L2210). frontend/index.html — the composer (label L287, #message-input L292, #send-btn L325). frontend/assets/app.js — handleSend (L2306), runTurn (the turn driver + the user append/save-point-1 at send), addMessage("user", …) (user bubble), rememberBrainTurn (L2108, the brain save point), renderStoredMessage (L1642, user branch). frontend/assets/shared.js — the shared page's message render (text-only today). app/api/config.py:30 — the public flags dict (phase 122 task 01 added images).
Objective
The user attaches one image to a question: a masked-by-server upload stores the bytes, the vision model (the chat model) receives a multimodal message, the user's bubble renders the image, the record persists the image path (not base64) so refresh and shared chats render it, and BOR_IMAGES=false rejects the request with a helpful hint.
Dependencies
122_image_documents(todo) — CODE dependency: theBOR_IMAGES/imagesconfig flag (the toggle gates this feature), the ext→mime map, and theimage_dirstorage convention (this phase'schat_image_dirfollows it).- Code dependencies (all complete): phase 14/50/55 conversation persistence, phase 74 history mapping, phase 114 SSE error-hint frames, phase 51 shared chats.
Design (shared by all tasks — the executor reads this, not the chat)
- Storage (task 01, LOCKED A5): user question-images are server-stored, NOT base64-in-saved-chats:
Settings.chat_image_dir: str = "~/bor-sources/chat-images"(BOR_CHAT_IMAGE_DIR, theimage_dirconvention — a sibling of phase 122'simage_dir, separate because question-images are per-conversation, not per-source) +Settings.chat_image_max_mb: int = 10(BOR_CHAT_IMAGE_MAX_MB, the ~10 MB cap of A5;upload_max_mb's fail-loud validator precedent for<= 0).POST /api/chat-images(multipart, inapp/api/chat.pyor a small newapp/api/chat_images.pyrouter — the executor's call, following the repo's one-concern-per-module style): accepts an image file, validates the mime/ext against the SAME six-extension set as phase 122 (reuse the frozenset; the Content-Type header is a hint — the EXTENSION is the source of truth, the archive-uploader precedent), rejects oversize with a 413 (the fixed-detail style), storeschat_image_dir/<uuid4().hex>.<ext>, returns{ "path": "/api/chat-images/<uuid-hex>.<ext>" }.GET /api/chat-images/{filename}serves the bytes (404 on missing/unknown — the filename is a uuid, no enumeration value) with the phase-122 mime map; PUBLIC like saved-chat content (a saved chat's id is already its credential — phase 55 A1 — the image is part of that content). - Request (task 01):
ChatRequest.image: str | None = Field(default=None, max_length=500)— a STORED PATH, pattern-validated (^/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$— the stored filename isuuid4().hex.<ext>) — never a raw data URL (the upload endpoint already did the size/mime enforcement; re-validating a 10 MB base64 string in the schema would be the anti-pattern). Toggle OFF (settings.imagesfalse) withimageset → the turn settles with the phase-114 SSE error frame:detail"Image support is turned off on this server." +hint"Enable BOR_IMAGES in the server's .env (and restart) to ask with an image." (the question itself is NOT persisted — a rejected turn saves nothing, the existing error-path convention).imageset but file missing → the same frame shape with a "that image is no longer available" detail (a stale-path edge: the stored file was deleted out-of-band). - Multimodal (task 01): the user message becomes
{"role": "user", "content": [{"type": "text", "text": request.message}, {"type": "image_url", "image_url": {"url": <data URL from the stored file>}}]}at BOTH construction sites (chat.py:645 and agent.py:1448 if it builds independently — verify the flow; whenrequest.imageis None the content stays the plain string, byte-identical to today). The data URL is built server-side from the stored bytes + mime map (the phase-122describe_imagedata-URL construction — reuse it).HistoryTurn/history_to_messagesare UNCHANGED (LOCKED A7): prior turns' images are never replayed into the model's history — the history budget is text, and a 10 MB image per past turn would blow every budget; the model simply sees the text of a prior turn that had an image. - Persistence (tasks 01+02):
ChatMessage.image: str | None = Field(default=None, max_length=500)— the stored path, on the USER record (the image belongs to the question). The user record is saved at save-point-1 (send), BEFORE the turn resolves — so the client uploads FIRST (POST /api/chat-images) and stores the returned path in the user record, then POSTs/api/chatwithimage=<path>. Saved chats, shared chats, and the localStorage shape all carry the path (≤500 chars — no phase-83 cap pressure). A brain record never carriesimage(the answer may reference the image's sources, but the attachment is the user's). - Composer (task 02): the attach control appears ONLY when
GET /api/configsaysimages: true(phase 122's flag; fetched once at boot like the other config — the composer reads the existing cached config if present). A paperclip button (SVG, the icon style of the other composer glyphs,aria-label="Attach an image") before the input → hidden<input type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp">→ on select: a preview strip above the input (thumbnail ≤48px, the filename, a remove ✕) + the file's data URL kept client-side until send; on send with an attachment:POST /api/chat-images(the file) → the returned path goes into the user record + the/api/chatbody → the preview clears. Upload failure (oversize, non-image, server down) → the phase-114-style out-of-turn banner ("Couldn't attach the image — …") and the send is BLOCKED (no question without the image the user attached — ASSUMPTION A8, locked below). The user bubble renders the image (from the data URL live, from the stored path after restore) withalt = filename, capped height, above/beside the text (the theme's bubble treatment; the image is part of the question, visible in both the live bubble and the restore). - Restore + shared (task 03):
renderStoredMessage's user branch:m.imagepresent → the user bubble includes<img src="{m.image}" alt="…">(a load failure collapses to a small "image unavailable" line — never a broken icon). The shared page (shared.js) renders the user image the same way (the image route is public — the shared view is faithful; no new shared-shape field beyondChatMessage.image, which the publicmessagesshape already carries). - NOT touched: the history budget/trimming, the honesty gate, the suggestion chips, phase-122's document-image pipeline (a QUESTION image is a separate concern — it is NOT indexed as a document), and the stop/failed-turn paths (they persist whatever records exist, including the new
imagekey, unmodified).
Tasks
01_vision_request.md—POST/GET /api/chat-images,ChatRequest.image+ChatMessage.image, the toggle-off/stale error frames, the multimodal user message at both construction sites.02_composer_attach.md— the config-gated attach control, preview, upload-then-send, the user bubble's image.03_restore_and_shared.md—ChatMessage.imageon restore (chat page) and on the shared page.04_chat_image_tests.md— unit + integration + isolated E2Etest_chat_image_questions.py.
Testing & Quality
- Unit:
tests/unit/test_chat_image_questions.py(new, task 04) — the path pattern validator (accepts well-formed, rejects data URLs / wrong ext / traversal), the multimodal message build (both sites;image=None→ byte-identical plain string), the toggle-off + stale-file error frames (detail + hint shapes), the upload endpoint's mime/size/ext rules (tmp-dir settings), the serve route (200/404),ChatMessage.imagebounds + omission. - Integration:
tests/integration/test_chat_api.py(extend, task 04) — upload →POST /api/chatwithimage=<path>→ the mock client RECEIVES the multimodal content list (text part + image_url data URL);imagewithimages=false→ the SSE error frame with the hint and NO model call, no persisted record;image=Nonerequests are byte-identical to pre-phase; a saved chat round-trips a user record withimage; a shared chat serves it. - E2E:
tests/e2e/test_chat_image_questions.py(new, task 04) — isolated run per AGENTS.md §4,BOR_IMAGES=true: attach a fixture PNG in the composer → preview + remove works → send → the user bubble shows the image → the (mock) answer streams → reload → the user bubble restores WITH its image → open the shared link → the shared page shows the image. Plus the default-off negative: withBOR_IMAGESunset, the attach control is ABSENT from the DOM. - Coverage: >90% on
app/(validate.sh gate).
Completion Criteria
- With
BOR_IMAGES=true: attach → send → the vision model gets text+image; the user bubble, the refreshed page, and the shared chat all show the image; the saved chat stores the PATH (assert no base64 in the stored payload). - With
BOR_IMAGES=false: the attach control is absent, an API request withimagegets the hinted error frame, and no model call / record happens. - Text-only questions behave byte-identically to pre-phase (the multimodal branch is inert).
uv run pytestgreen;uv run pytest --cov=app --cov-report=term-missingTOTAL >90%;uv run ruff check . && uv run pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agents/phases/complete/by the pipeline gate.
Locked decisions
- A5 — one image per question; the ~10 MB cap (
BOR_CHAT_IMAGE_MAX_MB); server-stored bytes underchat_image_dir; the saved/shared record carries the path, never base64 (owner-confirmed 2026-09-24, roadmap confirmation). - A7 — a question's image applies to the CURRENT turn only; prior turns' images are never replayed into the model's history (the text of a prior turn stands alone) (owner-confirmed: same confirmation — the proposed design).
- A8 — if the image upload fails, the send is blocked with a banner (the question is never sent without the image the user attached) (owner-confirmed: same confirmation).
Commit
git add app/ frontend/ tests/ .env.example .agents/phases/ && git commit --no-gpg-sign -m "feat(chat): attach an image to a question — vision input, in-bubble render, persisted and shared"