Pantalla completa ↗
← Código

Un chat de IA en tu web en 5 líneas

24 oct 2026· nivel fácil· 5 líneasJavaScriptIA en el navegadorPrompt APIchat

Pega 5 líneas en tu web: el script y tus preguntas frecuentes. Aparece una burbuja de chat que responde al momento sobre tu negocio, aunque el cliente lo escriba con faltas. Usa la IA que ya trae el navegador si está lista y, si no, un buscador inteligente en local. Nunca se inventa nada: si no lo sabe, ofrece «Habla con nosotros» por WhatsApp. Sin claves, sin cuotas y sin librerías.

PARA TU NEGOCIOQue tu web conteste sola las preguntas de siempre (horario, precios, aparcamiento, cómo pedir cita) a cualquier hora: en un restaurante, una clínica, una peluquería o una tienda. Lo que no sabe te llega por WhatsApp, con la pregunta ya escrita.

// CÓMO FUNCIONA

  1. Pega el script antes de </body>: <script src="chat-ia.js" defer data-negocio="Casa Pepa" data-whatsapp="34600000000"></script>. Pon el nombre de tu negocio y tu número (con el 34 delante, sin espacios).
  2. Debajo, tus preguntas y respuestas en un bloque <script type="application/json" id="chat-faq">: [{"p": "¿A qué hora abrís?", "r": "De 13:00 a 16:00…"}]. Si ya tienes una sección de preguntas con <details>, ponle data-chat-faq y el chat la lee sola.
  3. El buscador entiende al cliente como lo escribe: quita acentos, sabe que «cuánto vale» es lo mismo que «precio» y que «aparcar» es «parking», y perdona faltas como «orario» o «limpiesa». Si duda entre dos respuestas, pregunta «¿Te refieres a…?» en vez de adivinar.
  4. Si el navegador trae IA (la Prompt API de Chrome, LanguageModel) y ya está lista en ese equipo, la usa para entender mejor. Pero la IA solo elige cuál de tus preguntas encaja: la respuesta que se ve es siempre la tuya, palabra por palabra. Si no está, sigue con el buscador sin que el cliente note nada.
  5. Si ninguna pregunta encaja, no se inventa nada: dice «Eso no lo tengo apuntado» y enseña el botón «Habla con nosotros por WhatsApp», con la pregunta del cliente ya escrita en el mensaje.
  6. Vive en un Shadow DOM: tu CSS no lo rompe y él no rompe tu web. Se ve bien en móvil (sube como una hoja desde abajo) y en ordenador, sigue el modo oscuro, se maneja con teclado y lo lee un lector de pantalla. El color lo eliges con data-color.

// EL CÓDIGO

// El chat entero en un archivo. Súbelo junto a tu página y pega las 5 líneas de arriba. Las opciones (color, saludo, email…) están explicadas al principio del archivo.

