El bubble era un emoji 💬 sobre un color fijo hardcodeado en el JS. Ahora: - Ícono nuevo (spark/sparkle, el motivo visual estándar de "asistente IA" hoy) en vez del emoji, con anillo de pulso al cargar para llamar la atención sin ser invasivo. - Color configurable por tenant (UmindTenant.Color, validado como hex en el server) — se aplica en tiempo real vía /widget/:site_key/init, así que cambiarlo desde el panel no requiere recopiar el <script>. - Panel con animación de entrada, botón de cerrar explícito, subtítulo "Asistente con IA · uMind" y badge de marca — antes solo tenía el nombre del tenant y ningún indicio de qué lo potencia. - El widget ahora se inicializa al cargar el script (no al primer click), así el color/nombre reales ya están listos antes de que el visitante abra el chat. Selector de color agregado al form de tenant en el orquestador (input type=color + hex). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
9.8 KiB
Go
273 lines
9.8 KiB
Go
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 <script> embebido del sitio,
|
|
// cualquiera que vea el código fuente la puede ver) — la seguridad no
|
|
// depende de que sea secreta, sino de que la petición venga de uno de los
|
|
// DominiosPermitidos (igual que una site key de reCAPTCHA o Analytics).
|
|
type UmindTenant struct {
|
|
gorm.Model
|
|
Nombre string `json:"nombre" gorm:"column:nombre;size:150;not null"`
|
|
SiteKey string `json:"site_key" gorm:"column:site_key;uniqueIndex;size:40;not null"`
|
|
DominiosPermitidos string `json:"dominios_permitidos" gorm:"column:dominios_permitidos;type:text"` // coma-separado, ej: u-site.app,www.u-site.app
|
|
AiConfigID *uint `json:"ai_config_id" gorm:"column:ai_config_id"`
|
|
Tono string `json:"tono" gorm:"column:tono;type:text"` // instrucciones de personalidad/tono, se inyectan al system prompt
|
|
MensajeBienvenida string `json:"mensaje_bienvenida" gorm:"column:mensaje_bienvenida;type:text"`
|
|
Color string `json:"color" gorm:"column:color;size:7;default:'#8eb02f'"` // hex, ej: #8eb02f — color de marca del widget embebido
|
|
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
|
CreadoPorID uint `json:"creado_por_id" gorm:"column:creado_por_id"`
|
|
}
|
|
|
|
func (UmindTenant) TableName() string { return "umind_tenants" }
|
|
|
|
// GenerarSiteKey crea un identificador público único para el widget. No se
|
|
// hashea (a diferencia de un token de API) porque no es un secreto: viaja en
|
|
// el HTML público del sitio del cliente.
|
|
func GenerarSiteKey() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("no se pudo generar la site_key: %w", err)
|
|
}
|
|
return "umk_" + hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func CreateUmindTenant(t *UmindTenant) error {
|
|
if t.SiteKey == "" {
|
|
key, err := GenerarSiteKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.SiteKey = key
|
|
}
|
|
return app.Http.Database.DB.Create(t).Error
|
|
}
|
|
|
|
func GetAllUmindTenants(limit, offset int) ([]UmindTenant, int64, error) {
|
|
var items []UmindTenant
|
|
var total int64
|
|
db := app.Http.Database.DB.Model(&UmindTenant{})
|
|
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 GetUmindTenantByID(id uint) (*UmindTenant, error) {
|
|
var t UmindTenant
|
|
if err := app.Http.Database.DB.First(&t, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// GetUmindTenantBySiteKey resuelve el tenant a partir de la site_key pública
|
|
// que manda el widget. Solo hace match si el tenant está activo.
|
|
func GetUmindTenantBySiteKey(siteKey string) (*UmindTenant, error) {
|
|
var t UmindTenant
|
|
if err := app.Http.Database.DB.Where("site_key = ? AND activo = ?", siteKey, true).First(&t).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func UpdateUmindTenant(id uint, updates map[string]interface{}) error {
|
|
return app.Http.Database.DB.Model(&UmindTenant{}).Where("id = ?", id).Updates(updates).Error
|
|
}
|
|
|
|
func DeleteUmindTenant(id uint) error {
|
|
return app.Http.Database.DB.Delete(&UmindTenant{}, id).Error
|
|
}
|
|
|
|
// DominioPermitido valida el host de un Origin/Referer contra la lista
|
|
// configurada. Admite dominio exacto o comodín "*.dominio.com" para
|
|
// subdominios. Lista vacía = no permite nada (fail-closed): un tenant recién
|
|
// creado sin dominios configurados no debe poder ser usado desde ningún sitio.
|
|
func (t *UmindTenant) DominioPermitido(host string) bool {
|
|
host = strings.ToLower(strings.TrimSpace(host))
|
|
if host == "" {
|
|
return false
|
|
}
|
|
lista := strings.TrimSpace(t.DominiosPermitidos)
|
|
if lista == "" {
|
|
return false
|
|
}
|
|
for _, entrada := range strings.Split(lista, ",") {
|
|
entrada = strings.ToLower(strings.TrimSpace(entrada))
|
|
if entrada == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(entrada, "*.") {
|
|
sufijo := entrada[1:] // ".dominio.com"
|
|
if strings.HasSuffix(host, sufijo) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if entrada == host {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ─── Documentos y chunks de conocimiento ────────────────────────────────────
|
|
|
|
// UmindDocumento es una fuente de conocimiento del tenant: una URL crawleada
|
|
// o un archivo subido. Se trocea en UmindChunk para la búsqueda por similitud.
|
|
type UmindDocumento struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
|
Tipo string `json:"tipo" gorm:"column:tipo;size:20"` // url | archivo
|
|
Origen string `json:"origen" gorm:"column:origen;type:text"` // la URL crawleada, o el nombre del archivo
|
|
Estado string `json:"estado" gorm:"column:estado;default:'pendiente'"` // pendiente | procesando | listo | error
|
|
Error string `json:"error" gorm:"column:error;type:text"`
|
|
TotalChunks int `json:"total_chunks" gorm:"column:total_chunks;default:0"`
|
|
}
|
|
|
|
func (UmindDocumento) TableName() string { return "umind_documentos" }
|
|
|
|
func CreateUmindDocumento(d *UmindDocumento) error {
|
|
return app.Http.Database.DB.Create(d).Error
|
|
}
|
|
|
|
func GetUmindDocumentosByTenant(tenantID uint) ([]UmindDocumento, error) {
|
|
var items []UmindDocumento
|
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Order("id DESC").Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
func GetUmindDocumentoByID(id uint) (*UmindDocumento, error) {
|
|
var d UmindDocumento
|
|
if err := app.Http.Database.DB.First(&d, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
func UpdateUmindDocumentoEstado(id uint, estado, errMsg string, totalChunks int) error {
|
|
return app.Http.Database.DB.Model(&UmindDocumento{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"estado": estado,
|
|
"error": errMsg,
|
|
"total_chunks": totalChunks,
|
|
}).Error
|
|
}
|
|
|
|
func DeleteUmindDocumento(id uint) error {
|
|
if err := app.Http.Database.DB.Where("documento_id = ?", id).Delete(&UmindChunk{}).Error; err != nil {
|
|
return err
|
|
}
|
|
return app.Http.Database.DB.Delete(&UmindDocumento{}, id).Error
|
|
}
|
|
|
|
// UmindChunk es un fragmento de texto con su embedding, listo para búsqueda
|
|
// por similitud. Sin pgvector por ahora: el embedding se guarda como JSON de
|
|
// []float32 y la similitud se calcula en memoria (suficiente para el volumen
|
|
// de un piloto de un solo tenant; si el volumen crece, se migra a pgvector
|
|
// sin cambiar la interfaz de búsqueda).
|
|
type UmindChunk struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
|
DocumentoID uint `json:"documento_id" gorm:"column:documento_id;index;not null"`
|
|
Contenido string `json:"contenido" gorm:"column:contenido;type:text;not null"`
|
|
EmbeddingJSON string `json:"-" gorm:"column:embedding_json;type:text"`
|
|
}
|
|
|
|
func (UmindChunk) TableName() string { return "umind_chunks" }
|
|
|
|
// EmbeddingToJSON / EmbeddingFromJSON convierten el vector a/desde el formato
|
|
// de almacenamiento en texto.
|
|
func EmbeddingToJSON(v []float32) (string, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func EmbeddingFromJSON(s string) ([]float32, error) {
|
|
var v []float32
|
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
|
return nil, err
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
func CreateUmindChunks(chunks []UmindChunk) error {
|
|
if len(chunks) == 0 {
|
|
return nil
|
|
}
|
|
return app.Http.Database.DB.CreateInBatches(chunks, 50).Error
|
|
}
|
|
|
|
// GetUmindChunksByTenant retorna todos los chunks del tenant, para la
|
|
// búsqueda por similitud en memoria.
|
|
func GetUmindChunksByTenant(tenantID uint) ([]UmindChunk, error) {
|
|
var items []UmindChunk
|
|
err := app.Http.Database.DB.Where("tenant_id = ?", tenantID).Find(&items).Error
|
|
return items, err
|
|
}
|
|
|
|
// ─── Historial de conversación del widget ───────────────────────────────────
|
|
|
|
// UmindMensaje guarda el historial de conversación del widget, por tenant y
|
|
// sesión de navegador (no hay usuario autenticado del lado del visitante).
|
|
type UmindMensaje struct {
|
|
gorm.Model
|
|
TenantID uint `json:"tenant_id" gorm:"column:tenant_id;index;not null"`
|
|
SessionID string `json:"session_id" gorm:"column:session_id;index;not null"`
|
|
Role string `json:"role" gorm:"column:role;not null"` // user | assistant
|
|
Content string `json:"content" gorm:"column:content;type:text;not null"`
|
|
}
|
|
|
|
func (UmindMensaje) TableName() string { return "umind_mensajes" }
|
|
|
|
func SaveUmindMensaje(tenantID uint, sessionID, role, content string) error {
|
|
m := &UmindMensaje{TenantID: tenantID, SessionID: sessionID, Role: role, Content: content}
|
|
return app.Http.Database.DB.Create(m).Error
|
|
}
|
|
|
|
// GetUmindHistorial retorna los últimos n mensajes de una sesión, en orden cronológico.
|
|
func GetUmindHistorial(tenantID uint, sessionID string, n int) ([]UmindMensaje, error) {
|
|
var items []UmindMensaje
|
|
err := app.Http.Database.DB.
|
|
Where("tenant_id = ? AND session_id = ?", tenantID, sessionID).
|
|
Order("created_at DESC").
|
|
Limit(n).
|
|
Find(&items).Error
|
|
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
|
|
items[i], items[j] = items[j], items[i]
|
|
}
|
|
return items, err
|
|
}
|
|
|
|
// GetUmindSesiones lista las sesiones de conversación recientes de un tenant
|
|
// (para el panel admin), con el último mensaje como resumen.
|
|
func GetUmindSesiones(tenantID uint, limit int) ([]UmindMensaje, error) {
|
|
var items []UmindMensaje
|
|
err := app.Http.Database.DB.Raw(`
|
|
SELECT * FROM (
|
|
SELECT DISTINCT ON (session_id) *
|
|
FROM umind_mensajes
|
|
WHERE tenant_id = ? AND deleted_at IS NULL
|
|
ORDER BY session_id, created_at DESC
|
|
) ultimos
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`, tenantID, limit).Scan(&items).Error
|
|
return items, err
|
|
}
|