import { NextResponse } from "next/server"; import { z } from "zod"; import { generateText } from "ai"; import { auth } from "@/app/(auth)/auth"; import { getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; export const runtime = "nodejs"; export const maxDuration = 60; const requestSchema = z.object({ imageUrl: z.string().min(1, "图片地址不能为空"), }); interface OcrItem { id: string; text: string; confidence?: number; } function pickVisionModel(models: Awaited>) { const normalized = models .filter((item) => item.model.supportsVision) .sort((a, b) => { const aScore = (a.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) + (a.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) + (a.provider.type === "alibaba" ? 20 : 0) + (a.model.isDefault ? 5 : 0); const bScore = (b.model.modelId?.toLowerCase().includes("qwen3-vl") ? 100 : 0) + (b.model.displayName?.toLowerCase().includes("qwen3-vl") ? 100 : 0) + (b.provider.type === "alibaba" ? 20 : 0) + (b.model.isDefault ? 5 : 0); return bScore - aScore; }); return normalized[0]; } 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, })); } 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 models = await getEnabledAiModelsWithProvider(); const selected = pickVisionModel(models); if (!selected) { return NextResponse.json({ success: false, error: "未配置可用的视觉模型" }, { status: 500 }); } const model = await getLanguageModelById(selected.model.id); if (!model) { return NextResponse.json({ success: false, error: "视觉模型加载失败" }, { status: 500 }); } const prompt = [ "你是图片文字识别助手。", "请识别图片中可见文字并按阅读顺序输出 JSON。", "只返回 JSON,不要额外说明。", "格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}", "如果没有识别到文字,返回 {\"items\":[]}。", ].join("\n"); const { text } = await generateText({ model, messages: [ { role: "user", content: [ { type: "text", text: prompt }, { type: "image", image: imageUrl }, ], }, ], temperature: 0.1, }); 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: selected.model.displayName, }, }); } catch (error) { console.error("[Canvas Text Detect] Error:", error); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : "文字识别失败", }, { status: 500 }, ); } }