MEGAINFRA-WEBAPP/src/app/api/chat/attachments/route.ts
DEls 5cf828f4e3 feat(ask): read Excel, CSV, PDF, photos and text attachments
Adds src/lib/agent/attachments.ts (parseAttachment/previewForModel/
summarise for xlsx, xls, csv, pdf — text-layer or scanned, via
pdftotext — image and plain text) with fixtures under
src/lib/agent/__fixtures__/, POST /api/chat/attachments (multipart
upload into an append-only ask_messages row, rasterising a scanned
PDF's first pages so they ride along as vision images the same way a
photographed slip does), and the read_attachment / send_to_po_pipeline
tools in tools-actions.ts. send_to_po_pipeline mirrors
/api/purchase-orders/upload's own hash-dedupe-then-create logic
directly against PocketBase rather than a same-process HTTP call (no
reliable base URL to call itself with from a server-side tool handler).

chat-view.tsx gained a second attach control (FileUp icon) alongside
the existing PO-upload paperclip, wired to the new route; pending
attachment chips ride along on the next message the same way pending
photos already do.

Deviation: send_to_po_pipeline takes { id, site } (the plan's { id }
alone can't resolve which site's purchase_orders row to create,
since /api/purchase-orders/upload always required one).

Live browser check skipped: ask_messages doesn't exist yet in the
target PocketBase (pending the user's --apply of Task 1's schema).
Covered by mocked unit tests and real fixture files instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:25:45 +02:00

104 lines
3.5 KiB
TypeScript

/**
* POST /api/chat/attachments — multipart upload of a file into a thread.
*
* Accepts `file` (required), `threadId` (optional — created if absent,
* same as /api/chat), with `x-ask-user`. Parses the file, stores it as
* its own append-only `ask_messages` row (kind: "attachment"), and
* returns a one-line summary the UI shows as a chip.
*
* A scanned PDF (no text layer) has nothing for `read_attachment` to
* quote, so its first few pages are rasterised here and handed back as
* images — the client queues them as pendingImages the same way a
* photographed slip is queued, so they ride along on the user's next
* message exactly like a camera photo would.
*/
import { resolveActor } from "@/lib/agent/context";
import { createThread, getThreadOwner, appendMessage } from "@/lib/chat/threads";
import { parseAttachment, previewForModel, summarise } from "@/lib/agent/attachments";
import { splitPages } from "@/lib/dn-pipeline/split";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const MAX_BYTES = 25 * 1024 * 1024;
const SCANNED_PAGE_LIMIT = 5;
export async function POST(request: Request) {
const actor = await resolveActor(request);
if (actor instanceof Response) return actor;
let form: FormData;
try {
form = await request.formData();
} catch {
return Response.json({ error: "invalid_form_data" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) {
return Response.json({ error: "missing_file" }, { status: 400 });
}
if (file.size === 0) {
return Response.json({ error: "empty_file" }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return Response.json({ error: "file_too_large", max_bytes: MAX_BYTES }, { status: 400 });
}
let threadId = form.get("threadId") as string | null;
let threadTitle: string | undefined;
if (threadId) {
const owner = await getThreadOwner(threadId);
if (owner === null || owner !== actor.id) {
return Response.json({ error: "Thread not found" }, { status: 404 });
}
} else {
const created = await createThread(actor.id, file.name);
threadId = created.id;
threadTitle = created.title;
}
const bytes = new Uint8Array(await file.arrayBuffer());
let summary: string;
let parseError: string | undefined;
let parsedForRow: unknown;
let images: { data: string; mediaType: "image/png" }[] | undefined;
try {
const parsed = await parseAttachment(bytes, file.name, file.type);
summary = summarise(parsed);
parsedForRow = previewForModel(parsed);
if (parsed.kind === "pdf" && parsed.scanned) {
try {
const pages = await splitPages(Buffer.from(bytes), file.type);
images = pages
.slice(0, SCANNED_PAGE_LIMIT)
.map((p) => ({ data: p.png.toString("base64"), mediaType: "image/png" as const }));
} catch {
// Rasterising is a bonus for a scanned PDF — the upload itself
// still succeeds without it.
}
}
} catch (err) {
parseError = err instanceof Error ? err.message : String(err);
summary = `Could not read ${file.name}`;
}
const attachmentId = await appendMessage(threadId, actor.id, {
kind: "attachment",
text: summary,
file,
...(parsedForRow !== undefined ? { parsed: parsedForRow } : {}),
...(parseError ? { parseError } : {}),
});
return Response.json({
threadId,
...(threadTitle ? { threadTitle } : {}),
attachmentId,
kind: parseError ? "error" : "attachment",
summary,
...(images && images.length > 0 ? { images } : {}),
});
}