gwoe-antragspruefer/app/templates/v3/components/tour.html
Dotty Dotter 6ec05d2b86 feat(tour): ElevenLabs-Voice für die Tour (#185 Phase 2)
Audio-Backend:
- ``app/tour_audio.py`` ruft ElevenLabs-TTS mit voice_id=Domi
  (AZnzlk1XvdvUeBnXmlld) und model=eleven_multilingual_v2. ENV-konfiguriert
  via ``ELEVENLABS_API_KEY``, ``ELEVENLABS_VOICE_ID``, ``ELEVENLABS_MODEL_ID``.
- Voice-Settings: stability 0.55, similarity_boost 0.7 (warm, klar, natürlich).
- Caching: SHA-256(text|voice|model) → ``data/tour_audio/<hash>.mp3``.
  Folgeabrufe gehen aus dem Datei-Cache, kein API-Quota-Verbrauch.

Endpoint: ``GET /api/tour/voice?text=...`` rate-limited 30/min,
liefert audio/mpeg mit Cache-Control 30 Tage. Bei fehlendem
API-Key 503 — Frontend fällt dann auf ``speechSynthesis`` zurück
(Browser-eingebaute Stimme).

Frontend (tour.html):
- ``speak()`` versucht erst Server-Audio (ElevenLabs), bei 503/Fehler
  Fallback auf Web Speech API.
- Session-Cache via Blob-URL: Vor/Zurück-Navigation in der Tour zieht
  nicht jedes Mal eine neue Network-Roundtrip.
- ``stopSpeak()`` stoppt beide Audio-Pfade sauber.

Konfiguration für dev: ``ELEVENLABS_API_KEY`` und (optional)
``ELEVENLABS_VOICE_ID`` in ``/opt/gwoe-antragspruefer-dev/.env`` setzen,
dann Container restart.
2026-05-09 03:17:06 +02:00

374 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{# ─────────────────────────────────────────────────────────────────────
Geführte Tour-Engine (#185).
Was diese Komponente liefert:
- Spotlight-Overlay + Erklärblase + Vor/Zurück/Schließen + Mute
- Audio via Web Speech API (de-DE, möglichst weiblich)
- Engine: liest pro Page hinterlegte ``window.GWOE_TOUR_STEPS``
(Liste mit ``{selector, title, text}``), zeigt nur Stationen
deren Element wirklich da ist.
Pro Page:
<script>window.GWOE_TOUR_STEPS = [{selector: '...', title: '...', text: '...'}];</script>
{% include "v3/components/tour.html" %}
<button onclick="gwoeTourStart()">Tour starten</button>
Phase 1: Browser-TTS. Phase 2 (ElevenLabs) tauscht nur das
Audio-Backend; das Tour-Skript bleibt gleich.
───────────────────────────────────────────────────────────────────── #}
<div id="gwoe-tour-overlay" class="gwoe-tour-overlay" hidden aria-hidden="true">
<div id="gwoe-tour-spotlight" class="gwoe-tour-spotlight"></div>
<div id="gwoe-tour-bubble" class="gwoe-tour-bubble" role="dialog"
aria-labelledby="gwoe-tour-title" aria-describedby="gwoe-tour-text">
<div class="gwoe-tour-head">
<span id="gwoe-tour-step">Schritt 1 / 4</span>
<button type="button" onclick="gwoeTourEnd()" class="gwoe-tour-close"
title="Tour beenden" aria-label="Tour beenden">×</button>
</div>
<h3 id="gwoe-tour-title" class="gwoe-tour-title">Titel</h3>
<p id="gwoe-tour-text" class="gwoe-tour-text">Erklär-Text</p>
<div class="gwoe-tour-actions">
<button type="button" id="gwoe-tour-mute"
onclick="gwoeTourToggleMute()"
class="v3-action-btn gwoe-tour-mute"
title="Sprachausgabe an / aus">
<span id="gwoe-tour-mute-icon">🔊</span>
<span id="gwoe-tour-mute-label">Stimme an</span>
</button>
<span class="gwoe-tour-spacer"></span>
<button type="button" id="gwoe-tour-prev"
onclick="gwoeTourPrev()" class="v3-action-btn">← Zurück</button>
<button type="button" id="gwoe-tour-next"
onclick="gwoeTourNext()" class="v3-action-btn primary">Weiter →</button>
</div>
</div>
</div>
<style>
.gwoe-tour-overlay {
position: fixed;
inset: 0;
z-index: 99998;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
}
.gwoe-tour-overlay[hidden] { display: none; }
.gwoe-tour-spotlight {
position: absolute;
border-radius: 6px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
pointer-events: none;
transition: top 0.3s ease, left 0.3s ease, width 0.3s ease, height 0.3s ease;
outline: 2px solid var(--ecg-teal);
outline-offset: 4px;
}
.gwoe-tour-bubble {
position: absolute;
z-index: 99999;
background: var(--ecg-card-bg, #fff);
color: var(--ecg-dark, #1a1a1a);
border: 1px solid var(--hairline, rgba(0,0,0,0.12));
border-radius: 6px;
padding: 14px 16px 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
max-width: 380px;
font-family: var(--font-sans);
transition: top 0.3s ease, left 0.3s ease;
}
.gwoe-tour-head {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 4px;
}
.gwoe-tour-close {
background: none;
border: none;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: var(--ecg-dark);
opacity: 0.7;
padding: 0 4px;
}
.gwoe-tour-close:hover { opacity: 1; }
.gwoe-tour-title {
font-family: var(--font-display);
font-size: 16px;
margin: 4px 0 8px;
color: var(--ecg-teal);
}
.gwoe-tour-text {
font-size: 14px;
line-height: 1.5;
margin: 0 0 12px;
}
.gwoe-tour-actions {
display: flex;
gap: 6px;
align-items: center;
border-top: 1px dashed var(--hairline, rgba(0,0,0,0.1));
padding-top: 10px;
}
.gwoe-tour-spacer { flex: 1 1 auto; }
.gwoe-tour-mute { font-size: 12px; }
.gwoe-tour-bubble .v3-action-btn.primary {
background: var(--ecg-teal);
color: #fff;
border-color: var(--ecg-teal);
}
@media (max-width: 700px) {
.gwoe-tour-bubble {
/* auf Mobile zentriert unten andocken statt gefloated, sonst kollidiert
mit dem Spotlight */
left: 12px !important;
right: 12px !important;
max-width: none;
bottom: 12px;
top: auto !important;
}
}
</style>
<script>
(function () {
// Tour-Stationen kommen pro Page via window.GWOE_TOUR_STEPS.
// Jeder Eintrag: {selector, title, text}. Selectoren werden zur Laufzeit
// aufgelöst — fehlt das Element (z.B. keine Plenum-Votes auf einer
// bestimmten Drucksache), überspringt die Tour den Schritt automatisch.
function getSteps() {
return Array.isArray(window.GWOE_TOUR_STEPS) ? window.GWOE_TOUR_STEPS : [];
}
let _tourIdx = 0;
let _resolvedSteps = []; // STEPS gefiltert auf vorhandene Elemente
let _tourMuted = false;
let _tourUtter = null;
let _tourAudio = null; // <audio>-Element für ElevenLabs-MP3
// Cache nur in dieser Session: vermeidet doppelte API-Roundtrips bei
// Vor/Zurück-Navigation. Server-seitig sind die MP3s ohnehin gecacht.
const _audioBlobCache = {};
function $(id) { return document.getElementById(id); }
function speakWebSpeech(text) {
if (!('speechSynthesis' in window)) return;
try {
window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
u.lang = 'de-DE';
u.rate = 1.0;
u.pitch = 1.0;
const voices = window.speechSynthesis.getVoices();
const de = voices.filter(v => /de(-DE|_DE)?/i.test(v.lang));
const female = de.find(v => /female|frau|anna|petra|katja|helga|marlene|vicki/i.test(v.name));
u.voice = female || de[0] || null;
_tourUtter = u;
window.speechSynthesis.speak(u);
} catch (_) { /* TTS optional */ }
}
async function speakElevenLabs(text) {
// Server-Endpoint ruft ElevenLabs + cacht; bei 503 (kein API-Key)
// fallen wir auf Web Speech zurück.
if (_audioBlobCache[text]) {
playAudio(_audioBlobCache[text]);
return true;
}
try {
const url = '/api/tour/voice?text=' + encodeURIComponent(text);
const resp = await fetch(url);
if (resp.status === 503) return false; // ElevenLabs nicht konfiguriert
if (!resp.ok) {
console.warn('Tour-Audio ' + resp.status + ': fallback auf Web Speech');
return false;
}
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
_audioBlobCache[text] = blobUrl;
playAudio(blobUrl);
return true;
} catch (e) {
console.warn('Tour-Audio-Fehler, fallback Web Speech:', e);
return false;
}
}
function playAudio(blobUrl) {
stopSpeak();
_tourAudio = new Audio(blobUrl);
_tourAudio.play().catch(() => { /* Autoplay-Block, harmless */ });
}
async function speak(text) {
if (_tourMuted) return;
// Bevorzugt ElevenLabs (Server). Bei nicht-konfiguriert auf Web Speech.
const ok = await speakElevenLabs(text);
if (!ok) speakWebSpeech(text);
}
function stopSpeak() {
if ('speechSynthesis' in window) {
try { window.speechSynthesis.cancel(); } catch (_) {}
}
_tourUtter = null;
if (_tourAudio) {
try { _tourAudio.pause(); _tourAudio.currentTime = 0; } catch (_) {}
_tourAudio = null;
}
}
function resolveSteps() {
_resolvedSteps = [];
for (const s of getSteps()) {
// Erstes Match aus comma-separated Selector-Liste.
const parts = s.selector.split(',').map(x => x.trim());
let el = null;
for (const p of parts) {
el = document.querySelector(p);
if (el) break;
}
if (el) _resolvedSteps.push({ ...s, el });
}
}
function positionBubble(el) {
const rect = el.getBoundingClientRect();
const bubble = $('gwoe-tour-bubble');
const spotlight = $('gwoe-tour-spotlight');
// Spotlight um das Element legen.
spotlight.style.top = (rect.top - 4) + 'px';
spotlight.style.left = (rect.left - 4) + 'px';
spotlight.style.width = (rect.width + 8) + 'px';
spotlight.style.height = (rect.height + 8) + 'px';
// Bubble unter oder über das Element legen, wo Platz ist.
const bubbleH = 240; // grobe Annahme
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
let top, left;
if (spaceBelow > bubbleH + 20) {
top = rect.bottom + 14;
} else if (spaceAbove > bubbleH + 20) {
top = rect.top - bubbleH - 14;
} else {
// Nichts passt vertikal — Bubble nach rechts neben das Element.
top = Math.max(20, rect.top);
}
left = Math.max(20, Math.min(window.innerWidth - 400, rect.left));
bubble.style.top = top + 'px';
bubble.style.left = left + 'px';
}
function showStep(i) {
if (i < 0 || i >= _resolvedSteps.length) {
gwoeTourEnd();
return;
}
_tourIdx = i;
const step = _resolvedSteps[i];
const overlay = $('gwoe-tour-overlay');
overlay.hidden = false;
overlay.setAttribute('aria-hidden', 'false');
$('gwoe-tour-step').textContent = `Schritt ${i + 1} / ${_resolvedSteps.length}`;
$('gwoe-tour-title').textContent = step.title;
$('gwoe-tour-text').textContent = step.text;
$('gwoe-tour-prev').disabled = (i === 0);
$('gwoe-tour-next').textContent = (i === _resolvedSteps.length - 1)
? 'Fertig ✓' : 'Weiter →';
// Element ins Sichtfeld scrollen, dann positionieren.
step.el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => positionBubble(step.el), 350);
// TTS sprechen lassen
speak(step.title + '. ' + step.text);
}
// Globale Schnittstelle
window.gwoeTourStart = function () {
resolveSteps();
if (_resolvedSteps.length === 0) {
console.warn('Tour: keine sichtbaren Stationen.');
return;
}
_tourIdx = 0;
showStep(0);
document.body.style.overflow = 'hidden'; // Page-Scroll blockieren
};
window.gwoeTourEnd = function () {
const overlay = $('gwoe-tour-overlay');
overlay.hidden = true;
overlay.setAttribute('aria-hidden', 'true');
document.body.style.overflow = '';
stopSpeak();
};
window.gwoeTourNext = function () {
if (_tourIdx >= _resolvedSteps.length - 1) {
gwoeTourEnd();
return;
}
showStep(_tourIdx + 1);
};
window.gwoeTourPrev = function () {
if (_tourIdx <= 0) return;
showStep(_tourIdx - 1);
};
window.gwoeTourToggleMute = function () {
_tourMuted = !_tourMuted;
$('gwoe-tour-mute-icon').textContent = _tourMuted ? '🔇' : '🔊';
$('gwoe-tour-mute-label').textContent = _tourMuted ? 'Stimme aus' : 'Stimme an';
if (_tourMuted) stopSpeak();
else if (_resolvedSteps[_tourIdx]) {
const s = _resolvedSteps[_tourIdx];
speak(s.title + '. ' + s.text);
}
};
// ESC schließt; Klick auf Overlay (außerhalb der Bubble) auch.
document.addEventListener('keydown', (e) => {
const overlay = $('gwoe-tour-overlay');
if (!overlay || overlay.hidden) return;
if (e.key === 'Escape') gwoeTourEnd();
else if (e.key === 'ArrowRight') gwoeTourNext();
else if (e.key === 'ArrowLeft') gwoeTourPrev();
});
document.addEventListener('click', (e) => {
const overlay = $('gwoe-tour-overlay');
if (!overlay || overlay.hidden) return;
// Overlay-Klick hinter dem Spotlight oder Bubble → Tour-Ende
if (e.target === overlay) gwoeTourEnd();
});
// Bei Resize: aktive Bubble + Spotlight neu positionieren
window.addEventListener('resize', () => {
if (!$('gwoe-tour-overlay').hidden && _resolvedSteps[_tourIdx]) {
positionBubble(_resolvedSteps[_tourIdx].el);
}
});
// Beim Verlassen der Page: Sprachausgabe stoppen.
window.addEventListener('pagehide', stopSpeak);
// Voices laden — manche Browser füllen sie asynchron.
if ('speechSynthesis' in window) {
window.speechSynthesis.onvoiceschanged = () => { /* trigger preload */ };
window.speechSynthesis.getVoices();
}
})();
</script>