Add explorer AI chat and context endpoints

This commit is contained in:
defiQUG
2026-03-27 13:37:53 -07:00
parent 01e0633124
commit c9e792d55f
4 changed files with 1272 additions and 5 deletions

View File

@@ -1,5 +1,6 @@
const API_BASE = '/api';
const TOKEN_AGGREGATION_API_BASE = '/token-aggregation/api';
const EXPLORER_AI_API_BASE = API_BASE + '/v1/ai';
const FETCH_TIMEOUT_MS = 15000;
const RPC_HEALTH_TIMEOUT_MS = 5000;
const FETCH_MAX_RETRIES = 3;
@@ -34,6 +35,16 @@
const RPC_WS_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_WS_FQDN : RPC_WS_IP;
let _rpcUrlIndex = 0;
let _blocksScrollAnimationId = null;
let _explorerAIState = {
open: false,
loading: false,
messages: [
{
role: 'assistant',
content: 'Explorer AI is ready for read-only ecosystem analysis. Ask about routes, liquidity, bridges, addresses, transactions, or current Chain 138 status.'
}
]
};
async function getRpcUrl() {
if (RPC_URLS.length <= 1) return RPC_URLS[0];
const ac = new AbortController();
@@ -2313,6 +2324,48 @@
}
}
async function postJSON(url, payload) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
credentials: 'omit',
signal: controller.signal,
body: JSON.stringify(payload || {})
});
clearTimeout(timeoutId);
const text = await response.text();
let parsed = {};
if (text) {
try {
parsed = JSON.parse(text);
} catch (e) {
parsed = { reply: text };
}
}
if (!response.ok) {
var message = (parsed && parsed.error && (parsed.error.message || parsed.error.code)) || text || response.statusText || 'Request failed';
throw new Error('HTTP ' + response.status + ': ' + message);
}
return parsed;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('Request timeout. Please try again.');
}
throw error;
}
}
async function loadStats() {
const statsGrid = document.getElementById('statsGrid');
if (!statsGrid) return;
@@ -5358,6 +5411,230 @@
return ether.toFixed(6).replace(/\.?0+$/, '');
}
function getExplorerAIPageContext() {
return {
path: (window.location && window.location.pathname) ? window.location.pathname : '/home',
view: currentView || 'home'
};
}
function renderExplorerAIMessages() {
var list = document.getElementById('explorerAIMessageList');
var status = document.getElementById('explorerAIStatus');
if (!list) return;
list.innerHTML = _explorerAIState.messages.map(function(message) {
var isAssistant = message.role === 'assistant';
var bubbleStyle = isAssistant
? 'background: rgba(37,99,235,0.10); border:1px solid rgba(37,99,235,0.18);'
: 'background: rgba(15,23,42,0.06); border:1px solid rgba(148,163,184,0.25);';
return '<div style="display:flex; justify-content:' + (isAssistant ? 'flex-start' : 'flex-end') + ';">' +
'<div style="max-width: 88%; padding: 0.85rem 0.95rem; border-radius: 16px; ' + bubbleStyle + '">' +
'<div style="font-size:0.72rem; letter-spacing:0.06em; text-transform:uppercase; color:var(--text-light); margin-bottom:0.35rem;">' + (isAssistant ? 'Explorer AI' : 'You') + '</div>' +
'<div style="white-space:pre-wrap; line-height:1.55;">' + escapeHtml(message.content || '') + '</div>' +
'</div>' +
'</div>';
}).join('');
if (_explorerAIState.loading) {
list.innerHTML += '<div style="display:flex; justify-content:flex-start;"><div style="padding:0.8rem 0.95rem; border-radius:16px; background:rgba(37,99,235,0.08); border:1px solid rgba(37,99,235,0.16); color:var(--text-light);">Thinking through indexed data, live routes, and docs...</div></div>';
}
list.scrollTop = list.scrollHeight;
if (status) {
status.textContent = _explorerAIState.loading
? 'Querying explorer data and the model...'
: 'Read-only assistant using indexed explorer data, route APIs, and curated docs.';
}
}
function setExplorerAIOpen(open) {
_explorerAIState.open = !!open;
var panel = document.getElementById('explorerAIPanel');
var button = document.getElementById('explorerAIFab');
if (panel) panel.style.display = open ? 'flex' : 'none';
if (button) button.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) {
renderExplorerAIMessages();
var input = document.getElementById('explorerAIInput');
if (input) setTimeout(function() { input.focus(); }, 30);
}
}
function toggleExplorerAIPanel(forceOpen) {
if (typeof forceOpen === 'boolean') {
setExplorerAIOpen(forceOpen);
return;
}
setExplorerAIOpen(!_explorerAIState.open);
}
window.toggleExplorerAIPanel = toggleExplorerAIPanel;
function buildExplorerAISourceSummary(context) {
if (!context || !Array.isArray(context.sources) || !context.sources.length) return '';
return context.sources.map(function(source) {
return source.label || source.type || 'source';
}).filter(Boolean).join(' | ');
}
async function submitExplorerAIMessage(prefill) {
var input = document.getElementById('explorerAIInput');
var raw = typeof prefill === 'string' ? prefill : (input ? input.value : '');
var question = String(raw || '').trim();
if (!question || _explorerAIState.loading) return;
_explorerAIState.messages.push({ role: 'user', content: question });
if (input) input.value = '';
_explorerAIState.loading = true;
renderExplorerAIMessages();
try {
var payload = {
messages: _explorerAIState.messages.slice(-8),
pageContext: getExplorerAIPageContext()
};
var response = await postJSON(EXPLORER_AI_API_BASE + '/chat', payload);
var reply = (response && response.reply) ? String(response.reply) : 'No reply returned.';
var sourceSummary = buildExplorerAISourceSummary(response && response.context);
if (sourceSummary) {
reply += '\n\nSources: ' + sourceSummary;
}
if (response && Array.isArray(response.warnings) && response.warnings.length) {
reply += '\n\nWarnings: ' + response.warnings.join(' | ');
}
_explorerAIState.messages.push({ role: 'assistant', content: reply });
} catch (error) {
_explorerAIState.messages.push({
role: 'assistant',
content: 'Explorer AI could not complete that request.\n\n' + (error && error.message ? error.message : 'Unknown error') + '\n\nIf this is production, confirm the backend has OPENAI_API_KEY and TOKEN_AGGREGATION_API_BASE configured.'
});
} finally {
_explorerAIState.loading = false;
renderExplorerAIMessages();
}
}
window.submitExplorerAIMessage = submitExplorerAIMessage;
function initExplorerAIPanel() {
if (document.getElementById('explorerAIPanel') || !document.body) return;
var style = document.createElement('style');
style.textContent = `
#explorerAIFab {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20010;
border: 0;
border-radius: 999px;
padding: 0.9rem 1rem;
background: linear-gradient(135deg, #0f172a, #2563eb);
color: #fff;
box-shadow: 0 16px 36px rgba(15,23,42,0.28);
cursor: pointer;
font-weight: 700;
letter-spacing: 0.02em;
}
#explorerAIPanel {
position: fixed;
right: 20px;
bottom: 84px;
width: min(420px, calc(100vw - 24px));
height: min(72vh, 680px);
display: none;
flex-direction: column;
z-index: 20010;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 22px;
box-shadow: 0 24px 60px rgba(15,23,42,0.25);
overflow: hidden;
}
#explorerAIPanel textarea {
width: 100%;
min-height: 88px;
resize: vertical;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--light);
color: var(--text);
padding: 0.85rem 0.9rem;
font: inherit;
}
@media (max-width: 680px) {
#explorerAIPanel {
right: 12px;
left: 12px;
bottom: 76px;
width: auto;
height: min(74vh, 720px);
}
#explorerAIFab {
right: 12px;
bottom: 12px;
}
}
`;
document.head.appendChild(style);
var button = document.createElement('button');
button.id = 'explorerAIFab';
button.type = 'button';
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', 'explorerAIPanel');
button.innerHTML = '<i class="fas fa-robot" aria-hidden="true" style="margin-right:0.45rem;"></i>Explorer AI';
button.addEventListener('click', function() { toggleExplorerAIPanel(); });
var panel = document.createElement('section');
panel.id = 'explorerAIPanel';
panel.setAttribute('aria-label', 'Explorer AI');
panel.innerHTML = '' +
'<div style="padding:1rem 1rem 0.85rem; border-bottom:1px solid var(--border); background:linear-gradient(180deg, rgba(37,99,235,0.10), rgba(37,99,235,0));">' +
'<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1rem;">' +
'<div>' +
'<div style="font-size:1rem; font-weight:800;">Explorer AI</div>' +
'<div id="explorerAIStatus" style="font-size:0.84rem; color:var(--text-light); margin-top:0.2rem;">Read-only assistant using indexed explorer data, route APIs, and curated docs.</div>' +
'</div>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.6rem;" onclick="toggleExplorerAIPanel(false)">Close</button>' +
'</div>' +
'<div style="display:flex; flex-wrap:wrap; gap:0.45rem; margin-top:0.85rem;">' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Which Chain 138 routes are live right now?\')">Live routes</button>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Why would a route show partial instead of live?\')">Route status</button>' +
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Summarize the current page context and what I can do next.\')">Current page</button>' +
'</div>' +
'</div>' +
'<div id="explorerAIMessageList" style="flex:1; overflow:auto; padding:1rem; display:grid; gap:0.7rem; background:linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0));"></div>' +
'<div style="padding:1rem; border-top:1px solid var(--border); display:grid; gap:0.7rem;">' +
'<div style="font-size:0.78rem; color:var(--text-light);">Public explorer and route data only. No private key handling, no transaction execution.</div>' +
'<textarea id="explorerAIInput" placeholder="Ask about a tx hash, address, bridge path, liquidity pool, or route status..."></textarea>' +
'<div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">' +
'<div style="font-size:0.78rem; color:var(--text-light);">Shift+Enter for a new line. Enter to send.</div>' +
'<button type="button" class="btn btn-primary" id="explorerAISendBtn">Ask Explorer AI</button>' +
'</div>' +
'</div>';
document.body.appendChild(button);
document.body.appendChild(panel);
var input = document.getElementById('explorerAIInput');
var sendButton = document.getElementById('explorerAISendBtn');
if (sendButton) {
sendButton.addEventListener('click', function() {
submitExplorerAIMessage();
});
}
if (input) {
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submitExplorerAIMessage();
}
});
}
renderExplorerAIMessages();
}
// Export functions
function exportBlockData(blockNumber) {
// Fetch block data and export as JSON
@@ -5458,6 +5735,7 @@
// Search launcher, modal handlers, and mobile nav close on link click
document.addEventListener('DOMContentLoaded', () => {
initExplorerAIPanel();
const launchBtn = document.getElementById('searchLauncherBtn');
const modal = document.getElementById('smartSearchModal');
const backdrop = document.getElementById('smartSearchBackdrop');