fix: improve canvas text detect stability and qwen retries
This commit is contained in:
parent
225ab8c02f
commit
8f4afbba40
@ -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[] = [];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user