Compare commits
15 Commits
main
...
codex/atla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0accff6de8 | ||
|
|
ebc0680903 | ||
|
|
42a42e496d | ||
|
|
5e46ea698c | ||
|
|
d0a3708e6d | ||
|
|
e1044261d3 | ||
|
|
c8c6d9132a | ||
|
|
e4d527f0f2 | ||
|
|
bb62fa680f | ||
|
|
b3abaeef8d | ||
|
|
1ab1495e62 | ||
|
|
da5c0699dd | ||
|
|
5f15b99e7c | ||
|
|
56658a4d16 | ||
|
|
bc7b6f983f |
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||
|
||||
@ -88,6 +88,9 @@ export type AdminAiProvider = {
|
||||
| "fal-image"
|
||||
| "snapgen-image"
|
||||
| "openai-image"
|
||||
| "bananaimage"
|
||||
| "visionary"
|
||||
| "atlascloud"
|
||||
| "stepfun-image"
|
||||
| "midjourney"
|
||||
| "fal-video"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(), // 反向提示词
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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"
|
||||
|
||||
155
app/api/admin/atlascloud-models/route.ts
Normal file
155
app/api/admin/atlascloud-models/route.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
|
||||
import { db } from "@/lib/db";
|
||||
import { getAiProviderById } from "@/lib/db/queries";
|
||||
import { type AiModel, aiModel } from "@/lib/db/schema";
|
||||
import { requireAdmin } from "@/lib/middleware/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const result = await fetchAtlasCloudImagePresets();
|
||||
return NextResponse.json({
|
||||
presets: result.presets,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
counts: {
|
||||
total: result.catalogCount,
|
||||
available: result.presets.length,
|
||||
failed: result.errors.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Atlas Cloud Models API] GET error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch Atlas Cloud image models",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const body = await request.json();
|
||||
const providerId = String(body?.providerId || "").trim();
|
||||
const requestedModelIds = Array.isArray(body?.modelIds)
|
||||
? new Set(
|
||||
body.modelIds.map((item: unknown) => String(item || "").trim()).filter(Boolean)
|
||||
)
|
||||
: null;
|
||||
if (!providerId) {
|
||||
return NextResponse.json({ error: "Provider ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = await getAiProviderById({ id: providerId });
|
||||
if (!provider || provider.type !== "atlascloud") {
|
||||
return NextResponse.json(
|
||||
{ error: "Provider must be an Atlas Cloud provider" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const catalog = await fetchAtlasCloudImagePresets();
|
||||
const presets = requestedModelIds
|
||||
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
|
||||
: catalog.presets;
|
||||
const addedModels: AiModel[] = [];
|
||||
const updatedModels: AiModel[] = [];
|
||||
const errors = [...catalog.errors];
|
||||
|
||||
for (const preset of presets) {
|
||||
try {
|
||||
const existing = await db.query.aiModel.findFirst({
|
||||
where: and(
|
||||
eq(aiModel.providerId, providerId),
|
||||
eq(aiModel.modelId, preset.modelId)
|
||||
),
|
||||
});
|
||||
const values = {
|
||||
displayName: preset.displayName,
|
||||
avatarUrl: preset.avatarUrl || null,
|
||||
description: preset.description,
|
||||
supportsVision: preset.supportsVision,
|
||||
supportsImageGen: 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
128
app/api/admin/bananaimage-models/route.ts
Normal file
128
app/api/admin/bananaimage-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
139
app/api/admin/visionary-models/route.ts
Normal file
139
app/api/admin/visionary-models/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal file
66
app/api/ai-cad/zoo-ws-authorize/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal file
140
app/api/ai-cad/zoo/[...path]/route.ts
Normal 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;
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal file
47
components/ai-cad/zoo-design-studio-frame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, "/");
|
||||
|
||||
54
deploy/nginx-hicad-zoo-ws.conf.template
Normal file
54
deploy/nginx-hicad-zoo-ws.conf.template
Normal 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
650
lib/ai/atlascloud-models.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
209
lib/ai/atlascloud-request.ts
Normal file
209
lib/ai/atlascloud-request.ts
Normal 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 };
|
||||
}
|
||||
184
lib/ai/bananaimage-models.ts
Normal file
184
lib/ai/bananaimage-models.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
454
lib/ai/providers/atlascloud.ts
Normal file
454
lib/ai/providers/atlascloud.ts
Normal 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) => ``)
|
||||
.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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
664
lib/ai/providers/bananaimage.ts
Normal file
664
lib/ai/providers/bananaimage.ts
Normal 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) => ``)
|
||||
.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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
532
lib/ai/providers/visionary.ts
Normal file
532
lib/ai/providers/visionary.ts
Normal 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) => ``)
|
||||
.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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@ -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
161
lib/ai/visionary-models.ts
Normal 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()
|
||||
);
|
||||
}
|
||||
@ -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 图片模型`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
38
lib/db/migrations/0088_add_bananaimage_provider_type.sql
Normal file
38
lib/db/migrations/0088_add_bananaimage_provider_type.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
39
lib/db/migrations/0091_add_visionary_provider_type.sql
Normal file
39
lib/db/migrations/0091_add_visionary_provider_type.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
40
lib/db/migrations/0092_add_atlascloud_provider_type.sql
Normal file
40
lib/db/migrations/0092_add_atlascloud_provider_type.sql
Normal file
@ -0,0 +1,40 @@
|
||||
ALTER TABLE "AiProvider"
|
||||
DROP CONSTRAINT IF EXISTS "AiProvider_type_check";
|
||||
|
||||
ALTER TABLE "AiProvider"
|
||||
ADD CONSTRAINT "AiProvider_type_check"
|
||||
CHECK (
|
||||
"type" IN (
|
||||
'alibaba',
|
||||
'openai',
|
||||
'custom',
|
||||
'local',
|
||||
'gemini',
|
||||
'grsai-gpt-image',
|
||||
'grsai-video',
|
||||
'huanke-gemini',
|
||||
'qianwen-video',
|
||||
'doubao-video',
|
||||
'ruxa-video',
|
||||
'volcano-vision',
|
||||
'volcano-video',
|
||||
'alibaba-image',
|
||||
'bfl-image',
|
||||
'ruxa-image',
|
||||
'fal-image',
|
||||
'snapgen-image',
|
||||
'openai-image',
|
||||
'bananaimage',
|
||||
'visionary',
|
||||
'atlascloud',
|
||||
'stepfun-image',
|
||||
'midjourney',
|
||||
'fal-video',
|
||||
'zhipu',
|
||||
'tripo-3d',
|
||||
'worldlabs-3d',
|
||||
'kittycad-cad',
|
||||
'runway',
|
||||
'krea'
|
||||
)
|
||||
);
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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/", "/");
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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",
|
||||
|
||||
121
scripts/ai-cad-zoo-ws-dev-proxy.mjs
Normal file
121
scripts/ai-cad-zoo-ws-dev-proxy.mjs
Normal 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}`);
|
||||
});
|
||||
92
scripts/sync-atlascloud-models.ts
Normal file
92
scripts/sync-atlascloud-models.ts
Normal 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;
|
||||
});
|
||||
369
scripts/vendor-zoo-modeling-app.mjs
Normal file
369
scripts/vendor-zoo-modeling-app.mjs
Normal 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);
|
||||
});
|
||||
204
scripts/verify-atlascloud-frontend.ts
Normal file
204
scripts/verify-atlascloud-frontend.ts
Normal 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();
|
||||
191
tests/routes/model-classification.test.ts
Normal file
191
tests/routes/model-classification.test.ts
Normal 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
49
vendor/zoo-modeling-app/Logo.tsx
vendored
Normal 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
236
vendor/zoo-modeling-app/localization.ts
vendored
Normal 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,
|
||||
})
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user