phase: 123_chat_image_questions
All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
This commit is contained in:
@@ -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/`.
|
||||||
+108
@@ -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
|
||||||
+13
@@ -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)
|
||||||
+108
@@ -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
|
||||||
+17
@@ -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: <path>` (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).
|
||||||
+108
@@ -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
|
||||||
+17
@@ -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`
|
||||||
+108
@@ -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
|
||||||
+15
@@ -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.
|
||||||
+108
@@ -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
|
||||||
@@ -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
|
# BOR_IMAGE_DIR=~/bor-sources/images # persistent home for the served image bytes
|
||||||
# (uploads are replaced, checkouts re-cloned)
|
# (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) ---
|
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||||
# The git repo chat answers can be committed to — any remote (URL or
|
# 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
|
# local path). While empty, the "Save as doc" action is hidden and the
|
||||||
|
|||||||
+128
-2
@@ -179,6 +179,27 @@ as ``reasoning_content`` on the assistant message (the preserve-
|
|||||||
thinking wire convention, A4). The per-turn log line records
|
thinking wire convention, A4). The per-turn log line records
|
||||||
``history_msgs=N`` after ``kb_chars=N`` (0 when the request carries no
|
``history_msgs=N`` after ``kb_chars=N`` (0 when the request carries no
|
||||||
history — the two-message request stays byte-identical).
|
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: <data 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -188,6 +209,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator, Sequence
|
from collections.abc import AsyncIterator, Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.scaffolding import ScaffoldingFilter # phase 71: the streaming filter
|
||||||
from app.rag.suggestions import derive_suggestions
|
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 (
|
from app.schemas import (
|
||||||
ChatDoneEvent,
|
ChatDoneEvent,
|
||||||
ChatErrorEvent,
|
ChatErrorEvent,
|
||||||
@@ -281,6 +308,37 @@ def sse_event(payload: dict[str, Any]) -> str:
|
|||||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
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
|
@dataclass
|
||||||
class TurnPlan:
|
class TurnPlan:
|
||||||
"""What one chat turn sends to the LLM and reports on ``done``."""
|
"""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)
|
retries_used = 0 # phase 67: LLM requests restarted this turn (log line)
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
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
|
# Phase 74 (TODO L4): the client's prior turns, mapped ONCE
|
||||||
# per turn — trimmed newest-first against the settings
|
# per turn — trimmed newest-first against the settings
|
||||||
# budgets, assistant turns carrying their prior thinking as
|
# budgets, assistant turns carrying their prior thinking as
|
||||||
@@ -640,10 +751,21 @@ async def chat(
|
|||||||
).model_dump()
|
).model_dump()
|
||||||
)
|
)
|
||||||
return
|
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]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": plan.system_prompt},
|
{"role": "system", "content": plan.system_prompt},
|
||||||
*hist, # phase 74: the trimmed prior turns (empty by default)
|
*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).
|
# 3. Stream the answer (grounded, or an honest deflection).
|
||||||
@@ -689,7 +811,11 @@ async def chat(
|
|||||||
llm,
|
llm,
|
||||||
db_factory, # SEC-14-04: session factory, not a long-lived session
|
db_factory, # SEC-14-04: session factory, not a long-lived session
|
||||||
system_prompt=plan.system_prompt,
|
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
|
seed_docs=plan.suggested_docs, # phase 118 (A4): the suggestion tier
|
||||||
settings=settings,
|
settings=settings,
|
||||||
holder=holder,
|
holder=holder,
|
||||||
|
|||||||
@@ -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/<uuid4().hex>.<ext>`` — NOT
|
||||||
|
base64 in saved/shared chats. ``POST /api/chat-images`` answers
|
||||||
|
``{"path": "/api/chat-images/<uuid>.<ext>"}`` 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: ``<uuid4().hex>.<ext>`` — 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 ``<uuid4().hex>.<ext>`` 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/<uuid>.<ext>"}`` — 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 ``<uuid-hex>.<ext>`` (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"},
|
||||||
|
)
|
||||||
@@ -396,6 +396,22 @@ class Settings(BaseSettings):
|
|||||||
#: upload): the served copy must outlive the source file.
|
#: upload): the served copy must outlive the source file.
|
||||||
image_dir: str = "~/bor-sources/images"
|
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 ``<uuid4().hex>.<ext>`` — 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) ---
|
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
#: **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)")
|
raise ValueError("upload_max_mb must be > 0 (MiB)")
|
||||||
return v
|
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")
|
@field_validator("history_max_turns")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _history_max_turns_non_negative(cls, v: int) -> int:
|
def _history_max_turns_non_negative(cls, v: int) -> int:
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ from __future__ import annotations
|
|||||||
from starlette.datastructures import MutableHeaders
|
from starlette.datastructures import MutableHeaders
|
||||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
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
|
#: ``default-src 'self'`` is inherited by every sub-policy that has no
|
||||||
#: explicit entry (``script-src``, ``style-src``, ``connect-src``, …),
|
#: explicit entry (``script-src``, ``style-src``, ``connect-src``, …),
|
||||||
#: ``base-uri 'none'`` blocks base-tag hijacking, and
|
#: ``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`` /
|
#: (verified unnecessary — see module docstring), no ``report-uri`` /
|
||||||
#: ``report-to`` (no collector in the homelab — a report would just
|
#: ``report-to`` (no collector in the homelab — a report would just
|
||||||
#: vanish).
|
#: 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 ``<img>`` 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:
|
class SecurityHeadersMiddleware:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from starlette.responses import FileResponse
|
|||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.chat import router as chat_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 (
|
from app.api.chats import (
|
||||||
public_router as chats_public_router,
|
public_router as chats_public_router,
|
||||||
)
|
)
|
||||||
@@ -116,6 +117,10 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(docs_router, prefix="/api")
|
app.include_router(docs_router, prefix="/api")
|
||||||
app.include_router(git_sources_router, prefix="/api")
|
app.include_router(git_sources_router, prefix="/api")
|
||||||
app.include_router(chat_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(steering_router, prefix="/api")
|
||||||
app.include_router(sync_router, prefix="/api")
|
app.include_router(sync_router, prefix="/api")
|
||||||
app.include_router(chats_router, prefix="/api")
|
app.include_router(chats_router, prefix="/api")
|
||||||
|
|||||||
+15
-1
@@ -1393,7 +1393,7 @@ async def run_agent(
|
|||||||
db_factory: Callable[[], Session],
|
db_factory: Callable[[], Session],
|
||||||
*,
|
*,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
user_message: str,
|
user_message: str | list[dict[str, Any]],
|
||||||
seed_docs: Sequence[Document],
|
seed_docs: Sequence[Document],
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
holder: AgentHolder,
|
holder: AgentHolder,
|
||||||
@@ -1424,6 +1424,20 @@ async def run_agent(
|
|||||||
unchanged. ``()`` (the default) keeps the pre-phase-74 two-message
|
unchanged. ``()`` (the default) keeps the pre-phase-74 two-message
|
||||||
request byte-identical.
|
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
|
Retries (phase 67, owner-locked A2): every model request goes through
|
||||||
:func:`chat_stream_retried` — a failed round is retried **before** its
|
:func:`chat_stream_retried` — a failed round is retried **before** its
|
||||||
first piece (same messages, ``settings.llm_retries`` restarts, a flat
|
first piece (same messages, ``settings.llm_retries`` restarts, a flat
|
||||||
|
|||||||
+7
-3
@@ -487,11 +487,15 @@ class LLMClient:
|
|||||||
|
|
||||||
Messages are passed to the request body VERBATIM: string-only
|
Messages are passed to the request body VERBATIM: string-only
|
||||||
``{role, content}`` dicts are byte-identical on the wire to the
|
``{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
|
carry ``reasoning_content`` (the client's prior thinking, phase
|
||||||
74 — the same wire field the model uses for its OWN reasoning on
|
74 — the same wire field the model uses for its OWN reasoning on
|
||||||
the response side; the ``openai`` SDK passes message dicts
|
the response side), and a user message's content may be the
|
||||||
through untouched, so no transport change).
|
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
|
``stream=True`` against the OpenAI-compatible endpoint, yielding
|
||||||
typed :class:`StreamPiece` values. Wire convention (verified live
|
typed :class:`StreamPiece` values. Wire convention (verified live
|
||||||
|
|||||||
+13
-1
@@ -117,6 +117,18 @@ IMAGE_MIMES: dict[str, str] = {
|
|||||||
IMAGE_FALLBACK_MIME = "application/octet-stream"
|
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:<mime>;base64,<ascii>`` — 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):
|
class SummaryLLM(Protocol):
|
||||||
"""The one-shot chat surface the summarizer needs.
|
"""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
|
the doc is skipped, counted in ``images_failed``, and the sync
|
||||||
continues (LOCKED A3).
|
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]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
|
|||||||
+57
-1
@@ -1,6 +1,7 @@
|
|||||||
"""Pydantic request/response schemas (API contract)."""
|
"""Pydantic request/response schemas (API contract)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, Any, Literal
|
from typing import Annotated, Any, Literal
|
||||||
@@ -60,7 +61,8 @@ class HistoryTurn(BaseModel):
|
|||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
"""``POST /api/chat`` body: the current question plus the optional
|
"""``POST /api/chat`` body: the current question plus the optional
|
||||||
prior turns (phase 74 — the client-provided history, stateless per
|
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
|
``history`` is the client's earlier turns, oldest first (the
|
||||||
``bor.chat.v1`` record minus the current question); the mapper
|
``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)
|
message: str = Field(min_length=1, max_length=4000)
|
||||||
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
|
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/<uuid4-hex>.<ext>`` 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/<uuid4().hex>.<ext>`` (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):
|
class LoginRequest(BaseModel):
|
||||||
"""``POST /api/login`` body (phase 16): the single admin's password.
|
"""``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).
|
# persisted error detail (the phase-48 ``stopped`` precedent).
|
||||||
failed: bool | None = None
|
failed: bool | None = None
|
||||||
error: str | None = Field(default=None, max_length=500)
|
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/<uuid>.<ext>``, 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):
|
class SavedChatCreate(BaseModel):
|
||||||
|
|||||||
+262
-10
@@ -306,6 +306,14 @@ const sendBtn = document.querySelector("#send-btn");
|
|||||||
const sendLabel = document.querySelector("#send-label");
|
const sendLabel = document.querySelector("#send-label");
|
||||||
const sendStatus = document.querySelector("#send-status");
|
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
|
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 banner = document.querySelector("#kb-banner");
|
||||||
const bannerText = document.querySelector("#kb-banner-text");
|
const bannerText = document.querySelector("#kb-banner-text");
|
||||||
const versionEl = document.querySelector("#app-version");
|
const versionEl = document.querySelector("#app-version");
|
||||||
@@ -927,8 +935,17 @@ const USER_AVATAR =
|
|||||||
* scrolls only when the caller passes `scroll = true` — the user submit
|
* scrolls only when the caller passes `scroll = true` — the user submit
|
||||||
* (reveal my message) and the phase-14 restore landing. The streaming
|
* (reveal my message) and the phase-14 restore landing. The streaming
|
||||||
* path (thinking / tool / delta) creates bubbles with the default
|
* path (thinking / tool / delta) creates bubbles with the default
|
||||||
* (scroll = false): the page never follows a turn. */
|
* (scroll = false): the page never follows a turn.
|
||||||
function addMessage(who, html, scroll = false) {
|
*
|
||||||
|
* 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;
|
if (emptyState) emptyState.hidden = true;
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = `msg ${who}`;
|
wrap.className = `msg ${who}`;
|
||||||
@@ -937,11 +954,45 @@ function addMessage(who, html, scroll = false) {
|
|||||||
<div class="msg-body">
|
<div class="msg-body">
|
||||||
<div class="bubble">${html}</div>
|
<div class="bubble">${html}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
if (who === "user" && image) {
|
||||||
|
attachBubbleImage(wrap.querySelector(".bubble"), image.src, image.alt);
|
||||||
|
}
|
||||||
messagesEl.appendChild(wrap);
|
messagesEl.appendChild(wrap);
|
||||||
if (scroll) scrollReveal(wrap);
|
if (scroll) scrollReveal(wrap);
|
||||||
return 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() {
|
function addTyping() {
|
||||||
removeTyping(); // idempotent: at most one indicator at a time
|
removeTyping(); // idempotent: at most one indicator at a time
|
||||||
if (emptyState) emptyState.hidden = true;
|
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 turnAbort = null; // AbortController of the in-flight turn (null idle)
|
||||||
let stoppedByUser = false; // the Stop button took this turn (not the guard)
|
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() {
|
function stopThinkingClock() {
|
||||||
if (thinkingClock) {
|
if (thinkingClock) {
|
||||||
clearInterval(thinkingClock);
|
clearInterval(thinkingClock);
|
||||||
@@ -1788,7 +1859,22 @@ function renderStoredMessage(m) {
|
|||||||
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
|
// the default SCROLL (smooth; "auto" under prefers-reduced-motion)
|
||||||
// instead of the old forced "auto" — noted per the phase-42 task.
|
// instead of the old forced "auto" — noted per the phase-42 task.
|
||||||
if (m.who === "user") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
const wrap = addMessage("brain", renderMarkdown(m.text), true);
|
||||||
@@ -2355,6 +2441,7 @@ function startNewChat() {
|
|||||||
input.value = "";
|
input.value = "";
|
||||||
autoGrow();
|
autoGrow();
|
||||||
updateCharCount(); // phase 104: the cleared composer hides the counter again
|
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();
|
input.focus();
|
||||||
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
sendStatus.textContent = "New chat started — previous conversation cleared.";
|
||||||
}
|
}
|
||||||
@@ -2460,6 +2547,70 @@ function retryLastTurn(wrap) {
|
|||||||
return runTurn(text, { reask: true });
|
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) {
|
async function handleSend(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Phase 48: while a turn is in flight the Send button IS the Stop
|
// 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.");
|
showErrorBanner("Questions are limited to 4,000 characters — trim the question and try again.");
|
||||||
return;
|
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
|
// Phase 49: the user append + persistence save point 1 moved into
|
||||||
// runTurn with the rest of the turn — the `reask` flag skips them on
|
// 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 +
|
// the redo-in-place retry path (the question is already in the DOM +
|
||||||
@@ -2487,7 +2658,7 @@ async function handleSend(e) {
|
|||||||
autoGrow();
|
autoGrow();
|
||||||
updateCharCount(); // phase 104: the sent question clears the counter with the input
|
updateCharCount(); // phase 104: the sent question clears the counter with the input
|
||||||
clearErrorBanner();
|
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
|
/* 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,
|
* finally settle moved here verbatim, and the turn-local resets (acc,
|
||||||
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
|
* thinkingAcc, sawThinking, sawDone, toolAcc, stoppedByUser, turnAbort)
|
||||||
* stay turn-scoped exactly as phase 48 left them. */
|
* 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) {
|
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
|
// Persistence save point 1: the question is stored the moment it is
|
||||||
// sent, so a failed/interrupted turn never loses it.
|
// sent, so a failed/interrupted turn never loses it. The `image`
|
||||||
conversation.push({ who: "user", text });
|
// 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();
|
saveConversation();
|
||||||
// Phase 55 (A2): the auto-save rides the save point — an unlinked
|
// Phase 55 (A2): the auto-save rides the save point — an unlinked
|
||||||
// conversation creates its row here (auto-title, server-side), a
|
// conversation creates its row here (auto-title, server-side), a
|
||||||
// linked one refreshes. Fire-and-forget: it never blocks the turn.
|
// linked one refreshes. Fire-and-forget: it never blocks the turn.
|
||||||
persistConversation();
|
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;
|
let wrap = null;
|
||||||
@@ -2632,10 +2824,19 @@ async function runTurn(text, { reask = false } = {}) {
|
|||||||
text: m.text,
|
text: m.text,
|
||||||
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
|
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", {
|
res = await fetch("/api/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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
|
signal: turnAbort.signal, // phase 48: the Stop button aborts the fetch
|
||||||
});
|
});
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
@@ -3008,6 +3209,48 @@ input.addEventListener("keydown", (e) => {
|
|||||||
});
|
});
|
||||||
composer.addEventListener("submit", handleSend);
|
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
|
/* Phase 55 (owner-locked A2, 2026-08-31): the phase-50 Save binding is
|
||||||
* GONE with the pill — there is no Save control; persistConversation()
|
* GONE with the pill — there is no Save control; persistConversation()
|
||||||
* auto-saves headless at the save points (fire-and-forget, quiet on
|
* 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
|
// proven), so a restored conversation of a configured admin gets the
|
||||||
// "Save as doc" button exactly once: no flash, no re-render, no
|
// "Save as doc" button exactly once: no flash, no re-render, no
|
||||||
// second fetch (the brand fetch IS the config fetch).
|
// 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;
|
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)
|
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||||
// Phase 55 (task 03): no Share-reveal step — the pill is static,
|
// Phase 55 (task 03): no Share-reveal step — the pill is static,
|
||||||
// always-visible markup (visible to every visitor, phase 51 contract).
|
// always-visible markup (visible to every visitor, phase 51 contract).
|
||||||
|
|||||||
@@ -318,9 +318,42 @@ function addStoppedNote(wrap) {
|
|||||||
meta.appendChild(note);
|
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
|
/* One stored record through the SAME .msg structure the chat page
|
||||||
* uses (pixel-parity with the chat page's restore path): user → the
|
* 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,
|
* thinking block (restored COLLAPSED — phase 17), the tool lines,
|
||||||
* the deflection treatment + the plain-text "Maybe try" chips, the
|
* the deflection treatment + the plain-text "Maybe try" chips, the
|
||||||
* plain-text source chips, and the stopped note. NO interactive
|
* plain-text source chips, and the stopped note. NO interactive
|
||||||
@@ -331,7 +364,16 @@ function addStoppedNote(wrap) {
|
|||||||
* unchanged. */
|
* unchanged. */
|
||||||
function renderSharedMessage(m) {
|
function renderSharedMessage(m) {
|
||||||
if (m.who === "user") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
|
||||||
|
|||||||
@@ -551,6 +551,42 @@ body::before {
|
|||||||
}
|
}
|
||||||
.msg.user .bubble code { background: color-mix(in srgb, var(--bg) 16%, transparent); }
|
.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 .bubble { border-bottom-left-radius: 4px; }
|
||||||
.msg.brain.is-deflected .bubble {
|
.msg.brain.is-deflected .bubble {
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
@@ -1581,6 +1617,62 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
color, never color alone (B3). */
|
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 { 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); }
|
.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 {
|
.composer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1605,6 +1697,31 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.composer textarea::placeholder { color: var(--ink-soft); }
|
.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 {
|
.send-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -267,6 +267,24 @@
|
|||||||
(role=alert). -->
|
(role=alert). -->
|
||||||
<p class="char-count" id="char-count" hidden></p>
|
<p class="char-count" id="char-count" hidden></p>
|
||||||
|
|
||||||
|
<!-- Phase 123 (task 02, TODO L6): the attach PREVIEW STRIP —
|
||||||
|
the selected image (thumbnail ≤48px + the filename + a
|
||||||
|
remove X) ABOVE the input row: the attachment is part of
|
||||||
|
the question being typed. `hidden` by default — app.js
|
||||||
|
reveals it only while a file is attached (attachedImage)
|
||||||
|
and clears it with the send (the strip must not linger
|
||||||
|
into the turn). With no pick — and with the images flag
|
||||||
|
off — it is never visible (A5's default-off contract).
|
||||||
|
The thumbnail is decorative (alt="" — the filename is
|
||||||
|
the visible, readable label beside it). -->
|
||||||
|
<div class="attach-preview" id="attach-preview" hidden>
|
||||||
|
<img class="attach-preview-img" alt="">
|
||||||
|
<span class="attach-preview-name"></span>
|
||||||
|
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
<!-- Composer (phase 48, 2026-08-29, TODO.md L3): one button, two
|
||||||
roles — #send-btn reads "Send" when idle and morphs into the
|
roles — #send-btn reads "Send" when idle and morphs into the
|
||||||
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
enabled "Stop" control (.is-stop, rose treatment) while a turn
|
||||||
@@ -284,6 +302,24 @@
|
|||||||
`!text` guard in app.js is the real empty-input check (same
|
`!text` guard in app.js is the real empty-input check (same
|
||||||
precedent as the tuning form's noValidate). -->
|
precedent as the tuning form's noValidate). -->
|
||||||
<form class="composer" id="composer" novalidate>
|
<form class="composer" id="composer" novalidate>
|
||||||
|
<!-- Phase 123 (task 02, TODO L6): the attach control — the
|
||||||
|
paperclip glyph button LEFT of the input (the icon style
|
||||||
|
of the other composer glyphs; aria-label is its
|
||||||
|
accessible name, the SVG is decorative). HIDDEN BY
|
||||||
|
DEFAULT — app.js reveals it only when the boot
|
||||||
|
/api/config says `images: true` (the brand boot's ONE
|
||||||
|
config request — no second round-trip); flag off (the
|
||||||
|
default) keeps the button hidden for good, so the
|
||||||
|
flag-off DOM is byte-identical to pre-phase (A5's
|
||||||
|
default-off contract). The hidden #attach-file input is
|
||||||
|
its backend (a programmatic .click() — the native
|
||||||
|
picker); accept mirrors the server's six-extension gate
|
||||||
|
(the client re-checks the name's extension before any
|
||||||
|
state change; the upload endpoint is the authority). -->
|
||||||
|
<button type="button" class="attach-btn" id="attach-btn" hidden aria-label="Attach an image">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||||
|
</button>
|
||||||
|
<input type="file" id="attach-file" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp" hidden>
|
||||||
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
<label class="visually-hidden" for="message-input">Ask Brain of Reese a question</label>
|
||||||
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
|
<!-- maxlength=4000 mirrors ChatRequest.message max_length=4000
|
||||||
(app/schemas.py) — the server 422s beyond; the #char-count
|
(app/schemas.py) — the server 422s beyond; the #char-count
|
||||||
|
|||||||
@@ -1083,6 +1083,16 @@ _HTTPS_PER_DEAD_ATTEMPT = 3
|
|||||||
#: re-drives the failure sequence from zero.
|
#: re-drives the failure sequence from zero.
|
||||||
_fail_posts: dict[str, int] = {}
|
_fail_posts: dict[str, int] = {}
|
||||||
|
|
||||||
|
#: Phase 123 (task 04 — question images): a small ring buffer of the
|
||||||
|
#: recent ``/v1/chat/completions`` request bodies, exposed on the
|
||||||
|
#: ``/v1/e2e/captured`` pair below. Purely OBSERVATIONAL (the buffer
|
||||||
|
#: never influences an answer — determinism is untouched): it lets a
|
||||||
|
#: story suite assert on the EXACT request the app built — e.g. that a
|
||||||
|
#: question-image turn delivered the multimodal user content list
|
||||||
|
#: (text part + ``image_url`` data URL) to the model.
|
||||||
|
_CAPTURED: list[dict[str, Any]] = []
|
||||||
|
_CAPTURE_MAX = 100
|
||||||
|
|
||||||
|
|
||||||
def _llm_500(why: str) -> JSONResponse:
|
def _llm_500(why: str) -> JSONResponse:
|
||||||
"""A dead-proxy 500 with a JSON error body (phase 67 injection)."""
|
"""A dead-proxy 500 with a JSON error body (phase 67 injection)."""
|
||||||
@@ -2416,6 +2426,23 @@ def compose_thinking_paragraphs(body: dict[str, Any]) -> str:
|
|||||||
return "\n".join(out)
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/e2e/captured")
|
||||||
|
def e2e_captured() -> list[dict[str, Any]]:
|
||||||
|
"""Phase 123 (task 04): the recent chat-completions request bodies
|
||||||
|
(oldest first, capped at ``_CAPTURED_MAX``) — the mock's capture
|
||||||
|
for request-shape assertions (see ``_CAPTURED``)."""
|
||||||
|
return _CAPTURED
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/e2e/captured/reset")
|
||||||
|
def e2e_captured_reset() -> dict[str, int]:
|
||||||
|
"""Phase 123 (task 04): clear the capture so a suite can assert on
|
||||||
|
exactly the requests of the turn it is about to drive (e.g. the
|
||||||
|
toggle-off rejection proves ZERO chat calls — an empty capture)."""
|
||||||
|
_CAPTURED.clear()
|
||||||
|
return {"cleared": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/__shutdown__")
|
@app.post("/__shutdown__")
|
||||||
def shutdown() -> dict[str, Any]:
|
def shutdown() -> dict[str, Any]:
|
||||||
"""Test hook (loading-feedback story): terminate this mock process to
|
"""Test hook (loading-feedback story): terminate this mock process to
|
||||||
@@ -2619,6 +2646,12 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
|
|||||||
|
|
||||||
@app.post("/v1/chat/completions")
|
@app.post("/v1/chat/completions")
|
||||||
def chat_completions(body: dict[str, Any]) -> Any:
|
def chat_completions(body: dict[str, Any]) -> Any:
|
||||||
|
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
|
||||||
|
# recorded before any flow decision, so a 500-injected request is
|
||||||
|
# captured too; the buffer never touches the answer path).
|
||||||
|
_CAPTURED.append(body)
|
||||||
|
if len(_CAPTURED) > _CAPTURE_MAX:
|
||||||
|
del _CAPTURED[: len(_CAPTURED) - _CAPTURE_MAX]
|
||||||
user_lower = _user(body).lower()
|
user_lower = _user(body).lower()
|
||||||
# Phase 37 (agent document tools): the deterministic marker flow.
|
# Phase 37 (agent document tools): the deterministic marker flow.
|
||||||
# The app's chat path is the only streaming consumer of this mock, so
|
# The app's chat path is the only streaming consumer of this mock, so
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
"""Phase 123 E2E (Playwright): chat image questions — attach an image to a
|
||||||
|
question (TODO L6).
|
||||||
|
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov
|
||||||
|
|
||||||
|
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
|
||||||
|
override — the conftest per-suite-app pattern, leak guards included;
|
||||||
|
the question-image store is a suite-private scratch dir so the app
|
||||||
|
under test never writes into the owner's real ``~/bor-sources``), and
|
||||||
|
a SECOND module app runs with ``BOR_IMAGES`` forced ``false`` (the
|
||||||
|
default contract — both apps pin the toggle EXPLICITLY, so an
|
||||||
|
operator's local ``.env`` cannot leak it in either direction).
|
||||||
|
|
||||||
|
The KB is deliberately NOT seeded (each test truncates it): the
|
||||||
|
question-image turn is a DETERMINISTIC deflection (no chunks → LOW
|
||||||
|
→ the mock's honest "I haven't done anything like that" answer) —
|
||||||
|
the deflected branch is a construction site for the multimodal user
|
||||||
|
message (pinned by ``app/api/chat.py``'s docstring), and the answer
|
||||||
|
the mock streams is independent of the image part (the mock's
|
||||||
|
``_content_text`` maps a part list to its text parts).
|
||||||
|
|
||||||
|
* attach a fixture PNG in the composer → the preview strip shows
|
||||||
|
(thumbnail ≤48px + the filename) → remove → the strip clears and
|
||||||
|
the file state is gone (a fresh pick re-renders in place);
|
||||||
|
* re-attach → send → exactly ONE upload, the user bubble shows the
|
||||||
|
image (the live data URL, alt = the filename), the preview strip
|
||||||
|
must not linger into the turn, the mock's (text-only) answer
|
||||||
|
streams, and — via the mock's capture (``/v1/e2e/captured``) —
|
||||||
|
the model RECEIVED the multimodal user content list (the text
|
||||||
|
part == the question + the ``image_url`` data URL that decodes to
|
||||||
|
the uploaded bytes);
|
||||||
|
* the saved record carries the stored PATH (A5: never base64 —
|
||||||
|
nothing base64 crosses the localStorage boundary);
|
||||||
|
* ``page.reload()`` → the user bubble restores WITH its image from
|
||||||
|
the stored path (the image route's request count confirms a PATH
|
||||||
|
fetch, not an inline data URL);
|
||||||
|
* share the chat → a fresh anonymous context opening the shared link
|
||||||
|
sees the user's image on the shared page (the serve route is
|
||||||
|
public — the shared view is faithful);
|
||||||
|
* default-off negative (the flag-off app): ``#attach-btn`` stays
|
||||||
|
hidden for good (the default-off contract) and a direct
|
||||||
|
``POST /api/chat`` with an ``image`` settles the hinted error
|
||||||
|
frame with ZERO model calls (the capture stays empty).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||||||
|
#: the pipeline is content-agnostic; the well-formed bytes keep the
|
||||||
|
#: upload + serve + render + capture pins honest).
|
||||||
|
PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||||
|
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
PNG_NAME = "diagram.png"
|
||||||
|
|
||||||
|
QUESTION = "What is in this screenshot? (chat-images)"
|
||||||
|
TEXT_ONLY_QUESTION = "How is my Kubernetes cluster set up? (chat-images text-only)"
|
||||||
|
STORAGE_KEY = "bor.chat.v1"
|
||||||
|
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||||
|
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
|
||||||
|
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||||
|
TURN_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module apps: images ON (the story) and images forced OFF (the default
|
||||||
|
# contract). Each owns its port + its scratch question-image dir.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
|
||||||
|
"""The conftest app env (leak guards included) with the phase-123
|
||||||
|
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
|
||||||
|
false for the default app — process env ranks above an operator's
|
||||||
|
local gitignored ``.env``, so the contract under test cannot leak
|
||||||
|
in either direction) and the question-image store in the suite's
|
||||||
|
scratch dir (the app under test must not write into the owner's
|
||||||
|
real ``~/bor-sources``)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1"
|
||||||
|
if USE_REAL_LLM
|
||||||
|
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||||
|
)
|
||||||
|
# Mock-calibrated gate (conftest pattern) — with an UNSEEDED KB
|
||||||
|
# (this suite's determinism) every turn is a deflection anyway;
|
||||||
|
# the pins keep the gate's quadrant stable if the dev KB leaks in.
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||||
|
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||||
|
# Phase 67: instant retry waits + the code-default budget.
|
||||||
|
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||||
|
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# Leak guards (conftest pattern).
|
||||||
|
env["BOR_DOCS_REPO"] = ""
|
||||||
|
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
|
||||||
|
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||||
|
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||||
|
# Phase 123: the toggle under test + the suite-private image store.
|
||||||
|
env["BOR_IMAGES"] = "true" if images else "false"
|
||||||
|
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||||
|
"""The story app — ``BOR_IMAGES=true`` (the module env override;
|
||||||
|
the conftest session app is never started in this isolated run,
|
||||||
|
so no port clash)."""
|
||||||
|
scratch = tmp_path_factory.mktemp("bor_chat_image_on")
|
||||||
|
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES", "8160"))
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=_app_env(mock_llm, port, images=True, scratch=scratch),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def default_app_server(
|
||||||
|
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||||
|
) -> Iterator[str]:
|
||||||
|
"""The default-contract app — ``BOR_IMAGES=false`` (only the
|
||||||
|
negative test starts it)."""
|
||||||
|
scratch = tmp_path_factory.mktemp("bor_chat_image_off")
|
||||||
|
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES_OFF", "8161"))
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=_app_env(mock_llm, port, images=False, scratch=scratch),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||||
|
yield f"http://127.0.0.1:{port}"
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def default_app_url(default_app_server: str) -> str:
|
||||||
|
return default_app_server
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_db(mock_port: int, seed: bool) -> None:
|
||||||
|
"""Truncate the KB (and the turn log) so every turn in this suite
|
||||||
|
is a deterministic deflection. ``seed`` is always False here — a
|
||||||
|
question image is a separate concern from document ingestion (it
|
||||||
|
is NEVER indexed as a document), so the suite never imports."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||||
|
db.commit()
|
||||||
|
assert seed is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Browser helpers (the composer's attach flow + the turn's settle)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _png_file(tmp_path: Path) -> Path:
|
||||||
|
png = tmp_path / PNG_NAME
|
||||||
|
png.write_bytes(PNG_1X1)
|
||||||
|
return png
|
||||||
|
|
||||||
|
|
||||||
|
def _attach(page: Page, png: Path) -> None:
|
||||||
|
"""One file pick in the composer's HIDDEN file input (the
|
||||||
|
paperclip button's backend — the native picker is replaced by
|
||||||
|
``set_input_files``, the E2E's standard input simulation): the
|
||||||
|
preview strip must reveal with the data-URL thumbnail + name."""
|
||||||
|
page.set_input_files("#attach-file", str(png))
|
||||||
|
strip = page.locator("#attach-preview")
|
||||||
|
expect(strip).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||||
|
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||||
|
thumb = page.locator("#attach-preview img")
|
||||||
|
src = thumb.get_attribute("src") or ""
|
||||||
|
assert src.startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_deflected_turn(page: Page) -> None:
|
||||||
|
"""Wait until the (unseeded-KB) turn has fully settled — the
|
||||||
|
deflected answer streamed and the Send button is back (the
|
||||||
|
``done`` frame restored it)."""
|
||||||
|
bubble = page.locator(".msg.brain.is-deflected .bubble").first
|
||||||
|
bubble.wait_for(state="visible", timeout=TURN_TIMEOUT_MS)
|
||||||
|
expect(bubble).to_contain_text(
|
||||||
|
re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=TURN_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
expect(page.locator("#send-btn")).to_be_enabled()
|
||||||
|
expect(page.locator("#send-label")).to_have_text("Send")
|
||||||
|
|
||||||
|
|
||||||
|
def _stored(page: Page) -> dict[str, Any] | None:
|
||||||
|
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||||
|
return json.loads(raw) if raw else None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
c["name"]: c["value"]
|
||||||
|
for c in page.context.cookies()
|
||||||
|
if "name" in c and "value" in c
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
|
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()["chats"]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
|
||||||
|
return next((c for c in rows if c["title"] == title), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_title(question: str) -> str:
|
||||||
|
"""The phase-50 auto-title convention: the first question,
|
||||||
|
whitespace-collapsed, capped at 120 chars."""
|
||||||
|
return " ".join(question.split())[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_saved_row(
|
||||||
|
app_url: str,
|
||||||
|
cookies: dict[str, str],
|
||||||
|
title: str,
|
||||||
|
messages: int = 2,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
|
||||||
|
A2 — so there is no status line to wait on)."""
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
last: dict[str, Any] | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
last = _find_row(_chats(app_url, cookies), title)
|
||||||
|
if last is not None and last["message_count"] >= messages:
|
||||||
|
return last
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_row(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||||
|
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||||
|
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_with_attachment(page: Page, png: Path) -> tuple[str, list[str]]:
|
||||||
|
"""Attach → type → send → wait for the deflected settle. Returns
|
||||||
|
(the record's stored image PATH, the upload request URLs)."""
|
||||||
|
uploads: list[str] = []
|
||||||
|
|
||||||
|
def _on_request(req: Any) -> None:
|
||||||
|
if req.method == "POST" and req.url.endswith("/api/chat-images"):
|
||||||
|
uploads.append(req.url)
|
||||||
|
|
||||||
|
page.on("request", _on_request)
|
||||||
|
_attach(page, png)
|
||||||
|
page.fill("#message-input", QUESTION)
|
||||||
|
page.click("#send-btn")
|
||||||
|
img = page.locator(".msg.user .msg-image").first
|
||||||
|
expect(img).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||||
|
_wait_deflected_turn(page)
|
||||||
|
stored = _stored(page)
|
||||||
|
assert stored is not None, "the conversation must be persisted (save point 1)"
|
||||||
|
path = stored["messages"][0]["image"]
|
||||||
|
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
|
||||||
|
return path, uploads
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The mock's capture (phase 123, task 04 — the request the SERVER built)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_base(mock_llm: int) -> str:
|
||||||
|
return f"http://127.0.0.1:{mock_llm}"
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_capture(mock_llm: int) -> None:
|
||||||
|
r = httpx.post(f"{_mock_base(mock_llm)}/v1/e2e/captured/reset", timeout=10)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _captured(mock_llm: int) -> list[dict[str, Any]]:
|
||||||
|
r = httpx.get(f"{_mock_base(mock_llm)}/v1/e2e/captured", timeout=10)
|
||||||
|
assert r.status_code == 200
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Attach → preview → remove (the composer's draft state)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_preview_shows_and_remove_clears(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
|
||||||
|
# Flag on (the story app): the paperclip is revealed at boot, with
|
||||||
|
# its accessible name (the SVG is decorative).
|
||||||
|
btn = page.locator("#attach-btn")
|
||||||
|
expect(btn).to_be_visible()
|
||||||
|
expect(btn).to_have_attribute("aria-label", "Attach an image")
|
||||||
|
|
||||||
|
png = _png_file(tmp_path)
|
||||||
|
_attach(page, png)
|
||||||
|
|
||||||
|
# The strip: the data-URL thumbnail + the filename (the readable
|
||||||
|
# label) + the remove ✕ (its accessible name).
|
||||||
|
expect(page.locator("#attach-preview img")).to_be_visible()
|
||||||
|
remove = page.locator("#attach-remove")
|
||||||
|
expect(remove).to_be_visible()
|
||||||
|
expect(remove).to_have_attribute("aria-label", "Remove the attached image")
|
||||||
|
|
||||||
|
# Remove: the strip clears and the file state is GONE — a fresh
|
||||||
|
# pick re-renders the strip in place (the state was truly reset,
|
||||||
|
# not merely hidden under a stale one).
|
||||||
|
page.click("#attach-remove")
|
||||||
|
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||||
|
_attach(page, png)
|
||||||
|
expect(page.locator("#attach-preview")).to_be_visible()
|
||||||
|
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Send with an attachment: one upload, the image in the bubble, the
|
||||||
|
# multimodal request at the mock, the PATH (never base64) in storage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_with_attached_image_delivers_the_multimodal_request(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
png = _png_file(tmp_path)
|
||||||
|
|
||||||
|
_reset_capture(mock_llm) # exactly the requests of THIS turn
|
||||||
|
path, uploads = _send_with_attachment(page, png)
|
||||||
|
|
||||||
|
# A8's ordering at the wire level: EXACTLY one upload (the double-
|
||||||
|
# fire guard never let a second through) and it preceded the turn.
|
||||||
|
assert len(uploads) == 1
|
||||||
|
|
||||||
|
# The live user bubble: the data URL (no fetch), alt = the
|
||||||
|
# filename; the preview strip must not linger into the turn.
|
||||||
|
img = page.locator(".msg.user .msg-image").first
|
||||||
|
src = img.get_attribute("src") or ""
|
||||||
|
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
|
||||||
|
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
|
||||||
|
assert img.get_attribute("alt") == PNG_NAME
|
||||||
|
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||||
|
|
||||||
|
# The mock (text-only) answer streamed normally (the mock ignores
|
||||||
|
# the image part) — and the REQUEST it received is the multimodal
|
||||||
|
# user content list: text part == the question + the image_url
|
||||||
|
# data URL that decodes to the uploaded bytes (the server built
|
||||||
|
# it from the stored file + the phase-122 mime map).
|
||||||
|
captured = _captured(mock_llm)
|
||||||
|
assert len(captured) == 1, "the deflected turn is exactly one model request"
|
||||||
|
user = captured[0]["messages"][-1]
|
||||||
|
assert user["role"] == "user"
|
||||||
|
assert user["content"] == [
|
||||||
|
{"type": "text", "text": QUESTION},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# A5 at the storage boundary: the record carries the PATH, and
|
||||||
|
# NOTHING base64 crossed into the localStorage payload.
|
||||||
|
stored = _stored(page)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored["messages"][0]["image"] == path
|
||||||
|
assert "base64" not in json.dumps(stored)
|
||||||
|
|
||||||
|
# Cleanup: drop the auto-saved row (the dev DB is shared).
|
||||||
|
cookies = _admin_cookies(page)
|
||||||
|
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||||
|
_delete_row(app_url, cookies, row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Reload: the user bubble restores WITH its image (from the stored
|
||||||
|
# path — the image route's request count confirms the path fetch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reload_restores_the_image_from_the_stored_path(
|
||||||
|
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
png = _png_file(tmp_path)
|
||||||
|
|
||||||
|
path, _uploads = _send_with_attachment(page, png)
|
||||||
|
|
||||||
|
# Track the image route's fetches from here on — the restored
|
||||||
|
# bubble must load the image by FETCHING the stored path (not an
|
||||||
|
# inline data URL).
|
||||||
|
fetches: list[str] = []
|
||||||
|
|
||||||
|
def _on_request(req: Any) -> None:
|
||||||
|
if "/api/chat-images/" in req.url:
|
||||||
|
fetches.append(req.url)
|
||||||
|
|
||||||
|
page.on("request", _on_request)
|
||||||
|
page.reload()
|
||||||
|
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||||
|
|
||||||
|
# The restored user bubble carries the image — its src is the
|
||||||
|
# STORED PATH (the record's key), not the live data URL.
|
||||||
|
img = page.locator(".msg.user .msg-image").first
|
||||||
|
expect(img).to_be_visible(timeout=30_000)
|
||||||
|
assert (img.get_attribute("src") or "") == path, "the restore renders from the stored path"
|
||||||
|
|
||||||
|
# The path fetch must actually fire (the lazy img loading it) —
|
||||||
|
# poll with a deadline. ``wait_for_timeout`` (not ``time.sleep``)
|
||||||
|
# is the tick: the sync API dispatches the ``request`` events
|
||||||
|
# queued during it, and a bare sleep would starve the listener.
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
while not any(u.rstrip("/").endswith(path) for u in fetches):
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise AssertionError(
|
||||||
|
f"no fetch of the stored path ({len(fetches)} image requests: {fetches!r})"
|
||||||
|
)
|
||||||
|
page.wait_for_timeout(100)
|
||||||
|
|
||||||
|
# The rest of the conversation is unchanged.
|
||||||
|
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||||
|
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
|
||||||
|
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||||
|
expect(page.locator(".msg.brain .bubble")).to_contain_text(
|
||||||
|
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cleanup.
|
||||||
|
cookies = _admin_cookies(page)
|
||||||
|
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||||
|
_delete_row(app_url, cookies, row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Share: a fresh anonymous context sees the user's image on the
|
||||||
|
# shared page (the public serve route — the shared view is faithful)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_page_shows_the_user_image(
|
||||||
|
page: Page, browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url, next="/")
|
||||||
|
png = _png_file(tmp_path)
|
||||||
|
|
||||||
|
path, _uploads = _send_with_attachment(page, png)
|
||||||
|
cookies = _admin_cookies(page)
|
||||||
|
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||||
|
|
||||||
|
# The saved row's user record carries the PATH (the share's source
|
||||||
|
# of truth — A5: never base64).
|
||||||
|
detail = httpx.get(f"{app_url}/api/chats/{row['id']}", timeout=10, cookies=cookies)
|
||||||
|
assert detail.status_code == 200
|
||||||
|
saved_user = detail.json()["messages"][0]
|
||||||
|
assert saved_user["image"] == path
|
||||||
|
|
||||||
|
# Share (the chat page's pill — the owner-locked one action):
|
||||||
|
# grant the clipboard so the copy path runs (the status line is
|
||||||
|
# the assertion surface).
|
||||||
|
page.context.grant_permissions(
|
||||||
|
["clipboard-read", "clipboard-write"], origin=app_url
|
||||||
|
)
|
||||||
|
page.locator("#share-chat-btn").click()
|
||||||
|
expect(page.locator("#send-status")).to_have_text("Share link copied.", timeout=15_000)
|
||||||
|
|
||||||
|
row = _find_row(_chats(app_url, cookies), _auto_title(QUESTION))
|
||||||
|
assert row is not None and row.get("share_url")
|
||||||
|
share_url: str = row["share_url"]
|
||||||
|
assert SHARE_URL_RE.fullmatch(share_url)
|
||||||
|
try:
|
||||||
|
# A FRESH context (no cookies, no localStorage): the shared
|
||||||
|
# page renders the user's image (public route, same bubble
|
||||||
|
# treatment — alt = the record's text).
|
||||||
|
anon = browser.new_context()
|
||||||
|
try:
|
||||||
|
anon_page = anon.new_page()
|
||||||
|
anon_page.goto(app_url + share_url)
|
||||||
|
anon_img = anon_page.locator(".msg.user .msg-image").first
|
||||||
|
expect(anon_img).to_be_visible(timeout=30_000)
|
||||||
|
assert (anon_img.get_attribute("src") or "") == path
|
||||||
|
# The answer text is there too (the shared view is the
|
||||||
|
# full conversation, read-only).
|
||||||
|
expect(anon_page.locator(".msg.brain .bubble")).to_contain_text(
|
||||||
|
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
anon.close()
|
||||||
|
finally:
|
||||||
|
_delete_row(app_url, cookies, row["id"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Default-off negative: the control stays hidden; an API request
|
||||||
|
# with an image gets the hinted error frame, with NO model call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _post_chat_sse(
|
||||||
|
app_url: str, cookies: dict[str, str], body: dict[str, Any]
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""``POST /api/chat`` straight from the test process (the hand-
|
||||||
|
crafted request the absent composer control would otherwise make)."""
|
||||||
|
frames: list[dict[str, Any]] = []
|
||||||
|
with httpx.stream(
|
||||||
|
"POST", f"{app_url}/api/chat", json=body, cookies=cookies, timeout=30
|
||||||
|
) as r:
|
||||||
|
assert r.status_code == 200, r.read()
|
||||||
|
buf = ""
|
||||||
|
for part in r.iter_text():
|
||||||
|
buf += part
|
||||||
|
while "\n\n" in buf:
|
||||||
|
frame, buf = buf.split("\n\n", 1)
|
||||||
|
frame = frame.strip()
|
||||||
|
if frame.startswith("data:"):
|
||||||
|
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_flag_off_hides_the_control_and_rejects_the_request(
|
||||||
|
page: Page,
|
||||||
|
default_app_url: str,
|
||||||
|
mock_llm: int,
|
||||||
|
db_ready: None,
|
||||||
|
) -> None:
|
||||||
|
_reset_db(mock_llm, seed=False)
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, default_app_url, next="/")
|
||||||
|
|
||||||
|
# The config says images off — and the control stays hidden for
|
||||||
|
# GOOD (the default-off contract: the static markup ships hidden,
|
||||||
|
# the reveal gate never fires, the rendered DOM is pre-phase).
|
||||||
|
cfg = httpx.get(f"{default_app_url}/api/config", timeout=10).json()
|
||||||
|
assert cfg["images"] is False
|
||||||
|
expect(page.locator("#attach-btn")).to_be_hidden()
|
||||||
|
expect(page.locator("#attach-file")).to_be_hidden()
|
||||||
|
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||||
|
|
||||||
|
# The server-side contract (the API is the authority — a hand-
|
||||||
|
# crafted request with an image): the phase-114 error frame with
|
||||||
|
# the EXACT detail + hint, ONE terminal frame (no ``done``), and
|
||||||
|
# ZERO model calls (the capture stays empty — the embed never ran).
|
||||||
|
_reset_capture(mock_llm)
|
||||||
|
cookies = _admin_cookies(page)
|
||||||
|
frames = _post_chat_sse(
|
||||||
|
default_app_url,
|
||||||
|
cookies,
|
||||||
|
{"message": "What is in this image?", "image": "/api/chat-images/" + "b" * 32 + ".png"},
|
||||||
|
)
|
||||||
|
assert [f["type"] for f in frames] == ["error"]
|
||||||
|
assert frames[0] == {
|
||||||
|
"type": "error",
|
||||||
|
"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.",
|
||||||
|
}
|
||||||
|
assert _captured(mock_llm) == [] # no model call (the rejected turn)
|
||||||
@@ -33,8 +33,13 @@ import re
|
|||||||
|
|
||||||
from playwright.sync_api import ConsoleMessage, Page, expect
|
from playwright.sync_api import ConsoleMessage, Page, expect
|
||||||
|
|
||||||
#: The exact owner-approved policy (phase 82, decision A1).
|
#: The exact owner-approved policy (phase 82, decision A1), with the
|
||||||
CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
#: phase-123 img-src carve-out (the question-image composer's data-URL
|
||||||
|
#: preview + live bubble — ``data:`` is allowed for images ONLY).
|
||||||
|
CSP = (
|
||||||
|
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data:"
|
||||||
|
)
|
||||||
|
|
||||||
#: Chromium reports CSP denials to the console with this phrasing
|
#: Chromium reports CSP denials to the console with this phrasing
|
||||||
#: ("Refused to … because it violates the following Content Security
|
#: ("Refused to … because it violates the following Content Security
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ Requires: podman compose up -d db
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -29,6 +31,7 @@ from sqlalchemy import delete, func, select, text
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api import chat as chat_api
|
from app.api import chat as chat_api
|
||||||
|
from app.api import chat_images
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.main import app as fastapi_app
|
from app.main import app as fastapi_app
|
||||||
from app.models import Chunk, Document, GitSource, QueryLog
|
from app.models import Chunk, Document, GitSource, QueryLog
|
||||||
@@ -2197,3 +2200,310 @@ def test_text_only_grounded_turn_frame_has_no_image_url_key_anywhere(
|
|||||||
]
|
]
|
||||||
for ref in done["sources"] + done["related"]:
|
for ref in done["sources"] + done["related"]:
|
||||||
assert set(ref) == {"source", "path", "title"} # the pre-122 key set
|
assert set(ref) == {"source", "path", "title"} # the pre-122 key set
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 123 (task 01): image questions — the upload → chat flow delivers
|
||||||
|
# the multimodal user message to the model (both construction sites), the
|
||||||
|
# toggle-off / stale frames settle before any model call (no record),
|
||||||
|
# text-only turns stay byte-identical, and the saved/shared chat round-
|
||||||
|
# trips the stored PATH (never base64 — LOCKED A5).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the server
|
||||||
|
#: is content-agnostic (the extension + size gates only), but a well-
|
||||||
|
#: formed fixture keeps the data-URL pin honest.
|
||||||
|
PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def chat_image_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
"""Point the question-image store (the upload/serve pair) AND the
|
||||||
|
turn pipeline at a tmp dir, with the ``images`` toggle ON — the
|
||||||
|
env-calibrated settings (the conftest mock thresholds) plus the
|
||||||
|
phase-123 pair (``images`` + ``chat_image_dir``); the monkeypatch
|
||||||
|
fixture reverts both patches."""
|
||||||
|
store = tmp_path / "chat-images"
|
||||||
|
store.mkdir()
|
||||||
|
settings = Settings(
|
||||||
|
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||||
|
images=True,
|
||||||
|
chat_image_dir=str(store),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chat_images, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_image(client: TestClient, name: str = "screenshot.png") -> str:
|
||||||
|
"""One question-image upload (the composer's step before send) —
|
||||||
|
returns the served path (the value the chat request accepts)."""
|
||||||
|
r = client.post(
|
||||||
|
"/api/chat-images",
|
||||||
|
files={"file": (name, io.BytesIO(PNG_1X1), "image/png")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["path"]
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_chat_with(
|
||||||
|
client: TestClient, body: dict[str, Any]
|
||||||
|
) -> tuple[int, str, list[dict[str, Any]]]:
|
||||||
|
"""``_stream_chat`` with an arbitrary body (the ``image`` key)."""
|
||||||
|
with client.stream("POST", "/api/chat", json=body) as r:
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/event-stream")
|
||||||
|
buf = ""
|
||||||
|
frames: list[dict[str, Any]] = []
|
||||||
|
for part in r.iter_text():
|
||||||
|
buf += part
|
||||||
|
while "\n\n" in buf:
|
||||||
|
frame, buf = buf.split("\n\n", 1)
|
||||||
|
frame = frame.strip()
|
||||||
|
if frame.startswith("data:"):
|
||||||
|
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||||
|
assert buf.strip() == "", "stream must end on a frame boundary"
|
||||||
|
return r.status_code, r.headers["content-type"], frames
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_image_content(question: str) -> list[dict[str, Any]]:
|
||||||
|
"""The multimodal user content list the model must receive for a
|
||||||
|
question that carried the fixture image (the text part + the
|
||||||
|
image_url part — a data URL built server-side from the stored bytes
|
||||||
|
+ the phase-122 mime map)."""
|
||||||
|
return [
|
||||||
|
{"type": "text", "text": question},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_turn_delivers_the_multimodal_user_message(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""The full flow (LOCKED A5): the client uploads FIRST, then the
|
||||||
|
turn request carries the returned path — and the GROUNDED branch
|
||||||
|
(construction site 2: ``run_agent`` builds its own ``[system,
|
||||||
|
*history, user]`` from the value chat.py passes — the pinned flow)
|
||||||
|
delivers the multimodal content list to the model: the text part +
|
||||||
|
the image_url data URL that decodes to the stored bytes."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
path = _upload_image(client)
|
||||||
|
_, _, frames = _stream_chat_with(
|
||||||
|
client, {"message": QUESTION, "image": path}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert frames[-1]["type"] == "done"
|
||||||
|
assert frames[-1]["deflected"] is False
|
||||||
|
assert len(seeded_kb.seen_messages) == 1
|
||||||
|
assert seeded_kb.seen_messages[0][-1] == {
|
||||||
|
"role": "user",
|
||||||
|
"content": _expected_image_content(QUESTION),
|
||||||
|
}
|
||||||
|
# the data URL really is the stored bytes (decode + compare)
|
||||||
|
url = seeded_kb.seen_messages[0][-1]["content"][1]["image_url"]["url"]
|
||||||
|
assert base64.b64decode(url.split(",", 1)[1]) == PNG_1X1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deflected_image_turn_delivers_the_multimodal_user_message(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""Construction site 1 (the deflected branch consumes chat.py's
|
||||||
|
``messages`` list directly): an off-topic question + image still
|
||||||
|
delivers the SAME multimodal list — the LOW prompt path never
|
||||||
|
drops the attachment."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
path = _upload_image(client)
|
||||||
|
_, _, frames = _stream_chat_with(
|
||||||
|
client, {"message": OFF_TOPIC, "image": path}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert frames[-1]["type"] == "done"
|
||||||
|
assert frames[-1]["deflected"] is True
|
||||||
|
assert len(seeded_kb.seen_messages) == 1
|
||||||
|
assert seeded_kb.seen_messages[0][-1] == {
|
||||||
|
"role": "user",
|
||||||
|
"content": _expected_image_content(OFF_TOPIC),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_turn_with_toggle_off_yields_the_hinted_frame_and_calls_nothing(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""The ``BOR_IMAGES`` gate: off with an image set → the phase-114
|
||||||
|
error frame with the EXACT detail + hint (ONE terminal frame — no
|
||||||
|
``done``), and NO model call (zero embeds, zero requests) and NO
|
||||||
|
record (the rejected turn saves nothing — the existing error-path
|
||||||
|
convention)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
chat_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: Settings(
|
||||||
|
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||||
|
images=False,
|
||||||
|
chat_image_dir=str(chat_image_store),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
path = _upload_image(client)
|
||||||
|
_, _, frames = _stream_chat_with(
|
||||||
|
client, {"message": "What is in this image?", "image": path}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert [f["type"] for f in frames] == ["error"]
|
||||||
|
assert frames[0] == {
|
||||||
|
"type": "error",
|
||||||
|
"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.",
|
||||||
|
}
|
||||||
|
# NO model call: the QUESTION embed never ran (``question_embeds``
|
||||||
|
# counts ``embed_one`` calls — the import's batch embeds are a
|
||||||
|
# different counter) and the answer endpoint was never asked.
|
||||||
|
assert seeded_kb.question_embeds == []
|
||||||
|
assert seeded_kb.seen_messages == []
|
||||||
|
assert db.scalars(select(QueryLog)).all() == [] # NO query_log row
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_turn_with_a_missing_stored_file_yields_the_stale_frame(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""The stale-path edge (the file was deleted out-of-band): the SAME
|
||||||
|
frame shape with the "no longer available" detail and NO hint (the
|
||||||
|
banner's default copy is the honest fallback) — again before any
|
||||||
|
model call, no record."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
|
||||||
|
_, _, frames = _stream_chat_with(
|
||||||
|
client, {"message": "What is in this image?", "image": stale}
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert [f["type"] for f in frames] == ["error"]
|
||||||
|
assert frames[0] == {
|
||||||
|
"type": "error",
|
||||||
|
"detail": "That image is no longer available.",
|
||||||
|
"hint": None,
|
||||||
|
}
|
||||||
|
assert seeded_kb.question_embeds == [] # NO model call (see the toggle-off test)
|
||||||
|
assert seeded_kb.seen_messages == []
|
||||||
|
assert db.scalars(select(QueryLog)).all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_only_turn_is_byte_identical_with_images_enabled(
|
||||||
|
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""``image=None`` with the toggle ON: the user message is the PLAIN
|
||||||
|
STRING (not a content list) — the multimodal branch is inert, and
|
||||||
|
the turn behaves exactly as pre-phase (done frame, the question
|
||||||
|
embedded whole)."""
|
||||||
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||||
|
try:
|
||||||
|
_, _, frames = _stream_chat_with(client, {"message": QUESTION})
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
assert frames[-1]["type"] == "done"
|
||||||
|
assert seeded_kb.seen_messages[0][-1] == {"role": "user", "content": QUESTION}
|
||||||
|
assert isinstance(seeded_kb.seen_messages[0][-1]["content"], str)
|
||||||
|
assert seeded_kb.question_embeds == [QUESTION[: 1200]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_chat_round_trips_the_user_image_path(
|
||||||
|
client, db, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""Persistence (LOCKED A5): the user record carries the stored
|
||||||
|
PATH — the stored JSONB and the saved-chat response round-trip it,
|
||||||
|
the brain record carries NO image key, and NOTHING base64 crosses
|
||||||
|
the storage boundary. A text-only record stays without the key
|
||||||
|
(the phase-50 byte-identical contract for text-only chats)."""
|
||||||
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
path = _upload_image(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={
|
||||||
|
"messages": [
|
||||||
|
{"who": "user", "text": "What is in this image?", "image": path},
|
||||||
|
{"who": "brain", "text": "A cat, by the look of it."},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["messages"][0]["image"] == path
|
||||||
|
assert "image" not in body["messages"][1] # brain records never carry it
|
||||||
|
|
||||||
|
# The RAW stored JSONB: the path, never base64 (LOCKED A5)
|
||||||
|
raw = db.execute(
|
||||||
|
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||||
|
{"id": body["id"]},
|
||||||
|
).scalar_one()
|
||||||
|
assert raw[0]["image"] == path
|
||||||
|
assert "base64" not in json.dumps(raw)
|
||||||
|
|
||||||
|
# The admin GET round-trips it losslessly
|
||||||
|
got = client.get(f"/api/chats/{body['id']}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert got.json()["messages"][0]["image"] == path
|
||||||
|
assert "image" not in got.json()["messages"][1]
|
||||||
|
finally:
|
||||||
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_chat_serves_the_user_image_path(
|
||||||
|
client, db, chat_image_store: Path
|
||||||
|
) -> None:
|
||||||
|
"""The shared view is faithful: the public snapshot carries the
|
||||||
|
user's image path (the public ``messages`` shape already includes
|
||||||
|
the optional key), and the image bytes themselves are publicly
|
||||||
|
servable (the image is part of the chat's content — phase 55 A1)."""
|
||||||
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
path = _upload_image(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={
|
||||||
|
"share": True,
|
||||||
|
"messages": [
|
||||||
|
{"who": "user", "text": "What is in this image?", "image": path},
|
||||||
|
{"who": "brain", "text": "A cat, by the look of it."},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
share_url = r.json()["share_url"]
|
||||||
|
token = share_url.rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
anon = TestClient(fastapi_app)
|
||||||
|
got = anon.get(f"/api/shared/{token}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
messages = got.json()["messages"]
|
||||||
|
assert messages[0]["image"] == path
|
||||||
|
assert "image" not in messages[1]
|
||||||
|
|
||||||
|
img = anon.get(path)
|
||||||
|
assert img.status_code == 200
|
||||||
|
assert img.content == PNG_1X1
|
||||||
|
finally:
|
||||||
|
db.execute(text("TRUNCATE saved_chats"))
|
||||||
|
db.commit()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Requires: podman compose up -d db
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -799,6 +800,105 @@ def test_public_read_returns_snapshot_without_private_keys(
|
|||||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 123 (tasks 03/04): the question's attached image — the saved
|
||||||
|
# and shared shape carries the stored PATH (LOCKED A5: never base64),
|
||||||
|
# on the USER record only (the attachment belongs to the question — a
|
||||||
|
# brain record never carries the key). Text-only records stay WITHOUT
|
||||||
|
# the key (absent, never null — the phase-50 byte-identical contract,
|
||||||
|
# pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does
|
||||||
|
# the dropping, so every surface — the stored JSONB, the admin GET,
|
||||||
|
# the public shared read — inherits it).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: A well-formed stored path (the ``POST /api/chat-images`` response's
|
||||||
|
#: shape — the chat-images router names the file
|
||||||
|
#: ``<uuid4().hex>.<ext>``; the API boundary itself only bounds the
|
||||||
|
#: string to 500 chars, the pattern gate is the chat request's).
|
||||||
|
IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png"
|
||||||
|
|
||||||
|
|
||||||
|
def _user_with_image(text: str) -> dict[str, Any]:
|
||||||
|
return {"who": "user", "text": text, "image": IMAGE_PATH}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_round_trips_the_user_image_path(
|
||||||
|
admin_client: TestClient, db: Session
|
||||||
|
) -> None:
|
||||||
|
"""The user record carries the PATH at every boundary: the 201
|
||||||
|
body, the RAW stored JSONB (never base64 — the A5 contract), and
|
||||||
|
the admin GET — and the brain record stays WITHOUT the key.
|
||||||
|
A text-only record in the SAME chat stays key-free too."""
|
||||||
|
r = admin_client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={
|
||||||
|
"messages": [
|
||||||
|
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||||
|
_user_with_image("What is in this image?"),
|
||||||
|
{"who": "brain", "text": "A cat, by the look of it."},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert "image" not in body["messages"][0]
|
||||||
|
assert body["messages"][1]["image"] == IMAGE_PATH
|
||||||
|
assert "image" not in body["messages"][2]
|
||||||
|
|
||||||
|
# The RAW stored JSONB (the DB is the durable boundary — the
|
||||||
|
# served bodies could in principle re-derive it): the path, and
|
||||||
|
# NOTHING base64 anywhere in the payload.
|
||||||
|
raw = db.execute(
|
||||||
|
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||||
|
{"id": body["id"]},
|
||||||
|
).scalar_one()
|
||||||
|
assert "image" not in raw[0]
|
||||||
|
assert raw[1]["image"] == IMAGE_PATH
|
||||||
|
assert "image" not in raw[2]
|
||||||
|
assert "base64" not in json.dumps(raw)
|
||||||
|
|
||||||
|
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
assert "image" not in got.json()["messages"][0]
|
||||||
|
assert got.json()["messages"][1]["image"] == IMAGE_PATH
|
||||||
|
assert "image" not in got.json()["messages"][2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_serve_includes_the_user_image_path(
|
||||||
|
admin_client: TestClient,
|
||||||
|
) -> None:
|
||||||
|
"""The shared view is faithful (task 03's pin): the PUBLIC
|
||||||
|
snapshot's user record carries the image path — the public
|
||||||
|
``messages`` shape already gains the one optional key, so no new
|
||||||
|
shared-shape field exists — and the brain / text-only records
|
||||||
|
stay WITHOUT it. The image bytes themselves ride the public
|
||||||
|
serve route (phase 55 A1 — the image is part of the chat's
|
||||||
|
content, the token is the credential): pinned in
|
||||||
|
``test_chat_api.py`` alongside the upload."""
|
||||||
|
r = admin_client.post(
|
||||||
|
"/api/chats",
|
||||||
|
json={
|
||||||
|
"messages": [
|
||||||
|
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||||
|
_user_with_image("What is in this image?"),
|
||||||
|
{"who": "brain", "text": "A cat, by the look of it."},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
share_url = _share(admin_client, r.json()["id"])["share_url"]
|
||||||
|
|
||||||
|
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||||
|
got = anon.get(f"/api{share_url}")
|
||||||
|
assert got.status_code == 200
|
||||||
|
body = got.json()
|
||||||
|
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||||
|
messages = body["messages"]
|
||||||
|
assert "image" not in messages[0]
|
||||||
|
assert messages[1]["image"] == IMAGE_PATH
|
||||||
|
assert "image" not in messages[2]
|
||||||
|
|
||||||
|
|
||||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||||
admin_client: TestClient,
|
admin_client: TestClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ API JSON, static assets, even the static catch-all's 404s — carries:
|
|||||||
|
|
||||||
* ``Content-Security-Policy`` — the exact A1 string (``default-src 'self';
|
* ``Content-Security-Policy`` — the exact A1 string (``default-src 'self';
|
||||||
base-uri 'none'; frame-ancestors 'none'`` → clickjacking closed, no
|
base-uri 'none'; frame-ancestors 'none'`` → clickjacking closed, no
|
||||||
inline anything because the No-CDN frontend has none);
|
inline anything because the No-CDN frontend has none), extended by
|
||||||
|
the phase-123 ``img-src 'self' data:`` carve-out (the question-image
|
||||||
|
composer's data-URL preview + live bubble — see ``CSP`` in
|
||||||
|
``app/core/security_headers.py``; the ``data:`` allowance is scoped
|
||||||
|
to img-src only);
|
||||||
* ``X-Frame-Options: DENY`` — the legacy no-framing fallback;
|
* ``X-Frame-Options: DENY`` — the legacy no-framing fallback;
|
||||||
* ``X-Content-Type-Options: nosniff`` — the MIME-confusion belt.
|
* ``X-Content-Type-Options: nosniff`` — the MIME-confusion belt.
|
||||||
|
|
||||||
@@ -69,9 +73,7 @@ def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None
|
|||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
_assert_security_headers(response)
|
_assert_security_headers(response)
|
||||||
assert response.headers["content-security-policy"] == (
|
assert response.headers["content-security-policy"] == CSP
|
||||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_health_carries_all_three_headers(client: TestClient) -> None:
|
def test_api_health_carries_all_three_headers(client: TestClient) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -126,10 +126,24 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
|
|||||||
def test_save_points_user_on_send_and_brain_on_done() -> None:
|
def test_save_points_user_on_send_and_brain_on_done() -> None:
|
||||||
"""Save points: the user message is stored the moment it is sent (BEFORE
|
"""Save points: the user message is stored the moment it is sent (BEFORE
|
||||||
the fetch — a failed turn keeps the question); the brain message is
|
the fetch — a failed turn keeps the question); the brain message is
|
||||||
stored on `done` with the done metadata (sources/deflected/suggestions)."""
|
stored on `done` with the done metadata (sources/deflected/suggestions).
|
||||||
|
|
||||||
|
Phase 123 (task 02): the user push is conditional — the record gains
|
||||||
|
the optional `image` key (the STORED path from the upload step,
|
||||||
|
A5: never base64) only when an attachment exists; the text-only
|
||||||
|
branch is the pre-phase object verbatim. The save point (push +
|
||||||
|
save before the turn starts) is the contract."""
|
||||||
js = _js()
|
js = _js()
|
||||||
user_push = js.find('conversation.push({ who: "user", text })')
|
run_turn = js.find("async function runTurn")
|
||||||
assert user_push != -1
|
assert run_turn != -1, "runTurn must exist (the phase-49 extraction)"
|
||||||
|
user_push = js.find("conversation.push(", run_turn)
|
||||||
|
push_block = js[user_push : js.find("saveConversation()", user_push)]
|
||||||
|
assert '{ who: "user", text, image: image.path }' in push_block, (
|
||||||
|
"an attached question stores the image path (A5)"
|
||||||
|
)
|
||||||
|
assert '{ who: "user", text }' in push_block, (
|
||||||
|
"a text-only question keeps the pre-phase record shape"
|
||||||
|
)
|
||||||
assert user_push < js.find('fetch("/api/chat"'), (
|
assert user_push < js.find('fetch("/api/chat"'), (
|
||||||
"the user message must be saved before the turn starts"
|
"the user message must be saved before the turn starts"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -378,15 +378,22 @@ def test_composer_form_is_novalidate() -> None:
|
|||||||
def test_run_turn_is_the_extracted_turn_handler() -> None:
|
def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||||
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
|
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
|
||||||
machinery is extracted from handleSend into
|
machinery is extracted from handleSend into
|
||||||
`runTurn(text, { reask = false })`. handleSend keeps only the
|
`runTurn(text, { reask = false, image = null })` (the `image`
|
||||||
form-level pre-work (the in-flight stop guard, the !text guard, the
|
argument is phase 123 task 02's optional attachment). handleSend
|
||||||
composer pre-work) and delegates; the user append + persistence
|
keeps the form-level pre-work (the in-flight stop guard, the
|
||||||
save point 1 (push + save) sit in runTurn's `!reask` block — the
|
!text guard, the composer pre-work) plus — since phase 123 — the
|
||||||
redo-in-place retry path skips both, because the question is
|
locked-A8 upload step (an attached image uploads BEFORE the
|
||||||
already in the DOM and in `conversation`."""
|
input is cleared; a failed upload blocks the send) and delegates;
|
||||||
|
the user append + persistence save point 1 (push + save) sit in
|
||||||
|
runTurn's `!reask` block — the redo-in-place retry path skips
|
||||||
|
both, because the question is already in the DOM and in
|
||||||
|
`conversation`."""
|
||||||
js = _js()
|
js = _js()
|
||||||
assert re.search(r"async function runTurn\(text, \{ reask = false \} = \{\}\)", js), (
|
assert re.search(
|
||||||
"runTurn(text, { reask = false }) must be the extracted turn handler"
|
r"async function runTurn\(text, \{ reask = false, image = null \} = \{\}\)", js
|
||||||
|
), (
|
||||||
|
"runTurn(text, { reask = false, image = null }) must be the extracted "
|
||||||
|
"turn handler"
|
||||||
)
|
)
|
||||||
handle = js.find("async function handleSend")
|
handle = js.find("async function handleSend")
|
||||||
turn = js.find("async function runTurn")
|
turn = js.find("async function runTurn")
|
||||||
@@ -397,7 +404,12 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
|||||||
assert 'input.value = ""' in handle_body
|
assert 'input.value = ""' in handle_body
|
||||||
assert "autoGrow()" in handle_body
|
assert "autoGrow()" in handle_body
|
||||||
assert "clearErrorBanner()" in handle_body
|
assert "clearErrorBanner()" in handle_body
|
||||||
assert "runTurn(text, { reask: false })" in handle_body, ("handleSend delegates the turn")
|
# Phase 123 (task 02, locked A8): the delegation carries the
|
||||||
|
# upload step's `image` ({ path, src, alt } | null) — null for a
|
||||||
|
# text-only send (the pre-phase shape).
|
||||||
|
assert "runTurn(text, { reask: false, image })" in handle_body, (
|
||||||
|
"handleSend delegates the turn (with the attached image's path)"
|
||||||
|
)
|
||||||
assert 'addMessage("user"' not in handle_body, (
|
assert 'addMessage("user"' not in handle_body, (
|
||||||
"the user append moved with the turn into runTurn"
|
"the user append moved with the turn into runTurn"
|
||||||
)
|
)
|
||||||
@@ -412,8 +424,21 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
|||||||
)
|
)
|
||||||
turn_top = js[turn:wrap_idx]
|
turn_top = js[turn:wrap_idx]
|
||||||
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
|
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
|
||||||
assert 'addMessage("user", renderMarkdown(text), true)' in turn_top
|
# Phase 123 (task 02): the user append + push carry the optional
|
||||||
assert 'conversation.push({ who: "user", text })' in turn_top
|
# attachment — the bubble gets { src, alt } (the data URL live; the
|
||||||
|
# stored path is the fallback) and the record gains the `image`
|
||||||
|
# key (the STORED path — A5: never base64) only when one exists;
|
||||||
|
# a null image keeps the pre-phase shapes verbatim. The strip must
|
||||||
|
# not linger into the turn (cleared after the bubble renders).
|
||||||
|
assert re.search(
|
||||||
|
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', turn_top
|
||||||
|
), "the submit must reveal the user message (scroll intent true)"
|
||||||
|
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in turn_top
|
||||||
|
assert "{ who: \"user\", text, image: image.path }" in turn_top
|
||||||
|
assert "{ who: \"user\", text }" in turn_top
|
||||||
|
assert "clearAttachedImage()" in turn_top, (
|
||||||
|
"the preview strip must not linger into the turn"
|
||||||
|
)
|
||||||
assert "saveConversation()" in turn_top
|
assert "saveConversation()" in turn_top
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,12 +93,16 @@ def test_scroll_helper_is_unconditional() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_add_message_takes_explicit_scroll_intent() -> None:
|
def test_add_message_takes_explicit_scroll_intent() -> None:
|
||||||
"""addMessage(who, html, scroll = false): the phase-18
|
"""addMessage(who, html, scroll = false, image = null): the phase-18
|
||||||
scrollBehavior/force parameters are gone; the bubble scrolls only
|
scrollBehavior/force parameters are gone; the bubble scrolls only
|
||||||
when the caller explicitly asks (submit reveal, restore landing)."""
|
when the caller explicitly asks (submit reveal, restore landing).
|
||||||
|
Phase 123 (task 02) appended the optional `image` argument (the
|
||||||
|
question's attached image — { src, alt } on a user bubble, the ONE
|
||||||
|
renderer for live + restore + shared); the scroll contract is
|
||||||
|
untouched."""
|
||||||
js = _js()
|
js = _js()
|
||||||
body = _fn_body(js, "addMessage")
|
body = _fn_body(js, "addMessage")
|
||||||
assert "function addMessage(who, html, scroll = false)" in body
|
assert "function addMessage(who, html, scroll = false, image = null)" in body
|
||||||
assert "if (scroll) scrollReveal(wrap)" in body
|
assert "if (scroll) scrollReveal(wrap)" in body
|
||||||
assert "force" not in body
|
assert "force" not in body
|
||||||
assert "scrollBehavior" not in body
|
assert "scrollBehavior" not in body
|
||||||
@@ -119,9 +123,13 @@ def test_submit_reveals_user_message() -> None:
|
|||||||
assert turn != -1, "runTurn must exist (phase 49 extraction)"
|
assert turn != -1, "runTurn must exist (phase 49 extraction)"
|
||||||
body = js[turn : js.find("\n}\n", turn)]
|
body = js[turn : js.find("\n}\n", turn)]
|
||||||
assert "if (!reask) {" in body, "the user append is gated on !reask"
|
assert "if (!reask) {" in body, "the user append is gated on !reask"
|
||||||
assert 'addMessage("user", renderMarkdown(text), true)' in body, (
|
# Phase 123 (task 02): the user append gained the optional image
|
||||||
"the submit must reveal the user message (scroll intent true)"
|
# argument (the attached image's { src, alt }) — the call is
|
||||||
)
|
# multi-line now; the contract is the same: user + the raw text +
|
||||||
|
# the explicit scroll intent true.
|
||||||
|
assert re.search(
|
||||||
|
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', body
|
||||||
|
), "the submit must reveal the user message (scroll intent true)"
|
||||||
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
|
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
|
||||||
assert "true" not in call, (
|
assert "true" not in call, (
|
||||||
f"streaming brain bubbles must not scroll the page: {call!r}"
|
f"streaming brain bubbles must not scroll the page: {call!r}"
|
||||||
@@ -168,7 +176,16 @@ def test_restore_landing_is_one_shot() -> None:
|
|||||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
|
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
|
||||||
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
||||||
# Submit reveal + the two restore landings — nothing else scrolls.
|
# Submit reveal + the two restore landings — nothing else scrolls.
|
||||||
assert js.count(", true)") == 3, "only submit + the two restore calls may scroll"
|
# Phase 123 (task 02): the submit call is multi-line (the optional
|
||||||
|
# image argument follows the scroll intent), so it no longer ends
|
||||||
|
# in the single-line ", true)" literal — the two restore calls do;
|
||||||
|
# the submit reveal is counted by its own (multi-line) shape.
|
||||||
|
assert js.count(", true)") == 2, (
|
||||||
|
"only the two restore calls may scroll (single-line shape)"
|
||||||
|
)
|
||||||
|
assert len(re.findall(r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', js)) == 1, (
|
||||||
|
"the submit reveal may scroll (multi-line since phase 123's image argument)"
|
||||||
|
)
|
||||||
# The marker comment documents the one-shot, load-time contract.
|
# The marker comment documents the one-shot, load-time contract.
|
||||||
assert "restore landing" in body
|
assert "restore landing" in body
|
||||||
assert "one-shot" in body
|
assert "one-shot" in body
|
||||||
|
|||||||
@@ -313,9 +313,11 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
|||||||
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
|
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
|
||||||
binds it; the bindings live on the inner elements, the move is pure
|
binds it; the bindings live on the inner elements, the move is pure
|
||||||
HTML/CSS) — holding the `.chat-actions` row, the phase-104
|
HTML/CSS) — holding the `.chat-actions` row, the phase-104
|
||||||
`#char-count` counter, and the `#composer` form, in that order: the
|
`#char-count` counter, the phase-123 `#attach-preview` strip (hidden
|
||||||
row + counter + composer are ONE sticky unit, and the wrapper owns
|
by default — zero height at rest, the sticky geometry untouched),
|
||||||
the shell's bottom slot, so the sticky shift range is still that
|
and the `#composer` form, in that order: the row + counter +
|
||||||
|
preview + composer are ONE sticky unit, and the wrapper owns the
|
||||||
|
shell's bottom slot, so the sticky shift range is still that
|
||||||
column's box (a sibling after it would carve the range away and
|
column's box (a sibling after it would carve the range away and
|
||||||
re-break the pin). The composer form keeps `novalidate` and its
|
re-break the pin). The composer form keeps `novalidate` and its
|
||||||
contract ids."""
|
contract ids."""
|
||||||
@@ -331,11 +333,12 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
|||||||
"elements"
|
"elements"
|
||||||
)
|
)
|
||||||
kids = last["children"]
|
kids = last["children"]
|
||||||
assert len(kids) == 3, (
|
assert len(kids) == 4, (
|
||||||
"the unit holds exactly three element children: .chat-actions, "
|
"the unit holds exactly four element children: .chat-actions, "
|
||||||
"then #char-count (phase 104), then #composer"
|
"then #char-count (phase 104), then #attach-preview (phase 123), "
|
||||||
|
"then #composer"
|
||||||
)
|
)
|
||||||
row, counter, form = kids
|
row, counter, preview, form = kids
|
||||||
assert row["tag"] == "div" and (
|
assert row["tag"] == "div" and (
|
||||||
row["attrs"].get("class") or ""
|
row["attrs"].get("class") or ""
|
||||||
).split() == ["chat-actions"], (
|
).split() == ["chat-actions"], (
|
||||||
@@ -354,6 +357,18 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
|||||||
"the counter ships hidden — it appears only from 80% of the "
|
"the counter ships hidden — it appears only from 80% of the "
|
||||||
"4,000-char cap (app.js updateCharCount)"
|
"4,000-char cap (app.js updateCharCount)"
|
||||||
)
|
)
|
||||||
|
# Phase 123 (task 02, TODO L6): the attach preview strip — a
|
||||||
|
# hidden-by-default div between the counter and the composer (the
|
||||||
|
# selected image above the input row; zero height while hidden, the
|
||||||
|
# pinned-cluster geometry untouched).
|
||||||
|
assert preview["tag"] == "div" and (
|
||||||
|
preview["attrs"].get("id") == "attach-preview"
|
||||||
|
), "the third child is the phase-123 #attach-preview strip"
|
||||||
|
assert (preview["attrs"].get("class") or "") == "attach-preview"
|
||||||
|
assert "hidden" in preview["attrs"], (
|
||||||
|
"the strip ships hidden — it appears only while a file is "
|
||||||
|
"attached (app.js attachedImage)"
|
||||||
|
)
|
||||||
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
|
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
|
||||||
assert "novalidate" in form["attrs"], (
|
assert "novalidate" in form["attrs"], (
|
||||||
"phase 48: the composer form stays `novalidate` (a `required` "
|
"phase 48: the composer form stays `novalidate` (a `required` "
|
||||||
|
|||||||
@@ -696,11 +696,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
|||||||
from the top of the column to the bottom: below the ``#messages``
|
from the top of the column to the bottom: below the ``#messages``
|
||||||
section, directly above the composer; nothing but the row's own
|
section, directly above the composer; nothing but the row's own
|
||||||
comment lands between ``#messages`` and the row, and nothing but the
|
comment lands between ``#messages`` and the row, and nothing but the
|
||||||
phase-104 ``#char-count`` counter + the composer comment lands
|
phase-104 ``#char-count`` counter + the phase-123 attach preview
|
||||||
between the row and the composer (the counter is hidden by default —
|
strip + the composer comment lands between the row and the composer
|
||||||
zero height, the pinned-cluster geometry untouched). No
|
(both hidden by default — zero height at rest, the pinned-cluster
|
||||||
other page carries ``.chat-actions`` (chat-page only, like the
|
geometry untouched). No other page carries ``.chat-actions``
|
||||||
pills)."""
|
(chat-page only, like the pills)."""
|
||||||
html = _index()
|
html = _index()
|
||||||
start = html.find('<div class="chat-actions">')
|
start = html.find('<div class="chat-actions">')
|
||||||
assert start != -1, "index.html must carry the .chat-actions wrapper"
|
assert start != -1, "index.html must carry the .chat-actions wrapper"
|
||||||
@@ -734,8 +734,20 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
|||||||
after = html[end:composer_idx]
|
after = html[end:composer_idx]
|
||||||
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
|
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
|
||||||
# row and the composer is the hidden-by-default question-length
|
# row and the composer is the hidden-by-default question-length
|
||||||
# counter — everything else (ids, buttons, sections, forms) is
|
# counter; phase 123 (task 02, TODO L6) added the second — the
|
||||||
# still excluded from the gap.
|
# attach preview strip (the selected image above the input row:
|
||||||
|
# thumbnail + filename + remove button), also `hidden` by default
|
||||||
|
# (the global [hidden] rule — zero height at rest, the
|
||||||
|
# pinned-cluster geometry untouched). Everything else (ids,
|
||||||
|
# buttons, sections, forms) is still excluded from the gap — the
|
||||||
|
# strip's own element block is stripped (like the counter) so the
|
||||||
|
# exclusions below stay meaningful.
|
||||||
|
strip_start = after.find('<div class="attach-preview"')
|
||||||
|
assert strip_start != -1, (
|
||||||
|
"the phase-123 attach preview strip sits above the composer"
|
||||||
|
)
|
||||||
|
strip_end = after.find("</div>", strip_start) + len("</div>")
|
||||||
|
after = after[:strip_start] + after[strip_end:]
|
||||||
after_minus_counter = after.replace(
|
after_minus_counter = after.replace(
|
||||||
'<p class="char-count" id="char-count" hidden></p>', ""
|
'<p class="char-count" id="char-count" hidden></p>', ""
|
||||||
)
|
)
|
||||||
@@ -746,8 +758,9 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
|||||||
and "<section" not in after_minus_counter
|
and "<section" not in after_minus_counter
|
||||||
and "<form" not in after_minus_counter
|
and "<form" not in after_minus_counter
|
||||||
), (
|
), (
|
||||||
"nothing but the phase-104 counter + the composer comment lands "
|
"nothing but the phase-104 counter + the phase-123 attach "
|
||||||
"between the row and the composer"
|
"preview + the composer comment lands between the row and the "
|
||||||
|
"composer"
|
||||||
)
|
)
|
||||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||||
# chat view is the one and only carrier of the row — pinned above);
|
# chat view is the one and only carrier of the row — pinned above);
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ from app.core.security_headers import CSP, SecurityHeadersMiddleware
|
|||||||
#: The exact expected header set (decision A1 for the CSP, A4 for the
|
#: The exact expected header set (decision A1 for the CSP, A4 for the
|
||||||
#: other two).
|
#: other two).
|
||||||
EXPECTED_HEADERS = {
|
EXPECTED_HEADERS = {
|
||||||
"content-security-policy": "default-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
"content-security-policy": (
|
||||||
|
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data:"
|
||||||
|
),
|
||||||
"x-frame-options": "DENY",
|
"x-frame-options": "DENY",
|
||||||
"x-content-type-options": "nosniff",
|
"x-content-type-options": "nosniff",
|
||||||
}
|
}
|
||||||
@@ -111,10 +114,19 @@ def _assert_security_headers(start: Message, expected_extra: dict[str, str] | No
|
|||||||
|
|
||||||
|
|
||||||
def test_csp_constant_is_the_exact_a1_policy() -> None:
|
def test_csp_constant_is_the_exact_a1_policy() -> None:
|
||||||
"""The owner-approved A1 string, verbatim: same-origin default, no
|
"""The owner-approved A1 string (phase 82), verbatim, with the
|
||||||
base-tag hijack, no framing — no 'unsafe-inline', no report sink."""
|
phase-123 ``img-src`` carve-out (the question-image composer's
|
||||||
assert CSP == "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
data-URL preview + live bubble — see ``security_headers.CSP``):
|
||||||
|
same-origin default, no base-tag hijack, no framing — no
|
||||||
|
'unsafe-inline', no report sink, and the ``data:`` allowance is
|
||||||
|
SCOPED to img-src (never script/style/fetch)."""
|
||||||
|
assert CSP == (
|
||||||
|
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data:"
|
||||||
|
)
|
||||||
assert "unsafe-inline" not in CSP
|
assert "unsafe-inline" not in CSP
|
||||||
|
# the carve-out is img-src ONLY — no other directive gains data:
|
||||||
|
assert CSP.count("data:") == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -158,6 +170,7 @@ def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
|
|||||||
while the other two headers are still added."""
|
while the other two headers are still added."""
|
||||||
themed = (
|
themed = (
|
||||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
|
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
|
||||||
)
|
)
|
||||||
wrapped = SecurityHeadersMiddleware(
|
wrapped = SecurityHeadersMiddleware(
|
||||||
|
|||||||
@@ -101,10 +101,15 @@ def test_persisted_on_leave_flag_is_module_scoped_and_turn_reset() -> None:
|
|||||||
|
|
||||||
# Reset at the top of the turn handler (runTurn — phase 49 extracted
|
# Reset at the top of the turn handler (runTurn — phase 49 extracted
|
||||||
# the turn from handleSend) — before the turn's fetch, where the
|
# the turn from handleSend) — before the turn's fetch, where the
|
||||||
# other turn locals are initialized.
|
# other turn locals are initialized. The pin is the ORDER (resets
|
||||||
|
# before the fetch), not a char window: phase 123 task 02 grew the
|
||||||
|
# save-point-1 region above the resets (the attached image's bubble
|
||||||
|
# + record + strip clear) without moving the resets.
|
||||||
turn = js.find("async function runTurn")
|
turn = js.find("async function runTurn")
|
||||||
assert turn != -1
|
assert turn != -1
|
||||||
top = js[turn : turn + 1500]
|
fetch_idx = js.find('fetch("/api/chat"', turn)
|
||||||
|
assert fetch_idx != -1
|
||||||
|
top = js[turn:fetch_idx]
|
||||||
assert "persistedOnLeave = false;" in top, (
|
assert "persistedOnLeave = false;" in top, (
|
||||||
"persistedOnLeave must be reset per turn, at the top of the turn handler"
|
"persistedOnLeave must be reset per turn, at the top of the turn handler"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user