El chat que estás viendo es el mismo que describe este post

Si tienes esta página abierta en un navegador, en la esquina hay una burbuja cian. Es Lía. Lo que sigue es la anatomía de ese widget: un solo archivo, lia-chat.js, cargado con defer al final de las 23 páginas del sitio, sin ningún framework de por medio. Escribimos este post porque construirlo obligó a tomar una docena de decisiones pequeñas —ninguna espectacular por separado, todas relevantes juntas— que son exactamente el tipo de cosas que no se ven en una demo pero sí se notan cuando el widget lleva meses en producción recibiendo tráfico real.

No es un tutorial de "cómo integrar un chat de IA en tu web". Es la bitácora de las decisiones concretas que tomamos para el nuestro, y el motivo detrás de cada una.

Una sola implementación, dos formas de aparecer

El sitio necesitaba el chat en dos contextos distintos. En el landing y en el blog, Lía tiene que ser discreta: una burbuja flotante en la esquina inferior derecha que no estorba mientras se lee, y que se abre solo si el visitante quiere. En la sección de demo de la página de oferta, en cambio, Lía es el producto que se está mostrando —tiene que estar visible desde el primer segundo, ya abierta, ocupando su propio espacio en el layout, sin que nadie tenga que hacer clic en nada para verla funcionar.

La primera versión de esto fueron dos copias del widget: una burbuja genérica y una versión inline hecha a mano para la página de oferta. Divergieron rápido, como divergen siempre dos copias de lo mismo: la versión inline no reportaba a analítica y no seguía el selector de idioma del sitio, porque nadie se acordó de propagarle esos cambios cuando se agregaron a la otra. El arreglo no fue "sincronizar las dos copias" —eso solo pospone el próximo divorcio— sino borrar una y dejar una sola fuente de verdad que decide su propio modo de montaje:

var mount = document.querySelector("[data-lia-chat]");
var inline = !!mount;
...
if (inline) {
  mount.innerHTML = ""; // el contenedor trae un fallback estático para cuando el JS no carga
  mount.appendChild(panel);
} else {
  document.body.appendChild(panel);
  document.body.appendChild(bubble);
}

Si la página tiene un contenedor con el atributo data-lia-chat, el panel se monta dentro de él, siempre abierto, sin botón de burbuja ni botón de cerrar. Si no lo tiene, se monta como burbuja flotante en el <body>. Una sola pieza de CSS, un puñado de condicionales sobre esa variable inline, y las dos superficies quedan garantizadas a comportarse igual en todo lo que no es su posición en la página.

La lección detrás de la decisión

Cuando dos superficies de un mismo producto necesitan verse distinto, la tentación es escribirlas por separado porque "total, es poco código". El costo real no es escribir la segunda copia —es que alguien tiene que acordarse de tocar las dos cada vez que cambia algo, para siempre. Un único módulo con una rama de comportamiento es más barato de mantener que dos módulos sincronizados a mano, aunque el diff inicial sea más grande.

El motor está en otro sitio; el cliente resuelve casi todo lo demás

El widget habla con un único endpoint: POST https://agent.lemusdigital.com/chat con { session, message } y recibe { reply }. Esa es toda la superficie de contrato entre el cliente y el motor conversacional. Deliberadamente no hay nada más —ni streaming de tokens, ni WebSocket, ni estado compartido más allá de esos dos campos— porque cada pieza adicional de protocolo es una pieza adicional que puede romperse en producción, y un request-response simple es la que menos formas tiene de fallar a medias.

Lo interesante de ese diseño minimalista es que empuja casi toda la complejidad de experiencia de usuario hacia el cliente: sesión, historial, idioma, seguridad del renderizado, manejo de errores. El backend solo necesita responder texto; todo lo que convierte ese texto en una conversación utilizable vive en el archivo que se descarga al navegador.

Una sesión que no se pierde al cambiar de página

El visitante puede empezar a hablar con Lía en un post del blog y seguir la misma conversación cuando llega a la página de oferta. Eso exige dos capas de persistencia con propósitos distintos:

La distinción entre los dos storages no es arbitraria: localStorage es para lo que tiene que sobrevivir semanas (quién es este visitante), sessionStorage es para lo que solo importa mientras dura la pestaña (qué se dijeron). Usar uno para lo que le corresponde al otro produce, o una sesión que se resetea cada vez que se cierra el navegador, o un transcript de meses acumulándose en un storage que no se pensó para eso.

Lo que no se puede confiar de una respuesta generada

