attach a PDF and the agent reads it; the microphone button is gone

Two requests, one file apart.

── The microphone ──

Removed from the chat composer. The HOOK stays: `useAudioRecording` and `blobToWav`
are also the file browser's DictateDialog, which is not what was asked to go.

── PDFs, by not extracting anything ──

A PDF entry sat in the attach menu once with no `onSelect` at all — the menu offered
it and did nothing, because nothing here could extract PDF text. It is back and real,
and it still extracts nothing: the file is uploaded, left on disk, and the agent is
given its PATH. Its own `Read` handles PDFs by page, better than any extraction this
could have shipped, and it can go back for a section later without paying for it
twice.

The alternative was base64 in the message. That is megabytes in the turn, re-sent for
the rest of the conversation, to produce worse text.

── What this uncovered ──

`attachmentIds` has been on this websocket and in `types.ts` for a long time and
NOTHING on the server read it. Images work because they travel separately as base64,
so the ids were dead on the wire — and a PDF is exactly the case that needs them. They
resolve to paths now, appended to the prompt as `Attached files (read from disk)`.

Ids arrive in a websocket message and become a filesystem path, so they are checked
rather than trusted: only the `<uuid>.<ext>` shape uploads actually generate, and only
in the two directories uploads actually write to. Verified — a valid id resolves, an
unknown one yields nothing, and `../../../../etc/passwd` and `; rm -rf /` yield
nothing.

Both upload directories are searched because the first message of a chat carries ids
written to `tmp/` (no session yet) and later ones carry ids under the session.

PDFs may be 25 MB against an image's 5: nothing carries them into a prompt, so the
ceiling is storage rather than context.

tsgo clean, frontend builds, 914 pass / 7 fail unchanged.

`[open]` For a MEMBER the file lands under `data/<email>/attachments/`, and whether
their Linux user can read it depends on the confinement ACLs. Untested — the owner
path is what was verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:08:24 +00:00
co-authored by Claude Opus 5
parent f4f8ea32d1
commit 92bac490d7
9 changed files with 175 additions and 45 deletions
+24 -7
View File
@@ -4,6 +4,8 @@ import { join } from 'node:path';
import { getTmpAttachmentsDir, getAttachmentsDir } from '@@/data-path';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
/** PDFs never enter a prompt whole — the agent reads them from disk — so the ceiling is storage, not context. */
const PDF_MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
export const uploadRouter = createRouter();
@@ -18,12 +20,21 @@ uploadRouter.post('/', async (ctx) => {
return ctx.json({ error: 'file is required' }, 400);
}
if (!file.type.startsWith('image/')) {
return ctx.json({ error: 'Only image files are allowed' }, 400);
// Images and PDFs. The two are handled very differently downstream and it is worth knowing which is
// which here: an image travels to the model as base64 in the message, while a PDF is left on disk and
// the agent is told where it is, because reading a document is something its own tools already do well
// (page ranges, extraction) and a 10 MB base64 blob in a prompt is not.
const isImage = file.type.startsWith('image/');
const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf');
if (!isImage && !isPdf) {
return ctx.json({ error: 'Only images and PDFs are allowed' }, 400);
}
if (file.size > MAX_FILE_SIZE) {
return ctx.json({ error: 'File exceeds 5 MB limit' }, 400);
// A PDF is allowed to be bigger: 5 MB is a generous photo and a small report. The agent reads it from
// disk in page ranges, so size costs storage rather than context.
const limit = isPdf ? PDF_MAX_FILE_SIZE : MAX_FILE_SIZE;
if (file.size > limit) {
return ctx.json({ error: `File exceeds ${Math.round(limit / 1024 / 1024)} MB limit` }, 400);
}
try {
@@ -41,10 +52,16 @@ uploadRouter.post('/', async (ctx) => {
await mkdir(saveDir, { recursive: true });
await Bun.write(join(saveDir, filename), buffer);
const base64 = buffer.toString('base64');
const dataUrl = `data:${file.type};base64,${base64}`;
// Only an image gets a data URL. Building one for a PDF would mean carrying megabytes through the
// browser for a preview nothing renders.
const dataUrl = isImage ? `data:${file.type};base64,${buffer.toString('base64')}` : '';
return ctx.json({ filename: file.name, dataUrl, attachmentId: filename });
return ctx.json({
filename: file.name,
dataUrl,
attachmentId: filename,
kind: isPdf ? 'pdf' : 'image',
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload failed';
return ctx.json({ error: message }, 500);