Compare commits

...

15 Commits

58 changed files with 6120 additions and 78 deletions

6
.gitignore vendored
View File

@ -90,5 +90,11 @@ reference-sources/
# Local self-hosted Pascal Editor checkout.
.pascal-editor/
# Local self-hosted Zoo Design Studio checkout.
.zoo-modeling-app/
# Generated official Zoo Design Studio web bundle.
public/vendor/zoo-modeling-app/
# Drizzle migrations must be versioned (override the global *.sql ignore above)
!lib/db/migrations/*.sql

View File

@ -88,6 +88,9 @@ export type AdminAiProvider = {
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"

View File

@ -160,12 +160,15 @@ import {
import { FAL_IMAGE_MODEL_PRESETS } from "@/lib/ai/fal-image-models";
import { RUXA_IMAGE_MODEL_PRESETS } from "@/lib/ai/ruxa-image-models";
import { SNAPGEN_IMAGE_MODEL_PRESETS } from "@/lib/ai/snapgen-image-models";
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-models";
import {
formatImageModelCreditDiscountLabel,
getGenerationCreditCostFromImageGenConfig,
getImageSizeCreditMultiplier,
} from "@/lib/ai/image-model-pricing";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
@ -614,6 +617,9 @@ type AiProvider = {
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -1571,6 +1577,9 @@ const PROVIDER_TYPES = [
{ value: "fal-image", label: "FAL 图片生成/编辑" },
{ value: "snapgen-image", label: "SnapGen 生图" },
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@ -1605,6 +1614,9 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
"fal-image": "https://fal.run",
"snapgen-image": "https://api.snapgen.ai",
"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",
@ -2048,10 +2060,23 @@ export function AdminSettingsClient() {
const detailAgentModelOptions = useMemo(
() =>
(modelsData?.models || []).filter(
(model) => !model.supportsImageGen && model.isEnabled
),
[modelsData?.models]
(modelsData?.models || []).filter((model) => {
if (model.supportsImageGen || !model.isEnabled) return false;
const provider = (providersData?.providers || []).find(
(item) => item.id === model.providerId
);
const descriptor = {
providerType: provider?.type,
modelId: model.modelId,
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
};
return (
!is3DModelDescriptor(descriptor) &&
!isVideoModelDescriptor(descriptor)
);
}),
[modelsData?.models, providersData?.providers]
);
const imageGenerationModelOptions = useMemo(
() =>
@ -4005,6 +4030,9 @@ export function AdminSettingsClient() {
| "fal-image"
| "snapgen-image"
| "openai-image"
| "bananaimage"
| "visionary"
| "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"
@ -4138,6 +4166,11 @@ export function AdminSettingsClient() {
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen,
});
const is3DProviderForModelConfig = is3DModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
});
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
@ -4160,7 +4193,9 @@ export function AdminSettingsClient() {
? `${
selectedModelProviderType === "krea" ? "Krea" : "Runway"
} 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。`
: isVideoProviderForModelConfig
: is3DProviderForModelConfig
? "当前识别为 3D 模型,将与图片、视频和对话模型分开管理。"
: isVideoProviderForModelConfig
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
: isImageProviderForModelConfig
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
@ -6241,6 +6276,9 @@ export function AdminSettingsClient() {
const isFalImageProvider = providerForm.type === "fal-image";
const isRuxaImageProvider = providerForm.type === "ruxa-image";
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" &&
@ -6383,6 +6421,39 @@ export function AdminSettingsClient() {
}
}
if (isNewProvider && isBananaImageProvider && providerId) {
try {
await handleQuickAddBananaImageModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Add BananaImage Models] Error:", modelError);
toast.warning(
'BananaImage 预设模型添加失败,请手动点击"快速添加 BananaImage 图片预设"按钮'
);
}
}
if (isNewProvider && isVisionaryProvider && providerId) {
try {
await handleQuickAddVisionaryModels(providerId, { skipConfirm: true });
} catch (modelError) {
console.error("[Auto Add Visionary Models] Error:", modelError);
toast.warning(
'Visionary 预设模型添加失败,请手动点击"快速添加 Visionary 图片预设"按钮'
);
}
}
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 图片 / 3D 模型"按钮'
);
}
}
if (isNewProvider && isBltcyVideoProvider && providerId) {
try {
await handleQuickAddBltcyVideoModels(providerId, {
@ -7356,6 +7427,120 @@ export function AdminSettingsClient() {
}
};
const handleQuickAddBananaImageModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
`将同步 ${BANANAIMAGE_MODEL_PRESETS.length} 个 BananaImage 生图模型,支持多图参考、比例选择和 1K/2K/4K。\n\n继续吗?`
)
) {
return;
}
try {
toast.info("正在同步 BananaImage 生图预设模型...");
const response = await fetch("/api/admin/bananaimage-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);
if (totalSynced > 0) {
toast.success(`成功同步 ${totalSynced} 个 BananaImage 生图预设模型`);
mutateModels();
}
if (data.errors?.length) {
toast.warning(`${data.errors.length} 个 BananaImage 模型同步失败`);
}
} catch (error: any) {
console.error("[Quick Add BananaImage Models] Error:", error);
toast.error(error.message || "同步 BananaImage 生图预设模型失败");
}
};
const handleQuickAddVisionaryModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
`将同步 ${VISIONARY_MODEL_PRESETS.length} 个 Visionary 生图模型,支持最多 9 张参考图、比例选择、质量档位和 1K/2K/4K。\n\n继续吗?`
)
) {
return;
}
try {
toast.info("正在同步 Visionary 生图预设模型...");
const response = await fetch("/api/admin/visionary-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);
if (totalSynced > 0) {
toast.success(`成功同步 ${totalSynced} 个 Visionary 生图预设模型`);
mutateModels();
}
if (data.errors?.length) {
toast.warning(`${data.errors.length} 个 Visionary 模型同步失败`);
}
} catch (error: any) {
console.error("[Quick Add Visionary Models] Error:", error);
toast.error(error.message || "同步 Visionary 生图预设模型失败");
}
};
const handleQuickAddAtlasCloudModels = async (
providerId: string,
options?: { skipConfirm?: boolean }
) => {
if (
!options?.skipConfirm &&
!confirm(
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 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 图片 / 3D 模型(官方目录共 ${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 图片 / 3D 模型失败");
throw error;
}
};
const handleUseFalImageProviderPreset = () => {
setProviderForm({
id: "",
@ -15542,6 +15727,29 @@ export function AdminSettingsClient() {
https://api.snapgen.ai。保存后会自动添加文档里的全部生图模型预设。
</p>
)}
{providerForm.type === "bananaimage" && (
<p className="text-muted-foreground text-xs">
BananaImage 使用 Bearer API Key 认证,默认地址
https://bananaimage.aigenmedia.art。保存后自动同步 Nano
Banana 与 GPT Image 2 全部模型,支持最多 14 张参考图、比例选择和
1K/2K/4K 输出。
</p>
)}
{providerForm.type === "visionary" && (
<p className="text-muted-foreground text-xs">
Visionary 使用 Bearer API Key 认证,默认地址
https://api.visionary.beer。保存后自动同步 GPT Image 2
与 Nano Banana 全部开放模型,支持最多 9 张参考图、比例选择、
质量档位和 1K/2K/4K 输出。
</p>
)}
{providerForm.type === "atlascloud" && (
<p className="text-muted-foreground text-xs">
Atlas Cloud 使用 Bearer API Key 认证,默认地址
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片与 3D 模型,
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
</p>
)}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
<p className="text-muted-foreground text-xs">
@ -15662,6 +15870,43 @@ export function AdminSettingsClient() {
快速添加 SnapGen 图片预设
</Button>
)}
{provider.type === "bananaimage" && (
<Button
className="mt-2"
onClick={() =>
handleQuickAddBananaImageModels(provider.id)
}
size="sm"
variant="secondary"
>
<ImageIcon className="mr-1 h-4 w-4" />
快速添加 BananaImage 图片预设
</Button>
)}
{provider.type === "visionary" && (
<Button
className="mt-2"
onClick={() =>
handleQuickAddVisionaryModels(provider.id)
}
size="sm"
variant="secondary"
>
<ImageIcon className="mr-1 h-4 w-4" />
快速添加 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 图片 / 3D 模型
</Button>
)}
{provider.type === "bltcy-video" && (
<Button
className="mt-2"
@ -17154,6 +17399,11 @@ export function AdminSettingsClient() {
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
});
const is3DModel = is3DModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: (model as any).capabilities,
});
const isImageModel = isImageGenerationModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
@ -17216,6 +17466,11 @@ export function AdminSettingsClient() {
Agent
</span>
)}
{is3DModel && (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800 text-xs">
3D模型
</span>
)}
{isImageModel && (
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-800 text-xs">
图片模型
@ -17226,7 +17481,7 @@ export function AdminSettingsClient() {
视频模型
</span>
)}
{!isImageModel && !isVideoModel && (
{!is3DModel && !isImageModel && !isVideoModel && (
<span className="rounded bg-slate-100 px-2 py-0.5 text-slate-700 text-xs">
对话模型
</span>

View File

@ -1,7 +1,32 @@
import { AiCadWorkbench } from "@/components/ai-cad/ai-cad-workbench";
import { existsSync } from "node:fs";
import path from "node:path";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth";
import { ZooDesignStudioFrame } from "@/components/ai-cad/zoo-design-studio-frame";
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "HiCAD",
};
export const runtime = "nodejs";
export default function AiCadPage() {
return <AiCadWorkbench />;
export default async function AiCadPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=%2Fai-cad");
}
const isReady = existsSync(
path.join(process.cwd(), "public/vendor/zoo-modeling-app/index.html")
);
return (
<ZooDesignStudioFrame
isReady={isReady}
src={ZOO_MODELING_APP_SRC}
/>
);
}

View File

@ -15,6 +15,9 @@ export const IMAGE_FAILOVER_PROVIDER_ALLOWLIST = new Set([
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"runway",
"krea",
@ -243,6 +246,9 @@ export function getImageFailoverConvertProvider(providerType?: string | null) {
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "stepfun-image":
case "midjourney":
case "runway":

View File

@ -524,6 +524,9 @@ export async function convertMessagesWithImages(
normalizedProviderType === "fal-image" ||
normalizedProviderType === "snapgen-image" ||
normalizedProviderType === "openai-image" ||
normalizedProviderType === "bananaimage" ||
normalizedProviderType === "visionary" ||
normalizedProviderType === "atlascloud" ||
normalizedProviderType === "stepfun-image" ||
normalizedProviderType === "midjourney" ||
normalizedProviderType === "runway" ||

View File

@ -187,6 +187,9 @@ function normalizeImageLoadingProviderType(providerType?: string | null): string
normalized === "fal-image" ||
normalized === "snapgen-image" ||
normalized === "openai-image" ||
normalized === "bananaimage" ||
normalized === "visionary" ||
normalized === "atlascloud" ||
normalized === "stepfun-image" ||
normalized === "midjourney" ||
normalized === "runway" ||

View File

@ -9103,6 +9103,9 @@ export async function POST(request: Request) {
"fal-image": 4,
"snapgen-image": 5,
"openai-image": 6,
bananaimage: 7,
visionary: 7,
atlascloud: 7,
"stepfun-image": 7,
"grsai-gpt-image": 8,
runway: 9,
@ -9494,6 +9497,9 @@ export async function POST(request: Request) {
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"runway",
@ -9716,6 +9722,12 @@ export async function POST(request: Request) {
? "snapgen-image"
: (languageModel as any).provider === "openai-image"
? "openai-image"
: (languageModel as any).provider === "bananaimage"
? "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"
@ -9819,6 +9831,9 @@ export async function POST(request: Request) {
"fal-image": 4,
"snapgen-image": 5,
"openai-image": 6,
bananaimage: 7,
visionary: 7,
atlascloud: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -9862,6 +9877,12 @@ export async function POST(request: Request) {
? "snapgen-image"
: candidate.provider.type === "openai-image"
? "openai-image"
: candidate.provider.type === "bananaimage"
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -10248,6 +10269,9 @@ export async function POST(request: Request) {
"fal-image": 3,
"snapgen-image": 4,
"openai-image": 5,
bananaimage: 6,
visionary: 6,
atlascloud: 6,
"stepfun-image": 6,
runway: 7,
krea: 8,
@ -10292,6 +10316,12 @@ export async function POST(request: Request) {
? "snapgen-image"
: candidate.provider.type === "openai-image"
? "openai-image"
: candidate.provider.type === "bananaimage"
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"
@ -11121,6 +11151,9 @@ export async function POST(request: Request) {
"fal-image": 4,
"snapgen-image": 5,
"openai-image": 6,
bananaimage: 7,
visionary: 7,
atlascloud: 7,
"stepfun-image": 7,
runway: 8,
krea: 9,
@ -11167,6 +11200,12 @@ export async function POST(request: Request) {
? "snapgen-image"
: candidate.provider.type === "openai-image"
? "openai-image"
: candidate.provider.type === "bananaimage"
? "bananaimage"
: candidate.provider.type === "visionary"
? "visionary"
: candidate.provider.type === "atlascloud"
? "atlascloud"
: candidate.provider.type === "stepfun-image"
? "stepfun-image"
: candidate.provider.type === "midjourney"

View File

@ -48,6 +48,7 @@ const imageParamsSchema = z.object({
aspectRatio: z.string().optional(),
imageSize: z.preprocess(normalizeImageSizeInput, z.enum(imageSizeValues).optional()),
imageCount: z.number().min(1).max(10).optional(),
quality: z.enum(["auto", "low", "medium", "high"]).optional(),
// 阿里生图专用参数
size: z.string().optional(), // 自定义分辨率,如 "1024*1024"
negativePrompt: z.string().optional(), // 反向提示词

View File

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

View File

@ -32,6 +32,9 @@ const providerTypes = [
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"fal-video",
@ -73,6 +76,9 @@ const RUXA_VIDEO_BASE_URL = "https://api.ruxa.ai";
const RUXA_IMAGE_BASE_URL = "https://api.ruxa.ai";
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";
@ -237,6 +243,12 @@ export async function POST(request: Request) {
? data.baseUrl || SNAPGEN_IMAGE_BASE_URL
: data.type === "openai-image"
? data.baseUrl || OPENAI_IMAGE_BASE_URL
: data.type === "bananaimage"
? 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"

View 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: preset.supportsImageGen,
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 }
);
}
}

View File

@ -0,0 +1,128 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-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 async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: BANANAIMAGE_MODEL_PRESETS,
counts: { total: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch BananaImage model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? 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 !== "bananaimage") {
return NextResponse.json(
{ error: "Provider must be a BananaImage provider" },
{ status: 400 }
);
}
const presets = modelIds
? BANANAIMAGE_MODEL_PRESETS.filter((preset) => modelIds.includes(preset.modelId))
: BANANAIMAGE_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
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,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
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);
continue;
}
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: any) {
console.error(`[BananaImage Models API] Failed to sync ${preset.modelId}:`, error);
errors.push({
modelId: preset.modelId,
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: BANANAIMAGE_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[BananaImage Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync BananaImage models" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,139 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import { VISIONARY_MODEL_PRESETS } from "@/lib/ai/visionary-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 async function GET() {
try {
await requireAdmin();
return NextResponse.json({
presets: VISIONARY_MODEL_PRESETS,
counts: { total: VISIONARY_MODEL_PRESETS.length },
});
} catch (error: any) {
console.error("[Visionary Models API] GET error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch Visionary model presets" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
await requireAdmin();
const body = await request.json();
const providerId = String(body?.providerId || "").trim();
const modelIds = Array.isArray(body?.modelIds)
? 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 !== "visionary") {
return NextResponse.json(
{ error: "Provider must be a Visionary provider" },
{ status: 400 }
);
}
const presets = modelIds
? VISIONARY_MODEL_PRESETS.filter((preset) =>
modelIds.includes(preset.modelId)
)
: VISIONARY_MODEL_PRESETS;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors: { modelId: string; error: string }[] = [];
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,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
capabilities: preset.capabilities || null,
defaultParams: preset.defaultParams || null,
imageGenConfig: preset.imageGenConfig || null,
};
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);
continue;
}
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: any) {
console.error(
`[Visionary Models API] Failed to sync ${preset.modelId}:`,
error
);
errors.push({
modelId: preset.modelId,
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: VISIONARY_MODEL_PRESETS.length,
},
});
} catch (error: any) {
console.error("[Visionary Models API] POST error:", error);
return NextResponse.json(
{ error: error.message || "Failed to sync Visionary models" },
{ status: 500 }
);
}
}

View File

@ -19,6 +19,10 @@ type KittyCadTaskResponse = {
output_format?: string | null;
};
type KittyCadAsyncOperationResponse = KittyCadTaskResponse & {
type?: string | null;
};
type ArchivedFile = {
sourceHash: string;
archivedUrl: string;
@ -134,6 +138,78 @@ function pickPreviewImageFile(files: Array<{ name: string; url: string }>) {
);
}
function extractOutputEntries(task: KittyCadTaskResponse) {
return Object.entries(task.outputs || {}).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
}
async function enrichTextToCadOutputs(params: {
config: Awaited<ReturnType<typeof getKittyCadProviderConfig>>;
task: KittyCadTaskResponse;
taskId: string;
}) {
if (extractOutputEntries(params.task).length > 0) {
return params.task;
}
try {
const asyncOperation = await kittyCadRequest<KittyCadAsyncOperationResponse>(
params.config,
{
method: "GET",
path: `/async/operations/${encodeURIComponent(params.taskId)}`,
}
);
if (extractOutputEntries(asyncOperation).length === 0) {
return params.task;
}
return {
...params.task,
...asyncOperation,
code: asyncOperation.code || params.task.code || null,
conversation_id:
asyncOperation.conversation_id || params.task.conversation_id || null,
model_version:
asyncOperation.model_version || params.task.model_version || null,
output_format:
asyncOperation.output_format || params.task.output_format || null,
outputs: asyncOperation.outputs,
prompt: asyncOperation.prompt || params.task.prompt || null,
} satisfies KittyCadTaskResponse;
} catch (error) {
console.warn("[KittyCADStatus] async output fallback failed", {
taskId: params.taskId,
error: error instanceof Error ? error.message : String(error),
});
return params.task;
}
}
function createClientSafeTaskOutput(task: KittyCadTaskResponse) {
const outputEntries = extractOutputEntries(task);
const outputSummary = Object.fromEntries(
outputEntries.map(([name, base64]) => [
name,
{
base64Length: base64.length,
extension: getFileExtension(name) || null,
},
])
);
return {
...task,
outputs: outputSummary,
};
}
async function archiveOutputIfNeeded(params: {
taskId: string;
filename: string;
@ -269,7 +345,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
}
const config = await getKittyCadProviderConfig();
const result = await kittyCadRequest<KittyCadTaskResponse>(config, {
let result = await kittyCadRequest<KittyCadTaskResponse>(config, {
method: "GET",
path: `/user/text-to-cad/${encodeURIComponent(normalizedTaskId)}`,
});
@ -287,18 +363,18 @@ export async function GET(_request: Request, { params }: RouteParams) {
conversationId: result.conversation_id || null,
modelVersion: result.model_version || null,
outputFormat: result.output_format || null,
output: result,
output: createClientSafeTaskOutput(result),
},
});
}
const outputEntries = Object.entries(result.outputs || {}).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" &&
entry[0].trim().length > 0 &&
typeof entry[1] === "string" &&
entry[1].trim().length > 0
);
result = await enrichTextToCadOutputs({
config,
task: result,
taskId: normalizedTaskId,
});
const outputEntries = extractOutputEntries(result);
const archivedEntries = await Promise.all(
outputEntries.map(async ([name, base64]) => {
@ -365,7 +441,7 @@ export async function GET(_request: Request, { params }: RouteParams) {
outputFormat: result.output_format || null,
codeUrl: codeFile?.url || null,
downloadUrls,
output: result,
output: createClientSafeTaskOutput(result),
},
});
} catch (error) {

View File

@ -0,0 +1,66 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
const ALLOWED_WEBSOCKET_PATHS = new Set([
"/api/ai-cad/zoo-ws/ml/copilot",
"/api/ai-cad/zoo-ws/modeling/commands",
]);
function hasValidInternalSecret(request: Request) {
const expected = String(process.env.AI_CAD_WS_PROXY_SECRET || "").trim();
const received = String(
request.headers.get("x-hicad-ws-proxy-secret") || ""
).trim();
if (!expected || !received) {
return false;
}
const expectedBuffer = Buffer.from(expected);
const receivedBuffer = Buffer.from(received);
return (
expectedBuffer.length === receivedBuffer.length &&
timingSafeEqual(expectedBuffer, receivedBuffer)
);
}
export async function GET(request: Request) {
if (!hasValidInternalSecret(request)) {
return NextResponse.json({ error: "禁止访问" }, { status: 403 });
}
const originalUri = String(
request.headers.get("x-hicad-original-uri") || ""
).split("?", 1)[0];
if (!ALLOWED_WEBSOCKET_PATHS.has(originalUri)) {
return NextResponse.json({ error: "不支持的 Zoo WebSocket" }, { status: 403 });
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
try {
const config = await getKittyCadProviderConfig();
return new Response(null, {
headers: {
"Cache-Control": "private, no-store",
"X-Zoo-Authorization": `Bearer ${config.apiKey}`,
},
status: 204,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo WebSocket 鉴权失败",
},
{ status: 503 }
);
}
}

View File

@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getKittyCadProviderConfig } from "@/lib/kittycad/server";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
export const runtime = "nodejs";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const BLOCKED_PATH_PREFIXES = [
"/admin",
"/internal",
"/logout",
"/oauth2",
"/org/api-tokens",
"/user/api-tokens",
"/users",
];
type RouteContext = {
params: Promise<{ path: string[] }>;
};
function isBlockedPath(pathname: string) {
return BLOCKED_PATH_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
);
}
function copyRequestHeaders(request: Request, apiKey: string) {
const headers = new Headers();
for (const [name, value] of request.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "authorization" ||
normalizedName === "cookie" ||
normalizedName === "origin" ||
normalizedName === "referer" ||
normalizedName.startsWith("x-forwarded-") ||
normalizedName === "x-hicad-ws-proxy-secret"
) {
continue;
}
headers.set(name, value);
}
headers.set("Authorization", `Bearer ${apiKey}`);
headers.set("Accept", request.headers.get("accept") || "application/json");
return headers;
}
function copyResponseHeaders(upstream: Response) {
const headers = new Headers();
for (const [name, value] of upstream.headers.entries()) {
const normalizedName = name.toLowerCase();
if (
HOP_BY_HOP_HEADERS.has(normalizedName) ||
normalizedName === "content-encoding" ||
normalizedName === "set-cookie"
) {
continue;
}
headers.set(name, value);
}
headers.set("Cache-Control", "private, no-store");
return headers;
}
async function proxyZooRequest(request: Request, context: RouteContext) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { path } = await context.params;
const pathname = `/${path.map(encodeURIComponent).join("/")}`;
if (isBlockedPath(pathname)) {
return NextResponse.json(
{ error: "该 Zoo API 不允许通过 HiCAD 代理访问" },
{ status: 403 }
);
}
try {
const config = await getKittyCadProviderConfig();
const incomingUrl = new URL(request.url);
const baseUrl = config.baseUrl.replace(/\/+$/, "");
const upstreamUrl = `${baseUrl}${pathname}${incomingUrl.search}`;
const method = request.method.toUpperCase();
const hasBody = method !== "GET" && method !== "HEAD";
const init: RequestInit & { duplex?: "half" } = {
body: hasBody ? request.body : undefined,
cache: "no-store",
duplex: hasBody ? "half" : undefined,
headers: copyRequestHeaders(request, config.apiKey),
method,
redirect: "manual",
signal: request.signal,
};
const upstream = await fetch(upstreamUrl, init);
return new Response(upstream.body, {
headers: copyResponseHeaders(upstream),
status: upstream.status,
statusText: upstream.statusText,
});
} catch (error) {
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Zoo API 代理请求失败",
},
{ status: 502 }
);
}
}
export const DELETE = proxyZooRequest;
export const GET = proxyZooRequest;
export const HEAD = proxyZooRequest;
export const OPTIONS = proxyZooRequest;
export const PATCH = proxyZooRequest;
export const POST = proxyZooRequest;
export const PUT = proxyZooRequest;

View File

@ -11,6 +11,9 @@ import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
import { createFalImage } from "@/lib/ai/providers/fal-image";
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";
@ -51,6 +54,9 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"runway",
@ -115,6 +121,12 @@ function createTextEditProvider(
return createSnapgenImage({ baseURL, apiKey });
case "openai-image":
return createOpenAIImage({ baseURL, apiKey });
case "bananaimage":
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":
@ -467,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",
@ -480,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();

View File

@ -25,6 +25,9 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
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,
@ -1475,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(
@ -1527,6 +1531,40 @@ async function processSceneWithProvider(
case "openai-image":
return processWithOpenAIImage(productImageUrl, sceneImageUrl, prompt, provider, modelId, aspectRatio, imageSize);
case "bananaimage":
return processWithBananaImage(
productImageUrl,
sceneImageUrl,
prompt,
provider,
runtimeModelId || modelId,
aspectRatio,
imageSize,
);
case "visionary":
return processWithVisionary(
productImageUrl,
sceneImageUrl,
prompt,
provider,
runtimeModelId || modelId,
aspectRatio,
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);
@ -1782,6 +1820,119 @@ async function processWithOpenAIImage(
return { thumbnail, original, prompt };
}
async function processWithBananaImage(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
logger.info("provider_processing", { providerType: "bananaimage", modelId });
const bananaImageProvider = createBananaImage({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const result = await bananaImageProvider.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,
},
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
return { thumbnail, original, prompt };
}
async function processWithVisionary(
productImageUrl: string,
sceneImageUrl: string,
prompt: string,
provider: { baseUrl: string; apiKey: string },
modelId: string,
aspectRatio?: string,
imageSize?: string
): Promise<{ thumbnail: string; original: string; prompt: string }> {
logger.info("provider_processing", { providerType: "visionary", modelId });
const visionaryProvider = createVisionary({
baseURL: provider.baseUrl,
apiKey: provider.apiKey,
});
const result = await visionaryProvider.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,
},
});
const { thumbnail, original } = parseImageFromMarkdown(result.text);
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 };
}
/**
* 使用既梦/火山模型处理
*/
@ -2037,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();

