Spaces:
Runtime error
Runtime error
// Variables globales | |
let recognition = null; | |
let isProcessingSpeech = false; | |
let isPlayingAudio = false; | |
let currentMode = 'soporte'; | |
let currentModel = 'gemini'; | |
let currentTTS = 'EDGE'; | |
let isListening = false; | |
let sessionId = null; | |
let shouldRestartRecognition = true; | |
// Variables globales para audio | |
let currentAudio = null; | |
let isPlaying = false; | |
let audioQueue = []; | |
let isProcessingQueue = false; | |
// Variables para control de interrupción | |
let lastInterruptTime = 0; | |
const INTERRUPT_COOLDOWN = 1000; // 1 segundo entre interrupciones | |
const ENERGY_THRESHOLD = 1.5; // Umbral de energía para detectar interrupción | |
let audioContext = null; | |
let analyser = null; | |
let microphoneStream = null; | |
const STOP_WORDS = ['alto', 'detente', 'permiteme', 'callate', 'silencio', 'calla', 'para', 'espera']; | |
const GREETING_WORDS = ['hola', 'buenas', 'buenos días', 'buenas tardes', 'buenas noches']; | |
const INTEREST_WORDS = ['acepto', 'me interesa', 'dime más', 'cuéntame más', 'quiero saber más']; | |
// Elementos del DOM | |
const chatBox = document.getElementById('chatBox'); | |
const textInput = document.getElementById('textInput'); | |
const sendTextButton = document.getElementById('sendText'); | |
const modoSelect = document.getElementById('modoSelect'); | |
const modeloSelect = document.getElementById('modeloSelect'); | |
const vozSelect = document.getElementById('vozSelect'); | |
const configForm = document.getElementById('configForm'); | |
const statusLabel = document.getElementById('statusLabel'); | |
const startRecordingButton = document.getElementById('startRecording'); | |
// Comandos de voz | |
const VOICE_COMMANDS = { | |
stop: ['para', 'detente', 'silencio', 'cállate', 'espera', 'stop', 'alto', 'basta', 'suficiente', 'ya', 'shh', 'sh'], | |
greet: ['hola', 'buenas', 'buenos días', 'buenas tardes', 'buenas noches'], | |
interest: ['acepto', 'me interesa', 'dime más', 'cuéntame más', 'quiero saber más'] | |
}; | |
// Funciones de utilidad | |
const Utils = { | |
checkCommand(text, type) { | |
return VOICE_COMMANDS[type].some(cmd => text.toLowerCase().includes(cmd)); | |
}, | |
updateStatus(text, type = 'info') { | |
if (statusLabel) { | |
statusLabel.textContent = text; | |
statusLabel.classList.remove('error', 'success'); | |
if (type === 'error' || type === 'success') { | |
statusLabel.classList.add(type); | |
} | |
} | |
} | |
}; | |
// Funciones de chat | |
const ChatManager = { | |
addMessage(text, sender) { | |
if (!text || !sender || !chatBox) return; | |
try { | |
console.log('Agregando mensaje al chat:', { text, sender }); | |
const messageDiv = document.createElement('div'); | |
messageDiv.className = `message ${sender}-message`; | |
const iconSpan = document.createElement('span'); | |
iconSpan.className = 'message-icon'; | |
iconSpan.textContent = sender === 'user' ? '👤' : '🤖'; | |
const textSpan = document.createElement('span'); | |
textSpan.className = 'message-text'; | |
textSpan.textContent = text; | |
messageDiv.appendChild(iconSpan); | |
messageDiv.appendChild(textSpan); | |
chatBox.appendChild(messageDiv); | |
// Hacer scroll al último mensaje | |
chatBox.scrollTop = chatBox.scrollHeight; | |
console.log('Mensaje agregado exitosamente'); | |
} catch (error) { | |
console.error('Error al agregar mensaje:', error); | |
} | |
}, | |
async sendMessage(text) { | |
if (!text) return; | |
try { | |
Utils.updateStatus('Procesando...'); | |
this.addMessage(text, 'user'); | |
textInput.value = ''; | |
const response = await fetch('/chat', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ | |
mensaje: text, | |
mode: currentMode, | |
model: currentModel, | |
tts: currentTTS | |
}) | |
}); | |
if (!response.ok) { | |
throw new Error(`Error HTTP: ${response.status}`); | |
} | |
const data = await response.json(); | |
if (data.success && data.texto) { | |
this.addMessage(data.texto, 'bot'); | |
if (data.audio) { | |
try { | |
const audioType = data.audio_type || 'mp3'; | |
await AudioManager.playAudio(data.audio, audioType); | |
} catch (audioError) { | |
console.error('Error reproduciendo audio:', audioError); | |
handleAudioError(); | |
} | |
} | |
} else { | |
throw new Error(data.error || 'Error desconocido'); | |
} | |
} catch (error) { | |
console.error('Error:', error); | |
Utils.updateStatus('Error de conexión', 'error'); | |
this.addMessage('Lo siento, hubo un error al procesar tu mensaje.', 'bot'); | |
} finally { | |
Utils.updateStatus('Escuchando...'); | |
} | |
} | |
}; | |
// Gestión de audio | |
const AudioManager = { | |
async playAudio(base64Audio, audioType = 'wav') { | |
if (!base64Audio) return; | |
try { | |
if (isPlayingAudio) { | |
audioQueue.push({ base64Audio, audioType }); | |
return; | |
} | |
isPlayingAudio = true; | |
Utils.updateStatus('Reproduciendo audio...'); | |
const audioBlob = await fetch(`data:audio/${audioType};base64,${base64Audio}`) | |
.then(res => res.blob()); | |
currentAudio = new Audio(URL.createObjectURL(audioBlob)); | |
currentAudio.onended = () => { | |
isPlayingAudio = false; | |
URL.revokeObjectURL(currentAudio.src); | |
currentAudio = null; | |
Utils.updateStatus('Escuchando...'); | |
this.processQueue(); | |
}; | |
await currentAudio.play(); | |
} catch (error) { | |
console.error('Error reproduciendo audio:', error); | |
this.handleError(); | |
} | |
}, | |
async processQueue() { | |
if (isProcessingQueue || audioQueue.length === 0) return; | |
isProcessingQueue = true; | |
const nextAudio = audioQueue.shift(); | |
await this.playAudio(nextAudio.base64Audio, nextAudio.audioType); | |
isProcessingQueue = false; | |
}, | |
handleError() { | |
isPlayingAudio = false; | |
if (currentAudio) { | |
URL.revokeObjectURL(currentAudio.src); | |
currentAudio = null; | |
} | |
Utils.updateStatus('Error de audio', 'error'); | |
}, | |
stop() { | |
if (currentAudio) { | |
currentAudio.pause(); | |
URL.revokeObjectURL(currentAudio.src); | |
currentAudio = null; | |
} | |
isPlayingAudio = false; | |
audioQueue = []; | |
Utils.updateStatus('Audio detenido'); | |
} | |
}; | |
// Gestión de voz | |
const VoiceManager = { | |
async processCommand(text) { | |
if (!text) return false; | |
const currentTime = Date.now(); | |
if (Utils.checkCommand(text, 'stop')) { | |
if (currentTime - lastInterruptTime > INTERRUPT_COOLDOWN) { | |
lastInterruptTime = currentTime; | |
AudioManager.stop(); | |
Utils.updateStatus('Audio detenido por comando de voz'); | |
return true; | |
} | |
} | |
if (Utils.checkCommand(text, 'greet') || Utils.checkCommand(text, 'interest')) { | |
if (currentTime - lastInterruptTime > INTERRUPT_COOLDOWN) { | |
lastInterruptTime = currentTime; | |
if (isPlayingAudio) { | |
AudioManager.stop(); | |
await new Promise(resolve => setTimeout(resolve, 300)); | |
} | |
ChatManager.addMessage(text, 'user'); | |
await this.processVoiceResponse(text); | |
return true; | |
} | |
} | |
return false; | |
}, | |
async processVoiceResponse(text) { | |
try { | |
shouldRestartRecognition = false; | |
if (recognition && isListening) { | |
recognition.stop(); | |
} | |
const response = await fetch('/procesar_voz', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ | |
texto: text, | |
mode: currentMode, | |
model: currentModel, | |
tts: currentTTS | |
}) | |
}); | |
if (!response.ok) throw new Error(`Error HTTP: ${response.status}`); | |
const data = await response.json(); | |
if (data.success && data.texto) { | |
ChatManager.addMessage(data.texto, 'bot'); | |
if (data.audio) { | |
await AudioManager.playAudio(data.audio, data.audio_type || 'wav'); | |
} | |
} | |
} catch (error) { | |
console.error('Error procesando voz:', error); | |
Utils.updateStatus('Error procesando voz', 'error'); | |
} finally { | |
shouldRestartRecognition = true; | |
if (!isListening) { | |
await this.startRecognition(); | |
} | |
} | |
}, | |
async startRecognition() { | |
if (!recognition || !isListening) { | |
try { | |
await this.initializeSpeechRecognition(); | |
isListening = true; | |
startRecordingButton.classList.add('active'); | |
Utils.updateStatus('Escuchando...'); | |
} catch (error) { | |
console.error('Error iniciando reconocimiento:', error); | |
Utils.updateStatus('Error de micrófono', 'error'); | |
} | |
} | |
}, | |
async pauseRecognition() { | |
if (recognition && isListening) { | |
recognition.stop(); | |
isListening = false; | |
startRecordingButton.classList.remove('active'); | |
Utils.updateStatus('Reconocimiento pausado'); | |
} | |
}, | |
async initializeSpeechRecognition() { | |
if (!('webkitSpeechRecognition' in window)) { | |
throw new Error('Reconocimiento de voz no soportado'); | |
} | |
recognition = new webkitSpeechRecognition(); | |
recognition.continuous = true; | |
recognition.interimResults = true; | |
recognition.lang = 'es-ES'; | |
recognition.onstart = () => { | |
isListening = true; | |
Utils.updateStatus('Escuchando...'); | |
}; | |
recognition.onend = () => { | |
isListening = false; | |
if (shouldRestartRecognition) { | |
recognition.start(); | |
} | |
}; | |
recognition.onresult = async (event) => { | |
if (isProcessingSpeech) return; | |
const results = Array.from(event.results); | |
const lastResult = results[results.length - 1]; | |
if (lastResult.isFinal) { | |
const text = lastResult[0].transcript.trim(); | |
if (text) { | |
isProcessingSpeech = true; | |
await this.processCommand(text); | |
isProcessingSpeech = false; | |
} | |
} | |
}; | |
recognition.onerror = (event) => { | |
console.error('Error de reconocimiento:', event.error); | |
Utils.updateStatus('Error de reconocimiento', 'error'); | |
}; | |
recognition.start(); | |
} | |
}; | |
// Funciones de modo y modelo | |
function changeMode(mode) { | |
currentMode = mode; | |
fetch('/cambiar_modo', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ mode }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (data.success) { | |
ChatManager.addMessage(`Modo cambiado a: ${mode}`, 'bot'); | |
} else { | |
console.error('Error cambiando modo:', data.error); | |
} | |
}) | |
.catch(error => console.error('Error:', error)); | |
} | |
function changeModel(model) { | |
currentModel = model; | |
fetch('/cambiar_modelo', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ model }) | |
}) | |
.then(response => response.json()) | |
.then(data => { | |
if (data.success) { | |
ChatManager.addMessage(`Modelo cambiado a: ${model}`, 'bot'); | |
} else { | |
console.error('Error cambiando modelo:', data.error); | |
} | |
}) | |
.catch(error => console.error('Error:', error)); | |
} | |
async function changeTTS(model) { | |
console.log("Cambiando modelo TTS a:", model); | |
Utils.updateStatus("Cambiando voz..."); | |
try { | |
const response = await fetch('/cambiar_tts', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ model: model }) | |
}); | |
if (!response.ok) { | |
throw new Error('Error en la respuesta del servidor'); | |
} | |
const data = await response.json(); | |
let voiceName; | |
switch(model) { | |
case 'EDGE': | |
voiceName = "Jorge (MX)"; | |
break; | |
case 'EDGE_ES': | |
voiceName = "Álvaro (ES)"; | |
break; | |
case 'VITS': | |
voiceName = "VITS (ES)"; | |
break; | |
default: | |
voiceName = model; | |
} | |
Utils.updateStatus(`Voz cambiada a ${voiceName}`); | |
console.log("Modelo TTS cambiado exitosamente"); | |
} catch (error) { | |
console.error("Error al cambiar el modelo de voz:", error); | |
Utils.updateStatus("Error al cambiar la voz"); | |
} | |
} | |
// Reconocimiento de voz | |
async function checkMicrophonePermissions() { | |
try { | |
// Verificar si ya tenemos permisos | |
const permissionStatus = await navigator.permissions.query({ name: 'microphone' }); | |
if (permissionStatus.state === 'granted') { | |
return true; | |
} else if (permissionStatus.state === 'prompt') { | |
// Solicitar permisos explícitamente | |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
stream.getTracks().forEach(track => track.stop()); | |
return true; | |
} else if (permissionStatus.state === 'denied') { | |
Utils.updateStatus('Permisos de micrófono denegados. Por favor, habilítalos en la configuración del navegador.', 'error'); | |
ChatManager.addMessage('❌ El micrófono está bloqueado. Para habilitarlo:\n1. Haz clic en el icono 🔒 o 🎤 en la barra de direcciones\n2. Selecciona "Permitir" para el micrófono\n3. Recarga la página', 'bot'); | |
return false; | |
} | |
} catch (error) { | |
console.error('Error verificando permisos:', error); | |
return false; | |
} | |
} | |
// Función para inicializar el análisis de audio | |
async function setupAudioAnalysis() { | |
try { | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
analyser = audioContext.createAnalyser(); | |
analyser.fftSize = 256; | |
// Configurar filtros para reducir eco | |
const lowpass = audioContext.createBiquadFilter(); | |
lowpass.type = 'lowpass'; | |
lowpass.frequency.value = 2000; | |
const highpass = audioContext.createBiquadFilter(); | |
highpass.type = 'highpass'; | |
highpass.frequency.value = 85; | |
// Obtener stream del micrófono | |
microphoneStream = await navigator.mediaDevices.getUserMedia({ | |
audio: { | |
echoCancellation: true, | |
noiseSuppression: true, | |
autoGainControl: false | |
} | |
}); | |
const source = audioContext.createMediaStreamSource(microphoneStream); | |
source.connect(highpass); | |
highpass.connect(lowpass); | |
lowpass.connect(analyser); | |
return true; | |
} catch (error) { | |
console.error('Error configurando análisis de audio:', error); | |
return false; | |
} | |
} | |
// Función para detectar interrupción basada en energía de audio | |
function detectInterruption() { | |
if (!analyser) return false; | |
const dataArray = new Uint8Array(analyser.frequencyBinCount); | |
analyser.getByteFrequencyData(dataArray); | |
// Calcular energía promedio | |
const average = dataArray.reduce((a, b) => a + b) / dataArray.length; | |
const normalizedEnergy = average / 128; // Normalizar a un rango de 0-1 | |
return normalizedEnergy > ENERGY_THRESHOLD; | |
} | |
// Funciones de sesión | |
async function initSession() { | |
try { | |
const response = await fetch('/get_session_data'); | |
const data = await response.json(); | |
sessionId = data.id; | |
currentMode = data.mode; | |
currentModel = data.model; | |
currentTTS = data.tts; | |
// Actualizar selectores con valores de la sesión | |
if (modoSelect) modoSelect.value = currentMode; | |
if (modeloSelect) modeloSelect.value = currentModel; | |
if (vozSelect) vozSelect.value = currentTTS; | |
// Cargar historial de chat | |
if (data.chat_history && data.chat_history.length > 0) { | |
data.chat_history.forEach(msg => { | |
ChatManager.addMessage(msg.text, msg.sender); | |
}); | |
} | |
return sessionId; | |
} catch (error) { | |
console.error('Error inicializando sesión:', error); | |
return null; | |
} | |
} | |
async function updateAudioState(isPlaying, currentAudio = null) { | |
try { | |
await fetch('/update_audio_state', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ | |
is_playing: isPlaying, | |
current_audio: currentAudio | |
}) | |
}); | |
} catch (error) { | |
console.error('Error actualizando estado del audio:', error); | |
} | |
} | |
// Función para obtener el mensaje de presentación según el modo | |
function getMensajePresentacion(modo) { | |
const presentaciones = { | |
'soporte': '¡Hola! Soy tu asistente de soporte técnico. Estoy aquí para ayudarte con cualquier problema de PC o Android. ¿En qué puedo ayudarte?', | |
'seguros': '¡Hola! Soy tu asesor de seguros personal. Te ayudaré a encontrar la mejor protección para ti y tu familia. ¿Qué tipo de seguro te interesa?', | |
'creditos': '¡Hola! Soy tu asesor financiero. Estoy aquí para ayudarte a obtener el crédito que necesitas con las mejores condiciones. ¿Cuánto necesitas?', | |
'cobranza': '¡Hola! Soy tu gestor de cobranza. Estoy aquí para ayudarte a regularizar tu situación y encontrar la mejor solución para ti. ¿Cómo puedo ayudarte?', | |
'encuestas': '¡Hola! Soy tu encuestador profesional. Me gustaría conocer tu opinión sobre temas importantes. ¿Estás listo para comenzar?' | |
}; | |
return presentaciones[modo] || '¡Hola! ¿En qué puedo ayudarte?'; | |
} | |
// Función para iniciar el modo con presentación | |
async function iniciarModoConPresentacion(modo) { | |
try { | |
Utils.updateStatus('Iniciando modo...'); | |
const mensajePresentacion = getMensajePresentacion(modo); | |
const response = await fetch('/cambiar_modo', { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json' | |
}, | |
body: JSON.stringify({ mode: modo }) | |
}); | |
const data = await response.json(); | |
if (data.success) { | |
currentMode = modo; | |
ChatManager.addMessage(mensajePresentacion, 'bot'); | |
if (data.audio) { | |
try { | |
const audioType = data.audio_type || 'wav'; | |
await AudioManager.playAudio(data.audio, audioType); | |
} catch (error) { | |
console.error('Error reproduciendo audio:', error); | |
handleAudioError(); | |
} | |
} | |
} | |
} catch (error) { | |
console.error('Error iniciando modo:', error); | |
Utils.updateStatus('Error al iniciar modo'); | |
} | |
} | |
// Event Listeners | |
document.addEventListener('DOMContentLoaded', async () => { | |
console.log('Página cargada, inicializando...'); | |
// Inicializar sesión | |
await initSession(); | |
// Inicializar reconocimiento de voz | |
await VoiceManager.startRecognition(); | |
// Iniciar modo inicial con presentación | |
await iniciarModoConPresentacion(currentMode); | |
// Iniciar reconocimiento automáticamente | |
setTimeout(() => { | |
if (startRecordingButton && !isListening && !isPlayingAudio) { | |
console.log('Iniciando reconocimiento automático...'); | |
VoiceManager.startRecognition(); | |
Utils.updateStatus('Escuchando...'); | |
} | |
}, 1000); | |
// Botón de envío de texto | |
if (sendTextButton) { | |
sendTextButton.addEventListener('click', () => { | |
const text = textInput.value.trim(); | |
if (text) { | |
ChatManager.sendMessage(text); | |
} | |
}); | |
} | |
// Input de texto (Enter) | |
if (textInput) { | |
textInput.addEventListener('keypress', (e) => { | |
if (e.key === 'Enter') { | |
const text = textInput.value.trim(); | |
if (text) { | |
ChatManager.sendMessage(text); | |
} | |
} | |
}); | |
} | |
// Selectores | |
if (modoSelect) { | |
modoSelect.addEventListener('change', async function() { | |
try { | |
Utils.updateStatus('Cambiando modo...'); | |
// Detener cualquier audio actual | |
if (currentAudio && isPlaying) { | |
currentAudio.pause(); | |
currentAudio.currentTime = 0; | |
currentAudio = null; | |
isPlaying = false; | |
} | |
// Iniciar nuevo modo con presentación | |
await iniciarModoConPresentacion(this.value); | |
} catch (error) { | |
console.error('Error en cambio de modo:', error); | |
Utils.updateStatus('Error al cambiar modo'); | |
this.value = currentMode; // Revertir al modo anterior | |
} finally { | |
setTimeout(() => Utils.updateStatus('Escuchando...'), 2000); | |
} | |
}); | |
} | |
if (modeloSelect) { | |
modeloSelect.addEventListener('change', async (e) => { | |
currentModel = e.target.value; | |
changeModel(currentModel); | |
}); | |
} | |
if (vozSelect) { | |
vozSelect.addEventListener('change', async (e) => { | |
const newVoice = e.target.value; | |
console.log('Seleccionada nueva voz:', newVoice); | |
Utils.updateStatus('Cambiando voz...'); | |
currentTTS = newVoice; | |
changeTTS(newVoice); | |
}); | |
} | |
// Botón de grabación | |
if (startRecordingButton) { | |
startRecordingButton.addEventListener('click', async () => { | |
if (!isListening) { | |
await VoiceManager.startRecognition(); | |
Utils.updateStatus('Escuchando...'); | |
} else { | |
await VoiceManager.pauseRecognition(); | |
Utils.updateStatus('Reconocimiento pausado'); | |
} | |
}); | |
} | |
// Manejar visibilidad de la página | |
document.addEventListener('visibilitychange', () => { | |
if (document.hidden) { | |
if (isListening) { | |
VoiceManager.pauseRecognition(); | |
} | |
} else { | |
if (!isListening && !isPlayingAudio) { | |
setTimeout(VoiceManager.startRecognition, 500); | |
} | |
} | |
}); | |
}); | |
// Agregar al inicio del archivo, después de las variables globales | |
const permissionModal = ` | |
<div class="permission-modal" id="permissionModal"> | |
<div class="permission-content"> | |
<h4>Permisos de Micrófono</h4> | |
<p>Para usar el chat por voz, necesitamos acceso a tu micrófono.</p> | |
<div class="alert alert-warning"> | |
<small>⚠️ Si el sitio no es seguro (HTTPS), el navegador puede bloquear el acceso al micrófono.</small> | |
</div> | |
<div class="permission-buttons"> | |
<button class="btn btn-primary" onclick="requestMicrophonePermission()">Permitir Micrófono</button> | |
<button class="btn btn-secondary" onclick="closePermissionModal()">Cancelar</button> | |
</div> | |
</div> | |
</div>`; | |
// Agregar después de document.addEventListener('DOMContentLoaded'... | |
document.body.insertAdjacentHTML('beforeend', permissionModal); | |
// Funciones para el modal de permisos | |
function showPermissionModal() { | |
document.getElementById('permissionModal').classList.add('show'); | |
} | |
function closePermissionModal() { | |
document.getElementById('permissionModal').classList.remove('show'); | |
} | |
async function requestMicrophonePermission() { | |
try { | |
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
stream.getTracks().forEach(track => track.stop()); | |
closePermissionModal(); | |
await VoiceManager.initializeSpeechRecognition(); | |
} catch (error) { | |
console.error('Error al solicitar permisos:', error); | |
Utils.updateStatus('Error: No se pudo acceder al micrófono', 'error'); | |
ChatManager.addMessage('❌ No se pudo acceder al micrófono. Por favor, verifica los permisos en tu navegador.', 'bot'); | |
} | |
} | |