/*!
 * chat-ia.js · Un chat que responde sobre tu negocio con TUS preguntas frecuentes.
 * AE Lab · Código con AEWorks · https://aeworks.tech/lab/codigo/chat-ia-5-lineas · Licencia MIT
 *
 * INSTALACIÓN (5 líneas, antes de </body>):
 *
 *   <script src="chat-ia.js" defer data-negocio="Casa Pepa" data-whatsapp="34600000000"></script>
 *   <script type="application/json" id="chat-faq">[
 *     {"p": "¿A qué hora abrís?", "r": "De martes a domingo, de 13:00 a 16:00 y de 20:00 a 23:30."},
 *     {"p": "¿Se puede reservar?", "r": "Sí, por teléfono o por WhatsApp. Te confirmamos al momento."}
 *   ]</script>
 *
 * Y aparece una burbuja de chat abajo a la derecha. Nada más.
 *
 * CÓMO RESPONDE (de lo mejor a lo más sencillo; si uno no está, pasa solo al siguiente):
 *   1. data-servidor="/chat-ia.php"  → tu propia función en el servidor (p. ej. con Claude). La clave vive allí, nunca aquí.
 *   2. La IA que trae el navegador (Prompt API de Chrome, `LanguageModel`), solo si ya está lista en ese equipo.
 *   3. Un buscador inteligente en local: sin acentos, con sinónimos y tolerante a faltas («orario», «cuanto bale»).
 * En los tres casos la IA solo ELIGE cuál de tus preguntas frecuentes encaja: la respuesta que se enseña es
 * siempre la tuya, palabra por palabra. Si ninguna encaja, no se la inventa: ofrece «Habla con nosotros».
 *
 * OPCIONES (atributos del <script>):
 *   data-negocio="Nombre"           el nombre que sale arriba del chat
 *   data-whatsapp="34600000000"     número con prefijo, sin + ni espacios (botón «Habla con nosotros»)
 *   data-email="hola@tuweb.com"     si prefieres correo (o además del WhatsApp)
 *   data-faq="preguntas.json"       las preguntas en un archivo aparte, en vez del bloque <script id="chat-faq">
 *   data-color="#0a7d5a"            color principal (por defecto, verde AEWorks)
 *   data-saludo="¡Hola! ¿En qué te ayudo?"
 *   data-texto-boton="¿Dudas? Pregúntame"
 *   data-ia="no"                    no usar nunca la IA del navegador · "descargar": permitir que la descargue (pesa varios GB)
 *   data-servidor="/chat-ia.php"    tu función de servidor (ver servidor-claude.php)
 * Cada pregunta admite "tambien": ["otras formas de preguntarlo", "palabras sueltas"] para afinar el buscador.
 * También lee tu sección de preguntas de la web: pon data-chat-faq en un bloque con <details><summary>Pregunta</summary>Respuesta</details>.
 *
 * DESDE TU CÓDIGO: ChatIA.abrir() · ChatIA.cerrar() · ChatIA.preguntar('texto') · ChatIA.motor ('servidor' | 'ia' | 'local')
 */
