Entre que el visitante manda y el agente contesta pasan varios segundos —más si tiene que buscar en la base de conocimiento— y el widget se quedaba mudo. Sin señal de vida, la reacción normal es pensar que se colgó y volver a mandar. Ahora aparecen los tres puntos que cualquiera reconoce de WhatsApp, y el campo se bloquea mientras responde: mandar tres mensajes seguidos desordena el hilo y multiplica el consumo sin que nadie lo pida. La limpieza va en un then final y no en el de éxito: si quedara ahí, un error de red dejaría los puntitos animándose para siempre y el campo bloqueado, que es peor que no haber puesto nada. Probado con la respuesta normal y con un fetch que falla. El indicador se inserta siempre al final, así que un mensaje que llegue mientras tanto no lo deja en el medio del hilo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
16 KiB
Go
360 lines
16 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 agenteDeContexto(c *fiber.Ctx) (*models.UmindAgente, error) {
|
|
agente, ok := c.Locals("umind_agente").(*models.UmindAgente)
|
|
if !ok || agente == nil {
|
|
return nil, fiber.NewError(fiber.StatusUnauthorized, "no autenticado")
|
|
}
|
|
return agente, nil
|
|
}
|
|
|
|
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
|
|
// agente, sin gastar una llamada al LLM solo para saludar. El nombre que se
|
|
// muestra es el del negocio (tenant dueño del agente), no la etiqueta
|
|
// interna del agente.
|
|
// Ruta: GET /widget/:site_key/init
|
|
func UmindWidgetInit(c *fiber.Ctx) error {
|
|
agente, err := agenteDeContexto(c)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
|
|
}
|
|
tenant, err := tenantDeContexto(c)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": true, "message": err.Error()})
|
|
}
|
|
bienvenida := strings.TrimSpace(agente.MensajeBienvenida)
|
|
if bienvenida == "" {
|
|
bienvenida = "¡Hola! ¿En qué puedo ayudarte?"
|
|
}
|
|
color := strings.TrimSpace(agente.Color)
|
|
if color == "" {
|
|
color = "#8eb02f"
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"session_id": nuevaSessionID(),
|
|
"nombre": tenant.Nombre,
|
|
"mensaje_bienvenida": bienvenida,
|
|
"color": color,
|
|
})
|
|
}
|
|
|
|
// UmindWidgetMensaje procesa un mensaje del visitante y devuelve la respuesta del agente.
|
|
// Ruta: POST /widget/:site_key/mensaje
|
|
func UmindWidgetMensaje(c *fiber.Ctx) error {
|
|
agente, err := agenteDeContexto(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(agente, 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; }
|
|
// El origen sale de la URL del propio script: así el badge apunta al mismo
|
|
// host que sirve el widget, sin tener que inyectar la URL desde el backend.
|
|
var origen = script.src.replace(/\/widget\/umind\.js.*$/, '');
|
|
var apiBase = origen + '/widget/' + siteKey;
|
|
var storageKey = 'umind_session_' + siteKey;
|
|
var color = '#8eb02f';
|
|
|
|
var style = document.createElement('style');
|
|
style.textContent = [
|
|
'.umind-ring{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;z-index:999998;pointer-events:none;animation:umind-pulse 2.4s ease-out 3;}',
|
|
'@keyframes umind-pulse{0%{box-shadow:0 0 0 0 var(--umind-color,#8eb02f);}100%{box-shadow:0 0 0 24px transparent;}}',
|
|
'.umind-bubble{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 6px 20px rgba(0,0,0,.22);z-index:999999;border:none;transition:transform .15s ease;padding:0;}',
|
|
'.umind-bubble:hover{transform:scale(1.06);}',
|
|
'.umind-bubble svg{width:26px;height:26px;display:block;}',
|
|
'.umind-panel{position:fixed;bottom:92px;right:20px;width:356px;max-width:92vw;height:480px;max-height:72vh;background:#fff;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,.22);z-index:999999;display:flex;flex-direction:column;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;opacity:0;transform:translateY(14px) scale(.97);pointer-events:none;transition:opacity .18s ease,transform .18s ease;}',
|
|
'.umind-panel.umind-abierto{opacity:1;transform:translateY(0) scale(1);pointer-events:auto;}',
|
|
'.umind-header{padding:16px 40px 16px 16px;color:#fff;position:relative;flex-shrink:0;}',
|
|
'.umind-header b{display:block;font-size:14.5px;font-weight:600;}',
|
|
'.umind-header span{display:block;font-size:11px;opacity:.85;margin-top:2px;}',
|
|
'.umind-close{position:absolute;top:12px;right:12px;background:rgba(255,255,255,.22);border:none;color:#fff;width:24px;height:24px;border-radius:50%;cursor:pointer;font-size:15px;line-height:1;}',
|
|
'.umind-msgs{flex:1;overflow-y:auto;padding:14px;font-size:13px;line-height:1.45;background:#fafafa;}',
|
|
'.umind-msg{margin-bottom:10px;padding:9px 12px;border-radius:13px;max-width:82%;white-space:pre-wrap;}',
|
|
'.umind-msg-bot{background:#fff;color:#222;border:1px solid #ececec;border-bottom-left-radius:4px;}',
|
|
'.umind-msg-user{color:#fff;margin-left:auto;border-bottom-right-radius:4px;}',
|
|
'.umind-msg-bot a{color:var(--umind-color);text-decoration:underline;word-break:break-all;}',
|
|
'.umind-msg-user a{color:#fff;text-decoration:underline;word-break:break-all;}',
|
|
// "Escribiendo": los tres puntos que cualquiera reconoce de WhatsApp. Sin
|
|
// esto, entre que la persona manda y el agente contesta —varios segundos
|
|
// si tiene que buscar en el conocimiento— el widget se queda mudo y parece
|
|
// colgado.
|
|
'.umind-escribiendo{display:flex;gap:4px;align-items:center;width:fit-content;margin-bottom:10px;padding:11px 13px;background:#fff;border:1px solid #ececec;border-radius:13px;border-bottom-left-radius:4px;}',
|
|
'.umind-escribiendo i{width:6px;height:6px;border-radius:50%;background:#b6b6b6;display:block;animation:umind-punto 1.3s infinite;}',
|
|
'.umind-escribiendo i:nth-child(2){animation-delay:.18s;}',
|
|
'.umind-escribiendo i:nth-child(3){animation-delay:.36s;}',
|
|
'@keyframes umind-punto{0%,60%,100%{opacity:.3;transform:translateY(0);}30%{opacity:1;transform:translateY(-3px);}}',
|
|
'@media (prefers-reduced-motion:reduce){.umind-escribiendo i{animation:none;opacity:.6;}}',
|
|
'.umind-inputbar{display:flex;align-items:center;border-top:1px solid #eee;padding:8px;gap:6px;background:#fff;flex-shrink:0;}',
|
|
'.umind-inputbar input{flex:1;border:1px solid #e5e5e5;border-radius:20px;padding:9px 14px;font-size:13px;outline:none;}',
|
|
'.umind-inputbar button{border:none;color:#fff;width:34px;height:34px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;}',
|
|
'.umind-inputbar button svg{width:16px;height:16px;}',
|
|
'.umind-badge{text-align:center;font-size:10.5px;padding:5px 0 9px;background:#fff;letter-spacing:.02em;flex-shrink:0;}',
|
|
'.umind-badge a{color:#aaa;text-decoration:none;transition:color .15s;}',
|
|
'.umind-badge a:hover{color:#666;}',
|
|
].join('');
|
|
document.head.appendChild(style);
|
|
|
|
var sparkSVG = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">' +
|
|
'<path d="M12 2.5c.6 3.6 2.4 5.4 6 6-3.6.6-5.4 2.4-6 6-.6-3.6-2.4-5.4-6-6 3.6-.6 5.4-2.4 6-6z" fill="#fff"/>' +
|
|
'<path d="M19 15c.3 1.8 1.1 2.7 2.9 3-1.8.3-2.6 1.2-2.9 3-.3-1.8-1.1-2.7-2.9-3 1.8-.3 2.6-1.2 2.9-3z" fill="#fff" opacity=".85"/>' +
|
|
'</svg>';
|
|
var sendSVG = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 19V5M12 5l-6 6M12 5l6 6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
|
|
var ring = document.createElement('div');
|
|
ring.className = 'umind-ring';
|
|
document.body.appendChild(ring);
|
|
|
|
var bubble = document.createElement('button');
|
|
bubble.className = 'umind-bubble';
|
|
bubble.setAttribute('aria-label', 'Abrir chat');
|
|
bubble.innerHTML = sparkSVG;
|
|
document.body.appendChild(bubble);
|
|
|
|
var panel = document.createElement('div');
|
|
panel.className = 'umind-panel';
|
|
panel.innerHTML =
|
|
'<div class="umind-header" id="umind-header">' +
|
|
'<b id="umind-title">Asistente</b><span>Asistente con IA · uMind</span>' +
|
|
'<button class="umind-close" id="umind-close" aria-label="Cerrar">×</button>' +
|
|
'</div>' +
|
|
'<div class="umind-msgs" id="umind-msgs"></div>' +
|
|
'<div class="umind-inputbar">' +
|
|
'<input id="umind-input" type="text" placeholder="Escribe tu mensaje..." />' +
|
|
'<button id="umind-send" aria-label="Enviar"></button>' +
|
|
'</div>' +
|
|
'<div class="umind-badge">' +
|
|
'<a href="' + origen + '/?utm_source=widget&utm_medium=badge&utm_campaign=umind" ' +
|
|
'target="_blank" rel="noopener">✦ Impulsado por uMind</a>' +
|
|
'</div>';
|
|
document.body.appendChild(panel);
|
|
panel.querySelector('#umind-send').innerHTML = sendSVG;
|
|
|
|
function aplicarColor(c) {
|
|
if (!c) return;
|
|
color = c;
|
|
ring.style.setProperty('--umind-color', color);
|
|
// También en el panel, que es el ancestro de las burbujas: los enlaces
|
|
// dentro de un mensaje toman el color de marca desde esta variable.
|
|
panel.style.setProperty('--umind-color', color);
|
|
bubble.style.background = 'radial-gradient(circle at 30% 28%, rgba(255,255,255,.35), transparent 55%), ' + color;
|
|
panel.querySelector('#umind-header').style.background = color;
|
|
panel.querySelector('#umind-send').style.background = color;
|
|
}
|
|
aplicarColor(color);
|
|
|
|
var msgsEl = panel.querySelector('#umind-msgs');
|
|
var inputEl = panel.querySelector('#umind-input');
|
|
var sendEl = panel.querySelector('#umind-send');
|
|
var sessionId = null;
|
|
var abierto = false;
|
|
|
|
// El contenido del agente sale de páginas crawleadas, así que NUNCA se
|
|
// inserta como HTML. Se parte por URL y cada trozo se agrega con
|
|
// createTextNode / un <a> cuyo href se arma con la URL ya validada: el
|
|
// texto sigue siendo inerte y los enlaces quedan clickeables.
|
|
function pintarConEnlaces(el, texto) {
|
|
var partes = String(texto).split(/(https?:\/\/[^\s<>"')\]]+)/g);
|
|
for (var i = 0; i < partes.length; i++) {
|
|
var p = partes[i];
|
|
if (!p) continue;
|
|
if (i % 2 === 1) {
|
|
var a = document.createElement('a');
|
|
a.href = p;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener noreferrer nofollow';
|
|
a.textContent = p;
|
|
el.appendChild(a);
|
|
} else {
|
|
el.appendChild(document.createTextNode(p));
|
|
}
|
|
}
|
|
}
|
|
|
|
// El indicador es un elemento suelto que se agrega y se saca: así no hay que
|
|
// llevar estado ni limpiar nada si la petición falla a mitad de camino.
|
|
var escribiendoEl = null;
|
|
|
|
function mostrarEscribiendo() {
|
|
if (escribiendoEl) return;
|
|
escribiendoEl = document.createElement('div');
|
|
escribiendoEl.className = 'umind-escribiendo';
|
|
escribiendoEl.setAttribute('aria-label', 'El asistente está escribiendo');
|
|
for (var i = 0; i < 3; i++) escribiendoEl.appendChild(document.createElement('i'));
|
|
msgsEl.appendChild(escribiendoEl);
|
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
}
|
|
|
|
function ocultarEscribiendo() {
|
|
if (!escribiendoEl) return;
|
|
if (escribiendoEl.parentNode) escribiendoEl.parentNode.removeChild(escribiendoEl);
|
|
escribiendoEl = null;
|
|
}
|
|
|
|
function agregarMensaje(texto, quien) {
|
|
var burbuja = document.createElement('div');
|
|
pintarConEnlaces(burbuja, texto == null ? '' : texto);
|
|
var esUser = quien === 'user';
|
|
burbuja.className = 'umind-msg ' + (esUser ? 'umind-msg-user' : 'umind-msg-bot');
|
|
if (esUser) burbuja.style.background = color;
|
|
// Antes del indicador, para que los puntitos queden siempre al final.
|
|
if (escribiendoEl && escribiendoEl.parentNode === msgsEl) {
|
|
msgsEl.insertBefore(burbuja, escribiendoEl);
|
|
} else {
|
|
msgsEl.appendChild(burbuja);
|
|
}
|
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
}
|
|
|
|
// El servidor responde los errores como {"error":true,"message":"..."} con
|
|
// un status 4xx/5xx. Antes esto se leía igual que una respuesta buena y el
|
|
// texto del error terminaba en una burbuja, como si lo hubiera dicho el
|
|
// agente: imposible distinguir "el bot contestó raro" de "el widget está
|
|
// rechazado". Ahora el motivo real va a la consola y al visitante se le
|
|
// muestra un mensaje neutro.
|
|
function leerRespuesta(r) {
|
|
return r.json().catch(function () { return {}; }).then(function (data) {
|
|
if (!r.ok || data.error) {
|
|
var motivo = data.message || ('HTTP ' + r.status);
|
|
console.error('[uMind] el asistente no pudo responder:', motivo,
|
|
'— revisá la pestaña Auditoría del agente en uMind Studio.');
|
|
var e = new Error(motivo);
|
|
e.esDelServidor = true;
|
|
throw e;
|
|
}
|
|
return data;
|
|
});
|
|
}
|
|
|
|
function iniciar() {
|
|
sessionId = sessionStorage.getItem(storageKey) || null;
|
|
fetch(apiBase + '/init')
|
|
.then(leerRespuesta)
|
|
.then(function (data) {
|
|
if (!sessionId) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
|
|
if (data.nombre) panel.querySelector('#umind-title').textContent = data.nombre;
|
|
aplicarColor(data.color);
|
|
agregarMensaje(data.mensaje_bienvenida, 'bot');
|
|
})
|
|
.catch(function () { agregarMensaje('El asistente no está disponible en este momento.', 'bot'); });
|
|
}
|
|
iniciar(); // arranca ya, así el color/nombre reales están listos antes del primer click
|
|
|
|
function enviar() {
|
|
var texto = inputEl.value.trim();
|
|
if (!texto) return;
|
|
inputEl.value = '';
|
|
agregarMensaje(texto, 'user');
|
|
mostrarEscribiendo();
|
|
// Se bloquea el envío mientras responde: mandar tres mensajes seguidos
|
|
// desordena el hilo y multiplica el consumo sin que nadie lo pida.
|
|
inputEl.disabled = true;
|
|
sendEl.disabled = true;
|
|
sendEl.style.opacity = '.5';
|
|
|
|
fetch(apiBase + '/mensaje', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ session_id: sessionId, mensaje: texto })
|
|
})
|
|
.then(leerRespuesta)
|
|
.then(function (data) {
|
|
if (data.session_id) { sessionId = data.session_id; sessionStorage.setItem(storageKey, sessionId); }
|
|
agregarMensaje(data.respuesta || 'No pude responder eso.', 'bot');
|
|
})
|
|
.catch(function (e) {
|
|
agregarMensaje(
|
|
e && e.esDelServidor
|
|
? 'El asistente no está disponible en este momento.'
|
|
: 'Error de conexión, intenta de nuevo.',
|
|
'bot');
|
|
})
|
|
.then(function () {
|
|
// Pase lo que pase: si esto quedara solo en el .then de éxito, un
|
|
// error dejaría los puntitos animándose para siempre y el campo de
|
|
// texto bloqueado.
|
|
ocultarEscribiendo();
|
|
inputEl.disabled = false;
|
|
sendEl.disabled = false;
|
|
sendEl.style.opacity = '';
|
|
inputEl.focus();
|
|
});
|
|
}
|
|
|
|
function alternar() {
|
|
abierto = !abierto;
|
|
panel.classList.toggle('umind-abierto', abierto);
|
|
if (abierto) inputEl.focus();
|
|
}
|
|
|
|
bubble.addEventListener('click', alternar);
|
|
panel.querySelector('#umind-close').addEventListener('click', alternar);
|
|
sendEl.addEventListener('click', enviar);
|
|
inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter') enviar(); });
|
|
})();
|
|
`
|