jaeswift-website/js/chat.js

740 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===================================================
JAESWIFT.XYZ — JAE-AI Agentic Chat Terminal
Tiered model routing + tool calls + wallet auth
=================================================== */
(function () {
'use strict';
const chatMessages = document.getElementById('chatMessages');
const chatInput = document.getElementById('chatInput');
const chatSend = document.getElementById('chatSend');
const chatStatus = document.getElementById('chatStatus');
const chatHeader = document.querySelector('.chat-terminal .panel-header');
if (!chatMessages || !chatInput || !chatSend) return;
const mem = window.chatMemory || null;
let history = [];
let isWaiting = false;
let currentTier = 'anonymous';
let currentAddress = null;
// ─── Utility ──────────────────────────────────────
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
// ─── Minimal safe Markdown → HTML (assistant messages only) ──
function md(text) {
if (!text) return '';
let html = String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Fenced code blocks ```...```
html = html.replace(/```([\s\S]*?)```/g, function (_m, code) {
return '<pre><code>' + code + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`\n]+)`/g, '<code>$1</code>');
// Bold **...**
html = html.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
// Italic *...* (avoid double-asterisk leftovers)
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Headings
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
// Bullet list lines: "- item" / "* item" → <li>
html = html.replace(/^(?:-|\*) (.+)$/gm, '<li>$1</li>');
html = html.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>');
// Line breaks (avoid inside <pre>)
html = html.replace(/(<pre>[\s\S]*?<\/pre>)|\n/g, function (m, pre) {
return pre ? pre : '<br>';
});
return html;
}
// ─── Render a message bubble ──────────────────────
function addMessage(role, text) {
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
const msg = document.createElement('div');
msg.className = `chat-msg chat-msg-${role}`;
const label = document.createElement('span');
label.className = 'chat-msg-label';
label.textContent = role === 'user' ? 'YOU' : 'JAE-AI';
const body = document.createElement('div');
body.className = 'chat-msg-body';
if (role === 'assistant') { body.innerHTML = md(text); } else { body.textContent = text; }
msg.appendChild(label);
msg.appendChild(body);
chatMessages.appendChild(msg);
chatMessages.scrollTop = chatMessages.scrollHeight;
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 result = call.result || {};
const errored = !!result.error;
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;
}
// ─── Typing indicator ─────────────────────────────
function showTyping() {
const indicator = document.createElement('div');
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
indicator.id = 'chatTyping';
indicator.innerHTML = `
<span class="chat-msg-label">JAE-AI</span>
<div class="chat-msg-body">
<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
</div>`;
chatMessages.appendChild(indicator);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('chatTyping');
if (el) el.remove();
}
// ─── Typewriter effect ────────────────────────────
function typewriterEffect(element, text, speed) {
speed = speed || 10;
element.innerHTML = '';
// Bypass animation for long replies — render full markdown immediately
if (!text || text.length > 600) {
element.innerHTML = md(text || '');
chatMessages.scrollTop = chatMessages.scrollHeight;
return Promise.resolve();
}
let i = 0;
return new Promise(function (resolve) {
function tick() {
if (i < text.length) {
i++;
element.innerHTML = md(text.slice(0, i));
chatMessages.scrollTop = chatMessages.scrollHeight;
setTimeout(tick, speed);
} else {
// Dispatch event so voice-mode / others can hook
try { document.dispatchEvent(new CustomEvent('jae-agent-reply', { detail: { text: text } })); } catch (e) {}
resolve();
}
}
tick();
});
}
// ─── History restore ──────────────────────────────
function restoreHistory() {
if (!mem) return false;
const saved = mem.getHistory();
if (!saved || saved.length === 0) return false;
const welcome = chatMessages.querySelector('.chat-welcome');
if (welcome) welcome.remove();
saved.forEach(function (m) {
const role = m.role === 'user' ? 'user' : 'assistant';
const label = document.createElement('span');
label.className = 'chat-msg-label';
label.textContent = role === 'user' ? 'YOU' : 'JAE-AI';
const body = document.createElement('div');
body.className = 'chat-msg-body';
if (role === 'assistant') { body.innerHTML = md(m.content || ''); } else { body.textContent = m.content || ''; }
const el = document.createElement('div');
el.className = `chat-msg chat-msg-${role}`;
el.appendChild(label);
el.appendChild(body);
chatMessages.appendChild(el);
});
const divider = document.createElement('div');
divider.className = 'chat-divider';
divider.innerHTML = '<span>— RESTORED FROM MEMORY —</span>';
chatMessages.appendChild(divider);
chatMessages.scrollTop = chatMessages.scrollHeight;
history = saved.slice();
return true;
}
// ─── Tier badge ───────────────────────────────────
const TIER_DISPLAY = {
anonymous: { label: 'ANONYMOUS', icon: '👁' },
operator: { label: 'OPERATOR', icon: '🎖️' },
elite: { label: 'ELITE', icon: '⭐' },
admin: { label: 'ADMIN', icon: '🛠️' },
};
function renderTierBadge() {
if (!chatHeader) return;
let badge = document.getElementById('chatTierBadge');
if (!badge) {
badge = document.createElement('span');
badge.id = 'chatTierBadge';
badge.className = 'tier-badge';
chatHeader.appendChild(badge);
}
const info = TIER_DISPLAY[currentTier] || TIER_DISPLAY.anonymous;
badge.className = 'tier-badge tier-' + currentTier;
badge.textContent = info.icon + ' ' + info.label;
badge.title = currentAddress ? (currentAddress.slice(0, 4) + '…' + currentAddress.slice(-4)) : 'Not authenticated';
}
// ─── Wallet auth handshake ────────────────────────
async function refreshWhoAmI() {
try {
const r = await fetch('/api/auth/whoami', { credentials: 'same-origin' });
if (!r.ok) return;
const j = await r.json();
currentTier = j.tier || 'anonymous';
currentAddress = j.address || null;
renderTierBadge();
} catch (e) { /* silent */ }
}
async function walletSignIn(address, provider) {
if (!address || !provider) return;
try {
// 1. Get nonce
const nr = await fetch('/api/auth/nonce', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
});
if (!nr.ok) { console.warn('nonce fetch failed'); return; }
const { message, nonce } = await nr.json();
// 2. Sign message
const encoded = new TextEncoder().encode(message);
let sigBytes;
if (typeof provider.signMessage === 'function') {
const res = await provider.signMessage(encoded, 'utf8');
sigBytes = res && (res.signature || res);
} else if (provider.features && provider.features['solana:signMessage']) {
const feat = provider.features['solana:signMessage'];
const res = await feat.signMessage({ message: encoded });
sigBytes = Array.isArray(res) ? res[0].signature : res.signature;
} else {
console.warn('wallet does not support signMessage');
return;
}
if (!sigBytes) return;
// sigBytes -> base58
const sigB58 = bs58encode(sigBytes);
// 3. Verify
const vr = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ address, signature: sigB58, nonce }),
});
if (!vr.ok) {
const err = await vr.json().catch(() => ({}));
console.warn('verify failed:', err);
return;
}
const j = await vr.json();
currentTier = j.tier || 'operator';
currentAddress = j.address || address;
renderTierBadge();
const info = TIER_DISPLAY[currentTier];
addMessage('assistant', `🔓 Wallet verified. Tier: ${info.icon} ${info.label}. Model: ${j.model || 'default'}.`);
} catch (e) {
console.warn('walletSignIn error', e);
}
}
// Minimal bs58 encoder (no external deps)
const BS58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function bs58encode(bytes) {
if (!bytes) return '';
if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes);
const digits = [0];
for (let i = 0; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j++) {
carry += digits[j] << 8;
digits[j] = carry % 58;
carry = (carry / 58) | 0;
}
while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; }
}
let str = '';
for (let k = 0; k < bytes.length && bytes[k] === 0; k++) str += '1';
for (let q = digits.length - 1; q >= 0; q--) str += BS58_ALPHABET[digits[q]];
return str;
}
// Listen to wallet-connect events
window.addEventListener('wallet-connected', function (e) {
const detail = e.detail || {};
const addr = detail.address || (window.solWallet && window.solWallet.address);
const provider = detail.provider || (window.solWallet && window.solWallet.provider);
if (addr && provider) walletSignIn(addr, provider);
});
window.addEventListener('wallet-disconnected', async function () {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (e) {}
currentTier = 'anonymous';
currentAddress = null;
renderTierBadge();
});
// ─── Send message to agent ────────────────────────
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isWaiting) return;
// CLI interception (slash commands, or nested CLI mode)
const inCliMode = window.__jaeCLI && typeof window.__jaeCLI.isInMode === 'function' && window.__jaeCLI.isInMode();
if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') {
isWaiting = true;
chatInput.value = '';
const userBubble = addMessage('user', text);
const userMsg = userBubble && userBubble.parentElement;
if (userMsg) userMsg.classList.add('chat-msg-user-cli');
try {
const res = await window.__jaeCLI.handle(text);
if (res && res.handled) {
if (res.output) {
const body = addMessage('assistant', '');
const msgEl = body && body.parentElement;
if (msgEl) msgEl.classList.add('chat-msg-cli');
body.innerHTML = md(res.output);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
} catch (e) {
const body = addMessage('assistant', 'cli error: ' + e.message);
const msgEl = body && body.parentElement;
if (msgEl) msgEl.classList.add('chat-msg-cli');
}
isWaiting = false;
chatInput.focus();
return;
}
isWaiting = true;
chatInput.value = '';
chatStatus.textContent = '● PROCESSING';
chatStatus.classList.remove('status-green');
chatStatus.classList.add('status-amber');
addMessage('user', text);
history.push({ role: 'user', content: text });
if (mem) mem.appendHistory({ role: 'user', content: text });
showTyping();
// Build conversation window (last 20 exchanges)
const convo = history.slice(-20).map(function (m) {
return { role: m.role, content: m.content };
});
const memoryBlock = mem ? mem.getContextBlock(text) : '';
try {
const resp = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
messages: convo,
memory_context: memoryBlock,
}),
});
hideTyping();
if (!resp.ok) {
const err = await resp.json().catch(function () { return {}; });
addMessage('assistant', 'ERROR: ' + (err.error || ('HTTP ' + resp.status)));
chatStatus.textContent = '● ERROR';
chatStatus.classList.remove('status-amber');
chatStatus.classList.add('status-red');
isWaiting = false;
return;
}
const data = await resp.json();
// Update tier from response
if (data.tier) { currentTier = data.tier; renderTierBadge(); }
// Render tool calls
if (Array.isArray(data.tool_calls)) {
data.tool_calls.forEach(addToolCallCard);
}
const reply = data.content || '(no reply)';
history.push({ role: 'assistant', content: reply });
if (mem) mem.appendHistory({ role: 'assistant', content: reply });
if (history.length > 40) history = history.slice(-30);
const bodyEl = addMessage('assistant', '');
bodyEl.parentElement.classList.add('chat-msg-agent');
bodyEl.parentElement.setAttribute('data-model', data.model_used || '');
await typewriterEffect(bodyEl, reply, 8);
// Execute frontend actions (effects etc.)
if (Array.isArray(data.frontend_actions)) {
data.frontend_actions.forEach(function (act) {
if (act.action === 'trigger_effect' && window.__jaeEffects && typeof window.__jaeEffects.toggle === 'function') {
try { window.__jaeEffects.toggle(act.effect); } catch (e) { console.warn('effect toggle failed', e); }
}
});
}
if (mem) {
mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ });
}
} catch (e) {
hideTyping();
addMessage('assistant', 'ERROR: Network failure — ' + e.message);
chatStatus.textContent = '● OFFLINE';
chatStatus.classList.remove('status-amber');
chatStatus.classList.add('status-red');
}
chatStatus.textContent = '● ONLINE';
chatStatus.classList.remove('status-amber', 'status-red');
chatStatus.classList.add('status-green');
isWaiting = false;
chatInput.focus();
}
// ─── Memory modal ─────────────────────────────────
function formatDate(ts) {
try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; }
}
function renderMemoryModal() {
if (!mem) return;
const existing = document.getElementById('memModal');
if (existing) { existing.remove(); return; }
const modal = document.createElement('div');
modal.id = 'memModal';
modal.className = 'mem-modal';
const all = mem.getAll();
const grouped = {};
all.forEach(function (m) {
const c = m.category || 'other';
if (!grouped[c]) grouped[c] = [];
grouped[c].push(m);
});
const categories = ['identity', 'preference', 'project', 'skill', 'goal', 'relationship', 'other'];
const order = categories.filter(function (c) { return grouped[c]; })
.concat(Object.keys(grouped).filter(function (c) { return categories.indexOf(c) === -1; }));
let bodyHTML = '';
if (all.length === 0) {
bodyHTML = '<div class="mem-empty">NO MEMORIES STORED // CHAT TO BUILD PROFILE</div>';
} else {
order.forEach(function (cat) {
bodyHTML += '<div class="mem-group"><div class="mem-group-title">' + cat.toUpperCase() + ' <span class="mem-count">(' + grouped[cat].length + ')</span></div>';
grouped[cat].sort(function (a, b) { return (b.timestamp || 0) - (a.timestamp || 0); }).forEach(function (m) {
bodyHTML += '<div class="mem-item" data-id="' + m.id + '">' +
'<div class="mem-item-text">' + escapeHtml(m.text) + '</div>' +
'<div class="mem-item-meta">imp: ' + (m.importance || 0).toFixed(2) + ' // ' + formatDate(m.timestamp) + '</div>' +
'<button class="mem-del" data-id="' + m.id + '" title="Delete">×</button>' +
'</div>';
});
bodyHTML += '</div>';
});
}
modal.innerHTML = `
<div class="mem-modal-inner">
<div class="mem-modal-header">
<span class="mem-modal-title">🧠 JAE-AI MEMORY VAULT</span>
<button class="mem-close" id="memClose">× CLOSE</button>
</div>
<div class="mem-modal-subtitle">STORED LOCALLY // ${all.length} MEMORIES // NEVER LEAVES DEVICE</div>
<div class="mem-modal-body">${bodyHTML}</div>
<div class="mem-modal-footer">
<button class="mem-btn mem-btn-export" id="memExport">⬇ EXPORT JSON</button>
<button class="mem-btn mem-btn-extract" id="memExtract">⚡ FORCE EXTRACT</button>
<button class="mem-btn mem-btn-clearhist" id="memClearHist">🗑 CLEAR HISTORY</button>
<button class="mem-btn mem-btn-clear" id="memClear">⚠ CLEAR ALL MEMORIES</button>
</div>
</div>`;
document.body.appendChild(modal);
document.getElementById('memClose').addEventListener('click', function () { modal.remove(); });
modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); });
modal.querySelectorAll('.mem-del').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-id');
mem.remove(id);
renderMemoryModal(); renderMemoryModal();
});
});
document.getElementById('memExport').addEventListener('click', function () {
const blob = new Blob([mem.exportJSON()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'jae-ai-memory-' + Date.now() + '.json'; a.click();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
});
document.getElementById('memExtract').addEventListener('click', function () {
const btn = document.getElementById('memExtract');
btn.disabled = true; btn.textContent = '⌛ EXTRACTING...';
mem.extractFromRecentChat().then(function () {
renderMemoryModal(); renderMemoryModal();
}).catch(function () { btn.textContent = '✖ EXTRACT FAILED'; });
});
document.getElementById('memClearHist').addEventListener('click', function () {
if (confirm('Clear chat history? Memories will be kept.')) {
mem.clearHistory(); alert('Chat history cleared. Reload the page.');
}
});
document.getElementById('memClear').addEventListener('click', function () {
if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) {
mem.clear(); renderMemoryModal(); renderMemoryModal();
}
});
}
// ─── Header UI injection ──────────────────────────
function injectMemoryUI() {
if (!chatHeader) return;
if (document.getElementById('memBtn')) return;
const btn = document.createElement('button');
btn.id = 'memBtn';
btn.className = 'chat-mem-btn';
btn.title = 'View / manage local memories';
btn.innerHTML = '🧠 <span class="chat-mem-count" id="memBtnCount">0</span>';
btn.addEventListener('click', renderMemoryModal);
const info = document.createElement('a');
info.href = '#';
info.className = 'chat-mem-info';
info.title = 'All memories stored locally in your browser. Nothing leaves your device except current message context.';
info.textContent = '';
info.addEventListener('click', function (e) {
e.preventDefault();
alert('PRIVACY NOTICE\n\nAll JAE-AI memories are stored locally in your browser (localStorage). They never leave your device.\n\nPer message, only the current text plus a small set of relevant memories is sent as context to Venice AI to provide continuity.\n\nYou can view, export, or clear all memories via the 🧠 button.');
});
chatHeader.appendChild(info);
chatHeader.appendChild(btn);
updateMemCount();
}
function updateMemCount() {
const el = document.getElementById('memBtnCount');
if (el && mem) el.textContent = mem.getAll().length;
}
// ─── Event listeners ──────────────────────────────
chatSend.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
// ─── Init ─────────────────────────────────────────
injectMemoryUI();
renderTierBadge();
refreshWhoAmI();
setInterval(updateMemCount, 5000);
const restored = restoreHistory();
if (!restored) {
setTimeout(function () {
showTyping();
setTimeout(function () {
hideTyping();
const greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI. I can now search the site, fetch crypto prices, scan Solana wallets, look up .sol domains, pull SITREPs, and trigger visual effects. Connect your wallet for the Elite tier (1+ SOL) with Kimi-K2-Thinking. Ask me anything.';
history.push({ role: 'assistant', content: greeting });
if (mem) mem.appendHistory({ role: 'assistant', content: greeting });
const bodyEl = addMessage('assistant', '');
typewriterEffect(bodyEl, greeting, 10);
}, 1500);
}, 2000);
}
// Expose minimal API
window.__jaeChat = {
refreshWhoAmI: refreshWhoAmI,
getTier: function () { return currentTier; },
getAddress: function () { return currentAddress; },
};
})();