ai-chatbot/lib/ai/atlascloud-models.ts

651 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
export const ATLASCLOUD_CATALOG_URL =
"https://console.atlascloud.ai/api/v1/models";
const COMMON_RATIO_ORDER = [
"auto",
"1:1",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"21:9",
];
const IMAGE_INPUT_KEYS = [
"images",
"image_urls",
"input_images",
"reference_images",
"reference_image_urls",
"image",
"Image",
"image_url",
"input_image",
"reference_image",
];
const RESOLUTION_KEYS = ["resolution", "image_size", "output_resolution"];
const ASPECT_RATIO_KEYS = ["aspect_ratio", "aspectRatio"];
const OUTPUT_COUNT_KEYS = ["num_images", "number_of_images", "n", "output_count"];
export interface AtlasCloudCatalogModel {
uuid?: string;
model: string;
type?: string;
displayName?: string;
profile?: string;
logo?: string;
avatar?: string;
schema?: string;
priority?: number;
categories?: string[];
tags?: string[];
display_console?: boolean;
}
export interface AtlasCloudRuntimeConfig {
submitEndpoint: string;
pollEndpoint: string;
propertyNames: string[];
requiredFields: string[];
imageInputs: Array<{
key: string;
mode: "array" | "single";
maxItems: number;
required: boolean;
}>;
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>>;
sizeValueMap: Record<string, Record<string, string>>;
}
export interface AtlasCloudImageModelPreset {
modelId: string;
displayName: string;
avatarUrl?: string;
description: string;
supportsVision: boolean;
supportsImageGen: boolean;
sortOrder: number;
capabilities: Record<string, unknown>;
defaultParams: Record<string, unknown>;
imageGenConfig: Record<string, unknown>;
}
type JsonRecord = Record<string, any>;
export type AtlasCloudMediaKind = "image" | "video" | "3d";
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
: {};
}
function uniqueStrings(values: unknown[]) {
return Array.from(
new Set(values.map((value) => String(value || "").trim()).filter(Boolean))
);
}
function normalizeIntentToken(value: unknown) {
return String(value || "")
.trim()
.toLowerCase()
.replaceAll("_", "-");
}
export function inferAtlasCloudMediaKind(
catalogModel: AtlasCloudCatalogModel
): AtlasCloudMediaKind {
const catalogType = normalizeIntentToken(catalogModel.type);
const intentTokens = uniqueStrings([
catalogModel.model,
catalogModel.displayName,
...(catalogModel.categories || []),
...(catalogModel.tags || []),
]).map(normalizeIntentToken);
const intentText = intentTokens.join(" ");
if (
/(?:^|[\s/.-])(?:text|image)-to-3d(?:$|[\s/.-])/.test(intentText) ||
/(?:^|[\s/.-])(?:hunyuan3d|seed3d|tripo-h3(?:\.\d+)?)(?:$|[\s/.-])/.test(
intentText
)
) {
return "3d";
}
if (
catalogType === "video" ||
intentTokens.some((token) =>
[
"text-to-video",
"image-to-video",
"video-to-video",
"audio-to-video",
].includes(token)
)
) {
return "video";
}
return "image";
}
function derive3DSupportedModes(catalogModel: AtlasCloudCatalogModel) {
const intentText = uniqueStrings([
catalogModel.model,
catalogModel.displayName,
...(catalogModel.categories || []),
...(catalogModel.tags || []),
])
.map(normalizeIntentToken)
.join(" ");
const modes = [
...(intentText.includes("text-to-3d") ? ["text-to-3d"] : []),
...(intentText.includes("image-to-3d") ? ["image-to-3d"] : []),
];
return modes.length > 0 ? modes : ["3d"];
}
function findFirstKey(properties: JsonRecord, candidates: string[]) {
return candidates.find((key) => key in properties);
}
function getImageInputs(properties: JsonRecord, requiredFields: string[]) {
return Object.entries(properties)
.filter(([key, property]) => {
const normalizedKey = key.toLowerCase();
const record = asRecord(property);
const acceptsMediaValue = record.type === "string" || record.type === "array";
return (
IMAGE_INPUT_KEYS.includes(key) ||
(acceptsMediaValue &&
normalizedKey.includes("image") &&
!/(?:size|count|format|resolution|width|height)/.test(normalizedKey)) ||
(acceptsMediaValue &&
String(record["x-format"] || "").toLowerCase() === "image")
);
})
.map(([key, property]) => {
const record = asRecord(property);
const mode = record.type === "array" ? "array" : "single";
return {
key,
mode,
maxItems:
mode === "array"
? Math.max(1, Math.min(14, Number(record.maxItems || 14)))
: 1,
required: requiredFields.includes(key),
} as const;
})
.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 : [];
return values.filter(
(value) => typeof value === "string" || typeof value === "number"
) as Array<string | number>;
}
function getPostEndpoint(schema: JsonRecord) {
const paths = asRecord(schema.paths);
return (
Object.entries(paths).find(([, value]) => asRecord(value).post)?.[0] ||
"/api/v1/model/generateImage"
);
}
function getPollEndpoint(schema: JsonRecord) {
const paths = asRecord(schema.paths);
return (
Object.entries(paths).find(
([path, value]) =>
path.includes("{") && Boolean(asRecord(value).get)
)?.[0] || "/api/v1/model/prediction/{request_id}"
);
}
function getInputSchema(schema: JsonRecord) {
const components = asRecord(schema.components);
const schemas = asRecord(components.schemas);
return asRecord(schemas.Input || schemas.input);
}
function normalizeKSize(value: unknown) {
const match = String(value || "")
.trim()
.match(/^(1|2|4)\s*k$/i);
return match?.[1] ? `${match[1]}K` : "";
}
function gcd(a: number, b: number): number {
return b ? gcd(b, a % b) : a;
}
function parsePixelSize(value: unknown) {
const match = String(value || "")
.trim()
.match(/^(\d+)\s*[x*×]\s*(\d+)$/i);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!width || !height) return null;
const divisor = gcd(width, height);
return {
value: String(value),
width,
height,
ratio: `${Math.round(width / divisor)}:${Math.round(height / divisor)}`,
};
}
function getPixelTier(width: number, height: number) {
const maxSide = Math.max(width, height);
if (maxSide <= 1400) return "1K";
if (maxSide <= 2800) return "2K";
return "4K";
}
function parseRatio(value: string) {
const match = value.match(/^(\d+):(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
return width && height ? width / height : null;
}
function closestCommonRatio(ratio: string) {
const target = parseRatio(ratio);
if (!target) return ratio;
let winner = ratio;
let score = Number.POSITIVE_INFINITY;
for (const candidate of COMMON_RATIO_ORDER) {
const value = parseRatio(candidate);
if (!value) continue;
const nextScore = Math.abs(Math.log(target / value));
if (nextScore < score) {
winner = candidate;
score = nextScore;
}
}
return winner;
}
function normalizeAspectRatioEnum(value: unknown) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "auto") return "auto";
if (/^\d+:\d+$/.test(normalized)) return normalized;
if (normalized.startsWith("square")) return "1:1";
const namedRatio = normalized.match(
/^(landscape|portrait)[_-](\d+)[_x:](\d+)$/
);
if (!namedRatio) return "";
const [, orientation, first, second] = namedRatio;
return orientation === "portrait" ? `${second}:${first}` : `${first}:${second}`;
}
function buildAspectRatioValueMap(properties: JsonRecord) {
for (const key of [...ASPECT_RATIO_KEYS, "image_size"]) {
if (!(key in properties)) continue;
const map: Record<string, string> = {};
for (const value of getEnumValues(properties[key])) {
const ratio = normalizeAspectRatioEnum(value);
if (ratio && !map[ratio]) map[ratio] = String(value);
}
if (Object.keys(map).length > 0) return { key, map };
}
return { key: undefined, map: {} };
}
function buildSizeValueMap(sizeValues: Array<string | number>) {
const map: Record<string, Record<string, string>> = {};
for (const rawValue of sizeValues) {
const parsed = parsePixelSize(rawValue);
if (!parsed) continue;
const tier = getPixelTier(parsed.width, parsed.height);
const ratio = closestCommonRatio(parsed.ratio);
map[tier] ||= {};
map[tier][ratio] ||= parsed.value;
}
return map;
}
function deriveSupportedImageSizes(
properties: JsonRecord,
resolutionKey: string | undefined,
sizeValueMap: Record<string, Record<string, string>>
) {
const direct = resolutionKey
? getEnumValues(properties[resolutionKey]).map(normalizeKSize).filter(Boolean)
: [];
return uniqueStrings([...direct, ...Object.keys(sizeValueMap)]).filter((size) =>
["1K", "2K", "4K"].includes(size)
);
}
function deriveAspectRatios(
aspectRatioValueMap: Record<string, string>,
sizeValueMap: Record<string, Record<string, string>>
) {
const fromSizes = Object.values(sizeValueMap).flatMap((entry) =>
Object.keys(entry)
);
const ratios = uniqueStrings([...Object.keys(aspectRatioValueMap), ...fromSizes]);
return ratios.sort((a, b) => {
const ai = COMMON_RATIO_ORDER.indexOf(a);
const bi = COMMON_RATIO_ORDER.indexOf(b);
return (ai < 0 ? 999 : ai) - (bi < 0 ? 999 : bi);
});
}
function buildScalarDefaults(properties: JsonRecord) {
const defaults: Record<string, string | number | boolean> = {};
for (const [key, property] of Object.entries(properties)) {
if (["model", "prompt", ...IMAGE_INPUT_KEYS].includes(key)) continue;
const value = asRecord(property).default;
if (["string", "number", "boolean"].includes(typeof value)) {
defaults[key] = value;
}
}
return defaults;
}
function buildEnums(properties: JsonRecord) {
const enums: Record<string, Array<string | number>> = {};
for (const [key, property] of Object.entries(properties)) {
const values = getEnumValues(property);
if (values.length > 0) enums[key] = values;
}
return enums;
}
function deriveMaxImages(properties: JsonRecord, outputCountKey?: string) {
if (!outputCountKey) return 1;
const property = asRecord(properties[outputCountKey]);
const enumValues = getEnumValues(property)
.map(Number)
.filter((value) => Number.isFinite(value) && value > 0);
const configuredMax = Number(property.maximum || property.max || 0);
return Math.max(1, Math.min(15, configuredMax || Math.max(1, ...enumValues)));
}
export function createAtlasCloudImagePreset(
catalogModel: AtlasCloudCatalogModel,
schemaValue: unknown,
index = 0
): AtlasCloudImageModelPreset {
const schema = asRecord(schemaValue);
const input = getInputSchema(schema);
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;
const values = getEnumValues(properties[key]);
return values.length === 0 || values.some((value) => Boolean(normalizeKSize(value)));
});
const aspectRatioConfig = buildAspectRatioValueMap(properties);
const aspectRatioKey = aspectRatioConfig.key;
const outputCountKey = findFirstKey(properties, OUTPUT_COUNT_KEYS);
const sizeValueMap = buildSizeValueMap(getEnumValues(properties.size));
const supportedImageSizes = deriveSupportedImageSizes(
properties,
resolutionKey,
sizeValueMap
);
const supportedAspectRatios = deriveAspectRatios(
aspectRatioConfig.map,
sizeValueMap
);
const imageProperty = imageInputKey ? asRecord(properties[imageInputKey]) : {};
const maxReferenceImages = Math.min(
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()
);
const mediaKind = inferAtlasCloudMediaKind(catalogModel);
const supportedModes =
mediaKind === "3d"
? derive3DSupportedModes(catalogModel)
: [
...(categories.some((value) => value.includes("text-to-image"))
? ["text-to-image"]
: []),
...(imageInputKey ||
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");
}
const runtimeConfig: AtlasCloudRuntimeConfig = {
submitEndpoint: getPostEndpoint(schema),
pollEndpoint: getPollEndpoint(schema),
propertyNames: Object.keys(properties),
requiredFields,
imageInputs,
imageInputKey,
imageInputMode: imageProperty.type === "array" ? "array" : "single",
videoClipInput,
resolutionKey,
aspectRatioKey,
outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map,
maxReferenceImages,
maxVideoReferences,
maxImages,
defaults: buildScalarDefaults(properties),
enums: buildEnums(properties),
sizeValueMap,
};
const supportsSize = Boolean(
resolutionKey || Object.keys(sizeValueMap).length > 0
);
const defaultImageSize =
supportedImageSizes.find((value) => value === "2K") ||
supportedImageSizes[0] ||
"1K";
const defaultAspectRatio =
supportedAspectRatios.find((value) => value === "auto") ||
supportedAspectRatios.find((value) => value === "1:1") ||
supportedAspectRatios[0] ||
"1:1";
return {
modelId: catalogModel.model,
displayName: catalogModel.displayName || catalogModel.model,
avatarUrl: catalogModel.avatar || catalogModel.logo || undefined,
description:
catalogModel.profile ||
`Atlas Cloud ${mediaKind === "3d" ? "3D" : mediaKind === "video" ? "视频" : "图片"}模型 ${catalogModel.displayName || catalogModel.model}`,
supportsVision: Boolean(imageInputKey),
supportsImageGen: mediaKind === "image",
sortOrder: Math.max(0, 20_000 - index),
capabilities: {
kind:
mediaKind === "3d"
? "3d"
: mediaKind === "video"
? "video"
: imageInputKey
? "image-edit"
: "text-to-image",
mediaType: mediaKind,
supportedModes,
maxReferenceImages,
maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1,
supportsVideoReference: maxVideoReferences > 0,
requiresVideoReference: videoClipInput?.required === true,
provider: "atlascloud",
schemaUrl: catalogModel.schema || null,
},
defaultParams: {
imageSize: defaultImageSize,
aspectRatio: defaultAspectRatio,
imageCount: 1,
runtimeModelId: catalogModel.model,
},
imageGenConfig: {
apiFormat: "atlascloud-unified-image",
maxImages,
maxReferenceImages,
maxVideoReferences,
supportsSize,
supports1K: supportedImageSizes.includes("1K"),
supports2K: supportedImageSizes.includes("2K"),
supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes,
supportedAspectRatios,
supportsSeed: "seed" in properties,
supportsNegativePrompt: "negative_prompt" in properties,
supportsWatermark: "watermark" in properties,
enableLoadingPlaceholder: true,
atlasCloud: runtimeConfig,
},
};
}
async function mapWithConcurrency<T, R>(
values: T[],
limit: number,
mapper: (value: T, index: number) => Promise<R>
) {
const results = new Array<R>(values.length);
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, values.length) }, async () => {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
results[index] = await mapper(values[index], index);
}
});
await Promise.all(workers);
return results;
}
export async function fetchAtlasCloudImagePresets() {
const catalogResponse = await fetch(ATLASCLOUD_CATALOG_URL, {
cache: "no-store",
headers: { Accept: "application/json", "X-Source-From": "website" },
signal: AbortSignal.timeout(60_000),
});
if (!catalogResponse.ok) {
throw new Error(`Atlas Cloud 模型目录请求失败(HTTP ${catalogResponse.status})`);
}
const catalogPayload = await catalogResponse.json();
const catalogModels: AtlasCloudCatalogModel[] = Array.isArray(catalogPayload?.data)
? catalogPayload.data
: [];
const models = catalogModels
.filter(
(model: AtlasCloudCatalogModel) =>
String(model?.type || "").toLowerCase() === "image" &&
model?.display_console !== false &&
Boolean(model?.model) &&
Boolean(model?.schema)
)
.sort(
(a: AtlasCloudCatalogModel, b: AtlasCloudCatalogModel) =>
Number(b.priority || 0) - Number(a.priority || 0)
);
const settled = await mapWithConcurrency(models, 10, async (model, index) => {
try {
const schemaResponse = await fetch(String(model.schema), {
cache: "no-store",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(30_000),
});
if (!schemaResponse.ok) {
throw new Error(`HTTP ${schemaResponse.status}`);
}
return {
preset: createAtlasCloudImagePreset(model, await schemaResponse.json(), index),
error: null,
};
} catch (error) {
return {
preset: null,
error: {
modelId: model.model,
error: error instanceof Error ? error.message : String(error),
},
};
}
});
return {
presets: settled.flatMap((result) => (result.preset ? [result.preset] : [])),
errors: settled.flatMap((result) => (result.error ? [result.error] : [])),
catalogCount: models.length,
};
}