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,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>
)
}