复用画布识图原图并区分提交前后超时 #60

Merged
WuYongJie merged 1 commits from codex/canvas-reference-read-timeout into main 2026-11-09 14:13:34 +08:00
8 changed files with 144 additions and 15 deletions
Showing only changes of commit e8e8edcefe - Show all commits

View File

@ -173,6 +173,8 @@ export async function buildCanvasRuntimeTools(params: {
};
},
);
const inspectedImages = new Map<string, Buffer>();
let inspectedImageBytes = 0;
register(
"analyzeCanvasImages",
"检查图片",
@ -181,7 +183,23 @@ export async function buildCanvasRuntimeTools(params: {
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
question: z.string().min(1).max(2000),
}),
async (input) => analyzeRuntimeImages({ ...params, ...input }),
async (input) => analyzeRuntimeImages({
...params,
...input,
reviewPorts: {
readImage: async (url) => {
const cached = inspectedImages.get(url);
if (cached) return cached;
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
const original = await fetchImageBufferFromUrl(url);
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
inspectedImages.set(url, original);
inspectedImageBytes += original.length;
}
return original;
},
},
}),
);
let submittedCount = 0;
@ -266,6 +284,7 @@ export async function buildCanvasRuntimeTools(params: {
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!),
referenceImageBuffers: inspectedImages,
debugLabel: "canvas-agent-runtime",
debugMeta: { runId: params.runId, callId },
});

View File

@ -12,6 +12,7 @@ import sharp from "sharp";
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
@ -165,7 +166,7 @@ async function fetchImageBufferWithRetry(
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
throw lastError ?? new Error("下载图片失败");
}
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
@ -289,12 +290,20 @@ async function prepareAlibabaInputImageUrl(
imageUrl: string,
modelId: string,
inputRole?: AlibabaImageInputRole,
original?: Buffer,
): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
const { buffer, contentType } = original
? { buffer: original, contentType: null }
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
if (["AbortError", "TimeoutError"].includes(error?.name)) {
throw new ImageReferenceReadTimeoutError();
}
throw error;
});
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer, { failOn: "none" }).metadata();
@ -561,7 +570,7 @@ async function sendAlibabaImageAsyncRequest(params: {
url: params.requestURL,
});
const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id");
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now();
@ -585,7 +594,7 @@ async function sendAlibabaImageAsyncRequest(params: {
console.log("[Alibaba Image] Async task completed:", taskId);
return task;
}
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
if (["FAILED", "CANCELED"].includes(taskStatus)) {
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
const code = task.output?.code || task.code;
throw new AlibabaImageTerminalTaskError(
@ -601,7 +610,7 @@ async function sendAlibabaImageAsyncRequest(params: {
error.status < 500 &&
![408, 425, 429].includes(error.status)
) {
throw error;
throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error });
}
lastPollError = error;
console.warn("[Alibaba Image] Async task poll failed; retrying:", {
@ -723,6 +732,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
overrideModelId?: string; // 🆕 备用模型 ID
}) => {
@ -798,6 +808,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
imageUrl,
activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
),
),
);
@ -938,7 +949,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
);
// 处理响应
const images = await processResponse(response);
const images = await processResponse(response).catch((error) => {
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -8,6 +8,12 @@ export class AgentImageGenerationError extends Error {
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
}
export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
constructor() {
super("提交生图前读取参考图超时,任务尚未提交,可以稍后重试。", true);
}
}
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
const rejected = error instanceof ImageSubmissionRejectedError;

View File

@ -5,7 +5,7 @@ import sharp from "sharp";
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
@ -181,6 +181,7 @@ export interface CanvasGenerateImageRequest {
providerImageParams?: Record<string, unknown>;
referenceImage?: string;
referenceImages?: string[];
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
billingFeature?: "multi-view" | "repaint";
requiredReferenceImageCount?: number;
ownerKey?: string;
@ -5121,8 +5122,11 @@ export async function generateCanvasImage(
});
}
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
const referenceImageBuffers = provider.type === "alibaba-image" ? request.referenceImageBuffers : undefined;
const referencesToCheck = normalizedReferenceImages.filter(url => !referenceImageBuffers?.has(url));
const { kept, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(referencesToCheck, { timeoutMs: 3000 });
const reachableReferenceImages = normalizedReferenceImages.filter(url => referenceImageBuffers?.has(url) || kept.includes(url));
const effectiveReferenceImages = selectCanvasReferenceImages({
requested: normalizedReferenceImages, reachable: reachableReferenceImages,
requiredCount: request.requiredReferenceImageCount,
@ -5233,6 +5237,7 @@ export async function generateCanvasImage(
executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({
messages,
referenceImageBuffers,
imageParams: {
...imageParams,
imageCount: batchImageCount,
@ -5662,6 +5667,11 @@ export async function generateCanvasImage(
await slot.release();
}
if (caughtError instanceof ImageReferenceReadTimeoutError) {
if (!collectedImages.length) throw caughtError;
return { images: collectedImages, model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt };
}
// An accepted or uncertain request must not start another paid model/variant.
if (caughtError && (
caughtError instanceof ProviderTaskUncertainError ||

View File

@ -293,6 +293,9 @@ export function getRequestFailureDetails(
if (/AOS结果下载超时/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (/提交生图前读取参考图超时/.test(text)) {
return detail("IMAGE_REFERENCE_READ_TIMEOUT", "提交生图前读取参考图超时,任务尚未提交。", "可以稍后重试。");
}
const errorName = isRecord(error) ? String(error.name || "") : "";
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");

View File

@ -32,6 +32,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
const jpeg = await sharp(png).jpeg().toBuffer();
const webp = await sharp(png).webp().toBuffer();
const originals = [png, jpeg, webp];
const downloads: number[] = [];
let submitted: any;
let posts = 0;
const loader = serverLoader({
@ -41,14 +42,16 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
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));
downloads.push(index);
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");
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" }, referenceImageBuffers: new Map([["https://source.example/0", png]]) })).rejects.toThrow("transport-inspection-complete");
expect(posts).toBe(1);
expect(downloads).toEqual([1, 2]);
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);
@ -57,3 +60,43 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
});
});
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
throw new DOMException("This operation was aborted", "AbortError");
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, { type: "image", image: "https://source.example/slow.png" },
] }] }).catch((error: unknown) => error);
expect(methods).toEqual(["GET", "GET"]);
expect(error).toMatchObject({ confirmedNoResult: true });
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({
code: "IMAGE_REFERENCE_READ_TIMEOUT", outcome: "failed", recommendation: "可以稍后重试。",
});
});
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", 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 init.method === "POST"
? Response.json({ output: { task_id: "accepted-task", task_status: "PENDING" } })
: Response.json({ code: "NotFound", message: "Task lookup failed" }, { status: 404 });
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST", "GET"]);
expect(String(error)).toContain("accepted-task");
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
});

