feat(LIA): barra de tokens 1M con bloqueo al agotarse

- ai_chat.php: cuenta tokens reales desde Gemini usageMetadata, persiste en lab_config, bloquea con HTTP 402 al agotar 1.000.000
- dashboard.php: _actualizarTokens() actualiza barra visual, _bloquearLIA() deshabilita input+mic y muestra aviso de soporte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-30 20:09:18 -05:00
co-authored by Claude Sonnet 4.6
parent 34d0fed21b
commit 320b47797a
2 changed files with 68 additions and 6 deletions
+45 -2
View File
@@ -140,6 +140,11 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
.lia-wave span:nth-child(4){height:16px;animation-delay:.3s}
.lia-wave span:nth-child(5){height:8px;animation-delay:.4s}
@keyframes wave { 0%,100%{transform:scaleY(.5);opacity:.6} 50%{transform:scaleY(1);opacity:1} }
.lia-tokens { padding:6px 14px; background:#f8faff; border-top:1px solid #f1f5f9; flex-shrink:0; }
.lia-tokens-bar { height:5px; border-radius:3px; background:#e2e8f0; overflow:hidden; margin-bottom:3px; }
.lia-tokens-fill { height:100%; border-radius:3px; background:linear-gradient(90deg,#1565c0,#0288d1); transition:width .6s ease; }
.lia-tokens-fill.warn { background:linear-gradient(90deg,#f59e0b,#ef4444); }
.lia-tokens-label { font-size:.68rem; color:#94a3b8; display:flex; justify-content:space-between; }
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px; flex-shrink:0; background:#fafbff; }
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px; font-size:.9rem; outline:none; background:#fff; }
.ai-footer input:focus { border-color:#1565c0; }
@@ -352,6 +357,13 @@ try {
<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>
<div class="lia-tokens" id="liaTokensBar">
<div class="lia-tokens-bar"><div class="lia-tokens-fill" id="liaFill" style="width:0%"></div></div>
<div class="lia-tokens-label">
<span id="liaTokensUsed">0 tokens usados</span>
<span id="liaTokensLeft">1.000.000 restantes</span>
</div>
</div>
<div id="liaListening" style="display:none;text-align:center;padding:10px 0">
<div class="lia-wave"><span></span><span></span><span></span><span></span><span></span></div>
<div style="font-size:.75rem;color:#64748b;margin-top:4px">Escuchando…</div>
@@ -669,6 +681,31 @@ let _ttsOn = true;
let _recog = null;
let _chartProy = null;
function _actualizarTokens(usados, max) {
const pct = Math.min(100, (usados / max) * 100);
const left = max - usados;
const fill = document.getElementById('liaFill');
const fmt = n => Intl.NumberFormat('es-CO').format(n);
fill.style.width = pct + '%';
fill.className = 'lia-tokens-fill' + (pct > 80 ? ' warn' : '');
document.getElementById('liaTokensUsed').textContent = fmt(usados) + ' tokens usados';
document.getElementById('liaTokensLeft').textContent = fmt(left) + ' restantes';
if (left <= 0) _bloquearLIA();
}
function _bloquearLIA() {
document.getElementById('aiInput').disabled = true;
document.getElementById('btnMic').disabled = true;
document.getElementById('btnMic').style.opacity = '.4';
const msgs = document.getElementById('aiMessages');
const el = document.createElement('div');
el.className = 'ai-msg bot';
el.innerHTML = '⚠️ <strong>Se agotaron los tokens de LIA.</strong><br>Solicita tokens a soporte para continuar.';
msgs.appendChild(el);
msgs.scrollTop = msgs.scrollHeight;
hablarIA('Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.');
}
function mdToHtml(md) {
let s = md
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
@@ -820,8 +857,14 @@ async function enviarIA() {
});
const d = await res.json();
typing.className = 'ai-msg bot';
if (d.ok) { typing.innerHTML = mdToHtml(d.respuesta); hablarIA(d.respuesta); }
else { typing.textContent = d.error || 'Error al conectar con la IA'; }
if (d.ok) {
typing.innerHTML = mdToHtml(d.respuesta);
hablarIA(d.respuesta);
if (d.tokens_max) _actualizarTokens(d.tokens_usados, d.tokens_max);
} else {
typing.textContent = d.error || 'Error al conectar con la IA';
if (d.status === 402 || (d.error && d.error.includes('agotaron'))) _bloquearLIA();
}
} catch(_) {
typing.className = 'ai-msg bot';
typing.textContent = 'Error de conexión';