Esta fue la parte que más nos hizo repensar el diseño inicial. La primera versión del renderizado tomaba la respuesta del modelo, escapaba <, > y &, convertía URLs en enlaces con una plantilla de texto, y metía el resultado con innerHTML. Funcionaba en cualquier prueba manual. El problema apareció al pensar en el caso adversario: ese escape no tocaba las comillas. Un mensaje que contuviera algo como www.sitio.com/x"onfocus="... —ya fuera escrito por un visitante malicioso o, más preocupante, generado por el propio modelo repitiendo texto de su contexto— podía cerrar el atributo href de la plantilla y plantar sus propios atributos en el <a> resultante, disparándose sin que nadie hiciera clic.

El arreglo no fue "escapar mejor" —seguir por ese camino es una carrera armamentista contra cada carácter especial que a uno se le pudo pasar. Fue eliminar por completo la construcción de HTML a partir de texto. La versión actual recorre la respuesta con una expresión regular que detecta enlaces y negritas en **texto**, y por cada trozo crea un nodo del DOM directamente —document.createTextNode, document.createElement("a")— en vez de ensamblar una cadena de marcado:

var a = document.createElement("a");
a.href = href;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = url;  // se muestra lo que escribió, no el href normalizado
frag.appendChild(a);

Con textContent, cualquier carácter del mensaje —comillas incluidas— se trata siempre como texto plano, nunca como marcado, porque nunca pasa por un parser de HTML. Y el href tampoco se acepta a ciegas: se construye con el parser de URL nativo del navegador y se verifica que el protocolo resultante sea http: o https:, así que un esquema como javascript: se descarta como texto en vez de convertirse en un enlace ejecutable.

Por qué esto importa más en un chat de IA que en un formulario cualquiera

Un formulario de contacto normal solo tiene que defenderse de lo que escribe el visitante. Un chat que muestra texto generado por un modelo tiene una segunda fuente de contenido no confiable: la respuesta misma, que puede terminar repitiendo fragmentos de lo que el usuario escribió. Tratar toda salida —la del visitante y la del modelo— como texto sin privilegios, nunca como marcado, es la única regla que cubre ambos casos a la vez.

Que un motor colgado no deje "escribiendo…" para siempre

Un chat que espera una respuesta HTTP sin límite de tiempo tiene un modo de fallo silencioso y particularmente malo: si el motor conversacional se cuelga o la red se cae a medias, el visitante se queda mirando "escribiendo…" indefinidamente, con el campo de texto bloqueado, sin ningún indicio de que algo salió mal. Es exactamente lo contrario de lo que un chat de ventas necesita transmitir.

El arreglo es un AbortController con un temporizador de 30 segundos que cancela el fetch si el motor no respondió a tiempo:

var ctrl = ("AbortController" in window) ? new AbortController() : null;
var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, TIMEOUT_MS);

fetch(ENDPOINT, { method: "POST", ..., signal: ctrl ? ctrl.signal : undefined })
  .then(function (r) { return r.json(); })
  .then(function (data) { ... })
  .catch(function (e) {
    addMsg(e && e.name === "AbortError" ? T.slow : T.err, "bot");
  })
  .finally(function () {
    clearTimeout(timer);
    busy = false;
    send.disabled = false;
  });

El detalle que separa esto de un simple "mostrar un error" es que distingue las dos causas de fallo y responde distinto a cada una. Un AbortError significa que el motor tardó demasiado —el mensaje invita a intentar de nuevo—. Cualquier otro error de red significa que la conexión falló de plano —el mensaje ofrece el correo de contacto como salida, porque insistir con el mismo botón probablemente no va a funcionar mejor la segunda vez—. Y el bloque finally siempre libera el botón de envío, incluso si la promesa nunca resolvió: sin él, un solo fallo de red deja el chat inutilizable hasta que se recargue la página.

Seguir el idioma del sitio sin recargar nada

El sitio detecta español o inglés según el navegador y permite cambiarlo con un selector en el header, sin recargar la página —window.__LD_setLang cambia el atributo lang del <html> en vivo. El chat tenía que seguir ese cambio: encabezado, estado "en línea", placeholder del campo, todos los aria-label. La forma más simple de lograrlo, en vez de que el widget escuche un evento personalizado que alguien tendría que recordar disparar, fue observar directamente el atributo que ya cambia:

new MutationObserver(function () { applyLang(currentLang()); })
  .observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });

