Multi-tenant dentro de soft_usite, reutilizando la infraestructura ya existente (AiConfig, motor de function-calling del agente de Telegram) en vez de un servicio nuevo aparte: - UmindTenant: sitio/cliente con dominios permitidos, config de IA para el chat y personalidad/tono. - Ingesta: crawler simple (mismo dominio, N páginas) + chunking + embeddings (config global con módulo "umind_embeddings", pensada para OpenAI ya que Claude no ofrece embeddings) guardados como JSON, con búsqueda por similitud coseno en memoria (sin pgvector todavía). - Agente acotado: única herramienta buscar_conocimiento, sin acceso a nada interno — si no encuentra la respuesta, lo dice en vez de inventar. - Widget público (/widget/umind.js + /widget/:site_key/*), autenticado por site_key + validación de dominio (Origin/Referer), no por secreto, ya que la key viaja en el HTML público del sitio instalado. - Panel /app/umind: tenants, estado de ingesta, historial de conversaciones por sesión.
176 lines
6.9 KiB
Go
176 lines
6.9 KiB
Go
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: <script src="https://admin.u-site.app/widget/umind.js" data-site="SITE_KEY" defer></script>
|
|
// 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 <script>'); return; }
|
|
var apiBase = script.src.replace(/\/widget\/umind\.js.*$/, '') + '/widget/' + siteKey;
|
|
var storageKey = 'umind_session_' + siteKey;
|
|
|
|
var bubble = document.createElement('div');
|
|
bubble.innerHTML = '💬';
|
|
bubble.setAttribute('style', 'position:fixed;bottom:20px;right:20px;width:56px;height:56px;border-radius:50%;background:#8eb02f;color:#fff;display:flex;align-items:center;justify-content:center;font-size:26px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.2);z-index:999999;');
|
|
document.body.appendChild(bubble);
|
|
|
|
var panel = document.createElement('div');
|
|
panel.setAttribute('style', 'display:none;position:fixed;bottom:86px;right:20px;width:340px;max-width:92vw;height:460px;max-height:70vh;background:#fff;border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.25);z-index:999999;flex-direction:column;overflow:hidden;font-family:system-ui,sans-serif;');
|
|
panel.innerHTML =
|
|
'<div style="background:#8eb02f;color:#fff;padding:12px 14px;font-weight:600;font-size:14px;">' +
|
|
'<span id="umind-title">Asistente</span>' +
|
|
'</div>' +
|
|
'<div id="umind-msgs" style="flex:1;overflow-y:auto;padding:12px;font-size:13px;line-height:1.4;"></div>' +
|
|
'<div style="display:flex;border-top:1px solid #eee;">' +
|
|
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." style="flex:1;border:none;padding:10px 12px;font-size:13px;outline:none;">' +
|
|
'<button id="umind-send" style="border:none;background:#8eb02f;color:#fff;padding:0 16px;cursor:pointer;">Enviar</button>' +
|
|
'</div>';
|
|
document.body.appendChild(panel);
|
|
|
|
var msgsEl = panel.querySelector('#umind-msgs');
|
|
var inputEl = panel.querySelector('#umind-input');
|
|
var sendEl = panel.querySelector('#umind-send');
|
|
var sessionId = null;
|
|
var abierto = false;
|
|
var iniciado = false;
|
|
|
|
function agregarMensaje(texto, quien) {
|
|
var burbuja = document.createElement('div');
|
|
burbuja.textContent = texto;
|
|
var esUser = quien === 'user';
|
|
burbuja.setAttribute('style', 'margin-bottom:8px;padding:8px 10px;border-radius:10px;max-width:80%;white-space:pre-wrap;' +
|
|
(esUser ? 'background:#8eb02f;color:#fff;margin-left:auto;' : 'background:#f1f1f1;color:#222;'));
|
|
msgsEl.appendChild(burbuja);
|
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
}
|
|
|
|
function iniciar() {
|
|
if (iniciado) return;
|
|
iniciado = true;
|
|
sessionId = sessionStorage.getItem(storageKey) || null;
|
|
fetch(apiBase + '/init')
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
|
|
if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre;
|
|
agregarMensaje(data.mensaje_bienvenida, 'bot');
|
|
})
|
|
.catch(function () { agregarMensaje('No se pudo conectar el asistente.', 'bot'); });
|
|
}
|
|
|
|
function enviar() {
|
|
var texto = inputEl.value.trim();
|
|
if (!texto) return;
|
|
inputEl.value = '';
|
|
agregarMensaje(texto, 'user');
|
|
fetch(apiBase + '/mensaje', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ session_id: sessionId, mensaje: texto })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
|
|
agregarMensaje(data.respuesta || data.message || 'No pude responder eso.', 'bot');
|
|
})
|
|
.catch(function () { agregarMensaje('Error de conexión, intenta de nuevo.', 'bot'); });
|
|
}
|
|
|
|
bubble.addEventListener('click', function () {
|
|
abierto = !abierto;
|
|
panel.style.display = abierto ? 'flex' : 'none';
|
|
if (abierto) iniciar();
|
|
});
|
|
sendEl.addEventListener('click', enviar);
|
|
inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') enviar(); });
|
|
})();
|
|
`
|