2026-05-26 22:02:17 +08:00

222 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
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 },
);
}
}