Add live voice transcript, keyboard shortcuts, agent topology map

#3 Live Voice Transcript: Real-time subtitle bar at bottom of screen shows
what JARVIS hears as you speak. Interim results appear word-by-word via
SpeechRecognition.onresult interim events; bar fades 3.2s after final result.

#4 Keyboard Shortcuts: Global keydown handler (skips input fields):
F5=refresh all, Esc=close modals/overlays, M=mute mic toggle,
Space=focus chat input, 1/2/3/4=switch HOME/ALERTS/NEWS/AGENTS tabs.
Shortcut hints added to Ctrl+K palette footer.

#5 Agent Topology Map: TOPOLOGY button in AGENTS tab switches from card
view to animated ring-based canvas showing all agents by type (Proxmox=green
inner ring, HA=yellow mid ring, Linux/Windows=blue outer ring). Live particles
flow hub→agents; offline nodes shown in red. Reads from rendered agent cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 03:32:48 +00:00
parent b014fd96ab
commit 2c712a4fc6
4 changed files with 180 additions and 4 deletions
+45
View File
@@ -1216,6 +1216,11 @@ function initVoice() {
recognition.onresult = (e) => {
if (isSpeaking) return;
let interimText = '';
for (let ri = e.resultIndex; ri < e.results.length; ri++) {
if (!e.results[ri].isFinal) interimText += e.results[ri][0].transcript;
}
if (interimText && voiceMode && !voiceMuted) _showInterimTranscript(interimText);
const transcript = (e.results[0][0].transcript || '').trim();
if (!transcript) return;
const lc = transcript.toLowerCase();
@@ -1269,9 +1274,23 @@ function initVoice() {
};
}
let _transcriptTimer = null;
function _showTranscript(text) {
const el = document.getElementById('textInput');
if (el) { el.placeholder = '▶ ' + text.substring(0, 60); setTimeout(() => { el.placeholder = 'Enter command or speak to JARVIS...'; }, 3000); }
const bar = document.getElementById('voiceTranscriptBar');
if (!bar) return;
bar.textContent = text;
bar.classList.add('vt-active');
if (_transcriptTimer) clearTimeout(_transcriptTimer);
_transcriptTimer = setTimeout(() => { bar.classList.remove('vt-active'); bar.textContent = ''; }, 3200);
}
function _showInterimTranscript(text) {
const bar = document.getElementById('voiceTranscriptBar');
if (!bar || !text) return;
bar.textContent = text + '…';
bar.classList.add('vt-active');
if (_transcriptTimer) clearTimeout(_transcriptTimer);
}
function enterVoiceMode(source) {
@@ -1527,3 +1546,29 @@ async function triggerMorningBriefing() {
speak(msg);
} catch(e) {}
}
// ── KEYBOARD SHORTCUTS ───────────────────────────────────────────────────────────────
document.addEventListener('keydown', function(e) {
const tag = (document.activeElement?.tagName || '').toLowerCase();
const inInput = tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable;
if ((e.ctrlKey || e.metaKey) && e.key === 'k') return; // handled by palette
if (e.key === 'Escape') {
['sitesModal','agentModal','searchModal'].forEach(id => {
const el = document.getElementById(id);
if (el && (el.style.display === 'flex' || el.style.display === 'block')) el.style.display = 'none';
});
if (document.getElementById('netMapOverlay')?.classList.contains('nm-open')) closeNetMap();
return;
}
if (inInput) return;
if (e.key === 'F5') { e.preventDefault(); refreshAll(); return; }
if (e.key === 'm' || e.key === 'M') { toggleVoice(); 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]) {
document.querySelectorAll('.tab').forEach(t => {
const oc = t.getAttribute('onclick') || '';
if (oc.includes("'" + tabMap[e.key] + "'")) t.click();
});
}
});
+117
View File
@@ -1543,3 +1543,120 @@ document.getElementById('cmdPaletteInput')?.addEventListener('input', e => {
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();
}