mirror of
https://github.com/myronblair/jarvis
synced 2026-07-28 16:52:46 -05:00
Adopt live production state as git baseline
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
// ── 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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user