feat(ai): agente IA interno con RAG sobre NAN + app web en /ai

- API: /api/ai/chat, /conversations, /documents con embeddings qwen3-embedding,
  coseno en SQLite, dedup por hash, historial de conversaciones
- Base de conocimiento semilla (6 docs, 36 chunks): catálogo DIAGONAL,
  propuestas del cotizador, créditos, proceso de venta, FAQ, privacidad
- App agente-ia (Vite/React/Tailwind, StudioBlank) servida en /ai/ vía nginx
- 21 tests nuevos (91/91 verdes)
This commit is contained in:
2026-08-22 03:04:47 +00:00
parent 8407fc24c3
commit 05c44478ec
32 changed files with 5448 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import type { Mensaje, RespuestaChat } from '../types'
// Cliente HTTP del API Gateway de FRASA (Express + Prisma)
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
/**
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
* NO es error de red: el servidor respondió y su respuesta manda.
*/
export class ErrorApi extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApi'
}
}
/**
* fetch solo rechaza (TypeError) cuando falla la red; una respuesta HTTP de
* error resuelve normalmente. El aviso ámbar solo aplica al primer caso.
*/
export function esErrorDeRed(err: unknown): boolean {
return err instanceof TypeError
}
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${ruta}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
let mensaje = `Error ${res.status} al llamar ${ruta}`
try {
const cuerpo = (await res.json()) as { error?: string }
if (typeof cuerpo.error === 'string') mensaje = cuerpo.error
} catch {
// cuerpo no JSON: se queda el mensaje genérico
}
throw new ErrorApi(res.status, mensaje)
}
return (await res.json()) as T
}
export function enviarMensaje(sessionId: string | null, mensaje: string): Promise<RespuestaChat> {
return request<RespuestaChat>('/ai/chat', {
method: 'POST',
body: JSON.stringify({ sessionId, mensaje }),
})
}
interface ConversacionApi {
id: string
mensajes: { id: string; rol: string; contenido: string; fuentes: Mensaje['fuentes'] }[]
}
/** Recupera el historial de una conversación previa (al recargar con sessionId en localStorage) */
export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
const conv = await request<ConversacionApi>(`/ai/conversations/${sessionId}`)
return conv.mensajes.map((m) => ({
id: m.id,
rol: m.rol === 'assistant' ? 'assistant' : 'user',
contenido: m.contenido,
fuentes: m.fuentes ?? [],
}))
}