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:
1475
apps/agente-ia/package-lock.json
generated
1475
apps/agente-ia/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,9 @@
|
||||
"lucide-react": "^1.41.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
||||
@@ -1,26 +1,94 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { Mensaje } from '../types'
|
||||
|
||||
/** Etiquetas legibles de las herramientas de datos que consulta el asistente */
|
||||
const ETIQUETA_HERRAMIENTA: Record<string, string> = {
|
||||
cotizar_unidad: 'cotizó una unidad',
|
||||
buscar_unidad: 'buscó en el inventario',
|
||||
contar_unidades: 'contó el inventario',
|
||||
buscar_contactos: 'buscó contactos',
|
||||
ver_cliente: 'consultó la ficha del cliente',
|
||||
ver_cobranza: 'consultó cobranza',
|
||||
ver_apartados: 'consultó apartados',
|
||||
ver_escrituracion: 'consultó escrituración',
|
||||
ver_captura: 'consultó la captura por departamentos',
|
||||
ver_bitacora: 'consultó la bitácora',
|
||||
ver_calendario: 'consultó el calendario',
|
||||
ver_soporte: 'consultó soporte',
|
||||
estadisticas: 'calculó estadísticas',
|
||||
}
|
||||
|
||||
function Burbuja({ mensaje }: { mensaje: Mensaje }) {
|
||||
const esUsuario = mensaje.rol === 'user'
|
||||
const herramientas = [...new Set(mensaje.herramientasUsadas ?? [])]
|
||||
return (
|
||||
<div className={`flex ${esUsuario ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[80%] ${esUsuario ? 'text-right' : 'text-left'}`}>
|
||||
<div className={`max-w-[85%] ${esUsuario ? 'text-right' : 'text-left'}`}>
|
||||
<div
|
||||
className={`inline-block whitespace-pre-wrap rounded-2xl px-4 py-3 text-sm leading-relaxed ${
|
||||
className={`inline-block rounded-2xl px-4 py-3 text-sm leading-relaxed ${
|
||||
esUsuario
|
||||
? 'rounded-br-md bg-accent text-white shadow-sm'
|
||||
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] dark:text-[#E2E8F0] shadow-sm'
|
||||
? 'whitespace-pre-wrap rounded-br-md bg-accent text-white shadow-sm'
|
||||
: 'rounded-bl-md border border-[#E5E7EB] bg-white text-[#111827] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#E2E8F0] shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{mensaje.contenido}
|
||||
{esUsuario ? (
|
||||
mensaje.contenido
|
||||
) : (
|
||||
<div className="markdown-chat space-y-2">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="leading-relaxed">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc space-y-1 pl-5">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal space-y-1 pl-5">{children}</ol>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
h1: ({ children }) => <p className="text-base font-semibold">{children}</p>,
|
||||
h2: ({ children }) => <p className="text-sm font-semibold">{children}</p>,
|
||||
h3: ({ children }) => <p className="text-sm font-semibold">{children}</p>,
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-[#F1F5F9] px-1 py-0.5 text-[12px] dark:bg-[#0F172A]">{children}</code>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border-b border-[#E5E7EB] px-2 py-1 font-semibold dark:border-[#334155]">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border-b border-[#F1F5F9] px-2 py-1 dark:border-[#1E293B]">{children}</td>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{mensaje.contenido}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!esUsuario && mensaje.fuentes.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
|
||||
{!esUsuario && (herramientas.length > 0 || mensaje.fuentes.length > 0) && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{herramientas.map((h) => (
|
||||
<span
|
||||
key={h}
|
||||
title={`El asistente ${ETIQUETA_HERRAMIENTA[h] ?? h} para responder`}
|
||||
className="rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300"
|
||||
>
|
||||
⚙ {ETIQUETA_HERRAMIENTA[h] ?? h}
|
||||
</span>
|
||||
))}
|
||||
{mensaje.fuentes.map((f) => (
|
||||
<span
|
||||
key={f.titulo}
|
||||
title={`Relevancia: ${(f.score * 100).toFixed(0)}%`}
|
||||
className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700"
|
||||
className="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300"
|
||||
>
|
||||
{f.titulo}
|
||||
</span>
|
||||
|
||||
@@ -31,3 +31,34 @@ export function preguntasSugeridas(fecha: Date = new Date()): string[] {
|
||||
return plantilla(desarrollo)
|
||||
})
|
||||
}
|
||||
|
||||
/** Preguntas sugeridas para vendedores: su día a día con clientes y unidades */
|
||||
const SUGERENCIAS_VENDEDOR = [
|
||||
'¿Qué unidades disponibles tengo en SOLANA?',
|
||||
'¿Cuáles son mis citas de esta semana?',
|
||||
'¿Cómo va la cobranza de mi cliente Ricardo Camacho?',
|
||||
'¿Qué notas hay en la bitácora de Juan Pérez?',
|
||||
'Cotiza la unidad DIAGONAL U-101 con enganche',
|
||||
'¿Cuántos leads nuevos capté este mes?',
|
||||
]
|
||||
|
||||
/** Preguntas sugeridas para administración y dirección: negocio completo */
|
||||
const SUGERENCIAS_DIRECCION = [
|
||||
'¿Cuántos leads nuevos hay esta semana y por vendedor?',
|
||||
'Dame el resumen de cobranza: adeudo, vencidos y cobranza de hoy',
|
||||
'¿Cuántas unidades hay por estatus en cada desarrollo?',
|
||||
'¿Qué clientes están en escrituración y en qué estatus?',
|
||||
'Muéstrame las cancelaciones del último mes con sus motivos',
|
||||
'¿Qué solicitudes de soporte están abiertas?',
|
||||
'Ranking de vendedores por apartados y leads',
|
||||
]
|
||||
|
||||
/** Preguntas sugeridas según el rol de la sesión (vendedor vs dirección/administración) */
|
||||
export function preguntasSugeridasPorRol(rol: string | undefined, fecha: Date = new Date()): string[] {
|
||||
const esDireccion = rol === 'admin' || rol === 'director_ventas' || rol === 'direccion_general' || rol === 'administracion_venta'
|
||||
const base = esDireccion ? SUGERENCIAS_DIRECCION : SUGERENCIAS_VENDEDOR
|
||||
const dia = diaDelAnio(fecha)
|
||||
// Rota el punto de partida para que no salgan siempre las mismas seis
|
||||
const inicio = dia % base.length
|
||||
return [...base.slice(inicio), ...base.slice(0, inicio)].slice(0, 6)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -77,6 +77,8 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
|
||||
export interface ConversacionResumen {
|
||||
id: string
|
||||
createdAt: string
|
||||
/** Primer mensaje del usuario (título de la conversación) */
|
||||
titulo?: string
|
||||
ultimoMensaje: { rol: string; contenido: string } | null
|
||||
}
|
||||
|
||||
@@ -84,3 +86,97 @@ export interface ConversacionResumen {
|
||||
export function listarConversaciones(): Promise<ConversacionResumen[]> {
|
||||
return request<ConversacionResumen[]>('/ai/conversations')
|
||||
}
|
||||
|
||||
/** DELETE /ai/conversations/:id — borra una conversación propia */
|
||||
export function eliminarConversacion(id: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>(`/ai/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export interface ResultadoStream {
|
||||
sessionId: string
|
||||
respuesta: string
|
||||
fuentes: Mensaje['fuentes']
|
||||
herramientasUsadas: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ai/chat/stream — envía el mensaje y va entregando el texto conforme el
|
||||
* proveedor lo escribe (onDelta), devolviendo al final la respuesta completa con
|
||||
* sesión, fuentes y herramientas usadas. Cae al endpoint sin streaming si el
|
||||
* navegador o el servidor no soportan SSE.
|
||||
*/
|
||||
export async function enviarMensajeStream(
|
||||
sessionId: string | null,
|
||||
mensaje: string,
|
||||
onDelta: (textoParcial: string) => void,
|
||||
): Promise<ResultadoStream> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(
|
||||
`${API_URL}/ai/chat/stream`,
|
||||
conAuth({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, mensaje }),
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
// Sin red: se intenta el endpoint normal (deja que el caller reporte el fallo)
|
||||
const simple = await enviarMensaje(sessionId, mensaje)
|
||||
return { ...simple, herramientasUsadas: [] }
|
||||
}
|
||||
manejar401(res)
|
||||
if (!res.ok || !res.body) {
|
||||
const simple = await enviarMensaje(sessionId, mensaje)
|
||||
return { ...simple, herramientasUsadas: [] }
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let respuesta = ''
|
||||
let resultado: ResultadoStream | null = null
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lineas = buffer.split('\n')
|
||||
buffer = lineas.pop() ?? ''
|
||||
for (const linea of lineas) {
|
||||
const limpia = linea.trim()
|
||||
if (!limpia.startsWith('data:')) continue
|
||||
const payload = limpia.slice(5).trim()
|
||||
if (payload === '') continue
|
||||
let evento: {
|
||||
delta?: string
|
||||
done?: boolean
|
||||
sessionId?: string
|
||||
fuentes?: Mensaje['fuentes']
|
||||
herramientasUsadas?: string[]
|
||||
error?: string
|
||||
}
|
||||
try {
|
||||
evento = JSON.parse(payload) as typeof evento
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (evento.error) throw new Error(evento.error)
|
||||
if (typeof evento.delta === 'string') {
|
||||
respuesta += evento.delta
|
||||
onDelta(evento.delta)
|
||||
}
|
||||
if (evento.done === true && evento.sessionId) {
|
||||
resultado = {
|
||||
sessionId: evento.sessionId,
|
||||
respuesta,
|
||||
fuentes: evento.fuentes ?? [],
|
||||
herramientasUsadas: evento.herramientasUsadas ?? [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resultado) throw new Error('El asistente no completó la respuesta')
|
||||
return resultado
|
||||
}
|
||||
|
||||
@@ -8,16 +8,20 @@ export interface Mensaje {
|
||||
rol: 'user' | 'assistant'
|
||||
contenido: string
|
||||
fuentes: Fuente[]
|
||||
/** Herramientas de datos consultadas (solo en respuestas en vivo) */
|
||||
herramientasUsadas?: string[]
|
||||
}
|
||||
|
||||
export interface RespuestaChat {
|
||||
sessionId: string
|
||||
respuesta: string
|
||||
fuentes: Fuente[]
|
||||
herramientasUsadas?: string[]
|
||||
}
|
||||
|
||||
export interface ConversacionResumen {
|
||||
id: string
|
||||
createdAt: string
|
||||
titulo?: string
|
||||
ultimoMensaje: { rol: string; contenido: string } | null
|
||||
}
|
||||
|
||||
124
apps/landing/src/components/config/AsistenteIA.tsx
Normal file
124
apps/landing/src/components/config/AsistenteIA.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { getEstadoAsistente, reindexarConocimiento, type EstadoAsistente } from '../../services/config'
|
||||
|
||||
/**
|
||||
* Mantenimiento del Chat Frasa (asistente IA): uso registrado y re-indexación
|
||||
* de la base de conocimiento (knowledge/*.md) sin entrar al servidor.
|
||||
*/
|
||||
export function AsistenteIA() {
|
||||
const [estado, setEstado] = useState<EstadoAsistente | null>(null)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [reindexando, setReindexando] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [aviso, setAviso] = useState('')
|
||||
|
||||
const cargar = useCallback(async () => {
|
||||
setCargando(true)
|
||||
setError('')
|
||||
try {
|
||||
setEstado(await getEstadoAsistente())
|
||||
} catch {
|
||||
setError('No se pudo cargar el estado del asistente.')
|
||||
} finally {
|
||||
setCargando(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { void cargar() }, [cargar])
|
||||
|
||||
const reindexar = async () => {
|
||||
setReindexando(true)
|
||||
setError('')
|
||||
setAviso('')
|
||||
try {
|
||||
const r = await reindexarConocimiento()
|
||||
setAviso(
|
||||
`Conocimiento actualizado: ${r.documentos} documento(s) — ${r.creados} nuevo(s), ` +
|
||||
`${r.actualizados} actualizado(s), ${r.sinCambios} sin cambios.`,
|
||||
)
|
||||
await cargar()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : 'No se pudo reindexar el conocimiento.')
|
||||
} finally {
|
||||
setReindexando(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (cargando) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando estado del asistente…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Chat Frasa (asistente)</h2>
|
||||
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
El asistente responde con los datos vivos del sistema (clientes, apartados, cobranza, inventario,
|
||||
captura) y con los documentos de su base de conocimiento.
|
||||
</p>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{aviso && <p role="status" className="mt-3 text-sm text-green-600">{aviso}</p>}
|
||||
{estado && (
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{[
|
||||
{ etiqueta: 'Conversaciones', valor: estado.conversaciones },
|
||||
{ etiqueta: 'Mensajes', valor: estado.mensajes },
|
||||
{ etiqueta: 'Documentos', valor: estado.documentos },
|
||||
{ etiqueta: 'Fragmentos indexados', valor: estado.chunks },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.etiqueta} className="rounded-lg border border-[#E5E7EB] px-3 py-2 dark:border-[#334155]">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">{kpi.etiqueta}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">{kpi.valor}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Base de conocimiento</h3>
|
||||
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Re-indexa los documentos de <code className="rounded bg-[#F1F5F9] px-1 dark:bg-[#0F172A]">knowledge/</code>
|
||||
{' '}(guía operativa, procesos, catálogos y FAQ). Solo vuelve a generar los vectores de lo que cambió.
|
||||
Después de actualizar inventario conviene correr también{' '}
|
||||
<code className="rounded bg-[#F1F5F9] px-1 dark:bg-[#0F172A]">npm run seed:catalogos</code> en el servidor.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reindexar()}
|
||||
disabled={reindexando}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{reindexando ? 'Reindexando…' : 'Reindexar conocimiento'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void cargar()}
|
||||
className="rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm font-medium text-[#374151] transition-colors hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
|
||||
>
|
||||
Actualizar métricas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{estado && estado.ultimasPreguntas.length > 0 && (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
||||
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Últimas preguntas</h3>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{estado.ultimasPreguntas.map((p, i) => (
|
||||
<li key={i} className="flex flex-wrap items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-[#374151] dark:text-[#CBD5E1]">{p.pregunta}</span>
|
||||
<span className="text-[#94A3B8]">{p.fecha.slice(0, 10)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { RolesPermisos } from './RolesPermisos'
|
||||
import { Usuarios } from './Usuarios'
|
||||
import { Sistema } from './Sistema'
|
||||
import { AuditoriaLog } from './AuditoriaLog'
|
||||
import { AsistenteIA } from './AsistenteIA'
|
||||
import { getSesion } from '../../services/auth'
|
||||
|
||||
interface Props {
|
||||
onVolver: () => void
|
||||
}
|
||||
|
||||
type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
||||
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
|
||||
|
||||
const TABS_BASE: { key: Tab; label: string }[] = [
|
||||
{ key: 'roles', label: 'Roles y permisos' },
|
||||
@@ -20,7 +21,9 @@ const TABS_BASE: { key: Tab; label: string }[] = [
|
||||
export function ConfigPage({ onVolver }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('roles')
|
||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
||||
const tabs = esAdmin ? [...TABS_BASE, { key: 'auditoria' as const, label: 'Auditoría' }] : TABS_BASE
|
||||
const tabs = esAdmin
|
||||
? [...TABS_BASE, { key: 'asistente' as const, label: 'Asistente IA' }, { key: 'auditoria' as const, label: 'Auditoría' }]
|
||||
: TABS_BASE
|
||||
|
||||
return (
|
||||
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
|
||||
@@ -63,6 +66,7 @@ export function ConfigPage({ onVolver }: Props) {
|
||||
{tab === 'roles' && <RolesPermisos />}
|
||||
{tab === 'usuarios' && <Usuarios />}
|
||||
{tab === 'sistema' && <Sistema />}
|
||||
{tab === 'asistente' && esAdmin && <AsistenteIA />}
|
||||
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -131,6 +131,40 @@ export async function crearUsuarioConfig(data: {
|
||||
return (await resp.json()) as UsuarioConfig
|
||||
}
|
||||
|
||||
// ─── Asistente IA (Chat Frasa) ─────────────────────────────────
|
||||
|
||||
export interface EstadoAsistente {
|
||||
conversaciones: number
|
||||
mensajes: number
|
||||
documentos: number
|
||||
chunks: number
|
||||
ultimasPreguntas: { pregunta: string; fecha: string }[]
|
||||
}
|
||||
|
||||
/** GET /ai/estado — métricas de uso del asistente (admin) */
|
||||
export async function getEstadoAsistente(): Promise<EstadoAsistente> {
|
||||
const resp = await fetch(`${API_URL}/ai/estado`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return (await resp.json()) as EstadoAsistente
|
||||
}
|
||||
|
||||
/** POST /ai/reindexar — re-indexa la base de conocimiento del asistente (admin) */
|
||||
export async function reindexarConocimiento(): Promise<{
|
||||
documentos: number
|
||||
creados: number
|
||||
actualizados: number
|
||||
sinCambios: number
|
||||
}> {
|
||||
const resp = await fetch(`${API_URL}/ai/reindexar`, conAuth({ method: 'POST' }))
|
||||
manejar401(resp)
|
||||
if (!resp.ok) {
|
||||
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
|
||||
throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`)
|
||||
}
|
||||
return (await resp.json()) as { documentos: number; creados: number; actualizados: number; sinCambios: number }
|
||||
}
|
||||
|
||||
// ─── Auditoría ─────────────────────────────────────────────────
|
||||
|
||||
export interface AuditoriaItem {
|
||||
|
||||
Reference in New Issue
Block a user