ai-chatbot/lib/kittycad/client.ts
Your Name 413b5ffac1
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add AI-CAD workspace and KittyCAD integration
2026-04-06 14:46:51 +08:00

135 lines
3.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 { 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;
}