184 lines
5.4 KiB
TypeScript
184 lines
5.4 KiB
TypeScript
import "server-only";
|
||
|
||
import type { WorldLabsProviderConfig } from "./types";
|
||
|
||
type WorldLabsRequestOptions = {
|
||
method?: "GET" | "POST";
|
||
path: string;
|
||
json?: Record<string, unknown>;
|
||
headers?: Record<string, string>;
|
||
timeoutMs?: number;
|
||
};
|
||
|
||
export const DEFAULT_WORLDLABS_BASE_URL = "https://api.vip98k.com";
|
||
export const WORLDLABS_EXTERNAL_TASK_MODEL = "world-generation";
|
||
const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000;
|
||
|
||
function toIntMs(value: string | undefined) {
|
||
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
return null;
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function getDefaultWorldLabsTimeoutMs() {
|
||
return toIntMs(process.env.WORLDLABS_API_TIMEOUT_MS) || DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS;
|
||
}
|
||
|
||
function createRequestSignal(timeoutMs: number) {
|
||
const timeoutSignal = (globalThis as Record<string, unknown>).AbortSignal;
|
||
if (timeoutSignal && typeof (timeoutSignal as typeof AbortSignal).timeout === "function") {
|
||
return {
|
||
signal: (timeoutSignal as typeof AbortSignal).timeout(timeoutMs),
|
||
cancel: () => undefined,
|
||
};
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => {
|
||
controller.abort(`World Labs 请求超时 (${timeoutMs}ms)`);
|
||
}, timeoutMs);
|
||
|
||
return {
|
||
signal: controller.signal,
|
||
cancel: () => clearTimeout(timer),
|
||
};
|
||
}
|
||
|
||
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(/\/+$/, "");
|
||
}
|
||
|
||
export function isWorldLabsExternalTaskApi(baseUrl?: string | null) {
|
||
const normalized = normalizeBaseUrl(baseUrl || "");
|
||
try {
|
||
const parsed = new URL(normalized);
|
||
const hostname = parsed.hostname.toLowerCase();
|
||
return hostname === "api.vip98k.com" || normalized.includes("/api/external/task");
|
||
} catch {
|
||
return normalized.includes("api.vip98k.com") || normalized.includes("/api/external/task");
|
||
}
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
if (isWorldLabsExternalTaskApi(config.baseUrl)) {
|
||
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
|
||
} else {
|
||
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 timeoutMs = options.timeoutMs || getDefaultWorldLabsTimeoutMs();
|
||
const { signal, cancel } = createRequestSignal(timeoutMs);
|
||
|
||
try {
|
||
const response = await fetch(url, {
|
||
method: options.method || "GET",
|
||
headers,
|
||
body,
|
||
cache: "no-store",
|
||
signal,
|
||
});
|
||
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;
|
||
} catch (error) {
|
||
if (error instanceof Error && error.name === "AbortError") {
|
||
throw new WorldLabsApiError({
|
||
message: `World Labs 请求超时(${timeoutMs}ms)`,
|
||
httpStatus: 504,
|
||
});
|
||
}
|
||
throw error;
|
||
} finally {
|
||
cancel();
|
||
}
|
||
}
|