import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { createLogger } from "@/lib/observability/logger"; import sharp from "sharp"; export const runtime = "nodejs"; export const maxDuration = 120; const requestSchema = z.object({ imageUrl: z.string().min(1, "图片地址不能为空"), }); const OCR_IMAGE_FETCH_TIMEOUT_MS = 15000; const OCR_IMAGE_MAX_SIDE = 1400; const logger = createLogger("canvas-text-detect"); interface OcrItem { id: string; text: string; confidence?: number; } function parseJsonText(raw: string): any | null { if (!raw) return null; const trimmed = raw.trim(); try { return JSON.parse(trimmed); } catch { // continue } const markdownJsonMatch = trimmed.match(/```json\s*([\s\S]*?)\s*```/i); if (markdownJsonMatch?.[1]) { try { return JSON.parse(markdownJsonMatch[1]); } catch { // continue } } const objectMatch = trimmed.match(/\{[\s\S]*\}/); if (objectMatch?.[0]) { try { return JSON.parse(objectMatch[0]); } catch { // continue } } return null; } function sanitizeItems(items: unknown[]): OcrItem[] { const result: OcrItem[] = []; for (let index = 0; index < items.length; index += 1) { const item = items[index] as Record; const rawText = typeof item?.text === "string" ? item.text : ""; const text = rawText.replace(/\s+/g, " ").trim(); if (!text) continue; const confidenceValue = typeof item?.confidence === "number" && Number.isFinite(item.confidence) ? Math.min(Math.max(item.confidence, 0), 1) : undefined; result.push({ id: String(item?.id || index + 1), text, confidence: confidenceValue, }); } return result.slice(0, 50); } function fallbackItemsFromRaw(raw: string): OcrItem[] { const lines = raw .split("\n") .map((line) => line.replace(/^[-*\d.\s]+/, "").trim()) .filter(Boolean) .slice(0, 20); return lines.map((text, index) => ({ id: String(index + 1), text, })); } function shouldUseDataUrlFallback(error: unknown) { const message = error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase(); return /abort|timeout|timed out|fetch failed|econnreset|etimedout|503|504/.test(message); } async function buildVisionDataUrl(imageUrl: string) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), OCR_IMAGE_FETCH_TIMEOUT_MS); try { const response = await fetch(imageUrl, { method: "GET", signal: controller.signal }); if (!response.ok) { throw new Error(`拉取图片失败: HTTP ${response.status}`); } const sourceBuffer = Buffer.from(await response.arrayBuffer()); const source = sharp(sourceBuffer, { failOn: "none" }).rotate(); const meta = await source.metadata(); const hasAlpha = Boolean(meta.hasAlpha); const resized = source.resize({ width: OCR_IMAGE_MAX_SIDE, height: OCR_IMAGE_MAX_SIDE, fit: "inside", withoutEnlargement: true, }); if (hasAlpha) { const pngBuffer = await resized.png({ compressionLevel: 9 }).toBuffer(); return `data:image/png;base64,${pngBuffer.toString("base64")}`; } const jpegBuffer = await resized.jpeg({ quality: 86, mozjpeg: true }).toBuffer(); return `data:image/jpeg;base64,${jpegBuffer.toString("base64")}`; } finally { clearTimeout(timeout); } } export async function POST(request: Request) { try { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); } const body = await request.json(); const parsed = requestSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ success: false, error: parsed.error.errors[0]?.message || "参数错误" }, { status: 400 }); } const imageUrl = parsed.data.imageUrl.trim(); if (!/^https?:\/\//i.test(imageUrl)) { return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); } logger.debug("model_selected", { model: "qwen3-vl" }); const prompt = [ "你是图片文字识别助手。", "请识别图片中可见文字并按阅读顺序输出 JSON。", "只返回 JSON,不要额外说明。", "格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}", "如果没有识别到文字,返回 {\"items\":[]}。", ].join("\n"); let text = ""; let model = ""; try { const directResult = await callQwen3Vl({ imageUrl, prompt, temperature: 0.1, maxTokens: 600, timeoutMs: 45000, retries: 1, }); text = directResult.text; model = directResult.model; } catch (error) { if (!shouldUseDataUrlFallback(error)) { throw error; } logger.warn("vision_url_fallback", { reason: error instanceof Error ? error.message : "unknown", }); const dataUrl = await buildVisionDataUrl(imageUrl); const fallbackResult = await callQwen3Vl({ imageUrl: dataUrl, prompt, temperature: 0.1, maxTokens: 600, timeoutMs: 60000, retries: 1, }); text = fallbackResult.text; model = `${fallbackResult.model}-dataurl`; } const parsedJson = parseJsonText(text || ""); let items: OcrItem[] = []; if (parsedJson && Array.isArray(parsedJson.items)) { items = sanitizeItems(parsedJson.items); } if (items.length === 0 && text) { items = fallbackItemsFromRaw(text); } return NextResponse.json({ success: true, data: { items, model, }, }); } catch (error) { logger.error("text_detect_failed", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "文字识别失败", }, { status: 500 }, ); } }