This commit is contained in:
Lizandro Guarnizo
2026-06-02 15:41:20 -05:00
parent c5603c51f8
commit 1e1ba682b0
3 changed files with 110 additions and 44 deletions
+47 -22
View File
@@ -1,7 +1,9 @@
package models
import (
"fmt"
"log"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
@@ -70,39 +72,62 @@ func GetActiveAiConfig(provider string) (*AiConfig, error) {
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 {
var out []string
for _, s := range strings.Split(modulo, ",") {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
// JoinModulos normaliza y une un slice de módulos en comma-separated.
func JoinModulos(modules []string) string {
var clean []string
for _, s := range modules {
if s = strings.TrimSpace(s); s != "" {
clean = append(clean, s)
}
}
return strings.Join(clean, ",")
}
// GetAiConfigForService retorna la config activa asignada al módulo indicado.
// Lógica de prioridad:
// 1. Config activa con modulo == service (exclusiva del servicio)
// 1. Config activa con modulo conteniendo service (puede ser comma-separated)
// 2. Config activa con modulo == "" (global, fallback)
// 3. Cualquier config activa (último recurso)
func GetAiConfigForService(service string) (*AiConfig, error) {
var item AiConfig
var items []AiConfig
if err := app.Http.Database.DB.Where("is_active = ?", true).Order("id ASC").Find(&items).Error; err != nil {
return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
}
if len(items) == 0 {
return nil, fmt.Errorf("no hay configs de IA activas")
}
// 1. Buscar config específica para el servicio
// 1. Config específica para el servicio (puede tener varios módulos)
if service != "" {
err := app.Http.Database.DB.
Where("is_active = ? AND modulo = ?", true, service).
First(&item).Error
if err == nil {
return &item, nil
for i := range items {
for _, m := range SplitModulos(items[i].Modulo) {
if m == service {
return &items[i], nil
}
}
}
}
// 2. Buscar config global (modulo vacío)
err := app.Http.Database.DB.
Where("is_active = ? AND (modulo = '' OR modulo IS NULL)", true).
First(&item).Error
if err == nil {
return &item, nil
// 2. Config global (modulo vacío = sin restricción de servicio)
for i := range items {
if strings.TrimSpace(items[i].Modulo) == "" {
return &items[i], nil
}
}
// 3. Cualquier config activa como último recurso
err = app.Http.Database.DB.
Where("is_active = ?", true).
First(&item).Error
if err != nil {
log.Printf("[AI_CONFIG] No se encontró config activa para servicio '%s': %v", service, err)
return nil, err
}
return &item, nil
log.Printf("[AI_CONFIG] No se encontró config para servicio '%s', usando cualquier activa", service)
return &items[0], nil
}
+61 -20
View File
@@ -64,14 +64,22 @@
<tr class="hover:bg-gray-50 transition">
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
:class="{
'bg-blue-100 text-blue-700': item.modulo === 'landing',
'bg-[#e9f0cf] text-[#5a7a1e]': item.modulo === 'query_runner',
'bg-gray-100 text-gray-500': !item.modulo
}"
x-text="item.modulo === 'landing' ? 'Landing' : item.modulo === 'query_runner' ? 'Query Runner' : 'Global'">
</span>
<template x-if="!item.modulo">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
</template>
<template x-if="item.modulo">
<div class="flex flex-wrap gap-1">
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
:class="{
'bg-blue-100 text-blue-700': m.trim() === 'landing',
'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner'
}"
x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim()">
</span>
</template>
</div>
</template>
</td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
@@ -174,15 +182,29 @@
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Módulo / Servicio</label>
<select x-model="form.modulo"
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="">Global (usado por todos como fallback)</option>
<option value="landing">Landing Generator</option>
<option value="query_runner">Query Runner SQL</option>
</select>
<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">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox"
:checked="form.modulos.length === 0"
@change="form.modulos = []"
class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700">Global</span>
<span class="text-[10px] text-gray-400 ml-1">— fallback para todos los servicios</span>
</label>
<template x-for="opt in moduleOptions" :key="opt.value">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox"
:value="opt.value"
:checked="form.modulos.includes(opt.value)"
@change="toggleModulo(opt.value)"
class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700" x-text="opt.label"></span>
</label>
</template>
</div>
<p class="text-[10px] text-gray-400 mt-1">
Asigna esta IA a un servicio específico. "Global" actúa de fallback cuando un servicio no tiene IA propia.
"Global" actúa de fallback. Si seleccionas servicios específicos, solo se usará para ellos.
</p>
</div>
</div>
@@ -231,7 +253,12 @@ function aiConfigApp() {
search: '',
showModal: false, editItem: null, deleteId: null,
errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulo: '' },
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [] },
moduleOptions: [
{ value: 'landing', label: 'Landing Generator' },
{ value: 'query_runner', label: 'Query Runner SQL' },
],
async init() { await this.load() },
@@ -249,16 +276,28 @@ function aiConfigApp() {
goPage(p) { this.page = p; this.load() },
toggleModulo(value) {
const idx = this.form.modulos.indexOf(value)
if (idx >= 0) {
this.form.modulos.splice(idx, 1)
} else {
this.form.modulos.push(value)
}
},
openAdd() {
this.editItem = null
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulo: '' }
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [] }
this.formError = ''
this.showModal = true
},
openEdit(item) {
this.editItem = item
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, modulo: item.modulo || '' }
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.formError = ''
this.showModal = true
},
@@ -267,9 +306,11 @@ function aiConfigApp() {
async save() {
this.saving = true; this.formError = ''
const payload = { ...this.form, modulo: this.form.modulos.join(',') }
delete payload.modulos
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(this.form) })
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
const data = await res.json()
this.saving = false
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
+2 -2
View File
@@ -106,7 +106,7 @@ func CreateAiConfigHandler(c *fiber.Ctx) error {
ModelName: strings.TrimSpace(req.ModelName),
IsActive: req.IsActive,
Notes: req.Notes,
Modulo: strings.TrimSpace(req.Modulo),
Modulo: models.JoinModulos(strings.Split(req.Modulo, ",")),
}
if err := models.CreateAiConfig(&item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -143,7 +143,7 @@ func UpdateAiConfigHandler(c *fiber.Ctx) error {
"model_name": strings.TrimSpace(req.ModelName),
"is_active": req.IsActive,
"notes": req.Notes,
"modulo": strings.TrimSpace(req.Modulo),
"modulo": models.JoinModulos(strings.Split(req.Modulo, ",")),
}
if strings.TrimSpace(req.ApiKey) != "" {
updates["api_key"] = strings.TrimSpace(req.ApiKey)