jaeswift-website/js/chat.js
jae e73b74cfa2 feat(agent): agentic chat with wallet auth and tiered model routing
- New /api/agent/chat endpoint with Venice tool-calling loop (max 8 iter)
- Tiered models: glm-4.7-flash default, kimi-k2-thinking for Elite+
- Wallet auth: /api/auth/{nonce,verify,whoami,logout} with Ed25519 + JWT
- 10 tools registered: site search, crypto prices, SITREP, .sol lookup,
  wallet xray, contraband/awesomelist search, changelog, trigger_effect
- Per-tool rate limits, 30s timeout, \$30/mo budget guard
- Frontend: tier badge, tool call cards, wallet sign-in handshake
- Changelog v1.40.0
2026-04-20 10:40:27 +00:00

564 lines
25 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];
});
}
// ─── 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';
body.textContent = text;
msg.appendChild(label);
msg.appendChild(body);
chatMessages.appendChild(msg);
chatMessages.scrollTop = chatMessages.scrollHeight;
return body;
}
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>
`;
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;
// If reply is too long, bypass animation
if (!text || text.length > 600) {
element.textContent = text || '';
chatMessages.scrollTop = chatMessages.scrollHeight;
return Promise.resolve();
}
let i = 0;
element.textContent = '';
return new Promise(function (resolve) {
function tick() {
if (i < text.length) {
element.textContent += text.charAt(i);
i++;
chatMessages.scrollTop = chatMessages.scrollHeight;
setTimeout(tick, speed);
} else {
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';
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.textContent = 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; },
};
})();