
Chismes del Lince 07: La Gata Montesa te habla
La Gata de la lumbre
Cuando el Lince termina la ronda por Sierra Bermeja, no todas las noches se va a dormir enseguida. Hay noches en que se sienta junto a la lumbre y espera a que llegue la Gata Montesa.
La Gata no caza ni vigila. Lo suyo es otra cosa: cuenta cuentos. Sabe historias de la luna, de las estrellas y del buitre que no tenía prisa, y las cuenta despacio, con voz de lumbre. Después pregunta si te han gustado y se queda charlando lo que haga falta.
Hasta ahora la Gata solo sabía escribir (Chismes 02 y 03). Hoy le damos voz y oídos.
Introducción
En los chismes anteriores el móvil escuchaba ruido (Chisme 05), medía la luz (Chisme 04), veía (Chisme 06) y charlaba por escrito con la Gata Montesa (Chismes 02 y 03).
Ahora la conversación es de viva voz: tocas un botón, hablas, y la Gata te contesta hablando.
La idea nació pensando en personas mayores y, en concreto, en personas con Parkinson, a las que queremos enseñar esta prueba. Por eso el diseño tiene tres reglas:
- Nada de teclear. Todo se hace con la voz.
- Un solo botón grande. Un toque para hablar. Sin menús ni ajustes.
- Todo lo que dice la Gata, también escrito en grande, por si no se oye bien.
⚠️ Esto es una prueba técnica, no un producto
No sabemos todavía si esta herramienta le sirve a alguien con Parkinson. Lo que queremos comprobar es si técnicamente funciona: si el micrófono entiende bien, si las voces se distinguen, si el botón es cómodo. La visita servirá para aprender qué hay que cambiar, no para demostrar nada.
Además, esta vez viaja la voz de una persona. En Chrome, el reconocimiento de voz lo hace un servicio de Google; lo que la persona dice, ya convertido en texto, sale hacia Cloudflare y Google Gemini. Por eso conviene tener el consentimiento de la persona y de su familia, y no decir datos personales ni de salud.
Cómo funciona por dentro
El recorrido completo es este:
Persona → navegador (escucha) → Cloudflare Worker → Gemini → Cloudflare Worker → navegador (habla) → persona
- La persona toca el botón grande.
- El navegador escucha con
SpeechRecognitiony convierte la voz en texto. - Cuando la persona hace una pausa, el navegador da la frase por terminada.
- El texto viaja al Worker junto con el historial de la charla y el modo elegido.
- El Worker añade las instrucciones de la Gata y se lo manda a Gemini.
- Gemini devuelve el texto de la respuesta.
- El navegador la lee en voz alta con
speechSynthesis, frase a frase, y la muestra en pantalla.
Dos piezas del navegador hacen todo el trabajo de voz, sin instalar nada:
SpeechRecognition(escuchar)speechSynthesis(hablar)
Los cuentos no gastan nada
Los tres cuentos están escritos dentro de la página. Cuando se toca uno, la Gata lo lee en local, sin llamar a Gemini, y después pregunta:
«¿Te ha gustado el cuento?»
Ahí el botón grande late para invitar a contestar. La respuesta sí viaja a Gemini, pero con un detalle importante: antes de enviarla, la página mete el cuento y la pregunta en el historial. Así Gemini sabe de qué le estás hablando y la conversación continúa con sentido.
Es una buena lección de IA: un modelo no recuerda nada. Solo sabe lo que le mandamos en cada petición.
Tres voces, un mismo prompt por capas
Este es el corazón didáctico del chisme.
Un prompt no tiene por qué ser un solo bloque. Aquí lo montamos en dos capas:
Capa 1: la base (no se toca nunca). Quién es la Gata, que siempre es amable, que su respuesta se va a leer en voz alta (frases cortas, sin listas ni emojis), que no da consejos médicos y que, si alguien dice que se encuentra mal, le indica que llame al 112 o avise a su familia.
Capa 2: el modo (cambia la forma de hablar). Aquí es donde la Gata puede sonar distinta:
- Cercana: humor amable y un toque andaluz, como una vecina de confianza.
- Cuentacuentos: voz pausada y cálida, como junto a la lumbre.
- Locutora de radio: alegre y animada, como en los programas de las mañanas de antes.
El botón que pulsa la persona solo manda el nombre del modo. El texto de las instrucciones vive en el Worker. Así la página no puede saltarse las reglas de la capa 1 ni cambiar lo que la Gata puede decir.
Y el modo cambia también la voz en el navegador, con la velocidad (rate) y el tono (pitch) de speechSynthesis. Si el dispositivo tiene varias voces en español, cada modo usa una distinta.
Para el aula
- Haz la misma pregunta en los tres modos y compara qué cambia: las palabras, el ritmo, el tono.
- Que cada grupo invente un modo nuevo (una pirata bonachona, una poeta tranquila…). Regla: siempre amable.
- Reto: intenta romper la capa base desde un modo. ¿Consigues que la Gata deje de ser amable o dé un consejo médico? Es la mejor forma de entender por qué las reglas importantes van aparte.
Paso 1: crea un Worker
En Cloudflare: Workers y Pages → Crear → Crear Worker. Puedes llamarlo gata-voz. Pulsa Desplegar.
Paso 2: guarda tu clave como secreto
Configuración → Variables y secretos → Añadir:
GEMINI_API_KEY: la clave de Gemini de los chismes anteriores.
No hay código de demostración en esta versión, porque la prueba es con personas que no deben teclear nada. Eso significa que el Worker está abierto a cualquiera que conozca su dirección, así que al menos lo limitamos a nuestro dominio (siguiente paso).
Paso 3: el código del Worker
Sustituye todo lo que trae el editor por el código que tienes al final de esta entrada (sección WORKER). Antes de desplegar, revisa esta línea y pon tu dominio, con y sin www:
const ORIGENES = ['https://montesteam.org', 'https://www.montesteam.org'];
Copia la dirección del Worker, algo como https://gata-voz.tu-usuario.workers.dev.
Paso 4: la página
Copia el código de la sección HTML y cambia esta línea por la dirección de tu Worker:
const WORKER = 'https://gata-voz.TU-USUARIO.workers.dev';
Súbelo a tu carpeta app y ábrelo directamente en su dirección, en Chrome.
Paso 5: pruébalo
- Toca el botón grande y permite el micrófono cuando lo pida.
- Habla, haz una pausa y espera.
- Prueba después un cuento y contéstale.
- Cambia de modo y repite.
Lo que hemos aprendido probándolo
Esta vez los fallos fueron tantos que valen media entrada.
1. «El micrófono no funciona» (y la solución fue tan simple…). La página no pedía permiso y la Gata no escuchaba. Después de revisar permisos, https y la configuración de Chrome, resulta que el micrófono estaba sin volumen. Moraleja de ingeniería: antes de sospechar del código, comprueba lo más tonto. La cadena es micrófono → permiso → navegador → página, y hay que mirarla eslabón a eslabón.
2. La página incrustada no puede usar el micrófono. Si la app se muestra dentro de una entrada de WordPress (un iframe), Chrome bloquea el micrófono salvo que el iframe lo permita expresamente con allow="microphone". Lo más fácil: abre la app en su propia dirección.
3. «Failed to fetch». La página no llegaba al Worker. El culpable suele ser el origen permitido (CORS): el Worker solo acepta peticiones desde el dominio que le hayamos indicado, y montesteam.org no es lo mismo que www.montesteam.org. Por eso el Worker acepta ahora las dos.
4. «Quota exceeded… limit: 20». Este es el más interesante. Gemini contestaba, pero con un 429: la capa gratuita permite solo 20 peticiones al día por modelo y proyecto, y entre las pruebas de los chismes anteriores ya se habían gastado. Aquí reintentar no sirve de nada, porque no es un fallo pasajero como el 503 del Chisme 06, sino un tope diario. El Worker ahora, si un modelo agota su cuota, pasa al siguiente de la lista.
Dos conclusiones para clase:
- Una API gratuita tiene límites, y conviene conocerlos antes de la demostración.
- No todos los errores se arreglan igual: un
503se reintenta, un429de cuota diaria se cambia de modelo o se espera.
Los límites: cuánto da la prueba
Con 20 peticiones al día, cada respuesta de la persona gasta una, así que da para una charla corta. Los cuentos, al ser locales, no gastan nada. Para una prueba con usuarios es suficiente si no se ha gastado la cuota en ensayos previos (la cuota diaria se reinicia por la mañana en España).
Si esto pasara de prueba a algo más serio, habría que plantearse una cuenta de pago, a nombre del centro, con un límite de gasto puesto. Y revisar las condiciones de uso de Google sobre qué se hace con los datos que se envían, que no son las mismas en la capa gratuita y en la de pago.

