From bef24e05e296270113aaea05157f3ca8a112c69a Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 25 Sep 2026 05:19:18 -0400 Subject: [PATCH] phase: 123_chat_image_questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/`. --- .../123_chat_image_questions/00_phase.md | 0 .../01_vision_request.md | 0 .../02_composer_attach.md | 0 .../03_restore_and_shared.md | 0 .../04_chat_image_tests.md | 0 .../123_chat_image_questions__00_phase.a1.err | 0 .../123_chat_image_questions__00_phase.a1.md | 13 + ...chat_image_questions__00_phase.a1.validate | 108 ++ ..._image_questions__01_vision_request.a1.err | 0 ...t_image_questions__01_vision_request.a1.md | 13 + ...e_questions__01_vision_request.a1.validate | 108 ++ ...image_questions__02_composer_attach.a1.err | 0 ..._image_questions__02_composer_attach.a1.md | 17 + ..._questions__02_composer_attach.a1.validate | 108 ++ ...ge_questions__03_restore_and_shared.a1.err | 0 ...age_questions__03_restore_and_shared.a1.md | 17 + ...estions__03_restore_and_shared.a1.validate | 108 ++ ...mage_questions__04_chat_image_tests.a1.err | 0 ...image_questions__04_chat_image_tests.a1.md | 15 + ...questions__04_chat_image_tests.a1.validate | 108 ++ .env.example | 9 + app/api/chat.py | 130 +- app/api/chat_images.py | 158 +++ app/config.py | 25 + app/core/security_headers.py | 24 +- app/main.py | 5 + app/rag/agent.py | 16 +- app/rag/llm.py | 10 +- app/rag/summarizer.py | 14 +- app/schemas.py | 58 +- frontend/assets/app.js | 272 +++- frontend/assets/shared.js | 46 +- frontend/assets/styles.css | 117 ++ frontend/index.html | 36 + tests/e2e/mock_llm.py | 33 + tests/e2e/test_chat_image_questions.py | 626 +++++++++ tests/e2e/test_security_headers.py | 9 +- tests/integration/test_chat_api.py | 310 +++++ tests/integration/test_chats_api.py | 100 ++ tests/integration/test_security_headers.py | 10 +- tests/unit/test_chat_image_questions.py | 1170 +++++++++++++++++ tests/unit/test_chat_persistence.py | 20 +- tests/unit/test_frontend_feedback.py | 47 +- tests/unit/test_frontend_scroll.py | 31 +- tests/unit/test_pinned_composer.py | 29 +- tests/unit/test_save_chat_ui.py | 31 +- tests/unit/test_security_headers.py | 21 +- tests/unit/test_sources_midstream.py | 9 +- 48 files changed, 3910 insertions(+), 71 deletions(-) rename .agents/phases/{todo => complete}/123_chat_image_questions/00_phase.md (100%) rename .agents/phases/{todo => complete}/123_chat_image_questions/01_vision_request.md (100%) rename .agents/phases/{todo => complete}/123_chat_image_questions/02_composer_attach.md (100%) rename .agents/phases/{todo => complete}/123_chat_image_questions/03_restore_and_shared.md (100%) rename .agents/phases/{todo => complete}/123_chat_image_questions/04_chat_image_tests.md (100%) create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.err create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.md create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.validate create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.err create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.md create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.validate create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.err create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.md create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.validate create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.err create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.md create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.validate create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.err create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.md create mode 100644 .agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.validate create mode 100644 app/api/chat_images.py create mode 100644 tests/e2e/test_chat_image_questions.py create mode 100644 tests/unit/test_chat_image_questions.py diff --git a/.agents/phases/todo/123_chat_image_questions/00_phase.md b/.agents/phases/complete/123_chat_image_questions/00_phase.md similarity index 100% rename from .agents/phases/todo/123_chat_image_questions/00_phase.md rename to .agents/phases/complete/123_chat_image_questions/00_phase.md diff --git a/.agents/phases/todo/123_chat_image_questions/01_vision_request.md b/.agents/phases/complete/123_chat_image_questions/01_vision_request.md similarity index 100% rename from .agents/phases/todo/123_chat_image_questions/01_vision_request.md rename to .agents/phases/complete/123_chat_image_questions/01_vision_request.md diff --git a/.agents/phases/todo/123_chat_image_questions/02_composer_attach.md b/.agents/phases/complete/123_chat_image_questions/02_composer_attach.md similarity index 100% rename from .agents/phases/todo/123_chat_image_questions/02_composer_attach.md rename to .agents/phases/complete/123_chat_image_questions/02_composer_attach.md diff --git a/.agents/phases/todo/123_chat_image_questions/03_restore_and_shared.md b/.agents/phases/complete/123_chat_image_questions/03_restore_and_shared.md similarity index 100% rename from .agents/phases/todo/123_chat_image_questions/03_restore_and_shared.md rename to .agents/phases/complete/123_chat_image_questions/03_restore_and_shared.md diff --git a/.agents/phases/todo/123_chat_image_questions/04_chat_image_tests.md b/.agents/phases/complete/123_chat_image_questions/04_chat_image_tests.md similarity index 100% rename from .agents/phases/todo/123_chat_image_questions/04_chat_image_tests.md rename to .agents/phases/complete/123_chat_image_questions/04_chat_image_tests.md diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.err b/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.md b/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.md new file mode 100644 index 0000000..0ae746b --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.md @@ -0,0 +1,13 @@ +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/`. diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.validate b/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.validate new file mode 100644 index 0000000..ea8d9d8 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__00_phase.a1.validate @@ -0,0 +1,108 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 97%] +............................................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 248 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 255 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 131 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 349 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 316 16 95% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 48 0 100% +app/schemas.py 390 0 100% +-------------------------------------------------- +TOTAL 4615 29 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.err b/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.md b/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.md new file mode 100644 index 0000000..2147667 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.md @@ -0,0 +1,13 @@ +All gates green. Final report: + +**Phase 123 / Task 01 — Vision request: complete** + +- **Implemented (server side):** + - `app/config.py` + `.env.example`: `chat_image_dir` (`~/bor-sources/chat-images`) + `chat_image_max_mb` (10, fail-loud `<=0` validator) + - New `app/api/chat_images.py` (registered in `main.py`): `POST /api/chat-images` (user-gated; phase-122 extension frozenset, streamed cap → 413 fixed detail, uuid-named store, returns `{"path": …}`) + public `GET /api/chat-images/{filename}` (regex guard → one fixed 404, phase-122 mime map, `private, max-age=3600`) + - `schemas.py`: `ChatRequest.image` (stored-path pattern, fixed 422, no echo) + `ChatMessage.image` (≤500, user record only, omitted-when-None serializer → text-only payloads byte-identical) + - `app/api/chat.py`: pre-stream gates (toggle-off → hinted phase-114 frame; stale file → same shape, no hint — both before any model call, no record) + `build_user_content` multimodal build at **both** sites; `run_agent` signature widened (`str | list`), flow pinned in docstring; data-URL factored to shared `summarizer.image_data_url`; `HistoryTurn`/history unchanged (A7) +- **Tests:** new `tests/unit/test_chat_image_questions.py` (52 tests) + 7 new integration tests in `test_chat_api.py` (multimodal delivery both branches, toggle-off/stale no-model-call, text-only byte-identity, saved + shared round-trip with no base64 in stored JSONB) +- **Results:** `uv run pytest` → green; `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (touched modules 99–100%); `uv run ruff check . && uv run pyright` → clean; existing E2E `test_image_documents.py` (4) + `test_chat_rag.py` (3) → pass +- **Notable decisions:** upload endpoint is `require_user` (matches the chat turn it feeds; anonymous 10 MB disk-fill DoS); GET public per design (uuid = credential); upload extension set = `settings.image_extension_set` (reuses the phase-122 frozenset, matches the schema's six by default); upload is NOT toggle-gated (the gate is at the chat request, per the pinned design) +- **Next pending task:** `123_chat_image_questions/02_composer_attach.md` (config-gated attach control, preview, upload-then-send, in-bubble image) diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.validate b/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.validate new file mode 100644 index 0000000..0a81cd4 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__01_vision_request.a1.validate @@ -0,0 +1,108 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 98%] +...................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 248 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 255 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 131 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 349 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 316 16 95% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 48 0 100% +app/schemas.py 390 0 100% +-------------------------------------------------- +TOTAL 4615 29 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.err b/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.md b/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.md new file mode 100644 index 0000000..1449a5c --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.md @@ -0,0 +1,17 @@ +Task 02 is complete and fully green. Final report: + +**Implemented (phase 123, task 02 — composer attach):** +- `frontend/index.html`: paperclip `#attach-btn` (hidden by default, `aria-label`, composer icon style) + hidden `#attach-file` input before the textarea; hidden-by-default `#attach-preview` strip (≤48px thumbnail + filename + `#attach-remove` X) above the input row +- `frontend/assets/app.js`: attach reveal gated on the boot `/api/config` `images` flag (brand boot's single fetch — no extra round-trip); six-extension client pre-check (bad pick → out-of-turn banner, no state change); preview show/remove/replace; send flow (LOCKED A8) uploads first via `POST /api/chat-images` with a double-fire guard — failure → banner + blocked send with the question kept; `runTurn` gains the attachment: user bubble renders via the new shared `attachBubbleImage` helper (data URL live), record gains `image: ` (A5: never base64), request body carries top-level `image` only when attached (text-only byte-identical), strip cleared after the bubble renders, redo stays text-only (A7); `startNewChat` resets the attachment +- `frontend/assets/styles.css`: `.attach-btn` / `.attach-preview` / `.msg-image` (44px targets, AA pairings, focus-visible, ~240px-capped bubble image) +- Updated 6 existing source-pinning tests to the new shapes (contracts preserved: save-point-before-fetch, scroll intents, sticky-unit children, action-row gap, turn-local reset order) + +**Gates:** +- `uv run pytest` → exit 0, all pass +- `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings) +- Throwaway Playwright smoke: flag on (attach → preview → remove → send: bubble image, path in body + localStorage record, bytes served back, no banner), text-only body omits `image`, flag off (button hidden; image request → exact hinted error frame, no model output) — all verified live + +**Decisions:** preview strip sits above the input (phase design's "strip above the input"); upload double-fire guard added (the upload is now handleSend's first `await` — a double-click could otherwise double-upload/double-turn); frontend test pins are task 04's per the phase split ("tasks ship code; this task ships the full pin"). + +**Next pending task:** `03_restore_and_shared.md` (restore + shared-page image render, onerror degradation). diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.validate b/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.validate new file mode 100644 index 0000000..0a81cd4 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__02_composer_attach.a1.validate @@ -0,0 +1,108 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 98%] +...................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 248 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 255 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 131 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 349 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 316 16 95% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 48 0 100% +app/schemas.py 390 0 100% +-------------------------------------------------- +TOTAL 4615 29 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.err b/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.md b/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.md new file mode 100644 index 0000000..fe8d6c4 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.md @@ -0,0 +1,17 @@ +All green and verified. Final report: + +**Task 03 — Restore + shared: complete** + +- `app.js` `renderStoredMessage` user branch: `m.image` (stored path) renders through the same `attachBubbleImage` one-renderer (live = data URL, restore = path); alt = `m.text || "attached image"` +- `app.js` `attachBubbleImage`: `onerror` degrades the img in place to a small "image unavailable" line (deleted out-of-band file — never a broken icon) +- `shared.js`: `renderSharedMessage` user branch + local `addBubbleImage` copy (per-page duplication house style) — identical treatment, alt, and onerror; public image route makes the shared view faithful +- `styles.css`: shared page reuses task 02's `.msg-image` as-is (same stylesheet + bubble class, verified); one new rule only for the new "image unavailable" note element +- Re-ask (`retryLastTurn`): byte-unchanged per the phase-120 lock — A7 (re-ask sends `prev.text` only, no attachment) is pinned by that byte-pin itself +- Restore filters (localStorage / saved-chat / shared) pass records through untouched — optional `image` key needs no migration +- Server round-trip verified live: user record with `image` survives POST/PUT `/api/chats` and serves through `GET /api/shared/{token}` (public shape) + +**Gates:** `uv run pytest` → exit 0, all pass · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) · `uv run ruff check .` → clean · `uv run pyright` → 0 errors, 0 warnings + +**Deviation note:** an A7 pin-comment in `retryLastTurn` initially broke the phase-120 whole-function byte-pin; removed the comment and kept the function byte-identical (the pin itself proves the no-attachment re-ask). + +**Next pending task:** `.agents/phases/todo/123_chat_image_questions/04_chat_image_tests.md` diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.validate b/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.validate new file mode 100644 index 0000000..0a81cd4 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__03_restore_and_shared.a1.validate @@ -0,0 +1,108 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 31%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 67%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 88%] +........................................................................ [ 90%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 98%] +...................................... [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 248 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 255 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 131 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 349 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 316 16 95% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 48 0 100% +app/schemas.py 390 0 100% +-------------------------------------------------- +TOTAL 4615 29 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.err b/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.md b/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.md new file mode 100644 index 0000000..2307242 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.md @@ -0,0 +1,15 @@ +All gates green. Final report: + +**Phase 123 task 04 — complete.** + +**Implemented (test suite for question-images):** +- `tests/unit/test_chat_image_questions.py` (extended): config env overrides, data-URL-helper import-not-copy identity pin, all-six-extension upload acceptance, +12 frontend source pins (hidden-by-default markup, config-gated reveal, A8 upload-before-send ordering/block-on-failure, user record carries path never base64, A7 no-history-replay + text-only redo, one bubble-image renderer + onerror degradation on both pages, single `.msg-image` CSS rule) +- `tests/integration/test_chats_api.py` (extended): saved + shared round-trips of the user image path, no base64 in raw JSONB, brain/text-only records stay key-free (task 04's `test_chat_api.py` cases were already shipped by task 01 — verified green) +- `tests/e2e/test_chat_image_questions.py` (new, isolated): preview+remove, send→exactly-one upload + live data-URL bubble + mock capture asserts the exact multimodal request (text + decodable data URL), reload restores from stored path (path-fetch counted), fresh anonymous context sees the image on the shared page, flag-off app: control hidden + exact hinted error frame + zero model calls; `tests/e2e/mock_llm.py` gained an additive observational capture (`/v1/e2e/captured` + reset) +- **Defect fix (task 02 work):** task-02's data-URL images (preview + live bubble) were blocked by the phase-82 CSP (`default-src 'self'`, no img-src) — the new E2E caught it (bubble degraded to "image unavailable"). Minimal fix: `img-src 'self' data:` appended to the CSP constant (scripts/styles/fetches stay strict; bytes are the user's own local file). Updated the three security-header test pins. **Flagged, not silent: this extends the A20-derived policy string per the owner-confirmed phase-123 data-URL rendering contract.** + +**Gates:** `uv run pytest` → 2796 passed · `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) · `uv run pytest tests/e2e/test_chat_image_questions.py --no-cov` → 5 passed in isolation · `uv run ruff check .` clean · `uv run pyright` → 0 errors. CSP-impact checks: e2e security-headers (2) + theme-semantic (8) pass. No live-infra changes. + +**Notable:** E2E runs against an unseeded KB (deterministic deflection); the sync-API request-event pitfall (polls must tick via Playwright calls, not `time.sleep`) is pinned with a comment. + +**Next pending task:** none — task 04 is the last of phase 123; the phase dir is ready for the pipeline gate. diff --git a/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.validate b/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.validate new file mode 100644 index 0000000..ea8d9d8 --- /dev/null +++ b/.agents/reports/123_chat_image_questions/123_chat_image_questions__04_chat_image_tests.a1.validate @@ -0,0 +1,108 @@ +........................................................................ [ 2%] +........................................................................ [ 5%] +........................................................................ [ 7%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 18%] +........................................................................ [ 20%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 33%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 51%] +........................................................................ [ 54%] +........................................................................ [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 97%] +............................................................ [100%] +=============================== warnings summary =============================== +.venv/lib/python3.13/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead. + from starlette.testclient import TestClient as TestClient # noqa + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================================ tests coverage ================================ +_______________ coverage: platform linux, python 3.13.13-final-0 _______________ + +Name Stmts Miss Cover +-------------------------------------------------- +app/__init__.py 1 0 100% +app/api/__init__.py 0 0 100% +app/api/auth.py 52 0 100% +app/api/chat.py 248 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 241 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 139 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 255 0 100% +app/core/__init__.py 0 0 100% +app/core/auth.py 45 0 100% +app/core/caching.py 124 0 100% +app/core/debugging.py 29 2 93% +app/core/docs_push.py 39 0 100% +app/core/errors.py 5 0 100% +app/core/logging.py 13 0 100% +app/core/rate_limit.py 44 0 100% +app/core/security_headers.py 20 0 100% +app/core/theming.py 38 0 100% +app/core/tokens.py 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 131 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 349 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 206 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 123 0 100% +app/rag/git_sources.py 38 0 100% +app/rag/importer.py 316 16 95% +app/rag/llm.py 244 1 99% +app/rag/overview.py 71 0 100% +app/rag/prompts.py 102 0 100% +app/rag/retriever.py 248 3 99% +app/rag/scaffolding.py 55 0 100% +app/rag/source_removal.py 41 0 100% +app/rag/sources_meta.py 16 0 100% +app/rag/suggestions.py 27 0 100% +app/rag/summarizer.py 48 0 100% +app/schemas.py 390 0 100% +-------------------------------------------------- +TOTAL 4615 29 99% +coverage gate: app/ 99% (>90%) OK +All checks passed! +0 errors, 0 warnings, 0 informations +WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.env.example b/.env.example index ad7d256..fa55979 100644 --- a/.env.example +++ b/.env.example @@ -126,6 +126,15 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume, # BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes # (uploads are replaced, checkouts re-cloned) +# --- Chat image questions (phase 123: attach an image to a question) --- +# Gated by the SAME BOR_IMAGES toggle above (enable only when the chat +# model supports vision). The question image is stored on the server — +# never base64 in saved/shared chats; the record carries the path. +# One image per question; prior turns' images are not replayed to the +# model (the question's image applies to the current turn only). +# BOR_CHAT_IMAGE_DIR=~/bor-sources/chat-images # where question images are stored +# BOR_CHAT_IMAGE_MAX_MB=10 # upload cap, MiB (must be > 0) + # --- Docs push (phase 59: save a chat answer as documentation) --- # The git repo chat answers can be committed to — any remote (URL or # local path). While empty, the "Save as doc" action is hidden and the diff --git a/app/api/chat.py b/app/api/chat.py index 80dc74c..14a3ff1 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -179,6 +179,27 @@ as ``reasoning_content`` on the assistant message (the preserve- thinking wire convention, A4). The per-turn log line records ``history_msgs=N`` after ``kb_chars=N`` (0 when the request carries no history — the two-message request stays byte-identical). + +Question images (phase 123, TODO L6; LOCKED A5/A7): the request may +attach ONE image to the CURRENT question — ``ChatRequest.image`` is the +STORED path from ``POST /api/chat-images`` (``app.api.chat_images``), +never a data URL. The turn validates it BEFORE any model call (the +toggle gate — ``settings.images`` false settles the phase-114 hinted +error frame; the stale-file gate — the stored bytes deleted out-of- +band settles the same frame shape) and, when valid, the user message's +content becomes the multimodal list ``[{type: "text", …}, {type: +"image_url", image_url: {url: }}]`` — built by +:func:`build_user_content` at BOTH construction sites (this module's +deflected-branch ``messages`` list and the grounded branch's +``run_agent`` call, which builds its own ``[system, *history, user]`` +— the flow is pinned in ``app.rag.agent.run_agent``). The data URL is +the shared :func:`app.rag.summarizer.image_data_url` (one +construction, both call sites — the phase-122 describe path). +``image=None`` keeps the plain-string content byte-identical to +pre-phase. A question image is turn-local: it is NEVER indexed as a +document, and prior turns' images are never replayed into the model's +history (``history_to_messages`` is unchanged — the text of a prior +turn that had an image stands alone, LOCKED A7). """ from __future__ import annotations @@ -188,6 +209,7 @@ import logging import time from collections.abc import AsyncIterator, Sequence from dataclasses import dataclass, field +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends @@ -229,6 +251,11 @@ from app.rag.retriever import ( ) from app.rag.scaffolding import ScaffoldingFilter # phase 71: the streaming filter from app.rag.suggestions import derive_suggestions +from app.rag.summarizer import ( + IMAGE_FALLBACK_MIME, + IMAGE_MIMES, + image_data_url, +) # phase 123: the phase-122 mime map + the shared data-URL helper from app.schemas import ( ChatDoneEvent, ChatErrorEvent, @@ -281,6 +308,37 @@ def sse_event(payload: dict[str, Any]) -> str: return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" +def build_user_content( + message: str, + image_path: str | None, + settings: Settings, +) -> str | list[dict[str, Any]]: + """The current turn's user message content (phase 123, task 01). + + *image_path* ``None`` (every text-only question) → the plain + question string — byte-identical to pre-phase-123 (the multimodal + branch is inert). Set → the OpenAI-compatible multimodal content + list: the text part + the image part, a data URL built server-side + from the stored bytes (``settings.chat_image_dir`` + the path's + filename) and the phase-122 ``IMAGE_MIMES`` map (one map, one + truth) through the shared :func:`app.rag.summarizer.image_data_url` + helper (one construction, both call sites — the phase-122 describe + path). The caller has already validated the path shape (the + ``ChatRequest.image`` schema guard) and the file's existence (the + pre-stream turn gate). The image applies to the CURRENT turn only + (LOCKED A7) — prior turns' images are never replayed. + """ + if image_path is None: + return message + filename = image_path.rsplit("/", 1)[-1] + data = (Path(settings.chat_image_dir).expanduser() / filename).read_bytes() + mime = IMAGE_MIMES.get(Path(filename).suffix.lower(), IMAGE_FALLBACK_MIME) + return [ + {"type": "text", "text": message}, + {"type": "image_url", "image_url": {"url": image_data_url(data, mime)}}, + ] + + @dataclass class TurnPlan: """What one chat turn sends to the LLM and reports on ``done``.""" @@ -494,6 +552,59 @@ async def chat( retries_used = 0 # phase 67: LLM requests restarted this turn (log line) try: settings = get_settings() + # Phase 123 (TODO L6, task 01): the question's attached + # image — validated BEFORE any model call (the embed is + # a model call): a rejected turn calls nothing and + # settles with the phase-114 error frame (the existing + # error-path convention — no ``done``, no ``query_log`` + # row, no persisted record; the question is not saved). + # Two gates, in order: + # 1. the ``images`` toggle (``BOR_IMAGES``) — off with + # an image set: the HINTED frame (the client's + # banner shows the hint in place of its default + # reachability copy, phase 114); + # 2. the stored file — the schema already pinned the + # path shape, but the file may have been deleted + # out-of-band (the stale-path edge): the same frame + # shape, no hint (the banner's default copy is the + # honest fallback — there is nothing to point at). + if request.image is not None: + if not settings.images: + logger.warning( + "chat: image question rejected (images toggle " + "off) question=%r image=%r", + request.message, + request.image, + ) + settled = True # terminal: the error frame settles the turn + yield sse_event( + ChatErrorEvent( + 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." + ), + ).model_dump() + ) + return + image_file = ( + Path(settings.chat_image_dir).expanduser() + / request.image.rsplit("/", 1)[-1] + ) + if not image_file.is_file(): + logger.warning( + "chat: image question rejected (stored file " + "missing) question=%r image=%r", + request.message, + request.image, + ) + settled = True # terminal: the error frame settles the turn + yield sse_event( + ChatErrorEvent( + detail="That image is no longer available." + ).model_dump() + ) + return # Phase 74 (TODO L4): the client's prior turns, mapped ONCE # per turn — trimmed newest-first against the settings # budgets, assistant turns carrying their prior thinking as @@ -640,10 +751,21 @@ async def chat( ).model_dump() ) return + # Phase 123 (task 01): the user message's content — the + # plain question string (``image=None`` — byte-identical + # to pre-phase) or the multimodal content list (text + # part + image_url data URL; the image was validated + # above). BOTH construction sites use the same build: + # this deflected-branch list and the grounded branch's + # ``run_agent`` call below (run_agent builds its own + # ``[system, *history, user]`` — pinned there). + user_content: str | list[dict[str, Any]] = build_user_content( + request.message, request.image, settings + ) messages: list[dict[str, Any]] = [ {"role": "system", "content": plan.system_prompt}, *hist, # phase 74: the trimmed prior turns (empty by default) - {"role": "user", "content": request.message}, + {"role": "user", "content": user_content}, ] # 3. Stream the answer (grounded, or an honest deflection). @@ -689,7 +811,11 @@ async def chat( llm, db_factory, # SEC-14-04: session factory, not a long-lived session system_prompt=plan.system_prompt, - user_message=request.message, + # Phase 123 (task 01): the plain question string + # (image=None) or the multimodal content list + # (validated above) — run_agent builds its own + # user message from this value (see its docstring). + user_message=user_content, seed_docs=plan.suggested_docs, # phase 118 (A4): the suggestion tier settings=settings, holder=holder, diff --git a/app/api/chat_images.py b/app/api/chat_images.py new file mode 100644 index 0000000..7b9fa1e --- /dev/null +++ b/app/api/chat_images.py @@ -0,0 +1,158 @@ +"""Question-image upload/serve pair (phase 123, TODO L6 — attach an +image to a question). + +A user attaches ONE image to a chat question (LOCKED A5): the bytes are +stored server-side under ``chat_image_dir/.`` — NOT +base64 in saved/shared chats. ``POST /api/chat-images`` answers +``{"path": "/api/chat-images/."}`` and that path is what +``ChatRequest.image`` / ``ChatMessage.image`` carry (a stored PATH, +never a data URL — the upload endpoint owns the size/mime +enforcement). ``GET /api/chat-images/{filename}`` serves the bytes back +so the user bubble, the refreshed page, and the shared chat can render +the attachment. A question image is NEVER indexed as a document (no +importer call) — it is turn-local storage, not a source. + +Auth posture: the upload is user-gated exactly like the chat turn it +feeds (``require_user`` — the question itself is user-gated, and an +anonymous 10 MiB disk-fill would be a DoS); the serve route is PUBLIC +like saved-chat content (phase 55 A1 — a saved chat's id is already its +credential, and the image is part of that content; the filename is an +unguessable ``uuid4().hex`` — no enumeration value). + +The extension is the source of truth (the Content-Type header is a +hint — the archive-uploader precedent): it must be in the phase-122 +image set (``settings.image_extension_set`` — the same frozenset the +image-document walk uses), lowercased; total bytes are streamed with a +``chat_image_max_mb`` cap (413, fixed detail naming the cap — never +echoing the filename). +""" +from __future__ import annotations + +import logging +import re +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app.config import get_settings +from app.core.auth import require_user +from app.rag.summarizer import IMAGE_FALLBACK_MIME, IMAGE_MIMES + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["chat-images"]) + +#: Stream receive chunk (the git-sources upload's 1 MiB pattern). +_STREAM_CHUNK = 1 << 20 + +#: Stored question-image filename guard: ``.`` — 32 +#: hex chars + one of the six image extensions (the ``ChatRequest.image`` +#: path pattern's filename part, kept in lockstep with it). Anything +#: else 404s — no path traversal by construction (the route parameter +#: cannot carry a ``/`` and the regex rejects everything but the +#: upload endpoint's own naming). +_CHAT_IMAGE_FILENAME_RE = re.compile(r"^[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)$") + + +@router.post("/chat-images") +async def upload_chat_image( + file: UploadFile = File(...), # noqa: B008 + _user: None = Depends(require_user), # noqa: B008 +) -> dict[str, str]: + """Store one question image (phase 123, task 01; LOCKED A5). + + Gates, in order: + + 1. **extension** — the file name's extension (lowercased) must be in + the phase-122 image set (``settings.image_extension_set``); the + Content-Type header is a hint, never a source of truth (the + archive-uploader precedent). A missing/unknown extension is a + 422 naming the accepted set — a fixed detail, no filename echo. + 2. **size** — the bytes are streamed (1 MiB chunks) with the + ``chat_image_max_mb`` cap; over-cap is a 413 naming the cap + (fixed detail — never echoing the filename), the temp file is + removed, and nothing is stored. + + The file lands as ``.`` in ``chat_image_dir`` + (created on demand; a dotfile temp is renamed into place, so a + failed/partial receive never leaves a servable-looking file). The + response is the served path — ``{"path": + "/api/chat-images/."}`` — the value ``ChatRequest.image`` + accepts (never a data URL, never the on-disk location). + """ + settings = get_settings() + filename = file.filename or "" + ext = Path(filename).suffix.lower() + if ext not in settings.image_extension_set: + accepted = ", ".join(sorted(settings.image_extension_set)) + raise HTTPException( + status_code=422, detail=f"only {accepted} images are accepted" + ) + + root = Path(settings.chat_image_dir).expanduser() + root.mkdir(parents=True, exist_ok=True) + name = f"{uuid.uuid4().hex}{ext}" + temp = root / f".{name}.upload" + max_bytes = settings.chat_image_max_mb * 1024 * 1024 + total = 0 + try: + # Stream with the cap — the dotfile temp is hidden from any + # listing of the store dir (the git-sources upload pattern). + with open(temp, "wb") as out: + while chunk := await file.read(_STREAM_CHUNK): + total += len(chunk) + if total > max_bytes: + raise HTTPException( + status_code=413, + detail=( + f"the image exceeds the " + f"{settings.chat_image_max_mb} MB limit" + ), + ) + out.write(chunk) + temp.rename(root / name) + except BaseException: + # A failed receive (413, broken pipe, cancellation) leaves no + # file behind — the final name was never created. + temp.unlink(missing_ok=True) + raise + logger.info( + "chat-image: uploaded name=%s bytes=%d", name, total + ) + return {"path": f"/api/chat-images/{name}"} + + +@router.get("/chat-images/{filename}", response_class=FileResponse) +def serve_chat_image(filename: str) -> FileResponse: + """Serve one stored question image (phase 123, task 01). + + PUBLIC (no auth dependency) — like saved-chat content: the saved + chat's id is already its credential (phase 55 A1), the image is part + of that content, and the filename is an unguessable ``uuid4().hex`` + (no enumeration value). + + Every non-servable case is a 404 with the same fixed detail — a + filename that does not match ``.`` (the regex guard: + no path traversal by construction, no 422 that would hint at + accepted shapes) and a matching name whose file is missing (the + stale-path edge — the file was deleted out-of-band). Servable files + stream the exact bytes with the phase-122 ``IMAGE_MIMES`` + ``Content-Type`` (one map, one truth with the describe call's + data-URL mime; the six-extension guard makes the fallback + unreachable) and ``Cache-Control: private, max-age=3600`` (the + phase-122 serve-route convention — the bytes are content-hashed + uuids, bustable by re-upload). + """ + if _CHAT_IMAGE_FILENAME_RE.fullmatch(filename) is None: + raise HTTPException(status_code=404, detail="chat image not found") + path = Path(get_settings().chat_image_dir).expanduser() / filename + if not path.is_file(): + raise HTTPException(status_code=404, detail="chat image not found") + media_type = IMAGE_MIMES.get(path.suffix.lower(), IMAGE_FALLBACK_MIME) + return FileResponse( + path, + media_type=media_type, + headers={"Cache-Control": "private, max-age=3600"}, + ) diff --git a/app/config.py b/app/config.py index 273e839..9f33f9f 100644 --- a/app/config.py +++ b/app/config.py @@ -396,6 +396,22 @@ class Settings(BaseSettings): #: upload): the served copy must outlive the source file. image_dir: str = "~/bor-sources/images" + # --- Chat image questions (phase 123: attach an image to a question) --- + #: Where a user's question image bytes are stored (phase 123, + #: ``BOR_CHAT_IMAGE_DIR`` — the ``image_dir`` convention: a sibling of + #: phase 122's document-image dir, separate because question-images + #: are per-conversation, not per-source). Raw string — + #: ``Path.expanduser()`` is applied by the upload/serve routes, not + #: here. Files land as ``.`` — the uuid is the + #: credential (no enumeration value; the saved/shared chat record + #: carries the served path, never base64, LOCKED A5). + chat_image_dir: str = "~/bor-sources/chat-images" + #: Cap in MiB for one question-image upload (phase 123, LOCKED A5 — + #: the ~10 MB cap; ``BOR_CHAT_IMAGE_MAX_MB``). ``<= 0`` would reject + #: every upload — a typo, so the validator fails loudly at startup + #: (the ``upload_max_mb`` pattern). + chat_image_max_mb: int = 10 + # --- Docs push (phase 59: save a chat answer as documentation) --- #: The git repo a saved chat answer is committed to (phase 59, D3): #: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a @@ -574,6 +590,15 @@ class Settings(BaseSettings): raise ValueError("upload_max_mb must be > 0 (MiB)") return v + @field_validator("chat_image_max_mb") + @classmethod + def _chat_image_max_mb_positive(cls, v: int) -> int: + """``0``/negative would reject every question-image upload — fail + loud at startup (the ``upload_max_mb`` precedent, phase 123).""" + if v <= 0: + raise ValueError("chat_image_max_mb must be > 0 (MiB)") + return v + @field_validator("history_max_turns") @classmethod def _history_max_turns_non_negative(cls, v: int) -> int: diff --git a/app/core/security_headers.py b/app/core/security_headers.py index c51b72d..40beeba 100644 --- a/app/core/security_headers.py +++ b/app/core/security_headers.py @@ -38,7 +38,8 @@ from __future__ import annotations from starlette.datastructures import MutableHeaders from starlette.types import ASGIApp, Message, Receive, Scope, Send -#: The exact owner-approved policy (phase 82, decision A1). +#: The exact owner-approved policy (phase 82, decision A1), extended +#: by phase 123's ``img-src`` carve-out (see below). #: ``default-src 'self'`` is inherited by every sub-policy that has no #: explicit entry (``script-src``, ``style-src``, ``connect-src``, …), #: ``base-uri 'none'`` blocks base-tag hijacking, and @@ -46,7 +47,26 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send #: (verified unnecessary — see module docstring), no ``report-uri`` / #: ``report-to`` (no collector in the homelab — a report would just #: vanish). -CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'" +# +#: Phase 123 (chat image questions, owner-confirmed 2026-09-24) added +#: the one scoped relaxation the design requires: ``img-src 'self' +#: data:``. The question-image composer renders the picked file as a +#: ``data:`` URL — the PREVIEW thumbnail (before the send-time upload +#: there is no served path yet) and the LIVE user bubble (the data URL +#: needs no fetch) — and ``default-src 'self'`` alone blocks ``data:`` +#: images in every real browser (the phase-123 E2E caught it: the +#: bubble degraded to the "image unavailable" line). The carve-out is +#: ``img-src`` ONLY: ``data:`` never becomes a source for scripts, +#: styles, or fetches (those keep the strict ``default-src 'self'`` +#: inheritance), and the bytes are the user's OWN locally-picked file +#: (no exfiltration vector — an ```` cannot read them back). +#: Restored / shared bubbles render from the served path (``'self'``), +#: so the ``data:`` allowance exists for the two pre-upload/first-paint +#: surfaces only. +CSP = ( + "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; " + "img-src 'self' data:" +) class SecurityHeadersMiddleware: diff --git a/app/main.py b/app/main.py index faa5025..90514f2 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from starlette.responses import FileResponse from app.api.auth import router as auth_router from app.api.chat import router as chat_router +from app.api.chat_images import router as chat_images_router from app.api.chats import ( public_router as chats_public_router, ) @@ -116,6 +117,10 @@ def create_app() -> FastAPI: app.include_router(docs_router, prefix="/api") app.include_router(git_sources_router, prefix="/api") app.include_router(chat_router, prefix="/api") + # Phase 123: the question-image upload/serve pair (POST is + # user-gated like the chat turn; GET is public like saved-chat + # content — the uuid filename is the credential). + app.include_router(chat_images_router, prefix="/api") app.include_router(steering_router, prefix="/api") app.include_router(sync_router, prefix="/api") app.include_router(chats_router, prefix="/api") diff --git a/app/rag/agent.py b/app/rag/agent.py index f6e6ff8..c6b9376 100644 --- a/app/rag/agent.py +++ b/app/rag/agent.py @@ -1393,7 +1393,7 @@ async def run_agent( db_factory: Callable[[], Session], *, system_prompt: str, - user_message: str, + user_message: str | list[dict[str, Any]], seed_docs: Sequence[Document], settings: Settings, holder: AgentHolder, @@ -1424,6 +1424,20 @@ async def run_agent( unchanged. ``()`` (the default) keeps the pre-phase-74 two-message request byte-identical. + User message (phase 123, TODO L6): *user_message* is the current + turn's user content — the plain question string (every text-only + turn, byte-identical to pre-phase) OR the multimodal content list + ``[{type: "text", …}, {type: "image_url", …}]`` for a question that + carried an image. FLOW (pinned): the API layer + (``app.api.chat``) builds the content via its ``build_user_content`` + helper and passes it HERE — ``run_agent`` builds its OWN + ``[system, *history, user]`` list from this value (it does NOT + receive the already-built ``messages``; the deflected branch is the + one that consumes chat.py's list directly), so a single value + covers both shapes and the tool rounds / recovery / retries operate + on it untouched. Prior turns' images are never replayed (LOCKED A7 + — *history* is text-only by construction). + Retries (phase 67, owner-locked A2): every model request goes through :func:`chat_stream_retried` — a failed round is retried **before** its first piece (same messages, ``settings.llm_retries`` restarts, a flat diff --git a/app/rag/llm.py b/app/rag/llm.py index 362dde7..2b3dc3e 100644 --- a/app/rag/llm.py +++ b/app/rag/llm.py @@ -487,11 +487,15 @@ class LLMClient: Messages are passed to the request body VERBATIM: string-only ``{role, content}`` dicts are byte-identical on the wire to the - pre-phase-74 requests, and an assistant message may additionally + pre-phase-74 requests, an assistant message may additionally carry ``reasoning_content`` (the client's prior thinking, phase 74 — the same wire field the model uses for its OWN reasoning on - the response side; the ``openai`` SDK passes message dicts - through untouched, so no transport change). + the response side), and a user message's content may be the + multimodal parts list of a question that carried an image + (phase 123 — ``[{type: "text", …}, {type: "image_url", …}]``, + built by ``app.api.chat.build_user_content``); the ``openai`` + SDK passes message dicts through untouched, so no transport + change covers all three shapes. ``stream=True`` against the OpenAI-compatible endpoint, yielding typed :class:`StreamPiece` values. Wire convention (verified live diff --git a/app/rag/summarizer.py b/app/rag/summarizer.py index 214a861..fbd9328 100644 --- a/app/rag/summarizer.py +++ b/app/rag/summarizer.py @@ -117,6 +117,18 @@ IMAGE_MIMES: dict[str, str] = { IMAGE_FALLBACK_MIME = "application/octet-stream" +def image_data_url(data: bytes, mime: str) -> str: + """One image's bytes as a data URL for a multimodal message (phase + 122; factored for phase 123, task 01). + + ``data:;base64,`` — the OpenAI-compatible ``image_url`` + payload's ``url``. ONE helper, both call sites: :func:`describe_image` + (document-image descriptions) and the chat question-image path + (``app.api.chat``'s multimodal user message, phase 123). + """ + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + + class SummaryLLM(Protocol): """The one-shot chat surface the summarizer needs. @@ -223,7 +235,7 @@ async def describe_image( the doc is skipped, counted in ``images_failed``, and the sync continues (LOCKED A3). """ - data_url = f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + data_url = image_data_url(data, mime) messages: list[dict[str, Any]] = [ { "role": "user", diff --git a/app/schemas.py b/app/schemas.py index b896416..b4b8db8 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,6 +1,7 @@ """Pydantic request/response schemas (API contract).""" from __future__ import annotations +import re import uuid from datetime import datetime from typing import Annotated, Any, Literal @@ -60,7 +61,8 @@ class HistoryTurn(BaseModel): class ChatRequest(BaseModel): """``POST /api/chat`` body: the current question plus the optional prior turns (phase 74 — the client-provided history, stateless per - A10). + A10) and, since phase 123, the question's attached image (the + stored path — :attr:`image`). ``history`` is the client's earlier turns, oldest first (the ``bor.chat.v1`` record minus the current question); the mapper @@ -74,6 +76,30 @@ class ChatRequest(BaseModel): message: str = Field(min_length=1, max_length=4000) history: list[HistoryTurn] = Field(default_factory=list, max_length=100) + #: Phase 123 (TODO L6, LOCKED A5): the STORED PATH of the question's + #: attached image — ``/api/chat-images/.`` exactly as + #: ``POST /api/chat-images`` returns it. NEVER a raw data URL: the + #: upload endpoint already did the size/mime enforcement, and + #: re-validating a 10 MB base64 string at the schema would be the + #: anti-pattern. ``None`` (the default, every text-only question) + #: keeps the turn byte-identical to pre-phase-123. + image: str | None = Field(default=None, max_length=500) + + @field_validator("image") + @classmethod + def _image_is_stored_path(cls, v: str | None) -> str | None: + """A set ``image`` must be the upload endpoint's stored-path + shape — ``/api/chat-images/.`` (32 hex chars, + the six image extensions; the pattern is pinned in the + phase-123 design and mirrored by the serve route's filename + guard, ``app.api.chat_images``). A data URL, a bare filename, a + traversal, a wrong extension, or a mistyped uuid all 422 with + ONE fixed detail — no echo of the input.""" + if v is None: + return v + if re.fullmatch(r"/api/chat-images/[0-9a-fA-F]{32}\.(png|jpe?g|webp|gif|bmp)", v) is None: + raise ValueError("image must be an uploaded chat image path") + return v class LoginRequest(BaseModel): """``POST /api/login`` body (phase 16): the single admin's password. @@ -928,6 +954,36 @@ class ChatMessage(BaseModel): # persisted error detail (the phase-48 ``stopped`` precedent). failed: bool | None = None error: str | None = Field(default=None, max_length=500) + # Phase 123 (task 01, LOCKED A5): the question's attached image — + # the STORED PATH (``/api/chat-images/.``, from + # ``POST /api/chat-images``), on the USER record only: the + # attachment belongs to the question, so a brain record never + # carries it (the answer may cite the image doc's sources, but the + # attachment itself is the user's). The saved/shared shape gains + # this one optional key — omitted when ``None`` (the + # :meth:`_drop_image_when_absent` serializer below), so a text-only + # chat round-trips byte-identically to pre-phase-123 (the + # phase-50 contract; the phase-122 ``SourceRef.image_url`` + # omission precedent). The path is ≤ 500 chars — no phase-83 + # cap pressure (it is never a data URL, LOCKED A5). + image: str | None = Field(default=None, max_length=500) + + @model_serializer(mode="wrap") + def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any: + """The phase-123 image omission rule: ``image: None`` (every + text-only record, and every pre-phase-123 record) serializes + WITHOUT the key — ABSENT, never ``null`` — so the stored + ``bor.chat.v1`` JSONB and the saved/shared wire shape stay + byte-identical to pre-phase for text-only chats (the phase-50 + round-trip contract). A record WITH an image keeps the path — + the user bubble, the refreshed page, and the shared chat all + render it from the served route (the image is part of the + chat's content, so it rides the same public/credential- + is-the-id trust model).""" + data = handler(self) + if self.image is None: + data.pop("image", None) + return data class SavedChatCreate(BaseModel): diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 2e5cbfc..47a98d5 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -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) {
${html}
`; + 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). diff --git a/frontend/assets/shared.js b/frontend/assets/shared.js index 969bdb2..c92eb3e 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -318,9 +318,42 @@ function addStoppedNote(wrap) { 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; brain → the .msg.brain bubble with the optional + * .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 @@ -331,7 +364,16 @@ function addStoppedNote(wrap) { * unchanged. */ function renderSharedMessage(m) { if (m.who === "user") { - addSharedMessage("user", renderMarkdown(m.text)); + 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)); diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index 3e0f0c9..37f0e34 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -551,6 +551,42 @@ body::before { } .msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); } +/* Phase 123 (task 02, TODO L6): the question's IMAGE in the user + bubble — the attachment is PART of the question, so it sits at the + TOP of the bubble (above the text). Capped height (a tall portrait + must not blow the chat column) + full-width safe; the theme's + bordered-image treatment (the .source-image-img language) — the + border gives the bytes a boundary against the brand bubble fill. */ +.msg-image { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: 240px; + object-fit: contain; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + margin-bottom: 0.45rem; +} + +/* Phase 123 (task 03, TODO L6): the question's image is UNAVAILABLE — + the stored file was deleted out-of-band (the record keeps its + path, the render degrades): the small muted line takes the img's + place at the top of the user bubble on BOTH pages (the chat + restore and the shared view render the same record shape through + the same .msg/.bubble structure, and share this stylesheet). It + inherits the bubble's text color — the user bubble's text already + passes 4.5:1 (PLAN §7.2), so the note does too; the smaller size + + italic make it read as a note, never a broken-image icon. The + margin-bottom mirrors .msg-image's, so the text below keeps its + spacing either way. */ +.msg-image-unavailable { + display: block; + margin-bottom: 0.45rem; + font-size: 0.75rem; + font-style: italic; +} + .msg.brain .bubble { border-bottom-left-radius: 4px; } .msg.brain.is-deflected .bubble { background: var(--accent-bg); @@ -1581,6 +1617,62 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } color, never color alone (B3). */ .char-count { margin: 0; text-align: right; font-size: 0.75rem; line-height: 1.2; color: var(--ink-soft); } .char-count.is-max { color: var(--err-ink); } +/* Phase 123 (task 02, TODO L6): the ATTACH PREVIEW STRIP — the + selected image ABOVE the input row (thumbnail ≤48px + the filename + + the remove button): a surface card in the .chat-bottom stack, between + the char-count line and the composer. The name is ellipsized + (AA-safe --ink on --surface = 13.8:1) — a long filename never widens + the strip; the thumbnail is a fixed 48px cover box (the preview + crops, the bubble shows the whole image); the remove button keeps the + 44px touch + target (PLAN §7.1) with a destructive hover (--err-ink on --err-bg = + 9.3:1 — text + color, never color alone, B3). Hidden by default + (the global [hidden] rule) — revealed only while a file is attached, + cleared with the send so the strip never lingers into a turn. */ +.attach-preview { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.45rem; + padding: 0.35rem 0.5rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} +.attach-preview-img { + flex: none; + width: 48px; + height: 48px; + object-fit: cover; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--bg); +} +.attach-preview-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.85rem; + color: var(--ink); +} +.attach-preview-remove { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 44px; + min-height: 44px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--ink-soft); + cursor: pointer; + padding: 0; +} +.attach-preview-remove svg { width: 18px; height: 18px; } +.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); } .composer { display: flex; align-items: flex-end; @@ -1605,6 +1697,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } background: transparent; } .composer textarea::placeholder { color: var(--ink-soft); } +/* Phase 123 (task 02, TODO L6): the composer's ATTACH CONTROL — the + paperclip glyph button LEFT of the input. Hidden in the markup until + app.js reveals it from the config's images flag (the global [hidden] + rule keeps it out of the flag-off DOM — A5's default-off contract). + A neutral cut of the .send-btn family: the same 44px hit target + + radius + the global :focus-visible ring (PLAN §7.2); surface fill + with a muted hover step (the icon: --ink-soft on --surface = 5.1:1, + hover --brand-ink on --brand-soft = 12.4:1 — both past the 3:1 + non-text floor). */ +.attach-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 44px; + min-height: 44px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink-soft); + cursor: pointer; + padding: 0; +} +.attach-btn svg { width: 20px; height: 20px; } +.attach-btn:hover { background: var(--brand-soft); border-color: var(--brand-soft); color: var(--brand-ink); } .send-btn { display: inline-flex; align-items: center; diff --git a/frontend/index.html b/frontend/index.html index fe988dd..f67261b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -267,6 +267,24 @@ (role=alert). --> + + +
+ + +