fix: validate AtlasCloud frontend model requests

This commit is contained in:
134 2026-07-22 10:25:32 +08:00 committed by root
parent 758be188cf
commit 75cecdfcad
9 changed files with 908 additions and 212 deletions

View File

@ -18,6 +18,11 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/heic",
"image/heif",
]);
const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
@ -197,6 +202,9 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
const isAtlasCloudReferenceVideo =
uploadPurpose === "canvas-video-process" &&
ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
@ -227,7 +235,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
if (
!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
!isAtlasCloudReferenceVideo
) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@ -361,18 +372,19 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
mediaType: "image",
mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
sourceType: "upload",
status: "ready",
originalUrl: data.url,
previewUrl: data.url,
thumbnailUrl: data.url,
previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
purpose: uploadPurpose || null,
clientUploadId,
convertedFrom,
},

View File

@ -2197,6 +2197,39 @@ function isLikelyVideoAttachment(url: string, contentType: string) {
);
}
function collectMessageVideoAttachments(message: ChatMessage) {
const attachments: PickedObjectAttachment[] = [];
const seenUrls = new Set<string>();
const append = (value: any, fallbackName: string) => {
const url = String(value?.url || value?.data || "").trim();
if (!isValidChatHttpUrl(url) || seenUrls.has(url)) return;
const contentType = normalizeChatMediaType(
value?.contentType || value?.mediaType || value?.mimeType
);
if (!isLikelyVideoAttachment(url, contentType)) return;
const attachment = sanitizeChatAttachment({
url,
name: normalizeChatAttachmentName(value?.name || fallbackName),
contentType,
assetId:
typeof value?.assetId === "string" && value.assetId.trim()
? value.assetId.trim()
: undefined,
} as PickedObjectAttachment);
if (!attachment) return;
seenUrls.add(url);
attachments.push(attachment);
};
(message.parts || []).forEach((part: any, index: number) => {
if (part?.type === "file") append(part, `参考视频-${index + 1}.mp4`);
});
((message as any).attachments || []).forEach((attachment: any, index: number) => {
append(attachment, `参考视频-${index + 1}.mp4`);
});
return attachments;
}
function buildCopiedMessageAttachment(
value: any,
fallbackName: string
@ -4206,6 +4239,7 @@ export function CanvasChatPanel({
videoToolCapability?: VideoToolCapabilityContract | null;
imageGenConfig?: {
maxImages?: number;
maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@ -4234,6 +4268,7 @@ export function CanvasChatPanel({
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@ -4731,6 +4766,25 @@ export function CanvasChatPanel({
[visualModels]
);
const currentModel = allModels.find((model) => model.id === selectedModelId);
const atlasVideoReferenceLimit =
currentModel?.providerType === "atlascloud"
? Math.max(
0,
Math.min(
4,
Number(
currentModel.capabilities?.maxVideoReferences ||
currentModel.imageGenConfig?.maxVideoReferences ||
0
)
)
)
: 0;
const supportsAtlasVideoReference = atlasVideoReferenceLimit > 0;
const requiresAtlasVideoReference = Boolean(
supportsAtlasVideoReference &&
currentModel?.capabilities?.requiresVideoReference === true
);
const isCanvasVideoModel = useCallback(
(model?: (typeof allModels)[number] | null) =>
Boolean(
@ -11295,6 +11349,20 @@ export function CanvasChatPanel({
let effectiveAttachments = attachments
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
if (supportsAtlasVideoReference) {
let keptVideoCount = 0;
effectiveAttachments = effectiveAttachments.filter((attachment) => {
if (!isLikelyVideoAttachment(attachment.url, attachment.contentType)) {
return true;
}
keptVideoCount += 1;
return keptVideoCount <= atlasVideoReferenceLimit;
});
if (requiresAtlasVideoReference && keptVideoCount === 0) {
toast.error("当前模型需要先上传 1 个参考视频");
return;
}
}
if (hasAgentUploadSlots) {
if (effectiveAttachments.length > agentRequiredUploadCount) {
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
@ -11918,6 +11986,57 @@ export function CanvasChatPanel({
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const uploadVideoFileForComposerAttachment = useCallback(async (
file: File,
progressOptions?: CanvasUploadProgressOptions
): Promise<ComposerUploadedImage | null> => {
if (!file.type.startsWith("video/")) {
toast.error("请选择视频文件");
return null;
}
if (file.size > 15 * 1024 * 1024) {
toast.error("参考视频大小不能超过 15MB");
return null;
}
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
if (progressOptions) {
updateComposerUploadProgress(progressOptions);
}
const response = await fetchUploadWithRetry(
formData,
progressOptions
? {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
}
: undefined
);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
clearComposerUploadProgress(250);
return {
imageUrl: String(data.url || ""),
name: file.name || `参考视频-${Date.now()}.mp4`,
contentType: file.type || "video/mp4",
assetId:
typeof data.assetId === "string" && data.assetId.trim()
? data.assetId.trim()
: undefined,
};
} catch (_error) {
toast.error("参考视频上传失败");
return null;
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@ -12250,6 +12369,58 @@ export function CanvasChatPanel({
e.target.value = "";
return;
}
if (supportsAtlasVideoReference) {
const selectedVideoFiles = selectedFiles.filter((file) =>
file.type.startsWith("video/")
);
const existingVideoCount = attachments.filter((attachment) =>
isLikelyVideoAttachment(attachment.url, attachment.contentType)
).length;
const availableVideoSlots = Math.max(
0,
atlasVideoReferenceLimit - existingVideoCount
);
const acceptedVideoFiles = selectedVideoFiles.slice(0, availableVideoSlots);
if (selectedVideoFiles.length > acceptedVideoFiles.length) {
toast.info(`当前模型最多支持 ${atlasVideoReferenceLimit} 个参考视频`);
}
if (acceptedVideoFiles.length > 0) {
let uploadedCount = 0;
try {
for (const [index, file] of acceptedVideoFiles.entries()) {
const uploaded = await uploadVideoFileForComposerAttachment(file, {
title: "正在上传参考视频",
fileName: file.name,
fileIndex: index,
fileTotal: acceptedVideoFiles.length,
target: "composer-reference",
});
if (!uploaded?.imageUrl) continue;
addUploadedImageAttachmentToComposer(uploaded);
uploadedCount += 1;
}
} finally {
clearComposerUploadProgress(450);
}
if (uploadedCount > 0) {
toast.success(
uploadedCount === 1
? "参考视频已添加到对话"
: `已添加 ${uploadedCount} 个参考视频`
);
}
}
selectedFiles = selectedFiles.filter(
(file) => !file.type.startsWith("video/")
);
if (selectedFiles.length === 0) {
e.target.value = "";
return;
}
}
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
@ -13802,6 +13973,10 @@ export function CanvasChatPanel({
message.role === "user"
? collectMessageImageAttachments(message as any)
: [];
const userVideoAttachments =
message.role === "user"
? collectMessageVideoAttachments(message as any)
: [];
const assistantTextParts = message.role === "assistant"
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
: [];
@ -13954,6 +14129,25 @@ export function CanvasChatPanel({
);
});
userVideoAttachments.forEach((attachment, idx) => {
renderedUserParts.push(
<span
key={`video-${attachment.url}-${idx}`}
className={cn(
"inline-flex items-center gap-1 rounded-[var(--ui-radius-control)] px-1.5 py-1 text-[11px]",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
<Video className="h-4 w-4 shrink-0" />
<span className="max-w-[72px] truncate text-current">
{attachment.name || "参考视频"}
</span>
</span>
);
});
message.parts?.forEach((part: any, idx: number) => {
if (part.type === 'text' && part.text) {
const text = String(part.text)
@ -15619,8 +15813,8 @@ export function CanvasChatPanel({
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title="添加图片"
aria-label="添加图片"
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
>
<Plus className="h-4 w-4" />
{composerReferenceUploadProgress ? (
@ -15926,7 +16120,13 @@ export function CanvasChatPanel({
<input
ref={fileInputRef}
type="file"
accept={isClawdMode ? `image/*,${CLAWD_TABLE_ACCEPT}` : "image/*"}
accept={
isClawdMode
? `image/*,${CLAWD_TABLE_ACCEPT}`
: supportsAtlasVideoReference
? "image/*,video/mp4,video/quicktime,video/webm"
: "image/*"
}
multiple
className="hidden"
onChange={handleFileSelect}

View File

@ -17,6 +17,10 @@ const ALLOWED_CHAT_MEDIA_TYPES = new Set([
"image/jpg",
"image/bmp",
"image/svg+xml",
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-matroska",
]);
const GENERIC_CANVAS_PROMPT_SET = new Set([

View File

@ -60,11 +60,18 @@ export interface AtlasCloudRuntimeConfig {
}>;
imageInputKey?: string;
imageInputMode?: "array" | "single";
videoClipInput?: {
key: string;
maxItems: number;
required: boolean;
itemDefaults: Record<string, string | number | boolean>;
};
resolutionKey?: string;
aspectRatioKey?: string;
outputCountKey?: string;
aspectRatioValueMap: Record<string, string>;
maxReferenceImages: number;
maxVideoReferences: number;
maxImages: number;
defaults: Record<string, string | number | boolean>;
enums: Record<string, Array<string | number>>;
@ -196,6 +203,39 @@ function getImageInputs(properties: JsonRecord, requiredFields: string[]) {
.sort((a, b) => Number(b.required) - Number(a.required));
}
function getVideoClipInput(properties: JsonRecord, requiredFields: string[]) {
const entry = Object.entries(properties).find(([key, property]) => {
const normalizedKey = key.toLowerCase();
const record = asRecord(property);
const itemProperties = asRecord(asRecord(record.items).properties);
return (
record.type === "array" &&
normalizedKey.includes("video") &&
("url" in itemProperties || normalizedKey.includes("clip"))
);
});
if (!entry) return undefined;
const [key, property] = entry;
const record = asRecord(property);
const itemProperties = asRecord(asRecord(record.items).properties);
const itemDefaults: Record<string, string | number | boolean> = {};
for (const [itemKey, itemProperty] of Object.entries(itemProperties)) {
if (itemKey === "url") continue;
const value = asRecord(itemProperty).default;
if (["string", "number", "boolean"].includes(typeof value)) {
itemDefaults[itemKey] = value;
}
}
return {
key,
maxItems: Math.max(1, Math.min(4, Number(record.maxItems || 1))),
required: requiredFields.includes(key),
itemDefaults,
};
}
function getEnumValues(property: unknown) {
const record = asRecord(property);
const values = Array.isArray(record.enum) ? record.enum : [];
@ -396,6 +436,7 @@ export function createAtlasCloudImagePreset(
const properties = asRecord(input.properties);
const requiredFields = uniqueStrings(Array.isArray(input.required) ? input.required : []);
const imageInputs = getImageInputs(properties, requiredFields);
const videoClipInput = getVideoClipInput(properties, requiredFields);
const imageInputKey = imageInputs[0]?.key;
const resolutionKey = RESOLUTION_KEYS.find((key) => {
if (!(key in properties)) return false;
@ -420,6 +461,7 @@ export function createAtlasCloudImagePreset(
14,
imageInputs.reduce((total, input) => total + input.maxItems, 0)
);
const maxVideoReferences = videoClipInput?.maxItems || 0;
const maxImages = deriveMaxImages(properties, outputCountKey);
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
@ -436,6 +478,7 @@ export function createAtlasCloudImagePreset(
categories.some((value) => value.includes("image-to-image"))
? ["image-edit", "reference-images"]
: []),
...(videoClipInput ? ["reference-video"] : []),
];
if (mediaKind === "image" && supportedModes.length === 0) {
supportedModes.push("text-to-image");
@ -449,11 +492,13 @@ export function createAtlasCloudImagePreset(
imageInputs,
imageInputKey,
imageInputMode: imageProperty.type === "array" ? "array" : "single",
videoClipInput,
resolutionKey,
aspectRatioKey,
outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map,
maxReferenceImages,
maxVideoReferences,
maxImages,
defaults: buildScalarDefaults(properties),
enums: buildEnums(properties),
@ -495,7 +540,10 @@ export function createAtlasCloudImagePreset(
mediaType: mediaKind,
supportedModes,
maxReferenceImages,
maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1,
supportsVideoReference: maxVideoReferences > 0,
requiresVideoReference: videoClipInput?.required === true,
provider: "atlascloud",
schemaUrl: catalogModel.schema || null,
},
@ -509,6 +557,7 @@ export function createAtlasCloudImagePreset(
apiFormat: "atlascloud-unified-image",
maxImages,
maxReferenceImages,
maxVideoReferences,
supportsSize,
supports1K: supportedImageSizes.includes("1K"),
supports2K: supportedImageSizes.includes("2K"),

View File

@ -0,0 +1,209 @@
import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
export interface AtlasCloudImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
promptExtend?: boolean;
quality?: string;
seed?: number;
watermark?: boolean;
}
export interface AtlasCloudModelConfig {
maxReferenceImages?: number;
maxVideoReferences?: number;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
atlasCloud?: AtlasCloudRuntimeConfig;
}
function normalizeAspectRatio(
requested: string | undefined,
supported: string[] | undefined
) {
const values = (supported || []).map(String);
const normalized = String(requested || "auto").trim().toLowerCase();
const exact = values.find((value) => value.toLowerCase() === normalized);
if (exact) return exact;
return (
values.find((value) => value === "auto") ||
values.find((value) => value === "1:1") ||
values[0] ||
"1:1"
);
}
function normalizeImageSize(requested: string | undefined, supported?: string[]) {
const values = (supported || []).map((value) => String(value).trim().toUpperCase());
const normalized = String(requested || "2K").trim().toUpperCase();
if (values.includes(normalized)) return normalized;
return values.includes("2K") ? "2K" : values[0] || normalized;
}
function matchEnumValue(values: Array<string | number> | undefined, value: unknown) {
if (!values?.length) return value;
const normalized = String(value || "").trim().toLowerCase();
return values.find(
(item) => String(item).trim().toLowerCase() === normalized
);
}
function parseRatio(value: string) {
const match = value.match(/^(\d+):(\d+)$/);
if (!match) return 1;
return Number(match[1]) / Number(match[2]);
}
function pickClosestSizeValue(
map: Record<string, Record<string, string>>,
imageSize: string,
aspectRatio: string
) {
const tiers = [imageSize, "2K", "1K", "4K"];
const values = tiers.map((tier) => map[tier]).find(Boolean);
if (!values) return undefined;
if (values[aspectRatio]) return values[aspectRatio];
const target = parseRatio(aspectRatio);
return Object.entries(values).sort(
([a], [b]) =>
Math.abs(Math.log(target / parseRatio(a))) -
Math.abs(Math.log(target / parseRatio(b)))
)[0]?.[1];
}
export function createAtlasCloudRequestBody(params: {
modelId: string;
prompt: string;
referenceImages: string[];
referenceVideos?: string[];
imageParams?: AtlasCloudImageParams;
modelConfig: AtlasCloudModelConfig;
}) {
const runtime = params.modelConfig.atlasCloud;
if (!runtime) {
throw new Error("Atlas Cloud 模型运行参数缺失,请重新同步模型");
}
const body: Record<string, unknown> = { ...runtime.defaults };
if (
runtime.propertyNames.includes("model") ||
runtime.requiredFields.includes("model")
) {
body.model = params.modelId;
}
if (
runtime.propertyNames.includes("prompt") ||
runtime.requiredFields.includes("prompt")
) {
body.prompt = params.prompt;
}
const aspectRatio = normalizeAspectRatio(
params.imageParams?.aspectRatio,
params.modelConfig.supportedAspectRatios
);
const imageSize = normalizeImageSize(
params.imageParams?.imageSize,
params.modelConfig.supportedImageSizes
);
if (runtime.aspectRatioKey) {
body[runtime.aspectRatioKey] =
runtime.aspectRatioValueMap?.[aspectRatio] ||
matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) ||
aspectRatio;
}
if (runtime.resolutionKey) {
body[runtime.resolutionKey] =
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
imageSize.toLowerCase();
} else {
const sizeValue = pickClosestSizeValue(
runtime.sizeValueMap,
imageSize,
aspectRatio === "auto" ? "1:1" : aspectRatio
);
if (sizeValue) body.size = sizeValue;
}
const imageInputs = runtime.imageInputs?.length
? runtime.imageInputs
: runtime.imageInputKey
? [
{
key: runtime.imageInputKey,
mode: runtime.imageInputMode || ("single" as const),
maxItems: Math.max(1, runtime.maxReferenceImages || 1),
required: runtime.requiredFields.includes(runtime.imageInputKey),
},
]
: [];
let referenceCursor = 0;
for (const input of imageInputs) {
const values = params.referenceImages.slice(
referenceCursor,
referenceCursor + input.maxItems
);
if (values.length > 0) {
body[input.key] = input.mode === "single" ? values[0] : values;
referenceCursor += values.length;
} else if (input.required) {
throw new Error(
`该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
);
}
}
if (imageInputs.length === 0 && params.referenceImages.length > 0) {
throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
}
const referenceVideos = params.referenceVideos || [];
if (runtime.videoClipInput) {
const values = referenceVideos.slice(0, runtime.videoClipInput.maxItems);
if (values.length > 0) {
body[runtime.videoClipInput.key] = values.map((url) => ({
...runtime.videoClipInput?.itemDefaults,
url,
}));
} else if (runtime.videoClipInput.required) {
throw new Error("该 Atlas Cloud 模型至少需要 1 个参考视频");
}
} else if (referenceVideos.length > 0) {
throw new Error("当前 Atlas Cloud 模型不支持参考视频");
}
if (runtime.outputCountKey) {
const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
}
const optionalParams: Array<[string, unknown]> = [
["negative_prompt", params.imageParams?.negativePrompt],
["prompt_extend", params.imageParams?.promptExtend],
["quality", params.imageParams?.quality],
["seed", params.imageParams?.seed],
["watermark", params.imageParams?.watermark],
];
for (const [key, value] of optionalParams) {
if (value !== undefined && runtime.propertyNames.includes(key)) {
body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
}
}
if (runtime.propertyNames.includes("enable_sync_mode")) {
body.enable_sync_mode = false;
}
if (runtime.propertyNames.includes("enable_base64_output")) {
body.enable_base64_output = false;
}
const unsupportedRequiredFields = runtime.requiredFields.filter(
(key) => body[key] === undefined
);
if (unsupportedRequiredFields.length > 0) {
throw new Error(
`当前界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
);
}
return { body, imageSize };
}

View File

@ -1,10 +1,12 @@
import "server-only";
import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
ATLASCLOUD_BASE_URL,
type AtlasCloudRuntimeConfig,
} from "@/lib/ai/atlascloud-models";
createAtlasCloudRequestBody,
type AtlasCloudImageParams,
type AtlasCloudModelConfig,
} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
@ -13,24 +15,6 @@ interface AtlasCloudConfig {
baseURL?: string;
}
interface AtlasCloudImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
negativePrompt?: string;
promptExtend?: boolean;
quality?: string;
seed?: number;
watermark?: boolean;
}
interface AtlasCloudModelConfig {
maxReferenceImages?: number;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
atlasCloud?: AtlasCloudRuntimeConfig;
}
interface UploadedImage {
thumbnail: string;
original: string;
@ -116,18 +100,19 @@ function extractOutputUrls(payload: any) {
return urls;
}
function extractPromptAndImages(
function extractPromptAndMedia(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const message = messages.filter((item) => item.role === "user").pop();
if (!message) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushImage = (value: unknown) => {
const pushMedia = (value: unknown, mediaType?: unknown) => {
const url = String(
typeof value === "string"
? value
@ -137,24 +122,31 @@ function extractPromptAndImages(
(value as any)?.src ||
""
).trim();
if (
url &&
(/^https?:\/\//i.test(url) || url.startsWith("data:image/")) &&
!imageUrls.includes(url)
) {
imageUrls.push(url);
}
if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
const normalizedMediaType = String(mediaType || "").toLowerCase();
const isVideo =
normalizedMediaType.startsWith("video/") ||
url.startsWith("data:video/") ||
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
const target = isVideo ? videoUrls : imageUrls;
if (!target.includes(url)) target.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") return pushText(part);
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
if (part.type === "image_url") pushImage(part.image_url);
if (part.type === "image") pushImage(part.image || part.url);
if (part.type === "file") pushImage(part.url || part.file || part.data);
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
if (part.type === "file") {
pushMedia(
part.url || part.file || part.data,
part.mediaType || part.contentType || part.mimeType
);
}
if (part.inlineData?.data) {
pushImage(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
pushMedia(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
part.inlineData.mimeType
);
}
};
@ -165,31 +157,31 @@ function extractPromptAndImages(
const prompt = texts.join("\n").trim();
if (!prompt) throw new Error("请输入图片生成或编辑要求");
return { prompt, imageUrls };
return { prompt, imageUrls, videoUrls };
}
function parseDataUrl(value: string) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) throw new Error("Atlas Cloud 参考图 Base64 格式无效");
if (!match) throw new Error("Atlas Cloud 参考素材 Base64 格式无效");
return {
mimeType: match[1] || "image/png",
buffer: Buffer.from(match[2] || "", "base64"),
};
}
async function loadImageSource(imageUrl: string) {
if (imageUrl.startsWith("data:")) return parseDataUrl(imageUrl);
const response = await fetch(imageUrl, {
async function loadMediaSource(mediaUrl: string) {
if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl);
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
Accept: "image/*,video/*,*/*;q=0.8",
"User-Agent": "yedir-ai-atlascloud/1.0",
},
});
if (!response.ok) {
throw new Error(`Atlas Cloud 参考图下载失败(HTTP ${response.status})`);
throw new Error(`Atlas Cloud 参考素材下载失败(HTTP ${response.status})`);
}
return {
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
@ -201,16 +193,19 @@ function detectExtension(mimeType: string) {
if (/jpe?g/i.test(mimeType)) return "jpg";
if (/webp/i.test(mimeType)) return "webp";
if (/gif/i.test(mimeType)) return "gif";
if (/quicktime/i.test(mimeType)) return "mov";
if (/webm/i.test(mimeType)) return "webm";
if (/mp4/i.test(mimeType)) return "mp4";
return "png";
}
async function uploadReferenceImage(
async function uploadReferenceMedia(
baseURL: string | undefined,
apiKey: string,
imageUrl: string
mediaUrl: string
) {
if (/^https:\/\//i.test(imageUrl)) return imageUrl;
const source = await loadImageSource(imageUrl);
if (/^https:\/\//i.test(mediaUrl)) return mediaUrl;
const source = await loadMediaSource(mediaUrl);
const form = new FormData();
form.append(
"file",
@ -226,171 +221,17 @@ async function uploadReferenceImage(
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(
`${extractErrorMessage(payload, "Atlas Cloud 参考图上传失败")}(HTTP ${response.status})`
`${extractErrorMessage(payload, "Atlas Cloud 参考素材上传失败")}(HTTP ${response.status})`
);
}
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
const url = String(
data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || ""
).trim();
if (!url) throw new Error("Atlas Cloud 参考图上传成功但未返回文件地址");
if (!url) throw new Error("Atlas Cloud 参考素材上传成功但未返回文件地址");
return url;
}
function normalizeAspectRatio(
requested: string | undefined,
supported: string[] | undefined
) {
const values = (supported || []).map(String);
const normalized = String(requested || "auto").trim().toLowerCase();
const exact = values.find((value) => value.toLowerCase() === normalized);
if (exact) return exact;
return values.find((value) => value === "auto") || values.find((value) => value === "1:1") || values[0] || "1:1";
}
function normalizeImageSize(requested: string | undefined, supported?: string[]) {
const values = (supported || []).map((value) => String(value).trim().toUpperCase());
const normalized = String(requested || "2K").trim().toUpperCase();
if (values.includes(normalized)) return normalized;
return values.includes("2K") ? "2K" : values[0] || normalized;
}
function matchEnumValue(values: Array<string | number> | undefined, value: unknown) {
if (!values?.length) return value;
const normalized = String(value || "").trim().toLowerCase();
return values.find((item) => String(item).trim().toLowerCase() === normalized);
}
function parseRatio(value: string) {
const match = value.match(/^(\d+):(\d+)$/);
if (!match) return 1;
return Number(match[1]) / Number(match[2]);
}
function pickClosestSizeValue(
map: Record<string, Record<string, string>>,
imageSize: string,
aspectRatio: string
) {
const tiers = [imageSize, "2K", "1K", "4K"];
const values = tiers.map((tier) => map[tier]).find(Boolean);
if (!values) return undefined;
if (values[aspectRatio]) return values[aspectRatio];
const target = parseRatio(aspectRatio);
return Object.entries(values).sort(
([a], [b]) =>
Math.abs(Math.log(target / parseRatio(a))) -
Math.abs(Math.log(target / parseRatio(b)))
)[0]?.[1];
}
function createRequestBody(params: {
modelId: string;
prompt: string;
referenceImages: string[];
imageParams?: AtlasCloudImageParams;
modelConfig: AtlasCloudModelConfig;
}) {
const runtime = params.modelConfig.atlasCloud;
if (!runtime) throw new Error("Atlas Cloud 模型运行参数缺失,请重新同步模型");
const body: Record<string, unknown> = {
...runtime.defaults,
};
if (runtime.propertyNames.includes("model")) body.model = params.modelId;
if (runtime.propertyNames.includes("prompt")) body.prompt = params.prompt;
const aspectRatio = normalizeAspectRatio(
params.imageParams?.aspectRatio,
params.modelConfig.supportedAspectRatios
);
const imageSize = normalizeImageSize(
params.imageParams?.imageSize,
params.modelConfig.supportedImageSizes
);
if (runtime.aspectRatioKey) {
body[runtime.aspectRatioKey] =
runtime.aspectRatioValueMap?.[aspectRatio] ||
matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) || aspectRatio;
}
if (runtime.resolutionKey) {
const resolutionValue =
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
imageSize.toLowerCase();
body[runtime.resolutionKey] = resolutionValue;
} else {
const sizeValue = pickClosestSizeValue(
runtime.sizeValueMap,
imageSize,
aspectRatio === "auto" ? "1:1" : aspectRatio
);
if (sizeValue) body.size = sizeValue;
}
const imageInputs = runtime.imageInputs?.length
? runtime.imageInputs
: runtime.imageInputKey
? [
{
key: runtime.imageInputKey,
mode: runtime.imageInputMode || "single",
maxItems: Math.max(1, runtime.maxReferenceImages || 1),
required: runtime.requiredFields.includes(runtime.imageInputKey),
},
]
: [];
let referenceCursor = 0;
for (const input of imageInputs) {
const values = params.referenceImages.slice(
referenceCursor,
referenceCursor + input.maxItems
);
if (values.length > 0) {
body[input.key] = input.mode === "single" ? values[0] : values;
referenceCursor += values.length;
} else if (input.required) {
throw new Error(
`该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
);
}
}
if (imageInputs.length === 0 && params.referenceImages.length > 0) {
throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
}
if (runtime.outputCountKey) {
const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
}
const optionalParams: Array<[string, unknown]> = [
["negative_prompt", params.imageParams?.negativePrompt],
["prompt_extend", params.imageParams?.promptExtend],
["quality", params.imageParams?.quality],
["seed", params.imageParams?.seed],
["watermark", params.imageParams?.watermark],
];
for (const [key, value] of optionalParams) {
if (value !== undefined && runtime.propertyNames.includes(key)) {
body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
}
}
if (runtime.propertyNames.includes("enable_sync_mode")) {
body.enable_sync_mode = false;
}
if (runtime.propertyNames.includes("enable_base64_output")) {
body.enable_base64_output = false;
}
const unsupportedRequiredFields = runtime.requiredFields.filter(
(key) => body[key] === undefined
);
if (unsupportedRequiredFields.length > 0) {
throw new Error(
`当前界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
);
}
return { body, imageSize };
}
async function submitTask(params: {
apiKey: string;
baseURL?: string;
@ -540,7 +381,9 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
if (!modelConfig.atlasCloud) {
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
}
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(
options.messages
);
const maxReferenceImages = Math.max(
0,
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
@ -548,12 +391,27 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
const referenceImages = await Promise.all(
imageUrls
.slice(0, maxReferenceImages)
.map((url) => uploadReferenceImage(baseURL, apiKey, url))
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
const { body, imageSize } = createRequestBody({
const maxVideoReferences = Math.max(
0,
Math.min(
4,
modelConfig.maxVideoReferences ??
modelConfig.atlasCloud.maxVideoReferences ??
0
)
);
const referenceVideos = await Promise.all(
videoUrls
.slice(0, maxVideoReferences)
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
const { body, imageSize } = createAtlasCloudRequestBody({
modelId: runtimeModelId,
prompt,
referenceImages,
referenceVideos,
imageParams: options.imageParams,
modelConfig,
});

View File

@ -0,0 +1,92 @@
import { and, eq } from "drizzle-orm";
import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema";
async function main() {
const providers = await db
.select()
.from(aiProvider)
.where(and(eq(aiProvider.type, "atlascloud"), eq(aiProvider.isEnabled, true)));
if (providers.length === 0) {
throw new Error("没有找到已启用的 AtlasCloud 服务商");
}
const catalog = await fetchAtlasCloudImagePresets();
if (catalog.errors.length > 0) {
throw new Error(
`AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
.map((item) => item.modelId)
.join(", ")}`
);
}
const summaries = [];
for (const provider of providers) {
let added = 0;
let updated = 0;
for (const preset of catalog.presets) {
const existing = await db.query.aiModel.findFirst({
where: and(
eq(aiModel.providerId, provider.id),
eq(aiModel.modelId, preset.modelId)
),
});
const values = {
displayName: preset.displayName,
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
updatedAt: new Date(),
};
if (existing) {
await db
.update(aiModel)
.set(values)
.where(eq(aiModel.id, existing.id));
updated += 1;
} else {
await db.insert(aiModel).values({
providerId: provider.id,
modelId: preset.modelId,
...values,
isDefault: false,
isSubModel: false,
parentModelId: null,
});
added += 1;
}
}
summaries.push({
providerId: provider.id,
providerName: provider.name,
added,
updated,
});
}
console.log(
JSON.stringify(
{
catalogModels: catalog.catalogCount,
availableModels: catalog.presets.length,
providers: summaries,
},
null,
2
)
);
}
void main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

View File

@ -0,0 +1,204 @@
import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
import { createAtlasCloudRequestBody } from "../lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "../lib/ai/model-classification";
const FAKE_IMAGES = Array.from(
{ length: 14 },
(_, index) => `https://example.com/reference-${index + 1}.png`
);
const FAKE_VIDEOS = Array.from(
{ length: 4 },
(_, index) => `https://example.com/reference-${index + 1}.mp4`
);
type Failure = {
modelId: string;
scenario: string;
error: string;
};
function validateBody(
body: Record<string, unknown>,
runtime: any
) {
const missing = runtime.requiredFields.filter(
(key: string) => body[key] === undefined
);
if (missing.length > 0) {
throw new Error(`缺少必填参数:${missing.join(", ")}`);
}
for (const [key, values] of Object.entries(
runtime.enums as Record<string, Array<string | number>>
)) {
if (body[key] === undefined || !values.length) continue;
if (!values.some((value) => String(value) === String(body[key]))) {
throw new Error(`${key}=${String(body[key])} 不在官方枚举中`);
}
}
if (runtime.videoClipInput && body[runtime.videoClipInput.key] !== undefined) {
const clips = body[runtime.videoClipInput.key];
if (!Array.isArray(clips) || clips.length === 0) {
throw new Error("video_clips 请求格式无效");
}
for (const clip of clips) {
if (!clip || typeof clip !== "object" || !("url" in clip)) {
throw new Error("video_clips 缺少 url");
}
}
}
}
async function main() {
const catalog = await fetchAtlasCloudImagePresets();
const failures: Failure[] = catalog.errors.map((item) => ({
modelId: item.modelId,
scenario: "catalog-schema",
error: item.error,
}));
let simulatedCases = 0;
let imageModels = 0;
let model3dModels = 0;
let videoReferenceModels = 0;
for (const preset of catalog.presets) {
const capabilities = preset.capabilities as Record<string, any>;
const descriptor = {
providerType: "atlascloud",
modelId: preset.modelId,
capabilities,
supportsImageGen: preset.supportsImageGen,
};
const mediaType = String(capabilities.mediaType || "");
if (mediaType === "3d") {
model3dModels += 1;
simulatedCases += 1;
if (
!is3DModelDescriptor(descriptor) ||
isImageGenerationModelDescriptor(descriptor) ||
isVideoModelDescriptor(descriptor)
) {
failures.push({
modelId: preset.modelId,
scenario: "3d-classification",
error: "3D 模型被错误归入图片或视频模型",
});
}
continue;
}
imageModels += 1;
if (
!isImageGenerationModelDescriptor(descriptor) ||
is3DModelDescriptor(descriptor) ||
isVideoModelDescriptor(descriptor)
) {
failures.push({
modelId: preset.modelId,
scenario: "image-classification",
error: "图片模型分类不正确",
});
continue;
}
const imageGenConfig = preset.imageGenConfig as Record<string, any>;
const runtime = imageGenConfig.atlasCloud;
if (!runtime) {
failures.push({
modelId: preset.modelId,
scenario: "runtime-config",
error: "缺少 AtlasCloud 运行配置",
});
continue;
}
const ratios = imageGenConfig.supportedAspectRatios?.length
? imageGenConfig.supportedAspectRatios
: [String(preset.defaultParams.aspectRatio || "1:1")];
const sizes = imageGenConfig.supportedImageSizes?.length
? imageGenConfig.supportedImageSizes
: [String(preset.defaultParams.imageSize || "1K")];
const counts = runtime.outputCountKey && runtime.maxImages > 1
? [1, runtime.maxImages]
: [1];
const requiredImageCount = runtime.imageInputs
.filter((input: any) => input.required)
.reduce((total: number, input: any) => total + input.maxItems, 0);
const imageReferenceCounts = new Set<number>([
requiredImageCount,
...(runtime.maxReferenceImages > requiredImageCount
? [runtime.maxReferenceImages]
: []),
]);
const requiredVideoCount = runtime.videoClipInput?.required
? runtime.videoClipInput.maxItems
: 0;
const videoReferenceCounts = new Set<number>([
requiredVideoCount,
...(runtime.maxVideoReferences > requiredVideoCount
? [runtime.maxVideoReferences]
: []),
]);
if (runtime.maxVideoReferences > 0) videoReferenceModels += 1;
for (const ratio of ratios) {
for (const size of sizes) {
for (const imageCount of counts) {
for (const referenceImageCount of imageReferenceCounts) {
for (const referenceVideoCount of videoReferenceCounts) {
const scenario = [
`ratio=${ratio}`,
`size=${size}`,
`count=${imageCount}`,
`images=${referenceImageCount}`,
`videos=${referenceVideoCount}`,
].join(",");
simulatedCases += 1;
try {
const { body } = createAtlasCloudRequestBody({
modelId: preset.modelId,
prompt: "AtlasCloud 前端请求模拟测试",
referenceImages: FAKE_IMAGES.slice(0, referenceImageCount),
referenceVideos: FAKE_VIDEOS.slice(0, referenceVideoCount),
imageParams: {
aspectRatio: ratio,
imageSize: size,
imageCount,
},
modelConfig: imageGenConfig,
});
validateBody(body, runtime);
} catch (error) {
failures.push({
modelId: preset.modelId,
scenario,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
}
}
}
}
const summary = {
catalogModels: catalog.catalogCount,
availableModels: catalog.presets.length,
imageModels,
model3dModels,
videoReferenceModels,
simulatedCases,
passedCases: simulatedCases - failures.length,
failedCases: failures.length,
failures,
};
console.log(JSON.stringify(summary, null, 2));
if (failures.length > 0) process.exitCode = 1;
}
void main();

View File

@ -3,6 +3,7 @@ import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
} from "@/lib/ai/atlascloud-models";
import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
@ -120,4 +121,71 @@ test.describe("AI model media classification", () => {
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
});
test("maps Atlas reference-video image models to a valid frontend request", () => {
const preset = createAtlasCloudImagePreset(
{
model: "google/nano-banana-2/reference-to-image",
type: "Image",
displayName: "Nano Banana 2 Reference to Image",
categories: ["IMAGE-TO-IMAGE"],
},
{
paths: {
"/api/v1/model/generateImage": { post: {} },
"/api/v1/model/prediction/{request_id}": { get: {} },
},
components: {
schemas: {
Input: {
type: "object",
properties: {
model: { type: "string" },
prompt: { type: "string" },
resolution: { type: "string", enum: ["1k", "2k", "4k"] },
video_clips: {
type: "array",
minItems: 1,
maxItems: 1,
items: {
type: "object",
properties: {
url: { type: "string" },
start: { type: "number", default: 0 },
ends: { type: "number", default: 0 },
fps: { type: "number", default: 1 },
},
},
},
},
required: ["model", "prompt", "video_clips"],
},
},
},
}
);
expect(preset.capabilities).toMatchObject({
mediaType: "image",
supportsVideoReference: true,
requiresVideoReference: true,
maxVideoReferences: 1,
});
const { body } = createAtlasCloudRequestBody({
modelId: preset.modelId,
prompt: "参考视频生成商品图",
referenceImages: [],
referenceVideos: ["https://example.com/reference.mp4"],
imageParams: { imageSize: "2K", aspectRatio: "1:1" },
modelConfig: preset.imageGenConfig,
});
expect(body.video_clips).toEqual([
{
url: "https://example.com/reference.mp4",
start: 0,
ends: 0,
fps: 1,
},
]);
});
});