From ce94241234e33d3a6ad935fe770b45b7d10d0162 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 4 Nov 2026 17:55:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=98=BF=E9=87=8C=E4=BA=91?= =?UTF-8?q?=E5=8F=82=E8=80=83=E5=9B=BE=E4=BC=A0=E8=BE=93=E5=8F=8A=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BB=BB=E5=8A=A1=E9=94=99=E8=AF=AF=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ai/providers/alibaba-image.ts | 25 ++++++++-- lib/request-error.ts | 3 ++ playwright.helpers.config.ts | 1 + tests/routes/alibaba-task-errors.test.ts | 59 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 tests/routes/alibaba-task-errors.test.ts diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index d173d28fa..e76fe5240 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -5,7 +5,7 @@ * - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化 * - qwen-image-edit:仅支持 1 张输出 * - * API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus + * API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference */ import sharp from "sharp"; @@ -46,6 +46,8 @@ interface AlibabaImageResponse { output?: { task_id?: string; task_status?: string; + code?: string; + message?: string; choices?: Array<{ finish_reason: string; message: { @@ -122,6 +124,11 @@ async function fetchImageBufferWithRetry( timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS, attempts = 2 ): Promise<{ buffer: Buffer; contentType: string | null }> { + if (imageUrl.startsWith("data:image/")) { + const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i); + if (!match) throw new Error("输入图片数据格式无效"); + return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; + } let lastError: unknown = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const ctx = createAbortSignal(timeoutMs); @@ -168,7 +175,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st } async function inferAspectRatioFromImageUrl(imageUrl: string): Promise { - if (!/^https?:\/\//i.test(imageUrl)) return null; + if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null; try { const { buffer } = await fetchImageBufferWithRetry(imageUrl); @@ -186,7 +193,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise { - if (!/^https?:\/\//i.test(imageUrl)) return null; + if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null; try { const { buffer } = await fetchImageBufferWithRetry(imageUrl); @@ -302,6 +309,14 @@ async function prepareAlibabaInputImageUrl( maxSide: ALIBABA_MAX_INPUT_SIDE, modelId, }); + if (isAlibabaQwenImage3ModelId(modelId)) { + if (prepared.buffer.length > maxInputBytes) { + throw new Error("输入图片超过模型大小限制,请先压缩图片后重试"); + } + const format = prepared.transformed ? prepared.extension : metadata.format; + const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`; + return `data:${mime};base64,${prepared.buffer.toString("base64")}`; + } if (!prepared.transformed) { return normalizedUrl; } @@ -565,8 +580,10 @@ async function sendAlibabaImageAsyncRequest(params: { return task; } if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) { + const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`; + const code = task.output?.code || task.code; throw new AlibabaImageTerminalTaskError( - task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`, + `${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`, ); } lastPollError = null; diff --git a/lib/request-error.ts b/lib/request-error.ts index ec04a765c..1bd2d9ffe 100644 --- a/lib/request-error.ts +++ b/lib/request-error.ts @@ -246,6 +246,9 @@ export function getRequestFailureDetails( if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) { return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown"); } + if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) { + return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。"); + } if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) { return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。"); } diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index c5ab48c86..139a12601 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ /routes\/replacement-product-identity\.test\.ts/, /routes\/detail-replacement-batch\.test\.ts/, /routes\/alibaba-image-input\.test\.ts/, + /routes\/alibaba-task-errors\.test\.ts/, /routes\/model3d-style-pro-regressions\.test\.ts/, /routes\/clawd-request-lifecycle\.test\.ts/, /routes\/canvas-skill-tag-order\.test\.ts/, diff --git a/tests/routes/alibaba-task-errors.test.ts b/tests/routes/alibaba-task-errors.test.ts new file mode 100644 index 000000000..ed4b909b1 --- /dev/null +++ b/tests/routes/alibaba-task-errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; +import sharp from "sharp"; + +test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => { + const methods: string[] = []; + const loader = serverLoader({ + "@/lib/storage/image-archive": {}, "@/lib/storage/service": {}, + setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms), + fetch: async (_url: string, init: RequestInit) => { + methods.push(init.method || "GET"); + return Response.json(methods.length === 1 + ? { output: { task_id: "original-task", task_status: "PENDING" } } + : { output: { task_id: "original-task", task_status: "FAILED", code: "InvalidParameter", message: "Failed to download image URL after retries." } }); + }, + }); + const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example/api/v1/services/aigc/multimodal-generation/generation" }); + let error: unknown; + try { await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }], imageParams: { aspectRatio: "1:1" } }); } catch (caught) { error = caught; } + expect(String(error)).toContain("Failed to download image URL after retries."); + expect(String(error)).toContain("InvalidParameter"); + expect(String(error)).toContain("original-task"); + expect(methods).toEqual(["POST", "GET"]); + const failure = loader("lib/request-error.ts").getRequestFailureDetails(error); + expect(failure).toMatchObject({ code: "PROVIDER_REFERENCE_FETCH_FAILED", message: "模型服务未能读取参考图。" }); + expect(failure.recommendation).toContain("模型取图线路"); + expect(failure.recommendation).not.toContain("稍后重试"); +}); + +test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto resolution", async () => { + const png = await sharp({ create: { width: 32, height: 64, channels: 3, background: "blue" } }).png().toBuffer(); + const jpeg = await sharp(png).jpeg().toBuffer(); + const webp = await sharp(png).webp().toBuffer(); + const originals = [png, jpeg, webp]; + let submitted: any; + let posts = 0; + const loader = serverLoader({ + "@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } }, + setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms), + fetch: async (url: string, init: RequestInit = {}) => { + if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); } + if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } }); + const index = Number(new URL(url).pathname.slice(1)); + return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } }); + }, + }); + const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" }); + await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [ + { type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })), + ] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete"); + expect(posts).toBe(1); + expect(submitted.parameters.size).toBe("1024*2048"); + const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image); + expect(images).toHaveLength(3); + images.forEach((value: string, index: number) => { + expect(value.split(";", 1)[0]).toBe(["data:image/png", "data:image/jpeg", "data:image/webp"][index]); + expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true); + }); +}); -- 2.47.2