fix image providers and model deletion
This commit is contained in:
parent
f80b3e8117
commit
04972684c3
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
$$;
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user