Spaces:
Sleeping
Sleeping
| ο»Ώ/* ============================================================ | |
| Web3 Research Co-Pilot β app.js | |
| Optimised streaming chat client with inline tool activity | |
| ============================================================ */ | |
| // ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let chatHistory = []; | |
| let useGemini = false; | |
| // ββ Tool icon map βββββββββββββββββββββββββββββββββββββββββββββ | |
| const TOOL_ICONS = { | |
| coingecko: 'fa-coins', | |
| defillama: 'fa-droplet', | |
| cryptocompare: 'fa-chart-bar', | |
| etherscan: 'fa-magnifying-glass', | |
| chart: 'fa-chart-line', | |
| price: 'fa-dollar-sign', | |
| market: 'fa-chart-area', | |
| gas: 'fa-gas-pump', | |
| whale: 'fa-fish', | |
| 'default': 'fa-gear', | |
| }; | |
| // ββ Marked.js setup βββββββββββββββββββββββββββββββββββββββββββ | |
| try { | |
| marked.use({ breaks: true, gfm: true }); | |
| } catch(e) { /* older marked version β ignore */ } | |
| // ββ Init βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initTheme(); | |
| initModel(); | |
| initTextarea(); | |
| checkStatus(); | |
| document.getElementById('queryInput').focus(); | |
| }); | |
| // ββ Textarea auto-grow βββββββββββββββββββββββββββββββββββββββ | |
| function initTextarea() { | |
| const ta = document.getElementById('queryInput'); | |
| ta.addEventListener('input', () => { | |
| ta.style.height = 'auto'; | |
| ta.style.height = Math.min(ta.scrollHeight, 130) + 'px'; | |
| document.getElementById('charCount').textContent = | |
| `${ta.value.length} / 1000`; | |
| }); | |
| ta.addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendQuery(); | |
| } | |
| }); | |
| } | |
| // ββ Model selection ββββββββββββββββββββββββββββββββββββββββββ | |
| function setModel(model) { | |
| useGemini = model === 'gemini'; | |
| localStorage.setItem('useGemini', useGemini); | |
| document.getElementById('btnOllama').classList.toggle('active', !useGemini); | |
| document.getElementById('btnGemini').classList.toggle('active', useGemini); | |
| showToast(`Switched to ${useGemini ? 'Gemini (Cloud)' : 'Ollama (Local)'}`, 'info'); | |
| checkStatus(); | |
| } | |
| function initModel() { | |
| useGemini = localStorage.getItem('useGemini') === 'true'; | |
| document.getElementById('btnOllama').classList.toggle('active', !useGemini); | |
| document.getElementById('btnGemini').classList.toggle('active', useGemini); | |
| } | |
| // ββ Status check βββββββββββββββββββββββββββββββββββββββββββββ | |
| async function checkStatus() { | |
| const badge = document.getElementById('statusBadge'); | |
| const text = document.getElementById('statusBadgeText'); | |
| try { | |
| const res = await fetch('/status'); | |
| const data = await res.json(); | |
| if (data.enabled) { | |
| badge.className = 'status-badge online'; | |
| text.textContent = 'Online'; | |
| } else { | |
| badge.className = 'status-badge offline'; | |
| text.textContent = 'Limited'; | |
| } | |
| } catch { | |
| badge.className = 'status-badge offline'; | |
| text.textContent = 'Offline'; | |
| } | |
| } | |
| // ββ Send query βββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function sendQuery() { | |
| const ta = document.getElementById('queryInput'); | |
| const sendBtn = document.getElementById('sendBtn'); | |
| const query = ta.value.trim(); | |
| if (!query) return; | |
| addMessage('user', query); | |
| ta.value = ''; | |
| ta.style.height = 'auto'; | |
| document.getElementById('charCount').textContent = '0 / 1000'; | |
| const thinkingId = showThinking(); | |
| sendBtn.disabled = true; | |
| sendBtn.innerHTML = '<i class="fas fa-circle-notch fa-spin"></i>'; | |
| try { | |
| const controller = new AbortController(); | |
| const timer = setTimeout(() => controller.abort(), 300000); | |
| const res = await fetch('/query/stream', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, | |
| body: JSON.stringify({ query, chat_history: chatHistory, use_gemini: useGemini }), | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(timer); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| outer: while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); // keep incomplete line | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| try { | |
| const evt = JSON.parse(line.slice(6)); | |
| if (handleStreamEvent(evt, thinkingId) === 'done') break outer; | |
| } catch { /* skip malformed JSON */ } | |
| } | |
| } | |
| } catch (err) { | |
| removeThinking(thinkingId); | |
| if (err.name === 'AbortError') { | |
| addMessage('assistant', 'Request timed out after 5 minutes. Try a shorter or simpler query.'); | |
| } else if (err.message.includes('Failed to fetch')) { | |
| addMessage('assistant', 'Network error β please check your connection.'); | |
| } else { | |
| addMessage('assistant', 'An unexpected error occurred. Please try again.'); | |
| } | |
| } finally { | |
| sendBtn.disabled = false; | |
| sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>'; | |
| ta.focus(); | |
| } | |
| } | |
| // ββ Handle SSE events ββββββββββββββββββββββββββββββββββββββββ | |
| function handleStreamEvent(data, thinkingId) { | |
| switch (data.type) { | |
| case 'status': | |
| updateThinking(thinkingId, data.message, data.progress); | |
| break; | |
| case 'tools': | |
| addToolStep(thinkingId, data.message); | |
| break; | |
| case 'result': | |
| removeThinking(thinkingId); | |
| if (data.data && data.data.success) { | |
| addMessage('assistant', data.data.response, data.data.sources, data.data.visualizations); | |
| } else { | |
| const msg = (data.data && data.data.response) || 'Analysis temporarily unavailable.'; | |
| addMessage('assistant', msg); | |
| } | |
| return 'done'; | |
| case 'complete': | |
| return 'done'; | |
| case 'error': | |
| removeThinking(thinkingId); | |
| addMessage('assistant', data.message || 'An error occurred.'); | |
| return 'done'; | |
| } | |
| } | |
| // ββ Thinking bubble ββββββββββββββββββββββββββββββββββββββββββ | |
| function showThinking() { | |
| const id = 'thinking-' + Date.now(); | |
| const msgs = document.getElementById('chatMessages'); | |
| clearWelcome(); | |
| const div = document.createElement('div'); | |
| div.className = 'message assistant'; | |
| div.id = id; | |
| div.innerHTML = ` | |
| <div class="thinking-bubble"> | |
| <div class="thinking-header"> | |
| <div class="thinking-spinner"></div> | |
| <span class="thinking-label" id="${id}-label">Analyzing queryβ¦</span> | |
| </div> | |
| <div class="tool-steps" id="${id}-steps"></div> | |
| <div class="progress-strip"><div class="progress-fill" id="${id}-bar"></div></div> | |
| </div> | |
| <div class="message-time">${fmtTime()}</div>`; | |
| msgs.appendChild(div); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| return id; | |
| } | |
| function updateThinking(id, message, progress) { | |
| const label = document.getElementById(`${id}-label`); | |
| if (label) label.textContent = message; | |
| const bar = document.getElementById(`${id}-bar`); | |
| if (bar && progress != null) bar.style.width = `${progress}%`; | |
| } | |
| function addToolStep(id, message) { | |
| const steps = document.getElementById(`${id}-steps`); | |
| if (!steps) return; | |
| // Parse "Available tools: ['CoinGecko', 'DeFiLlama', ...]" into individual rows | |
| const listMatch = message.match(/\[(.+)\]/); | |
| const toolNames = listMatch | |
| ? [...listMatch[1].matchAll(/'([^']+)'/g)].map(m => m[1]) | |
| : null; | |
| if (toolNames && toolNames.length > 0) { | |
| toolNames.forEach(name => { | |
| const step = document.createElement('div'); | |
| step.className = 'tool-step'; | |
| step.innerHTML = `<i class="fas ${getToolIcon(name)}"></i><span>${escapeHtml(name)}</span>`; | |
| steps.appendChild(step); | |
| }); | |
| } else { | |
| const step = document.createElement('div'); | |
| step.className = 'tool-step'; | |
| step.innerHTML = `<i class="fas ${getToolIcon(message)}"></i><span>${escapeHtml(message)}</span>`; | |
| steps.appendChild(step); | |
| } | |
| document.getElementById('chatMessages').scrollTop = 9999; | |
| } | |
| function removeThinking(id) { | |
| const el = document.getElementById(id); | |
| if (el) el.remove(); | |
| } | |
| // ββ Add message ββββββββββββββββββββββββββββββββββββββββββββββ | |
| function addMessage(sender, content, sources = [], visualizations = []) { | |
| clearWelcome(); | |
| const msgs = document.getElementById('chatMessages'); | |
| const div = document.createElement('div'); | |
| div.className = `message ${sender}`; | |
| // Render content | |
| let bodyHtml = ''; | |
| if (sender === 'assistant') { | |
| try { bodyHtml = `<div class="md-content">${marked.parse(String(content))}</div>`; } | |
| catch { bodyHtml = `<div class="md-content">${escapeHtml(String(content)).replace(/\n/g, '<br>')}</div>`; } | |
| } else { | |
| bodyHtml = `<div class="md-content">${escapeHtml(String(content))}</div>`; | |
| } | |
| // Sources | |
| let srcHtml = ''; | |
| if (sources && sources.length > 0) { | |
| const tags = sources.map(s => `<span class="source-tag">${escapeHtml(s)}</span>`).join(''); | |
| srcHtml = `<div class="sources-list"><span class="sources-label">Sources</span>${tags}</div>`; | |
| } | |
| // Visualizations | |
| const vizHtml = (visualizations || []).map((v, i) => | |
| `<div class="viz-container" id="viz-${Date.now()}-${i}">${v}</div>` | |
| ).join(''); | |
| div.innerHTML = ` | |
| <div class="message-bubble">${bodyHtml}${srcHtml}</div> | |
| ${vizHtml} | |
| <div class="message-time">${fmtTime()}</div>`; | |
| msgs.appendChild(div); | |
| msgs.scrollTop = msgs.scrollHeight; | |
| // Apply syntax highlighting to code blocks | |
| if (sender === 'assistant') { | |
| requestAnimationFrame(() => { | |
| if (typeof hljs !== 'undefined') { | |
| div.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); | |
| } | |
| }); | |
| } | |
| // Execute embedded Plotly scripts | |
| if (visualizations && visualizations.length > 0) { | |
| setTimeout(() => { | |
| div.querySelectorAll('script').forEach(s => { | |
| try { new Function(s.textContent).call(window); } | |
| catch (e) { console.warn('Viz script error:', e); } | |
| }); | |
| }, 120); | |
| } | |
| // Maintain rolling chat history (last 20 turns) | |
| chatHistory.push({ role: sender, content }); | |
| if (chatHistory.length > 20) chatHistory = chatHistory.slice(-20); | |
| } | |
| // ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function clearWelcome() { | |
| const w = document.querySelector('.welcome-screen'); | |
| if (w) w.remove(); | |
| } | |
| function setQuery(query) { | |
| const ta = document.getElementById('queryInput'); | |
| ta.value = query; | |
| ta.style.height = 'auto'; | |
| ta.style.height = Math.min(ta.scrollHeight, 130) + 'px'; | |
| document.getElementById('charCount').textContent = `${query.length} / 1000`; | |
| setTimeout(sendQuery, 50); | |
| } | |
| function getToolIcon(message) { | |
| const m = message.toLowerCase(); | |
| for (const [key, icon] of Object.entries(TOOL_ICONS)) { | |
| if (key !== 'default' && m.includes(key)) return icon; | |
| } | |
| return TOOL_ICONS.default; | |
| } | |
| function escapeHtml(str) { | |
| return str | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function fmtTime() { | |
| return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| // ββ Toast βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let toastTimer = null; | |
| function showToast(message, type = 'info') { | |
| const toast = document.getElementById('statusIndicator'); | |
| const text = document.getElementById('statusText'); | |
| const icon = toast.querySelector('.toast-icon'); | |
| const icons = { | |
| info: 'fa-circle-info', | |
| processing: 'fa-circle-notch fa-spin', | |
| success: 'fa-circle-check', | |
| error: 'fa-circle-xmark', | |
| warning: 'fa-triangle-exclamation', | |
| }; | |
| text.textContent = message; | |
| toast.className = `toast show ${type}`; | |
| if (icon) icon.className = `fas ${icons[type] || icons.info} toast-icon`; | |
| clearTimeout(toastTimer); | |
| if (type !== 'processing') { | |
| toastTimer = setTimeout(() => { | |
| toast.classList.remove('show'); | |
| }, 3000); | |
| } | |
| } | |
| // ββ Theme βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initTheme() { | |
| const theme = localStorage.getItem('theme') || 'dark'; | |
| document.documentElement.setAttribute('data-theme', theme); | |
| syncThemeIcon(theme); | |
| } | |
| function toggleTheme() { | |
| const cur = document.documentElement.getAttribute('data-theme'); | |
| const next = cur === 'light' ? 'dark' : 'light'; | |
| document.documentElement.setAttribute('data-theme', next); | |
| localStorage.setItem('theme', next); | |
| syncThemeIcon(next); | |
| } | |
| function syncThemeIcon(theme) { | |
| const icon = document.querySelector('#themeToggle i'); | |
| if (icon) icon.className = theme === 'light' ? 'fas fa-moon' : 'fas fa-sun'; | |
| } | |
| // ββ Event listeners βββββββββββββββββββββββββββββββββββββββββββ | |
| document.getElementById('sendBtn').addEventListener('click', sendQuery); | |
| document.getElementById('themeToggle').addEventListener('click', toggleTheme); | |