feat(chat): rich tool cards + voice mode + sitewide broadcast banner

This commit is contained in:
jae 2026-04-20 11:09:08 +00:00
parent 36d1ba6268
commit 5ed5349052
17 changed files with 651 additions and 20 deletions

View file

@ -202,5 +202,6 @@
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -198,3 +198,177 @@
padding: 0;
}
.chat-msg-body li { margin: 0.15rem 0; list-style: disc; }
/* ═══ Phase 4: Rich tool cards ═══ */
.chat-msg-body strong { color: var(--accent, #00ffc8); }
.chat-msg-body em { color: var(--text-dim, #9ab); font-style: italic; }
.chat-msg-body code {
background: rgba(0,255,200,0.08);
padding: 0 0.3rem;
border-radius: 2px;
color: var(--accent, #00ffc8);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.88em;
}
.chat-msg-body pre {
background: rgba(0,0,0,0.5);
padding: 0.6rem;
border-radius: 4px;
overflow-x: auto;
border-left: 2px solid var(--accent, #00ffc8);
margin: 0.4rem 0;
font-size: 0.82em;
color: #dfe;
}
.chat-msg-body a { color: var(--accent, #00ffc8); text-decoration: underline; }
.chat-msg-body h2, .chat-msg-body h3, .chat-msg-body h4 {
color: var(--accent, #00ffc8);
margin: 0.5rem 0 0.25rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
letter-spacing: 0.02em;
}
.chat-msg-body ul, .chat-msg-body ol { margin-left: 1.2rem; }
.tool-card {
margin: 0.4rem 0 0.2rem;
padding: 0.55rem 0.7rem;
background: linear-gradient(180deg, rgba(0,255,200,0.05), rgba(0,255,200,0.02));
border: 1px solid rgba(0,255,200,0.25);
border-left: 3px solid var(--accent, #00ffc8);
border-radius: 4px;
font-size: 0.85em;
color: #dfe;
}
.tool-card .tc-head { font-weight: 600; color: var(--accent, #00ffc8); margin-bottom: 0.3rem; font-family: 'JetBrains Mono', monospace; letter-spacing: 0.02em; }
.tool-card .tc-sub { color: #9ab; font-size: 0.9em; }
.tool-card .tc-sub.pos { color: #14f195; }
.tool-card .tc-sub.neg { color: #ff5577; }
.tool-card .tc-list { list-style: none; padding: 0; margin: 0; }
.tool-card .tc-list li { padding: 0.35rem 0; border-bottom: 1px dashed rgba(0,255,200,0.12); }
.tool-card .tc-list li:last-child { border-bottom: 0; }
.tool-card .tc-tag {
display: inline-block;
background: rgba(0,255,200,0.12);
color: var(--accent, #00ffc8);
padding: 1px 6px;
border-radius: 2px;
font-size: 0.75em;
margin-left: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tool-card .tc-snippet { color: #9ab; font-size: 0.88em; margin-top: 0.15rem; }
.tool-card .tc-link { display: inline-block; margin-top: 0.35rem; color: var(--accent, #00ffc8); font-size: 0.85em; }
.tool-card .tc-metrics { display: flex; gap: 0.9rem; margin-top: 0.3rem; flex-wrap: wrap; }
.tool-card .tc-metric { display: flex; flex-direction: column; align-items: center; }
.tool-card .tc-metric .tc-m-num { font-size: 1.15rem; color: var(--accent, #00ffc8); font-weight: 600; }
.tool-card .tc-metric .tc-m-lbl { font-size: 0.7em; color: #89a; text-transform: uppercase; letter-spacing: 0.08em; }
.tool-card.tool-price { display: flex; align-items: center; gap: 0.6rem; }
.tool-card.tool-price .tc-icon { font-size: 1.3rem; }
.tool-card.tool-price .tc-main { flex: 1; font-size: 1rem; }
.tool-card.tool-price .tc-label { color: #9ab; font-size: 0.8em; margin-right: 0.3rem; }
.tool-card.tool-effect, .tool-card.tool-fortune, .tool-card.tool-stats { padding: 0.5rem 0.7rem; }
.tool-card.tool-ascii pre { background: transparent; color: var(--accent, #00ffc8); border: 0; padding: 0; font-size: 0.75em; line-height: 1.1; }
.tool-card .tc-badge { display: inline-block; padding: 2px 8px; border-radius: 2px; font-size: 0.75em; font-weight: 700; margin-left: 0.5rem; }
.tool-card .tc-badge.taken { background: rgba(255,85,119,0.2); color: #ff5577; }
.tool-card .tc-badge.available { background: rgba(20,241,149,0.2); color: #14f195; }
.tool-card .tc-ver { color: var(--accent, #00ffc8); font-family: 'JetBrains Mono', monospace; font-weight: 600; }
.tool-card .tc-date { color: #678; font-size: 0.8em; margin-left: 0.3rem; }
.tool-card .tc-title { font-weight: 500; margin-top: 0.15rem; }
.atc-details { margin-top: 0.3rem; }
.atc-details summary { cursor: pointer; color: #678; font-size: 0.8em; }
.atc-status.err { color: #ff5577; font-size: 0.9em; }
/* ═══ Phase 4: Voice mode ═══ */
.chat-mic-btn, .chat-voice-settings-btn {
background: transparent;
border: 1px solid rgba(0,255,200,0.3);
color: var(--accent, #00ffc8);
width: 36px; height: 36px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
margin-right: 4px;
transition: all 0.15s;
display: inline-flex; align-items: center; justify-content: center;
}
.chat-mic-btn:hover, .chat-voice-settings-btn:hover {
background: rgba(0,255,200,0.1);
border-color: var(--accent, #00ffc8);
}
.chat-mic-btn.listening {
background: rgba(255,60,90,0.3);
border-color: #ff3c5a;
color: #ff3c5a;
animation: jae-mic-pulse 1s infinite;
}
@keyframes jae-mic-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255,60,90,0.6); }
50% { box-shadow: 0 0 0 6px rgba(255,60,90,0); }
}
.jae-speaking .agent-chat-header::after {
content: '🔊';
margin-left: 0.5rem;
animation: jae-wave 0.9s infinite;
}
@keyframes jae-wave {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.jae-voice-modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 10001;
backdrop-filter: blur(4px);
}
.jae-voice-modal .jvm-panel {
background: #0a0f12;
border: 1px solid var(--accent, #00ffc8);
border-radius: 6px;
padding: 1.2rem 1.4rem;
min-width: 340px;
max-width: 90vw;
color: #dfe;
font-family: 'JetBrains Mono', monospace;
box-shadow: 0 0 30px rgba(0,255,200,0.3);
}
.jvm-head {
color: var(--accent, #00ffc8);
font-weight: 700;
letter-spacing: 0.1em;
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.jvm-close {
background: transparent; border: 0; color: #9ab; cursor: pointer; font-size: 1rem;
}
.jvm-row { display: flex; flex-direction: column; gap: 0.3rem; margin: 0.8rem 0; font-size: 0.85em; }
.jvm-row select, .jvm-row input[type=range] {
background: #05080a;
border: 1px solid rgba(0,255,200,0.25);
color: #dfe;
padding: 0.3rem;
font-family: inherit;
}
.jvm-row input[type=checkbox] { accent-color: var(--accent, #00ffc8); }
.jvm-actions { flex-direction: row; justify-content: flex-end; gap: 0.5rem; }
.jvm-actions button {
background: rgba(0,255,200,0.1);
border: 1px solid var(--accent, #00ffc8);
color: var(--accent, #00ffc8);
padding: 0.4rem 0.9rem;
border-radius: 3px;
cursor: pointer;
font-family: inherit;
}
.jvm-actions button:hover { background: rgba(0,255,200,0.2); }
/* mobile */
@media (max-width: 768px) {
.chat-mic-btn, .chat-voice-settings-btn { width: 32px; height: 32px; font-size: 0.85rem; }
.tool-card { font-size: 0.8em; padding: 0.45rem 0.55rem; }
.tool-card .tc-metrics { gap: 0.6rem; }
}

View file

@ -329,3 +329,47 @@ html.fx-hacker body > *:not(canvas):not(script):not(style) {
animation: none !important;
}
}
/* ═══ Broadcast banner (Phase 3) ═══ */
.jae-broadcast {
position: fixed;
top: 0; left: 0; right: 0;
background: linear-gradient(90deg, #5a0010 0%, #7a001a 50%, #5a0010 100%);
color: #ffecec;
padding: 0.55rem 2.5rem 0.55rem 1rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.85rem;
font-weight: 500;
z-index: 10000;
display: flex;
align-items: center;
gap: 0.6rem;
border-bottom: 1px solid #ff3c5a;
box-shadow: 0 2px 12px rgba(255,60,90,0.4);
animation: jbb-slide-down 0.4s ease-out;
}
@keyframes jbb-slide-down {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
.jae-broadcast .jbb-icon { font-size: 1rem; }
.jae-broadcast .jbb-msg { flex: 1; letter-spacing: 0.02em; }
.jae-broadcast .jbb-close {
background: transparent;
border: 1px solid rgba(255,255,255,0.3);
color: #ffecec;
width: 24px; height: 24px;
border-radius: 3px;
cursor: pointer;
font-size: 0.85rem;
padding: 0;
display: inline-flex; align-items: center; justify-content: center;
}
.jae-broadcast .jbb-close:hover {
background: rgba(255,255,255,0.15);
}
body.has-broadcast { padding-top: 38px; }
@media (max-width: 768px) {
.jae-broadcast { font-size: 0.78rem; padding: 0.45rem 2.2rem 0.45rem 0.8rem; }
body.has-broadcast { padding-top: 44px; }
}

View file

@ -68,5 +68,6 @@
<script src="/js/clock.js"></script>
<script src="/js/contraband.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -65,5 +65,6 @@
<script src="/js/clock.js"></script>
<script src="/js/awesomelist.js?v=20260404"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -68,5 +68,6 @@
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -80,5 +80,6 @@
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -114,5 +114,6 @@
<script src="/js/nav.js"></script>
<script src="/js/leaderboards.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -55,5 +55,6 @@
<script src="/js/clock.js"></script>
<script src="/js/changelog.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -68,5 +68,6 @@
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -228,5 +228,6 @@
<script src="/js/clock.js"></script>
<script src="/js/telemetry.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -596,9 +596,11 @@
<script src="/js/chat-memory.js"></script>
<script src="/js/chat-cli.js"></script>
<script src="/js/chat.js"></script>
<script src="/js/voice-mode.js" defer></script>
<script src="/js/globe.js"></script>
<script src="/js/processes.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="js/broadcast.js" defer></script>
</body>
</html>

53
js/broadcast.js Normal file
View file

@ -0,0 +1,53 @@
// Broadcast banner — polls /api/broadcast/current every 60s (Phase 3/4)
(function () {
'use strict';
const POLL_MS = 60000;
const DISMISS_KEY = 'jae-broadcast-dismissed';
function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
function renderBanner(data) {
const dismissed = localStorage.getItem(DISMISS_KEY);
const fingerprint = (data.created_at || '') + '|' + (data.message || '');
if (dismissed === fingerprint) return;
let el = document.getElementById('jaeBroadcastBanner');
if (!el) {
el = document.createElement('div');
el.id = 'jaeBroadcastBanner';
el.className = 'jae-broadcast';
document.body.appendChild(el);
}
el.innerHTML = `
<span class="jbb-icon">📣</span>
<span class="jbb-msg"></span>
<button type="button" class="jbb-close" aria-label="Dismiss"></button>`;
el.querySelector('.jbb-msg').textContent = data.message || '';
el.querySelector('.jbb-close').onclick = () => {
localStorage.setItem(DISMISS_KEY, fingerprint);
el.remove();
document.body.classList.remove('has-broadcast');
};
document.body.classList.add('has-broadcast');
}
function hideBanner() {
const el = document.getElementById('jaeBroadcastBanner');
if (el) el.remove();
document.body.classList.remove('has-broadcast');
}
async function check() {
try {
const res = await fetch('/api/broadcast/current', { cache: 'no-store' });
if (res.status === 204) { hideBanner(); return; }
if (!res.ok) return;
const data = await res.json();
if (data && data.message) renderBanner(data); else hideBanner();
} catch (e) { /* offline: ignore */ }
}
ready(function () {
check();
setInterval(check, POLL_MS);
});
})();

View file

@ -81,31 +81,175 @@
return body;
}
// ─── Per-tool rich card renderers (Phase 4) ──────────────────────────
const TOOL_RENDERERS = {
get_sol_price: (r) => {
if (r.error) return null;
const pct = r.change_24h_pct || 0;
const cls = pct >= 0 ? 'pos' : 'neg';
const arrow = pct >= 0 ? '▲' : '▼';
return `<div class="tool-card tool-price">
<span class="tc-icon">💰</span>
<div class="tc-main"><span class="tc-label">SOL</span> <strong>$${(r.price_usd||0).toFixed(2)}</strong></div>
<div class="tc-sub ${cls}">${arrow} ${Math.abs(pct).toFixed(2)}%</div>
</div>`;
},
get_crypto_price: (r) => {
if (r.error) return null;
const pct = r.change_24h_pct || 0;
const cls = pct >= 0 ? 'pos' : 'neg';
const arrow = pct >= 0 ? '▲' : '▼';
return `<div class="tool-card tool-price">
<span class="tc-icon">📊</span>
<div class="tc-main"><span class="tc-label">${escapeHtml(r.symbol||'')}</span> <strong>$${(r.price_usd||0).toFixed(4)}</strong></div>
<div class="tc-sub ${cls}">${arrow} ${Math.abs(pct).toFixed(2)}%</div>
</div>`;
},
search_site: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}"><strong>${escapeHtml(x.title||x.source||'')}</strong></a><div class="tc-snippet">${escapeHtml((x.snippet||'').slice(0,140))}</div></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🔎 <strong>${r.results.length}</strong> site results</div><ul class="tc-list">${items}</ul></div>`;
},
search_contraband: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.title||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.category||'')}</span><div class="tc-snippet">${escapeHtml((x.description||'').slice(0,140))}</div></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">📦 <strong>${r.results.length}</strong> CONTRABAND hits</div><ul class="tc-list">${items}</ul></div>`;
},
search_awesomelist: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.name||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.type||'')}</span><div class="tc-snippet">${escapeHtml((x.description||'').slice(0,140))}</div></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🌟 <strong>${r.results.length}</strong> awesome-list hits</div><ul class="tc-list">${items}</ul></div>`;
},
search_unredacted: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.title||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.collection||'unredacted')}</span><div class="tc-snippet">${escapeHtml((x.summary||'').slice(0,140))}</div></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🛸 <strong>${r.results.length}</strong> UNREDACTED hits</div><ul class="tc-list">${items}</ul></div>`;
},
search_crimescene: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.case||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.year||'')}</span><div class="tc-snippet">${escapeHtml((x.summary||'').slice(0,140))}</div></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🩸 <strong>${r.results.length}</strong> cold cases</div><ul class="tc-list">${items}</ul></div>`;
},
search_radar: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 5).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.title||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.source||'')}</span></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">📡 <strong>${r.results.length}</strong> RADAR items</div><ul class="tc-list">${items}</ul></div>`;
},
search_docs: (r) => {
if (!Array.isArray(r.results) || !r.results.length) return null;
const items = r.results.slice(0, 10).map(x =>
`<li><a href="${escapeHtml(x.url||'#')}" target="_blank" rel="noopener"><strong>${escapeHtml(x.title||x.case||'')}</strong></a> <span class="tc-tag">${escapeHtml(x.source||'')}</span></li>`
).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🗂 <strong>${r.results.length}</strong> doc hits (unified)</div><ul class="tc-list">${items}</ul></div>`;
},
get_changelog: (r) => {
if (!Array.isArray(r.entries) || !r.entries.length) return null;
const items = r.entries.slice(0, 8).map(e =>
`<li><span class="tc-ver">v${escapeHtml(e.version||'')}</span> <span class="tc-tag">${escapeHtml(e.category||'')}</span> <span class="tc-date">${escapeHtml(e.date||'')}</span><div class="tc-title">${escapeHtml(e.title||'')}</div></li>`
).join('');
return `<div class="tool-card tool-changelog"><div class="tc-head">📜 Last <strong>${r.entries.length}</strong> of ${r.total_available||'?'}</div><ul class="tc-list">${items}</ul></div>`;
},
wallet_xray: (r) => {
if (r.error) return null;
return `<div class="tool-card tool-wallet">
<div class="tc-head">👁 Wallet X-Ray <code>${escapeHtml((r.address||'').slice(0,4))}${escapeHtml((r.address||'').slice(-4))}</code></div>
<div class="tc-metrics">
<div class="tc-metric"><span class="tc-m-num">${r.balance_sol ?? '?'}</span><span class="tc-m-lbl">SOL</span></div>
<div class="tc-metric"><span class="tc-m-num">${r.non_zero_tokens ?? '?'}</span><span class="tc-m-lbl">Tokens</span></div>
<div class="tc-metric"><span class="tc-m-num">${(r.recent_txs||[]).length}</span><span class="tc-m-lbl">Recent TXs</span></div>
</div>
${r.solscan_url ? `<a class="tc-link" href="${escapeHtml(r.solscan_url)}" target="_blank" rel="noopener">solscan →</a>` : ''}
</div>`;
},
get_my_wallet_summary: (r) => (TOOL_RENDERERS.wallet_xray ? TOOL_RENDERERS.wallet_xray(r) : null),
lookup_sol_domain: (r) => {
if (r.error) return null;
const badge = r.registered ? `<span class="tc-badge taken">TAKEN</span>` : `<span class="tc-badge available">AVAILABLE</span>`;
const link = r.register_url || r.solscan_url;
return `<div class="tool-card tool-domain">
<div class="tc-head">🌐 <strong>${escapeHtml(r.name||'')}</strong> ${badge}</div>
${r.owner ? `<div class="tc-sub">owner: <code>${escapeHtml((r.owner||'').slice(0,4))}${escapeHtml((r.owner||'').slice(-4))}</code></div>` : ''}
${link ? `<a class="tc-link" href="${escapeHtml(link)}" target="_blank" rel="noopener">sns.id →</a>` : ''}
</div>`;
},
trigger_effect: (r) => {
if (!r.effect) return null;
return `<div class="tool-card tool-effect">⚡ Effect <strong>${escapeHtml(r.effect)}</strong> triggered</div>`;
},
get_server_status: (r) => {
if (r.error) return null;
return `<div class="tool-card tool-server">
<div class="tc-head">🖥 Server status</div>
<div class="tc-metrics">
<div class="tc-metric"><span class="tc-m-num">${(r.cpu_pct ?? 0).toFixed(1)}%</span><span class="tc-m-lbl">CPU</span></div>
<div class="tc-metric"><span class="tc-m-num">${(r.ram_pct ?? 0).toFixed(1)}%</span><span class="tc-m-lbl">RAM</span></div>
<div class="tc-metric"><span class="tc-m-num">${(r.disk_pct ?? 0).toFixed(1)}%</span><span class="tc-m-lbl">Disk</span></div>
</div>
</div>`;
},
random_fortune: (r) => r.fortune ? `<div class="tool-card tool-fortune">🔮 <em>${escapeHtml(r.fortune)}</em></div>` : null,
ascii_banner: (r) => r.banner ? `<div class="tool-card tool-ascii"><pre>${escapeHtml(r.banner)}</pre></div>` : null,
get_gov_domains_stats: (r) => r.error ? null : `<div class="tool-card tool-stats">🏛️ Gov domains: <strong>${r.total_domains}</strong> tracked · <strong>${r.added_last_24h}</strong> new in 24h</div>`,
get_leaderboards: (r) => {
if (r.error) return null;
const top = (r.top_countries||[]).slice(0,3).map(c => `<li>${escapeHtml(c.country||c.code||'')} <span class="tc-num">${c.count||c.visits||0}</span></li>`).join('');
return `<div class="tool-card tool-stats"><div class="tc-head">🏆 Top countries</div><ul class="tc-list">${top}</ul></div>`;
},
get_network_graph_data: (r) => `<div class="tool-card tool-stats">🌐 Recent arcs: <strong>${r.total_arcs||0}</strong> · Top: ${(r.top_countries||[]).slice(0,3).map(c=>escapeHtml(c.code)).join(', ')}</div>`,
get_guestbook: (r) => {
if (!Array.isArray(r.entries) || !r.entries.length) return `<div class="tool-card tool-stats">📖 Guestbook is empty</div>`;
const items = r.entries.slice(0, 5).map(e => `<li><code>${escapeHtml(e.truncated_address||'')}</code> <span class="tc-snippet">${escapeHtml((e.message||'').slice(0,140))}</span></li>`).join('');
return `<div class="tool-card tool-search"><div class="tc-head">📖 Guestbook — ${r.entries.length} entries</div><ul class="tc-list">${items}</ul></div>`;
},
list_memories: (r) => {
if (!Array.isArray(r.memories) || !r.memories.length) return `<div class="tool-card tool-stats">🧠 No memories stored yet</div>`;
const items = r.memories.slice(-10).map(m => `<li>#${m.id} · ${escapeHtml((m.fact||'').slice(0,140))}</li>`).join('');
return `<div class="tool-card tool-search"><div class="tc-head">🧠 ${r.memories.length} memories</div><ul class="tc-list">${items}</ul></div>`;
},
save_memory: (r) => r.saved ? `<div class="tool-card tool-stats">🧠 Memory saved (#${r.id} · total ${r.total})</div>` : null,
delete_memory: (r) => r.deleted ? `<div class="tool-card tool-stats">🗑️ Forgot: <em>${escapeHtml(r.removed_fact||'')}</em></div>` : null,
get_sitrep: (r) => r.error ? null : `<div class="tool-card tool-changelog"><div class="tc-head">📋 SITREP <strong>${escapeHtml(r.date||'')}</strong></div><div class="tc-snippet">${escapeHtml((r.summary||'').slice(0,240))}</div><a class="tc-link" href="/transmissions/sitrep">view full →</a></div>`,
};
function addToolCallCard(call) {
const wrap = document.createElement('div');
wrap.className = 'agent-tool-call';
const argsStr = escapeHtml(JSON.stringify(call.args || {}, null, 0));
const result = call.result || {};
const errored = !!result.error;
const statusIcon = errored ? '❌' : '✅';
let summary = errored ? (result.error || 'error') : 'ok';
if (!errored) {
if (Array.isArray(result.results)) summary = `${result.results.length} result${result.results.length === 1 ? '' : 's'}`;
else if (Array.isArray(result.entries)) summary = `${result.entries.length} entries`;
else if (typeof result.price_usd === 'number') summary = `$${result.price_usd.toFixed(2)} (${result.change_24h_pct > 0 ? '+' : ''}${(result.change_24h_pct || 0).toFixed(2)}%)`;
else if (result.effect) summary = `effect: ${result.effect}`;
else if (result.balance_sol !== undefined) summary = `${result.balance_sol} SOL`;
}
const resultStr = escapeHtml(JSON.stringify(result, null, 2).slice(0, 1400));
wrap.innerHTML = `
<div class="atc-header">
<span class="atc-icon">🔧</span>
const argsStr = escapeHtml(JSON.stringify(call.args || {}, null, 0));
// Try rich renderer first
let richBody = '';
try {
const renderer = TOOL_RENDERERS[call.name];
if (renderer && !errored) richBody = renderer(result) || '';
} catch (e) { console.warn('tool renderer error', call.name, e); }
// Header always shown
const header = `<div class="atc-header">
<span class="atc-icon">${errored ? '❌' : '🔧'}</span>
<span class="atc-name">${escapeHtml(call.name)}</span>
<span class="atc-args">(${argsStr})</span>
</div>
<div class="atc-status"> ${statusIcon} ${escapeHtml(summary)}</div>
<details class="atc-details"><summary> (click to expand)</summary><pre>${resultStr}</pre></details>
`;
</div>`;
const resultStr = escapeHtml(JSON.stringify(result, null, 2).slice(0, 1600));
const fallback = errored
? `<div class="atc-status err">└─ ❌ ${escapeHtml(result.error || 'error')}</div>`
: `<details class="atc-details"><summary>└─ raw payload</summary><pre>${resultStr}</pre></details>`;
wrap.innerHTML = header + (richBody || '') + fallback;
chatMessages.appendChild(wrap);
chatMessages.scrollTop = chatMessages.scrollHeight;
}

203
js/voice-mode.js Normal file
View file

@ -0,0 +1,203 @@
// JAE-AI Voice Mode — Web Speech API (Phase 4)
// Adds mic button + auto-speak of assistant replies.
(function () {
'use strict';
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const SY = window.speechSynthesis;
if (!SR || !SY) { console.warn('[voice] Web Speech API unsupported in this browser'); return; }
// Wait for DOM ready
function ready(fn) { if (document.readyState !== 'loading') fn(); else document.addEventListener('DOMContentLoaded', fn); }
const LS_AUTO = 'jae-voice-auto-speak';
const LS_VOICE = 'jae-voice-name';
const LS_RATE = 'jae-voice-rate';
const LS_LANG = 'jae-voice-lang';
let recognition = null;
let recognizing = false;
let silenceTimer = null;
let finalBuffer = '';
let voices = [];
function loadVoices() {
voices = SY.getVoices();
if (!voices.length) setTimeout(loadVoices, 250);
}
if (typeof SY.onvoiceschanged !== 'undefined') SY.onvoiceschanged = loadVoices;
loadVoices();
function stripMarkdownForSpeech(txt) {
if (!txt) return '';
return txt
.replace(/```[\s\S]*?```/g, ' code block ')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[#>_~]/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function chooseVoice() {
const preferName = localStorage.getItem(LS_VOICE);
const lang = localStorage.getItem(LS_LANG) || 'en-GB';
if (preferName) {
const m = voices.find(v => v.name === preferName);
if (m) return m;
}
const byLang = voices.find(v => v.lang === lang) || voices.find(v => v.lang.startsWith('en'));
return byLang || voices[0] || null;
}
function speak(text) {
if (!text) return;
try { SY.cancel(); } catch (e) {}
const clean = stripMarkdownForSpeech(text).slice(0, 600);
if (!clean) return;
const utter = new SpeechSynthesisUtterance(clean);
const voice = chooseVoice();
if (voice) { utter.voice = voice; utter.lang = voice.lang; }
utter.rate = parseFloat(localStorage.getItem(LS_RATE) || '1.05');
utter.pitch = 1.0;
utter.onstart = () => document.body.classList.add('jae-speaking');
utter.onend = () => document.body.classList.remove('jae-speaking');
utter.onerror = () => document.body.classList.remove('jae-speaking');
SY.speak(utter);
}
function findInput() {
return document.getElementById('chatInput') || document.querySelector('.chat-input textarea, .chat-input input');
}
function findSendBtn() {
return document.getElementById('chatSendBtn') || document.querySelector('.chat-send-btn, button[data-chat-send]');
}
function submitChat() {
const input = findInput();
const send = findSendBtn();
if (input && (input.value || '').trim() && send) send.click();
}
function setListeningUI(on) {
const btn = document.getElementById('chatMicBtn');
if (btn) btn.classList.toggle('listening', on);
document.body.classList.toggle('jae-listening', on);
}
function startListening() {
if (recognizing) return;
try {
recognition = new SR();
recognition.lang = localStorage.getItem(LS_LANG) || 'en-GB';
recognition.continuous = true;
recognition.interimResults = true;
finalBuffer = '';
recognition.onstart = () => { recognizing = true; setListeningUI(true); };
recognition.onresult = (ev) => {
let interim = '';
for (let i = ev.resultIndex; i < ev.results.length; i++) {
const r = ev.results[i];
if (r.isFinal) finalBuffer += r[0].transcript + ' ';
else interim += r[0].transcript;
}
const input = findInput();
if (input) input.value = (finalBuffer + interim).trim();
// auto-submit after 1.5s of silence (no new results)
if (silenceTimer) clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
stopListening();
setTimeout(submitChat, 120);
}, 1500);
};
recognition.onerror = (e) => { console.warn('[voice] err', e.error); stopListening(); };
recognition.onend = () => { recognizing = false; setListeningUI(false); };
recognition.start();
} catch (e) {
console.warn('[voice] start failed', e);
}
}
function stopListening() {
if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
try { if (recognition) recognition.stop(); } catch (e) {}
recognizing = false;
setListeningUI(false);
}
function injectMicButton() {
const send = findSendBtn();
if (!send || document.getElementById('chatMicBtn')) return false;
const mic = document.createElement('button');
mic.id = 'chatMicBtn';
mic.className = 'chat-mic-btn';
mic.type = 'button';
mic.title = 'Voice mode (click to talk, auto-submits after 1.5s silence)';
mic.innerHTML = '🎙';
mic.addEventListener('click', () => { recognizing ? stopListening() : startListening(); });
send.parentNode.insertBefore(mic, send);
const gear = document.createElement('button');
gear.id = 'chatVoiceSettingsBtn';
gear.className = 'chat-voice-settings-btn';
gear.type = 'button';
gear.title = 'Voice settings';
gear.innerHTML = '⚙';
gear.addEventListener('click', openSettingsModal);
send.parentNode.insertBefore(gear, send);
return true;
}
function openSettingsModal() {
if (document.getElementById('jaeVoiceModal')) return;
const vs = (voices.length ? voices : SY.getVoices()).filter(v => (v.lang || '').toLowerCase().startsWith('en'));
const curName = localStorage.getItem(LS_VOICE) || '';
const curRate = parseFloat(localStorage.getItem(LS_RATE) || '1.05');
const autoSpeak = localStorage.getItem(LS_AUTO) === '1';
const overlay = document.createElement('div');
overlay.id = 'jaeVoiceModal';
overlay.className = 'jae-voice-modal';
overlay.innerHTML = `
<div class="jvm-panel">
<div class="jvm-head">VOICE SETTINGS<button class="jvm-close" type="button"></button></div>
<label class="jvm-row"><input type="checkbox" id="jvm-auto" ${autoSpeak ? 'checked' : ''}> Auto-speak agent replies</label>
<label class="jvm-row">Voice<select id="jvm-voice">${vs.map(v => `<option value="${v.name}" ${v.name === curName ? 'selected' : ''}>${v.name} (${v.lang})</option>`).join('')}</select></label>
<label class="jvm-row">Rate <span id="jvm-rate-val">${curRate.toFixed(2)}</span><input type="range" id="jvm-rate" min="0.8" max="1.3" step="0.05" value="${curRate}"></label>
<div class="jvm-row jvm-actions">
<button type="button" id="jvm-test"> Test voice</button>
<button type="button" id="jvm-save">Save</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.jvm-close').onclick = close;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const rateEl = overlay.querySelector('#jvm-rate');
const rateVal = overlay.querySelector('#jvm-rate-val');
rateEl.oninput = () => { rateVal.textContent = parseFloat(rateEl.value).toFixed(2); };
overlay.querySelector('#jvm-test').onclick = () => {
localStorage.setItem(LS_VOICE, overlay.querySelector('#jvm-voice').value);
localStorage.setItem(LS_RATE, rateEl.value);
speak('Voice check. JAE-AI online. All systems nominal.');
};
overlay.querySelector('#jvm-save').onclick = () => {
localStorage.setItem(LS_AUTO, overlay.querySelector('#jvm-auto').checked ? '1' : '0');
localStorage.setItem(LS_VOICE, overlay.querySelector('#jvm-voice').value);
localStorage.setItem(LS_RATE, rateEl.value);
close();
};
}
ready(function () {
// Try inject now; if chat not yet rendered, observe.
if (!injectMicButton()) {
const obs = new MutationObserver(() => { if (injectMicButton()) obs.disconnect(); });
obs.observe(document.body, { childList: true, subtree: true });
}
document.addEventListener('jae-agent-reply', (e) => {
if (localStorage.getItem(LS_AUTO) === '1') speak(e.detail && e.detail.text);
});
});
window.__jaeVoice = { speak, start: startListening, stop: stopListening, openSettings: openSettingsModal };
})();

View file

@ -10,5 +10,6 @@
<body style="background:#0a0a0c;">
<p>Redirecting to <a href="/depot/recon">RECON</a>...</p>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>

View file

@ -141,5 +141,6 @@
<script src="/js/clock.js"></script>
<script src="/js/radar.js?v=20260415"></script>
<script src="/js/sitewide-effects.js" defer></script>
<script src="../js/broadcast.js" defer></script>
</body>
</html>