Files
soft_usite/pkg/models/umind_agente.go
T
Lizandro GuarnizoandClaude Sonnet 5 5182c45d11 feat(studio): tarjetas de agente con señales de estado
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>
2026-08-14 22:09:32 -05:00

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
}