feat: add Atlas Cloud image provider
This commit is contained in:
parent
31cc75823c
commit
e8d1aa25aa
@ -90,6 +90,7 @@ export type AdminAiProvider = {
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
|
||||
@ -625,6 +625,7 @@ type AiProvider = {
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -1585,6 +1586,7 @@ const PROVIDER_TYPES = [
|
||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||
{ value: "visionary", label: "Visionary 生图" },
|
||||
{ value: "atlascloud", label: "Atlas Cloud 图片模型" },
|
||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||
@ -1621,6 +1623,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
"openai-image": "https://api.openai.com/v1",
|
||||
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||
visionary: "https://api.visionary.beer",
|
||||
atlascloud: "https://api.atlascloud.ai",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bltcy-video": "https://api.bltcy.ai",
|
||||
"grsai-gpt-image": "https://grsai.dakka.com.cn",
|
||||
@ -4039,6 +4042,7 @@ export function AdminSettingsClient() {
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
@ -6277,6 +6281,7 @@ export function AdminSettingsClient() {
|
||||
const isSnapgenImageProvider = providerForm.type === "snapgen-image";
|
||||
const isBananaImageProvider = providerForm.type === "bananaimage";
|
||||
const isVisionaryProvider = providerForm.type === "visionary";
|
||||
const isAtlasCloudProvider = providerForm.type === "atlascloud";
|
||||
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
|
||||
const isLemonProvider =
|
||||
providerForm.type === "openai" &&
|
||||
@ -6441,6 +6446,17 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isAtlasCloudProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddAtlasCloudModels(providerId, { skipConfirm: true });
|
||||
} catch (modelError) {
|
||||
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
|
||||
toast.warning(
|
||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 全部图片模型"按钮'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isNewProvider && isBltcyVideoProvider && providerId) {
|
||||
try {
|
||||
await handleQuickAddBltcyVideoModels(providerId, {
|
||||
@ -7490,6 +7506,45 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickAddAtlasCloudModels = async (
|
||||
providerId: string,
|
||||
options?: { skipConfirm?: boolean }
|
||||
) => {
|
||||
if (
|
||||
!options?.skipConfirm &&
|
||||
!confirm(
|
||||
"将从 Atlas Cloud 官方目录同步全部开放图片模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.info("正在读取 Atlas Cloud 官方目录和模型参数...");
|
||||
const response = await fetch("/api/admin/atlascloud-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "同步失败");
|
||||
|
||||
const totalSynced =
|
||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||
toast.success(
|
||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||
);
|
||||
mutateModels();
|
||||
if (data.errors?.length) {
|
||||
toast.warning(`${data.errors.length} 个 Atlas Cloud 模型读取或同步失败`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Quick Add Atlas Cloud Models] Error:", error);
|
||||
toast.error(error.message || "同步 Atlas Cloud 图片模型失败");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseFalImageProviderPreset = () => {
|
||||
setProviderForm({
|
||||
id: "",
|
||||
@ -15706,6 +15761,13 @@ export function AdminSettingsClient() {
|
||||
质量档位和 1K/2K/4K 输出。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "atlascloud" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Atlas Cloud 使用 Bearer API Key 认证,默认地址
|
||||
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片模型,
|
||||
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
|
||||
</p>
|
||||
)}
|
||||
{providerForm.type === "bfl-image" &&
|
||||
isBltcyBflBaseUrl(providerForm.baseUrl) && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@ -15852,6 +15914,17 @@ export function AdminSettingsClient() {
|
||||
快速添加 Visionary 图片预设
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "atlascloud" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
onClick={() => handleQuickAddAtlasCloudModels(provider.id)}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<ImageIcon className="mr-1 h-4 w-4" />
|
||||
同步 Atlas Cloud 全部图片模型
|
||||
</Button>
|
||||
)}
|
||||
{provider.type === "bltcy-video" && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
|
||||
@ -17,6 +17,7 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"runway",
|
||||
"krea",
|
||||
@ -247,6 +248,7 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
|
||||
@ -526,6 +526,7 @@ export async function convertMessagesWithImages(
|
||||
normalizedProviderType === "openai-image" ||
|
||||
normalizedProviderType === "bananaimage" ||
|
||||
normalizedProviderType === "visionary" ||
|
||||
normalizedProviderType === "atlascloud" ||
|
||||
normalizedProviderType === "stepfun-image" ||
|
||||
normalizedProviderType === "midjourney" ||
|
||||
normalizedProviderType === "runway" ||
|
||||
|
||||
@ -190,6 +190,7 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
|
||||
normalized === "openai-image" ||
|
||||
normalized === "bananaimage" ||
|
||||
normalized === "visionary" ||
|
||||
normalized === "atlascloud" ||
|
||||
normalized === "stepfun-image" ||
|
||||
normalized === "midjourney" ||
|
||||
normalized === "runway" ||
|
||||
|
||||
@ -9131,6 +9131,7 @@ export async function POST(request: Request) {
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
"stepfun-image": 7,
|
||||
"grsai-gpt-image": 8,
|
||||
runway: 9,
|
||||
@ -9524,6 +9525,7 @@ export async function POST(request: Request) {
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -9750,6 +9752,8 @@ export async function POST(request: Request) {
|
||||
? "bananaimage"
|
||||
: (languageModel as any).provider === "visionary"
|
||||
? "visionary"
|
||||
: (languageModel as any).provider === "atlascloud"
|
||||
? "atlascloud"
|
||||
: (languageModel as any).provider === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: (languageModel as any).provider === "midjourney"
|
||||
@ -9855,6 +9859,7 @@ export async function POST(request: Request) {
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -9902,6 +9907,8 @@ export async function POST(request: Request) {
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -10290,6 +10297,7 @@ export async function POST(request: Request) {
|
||||
"openai-image": 5,
|
||||
bananaimage: 6,
|
||||
visionary: 6,
|
||||
atlascloud: 6,
|
||||
"stepfun-image": 6,
|
||||
runway: 7,
|
||||
krea: 8,
|
||||
@ -10338,6 +10346,8 @@ export async function POST(request: Request) {
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
@ -11215,6 +11225,7 @@ export async function POST(request: Request) {
|
||||
"openai-image": 6,
|
||||
bananaimage: 7,
|
||||
visionary: 7,
|
||||
atlascloud: 7,
|
||||
"stepfun-image": 7,
|
||||
runway: 8,
|
||||
krea: 9,
|
||||
@ -11265,6 +11276,8 @@ export async function POST(request: Request) {
|
||||
? "bananaimage"
|
||||
: candidate.provider.type === "visionary"
|
||||
? "visionary"
|
||||
: candidate.provider.type === "atlascloud"
|
||||
? "atlascloud"
|
||||
: candidate.provider.type === "stepfun-image"
|
||||
? "stepfun-image"
|
||||
: candidate.provider.type === "midjourney"
|
||||
|
||||
@ -34,6 +34,7 @@ const providerTypes = [
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"fal-video",
|
||||
@ -77,6 +78,7 @@ const SNAPGEN_IMAGE_BASE_URL = "https://api.snapgen.ai";
|
||||
const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
|
||||
const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
|
||||
const VISIONARY_BASE_URL = "https://api.visionary.beer";
|
||||
const ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai";
|
||||
const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
@ -245,6 +247,8 @@ export async function POST(request: Request) {
|
||||
? data.baseUrl || BANANAIMAGE_BASE_URL
|
||||
: data.type === "visionary"
|
||||
? data.baseUrl || VISIONARY_BASE_URL
|
||||
: data.type === "atlascloud"
|
||||
? data.baseUrl || ATLASCLOUD_BASE_URL
|
||||
: data.type === "stepfun-image"
|
||||
? data.baseUrl || STEPFUN_IMAGE_BASE_URL
|
||||
: data.type === "midjourney"
|
||||
|
||||
155
app/api/admin/atlascloud-models/route.ts
Normal file
155
app/api/admin/atlascloud-models/route.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const result = await fetchAtlasCloudImagePresets();
|
||||
return NextResponse.json({
|
||||
presets: result.presets,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
counts: {
|
||||
total: result.catalogCount,
|
||||
available: result.presets.length,
|
||||
failed: result.errors.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Atlas Cloud Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const body = await request.json();
|
||||
const providerId = String(body?.providerId || "").trim();
|
||||
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||
? new Set(
|
||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||||
)
|
||||
: null;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "atlascloud") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be an Atlas Cloud provider" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = await fetchAtlasCloudImagePresets();
|
||||
const presets = requestedModelIds
|
||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: catalog.presets;
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors = [...catalog.errors];
|
||||
|
||||
for (const preset of presets) {
|
||||
try {
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
avatarUrl: preset.avatarUrl || null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: true,
|
||||
isEnabled: true,
|
||||
isHidden: false,
|
||||
sortOrder: preset.sortOrder,
|
||||
capabilities: preset.capabilities,
|
||||
defaultParams: preset.defaultParams,
|
||||
imageGenConfig: preset.imageGenConfig,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(aiModel)
|
||||
.set(values)
|
||||
.where(
|
||||
and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
if (updated) updatedModels.push(updated);
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(aiModel)
|
||||
.values({
|
||||
providerId,
|
||||
modelId: preset.modelId,
|
||||
...values,
|
||||
isDefault: false,
|
||||
isSubModel: false,
|
||||
parentModelId: null,
|
||||
})
|
||||
.returning();
|
||||
if (created) addedModels.push(created);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Atlas Cloud Models API] Failed to sync ${preset.modelId}:`,
|
||||
error
|
||||
);
|
||||
errors.push({
|
||||
modelId: preset.modelId,
|
||||
error: error instanceof Error ? error.message : "Sync failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
addedCount: addedModels.length,
|
||||
updatedCount: updatedModels.length,
|
||||
addedModels,
|
||||
updatedModels,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
counts: {
|
||||
requested: presets.length,
|
||||
total: catalog.catalogCount,
|
||||
available: catalog.presets.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Atlas Cloud Models API] POST error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to sync Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -55,6 +56,7 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -123,6 +125,8 @@ function createTextEditProvider(
|
||||
return createBananaImage({ baseURL, apiKey });
|
||||
case "visionary":
|
||||
return createVisionary({ baseURL, apiKey });
|
||||
case "atlascloud":
|
||||
return createAtlasCloud({ baseURL, apiKey });
|
||||
case "stepfun-image":
|
||||
return createStepfunImage({ baseURL, apiKey });
|
||||
case "midjourney":
|
||||
@ -475,7 +479,7 @@ async function generateTextEditImageWithFailover(params: {
|
||||
const runtimeModelId = resolveModelRuntimeId(selected.model, {
|
||||
providerType: selected.provider.type,
|
||||
});
|
||||
const result = await provider.chat(runtimeModelId).doGenerate({
|
||||
const generationOptions: any = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
@ -488,7 +492,11 @@ async function generateTextEditImageWithFailover(params: {
|
||||
imageParams: {
|
||||
imageCount: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
if (selected.provider.type === "atlascloud") {
|
||||
generationOptions.imageGenConfig = selected.model.imageGenConfig || null;
|
||||
}
|
||||
const result = await provider.chat(runtimeModelId).doGenerate(generationOptions);
|
||||
|
||||
const resultText = String(result?.text || "").trim();
|
||||
const finishReason = String(result?.finishReason || "").toLowerCase();
|
||||
|
||||
@ -27,6 +27,7 @@ import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
|
||||
import {
|
||||
canProcessSceneFusionProviderType,
|
||||
@ -1477,7 +1478,8 @@ async function processSceneWithProvider(
|
||||
modelId: string,
|
||||
runtimeModelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string
|
||||
imageSize?: string,
|
||||
imageGenConfig?: Record<string, any> | null
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
// 构建智能提示词
|
||||
const prompt = buildPrompt(
|
||||
@ -1551,6 +1553,18 @@ async function processSceneWithProvider(
|
||||
imageSize,
|
||||
);
|
||||
|
||||
case "atlascloud":
|
||||
return processWithAtlasCloud(
|
||||
productImageUrl,
|
||||
sceneImageUrl,
|
||||
prompt,
|
||||
provider,
|
||||
runtimeModelId || modelId,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
imageGenConfig,
|
||||
);
|
||||
|
||||
case "gemini":
|
||||
case "gemini-image":
|
||||
return processWithGemini(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
|
||||
@ -1880,6 +1894,45 @@ async function processWithVisionary(
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
async function processWithAtlasCloud(
|
||||
productImageUrl: string,
|
||||
sceneImageUrl: string,
|
||||
prompt: string,
|
||||
provider: { baseUrl: string; apiKey: string },
|
||||
modelId: string,
|
||||
aspectRatio?: string,
|
||||
imageSize?: string,
|
||||
imageGenConfig?: Record<string, any> | null
|
||||
): Promise<{ thumbnail: string; original: string; prompt: string }> {
|
||||
logger.info("provider_processing", { providerType: "atlascloud", modelId });
|
||||
|
||||
const atlasCloudProvider = createAtlasCloud({
|
||||
baseURL: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
const result = await atlasCloudProvider.chat(modelId).doGenerate({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: productImageUrl } },
|
||||
{ type: "image_url", image_url: { url: sceneImageUrl } },
|
||||
{ type: "text", text: prompt },
|
||||
],
|
||||
},
|
||||
],
|
||||
imageParams: {
|
||||
aspectRatio: aspectRatio || "auto",
|
||||
imageSize: imageSize || "2K",
|
||||
imageCount: 1,
|
||||
},
|
||||
imageGenConfig,
|
||||
});
|
||||
|
||||
const { thumbnail, original } = parseImageFromMarkdown(result.text);
|
||||
return { thumbnail, original, prompt };
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用既梦/火山模型处理
|
||||
*/
|
||||
@ -2135,7 +2188,8 @@ async function processSceneWithDiversity(
|
||||
candidate.model.modelId,
|
||||
runtimeModelId,
|
||||
normalizedOutputParams.aspectRatio,
|
||||
normalizedOutputParams.imageSize
|
||||
normalizedOutputParams.imageSize,
|
||||
(candidate.model.imageGenConfig as Record<string, any> | null) || null
|
||||
);
|
||||
} finally {
|
||||
await modelSlot.release();
|
||||
|
||||
@ -28,6 +28,7 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
|
||||
"openai-image": "OpenAI 图片",
|
||||
bananaimage: "BananaImage 图片",
|
||||
visionary: "Visionary 图片",
|
||||
atlascloud: "Atlas Cloud 图片",
|
||||
"stepfun-image": "StepFun 图片",
|
||||
midjourney: "Midjourney",
|
||||
"volcano-vision": "即梦 4.5",
|
||||
|
||||
@ -176,6 +176,7 @@ export function ImageParamsBar({
|
||||
providerType === "openai-image" ||
|
||||
providerType === "bananaimage" ||
|
||||
providerType === "visionary" ||
|
||||
providerType === "atlascloud" ||
|
||||
providerType === "stepfun-image" ||
|
||||
providerType === "midjourney"
|
||||
) {
|
||||
|
||||
@ -190,6 +190,9 @@ export function ImagePreview({
|
||||
} else if (src.includes("visionary-thumb-")) {
|
||||
originalUrl = src.replace("visionary-thumb-", "visionary-orig-");
|
||||
console.log("[ImagePreview] Inferred original URL from visionary-thumb filename");
|
||||
} else if (src.includes("atlascloud-thumb-")) {
|
||||
originalUrl = src.replace("atlascloud-thumb-", "atlascloud-orig-");
|
||||
console.log("[ImagePreview] Inferred original URL from Atlas Cloud filename");
|
||||
} else if (src.includes("/thumb/") || src.includes("-thumb")) {
|
||||
// 尝试从S3 URL中推断原图URL(常见格式:xxx-thumb.jpg -> xxx.jpg 或 xxx/thumb/xxx -> xxx/xxx)
|
||||
originalUrl = src.replace(/-thumb\./g, ".").replace(/\/thumb\//g, "/");
|
||||
|
||||
523
lib/ai/atlascloud-models.ts
Normal file
523
lib/ai/atlascloud-models.ts
Normal file
@ -0,0 +1,523 @@
|
||||
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";
|
||||
resolutionKey?: string;
|
||||
aspectRatioKey?: string;
|
||||
outputCountKey?: string;
|
||||
aspectRatioValueMap: Record<string, string>;
|
||||
maxReferenceImages: 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: true;
|
||||
sortOrder: number;
|
||||
capabilities: Record<string, unknown>;
|
||||
defaultParams: Record<string, unknown>;
|
||||
imageGenConfig: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, any>;
|
||||
|
||||
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 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 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 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 maxImages = deriveMaxImages(properties, outputCountKey);
|
||||
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
|
||||
value.toLowerCase()
|
||||
);
|
||||
const supportedModes = [
|
||||
...(categories.some((value) => value.includes("text-to-image"))
|
||||
? ["text-to-image"]
|
||||
: []),
|
||||
...(imageInputKey || categories.some((value) => value.includes("image-to-image"))
|
||||
? ["image-edit", "reference-images"]
|
||||
: []),
|
||||
];
|
||||
if (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",
|
||||
resolutionKey,
|
||||
aspectRatioKey,
|
||||
outputCountKey,
|
||||
aspectRatioValueMap: aspectRatioConfig.map,
|
||||
maxReferenceImages,
|
||||
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 图片模型 ${catalogModel.displayName || catalogModel.model}`,
|
||||
supportsVision: Boolean(imageInputKey),
|
||||
supportsImageGen: true,
|
||||
sortOrder: Math.max(0, 20_000 - index),
|
||||
capabilities: {
|
||||
kind: imageInputKey ? "image-edit" : "text-to-image",
|
||||
supportedModes,
|
||||
maxReferenceImages,
|
||||
supportsMultiImageReference: maxReferenceImages > 1,
|
||||
provider: "atlascloud",
|
||||
schemaUrl: catalogModel.schema || null,
|
||||
},
|
||||
defaultParams: {
|
||||
imageSize: defaultImageSize,
|
||||
aspectRatio: defaultAspectRatio,
|
||||
imageCount: 1,
|
||||
runtimeModelId: catalogModel.model,
|
||||
},
|
||||
imageGenConfig: {
|
||||
apiFormat: "atlascloud-unified-image",
|
||||
maxImages,
|
||||
maxReferenceImages,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -26,6 +26,7 @@ import { createSnapgenImage } from "./providers/snapgen-image";
|
||||
import { createOpenAIImage } from "./providers/openai-image";
|
||||
import { createBananaImage } from "./providers/bananaimage";
|
||||
import { createVisionary } from "./providers/visionary";
|
||||
import { createAtlasCloud } from "./providers/atlascloud";
|
||||
import { createStepfunImage } from "./providers/stepfun-image";
|
||||
import { createMidjourney } from "./providers/midjourney";
|
||||
import { createFalVideo } from "./providers/fal-video";
|
||||
@ -314,6 +315,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
||||
return visionaryProvider as any;
|
||||
}
|
||||
|
||||
case "atlascloud": {
|
||||
const atlasCloudProvider = createAtlasCloud({
|
||||
baseURL: resolvedBaseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
logProviderCreated("atlascloud", resolvedBaseUrl);
|
||||
return atlasCloudProvider as any;
|
||||
}
|
||||
|
||||
case "stepfun-image": {
|
||||
const stepfunImageProvider = createStepfunImage({
|
||||
baseURL: resolvedBaseUrl,
|
||||
@ -488,6 +498,9 @@ export async function getDynamicModel(
|
||||
} else if (provider.type === "visionary") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("visionary", baseModel);
|
||||
} else if (provider.type === "atlascloud") {
|
||||
baseModel = providerInstance.chat(resolvedModelId, model.imageGenConfig || null);
|
||||
logChatModelResult("atlascloud", baseModel);
|
||||
} else if (provider.type === "stepfun-image") {
|
||||
baseModel = providerInstance.chat(resolvedModelId);
|
||||
logChatModelResult("stepfun-image", baseModel);
|
||||
|
||||
@ -21,6 +21,7 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"volcano-vision",
|
||||
|
||||
@ -62,6 +62,7 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"gemini-image",
|
||||
@ -174,7 +175,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
> = {
|
||||
// 图片编辑:优先 alibaba-image(灵魂4.5,支持提示词扩展)
|
||||
image_edit: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini"],
|
||||
requireVision: true,
|
||||
requireImageGen: true,
|
||||
description: "图片编辑",
|
||||
@ -189,7 +190,7 @@ const INTENT_MODEL_MAP: Record<
|
||||
|
||||
// 图片生成:优先灵魂4.5
|
||||
image_gen: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "huanke-gemini", "volcano-vision"],
|
||||
requireImageGen: true,
|
||||
description: "图片生成",
|
||||
},
|
||||
@ -217,25 +218,25 @@ const SUB_INTENT_OVERRIDES: Record<
|
||||
> = {
|
||||
// 抠图:alibaba-image 效果最好
|
||||
remove_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "抠图首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 换背景:alibaba-image 支持场景生成
|
||||
replace_background: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini"],
|
||||
reason: "换背景首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 图片增强
|
||||
enhance: {
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
providerTypes: ["alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea", "gemini", "volcano-vision"],
|
||||
reason: "增强首选灵魂4.5",
|
||||
},
|
||||
|
||||
// 风格迁移:gemini 可能更有创意
|
||||
style_transfer: {
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "runway", "krea"],
|
||||
providerTypes: ["gemini", "alibaba-image", "bfl-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "runway", "krea"],
|
||||
reason: "风格迁移优选Gemini",
|
||||
},
|
||||
};
|
||||
|
||||
@ -19,6 +19,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
"openai-image": "https://api.openai.com/v1",
|
||||
bananaimage: "https://bananaimage.aigenmedia.art",
|
||||
visionary: "https://api.visionary.beer",
|
||||
atlascloud: "https://api.atlascloud.ai",
|
||||
"stepfun-image": "https://api.stepfun.com",
|
||||
"bfl-image": "https://api.bfl.ai",
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
|
||||
596
lib/ai/providers/atlascloud.ts
Normal file
596
lib/ai/providers/atlascloud.ts
Normal file
@ -0,0 +1,596 @@
|
||||
import "server-only";
|
||||
|
||||
import sharp from "sharp";
|
||||
import {
|
||||
ATLASCLOUD_BASE_URL,
|
||||
type AtlasCloudRuntimeConfig,
|
||||
} from "@/lib/ai/atlascloud-models";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
interface AtlasCloudConfig {
|
||||
apiKey: string;
|
||||
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;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const TRANSIENT_FAILURE_LIMIT = 8;
|
||||
|
||||
function normalizeBaseUrl(baseURL?: string) {
|
||||
return String(baseURL || ATLASCLOUD_BASE_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function joinApiPath(baseURL: string | undefined, path: string) {
|
||||
const base = normalizeBaseUrl(baseURL);
|
||||
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (base.endsWith("/api/v1") && cleanPath.startsWith("/api/v1/")) {
|
||||
return `${base}${cleanPath.slice("/api/v1".length)}`;
|
||||
}
|
||||
return `${base}${cleanPath}`;
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseJsonResponse(response: Response) {
|
||||
return response.text().then((text) => {
|
||||
if (!text.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: any, fallback = "Atlas Cloud 请求失败") {
|
||||
if (typeof payload === "string" && payload.trim()) return payload.trim();
|
||||
const value =
|
||||
payload?.data?.error?.message ||
|
||||
payload?.data?.error ||
|
||||
payload?.error?.message ||
|
||||
payload?.error ||
|
||||
payload?.message ||
|
||||
fallback;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function unwrapPrediction(payload: any) {
|
||||
return payload?.data && typeof payload.data === "object" ? payload.data : payload;
|
||||
}
|
||||
|
||||
function extractTaskId(payload: any) {
|
||||
const prediction = unwrapPrediction(payload);
|
||||
return String(
|
||||
prediction?.id || prediction?.request_id || prediction?.task_id || ""
|
||||
).trim();
|
||||
}
|
||||
|
||||
function extractOutputUrls(payload: any) {
|
||||
const prediction = unwrapPrediction(payload);
|
||||
const candidates = [
|
||||
prediction?.outputs,
|
||||
prediction?.output,
|
||||
prediction?.images,
|
||||
prediction?.urls?.outputs,
|
||||
];
|
||||
const urls: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const values = Array.isArray(candidate) ? candidate : candidate ? [candidate] : [];
|
||||
for (const value of values) {
|
||||
const url =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: String(value?.url || value?.image_url || value?.src || "");
|
||||
if (/^(?:https?:\/\/|data:image\/)/i.test(url) && !urls.includes(url)) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function extractPromptAndImages(
|
||||
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 pushText = (value: unknown) => {
|
||||
if (typeof value === "string" && value.trim()) texts.push(value.trim());
|
||||
};
|
||||
const pushImage = (value: unknown) => {
|
||||
const url = String(
|
||||
typeof value === "string"
|
||||
? value
|
||||
: (value as any)?.url ||
|
||||
(value as any)?.imageUrl ||
|
||||
(value as any)?.fileUrl ||
|
||||
(value as any)?.src ||
|
||||
""
|
||||
).trim();
|
||||
if (
|
||||
url &&
|
||||
(/^https?:\/\//i.test(url) || url.startsWith("data:image/")) &&
|
||||
!imageUrls.includes(url)
|
||||
) {
|
||||
imageUrls.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.inlineData?.data) {
|
||||
pushImage(
|
||||
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof message.content === "string") pushText(message.content);
|
||||
else if (Array.isArray(message.content)) message.content.forEach(visitPart);
|
||||
if (Array.isArray(message.parts)) message.parts.forEach(visitPart);
|
||||
|
||||
const prompt = texts.join("\n").trim();
|
||||
if (!prompt) throw new Error("请输入图片生成或编辑要求");
|
||||
return { prompt, imageUrls };
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string) {
|
||||
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
|
||||
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, {
|
||||
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",
|
||||
"User-Agent": "yedir-ai-atlascloud/1.0",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Atlas Cloud 参考图下载失败(HTTP ${response.status})`);
|
||||
}
|
||||
return {
|
||||
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
return "png";
|
||||
}
|
||||
|
||||
async function uploadReferenceImage(
|
||||
baseURL: string | undefined,
|
||||
apiKey: string,
|
||||
imageUrl: string
|
||||
) {
|
||||
if (/^https:\/\//i.test(imageUrl)) return imageUrl;
|
||||
const source = await loadImageSource(imageUrl);
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"file",
|
||||
new Blob([source.buffer], { type: source.mimeType }),
|
||||
`reference-${Date.now()}.${detectExtension(source.mimeType)}`
|
||||
);
|
||||
const response = await fetch(joinApiPath(baseURL, "/api/v1/model/uploadMedia"), {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${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 参考图上传成功但未返回文件地址");
|
||||
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;
|
||||
endpoint: string;
|
||||
body: Record<string, unknown>;
|
||||
}) {
|
||||
const response = await fetch(joinApiPath(params.baseURL, params.endpoint), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`${extractErrorMessage(payload)}(HTTP ${response.status})`
|
||||
);
|
||||
}
|
||||
const immediateOutputs = extractOutputUrls(payload);
|
||||
if (immediateOutputs.length > 0) return { outputs: immediateOutputs, taskId: "" };
|
||||
const taskId = extractTaskId(payload);
|
||||
if (!taskId) throw new Error("Atlas Cloud 未返回任务 ID");
|
||||
return { outputs: [] as string[], taskId };
|
||||
}
|
||||
|
||||
async function waitForTask(params: {
|
||||
apiKey: string;
|
||||
baseURL?: string;
|
||||
endpoint: string;
|
||||
taskId: string;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let transientFailures = 0;
|
||||
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
try {
|
||||
const endpoint = params.endpoint.replace(
|
||||
/\{(?:request_id|id|task_id)\}/g,
|
||||
encodeURIComponent(params.taskId)
|
||||
);
|
||||
const response = await fetch(joinApiPath(params.baseURL, endpoint), {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${params.apiKey}` },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const payload = await parseJsonResponse(response);
|
||||
if (!response.ok) {
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
throw new Error(extractErrorMessage(payload));
|
||||
}
|
||||
transientFailures = 0;
|
||||
const prediction = unwrapPrediction(payload);
|
||||
const status = String(prediction?.status || "").toLowerCase();
|
||||
const outputs = extractOutputUrls(payload);
|
||||
if (status === "completed" || status === "succeeded" || status === "success") {
|
||||
if (outputs.length === 0) {
|
||||
throw new Error("Atlas Cloud 任务完成但未返回图片");
|
||||
}
|
||||
return outputs;
|
||||
}
|
||||
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) {
|
||||
throw new Error(extractErrorMessage(payload, "Atlas Cloud 图片生成失败"));
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/^HTTP (?:429|5\d\d)$/.test(message) && !/fetch|network|abort/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
transientFailures += 1;
|
||||
if (transientFailures >= TRANSIENT_FAILURE_LIMIT) throw error;
|
||||
}
|
||||
}
|
||||
throw new Error("Atlas Cloud 图片生成超时,任务可能仍在处理,请稍后重试");
|
||||
}
|
||||
|
||||
function toArrayBuffer(buffer: Buffer) {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async function uploadImageBuffer(buffer: Buffer, mimeType: string) {
|
||||
const extension = detectExtension(mimeType);
|
||||
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
|
||||
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 82 })
|
||||
.toBuffer()
|
||||
.catch(() => buffer);
|
||||
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const [original, thumbnail] = await Promise.all([
|
||||
uploadFile(`atlascloud-orig-${suffix}.${extension}`, toArrayBuffer(buffer)),
|
||||
uploadFile(`atlascloud-thumb-${suffix}.jpg`, toArrayBuffer(thumbnailBuffer)),
|
||||
]);
|
||||
return { original: original.url, thumbnail: thumbnail.url };
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "atlascloud",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
headers: { "User-Agent": "yedir-ai-atlascloud/1.0" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Atlas Cloud 结果图片下载失败(HTTP ${response.status})`);
|
||||
}
|
||||
return uploadImageBuffer(
|
||||
Buffer.from(await response.arrayBuffer()),
|
||||
response.headers.get("content-type") || "image/png"
|
||||
);
|
||||
}
|
||||
|
||||
function formatMarkdown(images: UploadedImage[], imageSize: string) {
|
||||
return images
|
||||
.map((image) => ``)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
|
||||
return {
|
||||
provider: "atlascloud",
|
||||
chat: (modelId: string, defaultModelConfig?: AtlasCloudModelConfig | null) => ({
|
||||
provider: "atlascloud",
|
||||
doGenerate: async (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: AtlasCloudImageParams;
|
||||
imageGenConfig?: AtlasCloudModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) => {
|
||||
const runtimeModelId = String(options.overrideModelId || modelId || "").trim();
|
||||
if (!runtimeModelId) throw new Error("Atlas Cloud 模型 ID 未配置");
|
||||
const modelConfig = options.imageGenConfig || defaultModelConfig || {};
|
||||
if (!modelConfig.atlasCloud) {
|
||||
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
|
||||
}
|
||||
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
|
||||
const maxReferenceImages = Math.max(
|
||||
0,
|
||||
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
|
||||
);
|
||||
const referenceImages = await Promise.all(
|
||||
imageUrls
|
||||
.slice(0, maxReferenceImages)
|
||||
.map((url) => uploadReferenceImage(baseURL, apiKey, url))
|
||||
);
|
||||
const { body, imageSize } = createRequestBody({
|
||||
modelId: runtimeModelId,
|
||||
prompt,
|
||||
referenceImages,
|
||||
imageParams: options.imageParams,
|
||||
modelConfig,
|
||||
});
|
||||
const submitted = await submitTask({
|
||||
apiKey,
|
||||
baseURL,
|
||||
endpoint: modelConfig.atlasCloud.submitEndpoint,
|
||||
body,
|
||||
});
|
||||
const outputUrls = submitted.outputs.length
|
||||
? submitted.outputs
|
||||
: await waitForTask({
|
||||
apiKey,
|
||||
baseURL,
|
||||
endpoint: modelConfig.atlasCloud.pollEndpoint,
|
||||
taskId: submitted.taskId,
|
||||
});
|
||||
const requestedCount = Math.max(1, options.imageParams?.imageCount || 1);
|
||||
const archived = await Promise.all(
|
||||
outputUrls.slice(0, requestedCount).map(archiveOutputImage)
|
||||
);
|
||||
const text = formatMarkdown(archived, imageSize);
|
||||
return {
|
||||
text,
|
||||
finishReason: "stop" as const,
|
||||
usage: { promptTokens: 0, completionTokens: text.length },
|
||||
};
|
||||
},
|
||||
doStream: async function* (options: {
|
||||
messages: Array<{ role: string; content?: any; parts?: any[] }>;
|
||||
imageParams?: AtlasCloudImageParams;
|
||||
imageGenConfig?: AtlasCloudModelConfig | null;
|
||||
overrideModelId?: string;
|
||||
}) {
|
||||
const result = await this.doGenerate(options);
|
||||
yield { type: "text-delta", textDelta: result.text };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { createSnapgenImage } from "@/lib/ai/providers/snapgen-image";
|
||||
import { createOpenAIImage } from "@/lib/ai/providers/openai-image";
|
||||
import { createBananaImage } from "@/lib/ai/providers/bananaimage";
|
||||
import { createVisionary } from "@/lib/ai/providers/visionary";
|
||||
import { createAtlasCloud } from "@/lib/ai/providers/atlascloud";
|
||||
import { createStepfunImage } from "@/lib/ai/providers/stepfun-image";
|
||||
import { createMidjourney } from "@/lib/ai/providers/midjourney";
|
||||
import { createRunway } from "@/lib/ai/providers/runway";
|
||||
@ -298,6 +299,7 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
|
||||
"openai-image",
|
||||
"bananaimage",
|
||||
"visionary",
|
||||
"atlascloud",
|
||||
"stepfun-image",
|
||||
"midjourney",
|
||||
"runway",
|
||||
@ -4152,6 +4154,7 @@ function getProviderBatchLimit(providerType: string) {
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "stepfun-image":
|
||||
case "midjourney":
|
||||
case "runway":
|
||||
@ -5318,6 +5321,26 @@ export async function generateCanvasImage(
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "atlascloud") {
|
||||
const atlasCloudProvider = createAtlasCloud({
|
||||
apiKey: provider.apiKey,
|
||||
baseURL: provider.baseUrl,
|
||||
});
|
||||
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
|
||||
providerType: provider.type,
|
||||
});
|
||||
const modelInstance = atlasCloudProvider.chat(runtimeModelId);
|
||||
executeGenerateBatch = async (batchImageCount) => {
|
||||
const result = await modelInstance.doGenerate({
|
||||
messages,
|
||||
imageParams: {
|
||||
...imageParams,
|
||||
imageCount: batchImageCount,
|
||||
},
|
||||
imageGenConfig: selectedModel.model.imageGenConfig || null,
|
||||
});
|
||||
return result.text || "";
|
||||
};
|
||||
} else if (provider.type === "stepfun-image") {
|
||||
const stepfunProvider = createStepfunImage({
|
||||
apiKey: provider.apiKey,
|
||||
@ -5394,7 +5417,7 @@ export async function generateCanvasImage(
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/Atlas Cloud/StepFun/Midjourney/Runway/Krea 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "openai-image", baseUrl: "https://api.openai.com/v1" },
|
||||
{ type: "bananaimage", baseUrl: "https://bananaimage.aigenmedia.art" },
|
||||
{ type: "visionary", baseUrl: "https://api.visionary.beer" },
|
||||
{ type: "atlascloud", baseUrl: "https://api.atlascloud.ai" },
|
||||
{ type: "stepfun-image", baseUrl: "https://api.stepfun.com" },
|
||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||
@ -152,7 +153,7 @@ async function runMigrationStatements(db: ReturnType<typeof drizzle>) {
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (type IN (
|
||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
'grsai-gpt-image', 'grsai-video', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'snapgen-image', 'openai-image', 'bananaimage', 'visionary', 'atlascloud', 'stepfun-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
|
||||
));
|
||||
`);
|
||||
await db.execute(sql`
|
||||
|
||||
40
lib/db/migrations/0092_add_atlascloud_provider_type.sql
Normal file
40
lib/db/migrations/0092_add_atlascloud_provider_type.sql
Normal file
@ -0,0 +1,40 @@
|
||||
ALTER TABLE "AiProvider"
|
||||
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
|
||||
|
||||
ALTER TABLE "AiProvider"
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (
|
||||
"type" IN (
|
||||
'alibaba',
|
||||
'openai',
|
||||
'custom',
|
||||
'local',
|
||||
'gemini',
|
||||
'grsai-gpt-image',
|
||||
'grsai-video',
|
||||
'huanke-gemini',
|
||||
'qianwen-video',
|
||||
'doubao-video',
|
||||
'ruxa-video',
|
||||
'volcano-vision',
|
||||
'volcano-video',
|
||||
'alibaba-image',
|
||||
'bfl-image',
|
||||
'ruxa-image',
|
||||
'fal-image',
|
||||
'snapgen-image',
|
||||
'openai-image',
|
||||
'bananaimage',
|
||||
'visionary',
|
||||
'atlascloud',
|
||||
'stepfun-image',
|
||||
'midjourney',
|
||||
'fal-video',
|
||||
'zhipu',
|
||||
'tripo-3d',
|
||||
'worldlabs-3d',
|
||||
'kittycad-cad',
|
||||
'runway',
|
||||
'krea'
|
||||
)
|
||||
);
|
||||
@ -554,6 +554,13 @@
|
||||
"when": 1784025600000,
|
||||
"tag": "0092_add_visionary_provider_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1784592000000,
|
||||
"tag": "0092_add_atlascloud_provider_type",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -3762,7 +3762,7 @@ export async function createAiProvider({
|
||||
updatedBy,
|
||||
}: {
|
||||
name: string;
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
isEnabled?: boolean;
|
||||
@ -3800,7 +3800,7 @@ export async function updateAiProvider({
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "grsai-video" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "snapgen-image" | "openai-image" | "bananaimage" | "visionary" | "atlascloud" | "stepfun-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
isEnabled?: boolean;
|
||||
|
||||
@ -308,7 +308,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
||||
export const aiProvider = pgTable("AiProvider", {
|
||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "grsai-video", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "snapgen-image", "openai-image", "bananaimage", "visionary", "atlascloud", "stepfun-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
|
||||
.notNull()
|
||||
.default("custom"), // 提供商类型
|
||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||
|
||||
@ -347,6 +347,7 @@ function supportsImageEditForReferenceCount(
|
||||
providerType === "openai-image" ||
|
||||
providerType === "bananaimage" ||
|
||||
providerType === "visionary" ||
|
||||
providerType === "atlascloud" ||
|
||||
providerType === "grsai-gpt-image" ||
|
||||
providerType === "ruxa-image" ||
|
||||
providerType === "snapgen-image";
|
||||
|
||||
@ -79,6 +79,7 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "gemini":
|
||||
case "volcano-vision":
|
||||
return true;
|
||||
@ -135,6 +136,7 @@ function getSupportedAspectRatiosByProvider(
|
||||
case "openai-image":
|
||||
case "bananaimage":
|
||||
case "visionary":
|
||||
case "atlascloud":
|
||||
case "gemini":
|
||||
default:
|
||||
return [...ALL_ASPECT_RATIO_VALUES];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user