feat(voice): implementa voz y TTS en chats POS y dashboard

- Agrega TTS (speechSynthesis) a chat.js del POS para leer respuestas IA
- Copia lógica de voz completa (STT + TTS) a dashboard/chat-public.js
- Extiende estilos TTS en chat.css y chat-public.css
- Agrega chat widget a 13 templates POS que no lo tenían
- Corrige duplicado de chat.css en diagrams.html
- Minifica assets actualizados
- 73/73 tests pasan
This commit is contained in:
2026-04-28 00:53:57 +00:00
parent 1f909f4c42
commit afb3b2405c
20 changed files with 443 additions and 10 deletions

View File

@@ -1,15 +1,20 @@
// /home/Autopartes/dashboard/chat-public.js
// Public catalog chatbot — no auth required, calls /api/chat
// Public catalog chatbot — voice + TTS enabled
(function () {
'use strict';
var isOpen = false;
var isSending = false;
var isListening = false;
var recognition = null;
var history = [];
var ttsEnabled = true;
var ttsUtterance = null;
var hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
var hasTTS = ('speechSynthesis' in window);
function init() {
// FAB button
var fab = document.createElement('button');
fab.className = 'chat-fab';
fab.id = 'chatFab';
@@ -17,14 +22,16 @@
fab.innerHTML = '💬';
fab.setAttribute('aria-label', 'Abrir asistente IA');
// Chat panel
var panel = document.createElement('div');
panel.className = 'chat-panel';
panel.id = 'chatPanel';
panel.innerHTML =
'<div class="chat-header">' +
'<h3>Asistente — Buscar partes</h3>' +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'<div class="chat-header-actions">' +
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">&#128266;</button>' : '') +
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">&times;</button>' +
'</div>' +
'</div>' +
'<div class="chat-messages" id="chatMessages">' +
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
@@ -32,6 +39,7 @@
'</div>' +
'<div class="chat-input-area">' +
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
(hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">&#127908;</button>' : '') +
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">&#9654;</button>' +
'</div>';
@@ -52,8 +60,139 @@
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
});
if (hasSpeechAPI) {
document.getElementById('chatMic').addEventListener('click', toggleVoice);
}
if (hasTTS) {
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
}
// Stop TTS when closing chat
document.getElementById('chatClose').addEventListener('click', function () {
if (hasTTS) stopSpeaking();
});
}
// ─── TTS ───
function toggleTTS() {
ttsEnabled = !ttsEnabled;
var btn = document.getElementById('chatTtsToggle');
if (btn) {
btn.classList.toggle('off', !ttsEnabled);
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
}
if (!ttsEnabled) stopSpeaking();
}
function speak(text) {
if (!hasTTS || !ttsEnabled || !text) return;
stopSpeaking();
ttsUtterance = new SpeechSynthesisUtterance(text);
ttsUtterance.lang = 'es-MX';
ttsUtterance.rate = 1.1;
ttsUtterance.pitch = 1;
window.speechSynthesis.speak(ttsUtterance);
}
function stopSpeaking() {
if (hasTTS && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
ttsUtterance = null;
}
// ─── Voice Input ───
function toggleVoice() {
if (isListening) { stopVoice(); return; }
startVoice();
}
function startVoice() {
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) return;
recognition = new SpeechRecognition();
recognition.lang = 'es-MX';
recognition.continuous = false;
recognition.interimResults = true;
var input = document.getElementById('chatInput');
var micBtn = document.getElementById('chatMic');
var savedPlaceholder = input.placeholder;
recognition.onstart = function () {
isListening = true;
micBtn.classList.add('listening');
input.placeholder = 'Escuchando...';
input.value = '';
stopSpeaking();
};
recognition.onresult = function (e) {
var interim = '';
var finalTranscript = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
finalTranscript += e.results[i][0].transcript;
} else {
interim += e.results[i][0].transcript;
}
}
if (finalTranscript) {
input.value = finalTranscript;
} else {
input.value = interim;
}
};
recognition.onend = function () {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (input.value.trim()) {
sendMessage();
}
};
recognition.onerror = function (e) {
isListening = false;
micBtn.classList.remove('listening');
input.placeholder = savedPlaceholder;
recognition = null;
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
showVoiceToast('No se detecto voz');
}
};
recognition.start();
}
function stopVoice() {
if (recognition) {
recognition.abort();
recognition = null;
}
isListening = false;
var micBtn = document.getElementById('chatMic');
if (micBtn) micBtn.classList.remove('listening');
}
function showVoiceToast(msg) {
var toast = document.createElement('div');
toast.className = 'chat-voice-toast';
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(function () { toast.classList.add('visible'); }, 10);
setTimeout(function () {
toast.classList.remove('visible');
setTimeout(function () { toast.remove(); }, 300);
}, 2000);
}
// ─── Chat UI ───
function toggleChat() {
isOpen = !isOpen;
var panel = document.getElementById('chatPanel');
@@ -104,6 +243,8 @@
addBubble(aiMsg, 'ai');
history.push({ role: 'assistant', content: aiMsg });
if (ttsEnabled) speak(aiMsg);
if (data.search_results && data.search_results.length > 0) {
addPartResults(data.search_results);
}
@@ -149,7 +290,6 @@
card.style.cursor = 'pointer';
card.addEventListener('click', function () {
// Search in catalog
var searchInput = document.getElementById('searchInput');
if (searchInput && partNum) {
searchInput.value = partNum;