From 8f4afbba4031e801897dbc42a08ccfd1850a11da Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 21 Feb 2026 00:16:33 +0800 Subject: [PATCH] fix: improve canvas text detect stability and qwen retries --- app/api/canvas/text-detect/route.ts | 83 +++++++++++++++++++-- lib/ai/qwen3-vl.ts | 112 ++++++++++++++++------------ 2 files changed, 140 insertions(+), 55 deletions(-) diff --git a/app/api/canvas/text-detect/route.ts b/app/api/canvas/text-detect/route.ts index 1b91668ef..8870a5e54 100644 --- a/app/api/canvas/text-detect/route.ts +++ b/app/api/canvas/text-detect/route.ts @@ -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[] = []; diff --git a/lib/ai/qwen3-vl.ts b/lib/ai/qwen3-vl.ts index 00c1a0e53..e7d77a8be 100644 --- a/lib/ai/qwen3-vl.ts +++ b/lib/ai/qwen3-vl.ts @@ -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; } }