import { z } from "zod"; import { extractNotes, notesToText } from "@/lib/ai"; import { getServerConfig } from "@/lib/config"; import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server"; 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({ model: z.string().min(1).max(200).optional(), 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); // Ollama (server-configured) or a cloud provider the browser sent a key for. const resolved = resolveModelTarget(config, request, locale); if ("error" in resolved) { return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status }); } const target = resolved.target; const provider = target.kind === "provider" ? target.target : undefined; const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth; 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 }); } // Provider runs carry the model the user picked in Settings → Providers. const providerModel = provider ? parsed.data.model : undefined; if (provider && !providerModel) { return Response.json( { error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" }, { status: 400 }, ); } const textModel = provider ? providerModel! : config.textModel; const visionModel = provider ? providerModel! : config.visionModel; const gate = acquireModelGate(request, config, locale); if ("response" in gate) return gate.response; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), config.timeoutMs); const onRequestAbort = () => abort.abort(); if (request.signal.aborted) abort.abort(); else request.signal.addEventListener("abort", onRequestAbort, { once: true }); 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: visionModel, repairModel: textModel, auth: baseAuth, provider, 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, baseAuth.baseUrl || provider?.baseUrl || '', locale), kind: error instanceof OllamaError ? error.kind : "unknown", }, { status: 502 }, ); } finally { clearTimeout(timer); request.signal.removeEventListener("abort", onRequestAbort); 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; }