fix image providers and model deletion

This commit is contained in:
134 2026-06-11 19:35:09 +08:00
parent f80b3e8117
commit 04972684c3
13 changed files with 360 additions and 134 deletions

View File

@ -766,23 +766,6 @@ export function LovartHome({
.filter(Boolean);
}, [composerFixedPrefix, composerPreviewTexts]);
const lovartPromptTagVisibility = useMemo<LovartPromptTagVisibility>(() => {
const raw = siteSettingsData?.lovartPromptTagVisibility || {};
return {
nanoBananaPro: raw.nanoBananaPro !== false,
design: raw.design !== false,
branding: raw.branding !== false,
illustration: raw.illustration !== false,
ecommerce: raw.ecommerce !== false,
video: raw.video !== false,
};
}, [siteSettingsData?.lovartPromptTagVisibility]);
const visiblePromptTags = useMemo(
() => PROMPT_TAGS.filter((tag) => lovartPromptTagVisibility[tag.key]),
[lovartPromptTagVisibility]
);
const contactConfig = useMemo(
() => ({
phone: String(siteSettingsData?.contactConfig?.phone || "").trim(),
@ -1826,7 +1809,6 @@ export function LovartHome({
shouldInvertLogo={shouldInvertLogo}
siteName={siteName}
selectedModelId={selectedComposerModel.id}
visiblePromptTags={visiblePromptTags}
/>
)}

View File

@ -11,9 +11,6 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import type { ModelOption, UploadFileItem } from "./types";
import type { PROMPT_TAGS } from "./utils";
type PromptTag = (typeof PROMPT_TAGS)[number];
type LovartHeroComposerProps = {
composerPlaceholder: string;
@ -43,7 +40,6 @@ type LovartHeroComposerProps = {
onModelChange: (modelId: string) => void;
onStartAgentMode: () => void;
selectedModelId: string;
visiblePromptTags: PromptTag[];
};
export function LovartHeroComposer({
@ -70,7 +66,6 @@ export function LovartHeroComposer({
onModelChange,
onStartAgentMode,
selectedModelId,
visiblePromptTags,
}: LovartHeroComposerProps) {
return (
<section className="mt-8 text-center">
@ -111,7 +106,7 @@ export function LovartHeroComposer({
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-[34px] w-full max-w-[990px] rounded-[22px] border border-[#e1e4e8] bg-white px-[24px] pb-[12px] pt-[18px] shadow-[0_10px_26px_rgba(0,0,0,0.06)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<div className="mx-auto mt-[34px] w-full max-w-[900px] rounded-[22px] border border-[#e1e4e8] bg-white px-[24px] pb-[12px] pt-[18px] shadow-[0_10px_26px_rgba(0,0,0,0.06)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]">
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
@ -222,23 +217,6 @@ export function LovartHeroComposer({
</div>
</div>
<div className="mt-[22px] flex flex-wrap items-center justify-center gap-x-[12px] gap-y-[12px]">
{visiblePromptTags.map((tag) => (
<button
key={tag.label}
type="button"
className={`inline-flex h-[36px] items-center gap-1.5 rounded-full border px-[13px] text-[14px] font-normal ${
tag.highlighted
? "border-[#ffb15f] bg-[#fff4e7] text-[#ff8c1f] dark:border-[#8b6330] dark:bg-[#38270f] dark:text-[#ffbe6b]"
: "border-black/15 bg-white text-black/70 dark:border-white/10 dark:bg-[#141416] dark:text-white/70"
}`}
>
<tag.icon className="h-3.5 w-3.5" />
{tag.label}
</button>
))}
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />

View File

@ -29,9 +29,9 @@ export function LovartProjectsSection({
return (
<section
ref={projectSectionRef}
className={isProjectsOnlyView ? "mt-6" : "mt-[86px]"}
className={isProjectsOnlyView ? "mt-6" : "mt-[156px]"}
>
<div className="mb-[56px] flex items-center justify-between">
<div className="mb-[32px] flex items-center justify-between">
<h2 className="text-[26px] font-semibold leading-none tracking-[-0.01em] text-[#1f1f1f] dark:text-white">
{isProjectsOnlyView ? "所有项目" : "最近项目"}
</h2>

View File

@ -7,33 +7,36 @@ type GrsaiGptImageSizeOption = {
};
const GRSAI_GPT_IMAGE_SIZE_OPTIONS: GrsaiGptImageSizeOption[] = [
{ bucket: "1K", width: 1024, height: 1024 },
{ bucket: "1K", width: 1774, height: 887 },
{ bucket: "1K", width: 887, height: 1774 },
{ bucket: "1K", width: 1536, height: 1024 },
{ bucket: "1K", width: 1024, height: 1536 },
{ bucket: "1K", width: 1280, height: 1280 },
{ bucket: "1K", width: 848, height: 1280 },
{ bucket: "1K", width: 1280, height: 848 },
{ bucket: "1K", width: 960, height: 1280 },
{ bucket: "1K", width: 1280, height: 960 },
{ bucket: "1K", width: 1024, height: 1280 },
{ bucket: "1K", width: 1280, height: 1024 },
{ bucket: "1K", width: 720, height: 1280 },
{ bucket: "1K", width: 1280, height: 720 },
{ bucket: "1K", width: 1280, height: 544 },
{ bucket: "2K", width: 2048, height: 2048 },
{ bucket: "2K", width: 2048, height: 1152 },
{ bucket: "2K", width: 1152, height: 2048 },
{ bucket: "2K", width: 2048, height: 1360 },
{ bucket: "2K", width: 1360, height: 2048 },
{ bucket: "2K", width: 2048, height: 880 },
{ bucket: "2K", width: 880, height: 2048 },
{ bucket: "2K", width: 2048, height: 688 },
{ bucket: "2K", width: 688, height: 2048 },
{ bucket: "2K", width: 2048, height: 1024 },
{ bucket: "2K", width: 1024, height: 2048 },
{ bucket: "2K", width: 2048, height: 1360 },
{ bucket: "2K", width: 1536, height: 2048 },
{ bucket: "2K", width: 2048, height: 1536 },
{ bucket: "2K", width: 1632, height: 2048 },
{ bucket: "2K", width: 2048, height: 1632 },
{ bucket: "2K", width: 1152, height: 2048 },
{ bucket: "2K", width: 2048, height: 1152 },
{ bucket: "2K", width: 2048, height: 864 },
{ bucket: "4K", width: 2880, height: 2880 },
{ bucket: "4K", width: 3840, height: 2160 },
{ bucket: "4K", width: 2336, height: 3520 },
{ bucket: "4K", width: 3520, height: 2336 },
{ bucket: "4K", width: 2480, height: 3312 },
{ bucket: "4K", width: 3312, height: 2480 },
{ bucket: "4K", width: 2560, height: 3216 },
{ bucket: "4K", width: 3216, height: 2560 },
{ bucket: "4K", width: 2160, height: 3840 },
{ bucket: "4K", width: 3504, height: 2336 },
{ bucket: "4K", width: 2336, height: 3504 },
{ bucket: "4K", width: 3840, height: 1648 },
{ bucket: "4K", width: 1648, height: 3840 },
{ bucket: "4K", width: 3840, height: 1280 },
{ bucket: "4K", width: 1280, height: 3840 },
{ bucket: "4K", width: 3840, height: 1920 },
{ bucket: "4K", width: 1920, height: 3840 },
{ bucket: "4K", width: 3840, height: 2160 },
{ bucket: "4K", width: 3840, height: 1632 },
];
const GRSAI_GPT_IMAGE_SIZE_VALUES = new Set(

View File

@ -50,6 +50,9 @@ interface AlibabaImageResponse {
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
const ALIBABA_MIN_SIZE = 512;
const ALIBABA_MAX_SIZE = 2048;
const ALIBABA_MAX_INPUT_SIDE = 4096;
const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024;
const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 1024 * 1024;
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
@ -57,6 +60,68 @@ function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
return "2K";
}
function getAlibabaMaxInputBytes(modelId: string) {
const normalized = modelId.trim().toLowerCase();
if (normalized.includes("qwen-image")) {
return ALIBABA_QWEN_MAX_INPUT_BYTES;
}
return ALIBABA_DEFAULT_MAX_INPUT_BYTES;
}
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
function createAbortSignal(timeoutMs: number) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return {
signal: controller.signal,
clear: () => clearTimeout(timeoutId),
};
}
async function fetchImageBufferWithRetry(
imageUrl: string,
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
attempts = 2
): Promise<{ buffer: Buffer; contentType: string | null }> {
let lastError: unknown = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const ctx = createAbortSignal(timeoutMs);
try {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: ctx.signal,
});
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (!buffer.length) {
throw new Error("Downloaded image is empty");
}
return {
buffer,
contentType: response.headers.get("content-type"),
};
} catch (error) {
lastError = error;
if (attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, attempt * 500));
}
} finally {
ctx.clear();
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
}
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
const safeAspect = Number.isFinite(aspectValue) && aspectValue > 0 ? aspectValue : 1;
const totalPixels = baseSize * baseSize;
@ -97,16 +162,8 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
if (!/^https?:\/\//i.test(imageUrl)) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ALIBABA_IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(imageUrl, { signal: controller.signal });
if (!response.ok) {
console.warn("[Alibaba Image] Failed to fetch reference image for auto size:", response.status);
return null;
}
const buffer = Buffer.from(await response.arrayBuffer());
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
if (!buffer.length) return null;
const metadata = await sharp(buffer).metadata();
@ -115,8 +172,6 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
} catch (error) {
console.warn("[Alibaba Image] Failed to infer aspect ratio from reference image:", error);
return null;
} finally {
clearTimeout(timeoutId);
}
}
@ -125,16 +180,8 @@ async function inferImageDimensionsFromImageUrl(
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
if (!/^https?:\/\//i.test(imageUrl)) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ALIBABA_IMAGE_FETCH_TIMEOUT_MS);
try {
const response = await fetch(imageUrl, { signal: controller.signal });
if (!response.ok) {
console.warn("[Alibaba Image] Failed to fetch reference image dimensions:", response.status);
return null;
}
const buffer = Buffer.from(await response.arrayBuffer());
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
if (!buffer.length) return null;
const metadata = await sharp(buffer).metadata();
@ -148,8 +195,6 @@ async function inferImageDimensionsFromImageUrl(
} catch (error) {
console.warn("[Alibaba Image] Failed to infer image dimensions from reference image:", error);
return null;
} finally {
clearTimeout(timeoutId);
}
}
@ -219,6 +264,124 @@ function generateFilename(prefix: string, extension: string): string {
return `${prefix}-${timestamp}-${random}.${extension}`;
}
async function uploadPreparedAlibabaInputImage(
buffer: Buffer,
extension: "png" | "jpg"
): Promise<string> {
const filename = generateFilename("alibaba-input", extension);
const result = await uploadFile(filename, bufferToArrayBuffer(buffer));
console.log(`[Alibaba Image] Prepared input image uploaded to: ${result.url}`);
return result.url;
}
async function prepareAlibabaInputImageUrl(
imageUrl: string,
modelId: string
): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
let metadata: sharp.Metadata;
try {
metadata = await sharp(buffer, { failOn: "none" }).metadata();
} catch (error) {
throw new Error(
`输入图片无法识别,请转换为 JPG/PNG 后重试: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const format = String(metadata.format || "").toLowerCase();
const type = String(contentType || "").toLowerCase();
const cleanUrl = normalizedUrl.split("?")[0]?.toLowerCase() || "";
const isGif = format === "gif" || type.includes("gif") || cleanUrl.endsWith(".gif");
const isTooLarge = buffer.length > maxInputBytes;
const isTooWide = Number(metadata.width || 0) > ALIBABA_MAX_INPUT_SIDE;
const isTooTall = Number(metadata.height || 0) > ALIBABA_MAX_INPUT_SIDE;
if (!isGif && !isTooLarge && !isTooWide && !isTooTall) {
return normalizedUrl;
}
console.warn("[Alibaba Image] Normalizing input image before provider request:", {
bytes: buffer.length,
contentType,
format,
height: metadata.height,
isGif,
isTooLarge,
maxInputBytes,
modelId,
sourceHost: (() => {
try {
return new URL(normalizedUrl).host;
} catch {
return "";
}
})(),
width: metadata.width,
});
const basePipeline = sharp(buffer, {
animated: false,
failOn: "none",
})
.rotate()
.resize(ALIBABA_MAX_INPUT_SIDE, ALIBABA_MAX_INPUT_SIDE, {
fit: "inside",
withoutEnlargement: true,
});
let prepared = await basePipeline.png({ compressionLevel: 8 }).toBuffer();
let extension: "png" | "jpg" = "png";
if (prepared.length > maxInputBytes) {
prepared = await sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.resize(ALIBABA_MAX_INPUT_SIDE, ALIBABA_MAX_INPUT_SIDE, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 88, mozjpeg: true })
.toBuffer();
extension = "jpg";
}
if (prepared.length > maxInputBytes) {
prepared = await sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.resize(3072, 3072, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 82, mozjpeg: true })
.toBuffer();
extension = "jpg";
}
if (prepared.length > maxInputBytes) {
prepared = await sharp(buffer, { animated: false, failOn: "none" })
.rotate()
.resize(2048, 2048, {
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality: 76, mozjpeg: true })
.toBuffer();
extension = "jpg";
}
if (prepared.length > maxInputBytes) {
const limitMb = Math.floor(maxInputBytes / 1024 / 1024);
throw new Error(`输入图片压缩后仍超过 ${limitMb}MB,请先压缩图片后重试`);
}
return uploadPreparedAlibabaInputImage(prepared, extension);
}
/**
* 下载图片并上传到 S3
*/
@ -229,12 +392,7 @@ async function downloadAndUploadToS3(
try {
console.log("[Alibaba Image] Downloading image from:", imageUrl.substring(0, 100) + "...");
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to download image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const { buffer } = await fetchImageBufferWithRetry(imageUrl, 30000, 3);
console.log(`[Alibaba Image] Downloaded image, size: ${buffer.length} bytes`);
// 创建缩略图
@ -242,7 +400,7 @@ async function downloadAndUploadToS3(
// 上传原图(Buffer 转 ArrayBuffer)
const originalFilename = generateFilename(`${prefix}-orig`, "png");
const originalResult = await uploadFile(originalFilename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
const originalResult = await uploadFile(originalFilename, bufferToArrayBuffer(buffer));
console.log(`[Alibaba Image] Uploaded original to: ${originalResult.url}`);
// 上传缩略图
@ -271,7 +429,7 @@ async function uploadBase64ToS3(base64Data: string): Promise<string> {
const buffer = Buffer.from(base64Clean, "base64");
const filename = generateFilename("alibaba-ref", "png");
const result = await uploadFile(filename, buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
const result = await uploadFile(filename, bufferToArrayBuffer(buffer));
console.log(`[Alibaba Image] Uploaded reference image to: ${result.url}`);
return result.url;
} catch (error) {
@ -513,8 +671,13 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
throw new Error("No prompt found in message");
}
const preparedImageUrls: string[] = [];
for (const imageUrl of Array.from(new Set(imageUrls)).slice(0, 3)) {
preparedImageUrls.push(await prepareAlibabaInputImageUrl(imageUrl, activeModelId));
}
// 验证:qwen-image-edit 系列模型必须有输入图片
if (imageUrls.length === 0) {
if (preparedImageUrls.length === 0) {
const modelName = options.modelDisplayName || modelId;
throw new Error(`「${modelName}」是图片编辑模型,需要至少上传一张图片才能进行编辑。如需纯文生图,请使用其他生图模型。`);
}
@ -574,7 +737,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
if (aspectRatio === "auto") {
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
const referenceImageUrl = imageUrls[0];
const referenceImageUrl = preparedImageUrls[0];
const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl);
if (inferredDimensions) {
params.size = calculateAlibabaSizeFromDimensions(
@ -613,10 +776,10 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
}
// 根据图片数量确定编辑模式
const editMode = imageUrls.length === 1 ? "单图编辑" : `多图融合 (${imageUrls.length}张)`;
const editMode = preparedImageUrls.length === 1 ? "单图编辑" : `多图融合 (${preparedImageUrls.length}张)`;
console.log("[Alibaba Image] 编辑模式:", editMode);
console.log("[Alibaba Image] Prompt:", prompt.substring(0, 100) + (prompt.length > 100 ? "..." : ""));
console.log("[Alibaba Image] Input images:", imageUrls.length);
console.log("[Alibaba Image] Input images:", preparedImageUrls.length);
console.log("[Alibaba Image] Final params:", params);
// 发送请求
@ -625,7 +788,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
apiKey,
activeModelId,
prompt,
imageUrls,
preparedImageUrls,
params
);

View File

@ -42,7 +42,7 @@ const DEFAULT_ASPECT_RATIO = "auto";
const DEFAULT_RESOLUTION = "1K";
const DEFAULT_OUTPUT_FORMAT = "png";
const DEFAULT_SAFETY_TOLERANCE = "4";
const DEFAULT_TIMEOUT_MS = 180000;
const DEFAULT_TIMEOUT_MS = 300000;
const FAL_NANO_BANANA_MODEL_IDS = new Set([
"fal-ai/nano-banana",
"fal-ai/nano-banana-2",
@ -70,7 +70,10 @@ const FAL_ASPECT_RATIO_VALUES = new Set([
"3:4",
"2:3",
"9:16",
"9:21",
"4:1",
"1:4",
"8:1",
"1:8",
]);
const FAL_IMAGE_SIZE_PRESETS = new Set([
"square_hd",
@ -588,6 +591,34 @@ function summarizeFalError(error: any) {
};
}
function stringifyFalErrorDetail(value: unknown) {
if (!value) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function createDetailedFalError(error: unknown) {
const summary = summarizeFalError(error);
const detail = stringifyFalErrorDetail(summary.body);
const message = [
"FAL request failed",
summary.status ? `status=${summary.status}` : "",
summary.message ? `message=${summary.message}` : "",
detail ? `body=${detail}` : "",
]
.filter(Boolean)
.join("; ")
.slice(0, 1800);
const detailedError = new Error(message);
detailedError.name = "FalImageProviderError";
(detailedError as Error & { cause?: unknown }).cause = error;
return detailedError;
}
function createFalClientFromApiKey(apiKey: string) {
return import("@fal-ai/client").then(({ createFalClient }) =>
createFalClient({
@ -663,7 +694,9 @@ function buildFalRequestPayload(params: {
};
if (isFalNanoBananaModelId(falEndpointId)) {
input.aspect_ratio = aspectRatio;
input.aspect_ratio = FAL_ASPECT_RATIO_VALUES.has(aspectRatio)
? aspectRatio
: DEFAULT_ASPECT_RATIO;
input.resolution = resolution;
input.limit_generations = true;
} else if (isFalFluxKontextDevModelId(falEndpointId) && imageUrls.length > 0) {
@ -698,6 +731,10 @@ function buildFalRequestPayload(params: {
if (typeof params.imageParams?.enableWebSearch === "boolean") {
input.enable_web_search = params.imageParams.enableWebSearch;
}
if (typeof params.imageParams?.syncMode === "boolean") {
input.sync_mode = params.imageParams.syncMode;
}
}
return {
@ -862,7 +899,7 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
});
} catch (error) {
console.error("[Fal Image] Request failed:", summarizeFalError(error));
throw error;
throw createDetailedFalError(error);
}
const text = await normalizeFalResultToMarkdown({

View File

@ -798,39 +798,19 @@ async function sendGeminiRequest(
? await inferImageAspectRatioFromUrl(processedUrls[0])
: null;
const resolvedAspectRatio = inferredAspectRatio || requestedAspectRatio;
const normalizedRequestedAspectRatio = normalizeAspectRatio(requestedAspectRatio);
const resolvedSize = resolveGrsaiGptImageSize(resolvedAspectRatio, imageParams?.imageSize);
const shouldPassAspectRatio =
Boolean(normalizedRequestedAspectRatio && normalizedRequestedAspectRatio !== "1:1");
if (normalizedRequestedAspectRatio === "1:1") {
logger.warn(
`${apiLogPrefix} Received 1:1 aspect ratio for GPT image on ${baseURL}. Skip aspect ratio params for compatibility.`,
);
}
// GPT-image(如 gpt-image-2-vip)在不同网关中参数差异较大:
// - 有的网关只识别 size;
// - 有的网关同时支持 aspectRatio/aspect_ratio;
// - 有的会读取 imageSize/image_size;
// 兼容不同网关:优先使用 OpenAI 原生 size;对容易兼容失败的比例(如 1:1)不再附带 aspect 变体参数。
// Grsai GPT-image-2-vip 只接受固定像素 size;继续传比例或档位字段会触发
// “does not support 1:1/3:4 parameter, please use pixel value input”。
requestBody.size = resolvedSize;
requestBody.imageSize = imageParams?.imageSize;
requestBody.image_size = imageParams?.imageSize;
if (shouldPassAspectRatio) {
requestBody.aspectRatio = normalizedRequestedAspectRatio;
requestBody.aspect_ratio = normalizedRequestedAspectRatio;
requestBody.aspectRatioRaw = requestedAspectRatio;
}
requestBody.shutProgress = false;
if (processedUrls.length > 0) {
requestBody.image = processedUrls[0];
requestBody.imageUrls = [processedUrls[0]];
requestBody.imageUrls = processedUrls;
}
logger.debug(
`${apiLogPrefix} GPT image payload mapping: requestedAspectRatio=${requestedAspectRatio}, normalizedRequestedAspectRatio=${normalizedRequestedAspectRatio || "n/a"}, resolvedAspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"}, resolvedSize=${resolvedSize}`
`${apiLogPrefix} GPT image payload mapping: requestedAspectRatio=${requestedAspectRatio}, resolvedAspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"}, resolvedSize=${resolvedSize}`
);
if (inferredAspectRatio) {
logger.debug(`${apiLogPrefix} Inferred reference aspect ratio: ${inferredAspectRatio}`);

View File

@ -90,7 +90,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("model not exist") ||
normalized.includes("model_not_found") ||
normalized.includes("model not found") ||
normalized.includes("unknown model")
normalized.includes("unknown model") ||
normalized.includes("model name") && normalized.includes("not supported")
) {
return {
eventType: "invalid_model",
@ -102,7 +103,11 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("insufficient balance") ||
normalized.includes("credits insufficient") ||
normalized.includes("quota") ||
normalized.includes("余额") ||
normalized.includes("额度") ||
normalized.includes("预扣费额度") ||
normalized.includes("exceeded_current_quota") ||
normalized.includes("pre_consume_token_quota_failed") ||
normalized.includes("token quota is not enough")
@ -117,6 +122,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("timeout") ||
normalized.includes("超时") ||
normalized.includes("terminated") ||
normalized.includes("timed out") ||
normalized.includes("body timeout") ||
normalized.includes("headers timeout")
@ -140,7 +147,8 @@ export function classifyAiModelHealthFailure(error: unknown): {
normalized.includes("no available channel") ||
normalized.includes("no supported channel") ||
normalized.includes("service unavailable") ||
normalized.includes("fetch failed")
normalized.includes("fetch failed") ||
normalized.includes("econnreset")
) {
return {
eventType: "unavailable",

View File

@ -466,7 +466,10 @@ export async function generateCanvasImage(
).slice(0, 12);
const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
const effectiveReferenceImages = normalizedReferenceImages;
const effectiveReferenceImages =
reachableReferenceImages.length > 0
? reachableReferenceImages
: normalizedReferenceImages;
if (unreachableReferenceImages.length > 0) {
console.warn("[Canvas Generate Image] Local reachability check failed for some reference images:", {
count: unreachableReferenceImages.length,
@ -857,6 +860,9 @@ export async function generateCanvasImage(
imageSize,
imageCount,
requestedReferenceImageCount: normalizedReferenceImages.length,
reachableReferenceImageCount: reachableReferenceImages.length,
effectiveReferenceImageCount: effectiveReferenceImages.length,
droppedReferenceImageCount: unreachableReferenceImages.length,
...debugMeta,
},
}).catch((healthError) => {

View File

@ -0,0 +1,13 @@
DO $$
BEGIN
ALTER TABLE "ExternalImageApiRequestLog"
DROP CONSTRAINT IF EXISTS "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk";
ALTER TABLE "ExternalImageApiRequestLog"
ADD CONSTRAINT "ExternalImageApiRequestLog_modelDbId_AiModel_id_fk"
FOREIGN KEY ("modelDbId")
REFERENCES "public"."AiModel"("id")
ON DELETE SET NULL
ON UPDATE NO ACTION;
END
$$;

View File

@ -505,6 +505,13 @@
"when": 1780075200000,
"tag": "0085_add_system_setting_snapshots",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1781174400000,
"tag": "0086_external_image_log_model_delete_set_null",
"breakpoints": true
}
]
}

View File

@ -74,6 +74,7 @@ import {
type MediaAsset,
mediaAssetRelation,
type MediaAssetRelation,
externalImageApiRequestLog,
type AgentPreset,
type AgentPresetCategory,
type AgentPresetExecutionSnapshot,
@ -4354,8 +4355,54 @@ export async function updateAiModel({
export async function deleteAiModel({ id }: { id: string }) {
try {
return await db.delete(aiModel).where(eq(aiModel.id, id)).returning();
return await db.transaction(async (tx) => {
const [modelToDelete] = await tx
.select({
id: aiModel.id,
modelId: aiModel.modelId,
})
.from(aiModel)
.where(eq(aiModel.id, id))
.limit(1);
if (!modelToDelete) {
return [];
}
await tx
.update(externalImageApiRequestLog)
.set({ modelDbId: null })
.where(eq(externalImageApiRequestLog.modelDbId, id));
await tx
.update(aiModel)
.set({ parentModelId: null })
.where(eq(aiModel.parentModelId, id));
await tx
.update(aiModel)
.set({ fallbackModelId1: null })
.where(
inArray(aiModel.fallbackModelId1, [
modelToDelete.id,
modelToDelete.modelId,
])
);
await tx
.update(aiModel)
.set({ fallbackModelId2: null })
.where(
inArray(aiModel.fallbackModelId2, [
modelToDelete.id,
modelToDelete.modelId,
])
);
return await tx.delete(aiModel).where(eq(aiModel.id, id)).returning();
});
} catch (_error) {
console.error("deleteAiModel error:", _error);
throw new ChatSDKError(
"bad_request:database",
"Failed to delete AI model"

View File

@ -600,7 +600,9 @@ export const externalImageApiRequestLog = pgTable("ExternalImageApiRequestLog",
apiKeyId: varchar("apiKeyId", { length: 64 }),
apiKeyName: varchar("apiKeyName", { length: 128 }),
ownerUserId: uuid("ownerUserId").references(() => user.id),
modelDbId: uuid("modelDbId").references(() => aiModel.id),
modelDbId: uuid("modelDbId").references(() => aiModel.id, {
onDelete: "set null",
}),
modelRuntimeId: varchar("modelRuntimeId", { length: 128 }),
modelDisplayName: varchar("modelDisplayName", { length: 128 }),
prompt: text("prompt"),