diff --git a/migrations/migrate.go b/migrations/migrate.go
index 48fd64e..2fc9d5f 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -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")
}
}
diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go
new file mode 100644
index 0000000..dce2624
--- /dev/null
+++ b/pkg/models/ai_config.go
@@ -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
+}
diff --git a/resources/views/ai_config.html b/resources/views/ai_config.html
new file mode 100644
index 0000000..8fb7ca4
--- /dev/null
+++ b/resources/views/ai_config.html
@@ -0,0 +1,277 @@
+
+
+
+
+

+
+
+
+
+
+
+
+
Configuraciones de IA
+
Gestiona las claves de API para Qwen, OpenAI y otros proveedores de IA.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Nombre |
+ Provider |
+ Modelo |
+ API Key |
+ Base URL |
+ Estado |
+ Acciones |
+
+
+
+
+ | Sin configuraciones |
+
+
+
+ |
+
+
+ |
+ |
+ |
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
¿Eliminar configuración?
+
Esta acción no se puede deshacer.
+
+
+
+
+
+
+
+
+
diff --git a/rest/controllers/ai_config_controller.go b/rest/controllers/ai_config_controller.go
new file mode 100644
index 0000000..4ca3797
--- /dev/null
+++ b/rest/controllers/ai_config_controller.go
@@ -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})
+}
diff --git a/rest/controllers/api/landing_controller.go b/rest/controllers/api/landing_controller.go
index c780826..37145e4 100644
--- a/rest/controllers/api/landing_controller.go
+++ b/rest/controllers/api/landing_controller.go
@@ -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,
+ })
+}
diff --git a/rest/routes/publicas.go b/rest/routes/publicas.go
index 5382cbd..bc2724a 100755
--- a/rest/routes/publicas.go
+++ b/rest/routes/publicas.go
@@ -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)
}
diff --git a/rest/routes/user.go b/rest/routes/user.go
index 9bad609..6ec69f0 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -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)