feat(chat-frasa): acceso total a datos (13 herramientas con escopo por rol), streaming SSE, caché de embeddings, markdown y mantenimiento en Configuraciones
This commit is contained in:
@@ -3,14 +3,16 @@ import Burbuja from '../components/Burbuja'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import toast from "react-hot-toast"
|
||||
import {
|
||||
enviarMensaje,
|
||||
eliminarConversacion,
|
||||
enviarMensajeStream,
|
||||
esErrorDeRed,
|
||||
listarConversaciones,
|
||||
obtenerHistorial,
|
||||
type ConversacionResumen,
|
||||
} from '../services/api'
|
||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
||||
import { preguntasSugeridas } from '../lib/vocabulario'
|
||||
import { preguntasSugeridasPorRol } from '../lib/vocabulario'
|
||||
import { getSesion } from '../services/auth'
|
||||
import type { Mensaje } from '../types'
|
||||
|
||||
let contadorLocal = 0
|
||||
@@ -21,15 +23,19 @@ export default function ChatPage() {
|
||||
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
||||
const [entrada, setEntrada] = useState('')
|
||||
const [escribiendo, setEscribiendo] = useState(false)
|
||||
// true cuando el asistente ya empezó a escribir (antes: consultando datos)
|
||||
const [recibiendoTexto, setRecibiendoTexto] = useState(false)
|
||||
const [sinConexion, setSinConexion] = useState(false)
|
||||
// Preguntas sugeridas del día: rotan por desarrollo (VOCABULARIO.DESARROLLOS)
|
||||
const preguntasSugeridasHoy = useMemo(() => preguntasSugeridas(), [])
|
||||
// Preguntas sugeridas según el rol de la sesión (vendedor vs dirección)
|
||||
const rol = getSesion()?.usuario.rol
|
||||
const preguntasSugeridasHoy = useMemo(() => preguntasSugeridasPorRol(rol), [rol])
|
||||
// Texto del último envío fallido (habilita "Reintentar" junto al input)
|
||||
const [pendiente, setPendiente] = useState<string | null>(null)
|
||||
// Panel de conversaciones previas
|
||||
const [panelAbierto, setPanelAbierto] = useState(false)
|
||||
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
|
||||
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
|
||||
const [busquedaHistorial, setBusquedaHistorial] = useState('')
|
||||
const finalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
||||
@@ -48,28 +54,50 @@ export default function ChatPage() {
|
||||
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [mensajes, escribiendo])
|
||||
|
||||
/** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
|
||||
/** Envía el texto y va pintando la respuesta (streaming); en fallo deja `pendiente` */
|
||||
async function transmitir(texto: string) {
|
||||
setEscribiendo(true)
|
||||
setRecibiendoTexto(false)
|
||||
const idRespuesta = idLocal()
|
||||
try {
|
||||
const r = await enviarMensaje(sessionId, texto)
|
||||
const r = await enviarMensajeStream(sessionId, texto, (delta) => {
|
||||
setRecibiendoTexto(true)
|
||||
setMensajes((prev) => {
|
||||
const existe = prev.some((m) => m.id === idRespuesta)
|
||||
if (!existe) {
|
||||
return [...prev, { id: idRespuesta, rol: 'assistant', contenido: delta, fuentes: [] }]
|
||||
}
|
||||
return prev.map((m) => (m.id === idRespuesta ? { ...m, contenido: m.contenido + delta } : m))
|
||||
})
|
||||
})
|
||||
setSessionId(r.sessionId)
|
||||
guardarSesion(r.sessionId)
|
||||
setSinConexion(false)
|
||||
setPendiente(null)
|
||||
setMensajes((prev) => [
|
||||
...prev,
|
||||
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
||||
])
|
||||
setMensajes((prev) => {
|
||||
const completa: Mensaje = {
|
||||
id: idRespuesta,
|
||||
rol: 'assistant',
|
||||
contenido: r.respuesta,
|
||||
fuentes: r.fuentes,
|
||||
herramientasUsadas: r.herramientasUsadas,
|
||||
}
|
||||
return prev.some((m) => m.id === idRespuesta)
|
||||
? prev.map((m) => (m.id === idRespuesta ? completa : m))
|
||||
: [...prev, completa]
|
||||
})
|
||||
} catch (err) {
|
||||
// Quita la burbuja a medias para no dejar texto incompleto
|
||||
setMensajes((prev) => prev.filter((m) => m.id !== idRespuesta))
|
||||
if (esErrorDeRed(err)) {
|
||||
setSinConexion(true)
|
||||
} else {
|
||||
toast.error('Error al enviar el mensaje')
|
||||
toast.error(err instanceof Error && err.message ? err.message : 'Error al enviar el mensaje')
|
||||
}
|
||||
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
|
||||
} finally {
|
||||
setEscribiendo(false)
|
||||
setRecibiendoTexto(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +150,29 @@ export default function ChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Borra una conversación del historial (propia) */
|
||||
async function borrarConversacion(id: string, e: React.MouseEvent) {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await eliminarConversacion(id)
|
||||
setConversaciones((prev) => (prev ? prev.filter((c) => c.id !== id) : prev))
|
||||
if (id === sessionId) {
|
||||
limpiarSesion()
|
||||
setSessionId(null)
|
||||
setMensajes([])
|
||||
}
|
||||
toast.success('Conversación eliminada')
|
||||
} catch {
|
||||
toast.error('No se pudo eliminar la conversación')
|
||||
}
|
||||
}
|
||||
|
||||
const conversacionesFiltradas = (conversaciones ?? []).filter((c) => {
|
||||
const q = busquedaHistorial.trim().toLowerCase()
|
||||
if (q === '') return true
|
||||
return (c.titulo ?? '').toLowerCase().includes(q) || (c.ultimoMensaje?.contenido ?? '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A]">
|
||||
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
|
||||
@@ -175,8 +226,8 @@ export default function ChatPage() {
|
||||
Asistente interno
|
||||
</p>
|
||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827] dark:text-[#E2E8F0]">
|
||||
Pregunta sobre el catálogo DIAGONAL, las propuestas del cotizador, el proceso de
|
||||
venta o las políticas de Frasa.
|
||||
Pregúntame lo que necesites: clientes, leads, apartados, escrituración, cobranza,
|
||||
inventario, captura de obra, citas o procesos de Frasa.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-2 sm:grid-cols-2">
|
||||
{preguntasSugeridasHoy.map((pregunta) => (
|
||||
@@ -207,7 +258,7 @@ export default function ChatPage() {
|
||||
{escribiendo && (
|
||||
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
|
||||
<p className="rounded-full border border-[#E5E7EB] bg-white px-4 py-2 text-xs font-medium text-[#64748B] dark:text-[#94A3B8] shadow-md">
|
||||
Escribiendo…
|
||||
{recibiendoTexto ? 'Escribiendo…' : 'Consultando datos del sistema…'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -280,34 +331,57 @@ export default function ChatPage() {
|
||||
) : !conversaciones || conversaciones.length === 0 ? (
|
||||
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
|
||||
) : (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{conversaciones.map((c) => (
|
||||
<li key={c.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void abrirConversacion(c.id)}
|
||||
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||
c.id === sessionId
|
||||
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
|
||||
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
|
||||
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-[#94A3B8]">
|
||||
{new Date(c.createdAt).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
value={busquedaHistorial}
|
||||
onChange={(e) => setBusquedaHistorial(e.target.value)}
|
||||
placeholder="Buscar en el historial…"
|
||||
aria-label="Buscar en el historial"
|
||||
className="mt-4 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-xs text-[#111827] dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
|
||||
/>
|
||||
{conversacionesFiltradas.length === 0 ? (
|
||||
<p className="mt-4 text-xs text-[#94A3B8]">Sin resultados para esa búsqueda.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{conversacionesFiltradas.map((c) => (
|
||||
<li key={c.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void abrirConversacion(c.id)}
|
||||
className={`w-full rounded-lg border p-3 pr-9 text-left transition-colors ${
|
||||
c.id === sessionId
|
||||
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
|
||||
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||
{c.titulo || c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-[#94A3B8]">
|
||||
{new Date(c.createdAt).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => void borrarConversacion(c.id, e)}
|
||||
title="Eliminar conversación"
|
||||
aria-label={`Eliminar conversación ${c.titulo ?? ''}`}
|
||||
className="absolute right-2 top-2.5 rounded-md px-1.5 py-0.5 text-xs text-[#94A3B8] transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user