feat(chat): rich tool cards + voice mode + sitewide broadcast banner
This commit is contained in:
parent
36d1ba6268
commit
5ed5349052
17 changed files with 651 additions and 20 deletions
|
|
@ -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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
53
js/broadcast.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
184
js/chat.js
184
js/chat.js
|
|
@ -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>
|
||||
<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>
|
||||
`;
|
||||
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>`;
|
||||
|
||||
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
203
js/voice-mode.js
Normal 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 };
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue