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>
104 lines
3.5 KiB
TypeScript
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 } : {}),
|
|
});
|
|
}
|