Con esto, el widget no necesita saber nada sobre cómo el resto del sitio decide cambiar de idioma —solo reacciona al resultado. Es un acoplamiento mucho más débil que suscribirse a un evento propietario del selector de idioma, y significa que si mañana cambia el mecanismo del switcher, el chat sigue funcionando sin tocarlo. Una regla explícita completa el comportamiento: los mensajes ya intercambiados no se traducen retroactivamente —son historia, no interfaz—; solo el saludo inicial se traduce, y solo mientras siga siendo el único mensaje de la conversación.

Medir lo que de verdad importa, no lo que es fácil de medir

Un chat que "existe" y un chat que genera valor no son lo mismo, y la única forma de saber en cuál de los dos estamos es medirlo. La instrumentación se reduce a dos eventos, elegidos porque son los dos que de verdad separan interés de indiferencia: chat_abierto cuando el visitante muestra la primera señal de interés, y chat_primer_mensaje cuando esa conversación arranca de verdad. Ambos viajan con un parámetro modo (inline o burbuja) para poder comparar cómo se comporta cada superficie por separado.

La señal de "interés" tiene que definirse distinto según el modo, porque el modo inline no tiene un gesto de apertura —ya está abierto desde que carga la página—. Ahí, el equivalente honesto a "abrir el chat" es que el visitante ponga el foco en el campo de texto:

if (inline) {
  input.addEventListener("focus", markOpened, { once: true });
} else {
  bubble.addEventListener("click", function () { setOpen(!open); ... markOpened(); });
}

Copiar la misma definición de evento a los dos modos habría sido más simple de escribir y completamente engañoso de leer después: en modo inline, cada carga de página dispararía "chat abierto" sin que nadie hubiera mostrado ningún interés real, e inflaría artificialmente el embudo justo en el paso que se supone mide intención.

Límites defensivos que no se notan hasta que faltan

Dos números en el archivo existen exclusivamente para el caso feo, no para el caso normal. El primero es un tope de 1000 caracteres por mensaje, aplicado tanto en el atributo maxlength del <textarea> como al momento de enviar —sin ese segundo recorte, alguien podría saltarse el límite de la interfaz enviando la petición directamente. Sin ese tope, un pegote de texto enorme (pegado por accidente o de forma deliberada) va directo a la API con el mismo costo de procesamiento que cien mensajes normales, sin que el widget se dé cuenta de que algo fuera de lo común está pasando.

El segundo es el tamaño de fuente del campo de texto: 16px, ni un pixel menos. No es una preferencia estética —es la línea exacta por debajo de la cual Safari en iOS hace zoom automático al enfocar un campo de formulario. Un chat que se ve perfecto en Chrome de escritorio y que hace zoom involuntario en cualquier visitante que llega desde Safari en iPhone no es un detalle menor: es la diferencia entre un widget usable y uno que rompe su propio layout en el momento exacto en que alguien intenta escribir.

Por qué no un framework de chat

El sitio entero es HTML estático sin build step —ni bundler, ni transpilador, ni node_modules en producción—, y el chat se ajustó a esa restricción en vez de romperla. Meter una librería de chat completa, o React solo para este widget, habría significado un paso de compilación nuevo para una sola pieza del sitio, una superficie más de dependencias que actualizar, y una carga inicial más pesada para un componente que en la práctica es un panel, un campo de texto y un fetch. 364 líneas de JavaScript sin dependencias cubren sesión persistente, dos modos de montaje, renderizado seguro, timeout, seguimiento de idioma y analítica —sin necesitar nada de eso.

Esto no es una postura anti-framework en general —el resto del stack de Lemus Digital usa Next.js y Spring Boot donde el proyecto lo justifica—. Es una decisión de encaje: la herramienta correcta depende del contexto donde vive, no de cuál es la más nueva o la más completa. Para un sitio de un solo archivo sin pipeline de build, el framework correcto para el chat era no tener framework.

Cierre

Nada de lo descrito aquí es una técnica exótica. Un AbortController, un MutationObserver, nodos de DOM en vez de innerHTML, dos storages usados para lo que cada uno hace bien: son piezas estándar de la plataforma web, no una arquitectura sofisticada. Lo que sí requirió criterio fue decidir cuáles de esas piezas hacían falta para un widget que va a estar semanas en producción sin que nadie lo esté mirando, y cuáles habrían sido complejidad sin retorno. Esa es, en general, la diferencia entre un prototipo que funciona en la demo y un producto que sigue funcionando cuando nadie está viendo.

Si estás evaluando cómo poner un agente conversacional en tu propio sitio o producto —ya sea uno hecho a la medida o integrado sobre una plataforma existente— hablemos.