ai-chatbot/lib/tripo/client.ts
Your Name c1dee85366
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(canvas): refine toolbar labels/style and harden Tripo API key handling
2026-03-05 10:13:19 +08:00

204 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
];
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<string, unknown>).url === "string"
? String((item as Record<string, unknown>).url).trim()
: "";
if (nested) return nested;
}
}
return null;
}
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;
}