Skip to content

Commit 3281d30

Browse files
committed
Support PDF uploads via server-side text extraction
Users can now attach PDFs to any model. At LLM-prep time the server extracts the text with unpdf (pure-JS pdf.js, no native deps) and injects it through the existing <document> block path, so PDFs work with non-multimodal models, the Omni router, and any OpenAI-compatible backend. Previously a PDF was accepted, stored, and rendered as an attachment but silently dropped from the model payload. - extractDocumentText.ts: mime-keyed extractor dispatch (docx etc. slot in later); never throws; per-page extraction that stops at the 100k char budget, a 500-page cap, and a 20s timeout so adversarial PDFs can't pin the event loop; scanned/password/corrupt PDFs degrade to an explanatory note instead of failing generation - prepareFiles.ts: unconditional document branch (not gated on multimodal) rendered via formatDocumentBlock with truncation markers and quote-stripped name/type attributes - preprocessMessages.ts: preserve the original filename when downloading files from GridFS (document blocks previously showed the GridFS hash key instead of the real name) - Upload endpoint now validates declared MIME types against the allowlists (415 otherwise); empty types pass since content is sniffed at upload; clipboard pseudo-files and hash re-references unaffected - UI: PDFs accepted by picker/drop/paste/URL-fetch for every model, "Add file" menu, PDF chip for attachments; shared wildcard MIME matcher replaces per-component duplicates - Tests: PDF fixtures plus specs for extraction, injection, and MIME validation (29 new tests) Note: PDFs attached to old conversations were silently ignored before; on retry they will now be extracted and visible to the model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTEP3J7r9pdmAAPyc6NS3P
1 parent 7f07ba7 commit 3281d30

15 files changed

Lines changed: 564 additions & 44 deletions

File tree

docs/research/file-upload-types.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Research: Supporting More Upload File Types (starting with PDF)
22

3-
_Status: research write-up — no implementation yet. Scope agreed: PDF first, with an architecture that makes adding further types (docx, etc.) easy later._
3+
_Status: v1 implemented (server-side text extraction via `unpdf`, Option 1 below). Scope: PDF first, with an architecture that makes adding further types (docx, etc.) easy later._
44

55
## Problem statement
66

