package controllers import ( "encoding/json" "math" "net/http" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/services" ) // 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"` Modulo string `json:"modulo"` EsAgenteBot bool `json:"es_agente_bot"` TelegramConfigID *uint `json:"telegram_config_id"` } safeItems := make([]safe, len(items)) for i, it := range items { // El hint sale de la clave en claro: sobre el ciphertext mostraría // los últimos 4 del hex, que no le sirven a nadie para reconocerla. clave := it.ClaveEnClaro() hint := "••••" if len(clave) > 4 { hint = "••••" + clave[len(clave)-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, Modulo: it.Modulo, EsAgenteBot: it.EsAgenteBot, TelegramConfigID: it.TelegramConfigID, } } 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"` Modulo string `json:"modulo"` EsAgenteBot bool `json:"es_agente_bot"` TelegramConfigID *uint `json:"telegram_config_id"` } 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: models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)), BaseURL: strings.TrimSpace(req.BaseURL), ModelName: strings.TrimSpace(req.ModelName), IsActive: req.IsActive, Notes: req.Notes, Modulo: models.JoinModulos(strings.Split(req.Modulo, ",")), EsAgenteBot: req.EsAgenteBot, TelegramConfigID: req.TelegramConfigID, } if err := models.CreateAiConfig(&item); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } if item.EsAgenteBot { models.QuitarAgenteBotSalvo(item.ID) } 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"` Modulo string `json:"modulo"` EsAgenteBot bool `json:"es_agente_bot"` TelegramConfigID *uint `json:"telegram_config_id"` } var req Req if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"}) } // Qué campos vinieron de verdad. Sin esto, un formulario que no manda // es_agente_bot lo guardaba en false: editar cualquier config desde el // panel dejaba al bot de Telegram y al chat del dashboard sin cerebro. var presentes map[string]json.RawMessage _ = json.Unmarshal(c.Body(), &presentes) vino := func(campo string) bool { _, ok := presentes[campo]; return ok } 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, "modulo": models.JoinModulos(strings.Split(req.Modulo, ",")), } if vino("es_agente_bot") { updates["es_agente_bot"] = req.EsAgenteBot } if vino("telegram_config_id") { updates["telegram_config_id"] = req.TelegramConfigID } if strings.TrimSpace(req.ApiKey) != "" { updates["api_key"] = models.CifrarClaveAi(strings.TrimSpace(req.ApiKey)) } if req.EsAgenteBot && vino("es_agente_bot") { models.QuitarAgenteBotSalvo(uint(id)) } 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}) } // GetAiConfigSelect devuelve lista simple para selects func GetAiConfigSelect(c *fiber.Ctx) error { items, err := models.GetAiConfigSelect() if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"registros": items}) } // TestAiConfigHandler verifica conectividad con el provider de la config. func TestAiConfigHandler(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"}) } var item models.AiConfig if err := models.GetAiConfigByID(uint(id), &item); err != nil { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "config no encontrada"}) } client := &http.Client{Timeout: 10 * time.Second} var testURL string var req *http.Request clave := item.ClaveEnClaro() switch item.Provider { case "ollama": base := strings.TrimSuffix(strings.TrimRight(item.BaseURL, "/"), "/v1") testURL = base + "/api/tags" req, _ = http.NewRequest("GET", testURL, nil) if clave != "" && clave != "ollama" { req.SetBasicAuth("ollama", clave) } case "anthropic": // Anthropic no tiene /models; usamos /v1/models igual (devuelve 200 con lista) base := item.BaseURL if base == "" { base = "https://api.anthropic.com/v1" } testURL = base + "/models" req, _ = http.NewRequest("GET", testURL, nil) req.Header.Set("x-api-key", clave) req.Header.Set("anthropic-version", "2023-06-01") default: // Mismo mapeo proveedor→URL que usa el chat/embeddings real // (services.ProviderDefaultURL) — antes esta prueba tenía su propia // copia desactualizada que no conocía proveedores nuevos como Gemini, // así que "funcionaba" en el chat pero la prueba de conexión fallaba. base := item.BaseURL if base == "" { base = services.ProviderDefaultURL(item.Provider) } testURL = base + "/models" req, _ = http.NewRequest("GET", testURL, nil) req.Header.Set("Authorization", "Bearer "+clave) } resp, err := client.Do(req) if err != nil { return c.JSON(fiber.Map{"ok": false, "error": err.Error()}) } defer resp.Body.Close() if resp.StatusCode >= 400 { var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) return c.JSON(fiber.Map{"ok": false, "status": resp.StatusCode, "error": body}) } // Para Ollama devolvemos la lista de modelos disponibles if item.Provider == "ollama" { var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode, "data": body}) } return c.JSON(fiber.Map{"ok": true, "status": resp.StatusCode}) } // 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}) }