feat: add worldlabs key pool import api
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 23:08:04 +08:00
parent df1b1a017e
commit 078a115cfd
9 changed files with 590 additions and 105 deletions

View File

@ -195,7 +195,7 @@ interface RouteParams {
params: Promise<{ taskId: string }>;
}
export async function GET(_request: Request, { params }: RouteParams) {
export async function GET(request: Request, { params }: RouteParams) {
try {
const session = await auth();
if (!session?.user?.id) {
@ -208,7 +208,8 @@ export async function GET(_request: Request, { params }: RouteParams) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
}
const config = await getWorldLabsProviderConfig();
const providerId = new URL(request.url).searchParams.get("providerId");
const config = await getWorldLabsProviderConfig(providerId);
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
@ -224,6 +225,8 @@ export async function GET(_request: Request, { params }: RouteParams) {
progress: null,
output: operation.response ?? null,
assetPayload: {
providerId: config.providerId || null,
providerName: config.providerName || null,
operationMetadata: operation.metadata ?? null,
},
},
@ -291,7 +294,11 @@ export async function GET(_request: Request, { params }: RouteParams) {
fallbackExtension: "spz",
});
const assetPayload = buildAssetPayload(world, assets);
const assetPayload = {
providerId: config.providerId || null,
providerName: config.providerName || null,
...buildAssetPayload(world, assets),
};
return NextResponse.json({
success: true,

View File

@ -6,7 +6,11 @@ import {
getUserById,
} from "@/lib/db/queries";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import {
disableWorldLabsProvider,
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
} from "@/lib/worldlabs/server";
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
@ -16,6 +20,7 @@ type WorldTaskMode = "text" | "image" | "multi-image" | "video";
type CreateWorldTaskRequest = {
mode?: WorldTaskMode;
providerId?: string;
displayName?: string;
prompt?: string;
model?: WorldLabsModel | string;
@ -128,6 +133,13 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
};
}
function isPermanentProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
[401, 402, 403].includes(error.httpStatus)
);
}
export async function POST(request: Request) {
try {
const session = await auth();
@ -173,24 +185,61 @@ export async function POST(request: Request) {
}
}
const config = await getWorldLabsProviderConfig();
const preferredProviderId = readOptionalTrimmedText(body.providerId);
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 candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
let result: WorldLabsOperation | null = null;
let usedProvider = null as Awaited<
ReturnType<typeof getWorldLabsProviderConfig>
> | null;
let lastError: unknown = null;
for (const candidate of candidates) {
try {
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
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,
},
});
usedProvider = candidate;
break;
} catch (error) {
lastError = error;
if (isPermanentProviderError(error)) {
await disableWorldLabsProvider(
candidate.providerId,
error instanceof Error ? error.message : String(error)
);
}
if (preferredProviderId) {
break;
}
}
}
if (!result || !usedProvider) {
throw lastError instanceof Error
? lastError
: new Error("创建 World Labs 世界任务失败");
}
const taskId = String(result.operation_id || "").trim();
if (!taskId) {
@ -223,6 +272,10 @@ export async function POST(request: Request) {
data: {
taskId,
status: result.done ? "success" : "running",
assetPayload: {
providerId: usedProvider.providerId || null,
providerName: usedProvider.providerName || null,
},
raw: result,
},
});

View File