(() => {
  'use strict';
  if (window.ChatIA) return;
  const yo = document.currentScript || document.querySelector('script[src*="chat-ia"]');
  const opc = (k, def = '') => ((yo && yo.dataset[k]) || def).trim();

  // ============ 1. Entender el texto: normalizar, sinónimos y faltas ============
  const VACIAS = new Set(('a al algo algun alguna alguno de del el ella en es esta este esto ha hay la las le lo los me mi mis ' +
    'nos o os para pero por que se si sin su sus te tu tus un una uno unos unas y ya yo con como cual cuales donde cuando ' +
    'hola buenas buenos dias tardes noches gracias porfa favor quiero queria querria saber pregunta duda vosotros ustedes usted ' +
    'puedo podria puede pueden podeis teneis tienen tiene tengo hacer haceis hace hacen ser estar estoy esta estan muy mas ' +
    'tambien solo otra otro eso esa ese aqui alli hoy atendeis atender atienden ofreceis cosa cosas algo vale ok').split(' '));
  // Cada fila: la primera palabra es la «idea»; las demás significan lo mismo para el buscador.
  const SINONIMOS = [
    'horario hora horas abrir abris abren abierto abiertos abre cerrar cerrais cierran cerrado cierra apertura cuando',
    'precio precios cuesta cuestan cuanto vale valen tarifa tarifas coste costo pagar caro barato euros presupuesto',
    'reservar reserva reservas cita citas turno pedir agendar apuntar mesa hueco',
    'aparcar aparcamiento parking coche aparco estacionar garaje',
    'pago pagos tarjeta bizum efectivo financiar financiacion plazos datafono',
    'direccion donde ubicacion llegar sitio calle estais encuentra situados mapa local',
    'mascota mascotas perro perros gato animales',
    'gluten celiaco celiacos celiaca alergia alergias alergenos intolerancia',
    'envio envios enviar entrega entregas domicilio reparto llega mandar mandais',
    'devolver devolucion devoluciones cambio cambiar reembolso',
    'urgencia urgencias urgente dolor duele duelen doler emergencia',
    'seguro seguros mutua aseguradora sanitas adeslas',
    'telefono llamar contacto contactar whatsapp correo email hablar persona',
    'nino ninos infantil bebe hijos pequenos',
    'sabado sabados domingo domingos festivo festivos finde fin semana',
    'vegano vegana veganos vegetariano vegetariana',
    'limpieza limpiezas higiene',
    'implante implantes',
    'ortodoncia brackets invisible alineadores',
    'blanqueamiento blanquear blanco blancos',
    'primera visita revision valoracion diagnostico gratis gratuita',
  ].map((f) => f.split(' '));
  // Cómo suena: «bale» = «vale», «orario» = «horario», «limpiesa» = «limpieza».
  const fon = (w) => w.replace(/h/g, '').replace(/v/g, 'b').replace(/z/g, 's').replace(/c([ei])/g, 's$1').replace(/qu([ei])/g, 'k$1')
    .replace(/c/g, 'k').replace(/ll/g, 'y').replace(/(.)/g, '$1');
  const IDEA = new Map();
  SINONIMOS.forEach((fila) => fila.forEach((p) => { IDEA.set(p, fila[0]); IDEA.set(fon(p), fila[0]); }));

  const normalizar = (t) => String(t || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9ñ ]+/g, ' ').replace(/\s+/g, ' ').trim();

  function distancia(a, b, tope) {               // Levenshtein con salida rápida
    if (Math.abs(a.length - b.length) > tope) return tope + 1;
    let prev = Array.from({length: b.length + 1}, (_, j) => j);
    for (let i = 1; i <= a.length; i++) {
      const cur = [i];
      let min = i;
      for (let j = 1; j <= b.length; j++) {
        cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
        if (cur[j] < min) min = cur[j];
      }
      if (min > tope) return tope + 1;
      prev = cur;
    }
    return prev[b.length];
  }
  // Raíz sencilla: quita plurales y terminaciones verbales comunes («abrís» → «abr», «precios» → «preci»).
  const raiz = (w) => w.length <= 4 ? w : w.replace(/(aciones|acion|amente|mente|ais|eis|amos|emos|imos|ando|iendo|aron|ieron|ados|idos|ado|ido|as|es|os|an|en|ar|er|ir|a|o|e|s)$/, '') || w;

  // Busca la palabra en el diccionario de sinónimos, perdonando una falta (o dos en palabras largas).
  function idea(w) {
    if (IDEA.has(w)) return IDEA.get(w);
    const f = fon(w);
    if (IDEA.has(f)) return IDEA.get(f);
    if (f.length >= 5) {
      const tope = f.length >= 8 ? 2 : 1;
      for (const [k, v] of IDEA) if (k.length >= 4 && distancia(f, k, tope) <= tope) return v;
    }
    return null;
  }
  function fichas(texto) {
    return normalizar(texto).split(' ').filter((w) => w && !VACIAS.has(w)).map((w) => ({w, f: fon(w), r: raiz(fon(w)), i: idea(w)}));
  }
  // ¿Cuánto se parecen dos palabras? 1 = misma idea o misma raíz; 0,75 = una falta; 0 = nada.
  function parecido(a, b) {
    if (a.i && a.i === b.i) return 1;
    if (a.f === b.f || (a.r.length >= 3 && a.r === b.r)) return 1;
    if (a.f.length >= 5 && b.f.length >= 5) {
      if (a.f.slice(0, 5) === b.f.slice(0, 5)) return 0.85;
      const tope = Math.min(a.f.length, b.f.length) >= 8 ? 2 : 1;
      if (distancia(a.f, b.f, tope) <= tope) return 0.75;
    }
    return 0;
  }

  function crearBuscador(faqs) {
    const docs = faqs.map((f) => ({
      fuerte: fichas([f.p].concat(f.tambien || []).join(' ')),
      flojo: fichas(f.r),
    }));
    // Las palabras que salen en todas las preguntas pesan menos (idf).
    const df = new Map();
    docs.forEach((d) => new Set(d.fuerte.map((x) => x.i || x.r)).forEach((k) => df.set(k, (df.get(k) || 0) + 1)));
    // Una palabra que no sale en ninguna pregunta («cosas», «porfa») casi no cuenta: no hunde la nota.
    const peso = (x) => { const d = df.get(x.i || x.r) || 0; return d ? Math.log(1 + faqs.length / d) : 0.3; };
    return function buscar(pregunta) {
      const q = fichas(pregunta);
      if (!q.length) return [];
      return docs.map((d, n) => {
        let suma = 0, total = 0;
        for (const x of q) {
          const pw = peso(x);
          total += pw;
          let mejor = 0;
          for (const y of d.fuerte) mejor = Math.max(mejor, parecido(x, y));
          if (mejor < 1) for (const y of d.flojo) mejor = Math.max(mejor, parecido(x, y) * 0.45);
          suma += mejor * pw;
        }
        return {n, nota: suma / total};
      }).sort((a, b) => b.nota - a.nota);
    };
  }

  // ============ 2. Los tres «motores» que eligen la respuesta ============
  const conTiempo = (promesa, ms) => Promise.race([promesa, new Promise((_, no) => setTimeout(() => no(new Error('tiempo')), ms))]);

  // 2a. IA del navegador (Prompt API). Solo si ya está descargada y lista: no hace esperar a nadie.
  async function iaDisponible() {
    const modo = opc('ia', 'si');
    if (modo === 'no' || !('LanguageModel' in self)) return false;
    try {
      const estado = await conTiempo(LanguageModel.availability({
        expectedInputs: [{type: 'text', languages: ['es']}], expectedOutputs: [{type: 'text', languages: ['es']}],
      }), 3000);
      return estado === 'available' || (modo === 'descargar' && (estado === 'downloadable' || estado === 'downloading'));
    } catch (e) { return false; }
  }
  let sesionIA = null;
  async function preguntarIA(texto, faqs) {
    if (!sesionIA) {
      const lista = faqs.map((f, i) => `${i + 1}. ${f.p}`).join('\n');
      sesionIA = await LanguageModel.create({
        expectedInputs: [{type: 'text', languages: ['es']}], expectedOutputs: [{type: 'text', languages: ['es']}],
        initialPrompts: [{role: 'system', content:
          'Eres el asistente de la web de un negocio. NO respondes tú: solo eliges cuál de estas preguntas frecuentes ' +
          'contesta a lo que pide el cliente, o 0 si ninguna lo contesta de verdad. Si dudas, 0.\n' + lista}],
      });
    }
    const esquema = {type: 'object', properties: {n: {type: 'integer', minimum: 0, maximum: faqs.length}}, required: ['n']};
    const s = await sesionIA.clone();     // cada pregunta en limpio, sin arrastrar la anterior
    try {
      const r = JSON.parse(await conTiempo(s.prompt('Cliente: ' + texto, {responseConstraint: esquema}), 12000));
      return Number.isInteger(r.n) && r.n >= 0 && r.n <= faqs.length ? r.n - 1 : -1;
    } finally { s.destroy && s.destroy(); }
  }

  // 2b. Tu servidor (opcional). Recibe la pregunta y la lista; devuelve {"n": número de pregunta (1…N) o 0}.
  async function preguntarServidor(texto, faqs) {
    const r = await conTiempo(fetch(opc('servidor'), {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({pregunta: texto.slice(0, 500), preguntas: faqs.map((f) => f.p)}),
    }), 12000);
    if (!r.ok) throw new Error('servidor ' + r.status);
    const n = Number((await r.json()).n);
    return Number.isInteger(n) && n >= 0 && n <= faqs.length ? n - 1 : -1;
  }

  // ============ 3. Cargar tus preguntas ============
  async function cargarFaqs() {
    let lista = [];
    const bloque = document.getElementById('chat-faq');
    try {
      if (opc('faq')) lista = await (await fetch(opc('faq'))).json();
      else if (bloque) lista = JSON.parse(bloque.textContent);
    } catch (e) { console.warn('[chat-ia] No he podido leer tus preguntas: revisa que el JSON esté bien escrito.', e); }
    document.querySelectorAll('[data-chat-faq] details').forEach((d) => {
      const s = d.querySelector('summary');
      if (!s) return;
      const r = d.textContent.replace(s.textContent, '').replace(/\s+/g, ' ').trim();
      if (r) lista.push({p: s.textContent.trim(), r});
    });
    return (Array.isArray(lista) ? lista : []).map((f) => ({
      p: String(f.p || f.pregunta || '').trim(), r: String(f.r || f.respuesta || '').trim(),
      tambien: [].concat(f.tambien || []).map(String),
    })).filter((f) => f.p && f.r);
  }

  // ============ 4. La burbuja (Shadow DOM: tu CSS no la rompe y ella no rompe tu web) ============
  const ICONO_CHAT = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v8a2.5 2.5 0 0 1-2.5 2.5H10l-4.2 3.6c-.5.4-1.3.1-1.3-.6V16A2.5 2.5 0 0 1 4 13.5z" fill="currentColor"/><circle cx="8.5" cy="9.5" r="1.3" fill="var(--sobre)"/><circle cx="12" cy="9.5" r="1.3" fill="var(--sobre)"/><circle cx="15.5" cy="9.5" r="1.3" fill="var(--sobre)"/></svg>';
  const ICONO_X = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>';
  const ICONO_ENVIAR = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12l15-7-5 15-2.6-6.4z" fill="currentColor"/></svg>';
  const ICONO_PERSONA = '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4" fill="currentColor"/><path d="M4 20c1.2-4 4.4-6 8-6s6.8 2 8 6" fill="currentColor"/></svg>';

  const CSS = `
  :host{all:initial;--color:#00C26A;--sobre:#04140C;--fondo:#ffffff;--texto:#15201A;--suave:#5C6B63;--linea:#E3EAE6;--burbuja:#F1F5F3;
    font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;position:fixed;z-index:2147483000;right:20px;bottom:20px;color:var(--texto)}
  @media (prefers-color-scheme:dark){:host{--fondo:#0F1713;--texto:#EAF3EE;--suave:#98A89F;--linea:#24332B;--burbuja:#18241E}}
  *{box-sizing:border-box}
  button{font:inherit;cursor:pointer}
  .lanzador{display:flex;align-items:center;gap:10px;border:0;border-radius:999px;padding:14px 20px 14px 16px;background:var(--color);color:var(--sobre);
    font-size:16px;font-weight:800;box-shadow:0 10px 30px rgba(0,0,0,.25),0 0 0 0 var(--color);transition:transform .2s,box-shadow .2s;animation:latido 2.6s ease-out 1.2s 2}
  .lanzador:hover{transform:translateY(-2px)}
  .lanzador svg{width:26px;height:26px}
  @keyframes latido{0%{box-shadow:0 10px 30px rgba(0,0,0,.25),0 0 0 0 color-mix(in srgb,var(--color) 60%,transparent)}100%{box-shadow:0 10px 30px rgba(0,0,0,.25),0 0 0 18px transparent}}
  .lanzador[hidden]{display:none}
  .panel{position:absolute;right:0;bottom:0;width:380px;height:min(600px,calc(100vh - 40px));display:flex;flex-direction:column;background:var(--fondo);
    border:1px solid var(--linea);border-radius:22px;box-shadow:0 24px 70px rgba(0,0,0,.3);overflow:hidden;transform-origin:bottom right;
    animation:abre .28s cubic-bezier(.2,.9,.3,1.15)}
  .panel[hidden]{display:none}
  @keyframes abre{from{opacity:0;transform:translateY(16px) scale(.92)}}
  .cab{display:flex;align-items:center;gap:12px;padding:16px 16px 14px;background:var(--color);color:var(--sobre)}
  .ava{flex:none;width:40px;height:40px;border-radius:50%;display:grid;place-items:center;background:var(--sobre);color:var(--color)}
  .ava svg{width:22px;height:22px}
  .cab b{display:block;font-size:17px;line-height:1.2}
  .cab small{display:flex;align-items:center;gap:6px;font-size:13px;opacity:.85;margin-top:2px}
  .cab small i{width:8px;height:8px;border-radius:50%;background:var(--sobre);opacity:.7}
  .cerrar{margin-left:auto;flex:none;width:40px;height:40px;border-radius:50%;border:0;background:transparent;color:var(--sobre);display:grid;place-items:center}
  .cerrar:hover{background:rgba(0,0,0,.1)}
  .cerrar svg{width:22px;height:22px}
  .hilo{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px;scroll-behavior:smooth;overscroll-behavior:contain}
  .msg{max-width:86%;padding:11px 14px;border-radius:18px;font-size:15.5px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word;animation:entra .25s ease-out}
  @keyframes entra{from{opacity:0;transform:translateY(8px)}}
  .msg.bot{align-self:flex-start;background:var(--burbuja);border-bottom-left-radius:6px}
  .msg.yo{align-self:flex-end;background:var(--color);color:var(--sobre);font-weight:600;border-bottom-right-radius:6px}
  .msg .fuente{display:block;margin-top:6px;font-size:12px;color:var(--suave)}
  .escribe{display:inline-flex;gap:4px;padding:14px 16px}
  .escribe i{width:8px;height:8px;border-radius:50%;background:var(--suave);animation:punto 1s infinite}
  .escribe i:nth-child(2){animation-delay:.15s}.escribe i:nth-child(3){animation-delay:.3s}
  @keyframes punto{0%,80%,100%{opacity:.3;transform:translateY(0)}40%{opacity:1;transform:translateY(-4px)}}
  .chips{display:flex;flex-wrap:wrap;gap:8px;align-self:stretch}
  .chip{border:1.5px solid var(--color);background:transparent;color:var(--texto);border-radius:999px;padding:9px 14px;font-size:14.5px;font-weight:600;text-align:left}
  .chip:hover{background:color-mix(in srgb,var(--color) 14%,transparent)}
  .humano{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:14px;padding:12px 16px;background:var(--color);color:var(--sobre);
    font-size:15.5px;font-weight:800;text-decoration:none;align-self:flex-start}
  .humano svg{width:20px;height:20px}
  form{display:flex;gap:8px;padding:12px;border-top:1px solid var(--linea);background:var(--fondo)}
  input{flex:1;min-width:0;font:inherit;font-size:16px;color:var(--texto);background:var(--burbuja);border:2px solid transparent;border-radius:14px;padding:12px 14px}
  input:focus{outline:none;border-color:var(--color)}
  input::placeholder{color:var(--suave)}
  .enviar{flex:none;width:48px;border:0;border-radius:14px;background:var(--color);color:var(--sobre);display:grid;place-items:center}
  .enviar svg{width:22px;height:22px}
  .pie{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:0 14px 10px;font-size:12px;color:var(--suave);background:var(--fondo)}
  .pie button,.pie a{border:0;background:none;color:var(--suave);font-size:12px;text-decoration:underline;padding:4px 0}
  .info{font-size:13px;color:var(--suave);background:var(--burbuja);border-radius:12px;padding:10px 12px;margin:0 12px 10px;line-height:1.45}
  .info[hidden]{display:none}
  :focus-visible{outline:3px solid var(--color);outline-offset:2px}
  .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
  @media (max-width:520px){
    :host{right:14px;bottom:14px}
    .lanzador{padding:13px 18px 13px 14px;font-size:15.5px}
    .panel{position:fixed;inset:auto 0 0 0;width:100%;height:min(88svh,100%);border-radius:22px 22px 0 0;transform-origin:bottom center}
  }
  @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}.hilo{scroll-behavior:auto}}
  `;

  async function iniciar() {
    const faqs = await cargarFaqs();
    const buscar = crearBuscador(faqs);
    const negocio = opc('negocio', document.title.split(/[·|–-]/)[0].trim() || 'Nosotros');
    const whatsapp = opc('whatsapp').replace(/\D/g, '');
    const email = opc('email');
    let motor = 'local';
    if (opc('servidor')) motor = 'servidor';
    else if (await iaDisponible()) motor = 'ia';

    const host = document.createElement('div');
    host.id = 'chat-ia';
    const raizS = host.attachShadow({mode: 'open'});
    const color = opc('color');
    raizS.innerHTML = `<style>${CSS}${color ? `:host{--color:${color.replace(/[^#\w(),.% ]/g, '')}}` : ''}</style>
      <button class="lanzador" type="button" aria-haspopup="dialog" aria-expanded="false">${ICONO_CHAT}<span>${esc(opc('textoBoton', '¿Dudas? Pregúntame'))}</span></button>
      <section class="panel" role="dialog" aria-modal="false" aria-label="Chat de ${esc(negocio)}" hidden>
        <header class="cab"><span class="ava">${ICONO_CHAT}</span>
          <div><b>${esc(negocio)}</b><small><i></i><span class="estado">Responde al momento</span></small></div>
          <button class="cerrar" type="button" aria-label="Cerrar el chat">${ICONO_X}</button></header>
        <div class="hilo" role="log" aria-live="polite" aria-relevant="additions"></div>
        <p class="info" hidden></p>
        <form><label class="sr" for="q">Escribe tu pregunta</label>
          <input id="q" type="text" autocomplete="off" enterkeyhint="send" maxlength="300" placeholder="Escribe tu pregunta…">
          <button class="enviar" type="submit" aria-label="Enviar">${ICONO_ENVIAR}</button></form>
        <div class="pie"><button type="button" class="como">¿Cómo funciona?</button>${(whatsapp || email) ? '<a class="contacto" href="#">Habla con nosotros</a>' : ''}</div>
      </section>`;
    document.body.appendChild(host);

    const $ = (s) => raizS.querySelector(s);
    const lanzador = $('.lanzador'), panel = $('.panel'), hilo = $('.hilo'), input = $('input'), info = $('.info');
    let saludado = false;

    const enlaceHumano = (texto) => whatsapp
      ? `https://wa.me/${whatsapp}?text=${encodeURIComponent('Hola, tengo una pregunta: ' + (texto || ''))}`
      : `mailto:${email}?subject=${encodeURIComponent('Pregunta desde la web')}&body=${encodeURIComponent(texto || '')}`;
    $('.contacto') && $('.contacto').addEventListener('click', (e) => { e.preventDefault(); window.open(enlaceHumano(''), '_blank', 'noopener'); });

    const explicacion = {
      ia: 'Usa la inteligencia artificial que trae tu navegador, aquí en tu equipo: entiende lo que preguntas y busca la respuesta entre nuestras preguntas frecuentes. No se inventa nada y lo que escribes no sale de tu ordenador.',
      local: 'Busca tu respuesta entre nuestras preguntas frecuentes, aunque lo escribas a tu manera o con faltas. No se inventa nada y lo que escribes no sale de tu ordenador.',
      servidor: 'Una inteligencia artificial entiende lo que preguntas y elige la respuesta entre nuestras preguntas frecuentes. No se inventa nada.',
    };
    $('.estado').textContent = motor === 'local' ? 'Responde al momento' : 'Con IA · responde al momento';
    $('.como').addEventListener('click', () => { info.textContent = explicacion[motor]; info.hidden = !info.hidden; });

    function burbuja(clase, html) {
      const d = document.createElement('div');
      d.className = 'msg ' + clase;
      d.innerHTML = html;
      hilo.appendChild(d);
      hilo.scrollTop = hilo.scrollHeight;
      return d;
    }
    function chips(lista) {
      const c = document.createElement('div');
      c.className = 'chips';
      lista.forEach((f) => {
        const b = document.createElement('button');
        b.type = 'button'; b.className = 'chip'; b.textContent = f.p;
        b.addEventListener('click', () => { c.remove(); preguntar(f.p); });
        c.appendChild(b);
      });
      hilo.appendChild(c);
      hilo.scrollTop = hilo.scrollHeight;
    }
    function botonHumano(texto) {
      if (!whatsapp && !email) return;
      const a = document.createElement('a');
      a.className = 'humano'; a.href = enlaceHumano(texto); a.target = '_blank'; a.rel = 'noopener';
      a.innerHTML = `${ICONO_PERSONA}<span>Habla con nosotros${whatsapp ? ' por WhatsApp' : ''}</span>`;
      hilo.appendChild(a);
      hilo.scrollTop = hilo.scrollHeight;
    }

    function abrir() {
      lanzador.hidden = true; lanzador.setAttribute('aria-expanded', 'true'); panel.hidden = false;
      if (!saludado) {
        saludado = true;
        if (!faqs.length) {
          burbuja('bot', 'Aún no tengo preguntas cargadas.' + ((whatsapp || email) ? ' Escríbenos y te contestamos enseguida.' : ''));
          botonHumano('');
        } else {
          burbuja('bot', esc(opc('saludo', `¡Hola! Soy el asistente de ${negocio}. ¿En qué te ayudo?`)));
          chips(faqs.slice(0, 3));
        }
      }
      if (!matchMedia('(hover: none)').matches) setTimeout(() => input.focus(), 60);
    }
    function cerrar() { panel.hidden = true; lanzador.hidden = false; lanzador.setAttribute('aria-expanded', 'false'); lanzador.focus(); }
    lanzador.addEventListener('click', abrir);
    $('.cerrar').addEventListener('click', cerrar);
    panel.addEventListener('keydown', (e) => { if (e.key === 'Escape') cerrar(); });

    // Elige la respuesta: primero el motor bueno; si falla o tarda, el buscador local. Nunca deja al cliente sin respuesta.
    async function elegir(texto) {
      const local = buscar(texto);
      const seguro = local[0] && local[0].nota >= 0.6 && (!local[1] || local[0].nota - local[1].nota >= 0.15);
      if (motor !== 'local' && !seguro) {
        try {
          const n = motor === 'ia' ? await preguntarIA(texto, faqs) : await preguntarServidor(texto, faqs);
          return {n, dudas: []};
        } catch (e) {
          console.warn('[chat-ia] La IA no ha respondido; sigo con el buscador local.', e);
        }
      }
      if (!local[0] || local[0].nota < 0.4) return {n: -1, dudas: []};
      const segunda = local[1] && local[1].nota >= 0.4 ? local[1].nota : 0;
      // Clara: buena nota y sin otra que le haga sombra. Si no, pregunta «¿Te refieres a…?» en vez de adivinar.
      if (local[0].nota >= 0.5 && local[0].nota - segunda >= 0.12) return {n: local[0].n, dudas: []};
      return {n: -1, dudas: local.filter((x) => x.nota >= 0.4).slice(0, 3).map((x) => x.n)};
    }

    let ocupado = false;
    async function preguntar(texto) {
      texto = String(texto || '').trim();
      if (!texto || ocupado) return;
      ocupado = true;
      if (panel.hidden) abrir();
      hilo.querySelectorAll('.chips').forEach((c) => c.remove());
      burbuja('yo', esc(texto));
      const escribe = burbuja('bot escribe', '<i></i><i></i><i></i>');
      escribe.setAttribute('aria-label', 'Escribiendo');
      const [res] = await Promise.all([elegir(texto), new Promise((r) => setTimeout(r, 700))]);
      escribe.remove();
      if (res.n >= 0) {
        burbuja('bot', `${esc(faqs[res.n].r)}<span class="fuente">De nuestras preguntas frecuentes</span>`);
      } else if (res.dudas.length) {
        burbuja('bot', '¿Te refieres a alguna de estas?');
        chips(res.dudas.map((n) => faqs[n]));
      } else {
        burbuja('bot', (whatsapp || email)
          ? 'Eso no lo tengo apuntado y prefiero no inventármelo. Te pasamos con una persona y te lo dice seguro.'
          : 'Eso no lo tengo apuntado y prefiero no inventármelo. Prueba a preguntarlo de otra forma.');
        botonHumano(texto);
      }
      ocupado = false;
    }
    $('form').addEventListener('submit', (e) => { e.preventDefault(); const t = input.value; input.value = ''; preguntar(t); });

    window.ChatIA = {abrir, cerrar, preguntar, buscar, faqs, get motor() { return motor; }};
    window.dispatchEvent(new CustomEvent('chat-ia:listo', {detail: {motor, preguntas: faqs.length}}));
  }

  function esc(t) { return String(t).replace(/[&<>"']/g, (c) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c])); }

  window.ChatIA = {cargando: true};
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', iniciar);
  else iniciar();
})();
  • Funciona en cualquier navegador actual (Chrome, Edge, Safari en Mac y iPhone, Firefox, Samsung Internet), en móvil y en ordenador. No necesita HTTPS ni permisos.
  • La IA del navegador (Prompt API de Chrome) solo se usa si ya está descargada en ese equipo: en octubre de 2026 no está en todos los navegadores ni en todos los ordenadores. Donde no está, responde el buscador local, que también entiende faltas y sinónimos.
  • Privacidad: con el buscador local o la IA del navegador, lo que escribe el cliente no sale de su equipo. Solo si usas la opción de servidor, la pregunta viaja a tu servidor (y de ahí a Claude): avísalo en tu política de privacidad.
  • Sin claves de API en la web, sin cuotas y sin librerías. Cuantas más formas de preguntar añadas en "tambien", mejor acierta.

¿Lo quieres en tu web y no te ves con fuerzas?

En AEWorks lo montamos por ti, junto con lo que necesite tu negocio.
Hablemos →

Ver el vídeo ↗