up
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user