fix: improve canvas text detect stability and qwen retries
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-21 00:16:33 +08:00
parent 225ab8c02f
commit 8f4afbba40
2 changed files with 140 additions and 55 deletions

View File

@ -2,14 +2,18 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import sharp from "sharp";
export const runtime = "nodejs";
export const maxDuration = 60;
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;
interface OcrItem {
id: string;
text: string;
@ -84,6 +88,45 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
}));
}
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();
@ -110,12 +153,38 @@ export async function POST(request: Request) {
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
"如果没有识别到文字,返回 {\"items\":[]}。",
].join("\n");
const { text, model } = await callQwen3Vl({
imageUrl,
prompt,
temperature: 0.1,
maxTokens: 800,
});
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;
}
console.warn("[Canvas Text Detect] URL 拉图超时,切换 data URL 兜底");
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[] = [];

View File

@ -45,68 +45,84 @@ export async function callQwen3Vl(params: {
prompt: string;
temperature?: number;
maxTokens?: number;
timeoutMs?: number;
retries?: number;
}) {
const apiKey = await getQwenImageProviderApiKey();
const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`;
const timeoutMs = Math.max(10000, Math.min(params.timeoutMs ?? 45000, 120000));
const retries = Math.max(0, Math.min(params.retries ?? 1, 2));
let lastError: Error | null = null;
for (const model of QWEN3_VL_MODELS) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const response = await fetch(endpoint, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: params.temperature ?? 0.1,
max_tokens: params.maxTokens ?? 300,
messages: [
{
role: "user",
content: [
{ type: "text", text: params.prompt },
{
type: "image_url",
image_url: {
url: params.imageUrl,
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(endpoint, {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
temperature: params.temperature ?? 0.1,
max_tokens: params.maxTokens ?? 300,
messages: [
{
role: "user",
content: [
{ type: "text", text: params.prompt },
{
type: "image_url",
image_url: {
url: params.imageUrl,
},
},
},
],
},
],
}),
});
],
},
],
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
payload?.error?.message || payload?.message || `HTTP ${response.status}`;
throw new Error(message);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
payload?.error?.message || payload?.message || `HTTP ${response.status}`;
throw new Error(message);
}
const text = parseAssistantText(payload);
if (!text) {
throw new Error("Qwen3-VL 未返回文本内容");
}
return { text, model };
} finally {
clearTimeout(timeout);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const errorText = String(lastError.message || "").toLowerCase();
const isModelNotFound =
/model.*not.*found|invalid model|not support/i.test(errorText);
const isRetriable =
/abort|timeout|timed out|ecconnreset|econnreset|429|rate limit|503/.test(errorText);
if (isModelNotFound) {
break;
}
const text = parseAssistantText(payload);
if (!text) {
throw new Error("Qwen3-VL 未返回文本内容");
if (isRetriable && attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
continue;
}
return { text, model };
} finally {
clearTimeout(timeout);
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const errorText = String(lastError.message || "");
const isModelNotFound =
/model.*not.*found|invalid model|not support/i.test(errorText);
if (!isModelNotFound) {
break;
}
}