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 { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 60;
|
export const maxDuration = 120;
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
imageUrl: z.string().min(1, "图片地址不能为空"),
|
imageUrl: z.string().min(1, "图片地址不能为空"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const OCR_IMAGE_FETCH_TIMEOUT_MS = 15000;
|
||||||
|
const OCR_IMAGE_MAX_SIDE = 1400;
|
||||||
|
|
||||||
interface OcrItem {
|
interface OcrItem {
|
||||||
id: string;
|
id: string;
|
||||||
text: 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) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -110,12 +153,38 @@ export async function POST(request: Request) {
|
|||||||
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
|
"格式:{\"items\":[{\"id\":\"1\",\"text\":\"示例\",\"confidence\":0.95}]}",
|
||||||
"如果没有识别到文字,返回 {\"items\":[]}。",
|
"如果没有识别到文字,返回 {\"items\":[]}。",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const { text, model } = await callQwen3Vl({
|
let text = "";
|
||||||
imageUrl,
|
let model = "";
|
||||||
prompt,
|
|
||||||
temperature: 0.1,
|
try {
|
||||||
maxTokens: 800,
|
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 || "");
|
const parsedJson = parseJsonText(text || "");
|
||||||
let items: OcrItem[] = [];
|
let items: OcrItem[] = [];
|
||||||
|
|||||||
@ -45,68 +45,84 @@ export async function callQwen3Vl(params: {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
retries?: number;
|
||||||
}) {
|
}) {
|
||||||
const apiKey = await getQwenImageProviderApiKey();
|
const apiKey = await getQwenImageProviderApiKey();
|
||||||
const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`;
|
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;
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
for (const model of QWEN3_VL_MODELS) {
|
for (const model of QWEN3_VL_MODELS) {
|
||||||
try {
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const controller = new AbortController();
|
||||||
method: "POST",
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
try {
|
||||||
"Content-Type": "application/json",
|
const response = await fetch(endpoint, {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
method: "POST",
|
||||||
},
|
signal: controller.signal,
|
||||||
body: JSON.stringify({
|
headers: {
|
||||||
model,
|
"Content-Type": "application/json",
|
||||||
temperature: params.temperature ?? 0.1,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
max_tokens: params.maxTokens ?? 300,
|
},
|
||||||
messages: [
|
body: JSON.stringify({
|
||||||
{
|
model,
|
||||||
role: "user",
|
temperature: params.temperature ?? 0.1,
|
||||||
content: [
|
max_tokens: params.maxTokens ?? 300,
|
||||||
{ type: "text", text: params.prompt },
|
messages: [
|
||||||
{
|
{
|
||||||
type: "image_url",
|
role: "user",
|
||||||
image_url: {
|
content: [
|
||||||
url: params.imageUrl,
|
{ type: "text", text: params.prompt },
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: params.imageUrl,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
],
|
}),
|
||||||
}),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message =
|
const message =
|
||||||
payload?.error?.message || payload?.message || `HTTP ${response.status}`;
|
payload?.error?.message || payload?.message || `HTTP ${response.status}`;
|
||||||
throw new Error(message);
|
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 (isRetriable && attempt < retries) {
|
||||||
if (!text) {
|
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
|
||||||
throw new Error("Qwen3-VL 未返回文本内容");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user