|
| 1 | +# Research: Supporting More Upload File Types (starting with PDF) |
| 2 | + |
| 3 | +_Status: research write-up — no implementation yet. Scope agreed: PDF first, with an architecture that makes adding further types (docx, etc.) easy later._ |
| 4 | + |
| 5 | +## Problem statement |
| 6 | + |
| 7 | +Users want to attach documents — most commonly PDFs — to chat messages. Today the app only |
| 8 | +meaningfully supports two categories of attachments: |
| 9 | + |
| 10 | +- **Images** (`image/jpeg`, `image/png`), and only when the current model is multimodal. |
| 11 | +- **Text-like files** (`text/*`, `application/json`, `application/xml`, `application/csv`), |
| 12 | + which are inlined into the prompt. |
| 13 | + |
| 14 | +Worse than being unsupported, a PDF is **silently dropped** today: the upload endpoint accepts |
| 15 | +any MIME type (it only checks size), the file is stored in GridFS and rendered in the UI as an |
| 16 | +attachment, but at LLM-request time `prepareFiles.ts` only has an image branch and a |
| 17 | +text-allowlist branch — `application/pdf` matches neither, so the model never sees the file. |
| 18 | +The user believes the model read their PDF; it did not. |
| 19 | + |
| 20 | +## Current pipeline (as of this research) |
| 21 | + |
| 22 | +### Where each restriction lives |
| 23 | + |
| 24 | +| Restriction | Value | Location | |
| 25 | +| -------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------------- | |
| 26 | +| Text/doc allowlist | `text/*`, `application/json`, `application/xml`, `application/csv` | `src/lib/constants/mime.ts` (`TEXT_MIME_ALLOWLIST`) | |
| 27 | +| Default image allowlist | `image/jpeg`, `image/png` | `src/lib/constants/mime.ts` (`IMAGE_MIME_ALLOWLIST_DEFAULT`) | |
| 28 | +| Per-model image mimes | `["image/*"]` (inferred from router) or model override | `src/lib/server/models.ts` (`multimodalAcceptedMimetypes`) | |
| 29 | +| Active client set | text allowlist ∪ (images if model multimodal) | `src/lib/components/chat/ChatWindow.svelte` (`activeMimeTypes`) | |
| 30 | +| Drag-and-drop enforcement | wildcard MIME match + 10 MB limit | `src/lib/components/chat/FileDropzone.svelte` | |
| 31 | +| Paste enforcement | wildcard MIME match vs `activeMimeTypes` | `src/lib/components/chat/ChatWindow.svelte` (paste handler) | |
| 32 | +| File-input `accept` | `mimeTypes.join(",")` | `src/lib/components/chat/ChatInput.svelte` | |
| 33 | +| Server upload validation | **size only** (10 MB hardcoded, "todo: make configurable") | `src/routes/conversation/[id]/+server.ts` | |
| 34 | +| Server image processing | png/jpeg only, ≤ 1 MB, ≤ 1024 px (via `sharp`) | `src/lib/server/endpoints/openai/endpointOai.ts` | |
| 35 | +| LLM-payload file filtering | `image/*` → image parts; text allowlist → inline; **else dropped** | `src/lib/server/textGeneration/utils/prepareFiles.ts` | |
| 36 | + |
| 37 | +### Flow |
| 38 | + |
| 39 | +1. **Attach** — file picker, drag-and-drop, paste, or URL fetch (`/api/fetch-url`, SSRF-safe |
| 40 | + proxy). All gated client-side against `activeMimeTypes`; there is no server-side MIME check. |
| 41 | +2. **Upload** — files are base64-encoded and POSTed with the message to |
| 42 | + `/conversation/[id]`; `uploadFile.ts` sha256-hashes them, sniffs the real MIME with |
| 43 | + `file-type`, and stores them in a GridFS bucket keyed `{convId}-{sha}`. |
| 44 | +3. **LLM prep** — `prepareMessagesWithFiles()` in |
| 45 | + `src/lib/server/textGeneration/utils/prepareFiles.ts` turns files into OpenAI message |
| 46 | + content: |
| 47 | + - `image/*` files → resized/converted by the image processor, sent as base64 |
| 48 | + `image_url` data-URL parts (multimodal models only); |
| 49 | + - text-allowlist files → decoded to UTF-8 and prepended to the user message as |
| 50 | + `<document name="..." type="...">…</document>` blocks; |
| 51 | + - **anything else is silently ignored.** |
| 52 | +4. **Capabilities & routing** — `models.ts` infers `multimodal` from the HF router's |
| 53 | + `architecture.input_modalities` (only `image`/`vision` are recognized); the Omni router |
| 54 | + (`src/lib/server/router/endpoint.ts`) only treats `image/*` files as a multimodal signal. |
| 55 | + There is **no document/PDF capability flag anywhere** in the model metadata, and the router |
| 56 | + `/models` listing does not advertise one. |
| 57 | +5. **MCP** — uploaded files are exposed to MCP tools via short refs |
| 58 | + (`src/lib/server/textGeneration/mcp/fileRefs.ts`), but only an image ref kind is registered. |
| 59 | + The mechanism is explicitly written to support more kinds later. |
| 60 | + |
| 61 | +There is no document-parsing or text-extraction dependency in `package.json` today. |
| 62 | + |
| 63 | +## Options considered |
| 64 | + |
| 65 | +### Option 1 — Server-side text extraction → `<document>` injection ⭐ recommended |
| 66 | + |
| 67 | +Extract the PDF's text on the server at LLM-prep time and inject it through the **existing** |
| 68 | +`<document>` path that text files already use. |
| 69 | + |
| 70 | +- ✅ Works with **every** model — non-multimodal models, the Omni router, and any |
| 71 | + `OPENAI_BASE_URL` backend — because the model only ever sees plain text. |
| 72 | +- ✅ Smallest diff: one new branch in `prepareFiles.ts`, one allowlist entry, one pure-JS |
| 73 | + dependency. No model-capability changes, no router changes, no message-schema changes. |
| 74 | +- ✅ Cheap in tokens compared to sending page images. |
| 75 | +- ❌ Loses layout, tables-as-structure, and figures (text order can be imperfect). |
| 76 | +- ❌ Scanned/image-only PDFs yield no text (no OCR). Needs graceful degradation. |
| 77 | +- ❌ Long PDFs can blow the context window — needs a per-file character budget with an |
| 78 | + explicit truncation marker. |
| 79 | + |
| 80 | +### Option 2 — Rasterize pages → `image_url` parts |
| 81 | + |
| 82 | +Render each page to an image and reuse the existing image pipeline. |
| 83 | + |
| 84 | +- ✅ Preserves layout exactly; handles scanned documents; leverages vision models' strong |
| 85 | + document understanding. |
| 86 | +- ❌ Multimodal models only (and the router would need to learn that PDFs imply multimodal). |
| 87 | +- ❌ Token-expensive: every page is an image; needs page caps. |
| 88 | +- ❌ PDF rendering in Node needs native dependencies (`node-canvas`, or `sharp` built with |
| 89 | + poppler support — not the default build), which complicates the Docker image and any |
| 90 | + serverless deployment. |
| 91 | + |
| 92 | +### Option 3 — Native pass-through (OpenAI `file` content part) |
| 93 | + |
| 94 | +Send the raw PDF as a `{ type: "file", file: { filename, file_data } }` content part and let |
| 95 | +the provider do extraction/vision itself. |
| 96 | + |
| 97 | +- ✅ Near-zero code; best quality where supported (provider-side layout-aware parsing). |
| 98 | +- ❌ HF router providers do not generally accept file parts today, and the router's |
| 99 | + `input_modalities` gives no signal to detect support — requests would fail unpredictably. |
| 100 | +- ❌ Not portable across arbitrary OpenAI-compatible backends, which chat-ui explicitly |
| 101 | + targets. Would require a hand-maintained per-model opt-in flag. |
| 102 | + |
| 103 | +### Option 4 — Hybrid (Option 1 + image fallback for scanned PDFs) |
| 104 | + |
| 105 | +Extract text by default; if extraction yields (almost) nothing and the model is multimodal, |
| 106 | +fall back to rasterizing pages. |
| 107 | + |
| 108 | +- ✅ Best coverage overall. |
| 109 | +- ❌ Combines the complexity and deployment costs of Options 1 **and** 2. |
| 110 | +- Verdict: sensible **v2**, not v1. Option 1's architecture should leave room for it. |
| 111 | + |
| 112 | +## Library evaluation (for Option 1) |
| 113 | + |
| 114 | +| Library | Verdict | Notes | |
| 115 | +| ------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 116 | +| `unpdf` | ✅ pick | UnJS wrapper around Mozilla pdf.js. Pure JS, **zero native deps**, built for Node/serverless/edge, actively maintained, small API (`extractText`). | |
| 117 | +| `pdf-parse` | ❌ | Most popular but stale; wraps an old pdf.js build and pulls in an optional native `canvas` dependency that breaks serverless builds. | |
| 118 | +| `pdfjs-dist` | ❌ | Mozilla's full renderer — viewer-oriented, heavier API; `unpdf` already wraps it with the ergonomics we want. | |
| 119 | + |
| 120 | +Sources: [unjs/unpdf](https://github.com/unjs/unpdf), |
| 121 | +[unpdf on npm](https://www.npmjs.com/package/unpdf), |
| 122 | +[pdf-parse on npm](https://www.npmjs.com/package/pdf-parse), |
| 123 | +[unpdf vs pdf-parse vs pdf.js comparison (2026)](https://www.pkgpulse.com/blog/unpdf-vs-pdf-parse-vs-pdfjs-dist-pdf-parsing-extraction-nodejs-2026), |
| 124 | +[serverless PDF processing: unpdf vs pdf-parse](https://chudi.dev/blog/serverless-pdf-processing-unpdf-vs-pdfparse). |
| 125 | + |
| 126 | +## Recommendation |
| 127 | + |
| 128 | +**v1: Option 1 (text extraction with `unpdf`), structured so Option 4 can follow.** |
| 129 | + |
| 130 | +### Implementation sketch (v1) |
| 131 | + |
| 132 | +1. **`src/lib/constants/mime.ts`** — add a `DOCUMENT_MIME_ALLOWLIST = ["application/pdf"]` |
| 133 | + (separate from `TEXT_MIME_ALLOWLIST` so document types can grow independently). |
| 134 | +2. **`src/lib/components/chat/ChatWindow.svelte`** — include the document allowlist in |
| 135 | + `activeMimeTypes` for **all** models (extraction makes PDFs model-agnostic). Update the |
| 136 | + `ChatInput.svelte` attach menu copy ("Add file" instead of "Add text file"). |
| 137 | +3. **`src/lib/server/textGeneration/utils/prepareFiles.ts`** — add a document branch: |
| 138 | + extract text with `unpdf`'s `extractText()`, wrap it in the existing |
| 139 | + `<document name="..." type="...">` block. Apply a per-file character budget |
| 140 | + (e.g. ~100k chars, configurable) and append an explicit |
| 141 | + `[truncated: N of M pages included]` marker when exceeded. |
| 142 | + Extraction happens at LLM-prep time — the raw file is already in GridFS, so uploads stay |
| 143 | + fast and no message-schema change is needed. (Extracted-text caching can be added later if |
| 144 | + re-extraction on every turn shows up in latency.) |
| 145 | +4. **Graceful degradation** — if extraction yields ~no text, inject a short note instead |
| 146 | + (`<document …>[This PDF appears to be scanned or contains no extractable text.]</document>`) |
| 147 | + so the model can tell the user rather than hallucinating content. |
| 148 | +5. **`src/routes/conversation/[id]/+server.ts`** — add server-side MIME validation against |
| 149 | + the union of allowlists on upload (closes the existing client-only-validation gap), and |
| 150 | + consider making the 10 MB limit configurable while touching it. |
| 151 | +6. **Tests** — unit tests for the new `prepareFiles.ts` branch (normal PDF, scanned PDF, |
| 152 | + oversized PDF/truncation) using small fixture PDFs; these run in the existing "server |
| 153 | + tests" Vitest workspace. |
| 154 | + |
| 155 | +### Future extensions (explicitly out of v1 scope) |
| 156 | + |
| 157 | +- **Scanned-PDF fallback to page images** for multimodal models (Option 4) — would also need |
| 158 | + the Omni router's multimodal detection (`hasImageInput` in `router/endpoint.ts`) extended. |
| 159 | +- **More document types**: docx via `mammoth`, xlsx via `xlsx`/`exceljs` — each is just a new |
| 160 | + entry in `DOCUMENT_MIME_ALLOWLIST` plus an extractor in the document branch. |
| 161 | +- **MCP document refs**: register a `document` ref kind in |
| 162 | + `src/lib/server/textGeneration/mcp/fileRefs.ts` so MCP tools can receive PDFs. |
| 163 | +- **Native file parts** as a per-model opt-in (`MODELS` override flag) once HF router |
| 164 | + providers advertise document input. |
| 165 | + |
| 166 | +## Open questions |
| 167 | + |
| 168 | +1. **Limits** — is 10 MB the right upload cap for PDFs, and what character/page budget should |
| 169 | + extraction use before truncating? (Both should probably be env-configurable.) |
| 170 | +2. **UX on failure** — is the in-prompt "scanned PDF" note enough, or should the UI surface |
| 171 | + extraction failures to the user directly (e.g. a warning chip on the attachment)? |
| 172 | +3. **HuggingChat vs self-hosted** — should PDF support ship enabled everywhere, or behind a |
| 173 | + config flag initially (per the `publicConfig.isHuggingChat` convention)? |
| 174 | +4. **Re-extraction cost** — extraction runs on every generation turn that includes the file in |
| 175 | + history; do we cache extracted text (e.g. alongside the GridFS file) from day one or wait |
| 176 | + for real latency data? |
0 commit comments