diff --git a/go.mod b/go.mod
index b599d3e..450c9f9 100755
--- a/go.mod
+++ b/go.mod
@@ -45,6 +45,7 @@ require (
github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe
github.com/chromedp/chromedp v0.16.0
github.com/go-sql-driver/mysql v1.8.1
+ github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.22
github.com/microsoft/go-mssqldb v1.7.2
@@ -55,6 +56,7 @@ require (
github.com/sirupsen/logrus v1.9.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
go.mongodb.org/mongo-driver v1.17.9
+ golang.org/x/net v0.53.0
gorm.io/driver/sqlite v1.5.6
gorm.io/driver/sqlserver v1.5.3
)
@@ -94,7 +96,6 @@ require (
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.2.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -143,7 +144,6 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
- golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.43.0 // indirect
diff --git a/main.go b/main.go
index 93815d8..f13e5bc 100755
--- a/main.go
+++ b/main.go
@@ -146,6 +146,11 @@ func main() {
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
+ // uMind: chat con IA embebible por tenant (widget web + RAG)
+ &models.UmindTenant{},
+ &models.UmindDocumento{},
+ &models.UmindChunk{},
+ &models.UmindMensaje{},
}
for _, m := range modelosBase {
if err := app.Http.Database.DB.AutoMigrate(m); err != nil {
@@ -180,6 +185,7 @@ func main() {
migrations.SeedTareas()
migrations.SeedPagosExternos()
migrations.SeedAutomatizacionIA()
+ migrations.SeedUmind()
if n, err := models.RepararEstadosTareaInvalidos(); err != nil {
log.Printf("[FIX] Error reparando estados de tareas: %v", err)
} else if n > 0 {
diff --git a/migrations/migrate.go b/migrations/migrate.go
index 459e664..425cef7 100755
--- a/migrations/migrate.go
+++ b/migrations/migrate.go
@@ -117,6 +117,11 @@ func Migrate() {
&models.Arquitectura{},
// Vinculación de Telegram para staff interno
&models.TelegramStaffToken{},
+ // uMind: chat con IA embebible por tenant (widget web + RAG)
+ &models.UmindTenant{},
+ &models.UmindDocumento{},
+ &models.UmindChunk{},
+ &models.UmindMensaje{},
}
for _, m := range modelosPrincipales {
if err := app.Http.Database.DB.Migrator().AutoMigrate(m); err != nil {
@@ -1267,3 +1272,40 @@ func SeedPagosExternos() {
}
log.Println("[SEED] Seed de Pagos externos completado.")
}
+
+// SeedUmind registra el submódulo del panel de administración de uMind
+// (tenants, base de conocimiento y conversaciones del widget embebible).
+func SeedUmind() {
+ db := app.Http.Database.DB
+ var modulo models.Modules
+ if err := db.Where("title = ?", "Automatización IA").First(&modulo).Error; err != nil {
+ log.Println("[SEED] Módulo 'Automatización IA' no encontrado, se omite SeedUmind")
+ return
+ }
+ url := "/app/umind"
+ var sub models.Submodules
+ if err := db.Where("url = ?", url).First(&sub).Error; err != nil {
+ sub = models.Submodules{
+ Title: "uMind",
+ Description: "Chat con IA embebible por sitio, con base de conocimiento propia (RAG)",
+ Url: url,
+ ModuleId: modulo.ID,
+ ModifiedAt: time.Now(),
+ }
+ if err := db.Create(&sub).Error; err != nil {
+ log.Printf("[SEED] Error creando submódulo 'uMind': %v", err)
+ return
+ }
+ log.Printf("[SEED] Submódulo 'uMind' creado")
+ } else if sub.ModuleId != modulo.ID {
+ db.Model(&sub).Update("module_id", modulo.ID)
+ }
+ var rol models.Roles
+ if err := db.Where("name = ?", "Administrador").First(&rol).Error; err != nil {
+ log.Printf("[SEED] Rol 'Administrador' no encontrado, se omite asignación: %v", err)
+ } else {
+ asignarSubmodulosSiFaltan(&rol, []models.Submodules{sub})
+ log.Printf("[SEED] Submódulo 'uMind' asignado al rol 'Administrador'")
+ }
+ log.Println("[SEED] Seed de uMind completado.")
+}
diff --git a/pkg/models/ai_config.go b/pkg/models/ai_config.go
index c3d94e1..3f19085 100644
--- a/pkg/models/ai_config.go
+++ b/pkg/models/ai_config.go
@@ -194,3 +194,26 @@ func GetWhisperConfig() (*AiConfig, error) {
}
return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'whisper' en /app/ai-config")
}
+
+// GetUmindEmbeddingsConfig retorna la config activa etiquetada con el módulo
+// "umind_embeddings" (usada para generar los vectores de la base de
+// conocimiento de todos los tenants de uMind). Es global, no por tenant: los
+// embeddings de un tenant solo son comparables entre sí si se generaron con
+// el mismo modelo, así que cambiar de config invalida los chunks existentes
+// (habría que reingestar). Sin fallback, igual que GetWhisperConfig — Claude
+// no ofrece embeddings, así que aquí sí importa exigir una config explícita
+// en vez de caer a cualquier config activa.
+func GetUmindEmbeddingsConfig() (*AiConfig, error) {
+ var items []AiConfig
+ if err := app.Http.Database.DB.Where("is_active = ?", true).Find(&items).Error; err != nil {
+ return nil, fmt.Errorf("error leyendo ai_configs: %w", err)
+ }
+ for i := range items {
+ for _, m := range SplitModulos(items[i].Modulo) {
+ if m == "umind_embeddings" {
+ return &items[i], nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("no hay ninguna configuración activa con el módulo 'umind_embeddings' en /app/ai-config (necesaria para generar embeddings, ej. un proveedor OpenAI)")
+}
diff --git a/pkg/models/umind.go b/pkg/models/umind.go
new file mode 100644
index 0000000..fe6492d
--- /dev/null
+++ b/pkg/models/umind.go
@@ -0,0 +1,271 @@
+package models
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/sujit-baniya/fiber-boilerplate/app"
+ "gorm.io/gorm"
+)
+
+// UmindTenant representa un sitio/cliente que tiene el widget de uMind
+// instalado. SiteKey es pública (va en el
diff --git a/rest/controllers/api/umind_widget_controller.go b/rest/controllers/api/umind_widget_controller.go
new file mode 100644
index 0000000..55ec14b
--- /dev/null
+++ b/rest/controllers/api/umind_widget_controller.go
@@ -0,0 +1,175 @@
+package controllers
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
+ "github.com/sujit-baniya/fiber-boilerplate/pkg/services"
+)
+
+func tenantDeContexto(c *fiber.Ctx) (*models.UmindTenant, error) {
+ tenant, ok := c.Locals("umind_tenant").(*models.UmindTenant)
+ if !ok || tenant == nil {
+ return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
+ }
+ return tenant, nil
+}
+
+func nuevaSessionID() string {
+ b := make([]byte, 12)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// UmindWidgetInit devuelve una sesión nueva y el mensaje de bienvenida del
+// tenant, sin gastar una llamada al LLM solo para saludar.
+// Ruta: GET /widget/:site_key/init
+func UmindWidgetInit(c *fiber.Ctx) error {
+ tenant, err := tenantDeContexto(c)
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
+ }
+ bienvenida := strings.TrimSpace(tenant.MensajeBienvenida)
+ if bienvenida == "" {
+ bienvenida = "¡Hola! ¿En qué puedo ayudarte?"
+ }
+ return c.JSON(fiber.Map{
+ "session_id": nuevaSessionID(),
+ "nombre": tenant.Nombre,
+ "mensaje_bienvenida": bienvenida,
+ })
+}
+
+// UmindWidgetMensaje procesa un mensaje del visitante y devuelve la respuesta del agente.
+// Ruta: POST /widget/:site_key/mensaje
+func UmindWidgetMensaje(c *fiber.Ctx) error {
+ tenant, err := tenantDeContexto(c)
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
+ }
+
+ var req struct {
+ 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": true, "message": "body inválido"})
+ }
+ if strings.TrimSpace(req.Mensaje) == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": true, "message": "mensaje requerido"})
+ }
+ sessionID := strings.TrimSpace(req.SessionID)
+ if sessionID == "" {
+ sessionID = nuevaSessionID()
+ }
+ // Límite generoso pero real: evita que alguien mande un mensaje gigante al LLM.
+ if len(req.Mensaje) > 4000 {
+ req.Mensaje = req.Mensaje[:4000]
+ }
+
+ respuesta, err := services.ProcessWidgetMessage(tenant, sessionID, strings.TrimSpace(req.Mensaje))
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": true, "message": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{
+ "session_id": sessionID,
+ "respuesta": respuesta,
+ })
+}
+
+// UmindWidgetScript sirve el script embebible que renderiza la burbuja de
+// chat. Se instala con:
+// Ruta: GET /widget/umind.js
+func UmindWidgetScript(c *fiber.Ctx) error {
+ c.Set("Content-Type", "application/javascript; charset=utf-8")
+ c.Set("Cache-Control", "public, max-age=300")
+ return c.SendString(umindWidgetJS)
+}
+
+const umindWidgetJS = `(function () {
+ var script = document.currentScript;
+ var siteKey = script.getAttribute('data-site');
+ if (!siteKey) { console.error('[uMind] falta data-site en el