123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly body: unknown;
|
|
|
|
constructor(status: number, body: unknown) {
|
|
const data = recordSchema.safeParse(body);
|
|
super(data.success && typeof data.data.detail === "string" ? data.data.detail : `Error del servidor (${status}).`);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export async function request<T>(path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(path, init);
|
|
const body: unknown = await response.json().catch(() => null);
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, body);
|
|
}
|
|
return schema.parse(body);
|
|
}
|
|
|
|
export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signal?: AbortSignal) {
|
|
return request(path, schema, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
});
|
|
}
|
|
|
|
export async function optionalRequest<T>(path: string, schema: z.ZodType<T>, signal: AbortSignal) {
|
|
try {
|
|
return await request(path, schema, { signal });
|
|
} catch (error) {
|
|
if (signal.aborted) {
|
|
throw error;
|
|
}
|
|
// Profile and reports are optional in generic deployments.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getConfig(signal: AbortSignal) {
|
|
return request("/api/config", configSchema, { signal });
|
|
}
|
|
export async function getProfile(phone: string, signal: AbortSignal) {
|
|
if (!phone) {
|
|
return null;
|
|
}
|
|
const data = await optionalRequest(
|
|
`/api/user/phone?${new URLSearchParams({ phoneNumber: phone })}`,
|
|
recordSchema,
|
|
signal,
|
|
);
|
|
if (!data) {
|
|
return null;
|
|
}
|
|
const result = recordSchema.safeParse(data.result);
|
|
return result.success ? result.data : data;
|
|
}
|
|
export function getReports(phone: string, signal: AbortSignal) {
|
|
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
|
|
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
|
|
}
|
|
export async function getTools(signal: AbortSignal, refresh = false) {
|
|
const result = await request(
|
|
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
|
toolsEnvelopeSchema,
|
|
{ signal },
|
|
);
|
|
if (!result.success || !result.data) {
|
|
throw new Error("No se pudieron cargar las herramientas.");
|
|
}
|
|
return result.data.tools;
|
|
}
|
|
|
|
export const reasoningSchema = z.object({ answer: z.string().default(""), log_only: z.boolean().default(false) });
|
|
export const searchSchema = z.object({
|
|
answer: z.string().nullish(),
|
|
results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]),
|
|
});
|
|
|
|
/** Teardown must survive pagehide and must not hold up a new session. */
|
|
export function cancelQueue(queueId: string) {
|
|
const path = "/api/queue/end";
|
|
const payload = JSON.stringify({ queueId });
|
|
if (
|
|
typeof navigator.sendBeacon === "function" &&
|
|
navigator.sendBeacon(path, new Blob([payload], { type: "application/json" }))
|
|
) {
|
|
return;
|
|
}
|
|
void fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: payload,
|
|
keepalive: true,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
export function errorMessage(error: unknown) {
|
|
if (error instanceof z.ZodError) {
|
|
return "El servidor devolvió una respuesta inesperada.";
|
|
}
|
|
if (error instanceof DOMException) {
|
|
if (error.name === "NotAllowedError") {
|
|
return "No se pudo acceder al micrófono. Revisa el permiso del navegador y vuelve a intentarlo.";
|
|
}
|
|
if (error.name === "NotFoundError") {
|
|
return "No se encontró un micrófono. Conecta uno y vuelve a intentarlo.";
|
|
}
|
|
if (error.name === "NotReadableError") {
|
|
return "El micrófono está ocupado o no está disponible.";
|
|
}
|
|
}
|
|
return error instanceof Error ? error.message : "No se pudo completar la operación.";
|
|
}
|