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