From 27d44b3b77d85ecf5c5c70f2b4789ef018a22eb7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Apr 2026 22:09:43 +0800 Subject: [PATCH] feat: add configurable 3d model display aliases --- .../settings/admin-settings-client.tsx | 448 ++++++++++++++++++ app/api/admin/settings/route.ts | 8 + app/api/canvas/3d/tasks/route.ts | 20 +- app/api/canvas/[id]/model3d-assets/route.ts | 55 ++- app/api/canvas/model-aliases/route.ts | 23 + app/api/canvas/world/tasks/[taskId]/route.ts | 15 +- app/api/canvas/world/tasks/route.ts | 9 +- app/canvas/[id]/model3d/page.tsx | 125 +++-- components/canvas/nodes/Model3DNode.tsx | 27 +- components/canvas/nodes/World3DNode.tsx | 43 +- lib/model3d/model-alias-config-server.ts | 13 + lib/model3d/model-alias-config.ts | 315 ++++++++++++ 12 files changed, 1026 insertions(+), 75 deletions(-) create mode 100644 app/api/canvas/model-aliases/route.ts create mode 100644 lib/model3d/model-alias-config-server.ts create mode 100644 lib/model3d/model-alias-config.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 80b4a3607..9651bac56 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -68,6 +68,12 @@ import { normalizeWorldLabsKeyPoolConfig, WORLDLABS_KEY_POOL_CONFIG_KEY, } from "@/lib/worldlabs/key-pool-config-shared"; +import { + defaultThreeDModelAliasConfig, + normalizeThreeDModelAliasConfig, + THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, + type ThreeDModelAliasEntry, +} from "@/lib/model3d/model-alias-config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; @@ -844,6 +850,13 @@ export function AdminSettingsClient() { `/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`, fetcher ); + const { + data: threeDModelAliasConfigData, + mutate: mutateThreeDModelAliasConfig, + } = useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${THREED_MODEL_ALIAS_CONFIG_SETTING_KEY}`, + fetcher + ); const { data: enhanceProConfigData, mutate: mutateEnhanceProConfig } = useSWR<{ setting?: { value?: unknown } }>( `/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`, @@ -1374,6 +1387,9 @@ export function AdminSettingsClient() { const [worldlabsKeyPoolConfig, setWorldlabsKeyPoolConfig] = useState( defaultWorldLabsKeyPoolConfig ); + const [threeDModelAliasConfig, setThreeDModelAliasConfig] = useState( + defaultThreeDModelAliasConfig() + ); const [worldlabsProviderSearch, setWorldlabsProviderSearch] = useState(""); const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState< string | null @@ -1895,6 +1911,12 @@ export function AdminSettingsClient() { ); }, [worldlabsKeyPoolConfigData]); + useEffect(() => { + setThreeDModelAliasConfig( + normalizeThreeDModelAliasConfig(threeDModelAliasConfigData?.setting?.value) + ); + }, [threeDModelAliasConfigData]); + const worldlabsProviders = useMemo( () => (providersData?.providers || []).filter( @@ -4247,6 +4269,135 @@ export function AdminSettingsClient() { } }; + const updateThreeDModelAliasEntries = ( + key: "tripoModels" | "worldModels", + updater: (entries: ThreeDModelAliasEntry[]) => ThreeDModelAliasEntry[] + ) => { + setThreeDModelAliasConfig((prev) => ({ + ...prev, + [key]: updater(prev[key]), + })); + }; + + const handleThreeDModelAliasEntryChange = < + K extends keyof ThreeDModelAliasEntry, + >( + key: "tripoModels" | "worldModels", + index: number, + field: K, + value: ThreeDModelAliasEntry[K] + ) => { + updateThreeDModelAliasEntries(key, (entries) => + entries.map((entry, entryIndex) => { + if (entryIndex !== index) { + if (field === "isDefault" && value === true) { + return { ...entry, isDefault: false }; + } + return entry; + } + return { + ...entry, + [field]: value, + } as ThreeDModelAliasEntry; + }) + ); + }; + + const handleAddThreeDModelAliasEntry = ( + key: "tripoModels" | "worldModels" + ) => { + updateThreeDModelAliasEntries(key, (entries) => [ + ...entries, + { + id: `${key === "tripoModels" ? "tripo" : "world"}-custom-${crypto.randomUUID().slice(0, 8)}`, + displayName: "", + realModelId: "", + description: "", + enabled: true, + isDefault: false, + }, + ]); + }; + + const handleRemoveThreeDModelAliasEntry = ( + key: "tripoModels" | "worldModels", + index: number + ) => { + updateThreeDModelAliasEntries(key, (entries) => { + if (entries.length <= 1) return entries; + const nextEntries = entries.filter((_, entryIndex) => entryIndex !== index); + const hasDefault = nextEntries.some( + (entry) => entry.enabled !== false && entry.isDefault + ); + + if (hasDefault) return nextEntries; + + const fallbackIndex = nextEntries.findIndex( + (entry) => entry.enabled !== false + ); + const targetIndex = fallbackIndex >= 0 ? fallbackIndex : 0; + return nextEntries.map((entry, entryIndex) => ({ + ...entry, + isDefault: entryIndex === targetIndex, + })); + }); + }; + + const handleResetThreeDModelAliasConfig = () => { + setThreeDModelAliasConfig(defaultThreeDModelAliasConfig()); + }; + + const handleSaveThreeDModelAliasConfig = async () => { + try { + const configToSave = normalizeThreeDModelAliasConfig(threeDModelAliasConfig); + const invalidEntry = [ + ...configToSave.tripoModels.map((entry, index) => ({ + providerLabel: "Tripo3D", + index, + entry, + })), + ...configToSave.worldModels.map((entry, index) => ({ + providerLabel: "World Labs", + index, + entry, + })), + ].find( + ({ entry }) => + !entry.displayName.trim() || !entry.realModelId.trim() + ); + + if (invalidEntry) { + throw new Error( + `${invalidEntry.providerLabel} 第 ${invalidEntry.index + 1} 条映射缺少展示名或真实模型 ID` + ); + } + + const response = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, + value: configToSave, + }), + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + String(payload?.message || payload?.error || "保存失败") + ); + } + + setThreeDModelAliasConfig(configToSave); + mutateThreeDModelAliasConfig(); + toast.success("3D 模型显示名映射保存成功"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "保存 3D 模型显示名映射失败" + ); + } + }; + const handleEditWorldlabsPoolProvider = (provider: AiProvider) => { setProviderForm({ id: provider.id, @@ -12872,6 +13023,303 @@ export function AdminSettingsClient() { + + +
+ 3D / 世界模型显示名映射 + + 配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。 + +
+ toggleLocalAiSection("3d-model-alias")} + /> +
+ +
+ 前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。 +
+ +
+
+
+
+
Tripo3D 模型映射
+
+ 控制 3D 生成页面与模型节点显示的模型名称。 +
+
+ +
+ +
+ {threeDModelAliasConfig.tripoModels.map((entry, index) => ( +
+
+
+ Tripo3D 映射 {index + 1} +
+
+ +
+ + handleThreeDModelAliasEntryChange( + "tripoModels", + index, + "enabled", + checked + ) + } + /> + + {entry.enabled ? "已启用" : "已停用"} + +
+ +
+
+ +
+
+ + + handleThreeDModelAliasEntryChange( + "tripoModels", + index, + "displayName", + e.target.value + ) + } + placeholder="例如:标准模型" + /> +
+
+ + + handleThreeDModelAliasEntryChange( + "tripoModels", + index, + "realModelId", + e.target.value + ) + } + placeholder="例如:Turbo-v1.0-20250506" + /> +
+
+ +
+ + + handleThreeDModelAliasEntryChange( + "tripoModels", + index, + "description", + e.target.value + ) + } + placeholder="可选,用于帮助管理员区分不同映射用途" + /> +
+
+ ))} +
+
+ +
+
+
+
World Labs 模型映射
+
+ 控制 3D 世界工作台与世界节点显示的质量名称。 +
+
+ +
+ +
+ {threeDModelAliasConfig.worldModels.map((entry, index) => ( +
+
+
+ World 映射 {index + 1} +
+
+ +
+ + handleThreeDModelAliasEntryChange( + "worldModels", + index, + "enabled", + checked + ) + } + /> + + {entry.enabled ? "已启用" : "已停用"} + +
+ +
+
+ +
+
+ + + handleThreeDModelAliasEntryChange( + "worldModels", + index, + "displayName", + e.target.value + ) + } + placeholder="例如:标准质量" + /> +
+
+ + + handleThreeDModelAliasEntryChange( + "worldModels", + index, + "realModelId", + e.target.value + ) + } + placeholder="例如:marble-1.1" + /> +
+
+ +
+ + + handleThreeDModelAliasEntryChange( + "worldModels", + index, + "description", + e.target.value + ) + } + placeholder="可选,用于帮助管理员区分不同映射用途" + /> +
+
+ ))} +
+
+
+ +
+ + +
+
+
+
diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 9dcfe1bea..e2e683f5d 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -34,6 +34,10 @@ import { normalizeWorldLabsKeyPoolConfig, WORLDLABS_KEY_POOL_CONFIG_KEY, } from "@/lib/worldlabs/key-pool-config-shared"; +import { + normalizeThreeDModelAliasConfig, + THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, +} from "@/lib/model3d/model-alias-config"; const settingSchema = z.object({ key: z.string(), @@ -260,6 +264,10 @@ export async function POST(request: Request) { nextValue = normalizeWorldLabsKeyPoolConfig(value); } + if (key === THREED_MODEL_ALIAS_CONFIG_SETTING_KEY) { + nextValue = normalizeThreeDModelAliasConfig(value); + } + const setting = await upsertSystemSetting({ key, value: nextValue, diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts index c9565139a..ad4b02089 100644 --- a/app/api/canvas/3d/tasks/route.ts +++ b/app/api/canvas/3d/tasks/route.ts @@ -5,6 +5,8 @@ import { getPointsSystemConfig, getUserById, } from "@/lib/db/queries"; +import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { getTripoProviderConfig } from "@/lib/tripo/server"; import { TripoApiError, tripoRequest } from "@/lib/tripo/client"; @@ -87,6 +89,11 @@ function hasOwn(data: Record, key: string) { return Object.prototype.hasOwnProperty.call(data, key); } +function readOptionalTrimmedText(input: unknown) { + const value = String(input || "").trim(); + return value || null; +} + function assignMapped( payload: Record, source: Record, @@ -121,6 +128,7 @@ export async function POST(request: Request) { if (!taskType) { return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 }); } + const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const payload: Record = { type: taskType, @@ -296,7 +304,6 @@ export async function POST(request: Request) { } assignMapped(payload, body as Record, { - modelVersion: "model_version", negativePrompt: "negative_prompt", faceLimit: "face_limit", texture: "texture", @@ -335,6 +342,17 @@ export async function POST(request: Request) { animateInPlace: "animate_in_place", }); + if ( + taskType === "text_to_model" || + taskType === "image_to_model" || + taskType === "multiview_to_model" + ) { + payload.model_version = resolveThreeDModelRealId( + readOptionalTrimmedText(body.modelVersion), + modelAliasConfig.tripoModels, + ); + } + if (body.compress === true) { payload.compress = "geometry"; } diff --git a/app/api/canvas/[id]/model3d-assets/route.ts b/app/api/canvas/[id]/model3d-assets/route.ts index 9b0102a90..a476a37b5 100644 --- a/app/api/canvas/[id]/model3d-assets/route.ts +++ b/app/api/canvas/[id]/model3d-assets/route.ts @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; +import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { canvasProject, model3dAsset } from "@/lib/db/schema"; type Model3DTaskStatus = @@ -94,6 +96,22 @@ function readJsonObject(input: unknown): Record | null { : null; } +function normalizeAssetPayloadModelAlias( + payload: unknown, + entries: Awaited< + ReturnType + >["worldModels"], +) { + const objectValue = readJsonObject(payload); + if (!objectValue) return objectValue; + + const nextModel = resolveThreeDModelAliasId(objectValue.model, entries); + return { + ...objectValue, + ...(nextModel ? { model: nextModel } : {}), + }; +} + function readProgress(input: unknown): number | null { if (input === undefined || input === null || input === "") return null; const value = Number(input); @@ -155,8 +173,27 @@ export async function GET( ) .orderBy(desc(model3dAsset.updatedAt)) .limit(TASK_LIST_LIMIT); + const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); + const normalizedAssets = assets.map((asset) => { + const entries = + asset.provider === "worldlabs-3d" + ? modelAliasConfig.worldModels + : modelAliasConfig.tripoModels; - return NextResponse.json({ success: true, data: assets }); + return { + ...asset, + modelVersion: resolveThreeDModelAliasId(asset.modelVersion, entries), + assetPayload: + asset.provider === "worldlabs-3d" + ? normalizeAssetPayloadModelAlias( + asset.assetPayload, + modelAliasConfig.worldModels, + ) + : asset.assetPayload, + }; + }); + + return NextResponse.json({ success: true, data: normalizedAssets }); } catch (error) { console.error("[Model3DAssets] GET failed:", error); return NextResponse.json({ success: false, error: "获取 3D 资产失败" }, { status: 500 }); @@ -186,18 +223,30 @@ export async function POST( return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } + const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const status = normalizeTaskStatus(rawAsset.status); const mode = normalizeTaskMode(rawAsset.mode); const provider = readProvider(rawAsset.provider); const prompt = String(rawAsset.prompt || "").trim(); - const modelVersion = String(rawAsset.modelVersion || "").trim(); + const modelVersion = resolveThreeDModelAliasId( + rawAsset.modelVersion, + provider === "worldlabs-3d" + ? modelAliasConfig.worldModels + : modelAliasConfig.tripoModels, + ); const progress = readProgress(rawAsset.progress); const modelUrl = readOptionalText(rawAsset.modelUrl); const previewImageUrl = readOptionalText(rawAsset.previewImageUrl); const error = readOptionalText(rawAsset.error); const sourceTaskId = readOptionalText(rawAsset.sourceTaskId); const exportFormat = readOptionalText(rawAsset.exportFormat); - const assetPayload = readJsonObject(rawAsset.assetPayload); + const assetPayload = + provider === "worldlabs-3d" + ? normalizeAssetPayloadModelAlias( + rawAsset.assetPayload, + modelAliasConfig.worldModels, + ) + : readJsonObject(rawAsset.assetPayload); const createdAt = readDate(rawAsset.createdAt); const now = new Date(); diff --git a/app/api/canvas/model-aliases/route.ts b/app/api/canvas/model-aliases/route.ts new file mode 100644 index 000000000..ab6b19c5d --- /dev/null +++ b/app/api/canvas/model-aliases/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; +import { toPublicThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config"; + +export async function GET() { + try { + const config = await getThreeDModelAliasConfigForRuntime(); + + return NextResponse.json({ + success: true, + data: toPublicThreeDModelAliasConfig(config), + }); + } catch (error) { + console.error("[ThreeDModelAliases] GET failed:", error); + return NextResponse.json( + { + success: false, + error: "获取 3D 模型显示映射失败", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index a1a2906c5..818fe50f0 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { uploadFile } from "@/lib/storage/service"; import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; @@ -169,12 +171,18 @@ function extractWorldFromOperation(operation: WorldLabsOperation) { return null; } -function buildAssetPayload(world: WorldLabsWorld, assets?: WorldLabsWorldAssets | null) { +function buildAssetPayload( + world: WorldLabsWorld, + worldModelAliases: Awaited< + ReturnType + >["worldModels"], + assets?: WorldLabsWorldAssets | null, +) { return { worldId: getStringValue(world.world_id), worldUrl: getStringValue(world.world_marble_url), displayName: getStringValue(world.display_name), - model: getStringValue(world.model), + model: resolveThreeDModelAliasId(getStringValue(world.model), worldModelAliases), caption: getStringValue(assets?.caption), thumbnailUrl: getStringValue(assets?.thumbnail_url), panoUrl: getStringValue(assets?.imagery?.pano_url), @@ -210,6 +218,7 @@ export async function GET(request: Request, { params }: RouteParams) { const providerId = new URL(request.url).searchParams.get("providerId"); const config = await getWorldLabsProviderConfig(providerId); + const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const operation = await worldLabsRequest(config, { method: "GET", path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, @@ -297,7 +306,7 @@ export async function GET(request: Request, { params }: RouteParams) { const assetPayload = { providerId: config.providerId || null, providerName: config.providerName || null, - ...buildAssetPayload(world, assets), + ...buildAssetPayload(world, modelAliasConfig.worldModels, assets), }; return NextResponse.json({ diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index a25aea46d..9d196e95c 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -5,6 +5,8 @@ import { getPointsSystemConfig, getUserById, } from "@/lib/db/queries"; +import { resolveThreeDModelRealId } from "@/lib/model3d/model-alias-config"; +import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import { disableWorldLabsProvider, @@ -152,6 +154,7 @@ export async function POST(request: Request) { if (!["text", "image", "multi-image", "video"].includes(mode)) { return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 }); } + const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const pointsConfig = await getPointsSystemConfig(); const shouldChargePoints = shouldCharge3DPoints(mode); @@ -190,6 +193,10 @@ export async function POST(request: Request) { readOptionalTrimmedText(body.displayName) || readOptionalTrimmedText(body.prompt) || `World ${new Date().toLocaleString("zh-CN")}`; + const resolvedWorldModel = resolveThreeDModelRealId( + readOptionalTrimmedText(body.model), + modelAliasConfig.worldModels, + ); const worldPrompt = buildWorldPrompt(body, mode); const candidates = preferredProviderId @@ -212,7 +219,7 @@ export async function POST(request: Request) { path: "/marble/v1/worlds:generate", json: { display_name: displayName, - model: readOptionalTrimmedText(body.model) || "marble-1.1", + model: resolvedWorldModel || "marble-1.1", permission: { public: body.makePublic === true, }, diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 21e3e256a..761aa2bd6 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -91,6 +91,12 @@ import { getSortedWorldLabsSplatEntries, pickBestWorldLabsSplatUrl, } from "@/lib/worldlabs/splat-utils"; +import { + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, + getDefaultThreeDModelAliasId, + resolveThreeDModelDisplayName, + type PublicThreeDModelAliasConfig, +} from "@/lib/model3d/model-alias-config"; import { canvasService, type Model3DTaskStatus, @@ -175,31 +181,6 @@ type WorldImageSlot = { azimuth: number; }; -const MODEL_VERSION_OPTIONS = [ - "Turbo-v1.0-20250506", - "v3.1-20260211", - "v3.0-20250812", - "v2.5-20250123", - "v2.0-20240919", - "v1.4-20240625", -] as const; - -const WORLDLABS_MODEL_OPTIONS = [ - "marble-1.1", - "marble-1.1-plus", - "marble-1.0", - "marble-1.0-draft", -] as const; -const WORLD_MODEL_DISPLAY_LABELS: Record< - (typeof WORLDLABS_MODEL_OPTIONS)[number], - string -> = { - "marble-1.1": "标准质量", - "marble-1.1-plus": "高保真", - "marble-1.0": "兼容质量", - "marble-1.0-draft": "快速草稿", -}; - const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [ "city", "sunset", @@ -549,11 +530,6 @@ function worldModeToTaskMode(mode: WorldGenerationMode): TaskMode { } } -function getWorldModelDisplayLabel(value?: string | null) { - const normalized = String(value || "").trim() as (typeof WORLDLABS_MODEL_OPTIONS)[number]; - return WORLD_MODEL_DISPLAY_LABELS[normalized] || "标准质量"; -} - export default function CanvasModel3DPage() { const router = useRouter(); const routeParams = useParams(); @@ -570,9 +546,11 @@ export default function CanvasModel3DPage() { const [mode, setMode] = useState("text"); const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); - const [modelVersion, setModelVersion] = useState< - (typeof MODEL_VERSION_OPTIONS)[number] - >("Turbo-v1.0-20250506"); + const [modelVersion, setModelVersion] = useState( + getDefaultThreeDModelAliasId( + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels, + ), + ); const [faceLimit, setFaceLimit] = useState(120_000); const [texture, setTexture] = useState(true); const [pbr, setPbr] = useState(true); @@ -610,9 +588,11 @@ export default function CanvasModel3DPage() { const [worldMode, setWorldMode] = useState("text"); const [worldPrompt, setWorldPrompt] = useState(""); const [worldDisplayName, setWorldDisplayName] = useState(""); - const [worldModel, setWorldModel] = useState< - (typeof WORLDLABS_MODEL_OPTIONS)[number] - >("marble-1.1"); + const [worldModel, setWorldModel] = useState( + getDefaultThreeDModelAliasId( + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels, + ), + ); const [worldDisableRecaption, setWorldDisableRecaption] = useState(false); const [worldIsPano, setWorldIsPano] = useState(false); const [worldMakePublic, setWorldMakePublic] = useState(false); @@ -695,6 +675,10 @@ export default function CanvasModel3DPage() { // Fetch site settings for Logo const { data: siteSettingsData } = useSWR("/api/site-settings", fetcher); + const { data: modelAliasConfigResponse } = useSWR<{ + success?: boolean; + data?: PublicThreeDModelAliasConfig; + }>("/api/canvas/model-aliases", fetcher); const { data: membershipSummary } = useSWR<{ summary?: { currentCredits?: number; @@ -724,12 +708,65 @@ export default function CanvasModel3DPage() { // Display logo logic (prefer dark mode logo since we are in dark theme) const displayLogo = siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo; + const publicModelAliasConfig = + modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; + const tripoModelOptions = useMemo( + () => { + const enabledEntries = publicModelAliasConfig.tripoModels.filter( + (item) => item.enabled !== false, + ); + return enabledEntries.length > 0 + ? enabledEntries + : publicModelAliasConfig.tripoModels; + }, + [publicModelAliasConfig.tripoModels], + ); + const worldModelOptions = useMemo( + () => { + const enabledEntries = publicModelAliasConfig.worldModels.filter( + (item) => item.enabled !== false, + ); + return enabledEntries.length > 0 + ? enabledEntries + : publicModelAliasConfig.worldModels; + }, + [publicModelAliasConfig.worldModels], + ); + const getWorldModelDisplayLabel = useCallback( + (value?: string | null) => + resolveThreeDModelDisplayName( + value, + publicModelAliasConfig.worldModels, + "标准质量", + ), + [publicModelAliasConfig.worldModels], + ); // Effect hooks for data loading and cleanup useEffect(() => { console.log("CanvasModel3DPage mounted - v3.0 Dark Mode"); }, []); + useEffect(() => { + const nextDefaultTripoModelId = getDefaultThreeDModelAliasId( + publicModelAliasConfig.tripoModels, + ); + if (!nextDefaultTripoModelId) return; + if (!tripoModelOptions.some((item) => item.id === modelVersion)) { + setModelVersion(nextDefaultTripoModelId); + } + }, [modelVersion, publicModelAliasConfig.tripoModels, tripoModelOptions]); + + useEffect(() => { + const nextDefaultWorldModelId = getDefaultThreeDModelAliasId( + publicModelAliasConfig.worldModels, + ); + if (!nextDefaultWorldModelId) return; + if (!worldModelOptions.some((item) => item.id === worldModel)) { + setWorldModel(nextDefaultWorldModelId); + } + }, [publicModelAliasConfig.worldModels, worldModel, worldModelOptions]); + useEffect(() => { const html = document.documentElement; const body = document.body; @@ -2550,13 +2587,13 @@ export default function CanvasModel3DPage() { - {MODEL_VERSION_OPTIONS.map((v) => ( + {tripoModelOptions.map((option) => ( - {v} + {option.displayName} ))} @@ -2826,13 +2863,13 @@ export default function CanvasModel3DPage() { - {WORLDLABS_MODEL_OPTIONS.map((item) => ( + {worldModelOptions.map((item) => ( - {getWorldModelDisplayLabel(item)} + {item.displayName} ))} diff --git a/components/canvas/nodes/Model3DNode.tsx b/components/canvas/nodes/Model3DNode.tsx index bf78ca67b..01fae7fa6 100644 --- a/components/canvas/nodes/Model3DNode.tsx +++ b/components/canvas/nodes/Model3DNode.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { AlertCircle, Box, @@ -21,10 +21,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import useSWR from "swr"; import { toast } from "sonner"; +import { + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, + resolveThreeDModelDisplayName, + type PublicThreeDModelAliasConfig, +} from "@/lib/model3d/model-alias-config"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, Model3DNodeData } from "@/types/canvas"; -import { cn } from "@/lib/utils"; +import { cn, fetcher } from "@/lib/utils"; interface Model3DNodeProps { node: CanvasNode; @@ -68,8 +74,23 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) { const data = node.data as Model3DNodeData; const [isHovered, setIsHovered] = useState(false); const deleteNode = useCanvasStore((state) => state.deleteNode); + const { data: modelAliasConfigResponse } = useSWR<{ + success?: boolean; + data?: PublicThreeDModelAliasConfig; + }>("/api/canvas/model-aliases", fetcher); const providerLabel = data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果"; + const publicModelAliasConfig = + modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; + const modelVersionLabel = useMemo( + () => + resolveThreeDModelDisplayName( + data.modelVersion, + publicModelAliasConfig.tripoModels, + providerLabel, + ), + [data.modelVersion, providerLabel, publicModelAliasConfig.tripoModels], + ); const worldAssetPayload = readWorldAssetPayload(data); const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl); const worldImageUrl = @@ -178,7 +199,7 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) { {data.prompt || inferModelFileName(data, node.id)}

- {data.modelVersion ? `模型: ${data.modelVersion}` : providerLabel} + {data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}

diff --git a/components/canvas/nodes/World3DNode.tsx b/components/canvas/nodes/World3DNode.tsx index 30d969e1d..d5728fec8 100644 --- a/components/canvas/nodes/World3DNode.tsx +++ b/components/canvas/nodes/World3DNode.tsx @@ -24,10 +24,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import useSWR from "swr"; +import { + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, + resolveThreeDModelDisplayName, + type PublicThreeDModelAliasConfig, +} from "@/lib/model3d/model-alias-config"; import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas"; -import { cn } from "@/lib/utils"; +import { cn, fetcher } from "@/lib/utils"; interface World3DNodeProps { node: CanvasNode; @@ -44,24 +50,6 @@ const VIEW_META: Array<{ { id: "mesh", label: "M", icon: Box }, ]; -function getWorldNodeModelLabel(value?: string | null) { - const normalized = String(value || "").trim(); - switch (normalized) { - case "marble-1.1": - return "标准质量"; - case "marble-1.1-plus": - return "高保真"; - case "marble-1.0": - return "兼容质量"; - case "marble-1.0-draft": - return "快速草稿"; - case "": - return "原生世界节点"; - default: - return "自定义质量"; - } -} - function getWorldNodeViewLabel(view: World3DViewMode) { switch (view) { case "splat": @@ -134,9 +122,24 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) { const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate"); const deleteNode = useCanvasStore((state) => state.deleteNode); const updateNode = useCanvasStore((state) => state.updateNode); + const { data: modelAliasConfigResponse } = useSWR<{ + success?: boolean; + data?: PublicThreeDModelAliasConfig; + }>("/api/canvas/model-aliases", fetcher); const activeView = useMemo(() => resolveActiveView(data), [data]); const availableViews = useMemo(() => resolveAvailableViews(data), [data]); + const publicModelAliasConfig = + modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; + const worldModelLabel = useMemo( + () => + resolveThreeDModelDisplayName( + data.modelVersion, + publicModelAliasConfig.worldModels, + "原生世界节点", + ), + [data.modelVersion, publicModelAliasConfig.worldModels], + ); const primaryUrl = resolvePrimaryUrl(data, activeView); const downloadTarget = inferDownloadTarget(data, activeView); const hotspotCount = data.hotspots?.length || 0; @@ -281,7 +284,7 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {

- {getWorldNodeModelLabel(data.modelVersion)} + {worldModelLabel}

diff --git a/lib/model3d/model-alias-config-server.ts b/lib/model3d/model-alias-config-server.ts new file mode 100644 index 000000000..753ab369e --- /dev/null +++ b/lib/model3d/model-alias-config-server.ts @@ -0,0 +1,13 @@ +import { getSystemSetting } from "@/lib/db/queries"; +import { + normalizeThreeDModelAliasConfig, + THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, +} from "@/lib/model3d/model-alias-config"; + +export async function getThreeDModelAliasConfigForRuntime() { + const setting = await getSystemSetting({ + key: THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, + }).catch(() => null); + + return normalizeThreeDModelAliasConfig(setting?.value); +} diff --git a/lib/model3d/model-alias-config.ts b/lib/model3d/model-alias-config.ts new file mode 100644 index 000000000..d50486e26 --- /dev/null +++ b/lib/model3d/model-alias-config.ts @@ -0,0 +1,315 @@ +export const THREED_MODEL_ALIAS_CONFIG_SETTING_KEY = "threed_model_alias_config"; + +export type ThreeDModelAliasEntry = { + id: string; + displayName: string; + realModelId: string; + description: string; + enabled: boolean; + isDefault: boolean; +}; + +export type ThreeDModelAliasConfig = { + tripoModels: ThreeDModelAliasEntry[]; + worldModels: ThreeDModelAliasEntry[]; +}; + +export type PublicThreeDModelAliasEntry = Omit< + ThreeDModelAliasEntry, + "realModelId" +>; + +export type PublicThreeDModelAliasConfig = { + tripoModels: PublicThreeDModelAliasEntry[]; + worldModels: PublicThreeDModelAliasEntry[]; +}; + +const DEFAULT_TRIPO_MODEL_ALIASES: ThreeDModelAliasEntry[] = [ + { + id: "tripo-standard", + displayName: "标准模型", + realModelId: "Turbo-v1.0-20250506", + description: "默认高速高质量模型,适合大多数 3D 生成场景。", + enabled: true, + isDefault: true, + }, + { + id: "tripo-detail", + displayName: "高精模型", + realModelId: "v3.1-20260211", + description: "偏向细节质量和结构还原,适合高要求模型生成。", + enabled: true, + isDefault: false, + }, + { + id: "tripo-balance", + displayName: "均衡模型", + realModelId: "v3.0-20250812", + description: "速度与质量更均衡,适合日常生产。", + enabled: true, + isDefault: false, + }, + { + id: "tripo-classic", + displayName: "经典模型", + realModelId: "v2.5-20250123", + description: "兼顾稳定性与兼容性,适合保守方案。", + enabled: true, + isDefault: false, + }, + { + id: "tripo-stable", + displayName: "稳定模型", + realModelId: "v2.0-20240919", + description: "老版本稳定模型,适合兼容历史流程。", + enabled: true, + isDefault: false, + }, + { + id: "tripo-legacy", + displayName: "兼容模型", + realModelId: "v1.4-20240625", + description: "更旧的兼容模型,适合特定老任务回放。", + enabled: true, + isDefault: false, + }, +]; + +const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [ + { + id: "world-standard", + displayName: "标准质量", + realModelId: "marble-1.1", + description: "默认世界生成质量,适合大多数空间创建。", + enabled: true, + isDefault: true, + }, + { + id: "world-fidelity", + displayName: "高保真", + realModelId: "marble-1.1-plus", + description: "更高保真与细节表现,适合高质量空间生成。", + enabled: true, + isDefault: false, + }, + { + id: "world-compatible", + displayName: "兼容质量", + realModelId: "marble-1.0", + description: "兼容旧版世界任务和历史工作流。", + enabled: true, + isDefault: false, + }, + { + id: "world-draft", + displayName: "快速草稿", + realModelId: "marble-1.0-draft", + description: "更适合快速预览与草稿生成。", + enabled: true, + isDefault: false, + }, +]; + +function sanitizeText(value: unknown) { + return String(value || "").trim(); +} + +function cloneEntries(entries: ThreeDModelAliasEntry[]) { + return entries.map((entry) => ({ ...entry })); +} + +function normalizeEntry( + value: unknown, + index: number, + defaults: ThreeDModelAliasEntry[], + prefix: string, +): ThreeDModelAliasEntry { + const fallback = defaults[index] || defaults[0]; + const hasRawObject = + value && typeof value === "object" && !Array.isArray(value); + const raw = hasRawObject ? (value as Record) : {}; + + return { + id: sanitizeText(raw.id) || fallback?.id || `${prefix}-${index + 1}`, + displayName: hasRawObject + ? sanitizeText(raw.displayName) + : fallback?.displayName || "", + realModelId: hasRawObject + ? sanitizeText(raw.realModelId || raw.modelId) + : fallback?.realModelId || "", + description: hasRawObject + ? sanitizeText(raw.description) + : fallback?.description || "", + enabled: + typeof raw.enabled === "boolean" + ? raw.enabled + : fallback?.enabled !== false, + isDefault: + typeof raw.isDefault === "boolean" ? raw.isDefault : fallback?.isDefault === true, + }; +} + +function finalizeEntries( + entries: ThreeDModelAliasEntry[], + defaults: ThreeDModelAliasEntry[], +) { + const nextEntries = + entries.length > 0 ? cloneEntries(entries) : cloneEntries(defaults); + const usedIds = new Set(); + + nextEntries.forEach((entry, index) => { + let nextId = sanitizeText(entry.id) || `${defaults[0]?.id || "model"}-${index + 1}`; + while (usedIds.has(nextId)) { + nextId = `${nextId}-${index + 1}`; + } + entry.id = nextId; + usedIds.add(nextId); + }); + + const enabledIndex = nextEntries.findIndex((entry) => entry.enabled); + const defaultEnabledIndex = nextEntries.findIndex( + (entry) => entry.enabled && entry.isDefault, + ); + const targetDefaultIndex = + defaultEnabledIndex >= 0 ? defaultEnabledIndex : enabledIndex >= 0 ? enabledIndex : 0; + + return nextEntries.map((entry, index) => ({ + ...entry, + isDefault: index === targetDefaultIndex, + })); +} + +function normalizeEntryList( + value: unknown, + defaults: ThreeDModelAliasEntry[], + prefix: string, +) { + if (!Array.isArray(value) || value.length === 0) { + return cloneEntries(defaults); + } + + return finalizeEntries( + value.map((item, index) => normalizeEntry(item, index, defaults, prefix)), + defaults, + ); +} + +export function defaultThreeDModelAliasConfig(): ThreeDModelAliasConfig { + return { + tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES), + worldModels: cloneEntries(DEFAULT_WORLD_MODEL_ALIASES), + }; +} + +export function normalizeThreeDModelAliasConfig( + value: unknown, +): ThreeDModelAliasConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + return { + tripoModels: normalizeEntryList( + raw.tripoModels, + DEFAULT_TRIPO_MODEL_ALIASES, + "tripo-model", + ), + worldModels: normalizeEntryList( + raw.worldModels, + DEFAULT_WORLD_MODEL_ALIASES, + "world-model", + ), + }; +} + +export function toPublicThreeDModelAliasConfig( + config: ThreeDModelAliasConfig, +): PublicThreeDModelAliasConfig { + return { + tripoModels: config.tripoModels.map(({ realModelId: _realModelId, ...entry }) => ({ + ...entry, + })), + worldModels: config.worldModels.map(({ realModelId: _realModelId, ...entry }) => ({ + ...entry, + })), + }; +} + +export const DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG = + toPublicThreeDModelAliasConfig(defaultThreeDModelAliasConfig()); + +function findEntryByValue< + T extends { id: string; displayName: string; realModelId?: string }, +>(entries: T[], value: unknown) { + const normalized = sanitizeText(value); + if (!normalized) return null; + + return ( + entries.find((entry) => sanitizeText(entry.id) === normalized) || + entries.find( + (entry) => + "realModelId" in entry && sanitizeText(entry.realModelId) === normalized, + ) || + entries.find((entry) => sanitizeText(entry.displayName) === normalized) || + null + ); +} + +export function getDefaultThreeDModelAliasId( + entries: Array<{ id: string; enabled?: boolean; isDefault?: boolean }>, +) { + const enabledEntries = entries.filter((entry) => entry.enabled !== false); + const defaultEntry = + enabledEntries.find((entry) => entry.isDefault) || enabledEntries[0] || entries[0]; + return sanitizeText(defaultEntry?.id) || ""; +} + +export function resolveThreeDModelRealId( + value: unknown, + entries: ThreeDModelAliasEntry[], +) { + const match = findEntryByValue(entries, value); + if (match?.realModelId) { + return sanitizeText(match.realModelId); + } + + const normalized = sanitizeText(value); + if (normalized) { + return normalized; + } + + const fallbackEntry = + entries.find((entry) => entry.enabled && entry.isDefault) || + entries.find((entry) => entry.enabled) || + entries[0]; + return sanitizeText(fallbackEntry?.realModelId); +} + +export function resolveThreeDModelAliasId( + value: unknown, + entries: ThreeDModelAliasEntry[], +) { + const match = findEntryByValue(entries, value); + if (match?.id) { + return sanitizeText(match.id); + } + + return sanitizeText(value); +} + +export function resolveThreeDModelDisplayName< + T extends { id: string; displayName: string; realModelId?: string }, +>( + value: unknown, + entries: T[], + fallback = "", +) { + const match = findEntryByValue(entries, value); + if (match?.displayName) { + return sanitizeText(match.displayName); + } + + const normalized = sanitizeText(value); + return normalized || fallback; +}