Add explorer AI chat and context endpoints
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user