El siguiente chisme
La voz del navegador funciona, pero suena a voz de navegador. El siguiente paso natural es una voz más natural, generada por un modelo de voz de Gemini desde el Worker, y que la persona pueda hablar sin pausas artificiales, mandando el audio directamente a la IA.
Y una pregunta de fondo que aparecerá en cuanto la probemos con personas reales: ¿Qué pasa cuando la máquina no entiende bien a una persona porque habla bajo, despacio o con temblor? Ahí es donde una IA “que funciona” puede dejar de funcionar justo con quien más la necesita. Y de eso también se aprende.
Conclusión
Con muy poco cambio respecto al chatbot del Chisme 03, hemos pasado de una IA que escribe a una IA que habla y escucha:
voz → texto → Worker → Gemini → texto → voz
Lo técnico es asequible: dos funciones del navegador y un Worker. Lo interesante está en las decisiones: qué reglas van en la capa que nadie toca, qué datos salen del dispositivo, qué hacemos con un límite de cuota, y cómo se diseña algo pensando de verdad en la persona que lo va a usar.
La Gata ya tiene voz. Ahora toca escuchar a quienes van a hablar con ella.
⚠️ ANTES DE PUBLICAR NADA, LEE ESTO
Una IA puede equivocarse. Puede decir cosas falsas con total seguridad. La Gata no es una fuente médica: nunca la uses para decisiones sobre medicación, síntomas o tratamientos. Ante cualquier duda de salud, el médico o la familia.
Es una prueba técnica. No es un producto ni una ayuda terapéutica. No prometas beneficios que no hemos comprobado.
La voz también son datos. El reconocimiento de voz de Chrome envía el audio a un servicio de Google, y el texto resultante viaja a Cloudflare y Gemini. Pide consentimiento a la persona y a su familia, y no digas datos personales ni de salud.
Tu clave de API es tuya. No la pongas en el HTML ni la subas a un repositorio público. Si sospechas que se ha filtrado, genera una nueva.
Sin código de acceso, el Worker está abierto a quien conozca su dirección. Limita el dominio permitido, vigila el consumo y no lo dejes publicado más tiempo del necesario.
Si la usas con menores o en un centro educativo, revisa las condiciones de uso de los servicios y las indicaciones de la persona responsable de protección de datos.
La tecnología es la parte fácil. Decidir cómo, con quién y para qué la usamos es el verdadero chisme.
HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#7a2e1d">
<title>Chisme 07 · La Gata Montesa te habla</title>
<style>
:root{--bg:#f1e8e0;--card:#fbf7f3;--ink:#2e1d17;--mute:#6b5548;--line:#dccbbd;--pine:#7a2e1d;--sea:#a2422a;--ocre:#b9770e;--bad:#8a1c1c}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:20px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
header{background:var(--pine);color:#fff5ee;padding:16px 18px}
header h1{margin:0;font-size:1.5rem}
main{max-width:680px;margin:0 auto;padding:14px}
.aviso{background:#f6e3df;border:1px solid #dfb1a8;color:var(--bad);border-radius:10px;padding:10px 13px;font-size:.85rem;margin:0 0 14px}
#texto{min-height:8.5em;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:1.35rem;line-height:1.45;margin:0 0 14px;overflow-wrap:anywhere}
#texto.err{color:var(--bad)}
button{font:inherit;font-weight:700;border:0;border-radius:14px;cursor:pointer;color:#fff;background:var(--pine)}
button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:4px solid var(--ocre);outline-offset:3px}
#hablar{width:100%;min-height:34vh;font-size:2.1rem;margin:0 0 16px;padding:18px}
#hablar[data-e="escuchando"]{background:#2f6b3a}
#hablar[data-e="pensando"]{background:#6b5548}
#hablar[data-e="hablando"]{background:var(--ocre)}
#hablar.pide{animation:lat 1.6s ease-in-out infinite}
@keyframes lat{50%{transform:scale(1.02);box-shadow:0 0 0 10px rgba(185,119,14,.3)}}
@media (prefers-reduced-motion:reduce){#hablar.pide{animation:none;outline:6px solid var(--ocre)}}
h2{font-size:1.05rem;margin:14px 0 8px}
.fila{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.fila button{min-height:76px;font-size:1.05rem;padding:8px;background:var(--card);color:var(--pine);border:2.5px solid var(--pine)}
.fila button[aria-pressed="true"]{background:var(--pine);color:#fff}
</style>
</head>
<body>
<header><h1>La Gata Montesa te habla</h1></header>
<main>
<p class="aviso"><strong>Prueba técnica.</strong> Lo que dices se transcribe en el navegador (en Chrome lo hace Google) y el texto viaja a Cloudflare y Gemini. No digas datos personales.</p>
<div id="texto" aria-live="polite">Toca el botón grande, habla, y espera un momento.</div>
<button id="hablar" type="button" data-e="idle">Toca para hablar</button>
<h2>Un cuento</h2>
<div class="fila" id="cuentos"></div>
<h2>Cómo quieres que hable</h2>
<div class="fila" id="modos"></div>
</main>
<script>
const WORKER = 'https://gata-voz.TU-USUARIO.workers.dev'; // ← cambia por la tuya
/* Modos: el id viaja al Worker; rate y pitch cambian la voz en el navegador */
const MODOS = {
cercana: {n: 'Cercana', rate: 1.0, pitch: 1.05},
cuentos: {n: 'Cuentacuentos', rate: 0.8, pitch: 0.95},
radio: {n: 'Locutora de radio', rate: 1.12, pitch: 1.2},
};
const CUENTOS = [
{t: 'El lince y la luna', x: 'Cada noche, el lince subía a la peña más alta de Sierra Bermeja para saludar a la luna. La luna, agradecida, le dejaba un camino de plata para que nunca se perdiera.'},
{t: 'La gata y las estrellas', x: 'Una gata montesa quiso contar todas las estrellas del cielo. Contó hasta quedarse dormida, y soñó que cada estrella le daba las buenas noches por su nombre.'},
{t: 'El buitre y el viento', x: 'El buitre leonado no tenía prisa. Se dejaba llevar por el viento, y desde arriba veía el monte entero, tranquilo y en calma, como quien mira su propia casa.'},
];
const $ = id => document.getElementById(id);
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let fallo = '', modo = 'cercana', estado = 'idle', historial = [], dicho = '', rec = null;
let voces = [], vozDe = {}, cola = [];
function log(t) { console.log(t); }
function mensaje(t, err) { $('texto').textContent = t; $('texto').className = err ? 'err' : ''; }
function poner(e, etiqueta, pide) {
estado = e; const b = $('hablar');
b.dataset.e = e; b.textContent = etiqueta; b.classList.toggle('pide', !!pide);
}
/* ---------- Voces ---------- */
function cargarVoces() {
const todas = speechSynthesis.getVoices();
const es = todas.filter(v => v.lang.toLowerCase().startsWith('es'));
voces = es.length ? es : todas;
Object.keys(MODOS).forEach((k, i) => { if (voces.length) vozDe[k] = voces[i % voces.length].name; });
}
speechSynthesis.onvoiceschanged = cargarVoces; cargarVoces();
function limpiar(t) {
return t.replace(/\p{Extended_Pictographic}/gu, '').replace(/[*_#`>]/g, '').replace(/\s+/g, ' ').trim();
}
function decir(texto, alFinal) {
speechSynthesis.cancel();
const frases = limpiar(texto).split(/(?<=[.!?…])\s+/).filter(Boolean);
const m = MODOS[modo], voz = voces.find(v => v.name === vozDe[modo]);
cola = frases.map((f, i) => {
const u = new SpeechSynthesisUtterance(f);
u.lang = 'es-ES'; u.rate = m.rate; u.pitch = m.pitch; if (voz) u.voice = voz;
if (i === frases.length - 1) u.onend = () => { if (estado === 'hablando') alFinal && alFinal(); };
u.onerror = e => { if (e.error !== 'canceled' && e.error !== 'interrupted') log('Voz: ' + e.error); };
return u;
});
poner('hablando', 'Toca para callar');
cola.forEach(u => speechSynthesis.speak(u));
}
/* ---------- Escuchar ---------- */
function escuchar() {
if (!SR) { mensaje('Este navegador no sabe escuchar. Prueba con Chrome.', true); return; }
dicho = ''; fallo = ''; rec = new SR();
rec.lang = 'es-ES'; rec.continuous = false; rec.interimResults = true;
rec.onresult = ev => {
let fin = '', parcial = '';
for (let i = 0; i < ev.results.length; i++)
(ev.results[i].isFinal ? (fin += ev.results[i][0].transcript + ' ') : (parcial += ev.results[i][0].transcript));
dicho = fin; mensaje((fin + parcial).trim() || 'Te escucho…');
};
rec.onerror = ev => {
const causas = {
'not-allowed': 'El micrófono está bloqueado. Toca el candado junto a la dirección y permítelo.',
'service-not-allowed': 'Este navegador no deja usar el reconocimiento de voz aquí. Abre la página directamente en Chrome.',
'audio-capture': 'No encuentro ningún micrófono.',
'no-speech': 'No he oído nada. Toca el botón y habla un poco más cerca.',
'network': 'Sin conexión con el servicio de voz. Comprueba internet.',
};
fallo = causas[ev.error] || ('Error del micrófono: ' + ev.error);
log('Micrófono: ' + ev.error);
};
rec.onend = () => {
if (estado !== 'escuchando') return;
if (dicho.trim()) enviar(dicho.trim());
else { poner('idle', 'Toca para hablar'); mensaje(fallo || 'No te he oído. Toca el botón y prueba otra vez.', !!fallo); }
};
mensaje('Te escucho…');
poner('escuchando', 'Te escucho…');
try { rec.start(); }
catch (e) { poner('idle', 'Toca para hablar'); mensaje('No puedo arrancar el micrófono: ' + e.message, true); }
}
/* ---------- Conversación ---------- */
async function enviar(msg) {
historial.push({role: 'user', content: msg}); historial = historial.slice(-12);
poner('pensando', 'Pensando…'); mensaje('Pensando…');
try {
const r = await fetch(WORKER, {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({historial, modo}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok || d.error) throw new Error(`${d.etapa || 'Servidor'} ${r.status}: ${d.detalle || 'sin detalle'}`);
const t = limpiar(d.texto || '') || 'Me he quedado sin palabras. ¿Lo probamos otra vez?';
historial.push({role: 'assistant', content: t});
mensaje(t);
decir(t, () => poner('idle', 'Toca para contestar', true));
} catch (e) {
historial.pop();
poner('idle', 'Toca para hablar');
mensaje('No he podido responder. ' + e.message, true); log(e.message);
}
}
function contar(i) {
const c = CUENTOS[i];
speechSynthesis.cancel(); if (rec) { try { rec.abort(); } catch (_) {} }
const cierre = '¿Te ha gustado el cuento?';
historial = [{role: 'user', content: 'Cuéntame un cuento.'}, {role: 'assistant', content: c.x + ' ' + cierre}];
mensaje(c.t + '\n\n' + c.x + '\n\n' + cierre);
decir(c.t + '. ' + c.x + ' ' + cierre, () => poner('idle', 'Toca para contestar', true));
}
/* ---------- Botones ---------- */
$('hablar').onclick = () => {
speechSynthesis.speak(new SpeechSynthesisUtterance('')); // desbloquea el audio en iPhone
if (estado === 'hablando') { speechSynthesis.cancel(); poner('idle', 'Toca para hablar'); }
else if (estado === 'escuchando') rec.stop();
else if (estado === 'idle') escuchar();
};
CUENTOS.forEach((c, i) => {
const b = document.createElement('button'); b.textContent = c.t; b.onclick = () => contar(i); $('cuentos').appendChild(b);
});
Object.entries(MODOS).forEach(([k, m]) => {
const b = document.createElement('button'); b.textContent = m.n; b.dataset.m = k;
b.setAttribute('aria-pressed', k === modo);
b.onclick = () => {
modo = k; document.querySelectorAll('#modos button').forEach(x => x.setAttribute('aria-pressed', x.dataset.m === k));
decir('Muy bien. Ahora hablaré así.');
};
$('modos').appendChild(b);
});
if (window.self !== window.top) mensaje('Esta página está incrustada en otra: Chrome no dejará usar el micrófono. Ábrela en su propia dirección.', true);
else if (!window.isSecureContext) mensaje('La dirección no es https: el micrófono no funcionará.', true);
else if (!SR) mensaje('Este navegador no tiene reconocimiento de voz. Usa Chrome.', true);
log(SR ? 'Reconocimiento de voz disponible' : 'SIN reconocimiento de voz en este navegador');
</script>
</body>
</html>
WORKER
// Worker "gata-voz": Gata Montesa con modos de voz.
// Secreto necesario: GEMINI_API_KEY
// ⚠️ Cambia esto por el dominio donde vive la página
const ORIGENES = ['https://montesteam.org', 'https://www.montesteam.org'];
// Si un modelo agota su cuota diaria, se prueba el siguiente
const MODELOS = ['gemini-3.8-flash', 'gemini-3.6-flash', 'gemini-3.5-flash'];
// CAPA 1: quién es y reglas que ningún modo puede cambiar
const BASE =
'Eres la Gata Montesa, compañera de voz del proyecto MonteSTeam (Estepona, Málaga). ' +
'Hablas con una persona mayor. SIEMPRE eres amable, paciente y respetuosa; la tratas de usted ' +
'salvo que ella te tutee. Tu respuesta se LEERÁ EN VOZ ALTA: frases cortas, sin listas, ' +
'sin asteriscos, sin emojis, sin paréntesis; escribe los números y las horas como se dicen. ' +
'Responde en 2 o 3 frases y termina, cuando encaje, con una pregunta sencilla para seguir la charla. ' +
'Si acaba de escuchar un cuento y comenta algo, responde con cariño a lo que dice. ' +
'No das consejos médicos ni cambias tratamientos: ante dudas de salud, recomiendas hablar con ' +
'su médico o su familia. Si dice que se encuentra mal o que se ha caído, dile con calma que ' +
'llame al 112 o avise a alguien de su familia. Si no entiendes, lo dices con cariño y pides que repita. ' +
'No pidas ni guardes datos personales.';
// CAPA 2: cómo habla (el navegador solo envía el id del modo)
const MODOS = {
cercana:
'MODO CERCANA: habla con humor amable y un toque andaluz, como una vecina de confianza.',
cuentos:
'MODO CUENTACUENTOS: voz pausada y cálida, como junto a la lumbre. Usa imágenes sencillas ' +
'y un ritmo lento y tranquilo.',
radio:
'MODO LOCUTORA DE RADIO: alegre y animada, con energía de programa de las mañanas de antes, ' +
'sin gritar nunca.',
};
const esperar = ms => new Promise(r => setTimeout(r, ms));
export default {
async fetch(request, env) {
const cab = {
'Access-Control-Allow-Origin': ORIGENES.includes(request.headers.get('Origin')) ? request.headers.get('Origin') : ORIGENES[0],
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
const json = { ...cab, 'Content-Type': 'application/json' };
const fallo = (status, etapa, detalle) =>
new Response(JSON.stringify({ error: true, etapa, detalle }), { status, headers: json });
if (request.method === 'OPTIONS') return new Response(null, { headers: cab });
if (request.method !== 'POST') return fallo(405, 'Worker', 'Método no permitido');
let c;
try { c = await request.json(); } catch { return fallo(400, 'Worker', 'JSON inválido'); }
const historial = Array.isArray(c.historial) ? c.historial.slice(-12) : [];
if (!historial.length) return fallo(400, 'Worker', 'Falta el mensaje');
const sistema = BASE + '\n\n' + (MODOS[c.modo] || MODOS.cercana);
const contents = historial.map(m => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: String(m.content).slice(0, 2000) }],
}));
let ultimo = '', cuota = false;
for (const modelo of MODELOS) {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelo}:generateContent`;
for (let intento = 1; intento <= 2; intento++) {
try {
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': env.GEMINI_API_KEY },
body: JSON.stringify({ system_instruction: { parts: [{ text: sistema }] }, contents }),
});
if (r.ok) {
const d = await r.json();
const texto = d?.candidates?.[0]?.content?.parts?.map(p => p.text).join('') || '';
return new Response(JSON.stringify({ texto, modelo }), { headers: json });
}
ultimo = `${modelo} → HTTP ${r.status}: ${(await r.text().catch(() => '')).slice(0, 300)}`;
if (r.status === 429) { cuota = true; break; } // cuota agotada: siguiente modelo
if (r.status === 408 || r.status >= 500) { await esperar(1500); continue; } // saturado: reintento
return fallo(502, 'Gemini API', ultimo); // otro error: no insistimos
} catch (e) { ultimo = `${modelo} → Error de red: ${e.message}`; }
}
}
return cuota
? fallo(429, 'Cuota', 'Se ha agotado la cuota gratuita de Gemini por hoy. ' + ultimo)
: fallo(503, 'Gemini API', 'Gemini sigue saturado. ' + ultimo);
},
};
Etiqueta:chatbot con voz, Chismes del Lince, Cloudflare Workers, cuentacuentos con IA, Gata Montesa, Gemini API, IA con voz, ia en el aula, IA para personas mayores, ies monterroso, montesteam, prompt por capas, reconocimiento de voz en el navegador, SpeechRecognition, speechSynthesis, tecnología y Parkinson, Web Speech API


