Files
myron 2f4b4ef5c3 feat: HA tab — filter scenes/media_player, nightly full resync cron, remove JS polling
- ha.php skipDomains: added media_player, scene
- ha.php skipKeywords: konnected, energy/power/voltage/current, full camera list
- stats_cache.php: same filter updates, removed scene/media_player from sync
- Removed JS setInterval polling; entity state kept fresh by HA agent push
- Added nightly 3am cron for full HA entity resync
2026-06-22 03:56:47 +00:00

1822 lines
76 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── GLOBALS ──────────────────────────────────────────────────────────
let sessionToken = '';
let sessionUser = '';
let sessionId = 'session_' + Date.now();
let isListening = false;
let recognition = null;
let synth = window.speechSynthesis;
let selectedVoice = null;
let refreshTimer = null;
let isSpeaking = false;
let panelsVisible = true;
let cameraActive = false;
let faceLoopId = null;
let lastFaceSeen = 0;
let autoMicCooldown = 0;
let faceApiReady = false;
let lastActivity = Date.now();
const IDLE_RELOAD_MS = 5 * 60 * 1000; // 5 min inactivity → full reload
let voiceMode = false; // true = JARVIS awake (listening for commands)
let voiceMuted = false; // true = awake but mic muted
let voiceLastCmd = 0;
const VOICE_SLEEP_MS = 30 * 60 * 1000; // 30 min voice inactivity → sleep
const VOICE_ACTIVE_MS = 17000; // 17s active window after each command
let voiceActive = 0; // timestamp of last issued command
// Phase 1: full phrase required to wake from sleep
const WAKE_PHRASES = ["wake up jarvis", "daddy's home", "wake up, jarvis", "daddys home"];
// Phase 2: command prefix — "jarvis <command>"; then 17s free-listen window
const CMD_PREFIX = 'jarvis';
const FACE_MODEL_URL = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights';
// ── INIT ─────────────────────────────────────────────────────────────
function initCollapsiblePanels() {
document.querySelectorAll('.panel').forEach(panel => {
const title = panel.querySelector('.panel-title');
if (!title) return;
const btn = document.createElement('button');
btn.className = 'panel-collapse-btn';
btn.textContent = '▾';
btn.title = 'Collapse / expand';
title.appendChild(btn);
const key = 'pnl_' + (title.textContent||'').trim().substring(0,24).replace(/\s+/g,'_').toLowerCase().replace(/[^a-z0-9_]/g,'');
if (localStorage.getItem(key) === '1') panel.classList.add('collapsed');
title.addEventListener('click', e => {
if (e.target.closest('button:not(.panel-collapse-btn),a,input,select')) return;
const col = panel.classList.toggle('collapsed');
localStorage.setItem(key, col ? '1' : '0');
});
});
}
window.addEventListener("load", () => {
["mousemove","keydown","touchstart","click"].forEach(e =>
window.addEventListener(e, () => { lastActivity = Date.now(); }, {passive:true})
);
updateClock();
setInterval(updateClock, 1000);
initVoice();
loadVoices();
// Check if already logged in — prefer PHP-injected global, fall back to sessionStorage
const saved = (typeof __jarvisToken !== 'undefined' ? __jarvisToken : null)
|| sessionStorage.getItem('jarvis_token');
const savedUser = (typeof __jarvisUser !== 'undefined' ? __jarvisUser : null)
|| sessionStorage.getItem('jarvis_user') || '';
const autoReload = sessionStorage.getItem('jarvis_autoreload') === '1';
sessionStorage.removeItem('jarvis_autoreload');
if (saved) {
sessionToken = saved;
sessionUser = savedUser;
try { sessionStorage.setItem('jarvis_token', saved); sessionStorage.setItem('jarvis_user', savedUser); } catch(e) {}
if (localStorage.getItem('jarvis_panels_swapped') === '1') swapPanels();
showApp(savedUser, null, autoReload);
}
});
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
now.toLocaleTimeString('en-US',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
document.getElementById('date-display').textContent =
now.toLocaleDateString('en-US',{weekday:'short',year:'numeric',month:'short',day:'numeric'}).toUpperCase();
}
// ── LOGIN ─────────────────────────────────────────────────────────────
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const user = document.getElementById('loginUser').value;
const pass = document.getElementById('loginPass').value;
const errEl = document.getElementById('loginError');
errEl.textContent = '';
try {
const res = await api('auth', 'POST', {username:user, password:pass});
if (res.success) {
sessionToken = res.token;
sessionUser = res.display_name;
sessionStorage.setItem('jarvis_token', sessionToken);
sessionStorage.setItem('jarvis_user', sessionUser);
showApp(sessionUser, res.greeting);
} else {
errEl.textContent = 'ACCESS DENIED';
}
} catch(err) {
errEl.textContent = 'CONNECTION FAILED';
}
});
function showApp(name, greeting, silent = false) {
document.getElementById('loginScreen').style.display = 'none';
const app = document.getElementById('app');
app.style.display = 'flex';
// HUD boot sequence — staggered slide-in
const topBar = document.getElementById('topBar');
const leftPanel = document.getElementById('leftPanel');
const rightPanel = document.getElementById('rightPanel');
const centerPanel= document.getElementById('centerPanel');
[topBar, leftPanel, rightPanel, centerPanel].forEach(el => el && (el.style.opacity = '0'));
requestAnimationFrame(() => {
if (topBar) { topBar.style.opacity=''; topBar.style.animationDelay='0s'; topBar.classList.add('boot-top'); }
setTimeout(()=>{ if(leftPanel) { leftPanel.style.opacity=''; leftPanel.style.animationDelay='0s'; leftPanel.classList.add('boot-left'); }}, 120);
setTimeout(()=>{ if(rightPanel) { rightPanel.style.opacity=''; rightPanel.style.animationDelay='0s'; rightPanel.classList.add('boot-right'); }}, 180);
setTimeout(()=>{ if(centerPanel){ centerPanel.style.opacity='';centerPanel.style.animationDelay='0s';centerPanel.classList.add('boot-center');}}, 240);
setTimeout(()=>{ [topBar,leftPanel,rightPanel,centerPanel].forEach(el=>el?.classList.remove('boot-top','boot-left','boot-right','boot-center')); }, 1200);
});
if (!silent) {
if (greeting) {
addMessage('jarvis', greeting);
speak(greeting);
} else {
const g = `Welcome back, ${name}. All systems online and standing by.`;
addMessage('jarvis', g);
speak(g);
}
}
// Smart morning briefing: auto-speak once per day before noon
const _briefKey = 'jarvis_brief_' + new Date().toISOString().slice(0, 10);
const _briefHour = new Date().getHours();
if (!silent && _briefHour < 12 && !localStorage.getItem(_briefKey)) {
localStorage.setItem(_briefKey, '1');
setTimeout(triggerMorningBriefing, 3500);
}
// Arc Reactor boot spin-up
const _ar = document.getElementById('arcReactor');
if (_ar) {
_ar.classList.add('boot-spin');
setTimeout(() => _ar.classList.remove('boot-spin'), 1600);
}
// Start data refresh
initCollapsiblePanels();
refreshAll();
refreshTimer = setInterval(refreshAll, 10000); // every 10s
setInterval(() => {
if (!isAsleep && Date.now() - lastActivity > IDLE_RELOAD_MS) {
sessionStorage.setItem('jarvis_autoreload', '1');
location.reload();
}
}, 30000);
setInterval(() => {
if (voiceMode && voiceLastCmd > 0 && Date.now() - voiceLastCmd > VOICE_SLEEP_MS) {
exitVoiceMode();
}
}, 60000);
// Watchdog: reset isSpeaking if stuck; heartbeat keeps mic alive
setInterval(() => {
if (isSpeaking && !_ttsAudio && !window.speechSynthesis?.speaking) {
isSpeaking = false;
if (isListening) _scheduleRecStart(200);
}
}, 4000);
// Heartbeat: if mic should be on but recognition has gone quiet, nudge it
setInterval(() => {
if (isListening && !isSpeaking) {
try {
recognition.start(); // throws if already running — that's fine
} catch(_) {}
}
}, 12000);
startListening();
loadNetwork();
loadHA();
checkAgentStatus();
checkArcStatus().catch(() => {});
loadAgents();
loadAlerts();
loadWeather();
loadNews();
initMobile();
setTimeout(checkPlannerReminder, 3000);
setInterval(checkUpcomingAppts, 300000);
setTimeout(pollAlertsProactive, 8000);
setTimeout(checkSuggestions, 15000);
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
setInterval(pollAlertsProactive, 60000); // poll every 60s
setInterval(() => {
const layout = document.getElementById('mainLayout');
if (!layout) return;
if (Date.now() - lastActivity > 90000) layout.classList.add('ambient-dim-active');
else layout.classList.remove('ambient-dim-active');
}, 5000);
setTimeout(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
}, 9000);
// Guardian Mode — badge refresh + proactive chat
setTimeout(() => {
_refreshGuardianBadge();
_pollProactiveChat();
startGuardianPolling();
setInterval(_pollProactiveChat, 30000);
}, 5000);
// Clearance banner — poll every 30s
setTimeout(() => {
updateClearanceBanner();
setInterval(updateClearanceBanner, 30000);
}, 6000);
// Memory Core — poll count every 60s
setTimeout(() => {
updateMemoryCount();
setInterval(updateMemoryCount, 60000);
}, 8000);
}
async function logout() {
clearInterval(refreshTimer);
await api('auth', 'DELETE', {});
sessionStorage.clear();
location.reload();
}
// ── API HELPER ────────────────────────────────────────────────────────
async function api(endpoint, method='GET', body=null) {
const opts = {
method,
headers: {'Content-Type':'application/json','X-Session-Token':sessionToken},
credentials:'include',
};
if (body && method !== 'GET') opts.body = JSON.stringify(body);
const res = await fetch('/api/' + endpoint, opts);
if (res.status === 401) { logout(); return {}; }
return res.json();
}
// ── PANEL TOGGLE ─────────────────────────────────────────────────────
function swapPanels() {
const layout = document.getElementById('mainLayout');
const btn = document.getElementById('btn-swap-panels');
const isSwapped = layout.classList.toggle('swapped');
btn.classList.toggle('active', isSwapped);
localStorage.setItem('jarvis_panels_swapped', isSwapped ? '1' : '0');
}
function togglePanels(silent) {
panelsVisible = !panelsVisible;
const layout = document.getElementById('mainLayout');
const btn = document.getElementById('panelToggleBtn');
if (panelsVisible) {
layout.classList.remove('focus-mode');
btn.classList.remove('focus-active');
btn.textContent = '◧ PANELS';
if (!silent) speak('Full view restored.');
} else {
layout.classList.add('focus-mode');
btn.classList.add('focus-active');
btn.textContent = '◫ FOCUS';
if (!silent) speak('Focus mode activated. Side panels hidden.');
}
}
// Keyboard shortcut: backslash to toggle panels
document.addEventListener('keydown', (e) => {
if (e.key === '\\' && document.activeElement.id !== 'textInput') {
togglePanels();
}
});
// ── CAMERA FACE DETECTION / AUTO-MIC ─────────────────────────────────
async function loadFaceApi() {
if (faceApiReady) return true;
try {
if (typeof faceapi === 'undefined') {
addMessage('system', 'Face detection library not available.');
return false;
}
await faceapi.nets.tinyFaceDetector.loadFromUri(FACE_MODEL_URL);
faceApiReady = true;
return true;
} catch(e) {
addMessage('system', 'Could not load face detection model: ' + e.message);
return false;
}
}
async function startCamera() {
if (cameraActive) return;
const btn = document.getElementById('cameraBtn');
btn.textContent = '◉ LOADING…';
try {
const stream = await navigator.mediaDevices.getUserMedia(
{video:{facingMode:'user', width:{ideal:320}, height:{ideal:240}}, audio:false}
);
const video = document.getElementById('faceVideo');
video.srcObject = stream;
await video.play();
const ok = await loadFaceApi();
if (!ok) { stopCamera(); return; }
cameraActive = true;
btn.classList.add('cam-active');
btn.textContent = '◉ SENSING';
startFaceTracking();
addMessage('system', 'Face detection active — reactor tracking engaged.');
faceLoopId = setInterval(async () => {
if (!cameraActive) return;
// Run detection even while speaking — needed for tracking + prevents lastFaceSeen staling out
try {
const detection = await faceapi.detectSingleFace(
document.getElementById('faceVideo'),
new faceapi.TinyFaceDetectorOptions({inputSize:160, scoreThreshold:0.45})
);
const now = Date.now();
if (detection) {
lastFaceSeen = now;
const ratio = (detection.box.width * detection.box.height) / (320 * 240);
// Always drive the reactor
updateFaceTarget(detection.box, 320, 240);
// Only auto-trigger voice when not already speaking/active, cooldown passed
if (ratio > 0.03 && !voiceMode && !isSpeaking && now > autoMicCooldown) {
autoMicCooldown = now + 9000;
document.getElementById('cameraBtn').classList.add('cam-sensing');
enterVoiceMode();
}
} else {
// While JARVIS is speaking, keep lastFaceSeen fresh so the exit timer doesn't tick down
if (isSpeaking) { lastFaceSeen = now; }
else { clearFaceTarget(); }
document.getElementById('cameraBtn').classList.remove('cam-sensing');
// Exit voice mode only if: face gone >12s AND no command in that same window AND not speaking
const noFaceMs = now - lastFaceSeen;
const noCommandMs = now - (voiceLastCmd || 0);
if (voiceMode && !isSpeaking && noFaceMs > 12000 && noCommandMs > 12000) {
exitVoiceMode();
}
}
} catch(_) {}
}, 600);
} catch(e) {
btn.textContent = '◉ CAMERA';
if (e.name === 'NotAllowedError') {
addMessage('system', 'Camera permission denied. Grant camera access in browser settings to enable hands-free mode.');
} else {
addMessage('system', 'Camera unavailable: ' + e.message);
}
}
}
function stopCamera() {
cameraActive = false;
clearInterval(faceLoopId);
faceLoopId = null;
const video = document.getElementById('faceVideo');
if (video && video.srcObject) {
video.srcObject.getTracks().forEach(t => t.stop());
video.srcObject = null;
}
const btn = document.getElementById('cameraBtn');
if (btn) {
btn.classList.remove('cam-active', 'cam-sensing');
btn.textContent = '◉ CAMERA';
}
stopFaceTracking();
}
function toggleCamera() {
if (cameraActive) {
stopCamera();
addMessage('system', 'Face detection disabled.');
} else {
startCamera();
}
}
// ── REFRESH ALL ───────────────────────────────────────────────────────
let _refreshTick = 0;
let selectedContext = null;
const _panelCtx = {};
let _haEntities = {};
const _svcLabels = {nginx:'WEB','php8.3-fpm':'PHP',mariadb:'DB','redis-server':'REDIS','jarvis-arc':'ARC','jarvis-agent':'AGENT'};
async function refreshAll() {
_refreshTick++;
const el = document.getElementById('last-refresh');
if (el) el.textContent = new Date().toLocaleTimeString('en-US',{hour12:false});
// Fire core calls in parallel — cuts refresh latency from ~3s to ~600ms
const [s, n, d] = await Promise.all([
api('system').catch(() => null),
api('network').catch(() => null),
api('do').catch(() => null),
]);
if (s) renderSystem(s);
if (n) renderNetworkStatus(n);
if (d) renderDO(d);
// Agent status every tick (fire and forget — doesn't block)
checkAgentStatus().catch(() => {});
// Refresh right-panel tabs every 3rd tick (~30s) — all parallel
if (_refreshTick % 3 === 0) {
Promise.all([
loadHA().catch(() => {}),
loadAlerts().catch(() => {}),
loadAgents().catch(() => {}),
loadProxmox().catch(() => {}),
loadPlannerSummary().catch(() => {}),
]);
}
// Refresh Arc Reactor status every 6th tick (~60s)
if (_refreshTick % 6 === 0) {
checkArcStatus().catch(() => {});
}
// Refresh weather + news every 18th tick (~3 min)
if (_refreshTick % 18 === 0) {
Promise.all([
loadWeather().catch(() => {}),
loadNews().catch(() => {}),
]);
}
}
// ── ANIMATED NUMBER COUNTER ───────────────────────────────────────────
const _prevVals = {};
function tickTo(id, newVal, unit='%', decimals=0) {
const el = document.getElementById(id);
if (!el) return;
const prev = _prevVals[id] !== undefined ? _prevVals[id] : 0;
_prevVals[id] = newVal;
if (Math.abs(newVal - prev) < 0.5) { el.textContent = newVal.toFixed(decimals) + unit; return; }
const start = performance.now(), dur = 700;
(function frame(now) {
const p = Math.min((now - start) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.textContent = (prev + (newVal - prev) * ease).toFixed(decimals) + unit;
if (p < 1) requestAnimationFrame(frame);
})(performance.now());
}
// ── RENDER: SYSTEM ────────────────────────────────────────────────────
function renderSystem(s) {
if (!s || s.error) return;
const cpu = s.cpu || 0;
const mem = s.memory?.percent || 0;
const disk = s.disk?.percent || 0;
// Top bar (animated)
tickTo('tb-cpu', cpu, '');
tickTo('tb-mem', mem, '');
// Metric bars
setBar('cpu', cpu);
setBar('mem', mem);
setBar('disk', disk);
tickTo('cpu-val', cpu);
tickTo('mem-val', mem);
tickTo('disk-val', disk);
// Sparklines
pushSparkData('cpu', cpu);
pushSparkData('mem', mem);
pushSparkData('disk', disk);
drawSparkline('spark-cpu', _sparkData.cpu, 'rgb(0,212,255)');
drawSparkline('spark-mem', _sparkData.mem, 'rgb(0,255,136)');
drawSparkline('spark-disk', _sparkData.disk, 'rgb(255,166,0)');
// Flash the system panel on data arrival
flashPanel(document.querySelector('#leftPanel .panel'));
document.getElementById('uptime-val').textContent = s.uptime || '--';
document.getElementById('load-val').textContent = s.load?.['1m'] || '--';
document.getElementById('host-val').textContent = s.hostname || 'jarvis';
// Services
if (s.services) {
const svcEl = document.getElementById('services-list');
svcEl.innerHTML = Object.entries(s.services).map(([k,v]) =>
`<div class="service-row">
<span class="svc-name">${_svcLabels[k]||k.toUpperCase()}</span>
<div class="svc-dot ${v?'on':'off'}" title="${k}: ${v?'ACTIVE':'INACTIVE'}"></div>
</div>`
).join('');
}
// Processes
if (s.processes?.length) {
document.getElementById('procs-list').innerHTML = s.processes.map(p =>
`<div class="val-row">
<div class="lbl" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${p.cmd}</div>
<div class="val">${p.cpu}%</div>
</div>`
).join('');
}
}
function setBar(id, pct) {
const el = document.getElementById(id+'-bar');
if (!el) return;
el.style.width = Math.min(pct,100) + '%';
el.className = 'metric-bar-fill' + (pct>90?' danger':pct>75?' warn':'');
}
// ── RENDER: DO SERVER (site health only — metrics merged into system panel) ───
function renderDO(d) {
const dot = document.getElementById('bb-do-dot');
const status = document.getElementById('bb-do-status');
const sitesEl = document.getElementById('sites-list');
if (!d || d.error || !d.reachable) {
if (dot) dot.className = 'bb-dot offline';
if (status) status.textContent = 'OFFLINE';
document.getElementById('tb-do').className = 'text-red';
document.getElementById('tb-do').textContent = 'OFFLINE';
if (sitesEl) sitesEl.innerHTML = '<div class="text-dim" style="font-size:0.72rem">Unavailable</div>';
return;
}
dot.className = 'bb-dot online';
status.textContent = 'ONLINE';
document.getElementById('tb-do').className = 'text-green';
document.getElementById('tb-do').textContent = 'ONLINE';
if (sitesEl && d.sites && Object.keys(d.sites).length) {
sitesEl.innerHTML = Object.entries(d.sites).map(([k, v]) => {
const cls = v === 'up' ? 'ok' : v === 'down' ? 'danger' : 'warn';
const lbl = k.replace(/^https?:\/\//, '').replace(/\.orbishosting\.com$/, '').replace(/\.com$/, '');
return `<div class="val-row">
<div class="lbl" style="font-size:0.62rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${lbl}</div>
<div class="val ${cls}">${v.toUpperCase()}</div>
</div>`;
}).join('');
}
// WEB HOST (DO server agent metrics)
const ds = d.do_server || {};
const doStatus = document.getElementById('do-host-status');
const doCpu = document.getElementById('do-cpu');
const doMem = document.getElementById('do-mem');
const doDisk = document.getElementById('do-disk');
if (ds.online) {
if (doStatus) { doStatus.textContent = '●'; doStatus.style.color = 'var(--green)'; }
if (doCpu) doCpu.textContent = (ds.cpu || 0) + '%';
if (doMem) doMem.textContent = (ds.mem || 0) + '%';
if (doDisk) doDisk.textContent = (ds.disk || 0) + '%';
} else {
if (doStatus) { doStatus.textContent = '○'; doStatus.style.color = 'var(--red)'; }
}
}
async function loadNetwork() {
try {
const n = await api('network');
renderNetworkStatus(n);
} catch(e) {}
}
// ── RENDER: NETWORK ───────────────────────────────────────────────────
function renderNetworkStatus(n) {
if (!n) return;
renderTopology(n.devices || []);
const el = document.getElementById('network-list');
if (!el) return;
const devices = n.devices || [];
const online = devices.filter(d => d.alive || d.status === 'online').length;
const countEl = document.getElementById('net-agent-count');
if (countEl) countEl.textContent = online + '/' + devices.length + ' ONLINE';
const agents = devices.filter(d => d.source === 'agent');
const others = devices.filter(d => d.source !== 'agent');
function renderDev(d) {
const alive = d.alive || d.status === 'online';
const ctxKey = d.source === 'agent' ? 'agent_' + d.agent_id : 'net_' + (d.ip||'').replace(/\./g,'_');
_panelCtx[ctxKey] = {type: d.source === 'agent' ? 'agent' : 'network',
label: d.name || d.ip, ip: d.ip, status: d.status || (alive ? 'online' : 'offline'),
agent_id: d.agent_id, hostname: d.name};
const lat = d.latency_ms ? ' · ' + d.latency_ms + 'ms' : '';
const badge = d.source === 'agent'
? `<span style="font-size:0.53rem;color:var(--cyan);letter-spacing:1px;margin-left:4px">${(d.agent_type||'AGENT').toUpperCase()}</span>` : '';
const del = d.deletable
? `<button onclick="deleteNetworkDevice('${d.ip}',event)" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:0.9rem;padding:0 2px;opacity:0.5;flex-shrink:0" title="Remove">×</button>` : '';
const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : '';
return `<div class="device-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" style="${bl}display:flex;align-items:center">
<div class="device-status ${alive?'on':'off'}" style="flex-shrink:0"></div>
<div class="device-info" style="flex:1;min-width:0">
<div class="device-name" style="display:flex;align-items:center">${d.name||d.ip}${badge}</div>
<div class="device-ip">${d.ip||''}${lat}</div>
</div>${del}
</div>`;
}
let out = '';
if (agents.length) {
const agOn = agents.filter(d => d.alive || d.status === 'online').length;
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--cyan);letter-spacing:2px;padding:2px 0 3px">AGENTS (${agOn}/${agents.length})</div>`;
out += agents.map(renderDev).join('');
}
if (others.length) {
if (agents.length) out += '<div style="border-top:1px solid var(--panel-border);margin:5px 0 3px"></div>';
out += `<div style="font-family:var(--font-mono);font-size:0.53rem;color:var(--text-dim);letter-spacing:2px;padding:2px 0 3px">DEVICES</div>`;
out += others.map(renderDev).join('');
}
if (!out) out = '<div style="color:var(--text-dim);font-size:0.7rem;text-align:center;padding:8px">No devices</div>';
el.innerHTML = out;
}
// ── NETWORK SCAN ──────────────────────────────────────────────────────
async function scanNetwork() {
const btn = document.getElementById('scanBtn');
btn.textContent = 'QUEUING...';
btn.disabled = true;
try {
const data = await api('network/scan');
const count = data.count ?? 0;
const msg = data.queued
? `Network scan dispatched to PVE1 probe, Sir. Currently showing ${count} active device${count!==1?'s':''} — panel will refresh with live results in approximately 40 seconds.`
: `Showing last known network data: ${count} active device${count!==1?'s':''} on 10.48.200.0/24. PVE1 probe scans automatically every 3 minutes.`;
addMessage('jarvis', msg);
speak(count + ' devices online.');
// Refresh the network panel with current data
loadNetwork();
// Auto-refresh again after 45s to catch PVE1 scan results
if (data.queued) setTimeout(loadNetwork, 45000);
} catch(e) {
addMessage('jarvis', 'Network scan request failed, Sir.');
}
btn.textContent = 'RUN NETWORK SCAN';
btn.disabled = false;
}
// ── PROXMOX ───────────────────────────────────────────────────────────
async function loadProxmox() {
const data = await api('proxmox');
const el = document.getElementById('vm-list');
const dot = document.getElementById('bb-pve-dot');
const status = document.getElementById('bb-pve-status');
if (!data.configured) {
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
Set PROXMOX_HOST and PROXMOX_TOKEN_VAL in config.php to enable VM monitoring.
</div>`;
dot.className='bb-dot offline'; status.textContent='NOT CONFIGURED';
return;
}
dot.className='bb-dot online'; status.textContent='ONLINE';
const vms = [...(data.vms||[]), ...(data.containers||[])];
if (!vms.length) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No VMs found.</div>';
return;
}
el.innerHTML = vms.map(vm => {
const statusColor = vm.status==='running'?'var(--green)':vm.status==='stopped'?'var(--red)':'var(--yellow)';
const cpuClass = vm.cpu>80?'text-red':vm.cpu>60?'text-orange':'text-cyan';
const ctxKey = 'vm_' + vm.vmid;
_panelCtx[ctxKey] = {type:'vm', label:vm.name,
vmid:vm.vmid, name:vm.name, status:vm.status,
cpu:vm.cpu, mem_mb:vm.mem_mb, maxmem_mb:vm.maxmem_mb,
type_label:vm.type||'qemu', uptime:vm.uptime||0};
return `<div class="vm-card" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this VM">
<div class="vm-header">
<span class="vm-name">${vm.name}</span>
<span style="color:${statusColor};font-size:0.65rem">● ${(vm.status||'').toUpperCase()}</span>
</div>
<div class="vm-metrics">
<div class="vm-metric">CPU <span class="${cpuClass}">${vm.cpu}%</span></div>
<div class="vm-metric">RAM <span class="text-cyan">${vm.mem_mb||0}/${vm.maxmem_mb||0}MB</span></div>
<div class="vm-metric">ID <span class="text-dim">${vm.vmid}</span></div>
<div class="vm-metric">TYPE <span class="text-dim">${vm.type||'qemu'}</span></div>
</div>
</div>`;
}).join('');
}
// ── HOME ASSISTANT ────────────────────────────────────────────────────
async function loadHA() {
const data = await api('ha');
const el = document.getElementById('ha-list');
const dot = document.getElementById('bb-ha-dot');
const sta = document.getElementById('bb-ha-status');
if (!data.configured) {
el.innerHTML = `<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text-dim);line-height:1.5">
<div class="text-yellow" style="margin-bottom:8px">⚠ NOT CONFIGURED</div>
Set HA_URL and HA_TOKEN in config.php to enable smart home control.
</div>`;
dot.className='bb-dot offline'; sta.textContent='NOT CONFIGURED';
return;
}
dot.className='bb-dot online'; sta.textContent='ONLINE';
const entities = data.entities || {};
_haEntities = entities;
if (!Object.keys(entities).length) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem">No entities found.</div>';
return;
}
renderHATable(entities);
}
const _domainIcon = {
light:'\u{1F4A1}', switch:'\u{1F50C}', scene:'\u{1F3AC}',
media_player:'\u{1F4FA}', alarm_control_panel:'\u{1F512}',
lawn_mower:'\u{1F33F}', water_heater:'\u{1F321}', fan:'\u{1F4A8}',
lock:'\u{1F511}', cover:'\u{1FA9F}', climate:'☃', input_boolean:'⚙'
};
function renderHATable(entities) {
const el = document.getElementById('ha-list');
if (!el) return;
let rows = '';
let totalShown = 0;
for (const [domain, items] of Object.entries(entities)) {
const icon = _domainIcon[domain] || '•';
const available = items.filter(e => e.state !== 'unavailable' && e.state !== 'unknown');
if (!available.length) continue;
available.forEach(e => {
totalShown++;
const isOn = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night'].includes(e.state);
const isScene = domain === 'scene';
const ctxKey = 'ha_' + e.entity_id.replace(/[^a-z0-9]/gi,'_');
_panelCtx[ctxKey] = {type:'ha', label:e.name,
entity_id:e.entity_id, name:e.name, state:e.state, domain:domain};
const stateLabel = isScene ? '—' : (isOn ? 'ON' : 'OFF');
const stateClass = isOn ? 'on' : 'off';
const eid = e.entity_id.replace(/'/g,"\\'");
const ctrl = isScene
? `<button class="ha-scene-btn" onclick="toggleHA('${eid}','${domain}','${e.state}')">▶ RUN</button>`
: `<label class="ha-toggle"><input type="checkbox"${isOn?' checked':''} onchange="toggleHA('${eid}','${domain}','${e.state}')"><span class="ha-slider"></span></label>`;
rows += `<tr class="ha-row">
<td class="ha-col-domain" title="${domain}">${icon}</td>
<td class="ha-col-name" title="${e.name}">${e.name}</td>
<td class="ha-col-state ${stateClass}">${stateLabel}</td>
<td class="ha-col-ctrl">${ctrl}</td>
</tr>`;
});
}
if (!totalShown) {
el.innerHTML = '<div class="text-dim" style="font-size:0.75rem;margin-top:8px">No available entities.</div>';
return;
}
el.innerHTML = `<table class="ha-table"><thead class="ha-thead"><tr>
<th></th><th>DEVICE</th><th>STATE</th><th>CTRL</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
async function toggleHA(entityId, domain, currentState) {
let service;
const ON_STATES = ['on','home','open','locked','playing','mowing','armed_home','armed_away','armed_night','active'];
const wasOn = ON_STATES.includes(currentState);
if (domain === 'scene') {
service = 'turn_on';
} else if (domain === 'alarm_control_panel') {
service = currentState === 'disarmed' ? 'alarm_arm_away' : 'alarm_disarm';
} else {
service = wasOn ? 'turn_off' : 'turn_on';
}
try {
await api('ha/service', 'POST', {domain, service, entity_id: entityId});
// Optimistic update — flip state immediately so toggle doesn't snap back
if (_haEntities[domain]) {
const ent = _haEntities[domain].find(e => e.entity_id === entityId);
if (ent && domain !== 'scene') ent.state = wasOn ? 'off' : 'on';
}
renderHATable(_haEntities);
// Full sync after 4s — HA executes + agent pushes new state
setTimeout(loadHA, 4000);
} catch(e) {}
}
// ── PROACTIVE REMINDERS ──────────────────────────────────────────────────────
let _reminderShown = false;
async function checkPlannerReminder() {
if (_reminderShown || sessionStorage.getItem('reminderShown')) return;
_reminderShown = true;
sessionStorage.setItem('reminderShown', '1');
const d = await api('planner/today').catch(() => null);
if (!d) return;
const tasks = [...(d.tasks_overdue||[]), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
const overdue = d.tasks_overdue?.length || 0;
if (!tasks.length && !appts.length) return;
const parts = [];
if (overdue) parts.push(overdue + ' overdue task' + (overdue > 1 ? 's' : ''));
if (tasks.length - overdue > 0) parts.push((tasks.length - overdue) + ' task' + (tasks.length - overdue > 1 ? 's' : '') + ' due today');
if (appts.length) {
const nextAppt = appts[0];
const t = nextAppt.start_at ? new Date(nextAppt.start_at).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}) : '';
parts.push((t ? 'appointment at ' + t : appts.length + ' appointment' + (appts.length > 1 ? 's' : '') + ' today'));
}
const msg = 'Heads up, ' + (sessionUser||'Sir') + '. You have ' + parts.join(' and ') + '.';
addMessage('jarvis', msg);
if (typeof speak === 'function' && isVoiceActive) speak(msg);
}
// Check for upcoming appointments (fires every 5 min after load)
let _apptAlerted = new Set();
async function checkUpcomingAppts() {
const d = await api('planner/today').catch(() => null);
if (!d) return;
const now = Date.now();
for (const a of (d.appts_today||[])) {
if (!a.start_at || _apptAlerted.has(a.id)) continue;
const start = new Date(a.start_at).getTime();
const minsUntil = (start - now) / 60000;
if (minsUntil > 0 && minsUntil <= 15) {
_apptAlerted.add(a.id);
const msg = 'Reminder: ' + a.title + ' starts in ' + Math.round(minsUntil) + ' minutes' + (a.location ? ' at ' + a.location : '') + '.';
addMessage('jarvis', msg);
if (typeof speak === 'function' && isVoiceActive) speak(msg);
}
}
}
// ── PLANNER SUMMARY (top bar badge only) ─────────────────────────────────
async function loadPlannerSummary() {
const d = await api('planner/today');
const el = document.getElementById('tb-planner');
const tx = document.getElementById('tb-planner-text');
if (el && tx) {
const tasksDue = (d.tasks_today || []).length + (d.tasks_overdue || []).length;
const appts = (d.appts_today || []).length;
if (!tasksDue && !appts) { el.style.display = 'none'; }
else {
const parts = [];
if (tasksDue) parts.push(tasksDue + ' TASK' + (tasksDue > 1 ? 'S' : ''));
if (appts) parts.push(appts + ' APPT' + (appts > 1 ? 'S' : ''));
tx.textContent = parts.join(' · ');
el.style.display = '';
}
}
// Render planner mini panel
const pEl = document.getElementById('planner-tasks');
const badge = document.getElementById('planner-badge');
if (!pEl) return;
const priClass = {urgent:'pri-urgent',high:'pri-high',normal:'pri-normal',low:'pri-low'};
const fmtTime = s => { if(!s) return ''; const d=new Date(s); return d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); };
const fmtDate = s => { if(!s) return ''; const d=new Date(s+'T00:00:00'); return d.toLocaleDateString('en-US',{month:'short',day:'numeric'}); };
const tasks = [...(d.tasks_overdue||[]).map(t=>({...t,_overdue:true})), ...(d.tasks_today||[])];
const appts = d.appts_today || [];
let html = '';
if (!tasks.length && !appts.length) {
html = '<div style="color:var(--text-dim);font-size:0.6rem;padding:4px 0">No tasks or appointments today.</div>';
} else {
if (appts.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin-bottom:3px">TODAY\'S SCHEDULE</div>';
html += appts.map(a => `<div class="appt-row"><span class="appt-time">${fmtTime(a.start_at)}</span><span>${a.title}</span>${a.location?'<span style="color:var(--text-dim);font-size:0.55rem"> · '+a.location+'</span>':''}</div>`).join('');
}
if (tasks.length) {
html += '<div style="color:var(--cyan);font-size:0.55rem;letter-spacing:2px;margin:5px 0 3px">TASKS DUE</div>';
html += tasks.map(t => `<div class="task-item"><span class="pri-dot ${priClass[t.priority]||'pri-normal'}"></span><span style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.title}</span>${t._overdue?'<span style="color:#f66;font-size:0.55rem">OVERDUE</span>':''}</div>`).join('');
}
if (d.pending_count > tasks.length) {
html += `<div style="color:var(--text-dim);font-size:0.55rem;padding:3px 0">${d.pending_count} pending total</div>`;
}
}
pEl.innerHTML = html;
const total = tasks.length + appts.length;
if (badge) badge.textContent = total ? total + ' TODAY' : '';
}
// ── ALERTS ────────────────────────────────────────────────────────────
async function loadAlerts() {
const data = await api('alerts');
const el = document.getElementById('alerts-list');
const tb = document.getElementById('tb-alerts');
const alerts = data.alerts || [];
if (!alerts.length) {
el.innerHTML = '<div style="font-family:var(--font-mono);font-size:0.75rem;color:var(--green);text-align:center;margin-top:20px">✓ NO ACTIVE ALERTS</div>';
tb.textContent='NO ALERTS'; tb.className='text-green';
setAlertState(false);
setSystemHealth('ok');
return;
}
tb.textContent=alerts.length+' ALERT'+(alerts.length>1?'S':'');
tb.className='text-red';
setAlertState(true);
const hasCritical = alerts.some(a => a.severity === 'critical');
setSystemHealth(hasCritical ? 'critical' : 'warning');
el.innerHTML = alerts.map(a => {
const ctxKey = 'alert_' + a.id;
_panelCtx[ctxKey] = {type:'alert', label:a.title,
id:a.id, title:a.title, message:a.message, severity:a.severity};
return `<div class="alert-item ${a.severity}" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this alert">
<div style="flex:1">
<div style="font-family:var(--font-mono);font-size:0.72rem;color:var(--text)">${a.title}</div>
<div style="font-size:0.65rem;color:var(--text-dim)">${a.message}</div>
</div>
<button onclick="event.stopPropagation();resolveAlert(${a.id})" style="background:none;border:1px solid var(--dim);border-radius:3px;color:var(--text-dim);font-size:0.6rem;padding:2px 6px;cursor:pointer">✓</button>
</div>`;
}).join('');
}
async function resolveAlert(id) {
await api('alerts/resolve', 'POST', {id});
loadAlerts();
}
// ── PROACTIVE ALERT POLLING ───────────────────────────────────────────────────
let _knownAlertIds = null;
let _spokenAlertIds = new Set();
async function pollAlertsProactive() {
const data = await api('alerts').catch(() => null);
if (!data) return;
const alerts = (data.alerts || []);
if (_knownAlertIds === null) {
// First run: baseline existing alerts — do not speak them
_knownAlertIds = new Set(alerts.map(a => a.id));
return;
}
for (const a of alerts) {
if (_knownAlertIds.has(a.id) || _spokenAlertIds.has(a.id)) continue;
_knownAlertIds.add(a.id);
_spokenAlertIds.add(a.id);
if (a.severity === 'critical' || a.severity === 'warning') {
const prefix = a.severity === 'critical' ? '🚨' : '⚠';
addMessage('jarvis', `${prefix} ${a.title}: ${a.message}`);
const tts = (a.severity === 'critical' ? 'Critical alert. ' : 'Warning. ') + a.title + '. ' + a.message;
if (typeof speak === 'function' && isVoiceActive) speak(tts);
}
}
// Remove resolved alerts from known set so they can re-trigger if they come back
const liveIds = new Set(alerts.map(a => a.id));
for (const id of _knownAlertIds) {
if (!liveIds.has(id)) _knownAlertIds.delete(id);
}
}
// ── WEATHER ───────────────────────────────────────────────────────────
async function loadWeather() {
const d = await api('weather');
if (!d || !d.current) return;
const c = d.current;
document.getElementById('weather-temp').textContent = c.temp;
document.getElementById('weather-desc').textContent = (c.desc || '').toUpperCase();
document.getElementById('weather-feels').textContent = c.feels + '°F';
document.getElementById('weather-humidity').textContent = c.humidity + '%';
document.getElementById('weather-details').textContent =
'Wind ' + c.wind + ' mph · Cloud ' + c.cloud + '% · Vis ' + c.vis + ' mi';
const fc = d.forecast || [];
document.getElementById('weather-forecast').innerHTML = fc.slice(0, 4).map(day => `
<div class="forecast-card">
<div class="fc-day">${day.day}</div>
<div class="fc-icon" style="font-size:0.55rem;color:var(--cyan);padding:3px 0;line-height:1.3">${day.icon}</div>
<div class="fc-temps">${day.high}&deg;<span style="color:var(--text-dim)">${day.low}&deg;</span></div>
<div class="fc-rain">${day.rain_pct > 0 ? day.rain_pct+'%' : ''}</div>
</div>`).join('');
}
// ── NEWS ──────────────────────────────────────────────────────────────
function getNewsHidden() {
try { return JSON.parse(localStorage.getItem('news_hidden_cats') || '[]'); } catch(e) { return []; }
}
function setNewsHidden(arr) { localStorage.setItem('news_hidden_cats', JSON.stringify(arr)); }
function toggleNewsFilter() {
const panel = document.getElementById('news-filter-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
let _newsCats = [];
async function loadNews() {
const d = await api('news');
const el = document.getElementById('news-list');
if (!d || !d.categories || Object.keys(d.categories).length === 0) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">News loading...</div>';
return;
}
const catLabels = { headlines: '📰 TOP HEADLINES', technology: '💻 TECHNOLOGY', pinned: '📌 JARVIS PINNED' };
const hidden = getNewsHidden();
_newsCats = Object.keys(d.categories);
// Build filter checkboxes
const cbContainer = document.getElementById('news-filter-checkboxes');
if (cbContainer) {
cbContainer.innerHTML = _newsCats.map(cat => `
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:0.6rem;color:var(--text-primary)">
<input type="checkbox" ${hidden.includes(cat) ? '' : 'checked'} onchange="toggleNewsCat('${cat}',this.checked)"
style="accent-color:var(--cyan)"/>
${catLabels[cat] || cat.toUpperCase()}
</label>`).join('');
}
let html = '';
for (const [cat, articles] of Object.entries(d.categories)) {
if (!articles.length || hidden.includes(cat)) continue;
html += `<div class="news-cat-header">${catLabels[cat] || cat.toUpperCase()}</div>`;
for (const a of articles.slice(0, 5)) {
const ctxKey = 'news_' + (cat + '_' + a.title).replace(/[^a-z0-9]/gi,'').slice(0,30);
_panelCtx[ctxKey] = {type:'news', label:a.title,
title:a.title, source:a.source, pub:a.pub||'', category:cat};
html += `<div class="news-item" data-ctx-key="${ctxKey}" onclick="selectContext('${ctxKey}')" title="Click to ask Jarvis about this story">
<div class="news-source">${a.source}</div>
<div class="news-title">${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}</div>
${a.pub ? '<div class="news-time">' + a.pub + '</div>' : ''}
</div>`;
}
}
if (!html) html = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">All categories hidden — use ⚙ to show sources</div>';
const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0;
html += `<div style="font-size:0.5rem;color:var(--text-dim);text-align:right;margin-top:8px">Updated ${ageMin}m ago</div>`;
el.innerHTML = html;
}
function toggleNewsCat(cat, show) {
const hidden = getNewsHidden();
if (show) {
const idx = hidden.indexOf(cat);
if (idx > -1) hidden.splice(idx, 1);
} else {
if (!hidden.includes(cat)) hidden.push(cat);
}
setNewsHidden(hidden);
loadNews();
}
// ── TABS ──────────────────────────────────────────────────────────────
function switchTab(name) {
if (name === 'sites') { openSitesModal(); return; }
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
event.target.classList.add('active');
const pane = document.getElementById('tab-'+name);
if (pane) pane.classList.add('active');
if (name === 'news') loadNews();
if (name === 'agents') loadAgents();
if (name === 'intel') loadIntel();
if (name === 'comms') { loadComms(); loadCommsOutbox(); }
if (name === 'guardian') loadGuardian();
if (name === 'missions') loadMissionsHud();
if (name === 'directives') loadDirectivesHud();
if (name === 'clearance') loadClearanceHud();
if (name === 'alerts') loadAlerts();
}
// ── CHAT ──────────────────────────────────────────────────────────────
function sourceBadge(source) {
if (!source) return '';
let cls, label;
if (/^intent:|^planner:|^kb:/.test(source)) { cls = 'kb'; label = 'KB'; }
else if (/^groq:/.test(source)) { cls = 'groq'; label = 'GROQ'; }
else if (source === 'claude' || /^claude/.test(source)) { cls = 'claude'; label = 'CLAUDE'; }
else if (/^ollama/.test(source)) { cls = 'ollama'; label = 'LOCAL AI'; }
else return '';
const s = document.createElement('div');
s.style.cssText = 'margin-top:4px;text-align:right';
s.innerHTML = `<span class="tier-badge ${cls}">${label}</span>`;
return s;
}
function addMessage(role, text, source=null) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg ' + role;
log.appendChild(div);
if (role === 'jarvis' && text && text.length > 0) {
// Adaptive speed: fast for short, slower for long (feels intentional either way)
const msPerChar = Math.max(9, Math.min(25, 1600 / text.length));
const cursor = document.createElement('span');
cursor.className = 'type-cursor';
div.appendChild(cursor);
let i = 0;
const type = () => {
if (i < text.length) {
cursor.insertAdjacentText('beforebegin', text[i++]);
log.scrollTop = log.scrollHeight;
setTimeout(type, msPerChar + (text[i-1] === '.' || text[i-1] === ',' ? msPerChar * 4 : 0));
} else {
cursor.remove();
const badge = sourceBadge(source);
if (badge) div.appendChild(badge);
}
};
setTimeout(type, 0);
} else {
div.textContent = text;
}
log.scrollTop = log.scrollHeight;
return div;
}
function showThinking() {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg jarvis';
div.innerHTML = '<div class="thinking"><div class="thinking-dot"></div><div class="thinking-dot"></div><div class="thinking-dot"></div></div><button class="thinking-cancel" onclick="cancelRequest()">✕ CANCEL</button>';
div.id = 'thinking-bubble';
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
// ── PANEL CONTEXT SELECTION ───────────────────────────────────────────
function selectContext(key) {
const ctx = _panelCtx[key];
if (!ctx) return;
// Clear previous active highlight
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
selectedContext = ctx;
// Highlight clicked element
const el = document.querySelector('[data-ctx-key="' + key + '"]');
if (el) el.classList.add('ctx-active');
// Show chip
const chip = document.getElementById('contextChip');
const typeLabels = {vm:'VM', network:'DEVICE', alert:'ALERT', news:'NEWS', ha:'HOME'};
document.getElementById('contextType').textContent = typeLabels[ctx.type] || ctx.type.toUpperCase();
document.getElementById('contextLabel').textContent = ctx.label;
chip.classList.add('visible');
// Focus input for immediate question
document.getElementById('textInput').focus();
}
function clearContext() {
selectedContext = null;
document.querySelectorAll('.ctx-active').forEach(el => el.classList.remove('ctx-active'));
const chip = document.getElementById('contextChip');
chip.classList.remove('visible');
}
async function sendMessage() {
const input = document.getElementById('textInput');
const text = input.value.trim();
if (!text) return;
var t2 = text.toLowerCase();
if (SLEEP_CMDS.test(t2)) { input.value=''; addMessage('user',text); enterSleepMode(); return; }
if (NM_OPEN_RE.test(t2)) {
input.value=''; addMessage('user',text);
addMessage('jarvis','Launching network topology display.');
speak('Launching network topology display.'); openNetMap(); return;
}
if (NM_CLOSE_RE.test(t2)) {
input.value=''; addMessage('user',text);
var isOpen=document.getElementById('netMapOverlay')?.classList.contains('nm-open');
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
else addMessage('jarvis','Network map is not currently active.');
return;
}
input.value = '';
addMessage('user', text);
showThinking();
_abortController = new AbortController();
try {
const payload = {message:text, session_id:sessionId, stream:true};
if (selectedContext) { payload.context = selectedContext; clearContext(); }
const resp = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Session-Token':sessionToken},
body: JSON.stringify(payload),
signal: _abortController.signal,
credentials: 'include',
});
_abortController = null;
if (resp.status === 401) { logout(); return; }
const ct = resp.headers.get('Content-Type') || '';
if (ct.includes('text/event-stream')) {
// ── Streaming path (Groq LLM with token-by-token delivery) ──────
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
let msgEl = null, accum = '';
const reader = resp.body.getReader();
const dec = new TextDecoder();
let lineBuf = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
lineBuf += dec.decode(value, {stream:true});
const lines = lineBuf.split('\n');
lineBuf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let ev; try { ev = JSON.parse(line.slice(6)); } catch { continue; }
if (ev.type === 'token') {
accum += ev.token;
if (!msgEl) msgEl = _addStreamingMsg(accum);
else _updateStreamingMsg(msgEl, accum);
} else if (ev.type === 'complete') {
const finalText = ev.reply || accum;
if (msgEl) _finalizeStreamingMsg(msgEl, finalText, ev.source);
else addMessage('jarvis', finalText, ev.source);
speak(finalText);
if (ev.open_network_map) openNetMap();
if (ev.ui_action === 'focus_mode' && panelsVisible) togglePanels(true);
if (ev.ui_action === 'show_panels' && !panelsVisible) togglePanels(true);
if (ev.arc_job) onArcJobStarted(ev.arc_job, ev.source||'');
}
}
}
} else {
// ── Regular JSON path (intent/KB — near-instant) ────────────────
const data = await resp.json();
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
if (data.reply) { addMessage('jarvis', data.reply, data.source||null); speak(data.reply); }
if (data.open_network_map) openNetMap();
if (data.ui_action === 'focus_mode' && panelsVisible) togglePanels(true);
if (data.ui_action === 'show_panels' && !panelsVisible) togglePanels(true);
if (data.arc_job) onArcJobStarted(data.arc_job, data.source||'');
}
} catch(e) {
_abortController = null;
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
if (e.name === 'AbortError') addMessage('jarvis', 'Request cancelled, Sir.');
else addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.');
}
}
function _addStreamingMsg(text) {
const log = document.getElementById('chatLog');
const div = document.createElement('div');
div.className = 'msg jarvis streaming';
div.id = 'streaming-bubble';
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
return div;
}
function _updateStreamingMsg(el, text) {
if (!el) return;
el.textContent = text;
const log = document.getElementById('chatLog');
if (log) log.scrollTop = log.scrollHeight;
}
function _finalizeStreamingMsg(el, text, source) {
if (!el) return;
el.id = ''; el.classList.remove('streaming');
el.textContent = text;
if (source) {
const s = document.createElement('div');
s.className = 'msg-source'; s.textContent = source;
el.appendChild(s);
}
}
function cancelRequest() {
if (_abortController) { _abortController.abort(); _abortController = null; }
}
// ── VOICE RECOGNITION ─────────────────────────────────────────────────
function initVoice() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
if (window.isSecureContext === false) {
console.warn('Speech Recognition blocked: not a secure context');
} else {
console.warn('Speech Recognition not supported in this browser');
}
return;
}
recognition = new SR();
recognition.continuous = false; // restart-per-utterance — most reliable in Chrome
recognition.interimResults = false;
recognition.lang = 'en-US';
recognition.maxAlternatives = 1;
recognition.onresult = (e) => {
if (isSpeaking) return;
let interimText = '';
for (let ri = e.resultIndex; ri < e.results.length; ri++) {
if (!e.results[ri].isFinal) interimText += e.results[ri][0].transcript;
}
if (interimText && voiceMode && !voiceMuted) _showInterimTranscript(interimText);
const transcript = (e.results[0][0].transcript || '').trim();
if (!transcript) return;
const lc = transcript.toLowerCase();
// Sleeping: ONLY respond to master wake phrases
if (isAsleep) {
if (WAKE_PHRASES.some(p => lc.includes(p))) wakeFromSleep();
return;
}
if (!voiceMode) {
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
} else if (!voiceMuted) {
voiceLastCmd = Date.now();
voiceActive = Date.now();
const cmd = lc.startsWith(CMD_PREFIX)
? transcript.substring(CMD_PREFIX.length).trim()
: transcript;
if (cmd) {
// Check for sleep command by voice
if (SLEEP_CMDS.test(cmd)) {
addMessage('user', transcript);
enterSleepMode();
return;
}
_showTranscript(cmd);
document.getElementById('textInput').value = cmd;
sendMessage();
}
}
};
recognition.onend = () => {
// Restart immediately unless TTS is playing or mic is off
if (isListening && !isSpeaking) {
_scheduleRecStart(100);
}
};
recognition.onerror = (e) => {
if (e.error === 'not-allowed') {
isListening = false;
updateMicBtn();
addMessage('system', 'Microphone access denied. Please allow microphone permission in your browser, then reload.');
} else if (e.error === 'audio-capture') {
isListening = false;
updateMicBtn();
addMessage('system', 'No microphone detected. Please connect a microphone and try again.');
}
// no-speech / aborted / network: onend will fire and restart
};
}
let _transcriptTimer = null;
function _showTranscript(text) {
const el = document.getElementById('textInput');
if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); }
const bar = document.getElementById('voiceTranscriptBar');
if (!bar) return;
bar.textContent = text;
bar.classList.add('vt-active');
if (_transcriptTimer) clearTimeout(_transcriptTimer);
_transcriptTimer = setTimeout(() => { bar.classList.remove('vt-active'); bar.textContent = ''; }, 3200);
}
function _showInterimTranscript(text) {
const bar = document.getElementById('voiceTranscriptBar');
if (!bar || !text) return;
bar.textContent = text + '…';
bar.classList.add('vt-active');
if (_transcriptTimer) clearTimeout(_transcriptTimer);
}
function enterVoiceMode(source) {
voiceMode = true;
voiceMuted = false;
voiceLastCmd = Date.now();
voiceActive = Date.now();
updateMicBtn();
// Focus/notify when woken from minimized or sleep
_focusWindow();
if (source === 'wake') {
const g = 'All systems back online, ' + (sessionUser || 'Sir') + '. Good to have you back.';
addMessage('jarvis', g);
speak(g);
} else {
speak('Yes, ' + (sessionUser || 'Sir') + '?');
}
}
function exitVoiceMode() {
if (document.body.classList.contains('kiosk-mode')) return;
voiceMode = false;
voiceMuted = false;
updateMicBtn();
}
function updateMicBtn() {
const btn = document.getElementById('micBtn');
const icon = document.getElementById('micIcon');
const wave = document.getElementById('waveform');
if (!btn) return;
if (!voiceMode) {
btn.classList.remove('listening', 'muted');
btn.title = 'Click to activate, or say: wake up JARVIS / daddy\'s home';
icon.textContent = '🎤';
wave.classList.remove('active');
} else if (voiceMuted) {
btn.classList.remove('listening');
btn.classList.add('muted');
btn.title = 'Muted — click to unmute';
icon.textContent = '🔇';
wave.classList.remove('active');
} else {
btn.classList.add('listening');
btn.classList.remove('muted');
btn.title = 'Listening — click to mute';
icon.textContent = '🟢';
wave.classList.add('active');
}
}
function toggleVoice() {
if (!voiceMode) {
enterVoiceMode();
} else {
voiceMuted = !voiceMuted;
if (!voiceMuted) voiceLastCmd = Date.now();
updateMicBtn();
}
}
let _recTimer = null;
function _scheduleRecStart(ms = 100) {
clearTimeout(_recTimer);
_recTimer = setTimeout(() => {
if (isListening && !isSpeaking) {
try { recognition.start(); } catch(_) {}
}
}, ms);
}
function startListening() {
if (!recognition) {
if (!window.isSecureContext) {
addMessage('system', 'Voice recognition requires a trusted HTTPS connection. Please access JARVIS via https://jarvis.orbishosting.com for voice support.');
} else {
addMessage('system', 'Voice recognition requires Chrome or Edge browser.');
}
return;
}
isListening = true;
_startWaveform();
_scheduleRecStart(50);
}
function stopListening() {
isListening = false;
voiceMode = false;
voiceMuted = false;
updateMicBtn();
clearTimeout(_recTimer);
_stopWaveform();
try { recognition.abort(); } catch(_) {}
}
// ── VOICE WAVEFORM (Web Audio API) ──────────────────────────────────────────
async function _startWaveform() {
if (_waveAudioCtx) return;
try {
_waveStream = await navigator.mediaDevices.getUserMedia({audio:true, video:false});
_waveAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
_waveAnalyser = _waveAudioCtx.createAnalyser();
_waveAnalyser.fftSize = 32;
_waveAudioCtx.createMediaStreamSource(_waveStream).connect(_waveAnalyser);
const bars = document.querySelectorAll('#waveform .wave-bar');
bars.forEach(b => b.classList.add('live'));
const buf = new Uint8Array(_waveAnalyser.frequencyBinCount);
(function drawWave() {
_waveRafId = requestAnimationFrame(drawWave);
_waveAnalyser.getByteFrequencyData(buf);
bars.forEach((bar, i) => {
const v = (buf[i % buf.length] || 0) / 255;
bar.style.height = (4 + Math.round(v * 20)) + 'px';
});
})();
} catch(_) { /* mic permission denied — CSS animation continues */ }
}
function _stopWaveform() {
if (_waveRafId) { cancelAnimationFrame(_waveRafId); _waveRafId = null; }
if (_waveStream) { _waveStream.getTracks().forEach(t => t.stop()); _waveStream = null; }
if (_waveAudioCtx) { _waveAudioCtx.close().catch(()=>{}); _waveAudioCtx = null; }
_waveAnalyser = null;
document.querySelectorAll('#waveform .wave-bar').forEach(b => {
b.classList.remove('live'); b.style.height = '';
});
}
// ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
function loadVoices() {
const set = () => {
const voices = synth.getVoices();
// Priority: Australian male → Australian → British male → British → any English
selectedVoice =
voices.find(v => v.name === 'Nathan') // macOS Australian male
|| voices.find(v => v.name === 'Google Australian English') // Chrome Australian
|| voices.find(v => v.name === 'Karen') // macOS Australian female
|| voices.find(v => v.lang === 'en-AU') // any Australian
|| voices.find(v => v.name === 'Daniel') // macOS British male
|| voices.find(v => v.name === 'Google UK English Male') // Chrome British male
|| voices.find(v => v.lang === 'en-GB') // any British
|| voices.find(v => v.lang.startsWith('en')) // any English
|| voices[0]
|| null;
};
set();
synth.onvoiceschanged = set;
}
let _ttsAudio = null;
let _abortController = null;
let _waveAudioCtx = null;
let _waveAnalyser = null;
let _waveStream = null;
let _waveRafId = null;
async function speak(text) {
if (!text) return;
if (_ttsAudio) { _ttsAudio.pause(); _ttsAudio = null; }
synth?.cancel();
isSpeaking = true;
// Pause recognition while JARVIS speaks to avoid mic feedback
try { recognition?.abort(); } catch(_) {}
const reactor = document.getElementById('arcReactor');
reactor?.classList.add('speaking');
const _resumeMic = () => {
isSpeaking = false;
reactor?.classList.remove('speaking');
// onend will fire from the abort we did before TTS, and restart cleanly
if (isListening) _scheduleRecStart(900);
};
try {
const res = await fetch('/api/tts', {
method: 'POST',
headers: {'Content-Type':'application/json','X-Session-Token': sessionToken},
body: JSON.stringify({text: text.substring(0, 400)}),
});
if (!res.ok) throw new Error('tts');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
_ttsAudio = new Audio(url);
_ttsAudio.onended = () => { URL.revokeObjectURL(url); _ttsAudio = null; _resumeMic(); };
_ttsAudio.onerror = () => { _ttsAudio = null; _resumeMic(); };
await _ttsAudio.play();
} catch(e) {
_resumeMic();
_speakFallback(text);
}
}
function _speakFallback(text) {
if (!synth || !text) return;
synth.cancel();
isSpeaking = true;
const utter = new SpeechSynthesisUtterance(text);
if (selectedVoice) utter.voice = selectedVoice;
utter.rate = 0.92; utter.pitch = 0.85; utter.volume = 1;
const reactor = document.getElementById('arcReactor');
utter.onstart = () => reactor?.classList.add('speaking');
utter.onend = () => {
reactor?.classList.remove('speaking');
isSpeaking = false;
if (isListening) _scheduleRecStart(900);
};
synth.speak(utter);
}
// ── AGENT DETECTION & BROWSER INSTALL ─────────────────────────────────
let _agentOnline = false;
let _myAgent = null;
function detectOS() {
const ua = navigator.userAgent;
const p = (navigator.platform || '').toLowerCase();
// Tablets — check before desktop OS (iPads spoof MacIntel)
if (/iPad|Android/.test(ua) || (p.includes('mac') && navigator.maxTouchPoints > 1)) return 'tablet';
if (/iPhone/.test(ua)) return 'tablet';
if (p.includes('win') || ua.includes('Windows')) return 'windows';
if (p.includes('mac') || ua.includes('Macintosh')) return 'mac';
if (p.includes('linux') || ua.includes('Linux')) return 'linux';
return 'unknown';
}
async function checkAgentStatus() {
const dot = document.getElementById('bb-agent-dot');
const sta = document.getElementById('bb-agent-status');
const btn = document.getElementById('agentBtn');
if (!dot || !sta) return;
try {
const data = await api('agent/list');
const agents = data.agents || [];
const online = agents.filter(a => a.status === 'online');
dot.className = 'bb-dot ' + (online.length > 0 ? 'online' : 'offline');
sta.textContent = online.length > 0 ? online.length + ' ONLINE' : 'NONE';
const cnt = document.getElementById('net-agent-count');
if (cnt) cnt.textContent = online.length + ' AGENT' + (online.length !== 1 ? 'S' : '') + ' ONLINE';
const myIp = data.my_ip || '';
// Match by exact IP first, then by same /24 subnet (handles NAT behind same router)
const mySubnet = myIp.split('.').slice(0,3).join('.');
_myAgent = online.find(a => a.ip_address === myIp)
|| online.find(a => a.ip_address && a.ip_address.startsWith(mySubnet + '.'));
_agentOnline = !!_myAgent;
if (btn) {
const isTablet = detectOS() === 'tablet';
if (isTablet) {
btn.title = 'JARVIS Agent — not available for tablets';
btn.style.opacity = '0.5';
} else if (_agentOnline) {
btn.classList.add('agent-online');
btn.title = 'Agent active: ' + _myAgent.hostname;
} else {
btn.classList.remove('agent-online');
btn.title = 'Click to install JARVIS Agent on this machine';
}
}
// Also refresh the AGENTS tab if it's visible
if (document.getElementById('tab-agents').classList.contains('active')) {
renderAgentsTab(agents, data.metrics || {});
}
} catch(e) {
if (dot) dot.className = 'bb-dot offline';
if (sta) sta.textContent = 'ERROR';
}
}
// ── SMART MORNING BRIEFING ─────────────────────────────────────────────────
async function triggerMorningBriefing() {
try {
const [planner, alerts, weather] = await Promise.all([
api('planner/today').catch(() => null),
api('alerts').catch(() => null),
api('weather').catch(() => null),
]);
const tasks = (planner?.tasks || []).filter(t => t.status !== 'done');
const appts = planner?.appointments || [];
const active = (alerts?.alerts || alerts || []).filter(a =>
a.severity === 'critical' || a.severity === 'warning');
const temp = weather?.current?.temp_f ?? weather?.current?.temp ?? null;
const cond = weather?.current?.condition?.text ?? weather?.current?.description ?? null;
const parts = [];
if (tasks.length > 0) parts.push(`${tasks.length} task${tasks.length > 1 ? 's' : ''} due today`);
if (appts.length > 0) parts.push(`${appts.length} appointment${appts.length > 1 ? 's' : ''} on the calendar`);
if (active.length > 0) parts.push(`${active.length} active alert${active.length > 1 ? 's' : ''} requiring attention`);
if (temp !== null) parts.push(`currently ${Math.round(temp)}°${cond ? ' and ' + cond.toLowerCase() : ''}`);
const name = sessionUser || 'sir';
const msg = parts.length > 0
? `Good morning, ${name}. ${parts.join(', ')}. Systems nominal — ready when you are.`
: `Good morning, ${name}. No tasks or alerts today — clear skies ahead. All systems nominal.`;
addMessage('jarvis', msg);
speak(msg);
} catch(e) {}
}
// ── ACCENT COLOR THEMES ───────────────────────────────────────────────────────
const _THEMES = {
'stark-blue': {'--cyan':'#00d4ff','--cyan2':'#00a8cc','--cyan3':'rgba(0,212,255,0.15)'},
'widow-red': {'--cyan':'#ff3366','--cyan2':'#cc1a44','--cyan3':'rgba(255,51,102,0.15)'},
'hulk-green': {'--cyan':'#39ff14','--cyan2':'#27b30d','--cyan3':'rgba(57,255,20,0.15)'},
};
function applyTheme(name) {
const t = _THEMES[name]; if (!t) return;
const root = document.documentElement;
Object.entries(t).forEach(([k,v]) => root.style.setProperty(k, v));
localStorage.setItem('jarvis_theme', name);
document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === name));
}
// Apply saved theme on load
(function() {
const saved = localStorage.getItem('jarvis_theme');
if (saved && saved !== 'stark-blue') setTimeout(() => applyTheme(saved), 50);
})();
// ── QUICK-NOTE CAPTURE ────────────────────────────────────────────────────────
function openQuickNote() {
const bar = document.getElementById('quickNoteBar');
if (!bar) return;
bar.classList.add('open');
setTimeout(() => document.getElementById('quickNoteInput')?.focus(), 50);
}
function closeQuickNote() {
const bar = document.getElementById('quickNoteBar');
if (bar) bar.classList.remove('open');
const inp = document.getElementById('quickNoteInput');
if (inp) inp.value = '';
}
async function saveQuickNote() {
const inp = document.getElementById('quickNoteInput');
if (!inp || !inp.value.trim()) { closeQuickNote(); return; }
const note = inp.value.trim();
closeQuickNote();
try {
await api('chat', 'POST', {message: 'note: ' + note, session_id: sessionId});
addMessage('jarvis', 'Note saved to Memory Core, Sir: "' + note + '"');
} catch(_) {}
}
function handleNoteKey(e) {
if (e.key === 'Enter') { e.preventDefault(); saveQuickNote(); }
else if (e.key === 'Escape') { e.stopPropagation(); closeQuickNote(); }
}
// ── KEYBOARD SHORTCUTS ───────────────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) {
const tag = (document.activeElement?.tagName || '').toLowerCase();
const inInput = tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable;
if ((e.ctrlKey || e.metaKey) && e.key === 'k') return; // handled by palette
if (e.key === 'Escape') {
['sitesModal','agentModal','searchModal'].forEach(id => {
const el = document.getElementById(id);
if (el && (el.style.display === 'flex' || el.style.display === 'block')) el.style.display = 'none';
});
if (document.getElementById('netMapOverlay')?.classList.contains('nm-open')) closeNetMap();
if (document.getElementById('quickNoteBar')?.classList.contains('open')) closeQuickNote();
return;
}
if (inInput) return;
if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; }
if (e.key === 'm' || e.key === 'M') { toggleVoice(); return; }
if (e.key === 'n' || e.key === 'N') { openQuickNote(); return; }
if (e.key === ' ') { e.preventDefault(); document.getElementById('textInput')?.focus(); return; }
const tabMap = {'1':'ha','2':'alerts','3':'news','4':'agents'};
if (tabMap[e.key]) {
document.querySelectorAll('.tab').forEach(t => {
const oc = t.getAttribute('onclick') || '';
if (oc.includes("'" + tabMap[e.key] + "'")) t.click();
});
}
});
// ── FIRE HD 8 TABLET DETECTION ────────────────────────────────────────────────────────
const IS_SILK = /Silk\//i.test(navigator.userAgent);
const IS_FIRE = /KFTT|KFOT|KFJWI|KFSOWI|KFTHWI|KFTHWA|KFAPWI|KFAPWA|KFARWI|KFASWI|KFMEWI|KFFOWI|KFSAWA|KFMAWI|KFGIWI|KFDOWI|KFTBWI|KFTRWI|KFKAWI/i.test(navigator.userAgent);
function isTablet() { return IS_SILK || IS_FIRE; }
function applyTabletMode() {
document.body.classList.add("tablet-mode");
const kb = document.getElementById("kioskBtn");
if (kb) kb.title = "Full-screen kiosk (Fire HD 8 layout active)";
}
if (isTablet()) applyTabletMode();
// ── KIOSK MODE ────────────────────────────────────────────────────────────────────────
let _wakeLock = null;
async function toggleKiosk() {
const btn = document.getElementById("kioskBtn");
const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
if (!isFs) {
applyTabletMode();
const el = document.documentElement;
const req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
if (req) req.call(el).catch(() => {});
if ("wakeLock" in navigator) {
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
}
document.body.classList.add("kiosk-mode");
// Kiosk: silently activate mic + voice mode (no TTS greeting)
if (typeof wakeFromSleep === "function" && isAsleep) wakeFromSleep();
voiceMode = true; voiceMuted = false; voiceLastCmd = Date.now(); updateMicBtn();
if (typeof startListening === "function" && !isListening) startListening();
if (btn) { btn.textContent = "⧞ EXIT"; btn.style.color = "var(--cyan)"; }
} else {
const ex = document.exitFullscreen || document.webkitExitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen;
if (ex) ex.call(document).catch(() => {});
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
document.body.classList.remove("kiosk-mode");
if (typeof stopListening === "function") stopListening();
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
if (!isTablet()) document.body.classList.remove("tablet-mode");
}
}
document.addEventListener("visibilitychange", async () => {
if (_wakeLock && document.visibilityState === "visible") {
try { _wakeLock = await navigator.wakeLock.request("screen"); } catch(e) {}
}
});
function _onFsChange() {
const btn = document.getElementById("kioskBtn");
if (!document.fullscreenElement && !document.webkitFullscreenElement) {
if (_wakeLock) { _wakeLock.release().catch(() => {}); _wakeLock = null; }
document.body.classList.remove("kiosk-mode");
if (typeof stopListening === "function") stopListening();
if (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; }
if (!isTablet()) document.body.classList.remove("tablet-mode");
}
}
document.addEventListener("fullscreenchange", _onFsChange);
document.addEventListener("webkitfullscreenchange", _onFsChange);