Merge pull request '修复自动生图模型选择与提交结果确认' (#82) from codex/wutong-image-submission-20261113 into main
This commit is contained in:
commit
168de1db3c
@ -13517,7 +13517,8 @@ export function AdminSettingsClient() {
|
|||||||
系统智能选择(根据意图路由)
|
系统智能选择(根据意图路由)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{modelsData?.models
|
{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) => (
|
.map((model: any) => (
|
||||||
<SelectItem key={model.id} value={model.id}>
|
<SelectItem key={model.id} value={model.id}>
|
||||||
{model.displayName}
|
{model.displayName}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
|
aiModel,
|
||||||
aiProvider,
|
aiProvider,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
systemSettingHistory,
|
systemSettingHistory,
|
||||||
@ -2212,6 +2213,14 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const data = updateSchema.parse(body);
|
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 {
|
const {
|
||||||
surpriseCategories,
|
surpriseCategories,
|
||||||
aiDetailGenerationEnabled,
|
aiDetailGenerationEnabled,
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export function ImageParamsBar({
|
|||||||
normalizeImageSizeValue(size, "")
|
normalizeImageSizeValue(size, "")
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
);
|
).filter((size) => providerType !== "alibaba-image" || size === "1K" || size === "2K");
|
||||||
|
|
||||||
const buildImageSizeOptions = () => {
|
const buildImageSizeOptions = () => {
|
||||||
if (imageGenConfig?.supportsSize === false) {
|
if (imageGenConfig?.supportsSize === false) {
|
||||||
@ -220,6 +220,7 @@ export function ImageParamsBar({
|
|||||||
|
|
||||||
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
|
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
|
||||||
if (option.disabled) return false;
|
if (option.disabled) return false;
|
||||||
|
if (providerType === "alibaba-image" && option.value === "4K") return false;
|
||||||
if (!imageGenConfig) return true;
|
if (!imageGenConfig) return true;
|
||||||
if (option.value === "1K") return imageGenConfig.supports1K !== false;
|
if (option.value === "1K") return imageGenConfig.supports1K !== false;
|
||||||
if (option.value === "2K") return imageGenConfig.supports2K !== false;
|
if (option.value === "2K") return imageGenConfig.supports2K !== false;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
|||||||
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
|
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
|
||||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||||
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
|
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
|
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
@ -70,7 +71,7 @@ interface AlibabaImageResponse {
|
|||||||
|
|
||||||
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
||||||
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
|
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_TIMEOUT_MS = 30 * 1000;
|
||||||
const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000;
|
const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000;
|
||||||
const ALIBABA_MIN_SIZE = 512;
|
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();
|
const normalized = String(imageSize || "").trim().toUpperCase();
|
||||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
if (normalized === "1K") return normalized;
|
||||||
return "2K";
|
return "2K";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,6 +292,7 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
inputRole?: AlibabaImageInputRole,
|
inputRole?: AlibabaImageInputRole,
|
||||||
original?: Buffer,
|
original?: Buffer,
|
||||||
|
preparedBuffers?: Map<string, Buffer>,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const normalizedUrl = String(imageUrl || "").trim();
|
const normalizedUrl = String(imageUrl || "").trim();
|
||||||
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
||||||
@ -324,10 +326,27 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
|
preparedBuffers?.set(normalizedUrl, prepared.buffer);
|
||||||
if (isAlibabaQwenImage3ModelId(modelId)) {
|
if (isAlibabaQwenImage3ModelId(modelId)) {
|
||||||
if (prepared.buffer.length > maxInputBytes) {
|
if (prepared.buffer.length > maxInputBytes) {
|
||||||
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
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 format = prepared.transformed ? prepared.extension : metadata.format;
|
||||||
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
||||||
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
||||||
@ -561,6 +580,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
requestBody: Record<string, unknown>;
|
requestBody: Record<string, unknown>;
|
||||||
requestURL: string;
|
requestURL: string;
|
||||||
}): Promise<AlibabaImageResponse> {
|
}): Promise<AlibabaImageResponse> {
|
||||||
|
const submissionStartedAt = Date.now();
|
||||||
const submitted = await fetchAlibabaImageJson({
|
const submitted = await fetchAlibabaImageJson({
|
||||||
apiKey: params.apiKey,
|
apiKey: params.apiKey,
|
||||||
asyncRequest: true,
|
asyncRequest: true,
|
||||||
@ -568,6 +588,14 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
|
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
|
||||||
url: params.requestURL,
|
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();
|
const taskId = String(submitted.output?.task_id || "").trim();
|
||||||
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
|
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
|
||||||
@ -575,7 +603,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
|
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastPollError: unknown = null;
|
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) {
|
while (Date.now() - startedAt < ALIBABA_IMAGE_GENERATION_TIMEOUT_MS) {
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
@ -672,19 +700,13 @@ async function processResponse(
|
|||||||
function formatImageResponse(
|
function formatImageResponse(
|
||||||
images: Array<{ thumbnail: string; original: string }>,
|
images: Array<{ thumbnail: string; original: string }>,
|
||||||
size?: string,
|
size?: string,
|
||||||
requestedSizeLabel?: string,
|
|
||||||
): string {
|
): string {
|
||||||
if (images.length === 0) {
|
if (images.length === 0) {
|
||||||
return "图片生成失败,请重试。";
|
return "图片生成失败,请重试。";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
|
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
|
||||||
const normalizedRequestedSize = normalizeImageSizeLabel(requestedSizeLabel);
|
const sizeLabel = size ? getSizeLabel(size) : "2K";
|
||||||
const sizeLabel = requestedSizeLabel
|
|
||||||
? normalizedRequestedSize
|
|
||||||
: size
|
|
||||||
? getSizeLabel(size)
|
|
||||||
: "2K";
|
|
||||||
|
|
||||||
return images
|
return images
|
||||||
.map((img) => {
|
.map((img) => {
|
||||||
@ -703,7 +725,7 @@ function getSizeLabel(size: string): string {
|
|||||||
"1024*1024": "1K",
|
"1024*1024": "1K",
|
||||||
"1280*1280": "1K",
|
"1280*1280": "1K",
|
||||||
"1536*1536": "2K",
|
"1536*1536": "2K",
|
||||||
"2048*2048": "4K",
|
"2048*2048": "2K",
|
||||||
};
|
};
|
||||||
if (sizeMap[size]) return sizeMap[size];
|
if (sizeMap[size]) return sizeMap[size];
|
||||||
|
|
||||||
@ -712,7 +734,6 @@ function getSizeLabel(size: string): string {
|
|||||||
const width = Number.parseInt(matched[1], 10);
|
const width = Number.parseInt(matched[1], 10);
|
||||||
const height = Number.parseInt(matched[2], 10);
|
const height = Number.parseInt(matched[2], 10);
|
||||||
const maxSide = Math.max(width, height);
|
const maxSide = Math.max(width, height);
|
||||||
if (maxSide >= 1900) return "4K";
|
|
||||||
if (maxSide >= 1450) return "2K";
|
if (maxSide >= 1450) return "2K";
|
||||||
if (maxSide >= 950) return "1K";
|
if (maxSide >= 950) return "1K";
|
||||||
}
|
}
|
||||||
@ -802,6 +823,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||||
|
const preparedBuffers = new Map<string, Buffer>();
|
||||||
const preparedImageUrls = await Promise.all(
|
const preparedImageUrls = await Promise.all(
|
||||||
selectedImageUrls.map((imageUrl) =>
|
selectedImageUrls.map((imageUrl) =>
|
||||||
prepareAlibabaInputImageUrl(
|
prepareAlibabaInputImageUrl(
|
||||||
@ -809,6 +831,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
activeModelId,
|
activeModelId,
|
||||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||||
options.referenceImageBuffers?.get(imageUrl),
|
options.referenceImageBuffers?.get(imageUrl),
|
||||||
|
preparedBuffers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -840,7 +863,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
const baseSizeMap: Record<string, number> = {
|
const baseSizeMap: Record<string, number> = {
|
||||||
"1K": 1024,
|
"1K": 1024,
|
||||||
"2K": isQwenImage3 ? 2048 : 1536,
|
"2K": isQwenImage3 ? 2048 : 1536,
|
||||||
"4K": 2048,
|
|
||||||
};
|
};
|
||||||
const baseSize = baseSizeMap[imageSize] || 1536;
|
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}`);
|
console.log(`[Alibaba Image] Using user-specified size: ${params.size}`);
|
||||||
} else {
|
} else {
|
||||||
if (aspectRatio === "auto") {
|
if (aspectRatio === "auto") {
|
||||||
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
|
// 按参考图比例计算输出尺寸,复用准备阶段读取的图片。
|
||||||
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
|
|
||||||
const referenceImageUrl = preparedImageUrls[0];
|
const referenceImageUrl = preparedImageUrls[0];
|
||||||
if (!referenceImageUrl) {
|
if (!referenceImageUrl) {
|
||||||
params.size = `${baseSize}*${baseSize}`;
|
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}`
|
`[Alibaba Image] Text-to-image auto mode resolved size: imageSize=${imageSize} => size=${params.size}`
|
||||||
);
|
);
|
||||||
} else {
|
} 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) {
|
if (inferredDimensions) {
|
||||||
params.size = calculateAlibabaSizeFromDimensions(
|
params.size = calculateAlibabaSizeFromDimensions(
|
||||||
inferredDimensions.width,
|
inferredDimensions.width,
|
||||||
@ -952,7 +977,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
|||||||
const images = await processResponse(response).catch((error) => {
|
const images = await processResponse(response).catch((error) => {
|
||||||
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: 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`);
|
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import {
|
|||||||
} from "@/lib/ai/vip98k-reference-staging";
|
} from "@/lib/ai/vip98k-reference-staging";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
|
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
IMAGE_UPLOAD_MAX_BYTES,
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
@ -1075,6 +1076,14 @@ async function recoverImageSubmission(params: {
|
|||||||
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
||||||
if (lookup.ok) {
|
if (lookup.ok) {
|
||||||
const state = toRecord(await parseJsonResponse(lookup));
|
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 || !["processing", "succeeded"].includes(String(state.status))) return null;
|
||||||
if (state.status === "succeeded") {
|
if (state.status === "succeeded") {
|
||||||
if (state.result_available !== true || !state.request_id) return null;
|
if (state.result_available !== true || !state.request_id) return null;
|
||||||
@ -1104,7 +1113,7 @@ async function recoverImageSubmission(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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())));
|
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
|
|||||||
|
|
||||||
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
||||||
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
||||||
|
if (error instanceof AgentImageGenerationError) return error;
|
||||||
const rejected = error instanceof ImageSubmissionRejectedError;
|
const rejected = error instanceof ImageSubmissionRejectedError;
|
||||||
const message = error instanceof Error ? error.message : "";
|
const message = error instanceof Error ? error.message : "";
|
||||||
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";
|
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";
|
||||||
|
|||||||
@ -2,9 +2,57 @@ import { expect, test } from "@playwright/test";
|
|||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
import sharp from "sharp";
|
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 () => {
|
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||||
const methods: string[] = [];
|
const methods: string[] = [];
|
||||||
const loader = serverLoader({
|
const loader = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
fetch: async (_url: string, init: RequestInit) => {
|
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 submitted: any;
|
||||||
let posts = 0;
|
let posts = 0;
|
||||||
const loader = serverLoader({
|
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"); } },
|
"@/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),
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
fetch: async (url: string, init: RequestInit = {}) => {
|
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 () => {
|
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
|
||||||
const methods: string[] = [];
|
const methods: string[] = [];
|
||||||
const loader = serverLoader({
|
const loader = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
|
||||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
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 () => {
|
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
|
||||||
const methods: string[] = [];
|
const methods: string[] = [];
|
||||||
const loader = serverLoader({
|
const loader = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
fetch: async (_url: string, init: RequestInit = {}) => {
|
||||||
|
|||||||
@ -42,6 +42,58 @@ import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
|||||||
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
|
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
|
||||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
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 = {
|
const textToImageInvocation: Vip98kTaskInvocation = {
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user