refactor: Phase 3 — split jarvis-protocols.js into 3 panel files

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>
This commit is contained in:
2026-06-17 19:10:31 +00:00
parent 8085a113d5
commit 0b7f2d013b
5 changed files with 1674 additions and 1672 deletions
@@ -0,0 +1,345 @@
// ── 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,'&lt;')}</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();
}