mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
0b7f2d013b
A single SyntaxError in the 1668-line monolith kills every panel
(proven by the apostrophe bug on 2026-06-17). Split into:
panels/jarvis-arc.js (608 lines) — Arc Reactor, Intel, Comms, Guardian
panels/jarvis-agents.js (715 lines) — Missions, Directives, Memory,
Clearance, Agents tab, Sites, Vision
panels/jarvis-assistant.js (345 lines) — Chat History, Suggestions,
Mobile, Command Palette, Topo map
A parse error in any one file now fails only that group of panels.
escHtml() stays in jarvis-arc.js (loads first) and remains global.
All other dependencies (api, speak, addMessage) come from jarvis-app.js.
Version param bumped to ?v=20260617b to force Cloudflare cache miss.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
346 lines
16 KiB
JavaScript
346 lines
16 KiB
JavaScript
// ── CHAT HISTORY SEARCH ───────────────────────────────────────────────────────
|
|
function openSearchModal() {
|
|
document.getElementById('searchModal').style.display = 'flex';
|
|
document.getElementById('searchInput').focus();
|
|
}
|
|
function closeSearchModal() {
|
|
document.getElementById('searchModal').style.display = 'none';
|
|
document.getElementById('searchResults').innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Type to search your JARVIS conversations</div>';
|
|
document.getElementById('searchInput').value = '';
|
|
}
|
|
async function runSearch() {
|
|
const q = document.getElementById('searchInput').value.trim();
|
|
if (!q) return;
|
|
const el = document.getElementById('searchResults');
|
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">Searching...</div>';
|
|
try {
|
|
const d = await api('history?q=' + encodeURIComponent(q));
|
|
if (!d.results || !d.results.length) {
|
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.65rem;text-align:center;padding:20px">No results for "' + q + '"</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = d.results.map(r => {
|
|
const role = r.role === 'user' ? '👤' : '🤖';
|
|
const ts = new Date(r.created_at).toLocaleString('en-US', {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
|
|
const snippet = r.content.length > 200 ? r.content.slice(0,197) + '…' : r.content;
|
|
return `<div style="background:rgba(0,212,255,0.04);border:1px solid var(--panel-border);border-radius:4px;padding:10px 12px">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
|
<span style="font-family:var(--font-display);font-size:0.55rem;letter-spacing:1px;color:var(--cyan)">${role} ${r.role.toUpperCase()}</span>
|
|
<span style="font-size:0.52rem;color:var(--text-dim)">${ts}</span>
|
|
</div>
|
|
<div style="font-size:0.68rem;color:var(--text-primary);line-height:1.4">${snippet.replace(/</g,'<')}</div>
|
|
</div>`;
|
|
}).join('');
|
|
} catch(e) {
|
|
el.innerHTML = '<div style="color:var(--red);font-size:0.65rem;text-align:center;padding:20px">Search failed</div>';
|
|
}
|
|
}
|
|
document.getElementById('searchModal')?.addEventListener('click', e => {
|
|
if (e.target === document.getElementById('searchModal')) closeSearchModal();
|
|
});
|
|
|
|
// ── PROACTIVE SUGGESTIONS ────────────────────────────────────────────────────
|
|
const _shownSuggestions = new Set();
|
|
async function checkSuggestions() {
|
|
const d = await api('suggestions').catch(() => null);
|
|
if (!d || !d.suggestions || !d.suggestions.length) return;
|
|
for (const s of d.suggestions) {
|
|
const key = s.intent + ':' + d.hour + ':' + d.dow;
|
|
if (_shownSuggestions.has(key)) continue;
|
|
_shownSuggestions.add(key);
|
|
// Show as a soft suggestion chip in chat
|
|
const log = document.getElementById('chatLog');
|
|
const chip = document.createElement('div');
|
|
chip.style.cssText = 'display:flex;justify-content:flex-end;margin:4px 0';
|
|
chip.innerHTML = `<button onclick="sendSuggestion('${s.intent}',this)" style="background:rgba(0,212,255,0.06);border:1px solid rgba(0,212,255,0.25);border-radius:12px;color:var(--cyan);font-family:var(--font-display);font-size:0.52rem;letter-spacing:1px;padding:4px 12px;cursor:pointer;transition:all 0.2s" onmouseover="this.style.background='rgba(0,212,255,0.12)'" onmouseout="this.style.background='rgba(0,212,255,0.06)'">◈ ${s.prompt}</button>`;
|
|
log.appendChild(chip);
|
|
log.scrollTop = log.scrollHeight;
|
|
break; // show max one suggestion at a time
|
|
}
|
|
}
|
|
|
|
function sendSuggestion(intent, btn) {
|
|
btn.closest('div').remove();
|
|
const prompts = {
|
|
'network_scan': 'run a network scan',
|
|
'jellyfin_now_playing': 'what is playing on Jellyfin',
|
|
'ha_scene': 'what scenes are available',
|
|
'planner:briefing': 'daily briefing',
|
|
'vm_suggestions': 'VM resource suggestions',
|
|
'focus_mode': 'focus mode',
|
|
};
|
|
const msg = prompts[intent] || intent.replace(/_/g,' ');
|
|
document.getElementById('textInput').value = msg;
|
|
sendMessage();
|
|
}
|
|
|
|
// ── MOBILE PANEL SWITCHER ─────────────────────────────────────────────────────
|
|
function mobSwitch(which) {
|
|
if (window.innerWidth > 900) return;
|
|
const panels = {left:'leftPanel', center:'centerPanel', right:'rightPanel'};
|
|
Object.entries(panels).forEach(([k, id]) => {
|
|
document.getElementById(id)?.classList.toggle('mob-active', k === which);
|
|
});
|
|
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
|
document.getElementById('mob-btn-' + which)?.classList.add('active');
|
|
if (which === 'right') loadNews();
|
|
}
|
|
function initMobile() {
|
|
if (window.innerWidth > 900) return;
|
|
['leftPanel','centerPanel','rightPanel'].forEach(id =>
|
|
document.getElementById(id)?.classList.remove('mob-active'));
|
|
document.getElementById('leftPanel')?.classList.add('mob-active');
|
|
document.querySelectorAll('.mob-nav-btn').forEach(b => b.classList.remove('active'));
|
|
document.getElementById('mob-btn-left')?.classList.add('active');
|
|
}
|
|
window.addEventListener('resize', initMobile);
|
|
|
|
// ── COMMAND PALETTE (Ctrl+K) ──────────────────────────────────────────────
|
|
const _PALETTE_COMMANDS = [
|
|
{ label: 'Run a network scan', q: 'run a network scan', group: 'Network' },
|
|
{ label: 'Show online devices', q: 'who is online on the network', group: 'Network' },
|
|
{ label: 'Proxmox status', q: 'proxmox status', group: 'Network' },
|
|
{ label: 'Check agent status', q: 'check all agents', group: 'Agents' },
|
|
{ label: 'Restart JARVIS agent', q: 'restart jarvis agent', group: 'Agents' },
|
|
{ label: 'Check VM resources', q: 'VM resource suggestions', group: 'Agents' },
|
|
{ label: 'Daily briefing', q: 'daily briefing', group: 'Planner' },
|
|
{ label: 'My tasks today', q: 'my tasks today', group: 'Planner' },
|
|
{ label: 'My calendar', q: 'my calendar', group: 'Planner' },
|
|
{ label: "What's playing on Jellyfin", q: 'what is playing on Jellyfin', group: 'Media' },
|
|
{ label: 'Pause Jellyfin', q: 'pause Jellyfin', group: 'Media' },
|
|
{ label: 'Next track on Jellyfin', q: 'next track on Jellyfin', group: 'Media' },
|
|
{ label: 'Stop Jellyfin', q: 'stop Jellyfin', group: 'Media' },
|
|
{ label: 'List HA scenes', q: 'show home assistant scenes', group: 'Smart Home'},
|
|
{ label: 'Activate scene…', q: 'activate scene ', group: 'Smart Home'},
|
|
{ label: 'Focus mode', q: 'focus mode', group: 'UI' },
|
|
{ label: 'Show all panels', q: 'show all panels', group: 'UI' },
|
|
{ label: 'Check alerts', q: 'check alerts', group: 'System' },
|
|
{ label: 'Site health', q: 'site health', group: 'System' },
|
|
{ label: 'System status', q: 'system status', group: 'System' },
|
|
{ label: 'Check inbox', q: 'check inbox', group: 'Comms' },
|
|
{ label: 'Search history…', q: '', group: 'Chat', search: true },
|
|
];
|
|
|
|
let _paletteOpen = false;
|
|
|
|
function openPalette() {
|
|
if (_paletteOpen) return;
|
|
_paletteOpen = true;
|
|
const ov = document.getElementById('cmdPalette');
|
|
if (!ov) return;
|
|
ov.style.display = 'flex';
|
|
const inp = document.getElementById('cmdPaletteInput');
|
|
inp.value = '';
|
|
renderPaletteItems('');
|
|
requestAnimationFrame(() => { ov.classList.add('open'); inp.focus(); });
|
|
}
|
|
|
|
function closePalette() {
|
|
if (!_paletteOpen) return;
|
|
_paletteOpen = false;
|
|
const ov = document.getElementById('cmdPalette');
|
|
if (!ov) return;
|
|
ov.classList.remove('open');
|
|
setTimeout(() => { ov.style.display = 'none'; }, 180);
|
|
}
|
|
|
|
function renderPaletteItems(q) {
|
|
const list = document.getElementById('cmdPaletteList');
|
|
if (!list) return;
|
|
const low = q.toLowerCase().trim();
|
|
const filtered = low
|
|
? _PALETTE_COMMANDS.filter(c => c.label.toLowerCase().includes(low) || c.group.toLowerCase().includes(low))
|
|
: _PALETTE_COMMANDS;
|
|
|
|
let currentGroup = null;
|
|
list.innerHTML = '';
|
|
filtered.forEach((cmd, i) => {
|
|
if (cmd.group !== currentGroup) {
|
|
currentGroup = cmd.group;
|
|
const g = document.createElement('div');
|
|
g.className = 'cp-group';
|
|
g.textContent = cmd.group;
|
|
list.appendChild(g);
|
|
}
|
|
const row = document.createElement('div');
|
|
row.className = 'cp-item' + (i === 0 ? ' cp-active' : '');
|
|
row.dataset.q = cmd.q;
|
|
row.dataset.search = cmd.search ? '1' : '';
|
|
const lbl = cmd.label.replace(new RegExp(low.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'), 'gi'),
|
|
m => `<mark>${m}</mark>`);
|
|
row.innerHTML = `<span class="cp-icon">◈</span><span class="cp-label">${lbl}</span><kbd class="cp-kbd">↵</kbd>`;
|
|
row.addEventListener('click', () => firePaletteItem(row));
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function movePaletteSelection(dir) {
|
|
const items = Array.from(document.querySelectorAll('#cmdPaletteList .cp-item'));
|
|
if (!items.length) return;
|
|
const cur = items.findIndex(el => el.classList.contains('cp-active'));
|
|
const next = (cur + dir + items.length) % items.length;
|
|
items.forEach(el => el.classList.remove('cp-active'));
|
|
items[next].classList.add('cp-active');
|
|
items[next].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
function firePaletteItem(el) {
|
|
if (!el) {
|
|
const active = document.querySelector('#cmdPaletteList .cp-active');
|
|
if (!active) return;
|
|
el = active;
|
|
}
|
|
const q = el.dataset.q;
|
|
const isSearch = el.dataset.search === '1';
|
|
closePalette();
|
|
if (isSearch) {
|
|
if (typeof openSearchModal === 'function') openSearchModal();
|
|
return;
|
|
}
|
|
if (q) {
|
|
document.getElementById('textInput').value = q;
|
|
sendMessage();
|
|
}
|
|
}
|
|
|
|
// Keyboard events
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
_paletteOpen ? closePalette() : openPalette();
|
|
return;
|
|
}
|
|
if (!_paletteOpen) return;
|
|
if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); movePaletteSelection(1); }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); movePaletteSelection(-1); }
|
|
if (e.key === 'Enter') { e.preventDefault(); firePaletteItem(null); }
|
|
});
|
|
|
|
// Filter on type
|
|
document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
|
|
renderPaletteItems(e.target.value);
|
|
});
|
|
|
|
// Close on backdrop click
|
|
document.getElementById('cmdPalette')?.addEventListener('click', e => {
|
|
if (e.target.id === 'cmdPalette') closePalette();
|
|
});
|
|
|
|
// ── AGENT TOPOLOGY MAP ─────────────────────────────────────────────────────────────
|
|
let _agentTopoMode = false, _agentTopoRaf = null, _agentTopoData = [];
|
|
|
|
function toggleAgentTopo() {
|
|
_agentTopoMode = !_agentTopoMode;
|
|
const btn = document.getElementById('agent-topo-btn');
|
|
const list = document.getElementById('agents-list');
|
|
const cvs = document.getElementById('agentTopoCanvas');
|
|
if (!btn || !list || !cvs) return;
|
|
btn.classList.toggle('active', _agentTopoMode);
|
|
if (_agentTopoMode) {
|
|
list.style.display = 'none'; cvs.style.display = 'block';
|
|
_buildAgentTopoData(); _drawAgentTopo();
|
|
} else {
|
|
list.style.display = 'block'; cvs.style.display = 'none';
|
|
if (_agentTopoRaf) { cancelAnimationFrame(_agentTopoRaf); _agentTopoRaf = null; }
|
|
}
|
|
}
|
|
|
|
function _buildAgentTopoData() {
|
|
// Build node list from rendered agent cards
|
|
_agentTopoData = [{id:'jarvis',label:'JARVIS',online:true,type:'hub'}];
|
|
document.querySelectorAll('.agent-card').forEach(el => {
|
|
const nameEl = el.querySelector('.agent-name, [class*="name"]');
|
|
if (!nameEl) return;
|
|
const name = nameEl.textContent.trim();
|
|
const online = el.classList.contains('online') || !!el.querySelector('.agent-dot.online, .dot.online');
|
|
const lname = name.toLowerCase();
|
|
let type = 'linux';
|
|
if (lname.includes('pve') || lname.includes('proxmox') || el.querySelector('[class*="proxmox"]')) type = 'proxmox';
|
|
else if (lname.includes('ha') || lname.includes('homeassist')) type = 'homeassistant';
|
|
else if (lname.includes('windows') || lname.includes('mini')) type = 'windows';
|
|
_agentTopoData.push({id:name, label:name.substring(0,12), online, type});
|
|
});
|
|
// Fallback: use last known registered agent list if cards not rendered
|
|
if (_agentTopoData.length <= 1 && typeof _lastAgents !== 'undefined') {
|
|
(_lastAgents || []).forEach(a => {
|
|
_agentTopoData.push({id:a.agent_id,label:(a.hostname||a.agent_id).substring(0,12),online:a.status==='online',type:a.agent_type||'linux'});
|
|
});
|
|
}
|
|
}
|
|
|
|
function _drawAgentTopo() {
|
|
const cvs = document.getElementById('agentTopoCanvas');
|
|
if (!cvs || !_agentTopoMode) return;
|
|
const ctx = cvs.getContext('2d');
|
|
const rect = cvs.getBoundingClientRect();
|
|
const W = rect.width || 280, H = rect.height || 260;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
cvs.width = W * dpr; cvs.height = H * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
const typeRing = {hub:0, proxmox:0.28, homeassistant:0.48, linux:0.68, windows:0.68};
|
|
const typeColor = {hub:'0,212,255', proxmox:'0,255,136', homeassistant:'255,215,0', linux:'0,190,255', windows:'180,120,255'};
|
|
// Assign positions
|
|
const byType = {};
|
|
_agentTopoData.slice(1).forEach(n => { (byType[n.type]=byType[n.type]||[]).push(n); });
|
|
_agentTopoData[0].x = W/2; _agentTopoData[0].y = H/2;
|
|
Object.entries(byType).forEach(([tp, nodes]) => {
|
|
const rf = typeRing[tp] || 0.68;
|
|
const r = Math.min(W, H) / 2 * rf;
|
|
nodes.forEach((n, i) => {
|
|
const a = -Math.PI/2 + (i / nodes.length) * Math.PI * 2;
|
|
n.x = W/2 + Math.cos(a)*r; n.y = H/2 + Math.sin(a)*r;
|
|
});
|
|
});
|
|
let t = 0;
|
|
function frame() {
|
|
if (!_agentTopoMode) return;
|
|
t += 0.007; ctx.clearRect(0, 0, W, H);
|
|
// Orbit rings
|
|
[0.28, 0.48, 0.68].forEach(rf => {
|
|
ctx.beginPath(); ctx.arc(W/2, H/2, Math.min(W,H)/2*rf, 0, Math.PI*2);
|
|
ctx.strokeStyle = 'rgba(0,212,255,0.05)'; ctx.lineWidth = 0.5; ctx.stroke();
|
|
});
|
|
// Edges
|
|
_agentTopoData.slice(1).forEach(n => {
|
|
if (!n.x) return;
|
|
const col = typeColor[n.type] || '0,190,255';
|
|
ctx.beginPath(); ctx.moveTo(W/2, H/2); ctx.lineTo(n.x, n.y);
|
|
ctx.strokeStyle = n.online ? 'rgba('+col+',0.18)' : 'rgba(255,50,80,0.08)';
|
|
ctx.lineWidth = n.online ? 1 : 0.5; ctx.stroke();
|
|
});
|
|
// Particles
|
|
_agentTopoData.slice(1).filter(n=>n.online&&n.x).forEach((n,i) => {
|
|
const p = ((t*0.35+i*0.41)%1);
|
|
const col = typeColor[n.type]||'0,190,255';
|
|
const px = W/2+(n.x-W/2)*p, py = H/2+(n.y-H/2)*p;
|
|
ctx.beginPath(); ctx.arc(px,py,1.4,0,Math.PI*2);
|
|
ctx.fillStyle='rgba('+col+',0.75)'; ctx.fill();
|
|
});
|
|
// Nodes
|
|
_agentTopoData.forEach((n,i) => {
|
|
if (!n.x) return;
|
|
const col = typeColor[n.type]||'0,190,255';
|
|
const nr = n.type==='hub' ? 13 : 7;
|
|
const pulse = Math.sin(t+i*0.9)*0.25+0.75;
|
|
if (n.online||n.type==='hub') {
|
|
const g = ctx.createRadialGradient(n.x,n.y,0,n.x,n.y,nr*3.5);
|
|
g.addColorStop(0,'rgba('+col+','+(0.15*pulse)+')');
|
|
g.addColorStop(1,'transparent');
|
|
ctx.beginPath(); ctx.arc(n.x,n.y,nr*3.5,0,Math.PI*2);
|
|
ctx.fillStyle=g; ctx.fill();
|
|
}
|
|
ctx.beginPath(); ctx.arc(n.x,n.y,nr,0,Math.PI*2);
|
|
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.9)' : 'rgba(255,50,80,0.5)';
|
|
ctx.fill();
|
|
ctx.strokeStyle='rgba('+col+',0.6)'; ctx.lineWidth=1; ctx.stroke();
|
|
ctx.fillStyle = n.online||n.type==='hub' ? 'rgba('+col+',0.85)' : 'rgba(255,80,80,0.7)';
|
|
ctx.font = (n.type==='hub'?'600 8px':'6px')+' "Share Tech Mono",monospace';
|
|
ctx.textAlign='center';
|
|
ctx.fillText(n.label, n.x, n.y+nr+9);
|
|
});
|
|
_agentTopoRaf = requestAnimationFrame(frame);
|
|
}
|
|
frame();
|
|
}
|