feat: add configurable 3d model display aliases
This commit is contained in:
parent
1a71072497
commit
27d44b3b77
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
23
app/api/canvas/model-aliases/route.ts
Normal file
23
app/api/canvas/model-aliases/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
13
lib/model3d/model-alias-config-server.ts
Normal file
13
lib/model3d/model-alias-config-server.ts
Normal 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);
|
||||
}
|
||||
315
lib/model3d/model-alias-config.ts
Normal file
315
lib/model3d/model-alias-config.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user