Skip to content

Commit ec4f4b7

Browse files
committed
Add research doc on supporting more upload file types (PDF first)
Maps the current file-upload pipeline end to end, compares four approaches for getting PDF content to models (text extraction, page rasterization, native file parts, hybrid), evaluates extraction libraries, and recommends server-side text extraction via unpdf injected through the existing <document> path, with a phased implementation sketch and open questions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTEP3J7r9pdmAAPyc6NS3P
1 parent ffa7f16 commit ec4f4b7

1 file changed

Lines changed: 176 additions & 0 deletions

File tree

docs/research/file-upload-types.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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

Comments
 (0)