Prepara el terreno para que el cliente cargue su propia cuenta de IA sin que su clave quede en texto plano ni que el selector le muestre las de los demás. - ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se leen igual y quedan cifradas al primer guardado, sin script ni downtime. utils.Decrypt hace panic (no devuelve error) con entrada que no es un ciphertext válido, así que el fallback va sobre recover — eso mismo es el mecanismo de detección de "todavía está en claro". - Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram, Whisper, Query Runner, streaming de IA y Landing Generator. Un lector sin migrar mandaría el ciphertext como API key. - AiConfig gana TenantID (null = global del staff) y GetAiConfigSelectPorTenants para acotar el selector. - Test de los 4 casos del fallback, incluido hex válido que no descifra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
268 lines
9.0 KiB
Go
268 lines
9.0 KiB
Go
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()})
|
|
}
|
|
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"})
|
|
}
|
|
|
|
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, ",")),
|
|
"es_agente_bot": req.EsAgenteBot,
|
|
"telegram_config_id": req.TelegramConfigID,
|
|
}
|
|
if strings.TrimSpace(req.ApiKey) != "" {
|
|
updates["api_key"] = models.CifrarClaveAi(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})
|
|
}
|
|
|
|
// 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})
|
|
}
|