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); }); });