import { NextResponse } from "next/server"; import { z } from "zod"; import { and, eq, inArray } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime"; import { db } from "@/lib/db"; import { aiModel } from "@/lib/db/schema"; 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, })); } async function detectTextWithVip98k(imageUrl: string): Promise { const providers = await getVip98kRuntimeProviders(); if (!providers.length) return null; const model = await db.query.aiModel.findFirst({ columns: { providerId: true }, where: and( inArray(aiModel.providerId, providers.map(({ id }) => id)), eq(aiModel.modelId, "ocr"), eq(aiModel.isEnabled, true), ), }); if (!model) return null; const result = await executeConfiguredVip98kTask({ modelId: "ocr", kind: "image-tool", imageUrls: [imageUrl], preferredProviderId: model.providerId, }); const textUrl = result.outputUrls[0]; if (!textUrl) throw new Error("98K 文字识别未返回文本文件"); const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) }); const type = response.headers.get("content-type") || ""; if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) { await response.body?.cancel(); throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`); } return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim()) .filter(Boolean).slice(0, 50).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 }); } const detected = await detectTextWithVip98k(imageUrl); if (detected !== null) { return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } }); } 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 || ""); const items = Array.isArray(parsedJson?.items) ? sanitizeItems(parsedJson.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: "图片文字识别暂不可用,请稍后重试", }, { status: 500 }, ); } }