Vision mode: PDF pages sent as images to Olava, with tiered caching and upload-time prerender
nwhitehouse builds an eight-commit arc that renders PDF pages to PNG and splices them into the last user message as image content blocks for Qwen3-VL. The biggest lesson buried in the diff: pdfjs + node-canvas produces blank pages on this stack; the fix is `pdftoppm` from poppler-utils.
The initial impl auto-enables vision when any PDF is attached. lib/pdfRender.ts shells out to pdftoppm, lib/visionContext.ts downloads from docStore, renders, and splices base64 PNG blocks into LlmMessage.content. Claude and Gemini adapters flatten image blocks defensively. Olava gets them natively. Hard page cap of 30 (vLLM's --limit-mm-per-prompt).
The blank-image bug discovery (feat-008) is the most important commit. The initial pdfjs + node-canvas approach silently rendered blank PNGs - canvas v3 dropped Path2D, which breaks pdfjs glyph rendering. Every "vision" answer was actually coming from read_document text fallback. Rewriting pdfRender.ts to call pdftoppm fixed it. The 4-up grid compositor (four pages per output image, 2x2 layout) was validated against a 25-page services agreement and a 75-page SEK financing doc, showing ~3x token compression versus 1-up at no fidelity loss on legal-grade factual queries. 8-up was tried and rejected after hallucinations. With pagesPerImage=4 and a 100-image cap, the effective page capacity is 400 pages per request.
feat-009 cuts a 75-page render from 28s to 11.7s by splitting into 4 parallel pdftoppm workers via -f/-l page ranges, each writing to its own subdir to avoid filename collisions. A 5-entry LRU memory cache (~30MB per 75-page doc) and an R2 persistent cache survive restarts.
feat-010 pre-renders at upload time and gates the send button until rendering completes. The attachment chip shows a shimmer overlay while pending. A GET /single-documents/:id/vision-status endpoint returns {status: pending|ready|failed|missing}.
The per-marker citation verifier was disabled by default in bug-005 after it added ~12s to every chat response. It awaited parallel Olava calls (12-17s each, 3-of-4 returning empty) before sending [DONE]. Re-enable with OLAVA_VERIFIER=on.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?