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 @@ + +
+ +
+ Cargando... +
+ +
+ + +
+
+

Configuraciones de IA

+

Gestiona las claves de API para Qwen, OpenAI y otros proveedores de IA.

+
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + +
NombreProviderModeloAPI KeyBase URLEstadoAcciones
+
+ + +
+ +
+
+ + +
+
+

+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+
+ + +
+
+ + + +

¿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)