740 lines
38 KiB
JavaScript
740 lines
38 KiB
JavaScript
/* ===================================================
|
||
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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||
});
|
||
}
|
||
|
||
// ─── Minimal safe Markdown → HTML (assistant messages only) ──
|
||
function md(text) {
|
||
if (!text) return '';
|
||
let html = String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
// 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; },
|
||
};
|
||
})();
|