Files
vocero-client/s2s-frontend/src/lib/api.ts
T

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.";
}