View File

@ -12,6 +12,8 @@ function harness(results?: any[] | ((count: number) => any[])) {
const saved: any[] = [];
const generated: any[] = [];
const operations: any[] = [];
const referenceUrl = "https://test.example/reference.png";
const original = Buffer.from("original reference bytes");
const mock = serverLoader({
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
@ -38,8 +40,9 @@ function harness(results?: any[] | ((count: number) => any[])) {
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
sortStyleProImageModelEntries: (entries: any) => entries,
},
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] },
"./review": {},
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: referenceUrl } }] },
"./review": { analyzeRuntimeImages: async ({ reviewPorts }: { reviewPorts: { readImage(url: string): Promise<Buffer> } }) => ({ bytes: (await reviewPorts.readImage(referenceUrl)).length }) },
"@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original },
"./image-access-tools": { registerImageCanvasAccessTools() {} },
});
const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
@ -47,8 +50,20 @@ function harness(results?: any[] | ((count: number) => any[])) {
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
preferredModelId: "system-default", imagePreferences, priorCalls, emit() {},
});
return { build, generated, operations, saved };
return { build, generated, operations, saved, referenceUrl, original };
}
test("the generation tool can reuse original bytes read by inspection within the same task", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
await tools.executable.analyzeCanvasImages.execute({ nodeIds: ["reference"], question: "识别素材" }, "inspect");
await tools.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "generate");
expect(h.generated[0].referenceImageBuffers.get(h.referenceUrl)).toBe(h.original);
const nextTask = await h.build(selection);
await nextTask.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "next");
expect(h.generated[1].referenceImageBuffers.has(h.referenceUrl)).toBe(false);
});
test("explicit controls override conflicting planner settings without losing false or zero values", () => {
const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences);
expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });

View File

@ -25,7 +25,7 @@ function fixture() {
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
verify: async () => ({ passed: true, issues: [] }),
};
return { ports, run, snapshot };
return { ports, run, snapshot, load };
}
const result = { type: "result", sessionId: "test-session", response: "已完成" };
@ -81,6 +81,26 @@ test("an unconfirmed paid tool stops the planner and immediately preserves the r
expect(submissions).toBe(1);
});
test("a pre-submission reference timeout offers a safe retry without an automatic resubmission", async () => {
const { ports, run, snapshot, load } = fixture();
const { ImageReferenceReadTimeoutError } = load("lib/ai/runtime/image-generation-error.ts");
let attempts = 0;
ports.tools.generateCanvasImages.execute = async () => {
attempts++;
throw new ImageReferenceReadTimeoutError();
};
ports.native = async options => {
await options.execute("planCanvasTask", {}, "plan", options.signal!);
await options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "timeout", options.signal!);
return result;
};
expect(await run(ports)).toBe("needs_review");
expect(attempts).toBe(1);
expect(snapshot.calls.find(call => call.id === "timeout")).toMatchObject({ confirmedNoResult: true, uncertain: false });
expect(snapshot.message).toContain("任务尚未提交,可以稍后重试");
expect(snapshot.message).toContain(ports.runId);
});
test("repairing a failed result permits original images as references without admitting another passed target", async () => {
const { ports, run, snapshot } = fixture();
let submissions = 0;