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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user