/* ============================================================ 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 = ''; 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 = ''; 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 = `