From f5ffed7a13aebc01ec636921caf24cb8c0fc68d0 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:16:15 -0500 Subject: [PATCH] fix(ai-config): editar cualquier config dejaba al agente sin cerebro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit es_agente_bot no se podía marcar desde ninguna pantalla —el formulario nunca mandaba el campo— y el update lo escribía igual con el valor cero. O sea que guardar cualquier config desde /app/ai-config apagaba el cerebro del bot de Telegram y del chat del panel, y no había forma de volver a prenderlo salvo tocando la base. - El update solo escribe los campos que vinieron en el body. - El formulario tiene la casilla y el selector de bot de Telegram. - Marcar una desmarca la anterior: GetAgenteBotAiConfig hace First(), así que con dos marcadas ganaba la que estuviera primero en la tabla. Y el otro comportamiento raro: cuando ningún módulo coincidía, se usaba "cualquier config activa". Eso podía elegir la de embeddings o la de Whisper, que no conversan — el error que llegaba era del proveedor y no se parecía en nada a la causa. Ahora esas quedan excluidas del comodín y, si no queda ninguna usable, el error dice qué módulo asignar y dónde. De paso: la etiqueta "IA / vCard" mentía (ese módulo alimenta además soporte, el chat del panel y las plantillas), el comentario de is_active decía "solo uno activo a la vez" cuando hace falta uno por módulo, y GetActiveAiConfig no la usaba nadie. Co-Authored-By: Claude Opus 5 --- pkg/models/ai_config.go | 52 +++++++++++++++--------- pkg/models/ai_config_test.go | 23 +++++++++++ resources/views/ai_config.html | 39 +++++++++++++++--- rest/controllers/ai_config_controller.go | 35 ++++++++++++---- 4 files changed, 116 insertions(+), 33 deletions(-) create mode 100644 pkg/models/ai_config_test.go diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go index 83ce715..b7989b6 100644 --- a/pkg/models/ai_config.go +++ b/pkg/models/ai_config.go @@ -19,7 +19,7 @@ type AiConfig struct { ApiKey string `gorm:"type:text;not null" json:"api_key"` // Clave de API BaseURL string `gorm:"type:text" json:"base_url"` // URL base (override), vacío = default del provider ModelName string `gorm:"size:100" json:"model_name"` // ej: qwen2.5-72b-instruct - IsActive bool `gorm:"default:true" json:"is_active"` // Solo uno activo a la vez + IsActive bool `gorm:"default:true" json:"is_active"` // varias pueden estar activas: una por módulo Notes string `gorm:"type:text" json:"notes"` // Modulo indica a qué servicio pertenece esta config. // "" = global (disponible para todos como fallback) @@ -108,21 +108,6 @@ func GetAiConfigByID(id uint, out *AiConfig) error { return app.Http.Database.DB.First(out, id).Error } -// GetActiveAiConfig retorna la primera configuración activa del provider indicado. -// Si provider está vacío, retorna cualquier config activa. -func GetActiveAiConfig(provider string) (*AiConfig, error) { - var item AiConfig - db := app.Http.Database.DB.Where("is_active = ?", true) - if provider != "" { - db = db.Where("provider = ?", provider) - } - if err := db.First(&item).Error; err != nil { - log.Printf("[AI_CONFIG] No se encontró config activa para provider '%s': %v", provider, err) - return nil, err - } - return &item, nil -} - // SplitModulos parte el campo Modulo (comma-separated) en un slice limpio. // "" → [] (config global), "landing,query_runner" → ["landing","query_runner"] func SplitModulos(modulo string) []string { @@ -171,6 +156,15 @@ func GetAiConfigSelectPorTenants(tenantIDs []uint) ([]AiConfig, error) { return items, nil } +// QuitarAgenteBotSalvo deja como cerebro del agente solo a la config indicada. +// GetAgenteBotAiConfig hace First() sobre es_agente_bot: con dos marcadas, cuál +// gana depende del orden de la tabla, que no es una forma de elegir nada. +func QuitarAgenteBotSalvo(id uint) { + app.Http.Database.DB.Model(&AiConfig{}). + Where("id <> ? AND es_agente_bot = ?", id, true). + Update("es_agente_bot", false) +} + // GetAgenteBotConfig retorna la config marcada como agente Telegram, con su TelegramConfig cargada. func GetAgenteBotConfig() (*AiConfig, *TelegramConfig, error) { var ai AiConfig @@ -230,9 +224,29 @@ func GetAiConfigForService(service string) (*AiConfig, error) { } } - // 3. Cualquier config activa como último recurso - log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service) - return &items[0], nil + // 3. Cualquier config activa como último recurso, pero nunca una que esté + // dedicada a un servicio que no sabe conversar: la de embeddings devuelve + // vectores y la de Whisper transcribe audio. Caer ahí daba errores del + // proveedor imposibles de relacionar con esta elección. + for i := range items { + if esConfigDeUsoEspecial(items[i].Modulo) { + continue + } + log.Printf("[AI_CONFIG] Sin config para %q, se usa %q (que no la declara)", service, items[i].Nombre) + return &items[i], nil + } + return nil, fmt.Errorf("no hay ninguna configuración de IA para %q: asignale ese módulo a una config en /app/ai-config", service) +} + +// esConfigDeUsoEspecial marca los módulos cuyo endpoint no es de chat, así que +// no sirven como comodín para otra cosa. +func esConfigDeUsoEspecial(modulo string) bool { + for _, m := range SplitModulos(modulo) { + if m == "whisper" || m == "umind_embeddings" { + return true + } + } + return false } // HayAiConfigParaModulo dice si alguna config activa declara ese módulo. diff --git a/pkg/models/ai_config_test.go b/pkg/models/ai_config_test.go new file mode 100644 index 0000000..0b1a90b --- /dev/null +++ b/pkg/models/ai_config_test.go @@ -0,0 +1,23 @@ +package models + +import "testing" + +// El fallback de GetAiConfigForService puede terminar usando una config que no +// declara el servicio pedido. Lo que no puede es agarrar una dedicada a +// embeddings o a Whisper: esos endpoints no conversan, y el error del proveedor +// no se parece en nada a la causa real. +func TestConfigsDeUsoEspecialNoSirvenDeComodin(t *testing.T) { + casos := map[string]bool{ + "whisper": true, + "umind_embeddings": true, + "landing,umind_embeddings": true, + "": false, + "ia": false, + "landing,query_runner": false, + } + for modulo, want := range casos { + if got := esConfigDeUsoEspecial(modulo); got != want { + t.Errorf("esConfigDeUsoEspecial(%q) = %v, want %v", modulo, got, want) + } + } +} diff --git a/resources/views/ai_config.html b/resources/views/ai_config.html index a2f9972..0713eef 100644 --- a/resources/views/ai_config.html +++ b/resources/views/ai_config.html @@ -200,6 +200,26 @@ +
+ +

+ Solo una config puede serlo: al marcar esta, se desmarca la anterior. +

+
+ + +
+
+
@@ -288,18 +308,26 @@ function aiConfigApp() { search: '', showModal: false, editItem: null, deleteId: null, testResult: null, errorMsg: '', successMsg: '', formError: '', - form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] }, + form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' }, + telegramConfigs: [], moduleOptions: [ { value: 'landing', label: 'Landing Generator' }, { value: 'query_runner', label: 'Query Runner SQL' }, - { value: 'ia', label: 'IA / vCard' }, + { value: 'ia', label: 'IA general (vCard, soporte, chat del panel)' }, { value: 'plantillas', label: 'Plantillas de documento (importar con IA)' }, { value: 'whisper', label: 'Transcripción de audio (Whisper)' }, { value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' }, ], - async init() { await this.load() }, + async init() { + try { + const r = await fetch('/app/loadtelegram') + const d = await r.json() + this.telegramConfigs = d.registros || d.items || d || [] + } catch { this.telegramConfigs = [] } + await this.load() + }, async load() { this.loading = true @@ -326,7 +354,7 @@ function aiConfigApp() { openAdd() { this.editItem = null - this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [] } + this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' } this.formError = '' this.showModal = true }, @@ -336,7 +364,7 @@ function aiConfigApp() { const modulos = item.modulo ? item.modulo.split(',').map(s => s.trim()).filter(s => s) : [] - this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos } + this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos, es_agente_bot: !!item.es_agente_bot, telegram_config_id: item.telegram_config_id || '' } this.formError = '' this.showModal = true }, @@ -347,6 +375,7 @@ function aiConfigApp() { this.saving = true; this.formError = '' const payload = { ...this.form, modulo: this.form.modulos.join(',') } delete payload.modulos + payload.telegram_config_id = payload.telegram_config_id ? parseInt(payload.telegram_config_id) : null const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config' const method = this.editItem ? 'PUT' : 'POST' const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) diff --git a/rest/controllers/ai_config_controller.go b/rest/controllers/ai_config_controller.go index cbf2f61..5b91538 100644 --- a/rest/controllers/ai_config_controller.go +++ b/rest/controllers/ai_config_controller.go @@ -126,6 +126,9 @@ func CreateAiConfigHandler(c *fiber.Ctx) error { if err := models.CreateAiConfig(&item); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + if item.EsAgenteBot { + models.QuitarAgenteBotSalvo(item.ID) + } return c.JSON(fiber.Map{"ok": true, "id": item.ID}) } @@ -153,21 +156,35 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } + // Qué campos vinieron de verdad. Sin esto, un formulario que no manda + // es_agente_bot lo guardaba en false: editar cualquier config desde el + // panel dejaba al bot de Telegram y al chat del dashboard sin cerebro. + var presentes map[string]json.RawMessage + _ = json.Unmarshal(c.Body(), &presentes) + vino := func(campo string) bool { _, ok := presentes[campo]; return ok } + updates := map[string]interface{}{ - "nombre": strings.TrimSpace(req.Nombre), - "provider": strings.ToLower(strings.TrimSpace(req.Provider)), - "base_url": strings.TrimSpace(req.BaseURL), - "model_name": strings.TrimSpace(req.ModelName), - "is_active": req.IsActive, - "notes": req.Notes, - "modulo": models.JoinModulos(strings.Split(req.Modulo, ",")), - "es_agente_bot": req.EsAgenteBot, - "telegram_config_id": req.TelegramConfigID, + "nombre": strings.TrimSpace(req.Nombre), + "provider": strings.ToLower(strings.TrimSpace(req.Provider)), + "base_url": strings.TrimSpace(req.BaseURL), + "model_name": strings.TrimSpace(req.ModelName), + "is_active": req.IsActive, + "notes": req.Notes, + "modulo": models.JoinModulos(strings.Split(req.Modulo, ",")), + } + if vino("es_agente_bot") { + updates["es_agente_bot"] = req.EsAgenteBot + } + if vino("telegram_config_id") { + updates["telegram_config_id"] = req.TelegramConfigID } if strings.TrimSpace(req.ApiKey) != "" { updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)) } + if req.EsAgenteBot && vino("es_agente_bot") { + models.QuitarAgenteBotSalvo(uint(id)) + } if err := models.UpdateAiConfig(uint(id), updates); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) }