src/lib/components/chat/ChatInput.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
import CarbonChevronRight from "~icons/carbon/chevron-right";
1313
import CarbonClose from "~icons/carbon/close";
1414
import UrlFetchModal from "./UrlFetchModal.svelte";
15-
import { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from "$lib/constants/mime";
15+
import {
16+
TEXT_MIME_ALLOWLIST,
17+
DOCUMENT_MIME_ALLOWLIST,
18+
IMAGE_MIME_ALLOWLIST_DEFAULT,
19+
} from "$lib/constants/mime";
1620
import MCPServerManager from "$lib/components/mcp/MCPServerManager.svelte";
1721
import IconMCP from "$lib/components/icons/IconMCP.svelte";
1822
@@ -91,7 +95,7 @@
9195
function openFilePickerText() {
9296
const textAccept =
9397
mimeTypes.filter((m) => !(m === "image/*" || m.startsWith("image/"))).join(",") ||
94-
TEXT_MIME_ALLOWLIST.join(",");
98+
[...TEXT_MIME_ALLOWLIST, ...DOCUMENT_MIME_ALLOWLIST].join(",");
9599
openPickerWithAccept(textAccept);
96100
}
97101
@@ -318,7 +322,7 @@
318322
>
319323
<div class="flex items-center gap-1">
320324
<CarbonDocument class="size-4 opacity-90 dark:opacity-80" />
321-
Add text file
325+
Add file
322326
</div>
323327
<div class="ml-auto flex items-center">
324328
<CarbonChevronRight class="size-4 opacity-70 dark:opacity-80" />

src/lib/components/chat/ChatWindow.svelte

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,13 +382,19 @@
382382
providerOverride && providerOverride !== "auto" && !currentModel.isRouter
383383
);
384384
385-
// Always allow common text-like files; add images only when model is multimodal
386-
import { TEXT_MIME_ALLOWLIST, IMAGE_MIME_ALLOWLIST_DEFAULT } from "$lib/constants/mime";
385+
// Always allow common text-like files and documents (PDF, converted to text server-side);
386+
// add images only when model is multimodal
387+
import {
388+
TEXT_MIME_ALLOWLIST,
389+
DOCUMENT_MIME_ALLOWLIST,
390+
IMAGE_MIME_ALLOWLIST_DEFAULT,
391+
} from "$lib/constants/mime";
387392
388393
let activeMimeTypes = $derived(
389394
Array.from(
390395
new Set([
391396
...TEXT_MIME_ALLOWLIST,
397+
...DOCUMENT_MIME_ALLOWLIST,
392398
...(modelIsMultimodal
393399
? (currentModel.multimodalAcceptedMimetypes ?? [...IMAGE_MIME_ALLOWLIST_DEFAULT])
394400
: []),

src/lib/components/chat/UploadedFile.svelte

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
import CarbonDocumentBlank from "~icons/carbon/document-blank";
66
import CarbonDownload from "~icons/carbon/download";
77
import CarbonDocument from "~icons/carbon/document";
8+
import CarbonDocumentPdf from "~icons/carbon/document-pdf";
89
import Modal from "../Modal.svelte";
910
import AudioPlayer from "../players/AudioPlayer.svelte";
1011
import EosIconsLoading from "~icons/eos-icons/loading";
1112
import { base } from "$app/paths";
12-
import { TEXT_MIME_ALLOWLIST } from "$lib/constants/mime";
13+
import { TEXT_MIME_ALLOWLIST, DOCUMENT_MIME_ALLOWLIST } from "$lib/constants/mime";
14+
import { mimeMatchesAllowlist } from "$lib/utils/mime";
1315
1416
interface Props {
1517
file: MessageFile;
@@ -44,21 +46,10 @@
4446
const isVideo = (mime: string) =>
4547
mime.startsWith("video/") || mime === "mp4" || mime === "x-mpeg";
4648
47-
function matchesAllowed(contentType: string, allowed: readonly string[]): boolean {
48-
const ct = contentType.split(";")[0]?.trim().toLowerCase();
49-
if (!ct) return false;
50-
const [ctType, ctSubtype] = ct.split("/");
51-
for (const a of allowed) {
52-
const [aType, aSubtype] = a.toLowerCase().split("/");
53-
const typeOk = aType === "*" || aType === ctType;
54-
const subOk = aSubtype === "*" || aSubtype === ctSubtype;
55-
if (typeOk && subOk) return true;
56-
}
57-
return false;
58-
}
59-
6049
const isPlainText = (mime: string) =>
61-
mime === "application/vnd.chatui.clipboard" || matchesAllowed(mime, TEXT_MIME_ALLOWLIST);
50+
mime === "application/vnd.chatui.clipboard" || mimeMatchesAllowlist(mime, TEXT_MIME_ALLOWLIST);
51+
52+
const isDocument = (mime: string) => mimeMatchesAllowlist(mime, DOCUMENT_MIME_ALLOWLIST);
6253
6354
let isClickable = $derived(isImage(file.mime) || isPlainText(file.mime));
6455
</script>
@@ -191,6 +182,23 @@
191182
{/if}
192183
</dl>
193184
</div>
185+
{:else if isDocument(file.mime)}
186+
<div
187+
class="flex h-14 w-64 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 2xl:w-72 dark:border-gray-800 dark:bg-gray-900"
188+
class:file-hoverable={isClickable}
189+
>
190+
<div
191+
class="grid size-10 flex-none place-items-center rounded-lg bg-gray-100 dark:bg-gray-800"
192+
>
193+
<CarbonDocumentPdf class="text-base text-gray-700 dark:text-gray-300" />
194+
</div>
195+
<dl class="flex flex-col items-start truncate leading-tight">
196+
<dd class="text-sm">
197+
{truncateMiddle(file.name, 28)}
198+
</dd>
199+
<dt class="text-xs text-gray-400">PDF</dt>
200+
</dl>
201+
</div>
194202
{:else if file.mime === "application/octet-stream"}
195203
<div
196204
class="flex h-14 w-72 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900"

src/lib/server/endpoints/preprocessMessages.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ export async function preprocessMessages(
1616
async function downloadFiles(messages: Message[], convId: ObjectId): Promise<EndpointMessage[]> {
1717
return Promise.all(
1818
messages.map<Promise<EndpointMessage>>((message) =>
19-
Promise.all((message.files ?? []).map((file) => downloadFile(file.value, convId))).then(
20-
(files) => ({ ...message, files })
21-
)
19+
Promise.all(
20+
(message.files ?? []).map(async (file) => {
21+
// Keep the sniffed mime from storage, but restore the original filename —
22+
// the stored filename is the GridFS hash key, not what the user uploaded.
23+
const downloaded = await downloadFile(file.value, convId);
24+
return { ...downloaded, name: file.name || downloaded.name };
25+
})
26+
).then((files) => ({ ...message, files }))
2227
)
2328
);
2429
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { readFile } from "node:fs/promises";
2+
import { describe, expect, it } from "vitest";
3+
import { extractDocumentText, isDocumentMime } from "$lib/server/files/extractDocumentText";
4+
5+
const FIXTURE_TEXT = "Hello chat-ui PDF fixture";
6+
7+
async function loadFixture(name: string): Promise<Uint8Array> {
8+
return new Uint8Array(await readFile(new URL(`./fixtures/${name}`, import.meta.url)));
9+
}
10+
11+
describe("extractDocumentText", () => {
12+
it("extracts text from a valid PDF", async () => {
13+
const data = await loadFixture("sample.pdf");
14+
const result = await extractDocumentText(data, "application/pdf");
15+
16+
expect(result.kind).toBe("text");
17+
if (result.kind !== "text") throw new Error("expected text result");
18+
expect(result.text).toContain(FIXTURE_TEXT);
19+
expect(result.truncated).toBe(false);
20+
expect(result.totalChars).toBeGreaterThan(0);
21+
});
22+
23+
it("returns empty for a PDF page without extractable text", async () => {
24+
const data = await loadFixture("empty-text.pdf");
25+
const result = await extractDocumentText(data, "application/pdf");
26+
27+
expect(result.kind).toBe("empty");
28+
});
29+
30+
it("returns error (and does not throw) for corrupt bytes", async () => {
31+
const data = await loadFixture("corrupt.pdf");
32+
const result = await extractDocumentText(data, "application/pdf");
33+
34+
expect(result.kind).toBe("error");
35+
});
36+
37+
it("truncates extracted text to the char budget", async () => {
38+
const data = await loadFixture("sample.pdf");
39+
const full = await extractDocumentText(data, "application/pdf");
40+
if (full.kind !== "text") throw new Error("expected text result");
41+
42+
const result = await extractDocumentText(data, "application/pdf", { charBudget: 10 });
43+
expect(result.kind).toBe("text");
44+
if (result.kind !== "text") throw new Error("expected text result");
45+
expect(result.truncated).toBe(true);
46+
expect(result.text.length).toBe(10);
47+
expect(result.totalChars).toBe(full.totalChars);
48+
});
49+
50+
it("returns error for unsupported document types", async () => {
51+
const result = await extractDocumentText(new Uint8Array([1, 2, 3]), "application/zip");
52+
expect(result.kind).toBe("error");
53+
});
54+
});
55+
56+
describe("isDocumentMime", () => {
57+
it("accepts application/pdf", () => {
58+
expect(isDocumentMime("application/pdf")).toBe(true);
59+
});
60+
61+
it("is case-insensitive", () => {
62+
expect(isDocumentMime("application/PDF")).toBe(true);
63+
});
64+
65+
it("ignores mime parameters", () => {
66+
expect(isDocumentMime("application/pdf; charset=x")).toBe(true);
67+
});
68+
69+
it("rejects non-document mimes", () => {
70+
expect(isDocumentMime("text/plain")).toBe(false);
71+
});
72+
});
265 Bytes
Binary file not shown.
562 Bytes
Binary file not shown.
600 Bytes
Binary file not shown.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { DOCUMENT_MIME_ALLOWLIST } from "$lib/constants/mime";
2+
import { mimeMatchesAllowlist } from "$lib/utils/mime";
3+
import { logger } from "$lib/server/logger";
4+
5+
// Per-file cap on extracted characters injected into the prompt.
6+
export const DOCUMENT_TEXT_CHAR_BUDGET = 100_000;
7+
8+
export type DocumentExtractionResult =
9+
| { kind: "text"; text: string; truncated: boolean; totalChars: number }
10+
| { kind: "empty" }
11+
| { kind: "error"; reason: string };
12+
13+
export function isDocumentMime(mime: string): boolean {
14+
return mimeMatchesAllowlist(mime, DOCUMENT_MIME_ALLOWLIST);
15+
}
16+
17+
// Bounds applied while parsing, not after: a <=10MB PDF can still be a
18+
// decompression/object bomb, and unpdf runs pdf.js in-process on the event loop.
19+
const MAX_PDF_PAGES = 500;
20+
const EXTRACTION_TIMEOUT_MS = 20_000;
21+
22+
type DocumentExtractor = (
23+
data: Uint8Array,
24+
charBudget: number
25+
) => Promise<{ text: string; pagesTruncated: boolean }>;
26+
27+
const EXTRACTORS: Record<string, DocumentExtractor> = {
28+
"application/pdf": async (data, charBudget) => {
29+
// Dynamic import keeps pdf.js out of the cold-start path.
30+
const { getDocumentProxy } = await import("unpdf");
31+
const pdf = await getDocumentProxy(data);
32+
let timer: ReturnType<typeof setTimeout> | undefined;
33+
try {
34+
const timeout = new Promise<never>((_, reject) => {
35+
timer = setTimeout(() => {
36+
const err = new Error("document extraction timed out");
37+
err.name = "ExtractionTimeout";
38+
reject(err);
39+
}, EXTRACTION_TIMEOUT_MS);
40+
});
41+
const extractPages = async () => {
42+
const pageCount = Math.min(pdf.numPages, MAX_PDF_PAGES);
43+
const pageTexts: string[] = [];
44+
let length = 0;
45+
// Stop as soon as the budget is met so huge documents are never fully parsed.
46+
for (let i = 1; i <= pageCount && length <= charBudget; i++) {
47+
const page = await pdf.getPage(i);
48+
const content = await page.getTextContent();
49+
const pageText = content.items.map((item) => ("str" in item ? item.str : "")).join(" ");
50+
pageTexts.push(pageText);
51+
length += pageText.length;
52+
}
53+
return {
54+
text: pageTexts.join("\n\n"),
55+
pagesTruncated: pdf.numPages > pageTexts.length,
56+
};
57+
};
58+
return await Promise.race([extractPages(), timeout]);
59+
} finally {
60+
clearTimeout(timer);
61+
await pdf.destroy();
62+
}
63+
},
64+
};
65+
66+
export async function extractDocumentText(
67+
data: Uint8Array,
68+
mime: string,
69+
options?: { charBudget?: number }
70+
): Promise<DocumentExtractionResult> {
71+
const normalizedMime = (mime || "").toLowerCase().split(";")[0].trim();
72+
const extractor = EXTRACTORS[normalizedMime];
73+
if (!extractor) {
74+
return { kind: "error", reason: "unsupported document type" };
75+
}
76+
77+
try {
78+
// Copy the input: pdf.js may transfer/detach the underlying ArrayBuffer, and the
79+
// caller's view can be a slice of Node's shared Buffer pool.
80+
const copy = new Uint8Array(data);
81+
const budget = options?.charBudget ?? DOCUMENT_TEXT_CHAR_BUDGET;
82+
const { text, pagesTruncated } = await extractor(copy, budget);
83+
84+
if (text.trim().length === 0) {
85+
return { kind: "empty" };
86+
}
87+
88+
const totalChars = text.length;
89+
if (totalChars > budget || pagesTruncated) {
90+
return { kind: "text", text: text.slice(0, budget), truncated: true, totalChars };
91+
}
92+
return { kind: "text", text, truncated: false, totalChars };
93+
} catch (err) {
94+
if (err instanceof Error && err.name === "PasswordException") {
95+
return { kind: "error", reason: "password-protected" };
96+
}
97+
if (err instanceof Error && err.name === "ExtractionTimeout") {
98+
logger.warn({ mime: normalizedMime }, "[files] document text extraction timed out");
99+
return { kind: "error", reason: "extraction timed out" };
100+
}
101+
logger.warn({ err }, "[files] document text extraction failed");
102+
return { kind: "error", reason: "unreadable or corrupted" };
103+
}
104+
}

0 commit comments

Comments
 (0)