mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
1838 lines
77 KiB
JavaScript
1838 lines
77 KiB
JavaScript
// ── 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 && !document.body.classList.contains(kiosk-mode) && 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}°<span style="color:var(--text-dim)">${day.low}°</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();
|
||
// In kiosk mode: wake word also wakes from sleep
|
||
if (document.body.classList.contains("kiosk-mode")) { wakeFromSleep(); }
|
||
else return;
|
||
}
|
||
|
||
if (!voiceMode) {
|
||
if (WAKE_PHRASES.some(p => lc.includes(p))) enterVoiceMode();
|
||
// Kiosk: any speech enters conversation mode
|
||
else if (document.body.classList.contains("kiosk-mode") && transcript.length > 2) enterVoiceMode("kiosk");
|
||
} 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 — <a href="https://jarvis.orbishosting.com" style="color:#fff;font-weight:bold;text-decoration:underline">tap here to switch</a> <button onclick="this.parentNode.remove()" style="background:transparent;border:1px solid #fff;color:#fff;cursor:pointer;padding:2px 8px;font-size:0.75rem">✕</button>";
|
||
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)"; }
|
||
// Kiosk: auto-start mic and enter always-on conversation mode
|
||
if (isAsleep) wakeFromSleep();
|
||
if (!voiceMode) enterVoiceMode("kiosk");
|
||
if (!isListening) { isListening = true; updateMicBtn(); if (recognition) try { recognition.start(); } catch(_) {} }
|
||
} 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);
|