/** * GPT Kiosk - Chat Interface JavaScript * * v1.1.0 - Speech Recognition Fixes: * - Placeholder-Reset nach Fehlern * - Continuous-Modus für bessere Erkennung * - Auto-Retry bei no-speech * - Silence-Detection für automatisches Stoppen * - Besseres visuelles Feedback */ (function() { 'use strict'; // ============================================ // STATE // ============================================ const state = { threadId: null, isProcessing: false, isRecording: false, isSpeakerOn: true, mediaRecorder: null, audioChunks: [], recognition: null, inactivityTimer: null, screensaverVisible: false, currentAudio: null, // NEU: Speech-spezifische States speechRetryCount: 0, maxSpeechRetries: 3, speechSilenceTimer: null, speechHadResult: false, lastErrorWasNoSpeech: false, }; // ============================================ // CONFIG (von WordPress wp_localize_script) // ============================================ const config = window.kioskConfig || { ajaxUrl: '/wp-json/kiosk/v1/', nonce: '', companyName: 'GPT Assistent', welcomeText: 'Wie kann ich Ihnen helfen?', inactivityTimeout: 300, ttsEnabled: true, ttsVoice: 'nova', sttMode: 'webspeech', }; // ============================================ // DOM ELEMENTS // ============================================ const $ = (sel) => document.querySelector(sel); const chatContainer = $('#chat-container'); const chatInput = $('#chat-input'); const btnSend = $('#btn-send'); const btnMic = $('#btn-mic'); const btnSpeaker = $('#btn-speaker'); const btnNewChat = $('#btn-new-chat'); const screensaver = $('#screensaver'); const statusBar = $('#status-bar'); const welcomeScreen = $('#welcome-screen'); // ============================================ // INITIALIZATION // ============================================ function init() { // Event Listeners btnSend.addEventListener('click', handleSend); btnMic.addEventListener('click', handleMicToggle); btnSpeaker.addEventListener('click', handleSpeakerToggle); btnNewChat.addEventListener('click', handleNewChat); chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }); chatInput.addEventListener('input', resetInactivityTimer); // Screensaver Touch-to-Dismiss screensaver.addEventListener('click', dismissScreensaver); screensaver.addEventListener('touchstart', dismissScreensaver); // Inaktivitäts-Timer starten resetInactivityTimer(); document.addEventListener('click', resetInactivityTimer); document.addEventListener('touchstart', resetInactivityTimer); document.addEventListener('keydown', resetInactivityTimer); // Speaker-Status initialisieren updateSpeakerButton(); // Web Speech API initialisieren (falls webspeech Modus) if (config.sttMode === 'webspeech') { initWebSpeechRecognition(); } // Focus auf Input chatInput.focus(); console.log('GPT Kiosk v1.1.0 initialized'); } // ============================================ // CHAT LOGIC // ============================================ async function handleSend() { const message = chatInput.value.trim(); if (!message || state.isProcessing) return; // Welcome-Screen entfernen if (welcomeScreen) { welcomeScreen.style.display = 'none'; } // User-Nachricht anzeigen appendMessage('user', message); chatInput.value = ''; chatInput.placeholder = 'Nachricht eingeben...'; chatInput.focus(); // Typing-Indikator state.isProcessing = true; updateSendButton(); const typingEl = showTypingIndicator(); try { const response = await fetch(config.ajaxUrl + 'chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce, }, body: JSON.stringify({ message: message, thread_id: state.threadId, }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.message || `HTTP ${response.status}`); } const data = await response.json(); // Thread-ID speichern state.threadId = data.thread_id; // Typing-Indikator entfernen removeTypingIndicator(typingEl); // Bot-Antwort anzeigen const cleanReply = formatReply(data.reply); appendMessage('bot', cleanReply); // Sprachausgabe if (state.isSpeakerOn && config.ttsEnabled) { speakText(data.reply); } } catch (error) { removeTypingIndicator(typingEl); console.error('Chat error:', error); showStatus('Fehler: ' + error.message, 'error'); appendMessage('bot', 'Entschuldigung, es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'); } finally { state.isProcessing = false; updateSendButton(); } resetInactivityTimer(); } function appendMessage(role, text) { const msgDiv = document.createElement('div'); msgDiv.className = `message ${role}`; let html = ''; if (role === 'bot') { html += '
🤖
'; } html += `
${escapeHtml(text)}
`; msgDiv.innerHTML = html; chatContainer.appendChild(msgDiv); scrollToBottom(); } function formatReply(text) { return text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/#{1,6}\s/g, '') .trim(); } function showTypingIndicator() { const div = document.createElement('div'); div.className = 'message bot'; div.id = 'typing-indicator'; div.innerHTML = `
🤖
`; chatContainer.appendChild(div); scrollToBottom(); return div; } function removeTypingIndicator(el) { if (el && el.parentNode) { el.parentNode.removeChild(el); } } function scrollToBottom() { requestAnimationFrame(() => { chatContainer.scrollTop = chatContainer.scrollHeight; }); } function updateSendButton() { btnSend.disabled = state.isProcessing; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML.replace(/\n/g, '
'); } // ============================================ // SPEECH-TO-TEXT (komplett überarbeitet) // ============================================ function initWebSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { console.warn('Web Speech API nicht verfügbar'); btnMic.title = 'Spracheingabe nicht verfügbar'; return; } state.recognition = new SpeechRecognition(); state.recognition.lang = 'de-DE'; state.recognition.interimResults = true; state.recognition.continuous = true; // FIX: Continuous-Modus für längere Eingaben state.recognition.maxAlternatives = 1; state.recognition.onstart = () => { console.log('Speech recognition gestartet'); state.speechHadResult = false; state.lastErrorWasNoSpeech = false; }; state.recognition.onresult = (event) => { state.speechHadResult = true; let finalTranscript = ''; let interimTranscript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { finalTranscript += transcript; } else { interimTranscript += transcript; } } if (finalTranscript) { chatInput.value = finalTranscript; chatInput.style.color = ''; // FIX: Silence-Timer starten - nach 2 Sekunden ohne neues Ergebnis automatisch senden clearTimeout(state.speechSilenceTimer); state.speechSilenceTimer = setTimeout(() => { if (state.isRecording && chatInput.value.trim()) { console.log('Stille erkannt - sende Nachricht'); stopSpeechRecognition(); } }, 2000); } else if (interimTranscript) { chatInput.value = interimTranscript; chatInput.style.color = '#999'; // Silence-Timer bei Interim-Ergebnissen zurücksetzen clearTimeout(state.speechSilenceTimer); } }; state.recognition.onend = () => { console.log('Speech recognition beendet, hadResult:', state.speechHadResult, 'isRecording:', state.isRecording); clearTimeout(state.speechSilenceTimer); // FIX: Wenn noch im Recording-Modus und ein "no-speech" Fehler kam → Retry if (state.isRecording && state.lastErrorWasNoSpeech && state.speechRetryCount < state.maxSpeechRetries) { state.speechRetryCount++; console.log(`Speech retry ${state.speechRetryCount}/${state.maxSpeechRetries}`); showStatus(`Keine Sprache erkannt. Bitte sprechen... (Versuch ${state.speechRetryCount + 1})`, 'info'); // Kurze Pause, dann neu starten setTimeout(() => { if (state.isRecording) { try { state.recognition.start(); } catch (e) { console.warn('Retry fehlgeschlagen:', e); resetSpeechState(); } } }, 300); return; } // Normales Ende const hadText = chatInput.value.trim(); resetSpeechState(); // Auto-senden wenn Text vorhanden if (hadText) { handleSend(); } }; state.recognition.onerror = (event) => { console.error('Speech recognition error:', event.error); if (event.error === 'no-speech') { // FIX: no-speech nicht sofort als fatalen Fehler behandeln state.lastErrorWasNoSpeech = true; // onend-Handler kümmert sich um den Retry return; } if (event.error === 'aborted') { // Manuell abgebrochen - kein Fehler anzeigen return; } // Echte Fehler resetSpeechState(); if (event.error === 'not-allowed') { showStatus('Mikrofon-Zugriff nicht erlaubt. Bitte erlauben Sie den Zugriff in den Browser-Einstellungen.', 'error'); } else if (event.error === 'network') { showStatus('Netzwerkfehler bei der Spracherkennung. Ist eine Internetverbindung vorhanden?', 'error'); } else if (event.error === 'audio-capture') { showStatus('Kein Mikrofon gefunden. Bitte Mikrofon anschließen.', 'error'); } else if (event.error === 'service-not-allowed') { showStatus('Spracherkennung vom Browser blockiert. Bitte HTTPS verwenden.', 'error'); } else { showStatus('Spracherkennung fehlgeschlagen: ' + event.error, 'error'); } }; state.recognition.onspeechstart = () => { console.log('Sprache erkannt!'); showStatus('Sprache wird erkannt...', 'info'); }; state.recognition.onspeechend = () => { console.log('Sprechen beendet'); }; } /** * FIX: Zentrale Funktion zum Zurücksetzen des Speech-States */ function resetSpeechState() { state.isRecording = false; state.speechRetryCount = 0; state.speechHadResult = false; state.lastErrorWasNoSpeech = false; clearTimeout(state.speechSilenceTimer); btnMic.classList.remove('recording'); chatInput.style.color = ''; chatInput.placeholder = 'Nachricht eingeben...'; // FIX: Placeholder IMMER zurücksetzen } /** * FIX: Sauberes Stoppen der Spracherkennung */ function stopSpeechRecognition() { if (state.recognition) { try { state.recognition.stop(); } catch (e) { // Kann passieren wenn schon gestoppt resetSpeechState(); } } else { resetSpeechState(); } } function handleMicToggle() { if (state.isProcessing) return; if (config.sttMode === 'whisper') { handleWhisperToggle(); } else { handleWebSpeechToggle(); } resetInactivityTimer(); } function handleWebSpeechToggle() { if (!state.recognition) { showStatus('Spracheingabe nicht verfügbar in diesem Browser.', 'error'); return; } if (state.isRecording) { // Stoppen stopSpeechRecognition(); } else { // Starten stopCurrentAudio(); // Laufende Audioausgabe stoppen state.isRecording = true; state.speechRetryCount = 0; state.speechHadResult = false; state.lastErrorWasNoSpeech = false; btnMic.classList.add('recording'); chatInput.value = ''; chatInput.style.color = ''; chatInput.placeholder = 'Sprechen Sie jetzt...'; try { state.recognition.start(); showStatus('Mikrofon aktiv - bitte sprechen...', 'info'); } catch (e) { console.error('Speech start error:', e); resetSpeechState(); showStatus('Spracherkennung konnte nicht gestartet werden.', 'error'); } // FIX: Maximale Aufnahmedauer (30 Sekunden), danach automatisch stoppen setTimeout(() => { if (state.isRecording) { console.log('Max-Aufnahmedauer erreicht'); stopSpeechRecognition(); } }, 30000); } } async function handleWhisperToggle() { if (state.isRecording) { // Aufnahme stoppen if (state.mediaRecorder && state.mediaRecorder.state === 'recording') { state.mediaRecorder.stop(); } return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Laufende Audioausgabe stoppen stopCurrentAudio(); state.isRecording = true; btnMic.classList.add('recording'); chatInput.placeholder = 'Aufnahme läuft...'; state.audioChunks = []; // Prüfe verfügbare MIME-Types let mimeType = 'audio/webm'; if (!MediaRecorder.isTypeSupported('audio/webm')) { mimeType = 'audio/ogg'; if (!MediaRecorder.isTypeSupported('audio/ogg')) { mimeType = ''; // Fallback auf Browser-Default } } const recorderOptions = mimeType ? { mimeType } : {}; state.mediaRecorder = new MediaRecorder(stream, recorderOptions); state.mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { state.audioChunks.push(event.data); } }; state.mediaRecorder.onstop = async () => { state.isRecording = false; btnMic.classList.remove('recording'); chatInput.placeholder = 'Wird transkribiert...'; // Stream stoppen stream.getTracks().forEach(t => t.stop()); const audioBlob = new Blob(state.audioChunks, { type: mimeType || 'audio/webm' }); // Prüfe ob tatsächlich Audio-Daten vorhanden if (audioBlob.size < 1000) { chatInput.placeholder = 'Nachricht eingeben...'; showStatus('Aufnahme zu kurz. Bitte länger sprechen.', 'info'); return; } try { const formData = new FormData(); formData.append('audio', audioBlob, 'recording.webm'); const response = await fetch(config.ajaxUrl + 'stt', { method: 'POST', headers: { 'X-WP-Nonce': config.nonce }, body: formData, }); if (!response.ok) throw new Error('STT fehlgeschlagen'); const data = await response.json(); if (data.text) { chatInput.value = data.text; chatInput.placeholder = 'Nachricht eingeben...'; handleSend(); } else { chatInput.placeholder = 'Nachricht eingeben...'; showStatus('Keine Sprache erkannt.', 'info'); } } catch (error) { console.error('Whisper STT error:', error); chatInput.placeholder = 'Nachricht eingeben...'; showStatus('Spracherkennung fehlgeschlagen.', 'error'); } }; state.mediaRecorder.start(); showStatus('Aufnahme läuft... Tippen Sie erneut auf das Mikrofon zum Stoppen.', 'info'); // Auto-stop nach 30 Sekunden setTimeout(() => { if (state.mediaRecorder && state.mediaRecorder.state === 'recording') { state.mediaRecorder.stop(); } }, 30000); } catch (error) { console.error('Microphone error:', error); state.isRecording = false; btnMic.classList.remove('recording'); chatInput.placeholder = 'Nachricht eingeben...'; // FIX: Placeholder zurücksetzen if (error.name === 'NotAllowedError') { showStatus('Mikrofon-Zugriff nicht erlaubt. Bitte in den Browser-Einstellungen erlauben.', 'error'); } else if (error.name === 'NotFoundError') { showStatus('Kein Mikrofon gefunden. Bitte Mikrofon anschließen.', 'error'); } else { showStatus('Mikrofon-Fehler: ' + error.message, 'error'); } } } // ============================================ // TEXT-TO-SPEECH // ============================================ function handleSpeakerToggle() { state.isSpeakerOn = !state.isSpeakerOn; updateSpeakerButton(); if (!state.isSpeakerOn) { stopCurrentAudio(); } resetInactivityTimer(); } function updateSpeakerButton() { if (state.isSpeakerOn) { btnSpeaker.classList.add('active'); btnSpeaker.innerHTML = '🔊'; } else { btnSpeaker.classList.remove('active'); btnSpeaker.innerHTML = '🔇'; } } async function speakText(text) { if (!text || !state.isSpeakerOn) return; // Cleanup: Markdown und Sonderzeichen entfernen für TTS const cleanText = text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/#{1,6}\s/g, '') .replace(/```[\s\S]*?```/g, '') .replace(/`(.*?)`/g, '$1') .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') .replace(/\n{2,}/g, '. ') .trim(); if (!cleanText) return; // Versuche OpenAI TTS, Fallback auf Web Speech API if (config.ttsEnabled) { try { await speakWithOpenAI(cleanText); return; } catch (error) { console.warn('OpenAI TTS failed, falling back to Web Speech:', error); } } // Fallback: Web Speech API speakWithWebSpeech(cleanText); } async function speakWithOpenAI(text) { const maxLen = 4000; const chunks = []; let remaining = text; while (remaining.length > 0) { if (remaining.length <= maxLen) { chunks.push(remaining); break; } let splitIdx = remaining.lastIndexOf('.', maxLen); if (splitIdx < maxLen / 2) splitIdx = maxLen; chunks.push(remaining.substring(0, splitIdx + 1)); remaining = remaining.substring(splitIdx + 1).trim(); } for (const chunk of chunks) { if (!state.isSpeakerOn) break; const response = await fetch(config.ajaxUrl + 'tts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': config.nonce, }, body: JSON.stringify({ text: chunk }), }); if (!response.ok) throw new Error('TTS request failed'); const data = await response.json(); if (data.audio) { await playAudioBase64(data.audio); } } } function playAudioBase64(base64) { return new Promise((resolve, reject) => { const audio = new Audio('data:audio/mp3;base64,' + base64); state.currentAudio = audio; audio.onended = () => { state.currentAudio = null; resolve(); }; audio.onerror = (e) => { state.currentAudio = null; reject(e); }; audio.play().catch(reject); }); } function speakWithWebSpeech(text) { if (!('speechSynthesis' in window)) return; window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = 'de-DE'; utterance.rate = 1.0; utterance.pitch = 1.0; // Deutsche Stimme suchen const voices = window.speechSynthesis.getVoices(); const germanVoice = voices.find(v => v.lang.startsWith('de')) || voices[0]; if (germanVoice) utterance.voice = germanVoice; window.speechSynthesis.speak(utterance); } function stopCurrentAudio() { if (state.currentAudio) { state.currentAudio.pause(); state.currentAudio.currentTime = 0; state.currentAudio = null; } if ('speechSynthesis' in window) { window.speechSynthesis.cancel(); } } // ============================================ // SCREENSAVER // ============================================ function resetInactivityTimer() { clearTimeout(state.inactivityTimer); if (state.screensaverVisible) { dismissScreensaver(); } const timeout = (config.inactivityTimeout || 300) * 1000; state.inactivityTimer = setTimeout(showScreensaver, timeout); } function showScreensaver() { state.screensaverVisible = true; screensaver.classList.remove('hidden'); // Audio stoppen stopCurrentAudio(); // Aufnahme stoppen if (state.isRecording) { if (state.recognition) { try { state.recognition.stop(); } catch(e) {} } if (state.mediaRecorder && state.mediaRecorder.state === 'recording') { state.mediaRecorder.stop(); } resetSpeechState(); } } function dismissScreensaver() { if (!state.screensaverVisible) return; state.screensaverVisible = false; screensaver.classList.add('hidden'); // Chat zurücksetzen handleNewChat(); resetInactivityTimer(); // Focus auf Input setTimeout(() => chatInput.focus(), 300); } // ============================================ // NEW CHAT // ============================================ function handleNewChat() { // Thread zurücksetzen state.threadId = null; // Chat leeren chatContainer.innerHTML = ''; // Welcome-Screen wieder anzeigen const welcome = document.createElement('div'); welcome.className = 'welcome-message'; welcome.id = 'welcome-screen'; welcome.innerHTML = ` 💬

Willkommen!

${escapeHtml(config.welcomeText)}

`; chatContainer.appendChild(welcome); // Input leeren und Placeholder zurücksetzen chatInput.value = ''; chatInput.placeholder = 'Nachricht eingeben...'; chatInput.style.color = ''; chatInput.focus(); // Audio stoppen stopCurrentAudio(); // Speech-State zurücksetzen if (state.isRecording) { stopSpeechRecognition(); } resetInactivityTimer(); } // ============================================ // STATUS MESSAGES // ============================================ function showStatus(message, type = 'info') { statusBar.textContent = message; statusBar.className = `visible ${type}`; setTimeout(() => { statusBar.className = ''; }, 5000); } // ============================================ // START // ============================================ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();