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:
76
api-gateway/knowledge/flujo-operativo-sistema.md
Normal file
76
api-gateway/knowledge/flujo-operativo-sistema.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Guía operativa del sistema Frasa (CRM360)
|
||||||
|
|
||||||
|
Sistema interno de Frasa Desarrollos. Acceso: https://frasa.consultoria-as.com
|
||||||
|
Todo el trabajo vive en el sistema: ya no se usa Monday ni hojas de cálculo sueltas.
|
||||||
|
|
||||||
|
## Módulos y para qué sirve cada uno
|
||||||
|
|
||||||
|
- **Mi Día** (`/hoy`): centro de trabajo del vendedor. Pestañas: Mis leads, Mis oportunidades,
|
||||||
|
Mis apartados, Escrituración, Mi tablero. Cada card muestra los datos del contacto, el último
|
||||||
|
cambio (quién y cuándo) y los botones para moverlo de etapa. Al dar clic en el nombre se abre
|
||||||
|
la ficha con información, cita, tareas y bitácora.
|
||||||
|
- **Alta Afluencia** (`/client`, pestaña Afluencia): registro del prospecto con perfilamiento
|
||||||
|
(edad, estado civil, empleo, ocupación, rango salarial, crédito, economía, califica,
|
||||||
|
dependientes, para qué lo quiere). La afluencia entra como **Oportunidad** en etapa Afluencia.
|
||||||
|
- **Alta de Cliente** (`/client`): expediente completo por etapas que se guardan solas — datos
|
||||||
|
generales, domicilio, contacto, empleo, económicos, co-acreditado, referencias, vivienda,
|
||||||
|
crédito y documentos (INE obligatoria). Genera los PDFs del expediente.
|
||||||
|
- **Cotizador** (`/quote`): cuatro propuestas comerciales (bancario, infonavit/cofinavit,
|
||||||
|
contado y semi-contado) sobre el inventario vivo, con apartado configurable, acabados y
|
||||||
|
mensualidades diferibles. Genera el PDF de la propuesta elegida y la guarda ligada al cliente.
|
||||||
|
- **Clientes / Cobranza** (`/pay`): planes de pago, cobranza del día, registro de abonos,
|
||||||
|
recibos y estados de cuenta en PDF, mora configurable y notas por plan.
|
||||||
|
- **Tablero** (`/dashboards`): Mi tablero y Tablero comercial (KPIs, equipo, perfilamiento)
|
||||||
|
y **Reportes** (10 reportes descargables en Excel).
|
||||||
|
- **Contactos**: lista de todos los contactos con su ficha e historial.
|
||||||
|
- **Clientes maestro**: base histórica de clientes con toda su información.
|
||||||
|
- **Captura** (`/captura`): expediente por departamentos (Obra, Ventas, Escrituración,
|
||||||
|
Finanzas, Postventa) por unidad y desarrollo. En Obra, cada trámite (certificación de gas,
|
||||||
|
TO y C4-fianza, régimen inscrito, no. oficial, clave catastral, listas para avalúo) lleva
|
||||||
|
fecha meta y check; la fecha se pinta verde al completarse y roja si se vence sin completar.
|
||||||
|
Las fechas ya establecidas solo las puede cambiar un administrador.
|
||||||
|
- **Soporte**: solicitudes internas a cualquier departamento con prioridad, responsable y fecha límite.
|
||||||
|
- **Chat Frasa** (`/ai`): este asistente.
|
||||||
|
- **Configuraciones** (solo admin): roles y permisos por rol, alta de usuarios, auditoría,
|
||||||
|
sistema y mantenimiento del asistente.
|
||||||
|
|
||||||
|
## Flujo completo de venta
|
||||||
|
|
||||||
|
1. **Afluencia**: el asesor registra al prospecto (Alta Afluencia) o captura rápida de lead.
|
||||||
|
Se asigna a quien lo captura.
|
||||||
|
2. **Cita**: desde Mis oportunidades se agenda con fecha y hora; aparece en el calendario.
|
||||||
|
3. **Cotización**: se cotiza la unidad interesada y la propuesta queda ligada al cliente.
|
||||||
|
4. **Apartado**: al apartar, la unidad se **bloquea del inventario** (no se puede vender dos
|
||||||
|
veces) y el cliente pasa a Mis apartados con el pipeline Enkontrol:
|
||||||
|
Pago Apartado → Alta Enkontrol → Entrego documentación → Cita cliente → Contrato Enkontrol →
|
||||||
|
Firma cliente.
|
||||||
|
5. **Escrituración**: tras la firma del cliente, el expediente pasa a Escrituración:
|
||||||
|
Venta → Seguimiento → Integración → X Autorizar → Autorizado → X Escriturar → Escriturado →
|
||||||
|
Titulado. Cualquier etapa puede cancelarse (Cancelo) indicando el motivo.
|
||||||
|
6. **Cobranza**: los planes de enganche diferido se siguen en Clientes/Cobranza, con abonos,
|
||||||
|
mora y estados de cuenta.
|
||||||
|
7. **Postventa**: entrega y seguimiento (SAC) — se captura en el módulo de Captura.
|
||||||
|
|
||||||
|
## Reglas importantes
|
||||||
|
|
||||||
|
- **Notas y bitácora**: cada contacto guarda su historial completo; las notas siguen al cliente
|
||||||
|
en todas las etapas (lead → oportunidad → apartado → escrituración).
|
||||||
|
- **Descartes y cancelaciones**: siempre se pide el motivo (categoría y subcategoría del catálogo
|
||||||
|
MOTIVOS NO COMPRA: Timing, Cliente, Competencia, Financiamiento, Ubicación, Producto, Precio).
|
||||||
|
- **Asignación**: los leads nuevos se reparten por round-robin entre los vendedores activos, y
|
||||||
|
se pueden reasignar manualmente (solo administradores).
|
||||||
|
- **Permisos**: cada rol ve los módulos que le corresponden; los vendedores solo ven sus propios
|
||||||
|
contactos, dirección y administración ven todo.
|
||||||
|
- **Documentos**: en los apartados son obligatorios la captura del cotizador, la captura del pago
|
||||||
|
de apartado y la INE.
|
||||||
|
|
||||||
|
## Preguntas frecuentes de operación
|
||||||
|
|
||||||
|
- **No me aparece una unidad en el cotizador**: si está apartada o vendida aparece bloqueada;
|
||||||
|
solo se cotizan unidades DISPONIBLES.
|
||||||
|
- **El desarrollo SOLE / MUTUO**: SOLE tiene modelos Alba, Horizonte y Ocaso; MUTUO captura el
|
||||||
|
modelo en texto libre.
|
||||||
|
- **¿Dónde veo quién atendió a un cliente?**: en la ficha del contacto (bitácora y último cambio)
|
||||||
|
y en el reporte de contactos.
|
||||||
|
- **¿Cómo descargo información?**: en Tablero → Reportes hay 10 reportes con filtros de fecha,
|
||||||
|
desarrollo y vendedor, todos descargables a Excel.
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
||||||
"seed:config": "tsx scripts/seed-config.js",
|
"seed:config": "tsx scripts/seed-config.js",
|
||||||
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
|
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
|
||||||
"prisma:migrate": "prisma migrate dev"
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.14.0",
|
"@prisma/client": "^6.14.0",
|
||||||
|
|||||||
103
api-gateway/scripts/actualizar-catalogos-ia.js
Normal file
103
api-gateway/scripts/actualizar-catalogos-ia.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Regenera los catálogos de unidades de la base de conocimiento del asistente
|
||||||
|
* a partir del INVENTARIO VIVO (tabla Unidad), para que el chat nunca conteste
|
||||||
|
* con un catálogo viejo.
|
||||||
|
*
|
||||||
|
* Uso: node scripts/actualizar-catalogos-ia.js
|
||||||
|
* Requiere NAN_API_KEY (embeddings) en el .env del ecosistema.
|
||||||
|
*
|
||||||
|
* Idempotente: indexarDocumento re-embebe solo si el contenido cambió.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { indexarDocumento } from '../dist/services/ai.service.js'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
// Carga el .env del ecosistema (NAN_API_KEY / DATABASE_URL) si no vienen del entorno
|
||||||
|
for (const ruta of ['../.env', '../api-gateway/.env']) {
|
||||||
|
try {
|
||||||
|
const env = readFileSync(new URL(ruta, import.meta.url), 'utf8')
|
||||||
|
for (const linea of env.split('\n')) {
|
||||||
|
const m = linea.match(/^([A-Z_]+)=(.*)$/)
|
||||||
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
|
||||||
|
}
|
||||||
|
} catch { /* ignorar */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMoneda = (n) => `$${Math.round(n).toLocaleString('es-MX')}`
|
||||||
|
|
||||||
|
/** Markdown del catálogo de un desarrollo: modelos, disponibilidad y precios */
|
||||||
|
function catalogoDe(desarrollo, unidades) {
|
||||||
|
const porModelo = new Map()
|
||||||
|
for (const u of unidades) {
|
||||||
|
const modelo = u.modelo || '(sin modelo)'
|
||||||
|
const lista = porModelo.get(modelo) ?? []
|
||||||
|
lista.push(u)
|
||||||
|
porModelo.set(modelo, lista)
|
||||||
|
}
|
||||||
|
const conteoEstatus = {}
|
||||||
|
for (const u of unidades) conteoEstatus[u.status] = (conteoEstatus[u.status] ?? 0) + 1
|
||||||
|
|
||||||
|
const lineas = [
|
||||||
|
`# Catálogo de unidades — Desarrollo ${desarrollo}`,
|
||||||
|
'',
|
||||||
|
`Inventario actualizado desde el sistema (${unidades.length} unidades).`,
|
||||||
|
'',
|
||||||
|
'## Disponibilidad por estatus',
|
||||||
|
...Object.entries(conteoEstatus).map(([estatus, n]) => `- ${estatus}: ${n} unidades`),
|
||||||
|
'',
|
||||||
|
'## Modelos y precios',
|
||||||
|
]
|
||||||
|
for (const [modelo, lista] of [...porModelo.entries()].sort()) {
|
||||||
|
const disponibles = lista.filter((u) => u.status === 'DISPONIBLE')
|
||||||
|
const precios = lista.map((u) => u.precioLista).filter((p) => p > 0)
|
||||||
|
const min = precios.length > 0 ? Math.min(...precios) : 0
|
||||||
|
const max = precios.length > 0 ? Math.max(...precios) : 0
|
||||||
|
lineas.push(
|
||||||
|
`- **${modelo}**: ${lista.length} unidades (${disponibles.length} disponibles). ` +
|
||||||
|
(precios.length > 0 ? `Precio de lista ${fmtMoneda(min)} a ${fmtMoneda(max)}.` : 'Sin precio capturado.'),
|
||||||
|
)
|
||||||
|
if (disponibles.length > 0) {
|
||||||
|
const codigos = disponibles.slice(0, 12).map((u) => u.codigo).join(', ')
|
||||||
|
lineas.push(` - Disponibles: ${codigos}${disponibles.length > 12 ? ` (y ${disponibles.length - 12} más)` : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lineas.push(
|
||||||
|
'',
|
||||||
|
'## Notas para el asistente',
|
||||||
|
'- Los precios son de lista, sin acabados (cocina y closet se cotizan aparte).',
|
||||||
|
'- Para los números de una unidad concreta (enganche, mensualidades, pago final) usa la herramienta cotizar_unidad.',
|
||||||
|
'- Para disponibilidad exacta en este momento usa buscar_unidad o contar_unidades: este documento es un resumen.',
|
||||||
|
)
|
||||||
|
return lineas.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const unidades = await prisma.unidad.findMany({ orderBy: [{ desarrollo: 'asc' }, { modelo: 'asc' }, { codigo: 'asc' }] })
|
||||||
|
if (unidades.length === 0) {
|
||||||
|
console.error('No hay unidades en el inventario local; nada que regenerar.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const porDesarrollo = new Map()
|
||||||
|
for (const u of unidades) {
|
||||||
|
const lista = porDesarrollo.get(u.desarrollo) ?? []
|
||||||
|
lista.push(u)
|
||||||
|
porDesarrollo.set(u.desarrollo, lista)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Regenerando catálogos de ${porDesarrollo.size} desarrollo(s)…`)
|
||||||
|
for (const [desarrollo, lista] of [...porDesarrollo.entries()].sort()) {
|
||||||
|
const r = await indexarDocumento(`Catálogo de unidades — Desarrollo ${desarrollo}`, catalogoDe(desarrollo, lista))
|
||||||
|
console.log(` ${r.estado.padEnd(12)} ${r.titulo} (${r.chunks} chunks)`)
|
||||||
|
}
|
||||||
|
console.log('Listo.')
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Falló:', err instanceof Error ? err.message : err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -1,23 +1,66 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import type { Request, Response } from 'express'
|
import type { Request, Response } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
import { chat, indexarDocumento, type FuenteCitada } from '../services/ai.service.js'
|
||||||
import { NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
|
||||||
const chatSchema = z.object({
|
const chatSchema = z.object({
|
||||||
sessionId: z.string().uuid().nullish(),
|
sessionId: z.string().uuid().nullish(),
|
||||||
mensaje: z.string().min(1).max(4000),
|
mensaje: z.string().min(1).max(4000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function contextoDe(req: Request): { sub: string; rol: string } | undefined {
|
||||||
|
const t = req.usuarioToken
|
||||||
|
return t ? { sub: t.sub, rol: t.rol } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */
|
/** POST /api/ai/chat — conversación RAG; sessionId null/omitido crea conversación nueva */
|
||||||
export async function chatIA(req: Request, res: Response): Promise<void> {
|
export async function chatIA(req: Request, res: Response): Promise<void> {
|
||||||
const body = chatSchema.parse(req.body)
|
const body = chatSchema.parse(req.body)
|
||||||
const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, {
|
const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, {
|
||||||
usuarioId: req.usuarioToken?.sub ?? null,
|
usuarioId: req.usuarioToken?.sub ?? null,
|
||||||
|
contexto: contextoDe(req),
|
||||||
})
|
})
|
||||||
res.json(resultado)
|
res.json(resultado)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/chat/stream — igual que /chat pero con streaming SSE: manda
|
||||||
|
* { delta } conforme el proveedor escribe y cierra con { done, sessionId,
|
||||||
|
* fuentes, herramientasUsadas }.
|
||||||
|
*/
|
||||||
|
export async function chatIAStream(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = chatSchema.parse(req.body)
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
||||||
|
res.setHeader('Connection', 'keep-alive')
|
||||||
|
res.flushHeaders?.()
|
||||||
|
|
||||||
|
const enviar = (payload: unknown): void => {
|
||||||
|
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultado = await chat(body.sessionId ?? null, body.mensaje, undefined, {
|
||||||
|
usuarioId: req.usuarioToken?.sub ?? null,
|
||||||
|
contexto: contextoDe(req),
|
||||||
|
onDelta: (texto) => enviar({ delta: texto }),
|
||||||
|
})
|
||||||
|
enviar({
|
||||||
|
done: true,
|
||||||
|
sessionId: resultado.sessionId,
|
||||||
|
fuentes: resultado.fuentes,
|
||||||
|
herramientasUsadas: resultado.herramientasUsadas,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
enviar({ error: err instanceof Error ? err.message : 'Error del asistente' })
|
||||||
|
} finally {
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filtro "mis conversaciones": cada usuario solo ve las suyas; admin las ve
|
* Filtro "mis conversaciones": cada usuario solo ve las suyas; admin las ve
|
||||||
* todas. Sin token (AUTH_ENABLED=false) se devuelve todo.
|
* todas. Sin token (AUTH_ENABLED=false) se devuelve todo.
|
||||||
@@ -29,20 +72,28 @@ export function filtroConversaciones(
|
|||||||
return { usuarioId: token.sub }
|
return { usuarioId: token.sub }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/ai/conversations — listado con último mensaje como preview (solo las mías) */
|
/** GET /api/ai/conversations — listado con título (primer mensaje) y último mensaje */
|
||||||
export async function listarConversaciones(req: Request, res: Response): Promise<void> {
|
export async function listarConversaciones(req: Request, res: Response): Promise<void> {
|
||||||
const where = filtroConversaciones(req.usuarioToken)
|
const where = filtroConversaciones(req.usuarioToken)
|
||||||
const conversaciones = await prisma.aIConversacion.findMany({
|
const conversaciones = await prisma.aIConversacion.findMany({
|
||||||
...(where ? { where } : {}),
|
...(where ? { where } : {}),
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { mensajes: { orderBy: { createdAt: 'desc' }, take: 1, select: { contenido: true, rol: true } } },
|
include: {
|
||||||
|
mensajes: { orderBy: { createdAt: 'asc' }, select: { contenido: true, rol: true } },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
res.json(
|
res.json(
|
||||||
conversaciones.map((c) => ({
|
conversaciones.map((c) => {
|
||||||
id: c.id,
|
const primerUsuario = c.mensajes.find((m) => m.rol === 'user')
|
||||||
createdAt: c.createdAt,
|
const ultimo = c.mensajes[c.mensajes.length - 1] ?? null
|
||||||
ultimoMensaje: c.mensajes[0] ?? null,
|
return {
|
||||||
})),
|
id: c.id,
|
||||||
|
createdAt: c.createdAt,
|
||||||
|
titulo: (primerUsuario?.contenido ?? 'Conversación sin mensajes').slice(0, 80),
|
||||||
|
ultimoMensaje: ultimo,
|
||||||
|
mensajes: c.mensajes.length,
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +126,19 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DELETE /api/ai/conversations/:id — borra la conversación propia (admin: cualquiera) */
|
||||||
|
export async function eliminarConversacion(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = String(req.params['id'])
|
||||||
|
const conversacion = await prisma.aIConversacion.findUnique({ where: { id } })
|
||||||
|
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
const token = req.usuarioToken
|
||||||
|
if (token && token.rol !== 'admin' && conversacion.usuarioId !== token.sub) {
|
||||||
|
throw new AppError(403, 'Solo puedes borrar tus conversaciones')
|
||||||
|
}
|
||||||
|
await prisma.aIConversacion.delete({ where: { id } }) // cascade: mensajes
|
||||||
|
res.json({ ok: true, id })
|
||||||
|
}
|
||||||
|
|
||||||
const documentoSchema = z.object({
|
const documentoSchema = z.object({
|
||||||
titulo: z.string().min(1).max(200),
|
titulo: z.string().min(1).max(200),
|
||||||
contenido: z.string().min(1),
|
contenido: z.string().min(1),
|
||||||
@@ -111,3 +175,58 @@ export async function eliminarDocumento(req: Request, res: Response): Promise<vo
|
|||||||
await prisma.aIDocumento.delete({ where: { id: documento.id } })
|
await prisma.aIDocumento.delete({ where: { id: documento.id } })
|
||||||
res.json({ ok: true, titulo: documento.titulo })
|
res.json({ ok: true, titulo: documento.titulo })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ruta de la carpeta de conocimiento (api-gateway/knowledge) */
|
||||||
|
const KNOWLEDGE_DIR = fileURLToPath(new URL('../../knowledge', import.meta.url))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/reindexar — re-indexa todos los .md de knowledge/ (admin).
|
||||||
|
* Reemplaza a `npm run seed:ai` para refrescar la base de conocimiento sin
|
||||||
|
* entrar al servidor; es idempotente (solo re-embebe lo que cambió).
|
||||||
|
*/
|
||||||
|
export async function reindexarConocimiento(_req: Request, res: Response): Promise<void> {
|
||||||
|
let archivos: string[]
|
||||||
|
try {
|
||||||
|
archivos = (await readdir(KNOWLEDGE_DIR)).filter((f) => f.endsWith('.md')).sort()
|
||||||
|
} catch {
|
||||||
|
throw new AppError(500, 'No se encontró la carpeta de conocimiento del asistente')
|
||||||
|
}
|
||||||
|
const resultados: { titulo: string; estado: string; chunks: number }[] = []
|
||||||
|
for (const archivo of archivos) {
|
||||||
|
const contenido = await readFile(`${KNOWLEDGE_DIR}/${archivo}`, 'utf8')
|
||||||
|
const h1 = contenido.split('\n').find((l) => l.startsWith('# '))
|
||||||
|
const titulo = h1 ? h1.replace(/^#\s+/, '').trim() : archivo.replace(/\.md$/, '')
|
||||||
|
const r = await indexarDocumento(titulo, contenido)
|
||||||
|
resultados.push({ titulo: r.titulo, estado: r.estado, chunks: r.chunks })
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
documentos: resultados.length,
|
||||||
|
creados: resultados.filter((r) => r.estado === 'creado').length,
|
||||||
|
actualizados: resultados.filter((r) => r.estado === 'actualizado').length,
|
||||||
|
sinCambios: resultados.filter((r) => r.estado === 'sin-cambios').length,
|
||||||
|
detalle: resultados,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/ai/estado — métricas de uso del asistente (admin) */
|
||||||
|
export async function estadoAsistente(_req: Request, res: Response): Promise<void> {
|
||||||
|
const [conversaciones, mensajes, documentos, chunks] = await Promise.all([
|
||||||
|
prisma.aIConversacion.count(),
|
||||||
|
prisma.aIMensaje.count(),
|
||||||
|
prisma.aIDocumento.count(),
|
||||||
|
prisma.aIChunk.count(),
|
||||||
|
])
|
||||||
|
const ultimos = await prisma.aIMensaje.findMany({
|
||||||
|
where: { rol: 'user' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
select: { contenido: true, createdAt: true },
|
||||||
|
})
|
||||||
|
res.json({
|
||||||
|
conversaciones,
|
||||||
|
mensajes,
|
||||||
|
documentos,
|
||||||
|
chunks,
|
||||||
|
ultimasPreguntas: ultimos.map((m) => ({ pregunta: m.contenido.slice(0, 90), fecha: m.createdAt })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,10 +78,14 @@ import {
|
|||||||
} from '../controllers/automatizaciones.controller.js'
|
} from '../controllers/automatizaciones.controller.js'
|
||||||
import {
|
import {
|
||||||
chatIA,
|
chatIA,
|
||||||
|
chatIAStream,
|
||||||
|
eliminarConversacion,
|
||||||
eliminarDocumento,
|
eliminarDocumento,
|
||||||
|
estadoAsistente,
|
||||||
listarConversaciones,
|
listarConversaciones,
|
||||||
listarDocumentosIA,
|
listarDocumentosIA,
|
||||||
obtenerConversacion,
|
obtenerConversacion,
|
||||||
|
reindexarConocimiento,
|
||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
|
||||||
@@ -284,11 +288,16 @@ apiRouter.post('/automatizaciones/round-robin/run', correrRoundRobin)
|
|||||||
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
apiRouter.get('/automatizaciones/inactividad/preview', previewInactividad)
|
||||||
|
|
||||||
apiRouter.post('/ai/chat', chatIA)
|
apiRouter.post('/ai/chat', chatIA)
|
||||||
|
apiRouter.post('/ai/chat/stream', chatIAStream)
|
||||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||||
|
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
||||||
apiRouter.post('/ai/documents', subirDocumento)
|
apiRouter.post('/ai/documents', subirDocumento)
|
||||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
apiRouter.get('/ai/documents', listarDocumentosIA)
|
||||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
||||||
|
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
||||||
|
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
||||||
|
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
||||||
|
|
||||||
// ── Auditoría (admin) ────────────────────────────────────────────────
|
// ── Auditoría (admin) ────────────────────────────────────────────────
|
||||||
apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
|
apiRouter.get('/auditoria', requireAdmin, listarAuditoriaEndpoint)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
|
import type { HerramientaDef, MensajeApi, NanClient, RespuestaCruda } from './ai.service.js'
|
||||||
import {
|
import {
|
||||||
HERRAMIENTAS,
|
HERRAMIENTAS,
|
||||||
|
MAX_RONDAS_TOOLS,
|
||||||
chat,
|
chat,
|
||||||
construirSystemPrompt,
|
construirSystemPrompt,
|
||||||
crearEjecutoresHerramientas,
|
crearEjecutoresHerramientas,
|
||||||
@@ -169,8 +170,10 @@ describe('construirSystemPrompt', () => {
|
|||||||
{ titulo: 'Catálogo DIAGONAL', texto: 'Modelo 1R desde $2,850,000', score: 0.9 },
|
{ titulo: 'Catálogo DIAGONAL', texto: 'Modelo 1R desde $2,850,000', score: 0.9 },
|
||||||
{ titulo: 'Proceso de venta', texto: 'Apartado de $20,000', score: 0.8 },
|
{ titulo: 'Proceso de venta', texto: 'Apartado de $20,000', score: 0.8 },
|
||||||
])
|
])
|
||||||
expect(prompt).toContain('asistente interno de Frasa Desarrollos')
|
expect(prompt).toContain('Chat Frasa')
|
||||||
expect(prompt).toContain('Responde SOLO con base en el CONTEXTO')
|
expect(prompt).toContain('Nunca inventes cifras')
|
||||||
|
expect(prompt).toContain('buscar_contactos')
|
||||||
|
expect(prompt).toContain('estadisticas')
|
||||||
expect(prompt).toContain('[1] Fuente: Catálogo DIAGONAL')
|
expect(prompt).toContain('[1] Fuente: Catálogo DIAGONAL')
|
||||||
expect(prompt).toContain('[2] Fuente: Proceso de venta')
|
expect(prompt).toContain('[2] Fuente: Proceso de venta')
|
||||||
expect(prompt).toContain('Modelo 1R desde $2,850,000')
|
expect(prompt).toContain('Modelo 1R desde $2,850,000')
|
||||||
@@ -631,13 +634,15 @@ describe('chat con herramientas (tool calling)', () => {
|
|||||||
expect(roles).toEqual(['user', 'assistant'])
|
expect(roles).toEqual(['user', 'assistant'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('máx 2 rondas de tools: la llamada de cierre va sin herramientas', async () => {
|
it('agota las rondas de tools: la última llamada va sin herramientas', async () => {
|
||||||
mocksPrismaChat()
|
mocksPrismaChat()
|
||||||
const toolCall = (id: string): RespuestaCruda => ({
|
const toolCall = (id: string): RespuestaCruda => ({
|
||||||
contenido: null,
|
contenido: null,
|
||||||
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
|
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
|
||||||
})
|
})
|
||||||
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre forzado', toolCalls: [] }])
|
// El cliente pide herramientas SIEMPRE; el loop debe cortar en MAX_RONDAS_TOOLS
|
||||||
|
const respuestas = Array.from({ length: MAX_RONDAS_TOOLS + 1 }, (_, i) => toolCall(`c${i + 1}`))
|
||||||
|
const client = clienteConTools(respuestas)
|
||||||
const ejecutores = {
|
const ejecutores = {
|
||||||
async cotizar_unidad() {
|
async cotizar_unidad() {
|
||||||
return {}
|
return {}
|
||||||
@@ -659,11 +664,36 @@ describe('chat con herramientas (tool calling)', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = await chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })
|
// La ronda de cierre (MAX_RONDAS_TOOLS) va sin herramientas y el cliente
|
||||||
|
// devuelve texto plano ahí (la última respuesta simulada es un tool call,
|
||||||
|
// así que el loop lanza al no poder cerrar con texto)
|
||||||
|
await expect(chat(null, 'búscame algo y cotízalo todo', client, { ejecutores })).rejects.toThrow(
|
||||||
|
/no cerró la respuesta/,
|
||||||
|
)
|
||||||
|
expect(client.llamadas).toHaveLength(MAX_RONDAS_TOOLS + 1)
|
||||||
|
expect(client.llamadas[MAX_RONDAS_TOOLS]!.herramientas).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
expect(client.llamadas).toHaveLength(3) // 2 rondas de tools + cierre
|
it('cierra con texto cuando el modelo deja de pedir herramientas', async () => {
|
||||||
expect(client.llamadas[2]!.herramientas).toEqual([])
|
mocksPrismaChat()
|
||||||
expect(r.respuesta).toBe('cierre forzado')
|
const toolCall = (id: string): RespuestaCruda => ({
|
||||||
|
contenido: null,
|
||||||
|
toolCalls: [{ id, nombre: 'buscar_unidad', argumentosJson: '{}' }],
|
||||||
|
})
|
||||||
|
const client = clienteConTools([toolCall('c1'), toolCall('c2'), { contenido: 'cierre', toolCalls: [] }])
|
||||||
|
const r = await chat(null, 'búscame algo', client, {
|
||||||
|
ejecutores: {
|
||||||
|
cotizar_unidad: async () => ({}),
|
||||||
|
buscar_unidad: async () => [{ codigo: 'SOLANA-6' }],
|
||||||
|
buscar_leads: async () => [],
|
||||||
|
ver_cliente: async () => ({}),
|
||||||
|
ver_oportunidades: async () => [],
|
||||||
|
ver_cobranza: async () => ({}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(client.llamadas).toHaveLength(3)
|
||||||
|
expect(r.respuesta).toBe('cierre')
|
||||||
|
expect(r.herramientasUsadas).toEqual(['buscar_unidad', 'buscar_unidad'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('respuesta directa sin tools: una sola llamada', async () => {
|
it('respuesta directa sin tools: una sola llamada', async () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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",
|
"lucide-react": "^1.41.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
@@ -1,26 +1,94 @@
|
|||||||
|
import Markdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { Mensaje } from '../types'
|
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 }) {
|
function Burbuja({ mensaje }: { mensaje: Mensaje }) {
|
||||||
const esUsuario = mensaje.rol === 'user'
|
const esUsuario = mensaje.rol === 'user'
|
||||||
|
const herramientas = [...new Set(mensaje.herramientasUsadas ?? [])]
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${esUsuario ? 'justify-end' : 'justify-start'}`}>
|
<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
|
<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
|
esUsuario
|
||||||
? 'rounded-br-md bg-accent text-white 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:text-[#E2E8F0] 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>
|
</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) => (
|
{mensaje.fuentes.map((f) => (
|
||||||
<span
|
<span
|
||||||
key={f.titulo}
|
key={f.titulo}
|
||||||
title={`Relevancia: ${(f.score * 100).toFixed(0)}%`}
|
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}
|
{f.titulo}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -31,3 +31,34 @@ export function preguntasSugeridas(fecha: Date = new Date()): string[] {
|
|||||||
return plantilla(desarrollo)
|
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 DarkModeToggle from '../components/DarkModeToggle'
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
import {
|
import {
|
||||||
enviarMensaje,
|
eliminarConversacion,
|
||||||
|
enviarMensajeStream,
|
||||||
esErrorDeRed,
|
esErrorDeRed,
|
||||||
listarConversaciones,
|
listarConversaciones,
|
||||||
obtenerHistorial,
|
obtenerHistorial,
|
||||||
type ConversacionResumen,
|
type ConversacionResumen,
|
||||||
} from '../services/api'
|
} from '../services/api'
|
||||||
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
|
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'
|
import type { Mensaje } from '../types'
|
||||||
|
|
||||||
let contadorLocal = 0
|
let contadorLocal = 0
|
||||||
@@ -21,15 +23,19 @@ export default function ChatPage() {
|
|||||||
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
const [mensajes, setMensajes] = useState<Mensaje[]>([])
|
||||||
const [entrada, setEntrada] = useState('')
|
const [entrada, setEntrada] = useState('')
|
||||||
const [escribiendo, setEscribiendo] = useState(false)
|
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)
|
const [sinConexion, setSinConexion] = useState(false)
|
||||||
// Preguntas sugeridas del día: rotan por desarrollo (VOCABULARIO.DESARROLLOS)
|
// Preguntas sugeridas según el rol de la sesión (vendedor vs dirección)
|
||||||
const preguntasSugeridasHoy = useMemo(() => preguntasSugeridas(), [])
|
const rol = getSesion()?.usuario.rol
|
||||||
|
const preguntasSugeridasHoy = useMemo(() => preguntasSugeridasPorRol(rol), [rol])
|
||||||
// Texto del último envío fallido (habilita "Reintentar" junto al input)
|
// Texto del último envío fallido (habilita "Reintentar" junto al input)
|
||||||
const [pendiente, setPendiente] = useState<string | null>(null)
|
const [pendiente, setPendiente] = useState<string | null>(null)
|
||||||
// Panel de conversaciones previas
|
// Panel de conversaciones previas
|
||||||
const [panelAbierto, setPanelAbierto] = useState(false)
|
const [panelAbierto, setPanelAbierto] = useState(false)
|
||||||
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
|
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
|
||||||
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
|
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
|
||||||
|
const [busquedaHistorial, setBusquedaHistorial] = useState('')
|
||||||
const finalRef = useRef<HTMLDivElement>(null)
|
const finalRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Al cargar: si hay sesión en localStorage, recupera su historial del API
|
// 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' })
|
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [mensajes, escribiendo])
|
}, [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) {
|
async function transmitir(texto: string) {
|
||||||
setEscribiendo(true)
|
setEscribiendo(true)
|
||||||
|
setRecibiendoTexto(false)
|
||||||
|
const idRespuesta = idLocal()
|
||||||
try {
|
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)
|
setSessionId(r.sessionId)
|
||||||
guardarSesion(r.sessionId)
|
guardarSesion(r.sessionId)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
setPendiente(null)
|
setPendiente(null)
|
||||||
setMensajes((prev) => [
|
setMensajes((prev) => {
|
||||||
...prev,
|
const completa: Mensaje = {
|
||||||
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
|
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) {
|
} catch (err) {
|
||||||
|
// Quita la burbuja a medias para no dejar texto incompleto
|
||||||
|
setMensajes((prev) => prev.filter((m) => m.id !== idRespuesta))
|
||||||
if (esErrorDeRed(err)) {
|
if (esErrorDeRed(err)) {
|
||||||
setSinConexion(true)
|
setSinConexion(true)
|
||||||
} else {
|
} 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
|
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
|
||||||
} finally {
|
} finally {
|
||||||
setEscribiendo(false)
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col bg-[#F8FAFC] text-[#111827] dark:text-[#E2E8F0] dark:bg-[#0F172A]">
|
<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">
|
<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
|
Asistente interno
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 text-xl font-light leading-relaxed text-[#111827] dark:text-[#E2E8F0]">
|
<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
|
Pregúntame lo que necesites: clientes, leads, apartados, escrituración, cobranza,
|
||||||
venta o las políticas de Frasa.
|
inventario, captura de obra, citas o procesos de Frasa.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 grid gap-2 sm:grid-cols-2">
|
<div className="mt-6 grid gap-2 sm:grid-cols-2">
|
||||||
{preguntasSugeridasHoy.map((pregunta) => (
|
{preguntasSugeridasHoy.map((pregunta) => (
|
||||||
@@ -207,7 +258,7 @@ export default function ChatPage() {
|
|||||||
{escribiendo && (
|
{escribiendo && (
|
||||||
<div className="pointer-events-none absolute -top-11 left-4 sm:left-6">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -280,34 +331,57 @@ export default function ChatPage() {
|
|||||||
) : !conversaciones || conversaciones.length === 0 ? (
|
) : !conversaciones || conversaciones.length === 0 ? (
|
||||||
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
|
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-4 space-y-2">
|
<>
|
||||||
{conversaciones.map((c) => (
|
<input
|
||||||
<li key={c.id}>
|
type="search"
|
||||||
<button
|
value={busquedaHistorial}
|
||||||
type="button"
|
onChange={(e) => setBusquedaHistorial(e.target.value)}
|
||||||
onClick={() => void abrirConversacion(c.id)}
|
placeholder="Buscar en el historial…"
|
||||||
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
aria-label="Buscar en el historial"
|
||||||
c.id === sessionId
|
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]"
|
||||||
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
|
/>
|
||||||
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
|
{conversacionesFiltradas.length === 0 ? (
|
||||||
}`}
|
<p className="mt-4 text-xs text-[#94A3B8]">Sin resultados para esa búsqueda.</p>
|
||||||
>
|
) : (
|
||||||
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
|
<ul className="mt-3 space-y-2">
|
||||||
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
|
{conversacionesFiltradas.map((c) => (
|
||||||
</p>
|
<li key={c.id} className="group relative">
|
||||||
<p className="mt-1 text-[10px] text-[#94A3B8]">
|
<button
|
||||||
{new Date(c.createdAt).toLocaleString('es-MX', {
|
type="button"
|
||||||
day: '2-digit',
|
onClick={() => void abrirConversacion(c.id)}
|
||||||
month: '2-digit',
|
className={`w-full rounded-lg border p-3 pr-9 text-left transition-colors ${
|
||||||
year: 'numeric',
|
c.id === sessionId
|
||||||
hour: '2-digit',
|
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
|
||||||
minute: '2-digit',
|
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
|
||||||
})}
|
}`}
|
||||||
</p>
|
>
|
||||||
</button>
|
<p className="truncate text-xs font-medium text-[#111827] dark:text-[#E2E8F0]">
|
||||||
</li>
|
{c.titulo || c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
|
||||||
))}
|
</p>
|
||||||
</ul>
|
<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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
|
|||||||
export interface ConversacionResumen {
|
export interface ConversacionResumen {
|
||||||
id: string
|
id: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
/** Primer mensaje del usuario (título de la conversación) */
|
||||||
|
titulo?: string
|
||||||
ultimoMensaje: { rol: string; contenido: string } | null
|
ultimoMensaje: { rol: string; contenido: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +86,97 @@ export interface ConversacionResumen {
|
|||||||
export function listarConversaciones(): Promise<ConversacionResumen[]> {
|
export function listarConversaciones(): Promise<ConversacionResumen[]> {
|
||||||
return request<ConversacionResumen[]>('/ai/conversations')
|
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'
|
rol: 'user' | 'assistant'
|
||||||
contenido: string
|
contenido: string
|
||||||
fuentes: Fuente[]
|
fuentes: Fuente[]
|
||||||
|
/** Herramientas de datos consultadas (solo en respuestas en vivo) */
|
||||||
|
herramientasUsadas?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RespuestaChat {
|
export interface RespuestaChat {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
respuesta: string
|
respuesta: string
|
||||||
fuentes: Fuente[]
|
fuentes: Fuente[]
|
||||||
|
herramientasUsadas?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversacionResumen {
|
export interface ConversacionResumen {
|
||||||
id: string
|
id: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
titulo?: string
|
||||||
ultimoMensaje: { rol: string; contenido: string } | null
|
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 { Usuarios } from './Usuarios'
|
||||||
import { Sistema } from './Sistema'
|
import { Sistema } from './Sistema'
|
||||||
import { AuditoriaLog } from './AuditoriaLog'
|
import { AuditoriaLog } from './AuditoriaLog'
|
||||||
|
import { AsistenteIA } from './AsistenteIA'
|
||||||
import { getSesion } from '../../services/auth'
|
import { getSesion } from '../../services/auth'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onVolver: () => void
|
onVolver: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'roles' | 'usuarios' | 'sistema' | 'auditoria'
|
type Tab = 'roles' | 'usuarios' | 'sistema' | 'asistente' | 'auditoria'
|
||||||
|
|
||||||
const TABS_BASE: { key: Tab; label: string }[] = [
|
const TABS_BASE: { key: Tab; label: string }[] = [
|
||||||
{ key: 'roles', label: 'Roles y permisos' },
|
{ key: 'roles', label: 'Roles y permisos' },
|
||||||
@@ -20,7 +21,9 @@ const TABS_BASE: { key: Tab; label: string }[] = [
|
|||||||
export function ConfigPage({ onVolver }: Props) {
|
export function ConfigPage({ onVolver }: Props) {
|
||||||
const [tab, setTab] = useState<Tab>('roles')
|
const [tab, setTab] = useState<Tab>('roles')
|
||||||
const esAdmin = getSesion()?.usuario.rol === 'admin'
|
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 (
|
return (
|
||||||
<main className="w-full flex-1 px-6 py-12 sm:px-6 lg:px-8 sm:py-16">
|
<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 === 'roles' && <RolesPermisos />}
|
||||||
{tab === 'usuarios' && <Usuarios />}
|
{tab === 'usuarios' && <Usuarios />}
|
||||||
{tab === 'sistema' && <Sistema />}
|
{tab === 'sistema' && <Sistema />}
|
||||||
|
{tab === 'asistente' && esAdmin && <AsistenteIA />}
|
||||||
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
|
{tab === 'auditoria' && esAdmin && <AuditoriaLog />}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -131,6 +131,40 @@ export async function crearUsuarioConfig(data: {
|
|||||||
return (await resp.json()) as UsuarioConfig
|
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 ─────────────────────────────────────────────────
|
// ─── Auditoría ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface AuditoriaItem {
|
export interface AuditoriaItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user