diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 37bb911de..328c66183 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -173,6 +173,7 @@ import {
getImageSizeCreditMultiplier,
} from "@/lib/ai/image-model-pricing";
import {
+ is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
@@ -1586,7 +1587,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
- { value: "atlascloud", label: "Atlas Cloud 图片模型" },
+ { 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" },
@@ -2083,10 +2084,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(
() =>
@@ -4176,6 +4190,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,
@@ -4198,7 +4217,9 @@ export function AdminSettingsClient() {
? `${
selectedModelProviderType === "krea" ? "Krea" : "Runway"
} 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。`
- : isVideoProviderForModelConfig
+ : is3DProviderForModelConfig
+ ? "当前识别为 3D 模型,将与图片、视频和对话模型分开管理。"
+ : isVideoProviderForModelConfig
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
: isImageProviderForModelConfig
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
@@ -6452,7 +6473,7 @@ export function AdminSettingsClient() {
} catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning(
- 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 全部图片模型"按钮'
+ 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
);
}
}
@@ -7513,7 +7534,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
- "将从 Atlas Cloud 官方目录同步全部开放图片模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
+ "将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
)
) {
return;
@@ -7532,7 +7553,7 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
- `已同步 ${totalSynced} 个 Atlas Cloud 图片模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
+ `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
@@ -7540,7 +7561,7 @@ export function AdminSettingsClient() {
}
} catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error);
- toast.error(error.message || "同步 Atlas Cloud 图片模型失败");
+ toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
throw error;
}
};
@@ -15764,7 +15785,7 @@ export function AdminSettingsClient() {
{providerForm.type === "atlascloud" && (
Atlas Cloud 使用 Bearer API Key 认证,默认地址
- https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片模型,
+ https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片与 3D 模型,
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
)}
@@ -15922,7 +15943,7 @@ export function AdminSettingsClient() {
variant="secondary"
>
- 同步 Atlas Cloud 全部图片模型
+ 同步 Atlas Cloud 图片 / 3D 模型
)}
{provider.type === "bltcy-video" && (
@@ -17417,6 +17438,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,
@@ -17479,6 +17505,11 @@ export function AdminSettingsClient() {
Agent
)}
+ {is3DModel && (
+
+ 3D模型
+
+ )}
{isImageModel && (
图片模型
@@ -17489,7 +17520,7 @@ export function AdminSettingsClient() {
视频模型
)}
- {!isImageModel && !isVideoModel && (
+ {!is3DModel && !isImageModel && !isVideoModel && (
对话模型
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
index ba4803a76..74636b29d 100644
--- a/app/api/admin/atlascloud-models/route.ts
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -79,7 +79,7 @@ export async function POST(request: Request) {
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
- supportsImageGen: true,
+ supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,
diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
index 14d633723..06d23ce15 100644
--- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
+++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
@@ -2013,6 +2013,7 @@ export default function CanvasEditorLegacyPage({
useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
+ const chatPanelCanvasIdRef = useRef(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
useState(0);
@@ -2036,6 +2037,16 @@ export default function CanvasEditorLegacyPage({
Partial>
>({});
+ useLayoutEffect(() => {
+ if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
+ return;
+ }
+
+ chatPanelCanvasIdRef.current = id;
+ setPanelMode("chat");
+ setIsPanelOpen(true);
+ }, [id, isVideoMode]);
+
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);
diff --git a/lib/ai/atlascloud-models.ts b/lib/ai/atlascloud-models.ts
index 2ab430267..21f10e4a0 100644
--- a/lib/ai/atlascloud-models.ts
+++ b/lib/ai/atlascloud-models.ts
@@ -77,7 +77,7 @@ export interface AtlasCloudImageModelPreset {
avatarUrl?: string;
description: string;
supportsVision: boolean;
- supportsImageGen: true;
+ supportsImageGen: boolean;
sortOrder: number;
capabilities: Record;
defaultParams: Record;
@@ -86,6 +86,8 @@ export interface AtlasCloudImageModelPreset {
type JsonRecord = Record;
+export type AtlasCloudMediaKind = "image" | "video" | "3d";
+
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
@@ -98,6 +100,67 @@ function uniqueStrings(values: unknown[]) {
);
}
+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);
}
@@ -361,15 +424,22 @@ export function createAtlasCloudImagePreset(
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
);
- const supportedModes = [
- ...(categories.some((value) => value.includes("text-to-image"))
- ? ["text-to-image"]
- : []),
- ...(imageInputKey || categories.some((value) => value.includes("image-to-image"))
- ? ["image-edit", "reference-images"]
- : []),
- ];
- if (supportedModes.length === 0) supportedModes.push("text-to-image");
+ const 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"]
+ : []),
+ ];
+ if (mediaKind === "image" && supportedModes.length === 0) {
+ supportedModes.push("text-to-image");
+ }
const runtimeConfig: AtlasCloudRuntimeConfig = {
submitEndpoint: getPostEndpoint(schema),
@@ -409,12 +479,20 @@ export function createAtlasCloudImagePreset(
avatarUrl: catalogModel.avatar || catalogModel.logo || undefined,
description:
catalogModel.profile ||
- `Atlas Cloud 图片模型 ${catalogModel.displayName || catalogModel.model}`,
+ `Atlas Cloud ${mediaKind === "3d" ? "3D" : mediaKind === "video" ? "视频" : "图片"}模型 ${catalogModel.displayName || catalogModel.model}`,
supportsVision: Boolean(imageInputKey),
- supportsImageGen: true,
+ supportsImageGen: mediaKind === "image",
sortOrder: Math.max(0, 20_000 - index),
capabilities: {
- kind: imageInputKey ? "image-edit" : "text-to-image",
+ kind:
+ mediaKind === "3d"
+ ? "3d"
+ : mediaKind === "video"
+ ? "video"
+ : imageInputKey
+ ? "image-edit"
+ : "text-to-image",
+ mediaType: mediaKind,
supportedModes,
maxReferenceImages,
supportsMultiImageReference: maxReferenceImages > 1,
diff --git a/lib/ai/model-classification.ts b/lib/ai/model-classification.ts
index c861c6947..8e45c9552 100644
--- a/lib/ai/model-classification.ts
+++ b/lib/ai/model-classification.ts
@@ -21,12 +21,17 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
"openai-image",
"bananaimage",
"visionary",
- "atlascloud",
"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",
@@ -47,10 +52,42 @@ 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",
]);
+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([
"text-to-image",
"text2image",
@@ -107,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;
@@ -129,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;
}
@@ -156,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;
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index 04cc84f1a..0289d8932 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -1,35 +1,95 @@
import { expect, test } from "@playwright/test";
import {
+ createAtlasCloudImagePreset,
+ inferAtlasCloudMediaKind,
+} from "@/lib/ai/atlascloud-models";
+import {
+ is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
-test.describe("model classification", () => {
- test("keeps multi-reference BananaImage models classified as image models", () => {
+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 Atlas 3D entries out of image and video categories", () => {
+ expect(inferAtlasCloudMediaKind(atlasHunyuan3D)).toBe("3d");
+
const descriptor = {
- providerType: "bananaimage",
- modelId: "gemini-3-pro-image-preview",
+ providerType: "atlascloud",
+ modelId: atlasHunyuan3D.model,
capabilities: {
- supportedModes: [
- "text-to-image",
- "image-to-image",
- "reference-images",
- ],
+ 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(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);
+ expect(isImageGenerationModelDescriptor(imageDescriptor)).toBe(true);
+ expect(isVideoModelDescriptor(imageDescriptor)).toBe(false);
+ expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
+ expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
});
});