import { z } from "zod"; import { extractNotes, notesToText } from "@/lib/ai"; import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth } from "@/lib/config"; import { friendlyErrorMessage, OllamaError } from "@/lib/ollama"; import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard"; import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; import { acquireModelGate } from "@/lib/model-guard"; import { dataUrlSchema } from "@/lib/schemas"; import { translate } from "@/lib/i18n-dict"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 300; /** Same ceiling as deck generation: up to 16 resized photos. */ const MAX_BODY_BYTES = 33554432; const MAX_IMAGE_CHARS = 8_000_000; const requestSchema = z.object({ images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(), text: z.string().max(120_000).optional(), }); /** * Turns chat attachments (photos, scanned pages, pasted document text) into * plain text with the existing vision pipeline, so any chat model — including * text-only ones — can use them. Never stores anything. */ export async function POST(request: Request) { const locale = requestLocale(request); const crossOrigin = rejectCrossOrigin(request); if (crossOrigin) return crossOrigin; const oversized = rejectOversizedBody(request, MAX_BODY_BYTES); if (oversized) return oversized; const config = getServerConfig(); const limit = rateLimit(`chat:${clientKey(request)}`, config.rateLimits.chat, config.rateLimits.windowMs); if (!limit.allowed) return rateLimitResponse(limit, locale); const auth = resolveAuth(config, request); if ("error" in auth) { return Response.json( { error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) }, { status: 401 }, ); } let payload: unknown; try { payload = await request.json(); } catch { return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 }); } const parsed = requestSchema.safeParse(payload); if (!parsed.success) { return Response.json( { error: translate(locale, "server.invalidRequest", { detail: parsed.error.issues[0]?.message ?? "" }) }, { status: 400 }, ); } const images = (parsed.data.images ?? []).map(stripDataUrlPrefix).filter(Boolean); const text = parsed.data.text?.trim() ?? ""; if (images.length === 0 && !text) { return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 }); } const gate = acquireModelGate(request, config, locale); if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); try { if (images.length === 0) { // Plain text needs no model call at all. return Response.json({ text: text.slice(0, 48_000) }); } const extraction = await extractNotes({ model: config.visionModel, repairModel: config.textModel, auth: auth.auth, images, text: text || undefined, signal: abort.signal, }); const extracted = notesToText(extraction.notes).slice(0, 48_000); if (!extracted.trim()) { return Response.json({ error: translate(locale, "chat.attachEmpty"), kind: "bad_response" }, { status: 422 }); } return Response.json({ text: extracted }); } catch (error) { console.error("[api/chat/extract]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error); return Response.json( { error: friendlyErrorMessage(error, auth.auth.baseUrl, locale), kind: error instanceof OllamaError ? error.kind : "unknown", }, { status: 502 }, ); } finally { clearTimeout(timer); gate.gate.release(); } } function stripDataUrlPrefix(value: string): string { const commaIndex = value.indexOf(","); if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1); return value; }