// ── 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 "; 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]) => `
${_svcLabels[k]||k.toUpperCase()}
` ).join(''); } // Processes if (s.processes?.length) { document.getElementById('procs-list').innerHTML = s.processes.map(p => `
${p.cmd}
${p.cpu}%
` ).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 = '
Unavailable
'; 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 `
${lbl}
${v.toUpperCase()}
`; }).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' ? `${(d.agent_type||'AGENT').toUpperCase()}` : ''; const del = d.deletable ? `` : ''; const bl = d.source === 'agent' ? 'border-left:2px solid ' + (alive ? 'var(--green)' : 'var(--red)') + ';' : ''; return `
${d.name||d.ip}${badge}
${d.ip||''}${lat}
${del}
`; } let out = ''; if (agents.length) { const agOn = agents.filter(d => d.alive || d.status === 'online').length; out += `
AGENTS (${agOn}/${agents.length})
`; out += agents.map(renderDev).join(''); } if (others.length) { if (agents.length) out += '
'; out += `
DEVICES
`; out += others.map(renderDev).join(''); } if (!out) out = '
No devices
'; 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 = `
⚠ NOT CONFIGURED
Set PROXMOX_HOST and PROXMOX_TOKEN_VAL in config.php to enable VM monitoring.
`; 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 = '
No VMs found.
'; 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 `
${vm.name} ● ${(vm.status||'').toUpperCase()}
CPU ${vm.cpu}%
RAM ${vm.mem_mb||0}/${vm.maxmem_mb||0}MB
ID ${vm.vmid}
TYPE ${vm.type||'qemu'}
`; }).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 = `
⚠ NOT CONFIGURED
Set HA_URL and HA_TOKEN in config.php to enable smart home control.
`; 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 = '
No entities found.
'; 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 ? `` : ``; rows += ` ${icon} ${e.name} ${stateLabel} ${ctrl} `; }); } if (!totalShown) { el.innerHTML = '
No available entities.
'; return; } el.innerHTML = `${rows}
DEVICESTATECTRL
`; } 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 = '
No tasks or appointments today.
'; } else { if (appts.length) { html += '
TODAY\'S SCHEDULE
'; html += appts.map(a => `
${fmtTime(a.start_at)}${a.title}${a.location?' · '+a.location+'':''}
`).join(''); } if (tasks.length) { html += '
TASKS DUE
'; html += tasks.map(t => `
${t.title}${t._overdue?'OVERDUE':''}
`).join(''); } if (d.pending_count > tasks.length) { html += `
${d.pending_count} pending total
`; } } 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 = '
✓ NO ACTIVE ALERTS
'; 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 `
${a.title}
${a.message}
`; }).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 => `
${day.day}
${day.icon}
${day.high}°${day.low}°
${day.rain_pct > 0 ? day.rain_pct+'%' : ''}
`).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 = '
News loading...
'; 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 => ` `).join(''); } let html = ''; for (const [cat, articles] of Object.entries(d.categories)) { if (!articles.length || hidden.includes(cat)) continue; html += `
${catLabels[cat] || cat.toUpperCase()}
`; 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 += `
${a.source}
${a.title.length > 90 ? a.title.slice(0,87)+'…' : a.title}
${a.pub ? '
' + a.pub + '
' : ''}
`; } } if (!html) html = '
All categories hidden — use ⚙ to show sources
'; const ageMin = d.cache_age_s > 0 ? Math.round(d.cache_age_s/60) : 0; html += `
Updated ${ageMin}m ago
`; 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 = `${label}`; 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.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() { 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(); // On tablet via HTTP: show a banner prompting HTTPS for mic/camera if (isTablet() && location.protocol === "http:") { document.addEventListener("DOMContentLoaded", () => { const banner = document.createElement("div"); banner.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:99999;background:#ff6600;color:#fff;text-align:center;padding:10px 16px;font-family:monospace;font-size:0.85rem;display:flex;align-items:center;justify-content:center;gap:12px"; banner.innerHTML = "⚠ Mic & camera require HTTPS — tap here to switch  "; document.body.prepend(banner); }); } // ── 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"); // Switch away from hidden tabs if one is active const activeTab = document.querySelector(".tab-pane.active"); if (activeTab && (activeTab.id === "tab-agents" || activeTab.id === "tab-guardian")) { switchTab("intel"); } 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 (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 (btn) { btn.textContent = "⧞ KIOSK"; btn.style.color = ""; } if (!isTablet()) document.body.classList.remove("tablet-mode"); } } document.addEventListener("fullscreenchange", _onFsChange); document.addEventListener("webkitfullscreenchange", _onFsChange);