feat: implement 7 JARVIS UI enhancements

#1  Voice waveform: Web Audio API drives wave-bar heights in real time
#2  Ambient dim mode: panels fade to 12% after 90s idle
#6  Streaming AI replies: Groq tokens via SSE; frontend ReadableStream
#7  Quick-note capture: N key / "note: text" saves to kb_facts instantly
#8  Cancel in-flight request: AbortController + CANCEL button
#9  Accent color themes: Stark Blue / Widow Red / Hulk Green, localStorage
#10 Browser push notifications: critical alerts when tab is backgrounded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:39:45 +00:00
parent 58070c7f06
commit 6195f9bd3b
5 changed files with 368 additions and 29 deletions
+121 -1
View File
@@ -8,6 +8,7 @@ if ($method !== 'POST') {
$message = trim($data['message'] ?? ''); $message = trim($data['message'] ?? '');
$sessionId = $data['session_id'] ?? session_id(); $sessionId = $data['session_id'] ?? session_id();
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.) $panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
$stream = !empty($data['stream']);
if (!$message) { if (!$message) {
echo json_encode(['error' => 'Message required']); exit; echo json_encode(['error' => 'Message required']); exit;
@@ -1632,6 +1633,18 @@ if (!$reply) {
} }
} }
// ── Tier 0.25: Quick-note capture ────────────────────────────────────────────
if (!$reply && preg_match('/^note:\s*(.+)/iu', $message, $nm)) {
$noteText = trim($nm[1]);
$ts = date('Y-m-d H:i');
JarvisDB::query(
"INSERT INTO kb_facts (category, fact, source, confidence) VALUES (?,?,?,?)",
['notes', "[{$ts}] {$noteText}", 'user-note', 1.0]
);
$reply = "Note saved to Memory Core, {$userAddr}: \"{$noteText}\"";
$source = 'intent:quick_note';
}
// ── Tier 0.5: Multi-step command detection ────────────────────────────────── // ── Tier 0.5: Multi-step command detection ──────────────────────────────────
// Detect "do X and Y" or "X then Y" compound commands (only when no reply yet) // Detect "do X and Y" or "X then Y" compound commands (only when no reply yet)
if (!$reply) { if (!$reply) {
@@ -1670,7 +1683,7 @@ if (!$reply) {
if ($best && $bestS >= 1) { if ($best && $bestS >= 1) {
@file_get_contents(HA_URL.'/api/services/scene/turn_on', false, @file_get_contents(HA_URL.'/api/services/scene/turn_on', false,
stream_context_create(['http'=>['method'=>'POST','timeout'=>4, stream_context_create(['http'=>['method'=>'POST','timeout'=>4,
'header'=>"Authorization: Bearer ".HA_TOKEN." 'header'=>"Authorization: Bearer ".HA_TOKEN."
Content-Type: application/json", Content-Type: application/json",
'content'=>json_encode(['entity_id'=>$best['entity_id']])]])); 'content'=>json_encode(['entity_id'=>$best['entity_id']])]]));
$mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated'; $mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated';
@@ -1817,6 +1830,44 @@ Content-Type: application/json",
break; break;
} }
case 'restart_agent':
// Extract target hostname from message
$msgLow = strtolower($message);
$agentMap = [
'homebridge' => 'homebridge_b57cbaea',
'jellyfin' => 'jellyfin_7e386833',
'networkbackup' => 'networkbackup_NetworkB',
'network backup'=> 'networkbackup_NetworkB',
'novacpx' => 'novacpx_e3b07264',
'nova' => 'novacpx_e3b07264',
'mediastack' => 'MediaStack_2c00b1b8',
'media stack' => 'MediaStack_2c00b1b8',
'homeassistant' => 'homeassistant_ha',
'home assistant'=> 'homeassistant_ha',
];
$targetAgentId = null;
$targetName = null;
foreach ($agentMap as $keyword => $agentId) {
if (str_contains($msgLow, $keyword)) {
$targetAgentId = $agentId;
$targetName = ucfirst($keyword);
break;
}
}
if ($targetAgentId) {
JarvisDB::insert(
"INSERT INTO agent_commands (agent_id, command_type, command_data, status, created_at)
VALUES (?, 'restart_service', ?, 'pending', NOW())",
[$targetAgentId, json_encode(['service' => 'jarvis-agent'])]
);
$reply = "Restart command sent to the {$targetName} agent, {$userAddr}. It should come back online within 15 seconds.";
} else {
// Fall back to listing restartable agents
$reply = "Which agent should I restart, {$userAddr}? I can restart: HomeAssistant, Homebridge, Jellyfin, MediaStack, NetworkBackup, or NovaCPX.";
}
$source = 'intent:restart_agent';
break;
case 'restart_agent': case 'restart_agent':
// Extract target hostname from message // Extract target hostname from message
$msgLow = strtolower($message); $msgLow = strtolower($message);
@@ -2132,6 +2183,75 @@ if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message; $userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
$groqMessages[] = ['role' => 'user', 'content' => $userMsg]; $groqMessages[] = ['role' => 'user', 'content' => $userMsg];
if ($stream) {
// ── Streaming SSE path ──────────────────────────────────────────
while (ob_get_level()) ob_end_clean();
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
header('Connection: keep-alive');
ob_implicit_flush(true);
$streamedReply = '';
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $groqModel,
'messages' => $groqMessages,
'max_tokens' => 400,
'temperature' => 0.7,
'stream' => true,
]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_WRITEFUNCTION => function($ch, $rawData) use (&$streamedReply) {
$lines = explode("\n", $rawData);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line === 'data: [DONE]') continue;
if (!str_starts_with($line, 'data: ')) continue;
$ev = json_decode(substr($line, 6), true);
$tok = $ev['choices'][0]['delta']['content'] ?? null;
if ($tok !== null && $tok !== '') {
echo 'data: ' . json_encode(['type' => 'token', 'token' => $tok]) . "\n\n";
flush();
$streamedReply .= $tok;
}
}
return strlen($rawData);
},
]);
curl_exec($ch);
curl_close($ch);
$reply = $streamedReply ? trim($streamedReply) : "Groq AI is temporarily unavailable, {$userAddr}.";
$source = $streamedReply ? 'groq:' . $groqModel : 'fallback';
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'assistant', $reply]
);
KBEngine::learnFromConversation($message, $reply);
echo 'data: ' . json_encode([
'type' => 'complete',
'reply' => $reply,
'source' => $source,
'session_id' => $sessionId,
'ui_action' => $uiAction ?? null,
'arc_job' => null,
'open_network_map' => false,
]) . "\n\n";
flush();
exit;
}
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions'); $ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
+40
View File
@@ -648,6 +648,46 @@ body::after{
box-shadow:0 0 4px var(--red); box-shadow:0 0 4px var(--red);
} }
@keyframes waveBounce{from{height:4px}to{height:24px}} @keyframes waveBounce{from{height:4px}to{height:24px}}
.wave-bar.live{animation:none!important;transition:height 0.06s}
/* ── AMBIENT DIM MODE ─────────────────────────────────────────────────── */
.ambient-dim-active .panel,.ambient-dim-active #bottomBar{
opacity:0.12;transition:opacity 2s ease;pointer-events:none}
.ambient-dim-active .panel:hover,.ambient-dim-active #bottomBar:hover{
opacity:1;pointer-events:auto;transition:opacity 0.3s ease}
/* ── THEME BUTTONS ────────────────────────────────────────────────────── */
.theme-btn{
background:none;border:1px solid rgba(0,212,255,0.25);border-radius:50%;
width:14px;height:14px;cursor:pointer;padding:0;font-size:0.6rem;line-height:1;
display:flex;align-items:center;justify-content:center;color:var(--cyan);
transition:all 0.2s;flex-shrink:0}
.theme-btn.active{border-color:currentColor;box-shadow:0 0 6px currentColor}
.theme-btn:hover{opacity:0.8;transform:scale(1.2)}
/* ── CANCEL BUTTON (in thinking bubble) ──────────────────────────────── */
.thinking-cancel{
background:none;border:1px solid rgba(255,34,68,0.4);color:rgba(255,34,68,0.8);
font-family:var(--font-mono);font-size:0.55rem;letter-spacing:1px;
padding:2px 8px;border-radius:2px;cursor:pointer;margin-top:6px;display:block}
.thinking-cancel:hover{background:rgba(255,34,68,0.1)}
/* ── QUICK NOTE BAR ──────────────────────────────────────────────────── */
#quickNoteBar{
position:fixed;bottom:90px;left:50%;transform:translateX(-50%);
width:500px;max-width:90vw;background:rgba(0,8,16,0.95);
border:1px solid var(--cyan);border-radius:3px;padding:8px 14px;
display:none;z-index:1100;align-items:center;gap:8px}
#quickNoteBar.open{display:flex}
#quickNoteInput{
flex:1;background:none;border:none;color:var(--cyan);
font-family:var(--font-mono);font-size:0.75rem;outline:none;letter-spacing:0.5px}
#quickNoteInput::placeholder{color:rgba(0,212,255,0.4)}
/* ── STREAMING MESSAGE ───────────────────────────────────────────────── */
.msg.jarvis.streaming::after{
content:'▋';animation:blink 0.7s step-end infinite;color:var(--cyan);margin-left:2px}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
/* ── RIGHT PANEL ─────────────────────────────────────────────────── */ /* ── RIGHT PANEL ─────────────────────────────────────────────────── */
#rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto} #rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
+195 -28
View File
@@ -196,6 +196,17 @@ function showApp(name, greeting, silent = false) {
setTimeout(checkSuggestions, 15000); setTimeout(checkSuggestions, 15000);
setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load setInterval(checkSuggestions, 1800000); // every 30 min // baseline on load
setInterval(pollAlertsProactive, 60000); // poll every 60s 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 // Guardian Mode — badge refresh + proactive chat
setTimeout(() => { setTimeout(() => {
_refreshGuardianBadge(); _refreshGuardianBadge();
@@ -1101,7 +1112,7 @@ function showThinking() {
const log = document.getElementById('chatLog'); const log = document.getElementById('chatLog');
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'msg jarvis'; 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>'; 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'; div.id = 'thinking-bubble';
log.appendChild(div); log.appendChild(div);
log.scrollTop = log.scrollHeight; log.scrollTop = log.scrollHeight;
@@ -1144,26 +1155,18 @@ async function sendMessage() {
const text = input.value.trim(); const text = input.value.trim();
if (!text) return; if (!text) return;
// Local commands — no API round-trip
var t2 = text.toLowerCase(); var t2 = text.toLowerCase();
// Sleep command if (SLEEP_CMDS.test(t2)) { input.value=''; addMessage('user',text); enterSleepMode(); return; }
if (SLEEP_CMDS.test(t2)) {
input.value = '';
addMessage('user', text);
enterSleepMode();
return;
}
if (NM_OPEN_RE.test(t2)) { if (NM_OPEN_RE.test(t2)) {
input.value=''; addMessage('user',text); input.value=''; addMessage('user',text);
addMessage('jarvis','Launching network topology display.'); addMessage('jarvis','Launching network topology display.');
speak('Launching network topology display.'); speak('Launching network topology display.'); openNetMap(); return;
openNetMap(); return;
} }
if (NM_CLOSE_RE.test(t2)) { if (NM_CLOSE_RE.test(t2)) {
input.value=''; addMessage('user',text); input.value=''; addMessage('user',text);
var isOpen=document.getElementById('netMapOverlay')&&document.getElementById('netMapOverlay').classList.contains('nm-open'); var isOpen=document.getElementById('netMapOverlay')?.classList.contains('nm-open');
if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');} if(isOpen){closeNetMap();addMessage('jarvis','Network map closed.');speak('Network map closed.');}
else addMessage('jarvis','Network map is not currently active.'); else addMessage('jarvis','Network map is not currently active.');
return; return;
@@ -1171,32 +1174,108 @@ async function sendMessage() {
input.value = ''; input.value = '';
addMessage('user', text); addMessage('user', text);
showThinking(); showThinking();
_abortController = new AbortController();
try { try {
const payload = {message:text, session_id:sessionId}; const payload = {message:text, session_id:sessionId, stream:true};
if (selectedContext) { if (selectedContext) { payload.context = selectedContext; clearContext(); }
payload.context = selectedContext;
clearContext();
}
const data = await api('chat', 'POST', payload);
const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove();
if (data.reply) { const resp = await fetch('/api.php?action=chat', {
addMessage('jarvis', data.reply, data.source || null); method: 'POST',
speak(data.reply); 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||'');
} }
if (data.open_network_map) { openNetMap(); }
if (data.ui_action === 'focus_mode') { if (panelsVisible) togglePanels(true); }
if (data.ui_action === 'show_panels') { if (!panelsVisible) togglePanels(true); }
if (data.arc_job) { onArcJobStarted(data.arc_job, data.source || ''); }
} catch(e) { } catch(e) {
_abortController = null;
const bubble = document.getElementById('thinking-bubble'); const bubble = document.getElementById('thinking-bubble');
if (bubble) bubble.remove(); if (bubble) bubble.remove();
addMessage('jarvis', 'I encountered a communication error, Sir. Please check my API connection.'); 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 ───────────────────────────────────────────────── // ── VOICE RECOGNITION ─────────────────────────────────────────────────
function initVoice() { function initVoice() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
@@ -1371,6 +1450,7 @@ function startListening() {
return; return;
} }
isListening = true; isListening = true;
_startWaveform();
_scheduleRecStart(50); _scheduleRecStart(50);
} }
@@ -1380,9 +1460,42 @@ function stopListening() {
voiceMuted = false; voiceMuted = false;
updateMicBtn(); updateMicBtn();
clearTimeout(_recTimer); clearTimeout(_recTimer);
_stopWaveform();
try { recognition.abort(); } catch(_) {} 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 ────────────────────────────────────────────────── // ── SPEECH SYNTHESIS ──────────────────────────────────────────────────
function loadVoices() { function loadVoices() {
const set = () => { const set = () => {
@@ -1405,6 +1518,11 @@ function loadVoices() {
} }
let _ttsAudio = null; let _ttsAudio = null;
let _abortController = null;
let _waveAudioCtx = null;
let _waveAnalyser = null;
let _waveStream = null;
let _waveRafId = null;
async function speak(text) { async function speak(text) {
if (!text) return; if (!text) return;
@@ -1547,6 +1665,53 @@ async function triggerMorningBriefing() {
} catch(e) {} } 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 ─────────────────────────────────────────────────────────────── // ── KEYBOARD SHORTCUTS ───────────────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
const tag = (document.activeElement?.tagName || '').toLowerCase(); const tag = (document.activeElement?.tagName || '').toLowerCase();
@@ -1558,11 +1723,13 @@ document.addEventListener('keydown', function(e) {
if (el && (el.style.display === 'flex' || el.style.display === 'block')) el.style.display = 'none'; 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('netMapOverlay')?.classList.contains('nm-open')) closeNetMap();
if (document.getElementById('quickNoteBar')?.classList.contains('open')) closeQuickNote();
return; return;
} }
if (inInput) return; if (inInput) return;
if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; } if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; }
if (e.key === 'm' || e.key === 'M') { toggleVoice(); 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; } if (e.key === ' ') { e.preventDefault(); document.getElementById('textInput')?.focus(); return; }
const tabMap = {'1':'ha','2':'alerts','3':'news','4':'agents'}; const tabMap = {'1':'ha','2':'alerts','3':'news','4':'agents'};
if (tabMap[e.key]) { if (tabMap[e.key]) {
@@ -476,6 +476,12 @@ async function loadGuardian() {
_guardianUnread = unread; _guardianUnread = unread;
_updateGuardianBadge(unread, critU); _updateGuardianBadge(unread, critU);
if (critU > 0 && document.hidden && 'Notification' in window && Notification.permission === 'granted') {
new Notification('JARVIS ALERT', {
body: critU + ' critical alert' + (critU > 1 ? 's' : '') + ' require your attention.',
icon: '/favicon.ico',
});
}
const lastScan = status.last_scan const lastScan = status.last_scan
? new Date(status.last_scan + 'Z').toLocaleTimeString() ? new Date(status.last_scan + 'Z').toLocaleTimeString()
+6
View File
@@ -67,6 +67,11 @@
<button id="panelToggleBtn" class="btn-panels" onclick="togglePanels()" title="Toggle side panels (or say 'focus mode')">◧ PANELS</button> <button id="panelToggleBtn" class="btn-panels" onclick="togglePanels()" title="Toggle side panels (or say 'focus mode')">◧ PANELS</button>
<button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button> <button id="agentBtn" class="btn-agent" onclick="openAgentModal()" title="Install JARVIS Agent on this machine"><div class="agent-dot"></div>AGENT</button>
<div id="themeBar" style="display:flex;gap:3px;align-items:center;margin-right:2px">
<button class="theme-btn active" id="theme-blue" data-theme="stark-blue" onclick="applyTheme('stark-blue')" title="Stark Blue" style="color:#00d4ff"></button>
<button class="theme-btn" id="theme-red" data-theme="widow-red" onclick="applyTheme('widow-red')" title="Widow Red" style="color:#ff3366"></button>
<button class="theme-btn" id="theme-green" data-theme="hulk-green" onclick="applyTheme('hulk-green')" title="Hulk Green" style="color:#39ff14"></button>
</div>
<button id="btn-swap-panels" onclick="swapPanels()" title="Swap side panels">⇄ SWAP</button> <button id="btn-swap-panels" onclick="swapPanels()" title="Swap side panels">⇄ SWAP</button>
<button class="btn-logout" onclick="logout()">LOGOUT</button> <button class="btn-logout" onclick="logout()">LOGOUT</button>
</div> </div>
@@ -276,6 +281,7 @@
</div> </div>
<!-- Voice Transcript Bar --> <!-- Voice Transcript Bar -->
<div id="quickNoteBar"><span style="color:var(--cyan);font-family:var(--font-mono);font-size:0.6rem;letter-spacing:2px;flex-shrink:0">✎ NOTE</span><input id="quickNoteInput" placeholder="Type a note and press Enter…" onkeydown="handleNoteKey(event)"><button onclick="closeQuickNote()" style="background:none;border:none;color:rgba(0,212,255,0.5);cursor:pointer;font-size:0.8rem"></button></div>
<div id="voiceTranscriptBar" style="position:fixed;bottom:44px;left:50%;transform:translateX(-50%);max-width:580px;width:88%;background:rgba(0,8,16,0.9);border:1px solid rgba(0,212,255,0.3);border-radius:3px;font-family:var(--font-mono);font-size:0.62rem;color:rgba(0,212,255,0.9);letter-spacing:1px;padding:5px 14px;text-align:center;z-index:900;opacity:0;transition:opacity 0.18s ease;pointer-events:none"></div> <div id="voiceTranscriptBar" style="position:fixed;bottom:44px;left:50%;transform:translateX(-50%);max-width:580px;width:88%;background:rgba(0,8,16,0.9);border:1px solid rgba(0,212,255,0.3);border-radius:3px;font-family:var(--font-mono);font-size:0.62rem;color:rgba(0,212,255,0.9);letter-spacing:1px;padding:5px 14px;text-align:center;z-index:900;opacity:0;transition:opacity 0.18s ease;pointer-events:none"></div>
<!-- Bottom Bar --> <!-- Bottom Bar -->