import "server-only"; import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { sql } from "./db"; import { env } from "./env"; import { newId } from "./ids"; export interface AttachmentRow { id: string; conversation_id: string | null; project_id: string | null; message_id: string | null; kind: string; filename: string; mime: string; size_bytes: number; path: string; sha256: string | null; extracted_text: string | null; page_count: number | null; meta: Record; created_at: Date; } const SAFE_MIME = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", "text/plain", "text/markdown", "text/csv", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/json", "audio/mpeg", "audio/mp4", "audio/webm", "audio/wav", "audio/ogg", "audio/x-m4a"]); export function allowedMime(mime: string): boolean { return SAFE_MIME.has(mime) || mime.startsWith("text/"); } export async function saveAttachment(opts: { data: Buffer; filename: string; mime: string; conversationId?: string | null; projectId?: string | null; messageId?: string | null; kind?: string; extractedText?: string | null; pageCount?: number | null; meta?: Record }): Promise { const id = newId("att"); const ext = path.extname(opts.filename).slice(0, 10).replace(/[^.\w]/g, "") || ""; const day = new Date().toISOString().slice(0, 10); const rel = path.join(day, `${id}${ext}`); const abs = path.join(env.UPLOAD_DIR, rel); await fs.mkdir(path.dirname(abs), { recursive: true }); await fs.writeFile(abs, opts.data, { mode: 0o600 }); const sha = createHash("sha256").update(opts.data).digest("hex"); const rows = await sql`INSERT INTO attachments (id, conversation_id, project_id, message_id, kind, filename, mime, size_bytes, path, sha256, extracted_text, page_count, meta) VALUES (${id}, ${opts.conversationId ?? null}, ${opts.projectId ?? null}, ${opts.messageId ?? null}, ${opts.kind ?? "upload"}, ${opts.filename.slice(0, 255)}, ${opts.mime}, ${opts.data.length}, ${rel}, ${sha}, ${opts.extractedText ?? null}, ${opts.pageCount ?? null}, ${sql.json((opts.meta ?? {}) as never)}) RETURNING *`; return rows[0]; } export async function getAttachment(id: string): Promise { const rows = await sql`SELECT * FROM attachments WHERE id = ${id}`; return rows[0] ?? null; } export async function readAttachment(id: string): Promise<{ data: Buffer; mime: string; name: string; text: string | null } | null> { const a = await getAttachment(id); if (!a) return null; const data = await fs.readFile(path.join(env.UPLOAD_DIR, a.path)); return { data, mime: a.mime, name: a.filename, text: a.extracted_text }; } export async function deleteAttachment(id: string) { const a = await getAttachment(id); if (!a) return; await fs.rm(path.join(env.UPLOAD_DIR, a.path), { force: true }); await sql`DELETE FROM attachments WHERE id = ${id}`; } /** Text extraction for documents. Images return null (handled by vision). */ export async function extractText(data: Buffer, mime: string, filename: string): Promise<{ text: string | null; pages?: number }> { try { if (mime === "application/pdf") { const mod = await import("pdf-parse"); const PDFParse = (mod as unknown as { PDFParse?: new (o: { data: Buffer }) => { getText(): Promise<{ text: string; total?: number }> } }).PDFParse; if (PDFParse) { const p = new PDFParse({ data }); const r = await p.getText(); return { text: r.text, pages: r.total }; } const legacy = (mod as unknown as { default: (b: Buffer) => Promise<{ text: string; numpages: number }> }).default; const r = await legacy(data); return { text: r.text, pages: r.numpages }; } if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") { const mammoth = await import("mammoth"); const r = await mammoth.extractRawText({ buffer: data }); return { text: r.value }; } if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || /\.xlsx?$/i.test(filename)) { const XLSX = await import("xlsx"); const wb = XLSX.read(data, { type: "buffer" }); const parts = wb.SheetNames.map(n => `## ${n}\n${XLSX.utils.sheet_to_csv(wb.Sheets[n])}`); return { text: parts.join("\n\n"), pages: wb.SheetNames.length }; } if (mime.startsWith("text/") || mime === "application/json" || /\.(md|txt|csv|json|ya?ml|tsx?|jsx?|py|sql|html?)$/i.test(filename)) return { text: data.toString("utf8") }; } catch (e) { return { text: `[extraction failed: ${(e as Error).message}]` }; } return { text: null }; } /** Split text into ~1 200-char chunks on paragraph boundaries for RAG. */ export function chunkText(text: string, size = 1200, overlap = 150): string[] { const paras = text.split(/\n{2,}/).map(p => p.trim()).filter(Boolean); const chunks: string[] = []; let cur = ""; for (const p of paras) { if ((cur + "\n\n" + p).length > size && cur) { chunks.push(cur); cur = cur.slice(-overlap) + "\n\n" + p; } else cur = cur ? cur + "\n\n" + p : p; while (cur.length > size * 1.5) { chunks.push(cur.slice(0, size)); cur = cur.slice(size - overlap); } } if (cur) chunks.push(cur); return chunks; }