feat: add configurable 3d model display aliases
This commit is contained in:
parent
1a71072497
commit
27d44b3b77
@ -68,6 +68,12 @@ import {
|
|||||||
normalizeWorldLabsKeyPoolConfig,
|
normalizeWorldLabsKeyPoolConfig,
|
||||||
WORLDLABS_KEY_POOL_CONFIG_KEY,
|
WORLDLABS_KEY_POOL_CONFIG_KEY,
|
||||||
} from "@/lib/worldlabs/key-pool-config-shared";
|
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
|
||||||
@ -844,6 +850,13 @@ export function AdminSettingsClient() {
|
|||||||
`/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`,
|
`/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`,
|
||||||
fetcher
|
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 } =
|
const { data: enhanceProConfigData, mutate: mutateEnhanceProConfig } =
|
||||||
useSWR<{ setting?: { value?: unknown } }>(
|
useSWR<{ setting?: { value?: unknown } }>(
|
||||||
`/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`,
|
`/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`,
|
||||||
@ -1374,6 +1387,9 @@ export function AdminSettingsClient() {
|
|||||||
const [worldlabsKeyPoolConfig, setWorldlabsKeyPoolConfig] = useState(
|
const [worldlabsKeyPoolConfig, setWorldlabsKeyPoolConfig] = useState(
|
||||||
defaultWorldLabsKeyPoolConfig
|
defaultWorldLabsKeyPoolConfig
|
||||||
);
|
);
|
||||||
|
const [threeDModelAliasConfig, setThreeDModelAliasConfig] = useState(
|
||||||
|
defaultThreeDModelAliasConfig()
|
||||||
|
);
|
||||||
const [worldlabsProviderSearch, setWorldlabsProviderSearch] = useState("");
|
const [worldlabsProviderSearch, setWorldlabsProviderSearch] = useState("");
|
||||||
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@ -1895,6 +1911,12 @@ export function AdminSettingsClient() {
|
|||||||
);
|
);
|
||||||
}, [worldlabsKeyPoolConfigData]);
|
}, [worldlabsKeyPoolConfigData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setThreeDModelAliasConfig(
|
||||||
|
normalizeThreeDModelAliasConfig(threeDModelAliasConfigData?.setting?.value)
|
||||||
|
);
|
||||||
|
}, [threeDModelAliasConfigData]);
|
||||||
|
|
||||||
const worldlabsProviders = useMemo(
|
const worldlabsProviders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(providersData?.providers || []).filter(
|
(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) => {
|
const handleEditWorldlabsPoolProvider = (provider: AiProvider) => {
|
||||||
setProviderForm({
|
setProviderForm({
|
||||||
id: provider.id,
|
id: provider.id,
|
||||||
@ -12872,6 +13023,303 @@ export function AdminSettingsClient() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
|
<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">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@ -34,6 +34,10 @@ import {
|
|||||||
normalizeWorldLabsKeyPoolConfig,
|
normalizeWorldLabsKeyPoolConfig,
|
||||||
WORLDLABS_KEY_POOL_CONFIG_KEY,
|
WORLDLABS_KEY_POOL_CONFIG_KEY,
|
||||||
} from "@/lib/worldlabs/key-pool-config-shared";
|
} 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({
|
const settingSchema = z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
@ -260,6 +264,10 @@ export async function POST(request: Request) {
|
|||||||
nextValue = normalizeWorldLabsKeyPoolConfig(value);
|
nextValue = normalizeWorldLabsKeyPoolConfig(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === THREED_MODEL_ALIAS_CONFIG_SETTING_KEY) {
|
||||||
|
nextValue = normalizeThreeDModelAliasConfig(value);
|
||||||
|
}
|
||||||
|
|
||||||
const setting = await upsertSystemSetting({
|
const setting = await upsertSystemSetting({
|
||||||
key,
|
key,
|
||||||
value: nextValue,
|
value: nextValue,
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import {
|
|||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from "@/lib/db/queries";
|
} 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 { getTripoProviderConfig } from "@/lib/tripo/server";
|
||||||
import { TripoApiError, tripoRequest } from "@/lib/tripo/client";
|
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);
|
return Object.prototype.hasOwnProperty.call(data, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalTrimmedText(input: unknown) {
|
||||||
|
const value = String(input || "").trim();
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
|
||||||
function assignMapped(
|
function assignMapped(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
source: Record<string, unknown>,
|
source: Record<string, unknown>,
|
||||||
@ -121,6 +128,7 @@ export async function POST(request: Request) {
|
|||||||
if (!taskType) {
|
if (!taskType) {
|
||||||
return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少 taskType" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
type: taskType,
|
type: taskType,
|
||||||
@ -296,7 +304,6 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assignMapped(payload, body as Record<string, unknown>, {
|
assignMapped(payload, body as Record<string, unknown>, {
|
||||||
modelVersion: "model_version",
|
|
||||||
negativePrompt: "negative_prompt",
|
negativePrompt: "negative_prompt",
|
||||||
faceLimit: "face_limit",
|
faceLimit: "face_limit",
|
||||||
texture: "texture",
|
texture: "texture",
|
||||||
@ -335,6 +342,17 @@ export async function POST(request: Request) {
|
|||||||
animateInPlace: "animate_in_place",
|
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) {
|
if (body.compress === true) {
|
||||||
payload.compress = "geometry";
|
payload.compress = "geometry";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { db } from "@/lib/db";
|
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";
|
import { canvasProject, model3dAsset } from "@/lib/db/schema";
|
||||||
|
|
||||||
type Model3DTaskStatus =
|
type Model3DTaskStatus =
|
||||||
@ -94,6 +96,22 @@ function readJsonObject(input: unknown): Record<string, unknown> | null {
|
|||||||
: 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 {
|
function readProgress(input: unknown): number | null {
|
||||||
if (input === undefined || input === null || input === "") return null;
|
if (input === undefined || input === null || input === "") return null;
|
||||||
const value = Number(input);
|
const value = Number(input);
|
||||||
@ -155,8 +173,27 @@ export async function GET(
|
|||||||
)
|
)
|
||||||
.orderBy(desc(model3dAsset.updatedAt))
|
.orderBy(desc(model3dAsset.updatedAt))
|
||||||
.limit(TASK_LIST_LIMIT);
|
.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) {
|
} catch (error) {
|
||||||
console.error("[Model3DAssets] GET failed:", error);
|
console.error("[Model3DAssets] GET failed:", error);
|
||||||
return NextResponse.json({ success: false, error: "获取 3D 资产失败" }, { status: 500 });
|
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 });
|
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
const status = normalizeTaskStatus(rawAsset.status);
|
const status = normalizeTaskStatus(rawAsset.status);
|
||||||
const mode = normalizeTaskMode(rawAsset.mode);
|
const mode = normalizeTaskMode(rawAsset.mode);
|
||||||
const provider = readProvider(rawAsset.provider);
|
const provider = readProvider(rawAsset.provider);
|
||||||
const prompt = String(rawAsset.prompt || "").trim();
|
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 progress = readProgress(rawAsset.progress);
|
||||||
const modelUrl = readOptionalText(rawAsset.modelUrl);
|
const modelUrl = readOptionalText(rawAsset.modelUrl);
|
||||||
const previewImageUrl = readOptionalText(rawAsset.previewImageUrl);
|
const previewImageUrl = readOptionalText(rawAsset.previewImageUrl);
|
||||||
const error = readOptionalText(rawAsset.error);
|
const error = readOptionalText(rawAsset.error);
|
||||||
const sourceTaskId = readOptionalText(rawAsset.sourceTaskId);
|
const sourceTaskId = readOptionalText(rawAsset.sourceTaskId);
|
||||||
const exportFormat = readOptionalText(rawAsset.exportFormat);
|
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 createdAt = readDate(rawAsset.createdAt);
|
||||||
|
|
||||||
const now = new Date();
|
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 { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
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 { uploadFile } from "@/lib/storage/service";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||||
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
||||||
@ -169,12 +171,18 @@ function extractWorldFromOperation(operation: WorldLabsOperation) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAssetPayload(world: WorldLabsWorld, assets?: WorldLabsWorldAssets | null) {
|
function buildAssetPayload(
|
||||||
|
world: WorldLabsWorld,
|
||||||
|
worldModelAliases: Awaited<
|
||||||
|
ReturnType<typeof getThreeDModelAliasConfigForRuntime>
|
||||||
|
>["worldModels"],
|
||||||
|
assets?: WorldLabsWorldAssets | null,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
worldId: getStringValue(world.world_id),
|
worldId: getStringValue(world.world_id),
|
||||||
worldUrl: getStringValue(world.world_marble_url),
|
worldUrl: getStringValue(world.world_marble_url),
|
||||||
displayName: getStringValue(world.display_name),
|
displayName: getStringValue(world.display_name),
|
||||||
model: getStringValue(world.model),
|
model: resolveThreeDModelAliasId(getStringValue(world.model), worldModelAliases),
|
||||||
caption: getStringValue(assets?.caption),
|
caption: getStringValue(assets?.caption),
|
||||||
thumbnailUrl: getStringValue(assets?.thumbnail_url),
|
thumbnailUrl: getStringValue(assets?.thumbnail_url),
|
||||||
panoUrl: getStringValue(assets?.imagery?.pano_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 providerId = new URL(request.url).searchParams.get("providerId");
|
||||||
const config = await getWorldLabsProviderConfig(providerId);
|
const config = await getWorldLabsProviderConfig(providerId);
|
||||||
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
@ -297,7 +306,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
const assetPayload = {
|
const assetPayload = {
|
||||||
providerId: config.providerId || null,
|
providerId: config.providerId || null,
|
||||||
providerName: config.providerName || null,
|
providerName: config.providerName || null,
|
||||||
...buildAssetPayload(world, assets),
|
...buildAssetPayload(world, modelAliasConfig.worldModels, assets),
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import {
|
|||||||
getPointsSystemConfig,
|
getPointsSystemConfig,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from "@/lib/db/queries";
|
} 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 { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
disableWorldLabsProvider,
|
disableWorldLabsProvider,
|
||||||
@ -152,6 +154,7 @@ export async function POST(request: Request) {
|
|||||||
if (!["text", "image", "multi-image", "video"].includes(mode)) {
|
if (!["text", "image", "multi-image", "video"].includes(mode)) {
|
||||||
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
|
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
|
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||||
@ -190,6 +193,10 @@ export async function POST(request: Request) {
|
|||||||
readOptionalTrimmedText(body.displayName) ||
|
readOptionalTrimmedText(body.displayName) ||
|
||||||
readOptionalTrimmedText(body.prompt) ||
|
readOptionalTrimmedText(body.prompt) ||
|
||||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||||
|
const resolvedWorldModel = resolveThreeDModelRealId(
|
||||||
|
readOptionalTrimmedText(body.model),
|
||||||
|
modelAliasConfig.worldModels,
|
||||||
|
);
|
||||||
const worldPrompt = buildWorldPrompt(body, mode);
|
const worldPrompt = buildWorldPrompt(body, mode);
|
||||||
|
|
||||||
const candidates = preferredProviderId
|
const candidates = preferredProviderId
|
||||||
@ -212,7 +219,7 @@ export async function POST(request: Request) {
|
|||||||
path: "/marble/v1/worlds:generate",
|
path: "/marble/v1/worlds:generate",
|
||||||
json: {
|
json: {
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
model: readOptionalTrimmedText(body.model) || "marble-1.1",
|
model: resolvedWorldModel || "marble-1.1",
|
||||||
permission: {
|
permission: {
|
||||||
public: body.makePublic === true,
|
public: body.makePublic === true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -91,6 +91,12 @@ import {
|
|||||||
getSortedWorldLabsSplatEntries,
|
getSortedWorldLabsSplatEntries,
|
||||||
pickBestWorldLabsSplatUrl,
|
pickBestWorldLabsSplatUrl,
|
||||||
} from "@/lib/worldlabs/splat-utils";
|
} from "@/lib/worldlabs/splat-utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
|
getDefaultThreeDModelAliasId,
|
||||||
|
resolveThreeDModelDisplayName,
|
||||||
|
type PublicThreeDModelAliasConfig,
|
||||||
|
} from "@/lib/model3d/model-alias-config";
|
||||||
import {
|
import {
|
||||||
canvasService,
|
canvasService,
|
||||||
type Model3DTaskStatus,
|
type Model3DTaskStatus,
|
||||||
@ -175,31 +181,6 @@ type WorldImageSlot = {
|
|||||||
azimuth: number;
|
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"][] = [
|
const ENV_PRESET_OPTIONS: Model3DViewerSettings["envPreset"][] = [
|
||||||
"city",
|
"city",
|
||||||
"sunset",
|
"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() {
|
export default function CanvasModel3DPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const routeParams = useParams();
|
const routeParams = useParams();
|
||||||
@ -570,9 +546,11 @@ export default function CanvasModel3DPage() {
|
|||||||
const [mode, setMode] = useState<GenerationMode>("text");
|
const [mode, setMode] = useState<GenerationMode>("text");
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [negativePrompt, setNegativePrompt] = useState("");
|
const [negativePrompt, setNegativePrompt] = useState("");
|
||||||
const [modelVersion, setModelVersion] = useState<
|
const [modelVersion, setModelVersion] = useState(
|
||||||
(typeof MODEL_VERSION_OPTIONS)[number]
|
getDefaultThreeDModelAliasId(
|
||||||
>("Turbo-v1.0-20250506");
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels,
|
||||||
|
),
|
||||||
|
);
|
||||||
const [faceLimit, setFaceLimit] = useState(120_000);
|
const [faceLimit, setFaceLimit] = useState(120_000);
|
||||||
const [texture, setTexture] = useState(true);
|
const [texture, setTexture] = useState(true);
|
||||||
const [pbr, setPbr] = useState(true);
|
const [pbr, setPbr] = useState(true);
|
||||||
@ -610,9 +588,11 @@ export default function CanvasModel3DPage() {
|
|||||||
const [worldMode, setWorldMode] = useState<WorldGenerationMode>("text");
|
const [worldMode, setWorldMode] = useState<WorldGenerationMode>("text");
|
||||||
const [worldPrompt, setWorldPrompt] = useState("");
|
const [worldPrompt, setWorldPrompt] = useState("");
|
||||||
const [worldDisplayName, setWorldDisplayName] = useState("");
|
const [worldDisplayName, setWorldDisplayName] = useState("");
|
||||||
const [worldModel, setWorldModel] = useState<
|
const [worldModel, setWorldModel] = useState(
|
||||||
(typeof WORLDLABS_MODEL_OPTIONS)[number]
|
getDefaultThreeDModelAliasId(
|
||||||
>("marble-1.1");
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.worldModels,
|
||||||
|
),
|
||||||
|
);
|
||||||
const [worldDisableRecaption, setWorldDisableRecaption] = useState(false);
|
const [worldDisableRecaption, setWorldDisableRecaption] = useState(false);
|
||||||
const [worldIsPano, setWorldIsPano] = useState(false);
|
const [worldIsPano, setWorldIsPano] = useState(false);
|
||||||
const [worldMakePublic, setWorldMakePublic] = useState(false);
|
const [worldMakePublic, setWorldMakePublic] = useState(false);
|
||||||
@ -695,6 +675,10 @@ export default function CanvasModel3DPage() {
|
|||||||
|
|
||||||
// Fetch site settings for Logo
|
// Fetch site settings for Logo
|
||||||
const { data: siteSettingsData } = useSWR("/api/site-settings", fetcher);
|
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<{
|
const { data: membershipSummary } = useSWR<{
|
||||||
summary?: {
|
summary?: {
|
||||||
currentCredits?: number;
|
currentCredits?: number;
|
||||||
@ -724,12 +708,65 @@ export default function CanvasModel3DPage() {
|
|||||||
// Display logo logic (prefer dark mode logo since we are in dark theme)
|
// Display logo logic (prefer dark mode logo since we are in dark theme)
|
||||||
const displayLogo =
|
const displayLogo =
|
||||||
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
|
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
|
// Effect hooks for data loading and cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("CanvasModel3DPage mounted - v3.0 Dark Mode");
|
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(() => {
|
useEffect(() => {
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
@ -2550,13 +2587,13 @@ export default function CanvasModel3DPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||||||
{MODEL_VERSION_OPTIONS.map((v) => (
|
{tripoModelOptions.map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
className="focus:bg-zinc-800 focus:text-white"
|
className="focus:bg-zinc-800 focus:text-white"
|
||||||
key={v}
|
key={option.id}
|
||||||
value={v}
|
value={option.id}
|
||||||
>
|
>
|
||||||
{v}
|
{option.displayName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -2826,13 +2863,13 @@ export default function CanvasModel3DPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
<SelectContent className="border-zinc-800 bg-zinc-950 text-zinc-300">
|
||||||
{WORLDLABS_MODEL_OPTIONS.map((item) => (
|
{worldModelOptions.map((item) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
className="focus:bg-zinc-800 focus:text-white"
|
className="focus:bg-zinc-800 focus:text-white"
|
||||||
key={item}
|
key={item.id}
|
||||||
value={item}
|
value={item.id}
|
||||||
>
|
>
|
||||||
{getWorldModelDisplayLabel(item)}
|
{item.displayName}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Box,
|
Box,
|
||||||
@ -21,10 +21,16 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import useSWR from "swr";
|
||||||
import { toast } from "sonner";
|
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 { useCanvasStore } from "@/store/canvas";
|
||||||
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
import type { CanvasNode, Model3DNodeData } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
|
|
||||||
interface Model3DNodeProps {
|
interface Model3DNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
@ -68,8 +74,23 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
|||||||
const data = node.data as Model3DNodeData;
|
const data = node.data as Model3DNodeData;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
|
const { data: modelAliasConfigResponse } = useSWR<{
|
||||||
|
success?: boolean;
|
||||||
|
data?: PublicThreeDModelAliasConfig;
|
||||||
|
}>("/api/canvas/model-aliases", fetcher);
|
||||||
const providerLabel =
|
const providerLabel =
|
||||||
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
|
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 worldAssetPayload = readWorldAssetPayload(data);
|
||||||
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
|
const worldUrl = readOptionalUrl(worldAssetPayload?.worldUrl);
|
||||||
const worldImageUrl =
|
const worldImageUrl =
|
||||||
@ -178,7 +199,7 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
|||||||
{data.prompt || inferModelFileName(data, node.id)}
|
{data.prompt || inferModelFileName(data, node.id)}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground">
|
<p className="truncate text-[11px] text-muted-foreground">
|
||||||
{data.modelVersion ? `模型: ${data.modelVersion}` : providerLabel}
|
{data.modelVersion ? `模型: ${modelVersionLabel}` : providerLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,10 +24,16 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
|
import type { CanvasNode, World3DHotspot, World3DNodeData, World3DViewMode } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
|
|
||||||
interface World3DNodeProps {
|
interface World3DNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
@ -44,24 +50,6 @@ const VIEW_META: Array<{
|
|||||||
{ id: "mesh", label: "M", icon: Box },
|
{ 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) {
|
function getWorldNodeViewLabel(view: World3DViewMode) {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "splat":
|
case "splat":
|
||||||
@ -134,9 +122,24 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
|
|||||||
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
|
const [interactionMode, setInteractionMode] = useState<"navigate" | "hotspot">("navigate");
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
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 activeView = useMemo(() => resolveActiveView(data), [data]);
|
||||||
const availableViews = useMemo(() => resolveAvailableViews(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 primaryUrl = resolvePrimaryUrl(data, activeView);
|
||||||
const downloadTarget = inferDownloadTarget(data, activeView);
|
const downloadTarget = inferDownloadTarget(data, activeView);
|
||||||
const hotspotCount = data.hotspots?.length || 0;
|
const hotspotCount = data.hotspots?.length || 0;
|
||||||
@ -281,7 +284,7 @@ export function World3DNode({ node, isSelected }: World3DNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-zinc-400">
|
<p className="text-[11px] text-zinc-400">
|
||||||
{getWorldNodeModelLabel(data.modelVersion)}
|
{worldModelLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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