/** * Eurus Chat WebSocket Client */ class EurusChat { constructor() { this.ws = null; this.messageId = 0; this.currentAssistantMessage = null; this.isConnected = false; this.keysConfigured = false; this.serverKeysPresent = { openai: false, arraylake: false }; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.messagesContainer = document.getElementById('chat-messages'); this.messageInput = document.getElementById('message-input'); this.chatForm = document.getElementById('chat-form'); this.sendBtn = document.getElementById('send-btn'); this.connectionStatus = document.getElementById('connection-status'); this.clearBtn = document.getElementById('clear-btn'); this.cacheBtn = document.getElementById('cache-btn'); this.cacheModal = document.getElementById('cache-modal'); this.apiKeysPanel = document.getElementById('api-keys-panel'); this.saveKeysBtn = document.getElementById('save-keys-btn'); this.openaiKeyInput = document.getElementById('openai-key'); this.arraylakeKeyInput = document.getElementById('arraylake-key'); marked.setOptions({ highlight: (code, lang) => { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return hljs.highlightAuto(code).value; }, breaks: true, gfm: true }); this.themeToggle = document.getElementById('theme-toggle'); this.init(); } async init() { await this.checkKeysStatus(); this.connect(); this.setupEventListeners(); this.setupImageModal(); this.setupTheme(); this.setupKeysPanel(); } async checkKeysStatus() { try { const resp = await fetch('/api/keys-status'); const data = await resp.json(); this.serverKeysPresent = data; if (data.openai) { // Keys pre-configured on server — hide the panel this.apiKeysPanel.style.display = 'none'; this.keysConfigured = true; // Enable send if WS is already connected if (this.isConnected) { this.sendBtn.disabled = false; } } else { // No server keys — show panel, user must enter keys each session this.apiKeysPanel.style.display = 'block'; this.keysConfigured = false; } } catch (e) { // Can't reach server yet, show panel this.apiKeysPanel.style.display = 'block'; } } setupKeysPanel() { this.saveKeysBtn.addEventListener('click', () => this.saveAndSendKeys()); // Allow Enter in key fields to submit [this.openaiKeyInput, this.arraylakeKeyInput].forEach(input => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.saveAndSendKeys(); } }); }); // Restore keys from sessionStorage (survives refresh, cleared on browser close) this.restoreSessionKeys(); } restoreSessionKeys() { const saved = sessionStorage.getItem('eurus-keys'); if (!saved) return; try { const keys = JSON.parse(saved); if (keys.openai_api_key) this.openaiKeyInput.value = keys.openai_api_key; if (keys.arraylake_api_key) this.arraylakeKeyInput.value = keys.arraylake_api_key; } catch (e) { sessionStorage.removeItem('eurus-keys'); } } autoSendSessionKeys() { // After WS (re)connects, resend saved keys so the new server-side session gets them. // Only skip if the server has pre-configured env keys (not user-provided). if (this.serverKeysPresent.openai) return; const saved = sessionStorage.getItem('eurus-keys'); if (!saved) return; try { const keys = JSON.parse(saved); if (keys.openai_api_key && this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'configure_keys', openai_api_key: keys.openai_api_key, arraylake_api_key: keys.arraylake_api_key || '', })); } } catch (e) { sessionStorage.removeItem('eurus-keys'); } } saveAndSendKeys() { const openaiKey = this.openaiKeyInput.value.trim(); const arraylakeKey = this.arraylakeKeyInput.value.trim(); if (!openaiKey) { this.openaiKeyInput.focus(); return; } // Save to sessionStorage (cleared when browser closes, survives refresh) const keysPayload = { openai_api_key: openaiKey, arraylake_api_key: arraylakeKey, }; sessionStorage.setItem('eurus-keys', JSON.stringify(keysPayload)); // Send keys via WebSocket if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.saveKeysBtn.disabled = true; this.saveKeysBtn.textContent = 'Connecting...'; this.ws.send(JSON.stringify({ type: 'configure_keys', ...keysPayload, })); } } setupTheme() { // Load saved theme or default to dark (neosynth) const savedTheme = localStorage.getItem('eurus-theme') || 'dark'; document.documentElement.setAttribute('data-theme', savedTheme); this.updateThemeIcon(savedTheme); // Theme toggle click handler if (this.themeToggle) { this.themeToggle.addEventListener('click', () => { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('eurus-theme', newTheme); this.updateThemeIcon(newTheme); }); } } updateThemeIcon(theme) { if (this.themeToggle) { const icon = this.themeToggle.querySelector('.theme-icon'); if (icon) { icon.textContent = theme === 'dark' ? '☀️' : '🌙'; } } } connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/chat`; this.updateConnectionStatus('connecting'); try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.isConnected = true; this.reconnectAttempts = 0; this.updateConnectionStatus('connected'); // Always resend keys on (re)connect — server session may have been // destroyed on disconnect, so keys stored in sessionStorage must be // re-sent even if keysConfigured is true from the previous session. this.autoSendSessionKeys(); if (this.serverKeysPresent.openai || this.keysConfigured) { this.sendBtn.disabled = false; } }; this.ws.onclose = () => { this.isConnected = false; this.updateConnectionStatus('disconnected'); this.sendBtn.disabled = true; this.attemptReconnect(); }; this.ws.onerror = () => { this.updateConnectionStatus('disconnected'); }; this.ws.onmessage = (event) => { this.handleMessage(JSON.parse(event.data)); }; } catch (error) { this.updateConnectionStatus('disconnected'); } } attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) return; this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); this.updateConnectionStatus('connecting'); setTimeout(() => this.connect(), delay); } updateConnectionStatus(status) { this.connectionStatus.className = 'status-badge ' + status; const text = { connected: 'Connected', disconnected: 'Disconnected', connecting: 'Connecting...' }; this.connectionStatus.textContent = text[status] || status; } setupEventListeners() { this.chatForm.addEventListener('submit', (e) => { e.preventDefault(); this.sendMessage(); }); this.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); this.messageInput.addEventListener('input', () => { this.messageInput.style.height = 'auto'; this.messageInput.style.height = Math.min(this.messageInput.scrollHeight, 150) + 'px'; }); this.clearBtn.addEventListener('click', (e) => { e.preventDefault(); this.clearChat(); }); this.cacheBtn.addEventListener('click', (e) => { e.preventDefault(); this.showCacheModal(); }); this.cacheModal.querySelector('.close-modal').addEventListener('click', () => { this.cacheModal.close(); }); } setupImageModal() { // Create modal for enlarged images const modal = document.createElement('div'); modal.id = 'image-modal'; modal.innerHTML = `
`; document.body.appendChild(modal); // Add modal styles const style = document.createElement('style'); style.textContent = ` #image-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 1000; } #image-modal.active { display: flex; align-items: center; justify-content: center; } .image-modal-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); } .image-modal-content { position: relative; max-width: 90%; max-height: 90%; display: flex; flex-direction: column; align-items: center; } .image-modal-content img { max-width: 100%; max-height: calc(90vh - 60px); border-radius: 4px; } .image-modal-actions { margin-top: 12px; display: flex; gap: 8px; } .image-modal-actions button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .image-modal-actions .download-btn { background: #1976d2; color: white; } .image-modal-actions .close-btn { background: #757575; color: white; } `; document.head.appendChild(style); // Event listeners modal.querySelector('.image-modal-backdrop').addEventListener('click', () => { modal.classList.remove('active'); }); modal.querySelector('.close-btn').addEventListener('click', () => { modal.classList.remove('active'); }); modal.querySelector('.download-btn').addEventListener('click', () => { const img = modal.querySelector('img'); const link = document.createElement('a'); link.href = img.src; link.download = 'eurus_plot.png'; link.click(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) { modal.classList.remove('active'); } }); this.imageModal = modal; } showImageModal(src) { this.imageModal.querySelector('img').src = src; this.imageModal.classList.add('active'); } sendMessage() { const message = this.messageInput.value.trim(); if (!message || !this.isConnected) return; this.addUserMessage(message); this._lastSentMessage = message; this.ws.send(JSON.stringify({ message })); this.messageInput.value = ''; this.messageInput.style.height = 'auto'; this.sendBtn.disabled = true; } handleMessage(data) { switch (data.type) { case 'keys_configured': this.keysConfigured = data.ready; if (data.ready) { this.apiKeysPanel.style.display = 'none'; this.sendBtn.disabled = false; } else { this.saveKeysBtn.disabled = false; this.saveKeysBtn.textContent = 'Connect'; this.showError('Failed to initialize agent. Check your API keys.'); } break; case 'request_keys': // Server lost our session (e.g., container restart) — resend keys console.warn('Server requested keys:', data.reason); this.removeThinkingIndicator(); this.autoSendSessionKeys(); // Retry the last message after a short delay for session init if (this._lastSentMessage) { setTimeout(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ message: this._lastSentMessage })); this.showThinkingIndicator(); } }, 3000); } break; case 'thinking': this.showThinkingIndicator(); break; case 'status': this.updateStatusIndicator(data.content); break; case 'chunk': this.appendToAssistantMessage(data.content); break; case 'plot': this.addPlot(data.data, data.path, data.code || ''); break; case 'video': console.log('[WS] Video message received:', data); this.addVideo(data.data, data.path, data.mimetype || 'video/mp4'); break; case 'complete': this.finalizeAssistantMessage(data.content); this.sendBtn.disabled = false; break; case 'arraylake_snippet': this.addArraylakeSnippet(data.content); break; case 'error': this.showError(data.content); this.sendBtn.disabled = false; break; case 'clear': this.clearMessagesUI(); break; } } addUserMessage(content) { const div = document.createElement('div'); div.className = 'message user-message'; div.innerHTML = ` `; this.messagesContainer.appendChild(div); this.scrollToBottom(); } showThinkingIndicator() { this.removeThinkingIndicator(); const div = document.createElement('div'); div.className = 'message thinking-message'; div.id = 'thinking-indicator'; div.innerHTML = `Loading...
'; try { const response = await fetch('/api/cache'); const data = await response.json(); if (data.datasets && data.datasets.length > 0) { const formatSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / 1048576).toFixed(1) + ' MB'; }; let html = '| Variable | Period | Type | Size | |
|---|---|---|---|---|
| ${ds.variable} | ${ds.start_date} to ${ds.end_date} | ${ds.query_type} | ${formatSize(ds.file_size_bytes)} |
Total: ${formatSize(data.total_size_bytes)} across ${data.datasets.length} dataset(s)
`; content.innerHTML = html; // Attach download handlers content.querySelectorAll('.cache-download-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const path = e.target.dataset.path; const origText = e.target.textContent; e.target.textContent = '⏳'; e.target.disabled = true; try { const resp = await fetch(`/api/cache/download?path=${encodeURIComponent(path)}`); if (!resp.ok) throw new Error('Download failed'); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = path.split('/').pop() + '.zip'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); e.target.textContent = '✅'; setTimeout(() => { e.target.textContent = origText; e.target.disabled = false; }, 2000); } catch (err) { e.target.textContent = '❌'; setTimeout(() => { e.target.textContent = origText; e.target.disabled = false; }, 2000); } }); }); } else { content.innerHTML = 'No cached datasets.
'; } } catch (error) { content.innerHTML = `Error: ${error.message}
`; } } scrollToBottom() { this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } document.addEventListener('DOMContentLoaded', () => { window.eurusChat = new EurusChat(); });