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:
33
apps/agente-ia/src/components/Burbuja.tsx
Normal file
33
apps/agente-ia/src/components/Burbuja.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Mensaje } from '../types'
|
||||
|
||||
function Burbuja({ mensaje }: { mensaje: Mensaje }) {
|
||||
const esUsuario = mensaje.rol === 'user'
|
||||
return (
|
||||
<div className={`flex ${esUsuario ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] ${esUsuario ? 'text-right' : 'text-left'}`}>
|
||||
<div
|
||||
className={`inline-block whitespace-pre-wrap px-4 py-3 text-sm leading-relaxed ${
|
||||
esUsuario ? 'bg-accent text-white' : 'bg-neutral-100 text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{mensaje.contenido}
|
||||
</div>
|
||||
{!esUsuario && mensaje.fuentes.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{mensaje.fuentes.map((f) => (
|
||||
<span
|
||||
key={f.titulo}
|
||||
title={`Relevancia: ${(f.score * 100).toFixed(0)}%`}
|
||||
className="border border-neutral-300 px-2 py-1 text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
{f.titulo}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Burbuja
|
||||
23
apps/agente-ia/src/index.css
Normal file
23
apps/agente-ia/src/index.css
Normal file
@@ -0,0 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-accent: #1B4332;
|
||||
--radius-sm: 0;
|
||||
--radius-md: 0;
|
||||
--radius-lg: 0;
|
||||
--radius-xl: 0;
|
||||
--radius-2xl: 0;
|
||||
--radius-3xl: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-neutral-900 antialiased;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid #1B4332;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
34
apps/agente-ia/src/lib/sesion.test.ts
Normal file
34
apps/agente-ia/src/lib/sesion.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cargarSesion, guardarSesion, limpiarSesion, type StorageLike } from './sesion'
|
||||
|
||||
function storageFalso(): StorageLike & { datos: Map<string, string> } {
|
||||
const datos = new Map<string, string>()
|
||||
return {
|
||||
datos,
|
||||
getItem: (k) => datos.get(k) ?? null,
|
||||
setItem: (k, v) => void datos.set(k, v),
|
||||
removeItem: (k) => void datos.delete(k),
|
||||
}
|
||||
}
|
||||
|
||||
describe('sesion del chat (localStorage)', () => {
|
||||
it('devuelve null cuando no hay sesión guardada o está vacía', () => {
|
||||
const s = storageFalso()
|
||||
expect(cargarSesion(s)).toBeNull()
|
||||
s.setItem('frasa-ai:sessionId', '')
|
||||
expect(cargarSesion(s)).toBeNull()
|
||||
})
|
||||
|
||||
it('guarda y recupera el sessionId', () => {
|
||||
const s = storageFalso()
|
||||
guardarSesion('conv-123', s)
|
||||
expect(cargarSesion(s)).toBe('conv-123')
|
||||
})
|
||||
|
||||
it('limpiarSesion elimina el sessionId (nueva conversación)', () => {
|
||||
const s = storageFalso()
|
||||
guardarSesion('conv-123', s)
|
||||
limpiarSesion(s)
|
||||
expect(cargarSesion(s)).toBeNull()
|
||||
})
|
||||
})
|
||||
22
apps/agente-ia/src/lib/sesion.ts
Normal file
22
apps/agente-ia/src/lib/sesion.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Sesión del chat persistida en localStorage: al recargar, la conversación sigue viva.
|
||||
const CLAVE_SESION = 'frasa-ai:sessionId'
|
||||
|
||||
/** Subconjunto de Storage para poder probar sin DOM */
|
||||
export interface StorageLike {
|
||||
getItem(clave: string): string | null
|
||||
setItem(clave: string, valor: string): void
|
||||
removeItem(clave: string): void
|
||||
}
|
||||
|
||||
export function cargarSesion(storage: StorageLike = localStorage): string | null {
|
||||
const id = storage.getItem(CLAVE_SESION)
|
||||
return id && id.length > 0 ? id : null
|
||||
}
|
||||
|
||||
export function guardarSesion(sessionId: string, storage: StorageLike = localStorage): void {
|
||||
storage.setItem(CLAVE_SESION, sessionId)
|
||||
}
|
||||
|
||||
export function limpiarSesion(storage: StorageLike = localStorage): void {
|
||||
storage.removeItem(CLAVE_SESION)
|
||||
}
|
||||
14
apps/agente-ia/src/main.tsx
Normal file
14
apps/agente-ia/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '@fontsource/inter/300.css'
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
import './index.css'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ChatPage />
|
||||
</StrictMode>,
|
||||
)
|
||||
156
apps/agente-ia/src/pages/ChatPage.tsx
Normal file
156
apps/agente-ia/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Burbuja from '../components/Burbuja'
|
||||
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
|
||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||
import type { Mensaje } from '../types'
|
||||
|
||||
let contadorLocal = 0
|
||||
const idLocal = () => `local-${++contadorLocal}`
|
||||
|
||||
export default function ChatPage() {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
||||
const [entrada, setEntrada] = useState('')
|
||||
const [escribiendo, setEscribiendo] = useState(false)
|
||||
const [sinConexion, setSinConexion] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const finalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
||||
useEffect(() => {
|
||||
const id = cargarSesion()
|
||||
if (!id) return
|
||||
obtenerHistorial(id)
|
||||
.then((historial) => {
|
||||
setSessionId(id)
|
||||
setMensajes(historial)
|
||||
})
|
||||
.catch(() => limpiarSesion()) // sesión inválida o API caído: empezar limpio
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [mensajes, escribiendo])
|
||||
|
||||
async function enviar() {
|
||||
const texto = entrada.trim()
|
||||
if (!texto || escribiendo) return
|
||||
setEntrada('')
|
||||
setError(null)
|
||||
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
|
||||
setEscribiendo(true)
|
||||
try {
|
||||
const r = await enviarMensaje(sessionId, texto)
|
||||
setSessionId(r.sessionId)
|
||||
guardarSesion(r.sessionId)
|
||||
setSinConexion(false)
|
||||
setMensajes((prev) => [
|
||||
...prev,
|
||||
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
||||
])
|
||||
} catch (err) {
|
||||
if (esErrorDeRed(err)) {
|
||||
setSinConexion(true)
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
|
||||
}
|
||||
// el mensaje del usuario se queda visible; puede reintentar
|
||||
} finally {
|
||||
setEscribiendo(false)
|
||||
}
|
||||
}
|
||||
|
||||
function nuevaConversacion() {
|
||||
limpiarSesion()
|
||||
setSessionId(null)
|
||||
setMensajes([])
|
||||
setError(null)
|
||||
setSinConexion(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-white text-neutral-900">
|
||||
<header className="border-b border-neutral-200">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between px-4 py-4">
|
||||
<span className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>{' '}
|
||||
<span className="font-light text-neutral-500">ASISTENTE IA</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuevaConversacion}
|
||||
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Nueva conversación
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-8">
|
||||
{sinConexion && (
|
||||
<p className="mb-6 text-xs text-amber-700">
|
||||
Sin conexión al API del asistente. Revisa que el servicio esté disponible e inténtalo de
|
||||
nuevo.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mb-6 text-xs text-red-700">{error}</p>}
|
||||
|
||||
{mensajes.length === 0 && !escribiendo ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Asistente interno
|
||||
</p>
|
||||
<p className="max-w-md text-2xl font-light text-neutral-900">
|
||||
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de venta
|
||||
o las políticas de Frasa.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{mensajes.map((m) => (
|
||||
<Burbuja key={m.id} mensaje={m} />
|
||||
))}
|
||||
{escribiendo && (
|
||||
<div className="flex justify-start">
|
||||
<p className="bg-neutral-100 px-4 py-3 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Escribiendo…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div ref={finalRef} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-neutral-200">
|
||||
<form
|
||||
className="mx-auto flex w-full max-w-3xl items-stretch gap-4 px-4 py-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void enviar()
|
||||
}}
|
||||
>
|
||||
<label htmlFor="entrada-chat" className="sr-only">
|
||||
Escribe tu mensaje
|
||||
</label>
|
||||
<input
|
||||
id="entrada-chat"
|
||||
type="text"
|
||||
value={entrada}
|
||||
onChange={(e) => setEntrada(e.target.value)}
|
||||
placeholder="Escribe tu pregunta…"
|
||||
autoComplete="off"
|
||||
className="flex-1 border border-neutral-300 px-4 py-3 text-sm placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={escribiendo || entrada.trim().length === 0}
|
||||
className="bg-accent px-6 py-3 text-xs font-bold uppercase tracking-widest text-white transition-opacity duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
Enviar
|
||||
</button>
|
||||
</form>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
apps/agente-ia/src/services/api.ts
Normal file
69
apps/agente-ia/src/services/api.ts
Normal 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 ?? [],
|
||||
}))
|
||||
}
|
||||
23
apps/agente-ia/src/types/index.ts
Normal file
23
apps/agente-ia/src/types/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface Fuente {
|
||||
titulo: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface Mensaje {
|
||||
id: string
|
||||
rol: 'user' | 'assistant'
|
||||
contenido: string
|
||||
fuentes: Fuente[]
|
||||
}
|
||||
|
||||
export interface RespuestaChat {
|
||||
sessionId: string
|
||||
respuesta: string
|
||||
fuentes: Fuente[]
|
||||
}
|
||||
|
||||
export interface ConversacionResumen {
|
||||
id: string
|
||||
createdAt: string
|
||||
ultimoMensaje: { rol: string; contenido: string } | null
|
||||
}
|
||||
Reference in New Issue
Block a user