Logo. Hasta ahora no había: era el texto "uM" en un cuadrado, con tamaño y esquinas distintas en cada pantalla. Ahora hay un mark — la "u" del nombre con el punto de pulso integrado — probado a 16px, 20px y 32px sobre claro y oscuro, que es donde se cae la mayoría de los logos. Reemplaza el texto en el portal, el header, el sidebar del Studio y la promo, y va también de favicon. Plantillas por rubro. Un agente recién creado no sabía nada, y escribir el conocimiento desde cero frente a un campo en blanco es donde la mayoría abandona. Al crear uno se puede elegir un punto de partida —tienda, restaurante, consultorio, servicios, peluquería o la base genérica— y arranca con sus notas ya escritas, listas para editar. Los valores de ejemplo van entre corchetes a propósito, para que se vea que hay que reemplazarlos: una nota que parezca dato real termina en boca del asistente. Un test lo verifica nota por nota. Y la plantilla de salud abre con el límite — el asistente no da consejo médico ni interpreta síntomas — porque es el único rubro donde una respuesta inventada hace daño de verdad. El catálogo vive en código, no en base: es contenido editorial que mejoramos nosotros, no configuración que cada instalación toca por su lado. Las notas se cargan en segundo plano porque cada una necesita sus embeddings, y hacer esperar el alta sería castigar justo al que eligió plantilla. Con las opciones nuevas el formulario pasaba de largo la pantalla y el botón de guardar quedaba abajo del borde: el modal ahora tiene su propio scroll. Y la página promocional de uMind queda servida en /umind.html. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1043 lines
39 KiB
Go
1043 lines
39 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
"github.com/sujit-baniya/fiber-boilerplate/rest/middlewares"
|
|
)
|
|
|
|
// UmindIndex renderiza el panel de administración de uMind.
|
|
func UmindIndex(c *fiber.Ctx) error {
|
|
return c.Render("umind", fiber.Map{
|
|
"user": c.Locals("user"),
|
|
"modules": c.Locals("userModules"),
|
|
}, "layouts/main")
|
|
}
|
|
|
|
var umindColorHexRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
|
|
// umindColor valida el hex del color de marca del widget; si viene vacío o
|
|
// inválido cae al verde por defecto en vez de guardar basura (el valor se
|
|
// aplica tal cual como CSS custom property en el widget embebido).
|
|
func umindColor(color string) string {
|
|
color = strings.TrimSpace(color)
|
|
if umindColorHexRegex.MatchString(color) {
|
|
return color
|
|
}
|
|
return "#8eb02f"
|
|
}
|
|
|
|
// ─── Tenants ─────────────────────────────────────────────────────────────────
|
|
// Un tenant es el negocio/sitio dueño de los dominios permitidos — la config
|
|
// de IA, tono, tools, etc. viven en sus UmindAgente (ver más abajo).
|
|
|
|
func GetUmindTenants(c *fiber.Ctx) error {
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := 20
|
|
offset := (page - 1) * limit
|
|
items, total, err := models.GetAllUmindTenants(limit, offset)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
items = tenantsVisibles(c, items)
|
|
if _, esStaff := middlewares.UmindScopeDe(c); !esStaff {
|
|
// Para el cliente la paginación global no tiene sentido (ve pocos
|
|
// tenants); el total pasa a ser el de lo que realmente puede ver.
|
|
total = int64(len(items))
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"items": items,
|
|
"total": total,
|
|
"totalPages": int(math.Ceil(float64(total) / float64(limit))),
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
type umindTenantReq struct {
|
|
Nombre string `json:"nombre"`
|
|
DominiosPermitidos []string `json:"dominios_permitidos"`
|
|
Activo bool `json:"activo"`
|
|
ClienteID *uint `json:"cliente_id"`
|
|
PlanID *uint `json:"plan_id"`
|
|
}
|
|
|
|
func (r umindTenantReq) dominiosLimpios() []string {
|
|
var out []string
|
|
for _, d := range r.DominiosPermitidos {
|
|
d = strings.ToLower(strings.TrimSpace(d))
|
|
if d != "" {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func CreateUmindTenantHandler(c *fiber.Ctx) error {
|
|
var req umindTenantReq
|
|
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"})
|
|
}
|
|
dominios := req.dominiosLimpios()
|
|
if len(dominios) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agrega al menos un dominio permitido"})
|
|
}
|
|
|
|
tenant := &models.UmindTenant{
|
|
Nombre: strings.TrimSpace(req.Nombre),
|
|
DominiosPermitidos: strings.Join(dominios, ","),
|
|
Activo: true,
|
|
CreadoPorID: extraerUserID(c),
|
|
ClienteID: req.ClienteID,
|
|
PlanID: req.PlanID,
|
|
}
|
|
if err := models.CreateUmindTenant(tenant); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": tenant.ID})
|
|
}
|
|
|
|
func UpdateUmindTenantHandler(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 := accesoTenant(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindTenantReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
dominios := req.dominiosLimpios()
|
|
updates := map[string]interface{}{
|
|
"nombre": strings.TrimSpace(req.Nombre),
|
|
"dominios_permitidos": strings.Join(dominios, ","),
|
|
"activo": req.Activo,
|
|
"cliente_id": req.ClienteID,
|
|
"plan_id": req.PlanID,
|
|
}
|
|
if err := models.UpdateUmindTenant(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindTenantHandler(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 := accesoTenant(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindTenant(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Agentes ─────────────────────────────────────────────────────────────────
|
|
// Un tenant puede tener varios agentes independientes (ej. "Ventas",
|
|
// "Soporte"), cada uno con su propia config de IA, base de conocimiento,
|
|
// tools, canales y conexión de correo.
|
|
|
|
func GetUmindAgentesHandler(c *fiber.Ctx) error {
|
|
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
|
if err != nil || tenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindAgentesByTenant(uint(tenantID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// El cupo viaja con la lista para que la pantalla pueda mostrar "1 de 1"
|
|
// en vez de dejar que el límite aparezca recién como un error al guardar.
|
|
// Sin plan no hay límite (ver verificarCupoAgentes), y eso también hay que
|
|
// poder verlo: si no, un tenant sin plan parece un límite que no funciona.
|
|
ids := make([]uint, len(items))
|
|
for i, a := range items {
|
|
ids[i] = a.ID
|
|
}
|
|
resp := fiber.Map{
|
|
"items": items,
|
|
"usados": len(items),
|
|
"resumen": models.GetResumenAgentes(ids),
|
|
}
|
|
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
|
resp["plan"] = fiber.Map{"nombre": plan.Nombre, "max_agentes": plan.MaxAgentes}
|
|
}
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
type umindAgenteReq struct {
|
|
TenantID uint `json:"tenant_id"`
|
|
Nombre string `json:"nombre"`
|
|
AiConfigID *uint `json:"ai_config_id"`
|
|
Tono string `json:"tono"`
|
|
MensajeBienvenida string `json:"mensaje_bienvenida"`
|
|
Color string `json:"color"`
|
|
Activo bool `json:"activo"`
|
|
// PlantillaRubro precarga el conocimiento base de un rubro al crear el
|
|
// agente. Vacío = agente en blanco, como era antes.
|
|
PlantillaRubro string `json:"plantilla_rubro"`
|
|
}
|
|
|
|
func CreateUmindAgenteHandler(c *fiber.Ctx) error {
|
|
var req umindAgenteReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.TenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if strings.TrimSpace(req.Nombre) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "nombre es requerido"})
|
|
}
|
|
if err := accesoTenant(c, req.TenantID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := models.GetUmindTenantByID(req.TenantID); err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant no encontrado"})
|
|
}
|
|
if err := verificarCupoAgentes(c, req.TenantID); err != nil {
|
|
return err
|
|
}
|
|
|
|
agente := &models.UmindAgente{
|
|
TenantID: req.TenantID,
|
|
Nombre: strings.TrimSpace(req.Nombre),
|
|
AiConfigID: req.AiConfigID,
|
|
Tono: req.Tono,
|
|
MensajeBienvenida: req.MensajeBienvenida,
|
|
Color: umindColor(req.Color),
|
|
Activo: true,
|
|
}
|
|
if err := models.CreateUmindAgente(agente); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
// El conocimiento de la plantilla se carga en segundo plano: cada nota
|
|
// necesita sus embeddings y el agente ya existe. Hacer esperar el alta por
|
|
// esto sería castigar justo al que eligió una plantilla.
|
|
if clave := strings.TrimSpace(req.PlantillaRubro); clave != "" {
|
|
go services.AplicarPlantillaRubro(agente.ID, clave)
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": agente.ID, "site_key": agente.SiteKey})
|
|
}
|
|
|
|
func UpdateUmindAgenteHandler(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 := accesoAgente(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindAgenteReq
|
|
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),
|
|
"ai_config_id": req.AiConfigID,
|
|
"tono": req.Tono,
|
|
"mensaje_bienvenida": req.MensajeBienvenida,
|
|
"color": umindColor(req.Color),
|
|
"activo": req.Activo,
|
|
}
|
|
if err := models.UpdateUmindAgente(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindAgenteHandler(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 := accesoAgente(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindAgente(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Documentos / ingesta ────────────────────────────────────────────────────
|
|
|
|
func GetUmindDocumentosHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindDocumentosByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// CreateUmindDocumentoHandler registra una nueva fuente (por ahora solo URL a
|
|
// crawlear) y dispara la ingesta en segundo plano — puede tardar varios
|
|
// segundos/minutos según cuántas páginas tenga el sitio.
|
|
func CreateUmindDocumentoHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
URL string `json:"url"`
|
|
MaxPaginas int `json:"max_paginas"`
|
|
AutoActualizar bool `json:"auto_actualizar"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(req.URL) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "url requerida"})
|
|
}
|
|
if _, err := models.GetUmindAgenteByID(req.AgenteID); err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "agente no encontrado"})
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: req.AgenteID,
|
|
Tipo: "url",
|
|
Origen: strings.TrimSpace(req.URL),
|
|
Estado: "procesando",
|
|
MaxPaginas: req.MaxPaginas,
|
|
AutoActualizar: req.AutoActualizar,
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
go services.IngestarAgente(req.AgenteID, doc.ID, doc.Origen, req.MaxPaginas)
|
|
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
func DeleteUmindDocumentoHandler(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 := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindDocumento(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Conversaciones ───────────────────────────────────────────────────────────
|
|
|
|
func GetUmindSesionesHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindSesiones(uint(agenteID), 50)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
func GetUmindHistorialHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
sessionID := c.Query("session_id")
|
|
if sessionID == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "session_id requerido"})
|
|
}
|
|
items, err := models.GetUmindHistorial(uint(agenteID), sessionID, 200)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// ─── Tools custom (webhooks) ───────────────────────────────────────────────
|
|
|
|
var umindNombreToolRegex = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
|
|
|
|
func GetUmindHerramientasHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindHerramientasByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
// No se devuelve el secreto cifrado ni el descifrado — solo si hay uno configurado.
|
|
out := make([]fiber.Map, len(items))
|
|
for i, h := range items {
|
|
out[i] = fiber.Map{
|
|
"ID": h.ID, "agente_id": h.AgenteID, "nombre": h.Nombre, "descripcion": h.Descripcion,
|
|
"parametros_json": h.ParametrosJSON, "url": h.URL, "auth_header_nombre": h.AuthHeaderNombre,
|
|
"auth_configurado": h.AuthHeaderValorEnc != "", "activa": h.Activa,
|
|
}
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|
|
|
|
type umindHerramientaReq struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Nombre string `json:"nombre"`
|
|
Descripcion string `json:"descripcion"`
|
|
Parametros []models.UmindHerramientaParametro `json:"parametros"`
|
|
URL string `json:"url"`
|
|
AuthHeaderNombre string `json:"auth_header_nombre"`
|
|
AuthHeaderValor *string `json:"auth_header_valor"` // nil = no tocar (en updates)
|
|
Activa bool `json:"activa"`
|
|
}
|
|
|
|
func CreateUmindHerramientaHandler(c *fiber.Ctx) error {
|
|
var req umindHerramientaReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if !umindNombreToolRegex.MatchString(req.Nombre) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
|
|
}
|
|
|
|
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
|
|
}
|
|
var authEnc string
|
|
if req.AuthHeaderValor != nil && *req.AuthHeaderValor != "" {
|
|
authEnc, err = services.CifrarSecretoUmind(*req.AuthHeaderValor)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
|
|
h := &models.UmindHerramienta{
|
|
AgenteID: req.AgenteID, Nombre: req.Nombre, Descripcion: strings.TrimSpace(req.Descripcion),
|
|
ParametrosJSON: parametrosJSON, URL: strings.TrimSpace(req.URL),
|
|
AuthHeaderNombre: strings.TrimSpace(req.AuthHeaderNombre), AuthHeaderValorEnc: authEnc, Activa: true,
|
|
}
|
|
if err := models.CreateUmindHerramienta(h); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": h.ID})
|
|
}
|
|
|
|
func UpdateUmindHerramientaHandler(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 := accesoHerramienta(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindHerramientaReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if !umindNombreToolRegex.MatchString(req.Nombre) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el nombre debe ser minúsculas/números/guion_bajo, empezar con letra (3-64 caracteres)"})
|
|
}
|
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.URL)), "https://") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "la URL debe ser https"})
|
|
}
|
|
parametrosJSON, err := models.ParametrosToJSON(req.Parametros)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "parametros inválidos"})
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"nombre": req.Nombre, "descripcion": strings.TrimSpace(req.Descripcion),
|
|
"parametros_json": parametrosJSON, "url": strings.TrimSpace(req.URL),
|
|
"auth_header_nombre": strings.TrimSpace(req.AuthHeaderNombre), "activa": req.Activa,
|
|
}
|
|
if req.AuthHeaderValor != nil {
|
|
if *req.AuthHeaderValor == "" {
|
|
updates["auth_header_valor_enc"] = ""
|
|
} else {
|
|
enc, err := services.CifrarSecretoUmind(*req.AuthHeaderValor)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
updates["auth_header_valor_enc"] = enc
|
|
}
|
|
}
|
|
if err := models.UpdateUmindHerramienta(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindHerramientaHandler(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 := accesoHerramienta(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindHerramienta(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Canales (Telegram / WhatsApp) ─────────────────────────────────────────
|
|
|
|
func GetUmindCanalesHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindCanalesByAgente(uint(agenteID))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
out := make([]fiber.Map, len(items))
|
|
for i, canal := range items {
|
|
webhookURL := ""
|
|
if canal.Tipo == "telegram" {
|
|
webhookURL = fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
} else if canal.Tipo == "whatsapp" {
|
|
webhookURL = fmt.Sprintf("%s/webhooks/umind-whatsapp/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
}
|
|
out[i] = fiber.Map{
|
|
"ID": canal.ID, "agente_id": canal.AgenteID, "tipo": canal.Tipo, "activo": canal.Activo,
|
|
"webhook_url": webhookURL, "ultimo_error": canal.UltimoError,
|
|
"usar_whisper_audio": canal.UsarWhisperAudio, "usar_ocr_imagenes": canal.UsarOcrImagenes,
|
|
"usar_archivos_docs": canal.UsarArchivosDocs,
|
|
}
|
|
}
|
|
return c.JSON(fiber.Map{"items": out})
|
|
}
|
|
|
|
type umindCanalReq struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Tipo string `json:"tipo"`
|
|
Credenciales map[string]string `json:"credenciales"`
|
|
Activo bool `json:"activo"`
|
|
UsarWhisperAudio bool `json:"usar_whisper_audio"`
|
|
UsarOcrImagenes bool `json:"usar_ocr_imagenes"`
|
|
UsarArchivosDocs bool `json:"usar_archivos_docs"`
|
|
}
|
|
|
|
func CreateUmindCanalHandler(c *fiber.Ctx) error {
|
|
var req umindCanalReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if req.AgenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
switch req.Tipo {
|
|
case "telegram":
|
|
if strings.TrimSpace(req.Credenciales["bot_token"]) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "bot_token requerido"})
|
|
}
|
|
case "whatsapp":
|
|
for _, k := range []string{"phone_number_id", "access_token", "app_secret", "verify_token"} {
|
|
if strings.TrimSpace(req.Credenciales[k]) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": k + " requerido"})
|
|
}
|
|
}
|
|
default:
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tipo debe ser telegram o whatsapp"})
|
|
}
|
|
|
|
credencialesEnc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
canal := &models.UmindCanal{
|
|
AgenteID: req.AgenteID, Tipo: req.Tipo, Activo: true, CredencialesEnc: credencialesEnc,
|
|
UsarWhisperAudio: req.UsarWhisperAudio, UsarOcrImagenes: req.UsarOcrImagenes,
|
|
UsarArchivosDocs: req.UsarArchivosDocs,
|
|
}
|
|
if err := models.CreateUmindCanal(canal); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
if req.Tipo == "telegram" {
|
|
webhookURL := fmt.Sprintf("%s/webhooks/umind-telegram/%s", app.Http.Server.Url, canal.WebhookSecret)
|
|
if err := services.RegistrarWebhookTelegram(req.Credenciales["bot_token"], webhookURL); err != nil {
|
|
models.UpdateUmindCanal(canal.ID, map[string]interface{}{"ultimo_error": err.Error()})
|
|
}
|
|
}
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"ok": true, "id": canal.ID, "webhook_secret": canal.WebhookSecret})
|
|
}
|
|
|
|
func UpdateUmindCanalHandler(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 := accesoCanal(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
var req umindCanalReq
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
updates := map[string]interface{}{"activo": req.Activo, "usar_whisper_audio": req.UsarWhisperAudio, "usar_ocr_imagenes": req.UsarOcrImagenes, "usar_archivos_docs": req.UsarArchivosDocs}
|
|
if len(req.Credenciales) > 0 {
|
|
enc, err := services.CifrarCredencialesCanal(req.Credenciales)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
updates["credenciales_enc"] = enc
|
|
updates["ultimo_error"] = ""
|
|
}
|
|
if err := models.UpdateUmindCanal(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
func DeleteUmindCanalHandler(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 := accesoCanal(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
if err := models.DeleteUmindCanal(uint(id)); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ─── Chat de prueba ─────────────────────────────────────────────────────────
|
|
|
|
// UmindChatPruebaHandler deja que el staff pruebe un agente puntual directo
|
|
// desde el panel, sin pasar por site_key/dominio (ya está gateado por la
|
|
// sesión con la que se llega acá).
|
|
func UmindChatPruebaHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
SessionID string `json:"session_id"`
|
|
Mensaje string `json:"mensaje"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if strings.TrimSpace(req.Mensaje) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "mensaje requerido"})
|
|
}
|
|
agente, errAcceso := accesoAgente(c, req.AgenteID)
|
|
if errAcceso != nil {
|
|
return errAcceso
|
|
}
|
|
sessionID := strings.TrimSpace(req.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = "staff-preview:" + strconv.FormatUint(uint64(extraerUserID(c)), 10)
|
|
}
|
|
respuesta, err := services.ProcessWidgetMessage(agente, sessionID, strings.TrimSpace(req.Mensaje))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"session_id": sessionID, "respuesta": respuesta})
|
|
}
|
|
|
|
// ─── Auditoría ──────────────────────────────────────────────────────────────
|
|
|
|
// GetUmindEventosHandler expone el log de auditoría técnica de un agente —
|
|
// errores/eventos que antes solo se podían ver pidiendo los logs del
|
|
// servidor (fallos de AI, tools, correo, canales).
|
|
func GetUmindEventosHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "agente_id requerido"})
|
|
}
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
items, err := models.GetUmindEventosByAgente(uint(agenteID), 100)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"items": items})
|
|
}
|
|
|
|
// ─── Consumo ────────────────────────────────────────────────────────────────
|
|
|
|
// GetUmindUsoHandler devuelve el consumo de un tenant en un rango de fechas:
|
|
// el resumen agregado por tipo (lo que se factura) y el detalle línea a línea.
|
|
// Sin fechas, el rango es el mes en curso.
|
|
func GetUmindUsoHandler(c *fiber.Ctx) error {
|
|
tenantID, err := strconv.ParseUint(c.Query("tenant_id"), 10, 64)
|
|
if err != nil || tenantID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant_id requerido"})
|
|
}
|
|
if err := accesoTenant(c, uint(tenantID)); err != nil {
|
|
return err
|
|
}
|
|
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
|
|
|
resumen, err := models.GetResumenUso(uint(tenantID), desde, hasta)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
detalle, err := models.GetUsoUmind(uint(tenantID), desde, hasta, c.Query("tipo"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
pendiente, _ := models.SumarUsoPendiente(uint(tenantID))
|
|
|
|
total := 0.0
|
|
for _, r := range resumen {
|
|
total += r.Costo
|
|
}
|
|
|
|
out := fiber.Map{
|
|
"desde": desde.Format("2006-01-02"), "hasta": hasta.Format("2006-01-02"),
|
|
"resumen": resumen, "detalle": detalle,
|
|
"total_periodo": total, "pendiente_facturar": pendiente,
|
|
}
|
|
if plan := models.GetPlanDeTenant(uint(tenantID)); plan != nil {
|
|
out["plan"] = fiber.Map{
|
|
"nombre": plan.Nombre, "moneda": plan.Moneda,
|
|
"precio_mensual": plan.PrecioMensual, "tope_consumo_mensual": plan.TopeConsumoMensual,
|
|
"max_agentes": plan.MaxAgentes,
|
|
}
|
|
}
|
|
return c.JSON(out)
|
|
}
|
|
|
|
// rangoFechas parsea el filtro del panel. Ante una fecha inválida cae al mes
|
|
// en curso en vez de devolver error: es un filtro de lectura, no vale la pena
|
|
// romperle la pantalla al usuario por un query param mal escrito.
|
|
func rangoFechas(desdeStr, hastaStr string) (time.Time, time.Time) {
|
|
ahora := time.Now()
|
|
desde := time.Date(ahora.Year(), ahora.Month(), 1, 0, 0, 0, 0, ahora.Location())
|
|
hasta := desde.AddDate(0, 1, 0)
|
|
|
|
if d, err := time.Parse("2006-01-02", desdeStr); err == nil {
|
|
desde = d
|
|
}
|
|
if h, err := time.Parse("2006-01-02", hastaStr); err == nil {
|
|
hasta = h.AddDate(0, 0, 1) // inclusivo: "hasta el 31" incluye el 31 entero
|
|
}
|
|
if !hasta.After(desde) {
|
|
hasta = desde.AddDate(0, 1, 0)
|
|
}
|
|
return desde, hasta
|
|
}
|
|
|
|
// GetUmindReporteHandler entrega el reporte de actividad de un agente en
|
|
// Excel. Existe por una razón concreta: el cliente necesita algo que mostrar
|
|
// puertas adentro para justificar el gasto. Un panel que solo se ve entrando
|
|
// al portal no sirve para eso; un archivo que se reenvía, sí.
|
|
// Ruta: GET /umind/reporte.xlsx?agente_id=&desde=&hasta=
|
|
func GetUmindReporteHandler(c *fiber.Ctx) error {
|
|
agenteID, err := strconv.ParseUint(c.Query("agente_id"), 10, 64)
|
|
if err != nil || agenteID == 0 {
|
|
return c.Status(fiber.StatusBadRequest).SendString("agente_id requerido")
|
|
}
|
|
agente, errAcceso := accesoAgente(c, uint(agenteID))
|
|
if errAcceso != nil {
|
|
return errAcceso
|
|
}
|
|
desde, hasta := rangoFechas(c.Query("desde"), c.Query("hasta"))
|
|
|
|
negocio := ""
|
|
if t, err := models.GetUmindTenantByID(agente.TenantID); err == nil {
|
|
negocio = t.Nombre
|
|
}
|
|
|
|
sesiones, _ := models.GetUmindSesionesRango(agente.ID, desde, hasta)
|
|
resumen, _ := models.GetResumenUso(agente.TenantID, desde, hasta)
|
|
|
|
filas := [][]string{
|
|
{"Reporte de actividad"},
|
|
{"Negocio", negocio},
|
|
{"Agente", agente.Nombre},
|
|
{"Período", desde.Format("02/01/2006") + " al " + hasta.Format("02/01/2006")},
|
|
{},
|
|
{"Conversaciones atendidas", strconv.Itoa(len(sesiones))},
|
|
{},
|
|
{"Consumo del período"},
|
|
{"Tipo", "Cantidad", "Unidad", "Usos"},
|
|
}
|
|
etiquetas := map[string]string{
|
|
models.UsoTipoIA: "Inteligencia artificial",
|
|
models.UsoTipoOCR: "Lectura de imágenes",
|
|
models.UsoTipoWhisper: "Transcripción de audio",
|
|
}
|
|
for _, r := range resumen {
|
|
nombre := etiquetas[r.Tipo]
|
|
if nombre == "" {
|
|
nombre = r.Tipo
|
|
}
|
|
filas = append(filas, []string{
|
|
nombre,
|
|
strconv.FormatFloat(r.Cantidad, 'f', 0, 64),
|
|
r.Unidad,
|
|
strconv.FormatInt(r.Eventos, 10),
|
|
})
|
|
}
|
|
|
|
filas = append(filas, []string{}, []string{"Conversaciones"}, []string{"Fecha", "Mensajes", "Primer mensaje del visitante"})
|
|
for _, s := range sesiones {
|
|
filas = append(filas, []string{
|
|
s.Inicio.Format("02/01/2006 15:04"),
|
|
strconv.FormatInt(s.Mensajes, 10),
|
|
s.PrimerMensaje,
|
|
})
|
|
}
|
|
|
|
nombreArchivo := fmt.Sprintf("reporte-%s-%s.xlsx", slugSimple(agente.Nombre), hasta.Format("2006-01"))
|
|
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
c.Set("Content-Disposition", `attachment; filename="`+nombreArchivo+`"`)
|
|
var buf bytes.Buffer
|
|
if err := services.EscribirXLSX(&buf, "Reporte", filas); err != nil {
|
|
return c.Status(500).SendString("no se pudo generar el reporte")
|
|
}
|
|
return c.Send(buf.Bytes())
|
|
}
|
|
|
|
// slugSimple deja solo minúsculas, números y guiones: un nombre de agente con
|
|
// acentos o espacios rompe el filename en algunos navegadores.
|
|
func slugSimple(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == ' ' || r == '-' || r == '_':
|
|
b.WriteRune('-')
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), "-")
|
|
if out == "" {
|
|
return "agente"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ─── Fuentes de conocimiento que no son una URL ─────────────────────────────
|
|
|
|
// CreateUmindTextoHandler guarda como conocimiento un texto escrito a mano.
|
|
// Es la fuente más valiosa y la que no está en ningún lado: horarios, qué no
|
|
// hacen, la respuesta a la pregunta que les hacen todos los días.
|
|
// POST /umind/documentos/texto
|
|
func CreateUmindTextoHandler(c *fiber.Ctx) error {
|
|
var req struct {
|
|
AgenteID uint `json:"agente_id"`
|
|
Titulo string `json:"titulo"`
|
|
Contenido string `json:"contenido"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
if _, err := accesoAgente(c, req.AgenteID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(req.Contenido) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "escribí algo para que el agente lo aprenda"})
|
|
}
|
|
titulo := strings.TrimSpace(req.Titulo)
|
|
if titulo == "" {
|
|
titulo = "Nota"
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: req.AgenteID,
|
|
Tipo: "texto",
|
|
Origen: titulo,
|
|
Contenido: req.Contenido,
|
|
Estado: "procesando",
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
go services.IngestarTexto(req.AgenteID, doc.ID, req.Contenido)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
// CreateUmindArchivoHandler carga un archivo como conocimiento. Usa el mismo
|
|
// extractor que los adjuntos de los canales: PDF, Word, texto e imágenes.
|
|
// POST /umind/documentos/archivo (multipart: agente_id, archivo)
|
|
func CreateUmindArchivoHandler(c *fiber.Ctx) error {
|
|
agenteID, _ := strconv.ParseUint(c.FormValue("agente_id"), 10, 64)
|
|
if _, err := accesoAgente(c, uint(agenteID)); err != nil {
|
|
return err
|
|
}
|
|
archivo, err := c.FormFile("archivo")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "subí un archivo"})
|
|
}
|
|
if archivo.Size > 20<<20 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el archivo supera los 20 MB"})
|
|
}
|
|
f, err := archivo.Open()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
|
}
|
|
defer f.Close()
|
|
datos, err := io.ReadAll(io.LimitReader(f, 20<<20))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "no se pudo leer el archivo"})
|
|
}
|
|
|
|
// El texto se extrae acá y no en segundo plano: si el archivo no se puede
|
|
// leer, conviene decirlo mientras la persona está mirando la pantalla.
|
|
texto, err := services.ExtraerTextoDeArchivo(uint(agenteID), archivo.Filename, datos)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
doc := &models.UmindDocumento{
|
|
AgenteID: uint(agenteID),
|
|
Tipo: "archivo",
|
|
Origen: archivo.Filename,
|
|
Contenido: texto,
|
|
Estado: "procesando",
|
|
}
|
|
if err := models.CreateUmindDocumento(doc); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
go services.IngestarTexto(uint(agenteID), doc.ID, texto)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "id": doc.ID, "estado": "procesando"})
|
|
}
|
|
|
|
// ReprocesarUmindDocumentoHandler vuelve a leer una fuente. Para una URL la
|
|
// crawlea de nuevo —es la única forma de que el agente deje de contestar los
|
|
// precios del año pasado— y para el resto rearma los fragmentos del texto
|
|
// guardado.
|
|
// POST /umind/documentos/:id/reprocesar
|
|
func ReprocesarUmindDocumentoHandler(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 := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
doc, err := models.GetUmindDocumentoByID(uint(id))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
|
}
|
|
if doc.Estado == "procesando" {
|
|
return c.Status(409).JSON(fiber.Map{"error": "esta fuente ya se está procesando"})
|
|
}
|
|
services.ReprocesarDocumento(*doc)
|
|
return c.Status(fiber.StatusAccepted).JSON(fiber.Map{"ok": true, "estado": "procesando"})
|
|
}
|
|
|
|
// ActualizarUmindDocumentoHandler edita el texto de una fuente escrita a mano
|
|
// y la vuelve a procesar.
|
|
// PUT /umind/documentos/:id
|
|
func ActualizarUmindDocumentoHandler(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 := accesoDocumento(c, uint(id)); err != nil {
|
|
return err
|
|
}
|
|
doc, err := models.GetUmindDocumentoByID(uint(id))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "fuente no encontrada"})
|
|
}
|
|
|
|
var req struct {
|
|
Titulo string `json:"titulo"`
|
|
Contenido string `json:"contenido"`
|
|
AutoActualizar *bool `json:"auto_actualizar"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "body inválido"})
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
if req.AutoActualizar != nil {
|
|
updates["auto_actualizar"] = *req.AutoActualizar
|
|
}
|
|
reprocesar := false
|
|
if doc.Tipo == "texto" && strings.TrimSpace(req.Contenido) != "" {
|
|
updates["contenido"] = req.Contenido
|
|
if t := strings.TrimSpace(req.Titulo); t != "" {
|
|
updates["origen"] = t
|
|
}
|
|
doc.Contenido = req.Contenido
|
|
reprocesar = true
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := models.UpdateUmindDocumento(uint(id), updates); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
}
|
|
if reprocesar {
|
|
services.ReprocesarDocumento(*doc)
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// GetPlantillasRubroHandler lista los puntos de partida disponibles para un
|
|
// agente nuevo.
|
|
// GET /umind/plantillas-rubro
|
|
func GetPlantillasRubroHandler(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"items": services.ResumenPlantillasRubro()})
|
|
}
|