mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
+121
-1
@@ -8,6 +8,7 @@ if ($method !== 'POST') {
|
||||
$message = trim($data['message'] ?? '');
|
||||
$sessionId = $data['session_id'] ?? session_id();
|
||||
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
|
||||
$stream = !empty($data['stream']);
|
||||
|
||||
if (!$message) {
|
||||
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 ──────────────────────────────────
|
||||
// Detect "do X and Y" or "X then Y" compound commands (only when no reply yet)
|
||||
if (!$reply) {
|
||||
@@ -1670,7 +1683,7 @@ if (!$reply) {
|
||||
if ($best && $bestS >= 1) {
|
||||
@file_get_contents(HA_URL.'/api/services/scene/turn_on', false,
|
||||
stream_context_create(['http'=>['method'=>'POST','timeout'=>4,
|
||||
'header'=>"Authorization: Bearer ".HA_TOKEN."
|
||||
'header'=>"Authorization: Bearer ".HA_TOKEN."
|
||||
Content-Type: application/json",
|
||||
'content'=>json_encode(['entity_id'=>$best['entity_id']])]]));
|
||||
$mReply = ($best['attributes']['friendly_name'] ?? $best['entity_id']) . ' activated';
|
||||
@@ -1817,6 +1830,44 @@ Content-Type: application/json",
|
||||
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':
|
||||
// Extract target hostname from message
|
||||
$msgLow = strtolower($message);
|
||||
@@ -2132,6 +2183,75 @@ if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
|
||||
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
|
||||
$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');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
|
||||
@@ -648,6 +648,46 @@ body::after{
|
||||
box-shadow:0 0 4px var(--red);
|
||||
}
|
||||
@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 ─────────────────────────────────────────────────── */
|
||||
#rightPanel{display:flex;flex-direction:column;gap:10px;overflow-y:auto}
|
||||
|
||||
@@ -196,6 +196,17 @@ function showApp(name, greeting, silent = false) {
|
||||
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();
|
||||
@@ -1101,7 +1112,7 @@ 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>';
|
||||
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;
|
||||
@@ -1144,26 +1155,18 @@ async function sendMessage() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Local commands — no API round-trip
|
||||
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)) {
|
||||
input.value=''; addMessage('user',text);
|
||||
addMessage('jarvis','Launching network topology display.');
|
||||
speak('Launching network topology display.');
|
||||
openNetMap(); return;
|
||||
speak('Launching network topology display.'); openNetMap(); return;
|
||||
}
|
||||
if (NM_CLOSE_RE.test(t2)) {
|
||||
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.');}
|
||||
else addMessage('jarvis','Network map is not currently active.');
|
||||
return;
|
||||
@@ -1171,32 +1174,108 @@ async function sendMessage() {
|
||||
input.value = '';
|
||||
addMessage('user', text);
|
||||
showThinking();
|
||||
_abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const payload = {message:text, session_id:sessionId};
|
||||
if (selectedContext) {
|
||||
payload.context = selectedContext;
|
||||
clearContext();
|
||||
}
|
||||
const data = await api('chat', 'POST', payload);
|
||||
const bubble = document.getElementById('thinking-bubble');
|
||||
if (bubble) bubble.remove();
|
||||
const payload = {message:text, session_id:sessionId, stream:true};
|
||||
if (selectedContext) { payload.context = selectedContext; clearContext(); }
|
||||
|
||||
if (data.reply) {
|
||||
addMessage('jarvis', data.reply, data.source || null);
|
||||
speak(data.reply);
|
||||
const resp = await fetch('/api.php?action=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||'');
|
||||
}
|
||||
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) {
|
||||
_abortController = null;
|
||||
const bubble = document.getElementById('thinking-bubble');
|
||||
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 ─────────────────────────────────────────────────
|
||||
function initVoice() {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@@ -1371,6 +1450,7 @@ function startListening() {
|
||||
return;
|
||||
}
|
||||
isListening = true;
|
||||
_startWaveform();
|
||||
_scheduleRecStart(50);
|
||||
}
|
||||
|
||||
@@ -1380,9 +1460,42 @@ function stopListening() {
|
||||
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 = () => {
|
||||
@@ -1405,6 +1518,11 @@ function loadVoices() {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1547,6 +1665,53 @@ async function triggerMorningBriefing() {
|
||||
} 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();
|
||||
@@ -1558,11 +1723,13 @@ document.addEventListener('keydown', function(e) {
|
||||
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]) {
|
||||
|
||||
@@ -476,6 +476,12 @@ async function loadGuardian() {
|
||||
|
||||
_guardianUnread = unread;
|
||||
_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
|
||||
? new Date(status.last_scan + 'Z').toLocaleTimeString()
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
<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>
|
||||
|
||||
<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 class="btn-logout" onclick="logout()">LOGOUT</button>
|
||||
</div>
|
||||
@@ -276,6 +281,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Bottom Bar -->
|
||||
|
||||
Reference in New Issue
Block a user