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>
609 lines
27 KiB
JavaScript
609 lines
27 KiB
JavaScript
// ── ARC REACTOR STATUS ────────────────────────────────────────────────
|
||
let _arcOnline = false;
|
||
let _arcJobs = { queued: 0, running: 0, done: 0, failed: 0 };
|
||
|
||
async function checkArcStatus() {
|
||
const dot = document.getElementById('bb-arc-dot');
|
||
const sta = document.getElementById('bb-arc-status');
|
||
if (!dot || !sta) return;
|
||
try {
|
||
const d = await api('arc?action=status');
|
||
if (d && d.online) {
|
||
_arcOnline = true;
|
||
dot.className = 'bb-dot online';
|
||
const active = (d.active_jobs || 0) + (d.queued_jobs || 0);
|
||
sta.textContent = active > 0 ? active + ' JOB' + (active !== 1 ? 'S' : '') : 'ONLINE';
|
||
_arcJobs = { queued: d.queued_jobs||0, running: d.running_jobs||0,
|
||
done: d.jobs_done||0, failed: d.jobs_failed||0 };
|
||
} else {
|
||
_arcOnline = false;
|
||
dot.className = 'bb-dot offline';
|
||
sta.textContent = 'OFFLINE';
|
||
}
|
||
} catch(e) {
|
||
_arcOnline = false;
|
||
dot.className = 'bb-dot offline';
|
||
sta.textContent = 'OFFLINE';
|
||
}
|
||
}
|
||
|
||
// Submit a job to the Arc Reactor and return job_id
|
||
async function arcSubmitJob(type, payload, priority) {
|
||
payload = payload || {};
|
||
priority = priority || 5;
|
||
const d = await api('arc', { action: 'job_create', type: type, payload: payload, priority: priority });
|
||
return d.job_id || null;
|
||
}
|
||
|
||
// Poll a job until done or failed (max 120s), calling onProgress each tick
|
||
async function arcWaitJob(jobId, onProgress) {
|
||
var start = Date.now();
|
||
while (Date.now() - start < 120000) {
|
||
const d = await api('arc?action=job_get&id=' + jobId);
|
||
if (onProgress) onProgress(d);
|
||
if (d.status === 'done') return d;
|
||
if (d.status === 'failed') throw new Error(d.error || 'Job failed');
|
||
await new Promise(function(r){ setTimeout(r, 1500); });
|
||
}
|
||
throw new Error('Arc Reactor job timed out');
|
||
}
|
||
|
||
|
||
// ── INTEL PROTOCOL — HUD panel ────────────────────────────────────────
|
||
let _intelPollTimer = null;
|
||
let _intelActiveJobs = new Set();
|
||
let _intelLastLoad = 0;
|
||
|
||
async function loadIntel() {
|
||
const el = document.getElementById('intel-list');
|
||
if (!el) return;
|
||
_intelLastLoad = Date.now();
|
||
|
||
try {
|
||
// Fetch recent research + tool_loop jobs
|
||
const [resJobs, toolJobs] = await Promise.all([
|
||
api('arc?action=jobs&status=&limit=20').catch(() => []),
|
||
Promise.resolve([]),
|
||
]);
|
||
const jobs = Array.isArray(resJobs) ? resJobs.filter(j => ['research','tool_loop','llm'].includes(j.job_type)) : [];
|
||
|
||
if (!jobs.length) {
|
||
el.innerHTML = '<div class="intel-empty">◈ NO INTEL JOBS<br><span style="opacity:0.5">Say "research [topic]" to activate</span></div>';
|
||
stopIntelPolling();
|
||
return;
|
||
}
|
||
|
||
// Check for active jobs
|
||
const hasActive = jobs.some(j => j.status === 'queued' || j.status === 'running');
|
||
if (hasActive) startIntelPolling(); else stopIntelPolling();
|
||
|
||
let html = '<button class="intel-new-btn" onclick="intelPrompt()">⚡ NEW RESEARCH</button>';
|
||
for (const job of jobs) {
|
||
const isOpen = _intelActiveJobs.has(job.id) || job.status === 'running';
|
||
const statusClass = job.status === 'done' ? 'done' : job.status === 'failed' ? 'failed' : 'running';
|
||
const statusLabel = job.status === 'queued' ? 'QUEUED' : job.status === 'running' ? '● ACTIVE' : job.status.toUpperCase();
|
||
const typeLabel = job.job_type === 'research' ? '◈ INTEL' : job.job_type === 'tool_loop' ? '⚡ IRON' : '◈ LLM';
|
||
|
||
// Get result details if done
|
||
let bodyHtml = '';
|
||
if (job.status === 'done' && job.result) {
|
||
let r = job.result;
|
||
if (typeof r === 'string') { try { r = JSON.parse(r); } catch(e) {} }
|
||
if (typeof r === 'object') {
|
||
const synthesis = (r.synthesis || r.result || r.response || '').trim();
|
||
const sources = r.sources || [];
|
||
const query = r.query || r.task || '';
|
||
const provider = r.provider || '';
|
||
|
||
bodyHtml = `<div class="intel-card-body">`;
|
||
if (provider) bodyHtml += `<div style="font-size:0.55rem;color:var(--text-dim);margin:6px 0 2px;font-family:var(--font-mono)">PROVIDER: ${provider.toUpperCase()} · SOURCES: ${r.source_count||sources.length||'—'}</div>`;
|
||
if (synthesis) bodyHtml += `<div class="synthesis">${escHtml(synthesis.substring(0, 1500))}${synthesis.length>1500?'\n\n[...truncated — view in admin]':''}</div>`;
|
||
if (sources.length) {
|
||
bodyHtml += '<div class="intel-sources"><div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-dim);margin-bottom:4px;font-family:var(--font-display)">SOURCES</div>';
|
||
sources.slice(0,5).forEach((s,i) => {
|
||
const title = escHtml((s.title||s.url||'').substring(0,60));
|
||
const url = escHtml(s.url||'');
|
||
bodyHtml += `<div class="intel-source">${i+1}. <a href="${url}" target="_blank" rel="noopener">${title||url}</a></div>`;
|
||
});
|
||
bodyHtml += '</div>';
|
||
}
|
||
bodyHtml += '</div>';
|
||
}
|
||
} else if (job.status === 'running' || job.status === 'queued') {
|
||
const typeMsg = job.job_type === 'research' ? 'Searching sources and extracting content...' : 'Executing tool loop...';
|
||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--text-dim);padding:8px 0;font-family:var(--font-mono)">${typeMsg}</div></div>`;
|
||
} else if (job.status === 'failed' && job.error) {
|
||
bodyHtml = `<div class="intel-card-body"><div style="font-size:0.62rem;color:var(--red);padding:8px 0;font-family:var(--font-mono)">${escHtml(job.error.substring(0,200))}</div></div>`;
|
||
}
|
||
|
||
const queryText = job.created_by ? job.created_by.replace('chat:', '').replace(/session.*/, '') : '';
|
||
const ts = job.created_at ? new Date(job.created_at).toLocaleTimeString() : '';
|
||
|
||
html += `<div class="intel-card${(isOpen && bodyHtml) ? ' open':''}" id="intel-card-${job.id}">
|
||
<div class="intel-card-head" onclick="toggleIntelCard(${job.id})">
|
||
<span style="font-size:0.55rem;color:var(--text-dim);font-family:var(--font-mono);flex-shrink:0">${typeLabel}</span>
|
||
<span class="intel-card-query">#${job.id} ${escHtml((job.created_by||'').replace('chat:','').substring(0,40))}</span>
|
||
<span style="font-size:0.55rem;color:var(--text-dim);flex-shrink:0;font-family:var(--font-mono)">${ts}</span>
|
||
<span class="intel-card-status ${statusClass}">${statusLabel}</span>
|
||
</div>
|
||
${bodyHtml}
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="intel-empty">INTEL OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleIntelCard(id) {
|
||
const card = document.getElementById('intel-card-' + id);
|
||
if (!card) return;
|
||
if (_intelActiveJobs.has(id)) _intelActiveJobs.delete(id);
|
||
else _intelActiveJobs.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
function startIntelPolling() {
|
||
if (_intelPollTimer) return;
|
||
_intelPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-intel')?.classList.contains('active')) {
|
||
loadIntel();
|
||
}
|
||
}, 4000);
|
||
}
|
||
|
||
function stopIntelPolling() {
|
||
if (_intelPollTimer) { clearInterval(_intelPollTimer); _intelPollTimer = null; }
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function intelPrompt() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'research '; input.focus(); }
|
||
}
|
||
|
||
// Called when arc_job is returned from chat response
|
||
function onArcJobStarted(jobId, jobType) {
|
||
const commsTypes = ['arc:gmail_triage', 'arc:send_email', 'arc:compose_email', 'arc:schedule_event', 'arc:meeting_prep'];
|
||
if (commsTypes.includes(jobType)) {
|
||
const commsBtn = document.getElementById('tab-btn-comms');
|
||
if (commsBtn) commsBtn.click();
|
||
startCommsPolling();
|
||
} else {
|
||
_intelActiveJobs.add(jobId);
|
||
const intelTab = document.querySelector('[onclick*="switchTab(\'intel\')"]');
|
||
if (intelTab) intelTab.click();
|
||
startIntelPolling();
|
||
}
|
||
}
|
||
|
||
// ── COMMS PROTOCOL — email triage HUD ────────────────────────────────────
|
||
let _commsPollTimer = null;
|
||
let _commsFilter = 'priority';
|
||
let _commsOpenCards = new Set();
|
||
|
||
async function loadComms() {
|
||
const el = document.getElementById('comms-list');
|
||
if (!el) return;
|
||
|
||
try {
|
||
const res = await api('arc?action=triage&limit=50&filter=' + _commsFilter);
|
||
const items = Array.isArray(res) ? res : (res.items || []);
|
||
|
||
if (!items.length) {
|
||
el.innerHTML = '<button class="comms-triage-btn" onclick="commsTriageNow()">◈ TRIAGE INBOX NOW</button>'
|
||
+ '<div class="comms-empty">◈ NO TRIAGE DATA<br><span style="opacity:0.5">Say "check my email" to activate</span></div>';
|
||
stopCommsPolling();
|
||
return;
|
||
}
|
||
|
||
const catOrder = {urgent:0, action:1, reply:2, meeting:3, info:4, promo:5, spam:6};
|
||
const catIcons = {urgent:'🔴', action:'⚡', reply:'◈', meeting:'📅', info:'ℹ', promo:'📢', spam:'🗑'};
|
||
|
||
let html = '<div style="display:flex;gap:5px;margin-bottom:5px">';
|
||
html += '<button class="comms-triage-btn" style="flex:3;margin-bottom:0" onclick="commsTriageNow()">◈ TRIAGE INBOX</button>';
|
||
html += '<button class="comms-compose-btn" style="flex:2;margin-bottom:0" onclick="commsShowCompose()">+ COMPOSE</button>';
|
||
html += '</div>';
|
||
html += '<div class="comms-header-bar">';
|
||
for (const [f, label] of [['priority','PRIORITY'],['urgent','URGENT'],['action','ACTION'],['all','ALL']]) {
|
||
html += `<div class="comms-filter-btn${_commsFilter===f?' active':''}" onclick="commsSetFilter('${f}')">${label}</div>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
for (const item of items) {
|
||
const cat = item.category || 'info';
|
||
const icon = catIcons[cat] || '◈';
|
||
const prio = item.priority || 0;
|
||
const isOpen = _commsOpenCards.has(item.id);
|
||
const hasReply = item.draft_reply && item.draft_reply.trim().length > 5;
|
||
|
||
html += `<div class="comms-card${isOpen?' open':''}" id="comms-card-${item.id}">
|
||
<div class="comms-card-head" onclick="toggleCommsCard(${item.id})">
|
||
<span class="comms-card-cat ${cat}">${icon} ${cat.toUpperCase()}</span>
|
||
<span class="comms-card-subject">${escHtml((item.subject||'(no subject)').substring(0,60))}</span>
|
||
<span class="comms-prio">${prio}/10</span>
|
||
</div>
|
||
<div class="comms-card-body">
|
||
<div class="comms-card-from">FROM: ${escHtml((item.from_name||item.from_email||'').substring(0,50))}</div>
|
||
<div class="comms-card-summary">${escHtml(item.summary||'')}</div>
|
||
${hasReply ? `<div class="comms-draft-label">DRAFT REPLY</div><div class="comms-draft" id="comms-draft-${item.id}">${escHtml(item.draft_reply)}</div>` : ''}
|
||
<div style="display:flex;gap:5px;margin-top:8px">
|
||
${hasReply ? `<button class="comms-send-btn" id="comms-send-${item.id}" onclick="commsSendReply(${item.id})">◈ SEND REPLY</button>` : ''}
|
||
${hasReply ? `<button onclick="commsCopyReply(${item.id})" style="flex:1;background:rgba(0,212,255,0.05);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">COPY</button>` : ''}
|
||
<button onclick="commsDismiss(${item.id})" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">DISMISS</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
el.innerHTML = html;
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div class="comms-empty">COMMS OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function toggleCommsCard(id) {
|
||
const card = document.getElementById('comms-card-' + id);
|
||
if (!card) return;
|
||
if (_commsOpenCards.has(id)) _commsOpenCards.delete(id);
|
||
else _commsOpenCards.add(id);
|
||
card.classList.toggle('open');
|
||
}
|
||
|
||
function commsSetFilter(f) {
|
||
_commsFilter = f;
|
||
loadComms();
|
||
}
|
||
|
||
async function commsDismiss(id) {
|
||
await api('arc?action=triage_action&id=' + id, 'POST', {action: 'dismissed'}).catch(() => {});
|
||
loadComms();
|
||
}
|
||
|
||
async function commsCopyReply(id) {
|
||
const draft = document.querySelector(`#comms-draft-${id}`);
|
||
if (draft) {
|
||
navigator.clipboard.writeText(draft.innerText).catch(() => {});
|
||
const btn = document.querySelector(`#comms-card-${id} [onclick*="commsCopyReply"]`);
|
||
if (btn) { btn.textContent = 'COPIED!'; setTimeout(() => btn.textContent = 'COPY', 1500); }
|
||
}
|
||
}
|
||
|
||
async function commsSendReply(id) {
|
||
const btn = document.getElementById('comms-send-' + id);
|
||
const draft = document.getElementById('comms-draft-' + id);
|
||
if (!btn || !draft) return;
|
||
btn.disabled = true;
|
||
btn.textContent = '◈ SENDING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create',
|
||
type: 'send_email',
|
||
payload: { triage_id: id, content: draft.innerText },
|
||
priority: 8,
|
||
});
|
||
if (res.job_id) {
|
||
btn.textContent = '◈ SENT ✓';
|
||
btn.style.color = '#00ff88';
|
||
setTimeout(() => loadComms(), 3000);
|
||
loadCommsOutbox();
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.textContent = '◈ SEND REPLY';
|
||
alert('Send failed: ' + (res.error || 'unknown error'));
|
||
}
|
||
} catch(e) {
|
||
btn.disabled = false;
|
||
btn.textContent = '◈ SEND REPLY';
|
||
}
|
||
}
|
||
|
||
function commsShowCompose() {
|
||
const existing = document.getElementById('comms-compose-modal');
|
||
if (existing) existing.remove();
|
||
const modal = document.createElement('div');
|
||
modal.className = 'comms-compose-modal';
|
||
modal.id = 'comms-compose-modal';
|
||
modal.innerHTML = `
|
||
<div class="comms-compose-inner">
|
||
<div class="comms-compose-title">◈ COMPOSE MESSAGE</div>
|
||
<select id="cc-account" class="comms-compose-field" style="cursor:pointer">
|
||
<option value="gmail">Gmail</option>
|
||
<option value="icloud">iCloud</option>
|
||
</select>
|
||
<input id="cc-to" class="comms-compose-field" placeholder="To: email address" type="email">
|
||
<input id="cc-subject" class="comms-compose-field" placeholder="Subject">
|
||
<textarea id="cc-instructions" class="comms-compose-field" rows="4" placeholder="Describe what to say (AI will draft it)"></textarea>
|
||
<div id="cc-preview" style="display:none">
|
||
<div class="comms-draft-label">DRAFTED MESSAGE</div>
|
||
<div class="comms-draft" id="cc-preview-body" style="max-height:200px"></div>
|
||
</div>
|
||
<div class="comms-compose-actions">
|
||
<button class="comms-send-btn" style="flex:1" onclick="commsComposeDraft()">◈ DRAFT</button>
|
||
<button class="comms-send-btn" style="flex:1;display:none" id="cc-send-btn" onclick="commsComposeAndSend()">◈ SEND NOW</button>
|
||
<button onclick="document.getElementById('comms-compose-modal').remove()" style="flex:1;background:rgba(255,255,255,0.03);border:1px solid var(--panel-border);border-radius:3px;padding:3px 6px;color:var(--text-dim);font-family:var(--font-display);font-size:0.5rem;letter-spacing:1px;cursor:pointer">CANCEL</button>
|
||
</div>
|
||
<div id="cc-status" style="font-family:var(--font-mono);font-size:0.55rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
modal.addEventListener('click', e => { if (e.target === modal) modal.remove(); });
|
||
}
|
||
|
||
let _ccDraftedBody = '';
|
||
|
||
async function commsComposeDraft() {
|
||
const to = document.getElementById('cc-to')?.value.trim();
|
||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||
const instructions = document.getElementById('cc-instructions')?.value.trim();
|
||
const account = document.getElementById('cc-account')?.value;
|
||
const status = document.getElementById('cc-status');
|
||
if (!to || !instructions) { if (status) status.textContent = 'Please fill in To and message description.'; return; }
|
||
if (status) status.textContent = '◈ DRAFTING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create', type: 'compose_email',
|
||
payload: { recipient: to, subject, instructions, account, auto_send: false },
|
||
priority: 7,
|
||
});
|
||
if (!res.job_id) throw new Error(res.error || 'No job');
|
||
// poll for result
|
||
let attempts = 0;
|
||
const poll = async () => {
|
||
const job = await api('arc?action=job_get&id=' + res.job_id);
|
||
if (job.status === 'done' && job.result?.drafted_body) {
|
||
_ccDraftedBody = job.result.drafted_body;
|
||
document.getElementById('cc-preview-body').textContent = _ccDraftedBody;
|
||
document.getElementById('cc-preview').style.display = 'block';
|
||
document.getElementById('cc-send-btn').style.display = '';
|
||
if (status) status.textContent = '◈ DRAFT READY — Review and send';
|
||
} else if (job.status === 'failed') {
|
||
if (status) status.textContent = '✗ Draft failed: ' + (job.error || 'unknown');
|
||
} else if (attempts++ < 20) {
|
||
setTimeout(poll, 1500);
|
||
} else {
|
||
if (status) status.textContent = '◈ Job still running — check INTEL tab';
|
||
}
|
||
};
|
||
setTimeout(poll, 1500);
|
||
} catch(e) {
|
||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function commsComposeAndSend() {
|
||
const to = document.getElementById('cc-to')?.value.trim();
|
||
const subject = document.getElementById('cc-subject')?.value.trim();
|
||
const account = document.getElementById('cc-account')?.value;
|
||
const status = document.getElementById('cc-status');
|
||
const btn = document.getElementById('cc-send-btn');
|
||
if (!to || !_ccDraftedBody) return;
|
||
if (btn) { btn.disabled = true; btn.textContent = '◈ SENDING…'; }
|
||
if (status) status.textContent = '◈ TRANSMITTING…';
|
||
try {
|
||
const res = await api('arc', 'POST', {
|
||
action: 'job_create', type: 'send_email',
|
||
payload: { to_email: to, subject, body: _ccDraftedBody, account },
|
||
priority: 9,
|
||
});
|
||
if (res.job_id) {
|
||
if (status) status.textContent = '◈ SENT ✓ (Job #' + res.job_id + ')';
|
||
setTimeout(() => {
|
||
document.getElementById('comms-compose-modal')?.remove();
|
||
loadCommsOutbox();
|
||
}, 1500);
|
||
} else {
|
||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||
if (status) status.textContent = '✗ Send failed: ' + (res.error || 'unknown');
|
||
}
|
||
} catch(e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '◈ SEND NOW'; }
|
||
if (status) status.textContent = '✗ Error: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function loadCommsOutbox() {
|
||
const el = document.getElementById('comms-outbox');
|
||
if (!el) return;
|
||
try {
|
||
const data = await api('arc?action=comms_sent&limit=20');
|
||
const sent = Array.isArray(data) ? data : (data.sent || []);
|
||
if (!sent.length) {
|
||
el.innerHTML = '<div class="comms-empty" style="padding:10px">No sent messages yet</div>';
|
||
return;
|
||
}
|
||
const statusColor = {sent:'#00ff88', failed:'#ff2244', queued:'#ffd700'};
|
||
let html = '';
|
||
for (const m of sent) {
|
||
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
|
||
const sc = m.status || 'sent';
|
||
html += `<div class="comms-outbox-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<div class="comms-outbox-to">TO: ${escHtml((m.to_email||'').substring(0,40))}</div>
|
||
<span class="comms-outbox-status ${sc}">${sc.toUpperCase()}</span>
|
||
</div>
|
||
<div class="comms-outbox-subj">${escHtml((m.subject||'(no subject)').substring(0,60))}</div>
|
||
<div style="font-family:var(--font-mono);font-size:0.48rem;color:var(--text-dim)">${ts} · ${m.account||'gmail'}</div>
|
||
</div>`;
|
||
}
|
||
el.innerHTML = html;
|
||
} catch(e) {
|
||
el.innerHTML = '<div class="comms-empty" style="padding:10px">OUTBOX OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function commsTriageNow() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'check my email'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||
}
|
||
|
||
function startCommsPolling() {
|
||
if (_commsPollTimer) return;
|
||
_commsPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-comms')?.classList.contains('active')) { loadComms(); loadCommsOutbox(); }
|
||
}, 8000);
|
||
}
|
||
|
||
function stopCommsPolling() {
|
||
if (_commsPollTimer) { clearInterval(_commsPollTimer); _commsPollTimer = null; }
|
||
}
|
||
|
||
// ── GUARDIAN MODE ─────────────────────────────────────────────────────────────
|
||
let _guardianPollTimer = null;
|
||
let _guardianChatTimer = null;
|
||
let _guardianLastChat = '';
|
||
let _guardianUnread = 0;
|
||
|
||
async function loadGuardian() {
|
||
const el = document.getElementById('guardian-list');
|
||
if (!el) return;
|
||
|
||
try {
|
||
const [statusData, eventsData] = await Promise.all([
|
||
api('arc?action=guardian_status').catch(() => ({})),
|
||
api('arc?action=guardian_events&limit=40').catch(() => []),
|
||
]);
|
||
|
||
const events = Array.isArray(eventsData) ? eventsData : [];
|
||
const status = statusData || {};
|
||
const counts = status.counts || {};
|
||
const unread = parseInt(counts.unread || 0);
|
||
const critU = parseInt(counts.critical_unread || 0);
|
||
|
||
_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()
|
||
: '—';
|
||
|
||
let html = `<div style="padding:6px 10px 4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<span style="font-family:var(--font-display);font-size:0.5rem;letter-spacing:2px;color:var(--cyan)">◈ GUARDIAN MODE</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:${status.enabled?'var(--green)':'var(--red)'}">
|
||
${status.enabled ? '● ACTIVE' : '○ INACTIVE'}
|
||
</span>
|
||
<span style="font-family:var(--font-mono);font-size:0.5rem;color:var(--text-dim)">SCAN: ${lastScan}</span>
|
||
${unread ? `<button onclick="guardianAckAll()" class="guardian-ack-btn" style="margin-left:auto">ACK ALL (${unread})</button>` : '<span style="margin-left:auto"></span>'}
|
||
<button onclick="guardianSitrep()" style="background:rgba(0,212,255,0.08);border:1px solid var(--panel-border);color:var(--cyan);padding:3px 7px;border-radius:3px;font-family:var(--font-display);font-size:0.48rem;letter-spacing:1px;cursor:pointer">◈ SITREP</button>
|
||
</div>`;
|
||
|
||
if (!events.length) {
|
||
html += '<div style="text-align:center;padding:24px 10px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim);letter-spacing:1px">◈ ALL CLEAR<br><span style="opacity:0.5">Guardian is watching...</span></div>';
|
||
} else {
|
||
for (const ev of events) {
|
||
const sev = ev.severity || 'info';
|
||
const acked = ev.acknowledged;
|
||
const ts = ev.created_at ? new Date(ev.created_at).toLocaleTimeString() : '';
|
||
const typeIco = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',
|
||
mem_high:'⚡',disk_high:'💾',service_down:'✗',
|
||
service_recovered:'✓',sitrep:'◈',anomaly:'◈'}[ev.event_type] || '◈';
|
||
html += `<div class="guardian-event ${sev}${acked?' acked':''}" id="gev-${ev.id}">
|
||
<span class="guardian-sev ${sev}">${sev.toUpperCase()}</span>
|
||
<div style="flex:1">
|
||
<div class="guardian-msg">${typeIco} ${escHtml(ev.message||'')}</div>
|
||
${ev.ai_analysis ? `<div class="guardian-ai">${escHtml(ev.ai_analysis.substring(0,200))}</div>` : ''}
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0">
|
||
<span class="guardian-time">${ts}</span>
|
||
${!acked ? `<button class="guardian-ack-btn" onclick="guardianAck(${ev.id})">ACK</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
el.innerHTML = html;
|
||
startGuardianPolling();
|
||
|
||
} catch(e) {
|
||
if (el) el.innerHTML = '<div style="text-align:center;padding:20px;font-family:var(--font-mono);font-size:0.6rem;color:var(--text-dim)">GUARDIAN OFFLINE</div>';
|
||
}
|
||
}
|
||
|
||
function _updateGuardianBadge(unread, critical) {
|
||
const dot = document.getElementById('bb-guardian-dot');
|
||
const badge = document.getElementById('bb-guardian-badge');
|
||
const status = document.getElementById('bb-guardian-status');
|
||
if (!dot) return;
|
||
dot.className = 'bb-dot';
|
||
if (critical > 0) {
|
||
dot.classList.add('critical'); status.textContent = 'ALERT'; status.style.color = 'var(--red)';
|
||
} else if (unread > 0) {
|
||
dot.classList.add('warning'); status.textContent = 'WARNING'; status.style.color = '#f5a623';
|
||
} else {
|
||
dot.classList.add('all-clear'); status.textContent = 'CLEAR'; status.style.color = 'var(--green)';
|
||
}
|
||
if (unread > 0) {
|
||
badge.textContent = unread; badge.style.display = 'inline';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function guardianAck(id) {
|
||
await api('arc?action=guardian_ack&id=' + id).catch(() => {});
|
||
const ev = document.getElementById('gev-' + id);
|
||
if (ev) ev.classList.add('acked');
|
||
_guardianUnread = Math.max(0, _guardianUnread - 1);
|
||
_updateGuardianBadge(_guardianUnread, 0);
|
||
}
|
||
|
||
async function guardianAckAll() {
|
||
await api('arc?action=guardian_ack').catch(() => {});
|
||
loadGuardian();
|
||
}
|
||
|
||
function guardianSitrep() {
|
||
const input = document.getElementById('textInput');
|
||
if (input) { input.value = 'sitrep'; input.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',keyCode:13,bubbles:true})); }
|
||
}
|
||
|
||
function switchGuardianTab() {
|
||
const btn = document.getElementById('tab-btn-guardian');
|
||
if (btn) btn.click();
|
||
}
|
||
|
||
function startGuardianPolling() {
|
||
if (_guardianPollTimer) return;
|
||
_guardianPollTimer = setInterval(() => {
|
||
if (document.getElementById('tab-guardian')?.classList.contains('active')) loadGuardian();
|
||
else _refreshGuardianBadge();
|
||
}, 30000);
|
||
}
|
||
|
||
async function _refreshGuardianBadge() {
|
||
const s = await api('arc?action=guardian_status').catch(() => null);
|
||
if (!s) return;
|
||
const counts = s.counts || {};
|
||
_updateGuardianBadge(parseInt(counts.unread||0), parseInt(counts.critical_unread||0));
|
||
}
|
||
|
||
// Proactive chat polling — checks for guardian-injected messages every 30s
|
||
let _proactiveChatLastId = 0;
|
||
async function _pollProactiveChat() {
|
||
try {
|
||
const rows = await api('arc?action=guardian_chat').catch(() => []);
|
||
if (!Array.isArray(rows)) return;
|
||
for (const row of rows) {
|
||
if (row.id > _proactiveChatLastId) {
|
||
_proactiveChatLastId = row.id;
|
||
// Don't spam on first load — only show messages from last 5 min
|
||
const age = Date.now() - new Date(row.created_at + 'Z').getTime();
|
||
if (age < 300000) {
|
||
addMessage('jarvis', row.message);
|
||
speak(row.message);
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|