222 lines
6.0 KiB
TypeScript
222 lines
6.0 KiB
TypeScript
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 },
|
||
);
|
||
}
|
||
}
|