feat: add worldlabs key pool import api
This commit is contained in:
parent
df1b1a017e
commit
078a115cfd
@ -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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
237
app/api/worldlabs/key-pool/import/route.ts
Normal file
237
app/api/worldlabs/key-pool/import/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
5
proxy.ts
5
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 =
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user