This commit is contained in:
Lizandro Guarnizo
2026-05-27 10:16:28 -05:00
parent 5d07fac02d
commit 06d85dc0bf
7 changed files with 540 additions and 2 deletions
+2 -2
View File
@@ -760,10 +760,10 @@ func MigratePortal() {
// MigrateLanding crea/actualiza la tabla de sesiones del Landing Generator.
func MigrateLanding() {
db := app.Http.Database.DB
if err := db.AutoMigrate(&models.LandingSession{}); err != nil {
if err := db.AutoMigrate(&models.LandingSession{}, &models.AiConfig{}); err != nil {
log.Printf("[MIGRATE] Error en MigrateLanding: %v", err)
} else {
log.Println("[MIGRATE] Tabla landing_sessions OK")
log.Println("[MIGRATE] Tablas landing_sessions y ai_configs OK")
}
}
+66
View File
@@ -0,0 +1,66 @@
package models
import (
"log"
"github.com/sujit-baniya/fiber-boilerplate/app"
"gorm.io/gorm"
)
// AiConfig almacena las configuraciones de proveedores de IA (Qwen, OpenAI, etc.)
// que son usadas por el Landing Generator y otros módulos.
type AiConfig struct {
gorm.Model
Nombre string `gorm:"size:100;not null" json:"nombre"` // Alias amigable, ej: "Qwen 2.5 Producción"
Provider string `gorm:"size:50;not null" json:"provider"` // qwen | openai | anthropic | etc
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
Notes string `gorm:"type:text" json:"notes"`
}
func (AiConfig) TableName() string { return "ai_configs" }
func GetAllAiConfigs(limit, offset int, search string) ([]AiConfig, int64, error) {
var items []AiConfig
var total int64
db := app.Http.Database.DB.Model(&AiConfig{})
if search != "" {
db = db.Where("nombre LIKE ? OR provider LIKE ?", "%"+search+"%", "%"+search+"%")
}
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("id DESC").Limit(limit).Offset(offset).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func CreateAiConfig(item *AiConfig) error {
return app.Http.Database.DB.Create(item).Error
}
func UpdateAiConfig(id uint, updates map[string]interface{}) error {
return app.Http.Database.DB.Model(&AiConfig{}).Where("id = ?", id).Updates(updates).Error
}
func DeleteAiConfig(id uint) error {
return app.Http.Database.DB.Delete(&AiConfig{}, 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
}
+277
View File
@@ -0,0 +1,277 @@
<!-- Vista: Configuraciones de IA — Qwen, OpenAI, etc. -->
<div x-data="aiConfigApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">Configuraciones de IA</h1>
<p class="text-xs text-slate-500 mt-0.5">Gestiona las claves de API para Qwen, OpenAI y otros proveedores de IA.</p>
</div>
<button @click="openAdd()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Nueva configuración
</button>
</div>
<!-- Alerta -->
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
<!-- Búsqueda -->
<div class="flex flex-col sm:flex-row gap-3 mb-5">
<input x-model="search" @keyup.enter="load()" type="text" placeholder="Buscar por nombre o provider..."
class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="load()" :disabled="loading"
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>
<span x-text="loading ? 'Cargando...' : 'Buscar'"></span>
</button>
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Provider</th>
<th class="py-2 px-3">Modelo</th>
<th class="py-2 px-3">API Key</th>
<th class="py-2 px-3">Base URL</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-if="items.length === 0">
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin configuraciones</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<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-purple-100 text-purple-700': item.provider === 'qwen',
'bg-green-100 text-green-700': item.provider === 'openai',
'bg-orange-100 text-orange-700': item.provider === 'anthropic',
'bg-gray-100 text-gray-700': !['qwen','openai','anthropic'].includes(item.provider)
}" x-text="item.provider"></span>
</td>
<td class="py-2 px-3 text-gray-500 font-mono text-xs" x-text="item.model_name || '—'"></td>
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.api_key_hint"></td>
<td class="py-2 px-3 text-xs text-gray-400" x-text="item.base_url || 'default'"></td>
<td class="py-2 px-3">
<span :class="item.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="px-2 py-0.5 rounded-full text-xs font-semibold"
x-text="item.is_active ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2">
<button @click="openEdit(item)"
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
<button @click="confirmDelete(item.ID)"
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div x-show="totalPages > 1" class="flex justify-center gap-1 mt-5">
<template x-for="p in totalPages" :key="p">
<button @click="goPage(p)" :class="p === page ? 'bg-[#8eb02f] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="w-8 h-8 rounded text-sm font-medium transition" x-text="p"></button>
</template>
</div>
</div>
<!-- Modal crear / editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar configuración' : 'Nueva configuración de IA'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
<input x-model="form.nombre" type="text" required placeholder="Ej: Qwen 2.5 Producción"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Provider *</label>
<select x-model="form.provider" required
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="">Seleccionar...</option>
<option value="qwen">Qwen (Alibaba)</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="groq">Groq</option>
<option value="otro">Otro</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Modelo</label>
<input x-model="form.model_name" type="text" placeholder="Ej: qwen2.5-72b-instruct"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">
API Key <span x-show="editItem" class="text-gray-400">(dejar vacío para no cambiar)</span> <span x-show="!editItem">*</span>
</label>
<input x-model="form.api_key" type="password" autocomplete="new-password"
:required="!editItem"
placeholder="sk-..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] font-mono" />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL <span class="text-gray-400">(opcional — dejar vacío para usar el default del provider)</span></label>
<input x-model="form.base_url" type="url"
placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Notas</label>
<textarea x-model="form.notes" rows="2" placeholder="Uso, límites, etc."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
</div>
<div class="flex items-center gap-2">
<input x-model="form.is_active" type="checkbox" id="ai_is_active" class="rounded" />
<label for="ai_is_active" class="text-sm text-gray-700">Activo (usado por Landing Generator)</label>
</div>
</div>
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
<div class="flex justify-end gap-3 mt-5">
<button type="button" @click="closeModal()"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
<button type="submit" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal confirmar eliminación -->
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-red-500 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<p class="text-gray-700 font-semibold mb-1">¿Eliminar configuración?</p>
<p class="text-xs text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="doDelete()" :disabled="saving"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
</button>
</div>
</div>
</div>
</div>
<script>
function aiConfigApp() {
return {
loading: false, saving: false,
items: [], total: 0, totalPages: 1, page: 1,
search: '',
showModal: false, editItem: null, deleteId: null,
errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '' },
async init() { await this.load() },
async load() {
this.loading = true
this.errorMsg = ''
const res = await fetch(`/app/ai-config/list?page=${this.page}&search=${encodeURIComponent(this.search)}`)
const data = await res.json()
this.loading = false
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
this.items = data.items || []
this.total = data.total
this.totalPages = data.totalPages
},
goPage(p) { this.page = p; this.load() },
openAdd() {
this.editItem = null
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '' }
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 }
this.formError = ''
this.showModal = true
},
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
async save() {
this.saving = true; this.formError = ''
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 data = await res.json()
this.saving = false
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
this.closeModal()
this.showSuccess(this.editItem ? 'Configuración actualizada' : 'Configuración creada')
await this.load()
},
confirmDelete(id) { this.deleteId = id },
async doDelete() {
this.saving = true
const res = await fetch(`/app/ai-config/${this.deleteId}`, { method: 'DELETE' })
this.saving = false
this.deleteId = null
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
this.showSuccess('Configuración eliminada')
await this.load()
},
showSuccess(msg) {
this.successMsg = msg
setTimeout(() => { this.successMsg = '' }, 3000)
}
}
}
</script>
+162
View File
@@ -0,0 +1,162 @@
package controllers
import (
"math"
"strconv"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// AiConfigIndex renderiza la vista del panel de configuraciones de IA.
func AiConfigIndex(c *fiber.Ctx) error {
data := fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
}
return c.Render("ai_config", data, "layouts/main")
}
// GetAiConfigs devuelve la lista paginada en JSON.
func GetAiConfigs(c *fiber.Ctx) error {
page, _ := strconv.Atoi(c.Query("page", "1"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
search := c.Query("search", "")
items, total, err := models.GetAllAiConfigs(limit, offset, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Ocultar la API key en el listado (mostrar solo últimos 4 chars)
type safe struct {
ID uint `json:"ID"`
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKeyHint string `json:"api_key_hint"`
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
IsActive bool `json:"is_active"`
Notes string `json:"notes"`
}
safeItems := make([]safe, len(items))
for i, it := range items {
hint := "••••"
if len(it.ApiKey) > 4 {
hint = "••••" + it.ApiKey[len(it.ApiKey)-4:]
}
safeItems[i] = safe{
ID: it.ID,
Nombre: it.Nombre,
Provider: it.Provider,
ApiKeyHint: hint,
BaseURL: it.BaseURL,
ModelName: it.ModelName,
IsActive: it.IsActive,
Notes: it.Notes,
}
}
return c.JSON(fiber.Map{
"items": safeItems,
"total": total,
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
"page": page,
})
}
// CreateAiConfigHandler crea una nueva configuración de IA.
func CreateAiConfigHandler(c *fiber.Ctx) error {
type Req struct {
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKey string `json:"api_key"`
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
IsActive bool `json:"is_active"`
Notes string `json:"notes"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
if strings.TrimSpace(req.Nombre) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
}
if strings.TrimSpace(req.Provider) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "provider es requerido"})
}
if strings.TrimSpace(req.ApiKey) == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "api_key es requerido"})
}
item := models.AiConfig{
Nombre: strings.TrimSpace(req.Nombre),
Provider: strings.ToLower(strings.TrimSpace(req.Provider)),
ApiKey: strings.TrimSpace(req.ApiKey),
BaseURL: strings.TrimSpace(req.BaseURL),
ModelName: strings.TrimSpace(req.ModelName),
IsActive: req.IsActive,
Notes: req.Notes,
}
if err := models.CreateAiConfig(&item); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true, "id": item.ID})
}
// UpdateAiConfigHandler actualiza una configuración de IA existente.
func UpdateAiConfigHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
type Req struct {
Nombre string `json:"nombre"`
Provider string `json:"provider"`
ApiKey string `json:"api_key"` // vacío = no cambiar
BaseURL string `json:"base_url"`
ModelName string `json:"model_name"`
IsActive bool `json:"is_active"`
Notes string `json:"notes"`
}
var req Req
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
}
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,
}
if strings.TrimSpace(req.ApiKey) != "" {
updates["api_key"] = strings.TrimSpace(req.ApiKey)
}
if err := models.UpdateAiConfig(uint(id), updates); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
// DeleteAiConfigHandler elimina una configuración de IA.
func DeleteAiConfigHandler(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "id inválido"})
}
if err := models.DeleteAiConfig(uint(id)); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"ok": true})
}
@@ -357,3 +357,27 @@ func LandingAdminList(c *fiber.Ctx) error {
"page": page,
})
}
// ─── GET /landing/ai-config ───────────────────────────────────────────────────
// LandingGetAiConfig devuelve la configuración de IA activa para el Landing Generator.
// Protegido por X-Landing-Secret.
func LandingGetAiConfig(c *fiber.Ctx) error {
if !landingSecret(c) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no autorizado"})
}
config, err := models.GetActiveAiConfig("qwen")
if err != nil {
// Intentar cualquier provider activo como fallback
config, err = models.GetActiveAiConfig("")
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "no hay configuración de IA activa"})
}
}
return c.JSON(fiber.Map{
"provider": config.Provider,
"api_key": config.ApiKey,
"base_url": config.BaseURL,
"model_name": config.ModelName,
})
}
+2
View File
@@ -74,4 +74,6 @@ func RutasPublicas(web fiber.Router) {
web.Post("/landing/payment", apiControllers.LandingCreatePayment)
web.Get("/landing/answers/:token", apiControllers.LandingGetAnswers)
web.Get("/landing/download/:token", apiControllers.LandingDownload)
// Config de IA activa (para Landing Generator)
web.Get("/landing/ai-config", apiControllers.LandingGetAiConfig)
}
+7
View File
@@ -277,6 +277,13 @@ func UserRoutes(app fiber.Router) {
protected.Get("/saas-api/logs", middlewares.MenuMiddleware, controllers.SaasDispatchLogIndex)
protected.Get("/loadsaasdispatchlogs", controllers.GetSaasDispatchLogs)
// ─── Configuraciones de IA (Qwen, OpenAI, etc.) ─────────────────────────
protected.Get("/ai-config", middlewares.MenuMiddleware, controllers.AiConfigIndex)
protected.Get("/ai-config/list", controllers.GetAiConfigs)
protected.Post("/ai-config", controllers.CreateAiConfigHandler)
protected.Put("/ai-config/:id", controllers.UpdateAiConfigHandler)
protected.Delete("/ai-config/:id", controllers.DeleteAiConfigHandler)
// ─── Alibaba Cloud OSS API ────────────────────────────────────────────────
protected.Get("/oss-api", middlewares.MenuMiddleware, controllers.OssApiIndex)
protected.Get("/loadossapi", controllers.GetOssApiConfigs)