feat: add world labs 3d workspace mode
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-04 22:06:43 +08:00
parent 9b0f762654
commit d76b31952f
18 changed files with 2678 additions and 377 deletions

View File

@ -188,7 +188,7 @@ type ToolConfig = {
type AiProvider = {
id: string;
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway" | "volcano-multimodal";
baseUrl: string;
apiKey: string;
isEnabled: boolean;
@ -510,6 +510,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "midjourney", label: "Midjourney" },
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
{ value: "runway", label: "Runway API" },
{ value: "zhipu", label: "智普官方 4.7" },
{ value: "volcano-multimodal", label: "火山引擎多模态" },
@ -537,6 +538,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
runway: "https://api.dev.runwayml.com",
zhipu: "https://open.bigmodel.cn/api/paas/v4",
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
@ -1096,6 +1098,13 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
apiKey: "",
isEnabled: true,
});
const [worldlabs3dConfig, setWorldlabs3dConfig] = useState({
providerId: "",
name: "World Labs",
baseUrl: getProviderDefaultBaseUrl("worldlabs-3d"),
apiKey: "",
isEnabled: true,
});
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
providerId: "",
modelEntryId: "",
@ -1463,6 +1472,31 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
});
}, [providersData?.providers]);
useEffect(() => {
const worldlabsProvider = (providersData?.providers || []).find(
(provider) => provider.type === "worldlabs-3d"
);
if (worldlabsProvider) {
setWorldlabs3dConfig({
providerId: worldlabsProvider.id,
name: worldlabsProvider.name || "World Labs",
baseUrl:
worldlabsProvider.baseUrl || getProviderDefaultBaseUrl("worldlabs-3d"),
apiKey: "",
isEnabled: worldlabsProvider.isEnabled,
});
return;
}
setWorldlabs3dConfig({
providerId: "",
name: "World Labs",
baseUrl: getProviderDefaultBaseUrl("worldlabs-3d"),
apiKey: "",
isEnabled: true,
});
}, [providersData?.providers]);
// 导航链接状态
const [navLinkForm, setNavLinkForm] = useState({
id: "",
@ -1736,7 +1770,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
const [providerForm, setProviderForm] = useState({
id: "",
name: "",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway" | "volcano-multimodal",
type: "openai" as "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway" | "volcano-multimodal",
baseUrl: "",
apiKey: "",
isEnabled: true,
@ -3250,6 +3284,58 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
}
};
const handleSaveWorldlabs3dConfig = async () => {
try {
const trimmedName = worldlabs3dConfig.name.trim() || "World Labs";
const trimmedBaseUrl =
worldlabs3dConfig.baseUrl.trim() ||
getProviderDefaultBaseUrl("worldlabs-3d");
const hasProvider = Boolean(worldlabs3dConfig.providerId);
if (!hasProvider && !worldlabs3dConfig.apiKey.trim()) {
toast.error("请填写 World Labs API Key");
return;
}
const requestData: Record<string, any> = hasProvider
? {
id: worldlabs3dConfig.providerId,
name: trimmedName,
type: "worldlabs-3d",
baseUrl: trimmedBaseUrl,
isEnabled: worldlabs3dConfig.isEnabled,
}
: {
name: trimmedName,
type: "worldlabs-3d",
baseUrl: trimmedBaseUrl,
apiKey: worldlabs3dConfig.apiKey.trim(),
isEnabled: worldlabs3dConfig.isEnabled,
};
if (hasProvider && worldlabs3dConfig.apiKey.trim()) {
requestData.apiKey = worldlabs3dConfig.apiKey.trim();
}
const response = await fetch("/api/admin/ai-providers", {
method: hasProvider ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error("保存失败");
}
toast.success("World Labs 配置保存成功");
setWorldlabs3dConfig((prev) => ({ ...prev, apiKey: "" }));
mutateProviders();
} catch (error) {
console.error("保存 World Labs 配置失败:", error);
toast.error("保存 World Labs 配置失败");
}
};
const handleSaveMoonshotDetailAgentConfig = async () => {
try {
const trimmedBaseUrl =
@ -4728,6 +4814,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
"openai-image": "bg-green-600",
"midjourney": "bg-fuchsia-600",
"tripo-3d": "bg-teal-500",
"worldlabs-3d": "bg-sky-600",
"openai": "bg-green-500",
"zhipu": "bg-lime-500",
"volcano-multimodal": "bg-fuchsia-500",
@ -9375,6 +9462,94 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-xl">World Labs API 配置</CardTitle>
<CardDescription>
配置无限画布 3D 世界模式使用的 World Labs 接口,保存后写入数据库 AI 提供商配置。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="space-y-2">
<Label htmlFor="worldlabs3dName">提供商名称</Label>
<Input
id="worldlabs3dName"
value={worldlabs3dConfig.name}
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
name: e.target.value,
})
}
placeholder="World Labs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabs3dBaseUrl">API 地址</Label>
<Input
id="worldlabs3dBaseUrl"
value={worldlabs3dConfig.baseUrl}
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
baseUrl: e.target.value,
})
}
placeholder={getProviderDefaultBaseUrl("worldlabs-3d")}
/>
<div className="text-xs text-muted-foreground">
默认:{getProviderDefaultBaseUrl("worldlabs-3d")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabs3dApiKey">
API Key {worldlabs3dConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="worldlabs3dApiKey"
type="password"
value={worldlabs3dConfig.apiKey}
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
apiKey: e.target.value,
})
}
placeholder={
worldlabs3dConfig.providerId
? "留空则不修改当前 Key"
: "请输入 World Labs API Key"
}
/>
</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>
<div className="text-xs text-muted-foreground">
关闭后 3D 世界模式接口将不可用
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={worldlabs3dConfig.isEnabled}
onCheckedChange={(checked) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
isEnabled: checked,
})
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{worldlabs3dConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Button onClick={handleSaveWorldlabs3dConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 World Labs 配置
</Button>
</CardContent>
</Card>
{/* 抠图API配置 */}
<Card>
<CardHeader>

View File

@ -30,6 +30,7 @@ const providerTypes = [
"fal-video",
"zhipu",
"tripo-3d",
"worldlabs-3d",
"runway",
"volcano-multimodal",
] as const;
@ -62,6 +63,7 @@ const OPENAI_IMAGE_BASE_URL = "https://api.openai.com/v1";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
@ -208,6 +210,8 @@ export async function POST(request: Request) {
? data.baseUrl || MIDJOURNEY_BASE_URL
: data.type === "tripo-3d"
? data.baseUrl || TRIPO_3D_BASE_URL
: data.type === "worldlabs-3d"
? data.baseUrl || WORLDLABS_3D_BASE_URL
: data.type === "runway"
? data.baseUrl || RUNWAY_BASE_URL
: data.baseUrl,

View File

@ -19,6 +19,10 @@ type Model3DTaskMode =
| "image"
| "multiview"
| "batch"
| "world-text"
| "world-image"
| "world-multi-image"
| "world-video"
| "convert"
| "refine"
| "texture"
@ -46,6 +50,10 @@ const TASK_MODE_SET = new Set<Model3DTaskMode>([
"image",
"multiview",
"batch",
"world-text",
"world-image",
"world-multi-image",
"world-video",
"convert",
"refine",
"texture",
@ -75,6 +83,16 @@ function readOptionalText(input: unknown): string | null {
return normalized ? normalized : null;
}
function readProvider(input: unknown): "tripo-3d" | "worldlabs-3d" {
return String(input || "").trim() === "worldlabs-3d" ? "worldlabs-3d" : "tripo-3d";
}
function readJsonObject(input: unknown): Record<string, unknown> | null {
return input && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: null;
}
function readProgress(input: unknown): number | null {
if (input === undefined || input === null || input === "") return null;
const value = Number(input);
@ -165,6 +183,7 @@ export async function POST(
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 progress = readProgress(rawAsset.progress);
@ -173,6 +192,7 @@ export async function POST(
const error = readOptionalText(rawAsset.error);
const sourceTaskId = readOptionalText(rawAsset.sourceTaskId);
const exportFormat = readOptionalText(rawAsset.exportFormat);
const assetPayload = readJsonObject(rawAsset.assetPayload);
const createdAt = readDate(rawAsset.createdAt);
const now = new Date();
@ -182,6 +202,7 @@ export async function POST(
userId: session.user.id,
projectId,
taskId,
provider,
status,
mode,
prompt,
@ -192,12 +213,14 @@ export async function POST(
error,
sourceTaskId,
exportFormat,
assetPayload,
createdAt: createdAt || now,
})
.onConflictDoUpdate({
target: [model3dAsset.projectId, model3dAsset.taskId],
set: {
userId: session.user.id,
provider,
status,
mode,
prompt,
@ -208,6 +231,7 @@ export async function POST(
error,
sourceTaskId,
exportFormat,
assetPayload,
updatedAt: now,
},
})

View File

@ -0,0 +1,300 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type {
WorldLabsOperation,
WorldLabsWorld,
WorldLabsWorldAssets,
} from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 60;
type ArchiveCacheItem = {
sourceUrl: string;
archivedUrl: string;
};
function getArchiveCache() {
const globalState = globalThis as any;
if (!globalState.__worldLabsAssetArchiveCache) {
globalState.__worldLabsAssetArchiveCache = new Map<string, ArchiveCacheItem>();
}
return globalState.__worldLabsAssetArchiveCache as Map<string, ArchiveCacheItem>;
}
function getArchiveInflight() {
const globalState = globalThis as any;
if (!globalState.__worldLabsAssetArchiveInflight) {
globalState.__worldLabsAssetArchiveInflight = new Map<string, Promise<string | null>>();
}
return globalState.__worldLabsAssetArchiveInflight as Map<string, Promise<string | null>>;
}
function resolveRemoteFileExtension(url: string, fallback: string) {
try {
const parsed = new URL(url);
const pathname = parsed.pathname.toLowerCase();
const ext = pathname.split(".").pop() || "";
return ext || fallback;
} catch {
return fallback;
}
}
async function archiveRemoteAssetIfNeeded(params: {
cacheKey: string;
sourceUrl?: string | null;
filenamePrefix: string;
fallbackExtension: string;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
if (!sourceUrl) return null;
const cache = getArchiveCache();
const cached = cache.get(params.cacheKey);
if (cached && cached.sourceUrl === sourceUrl) {
return cached.archivedUrl;
}
const inflight = getArchiveInflight();
const existingTask = inflight.get(params.cacheKey);
if (existingTask) {
const result = await existingTask;
return result || sourceUrl;
}
const archiveTask = (async () => {
try {
const upstream = await fetch(sourceUrl, {
method: "GET",
headers: {
"User-Agent": "AI-Chatbot-WorldLabs-Archiver/1.0",
},
cache: "no-store",
});
if (!upstream.ok) {
throw new Error(`下载 World Labs 资源失败(HTTP ${upstream.status})`);
}
const assetBuffer = await upstream.arrayBuffer();
if (!assetBuffer || assetBuffer.byteLength <= 0) {
throw new Error("World Labs 资源文件为空");
}
const ext = resolveRemoteFileExtension(sourceUrl, params.fallbackExtension);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
});
return uploaded.url;
} catch (error) {
console.error("[WorldLabsArchive] failed", {
cacheKey: params.cacheKey,
sourceUrl,
error: error instanceof Error ? error.message : String(error),
});
return null;
} finally {
inflight.delete(params.cacheKey);
}
})();
inflight.set(params.cacheKey, archiveTask);
const archivedUrl = await archiveTask;
return archivedUrl || sourceUrl;
}
function getStringValue(...values: unknown[]) {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function getObjectValue(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function extractWorldId(operation: WorldLabsOperation) {
const response = getObjectValue(operation.response);
const metadata = getObjectValue(operation.metadata);
return getStringValue(
response?.world_id,
response?.worldId,
metadata?.world_id,
metadata?.worldId
);
}
function extractWorldFromOperation(operation: WorldLabsOperation) {
const response = operation.response;
if (response && typeof response === "object" && "world_marble_url" in (response as Record<string, unknown>)) {
return response as WorldLabsWorld;
}
return null;
}
function buildAssetPayload(world: WorldLabsWorld, assets?: WorldLabsWorldAssets | null) {
return {
worldId: getStringValue(world.world_id),
worldUrl: getStringValue(world.world_marble_url),
displayName: getStringValue(world.display_name),
model: getStringValue(world.model),
caption: getStringValue(assets?.caption),
thumbnailUrl: getStringValue(assets?.thumbnail_url),
panoUrl: getStringValue(assets?.imagery?.pano_url),
meshUrl: getStringValue(assets?.mesh?.collider_mesh_url),
spzUrls:
assets?.splats?.spz_urls && typeof assets.splats.spz_urls === "object"
? assets.splats.spz_urls
: null,
semanticsMetadata:
assets?.splats?.semantics_metadata &&
typeof assets.splats.semantics_metadata === "object"
? assets.splats.semantics_metadata
: null,
};
}
interface RouteParams {
params: Promise<{ taskId: string }>;
}
export async function GET(_request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const { taskId } = await params;
const normalizedTaskId = String(taskId || "").trim();
if (!normalizedTaskId) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
}
const config = await getWorldLabsProviderConfig();
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
});
if (!operation.done) {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: operation.response ?? null,
assetPayload: {
operationMetadata: operation.metadata ?? null,
},
},
});
}
if (operation.error?.message) {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage: operation.error.message,
output: operation.response ?? null,
},
});
}
let world = extractWorldFromOperation(operation);
const worldId = extractWorldId(operation);
if (!world && worldId) {
world = await worldLabsRequest<WorldLabsWorld>(config, {
method: "GET",
path: `/marble/v1/worlds/${encodeURIComponent(worldId)}`,
});
}
if (!world) {
throw new Error("World Labs 任务已完成,但未返回 world 数据");
}
const assets = world.assets || null;
const rawMeshUrl = getStringValue(assets?.mesh?.collider_mesh_url);
const rawThumbnailUrl = getStringValue(assets?.thumbnail_url);
const rawPanoUrl = getStringValue(assets?.imagery?.pano_url);
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const assetPayload = buildAssetPayload(world, assets);
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "success",
progress: 100,
output: operation.response ?? null,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: assetPayload.worldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload: {
...assetPayload,
thumbnailUrl: stableThumbnailUrl || assetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl || assetPayload.meshUrl,
},
},
});
} catch (error) {
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "查询 World Labs 任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,249 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
consumeUserPoints,
getPointsSystemConfig,
getUserById,
} from "@/lib/db/queries";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 60;
type WorldTaskMode = "text" | "image" | "multi-image" | "video";
type CreateWorldTaskRequest = {
mode?: WorldTaskMode;
displayName?: string;
prompt?: string;
model?: WorldLabsModel | string;
disableRecaption?: boolean;
isPano?: boolean;
makePublic?: boolean;
reconstructImages?: boolean;
mediaAssetId?: string;
mediaAssets?: Array<{
mediaAssetId?: string;
azimuth?: number | null;
}>;
};
function readOptionalTrimmedText(input: unknown) {
const value = String(input || "").trim();
return value || null;
}
function shouldCharge3DPoints(mode: WorldTaskMode) {
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt);
if (mode === "text") {
if (!textPrompt) {
throw new Error("请输入世界描述");
}
return {
type: "text",
text_prompt: textPrompt,
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
};
}
if (mode === "image") {
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("图生世界缺少 mediaAssetId");
}
return {
type: "image",
image_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
...(body.isPano !== undefined ? { is_pano: body.isPano } : {}),
};
}
if (mode === "multi-image") {
const mediaAssets = Array.isArray(body.mediaAssets) ? body.mediaAssets : [];
const multiImagePrompt = mediaAssets
.map((item) => {
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
if (!mediaAssetId) return null;
const azimuth =
item?.azimuth === null || item?.azimuth === undefined
? null
: Number.isFinite(Number(item.azimuth))
? Number(item.azimuth)
: null;
return {
...(azimuth === null ? {} : { azimuth }),
content: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
};
})
.filter(Boolean);
if (multiImagePrompt.length < 2) {
throw new Error("多图生成世界至少需要 2 张图片");
}
return {
type: "multi-image",
multi_image_prompt: multiImagePrompt,
...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined
? { disable_recaption: body.disableRecaption }
: {}),
...(body.reconstructImages !== undefined
? { reconstruct_images: body.reconstructImages }
: {}),
};
}
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) {
throw new Error("视频生成世界缺少 mediaAssetId");
}
return {
type: "video",
video_prompt: {
source: "media_asset",
media_asset_id: mediaAssetId,
},
...(textPrompt ? { text_prompt: textPrompt } : {}),
};
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as CreateWorldTaskRequest;
const mode = String(body.mode || "").trim() as WorldTaskMode;
if (!["text", "image", "multi-image", "video"].includes(mode)) {
return NextResponse.json({ success: false, error: "缺少有效的 mode" }, { status: 400 });
}
const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = Number.isFinite(Number(pointsConfig.creditCostPer3D))
? Math.max(0, Math.round(Number(pointsConfig.creditCostPer3D)))
: 0;
if (
shouldChargePoints &&
pointsConfig.enabled &&
unitCost > 0 &&
pointsConfig.blockOnInsufficient !== false
) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
if (currentBalance < unitCost) {
return NextResponse.json(
{
success: false,
error: "积分不足",
code: "insufficient_points",
message: `积分不足,当前余额 ${currentBalance},本次预计消耗 ${unitCost}。`,
balance: currentBalance,
expectedCost: unitCost,
unitCost,
},
{ status: 402 }
);
}
}
}
const config = await getWorldLabsProviderConfig();
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const worldPrompt = buildWorldPrompt(body, mode);
const result = await worldLabsRequest<WorldLabsOperation>(config, {
method: "POST",
path: "/marble/v1/worlds:generate",
json: {
display_name: displayName,
model: readOptionalTrimmedText(body.model) || "marble-1.1",
permission: {
public: body.makePublic === true,
},
world_prompt: worldPrompt,
},
});
const taskId = String(result.operation_id || "").trim();
if (!taskId) {
return NextResponse.json(
{
success: false,
error: "World Labs 返回缺少 operation_id",
raw: result,
},
{ status: 502 }
);
}
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: unitCost,
reason: "model3d_generation",
modelId: "worldlabs-3d",
note: "3D世界生成消耗积分",
});
} catch (error) {
console.error("[Points] Failed to consume World Labs 3D points:", error);
}
}
return NextResponse.json({
success: true,
data: {
taskId,
status: result.done ? "success" : "running",
raw: result,
},
});
} catch (error) {
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "创建 World Labs 世界任务失败",
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,160 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
export const maxDuration = 120;
const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
const ALLOWED_VIDEO_TYPES = new Set([
"video/mp4",
"video/quicktime",
"video/webm",
"video/x-m4v",
]);
const MAX_IMAGE_UPLOAD_SIZE = 20 * 1024 * 1024;
const MAX_VIDEO_UPLOAD_SIZE = 100 * 1024 * 1024;
function guessExtension(file: File) {
const fromName = String(file.name || "")
.split(".")
.pop()
?.trim()
.toLowerCase();
if (fromName) return fromName;
const byType: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
"video/x-m4v": "m4v",
};
return byType[file.type] || "bin";
}
function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const formData = await request.formData();
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: "请上传文件" }, { status: 400 });
}
const requestedKind = String(formData.get("kind") || "").trim();
const isImage = requestedKind
? requestedKind === "image"
: ALLOWED_IMAGE_TYPES.has(file.type);
const isVideo = requestedKind
? requestedKind === "video"
: ALLOWED_VIDEO_TYPES.has(file.type);
if (!isImage && !isVideo) {
return NextResponse.json(
{ success: false, error: "仅支持 PNG/JPEG/WEBP 图片或 MP4/MOV/WEBM 视频" },
{ status: 400 }
);
}
if (file.size <= 0) {
return NextResponse.json({ success: false, error: "文件不能为空" }, { status: 400 });
}
const maxSize = isImage ? MAX_IMAGE_UPLOAD_SIZE : MAX_VIDEO_UPLOAD_SIZE;
if (file.size > maxSize) {
return NextResponse.json(
{
success: false,
error: isImage ? "图片大小不能超过 20MB" : "视频大小不能超过 100MB",
},
{ status: 400 }
);
}
const config = await getWorldLabsProviderConfig();
const extension = guessExtension(file);
const prepareUpload = await worldLabsRequest<WorldLabsPrepareUploadResponse>(config, {
method: "POST",
path: "/marble/v1/media-assets:prepare_upload",
json: {
file_name: file.name || `upload.${extension}`,
extension,
kind: isImage ? "image" : "video",
metadata: {
mime_type: file.type || null,
uploaded_from: "canvas_world_mode",
},
},
});
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
}
const uploadHeaders = new Headers();
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue));
}
const uploadMethod = String(prepareUpload.upload_info?.upload_method || "PUT").toUpperCase();
const uploadResponse = await fetch(uploadUrl, {
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
headers: uploadHeaders,
body: file,
cache: "no-store",
});
if (!uploadResponse.ok) {
const uploadError = await uploadResponse.text().catch(() => "");
throw new Error(
uploadError || `World Labs 文件上传失败(HTTP ${uploadResponse.status})`
);
}
return NextResponse.json({
success: true,
data: {
mediaAssetId,
mediaAsset: prepareUpload.media_asset,
uploadInfo: prepareUpload.upload_info,
},
});
} catch (error) {
if (error instanceof WorldLabsApiError) {
return NextResponse.json(
{
success: false,
error: error.message,
payload: error.payload,
},
{ status: error.httpStatus || 502 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "上传 World Labs 媒体失败",
},
{ status: 500 }
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,8 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
const data = node.data as Model3DNodeData;
const [isHovered, setIsHovered] = useState(false);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const providerLabel =
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
const handleOpen = () => {
if (!data.src) {
@ -133,7 +135,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}` : "Tripo3D 结果"}
{data.modelVersion ? `模型: ${data.modelVersion}` : providerLabel}
</p>
</div>
</div>
@ -181,4 +183,3 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
</div>
);
}

View File

@ -18,6 +18,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
runway: "https://api.dev.runwayml.com",
};

View File

@ -33,6 +33,7 @@ const providerBaseUrlDefaults = [
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
{ type: "fal-video", baseUrl: "https://fal.run" },
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
] as const;
@ -93,7 +94,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'runway'
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'runway'
));
`);
await db.execute(sql`

View File

@ -0,0 +1,5 @@
ALTER TABLE "Model3DAsset"
ADD COLUMN IF NOT EXISTS "provider" varchar(32) NOT NULL DEFAULT 'tripo-3d';
ALTER TABLE "Model3DAsset"
ADD COLUMN IF NOT EXISTS "assetPayload" jsonb;

View File

@ -344,6 +344,13 @@
"when": 1775145600000,
"tag": "0062_add_single_active_session",
"breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1775289600000,
"tag": "0063_add_worldlabs_3d_assets",
"breakpoints": true
}
]
}

View File

@ -3386,7 +3386,7 @@ export async function createAiProvider({
updatedBy,
}: {
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@ -3424,7 +3424,7 @@ export async function updateAiProvider({
}: {
id: string;
name?: string;
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "runway";
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "runway";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;

View File

@ -264,7 +264,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "runway", "bfl-image"] })
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "runway", "bfl-image"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL
@ -767,6 +767,7 @@ export const model3dAsset = pgTable("Model3DAsset", {
.notNull()
.references(() => canvasProject.id, { onDelete: "cascade" }),
taskId: varchar("taskId", { length: 128 }).notNull(),
provider: varchar("provider", { length: 32 }).notNull().default("tripo-3d"),
status: varchar("status", { length: 32 }).notNull().default("queued"),
mode: varchar("mode", { length: 32 }).notNull().default("text"),
prompt: text("prompt").notNull().default(""),
@ -777,6 +778,7 @@ export const model3dAsset = pgTable("Model3DAsset", {
error: text("error"),
sourceTaskId: varchar("sourceTaskId", { length: 128 }),
exportFormat: varchar("exportFormat", { length: 32 }),
assetPayload: jsonb("assetPayload").$type<Record<string, unknown> | null>(),
createdAt: timestamp("createdAt").notNull().defaultNow(),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
});

117
lib/worldlabs/client.ts Normal file
View File

@ -0,0 +1,117 @@
import "server-only";
import type { WorldLabsProviderConfig } from "./types";
type WorldLabsRequestOptions = {
method?: "GET" | "POST";
path: string;
json?: Record<string, unknown>;
headers?: Record<string, string>;
};
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
export class WorldLabsApiError extends Error {
httpStatus: number;
payload: unknown;
constructor(params: {
message: string;
httpStatus: number;
payload?: unknown;
}) {
super(params.message);
this.name = "WorldLabsApiError";
this.httpStatus = params.httpStatus;
this.payload = params.payload;
}
}
function normalizeBaseUrl(baseUrl?: string) {
const resolved = String(baseUrl || "").trim() || DEFAULT_WORLDLABS_BASE_URL;
return resolved.replace(/\/+$/, "");
}
function sanitizeApiKey(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
async function parseResponseBody(response: Response): Promise<unknown> {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json().catch(() => null);
}
const text = await response.text().catch(() => "");
return text || null;
}
function extractErrorMessage(payload: unknown, fallback: string) {
if (!payload) return fallback;
if (typeof payload === "string") return payload || fallback;
if (typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
if (typeof record.message === "string" && record.message.trim()) {
return record.message.trim();
}
if (typeof record.detail === "string" && record.detail.trim()) {
return record.detail.trim();
}
if (Array.isArray(record.detail) && record.detail.length > 0) {
const firstDetail = record.detail[0];
if (firstDetail && typeof firstDetail === "object") {
const msg = (firstDetail as Record<string, unknown>).msg;
if (typeof msg === "string" && msg.trim()) {
return msg.trim();
}
}
}
return fallback;
}
export async function worldLabsRequest<T = unknown>(
config: WorldLabsProviderConfig,
options: WorldLabsRequestOptions
): Promise<T> {
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
const headers = new Headers(options.headers || {});
const normalizedApiKey = sanitizeApiKey(String(config.apiKey || "")).replace(/\s+/g, "");
if (!normalizedApiKey) {
throw new WorldLabsApiError({
message: "World Labs API Key 为空,请在本地AI设置中重新保存",
httpStatus: 400,
});
}
headers.set("WLT-Api-Key", normalizedApiKey);
headers.set("Accept", "application/json");
let body: BodyInit | undefined;
if (options.json) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.json);
}
const response = await fetch(url, {
method: options.method || "GET",
headers,
body,
cache: "no-store",
});
const parsed = await parseResponseBody(response);
if (!response.ok) {
throw new WorldLabsApiError({
message: extractErrorMessage(parsed, `World Labs 请求失败(HTTP ${response.status})`),
httpStatus: response.status,
payload: parsed,
});
}
return parsed as T;
}

50
lib/worldlabs/server.ts Normal file
View File

@ -0,0 +1,50 @@
import "server-only";
import { getEnabledAiProviders } from "@/lib/db/queries";
import type { WorldLabsProviderConfig } from "./types";
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
function sanitizeWorldLabsApiKey(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/\s+/g, "")
.trim();
}
function sanitizeWorldLabsBaseUrl(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.trim();
}
export async function getWorldLabsProviderConfig(): Promise<WorldLabsProviderConfig> {
const providers = await getEnabledAiProviders();
const worldLabsProvider = providers.find((item) => item.type === "worldlabs-3d");
const providerApiKey = sanitizeWorldLabsApiKey(String(worldLabsProvider?.apiKey || ""));
if (worldLabsProvider && providerApiKey) {
return {
apiKey: providerApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(worldLabsProvider?.baseUrl || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
providerId: worldLabsProvider.id,
providerName: worldLabsProvider.name,
};
}
const envApiKey = sanitizeWorldLabsApiKey(String(process.env.WORLDLABS_API_KEY || ""));
if (!envApiKey) {
throw new Error("未配置 World Labs API,请在系统设置-本地AI新增 World Labs 提供商");
}
return {
apiKey: envApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(process.env.WORLDLABS_BASE_URL || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
};
}

77
lib/worldlabs/types.ts Normal file
View File

@ -0,0 +1,77 @@
export type WorldLabsModel =
| "marble-1.0-draft"
| "marble-1.0"
| "marble-1.1"
| "marble-1.1-plus"
| "Marble 0.1-mini"
| "Marble 0.1-plus"
| "Marble 1.1-plus";
export type WorldLabsMediaKind = "image" | "video";
export interface WorldLabsMediaAsset {
media_asset_id?: string;
id?: string;
file_name?: string;
kind?: WorldLabsMediaKind | string;
extension?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface WorldLabsUploadInfo {
upload_url: string;
upload_method: string;
required_headers?: Record<string, string> | null;
curl_example?: string | null;
}
export interface WorldLabsPrepareUploadResponse {
media_asset: WorldLabsMediaAsset;
upload_info: WorldLabsUploadInfo;
}
export interface WorldLabsOperationError {
code?: number | null;
message?: string | null;
}
export interface WorldLabsOperation<T = unknown> {
operation_id?: string;
done?: boolean;
error?: WorldLabsOperationError | null;
metadata?: Record<string, unknown> | null;
response?: T | null;
}
export interface WorldLabsWorldAssets {
caption?: string | null;
thumbnail_url?: string | null;
imagery?: {
pano_url?: string | null;
} | null;
mesh?: {
collider_mesh_url?: string | null;
} | null;
splats?: {
spz_urls?: Record<string, string> | null;
semantics_metadata?: Record<string, unknown> | null;
} | null;
}
export interface WorldLabsWorld {
world_id: string;
display_name: string;
model?: string | null;
world_marble_url: string;
assets?: WorldLabsWorldAssets | null;
permission?: Record<string, unknown> | null;
tags?: string[] | null;
world_prompt?: Record<string, unknown> | null;
}
export interface WorldLabsProviderConfig {
apiKey: string;
baseUrl: string;
providerId?: string;
providerName?: string;
}

View File

@ -579,9 +579,84 @@ export const model3DGenerationService = {
},
};
export type WorldLabsTaskMode = 'text' | 'image' | 'multi-image' | 'video';
export type WorldLabsTaskAssetPayload = {
worldId?: string | null;
worldUrl?: string | null;
displayName?: string | null;
model?: string | null;
caption?: string | null;
thumbnailUrl?: string | null;
panoUrl?: string | null;
meshUrl?: string | null;
spzUrls?: Record<string, string> | null;
semanticsMetadata?: Record<string, any> | null;
};
export const worldLabsGenerationService = {
async uploadMedia(
file: File,
kind: 'image' | 'video'
): Promise<ApiResponse<{ mediaAssetId?: string; mediaAsset?: Record<string, any>; uploadInfo?: Record<string, any> }>> {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('kind', kind);
const response = await fetch('/api/canvas/world/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '上传 World Labs 媒体失败' };
}
},
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; raw?: any }>> {
try {
const response = await fetch('/api/canvas/world/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '创建 World Labs 任务失败' };
}
},
async getTask(taskId: string): Promise<ApiResponse<{
taskId: string;
type: string;
status: Model3DTaskStatus;
progress?: number | null;
output?: Record<string, any>;
modelUrl?: string | null;
previewImageUrl?: string | null;
worldId?: string | null;
worldUrl?: string | null;
panoUrl?: string | null;
caption?: string | null;
assetPayload?: WorldLabsTaskAssetPayload | null;
errorMessage?: string | null;
}>> {
try {
const response = await fetch(`/api/canvas/world/tasks/${taskId}`);
const data = await response.json();
return data;
} catch (error) {
return { success: false, error: '查询 World Labs 任务失败' };
}
},
};
export default {
canvas: canvasService,
image: imageGenerationService,
video: videoGenerationService,
model3d: model3DGenerationService,
world: worldLabsGenerationService,
};