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:
2026-09-17 08:41:21 +00:00
parent 278a79c984
commit 9643f866cc
17 changed files with 3133 additions and 177 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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
}

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

View File

@@ -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>

View File

@@ -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 {