diff --git a/lib/ai/providers/volcano-vision.ts b/lib/ai/providers/volcano-vision.ts index f7fc4c781..da81fe5e9 100644 --- a/lib/ai/providers/volcano-vision.ts +++ b/lib/ai/providers/volcano-vision.ts @@ -15,6 +15,83 @@ type VolcanoUrlRewriteConfig = { }; let cachedRewriteConfig: VolcanoUrlRewriteConfig | null | undefined; +const VOLCANO_IMAGE_FETCH_TIMEOUT_MS = 20_000; +const VOLCANO_INLINE_MAX_SIDE = 1024; + +function guessImageMimeTypeFromUrl(url: string): string { + const cleanUrl = url.split("?")[0]?.toLowerCase() || ""; + if (cleanUrl.endsWith(".png")) return "image/png"; + if (cleanUrl.endsWith(".webp")) return "image/webp"; + if (cleanUrl.endsWith(".gif")) return "image/gif"; + if (cleanUrl.endsWith(".bmp")) return "image/bmp"; + return "image/jpeg"; +} + +function shouldRetryVolcanoWithInlineImage(errorText: string): boolean { + if (!errorText) return false; + return /timeout while downloading url|download multimodal file timed out|invalidparameter/i.test( + errorText + ); +} + +async function fetchImageAsDataUri(url: string): Promise { + if (!/^https?:\/\//i.test(url)) return null; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), VOLCANO_IMAGE_FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + console.warn("[Volcano Vision] Failed to fetch source image for inline retry:", response.status, url); + return null; + } + + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + if (buffer.length === 0) return null; + + const inputMime = response.headers.get("content-type") || guessImageMimeTypeFromUrl(url); + + try { + const sharp = await import("sharp"); + const optimized = await sharp.default(buffer) + .rotate() + .resize(VOLCANO_INLINE_MAX_SIDE, VOLCANO_INLINE_MAX_SIDE, { + fit: "inside", + withoutEnlargement: true, + }) + .jpeg({ quality: 86, mozjpeg: true }) + .toBuffer(); + return `data:image/jpeg;base64,${optimized.toString("base64")}`; + } catch { + return `data:${inputMime};base64,${buffer.toString("base64")}`; + } + } catch (error) { + console.warn("[Volcano Vision] Inline image fetch failed:", error); + return null; + } finally { + clearTimeout(timeoutId); + } +} + +async function convertImageFieldToInlineDataUri( + image: string | string[] | undefined +): Promise { + if (!image) return image; + const list = Array.isArray(image) ? image : [image]; + const converted = await Promise.all( + list.map(async (item) => { + if (typeof item !== "string") return null; + if (item.startsWith("data:image")) return item; + return fetchImageAsDataUri(item); + }) + ); + + const ready = converted.filter((item): item is string => typeof item === "string" && item.length > 0); + if (ready.length === 0) return undefined; + return Array.isArray(image) ? ready : ready[0]; +} async function getVolcanoUrlRewriteConfig(): Promise { if (cachedRewriteConfig !== undefined) { @@ -426,49 +503,66 @@ async function sendVolcanoRequest( } } - // 设置 10 分钟超时 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 600000); + const executeRequest = async (payload: any, tag: string): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 600000); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`[Volcano Vision] Request failed (${tag}):`, response.status, errorText); + const err = new Error( + `Volcano Vision API error: ${response.status} ${response.statusText} - ${errorText}` + ) as Error & { status?: number; body?: string }; + err.status = response.status; + err.body = errorText; + throw err; + } + + const data = await response.json(); + console.log( + `[Volcano Vision] Response received (${tag}):`, + JSON.stringify(data).substring(0, 200) + "..." + ); + + if (data.error) { + throw new Error(`API Error: ${data.error.message || data.error.code}`); + } + + return data as VolcanoVisionResponse; + } finally { + clearTimeout(timeoutId); + } + }; try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify(requestBody), - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - console.error("[Volcano Vision] Request failed:", response.status, errorText); - throw new Error( - `Volcano Vision API error: ${response.status} ${response.statusText}` - ); - } - - const data = await response.json(); - console.log( - "[Volcano Vision] Response received:", - JSON.stringify(data).substring(0, 200) + "..." - ); - - // 检查是否有错误 - if (data.error) { - throw new Error(`API Error: ${data.error.message || data.error.code}`); - } - - return data as VolcanoVisionResponse; + return await executeRequest(requestBody, "primary"); } catch (error) { - clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { console.error("[Volcano Vision] Request timeout after 10 minutes"); throw new Error("请求超时,图片处理时间过长,请稍后重试"); } + + const errorBody = (error as any)?.body || String(error || ""); + if (requestBody.image && shouldRetryVolcanoWithInlineImage(errorBody)) { + console.warn("[Volcano Vision] Retrying with inline base64 image payload..."); + const inlineImage = await convertImageFieldToInlineDataUri(requestBody.image); + if (inlineImage) { + const retryBody = { ...requestBody, image: inlineImage }; + return await executeRequest(retryBody, "inline-retry"); + } + console.warn("[Volcano Vision] Inline retry skipped: failed to convert source image(s)."); + } throw error; } }