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
+23 -4
View File
@@ -14,12 +14,19 @@ $pregunta = trim($body['pregunta'] ?? '');
if ($pregunta === '') jsonError('Pregunta vacía', 400);
if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400);
define('LIA_TOKENS_MAX', 1_000_000);
$pdo = db();
// ── Clave Gemini ──────────────────────────────────────────────
$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch();
$apiKey = trim($row['valor'] ?? '');
$cfg = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')")->fetchAll(PDO::FETCH_KEY_PAIR);
$apiKey = trim($cfg['gemini_api_key'] ?? '');
$tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
if ($tokensUsados >= LIA_TOKENS_MAX) {
jsonError('⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.', 402);
}
// ── Contexto del día ──────────────────────────────────────────
$hoy = date('Y-m-d');
@@ -207,5 +214,17 @@ if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text
jsonError("Error IA: {$detail}", 502);
}
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
jsonOk(['respuesta' => $respuesta]);
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
$usage = $gemini['usageMetadata'] ?? [];
$tokensEsta = (int)($usage['totalTokenCount'] ?? (int)((mb_strlen($pregunta) + mb_strlen($respuesta)) / 4));
$tokensUsados += $tokensEsta;
$pdo->prepare("INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
ON DUPLICATE KEY UPDATE valor = ?")->execute([$tokensUsados, $tokensUsados]);
jsonOk([
'respuesta' => $respuesta,
'tokens_usados' => $tokensUsados,
'tokens_restantes' => max(0, LIA_TOKENS_MAX - $tokensUsados),
'tokens_max' => LIA_TOKENS_MAX,
]);
+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';