135 lines
3.4 KiB
TypeScript
135 lines
3.4 KiB
TypeScript
import "server-only";
|
||
|
||
import type { KittyCadProviderConfig } from "./server";
|
||
|
||
type KittyCadRequestOptions = {
|
||
method?: "GET" | "POST";
|
||
path: string;
|
||
json?: Record<string, unknown>;
|
||
headers?: Record<string, string>;
|
||
};
|
||
|
||
const DEFAULT_KITTYCAD_BASE_URL = "https://api.zoo.dev";
|
||
const TRAILING_SLASH_RE = /\/+$/;
|
||
|
||
export class KittyCadApiError extends Error {
|
||
httpStatus: number;
|
||
payload: unknown;
|
||
|
||
constructor(params: {
|
||
message: string;
|
||
httpStatus: number;
|
||
payload?: unknown;
|
||
}) {
|
||
super(params.message);
|
||
this.name = "KittyCadApiError";
|
||
this.httpStatus = params.httpStatus;
|
||
this.payload = params.payload;
|
||
}
|
||
}
|
||
|
||
function normalizeBaseUrl(baseUrl?: string) {
|
||
const resolved = String(baseUrl || "").trim() || DEFAULT_KITTYCAD_BASE_URL;
|
||
return resolved.replace(TRAILING_SLASH_RE, "");
|
||
}
|
||
|
||
function sanitizeApiKey(value: string): string {
|
||
let result = "";
|
||
|
||
for (const char of String(value || "")) {
|
||
const code = char.charCodeAt(0);
|
||
if (code === 0x20_28 || code === 0x20_29) {
|
||
continue;
|
||
}
|
||
if ((code >= 0 && code <= 0x1f) || code === 0x7f) {
|
||
continue;
|
||
}
|
||
result += char;
|
||
}
|
||
|
||
return result.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.error === "string" && record.error.trim()) {
|
||
return record.error.trim();
|
||
}
|
||
if (typeof record.message === "string" && record.message.trim()) {
|
||
return record.message.trim();
|
||
}
|
||
if (typeof record.detail === "string" && record.detail.trim()) {
|
||
return record.detail.trim();
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
export async function kittyCadRequest<T = unknown>(
|
||
config: KittyCadProviderConfig,
|
||
options: KittyCadRequestOptions
|
||
): 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 KittyCadApiError({
|
||
message: "KittyCAD API Token 为空,请在本地AI设置中重新保存",
|
||
httpStatus: 400,
|
||
});
|
||
}
|
||
|
||
headers.set("Authorization", `Bearer ${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 response = await fetch(url, {
|
||
method: options.method || "GET",
|
||
headers,
|
||
body,
|
||
cache: "no-store",
|
||
});
|
||
const parsed = await parseResponseBody(response);
|
||
|
||
if (!response.ok) {
|
||
throw new KittyCadApiError({
|
||
message: extractErrorMessage(
|
||
parsed,
|
||
`KittyCAD 请求失败(HTTP ${response.status})`
|
||
),
|
||
httpStatus: response.status,
|
||
payload: parsed,
|
||
});
|
||
}
|
||
|
||
return parsed as T;
|
||
}
|