feat: improve worldlabs admin controls and splat selection
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 23:42:37 +08:00
parent c4aafe9413
commit b3b49b4fe2
8 changed files with 345 additions and 110 deletions

View File

@ -63,6 +63,11 @@ import {
PRODUCT_PLANNING_AI_MODEL_DEFAULT_MODEL_ID,
type ProductPlanningAiModelConfig,
} from "@/lib/product-planning/model-config";
import {
defaultWorldLabsKeyPoolConfig,
normalizeWorldLabsKeyPoolConfig,
WORLDLABS_KEY_POOL_CONFIG_KEY,
} from "@/lib/worldlabs/key-pool-config-shared";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
@ -828,6 +833,13 @@ export function AdminSettingsClient() {
`/api/admin/settings?key=${PRODUCT_PLANNING_AI_MODEL_CONFIG_KEY}`,
fetcher
);
const {
data: worldlabsKeyPoolConfigData,
mutate: mutateWorldlabsKeyPoolConfig,
} = useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${WORLDLABS_KEY_POOL_CONFIG_KEY}`,
fetcher
);
const { data: enhanceProConfigData, mutate: mutateEnhanceProConfig } =
useSWR<{ setting?: { value?: unknown } }>(
`/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`,
@ -1352,6 +1364,13 @@ export function AdminSettingsClient() {
apiKey: "",
isEnabled: true,
});
const [worldlabsKeyPoolConfig, setWorldlabsKeyPoolConfig] = useState(
defaultWorldLabsKeyPoolConfig
);
const [worldlabsProviderSearch, setWorldlabsProviderSearch] = useState("");
const [worldlabsProviderBusyId, setWorldlabsProviderBusyId] = useState<
string | null
>(null);
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
providerId: "",
modelEntryId: "",
@ -1855,6 +1874,34 @@ export function AdminSettingsClient() {
});
}, [providersData?.providers]);
useEffect(() => {
setWorldlabsKeyPoolConfig(
normalizeWorldLabsKeyPoolConfig(worldlabsKeyPoolConfigData?.setting?.value)
);
}, [worldlabsKeyPoolConfigData]);
const worldlabsProviders = useMemo(
() =>
(providersData?.providers || []).filter(
(provider) => provider.type === "worldlabs-3d"
),
[providersData?.providers]
);
const filteredWorldlabsProviders = useMemo(() => {
const keyword = worldlabsProviderSearch.trim().toLowerCase();
if (!keyword) return worldlabsProviders;
return worldlabsProviders.filter((provider) =>
[provider.name, provider.apiKey, provider.baseUrl]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(keyword))
);
}, [worldlabsProviderSearch, worldlabsProviders]);
const enabledWorldlabsProviderCount = useMemo(
() => worldlabsProviders.filter((provider) => provider.isEnabled).length,
[worldlabsProviders]
);
// 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({
id: "",
@ -4130,12 +4177,74 @@ export function AdminSettingsClient() {
throw new Error("保存失败");
}
const keyPoolResponse = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: WORLDLABS_KEY_POOL_CONFIG_KEY,
value: normalizeWorldLabsKeyPoolConfig(worldlabsKeyPoolConfig),
}),
});
if (!keyPoolResponse.ok) {
const payload = await keyPoolResponse.json().catch(() => ({}));
throw new Error(
String(payload?.message || payload?.error || "保存 World Labs key 池导入令牌失败")
);
}
toast.success("World Labs 配置保存成功");
setWorldlabs3dConfig((prev) => ({ ...prev, apiKey: "" }));
mutateProviders();
mutateWorldlabsKeyPoolConfig();
} catch (error) {
console.error("保存 World Labs 配置失败:", error);
toast.error("保存 World Labs 配置失败");
toast.error(
error instanceof Error ? error.message : "保存 World Labs 配置失败"
);
}
};
const handleEditWorldlabsPoolProvider = (provider: AiProvider) => {
setProviderForm({
id: provider.id,
name: provider.name,
type: provider.type,
baseUrl: provider.baseUrl,
apiKey: "",
isEnabled: provider.isEnabled,
});
setEditingProvider(provider.id);
setShowProviderDialog(true);
};
const handleToggleWorldlabsPoolProvider = async (
provider: AiProvider,
isEnabled: boolean
) => {
setWorldlabsProviderBusyId(provider.id);
try {
const response = await fetch("/api/admin/ai-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: provider.id,
isEnabled,
}),
});
if (!response.ok) {
throw new Error("更新状态失败");
}
toast.success(
`已${isEnabled ? "启用" : "停用"} ${provider.name || "World Labs Key"}`
);
mutateProviders();
} catch (error) {
toast.error("更新 World Labs Key 状态失败");
} finally {
setWorldlabsProviderBusyId(null);
}
};
@ -12461,6 +12570,124 @@ export function AdminSettingsClient() {
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabsKeyPoolImportToken">
Key 池导入令牌
</Label>
<Input
id="worldlabsKeyPoolImportToken"
type="password"
value={worldlabsKeyPoolConfig.importToken}
onChange={(e) =>
setWorldlabsKeyPoolConfig({
importToken: e.target.value,
})
}
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
/>
<div className="text-xs text-muted-foreground">
用于 Python 脚本通过
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/worldlabs/key-pool/import
</code>
批量导入新 Key,保存到数据库系统设置。
</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>
<div className="text-sm font-medium">Key 池明细</div>
<div className="text-xs text-muted-foreground">
当前共 {worldlabsProviders.length} 个 Key,已启用{" "}
{enabledWorldlabsProviderCount} 个。
</div>
</div>
<div className="relative w-full md:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
placeholder="搜索名称 / Key / API 地址"
value={worldlabsProviderSearch}
onChange={(e) => setWorldlabsProviderSearch(e.target.value)}
/>
</div>
</div>
<div className="space-y-3">
{filteredWorldlabsProviders.length ? (
filteredWorldlabsProviders.map((provider) => (
<div
key={provider.id}
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{provider.name}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
provider.isEnabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-zinc-200 text-zinc-600"
)}
>
{provider.isEnabled ? "已启用" : "已禁用"}
</span>
</div>
<div className="font-mono text-xs text-muted-foreground">
Key: {provider.apiKey}
</div>
<div className="text-xs text-muted-foreground">
API: {provider.baseUrl}
</div>
<div className="text-xs text-muted-foreground">
创建时间: {formatBeijingTime(provider.createdAt)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditWorldlabsPoolProvider(provider)}
>
<Edit className="mr-1 h-4 w-4" />
编辑
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={provider.isEnabled}
disabled={worldlabsProviderBusyId === provider.id}
onCheckedChange={(checked) =>
void handleToggleWorldlabsPoolProvider(
provider,
checked
)
}
/>
<span className="text-xs text-muted-foreground">
{provider.isEnabled ? "启用中" : "已停用"}
</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteProvider(provider.id)}
>
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
</div>
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
{worldlabsProviders.length
? "没有匹配到符合筛选条件的 World Labs Key。"
: "当前还没有导入任何 World Labs Key。"}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 World Labs</Label>

View File

@ -30,6 +30,10 @@ import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
} from "@/lib/enhance-pro/config";
import {
normalizeWorldLabsKeyPoolConfig,
WORLDLABS_KEY_POOL_CONFIG_KEY,
} from "@/lib/worldlabs/key-pool-config-shared";
const settingSchema = z.object({
key: z.string(),
@ -252,6 +256,10 @@ export async function POST(request: Request) {
nextValue = normalizedValue;
}
if (key === WORLDLABS_KEY_POOL_CONFIG_KEY) {
nextValue = normalizeWorldLabsKeyPoolConfig(value);
}
const setting = await upsertSystemSetting({
key,
value: nextValue,

View File

@ -13,7 +13,6 @@ import {
Eye,
EyeOff,
Grid3X3,
History,
Home,
ImagePlus,
Images,
@ -88,6 +87,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn, fetcher } from "@/lib/utils";
import {
getSortedWorldLabsSplatEntries,
pickBestWorldLabsSplatUrl,
} from "@/lib/worldlabs/splat-utils";
import {
canvasService,
type Model3DTaskStatus,
@ -1611,9 +1614,7 @@ export default function CanvasModel3DPage() {
const isWorldTask = currentTask.provider === "worldlabs-3d";
const worldAssetPayload = currentTask.assetPayload ?? null;
const primarySplatUrl = isWorldTask
? Object.values(worldAssetPayload?.spzUrls || {}).find(
(value): value is string => typeof value === "string" && value.trim().length > 0
) || null
? pickBestWorldLabsSplatUrl(worldAssetPayload?.spzUrls)
: null;
const worldImageUrl =
worldAssetPayload?.panoUrl ||
@ -2144,9 +2145,8 @@ export default function CanvasModel3DPage() {
currentTask?.provider === "worldlabs-3d" && currentTask?.shareEnabled
? buildWorldShareUrl(currentTask.shareToken)
: null;
const currentSpzEntries = Object.entries(currentWorldAsset?.spzUrls || {});
const currentPrimarySpzUrl =
currentSpzEntries.find(([, url]) => typeof url === "string" && url.trim())?.[1] || null;
const currentSpzEntries = getSortedWorldLabsSplatEntries(currentWorldAsset?.spzUrls);
const currentPrimarySpzUrl = currentSpzEntries[0]?.[1] || null;
const currentWorldImageUrl =
currentWorldAsset?.panoUrl ||
currentWorldAsset?.thumbnailUrl ||
@ -2163,22 +2163,6 @@ export default function CanvasModel3DPage() {
currentPrimarySpzUrl
)
: !currentModelUrl);
const activeStatusLabel = currentTask
? statusLabel(currentTask.status)
: "未开始";
const activePromptSummary =
currentTask?.prompt?.trim() ||
(workspaceMode === "world" ? "尚未创建世界任务" : "尚未创建模型任务");
const activeProviderLabel =
currentTask?.provider === "worldlabs-3d" || workspaceMode === "world"
? "World Labs"
: "Tripo3D";
const activeRelativeTime = currentTask
? formatDistanceToNow(new Date(currentTask.createdAt), {
addSuffix: true,
locale: zhCN,
})
: null;
const availableWorldViewportModes = useMemo(() => {
const modes: WorldViewportMode[] = [];
if (currentPrimarySpzUrl) modes.push("splat");
@ -2201,8 +2185,6 @@ export default function CanvasModel3DPage() {
workspaceMode === "world" ? "3D 世界工作台" : "3D 工作台";
const viewerEmptyStateTitle =
workspaceMode === "world" ? "准备生成 3D 世界" : "准备生成 3D 资产";
const waitingTaskLabel =
workspaceMode === "world" ? "等待创建世界任务" : "等待创建模型任务";
useEffect(() => {
if (workspaceMode !== "world") return;
setWorldViewportMode(preferredWorldViewportMode);
@ -3362,40 +3344,6 @@ export default function CanvasModel3DPage() {
{/* 3. Main Viewer Area */}
<div className="relative flex-1 bg-black">
<div className="absolute left-6 top-6 z-10 max-w-md rounded-2xl border border-white/10 bg-zinc-900/75 p-4 shadow-2xl backdrop-blur-md">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={currentTask?.status === "success" ? "default" : "secondary"}>
{activeStatusLabel}
</Badge>
<Badge
className="border-white/10 bg-white/5 text-zinc-300"
variant="outline"
>
{activeProviderLabel}
</Badge>
{workspaceMode === "world" && (
<Badge
className="border-sky-500/30 bg-sky-500/10 text-sky-300"
variant="outline"
>
3D 世界
</Badge>
)}
</div>
<p className="mt-3 line-clamp-2 text-sm text-white">
{activePromptSummary}
</p>
<div className="mt-2 flex items-center gap-2 text-[11px] text-zinc-500">
<History className="h-3.5 w-3.5" />
<span>{activeRelativeTime || waitingTaskLabel}</span>
{currentWorldId && (
<>
<span className="text-zinc-700">•</span>
<span className="truncate">World {currentWorldId}</span>
</>
)}
</div>
</div>
{workspaceMode === "world" && (
<div className="absolute right-6 top-6 z-10 flex items-center gap-2 rounded-full border border-white/10 bg-zinc-900/75 p-2 shadow-2xl backdrop-blur-md">
{[

View File

@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { WorldSharePage } from "@/components/canvas/world3d/WorldSharePage";
import { db } from "@/lib/db";
import { model3dAsset } from "@/lib/db/schema";
import { pickBestWorldLabsSplatUrl } from "@/lib/worldlabs/splat-utils";
export const dynamic = "force-dynamic";
@ -34,16 +35,6 @@ function readAssetPayload(
: null;
}
function pickPrimarySplatUrl(assetPayload: ReturnType<typeof readAssetPayload>) {
if (!assetPayload?.spzUrls || typeof assetPayload.spzUrls !== "object") return null;
for (const value of Object.values(assetPayload.spzUrls)) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
export default async function SharedWorldPage({
params,
}: {
@ -92,7 +83,7 @@ export default async function SharedWorldPage({
prompt={asset.prompt || null}
semanticsMetadata={assetPayload?.semanticsMetadata || null}
shareUrl={sharePath}
splatUrl={pickPrimarySplatUrl(assetPayload)}
splatUrl={pickBestWorldLabsSplatUrl(assetPayload?.spzUrls)}
title={assetPayload?.displayName || asset.prompt || `World ${asset.taskId.slice(0, 8)}`}
worldUrl={assetPayload?.worldUrl || null}
/>

View File

@ -3,7 +3,6 @@
import {
Box,
Download,
ExternalLink,
Globe,
ImageIcon,
Link2,
@ -147,16 +146,6 @@ export function WorldSharePage({
<Link2 className="mr-2 h-4 w-4" />
{copied ? "已复制链接" : "复制分享链接"}
</Button>
{worldUrl ? (
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"
onClick={() => window.open(worldUrl, "_blank", "noopener,noreferrer")}
variant="outline"
>
<ExternalLink className="mr-2 h-4 w-4" />
打开原始 World
</Button>
) : null}
{meshUrl ? (
<Button
className="border-white/10 bg-white/5 text-zinc-100 hover:bg-white/10"

View File

@ -0,0 +1,29 @@
export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config";
export type WorldLabsKeyPoolConfig = {
importToken: string;
};
export const defaultWorldLabsKeyPoolConfig: WorldLabsKeyPoolConfig = {
importToken: "",
};
function normalizeImportToken(value: unknown) {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
export function normalizeWorldLabsKeyPoolConfig(
value: unknown
): WorldLabsKeyPoolConfig {
if (!value || typeof value !== "object") {
return defaultWorldLabsKeyPoolConfig;
}
const raw = value as Partial<WorldLabsKeyPoolConfig>;
return {
importToken: normalizeImportToken(raw.importToken),
};
}

View File

@ -1,37 +1,20 @@
import "server-only";
import { getSystemSetting } from "@/lib/db/queries";
import {
defaultWorldLabsKeyPoolConfig,
normalizeWorldLabsKeyPoolConfig,
WORLDLABS_KEY_POOL_CONFIG_KEY,
type WorldLabsKeyPoolConfig,
} from "@/lib/worldlabs/key-pool-config-shared";
export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config";
export type WorldLabsKeyPoolConfig = {
importToken: string;
export {
defaultWorldLabsKeyPoolConfig,
normalizeWorldLabsKeyPoolConfig,
WORLDLABS_KEY_POOL_CONFIG_KEY,
type WorldLabsKeyPoolConfig,
};
export const defaultWorldLabsKeyPoolConfig: WorldLabsKeyPoolConfig = {
importToken: "",
};
function normalizeImportToken(value: unknown) {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
export function normalizeWorldLabsKeyPoolConfig(
value: unknown
): WorldLabsKeyPoolConfig {
if (!value || typeof value !== "object") {
return defaultWorldLabsKeyPoolConfig;
}
const raw = value as Partial<WorldLabsKeyPoolConfig>;
return {
importToken: normalizeImportToken(raw.importToken),
};
}
export async function getWorldLabsKeyPoolConfig(): Promise<WorldLabsKeyPoolConfig> {
try {
const setting = await getSystemSetting({

View File

@ -0,0 +1,60 @@
function normalizeSplatLabel(label: string) {
return String(label || "").trim().toLowerCase();
}
function parseResolutionScore(label: string) {
const normalized = normalizeSplatLabel(label);
if (!normalized) return 0;
if (
normalized.includes("full") ||
normalized.includes("origin") ||
normalized.includes("source") ||
normalized.includes("max")
) {
return Number.MAX_SAFE_INTEGER;
}
const match = normalized.match(/(\d+(?:\.\d+)?)\s*([km]?)/);
if (!match) return 0;
const value = Number(match[1]);
if (!Number.isFinite(value)) return 0;
const unit = match[2];
if (unit === "m") return Math.round(value * 1_000_000);
if (unit === "k") return Math.round(value * 1_000);
return Math.round(value);
}
function compareSplatEntries(
a: [string, string],
b: [string, string]
) {
const scoreDelta = parseResolutionScore(b[0]) - parseResolutionScore(a[0]);
if (scoreDelta !== 0) return scoreDelta;
return a[0].localeCompare(b[0], "en");
}
export function getSortedWorldLabsSplatEntries(
spzUrls?: Record<string, string> | null
) {
return Object.entries(spzUrls || {})
.filter(
(entry): entry is [string, string] =>
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
)
.sort(compareSplatEntries);
}
export function pickBestWorldLabsSplatEntry(
spzUrls?: Record<string, string> | null
) {
return getSortedWorldLabsSplatEntries(spzUrls)[0] || null;
}
export function pickBestWorldLabsSplatUrl(
spzUrls?: Record<string, string> | null
) {
return pickBestWorldLabsSplatEntry(spzUrls)?.[1] || null;
}