View File

@ -2009,6 +2009,7 @@ export default function CanvasEditorLegacyPage({
useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
const chatPanelCanvasIdRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
useState(0);
@ -2032,6 +2033,16 @@ export default function CanvasEditorLegacyPage({
Partial<Record<AgentWorkflowStepKey, string>>
>({});
useLayoutEffect(() => {
if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
return;
}
chatPanelCanvasIdRef.current = id;
setPanelMode("chat");
setIsPanelOpen(true);
}, [id, isVideoMode]);
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);

View File

@ -0,0 +1,47 @@
"use client";
import { AlertTriangle } from "lucide-react";
type ZooDesignStudioFrameProps = {
isReady: boolean;
src: string;
};
export function ZooDesignStudioFrame({ isReady, src }: ZooDesignStudioFrameProps) {
if (!isReady) {
return (
<div className="flex h-svh w-full items-center justify-center overflow-hidden bg-[#10100e] p-6 text-[#f8f6ef]">
<div className="w-full max-w-xl rounded-lg border border-[#3a352c] bg-[#171510] p-6 shadow-2xl">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" />
<div className="min-w-0">
<h1 className="font-semibold text-lg">
HiCAD 官方前端尚未构建
</h1>
<p className="mt-2 text-[#d3cabb] text-sm leading-6">
请运行
<code className="mx-1 rounded bg-white/10 px-1.5 py-0.5">
pnpm zoo-modeling-app:vendor
</code>
生成官方 Zoo Design Studio 前端。生成后刷新本页即可使用。
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="h-svh w-full overflow-hidden bg-[#10100e]">
<iframe
allow="clipboard-read; clipboard-write; fullscreen; gamepad; pointer-lock; web-share; xr-spatial-tracking"
className="h-full w-full border-0"
referrerPolicy="strict-origin-when-cross-origin"
sandbox="allow-downloads allow-forms allow-modals allow-pointer-lock allow-popups allow-presentation allow-same-origin allow-scripts"
src={src}
title="HiCAD"
/>
</div>
);
}

View File

@ -4168,6 +4168,7 @@ export function CanvasChatPanel({
videoToolCapability?: VideoToolCapabilityContract | null;
imageGenConfig?: {
maxImages?: number;
maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@ -4196,6 +4197,7 @@ export function CanvasChatPanel({
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@ -4693,6 +4695,25 @@ export function CanvasChatPanel({
[visualModels]
);
const currentModel = allModels.find((model) => model.id === selectedModelId);
const atlasVideoReferenceLimit =
currentModel?.providerType === "atlascloud"
? Math.max(
0,
Math.min(
4,
Number(
currentModel.capabilities?.maxVideoReferences ||
currentModel.imageGenConfig?.maxVideoReferences ||
0
)
)
)
: 0;
const supportsAtlasVideoReference = atlasVideoReferenceLimit > 0;
const requiresAtlasVideoReference = Boolean(
supportsAtlasVideoReference &&
currentModel?.capabilities?.requiresVideoReference === true
);
const isCanvasVideoModel = useCallback(
(model?: (typeof allModels)[number] | null) =>
Boolean(
@ -11239,6 +11260,20 @@ export function CanvasChatPanel({
let effectiveAttachments = attachments
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
if (supportsAtlasVideoReference) {
let keptVideoCount = 0;
effectiveAttachments = effectiveAttachments.filter((attachment) => {
if (!isLikelyVideoAttachment(attachment.url, attachment.contentType)) {
return true;
}
keptVideoCount += 1;
return keptVideoCount <= atlasVideoReferenceLimit;
});
if (requiresAtlasVideoReference && keptVideoCount === 0) {
toast.error("当前模型需要先上传 1 个参考视频");
return;
}
}
if (hasAgentUploadSlots) {
if (effectiveAttachments.length > agentRequiredUploadCount) {
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
@ -11861,6 +11896,57 @@ export function CanvasChatPanel({
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const uploadVideoFileForComposerAttachment = useCallback(async (
file: File,
progressOptions?: CanvasUploadProgressOptions
): Promise<ComposerUploadedImage | null> => {
if (!file.type.startsWith("video/")) {
toast.error("请选择视频文件");
return null;
}
if (file.size > 15 * 1024 * 1024) {
toast.error("参考视频大小不能超过 15MB");
return null;
}
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
if (progressOptions) {
updateComposerUploadProgress(progressOptions);
}
const response = await fetchUploadWithRetry(
formData,
progressOptions
? {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
}
: undefined
);
if (!response.ok) {
throw new Error("上传失败");
}
const data = await response.json();
clearComposerUploadProgress(250);
return {
imageUrl: String(data.url || ""),
name: file.name || `参考视频-${Date.now()}.mp4`,
contentType: file.type || "video/mp4",
assetId:
typeof data.assetId === "string" && data.assetId.trim()
? data.assetId.trim()
: undefined,
};
} catch (_error) {
toast.error("参考视频上传失败");
return null;
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@ -12193,6 +12279,58 @@ export function CanvasChatPanel({
e.target.value = "";
return;
}
if (supportsAtlasVideoReference) {
const selectedVideoFiles = selectedFiles.filter((file) =>
file.type.startsWith("video/")
);
const existingVideoCount = attachments.filter((attachment) =>
isLikelyVideoAttachment(attachment.url, attachment.contentType)
).length;
const availableVideoSlots = Math.max(
0,
atlasVideoReferenceLimit - existingVideoCount
);
const acceptedVideoFiles = selectedVideoFiles.slice(0, availableVideoSlots);
if (selectedVideoFiles.length > acceptedVideoFiles.length) {
toast.info(`当前模型最多支持 ${atlasVideoReferenceLimit} 个参考视频`);
}
if (acceptedVideoFiles.length > 0) {
let uploadedCount = 0;
try {
for (const [index, file] of acceptedVideoFiles.entries()) {
const uploaded = await uploadVideoFileForComposerAttachment(file, {
title: "正在上传参考视频",
fileName: file.name,
fileIndex: index,
fileTotal: acceptedVideoFiles.length,
target: "composer-reference",
});
if (!uploaded?.imageUrl) continue;
addUploadedImageAttachmentToComposer(uploaded);
uploadedCount += 1;
}
} finally {
clearComposerUploadProgress(450);
}
if (uploadedCount > 0) {
toast.success(
uploadedCount === 1
? "参考视频已添加到对话"
: `已添加 ${uploadedCount} 个参考视频`
);
}
}
selectedFiles = selectedFiles.filter(
(file) => !file.type.startsWith("video/")
);
if (selectedFiles.length === 0) {
e.target.value = "";
return;
}
}
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
@ -13871,6 +14009,12 @@ export function CanvasChatPanel({
: "";
if (part.type === 'file' && fileUrl) {
const isVideoAttachment = isLikelyVideoAttachment(
fileUrl,
normalizeChatMediaType(
part.contentType || part.mediaType || part.mimeType
)
);
renderedUserParts.push(
<span
key={`part-${idx}`}
@ -13881,19 +14025,25 @@ export function CanvasChatPanel({
: "border border-black/5 bg-white/90 text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
)}
>
<img
src={buildPreviewImageUrl(fileUrl, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== fileUrl) {
event.currentTarget.src = fileUrl;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{part.name || 'Image'}</span>
{isVideoAttachment ? (
<Video className="h-4 w-4 shrink-0" />
) : (
<img
src={buildPreviewImageUrl(fileUrl, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== fileUrl) {
event.currentTarget.src = fileUrl;
}
}}
/>
)}
<span className="max-w-[80px] truncate text-current">
{part.name || (isVideoAttachment ? 'Video' : 'Image')}
</span>
</span>
);
return;
@ -13916,24 +14066,36 @@ export function CanvasChatPanel({
) {
(message as any).attachments.forEach((att: any, idx: number) => {
if (!att?.url) return;
const isVideoAttachment = isLikelyVideoAttachment(
att.url,
normalizeChatMediaType(
att.contentType || att.mediaType || att.mimeType
)
);
renderedUserParts.push(
<span
key={`att-${idx}`}
className="inline-flex items-center gap-1 rounded-xl border border-black/5 bg-white/90 px-2 py-1 text-xs text-[#334155] dark:border-white/10 dark:bg-white/10 dark:text-zinc-100"
>
<img
src={buildPreviewImageUrl(att.url, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== att.url) {
event.currentTarget.src = att.url;
}
}}
/>
<span className="max-w-[80px] truncate text-current">{att.name || 'Image'}</span>
{isVideoAttachment ? (
<Video className="h-4 w-4 shrink-0" />
) : (
<img
src={buildPreviewImageUrl(att.url, 48)}
alt=""
className="w-5 h-5 object-cover rounded"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== att.url) {
event.currentTarget.src = att.url;
}
}}
/>
)}
<span className="max-w-[80px] truncate text-current">
{att.name || (isVideoAttachment ? 'Video' : 'Image')}
</span>
</span>
);
});
@ -15592,8 +15754,8 @@ export function CanvasChatPanel({
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title="添加图片"
aria-label="添加图片"
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
>
<Plus className="h-4 w-4" />
{composerReferenceUploadProgress ? (
@ -15899,7 +16061,13 @@ export function CanvasChatPanel({
<input
ref={fileInputRef}
type="file"
accept={isClawdMode ? `image/*,${CLAWD_TABLE_ACCEPT}` : "image/*"}
accept={
isClawdMode
? `image/*,${CLAWD_TABLE_ACCEPT}`
: supportsAtlasVideoReference
? "image/*,video/mp4,video/quicktime,video/webm"
: "image/*"
}
multiple
className="hidden"
onChange={handleFileSelect}

View File

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

View File

@ -26,6 +26,9 @@ const PROVIDER_MODEL_LABEL: Record<string, string> = {
"fal-image": "FAL 图片",
"snapgen-image": "SnapGen 图片",
"openai-image": "OpenAI 图片",
bananaimage: "BananaImage 图片",
visionary: "Visionary 图片",
atlascloud: "Atlas Cloud 图片",
"stepfun-image": "StepFun 图片",
midjourney: "Midjourney",
"volcano-vision": "即梦 4.5",

View File

@ -18,6 +18,7 @@ export interface ImageGenerationParams {
aspectRatio: string;
imageSize: string;
imageCount?: number; // 生成图片数量
quality?: "auto" | "low" | "medium" | "high";
// 🆕 阿里生图专用参数
size?: string; // 自定义分辨率,如 "1024*1024"
negativePrompt?: string; // 反向提示词
@ -47,6 +48,7 @@ interface ImageParamsBarProps {
supportsNegativePrompt?: boolean;
supportsWatermark?: boolean;
supportsSeed?: boolean;
supportsQuality?: boolean;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
};
@ -144,6 +146,7 @@ export function ImageParamsBar({
"aspectRatio",
"imageSize",
"imageCount",
"quality",
"size",
"negativePrompt",
"promptExtend",
@ -171,6 +174,9 @@ export function ImageParamsBar({
if (
providerType === "openai-image" ||
providerType === "bananaimage" ||
providerType === "visionary" ||
providerType === "atlascloud" ||
providerType === "stepfun-image" ||
providerType === "midjourney"
) {
@ -280,6 +286,7 @@ export function ImageParamsBar({
a.aspectRatio === b.aspectRatio &&
a.imageSize === b.imageSize &&
(a.imageCount || 1) === (b.imageCount || 1) &&
(a.quality || "medium") === (b.quality || "medium") &&
a.size === b.size &&
a.negativePrompt === b.negativePrompt &&
a.promptExtend === b.promptExtend &&
@ -294,6 +301,7 @@ export function ImageParamsBar({
aspectRatio: inlineTheme === "minimal" ? "1:1" : "auto",
imageSize: "2K",
imageCount: 1,
quality: "medium",
promptExtend: true,
watermark: false,
...source,
@ -307,7 +315,14 @@ export function ImageParamsBar({
...buildBaseParams(initialParams),
});
const [minimalParamsOpen, setMinimalParamsOpen] = useState(false);
const [qualityLabel, setQualityLabel] = useState<"自动" | "高" | "中" | "低">("中");
const qualityOptions = [
{ label: "自动", value: "auto" },
{ label: "高", value: "high" },
{ label: "中", value: "medium" },
{ label: "低", value: "low" },
] as const;
const qualityLabel =
qualityOptions.find((option) => option.value === params.quality)?.label || "中";
const imageCountOptions = getImageCountOptions();
const showImageCountSelector = !hideImageCount && imageCountOptions.length > 1;
@ -498,19 +513,19 @@ export function ImageParamsBar({
<div>
<div className="mb-2 text-[14px] font-medium leading-none">质量</div>
<div className="grid h-9 grid-cols-4 rounded-full bg-[#f4f4f5] p-0.5 dark:bg-white/8">
{(["自动", "高", "中", "低"] as const).map((quality) => (
{qualityOptions.map((quality) => (
<button
key={quality}
key={quality.value}
type="button"
className={cn(
"rounded-full text-[13px] font-medium transition-colors",
qualityLabel === quality
params.quality === quality.value
? "border border-[#c8cdd5] bg-white shadow-[0_1px_4px_rgba(15,23,42,0.1)] dark:border-white/12 dark:bg-white/16"
: "text-[#3d434d] hover:bg-white/55 dark:text-zinc-300 dark:hover:bg-white/10"
)}
onClick={() => setQualityLabel(quality)}
onClick={() => updateParam("quality", quality.value)}
>
{quality}
{quality.label}
</button>
))}
</div>

View File

@ -169,6 +169,15 @@ export function ImagePreview({
} else if (src.includes("snapgen-image-thumb-")) {
originalUrl = src.replace("snapgen-image-thumb-", "snapgen-image-orig-");
console.log("[ImagePreview] Inferred original URL from snapgen-image-thumb filename");
} else if (src.includes("bananaimage-thumb-")) {
originalUrl = src.replace("bananaimage-thumb-", "bananaimage-orig-");
console.log("[ImagePreview] Inferred original URL from bananaimage-thumb filename");
} 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, "/");

View File

@ -0,0 +1,54 @@
# Include inside the authenticated HiCAD HTTPS server block.
# Replace __AI_CAD_WS_PROXY_SECRET__ during deployment and chmod the rendered file 600.
location ^~ /vendor/zoo-modeling-app/assets/ {
root /var/www/ai-chatbot/public;
try_files $uri =404;
gzip_static on;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ^~ /vendor/zoo-modeling-app/fonts/ {
root /var/www/ai-chatbot/public;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location = /vendor/zoo-modeling-app/kcl_wasm_lib_bg.wasm {
root /var/www/ai-chatbot/public;
try_files $uri =404;
add_header Cache-Control "public, max-age=3600" always;
}
location = /_hicad_zoo_ws_auth {
internal;
proxy_pass http://127.0.0.1:3125/api/ai-cad/zoo-ws-authorize;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-HiCAD-Original-URI $request_uri;
proxy_set_header X-HiCAD-WS-Proxy-Secret "__AI_CAD_WS_PROXY_SECRET__";
}
location ^~ /api/ai-cad/zoo-ws/ {
auth_request /_hicad_zoo_ws_auth;
auth_request_set $zoo_authorization $upstream_http_x_zoo_authorization;
proxy_pass https://api.zoo.dev/ws/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_ssl_name api.zoo.dev;
proxy_set_header Host api.zoo.dev;
proxy_set_header Origin https://app.zoo.dev;
proxy_set_header Authorization $zoo_authorization;
proxy_set_header Cookie "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}

650
lib/ai/atlascloud-models.ts Normal file
View File

@ -0,0 +1,650 @@
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,
};
}

View File

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

View File

@ -0,0 +1,184 @@
export interface BananaImageModelPreset {
modelId: string;
displayName: string;
description: string;
supportsVision: boolean;
supportsImageGen: boolean;
capabilities?: Record<string, any>;
defaultParams?: Record<string, any>;
imageGenConfig?: Record<string, any>;
sortOrder: number;
}
export const BANANAIMAGE_BASE_URL = "https://bananaimage.aigenmedia.art";
const BANANAIMAGE_STANDARD_RATIOS = [
"auto",
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
"21:9",
];
const BANANAIMAGE_EXTENDED_RATIOS = [
"auto",
"1:1",
"1:4",
"1:8",
"2:3",
"3:2",
"3:4",
"4:1",
"4:3",
"4:5",
"5:4",
"8:1",
"9:16",
"16:9",
"21:9",
];
const BANANAIMAGE_GPT_IMAGE_2_RATIOS = [
"auto",
"1:1",
"4:5",
"5:4",
"3:4",
"4:3",
"2:3",
"3:2",
"9:16",
"16:9",
"1:2",
"2:1",
"9:21",
"21:9",
];
const BANANAIMAGE_IMAGE_SIZES = ["1K", "2K", "4K"];
const BANANAIMAGE_IMAGE_CONFIG = {
maxImages: 1,
supportsSize: true,
supports1K: true,
supports2K: true,
supports4K: true,
supportsPromptExtend: false,
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
maxReferenceImages: 14,
supportedImageSizes: BANANAIMAGE_IMAGE_SIZES,
enableLoadingPlaceholder: true,
loadingColor: "yellow",
};
function buildBananaImagePreset(params: {
modelId: string;
displayName: string;
description: string;
apiFormat: "bananaimage-gemini" | "bananaimage-gpt-image-2";
supportedAspectRatios: string[];
sortOrder: number;
}) {
return {
modelId: params.modelId,
displayName: params.displayName,
description: params.description,
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "bananaimage",
apiFormat: params.apiFormat,
supportedModes: ["text-to-image", "image-to-image", "reference-images"],
supportedAspectRatios: params.supportedAspectRatios,
supportedImageSizes: BANANAIMAGE_IMAGE_SIZES,
supportsMultiImageReference: true,
maxReferenceImages: 14,
fourKAllowedRatios:
params.apiFormat === "bananaimage-gpt-image-2"
? BANANAIMAGE_GPT_IMAGE_2_RATIOS.filter((ratio) => ratio !== "auto")
: undefined,
},
defaultParams: {
provider: "bananaimage",
apiFormat: params.apiFormat,
endpoint: params.modelId,
imageSize: "2K",
aspectRatio: "auto",
supportedModes: ["text-to-image", "image-to-image", "reference-images"],
},
imageGenConfig: {
...BANANAIMAGE_IMAGE_CONFIG,
apiFormat: params.apiFormat,
supportedAspectRatios: params.supportedAspectRatios,
},
sortOrder: params.sortOrder,
} satisfies BananaImageModelPreset;
}
export const BANANAIMAGE_MODEL_PRESETS: BananaImageModelPreset[] = [
buildBananaImagePreset({
modelId: "gemini-3-pro-image-preview",
displayName: "BananaImage Nano Banana Pro",
description: "BananaImage Nano Banana Pro。支持文生图、图片编辑、多图参考、比例选择和 1K/2K/4K 输出。",
apiFormat: "bananaimage-gemini",
supportedAspectRatios: BANANAIMAGE_STANDARD_RATIOS,
sortOrder: 10,
}),
buildBananaImagePreset({
modelId: "gemini-3.1-flash-image-preview",
displayName: "BananaImage Nano Banana 2",
description: "BananaImage Nano Banana 2。支持文生图、图片编辑、多图参考、极端比例和 1K/2K/4K 输出。",
apiFormat: "bananaimage-gemini",
supportedAspectRatios: BANANAIMAGE_EXTENDED_RATIOS,
sortOrder: 20,
}),
buildBananaImagePreset({
modelId: "gemini-3-pro-image-preview-plus",
displayName: "BananaImage Nano Banana Pro+",
description: "BananaImage Nano Banana Pro+ 放宽限制版。支持文生图、图片编辑、多图参考、比例选择和 1K/2K/4K 输出。",
apiFormat: "bananaimage-gemini",
supportedAspectRatios: BANANAIMAGE_STANDARD_RATIOS,
sortOrder: 30,
}),
buildBananaImagePreset({
modelId: "gemini-3.1-flash-image-preview-plus",
displayName: "BananaImage Nano Banana 2+",
description: "BananaImage Nano Banana 2+ 放宽限制版。支持文生图、图片编辑、多图参考、极端比例和 1K/2K/4K 输出。",
apiFormat: "bananaimage-gemini",
supportedAspectRatios: BANANAIMAGE_EXTENDED_RATIOS,
sortOrder: 40,
}),
buildBananaImagePreset({
modelId: "gpt-image-2",
displayName: "BananaImage GPT Image 2",
description: "BananaImage GPT Image 2。支持文生图、图片编辑、最多 14 张参考图、比例选择和 1K/2K/4K 输出。",
apiFormat: "bananaimage-gpt-image-2",
supportedAspectRatios: BANANAIMAGE_GPT_IMAGE_2_RATIOS,
sortOrder: 50,
}),
];
export function getBananaImageModelPresetById(modelId: string) {
return BANANAIMAGE_MODEL_PRESETS.find(
(preset) => preset.modelId === String(modelId || "").trim()
);
}
export function resolveBananaImageRuntimeModelId(modelId: string) {
const preset = getBananaImageModelPresetById(modelId);
const endpoint = preset?.defaultParams?.endpoint;
if (typeof endpoint === "string" && endpoint.trim()) {
return endpoint.trim();
}
return modelId;
}

View File

@ -24,6 +24,9 @@ import { createRuxaImage } from "./providers/ruxa-image";
import { createFalImage } from "./providers/fal-image";
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";
@ -294,6 +297,33 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return openAIImageProvider as any;
}
case "bananaimage": {
const bananaImageProvider = createBananaImage({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
logProviderCreated("bananaimage", resolvedBaseUrl);
return bananaImageProvider as any;
}
case "visionary": {
const visionaryProvider = createVisionary({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
logProviderCreated("visionary", resolvedBaseUrl);
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,
@ -462,6 +492,15 @@ export async function getDynamicModel(
// OpenAI Images 兼容图片生成/编辑使用 chat() 方法
baseModel = providerInstance.chat(resolvedModelId);
logChatModelResult("openai-image", baseModel);
} else if (provider.type === "bananaimage") {
baseModel = providerInstance.chat(resolvedModelId);
logChatModelResult("bananaimage", baseModel);
} 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);

View File

@ -19,11 +19,19 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
"snapgen-image",
"grsai-gpt-image",
"openai-image",
"bananaimage",
"visionary",
"stepfun-image",
"midjourney",
"volcano-vision",
]);
const STATIC_3D_PROVIDER_TYPES = new Set([
"tripo-3d",
"worldlabs-3d",
"kittycad-cad",
]);
const RUNWAY_IMAGE_MODEL_IDS = new Set([
"gen4_image",
"gen4_image_turbo",
@ -44,9 +52,40 @@ const RUNWAY_VIDEO_MODEL_PREFIXES = [
const VIDEO_MODE_KEYS = new Set([
"text-to-video",
"image-to-video",
"video-to-video",
"audio-to-video",
"first-frame",
"first-last-frame",
"reference-images",
]);
const VIDEO_KIND_KEYS = new Set([
"video",
"text-to-video",
"image-to-video",
"video-to-video",
"audio-to-video",
]);
const THREE_D_MODE_KEYS = new Set([
"3d",
"text-to-3d",
"text_to_3d",
"image-to-3d",
"image_to_3d",
"mesh-generation",
"mesh_generation",
"cad",
]);
const THREE_D_KIND_KEYS = new Set([
"3d",
"model-3d",
"model_3d",
"mesh",
"mesh-generation",
"mesh_generation",
"cad",
]);
const TEXT_TO_IMAGE_MODE_KEYS = new Set([
@ -105,6 +144,48 @@ function hasVideoModes(capabilities: unknown) {
return supportedModes.some((mode) => VIDEO_MODE_KEYS.has(String(mode || "").trim()));
}
function hasVideoKind(capabilities: unknown) {
return ["kind", "mediaType", "modelType", "outputType"].some((key) =>
VIDEO_KIND_KEYS.has(getStringField(capabilities, key))
);
}
function has3DModes(capabilities: unknown) {
const supportedModes = getStringArrayField(capabilities, "supportedModes");
return supportedModes.some((mode) => THREE_D_MODE_KEYS.has(mode));
}
function has3DKind(capabilities: unknown) {
return ["kind", "mediaType", "modelType", "outputType"].some((key) =>
THREE_D_KIND_KEYS.has(getStringField(capabilities, key))
);
}
function modelIdLooks3D(modelId?: string | null) {
const normalized = normalizeText(modelId);
if (!normalized) return false;
return (
/(?:^|[/_.-])(?:text|image)[_-]to[_-]3d(?:$|[/_.-])/.test(normalized) ||
/(?:^|[/_.-])(?:hunyuan3d|seed3d|tripo-h3(?:\.\d+)?)(?:$|[/_.-])/.test(
normalized
)
);
}
export function is3DModelDescriptor(params: {
providerType?: string | null;
modelId?: string | null;
capabilities?: unknown;
}): boolean {
const providerType = normalizeText(params.providerType);
return (
STATIC_3D_PROVIDER_TYPES.has(providerType) ||
has3DModes(params.capabilities) ||
has3DKind(params.capabilities) ||
modelIdLooks3D(params.modelId)
);
}
export function inferRunwayModelKind(modelId?: string | null): "image" | "video" | null {
const normalized = normalizeText(modelId);
if (!normalized) return null;
@ -127,11 +208,15 @@ export function isVideoModelDescriptor(params: {
}): boolean {
const providerType = normalizeText(params.providerType);
if (is3DModelDescriptor(params)) {
return false;
}
if (isStaticVideoProviderType(providerType)) {
return true;
}
if (hasVideoModes(params.capabilities)) {
if (hasVideoModes(params.capabilities) || hasVideoKind(params.capabilities)) {
return true;
}
@ -154,6 +239,10 @@ export function isImageGenerationModelDescriptor(params: {
}): boolean {
const providerType = normalizeText(params.providerType);
if (is3DModelDescriptor(params)) {
return false;
}
if (providerType === "runway") {
const runwayModelKind = inferRunwayModelKind(params.modelId);
if (runwayModelKind === "image") return true;

View File

@ -60,6 +60,9 @@ const IMAGE_ONLY_PROVIDER_TYPES = new Set([
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"gemini-image",
@ -172,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", "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: "图片编辑",
@ -187,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", "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: "图片生成",
},
@ -215,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", "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", "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", "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", "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",
},
};

View File

@ -17,6 +17,9 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
"fal-image": "https://fal.run",
"snapgen-image": "https://api.snapgen.ai",
"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",

View File

@ -0,0 +1,454 @@
import "server-only";
import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
createAtlasCloudRequestBody,
type AtlasCloudImageParams,
type AtlasCloudModelConfig,
} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
interface AtlasCloudConfig {
apiKey: string;
baseURL?: string;
}
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 extractPromptAndMedia(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const message = messages.filter((item) => item.role === "user").pop();
if (!message) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
const videoUrls: string[] = [];
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushMedia = (value: unknown, mediaType?: 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?:\/\/|data:)/i.test(url)) return;
const normalizedMediaType = String(mediaType || "").toLowerCase();
const isVideo =
normalizedMediaType.startsWith("video/") ||
url.startsWith("data:video/") ||
/\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
const target = isVideo ? videoUrls : imageUrls;
if (!target.includes(url)) target.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") return pushText(part);
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
if (part.type === "file") {
pushMedia(
part.url || part.file || part.data,
part.mediaType || part.contentType || part.mimeType
);
}
if (part.inlineData?.data) {
pushMedia(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
part.inlineData.mimeType
);
}
};
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, videoUrls };
}
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 loadMediaSource(mediaUrl: string) {
if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl);
const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
Accept: "image/*,video/*,*/*;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";
if (/quicktime/i.test(mimeType)) return "mov";
if (/webm/i.test(mimeType)) return "webm";
if (/mp4/i.test(mimeType)) return "mp4";
return "png";
}
async function uploadReferenceMedia(
baseURL: string | undefined,
apiKey: string,
mediaUrl: string
) {
if (/^https:\/\//i.test(mediaUrl)) return mediaUrl;
const source = await loadMediaSource(mediaUrl);
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;
}
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) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.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, videoUrls } = extractPromptAndMedia(
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) => uploadReferenceMedia(baseURL, apiKey, url))
);
const maxVideoReferences = Math.max(
0,
Math.min(
4,
modelConfig.maxVideoReferences ??
modelConfig.atlasCloud.maxVideoReferences ??
0
)
);
const referenceVideos = await Promise.all(
videoUrls
.slice(0, maxVideoReferences)
.map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
const { body, imageSize } = createAtlasCloudRequestBody({
modelId: runtimeModelId,
prompt,
referenceImages,
referenceVideos,
imageParams: options.imageParams,
modelConfig,
});
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 };
},
}),
};
}

View File

@ -0,0 +1,664 @@
import "server-only";
import sharp from "sharp";
import {
getBananaImageModelPresetById,
resolveBananaImageRuntimeModelId,
} from "@/lib/ai/bananaimage-models";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
interface BananaImageConfig {
apiKey: string;
baseURL: string;
}
interface BananaImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
}
interface BananaImageModelConfig {
apiFormat?: "bananaimage-gemini" | "bananaimage-gpt-image-2";
maxReferenceImages?: number;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
}
interface ReferenceImage {
base64: string;
dataUrl: string;
mimeType: string;
width?: number;
height?: number;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const GEMINI_POLL_INTERVAL_MS = 3000;
const GEMINI_POLL_MAX_ATTEMPTS = 600;
const GPT_POLL_INTERVAL_MS = 4000;
const GPT_POLL_MAX_ATTEMPTS = 90;
const MAX_TRANSIENT_POLL_FAILURES = 8;
const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000;
function joinApiPath(baseURL: string, path: string) {
const cleanBase = baseURL.replace(/\/+$/, "");
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeModelId(modelId?: string) {
const normalized = String(modelId || "").trim();
if (!normalized) throw new Error("BananaImage 模型 ID 未配置");
return normalized;
}
function normalizeAspectRatioLabel(value?: string) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) return "auto";
if (normalized === "auto") return "auto";
const match = normalized.match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return "auto";
return `${Number(match[1])}:${Number(match[2])}`;
}
function parseAspectRatio(value: string) {
const match = value.match(/^(\d+):(\d+)$/);
if (!match) return null;
const width = Number(match[1]);
const height = Number(match[2]);
if (!width || !height) return null;
return width / height;
}
function findClosestAspectRatio(
width: number,
height: number,
supportedAspectRatios: string[]
) {
const target = width / height;
let closest = supportedAspectRatios.includes("1:1")
? "1:1"
: supportedAspectRatios[0] || "1:1";
let score = Number.POSITIVE_INFINITY;
for (const ratio of supportedAspectRatios) {
const value = parseAspectRatio(ratio);
if (!value) continue;
const candidateScore = Math.abs(Math.log(target / value));
if (candidateScore < score) {
closest = ratio;
score = candidateScore;
}
}
return closest;
}
function getSupportedAspectRatios(
modelId: string,
modelConfig?: BananaImageModelConfig | null
) {
const configured = modelConfig?.supportedAspectRatios;
const preset = getBananaImageModelPresetById(modelId);
const ratios = Array.isArray(configured)
? configured
: (preset?.imageGenConfig?.supportedAspectRatios as string[] | undefined);
const normalized = (ratios || [])
.map((ratio) => normalizeAspectRatioLabel(ratio))
.filter((ratio) => ratio !== "auto" && parseAspectRatio(ratio) !== null);
return Array.from(new Set(normalized.length > 0 ? normalized : ["1:1"]));
}
function resolveAspectRatio({
requested,
referenceImages,
supportedAspectRatios,
}: {
requested?: string;
referenceImages: ReferenceImage[];
supportedAspectRatios: string[];
}) {
const normalized = normalizeAspectRatioLabel(requested);
if (normalized !== "auto" && supportedAspectRatios.includes(normalized)) {
return normalized;
}
const firstReference = referenceImages[0];
if (firstReference?.width && firstReference?.height) {
return findClosestAspectRatio(
firstReference.width,
firstReference.height,
supportedAspectRatios
);
}
return supportedAspectRatios.includes("1:1")
? "1:1"
: supportedAspectRatios[0] || "1:1";
}
function normalizeImageSize(
requested?: string,
modelConfig?: BananaImageModelConfig | null
) {
const supported = (modelConfig?.supportedImageSizes || ["1K", "2K", "4K"])
.map((size) => String(size).trim().toUpperCase())
.filter((size) => ["1K", "2K", "4K"].includes(size));
const normalized = String(requested || "2K").trim().toUpperCase();
if (supported.includes(normalized)) return normalized;
return supported.includes("2K") ? "2K" : supported[0] || "2K";
}
function resolveApiFormat(
modelId: string,
modelConfig?: BananaImageModelConfig | null
) {
if (modelConfig?.apiFormat) return modelConfig.apiFormat;
const preset = getBananaImageModelPresetById(modelId);
const configured = preset?.imageGenConfig?.apiFormat;
if (
configured === "bananaimage-gemini" ||
configured === "bananaimage-gpt-image-2"
) {
return configured;
}
return modelId === "gpt-image-2"
? "bananaimage-gpt-image-2"
: "bananaimage-gemini";
}
function extractPromptAndImages(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
const seenImages = new Set<string>();
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushImage = (value: unknown) => {
let url = "";
if (typeof value === "string") url = value;
else if (value instanceof URL) url = value.toString();
else if (value && typeof value === "object") {
url = String(
(value as any).url ||
(value as any).imageUrl ||
(value as any).fileUrl ||
(value as any).src ||
""
);
}
url = url.trim();
if (!url || seenImages.has(url)) return;
if (!url.startsWith("data:image/") && !/^https?:\/\//i.test(url)) return;
seenImages.add(url);
imageUrls.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") {
pushText(part);
return;
}
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}`
);
}
};
const content = lastUserMessage.content;
if (typeof content === "string") pushText(content);
else if (Array.isArray(content)) content.forEach(visitPart);
if (Array.isArray(lastUserMessage.parts)) lastUserMessage.parts.forEach(visitPart);
const prompt = texts.join("\n").trim();
if (!prompt) throw new Error("请输入图片生成或编辑要求");
return { prompt, imageUrls };
}
function parseDataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/s);
if (!match) throw new Error("参考图 Base64 格式无效");
const mimeType = match[1] || "image/png";
const base64 = match[2] || "";
return { mimeType, base64, buffer: Buffer.from(base64, "base64") };
}
async function loadReferenceImage(imageUrl: string): Promise<ReferenceImage> {
let mimeType = "image/png";
let base64 = "";
let buffer: Buffer;
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
mimeType = parsed.mimeType;
base64 = parsed.base64;
buffer = parsed.buffer;
} else {
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
headers: {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
"User-Agent": "yedir-ai-bananaimage/1.0",
},
});
if (!response.ok) {
throw new Error(`BananaImage 参考图下载失败(HTTP ${response.status})`);
}
mimeType = response.headers.get("content-type")?.split(";")[0] || "image/png";
buffer = Buffer.from(await response.arrayBuffer());
base64 = buffer.toString("base64");
}
const metadata = await sharp(buffer, { failOn: "none" }).metadata().catch(() => null);
return {
base64,
dataUrl: `data:${mimeType};base64,${base64}`,
mimeType,
width: metadata?.width,
height: metadata?.height,
};
}
function detectImageExtension(mimeType?: string | null) {
const normalized = String(mimeType || "").toLowerCase();
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
return "png";
}
function generateFilename(prefix: string, extension: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
async function uploadImageBuffer(
buffer: Buffer,
mimeType?: string | null
): Promise<UploadedImage> {
const extension = detectImageExtension(mimeType);
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer()
.catch(() => buffer);
const original = await uploadFile(
generateFilename("bananaimage-orig", extension),
toArrayBuffer(buffer)
);
const thumbnail = await uploadFile(
generateFilename("bananaimage-thumb", "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: "bananaimage",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(REFERENCE_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`BananaImage 结果图片下载失败(HTTP ${response.status})`);
}
return uploadImageBuffer(
Buffer.from(await response.arrayBuffer()),
response.headers.get("content-type")
);
}
async function parseJsonResponse(response: Response) {
const rawText = await response.text();
if (!rawText.trim()) return null;
try {
return JSON.parse(rawText);
} catch {
return rawText;
}
}
function extractErrorMessage(payload: any, fallback = "BananaImage 请求失败") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
return (
payload?.error?.message ||
payload?.error ||
payload?.message ||
payload?.detail ||
fallback
);
}
function extractTaskId(payload: any) {
const value =
payload?.task_id ||
payload?.taskId ||
payload?.id ||
payload?.data?.task_id ||
payload?.data?.taskId ||
payload?.data?.id;
return typeof value === "string" && value.trim() ? value.trim() : "";
}
function extractGeminiImages(payload: any) {
const parts = payload?.candidates?.[0]?.content?.parts;
if (!Array.isArray(parts)) return [];
const images: string[] = [];
for (const part of parts) {
if (part?.inlineData?.data) {
images.push(
`data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
);
} else if (part?.fileData?.fileUri) {
images.push(String(part.fileData.fileUri));
} else if (/^https?:\/\/\S+$/i.test(String(part?.text || "").trim())) {
images.push(String(part.text).trim());
}
}
return Array.from(new Set(images));
}
async function submitGeminiTask({
baseURL,
apiKey,
model,
prompt,
referenceImages,
aspectRatio,
imageSize,
}: {
baseURL: string;
apiKey: string;
model: string;
prompt: string;
referenceImages: ReferenceImage[];
aspectRatio: string;
imageSize: string;
}) {
const response = await fetch(
joinApiPath(baseURL, "/api/gemini/v1beta/asyncGenerateContent"),
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-Client-Source": "web",
},
body: JSON.stringify({
contents: [
{
role: "user",
parts: [
{ text: prompt },
...referenceImages.map((image) => ({
inlineData: { mimeType: image.mimeType, data: image.base64 },
})),
],
},
],
generationConfig: {
responseModalities: ["IMAGE"],
imageConfig: { aspectRatio, imageSize },
},
enableFallback: true,
model,
}),
}
);
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
}
const taskId = extractTaskId(payload);
if (!taskId) throw new Error("BananaImage 未返回 task_id");
return taskId;
}
async function waitForGeminiTask(baseURL: string, apiKey: string, taskId: string) {
let transientFailures = 0;
for (let attempt = 0; attempt < GEMINI_POLL_MAX_ATTEMPTS; attempt += 1) {
await delay(GEMINI_POLL_INTERVAL_MS);
try {
const response = await fetch(
joinApiPath(baseURL, `/api/gemini/v1beta/tasks/${encodeURIComponent(taskId)}`),
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" }
);
if (!response.ok) {
if (response.status >= 500) throw new Error(`HTTP ${response.status}`);
const payload = await parseJsonResponse(response);
throw new Error(extractErrorMessage(payload));
}
const payload = await parseJsonResponse(response);
transientFailures = 0;
if (payload?.status === "completed") {
const images = extractGeminiImages(payload.response);
if (images.length === 0) throw new Error("BananaImage 任务完成但未返回图片");
return images;
}
if (payload?.status === "failed") {
throw new Error(extractErrorMessage(payload, "BananaImage 图片生成失败"));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/^HTTP 5\d\d$/.test(message) && !/fetch|network|abort/i.test(message)) {
throw error;
}
transientFailures += 1;
if (transientFailures >= MAX_TRANSIENT_POLL_FAILURES) throw error;
}
}
throw new Error("BananaImage 生成超时,任务可能仍在处理,请稍后重试");
}
async function submitGptImageTask({
baseURL,
apiKey,
prompt,
referenceImages,
aspectRatio,
imageSize,
}: {
baseURL: string;
apiKey: string;
prompt: string;
referenceImages: ReferenceImage[];
aspectRatio: string;
imageSize: string;
}) {
const body: Record<string, unknown> = {
model: "gpt-image-2",
prompt,
size: aspectRatio.toLowerCase(),
resolution: imageSize.toLowerCase(),
};
if (referenceImages.length > 0) {
body.image_urls = referenceImages.map((image) => image.dataUrl);
}
const response = await fetch(joinApiPath(baseURL, "/api/v1/images/generations"), {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-Client-Source": "web",
},
body: JSON.stringify(body),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
}
const taskId = extractTaskId(payload);
if (!taskId) throw new Error("BananaImage GPT Image 2 未返回 task_id");
return taskId;
}
async function waitForGptImageTask(baseURL: string, apiKey: string, taskId: string) {
let transientFailures = 0;
for (let attempt = 0; attempt < GPT_POLL_MAX_ATTEMPTS; attempt += 1) {
await delay(GPT_POLL_INTERVAL_MS);
try {
const response = await fetch(
joinApiPath(baseURL, `/api/v1/images/tasks/${encodeURIComponent(taskId)}`),
{ headers: { Authorization: `Bearer ${apiKey}` }, cache: "no-store" }
);
if (!response.ok) {
if (response.status >= 500) throw new Error(`HTTP ${response.status}`);
const payload = await parseJsonResponse(response);
throw new Error(extractErrorMessage(payload));
}
const payload = await parseJsonResponse(response);
transientFailures = 0;
if (payload?.status === "completed") {
const url = String(payload?.url || "").trim();
if (!url) throw new Error("BananaImage GPT Image 2 任务完成但未返回图片");
return [url];
}
if (payload?.status === "failed") {
throw new Error(extractErrorMessage(payload, "BananaImage GPT Image 2 生成失败"));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!/^HTTP 5\d\d$/.test(message) && !/fetch|network|abort/i.test(message)) {
throw error;
}
transientFailures += 1;
if (transientFailures >= MAX_TRANSIENT_POLL_FAILURES) throw error;
}
}
throw new Error("BananaImage GPT Image 2 生成超时,请稍后重试");
}
function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.join("\n\n");
}
export function createBananaImage({ apiKey, baseURL }: BananaImageConfig) {
return {
provider: "bananaimage",
chat: (modelId: string) => ({
provider: "bananaimage",
doGenerate: async (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: BananaImageParams;
imageGenConfig?: BananaImageModelConfig | null;
overrideModelId?: string;
}) => {
const requestedModelId = normalizeModelId(options.overrideModelId || modelId);
const runtimeModelId = resolveBananaImageRuntimeModelId(requestedModelId);
const apiFormat = resolveApiFormat(requestedModelId, options.imageGenConfig);
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const maxReferenceImages = Math.max(
0,
Math.min(14, options.imageGenConfig?.maxReferenceImages || 14)
);
const referenceImages = await Promise.all(
imageUrls.slice(0, maxReferenceImages).map(loadReferenceImage)
);
const supportedAspectRatios = getSupportedAspectRatios(
requestedModelId,
options.imageGenConfig
);
const aspectRatio = resolveAspectRatio({
requested: options.imageParams?.aspectRatio,
referenceImages,
supportedAspectRatios,
});
const imageSize = normalizeImageSize(
options.imageParams?.imageSize,
options.imageGenConfig
);
let outputUrls: string[];
if (apiFormat === "bananaimage-gpt-image-2") {
const taskId = await submitGptImageTask({
baseURL,
apiKey,
prompt,
referenceImages,
aspectRatio,
imageSize,
});
outputUrls = await waitForGptImageTask(baseURL, apiKey, taskId);
} else {
const taskId = await submitGeminiTask({
baseURL,
apiKey,
model: runtimeModelId,
prompt,
referenceImages,
aspectRatio,
imageSize,
});
outputUrls = await waitForGeminiTask(baseURL, apiKey, taskId);
}
const uploadedImages = await Promise.all(
outputUrls.slice(0, Math.max(1, options.imageParams?.imageCount || 1)).map(archiveOutputImage)
);
const text = formatMarkdown(uploadedImages, 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?: BananaImageParams;
imageGenConfig?: BananaImageModelConfig | null;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield { type: "text-delta", textDelta: result.text };
},
}),
};
}

View File

@ -0,0 +1,532 @@
import "server-only";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import {
getVisionaryModelPresetById,
VISIONARY_BASE_URL,
} from "@/lib/ai/visionary-models";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
interface VisionaryConfig {
apiKey: string;
baseURL?: string;
}
interface VisionaryImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
promptExtend?: boolean;
quality?: "auto" | "low" | "medium" | "high" | string;
}
interface VisionaryModelConfig {
maxReferenceImages?: number;
supportedAspectRatios?: string[];
supportedImageSizes?: string[];
supportsPromptExtend?: boolean;
supportsQuality?: boolean;
}
interface ReferenceImage {
apiValue: string;
height?: number;
width?: number;
}
interface UploadedImage {
thumbnail: string;
original: string;
}
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_INTERVAL_MS = 15_000;
const POLL_TIMEOUT_MS = 18 * 60 * 1000;
const MAX_TRANSIENT_POLL_FAILURES = 8;
const IMAGE_DOWNLOAD_TIMEOUT_MS = 120_000;
function joinApiPath(baseURL: string, path: string) {
const cleanBase = String(baseURL || VISIONARY_BASE_URL).replace(/\/+$/, "");
const cleanPath = path.startsWith("/") ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeModelId(modelId?: string) {
const normalized = String(modelId || "").trim();
if (!normalized) throw new Error("Visionary 模型 ID 未配置");
return normalized;
}
function normalizeAspectRatio(value?: string) {
const normalized = String(value || "auto").trim().toLowerCase();
if (normalized === "auto") return "auto";
const match = normalized.match(/^(\d+)\s*:\s*(\d+)$/);
if (!match) return "auto";
return `${Number(match[1])}:${Number(match[2])}`;
}
function getSupportedAspectRatios(
modelId: string,
modelConfig?: VisionaryModelConfig | null
) {
const preset = getVisionaryModelPresetById(modelId);
const configured = modelConfig?.supportedAspectRatios;
const values = Array.isArray(configured)
? configured
: (preset?.imageGenConfig?.supportedAspectRatios as string[] | undefined);
return Array.from(
new Set((values || ["auto", "1:1"]).map(normalizeAspectRatio))
);
}
function resolveAspectRatio(
requested: string | undefined,
modelId: string,
modelConfig?: VisionaryModelConfig | null
) {
const supported = getSupportedAspectRatios(modelId, modelConfig);
const normalized = normalizeAspectRatio(requested);
if (supported.includes(normalized)) return normalized;
return supported.includes("auto") ? "auto" : supported[0] || "1:1";
}
function normalizeImageSize(
requested: string | undefined,
modelId: string,
modelConfig?: VisionaryModelConfig | null
) {
const preset = getVisionaryModelPresetById(modelId);
const configured = modelConfig?.supportedImageSizes;
const supported = (Array.isArray(configured)
? configured
: (preset?.imageGenConfig?.supportedImageSizes as string[] | undefined) || ["1K"]
)
.map((size) => String(size).trim().toUpperCase())
.filter((size) => ["1K", "2K", "4K"].includes(size));
const defaultSize = String(preset?.defaultParams?.imageSize || supported[0] || "1K")
.trim()
.toUpperCase();
const normalized = String(requested || defaultSize).trim().toUpperCase();
if (supported.includes(normalized)) return normalized;
return supported.includes(defaultSize) ? defaultSize : supported[0] || "1K";
}
function normalizeQuality(value?: string) {
const normalized = String(value || "auto").trim().toLowerCase();
return ["auto", "low", "medium", "high"].includes(normalized)
? normalized
: "auto";
}
function extractPromptAndImages(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
const seenImages = new Set<string>();
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
const pushImage = (value: unknown) => {
let url = "";
if (typeof value === "string") url = value;
else if (value instanceof URL) url = value.toString();
else if (value && typeof value === "object") {
url = String(
(value as any).url ||
(value as any).imageUrl ||
(value as any).fileUrl ||
(value as any).src ||
""
);
}
url = url.trim();
if (!url || seenImages.has(url)) return;
if (!url.startsWith("data:image/") && !/^https?:\/\//i.test(url)) return;
seenImages.add(url);
imageUrls.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") {
pushText(part);
return;
}
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}`
);
}
};
const content = lastUserMessage.content;
if (typeof content === "string") pushText(content);
else if (Array.isArray(content)) content.forEach(visitPart);
if (Array.isArray(lastUserMessage.parts)) lastUserMessage.parts.forEach(visitPart);
const prompt = texts.join("\n").trim();
if (!prompt) throw new Error("请输入图片生成或编辑要求");
return { prompt, imageUrls };
}
function parseDataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:(image\/(?:jpeg|png|webp));base64,(.+)$/s);
if (!match) throw new Error("Visionary 参考图仅支持 JPEG、PNG 或 WebP Data URI");
const mimeType = match[1] || "image/png";
const buffer = Buffer.from(match[2] || "", "base64");
return { mimeType, buffer, dataUrl };
}
async function loadReferenceImage(imageUrl: string): Promise<ReferenceImage> {
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
const metadata = await sharp(parsed.buffer, { failOn: "none" })
.metadata()
.catch(() => null);
return {
apiValue: parsed.dataUrl,
width: metadata?.width,
height: metadata?.height,
};
}
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS),
headers: {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
"User-Agent": "yedir-ai-visionary/1.0",
},
});
if (!response.ok) {
throw new Error(`Visionary 参考图下载失败(HTTP ${response.status})`);
}
const mimeType = response.headers.get("content-type")?.split(";")[0] || "";
if (!/^image\/(?:jpeg|png|webp)$/i.test(mimeType)) {
throw new Error(`Visionary 不支持此参考图格式:${mimeType || "unknown"}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const metadata = await sharp(buffer, { failOn: "none" })
.metadata()
.catch(() => null);
return {
apiValue: /^https:\/\//i.test(imageUrl)
? imageUrl
: `data:${mimeType};base64,${buffer.toString("base64")}`,
width: metadata?.width,
height: metadata?.height,
};
}
function detectImageExtension(mimeType?: string | null) {
const normalized = String(mimeType || "").toLowerCase();
if (normalized.includes("jpeg") || normalized.includes("jpg")) return "jpg";
if (normalized.includes("webp")) return "webp";
if (normalized.includes("gif")) return "gif";
return "png";
}
function generateFilename(prefix: string, extension: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
}
function toArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
}
async function uploadImageBuffer(
buffer: Buffer,
mimeType?: string | null
): Promise<UploadedImage> {
const extension = detectImageExtension(mimeType);
const thumbnailBuffer = await sharp(buffer, { failOn: "none" })
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 82 })
.toBuffer()
.catch(() => buffer);
const [original, thumbnail] = await Promise.all([
uploadFile(generateFilename("visionary-orig", extension), toArrayBuffer(buffer)),
uploadFile(generateFilename("visionary-thumb", "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: "visionary",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(IMAGE_DOWNLOAD_TIMEOUT_MS),
headers: { "User-Agent": "yedir-ai-visionary/1.0" },
});
if (!response.ok) {
throw new Error(`Visionary 结果图片下载失败(HTTP ${response.status})`);
}
return uploadImageBuffer(
Buffer.from(await response.arrayBuffer()),
response.headers.get("content-type")
);
}
async function parseJsonResponse(response: Response) {
const rawText = await response.text();
if (!rawText.trim()) return null;
try {
return JSON.parse(rawText);
} catch {
return rawText;
}
}
function extractErrorMessage(payload: any, fallback = "Visionary 请求失败") {
if (typeof payload === "string" && payload.trim()) return payload.trim();
const value =
payload?.error?.message ||
payload?.data?.error?.message ||
payload?.data?.error ||
payload?.error ||
payload?.message ||
fallback;
return typeof value === "string" ? value : fallback;
}
function extractSubmittedTask(payload: any) {
const task = Array.isArray(payload?.data) ? payload.data[0] : payload?.data || payload;
const taskId = String(task?.task_id || task?.taskId || task?.id || "").trim();
const retryAfter = Number(task?.retry_after || task?.retryAfter || 3);
return { taskId, retryAfter };
}
function extractTaskImages(payload: any) {
const task = payload?.data || payload;
const images = task?.result?.images;
if (!Array.isArray(images)) return [] as string[];
return Array.from(
new Set(
images.flatMap((image: any) => {
const value = image?.url;
if (Array.isArray(value)) return value;
return value ? [value] : [];
})
)
)
.map((value) => String(value || "").trim())
.filter((value) => /^https?:\/\//i.test(value) || value.startsWith("data:image/"));
}
async function submitTask(params: {
apiKey: string;
aspectRatio: string;
baseURL: string;
imageSize: string;
modelId: string;
prompt: string;
promptExtend?: boolean;
quality?: string;
referenceImages: ReferenceImage[];
}) {
const clientRequestId = `yedir-${randomUUID()}`;
const body: Record<string, unknown> = {
model: params.modelId,
prompt: params.prompt,
images: params.referenceImages.map((image) => image.apiValue),
size: params.aspectRatio,
resolution: params.imageSize,
client_request_id: clientRequestId,
};
if (params.modelId === "gpt-image-2") {
body.quality = normalizeQuality(params.quality);
}
if (params.modelId === "nano-banana-pro") {
body.optimizeChineseText = Boolean(params.promptExtend);
}
const response = await fetch(joinApiPath(params.baseURL, "/v1/images/generations"), {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": clientRequestId,
"User-Agent": "yedir-ai-visionary/1.0",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(`${extractErrorMessage(payload)}(HTTP ${response.status})`);
}
const task = extractSubmittedTask(payload);
if (!task.taskId) throw new Error("Visionary 未返回 task_id");
return task;
}
async function waitForTask(params: {
apiKey: string;
baseURL: string;
initialRetryAfter: number;
taskId: string;
}) {
const startedAt = Date.now();
let retryAfter = params.initialRetryAfter;
let transientFailures = 0;
while (Date.now() - startedAt < POLL_TIMEOUT_MS) {
const waitMs = Math.min(
MAX_POLL_INTERVAL_MS,
Math.max(POLL_INTERVAL_MS, Math.round(retryAfter * 1000))
);
await delay(waitMs);
try {
const response = await fetch(
joinApiPath(params.baseURL, `/v1/tasks/${encodeURIComponent(params.taskId)}`),
{
cache: "no-store",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"User-Agent": "yedir-ai-visionary/1.0",
},
signal: AbortSignal.timeout(30_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 task = payload?.data || payload;
const status = String(task?.status || "").trim().toLowerCase();
retryAfter = Number(task?.retry_after || retryAfter || 3);
if (status === "completed") {
const images = extractTaskImages(payload);
if (images.length === 0) throw new Error("Visionary 任务完成但未返回图片");
return images;
}
if (["failed", "error", "cancelled", "canceled"].includes(status)) {
throw new Error(extractErrorMessage(payload, "Visionary 图片生成失败"));
}
} 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 >= MAX_TRANSIENT_POLL_FAILURES) throw error;
}
}
throw new Error("Visionary 生成超时,任务可能仍在处理,请稍后重试");
}
function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`)
.join("\n\n");
}
export function createVisionary({ apiKey, baseURL = VISIONARY_BASE_URL }: VisionaryConfig) {
return {
provider: "visionary",
chat: (modelId: string) => ({
provider: "visionary",
doGenerate: async (options: {
messages: Array<{ role: string; content?: any; parts?: any[] }>;
imageParams?: VisionaryImageParams;
imageGenConfig?: VisionaryModelConfig | null;
overrideModelId?: string;
}) => {
const requestedModelId = normalizeModelId(options.overrideModelId || modelId);
const preset = getVisionaryModelPresetById(requestedModelId);
if (!preset) throw new Error(`Visionary 暂不支持模型:${requestedModelId}`);
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const maxReferenceImages = Math.max(
0,
Math.min(9, options.imageGenConfig?.maxReferenceImages || 9)
);
const referenceImages = await Promise.all(
imageUrls.slice(0, maxReferenceImages).map(loadReferenceImage)
);
const aspectRatio = resolveAspectRatio(
options.imageParams?.aspectRatio,
requestedModelId,
options.imageGenConfig
);
const imageSize = normalizeImageSize(
options.imageParams?.imageSize,
requestedModelId,
options.imageGenConfig
);
const submitted = await submitTask({
apiKey,
aspectRatio,
baseURL,
imageSize,
modelId: requestedModelId,
prompt,
promptExtend: options.imageParams?.promptExtend,
quality: options.imageParams?.quality,
referenceImages,
});
const outputUrls = await waitForTask({
apiKey,
baseURL,
initialRetryAfter: submitted.retryAfter,
taskId: submitted.taskId,
});
const uploadedImages = await Promise.all(
outputUrls
.slice(0, Math.max(1, options.imageParams?.imageCount || 1))
.map(archiveOutputImage)
);
const text = formatMarkdown(uploadedImages, 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?: VisionaryImageParams;
imageGenConfig?: VisionaryModelConfig | null;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield { type: "text-delta", textDelta: result.text };
},
}),
};
}

View File

@ -59,6 +59,10 @@ export function parseImageAltMetadata(altText: string, thumbnailUrl: string): {
originalUrl = thumbnailUrl.replace("fal-image-thumb-", "fal-image-orig-");
} else if (thumbnailUrl.includes("snapgen-image-thumb-")) {
originalUrl = thumbnailUrl.replace("snapgen-image-thumb-", "snapgen-image-orig-");
} else if (thumbnailUrl.includes("bananaimage-thumb-")) {
originalUrl = thumbnailUrl.replace("bananaimage-thumb-", "bananaimage-orig-");
} else if (thumbnailUrl.includes("visionary-thumb-")) {
originalUrl = thumbnailUrl.replace("visionary-thumb-", "visionary-orig-");
}
}

161
lib/ai/visionary-models.ts Normal file
View File

@ -0,0 +1,161 @@
export interface VisionaryModelPreset {
modelId: string;
displayName: string;
description: string;
supportsVision: boolean;
supportsImageGen: boolean;
capabilities?: Record<string, any>;
defaultParams?: Record<string, any>;
imageGenConfig?: Record<string, any>;
sortOrder: number;
}
export const VISIONARY_BASE_URL = "https://api.visionary.beer";
const VISIONARY_GPT_IMAGE_RATIOS = [
"auto",
"1:1",
"3:2",
"2:3",
"4:3",
"3:4",
"16:9",
"9:16",
"21:9",
"9:21",
"2:1",
"1:2",
"5:4",
"4:5",
"3:1",
"1:3",
];
const VISIONARY_NANO_BANANA_RATIOS = [
"auto",
"1:1",
"4:3",
"3:4",
"3:2",
"2:3",
"16:9",
"9:16",
"21:9",
"5:4",
"4:5",
"1:4",
"4:1",
"1:8",
"8:1",
];
function buildVisionaryPreset(params: {
modelId: string;
displayName: string;
description: string;
supportedAspectRatios: string[];
supportedImageSizes: string[];
defaultImageSize: string;
supportsQuality?: boolean;
supportsPromptExtend?: boolean;
sortOrder: number;
}) {
const supportedModes = ["text-to-image", "image-to-image", "reference-images"];
return {
modelId: params.modelId,
displayName: params.displayName,
description: params.description,
supportsVision: true,
supportsImageGen: true,
capabilities: {
provider: "visionary",
apiFormat: "visionary-task",
supportedModes,
supportedAspectRatios: params.supportedAspectRatios,
supportedImageSizes: params.supportedImageSizes,
supportsMultiImageReference: true,
maxReferenceImages: 9,
supportsQuality: Boolean(params.supportsQuality),
},
defaultParams: {
provider: "visionary",
apiFormat: "visionary-task",
endpoint: params.modelId,
imageSize: params.defaultImageSize,
aspectRatio: "auto",
quality: "auto",
supportedModes,
},
imageGenConfig: {
apiFormat: "visionary-task",
maxImages: 1,
supportsSize: true,
supports1K: params.supportedImageSizes.includes("1K"),
supports2K: params.supportedImageSizes.includes("2K"),
supports4K: params.supportedImageSizes.includes("4K"),
supportsPromptExtend: Boolean(params.supportsPromptExtend),
supportsNegativePrompt: false,
supportsWatermark: false,
supportsSeed: false,
supportsQuality: Boolean(params.supportsQuality),
maxReferenceImages: 9,
supportedImageSizes: params.supportedImageSizes,
supportedAspectRatios: params.supportedAspectRatios,
enableLoadingPlaceholder: true,
loadingColor: "yellow",
},
sortOrder: params.sortOrder,
} satisfies VisionaryModelPreset;
}
export const VISIONARY_MODEL_PRESETS: VisionaryModelPreset[] = [
buildVisionaryPreset({
modelId: "gpt-image-2",
displayName: "Visionary GPT Image 2",
description:
"Visionary GPT Image 2。支持文生图、最多 9 张参考图、完整比例选择、质量档位和 1K/2K/4K 输出。",
supportedAspectRatios: VISIONARY_GPT_IMAGE_RATIOS,
supportedImageSizes: ["1K", "2K", "4K"],
defaultImageSize: "1K",
supportsQuality: true,
sortOrder: 10,
}),
buildVisionaryPreset({
modelId: "nano-banana-pro",
displayName: "Visionary Nano Banana Pro",
description:
"Visionary Nano Banana Pro。支持文生图、多图编辑、中文文字增强、极端比例和 2K/4K 输出。",
supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS,
supportedImageSizes: ["2K", "4K"],
defaultImageSize: "2K",
supportsPromptExtend: true,
sortOrder: 20,
}),
buildVisionaryPreset({
modelId: "nano-banana-pro-cl",
displayName: "Visionary Nano Banana Pro CL",
description:
"Visionary Nano Banana Pro CL 开放 API 高质量线路。支持文生图、多图编辑、极端比例和 2K/4K 输出。",
supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS,
supportedImageSizes: ["2K", "4K"],
defaultImageSize: "2K",
sortOrder: 30,
}),
buildVisionaryPreset({
modelId: "nano-banana-2-lite",
displayName: "Visionary Nano Banana 2 Lite",
description:
"Visionary Nano Banana 2 Lite 低延迟线路。支持文生图、多图编辑和极端比例,固定输出 1K。",
supportedAspectRatios: VISIONARY_NANO_BANANA_RATIOS,
supportedImageSizes: ["1K"],
defaultImageSize: "1K",
sortOrder: 40,
}),
];
export function getVisionaryModelPresetById(modelId: string) {
return VISIONARY_MODEL_PRESETS.find(
(preset) => preset.modelId === String(modelId || "").trim()
);
}

View File

@ -10,6 +10,9 @@ import { createRuxaImage } from "@/lib/ai/providers/ruxa-image";
import { createFalImage } from "@/lib/ai/providers/fal-image";
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";
@ -231,6 +234,9 @@ const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"fal-image",
"snapgen-image",
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"runway",
@ -3155,6 +3161,9 @@ function getProviderBatchLimit(providerType: string) {
case "ruxa-image":
case "bfl-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "stepfun-image":
case "midjourney":
case "runway":
@ -3187,6 +3196,9 @@ function getCanvasScaleAdjustProviderFailoverPriority(providerType: string) {
return 10;
case "snapgen-image":
return 20;
case "bananaimage":
case "visionary":
return 25;
case "krea":
return 30;
case "gemini":
@ -3765,7 +3777,7 @@ export async function generateCanvasImage(
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(provider.type)) {
if (request.allowFailover === false) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/StepFun/Runway/Krea 图片模型。"
);
}
@ -3798,7 +3810,7 @@ export async function generateCanvasImage(
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/StepFun/Runway/Krea 图片模型。"
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/火山 Seedream/阿里/BFL/RUXA/FAL/SnapGen/OpenAI/BananaImage/Visionary/StepFun/Runway/Krea 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
@ -4040,6 +4052,66 @@ export async function generateCanvasImage(
});
return result.text || "";
};
} else if (provider.type === "bananaimage") {
const bananaImageProvider = createBananaImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = bananaImageProvider.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 === "visionary") {
const visionaryProvider = createVisionary({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
});
const runtimeModelId = resolveModelRuntimeId(selectedModel.model, {
providerType: provider.type,
});
const modelInstance = visionaryProvider.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 === "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,
@ -4116,7 +4188,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 图片模型`
);
}

View File

@ -33,6 +33,9 @@ const providerBaseUrlDefaults = [
{ type: "fal-image", baseUrl: "https://fal.run" },
{ type: "snapgen-image", baseUrl: "https://api.snapgen.ai" },
{ 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" },
@ -150,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', '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`

View File

@ -0,0 +1,38 @@
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',
'stepfun-image',
'midjourney',
'fal-video',
'zhipu',
'tripo-3d',
'worldlabs-3d',
'kittycad-cad',
'runway',
'krea'
)
);

View File

@ -0,0 +1,39 @@
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',
'stepfun-image',
'midjourney',
'fal-video',
'zhipu',
'tripo-3d',
'worldlabs-3d',
'kittycad-cad',
'runway',
'krea'
)
);

View 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'
)
);

View File

@ -519,6 +519,27 @@
"when": 1783680000000,
"tag": "0087_add_image_archive_tasks",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1783946203000,
"tag": "0088_add_bananaimage_provider_type",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1784025600000,
"tag": "0091_add_visionary_provider_type",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1784592000000,
"tag": "0092_add_atlascloud_provider_type",
"breakpoints": true
}
]
}

View File

@ -3914,7 +3914,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" | "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;
@ -3952,7 +3952,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" | "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;

View File

@ -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", "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

View File

@ -345,6 +345,9 @@ function supportsImageEditForReferenceCount(
const runtimeModelId = normalizeText(entry.model.modelId).toLowerCase();
const providerImpliesReferenceEdit =
providerType === "openai-image" ||
providerType === "bananaimage" ||
providerType === "visionary" ||
providerType === "atlascloud" ||
providerType === "grsai-gpt-image" ||
providerType === "ruxa-image" ||
providerType === "snapgen-image";
@ -417,6 +420,14 @@ function getExternalImageApiPriority(entry: ImageModelEntry) {
return 400;
}
if (isGptImage2 && providerType === "bananaimage") {
return 380;
}
if (isGptImage2 && providerType === "visionary") {
return 370;
}
if (isGptImage2 && providerType === "grsai-gpt-image") {
return isVip ? 350 : 340;
}

View File

@ -53,7 +53,9 @@ export function isLikelyThumbnailUrl(url: string): boolean {
lowerUrl.includes("ruxa-image-thumb-") ||
lowerUrl.includes("stepfun-image-thumb-") ||
lowerUrl.includes("fal-image-thumb-") ||
lowerUrl.includes("snapgen-image-thumb-")
lowerUrl.includes("snapgen-image-thumb-") ||
lowerUrl.includes("bananaimage-thumb-") ||
lowerUrl.includes("visionary-thumb-")
) {
return true;
}
@ -96,6 +98,12 @@ export function inferOriginalImageUrl(url: string): string {
if (url.includes("snapgen-image-thumb-")) {
return url.replace("snapgen-image-thumb-", "snapgen-image-orig-");
}
if (url.includes("bananaimage-thumb-")) {
return url.replace("bananaimage-thumb-", "bananaimage-orig-");
}
if (url.includes("visionary-thumb-")) {
return url.replace("visionary-thumb-", "visionary-orig-");
}
if (url.includes("/thumb/")) {
return url.replace("/thumb/", "/");

View File

@ -77,6 +77,9 @@ export function canProcessSceneFusionProviderType(providerType: string): boolean
case "fal-image":
case "snapgen-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "gemini":
case "volcano-vision":
return true;
@ -131,6 +134,9 @@ function getSupportedAspectRatiosByProvider(
case "alibaba-image":
case "fal-image":
case "openai-image":
case "bananaimage":
case "visionary":
case "atlascloud":
case "gemini":
default:
return [...ALL_ASPECT_RATIO_VALUES];

View File

@ -11,6 +11,8 @@
"scripts": {
"dev": "next dev --turbo",
"pascal:dev": "node scripts/pascal-editor-dev.mjs",
"zoo-modeling-app:vendor": "node scripts/vendor-zoo-modeling-app.mjs",
"zoo-modeling-app:ws-proxy": "node scripts/ai-cad-zoo-ws-dev-proxy.mjs",
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "next build",

View File

@ -0,0 +1,121 @@
#!/usr/bin/env node
import { createServer, request as httpRequest } from "node:http";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import WebSocket, { WebSocketServer } from "ws";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
dotenv.config({ path: path.join(root, ".env.local") });
const host = "127.0.0.1";
const port = Number(process.env.HICAD_ZOO_WS_DEV_PROXY_PORT || 3010);
const appPort = Number(process.env.HICAD_APP_DEV_PORT || 3003);
const apiBaseUrl = String(
process.env.KITTYCAD_BASE_URL ||
process.env.ZOO_BASE_URL ||
"https://api.zoo.dev"
).replace(/\/+$/, "");
const apiKey = String(
process.env.KITTYCAD_API_TOKEN ||
process.env.KITTYCAD_API_KEY ||
process.env.ZOO_API_TOKEN ||
""
)
.replace(/\s+/g, "")
.trim();
if (!apiKey) {
throw new Error("Missing KITTYCAD_API_TOKEN in .env.local");
}
const allowedPaths = new Map([
["/api/ai-cad/zoo-ws/ml/copilot", "/ws/ml/copilot"],
["/api/ai-cad/zoo-ws/modeling/commands", "/ws/modeling/commands"],
]);
const browserSockets = new WebSocketServer({ noServer: true });
const server = createServer((request, response) => {
const upstreamRequest = httpRequest(
{
headers: request.headers,
host,
method: request.method,
path: request.url,
port: appPort,
},
(upstreamResponse) => {
response.writeHead(
upstreamResponse.statusCode || 502,
upstreamResponse.headers
);
upstreamResponse.pipe(response);
}
);
upstreamRequest.on("error", (error) => {
if (!response.headersSent) {
response.writeHead(502, {
"Content-Type": "text/plain; charset=utf-8",
});
}
response.end(`HiCAD development proxy failed: ${error.message}`);
});
request.pipe(upstreamRequest);
});
server.on("upgrade", (request, socket, head) => {
const incomingUrl = new URL(request.url || "/", `http://${host}:${port}`);
const upstreamPath = allowedPaths.get(incomingUrl.pathname);
if (!upstreamPath) {
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
browserSockets.handleUpgrade(request, socket, head, (browserSocket) => {
const base = new URL(apiBaseUrl);
base.protocol = base.protocol === "http:" ? "ws:" : "wss:";
base.pathname = upstreamPath;
base.search = incomingUrl.search;
const upstreamSocket = new WebSocket(base, {
headers: {
Authorization: `Bearer ${apiKey}`,
Origin: "https://app.zoo.dev",
},
});
const closeBoth = (code = 1011, reason = "Zoo connection closed") => {
if (browserSocket.readyState < WebSocket.CLOSING) {
browserSocket.close(code, reason);
}
if (upstreamSocket.readyState < WebSocket.CLOSING) {
upstreamSocket.close(code, reason);
}
};
upstreamSocket.on("open", () => {
browserSocket.on("message", (data, isBinary) => {
if (upstreamSocket.readyState === WebSocket.OPEN) {
upstreamSocket.send(data, { binary: isBinary });
}
});
upstreamSocket.on("message", (data, isBinary) => {
if (browserSocket.readyState === WebSocket.OPEN) {
browserSocket.send(data, { binary: isBinary });
}
});
});
browserSocket.on("close", () => closeBoth(1000, "Browser disconnected"));
browserSocket.on("error", () => closeBoth());
upstreamSocket.on("close", () => closeBoth(1000, "Zoo disconnected"));
upstreamSocket.on("error", (error) => {
console.error("[HiCAD Zoo WS proxy]", error.message);
closeBoth();
});
});
});
server.listen(port, host, () => {
console.log(`HiCAD development proxy: http://${host}:${port}`);
});

View File

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

View File

@ -0,0 +1,369 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { cp } from "node:fs/promises";
import path from "node:path";
import { gzipSync } from "node:zlib";
const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
const repoRef =
process.env.ZOO_MODELING_APP_REF ||
"6f87f4e66c07c67f09d8f7db284496a3ae3332e7";
const repoTarballUrls = [
`https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
`https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
];
const root = process.cwd();
const workspaceDir = path.join(root, ".zoo-modeling-app");
const sourceDir = path.join(workspaceDir, "modeling-app");
const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
const customizationDir = path.join(root, "vendor/zoo-modeling-app");
const proxyToken = "hicad-session";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || root,
env: { ...process.env, ...(options.env || {}) },
shell: false,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} failed`);
}
}
function tryRun(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || root,
env: { ...process.env, ...(options.env || {}) },
shell: false,
stdio: "inherit",
});
return result.status === 0;
}
function downloadOfficialSource() {
mkdirSync(workspaceDir, { recursive: true });
const archivePath = path.join(workspaceDir, "modeling-app-main.tar.gz");
const extractedDir = path.join(workspaceDir, `modeling-app-${repoRef}`);
rmSync(archivePath, { force: true });
rmSync(extractedDir, { force: true, recursive: true });
const downloaded = repoTarballUrls.some((url) =>
tryRun("curl", [
"--http1.1",
"--location",
"--fail",
"--retry",
"3",
url,
"--output",
archivePath,
])
);
if (!downloaded) {
throw new Error("Unable to download KittyCAD/modeling-app");
}
run("tar", ["-xzf", archivePath, "-C", workspaceDir]);
rmSync(sourceDir, { force: true, recursive: true });
run("mv", [extractedDir, sourceDir]);
writeFileSync(sourceRefFile, `${repoRef}\n`);
}
function ensureOfficialSource() {
if (existsSync(path.join(sourceDir, "package.json"))) {
if (!existsSync(sourceRefFile)) {
writeFileSync(sourceRefFile, `${repoRef}\n`);
}
const currentRef = readFileSync(sourceRefFile, "utf8").trim();
if (currentRef !== repoRef) {
downloadOfficialSource();
}
return;
}
downloadOfficialSource();
}
function replaceSource(relativePath, before, after) {
const filePath = path.join(sourceDir, relativePath);
const source = readFileSync(filePath, "utf8");
if (after && source.includes(after)) {
return;
}
if (!source.includes(before)) {
if (!after) {
return;
}
throw new Error(`Official source changed; patch failed for ${relativePath}`);
}
writeFileSync(filePath, source.replace(before, after));
}
function patchOfficialSource() {
copyFileSync(
path.join(customizationDir, "Logo.tsx"),
path.join(sourceDir, "src/components/Logo.tsx")
);
copyFileSync(
path.join(customizationDir, "localization.ts"),
path.join(sourceDir, "src/lib/hicadLocalization.ts")
);
replaceSource(
"index.html",
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
' <meta name="description" content="HiCAD browser-based CAD workspace." />'
);
replaceSource(
"index.html",
' <script defer data-domain="app.zoo.dev" src="https://plausible.corp.zoo.dev/js/script.tagged-events.js"></script>\n',
""
);
replaceSource(
"index.html",
" <title>Zoo Design Studio</title>",
" <title>HiCAD</title>"
);
replaceSource(
"src/Router.tsx",
" createBrowserRouter,\n createHashRouter,",
" createHashRouter,"
);
replaceSource(
"src/Router.tsx",
"const createRouter = isDesktop() ? createHashRouter : createBrowserRouter",
"const createRouter = createHashRouter"
);
replaceSource(
"src/Router.tsx",
"import { isDesktop } from '@src/lib/isDesktop'\n",
""
);
replaceSource(
"src/lib/socket.ts",
` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`,
` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })`
);
replaceSource(
"src/network/websocketConnection.ts",
" if (token) {",
` if (token && token !== '${proxyToken}') {`
);
replaceSource(
"src/lib/withBaseURL.ts",
`export function withKittycadWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}`,
`export function withKittycadWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_KITTYCAD_WEBSOCKET_URL, qs)\n}\n\nexport function withMlephantWebSocketURL(qs: string): string {\n return mergeWebSocketQueryString(env().VITE_MLEPHANT_WEBSOCKET_URL, qs)\n}\n\nfunction mergeWebSocketQueryString(\n url: string | undefined,\n qs: string\n): string {\n if (!url) return ''\n const absoluteUrl =\n typeof window !== 'undefined' && url.startsWith('/')\n ? \`\${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//\${window.location.host}\${url}\`\n : url\n return mergeQueryString(absoluteUrl, qs)\n}`
);
replaceSource(
"src/lang/wasmUtils.ts",
`export const wasmUrl = () => {\n const wasmFile = '/kcl_wasm_lib_bg.wasm'\n // Check for test environment override\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + wasmFile\n }\n\n // For when we're in electron (file based) or web server (network based)\n // For some reason relative paths don't work as expected. Otherwise we would\n // just do /wasm_lib_bg.wasm. In particular, the issue arises when the path\n // is used from within worker.ts.\n const fullUrl = document.location.protocol.includes('http')\n ? document.location.origin + '/kcl_wasm_lib_bg.wasm'\n : document.location.protocol +\n webSafeJoin(webSafePathSplit(document.location.pathname).slice(0, -1)) +\n wasmFile\n\n return fullUrl\n}`,
`export const wasmUrl = () => {\n const wasmFile = 'kcl_wasm_lib_bg.wasm'\n if (typeof process !== 'undefined' && process.env?.VITE_WASM_BASE_URL) {\n return process.env.VITE_WASM_BASE_URL + '/' + wasmFile\n }\n\n return new URL(wasmFile, document.baseURI).toString()\n}`
);
replaceSource(
"src/lang/wasmUtils.ts",
"import { webSafeJoin, webSafePathSplit } from '@src/lib/paths'\n",
""
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
" const displayedName = getDisplayName(user)\n",
` const isHiCadEmbedded = env().VITE_ZOO_API_TOKEN === '${proxyToken}'\n const displayedName = isHiCadEmbedded ? 'HiCAD' : getDisplayName(user)\n`
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
` [\n {\n id: 'account',`,
` [\n {\n id: 'hicad-home',\n Element: 'button',\n children: 'Back to HiCAD',\n onClick: () => window.top?.location.assign('/'),\n className: isHiCadEmbedded ? '' : 'hidden',\n },\n {\n id: 'account',`
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
` ),\n },\n {\n id: 'settings',`,
` ),\n className: isHiCadEmbedded ? 'hidden' : '',\n },\n {\n id: 'settings',`
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
" className: '',",
" className: isHiCadEmbedded ? 'hidden' : '',"
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
" {user?.image && !imageLoadFailed ? (",
" {!isHiCadEmbedded && user?.image && !imageLoadFailed ? ("
);
replaceSource(
"src/components/UserSidebarMenu.tsx",
" {displayedName !== user.email && (",
" {!isHiCadEmbedded && displayedName !== user.email && ("
);
replaceSource(
"src/components/environment/Environment.tsx",
" new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),",
" new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? '', window.location.href),"
);
replaceSource(
"src/components/environment/Environment.tsx",
" new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '')",
" new URL(env().VITE_MLEPHANT_WEBSOCKET_URL ?? '', window.location.href)"
);
replaceSource(
"src/index.tsx",
"import { markOnce } from '@src/lib/performance'\n",
"import { installHiCadLocalization } from '@src/lib/hicadLocalization'\nimport { markOnce } from '@src/lib/performance'\n"
);
replaceSource(
"src/index.tsx",
"function launchApp(app: App) {\n initSingletonBehavior(app)",
"function launchApp(app: App) {\n installHiCadLocalization()\n initSingletonBehavior(app)"
);
replaceSource(
"src/routes/Onboarding/utils.tsx",
` <p className="m-0 text-center text-xs italic text-chalkboard-70 dark:text-chalkboard-30">
To view your account, manage payment methods, or change tiers, simply{' '}`,
` <p
data-hicad-official-account-link
className="m-0 text-center text-xs italic text-chalkboard-70 dark:text-chalkboard-30"
>
To view your account, manage payment methods, or change tiers, simply{' '}`
);
}
function ensureDependencies() {
if (existsSync(path.join(sourceDir, "node_modules/.package-lock.json"))) {
return;
}
run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], {
cwd: sourceDir,
});
}
function ensureWasm() {
const wasmJs = path.join(
sourceDir,
"rust/kcl-wasm-lib/pkg/kcl_wasm_lib.js"
);
const wasmBinary = path.join(
sourceDir,
"rust/kcl-wasm-lib/pkg/kcl_wasm_lib_bg.wasm"
);
if (!(existsSync(wasmJs) && existsSync(wasmBinary))) {
const cargoEnv = {
CARGO_HTTP_MULTIPLEXING: "false",
CARGO_REGISTRIES_CRATES_IO_INDEX:
process.env.CARGO_REGISTRIES_CRATES_IO_INDEX ||
"sparse+https://rsproxy.cn/index/",
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: "sparse",
};
run(
"wasm-pack",
[
"build",
"kcl-wasm-lib",
"--release",
"--target",
"web",
"--out-dir",
"pkg",
"--scope",
"kittycad",
"--no-opt",
],
{ cwd: path.join(sourceDir, "rust"), env: cargoEnv }
);
run(
"cargo",
[
"test",
"-p",
"kcl-lib",
"--features",
"artifact-graph",
"export_bindings",
],
{ cwd: path.join(sourceDir, "rust"), env: cargoEnv }
);
}
copyFileSync(
path.join(sourceDir, "rust/kcl-wasm-lib/README.md"),
path.join(sourceDir, "rust/kcl-wasm-lib/pkg/README.md")
);
copyFileSync(wasmBinary, path.join(sourceDir, "public/kcl_wasm_lib_bg.wasm"));
}
function precompressVersionedAssets() {
const assetsDir = path.join(outputDir, "assets");
for (const entry of readdirSync(assetsDir, { withFileTypes: true })) {
if (!entry.isFile() || !/\.(?:css|js)$/.test(entry.name)) {
continue;
}
const assetPath = path.join(assetsDir, entry.name);
writeFileSync(`${assetPath}.gz`, gzipSync(readFileSync(assetPath), { level: 9 }));
}
}
async function main() {
ensureOfficialSource();
ensureDependencies();
ensureWasm();
patchOfficialSource();
const buildEnv = {
VITE_KITTYCAD_WEBSOCKET_URL:
process.env.HICAD_ZOO_MODELING_WEBSOCKET_URL ||
"/api/ai-cad/zoo-ws/modeling/commands",
VITE_MLEPHANT_WEBSOCKET_URL:
process.env.HICAD_ZOO_AGENT_WEBSOCKET_URL ||
"/api/ai-cad/zoo-ws/ml/copilot",
VITE_ZOO_API_BASE_URL: "/api/ai-cad/zoo",
VITE_ZOO_API_TOKEN: proxyToken,
VITE_ZOO_BASE_DOMAIN: "zoo.dev",
};
rmSync(path.join(sourceDir, "build"), { force: true, recursive: true });
run("npx", ["vite", "build", "--base=./", "--mode=production"], {
cwd: sourceDir,
env: buildEnv,
});
const buildDir = path.join(sourceDir, "build");
if (!existsSync(path.join(buildDir, "index.html"))) {
throw new Error("Zoo Design Studio build did not produce index.html");
}
rmSync(outputDir, { force: true, recursive: true });
mkdirSync(path.dirname(outputDir), { recursive: true });
await cp(buildDir, outputDir, { recursive: true });
precompressVersionedAssets();
const license = path.join(sourceDir, "LICENSE");
if (existsSync(license)) {
await cp(license, path.join(outputDir, "LICENSE"));
}
writeFileSync(
path.join(outputDir, "SOURCE.txt"),
[
"HiCAD web frontend based on Zoo Design Studio",
`Source: ${repoUrl}`,
`Source commit: ${repoRef}`,
"License: MIT",
"Authentication: HiCAD session with server-side Zoo proxy",
"",
].join("\n")
);
console.log(`HiCAD official frontend copied to ${outputDir}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

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

View File

@ -0,0 +1,191 @@
import { expect, test } from "@playwright/test";
import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
} from "@/lib/ai/atlascloud-models";
import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
const atlasHunyuan3D = {
model: "tencent/hunyuan3d-pro/text-to-3d",
type: "Image",
displayName: "Hunyuan 3D Pro Text-to-3D",
categories: ["TEXT-TO-3D"],
tags: ["TEXT-TO-3D", "NEW"],
};
test.describe("AI model media classification", () => {
test("keeps multi-reference BananaImage models classified as image models", () => {
const descriptor = {
providerType: "bananaimage",
modelId: "gemini-3-pro-image-preview",
capabilities: {
supportedModes: [
"text-to-image",
"image-to-image",
"reference-images",
],
},
supportsImageGen: true,
};
expect(isVideoModelDescriptor(descriptor)).toBe(false);
expect(isImageGenerationModelDescriptor(descriptor)).toBe(true);
});
test("still recognizes explicit video generation modes", () => {
expect(
isVideoModelDescriptor({
providerType: "custom",
modelId: "custom-video-model",
capabilities: { supportedModes: ["text-to-video"] },
})
).toBe(true);
});
test("keeps Atlas 3D entries out of image and video categories", () => {
expect(inferAtlasCloudMediaKind(atlasHunyuan3D)).toBe("3d");
const descriptor = {
providerType: "atlascloud",
modelId: atlasHunyuan3D.model,
capabilities: {
kind: "3d",
mediaType: "3d",
supportedModes: ["text-to-3d"],
},
supportsImageGen: false,
};
expect(is3DModelDescriptor(descriptor)).toBe(true);
expect(isImageGenerationModelDescriptor(descriptor)).toBe(false);
expect(isVideoModelDescriptor(descriptor)).toBe(false);
});
test("writes 3D Atlas presets with model-level capabilities", () => {
const preset = createAtlasCloudImagePreset(
atlasHunyuan3D,
{
components: {
schemas: {
Input: {
type: "object",
properties: {
model: { type: "string" },
prompt: { type: "string" },
},
required: ["model", "prompt"],
},
},
},
},
0
);
expect(preset.supportsImageGen).toBe(false);
expect(preset.capabilities).toMatchObject({
kind: "3d",
mediaType: "3d",
supportedModes: ["text-to-3d"],
});
});
test("still distinguishes Atlas image and video descriptors", () => {
const imageDescriptor = {
providerType: "atlascloud",
modelId: "black-forest-labs/flux-2-pro/text-to-image",
capabilities: {
kind: "text-to-image",
mediaType: "image",
supportedModes: ["text-to-image"],
},
supportsImageGen: true,
};
const videoDescriptor = {
providerType: "atlascloud",
modelId: "veed/fabric-1.0/image-to-video",
capabilities: {
kind: "video",
mediaType: "video",
supportedModes: ["image-to-video"],
},
supportsImageGen: false,
};
expect(isImageGenerationModelDescriptor(imageDescriptor)).toBe(true);
expect(isVideoModelDescriptor(imageDescriptor)).toBe(false);
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
});
test("maps Atlas reference-video image models to a valid frontend request", () => {
const preset = createAtlasCloudImagePreset(
{
model: "google/nano-banana-2/reference-to-image",
type: "Image",
displayName: "Nano Banana 2 Reference to Image",
categories: ["IMAGE-TO-IMAGE"],
},
{
paths: {
"/api/v1/model/generateImage": { post: {} },
"/api/v1/model/prediction/{request_id}": { get: {} },
},
components: {
schemas: {
Input: {
type: "object",
properties: {
model: { type: "string" },
prompt: { type: "string" },
resolution: { type: "string", enum: ["1k", "2k", "4k"] },
video_clips: {
type: "array",
minItems: 1,
maxItems: 1,
items: {
type: "object",
properties: {
url: { type: "string" },
start: { type: "number", default: 0 },
ends: { type: "number", default: 0 },
fps: { type: "number", default: 1 },
},
},
},
},
required: ["model", "prompt", "video_clips"],
},
},
},
}
);
expect(preset.capabilities).toMatchObject({
mediaType: "image",
supportsVideoReference: true,
requiresVideoReference: true,
maxVideoReferences: 1,
});
const { body } = createAtlasCloudRequestBody({
modelId: preset.modelId,
prompt: "参考视频生成商品图",
referenceImages: [],
referenceVideos: ["https://example.com/reference.mp4"],
imageParams: { imageSize: "2K", aspectRatio: "1:1" },
modelConfig: preset.imageGenConfig,
});
expect(body.video_clips).toEqual([
{
url: "https://example.com/reference.mp4",
start: 0,
ends: 0,
fps: 1,
},
]);
});
});

49
vendor/zoo-modeling-app/Logo.tsx vendored Normal file
View File

@ -0,0 +1,49 @@
import { useEffect, useState, type ImgHTMLAttributes } from 'react'
type BrandingSettings = {
siteLogo?: string | null
siteLogoDark?: string | null
siteName?: string | null
}
const fallbackLogo = '/default-favicon.ico'
export const Logo = ({
className = 'w-auto h-5',
...props
}: Omit<ImgHTMLAttributes<HTMLImageElement>, 'alt' | 'src'>) => {
const [branding, setBranding] = useState<BrandingSettings>({})
useEffect(() => {
const controller = new AbortController()
fetch('/api/site-settings?scope=branding', {
credentials: 'same-origin',
signal: controller.signal,
})
.then((response) => (response.ok ? response.json() : null))
.then((settings: BrandingSettings | null) => {
if (settings) setBranding(settings)
})
.catch(() => undefined)
return () => controller.abort()
}, [])
const logo = branding.siteLogoDark || branding.siteLogo || fallbackLogo
const siteName = branding.siteName?.trim() || 'HiCAD'
return (
<img
{...props}
alt={siteName}
className={`${className} object-contain`}
onError={(event) => {
if (!event.currentTarget.src.endsWith(fallbackLogo)) {
event.currentTarget.src = fallbackLogo
}
}}
src={logo}
/>
)
}

236
vendor/zoo-modeling-app/localization.ts vendored Normal file
View File

@ -0,0 +1,236 @@
const translations = new Map<string, string>([
['3D View', '3D 视图'],
['Add file to project', '添加项目文件'],
['All', '全部'],
['Angularity', '倾斜度'],
['Ask me anything', '有问题尽管问我'],
['Attach files', '添加附件'],
['Auto', '自动'],
['Back to HiCAD', '返回 HiCAD'],
[
'Beyond geometry creation, I can help you with model-derived properties like center of mass, volume, and surface area.',
'除了创建几何体,我还可以分析质心、体积和表面积等模型属性。',
],
['Bodies', '实体'],
['Chamfer', '倒角'],
['Choose your adventure', '选择一种建模方式'],
['Circular Pattern', '圆周阵列'],
['Capture viewport screenshot', '截取视口'],
['Code', '代码'],
['Code Editor', '代码编辑器'],
['Code-driven CAD', '代码驱动 CAD'],
['Commands', '命令'],
['Connected', '已连接'],
['Connecting and setting up scene...', '正在连接并初始化场景...'],
['Create a new project', '新建项目'],
[
'Create a gear with 10 teeth and use sensible defaults for everything else...',
'创建一个 10 齿齿轮,其余参数使用合理默认值...',
],
['Design Studio', 'HiCAD'],
['Design using traditional point-and-click', '使用传统点击方式设计'],
['Dismiss', '关闭'],
['Edit', '编辑'],
['Export', '导出'],
['Export part', '导出零件'],
['Extrude', '拉伸'],
['Feature Tree', '特征树'],
['Fillet', '圆角'],
['Flip Surface', '翻转曲面'],
['Front plane', '前视基准面'],
[
'From designing parts to giving manufacturing feedback, I’m ready to help.',
'从零件设计到制造反馈,我都可以协助你。',
],
['Helix', '螺旋线'],
['Hello there, I\u2019m Zookeeper.', '你好,我是 HiCAD AI 助手。'],
["Hello there, I'm Zookeeper.", '你好,我是 HiCAD AI 助手。'],
['Help', '帮助'],
['Help and resources', '帮助与资源'],
['Hole', '孔'],
['How would you like to start?', '想从哪里开始?'],
[
"I'm here to help you create and edit real, parametric CAD geometry through incremental, structured commands.",
'我可以通过渐进、结构化的命令,帮助你创建和编辑参数化 CAD 几何体。',
],
[
'I create structured, fully editable geometry by building features step by step.',
'我会逐步构建特征,生成结构化且可完整编辑的几何模型。',
],
['Insert', '插入'],
['Logs', '日志'],
['Loft', '放样'],
['Measure', '测量'],
['Mirror', '镜像'],
['No selection', '未选择'],
['No selection.', '未选择。'],
['Offset Plane', '偏移基准面'],
['Open project', '打开项目'],
['Project Files', '项目文件'],
['Publish', '发布'],
['Redo', '重做'],
['Revolve', '旋转'],
['Settings', '设置'],
['Shell', '抽壳'],
['Side plane', '侧视基准面'],
['Sketch', '草图'],
['Split', '分割'],
['Start Sketch', '新建草图'],
['Supplemental analysis', '补充分析'],
['Sweep', '扫掠'],
['Top plane', '顶视基准面'],
['Translate', '平移'],
['Turn thoughts into geometry', '将想法变成几何模型'],
['Undo', '撤销'],
['Union', '并集'],
[
'Upload an image and I’ll interpret it, extract intent, and help you design or improve the geometry step by step.',
'上传图片后,我会理解参考内容、提取设计意图,并逐步帮助你设计或改进几何模型。',
],
[
'Upload your image to work from real references',
'上传图片并基于真实参考进行设计',
],
['Updating feature tree...', '正在更新特征树...'],
['Variables', '变量'],
['View', '视图'],
['View settings', '视图设置'],
['Zookeeper', 'HiCAD AI 助手'],
['Zookeeper, our conversational CAD agent', 'HiCAD 对话式 CAD 助手'],
[
'Zookeeper can make mistakes. We send selection context to help. Always verify information.',
'HiCAD AI 可能出错。我们会发送选区上下文以提高准确性,请始终核对结果。',
],
['Zoom to fit', '缩放至合适大小'],
['loading', '加载中'],
['zoo.dev', 'HiCAD 云端'],
])
const translatableAttributes = ['aria-label', 'placeholder', 'title'] as const
const excludedSelector = [
'code',
'pre',
'script',
'style',
'textarea',
'input',
'[contenteditable="true"]',
'.cm-editor',
'.cm-content',
'.cm-line',
].join(',')
const excludedAttributeSelector = [
'script',
'style',
'.cm-editor',
'.cm-content',
'.cm-line',
].join(',')
function normalizeLookupKey(value: string) {
return value.replace(/\s+/g, ' ').trim()
}
function isExcluded(node: Node) {
const element =
node.nodeType === Node.ELEMENT_NODE
? (node as Element)
: node.parentElement
return Boolean(element?.closest(excludedSelector))
}
function translateTextNode(node: Text) {
if (isExcluded(node)) return
const value = node.nodeValue || ''
const normalized = normalizeLookupKey(value)
const translated = translations.get(normalized)
if (!translated || translated === normalized) return
const leadingWhitespace = value.match(/^\s*/)?.[0] || ''
const trailingWhitespace = value.match(/\s*$/)?.[0] || ''
node.nodeValue = `${leadingWhitespace}${translated}${trailingWhitespace}`
}
function translateElement(element: Element) {
if (element.closest(excludedAttributeSelector)) return
for (const attribute of translatableAttributes) {
const value = normalizeLookupKey(element.getAttribute(attribute) || '')
if (!value) continue
const translated = translations.get(value)
if (translated) element.setAttribute(attribute, translated)
}
}
function translateTree(root: Node) {
if (root.nodeType === Node.TEXT_NODE) {
translateTextNode(root as Text)
return
}
if (root.nodeType === Node.ELEMENT_NODE) {
translateElement(root as Element)
}
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
)
let current = walker.nextNode()
while (current) {
if (current.nodeType === Node.TEXT_NODE) {
translateTextNode(current as Text)
} else {
translateElement(current as Element)
}
current = walker.nextNode()
}
}
export function installHiCadLocalization() {
document.documentElement.lang = 'zh-CN'
document.documentElement.dataset.hicadEmbedded = 'true'
const style = document.createElement('style')
style.textContent = `
html[data-hicad-embedded='true'] body {
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
}
html[data-hicad-embedded='true'] [data-testid='download-desktop-app'],
html[data-hicad-embedded='true'] [data-testid='billing-remaining-bar'],
html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
display: none !important;
}
`
document.head.appendChild(style)
translateTree(document.documentElement)
let frame = 0
const pendingRoots = new Set<Node>()
const flush = () => {
frame = 0
for (const root of pendingRoots) translateTree(root)
pendingRoots.clear()
}
const schedule = (root: Node) => {
pendingRoots.add(root)
if (!frame) frame = window.requestAnimationFrame(flush)
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) schedule(node)
} else {
schedule(mutation.target)
}
}
})
observer.observe(document.documentElement, {
attributeFilter: [...translatableAttributes],
attributes: true,
characterData: true,
childList: true,
subtree: true,
})
}