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; formData?: FormData; headers?: Record; }; 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 { 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( config: TripoProviderConfig, options: TripoRequestOptions ): Promise<{ data: T; traceId: string | null; raw: TripoApiEnvelope | 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) ? (parsed as TripoApiEnvelope) : 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 | 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, ]; for (const item of candidates) { if (typeof item === "string") { const value = item.trim(); if (value) return value; continue; } if (item && typeof item === "object") { const nested = typeof (item as Record).url === "string" ? String((item as Record).url).trim() : ""; if (nested) return nested; } } return null; } export function pickTaskPreviewImageUrl(output?: Record | 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; }