feat: uMind pasa a multi-agente por tenant

Un tenant (negocio/sitio, dueño de los dominios permitidos) puede tener
varios UmindAgente independientes (ej. "Ventas", "Soporte"), cada uno con
su propia config de IA, tono, base de conocimiento, tools, canales y
conexión de correo. El site_key también pasa a ser por agente, así cada
uno tiene su propio <script> de widget embebible y su propio color.

Backend:
- Nuevo modelo UmindAgente (pkg/models/umind_agente.go), con SiteKey,
  AiConfigID, Tono, MensajeBienvenida y Color — campos que antes vivían en
  UmindTenant y se sacan de ahí (las columnas viejas quedan huérfanas sin
  usar, no se hace DROP COLUMN).
- UmindDocumento, UmindChunk, UmindHerramienta, UmindCanal, UmindConexion y
  UmindMensaje pasan de TenantID a AgenteID. El campo se agrega sin
  "not null" para no romper el ALTER TABLE en Postgres sobre tablas que ya
  tienen filas (ej. emetropolitana).
- migrations.MigrarUmindAgentes(): idempotente, crea un agente "Principal"
  por cada tenant existente heredando lo que ya tenía configurado, y mueve
  sus datos de tenant_id a agente_id. Corre en cada arranque normal, mismo
  criterio que los Seed* — nada se rompe para los tenants ya en producción.
- Motor del agente, widget, canales (Telegram/WhatsApp) y OAuth de correo
  ahora operan sobre UmindAgente; el tenant solo se consulta para el
  chequeo de dominio permitido y el nombre del negocio que ve el visitante.

Frontend: nueva jerarquía de navegación tenant → lista de agentes
(TenantAgentes.vue) → detalle de un agente (AgenteDetail.vue, antes
TenantDetail.vue) con las mismas 6 tabs de siempre, ahora por agente. El
modal de tenant en el sidebar se achica a nombre/dominios/activo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-13 09:30:06 -05:00
co-authored by Claude Sonnet 5
parent 8eba3ab97f
commit f3f2f421d6
27 changed files with 760 additions and 368 deletions
+86
View File
@@ -119,11 +119,13 @@ func Migrate() {
&models.TelegramStaffToken{},
// uMind: chat con IA embebible por tenant (widget web + RAG)
&models.UmindTenant{},
&models.UmindAgente{},
&models.UmindDocumento{},
&models.UmindChunk{},
&models.UmindMensaje{},
&models.UmindHerramienta{},
&models.UmindCanal{},
&models.UmindConexion{},
// API Keys de /api/v2 (token + IP obligatoria + scopes)
&models.ApiKey{},
}
@@ -1364,3 +1366,87 @@ func SeedApiKeys() {
}
log.Println("[SEED] Seed de API Keys completado.")
}
// MigrarUmindAgentes crea un agente "Principal" por cada UmindTenant que
// todavía no tenga ninguno, heredando lo que antes vivía en el tenant
// (site_key, config de IA, tono, mensaje de bienvenida, color) y mueve los
// datos que ya tenía (documentos, chunks, tools, canales, conexiones,
// mensajes) de tenant_id a agente_id. Idempotente: un tenant que ya tiene
// al menos un agente se salta — así corre sola en cada arranque sin
// duplicar nada, mismo criterio que los Seed* de este archivo.
func MigrarUmindAgentes() {
db := app.Http.Database.DB
var tenants []models.UmindTenant
if err := db.Find(&tenants).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo umind_tenants: %v", err)
return
}
tablasConAgenteID := []string{
"umind_documentos", "umind_chunks", "umind_herramientas",
"umind_canales", "umind_conexiones", "umind_mensajes",
}
for _, t := range tenants {
var yaTieneAgente int64
if err := db.Model(&models.UmindAgente{}).Where("tenant_id = ?", t.ID).Count(&yaTieneAgente).Error; err != nil {
log.Printf("[MIGRACION] Error contando agentes del tenant %d: %v", t.ID, err)
continue
}
if yaTieneAgente > 0 {
continue
}
// Las columnas viejas (site_key, ai_config_id, tono,
// mensaje_bienvenida, color) ya no están en el struct Go UmindTenant,
// pero AutoMigrate nunca las borró de la tabla — se leen directo.
var viejo struct {
SiteKey string
AiConfigID *uint
Tono string
MensajeBienvenida string
Color string
}
if err := db.Table("umind_tenants").
Select("site_key, ai_config_id, tono, mensaje_bienvenida, color").
Where("id = ?", t.ID).Scan(&viejo).Error; err != nil {
log.Printf("[MIGRACION] Error leyendo datos viejos del tenant %d: %v", t.ID, err)
continue
}
agente := models.UmindAgente{
TenantID: t.ID,
Nombre: "Principal",
SiteKey: viejo.SiteKey,
AiConfigID: viejo.AiConfigID,
Tono: viejo.Tono,
MensajeBienvenida: viejo.MensajeBienvenida,
Color: viejo.Color,
Activo: true,
}
if agente.SiteKey == "" {
key, err := models.GenerarSiteKey()
if err != nil {
log.Printf("[MIGRACION] Error generando site_key para el agente del tenant %d: %v", t.ID, err)
continue
}
agente.SiteKey = key
}
if agente.Color == "" {
agente.Color = "#8eb02f"
}
if err := db.Create(&agente).Error; err != nil {
log.Printf("[MIGRACION] Error creando agente Principal del tenant %d: %v", t.ID, err)
continue
}
for _, tabla := range tablasConAgenteID {
sql := fmt.Sprintf("UPDATE %s SET agente_id = ? WHERE tenant_id = ? AND (agente_id IS NULL OR agente_id = 0)", tabla)
if err := db.Exec(sql, agente.ID, t.ID).Error; err != nil {
log.Printf("[MIGRACION] Error moviendo %s del tenant %d al agente %d: %v", tabla, t.ID, agente.ID, err)
}
}
log.Printf("[MIGRACION] Tenant %d (%s): agente 'Principal' creado (id=%d), datos migrados", t.ID, t.Nombre, agente.ID)
}
}