/**
* 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();
}
})();