const form = document.querySelector("#articleGeneratorForm"); const topicInput = document.querySelector("#topicInput"); const generateArticleButton = document.querySelector("#generateArticleButton"); const generatorStatus = document.querySelector("#generatorStatus"); const generatedKicker = document.querySelector("#generatedKicker"); const generatedTitle = document.querySelector("#generatedTitle"); const generatedDek = document.querySelector("#generatedDek"); const generatedSections = document.querySelector("#generatedSections"); const generatedThumbnail = document.querySelector("#generatedThumbnail"); const thumbnailReceipt = document.querySelector("#thumbnailReceipt"); const toggle = document.querySelector("#readerToggle"); const readerBar = document.querySelector(".reader-bar"); const modeStatus = document.querySelector("#modeStatus"); const currentStatus = document.querySelector("#currentStatus"); const runtimeStatus = document.querySelector("#runtimeStatus"); const voiceStatus = document.querySelector("#voiceStatus"); const latencyStatus = document.querySelector("#latencyStatus"); const liveNarration = document.querySelector("#liveNarration"); const transcriptLog = document.querySelector("#transcriptLog"); const copyTranscriptButton = document.querySelector("#copyTranscriptButton"); const clearTranscriptButton = document.querySelector("#clearTranscriptButton"); const audio = document.querySelector("#speechAudio"); const voiceControl = document.querySelector("#voiceControl"); const speedControl = document.querySelector("#speedControl"); const speedValue = document.querySelector("#speedValue"); const autoAdvanceControl = document.querySelector("#autoAdvanceControl"); const controls = { prev: document.querySelector("#prevButton"), play: document.querySelector("#playButton"), next: document.querySelector("#nextButton"), heading: document.querySelector("#headingButton"), image: document.querySelector("#imageButton"), summary: document.querySelector("#summaryButton"), repeat: document.querySelector("#repeatButton"), stop: document.querySelector("#stopButton"), }; let nodes = []; let enabled = false; let currentIndex = -1; let playing = false; let requestSerial = 0; let transcriptEntries = []; let lastSpokenText = ""; let activeAutoAdvance = false; let activeSpeechSerial = 0; function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[char])); } function formatElapsed(ms) { if (!Number.isFinite(ms)) { return "n/a"; } return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`; } async function postJson(url, payload) { const options = payload === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }; const response = await fetch(url, options); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } return response.json(); } function readerTypeLabel(type) { return { heading: "Heading", image: "Image", paragraph: "Paragraph", section: "Section", }[type] || type; } function imageNarration(text) { const spokenText = String(text || "").replace(/\s+/g, " ").trim() || "No image description is available."; return /^(image description\.|image\.|graphic\.|photo\.|illustration\.)/i.test(spokenText) ? spokenText : `Image description. ${spokenText}`; } function readableText(element) { if (element.dataset.readerType === "image") { const image = element.querySelector("img"); const caption = element.querySelector("figcaption"); return [image?.alt, caption?.innerText] .filter(Boolean) .join(" ") .replace(/\s+/g, " ") .trim(); } return element.innerText.replace(/\s+/g, " ").trim(); } function refreshReaderNodes() { nodes = [...document.querySelectorAll("#generatedArticle .speakable")].map((element, index) => ({ element, index, type: element.dataset.readerType || "paragraph", get text() { return readableText(element); }, })); nodes.forEach((node) => { if (!node.element.id) { node.element.id = `generated-reader-node-${node.index + 1}`; } node.element.dataset.readerPosition = String(node.index + 1); }); readerBar.setAttribute("aria-controls", nodes.map((node) => node.element.id).join(" ")); if (currentIndex >= nodes.length) { currentIndex = nodes.length - 1; } } function readerItemStatus(node) { return `${readerTypeLabel(node.type)}, item ${node.index + 1} of ${nodes.length}`; } function updateSpeedValue() { speedValue.textContent = `${Number(speedControl.value).toFixed(2)}x`; } function renderTranscript() { transcriptLog.innerHTML = transcriptEntries .map((entry) => `
  • ${escapeHtml(entry.type)} (${escapeHtml(formatElapsed(entry.elapsedMs))}) ${escapeHtml(entry.runtime)}

    ${escapeHtml(entry.position)}

    ${escapeHtml(entry.text)}

  • `) .join(""); } function addTranscriptEntry(entry) { transcriptEntries = [entry, ...transcriptEntries].slice(0, 12); renderTranscript(); } async function copyTextToClipboard(text, successMessage, emptyMessage, unavailableMessage) { if (!text) { liveNarration.textContent = emptyMessage; return false; } if (!navigator.clipboard) { liveNarration.textContent = unavailableMessage; return false; } await navigator.clipboard.writeText(text); liveNarration.textContent = successMessage; return true; } function isControlTarget(target) { return target instanceof Element && Boolean(target.closest("input, select, textarea, button, a, [contenteditable='true']")); } function shouldHandleReaderShortcut(event) { if (event.key === "Escape") { return true; } return !isControlTarget(event.target); } function setActive(index) { nodes.forEach((node) => { node.element.classList.remove("reader-active"); node.element.removeAttribute("aria-current"); node.element.removeAttribute("tabindex"); }); currentIndex = index; const node = nodes[currentIndex]; if (!node) { currentStatus.textContent = "No item selected"; return; } node.element.classList.add("reader-active"); node.element.setAttribute("aria-current", "true"); node.element.setAttribute("tabindex", "-1"); node.element.focus({ preventScroll: true }); node.element.scrollIntoView({ block: "center", behavior: "smooth" }); currentStatus.textContent = readerItemStatus(node); } function haltPlayback({ clearAutoAdvance = true } = {}) { audio.pause(); audio.removeAttribute("src"); window.speechSynthesis?.cancel(); if (clearAutoAdvance) { activeAutoAdvance = false; } activeSpeechSerial = 0; playing = false; controls.play.textContent = "Play"; } function maybeAutoAdvance(serial) { if (!enabled || !autoAdvanceControl.checked || !activeAutoAdvance || serial !== requestSerial) { return; } const nextIndex = currentIndex + 1; if (nextIndex < nodes.length) { window.setTimeout(() => narrate(nextIndex), 250); } else { liveNarration.textContent = "End of generated article."; activeAutoAdvance = false; } } function browserSpeak(text, serial) { if (!("speechSynthesis" in window)) { return false; } window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.rate = Number(speedControl.value); utterance.onend = () => { playing = false; controls.play.textContent = "Play"; maybeAutoAdvance(serial); }; window.speechSynthesis.speak(utterance); playing = true; controls.play.textContent = "Pause"; return true; } async function speakNarration(text, serial, autoAdvance = false) { lastSpokenText = text; activeAutoAdvance = autoAdvance; activeSpeechSerial = serial; const speech = await postJson("/api/speak", { text, voice: voiceControl.value || "af_heart", speed: Number(speedControl.value), }); if (serial !== requestSerial) return; if (speech.runtime === "fallback") { const spoke = browserSpeak(text, serial); voiceStatus.textContent = spoke ? "Browser fallback" : "Transcript only"; if (!spoke) { liveNarration.textContent = `${text} Audio fallback is unavailable. Transcript is visible.`; } return speech; } if (speech.audio_url) { audio.src = speech.audio_url; await audio.play().catch(() => { const spoke = browserSpeak(text, serial); voiceStatus.textContent = spoke ? "Browser fallback" : "Audio ready"; liveNarration.textContent = spoke ? text : `${text} Audio is ready. Press Play to hear it.`; }); playing = !audio.paused || window.speechSynthesis?.speaking; controls.play.textContent = playing ? "Pause" : "Play"; if (!window.speechSynthesis?.speaking) { voiceStatus.textContent = speech.runtime; } } return speech; } async function narrate(index) { if (!enabled || !nodes[index]) return; const serial = ++requestSerial; haltPlayback({ clearAutoAdvance: false }); setActive(index); const node = nodes[index]; runtimeStatus.textContent = "Thinking"; controls.play.disabled = true; try { const result = node.type === "image" ? await postJson("/api/reader-brain", { node_type: node.type, text: node.text, position: `item ${index + 1} of ${nodes.length}`, mode: "narrate", }) : { runtime: "raw text", narration: node.text, elapsed_ms: 0, }; if (serial !== requestSerial) return; if (node.type === "image") { result.narration = imageNarration(result.narration); } runtimeStatus.textContent = result.runtime; liveNarration.textContent = result.narration; const speech = await speakNarration(result.narration, serial, true); const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0); latencyStatus.textContent = formatElapsed(elapsedMs); addTranscriptEntry({ type: node.type, position: readerItemStatus(node), runtime: `${result.runtime}/${speech?.runtime || "voice"}`, text: result.narration, elapsedMs, }); } catch (error) { runtimeStatus.textContent = "Error"; liveNarration.textContent = `Narration failed: ${error.message}`; } finally { controls.play.disabled = false; } } function articleSummaryText() { return nodes.map((node) => node.text).filter(Boolean).join(" "); } async function summarizeGeneratedArticle() { if (!enabled) return; const serial = ++requestSerial; haltPlayback({ clearAutoAdvance: false }); runtimeStatus.textContent = "Summarizing"; controls.summary.disabled = true; try { const result = await postJson("/api/reader-brain", { node_type: "section", text: articleSummaryText(), position: generatedTitle.textContent, mode: "summarize", }); if (serial !== requestSerial) return; runtimeStatus.textContent = result.runtime; liveNarration.textContent = result.narration; const speech = await speakNarration(result.narration, serial, false); const elapsedMs = (result.elapsed_ms || 0) + (speech?.elapsed_ms || 0); latencyStatus.textContent = formatElapsed(elapsedMs); addTranscriptEntry({ type: "summary", position: generatedTitle.textContent, runtime: `${result.runtime}/${speech?.runtime || "voice"}`, text: result.narration, elapsedMs, }); } catch (error) { runtimeStatus.textContent = "Error"; liveNarration.textContent = `Summary failed: ${error.message}`; } finally { controls.summary.disabled = false; } } function announceIntro() { refreshReaderNodes(); const headings = nodes.filter((node) => node.type === "heading").length; const images = nodes.filter((node) => node.type === "image").length; const currentItem = nodes[currentIndex] ? ` Current item: ${readerItemStatus(nodes[currentIndex]).toLowerCase()}.` : ""; liveNarration.textContent = `Screen reader mode on. Generated article contains ${headings} headings, ${images} images, and ${nodes.length} readable items.${currentItem} Press N for next, H for heading, I for image, S for summary, or Space to begin.`; runtimeStatus.textContent = "Ready"; } function setEnabled(nextValue) { enabled = nextValue; toggle.setAttribute("aria-pressed", String(enabled)); toggle.lastChild.textContent = enabled ? " Screen reader on" : " Screen reader off"; readerBar.hidden = !enabled; modeStatus.textContent = enabled ? "Reader on" : "Reader off"; if (enabled) { refreshReaderNodes(); if (currentIndex < 0) { setActive(0); } announceIntro(); } else { haltPlayback(); setActive(-1); liveNarration.textContent = "Generate an article, then turn on screen reader mode."; } } function nextByType(type) { const start = Math.max(currentIndex + 1, 0); const found = nodes.find((node) => node.index >= start && node.type === type); if (found) return narrate(found.index); const wrapped = nodes.find((node) => node.type === type); if (wrapped) return narrate(wrapped.index); liveNarration.textContent = `No ${readerTypeLabel(type).toLowerCase()} items in this generated article.`; } function repeatCurrentItem() { if (currentIndex >= 0) { narrate(currentIndex); } else { liveNarration.textContent = "No reader item selected."; } } function renderArticle(payload) { const article = payload.article; const thumbnail = payload.thumbnail; generatedKicker.textContent = `${payload.runtime} article`; generatedTitle.textContent = article.title; generatedDek.textContent = article.dek; generatedThumbnail.src = thumbnail.image_url; generatedThumbnail.alt = `Generated thumbnail for ${payload.topic}.`; thumbnailReceipt.textContent = `${thumbnail.generation_model} | seed ${thumbnail.seed} | ${thumbnail.runtime}`; generatedSections.innerHTML = article.sections .map((section) => `

    ${escapeHtml(section.heading)}

    ${escapeHtml(section.body)}

    `) .join(""); generatorStatus.textContent = `Generated with ${payload.model}; thumbnail from ${thumbnail.generation_model}; ${formatElapsed(payload.elapsed_ms)}.`; refreshReaderNodes(); if (enabled) { setActive(0); announceIntro(); } } async function describeGeneratedThumbnail(payload) { const thumbnail = payload.thumbnail; const fallbackAlt = generatedThumbnail.alt; try { const result = await postJson("/api/describe-image", { image_id: "generated-thumbnail", caption: payload.article.title || payload.topic, prompt: thumbnail.prompt || payload.topic, image_url: thumbnail.image_url, }); if (result.ok && result.alt_text) { generatedThumbnail.alt = result.alt_text; refreshReaderNodes(); thumbnailReceipt.textContent = `${thumbnail.generation_model} | seed ${thumbnail.seed} | ${thumbnail.runtime} | described by ${result.runtime}`; } } catch { generatedThumbnail.alt = fallbackAlt; } } async function loadManifest() { try { const manifest = await postJson("/api/article-manifest"); const settings = manifest.reader_settings; if (settings?.voices?.length) { voiceControl.innerHTML = settings.voices .map((voice) => ``) .join(""); voiceControl.value = settings.default_voice; voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Ready"; } if (settings?.speed) { speedControl.min = settings.speed.min; speedControl.max = settings.speed.max; speedControl.step = settings.speed.step; speedControl.value = settings.default_speed; updateSpeedValue(); } autoAdvanceControl.checked = Boolean(settings?.default_auto_advance); } catch { voiceStatus.textContent = "Manifest unavailable"; } } form.addEventListener("submit", async (event) => { event.preventDefault(); const topic = topicInput.value.trim(); if (!topic) { generatorStatus.textContent = "Enter a topic first."; return; } generateArticleButton.disabled = true; generatorStatus.textContent = "Generating article and Klein thumbnail."; try { const payload = await postJson("/api/generate-article", { topic }); renderArticle(payload); describeGeneratedThumbnail(payload); } catch (error) { generatorStatus.textContent = `Generation failed: ${error.message}`; } finally { generateArticleButton.disabled = false; } }); toggle.addEventListener("click", () => setEnabled(!enabled)); controls.next.addEventListener("click", () => narrate(Math.min(currentIndex + 1, nodes.length - 1))); controls.prev.addEventListener("click", () => narrate(Math.max(currentIndex - 1, 0))); controls.heading.addEventListener("click", () => nextByType("heading")); controls.image.addEventListener("click", () => nextByType("image")); controls.summary.addEventListener("click", () => summarizeGeneratedArticle()); controls.repeat.addEventListener("click", () => repeatCurrentItem()); controls.stop.addEventListener("click", () => { haltPlayback(); liveNarration.textContent = "Reading stopped."; }); voiceControl.addEventListener("change", () => { voiceStatus.textContent = voiceControl.selectedOptions[0]?.textContent || "Custom voice"; }); speedControl.addEventListener("input", () => { updateSpeedValue(); }); copyTranscriptButton.addEventListener("click", async () => { const text = transcriptEntries .slice() .reverse() .map((entry) => `[${entry.type} / ${entry.position} / ${entry.runtime}] ${entry.text}`) .join("\n"); await copyTextToClipboard( text, "Transcript copied.", "Transcript is empty.", "Transcript is visible, but clipboard access is unavailable.", ); }); clearTranscriptButton.addEventListener("click", () => { transcriptEntries = []; renderTranscript(); liveNarration.textContent = "Transcript cleared."; }); controls.play.addEventListener("click", () => { if (!enabled) return; if (currentIndex < 0) { narrate(0); return; } if (playing) { audio.pause(); window.speechSynthesis?.pause(); playing = false; controls.play.textContent = "Play"; } else if (window.speechSynthesis?.paused) { window.speechSynthesis.resume(); playing = true; controls.play.textContent = "Pause"; } else if (audio.src) { audio.play(); playing = true; controls.play.textContent = "Pause"; } else if (lastSpokenText) { activeAutoAdvance = false; browserSpeak(lastSpokenText, requestSerial); } else { narrate(currentIndex); } }); audio.addEventListener("ended", () => { playing = false; controls.play.textContent = "Play"; maybeAutoAdvance(activeSpeechSerial); }); document.addEventListener("keydown", (event) => { if (!enabled) return; if (!shouldHandleReaderShortcut(event)) return; const key = event.key.toLowerCase(); if ([" ", "n", "p", "h", "i", "s", "r", "escape"].includes(key)) { event.preventDefault(); } if (key === " ") controls.play.click(); if (key === "n") controls.next.click(); if (key === "p") controls.prev.click(); if (key === "h") controls.heading.click(); if (key === "i") controls.image.click(); if (key === "s") controls.summary.click(); if (key === "r") controls.repeat.click(); if (key === "escape") controls.stop.click(); }); loadManifest(); refreshReaderNodes(); updateSpeedValue();