feat: add configurable 3d model display aliases
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-08 22:09:43 +08:00
parent 1a71072497
commit 27d44b3b77
12 changed files with 1026 additions and 75 deletions

View File

@ -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() {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle>
<CardDescription>
配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。
</CardDescription>
</div>
<LocalAiSectionToggleButton
expanded={isLocalAiSectionExpanded("3d-model-alias")}
onClick={() => toggleLocalAiSection("3d-model-alias")}
/>
</CardHeader>
<CardContent
className={cn(
"local-ai-config-card-content space-y-6 pt-6",
!isLocalAiSectionExpanded("3d-model-alias") && "hidden"
)}
>
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-4 text-sm text-muted-foreground">
前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="text-base font-medium">Tripo3D 模型映射</div>
<div className="text-sm text-muted-foreground">
控制 3D 生成页面与模型节点显示的模型名称。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddThreeDModelAliasEntry("tripoModels")}
>
<Plus className="mr-2 h-4 w-4" />
添加映射
</Button>
</div>
<div className="space-y-4">
{threeDModelAliasConfig.tripoModels.map((entry, index) => (
<div key={entry.id} className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm font-medium">
Tripo3D 映射 {index + 1}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={entry.isDefault ? "default" : "outline"}
size="sm"
onClick={() =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"isDefault",
true
)
}
>
{entry.isDefault ? "默认映射" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={entry.enabled}
onCheckedChange={(checked) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"enabled",
checked
)
}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? "已启用" : "已停用"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={threeDModelAliasConfig.tripoModels.length <= 1}
onClick={() =>
handleRemoveThreeDModelAliasEntry(
"tripoModels",
index
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前台显示名</Label>
<Input
value={entry.displayName}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"displayName",
e.target.value
)
}
placeholder="例如:标准模型"
/>
</div>
<div className="space-y-2">
<Label>真实模型 ID</Label>
<Input
value={entry.realModelId}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"realModelId",
e.target.value
)
}
placeholder="例如:Turbo-v1.0-20250506"
/>
</div>
</div>
<div className="space-y-2">
<Label>说明</Label>
<Input
value={entry.description}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"description",
e.target.value
)
}
placeholder="可选,用于帮助管理员区分不同映射用途"
/>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="text-base font-medium">World Labs 模型映射</div>
<div className="text-sm text-muted-foreground">
控制 3D 世界工作台与世界节点显示的质量名称。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleAddThreeDModelAliasEntry("worldModels")}
>
<Plus className="mr-2 h-4 w-4" />
添加映射
</Button>
</div>
<div className="space-y-4">
{threeDModelAliasConfig.worldModels.map((entry, index) => (
<div key={entry.id} className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm font-medium">
World 映射 {index + 1}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={entry.isDefault ? "default" : "outline"}
size="sm"
onClick={() =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"isDefault",
true
)
}
>
{entry.isDefault ? "默认映射" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={entry.enabled}
onCheckedChange={(checked) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"enabled",
checked
)
}
/>
<span className="text-xs text-muted-foreground">
{entry.enabled ? "已启用" : "已停用"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
disabled={threeDModelAliasConfig.worldModels.length <= 1}
onClick={() =>
handleRemoveThreeDModelAliasEntry(
"worldModels",
index
)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前台显示名</Label>
<Input
value={entry.displayName}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"displayName",
e.target.value
)
}
placeholder="例如:标准质量"
/>
</div>
<div className="space-y-2">
<Label>真实模型 ID</Label>
<Input
value={entry.realModelId}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"realModelId",
e.target.value
)
}
placeholder="例如:marble-1.1"
/>
</div>
</div>
<div className="space-y-2">
<Label>说明</Label>
<Input
value={entry.description}
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"description",
e.target.value
)
}
placeholder="可选,用于帮助管理员区分不同映射用途"
/>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={handleResetThreeDModelAliasConfig}
>
<RefreshCw className="mr-2 h-4 w-4" />
恢复默认映射
</Button>
<Button onClick={handleSaveThreeDModelAliasConfig}>
<Save className="mr-2 h-4 w-4" /> 保存模型显示映射
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">

View File

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

View File

@ -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<string, unknown>, 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<string, unknown>,
source: Record<string, unknown>,
@ -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<string, unknown> = {
type: taskType,
@ -296,7 +304,6 @@ export async function POST(request: Request) {
}
assignMapped(payload, body as Record<string, unknown>, {
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";
}

View File

@ -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<string, unknown> | null {
: null;
}
function normalizeAssetPayloadModelAlias(
payload: unknown,
entries: Awaited<
ReturnType<typeof getThreeDModelAliasConfigForRuntime>
>["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();

View File

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

View File

@ -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<typeof getThreeDModelAliasConfigForRuntime>
>["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<WorldLabsOperation>(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({

View File

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

View File

@ -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<GenerationMode>("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<WorldGenerationMode>("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() {
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
{MODEL_VERSION_OPTIONS.map((v) => (
{tripoModelOptions.map((option) => (
<SelectItem
className="focus:bg-zinc-800 focus:text-white"
key={v}
value={v}
key={option.id}
value={option.id}
>
{v}
{option.displayName}
</SelectItem>
))}
</SelectContent>
@ -2826,13 +2863,13 @@ export default function CanvasModel3DPage() {
<SelectValue />
</SelectTrigger>
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
{WORLDLABS_MODEL_OPTIONS.map((item) => (
{worldModelOptions.map((item) => (
<SelectItem
className="focus:bg-zinc-800 focus:text-white"
key={item}
value={item}
key={item.id}
value={item.id}
>
{getWorldModelDisplayLabel(item)}
{item.displayName}
</SelectItem>
))}
</SelectContent>

View File

@ -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)}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{data.modelVersion ? `模型: ${data.modelVersion}` : providerLabel}
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
</p>
</div>
</div>

View File

@ -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) {
</div>
</div>
<p className="text-[11px] text-zinc-400">
{getWorldNodeModelLabel(data.modelVersion)}
{worldModelLabel}
</p>
</div>
</div>

View File

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

View File

@ -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<string, unknown>) : {};
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<string>();
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<string, unknown>)
: {};
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;
}