feat(dashboard): asistente IA con voz, markdown y preguntas rápidas

- Audio input: botón micrófono con SpeechRecognition (es-CO)
- Audio output: TTS con SpeechSynthesis, toggle para silenciar
- Markdown: **negrita** → <strong>, * items → <ul> en respuestas
- Chips de preguntas rápidas: Resumen, Facturación, Tiempos, En espera
- Panel minimizable: clic en header colapsa/expande el cuerpo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-30 19:55:41 -05:00
co-authored by Claude Sonnet 4.6
parent 941b6740f0
commit a8752b9e9d
+105 -16
View File
@@ -89,28 +89,41 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
/* ── Panel IA ── */
#aiPanel {
position:fixed; bottom:0; right:24px; width:360px; z-index:1080;
position:fixed; bottom:0; right:24px; width:380px; z-index:1080;
box-shadow:0 -4px 32px rgba(0,0,0,.18); border-radius:16px 16px 0 0;
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
transform:translateY(100%); transition:transform .3s ease;
display:flex; flex-direction:column; max-height:520px;
display:flex; flex-direction:column; max-height:560px;
}
#aiPanel.open { transform:translateY(0); }
#aiPanel.minimized .ai-body { display:none; }
#aiPanel.minimized { max-height:none; }
.ai-header {
background:linear-gradient(135deg, var(--brand-dark,#0d47a1), var(--brand,#1565c0));
color:#fff; padding:12px 16px; border-radius:16px 16px 0 0;
display:flex; align-items:center; gap:8px; flex-shrink:0;
display:flex; align-items:center; gap:8px; flex-shrink:0; cursor:pointer; user-select:none;
}
.ai-header .ai-title { flex:1; font-weight:700; font-size:.92rem; }
.ai-header .btn-close { filter:invert(1) brightness(2); }
.btn-tts { background:transparent; border:none; color:rgba(255,255,255,.55); cursor:pointer; padding:3px 6px; border-radius:6px; font-size:.85rem; transition:color .2s; }
.btn-tts.active { color:#fff; }
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
.ai-quick { display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; border-bottom:1px solid #f1f5f9; flex-shrink:0; }
.ai-chip { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:99px; padding:4px 10px; font-size:.73rem; cursor:pointer; color:#475569; white-space:nowrap; transition:background .15s; }
.ai-chip:hover { background:#e2e8f0; }
.ai-messages { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:8px; }
.ai-msg { max-width:88%; padding:8px 12px; border-radius:12px; font-size:.83rem; line-height:1.45; }
.ai-msg { max-width:90%; padding:8px 12px; border-radius:12px; font-size:.83rem; line-height:1.5; }
.ai-msg.user { background:var(--brand,#1565c0); color:#fff; align-self:flex-end; border-radius:12px 12px 2px 12px; }
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:12px 12px 12px 2px; }
.ai-msg.bot.typing { color:#94a3b8; font-style:italic; }
.ai-footer { padding:10px 12px; border-top:1px solid #e2e8f0; display:flex; gap:8px; flex-shrink:0; }
.ai-msg.bot strong { color:#0d47a1; }
.ai-msg.bot ul { margin:4px 0 4px 14px; padding:0; }
.ai-footer { padding:10px 12px; border-top:1px solid #e2e8f0; display:flex; gap:6px; flex-shrink:0; }
.ai-footer input { flex:1; border:1px solid #e2e8f0; border-radius:8px; padding:7px 12px; font-size:.85rem; outline:none; }
.ai-footer input:focus { border-color:var(--brand,#1565c0); }
.btn-mic { background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:7px 11px; cursor:pointer; color:#64748b; font-size:.85rem; transition:all .2s; }
.btn-mic.listening { background:#fee2e2; color:#dc2626; border-color:#fca5a5; animation:mic-pulse 1s infinite; }
@keyframes mic-pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.ai-footer .btn-send { background:var(--brand,#1565c0); color:#fff; border:none; border-radius:8px; padding:7px 14px; cursor:pointer; font-size:.85rem; }
#btnAI {
position:fixed; bottom:24px; right:24px; z-index:1079;
@@ -260,18 +273,30 @@ try {
<!-- ── Panel IA ── -->
<div id="aiPanel">
<div class="ai-header">
<div class="ai-header" onclick="minimizarIA(event)">
<i class="fas fa-robot"></i>
<span class="ai-title">Asistente del Turnero</span>
<button class="btn-close" onclick="toggleAI()"></button>
<button id="btnTTS" class="btn-tts active" onclick="event.stopPropagation();toggleTTS()" title="Activar/silenciar voz">
<i class="fas fa-volume-up"></i>
</button>
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
</div>
<div class="ai-messages" id="aiMessages">
<div class="ai-msg bot">¡Hola! Puedo responder preguntas sobre el estado del turnero de hoy. ¿Qué necesitas saber?</div>
</div>
<div class="ai-footer">
<input type="text" id="aiInput" placeholder="¿Cuántos pacientes en espera?…" maxlength="400"
onkeydown="if(event.key==='Enter')enviarIA()">
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
<div class="ai-body" id="aiBody">
<div class="ai-quick" id="aiQuick">
<button class="ai-chip" onclick="enviarRapido('¿Cuántos pacientes atendidos hoy y cuántos faltan?')">📊 Resumen</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuánto se ha facturado hoy?')">💰 Facturación</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuáles son los tiempos promedio de hoy?')">⏱ Tiempos</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuántos pacientes están en espera ahora mismo?')">⏳ En espera</button>
</div>
<div class="ai-messages" id="aiMessages">
<div class="ai-msg bot">¡Hola! Pregúntame sobre el turnero de hoy o usa los botones de arriba. También puedes hablar 🎤</div>
</div>
<div class="ai-footer">
<input type="text" id="aiInput" placeholder="Escribe o toca el micrófono…" maxlength="400"
onkeydown="if(event.key==='Enter')enviarIA()">
<button class="btn-mic" id="btnMic" onclick="iniciarVoz()" title="Hablar"><i class="fas fa-microphone"></i></button>
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
</div>
</div>
</div>
<?php endif; ?>
@@ -568,11 +593,74 @@ function filtrarTabla() {
// ── Asistente IA ──────────────────────────────────────────────
let _aiOpen = false;
// ── IA helpers ───────────────────────────────────────────────
let _aiMinimized = false;
let _ttsOn = true;
let _recog = null;
function mdToHtml(md) {
let s = md
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^\* (.+)$/gm,'<li>$1</li>');
s = s.replace(/((?:<li>[^]*?<\/li>\n?)+)/g,
m => `<ul style="margin:4px 0 4px 14px;padding:0;list-style:disc">${m.replace(/\n/g,'')}</ul>`);
return s.replace(/\n/g,'<br>');
}
function _stripMd(text) {
return text.replace(/\*\*(.+?)\*\*/g,'$1').replace(/^\* /gm,'').replace(/\n/g,' ');
}
function hablarIA(text) {
if (!_ttsOn || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utt = new SpeechSynthesisUtterance(_stripMd(text));
utt.lang = 'es-CO'; utt.rate = 1.05;
window.speechSynthesis.speak(utt);
}
function toggleTTS() {
_ttsOn = !_ttsOn;
document.getElementById('btnTTS').classList.toggle('active', _ttsOn);
if (!_ttsOn) window.speechSynthesis?.cancel();
}
function iniciarVoz() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { alert('Tu navegador no soporta reconocimiento de voz (usa Chrome)'); return; }
const btn = document.getElementById('btnMic');
if (_recog) { _recog.stop(); return; }
_recog = new SR();
_recog.lang = 'es-CO'; _recog.interimResults = false;
btn.classList.add('listening');
_recog.onresult = e => {
document.getElementById('aiInput').value = e.results[0][0].transcript;
enviarIA();
};
_recog.onend = () => { btn.classList.remove('listening'); _recog = null; };
_recog.onerror= () => { btn.classList.remove('listening'); _recog = null; };
_recog.start();
}
function enviarRapido(pregunta) {
document.getElementById('aiInput').value = pregunta;
document.getElementById('aiQuick').style.display = 'none';
enviarIA();
}
function minimizarIA(e) {
if (e.target.closest('button')) return;
_aiMinimized = !_aiMinimized;
document.getElementById('aiPanel').classList.toggle('minimized', _aiMinimized);
}
function toggleAI() {
_aiOpen = !_aiOpen;
if (!_aiOpen) { _aiMinimized = false; document.getElementById('aiPanel').classList.remove('minimized'); }
document.getElementById('aiPanel').classList.toggle('open', _aiOpen);
document.getElementById('btnAI').classList.toggle('hidden', _aiOpen);
if (_aiOpen) document.getElementById('aiInput').focus();
if (_aiOpen && !_aiMinimized) document.getElementById('aiInput').focus();
}
async function enviarIA() {
@@ -599,7 +687,8 @@ async function enviarIA() {
});
const d = await res.json();
typing.className = 'ai-msg bot';
typing.textContent = d.ok ? d.respuesta : (d.error || 'Error al conectar con la IA');
if (d.ok) { typing.innerHTML = mdToHtml(d.respuesta); hablarIA(d.respuesta); }
else { typing.textContent = d.error || 'Error al conectar con la IA'; }
} catch(_) {
typing.className = 'ai-msg bot';
typing.textContent = 'Error de conexión';