La lista de agentes era un directorio: nombre y activo/inactivo. No decía si el agente estaba realmente listo para atender. - Tarjetas en grid con el color de marca del agente (el mismo que ve el visitante en el widget) y sus iniciales. - Tres datos por agente: conversaciones de los últimos 7 días, fuentes de conocimiento y canales activos. Salen de tres consultas agrupadas, no de tres por agente: el N+1 en un listado es el que después no se saca. - Badge de estado accionable. "sin conocimiento" cuando el agente está activo pero no tiene ninguna fuente — ese agente responde de memoria e inventa datos, que es exactamente el problema de la URL falsa, y hasta ahora no se veía hasta que un cliente recibía la respuesta inventada. - Esqueleto de carga: la pantalla hace 3 llamadas y quedaba en blanco el tiempo suficiente como para parecer rota. Arregla además el fondo en modo oscuro: index.html traía <body class="bg-gray-50">, y esa utilidad le ganaba a la regla de body de la capa base, así que el área principal quedaba clara con el tema oscuro puesto (medido: --fondo resolvía a 9 13 22 pero el body pintaba 249 250 251). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
4.9 KiB
Go
145 lines
4.9 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// UmindAgente es un agente de IA independiente dentro de un tenant — un
|
|
// mismo negocio puede tener varios (ej. "Ventas", "Soporte"), cada uno con
|
|
// su propia base de conocimiento, tools, canales y conexión de correo. Lo
|
|
// único que sigue siendo del tenant (no del agente) son los dominios
|
|
// permitidos y el nombre del negocio que se muestra al visitante — eso es
|
|
// del sitio, no de un agente puntual.
|
|
type UmindAgente struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
|
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"` // etiqueta interna, ej: "Ventas"
|
|
SiteKey string `json:"site_key" gorm:"column:site_key;uniqueIndex;size:40;not null"`
|
|
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
|
|
Tono string `json:"tono" gorm:"column:tono;type:text"`
|
|
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
|
|
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"`
|
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
|
}
|
|
|
|
func (UmindAgente) TableName() string { return "umind_agentes" }
|
|
|
|
func CreateUmindAgente(a *UmindAgente) error {
|
|
if a.SiteKey == "" {
|
|
key, err := GenerarSiteKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.SiteKey = key
|
|
}
|
|
if a.Color == "" {
|
|
a.Color = "#8eb02f"
|
|
}
|
|
return app.Http.Database.DB.Create(a).Error
|
|
}
|
|
|
|
func GetUmindAgentesByTenant(tenantID uint) ([]UmindAgente, error) {
|
|
var items []UmindAgente
|
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id ASC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func GetUmindAgenteByID(id uint) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.First(&a, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// GetUmindAgenteBySiteKey resuelve el agente a partir de la site_key pública
|
|
// que manda el widget. Solo hace match si el agente está activo.
|
|
func GetUmindAgenteBySiteKey(siteKey string) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.Where("site_key = ? AND activo = ?", siteKey, true).First(&a).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
func UpdateUmindAgente(id uint, updates map[string]interface{}) error {
|
|
return app.Http.Database.DB.Model(&UmindAgente{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
func DeleteUmindAgente(id uint) error {
|
|
return app.Http.Database.DB.Delete(&UmindAgente{}, id).Error
|
|
}
|
|
|
|
// GetUmindAgentePorSiteKey resuelve el agente por site_key SIN filtrar por
|
|
// activo, para que el llamador pueda distinguir "no existe" de "está
|
|
// apagado" y decirlo. GetUmindAgenteBySiteKey (que sí filtra) queda para
|
|
// quien solo necesita el camino feliz.
|
|
func GetUmindAgentePorSiteKey(siteKey string) (*UmindAgente, error) {
|
|
var a UmindAgente
|
|
if err := app.Http.Database.DB.Where("site_key = ?", siteKey).First(&a).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// ResumenAgente son las señales que hacen falta para saber de un vistazo si
|
|
// un agente está listo o le falta algo, sin entrar a configurarlo.
|
|
type ResumenAgente struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Documentos int64 `json:"documentos"`
|
|
Canales int64 `json:"canales"`
|
|
Conversaciones7d int64 `json:"conversaciones_7d"`
|
|
}
|
|
|
|
// GetResumenAgentes agrega las tres señales en tres consultas agrupadas, no
|
|
// en tres por agente: con 5 agentes la diferencia no se nota, pero el patrón
|
|
// N+1 en una pantalla de listado es el que después no se puede sacar.
|
|
func GetResumenAgentes(agenteIDs []uint) map[uint]*ResumenAgente {
|
|
out := make(map[uint]*ResumenAgente, len(agenteIDs))
|
|
for _, id := range agenteIDs {
|
|
out[id] = &ResumenAgente{AgenteID: id}
|
|
}
|
|
if len(agenteIDs) == 0 {
|
|
return out
|
|
}
|
|
db := app.Http.Database.DB
|
|
|
|
type fila struct {
|
|
AgenteID uint
|
|
Total int64
|
|
}
|
|
|
|
var docs []fila
|
|
db.Model(&UmindDocumento{}).Select("agente_id, COUNT(*) AS total").
|
|
Where("agente_id IN ? AND deleted_at IS NULL", agenteIDs).Group("agente_id").Scan(&docs)
|
|
for _, f := range docs {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Documentos = f.Total
|
|
}
|
|
}
|
|
|
|
var canales []fila
|
|
db.Model(&UmindCanal{}).Select("agente_id, COUNT(*) AS total").
|
|
Where("agente_id IN ? AND activo = ? AND deleted_at IS NULL", agenteIDs, true).Group("agente_id").Scan(&canales)
|
|
for _, f := range canales {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Canales = f.Total
|
|
}
|
|
}
|
|
|
|
// Conversaciones, no mensajes: lo que importa es a cuánta gente atendió.
|
|
var convs []fila
|
|
db.Model(&UmindMensaje{}).Select("agente_id, COUNT(DISTINCT session_id) AS total").
|
|
Where("agente_id IN ? AND created_at >= ? AND deleted_at IS NULL", agenteIDs, time.Now().AddDate(0, 0, -7)).
|
|
Group("agente_id").Scan(&convs)
|
|
for _, f := range convs {
|
|
if r, ok := out[f.AgenteID]; ok {
|
|
r.Conversaciones7d = f.Total
|
|
}
|
|
}
|
|
return out
|
|
}
|