diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 7355c1ce7..a1a2906c5 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -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(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, diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index b499585a2..a25aea46d 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -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(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 + > | null; + let lastError: unknown = null; + + for (const candidate of candidates) { + try { + result = await worldLabsRequest(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, }, }); diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts index 81812e531..d2dcc2abc 100644 --- a/app/api/canvas/world/upload/route.ts +++ b/app/api/canvas/world/upload/route.ts @@ -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(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( + 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( diff --git a/app/api/worldlabs/key-pool/import/route.ts b/app/api/worldlabs/key-pool/import/route.ts new file mode 100644 index 000000000..b1e760fd9 --- /dev/null +++ b/app/api/worldlabs/key-pool/import/route.ts @@ -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) { + 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(); + 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 } + ); + } +} diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 4861e68bf..35859b39f 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -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>; + }> = []; + 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, diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 38c78a7d1..ae5d24302 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -3357,6 +3357,32 @@ export async function getEnabledAiProviders(): Promise { } } +export async function getAiProvidersByType({ + type, + enabledOnly = false, +}: { + type: AiProvider["type"]; + enabledOnly?: boolean; +}): Promise { + 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; diff --git a/lib/worldlabs/server.ts b/lib/worldlabs/server.ts index 6475dab70..ebf7a8748 100644 --- a/lib/worldlabs/server.ts +++ b/lib/worldlabs/server.ts @@ -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 { - 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 { + 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), + }); + } } diff --git a/proxy.ts b/proxy.ts index 6eddc003b..4db0f4885 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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 = diff --git a/services/canvas/index.ts b/services/canvas/index.ts index afc88bf7a..9ea112b55 100644 --- a/services/canvas/index.ts +++ b/services/canvas/index.ts @@ -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; uploadInfo?: Record }>> { + kind: 'image' | 'video', + options?: { providerId?: string | null } + ): Promise< + ApiResponse<{ + mediaAssetId?: string; + mediaAsset?: Record; + uploadInfo?: Record; + 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): Promise> { + async createTask(params: Record): 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> { 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) {