feat: add world labs 3d workspace mode
This commit is contained in:
parent
9b0f762654
commit
d76b31952f
@ -188,7 +188,7 @@ type ToolConfig = {
|
|||||||
type AiProvider = {
|
type AiProvider = {
|
||||||
id: string;
|
id: string;
|
||||||
name: 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;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
@ -510,6 +510,7 @@ const PROVIDER_TYPES = [
|
|||||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||||
{ value: "midjourney", label: "Midjourney" },
|
{ value: "midjourney", label: "Midjourney" },
|
||||||
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
|
||||||
|
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
|
||||||
{ value: "runway", label: "Runway API" },
|
{ value: "runway", label: "Runway API" },
|
||||||
{ value: "zhipu", label: "智普官方 4.7" },
|
{ value: "zhipu", label: "智普官方 4.7" },
|
||||||
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
{ value: "volcano-multimodal", label: "火山引擎多模态" },
|
||||||
@ -537,6 +538,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||||||
midjourney: "https://your-midjourney-api.example.com",
|
midjourney: "https://your-midjourney-api.example.com",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
|
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
zhipu: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
"volcano-multimodal": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
@ -1096,6 +1098,13 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
|||||||
apiKey: "",
|
apiKey: "",
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
});
|
});
|
||||||
|
const [worldlabs3dConfig, setWorldlabs3dConfig] = useState({
|
||||||
|
providerId: "",
|
||||||
|
name: "World Labs",
|
||||||
|
baseUrl: getProviderDefaultBaseUrl("worldlabs-3d"),
|
||||||
|
apiKey: "",
|
||||||
|
isEnabled: true,
|
||||||
|
});
|
||||||
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
const [moonshotDetailAgentConfig, setMoonshotDetailAgentConfig] = useState({
|
||||||
providerId: "",
|
providerId: "",
|
||||||
modelEntryId: "",
|
modelEntryId: "",
|
||||||
@ -1463,6 +1472,31 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
|||||||
});
|
});
|
||||||
}, [providersData?.providers]);
|
}, [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({
|
const [navLinkForm, setNavLinkForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
@ -1736,7 +1770,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
|||||||
const [providerForm, setProviderForm] = useState({
|
const [providerForm, setProviderForm] = useState({
|
||||||
id: "",
|
id: "",
|
||||||
name: "",
|
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: "",
|
baseUrl: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
isEnabled: true,
|
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 () => {
|
const handleSaveMoonshotDetailAgentConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const trimmedBaseUrl =
|
const trimmedBaseUrl =
|
||||||
@ -4728,6 +4814,7 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
|||||||
"openai-image": "bg-green-600",
|
"openai-image": "bg-green-600",
|
||||||
"midjourney": "bg-fuchsia-600",
|
"midjourney": "bg-fuchsia-600",
|
||||||
"tripo-3d": "bg-teal-500",
|
"tripo-3d": "bg-teal-500",
|
||||||
|
"worldlabs-3d": "bg-sky-600",
|
||||||
"openai": "bg-green-500",
|
"openai": "bg-green-500",
|
||||||
"zhipu": "bg-lime-500",
|
"zhipu": "bg-lime-500",
|
||||||
"volcano-multimodal": "bg-fuchsia-500",
|
"volcano-multimodal": "bg-fuchsia-500",
|
||||||
@ -9375,6 +9462,94 @@ const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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配置 */}
|
{/* 抠图API配置 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const providerTypes = [
|
|||||||
"fal-video",
|
"fal-video",
|
||||||
"zhipu",
|
"zhipu",
|
||||||
"tripo-3d",
|
"tripo-3d",
|
||||||
|
"worldlabs-3d",
|
||||||
"runway",
|
"runway",
|
||||||
"volcano-multimodal",
|
"volcano-multimodal",
|
||||||
] as const;
|
] 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 BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
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_BASE_URL = "https://api.dev.runwayml.com";
|
||||||
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
|
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.baseUrl || MIDJOURNEY_BASE_URL
|
||||||
: data.type === "tripo-3d"
|
: data.type === "tripo-3d"
|
||||||
? data.baseUrl || TRIPO_3D_BASE_URL
|
? data.baseUrl || TRIPO_3D_BASE_URL
|
||||||
|
: data.type === "worldlabs-3d"
|
||||||
|
? data.baseUrl || WORLDLABS_3D_BASE_URL
|
||||||
: data.type === "runway"
|
: data.type === "runway"
|
||||||
? data.baseUrl || RUNWAY_BASE_URL
|
? data.baseUrl || RUNWAY_BASE_URL
|
||||||
: data.baseUrl,
|
: data.baseUrl,
|
||||||
|
|||||||
@ -19,6 +19,10 @@ type Model3DTaskMode =
|
|||||||
| "image"
|
| "image"
|
||||||
| "multiview"
|
| "multiview"
|
||||||
| "batch"
|
| "batch"
|
||||||
|
| "world-text"
|
||||||
|
| "world-image"
|
||||||
|
| "world-multi-image"
|
||||||
|
| "world-video"
|
||||||
| "convert"
|
| "convert"
|
||||||
| "refine"
|
| "refine"
|
||||||
| "texture"
|
| "texture"
|
||||||
@ -46,6 +50,10 @@ const TASK_MODE_SET = new Set<Model3DTaskMode>([
|
|||||||
"image",
|
"image",
|
||||||
"multiview",
|
"multiview",
|
||||||
"batch",
|
"batch",
|
||||||
|
"world-text",
|
||||||
|
"world-image",
|
||||||
|
"world-multi-image",
|
||||||
|
"world-video",
|
||||||
"convert",
|
"convert",
|
||||||
"refine",
|
"refine",
|
||||||
"texture",
|
"texture",
|
||||||
@ -75,6 +83,16 @@ function readOptionalText(input: unknown): string | null {
|
|||||||
return normalized ? normalized : 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 {
|
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);
|
||||||
@ -165,6 +183,7 @@ export async function POST(
|
|||||||
|
|
||||||
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 prompt = String(rawAsset.prompt || "").trim();
|
const prompt = String(rawAsset.prompt || "").trim();
|
||||||
const modelVersion = String(rawAsset.modelVersion || "").trim();
|
const modelVersion = String(rawAsset.modelVersion || "").trim();
|
||||||
const progress = readProgress(rawAsset.progress);
|
const progress = readProgress(rawAsset.progress);
|
||||||
@ -173,6 +192,7 @@ export async function POST(
|
|||||||
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 createdAt = readDate(rawAsset.createdAt);
|
const createdAt = readDate(rawAsset.createdAt);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -182,6 +202,7 @@ export async function POST(
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
projectId,
|
projectId,
|
||||||
taskId,
|
taskId,
|
||||||
|
provider,
|
||||||
status,
|
status,
|
||||||
mode,
|
mode,
|
||||||
prompt,
|
prompt,
|
||||||
@ -192,12 +213,14 @@ export async function POST(
|
|||||||
error,
|
error,
|
||||||
sourceTaskId,
|
sourceTaskId,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
|
assetPayload,
|
||||||
createdAt: createdAt || now,
|
createdAt: createdAt || now,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [model3dAsset.projectId, model3dAsset.taskId],
|
target: [model3dAsset.projectId, model3dAsset.taskId],
|
||||||
set: {
|
set: {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
|
provider,
|
||||||
status,
|
status,
|
||||||
mode,
|
mode,
|
||||||
prompt,
|
prompt,
|
||||||
@ -208,6 +231,7 @@ export async function POST(
|
|||||||
error,
|
error,
|
||||||
sourceTaskId,
|
sourceTaskId,
|
||||||
exportFormat,
|
exportFormat,
|
||||||
|
assetPayload,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
300
app/api/canvas/world/tasks/[taskId]/route.ts
Normal file
300
app/api/canvas/world/tasks/[taskId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/api/canvas/world/tasks/route.ts
Normal file
249
app/api/canvas/world/tasks/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/api/canvas/world/upload/route.ts
Normal file
160
app/api/canvas/world/upload/route.ts
Normal 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
@ -57,6 +57,8 @@ 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 providerLabel =
|
||||||
|
data.metadata?.provider === "worldlabs-3d" ? "World Labs 结果" : "Tripo3D 结果";
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
if (!data.src) {
|
if (!data.src) {
|
||||||
@ -133,7 +135,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}` : "Tripo3D 结果"}
|
{data.modelVersion ? `模型: ${data.modelVersion}` : providerLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -181,4 +183,3 @@ export function Model3DNode({ node, isSelected }: Model3DNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
|||||||
midjourney: "https://your-midjourney-api.example.com",
|
midjourney: "https://your-midjourney-api.example.com",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
|
"worldlabs-3d": "https://api.worldlabs.ai",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@ const providerBaseUrlDefaults = [
|
|||||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||||
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
{ 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" },
|
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@ -93,7 +94,7 @@ const runMigrate = async () => {
|
|||||||
ADD CONSTRAINT "AiProvider_type_check"
|
ADD CONSTRAINT "AiProvider_type_check"
|
||||||
CHECK (type IN (
|
CHECK (type IN (
|
||||||
'alibaba', 'openai', 'custom', 'local', 'gemini',
|
'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`
|
await db.execute(sql`
|
||||||
|
|||||||
5
lib/db/migrations/0063_add_worldlabs_3d_assets.sql
Normal file
5
lib/db/migrations/0063_add_worldlabs_3d_assets.sql
Normal 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;
|
||||||
@ -344,6 +344,13 @@
|
|||||||
"when": 1775145600000,
|
"when": 1775145600000,
|
||||||
"tag": "0062_add_single_active_session",
|
"tag": "0062_add_single_active_session",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 49,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775289600000,
|
||||||
|
"tag": "0063_add_worldlabs_3d_assets",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3386,7 +3386,7 @@ export async function createAiProvider({
|
|||||||
updatedBy,
|
updatedBy,
|
||||||
}: {
|
}: {
|
||||||
name: 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;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
@ -3424,7 +3424,7 @@ export async function updateAiProvider({
|
|||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: 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;
|
baseUrl?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
|
|||||||
@ -264,7 +264,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
|
|||||||
export const aiProvider = pgTable("AiProvider", {
|
export const aiProvider = pgTable("AiProvider", {
|
||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
|
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()
|
.notNull()
|
||||||
.default("custom"), // 提供商类型
|
.default("custom"), // 提供商类型
|
||||||
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
baseUrl: text("baseUrl").notNull(), // API 基础 URL
|
||||||
@ -767,6 +767,7 @@ export const model3dAsset = pgTable("Model3DAsset", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
.references(() => canvasProject.id, { onDelete: "cascade" }),
|
||||||
taskId: varchar("taskId", { length: 128 }).notNull(),
|
taskId: varchar("taskId", { length: 128 }).notNull(),
|
||||||
|
provider: varchar("provider", { length: 32 }).notNull().default("tripo-3d"),
|
||||||
status: varchar("status", { length: 32 }).notNull().default("queued"),
|
status: varchar("status", { length: 32 }).notNull().default("queued"),
|
||||||
mode: varchar("mode", { length: 32 }).notNull().default("text"),
|
mode: varchar("mode", { length: 32 }).notNull().default("text"),
|
||||||
prompt: text("prompt").notNull().default(""),
|
prompt: text("prompt").notNull().default(""),
|
||||||
@ -777,6 +778,7 @@ export const model3dAsset = pgTable("Model3DAsset", {
|
|||||||
error: text("error"),
|
error: text("error"),
|
||||||
sourceTaskId: varchar("sourceTaskId", { length: 128 }),
|
sourceTaskId: varchar("sourceTaskId", { length: 128 }),
|
||||||
exportFormat: varchar("exportFormat", { length: 32 }),
|
exportFormat: varchar("exportFormat", { length: 32 }),
|
||||||
|
assetPayload: jsonb("assetPayload").$type<Record<string, unknown> | null>(),
|
||||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
117
lib/worldlabs/client.ts
Normal file
117
lib/worldlabs/client.ts
Normal 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
50
lib/worldlabs/server.ts
Normal 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
77
lib/worldlabs/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 {
|
export default {
|
||||||
canvas: canvasService,
|
canvas: canvasService,
|
||||||
image: imageGenerationService,
|
image: imageGenerationService,
|
||||||
video: videoGenerationService,
|
video: videoGenerationService,
|
||||||
model3d: model3DGenerationService,
|
model3d: model3DGenerationService,
|
||||||
|
world: worldLabsGenerationService,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user