@ -1,7 +1,11 @@
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 {
disableWorldLabsProvider,
getWorldLabsProviderConfig,
getWorldLabsProviderConfigs,
} from "@/lib/worldlabs/server";
import type { WorldLabsPrepareUploadResponse } from "@/lib/worldlabs/types";
export const runtime = "nodejs";
@ -41,6 +45,13 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
}
function isPermanentProviderError(error: unknown) {
return (
error instanceof WorldLabsApiError &&
[401, 402, 403].includes(error.httpStatus)
);
}
export async function POST(request: Request) {
try {
const session = await auth();
@ -84,59 +95,91 @@ export async function POST(request: Request) {
);
}
const config = await getWorldLabsProviderConfig();
const requestedProviderId = String(formData.get("providerId") || "").trim();
const candidates = requestedProviderId
? [await getWorldLabsProviderConfig(requestedProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
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",
},
},
});
let lastError: unknown = null;
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整");
for (const config of candidates) {
try {
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,
providerId: config.providerId || null,
providerName: config.providerName || null,
},
});
} catch (error) {
lastError = error;
if (isPermanentProviderError(error)) {
await disableWorldLabsProvider(config.providerId, (error as Error).message);
}
if (requestedProviderId) {
break;
}
}
}
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,
},
});
throw lastError instanceof Error
? lastError
: new Error("上传 World Labs 媒体失败");
} catch (error) {
if (error instanceof WorldLabsApiError) {
return NextResponse.json(

View File

@ -0,0 +1,237 @@
import { z } from "zod";
import {
createAiProvider,
getAiProvidersByType,
} from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import {
sanitizeWorldLabsApiKey,
sanitizeWorldLabsBaseUrl,
} from "@/lib/worldlabs/server";
export const runtime = "nodejs";
export const maxDuration = 60;
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
const importPayloadSchema = z.object({
keys: z
.array(
z.union([
z.string().min(1),
z.object({
apiKey: z.string().min(1),
name: z.string().min(1).max(128).optional(),
baseUrl: z.string().min(1).optional(),
isEnabled: z.boolean().optional(),
}),
])
)
.max(5000)
.optional(),
apiKeysText: z.string().optional(),
namePrefix: z.string().min(1).max(96).optional(),
baseUrl: z.string().min(1).optional(),
isEnabled: z.boolean().optional(),
});
function readImportToken(request: Request) {
const authHeader = request.headers.get("authorization") || "";
const bearerToken = authHeader.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
const customToken =
request.headers.get("x-worldlabs-import-token")?.trim() || "";
return bearerToken || customToken || "";
}
function getConfiguredImportToken() {
return String(
process.env.WORLDLABS_KEY_POOL_IMPORT_TOKEN ||
process.env.WORLDLABS_IMPORT_TOKEN ||
""
).trim();
}
function normalizeImportItems(payload: z.infer<typeof importPayloadSchema>) {
const items: Array<{
apiKey: string;
name?: string;
baseUrl?: string;
isEnabled?: boolean;
}> = [];
for (const item of payload.keys || []) {
if (typeof item === "string") {
items.push({ apiKey: item });
} else {
items.push(item);
}
}
const lines = String(payload.apiKeysText || "")
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
items.push({ apiKey: line });
}
return items;
}
function buildProviderName(params: {
namePrefix: string;
index: number;
overrideName?: string;
}) {
const customName = String(params.overrideName || "").trim();
if (customName) return customName.slice(0, 128);
const datePart = new Date()
.toISOString()
.replace(/[-:TZ.]/g, "")
.slice(0, 14);
return `${params.namePrefix}-${datePart}-${String(params.index + 1).padStart(4, "0")}`.slice(
0,
128
);
}
function maskKey(value: string) {
if (value.length <= 10) {
return `${value.slice(0, 2)}****`;
}
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
export async function POST(request: Request) {
try {
const configuredToken = getConfiguredImportToken();
if (!configuredToken) {
return Response.json(
{
success: false,
error:
"服务端未配置 WORLDLABS_KEY_POOL_IMPORT_TOKEN,无法使用专用导入接口",
},
{ status: 503 }
);
}
const requestToken = readImportToken(request);
if (!requestToken || requestToken !== configuredToken) {
return Response.json(
{ success: false, error: "导入令牌无效" },
{ status: 401 }
);
}
const rawBody = await request.json().catch(() => ({}));
const payload = importPayloadSchema.parse(rawBody);
const inputItems = normalizeImportItems(payload);
if (inputItems.length === 0) {
return Response.json(
{ success: false, error: "请至少提供一个 World Labs API Key" },
{ status: 400 }
);
}
const existingProviders = await getAiProvidersByType({
type: "worldlabs-3d",
enabledOnly: false,
});
const existingKeySet = new Set(
existingProviders
.map((provider) => sanitizeWorldLabsApiKey(String(provider.apiKey || "")))
.filter(Boolean)
);
const defaultBaseUrl =
sanitizeWorldLabsBaseUrl(payload.baseUrl || "") ||
DEFAULT_WORLDLABS_BASE_URL;
const defaultIsEnabled = payload.isEnabled !== false;
const namePrefix = String(payload.namePrefix || "WorldLabsKey").trim();
const dedupedImports: Array<{
apiKey: string;
name?: string;
baseUrl: string;
isEnabled: boolean;
}> = [];
const requestKeySet = new Set<string>();
const skipped: Array<{ apiKey: string; reason: string }> = [];
for (const item of inputItems) {
const apiKey = sanitizeWorldLabsApiKey(item.apiKey);
if (!apiKey) {
skipped.push({ apiKey: "", reason: "empty_key" });
continue;
}
if (existingKeySet.has(apiKey)) {
skipped.push({ apiKey: maskKey(apiKey), reason: "duplicate_existing" });
continue;
}
if (requestKeySet.has(apiKey)) {
skipped.push({ apiKey: maskKey(apiKey), reason: "duplicate_in_request" });
continue;
}
requestKeySet.add(apiKey);
dedupedImports.push({
apiKey,
name: item.name,
baseUrl:
sanitizeWorldLabsBaseUrl(item.baseUrl || "") || defaultBaseUrl,
isEnabled: item.isEnabled ?? defaultIsEnabled,
});
}
const imported = [];
for (const [index, item] of dedupedImports.entries()) {
const created = await createAiProvider({
name: buildProviderName({
namePrefix,
index,
overrideName: item.name,
}),
type: "worldlabs-3d",
baseUrl: item.baseUrl,
apiKey: item.apiKey,
isEnabled: item.isEnabled,
});
imported.push({
id: created[0]?.id,
name: created[0]?.name,
isEnabled: created[0]?.isEnabled === true,
});
}
return Response.json({
success: true,
data: {
importedCount: imported.length,
skippedCount: skipped.length,
imported,
skipped,
},
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return Response.json(
{ success: false, error: error.flatten() },
{ status: 400 }
);
}
return Response.json(
{
success: false,
error:
error instanceof Error ? error.message : "导入 World Labs key 失败",
},
{ status: 500 }
);
}
}

View File

@ -1027,7 +1027,9 @@ export default function CanvasModel3DPage() {
const isWorldTask = currentTask.provider === "worldlabs-3d";
const result =
isWorldTask
? await worldLabsGenerationService.getTask(currentTask.taskId)
? await worldLabsGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
})
: await model3DGenerationService.getTask(currentTask.taskId);
if (!result.success || !result.data) {
const failMessage = result.error || "任务查询失败";
@ -1246,6 +1248,11 @@ export default function CanvasModel3DPage() {
if (!createResult.success || !createResult.data?.taskId) {
throw new Error(createResult.error || "创建任务失败");
}
const worldCreateResultData =
params.provider === "worldlabs-3d" &&
"assetPayload" in createResult.data
? createResult.data
: null;
const newTask: TaskRecord = {
taskId: createResult.data.taskId,
@ -1257,7 +1264,10 @@ export default function CanvasModel3DPage() {
createdAt: Date.now(),
sourceTaskId: params.sourceTaskId || null,
exportFormat: params.exportFormat || null,
assetPayload: params.assetPayload ?? null,
assetPayload:
params.provider === "worldlabs-3d"
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
: params.assetPayload ?? null,
};
setCurrentTask(newTask);
upsertTaskHistory(newTask);
@ -1492,6 +1502,7 @@ export default function CanvasModel3DPage() {
payload: {
mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId: uploadResult.data.providerId,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
@ -1521,6 +1532,7 @@ export default function CanvasModel3DPage() {
payload: {
mode: "video",
mediaAssetId: uploadResult.data.mediaAssetId,
providerId: uploadResult.data.providerId,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,
model: worldModel,
@ -1535,12 +1547,24 @@ export default function CanvasModel3DPage() {
}
setUploadingImage(true);
const uploads = await Promise.all(
selectedWorldImages.map(async (slot) => ({
slot,
result: await worldLabsGenerationService.uploadMedia(slot.file as File, "image"),
}))
);
let boundWorldProviderId: string | null = null;
const uploads: Array<{
slot: WorldImageSlot;
result: Awaited<ReturnType<typeof worldLabsGenerationService.uploadMedia>>;
}> = [];
for (const slot of selectedWorldImages) {
const result = await worldLabsGenerationService.uploadMedia(
slot.file as File,
"image",
{
providerId: boundWorldProviderId,
}
);
if (result.success && result.data?.providerId && !boundWorldProviderId) {
boundWorldProviderId = result.data.providerId;
}
uploads.push({ slot, result });
}
const mediaAssets = uploads.map(({ slot, result }) => {
if (!result.success || !result.data?.mediaAssetId) {
@ -1556,6 +1580,7 @@ export default function CanvasModel3DPage() {
provider: "worldlabs-3d",
payload: {
mode: "multi-image",
providerId: boundWorldProviderId,
mediaAssets,
prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined,

View File

@ -3357,6 +3357,32 @@ export async function getEnabledAiProviders(): Promise<AiProvider[]> {
}
}
export async function getAiProvidersByType({
type,
enabledOnly = false,
}: {
type: AiProvider["type"];
enabledOnly?: boolean;
}): Promise<AiProvider[]> {
try {
return await db
.select()
.from(aiProvider)
.where(
enabledOnly
? and(eq(aiProvider.type, type), eq(aiProvider.isEnabled, true))
: eq(aiProvider.type, type)
)
.orderBy(asc(aiProvider.createdAt));
} catch (_error) {
console.error("getAiProvidersByType error:", _error);
throw new ChatSDKError(
"bad_request:database",
typeof _error === "object" ? JSON.stringify(_error) : String(_error)
);
}
}
export async function getAiProviderById({
id,
}: {
@ -3386,7 +3412,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" | "worldlabs-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" | "kittycad-cad" | "runway";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@ -3424,7 +3450,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" | "worldlabs-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" | "kittycad-cad" | "runway";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;

View File

@ -1,11 +1,14 @@
import "server-only";
import { getEnabledAiProviders } from "@/lib/db/queries";
import {
getAiProvidersByType,
updateAiProvider,
} from "@/lib/db/queries";
import type { WorldLabsProviderConfig } from "./types";
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
function sanitizeWorldLabsApiKey(value: string): string {
export function sanitizeWorldLabsApiKey(value: string): string {
return String(value || "")
.replace(/[\u2028\u2029]/g, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
@ -13,38 +16,96 @@ function sanitizeWorldLabsApiKey(value: string): string {
.trim();
}
function sanitizeWorldLabsBaseUrl(value: string): string {
export 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");
export async function getWorldLabsProviderConfigs(): Promise<
WorldLabsProviderConfig[]
> {
const providers = await getAiProvidersByType({
type: "worldlabs-3d",
enabledOnly: true,
});
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 normalizedProviders = providers
.map((provider): WorldLabsProviderConfig | null => {
const providerApiKey = sanitizeWorldLabsApiKey(
String(provider.apiKey || "")
);
if (!providerApiKey) return null;
return {
apiKey: providerApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(provider.baseUrl || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
providerId: provider.id,
providerName: provider.name,
};
})
.filter((item): item is WorldLabsProviderConfig => Boolean(item));
if (normalizedProviders.length > 0) {
return normalizedProviders;
}
const envApiKey = sanitizeWorldLabsApiKey(String(process.env.WORLDLABS_API_KEY || ""));
if (!envApiKey) {
throw new Error("未配置 World Labs API,请在系统设置-本地AI新增 World Labs 提供商");
return [];
}
return {
apiKey: envApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(process.env.WORLDLABS_BASE_URL || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
};
return [
{
apiKey: envApiKey,
baseUrl:
sanitizeWorldLabsBaseUrl(String(process.env.WORLDLABS_BASE_URL || "")) ||
DEFAULT_WORLDLABS_BASE_URL,
providerName: "WORLDLABS_API_KEY",
},
];
}
export async function getWorldLabsProviderConfig(
preferredProviderId?: string | null
): Promise<WorldLabsProviderConfig> {
const providers = await getWorldLabsProviderConfigs();
const preferredId = String(preferredProviderId || "").trim();
const worldLabsProvider = preferredId
? providers.find((item) => item.providerId === preferredId) || null
: providers[0] || null;
if (worldLabsProvider) {
return worldLabsProvider;
}
throw new Error("未配置 World Labs API,请在系统设置-本地AI新增 World Labs 提供商");
}
export async function disableWorldLabsProvider(
providerId?: string | null,
reason?: string
) {
const normalizedProviderId = String(providerId || "").trim();
if (!normalizedProviderId) return;
try {
await updateAiProvider({
id: normalizedProviderId,
isEnabled: false,
});
console.warn(
`[WorldLabsPool] Disabled provider ${normalizedProviderId}${
reason ? `: ${reason}` : ""
}`
);
} catch (error) {
console.error("[WorldLabsPool] Failed to disable provider:", {
providerId: normalizedProviderId,
reason,
error: error instanceof Error ? error.message : String(error),
});
}
}

View File

@ -251,6 +251,11 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
// 允许 World Labs key 池导入接口由外部脚本调用(具体鉴权由接口内 token 负责)
if (pathname === "/api/worldlabs/key-pool/import") {
return NextResponse.next();
}
const token = await resolveToken();
const isPublicPage =

View File

@ -825,6 +825,8 @@ export const model3DGenerationService = {
export type WorldLabsTaskMode = 'text' | 'image' | 'multi-image' | 'video';
export type WorldLabsTaskAssetPayload = {
providerId?: string | null;
providerName?: string | null;
worldId?: string | null;
worldUrl?: string | null;
displayName?: string | null;
@ -840,12 +842,24 @@ export type WorldLabsTaskAssetPayload = {
export const worldLabsGenerationService = {
async uploadMedia(
file: File,
kind: 'image' | 'video'
): Promise<ApiResponse<{ mediaAssetId?: string; mediaAsset?: Record<string, any>; uploadInfo?: Record<string, any> }>> {
kind: 'image' | 'video',
options?: { providerId?: string | null }
): Promise<
ApiResponse<{
mediaAssetId?: string;
mediaAsset?: Record<string, any>;
uploadInfo?: Record<string, any>;
providerId?: string | null;
providerName?: string | null;
}>
> {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('kind', kind);
if (options?.providerId) {
formData.append('providerId', options.providerId);
}
const response = await fetch('/api/canvas/world/upload', {
method: 'POST',
body: formData,
@ -857,7 +871,14 @@ export const worldLabsGenerationService = {
}
},
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; raw?: any }>> {
async createTask(params: Record<string, any>): Promise<
ApiResponse<{
taskId: string;
status: Model3DTaskStatus;
raw?: any;
assetPayload?: WorldLabsTaskAssetPayload | null;
}>
> {
try {
const response = await fetch('/api/canvas/world/tasks', {
method: 'POST',
@ -871,7 +892,10 @@ export const worldLabsGenerationService = {
}
},
async getTask(taskId: string): Promise<ApiResponse<{
async getTask(
taskId: string,
options?: { providerId?: string | null }
): Promise<ApiResponse<{
taskId: string;
type: string;
status: Model3DTaskStatus;
@ -887,7 +911,11 @@ export const worldLabsGenerationService = {
errorMessage?: string | null;
}>> {
try {
const response = await fetch(`/api/canvas/world/tasks/${taskId}`);
const url = new URL(`/api/canvas/world/tasks/${taskId}`, window.location.origin);
if (options?.providerId) {
url.searchParams.set('providerId', options.providerId);
}
const response = await fetch(url.toString());
const data = await response.json();
return data;
} catch (error) {