feat: add world labs 3d workspace mode
This commit is contained in:
parent
9b0f762654
commit
d76b31952f
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
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 [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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
};
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
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,
|
||||
"tag": "0062_add_single_active_session",
|
||||
"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,
|
||||
}: {
|
||||
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;
|
||||
|
||||
@ -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
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 {
|
||||
canvas: canvasService,
|
||||
image: imageGenerationService,
|
||||
video: videoGenerationService,
|
||||
model3d: model3DGenerationService,
|
||||
world: worldLabsGenerationService,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user