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

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 {