fix(ai-config): editar cualquier config dejaba al agente sin cerebro
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a3a8c9248
commit
f5ffed7a13
+33
-19
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,26 @@
|
||||
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input x-model="form.es_agente_bot" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
|
||||
<span class="text-sm text-gray-700">Es el cerebro del agente (bot de Telegram y chat del panel)</span>
|
||||
</label>
|
||||
<p class="text-[11px] text-gray-400 mt-1 ml-6">
|
||||
Solo una config puede serlo: al marcar esta, se desmarca la anterior.
|
||||
</p>
|
||||
<div x-show="form.es_agente_bot" x-cloak class="mt-2 ml-6">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Bot de Telegram (opcional)</label>
|
||||
<select x-model="form.telegram_config_id"
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
|
||||
<option value="">Sin bot asignado</option>
|
||||
<template x-for="t in telegramConfigs" :key="t.ID">
|
||||
<option :value="t.ID" x-text="t.nombre"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label>
|
||||
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
|
||||
@@ -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) })
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user