Merge pull request '修复自动生图模型选择与提交结果确认' (#82) from codex/wutong-image-submission-20261113 into main

This commit is contained in:
WuYongJie 2026-11-13 09:58:14 +08:00
commit 168de1db3c
8 changed files with 172 additions and 21 deletions

View File

@ -13517,7 +13517,8 @@ export function AdminSettingsClient() {
系统智能选择(根据意图路由)
</SelectItem>
{modelsData?.models
?.filter((m: any) => m.supportsImageGen)
?.filter((m: any) => m.supportsImageGen && m.isEnabled && !m.isSubModel &&
providersData?.providers?.some((provider: any) => provider.id === m.providerId && provider.isEnabled))
.map((model: any) => (
<SelectItem key={model.id} value={model.id}>
{model.displayName}

View File

@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";
import {
aiModel,
aiProvider,
siteSettings,
systemSettingHistory,
@ -2212,6 +2213,14 @@ export async function PUT(request: NextRequest) {
}
}
const data = updateSchema.parse(body);
if (data.canvasAutoModeDefaultModelId) {
const [available] = await db.select({ id: aiModel.id }).from(aiModel)
.innerJoin(aiProvider, eq(aiProvider.id, aiModel.providerId))
.where(and(eq(aiModel.id, data.canvasAutoModeDefaultModelId),
eq(aiModel.isEnabled, true), eq(aiProvider.isEnabled, true),
eq(aiModel.isSubModel, false), eq(aiModel.supportsImageGen, true))).limit(1);
if (!available) return NextResponse.json({ error: "默认生图模型或其上游已停用,请选择可用的生图模型,或改为系统智能选择。" }, { status: 400 });
}
const {
surpriseCategories,
aiDetailGenerationEnabled,

View File

@ -207,7 +207,7 @@ export function ImageParamsBar({
normalizeImageSizeValue(size, "")
)
: []
);
).filter((size) => providerType !== "alibaba-image" || size === "1K" || size === "2K");
const buildImageSizeOptions = () => {
if (imageGenConfig?.supportsSize === false) {
@ -220,6 +220,7 @@ export function ImageParamsBar({
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
if (option.disabled) return false;
if (providerType === "alibaba-image" && option.value === "4K") return false;
if (!imageGenConfig) return true;
if (option.value === "1K") return imageGenConfig.supports1K !== false;
if (option.value === "2K") return imageGenConfig.supports2K !== false;

View File

@ -13,6 +13,7 @@ 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 { getDefaultStorageConfig } from "@/lib/db/queries";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
@ -70,7 +71,7 @@ interface AlibabaImageResponse {
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 60 * 1000;
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 120 * 1000;
const ALIBABA_IMAGE_POLL_TIMEOUT_MS = 30 * 1000;
const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000;
const ALIBABA_MIN_SIZE = 512;
@ -91,9 +92,9 @@ class AlibabaImageRequestError extends Error {
}
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
if (normalized === "1K") return normalized;
return "2K";
}
@ -291,6 +292,7 @@ async function prepareAlibabaInputImageUrl(
modelId: string,
inputRole?: AlibabaImageInputRole,
original?: Buffer,
preparedBuffers?: Map<string, Buffer>,
): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
@ -324,10 +326,27 @@ async function prepareAlibabaInputImageUrl(
maxSide: ALIBABA_MAX_INPUT_SIDE,
modelId,
});
preparedBuffers?.set(normalizedUrl, prepared.buffer);
if (isAlibabaQwenImage3ModelId(modelId)) {
if (prepared.buffer.length > maxInputBytes) {
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
}
if (!prepared.transformed) {
const storage = await getDefaultStorageConfig();
if (storage?.isEnabled && storage.type === "qiniu-kodo" && storage.publicUrl) {
const source = new URL(normalizedUrl);
const publicBase = new URL(storage.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
const isStoredOriginal = !source.search && !source.hash &&
source.pathname.startsWith(publicBase.pathname.replace(/\/$/, "") + "/uploads/") &&
(source.origin === publicBase.origin || (origin && source.origin === new URL(origin).origin));
if (publicBase.protocol === "https:" && isStoredOriginal) {
source.host = publicBase.host;
source.protocol = publicBase.protocol;
return source.toString();
}
}
}
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")}`;
@ -561,6 +580,7 @@ async function sendAlibabaImageAsyncRequest(params: {
requestBody: Record<string, unknown>;
requestURL: string;
}): Promise<AlibabaImageResponse> {
const submissionStartedAt = Date.now();
const submitted = await fetchAlibabaImageJson({
apiKey: params.apiKey,
asyncRequest: true,
@ -568,6 +588,14 @@ async function sendAlibabaImageAsyncRequest(params: {
method: "POST",
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
url: params.requestURL,
}).catch((error) => {
if (error instanceof AlibabaImageRequestError && error.status >= 400 && error.status < 500 && error.status !== 408) throw error;
console.error("[Alibaba Image] Submission outcome unknown:", {
elapsedMs: Date.now() - submissionStartedAt,
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
error: error instanceof Error ? error.message : String(error),
});
throw Object.assign(new Error("阿里图片提交响应未确认,可能已受理,请先核对原任务,避免重复生成。", { cause: error }), { providerOutcomeUnknown: true });
});
const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
@ -575,7 +603,7 @@ async function sendAlibabaImageAsyncRequest(params: {
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now();
let lastPollError: unknown = null;
console.log("[Alibaba Image] Async task submitted:", taskId);
console.log("[Alibaba Image] Async task submitted:", taskId, { elapsedMs: Date.now() - submissionStartedAt, requestId: submitted.request_id });
while (Date.now() - startedAt < ALIBABA_IMAGE_GENERATION_TIMEOUT_MS) {
await new Promise((resolve) =>
@ -672,19 +700,13 @@ async function processResponse(
function formatImageResponse(
images: Array<{ thumbnail: string; original: string }>,
size?: string,
requestedSizeLabel?: string,
): string {
if (images.length === 0) {
return "图片生成失败,请重试。";
}
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
const normalizedRequestedSize = normalizeImageSizeLabel(requestedSizeLabel);
const sizeLabel = requestedSizeLabel
? normalizedRequestedSize
: size
? getSizeLabel(size)
: "2K";
const sizeLabel = size ? getSizeLabel(size) : "2K";
return images
.map((img) => {
@ -703,7 +725,7 @@ function getSizeLabel(size: string): string {
"1024*1024": "1K",
"1280*1280": "1K",
"1536*1536": "2K",
"2048*2048": "4K",
"2048*2048": "2K",
};
if (sizeMap[size]) return sizeMap[size];
@ -712,7 +734,6 @@ function getSizeLabel(size: string): string {
const width = Number.parseInt(matched[1], 10);
const height = Number.parseInt(matched[2], 10);
const maxSide = Math.max(width, height);
if (maxSide >= 1900) return "4K";
if (maxSide >= 1450) return "2K";
if (maxSide >= 950) return "1K";
}
@ -802,6 +823,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
}
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
const preparedBuffers = new Map<string, Buffer>();
const preparedImageUrls = await Promise.all(
selectedImageUrls.map((imageUrl) =>
prepareAlibabaInputImageUrl(
@ -809,6 +831,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
preparedBuffers,
),
),
);
@ -840,7 +863,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
const baseSizeMap: Record<string, number> = {
"1K": 1024,
"2K": isQwenImage3 ? 2048 : 1536,
"4K": 2048,
};
const baseSize = baseSizeMap[imageSize] || 1536;
@ -876,8 +898,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
console.log(`[Alibaba Image] Using user-specified size: ${params.size}`);
} else {
if (aspectRatio === "auto") {
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
// 按参考图比例计算输出尺寸,复用准备阶段读取的图片。
const referenceImageUrl = preparedImageUrls[0];
if (!referenceImageUrl) {
params.size = `${baseSize}*${baseSize}`;
@ -885,7 +906,11 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
`[Alibaba Image] Text-to-image auto mode resolved size: imageSize=${imageSize} => size=${params.size}`
);
} else {
const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl);
const preparedBuffer = preparedBuffers.get(selectedImageUrls[0]);
const metadata = preparedBuffer ? await sharp(preparedBuffer).metadata() : null;
const inferredDimensions = metadata?.width && metadata.height
? { width: metadata.width, height: metadata.height, aspectRatio: metadata.width / metadata.height }
: await inferImageDimensionsFromImageUrl(referenceImageUrl);
if (inferredDimensions) {
params.size = calculateAlibabaSizeFromDimensions(
inferredDimensions.width,
@ -952,7 +977,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
const images = await processResponse(response).catch((error) => {
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize);
const actualSize = response.usage?.width && response.usage?.height
? `${response.usage.width}*${response.usage.height}` : params.size;
const markdownText = formatImageResponse(images, actualSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -49,6 +49,7 @@ import {
} from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import {
IMAGE_UPLOAD_MAX_BYTES,
@ -1075,6 +1076,14 @@ async function recoverImageSubmission(params: {
if ([401, 403, 404, 410].includes(lookup.status)) return null;
if (lookup.ok) {
const state = toRecord(await parseJsonResponse(lookup));
if (state?.status === "failed" && state.request_id && state.result_available === false) {
logger.warn("original image request confirmed failed", {
modelId: params.modelId,
providerRequestId: state.request_id,
durationMs: Date.now() - startedAt,
});
throw new AgentImageGenerationError("生图服务已确认本次生成失败,未生成图片。请稍后重试或手动选择其他模型。", true);
}
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
if (state.status === "succeeded") {
if (state.result_available !== true || !state.request_id) return null;
@ -1104,7 +1113,7 @@ async function recoverImageSubmission(params: {
}
}
} catch (error) {
if (error instanceof ProviderTaskUncertainError) throw error;
if (error instanceof ProviderTaskUncertainError || error instanceof AgentImageGenerationError) throw error;
}
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
}

View File

@ -16,6 +16,7 @@ export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
if (error instanceof AgentImageGenerationError) return error;
const rejected = error instanceof ImageSubmissionRejectedError;
const message = error instanceof Error ? error.message : "";
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";

View File

@ -2,9 +2,57 @@ import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import sharp from "sharp";
test("Qwen uses the public stored original without a second download and reports actual 2K output", async () => {
const png = await sharp({ create: { width: 384, height: 512, channels: 3, background: "blue" } }).png().toBuffer();
const source = "https://origin.example/uploads/fixture.png";
let submitted: any;
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => ({ isEnabled: true, type: "qiniu-kodo", publicUrl: "https://cdn.example" }) },
"@/lib/storage/service": {},
"@/lib/storage/image-archive": { createDeferredUploadedImage: async () => ({ thumbnail: "https://cdn.example/thumb.png", original: "https://cdn.example/result.png" }) },
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
if (init.method === "POST") {
submitted = JSON.parse(String(init.body));
return Response.json({ output: { task_id: "public-reference-task" } });
}
if (!url.includes("/tasks/")) throw new Error("Reference bytes must be reused");
return Response.json({ output: { task_status: "SUCCEEDED", choices: [{ message: { content: [{ image: "https://result.example/output.png" }] } }] }, usage: { width: 1536, height: 2048 } });
},
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" });
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const result = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, { type: "image", image: source },
] }], imageParams: { aspectRatio: "auto", imageSize: "4K" }, referenceImageBuffers: new Map([[source, png]]) });
expect(methods).toEqual(["POST", "GET"]);
expect(submitted.input.messages[0].content.find((part: any) => part.image).image).toBe("https://cdn.example/uploads/fixture.png");
expect(submitted.parameters.size).toBe("1536*2048");
expect(result.text).toContain("|||2K]");
expect(result.text).not.toContain("4K");
});
test("Qwen submission timeout remains unknown and never sends another POST", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": {}, "@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
fetch: async (_url: string, init: RequestInit) => {
methods.push(init.method || "GET");
throw new DOMException("The 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: "fixture" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST"]);
expect(error).toMatchObject({ providerOutcomeUnknown: true });
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
});
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit) => {
@ -36,6 +84,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
let submitted: any;
let posts = 0;
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/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 = {}) => {
@ -64,6 +113,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
@ -85,6 +135,7 @@ test("a timed out reference read is safe to retry because no generation was subm
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {

View File

@ -42,6 +42,58 @@ import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import { extractImageUrlsFromText } from "@/lib/image-url";
import { serverLoader } from "../helpers/canvas-download-server-loader";
for (const status of ["failed", "unresolved", "unknown", "missing"]) {
test(`a synchronous 502 reconciles ${status} without repeating submission`, async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@/lib/storage/service": {},
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
fetch: async (url: string, init: RequestInit) => {
methods.push(init.method || "GET");
if (init.method === "POST") return Response.json({ error: { message: "generate failed" } }, { status: 502 });
expect(url).toContain("/api/external/requests/by-idempotency-key");
return status === "missing" ? new Response(null, { status: 404 }) : Response.json({ status, request_id: "original-request", result_available: false });
},
});
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
const error = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST", "GET"]);
if (status === "failed") {
expect(error).toMatchObject({ confirmedNoResult: true });
expect(loader("lib/ai/runtime/image-generation-error.ts").agentImageGenerationError(error, "pinned")).toBe(error);
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("failed");
} else {
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("unknown");
}
});
}
test("a synchronous 502 recovers the original completed image through GET only", async () => {
const calls: string[] = [];
let lookups = 0;
const loader = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": { createDeferredUploadedImage: async (url: string) => ({ original: url, thumbnail: url }) },
"@/lib/storage/service": {},
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
setTimeout: (callback: () => void) => setTimeout(callback, 0),
fetch: async (url: string, init: RequestInit) => {
calls.push(init.method || "GET");
if (init.method === "POST") return Response.json({ error: { message: "bad gateway" } }, { status: 502 });
if (url.endsWith("/by-idempotency-key")) return Response.json({ status: ++lookups === 1 ? "processing" : "succeeded", request_id: "original-request", result_available: true });
expect(url).toContain("/original-request/result");
return Response.json({ data: [{ url: "https://assets.test/original.png" }] });
},
});
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
const result = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] });
expect(calls).toEqual(["POST", "GET", "GET", "GET"]);
expect(result.text).toContain("https://assets.test/original.png");
});
const textToImageInvocation: Vip98kTaskInvocation = {
kind: "tool",