Un chat de IA en tu web en 5 líneas
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.
// CÓMO FUNCIONA
- 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).
- 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.
- 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.
- 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.
- 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.
- 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) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); }
window.ChatIA = {cargando: true};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', iniciar);
else iniciar();
})();
// Una página normal con las 5 líneas y tres preguntas de un restaurante. Guárdala junto a chat-ia.js, ábrela y pregunta «¿tenéis algo para celíacos?».
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Casa Pepa · Restaurante</title>
<style>
body{font-family:Arial,sans-serif;max-width:560px;margin:24px auto;padding:0 16px;background:#fff;color:#222}
</style>
</head>
<body>
<h1>Casa Pepa</h1>
<p>Tu web de siempre. Abajo a la derecha aparece el chat.</p>
<!-- Estas 5 líneas son todo lo que añades a tu web (cambia el nombre, el número y tus preguntas) -->
<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. Los lunes cerramos."},
{"p": "¿Se puede reservar mesa?", "r": "Sí, por WhatsApp o llamando. Te confirmamos al momento.", "tambien": ["mesa para dos", "reserva"]},
{"p": "¿Tenéis platos sin gluten?", "r": "Sí: la mitad de la carta es sin gluten y lo marcamos con una espiga tachada."}
]</script>
</body>
</html>
// La demo del vídeo: una clínica ficticia con nueve preguntas y su sección de preguntas frecuentes. Usa el mismo chat-ia.js. Con ?demo=1 al final de la dirección, la conversación se escribe sola.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clínica Dental Nácar · Demo del chat con IA</title>
<style>
:root{--fondo:#F6FAF8;--tinta:#10231C;--suave:#5A6E65;--verde:#0FA37F;--verde2:#0B7F63;--tarjeta:#fff;--linea:#DDE8E3}
*{box-sizing:border-box}
html{color-scheme:light}
body{margin:0;min-height:100svh;background:radial-gradient(ellipse at 85% -10%,#CFF3E5,transparent 55%),var(--fondo);color:var(--tinta);
font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;overflow-x:hidden}
.aviso{background:var(--tinta);color:#CFEFE2;text-align:center;font-size:12.5px;padding:7px 16px;letter-spacing:.3px}
.aviso b{color:#fff}
nav{max-width:1180px;margin:0 auto;padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.logo{display:flex;align-items:center;gap:10px;font-weight:900;font-size:20px;letter-spacing:-.3px}
.logo svg{width:34px;height:34px}
.menu{display:flex;gap:26px;font-weight:600;color:var(--suave);font-size:15px}
.tel{font-weight:800;color:var(--verde2);font-size:15px;white-space:nowrap}
main{max-width:1180px;margin:0 auto;padding:clamp(18px,5vh,64px) 20px 40px;display:grid;gap:28px}
.eti{display:inline-flex;align-items:center;gap:8px;background:#E2F5EE;color:var(--verde2);font-weight:800;font-size:13px;border-radius:999px;padding:7px 13px}
h1{font-size:clamp(34px,6.2vw,66px);line-height:1.02;letter-spacing:-1.8px;margin:16px 0 14px;max-width:12ch}
h1 em{font-style:normal;color:var(--verde)}
.sub{font-size:clamp(16px,1.8vw,19px);color:var(--suave);max-width:46ch;line-height:1.5;margin:0}
.botones{display:flex;flex-wrap:wrap;gap:12px;margin-top:26px}
.btn{border:0;border-radius:14px;padding:15px 22px;font:800 16px system-ui,sans-serif;background:var(--verde);color:#fff;cursor:pointer;text-decoration:none}
.btn.b{background:#fff;color:var(--tinta);border:1.5px solid var(--linea)}
.datos{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-top:34px;max-width:560px}
.datos div{background:var(--tarjeta);border:1px solid var(--linea);border-radius:16px;padding:14px}
.datos b{display:block;font-size:22px;color:var(--verde2)}
.datos span{font-size:13px;color:var(--suave)}
.faq{max-width:760px}
.faq h2{font-size:24px;margin:30px 0 12px}
.faq details{background:var(--tarjeta);border:1px solid var(--linea);border-radius:14px;padding:14px 16px;margin-bottom:10px}
.faq summary{font-weight:700;cursor:pointer}
.faq details p{margin:10px 0 0;color:var(--suave);line-height:1.5}
@media (max-width:820px){.menu{display:none}.datos{grid-template-columns:repeat(3,1fr)}.datos b{font-size:18px}}
@media (max-width:420px){.tel{display:none}}
</style>
</head>
<body>
<div class="aviso"><b>Demo</b> · Clínica ficticia para enseñar el chat · Código con AEWorks</div>
<nav>
<div class="logo"><svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="12" fill="#0FA37F"/><path d="M13 12c-3 0-5 2.4-4.6 6 .4 3.4 1.8 5.4 2.6 9 .5 2.4 1.4 4 2.6 4 1.7 0 1.9-3 2.8-6 .5-1.5 1-2.2 2.1-2.2h2c1.1 0 1.6.7 2.1 2.2.9 3 1.1 6 2.8 6 1.2 0 2.1-1.6 2.6-4 .8-3.6 2.2-5.6 2.6-9 .4-3.6-1.6-6-4.6-6-2.6 0-3.6 1.4-6.5 1.4S15.6 12 13 12z" fill="#fff"/></svg>Clínica Dental Nácar</div>
<div class="menu"><span>Tratamientos</span><span>Precios</span><span>Equipo</span><span>Contacto</span></div>
<div class="tel">600 00 00 00</div>
</nav>
<main>
<section>
<span class="eti">● Primera visita gratis</span>
<h1>Tu sonrisa, <em>sin esperas</em>.</h1>
<p class="sub">Odontología general, ortodoncia invisible e implantes. ¿Tienes una duda? Pregúntale al chat: te responde al momento.</p>
<div class="botones"><a class="btn" href="#" onclick="ChatIA.abrir();return false">Pregúntanos ahora</a><a class="btn b" href="#faq">Ver preguntas frecuentes</a></div>
<div class="datos"><div><b>+2.000</b><span>pacientes</span></div><div><b>4,9 ★</b><span>valoración</span></div><div><b>Sáb.</b><span>abierto</span></div></div>
</section>
<!-- El chat también puede leer las preguntas que ya tienes en tu web (data-chat-faq) -->
<section class="faq" id="faq" data-chat-faq>
<h2>Preguntas frecuentes</h2>
<details><summary>¿La primera visita es gratis?</summary><p>Sí. La primera visita y la revisión con radiografía son gratis y sin compromiso.</p></details>
<details><summary>¿Atendéis urgencias?</summary><p>Sí. Si tienes dolor, llámanos o escríbenos por WhatsApp y te vemos el mismo día.</p></details>
</section>
</main>
<!-- Las 5 líneas del truco: el script y tus preguntas -->
<script src="chat-ia.js" defer data-negocio="Clínica Dental Nácar" data-whatsapp="34600000000" data-color="#0FA37F"></script>
<script type="application/json" id="chat-faq">[
{"p": "¿Qué horario tenéis?", "r": "De lunes a viernes, de 9:00 a 20:00. Y los sábados, de 10:00 a 14:00.", "tambien": ["abrís el sábado", "a qué hora abrís", "cuándo cerráis"]},
{"p": "¿Cuánto cuesta una limpieza?", "r": "La limpieza dental cuesta 45 €. Con la primera visita gratis, te revisamos antes sin compromiso.", "tambien": ["precio limpieza", "higiene dental"]},
{"p": "¿Hacéis ortodoncia invisible?", "r": "Sí. Hacemos ortodoncia invisible con alineadores transparentes. El estudio inicial es gratis.", "tambien": ["brackets", "alinear los dientes"]},
{"p": "¿Cuánto cuesta un implante?", "r": "Un implante completo, con la corona, desde 890 €. Te damos el presupuesto cerrado en la primera visita.", "tambien": ["precio implante"]},
{"p": "¿Se puede pagar a plazos?", "r": "Sí. Puedes pagar con tarjeta, Bizum o en efectivo, y financiar hasta 24 meses sin intereses.", "tambien": ["financiación", "pagar con tarjeta"]},
{"p": "¿Trabajáis con seguros?", "r": "Sí, trabajamos con las principales mutuas. Tráenos tu tarjeta y te decimos qué te cubre.", "tambien": ["mutua", "aseguradora"]},
{"p": "¿Hay aparcamiento?", "r": "Sí. Hay un parking público a 50 metros, y la primera hora te la pagamos nosotros.", "tambien": ["dónde aparco", "parking"]},
{"p": "¿Cómo pido cita?", "r": "Por WhatsApp, llamando al 600 00 00 00 o aquí mismo: pulsa «Habla con nosotros» y te damos hueco.", "tambien": ["reservar cita", "pedir turno"]},
{"p": "¿Hacéis blanqueamiento?", "r": "Sí. El blanqueamiento en clínica cuesta 250 € y se hace en una sola sesión de una hora.", "tambien": ["blanquear los dientes"]}
]</script>
<!-- Solo para grabar el vídeo: demo.html?demo=1 hace la conversación sola -->
<script>
if (new URLSearchParams(location.search).has('demo')) {
const espera = (ms) => new Promise((r) => setTimeout(r, ms));
async function teclear(texto) {
const raiz = document.getElementById('chat-ia').shadowRoot;
const input = raiz.querySelector('input');
for (const c of texto) { input.value += c; await espera(55); }
await espera(260);
raiz.querySelector('.enviar').click();
}
window.addEventListener('chat-ia:listo', async () => {
await espera(700);
ChatIA.abrir();
await espera(1000);
await teclear('¿abrís los sábados?');
await espera(2700);
await teclear('cuanto bale una limpiesa');
await espera(2900);
await teclear('¿Atendéis a domicilio?');
});
}
</script>
</body>
</html>
// Opcional, para que entienda todo en cualquier navegador: tu propia función en el servidor con Claude. La clave se queda en tu servidor y Claude solo devuelve el número de la pregunta. Se activa con data-servidor="/servidor-claude.php".
<?php
/**
* servidor-claude.php · OPCIONAL: el chat de chat-ia.js con Claude en tu servidor.
* AE Lab · Código con AEWorks · https://aeworks.tech/lab/codigo/chat-ia-5-lineas · Licencia MIT
*
* Para qué: si quieres que entienda TODAS las preguntas en TODOS los navegadores (no solo en Chrome con IA integrada).
* Lo importante: la clave de Claude vive aquí, en tu servidor. Nunca en la web: el navegador no la ve jamás.
* Y Claude tampoco se inventa nada: solo devuelve el NÚMERO de tu pregunta frecuente que encaja (o 0).
* La respuesta que lee el cliente sigue siendo la tuya, palabra por palabra.
*
* Cómo se conecta (3 pasos):
* 1. En tu servidor: composer require "anthropic-ai/sdk"
* 2. Guarda tu clave como variable de entorno ANTHROPIC_API_KEY (en el panel de tu hosting). No la escribas aquí.
* 3. En tu web, añade data-servidor="/servidor-claude.php" al <script src="chat-ia.js">. Listo.
*
* Si este archivo falla, tarda o no hay saldo, chat-ia.js pasa solo a su buscador local: el chat nunca se queda mudo.
* Coste orientativo: cada pregunta son unas pocas centésimas de céntimo (la lista de preguntas es corta).
*/
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Anthropic\Client;
use Anthropic\Lib\Attributes\Constrained;
use Anthropic\Lib\Concerns\StructuredOutputModelTrait;
use Anthropic\Lib\Contracts\StructuredOutputModel;
/** Lo único que puede contestar Claude: un número. */
class Eleccion implements StructuredOutputModel
{
use StructuredOutputModelTrait;
#[Constrained(description: 'Número de la pregunta frecuente que responde al cliente (1…N), o 0 si ninguna lo responde de verdad')]
public int $n;
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// Solo tu propia web puede usarlo (cambia el dominio por el tuyo).
$permitido = 'https://tuweb.com';
$origen = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origen !== '' && $origen !== $permitido) { http_response_code(403); echo '{"n":0}'; exit; }
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') { http_response_code(405); echo '{"n":0}'; exit; }
$datos = json_decode((string)file_get_contents('php://input', false, null, 0, 20000), true);
$pregunta = trim(mb_substr((string)($datos['pregunta'] ?? ''), 0, 500));
$preguntas = array_slice(array_values(array_filter((array)($datos['preguntas'] ?? []), 'is_string')), 0, 80);
if ($pregunta === '' || !$preguntas) { echo '{"n":0}'; exit; }
$lista = '';
foreach ($preguntas as $i => $p) $lista .= ($i + 1) . '. ' . mb_substr($p, 0, 200) . "\n";
try {
$claude = new Client(apiKey: getenv('ANTHROPIC_API_KEY'));
$respuesta = $claude->messages->create(
model: 'claude-opus-5-5',
maxTokens: 1024,
outputConfig: ['format' => Eleccion::class, 'effort' => 'low'],
system: '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,
messages: [['role' => 'user', 'content' => 'Cliente: ' . $pregunta]],
);
if ($respuesta->stopReason === 'refusal') { echo '{"n":0}'; exit; }
$n = $respuesta->parsedOutput()->n;
echo json_encode(['n' => ($n >= 0 && $n <= count($preguntas)) ? $n : 0]);
} catch (Throwable $e) {
error_log('[chat-ia] ' . $e->getMessage()); // el detalle, solo en tu registro
http_response_code(502); // chat-ia.js pasa solo al buscador local
echo '{"n":0}';
}
- 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.