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,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.

View File

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

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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