215 lines
5.8 KiB
TypeScript
215 lines
5.8 KiB
TypeScript
import "server-only";
|
||
|
||
import type { TripoApiEnvelope, TripoProviderConfig } from "./types";
|
||
|
||
const DEFAULT_TRIPO_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||
|
||
type TripoRequestOptions = {
|
||
method?: "GET" | "POST";
|
||
path: string;
|
||
json?: Record<string, unknown>;
|
||
formData?: FormData;
|
||
headers?: Record<string, string>;
|
||
};
|
||
|
||
export class TripoApiError extends Error {
|
||
code: number | null;
|
||
suggestion: string | null;
|
||
httpStatus: number;
|
||
traceId: string | null;
|
||
payload: unknown;
|
||
|
||
constructor(params: {
|
||
message: string;
|
||
code?: number | null;
|
||
suggestion?: string | null;
|
||
httpStatus: number;
|
||
traceId?: string | null;
|
||
payload?: unknown;
|
||
}) {
|
||
super(params.message);
|
||
this.name = "TripoApiError";
|
||
this.code = params.code ?? null;
|
||
this.suggestion = params.suggestion ?? null;
|
||
this.httpStatus = params.httpStatus;
|
||
this.traceId = params.traceId ?? null;
|
||
this.payload = params.payload;
|
||
}
|
||
}
|
||
|
||
function normalizeBaseUrl(baseUrl?: string) {
|
||
const resolved = String(baseUrl || "").trim() || DEFAULT_TRIPO_BASE_URL;
|
||
return resolved.replace(/\/+$/, "");
|
||
}
|
||
|
||
function sanitizeHeaderText(value: string): string {
|
||
return String(value || "")
|
||
.replace(/[\u2028\u2029]/g, "")
|
||
.replace(/[\u0000-\u001F\u007F]/g, "")
|
||
.trim();
|
||
}
|
||
|
||
function hasNonByteStringChar(value: string): boolean {
|
||
for (let i = 0; i < value.length; i += 1) {
|
||
if (value.charCodeAt(i) > 255) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export async function tripoRequest<T = unknown>(
|
||
config: TripoProviderConfig,
|
||
options: TripoRequestOptions
|
||
): Promise<{ data: T; traceId: string | null; raw: TripoApiEnvelope<T> | unknown }> {
|
||
const method = options.method || "GET";
|
||
const path = options.path.startsWith("/") ? options.path : `/${options.path}`;
|
||
const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
|
||
const headers = new Headers(options.headers || {});
|
||
|
||
const normalizedApiKey = sanitizeHeaderText(String(config.apiKey || "")).replace(/\s+/g, "");
|
||
if (!normalizedApiKey) {
|
||
throw new TripoApiError({
|
||
message: "Tripo3D API Key 为空,请在本地AI设置中重新保存",
|
||
httpStatus: 400,
|
||
});
|
||
}
|
||
if (hasNonByteStringChar(normalizedApiKey)) {
|
||
throw new TripoApiError({
|
||
message:
|
||
"Tripo3D API Key 含非法字符(可能是复制时带入的隐藏换行符),请在本地AI设置中重新粘贴保存",
|
||
httpStatus: 400,
|
||
});
|
||
}
|
||
|
||
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
|
||
headers.set("Accept", "application/json");
|
||
|
||
let body: BodyInit | undefined;
|
||
if (options.formData) {
|
||
body = options.formData;
|
||
} else if (options.json) {
|
||
headers.set("Content-Type", "application/json");
|
||
body = JSON.stringify(options.json);
|
||
}
|
||
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers,
|
||
body,
|
||
});
|
||
const traceId = response.headers.get("x-tripo-trace-id");
|
||
const parsed = await parseResponseBody(response);
|
||
|
||
const envelope =
|
||
parsed && typeof parsed === "object" && "code" in (parsed as Record<string, unknown>)
|
||
? (parsed as TripoApiEnvelope<T>)
|
||
: null;
|
||
|
||
if (!response.ok) {
|
||
const message =
|
||
envelope?.message ||
|
||
(typeof parsed === "string" ? parsed : `Tripo 请求失败(HTTP ${response.status})`);
|
||
throw new TripoApiError({
|
||
message,
|
||
code: envelope?.code ?? null,
|
||
suggestion: envelope?.suggestion ?? null,
|
||
httpStatus: response.status,
|
||
traceId,
|
||
payload: parsed,
|
||
});
|
||
}
|
||
|
||
if (!envelope) {
|
||
throw new TripoApiError({
|
||
message: "Tripo 响应格式异常",
|
||
httpStatus: response.status,
|
||
traceId,
|
||
payload: parsed,
|
||
});
|
||
}
|
||
|
||
if (envelope.code !== 0) {
|
||
throw new TripoApiError({
|
||
message: envelope.message || "Tripo 请求失败",
|
||
code: envelope.code,
|
||
suggestion: envelope.suggestion || null,
|
||
httpStatus: response.status,
|
||
traceId,
|
||
payload: envelope,
|
||
});
|
||
}
|
||
|
||
return {
|
||
data: envelope.data,
|
||
traceId,
|
||
raw: envelope,
|
||
};
|
||
}
|
||
|
||
export function pickTaskModelUrl(output?: Record<string, unknown> | null): string | null {
|
||
if (!output) return null;
|
||
const candidates = [
|
||
output.pbr_model,
|
||
output.model,
|
||
output.base_model,
|
||
output.rigged_model,
|
||
output.animated_model,
|
||
output.low_poly_model,
|
||
output.result_model,
|
||
output.glb,
|
||
output.fbx,
|
||
output.usdz,
|
||
output.obj,
|
||
output.stl,
|
||
];
|
||
const normalizedCandidates: string[] = [];
|
||
for (const item of candidates) {
|
||
if (typeof item === "string") {
|
||
const value = item.trim();
|
||
if (value) normalizedCandidates.push(value);
|
||
continue;
|
||
}
|
||
if (item && typeof item === "object") {
|
||
const nested =
|
||
typeof (item as Record<string, unknown>).url === "string"
|
||
? String((item as Record<string, unknown>).url).trim()
|
||
: "";
|
||
if (nested) normalizedCandidates.push(nested);
|
||
}
|
||
}
|
||
const firstCandidate = normalizedCandidates[0] || null;
|
||
if (!firstCandidate) return null;
|
||
|
||
if (/\.gltf(\?|#|$)/i.test(firstCandidate)) {
|
||
const embeddedCandidate = normalizedCandidates.find((value) =>
|
||
/\.glb(\?|#|$)/i.test(value)
|
||
);
|
||
if (embeddedCandidate) return embeddedCandidate;
|
||
}
|
||
|
||
return firstCandidate;
|
||
}
|
||
|
||
export function pickTaskPreviewImageUrl(output?: Record<string, unknown> | null): string | null {
|
||
if (!output) return null;
|
||
const candidates = [
|
||
output.generated_image,
|
||
output.rendered_image,
|
||
output.preview_image,
|
||
output.thumbnail,
|
||
];
|
||
for (const item of candidates) {
|
||
const value = typeof item === "string" ? item.trim() : "";
|
||
if (value) return value;
|
||
}
|
||
return null;
|
||
}
|