/* ============================================================ STEM Copilot — Application Logic Auth, BYOK, Chat, Settings, Streaming MD, PWA, Sidebar, Theme Toggle, Teaching Style Selector, Voice Input, Copy/Regenerate, Image Preview, Mobile Overlays, Adaptive Mic/Send Button ============================================================ */ /* ── State ──────────────────────────────────────────────────── */ let currentUser = null; let currentThreadId = crypto.randomUUID(); const threads = []; let isSending = false; let pendingImage = null; let pendingImageDataUrl = null; let isHeroMode = true; let isWebSearchEnabled = false; let isYtSearchEnabled = false; let hasTavilyKey = false; let pendingDocText = ''; let pendingDocName = ''; let pendingDocBytes = ''; let currentPersona = localStorage.getItem('stemcopilot_persona') || 'vidyut'; let currentLanguage = localStorage.getItem('stemcopilot_language') || 'auto'; let currentUsername = localStorage.getItem('stemcopilot_username') || ''; const _PERSONA_LABELS = { vidyut: 'Mentor', nerd: 'Nerd', noob: 'Beginner', thoughtful: 'Thoughtful', panic: 'Panic' }; /* ── Session auth: attach the Bearer token to every same-origin API call ──── The server authenticates each request from this token (it no longer trusts a client-supplied user_id). A 401 means the session is gone/expired → reset to the login screen. External requests (Google, CDNs) are left untouched. */ function _sessionToken() { try { return localStorage.getItem('sc_session') || ''; } catch (_) { return ''; } } let _handling401 = false; const _origFetch = window.fetch.bind(window); window.fetch = function (input, init) { init = init || {}; let url = ''; try { url = typeof input === 'string' ? input : (input && input.url) || ''; } catch (_) { } const isApi = url.startsWith('/') || url.startsWith(location.origin); if (isApi) { const tok = _sessionToken(); if (tok) { init = Object.assign({}, init); const h = new Headers(init.headers || {}); if (!h.has('Authorization')) h.set('Authorization', 'Bearer ' + tok); init.headers = h; } } return _origFetch(input, init).then(resp => { if (isApi && resp.status === 401 && !_handling401) { _handling401 = true; try { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); } catch (_) { } location.reload(); } return resp; }); }; /* ── DOM References ─────────────────────────────────────────── */ const loginScreen = document.getElementById('loginScreen'); const byokScreen = document.getElementById('byokScreen'); const appContainer = document.getElementById('appContainer'); const sidebar = document.getElementById('sidebar'); const sidebarOverlay = document.getElementById('sidebarOverlay'); const sidebarRail = document.getElementById('sidebarRail'); const toggleSidebarBtn = document.getElementById('toggleSidebarBtn'); const chatHistoryList = document.getElementById('chatHistoryList'); const chatContainer = document.getElementById('chatContainer'); const welcomeScreen = document.getElementById('welcomeScreen'); const bottomInputContainer = document.getElementById('bottomInputContainer'); const userInput = document.getElementById('userInput'); const newChatBtn = document.getElementById('newChatBtn'); const stopBtn = document.getElementById('stopBtn'); const adaptiveBtn = document.getElementById('adaptiveBtn'); const heroInput = document.getElementById('heroInput'); const heroAdaptiveBtn = document.getElementById('heroAdaptiveBtn'); const heroUploadBtn = document.getElementById('heroUploadBtn'); const heroImageInput = document.getElementById('heroImageInput'); const heroTitle = document.getElementById('heroTitle'); const byokInput = document.getElementById('byokInput'); const byokSubmitBtn = document.getElementById('byokSubmitBtn'); const userProfileBtn = document.getElementById('userProfileBtn'); const userMenu = document.getElementById('userMenu'); const userAvatar = document.getElementById('userAvatar'); const userDisplayName = document.getElementById('userDisplayName'); const logoutBtn = document.getElementById('logoutBtn'); const railExpandBtn = document.getElementById('railExpandBtn'); const railNewChatBtn = document.getElementById('railNewChatBtn'); const railProfileBtn = document.getElementById('railProfileBtn'); const railAvatar = document.getElementById('railAvatar'); const settingsOverlay = document.getElementById('settingsOverlay'); const openSettingsBtn = document.getElementById('openSettingsBtn'); const settingsCloseBtn = document.getElementById('settingsCloseBtn'); const usernameInput = document.getElementById('usernameInput'); const languageSelect = document.getElementById('languageSelect'); const profileInput = document.getElementById('profileInput'); const saveProfileBtn = document.getElementById('saveProfileBtn'); const settingsApiKeyInput = document.getElementById('settingsApiKeyInput'); const saveApiKeyBtn = document.getElementById('saveApiKeyBtn'); const settingsTavilyKeyInput = document.getElementById('settingsTavilyKeyInput'); const saveTavilyKeyBtn = document.getElementById('saveTavilyKeyBtn'); const byokTavilyInput = document.getElementById('byokTavilyInput'); const attachPlusBtn = document.getElementById('attachPlusBtn'); const attachMenu = document.getElementById('attachMenu'); const webSearchToggle = document.getElementById('webSearchToggle'); const ytSearchToggle = document.getElementById('ytSearchToggle'); const attachFileBtn = document.getElementById('attachFileBtn'); const docInput = document.getElementById('docInput'); const toolProgressBar = document.getElementById('toolProgressBar'); const toolProgressLabel = document.getElementById('toolProgressLabel'); const toolProgressFill = document.getElementById('toolProgressFill'); const imagePreviewBar = document.getElementById('imagePreviewBar'); const imagePreviewThumb = document.getElementById('imagePreviewThumb'); const imagePreviewName = document.getElementById('imagePreviewName'); const imagePreviewRemove = document.getElementById('imagePreviewRemove'); const installBanner = document.getElementById('installBanner'); const installBtn = document.getElementById('installBtn'); const installDismiss = document.getElementById('installDismiss'); const heroImagePreview = document.getElementById('heroImagePreview'); const heroImagePreviewThumb = document.getElementById('heroImagePreviewThumb'); const heroImagePreviewName = document.getElementById('heroImagePreviewName'); const heroImagePreviewRemove = document.getElementById('heroImagePreviewRemove'); const heroAttachPlusBtn = document.getElementById('heroAttachPlusBtn'); const heroAttachMenu = document.getElementById('heroAttachMenu'); const heroWebSearchToggle = document.getElementById('heroWebSearchToggle'); const heroYtSearchToggle = document.getElementById('heroYtSearchToggle'); const heroAttachFileBtn = document.getElementById('heroAttachFileBtn'); const heroDocInput = document.getElementById('heroDocInput'); const heroToolProgressBar = document.getElementById('heroToolProgressBar'); const heroToolProgressLabel = document.getElementById('heroToolProgressLabel'); const heroToolProgressFill = document.getElementById('heroToolProgressFill'); const styleSelectorBtn = document.getElementById('styleSelectorBtn'); const styleSelectorLabel = document.getElementById('styleSelectorLabel'); const styleDropdown = document.getElementById('styleDropdown'); // Mobile elements const mobileTopbar = document.getElementById('mobileTopbar'); const topbarProfileBtn = document.getElementById('topbarProfileBtn'); const topbarAvatar = document.getElementById('topbarAvatar'); const topbarHistoryBtn = document.getElementById('topbarHistoryBtn'); const topbarNewChatBtn = document.getElementById('topbarNewChatBtn'); const accountOverlay = document.getElementById('accountOverlay'); const accountOverlayClose = document.getElementById('accountOverlayClose'); const historyOverlay = document.getElementById('historyOverlay'); const historyOverlayClose = document.getElementById('historyOverlayClose'); const overlayHistoryList = document.getElementById('overlayHistoryList'); const overlayHistoryEmpty = document.getElementById('overlayHistoryEmpty'); const overlaySettingsBtn = document.getElementById('overlaySettingsBtn'); const overlayLogoutBtn = document.getElementById('overlayLogoutBtn'); const overlayUserAvatar = document.getElementById('overlayUserAvatar'); const overlayUserName = document.getElementById('overlayUserName'); const overlayUserEmail = document.getElementById('overlayUserEmail'); // Login theme toggle const loginThemeBtn = document.getElementById('loginThemeBtn'); // Settings theme buttons const themeDarkBtn = document.getElementById('themeDarkBtn'); const themeLightBtn = document.getElementById('themeLightBtn'); /* ── Theme ──────────────────────────────────────────────────── */ function _initTheme() { const saved = localStorage.getItem('stemcopilot_theme') || 'dark'; document.documentElement.setAttribute('data-theme', saved); _updateThemeColor(saved); _syncThemeButtons(saved); } function _setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('stemcopilot_theme', theme); _updateThemeColor(theme); _syncThemeButtons(theme); } function _toggleTheme() { const current = document.documentElement.getAttribute('data-theme') || 'dark'; _setTheme(current === 'dark' ? 'light' : 'dark'); } function _updateThemeColor(theme) { const meta = document.querySelector('meta[name="theme-color"]'); if (meta) meta.content = theme === 'light' ? '#f8f9fa' : '#0a0a0a'; } function _syncThemeButtons(theme) { if (themeDarkBtn) themeDarkBtn.classList.toggle('active', theme === 'dark'); if (themeLightBtn) themeLightBtn.classList.toggle('active', theme === 'light'); } _initTheme(); if (loginThemeBtn) loginThemeBtn.addEventListener('click', _toggleTheme); if (themeDarkBtn) themeDarkBtn.addEventListener('click', () => _setTheme('dark')); if (themeLightBtn) themeLightBtn.addEventListener('click', () => _setTheme('light')); /* ── Toast ──────────────────────────────────────────────────── */ function showToast(msg, type = 'info') { const t = document.createElement('div'); t.textContent = msg; Object.assign(t.style, { position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)', background: type === 'error' ? '#ff4a4a' : type === 'success' ? '#2ea44f' : '#333', color: '#fff', padding: '10px 20px', borderRadius: '10px', fontSize: '13px', fontFamily: 'inherit', zIndex: '9999', boxShadow: '0 4px 20px rgba(0,0,0,0.5)', transition: 'opacity 0.3s', opacity: '0', maxWidth: '90vw', textAlign: 'center', }); document.body.appendChild(t); requestAnimationFrame(() => t.style.opacity = '1'); setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 2500); } /* ── Feedback ───────────────────────────────────────────────── */ function openFeedbackInSettings() { if (userMenu) userMenu.classList.remove('show'); _closeAllOverlays(); document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); const fbBtn = document.querySelector('.settings-nav-btn[data-tab="feedback"]'); const fbTab = document.getElementById('tab-feedback'); if (fbBtn) fbBtn.classList.add('active'); if (fbTab) fbTab.classList.add('active'); settingsOverlay.classList.add('show'); } function _bindFeedbackSubmit() { // ── Star rating ── const stars = document.querySelectorAll('.fb-star'); const overallInput = document.getElementById('fbOverall'); stars.forEach(star => { star.addEventListener('mouseover', () => { const v = +star.dataset.val; stars.forEach(s => s.classList.toggle('hover', +s.dataset.val <= v)); }); star.addEventListener('mouseleave', () => { stars.forEach(s => s.classList.remove('hover')); }); star.addEventListener('click', () => { const v = +star.dataset.val; if (overallInput) overallInput.value = v; stars.forEach(s => s.classList.toggle('active', +s.dataset.val <= v)); }); }); // ── Emoji sub-ratings ── ['fbEaseEmojis', 'fbQualityEmojis'].forEach(id => { const wrap = document.getElementById(id); if (!wrap) return; const field = document.getElementById(wrap.dataset.field); wrap.querySelectorAll('.fb-emoji').forEach(emoji => { emoji.addEventListener('click', () => { wrap.querySelectorAll('.fb-emoji').forEach(e => e.classList.remove('active')); emoji.classList.add('active'); if (field) field.value = emoji.dataset.val; }); }); }); // ── Category pills ── document.querySelectorAll('.fb-cat-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.fb-cat-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); const catInput = document.getElementById('fbCategory'); if (catInput) catInput.value = btn.dataset.val; }); }); // ── Image attachments ── const fbImages = []; // [{filename, dataUrl, base64, type}] const attachBtn = document.getElementById('fbAttachBtn'); const imageInput = document.getElementById('fbImageInput'); const chipsEl = document.getElementById('fbAttachChips'); if (attachBtn && imageInput) { attachBtn.addEventListener('click', () => { if (fbImages.length >= 5) { showToast('Maximum 5 screenshots allowed.', 'error'); return; } imageInput.click(); }); imageInput.addEventListener('change', () => { const remaining = 5 - fbImages.length; Array.from(imageInput.files).slice(0, remaining).forEach(file => { const reader = new FileReader(); reader.onload = e => { const dataUrl = e.target.result; const base64 = dataUrl.split(',')[1]; const entry = { filename: file.name, dataUrl, base64, type: file.type }; fbImages.push(entry); _addFbChip(chipsEl, entry, fbImages); }; reader.readAsDataURL(file); }); imageInput.value = ''; }); } // ── Submit ── let _fbCooldown = false; const btn = document.getElementById('submitFeedbackBtn'); if (!btn) return; btn.addEventListener('click', () => { if (_fbCooldown) { showToast('Please wait before sending again.', 'error'); return; } const msg = (document.getElementById('feedbackMessage') || {}).value || ''; if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; } const overall = parseInt((document.getElementById('fbOverall') || {}).value || '0'); const ease = parseInt((document.getElementById('fbEase') || {}).value || '0'); const quality = parseInt((document.getElementById('fbQuality') || {}).value || '0'); const category = (document.getElementById('fbCategory') || {}).value || 'other'; btn.disabled = true; btn.textContent = 'Sending...'; const attachments = fbImages.map(img => ({ filename: img.filename, content: img.base64, content_type: img.type, })); fetch('/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser ? currentUser.google_id : 'anonymous', user_email: currentUser ? (currentUser.email || '') : '', user_name: currentUser ? (currentUser.name || '') : '', overall, ease, quality, category, message: msg, attachments: attachments.length ? attachments : null, }), }) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then(() => { showToast('Feedback sent. Thank you! 🙌', 'success'); // Reset form document.getElementById('feedbackMessage').value = ''; stars.forEach(s => s.classList.remove('active')); if (overallInput) overallInput.value = '0'; document.querySelectorAll('.fb-emoji').forEach(e => e.classList.remove('active')); ['fbEase','fbQuality'].forEach(id => { const el = document.getElementById(id); if (el) el.value = '0'; }); document.querySelectorAll('.fb-cat-btn').forEach((b, i) => { b.classList.toggle('active', i === 0); }); const catIn = document.getElementById('fbCategory'); if (catIn) catIn.value = 'bug'; fbImages.length = 0; if (chipsEl) chipsEl.innerHTML = ''; // Cooldown _fbCooldown = true; _startFbCooldown(() => { _fbCooldown = false; }); }) .catch(() => showToast('Could not send feedback.', 'error')) .finally(() => { btn.disabled = false; btn.textContent = 'Send Feedback'; }); }); } function _addFbChip(container, entry, list) { const chip = document.createElement('div'); chip.className = 'fb-attach-chip'; const img = document.createElement('img'); img.src = entry.dataUrl; img.alt = entry.filename; const removeBtn = document.createElement('button'); removeBtn.className = 'fb-attach-chip-remove'; removeBtn.innerHTML = '×'; removeBtn.addEventListener('click', () => { const idx = list.indexOf(entry); if (idx !== -1) list.splice(idx, 1); chip.remove(); }); chip.appendChild(img); chip.appendChild(removeBtn); container.appendChild(chip); } function _startFbCooldown(onDone) { const wrap = document.getElementById('fbCooldownWrap'); const bar = document.getElementById('fbCooldownBar'); const label = document.getElementById('fbCooldownLabel'); if (!wrap) { setTimeout(onDone, 60000); return; } wrap.style.display = 'flex'; // Reset animation if (bar) { bar.style.animation = 'none'; bar.offsetHeight; bar.style.animation = ''; } let secs = 60; const tick = setInterval(() => { secs--; if (label) label.textContent = `Wait ${secs}s before sending again`; if (secs <= 0) { clearInterval(tick); wrap.style.display = 'none'; onDone(); } }, 1000); } /* ── Key & History Cache Helpers ───────────────────────────── */ // Keys stored in localStorage so the backend can recover them after a restart. function _getCachedOrKey() { try { return localStorage.getItem('sc_or_key') || ''; } catch(_) { return ''; } } function _getCachedTvKey() { try { return localStorage.getItem('sc_tv_key') || ''; } catch(_) { return ''; } } function _setCachedOrKey(k) { try { if (k) localStorage.setItem('sc_or_key', k); else localStorage.removeItem('sc_or_key'); } catch(_) {} } function _setCachedTvKey(k) { try { if (k) localStorage.setItem('sc_tv_key', k); else localStorage.removeItem('sc_tv_key'); } catch(_) {} } // Push locally-cached API keys back to the server (e.g. after a server restart // wiped the DB), then call onDone. function _restoreCachedKeys(onDone) { const orKey = _getCachedOrKey(); const tvKey = _getCachedTvKey(); if (!orKey || !currentUser) { onDone(); return; } const saves = [ fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: orKey }), }), ]; if (tvKey) { saves.push( fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: tvKey }), }).then(() => { hasTavilyKey = true; }) ); } Promise.all(saves).then(onDone).catch(onDone); } // Thread list cache — keeps sidebar populated even if the server's DB is wiped. function _saveThreadsCache() { try { localStorage.setItem('sc_threads', JSON.stringify(threads.slice(0, 50))); } catch(_) {} } function _loadThreadsCache() { try { const r = localStorage.getItem('sc_threads'); return r ? JSON.parse(r) : []; } catch(_) { return []; } } // Per-thread message cache — lets /history fall back to localStorage when the // LangGraph checkpointer DB is empty (e.g. after a server restart). function _saveMsgsCache(threadId, messages) { try { localStorage.setItem('sc_msgs_' + threadId, JSON.stringify(messages.slice(-40))); } catch(_) {} } function _loadMsgsCache(threadId) { try { const r = localStorage.getItem('sc_msgs_' + threadId); return r ? JSON.parse(r) : []; } catch(_) { return []; } } function _deleteMsgsCache(threadId) { try { localStorage.removeItem('sc_msgs_' + threadId); } catch(_) {} } // Snapshot the current chat DOM into the message cache for the active thread. function _cacheCurrentMessages() { const messages = []; chatContainer.querySelectorAll('.message-row').forEach(row => { const isUser = row.classList.contains('user'); const isAi = row.classList.contains('ai'); if (!isUser && !isAi) return; const contentEl = row.querySelector('.message-content'); if (!contentEl) return; const content = isAi ? (contentEl.dataset.md || contentEl.textContent || '').trim() : (contentEl.dataset.raw != null ? contentEl.dataset.raw : contentEl.textContent || '').trim(); if (content) messages.push({ role: isUser ? 'user' : 'assistant', content }); }); if (messages.length > 0) _saveMsgsCache(currentThreadId, messages); } /* ── Auth — Google Sign-In ──────────────────────────────────── */ let _gsiInitialized = false; function initGoogleAuth() { return new Promise((resolve) => { if (_gsiInitialized) { resolve(); return; } fetch('/auth/client_id').then(r => r.json()).then(data => { if (!data.client_id) { showApp(); resolve(); return; } const script = document.createElement('script'); script.src = 'https://accounts.google.com/gsi/client'; script.async = true; script.defer = true; script.onload = () => { google.accounts.id.initialize({ client_id: data.client_id, callback: handleGoogleCredential, auto_select: false, cancel_on_tap_outside: false, }); _renderHiddenGoogleBtn(); _gsiInitialized = true; resolve(); }; script.onerror = () => { showApp(); resolve(); }; document.head.appendChild(script); }).catch(() => { showApp(); resolve(); }); }); } function _renderHiddenGoogleBtn(cb) { const container = document.getElementById('gsi-hidden-btn'); if (!container) return; container.innerHTML = ''; google.accounts.id.renderButton(container, { type: 'standard', theme: 'filled_black', size: 'large', text: 'signin_with', shape: 'pill', width: 240, }); setTimeout(() => { container.style.pointerEvents = 'auto'; if (cb) cb(); }, 150); } function triggerGoogleSignIn() { const tryClick = () => { const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]'); if (realBtn) realBtn.click(); else _renderHiddenGoogleBtn(() => { const btn = document.querySelector('#gsi-hidden-btn [role="button"]'); if (btn) btn.click(); }); }; if (_gsiInitialized) tryClick(); else initGoogleAuth().then(tryClick); } function handleGoogleCredential(response) { fetch('/auth/google', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: response.credential }), }).then(r => { if (!r.ok) throw new Error('Auth failed'); return r.json(); }) .then(data => { if (data.error) { showToast('Login failed: ' + data.error, 'error'); return; } currentUser = data.user; if (data.token) { try { localStorage.setItem('sc_session', data.token); } catch (_) { } } localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser)); currentUsername = currentUser.name; localStorage.setItem('stemcopilot_username', currentUsername); hasTavilyKey = !!data.has_tavily_key; if (!data.has_api_key) { const cached = _getCachedOrKey(); if (cached) _restoreCachedKeys(showApp); else showByok(); } else showApp(); }).catch(() => showToast('Login failed. Check your connection.', 'error')); } function checkExistingSession() { const saved = localStorage.getItem('stemcopilot_user'); if (saved) { currentUser = JSON.parse(saved); currentUsername = currentUser.name; fetch('/auth/me?user_id=' + encodeURIComponent(currentUser.google_id)) .then(r => r.json()) .then(data => { if (data.error) { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); currentUser = null; initGoogleAuth(); return; } currentUser = data.user; hasTavilyKey = !!data.has_tavily_key; if (!data.has_api_key) { const cached = _getCachedOrKey(); if (cached) _restoreCachedKeys(showApp); else showByok(); } else showApp(); }).catch(() => initGoogleAuth()); } else initGoogleAuth(); } /* ── BYOK ───────────────────────────────────────────────────── */ function showByok() { loginScreen.style.display = 'none'; byokScreen.style.display = 'flex'; appContainer.style.display = 'none'; } if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => { const key = byokInput.value.trim(); if (!key) { byokInput.focus(); return; } const tavilyKey = byokTavilyInput ? byokTavilyInput.value.trim() : ''; fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) }) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then(() => { _setCachedOrKey(key); if (tavilyKey) { return fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: tavilyKey }) }) .then(() => { hasTavilyKey = true; _setCachedTvKey(tavilyKey); }); } }) .then(() => showApp()) .catch(() => showToast('Failed to save key.', 'error')); }); // Enter in either BYOK field triggers "Start Learning". [byokInput, byokTavilyInput].forEach(inp => { if (inp) inp.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); byokSubmitBtn?.click(); } }); }); /* ── Show Main App ──────────────────────────────────────────── */ function showApp() { loginScreen.style.display = 'none'; byokScreen.style.display = 'none'; appContainer.style.display = 'flex'; if (currentUser) { if (userDisplayName) userDisplayName.textContent = currentUser.name || 'Student'; const pic = currentUser.picture || ''; if (pic) { if (userAvatar) userAvatar.src = pic; if (railAvatar) railAvatar.src = pic; if (topbarAvatar) topbarAvatar.src = pic; if (overlayUserAvatar) overlayUserAvatar.src = pic; } if (overlayUserName) overlayUserName.textContent = currentUser.name || 'Student'; if (overlayUserEmail) overlayUserEmail.textContent = currentUser.email || ''; fetch('/auth/me?user_id=' + encodeURIComponent(currentUser.google_id)) .then(r => r.json()) .then(data => { if (data.user) { if (data.user.student_profile && profileInput) profileInput.value = data.user.student_profile; if (data.user.openrouter_key && settingsApiKeyInput) settingsApiKeyInput.value = '••••••••••••'; if (data.user.tavily_key && settingsTavilyKeyInput) settingsTavilyKeyInput.value = '••••••••••••'; } hasTavilyKey = !!data.has_tavily_key; _applyWebSearchAvailability(); }).catch(() => { }); } if (usernameInput) usernameInput.value = currentUsername; if (languageSelect) languageSelect.value = currentLanguage; _syncStyleLabel(); const userId = currentUser ? currentUser.google_id : ''; fetch('/threads?user_id=' + encodeURIComponent(userId)) .then(r => r.json()) .then(data => { const serverThreads = data.threads || []; // If the server returned results, use them (and update the cache). // If the server's DB was wiped (empty), fall back to the local cache. const source = serverThreads.length > 0 ? serverThreads : _loadThreadsCache(); source.forEach(t => threads.push({ id: t.id, title: t.title })); if (serverThreads.length > 0) _saveThreadsCache(); renderHistory(); }) .catch(() => { _loadThreadsCache().forEach(t => threads.push({ id: t.id, title: t.title })); renderHistory(); }); if (window.innerWidth <= 768) closeSidebar(); enterHeroMode(); } /* ── Hero / Bottom Input Switching ──────────────────────────── */ function enterHeroMode() { isHeroMode = true; bottomInputContainer.style.display = 'none'; const old = document.getElementById('welcomeScreen'); if (old) old.remove(); chatContainer.innerHTML = ''; chatContainer.appendChild(createWelcomeScreen()); _updateHeroTitle(); } const _HERO_GREETINGS = [ 'What should we study today', 'Ready to learn something new', 'What topic are you curious about', 'Let\'s tackle a concept together', 'What can I help you understand', 'Pick a subject and let\'s dive in', 'Got a question? Fire away', ]; function _updateHeroTitle() { const el = document.getElementById('heroTitle'); if (!el) return; const name = currentUsername || (currentUser ? currentUser.name : ''); const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)]; if (name) el.innerHTML = `${greeting}, ${escapeHtml(name)}?`; else el.textContent = greeting + '?'; } function exitHeroMode() { isHeroMode = false; const ws = document.getElementById('welcomeScreen'); if (ws) ws.remove(); bottomInputContainer.style.display = ''; } /* ── Sidebar (Desktop) ──────────────────────────────────────── */ let sidebarOpen = false; function openSidebar() { sidebarOpen = true; sidebar.classList.remove('collapsed'); if (sidebarOverlay && window.innerWidth <= 768) sidebarOverlay.classList.add('visible'); if (sidebarRail) sidebarRail.classList.remove('visible'); } function closeSidebar() { sidebarOpen = false; sidebar.classList.add('collapsed'); if (sidebarOverlay) sidebarOverlay.classList.remove('visible'); if (sidebarRail && window.innerWidth > 768) sidebarRail.classList.add('visible'); } function toggleSidebar() { if (sidebarOpen) closeSidebar(); else openSidebar(); } if (toggleSidebarBtn) toggleSidebarBtn.addEventListener('click', toggleSidebar); if (sidebarOverlay) { sidebarOverlay.addEventListener('click', closeSidebar); sidebarOverlay.addEventListener('touchstart', (e) => { e.preventDefault(); closeSidebar(); }, { passive: false }); } if (railExpandBtn) railExpandBtn.addEventListener('click', openSidebar); if (railNewChatBtn) railNewChatBtn.addEventListener('click', () => startNewChat()); if (railProfileBtn) railProfileBtn.addEventListener('click', () => { openSidebar(); setTimeout(() => { if (userProfileBtn) userProfileBtn.click(); }, 150); }); if (newChatBtn) newChatBtn.addEventListener('click', startNewChat); function startNewChat() { currentThreadId = crypto.randomUUID(); chatContainer.innerHTML = ''; chatContainer.appendChild(createWelcomeScreen()); enterHeroMode(); renderHistory(); if (window.innerWidth <= 768) { closeSidebar(); _closeAllOverlays(); } } /* ── Mobile Overlays ────────────────────────────────────────── */ function _closeAllOverlays() { if (accountOverlay) accountOverlay.classList.remove('open'); if (historyOverlay) historyOverlay.classList.remove('open'); } if (topbarProfileBtn) topbarProfileBtn.addEventListener('click', () => { _closeAllOverlays(); if (accountOverlay) accountOverlay.classList.add('open'); }); if (accountOverlayClose) accountOverlayClose.addEventListener('click', () => { if (accountOverlay) accountOverlay.classList.remove('open'); }); if (topbarHistoryBtn) topbarHistoryBtn.addEventListener('click', () => { _closeAllOverlays(); _renderOverlayHistory(); if (historyOverlay) historyOverlay.classList.add('open'); }); if (historyOverlayClose) historyOverlayClose.addEventListener('click', () => { if (historyOverlay) historyOverlay.classList.remove('open'); }); if (topbarNewChatBtn) topbarNewChatBtn.addEventListener('click', () => { _closeAllOverlays(); startNewChat(); }); if (overlaySettingsBtn) overlaySettingsBtn.addEventListener('click', () => { _closeAllOverlays(); settingsOverlay.classList.add('show'); }); if (overlayLogoutBtn) overlayLogoutBtn.addEventListener('click', () => { pwaConfirm('Do you want to log out?').then(yes => { if (yes) { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); localStorage.removeItem('stemcopilot_username'); currentUser = null; location.reload(); } }); }); function _renderOverlayHistory() { if (!overlayHistoryList) return; overlayHistoryList.innerHTML = ''; if (threads.length === 0) { if (overlayHistoryEmpty) overlayHistoryEmpty.style.display = 'flex'; return; } if (overlayHistoryEmpty) overlayHistoryEmpty.style.display = 'none'; threads.forEach(thread => { const item = document.createElement('div'); item.className = `overlay-history-item ${thread.id === currentThreadId ? 'active' : ''}`; item.innerHTML = `
${escapeHtml(thread.title)}
`; // Load thread on main area click item.querySelector('.overlay-history-main').addEventListener('click', () => { _closeAllOverlays(); loadThread(thread.id); }); // Kebab toggle const kebab = item.querySelector('.overlay-kebab-btn'); const ctx = item.querySelector('.overlay-ctx-menu'); kebab.addEventListener('click', (e) => { e.stopPropagation(); const wasOpen = ctx.classList.contains('show'); _closeAllCtxMenus(); if (!wasOpen) ctx.classList.add('show'); }); // Rename item.querySelector('[data-action="rename"]').addEventListener('click', (e) => { e.stopPropagation(); ctx.classList.remove('show'); pwaPrompt('Rename chat:', thread.title).then(newTitle => { if (newTitle && newTitle.trim()) { fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: thread.id, title: newTitle.trim() }) }) .then(() => { thread.title = newTitle.trim(); _saveThreadsCache(); _renderOverlayHistory(); renderHistory(); }) .catch(() => showToast('Failed to rename.', 'error')); } }); }); // Delete item.querySelector('[data-action="delete"]').addEventListener('click', (e) => { e.stopPropagation(); ctx.classList.remove('show'); pwaConfirm('Delete this chat?').then(yes => { if (yes) { fetch('/thread/' + thread.id, { method: 'DELETE' }) .then(() => { const idx = threads.findIndex(t => t.id === thread.id); if (idx !== -1) threads.splice(idx, 1); _saveThreadsCache(); _deleteMsgsCache(thread.id); if (thread.id === currentThreadId) startNewChat(); _renderOverlayHistory(); renderHistory(); }) .catch(() => showToast('Failed to delete.', 'error')); } }); }); overlayHistoryList.appendChild(item); }); } function _closeAllCtxMenus() { document.querySelectorAll('.overlay-ctx-menu.show').forEach(m => m.classList.remove('show')); } document.addEventListener('click', () => _closeAllCtxMenus()); /* ── Create Welcome Screen (Dynamic) ───────────────────────── */ function createWelcomeScreen() { const div = document.createElement('div'); div.className = 'hero-welcome'; div.id = 'welcomeScreen'; const name = currentUsername || (currentUser ? currentUser.name : ''); const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)]; const titleHtml = name ? `${greeting}, ${escapeHtml(name)}?` : `${greeting}?`; const styleLabel = _PERSONA_LABELS[currentPersona] || 'Mentor'; div.innerHTML = ` STEM Copilot

${titleHtml}

${['vidyut', 'nerd', 'noob', 'thoughtful', 'panic'].map(p => `
${_PERSONA_LABELS[p]}
${_personaDesc(p)}
`).join('')}
`; const dynInput = div.querySelector('#heroInputDynamic'); const dynAdaptive = div.querySelector('#heroAdaptiveBtnDynamic'); const dynImgPreview = div.querySelector('#heroImagePreviewDynamic'); const dynStyleBtn = div.querySelector('#heroStyleSelectorBtnDynamic'); const dynStyleDropdown = div.querySelector('#heroStyleDropdownDynamic'); const dynStyleLabel = div.querySelector('#heroStyleSelectorLabelDynamic'); // Dynamic attach elements const dynPlusBtn = div.querySelector('#heroAttachPlusBtnDynamic'); const dynMenu = div.querySelector('#heroAttachMenuDynamic'); const dynWebSearchToggle = div.querySelector('#heroWebSearchToggleDynamic'); const dynYtSearchToggle = div.querySelector('#heroYtSearchToggleDynamic'); const dynAttachFileBtn = div.querySelector('#heroAttachFileBtnDynamic'); const dynDocInput = div.querySelector('#heroDocInputDynamic'); // Setup dynamic attach menu setupAttachMenu( dynPlusBtn, dynMenu, dynWebSearchToggle, dynYtSearchToggle, dynAttachFileBtn, null, dynDocInput, dynImgPreview ); // Sync toggle visual states immediately setTimeout(() => { _syncToggles(); _updateInputPlaceholder(); }, 0); // Auto-resize dynInput.addEventListener('input', function () { this.style.height = '54px'; this.style.height = this.scrollHeight + 'px'; }); // Adaptive button logic _bindAdaptiveBtn(dynAdaptive, dynInput); dynInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !_isMobile()) { e.preventDefault(); userInput.value = dynInput.value; sendMessage(); } }); dynInput.addEventListener('click', () => closeAllMenus()); dynInput.addEventListener('focus', () => closeAllMenus()); // Style selector if (dynStyleBtn) { dynStyleBtn.addEventListener('click', (e) => { e.stopPropagation(); dynStyleDropdown.classList.toggle('show'); dynStyleBtn.classList.toggle('open'); }); } if (dynStyleDropdown) { dynStyleDropdown.querySelectorAll('.style-option').forEach(opt => { opt.addEventListener('click', (e) => { e.stopPropagation(); _setPersona(opt.dataset.persona); dynStyleDropdown.classList.remove('show'); dynStyleBtn.classList.remove('open'); dynStyleLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Mentor'; dynStyleDropdown.querySelectorAll('.style-option').forEach(o => o.classList.toggle('active', o.dataset.persona === currentPersona)); }); }); } return div; } function _personaDesc(p) { const descs = { vidyut: 'Calm, clear, step-by-step master teacher. Makes hard concepts obvious.', nerd: 'Deep, rigorous, first-principles. Goes beyond the textbook.', noob: 'Patient, step-by-step. Explains like you\'re seeing it for the first time.', thoughtful: 'Connects science to the real world. Every formula has a story.', panic: 'Exam tomorrow? Concise bullets, key formulas, no fluff.', }; return descs[p] || ''; } function _showHeroImagePreview(previewEl) { // kept for compatibility — chip rendering is handled in handleImageFile if (previewEl) previewEl.classList.add('visible'); } /* ── Chat History ───────────────────────────────────────────── */ function addThreadToSidebar(id, title) { threads.unshift({ id, title }); _saveThreadsCache(); renderHistory(); } function renderHistory() { if (!chatHistoryList) return; chatHistoryList.innerHTML = ''; threads.forEach(thread => { const item = document.createElement('div'); item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`; item.setAttribute('data-thread-id', thread.id); item.innerHTML = ` ${escapeHtml(thread.title)}
Rename
Delete
`; item.addEventListener('click', (e) => { if (e.target.closest('.options-btn') || e.target.closest('.options-menu')) return; loadThread(thread.id); }); chatHistoryList.appendChild(item); }); } function loadThread(threadId) { currentThreadId = threadId; renderHistory(); chatContainer.innerHTML = ''; exitHeroMode(); if (window.innerWidth <= 768) closeSidebar(); // Show skeleton while loading chatContainer.innerHTML = `
`; fetch('/history/' + threadId).then(res => res.json()).then(data => { chatContainer.innerHTML = ''; chatContainer.classList.add('history-loading'); let messages = data.messages || []; if (messages.length === 0) { // Server's checkpointer DB may have been wiped — fall back to local cache. messages = _loadMsgsCache(threadId); } else { // Keep the local cache fresh for next time. _saveMsgsCache(threadId, messages.map(m => ({ role: m.role, content: Array.isArray(m.content) ? m.content.filter(p => p.type === 'text').map(p => p.text).join('') : m.content, }))); } messages.forEach(msg => { const sender = msg.role === 'user' ? 'user' : 'ai'; let textContent = msg.content; if (Array.isArray(msg.content)) textContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join(''); const el = appendMessage(sender, textContent); if (sender === 'ai') renderFinalContent(el, textContent); }); scrollToBottom(true); requestAnimationFrame(() => chatContainer.classList.remove('history-loading')); }).catch(() => { // Network error — show whatever we have cached. chatContainer.innerHTML = ''; chatContainer.classList.add('history-loading'); _loadMsgsCache(threadId).forEach(msg => { const sender = msg.role === 'user' ? 'user' : 'ai'; const el = appendMessage(sender, msg.content); if (sender === 'ai') renderFinalContent(el, msg.content); }); scrollToBottom(true); requestAnimationFrame(() => chatContainer.classList.remove('history-loading')); }); } /* ── Options Menu ───────────────────────────────────────────── */ function toggleMenu(e, btn) { e.stopPropagation(); closeAllMenus(); btn.nextElementSibling.classList.add('show'); btn.classList.add('menu-open'); const item = btn.closest('.history-item'); if (item) item.classList.add('menu-active'); } document.addEventListener('click', closeAllMenus); function closeAllMenus() { document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show')); document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open')); document.querySelectorAll('.history-item.menu-active').forEach(i => i.classList.remove('menu-active')); if (userMenu) userMenu.classList.remove('show'); // Close ALL style dropdowns — static and dynamic hero ones document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show')); document.querySelectorAll('.style-selector-btn.open').forEach(b => b.classList.remove('open')); // Close ALL attach menus document.querySelectorAll('.attach-menu.show').forEach(m => m.classList.remove('show')); document.querySelectorAll('.attach-plus-btn.open').forEach(b => b.classList.remove('open')); // Close the regenerate ("Try again / Customize") popup document.querySelectorAll('.regen-menu.show').forEach(m => m.classList.remove('show')); // Close any open info tooltip document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip')); } function deleteChat(e, optionEl) { e.stopPropagation(); const item = optionEl.closest('.history-item'); const threadId = item.getAttribute('data-thread-id'); const idx = threads.findIndex(t => t.id === threadId); if (idx !== -1) threads.splice(idx, 1); _saveThreadsCache(); _deleteMsgsCache(threadId); item.style.opacity = '0'; setTimeout(() => item.remove(), 200); fetch('/thread/' + threadId, { method: 'DELETE' }); } function renameChat(e, optionEl) { e.stopPropagation(); const item = optionEl.closest('.history-item'); const titleSpan = item.querySelector('.chat-title'); const threadId = item.getAttribute('data-thread-id'); closeAllMenus(); const currentTitle = titleSpan.innerText; const input = document.createElement('input'); input.type = 'text'; input.value = currentTitle; input.className = 'rename-input'; titleSpan.replaceWith(input); input.focus(); input.selectionStart = input.selectionEnd = input.value.length; function saveRename() { const newTitle = input.value.trim() || 'Untitled Chat'; titleSpan.innerText = newTitle; input.replaceWith(titleSpan); const thread = threads.find(t => t.id === threadId); if (thread) thread.title = newTitle; _saveThreadsCache(); fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: threadId, title: newTitle }) }); } input.addEventListener('blur', saveRename); input.addEventListener('keydown', evt => { if (evt.key === 'Enter') saveRename(); if (evt.key === 'Escape') { titleSpan.innerText = currentTitle; input.replaceWith(titleSpan); } }); input.addEventListener('click', evt => evt.stopPropagation()); } /* ── User Menu & Settings ───────────────────────────────────── */ if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => { e.stopPropagation(); userMenu.classList.toggle('show'); }); if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => { userMenu.classList.remove('show'); settingsOverlay.classList.add('show'); }); if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show')); if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) settingsOverlay.classList.remove('show'); }); if (logoutBtn) logoutBtn.addEventListener('click', () => { pwaConfirm('Do you want to log out?').then(yes => { if (yes) { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); localStorage.removeItem('stemcopilot_username'); currentUser = null; location.reload(); } }); }); document.querySelectorAll('.settings-nav-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); btn.classList.add('active'); const tab = document.getElementById('tab-' + btn.dataset.tab); if (tab) tab.classList.add('active'); }); }); if (usernameInput) usernameInput.addEventListener('input', () => { currentUsername = usernameInput.value.trim(); localStorage.setItem('stemcopilot_username', currentUsername); }); if (languageSelect) languageSelect.addEventListener('change', () => { currentLanguage = languageSelect.value; localStorage.setItem('stemcopilot_language', currentLanguage); }); if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => { const key = settingsApiKeyInput.value.trim(); if (!key || key.startsWith('••')) return; if (!currentUser) return; saveApiKeyBtn.disabled = true; saveApiKeyBtn.textContent = 'Saving...'; fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) }) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then(() => { _setCachedOrKey(key); settingsApiKeyInput.value = '••••••••••••'; showToast('API key saved!', 'success'); }) .catch(() => showToast('Failed to save API key.', 'error')) .finally(() => { saveApiKeyBtn.disabled = false; saveApiKeyBtn.textContent = 'Save Key'; }); }); if (saveTavilyKeyBtn) saveTavilyKeyBtn.addEventListener('click', () => { const key = settingsTavilyKeyInput.value.trim(); // Allow saving an empty value to clear/disable web search; ignore the masked placeholder. if (key.startsWith('••')) return; if (!currentUser) return; saveTavilyKeyBtn.disabled = true; saveTavilyKeyBtn.textContent = 'Saving...'; fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) }) .then(r => { if (!r.ok) throw new Error(); return r.json(); }) .then((data) => { hasTavilyKey = !!data.has_tavily_key; _setCachedTvKey(hasTavilyKey ? key : ''); _applyWebSearchAvailability(); if (settingsTavilyKeyInput) settingsTavilyKeyInput.value = hasTavilyKey ? '••••••••••••' : ''; showToast(hasTavilyKey ? 'Tavily key saved! Web search enabled.' : 'Tavily key cleared. Web search disabled.', 'success'); }) .catch(() => showToast('Failed to save Tavily key.', 'error')) .finally(() => { saveTavilyKeyBtn.disabled = false; saveTavilyKeyBtn.textContent = 'Save Key'; }); }); if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => { if (!currentUser) return; fetch('/user/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }) }) .then(r => { if (!r.ok) throw new Error(); showToast('Profile saved!', 'success'); }) .catch(() => showToast('Failed to save profile.', 'error')); }); /* ── Teaching Style Selector ────────────────────────────────── */ function _setPersona(persona) { currentPersona = persona; localStorage.setItem('stemcopilot_persona', currentPersona); _syncStyleLabel(); _syncStyleDropdown(); } function _syncStyleLabel() { if (styleSelectorLabel) styleSelectorLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Mentor'; } function _syncStyleDropdown() { if (!styleDropdown) return; styleDropdown.querySelectorAll('.style-option').forEach(opt => opt.classList.toggle('active', opt.dataset.persona === currentPersona)); } if (styleSelectorBtn) { styleSelectorBtn.addEventListener('click', (e) => { e.stopPropagation(); styleDropdown.classList.toggle('show'); styleSelectorBtn.classList.toggle('open'); }); } if (styleDropdown) { styleDropdown.querySelectorAll('.style-option').forEach(opt => { opt.addEventListener('click', (e) => { e.stopPropagation(); _setPersona(opt.dataset.persona); styleDropdown.classList.remove('show'); styleSelectorBtn.classList.remove('open'); }); }); } _syncStyleLabel(); _syncStyleDropdown(); /* ── Adaptive Mic/Send Button ───────────────────────────────── */ function _bindAdaptiveBtn(btn, inputEl) { if (!btn || !inputEl) return; // Watch input to toggle mic ↔ send inputEl.addEventListener('input', () => { btn.classList.toggle('has-text', inputEl.value.trim().length > 0); }); // Mic / Send / Voice logic const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; let recognition = null; btn.addEventListener('click', () => { const hasText = inputEl.value.trim().length > 0; if (hasText) { // Act as send if (inputEl === userInput) sendMessage(); else { userInput.value = inputEl.value; sendMessage(); } return; } // Act as mic if (!SpeechRecognition) { showToast('Voice input not supported in this browser.', 'error'); return; } if (btn.classList.contains('listening')) { if (recognition) recognition.stop(); btn.classList.remove('listening'); return; } recognition = new SpeechRecognition(); recognition.lang = 'en-IN'; recognition.interimResults = true; recognition.continuous = false; btn.classList.add('listening'); recognition.onresult = (event) => { let transcript = ''; for (let i = event.resultIndex; i < event.results.length; i++) transcript += event.results[i][0].transcript; inputEl.value = transcript; inputEl.dispatchEvent(new Event('input')); }; recognition.onend = () => btn.classList.remove('listening'); recognition.onerror = () => btn.classList.remove('listening'); recognition.start(); }); } // Bind bottom input adaptive button _bindAdaptiveBtn(adaptiveBtn, userInput); // Bind hero adaptive button (static HTML version) _bindAdaptiveBtn(heroAdaptiveBtn, heroInput); /* ── Image Upload ───────────────────────────────────────────── */ function _makeChip(dataUrl, filename, onRemove) { const chip = document.createElement('div'); chip.className = 'image-chip'; chip.innerHTML = `
${escapeHtml(filename)} `; chip.querySelector('.image-chip-remove').addEventListener('click', (e) => { e.stopPropagation(); onRemove(); }); return chip; } document.addEventListener('paste', (e) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type.startsWith('image/')) { e.preventDefault(); handleImageFile(item.getAsFile()); return; } } }); function _makeDocChip(filename, onRemove, statusText = '') { const chip = document.createElement('div'); chip.className = 'image-chip doc-chip'; const docIcon = ` `; chip.innerHTML = `
${docIcon}
${escapeHtml(filename)} ${statusText ? `${escapeHtml(statusText)}` : ''}
`; chip.querySelector('.image-chip-remove').addEventListener('click', (e) => { e.stopPropagation(); onRemove(); }); return chip; } function _clearImage() { pendingImage = null; pendingImageDataUrl = null; if (imageInput) imageInput.value = ''; if (heroImageInput) heroImageInput.value = ''; const dynImgInput = document.getElementById('heroImageInputDynamic'); if (dynImgInput) dynImgInput.value = ''; } function _clearDoc() { pendingDocText = ''; pendingDocName = ''; pendingDocBytes = ''; if (docInput) docInput.value = ''; const dynDocInput = document.getElementById('heroDocInputDynamic'); if (dynDocInput) dynDocInput.value = ''; } function handleImageFile(file, targetPreviewEl) { _clearDoc(); const reader = new FileReader(); reader.onload = (e) => { const dataUrl = e.target.result; pendingImage = dataUrl.split(',')[1]; pendingImageDataUrl = dataUrl; const filename = file.name || 'image'; const activePreview = targetPreviewEl || (isHeroMode ? (document.getElementById('heroImagePreviewDynamic') || heroImagePreview) : imagePreviewBar); if (activePreview === imagePreviewBar) { const heroPrev = document.getElementById('heroImagePreviewDynamic') || heroImagePreview; if (heroPrev) { heroPrev.innerHTML = ''; heroPrev.classList.remove('visible'); } } else { if (imagePreviewBar) { imagePreviewBar.innerHTML = ''; imagePreviewBar.style.display = 'none'; } } activePreview.innerHTML = ''; activePreview.appendChild(_makeChip(dataUrl, filename, () => { _clearImage(); activePreview.innerHTML = ''; if (activePreview === imagePreviewBar) activePreview.style.display = 'none'; else activePreview.classList.remove('visible'); })); if (activePreview === imagePreviewBar) activePreview.style.display = 'flex'; else activePreview.classList.add('visible'); }; reader.readAsDataURL(file); } // Upload a document with one automatic retry on a transient network error // (flaky mobile data was surfacing as a one-off "Failed to fetch"). 60s timeout. function _postUploadDoc(formData, retriesLeft) { const ctrl = new AbortController(); // Mobile networks + larger PDFs need a generous window, otherwise the abort // surfaces to the user as a confusing "Failed to fetch". const timer = setTimeout(() => ctrl.abort(), 120000); return fetch('/upload-doc', { method: 'POST', body: formData, signal: ctrl.signal }) .then(res => { clearTimeout(timer); if (!res.ok) { return res.json() .then(d => { throw new Error(d.error || 'Failed to parse file'); }) .catch(() => { throw new Error('Failed to parse file'); }); } return res.json(); }) .catch(err => { clearTimeout(timer); const transient = err && (err.name === 'TypeError' || err.name === 'AbortError' || /failed to fetch|network|load failed/i.test(err.message || '')); if (retriesLeft > 0 && transient) { return new Promise(r => setTimeout(r, 800)).then(() => _postUploadDoc(formData, retriesLeft - 1)); } throw err; }); } function handleDocFile(file, targetPreviewEl) { _clearImage(); pendingDocText = ''; pendingDocName = file.name; pendingDocBytes = ''; const base64Reader = new FileReader(); base64Reader.onload = (e) => { pendingDocBytes = e.target.result.split(',')[1]; }; base64Reader.readAsDataURL(file); const container = targetPreviewEl || (isHeroMode ? (document.getElementById('heroImagePreviewDynamic') || heroImagePreview) : imagePreviewBar); if (!container) return; if (container === imagePreviewBar) { const heroPrev = document.getElementById('heroImagePreviewDynamic') || heroImagePreview; if (heroPrev) { heroPrev.innerHTML = ''; heroPrev.classList.remove('visible'); } } else { if (imagePreviewBar) { imagePreviewBar.innerHTML = ''; imagePreviewBar.style.display = 'none'; } } const ext = file.name.split('.').pop().toLowerCase(); const isText = ['txt', 'md', 'py', 'js', 'ts', 'csv', 'json', 'html', 'css'].includes(ext) || file.type.startsWith('text/'); if (isText) { const reader = new FileReader(); reader.onload = (e) => { pendingDocText = e.target.result; container.innerHTML = ''; container.appendChild(_makeDocChip(file.name, () => { _clearDoc(); container.innerHTML = ''; if (container === imagePreviewBar) container.style.display = 'none'; else container.classList.remove('visible'); }, 'Attached')); if (container === imagePreviewBar) container.style.display = 'flex'; else container.classList.add('visible'); }; reader.readAsText(file); } else { container.innerHTML = ''; container.appendChild(_makeDocChip(file.name, () => { _clearDoc(); container.innerHTML = ''; if (container === imagePreviewBar) container.style.display = 'none'; else container.classList.remove('visible'); }, 'Extracting text...')); if (container === imagePreviewBar) container.style.display = 'flex'; else container.classList.add('visible'); const formData = new FormData(); formData.append('file', file); _postUploadDoc(formData, 2) .then(data => { if (data.error) throw new Error(data.error); pendingDocText = data.text; container.innerHTML = ''; container.appendChild(_makeDocChip(file.name, () => { _clearDoc(); container.innerHTML = ''; if (container === imagePreviewBar) container.style.display = 'none'; else container.classList.remove('visible'); }, 'Extracted successfully')); }) .catch(err => { showToast(err.message || 'Failed to extract text from document.', 'error'); container.innerHTML = ''; container.appendChild(_makeDocChip(file.name, () => { _clearDoc(); container.innerHTML = ''; if (container === imagePreviewBar) container.style.display = 'none'; else container.classList.remove('visible'); }, 'Extraction failed')); }); } } function handleAttachedFile(file, targetPreviewEl) { if (!file) return; if (file.type.startsWith('image/')) { handleImageFile(file, targetPreviewEl); } else { handleDocFile(file, targetPreviewEl); } } function _syncToggles() { const webToggles = [ webSearchToggle, document.getElementById('heroWebSearchToggle'), document.getElementById('heroWebSearchToggleDynamic') ].filter(Boolean); const ytToggles = [ ytSearchToggle, document.getElementById('heroYtSearchToggle'), document.getElementById('heroYtSearchToggleDynamic') ].filter(Boolean); webToggles.forEach(el => { el.classList.toggle('active', isWebSearchEnabled); el.setAttribute('data-active', isWebSearchEnabled ? 'true' : 'false'); }); ytToggles.forEach(el => { el.classList.toggle('active', isYtSearchEnabled); el.setAttribute('data-active', isYtSearchEnabled ? 'true' : 'false'); }); _updateSearchModeChip(); } // Show a clear, dismissible chip above the input whenever Web Search is on, so // students get visible confirmation the mode is active (and can turn it off). function _updateSearchModeChip() { document.querySelectorAll('.search-mode-chip').forEach(c => c.remove()); if (!isWebSearchEnabled) return; // Anchor the chip directly above whichever input box is currently visible. const inputBox = isHeroMode ? (document.getElementById('heroInputBoxDynamic') || document.getElementById('heroInputBox')) : document.getElementById('inputBox'); if (!inputBox || !inputBox.parentElement) return; // Outer row matches the input box's centered width so the pill lines up with // the input's left edge; inner element is the actual pill. const row = document.createElement('div'); row.className = 'search-mode-chip'; row.innerHTML = ` Web Search is on `; row.querySelector('.search-mode-chip-x').addEventListener('click', (e) => { e.stopPropagation(); isWebSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); }); inputBox.parentElement.insertBefore(row, inputBox); } function _applyWebSearchAvailability() { // Web search requires a Tavily API key. Without one, grey out the toggles. const webToggles = [ webSearchToggle, document.getElementById('heroWebSearchToggle'), document.getElementById('heroWebSearchToggleDynamic') ].filter(Boolean); webToggles.forEach(el => { el.classList.toggle('web-search-disabled', !hasTavilyKey); el.title = hasTavilyKey ? '' : 'Add a Tavily API key in Settings to enable web search'; }); // If the key was removed while web search was on, turn it off. if (!hasTavilyKey && isWebSearchEnabled) { isWebSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); } } function _updateInputPlaceholder() { let placeholder = 'Ask STEM Copilot...'; if (isWebSearchEnabled) { placeholder = 'Search the web'; } else if (isYtSearchEnabled) { placeholder = 'Ask video'; } const inputs = [ userInput, document.getElementById('heroInput'), document.getElementById('heroInputDynamic') ].filter(Boolean); inputs.forEach(inp => { inp.placeholder = placeholder; }); } function setupAttachMenu(plusBtn, menuEl, webSearchToggleEl, ytSearchToggleEl, attachFileBtnEl, imageInputEl, docInputEl, previewEl) { if (!plusBtn || !menuEl) return; plusBtn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = menuEl.classList.contains('show'); closeAllMenus(); if (!isOpen) { menuEl.classList.add('show'); plusBtn.classList.add('open'); } }); if (webSearchToggleEl) { webSearchToggleEl.addEventListener('click', (e) => { e.stopPropagation(); if (!hasTavilyKey) { showToast('Add a Tavily API key in Settings to enable web search.', 'info'); menuEl.classList.remove('show'); plusBtn.classList.remove('open'); return; } isWebSearchEnabled = !isWebSearchEnabled; if (isWebSearchEnabled) isYtSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); menuEl.classList.remove('show'); plusBtn.classList.remove('open'); }); } if (ytSearchToggleEl) { ytSearchToggleEl.addEventListener('click', (e) => { e.stopPropagation(); isYtSearchEnabled = !isYtSearchEnabled; if (isYtSearchEnabled) isWebSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); menuEl.classList.remove('show'); plusBtn.classList.remove('open'); }); } if (attachFileBtnEl && docInputEl) { attachFileBtnEl.addEventListener('click', (e) => { e.stopPropagation(); menuEl.classList.remove('show'); plusBtn.classList.remove('open'); docInputEl.accept = "image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv"; docInputEl.click(); }); } if (docInputEl) { docInputEl.addEventListener('change', () => { if (docInputEl.files[0]) { handleAttachedFile(docInputEl.files[0], previewEl); } }); } } /* ── Chat & Streaming ───────────────────────────────────────── */ let currentAbortController = null; let currentStreamReader = null; let currentStreamStopped = false; let currentRenderTimer = null; function _showStopBtn() { if (adaptiveBtn) adaptiveBtn.style.display = 'none'; if (stopBtn) { stopBtn.style.display = 'flex'; stopBtn.classList.add('visible'); } } function _showAdaptiveBtn() { if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('visible'); } if (adaptiveBtn) adaptiveBtn.style.display = 'flex'; } if (stopBtn) { stopBtn.addEventListener('click', () => { currentStreamStopped = true; if (currentRenderTimer) { clearTimeout(currentRenderTimer); currentRenderTimer = null; } if (currentStreamReader) { try { currentStreamReader.cancel(); } catch (_) { } currentStreamReader = null; } if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; } isSending = false; _showAdaptiveBtn(); const ct = document.getElementById('currentThinking'); if (ct) ct.style.display = 'none'; document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing')); document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor')); }); } // On phones, Enter should insert a newline (send is done with the send button). // On desktop, Enter still sends (Shift+Enter = newline). function _isMobile() { return window.matchMedia('(max-width: 768px)').matches; } if (userInput) { userInput.addEventListener('input', function () { this.style.height = '54px'; this.style.height = this.scrollHeight + 'px'; }); userInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !_isMobile()) { e.preventDefault(); sendMessage(); } }); } function sendMessage() { const text = userInput.value.trim(); if (!text || isSending) return; if (isHeroMode) exitHeroMode(); isSending = true; if (pendingImage) { const rowDiv = document.createElement('div'); rowDiv.classList.add('message-row', 'user'); rowDiv.innerHTML = `
Uploaded image
${escapeHtml(text)}
`; const contentEl = rowDiv.querySelector('.message-content'); contentEl.dataset.raw = text; _fillActions(rowDiv.querySelector('.msg-actions'), () => text, false); chatContainer.appendChild(rowDiv); } else if (pendingDocText) { const docName = pendingDocName || 'Document Attached'; const fullMessageText = text + '\n\n[ATTACHED DOCUMENT (Name: ' + docName + ')]\n' + pendingDocText + '\n[END DOCUMENT]'; appendMessage('user', fullMessageText); } else { appendMessage('user', text); } userInput.value = ''; userInput.style.height = '54px'; if (adaptiveBtn) adaptiveBtn.classList.remove('has-text'); const exists = threads.find(t => t.id === currentThreadId); if (!exists) { const title = text.length > 30 ? text.substring(0, 30) + '...' : text; addThreadToSidebar(currentThreadId, title); } else { const idx = threads.indexOf(exists); threads.splice(idx, 1); threads.unshift(exists); renderHistory(); } streamResponse(text); } function streamResponse(text) { const imageData = pendingImage || ''; const docData = pendingDocText || ''; const docName = pendingDocName || ''; const docBytes = pendingDocBytes || ''; pendingImage = null; pendingImageDataUrl = null; pendingDocText = ''; pendingDocName = ''; pendingDocBytes = ''; if (imagePreviewBar) { imagePreviewBar.innerHTML = ''; imagePreviewBar.style.display = 'none'; } const heroPreview = document.getElementById('heroImagePreviewDynamic') || heroImagePreview; if (heroPreview) { heroPreview.innerHTML = ''; heroPreview.classList.remove('visible'); } if (imageInput) imageInput.value = ''; if (heroImageInput) heroImageInput.value = ''; if (docInput) docInput.value = ''; const dynDocInput = document.getElementById('heroDocInputDynamic'); if (dynDocInput) dynDocInput.value = ''; const pBar = isHeroMode ? (document.getElementById('heroToolProgressBarDynamic') || heroToolProgressBar) : toolProgressBar; const pLabel = isHeroMode ? (document.getElementById('heroToolProgressLabelDynamic') || toolProgressLabel) : toolProgressLabel; const pFill = isHeroMode ? (document.getElementById('heroToolProgressFillDynamic') || toolProgressFill) : toolProgressFill; if (pBar) { pBar.style.display = 'none'; if (pFill) pFill.style.width = '0%'; } const rowDiv = document.createElement('div'); rowDiv.classList.add('message-row', 'ai'); rowDiv.innerHTML = `
`; chatContainer.appendChild(rowDiv); scrollToBottom(true); _showStopBtn(); const thinkingEl = rowDiv.querySelector('#currentThinking'); const contentEl = rowDiv.querySelector('.message-content'); const actionsEl = rowDiv.querySelector('.msg-actions'); let rawText = ''; let firstToken = true; currentStreamStopped = false; currentRenderTimer = null; const RENDER_INTERVAL = 120; function scheduleRender() { if (currentRenderTimer || currentStreamStopped) return; currentRenderTimer = setTimeout(() => { currentRenderTimer = null; if (!currentStreamStopped) { renderFinalContent(contentEl, rawText); scrollToBottom(); } }, RENDER_INTERVAL); } // Search mode is sent as a structured field the server trusts — no more // injecting a "[System Instruction: ...]" prefix into the message body. const searchMode = isWebSearchEnabled ? 'web' : 'none'; isWebSearchEnabled = false; isYtSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); const payload = { message: text, thread_id: currentThreadId, persona: currentPersona, language: currentLanguage, username: currentUsername, search_mode: searchMode, image: imageData, doc_text: docData, doc_name: docName, doc_bytes: docBytes, }; currentAbortController = new AbortController(); fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: currentAbortController.signal, }).then(response => { const reader = response.body.getReader(); currentStreamReader = reader; const decoder = new TextDecoder(); let buffer = ''; function read() { reader.read().then(({ done, value }) => { if (done || currentStreamStopped) { if (!currentStreamStopped) finishStream(thinkingEl, contentEl, rawText, currentRenderTimer, actionsEl); return; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (currentStreamStopped) return; if (!line.startsWith('data: ')) continue; const pl = line.substring(6); if (pl === '[DONE]') { finishStream(thinkingEl, contentEl, rawText, currentRenderTimer, actionsEl); currentStreamStopped = true; return; } try { const data = JSON.parse(pl); if (data.error) { thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.innerHTML = `
${escapeHtml(data.error)}
`; _showActionsBar(actionsEl, contentEl); isSending = false; _showAdaptiveBtn(); currentStreamStopped = true; return; } if (data.tool_event) { const toolLabel = data.tool === 'web_search' ? 'Searching the web…' : 'Fetching YouTube transcript…'; if (data.tool_event === 'tool_start') { // Docked bar (above the input bar) if (pLabel) pLabel.textContent = toolLabel; if (pFill) pFill.style.width = '40%'; if (pBar) pBar.style.display = 'block'; let w = 40; const iv = setInterval(() => { if (!pBar || pBar.style.display === 'none' || w >= 90) { clearInterval(iv); return; } w += 5; if (pFill) pFill.style.width = w + '%'; }, 300); // Inline indicator inside the conversation (below the user bubble) if (thinkingEl) { thinkingEl.style.display = 'block'; thinkingEl.innerHTML = `
${toolLabel}
`; } } else if (data.tool_event === 'tool_end') { if (pFill) pFill.style.width = '100%'; setTimeout(() => { if (pBar) pBar.style.display = 'none'; if (pFill) pFill.style.width = '0%'; if (pLabel) pLabel.textContent = 'Searching...'; }, 500); // Restore the thinking dots until the model starts replying if (thinkingEl && firstToken) { thinkingEl.innerHTML = '
'; } } } if (data.token !== undefined) { if (currentStreamStopped) return; if (firstToken) { thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.classList.add('cursor'); firstToken = false; } rawText += data.token; scheduleRender(); } } catch (_) { } } if (!currentStreamStopped) read(); }).catch(err => { if (err.name === 'AbortError' || currentStreamStopped) return; thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; if (!rawText) contentEl.innerHTML = '
Connection lost. Please try again.
'; _showActionsBar(actionsEl, contentEl); isSending = false; _showAdaptiveBtn(); }); } read(); }).catch(err => { if (err.name === 'AbortError' || currentStreamStopped) { thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; isSending = false; _showAdaptiveBtn(); return; } thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.innerHTML = '
Could not connect to the server.
'; _showActionsBar(actionsEl, contentEl); isSending = false; _showAdaptiveBtn(); }).finally(() => { currentAbortController = null; currentStreamReader = null; const avatar = rowDiv.querySelector('#avatarThinking'); if (avatar) avatar.classList.remove('pulsing'); }); } function _showActionsBar(actionsEl, contentEl) { if (!actionsEl) return; _fillActions(actionsEl, () => contentEl.textContent, true); _refreshRegen(); } function finishStream(thinkingEl, contentEl, rawText, timer, actionsEl) { if (timer) clearTimeout(timer); currentRenderTimer = null; thinkingEl.style.display = 'none'; contentEl.style.display = 'block'; contentEl.classList.remove('cursor'); contentEl.dataset.md = rawText || ''; // raw markdown — used by "Add to Canvas" renderFinalContent(contentEl, rawText); isSending = false; _showAdaptiveBtn(); const row = thinkingEl.closest('.message-row'); if (row) { const avatar = row.querySelector('.ai-avatar'); if (avatar) avatar.classList.remove('pulsing'); } if (actionsEl && rawText) { _fillActions(actionsEl, () => rawText, true); _refreshRegen(); } // Persist the conversation in localStorage so history survives a server restart. _cacheCurrentMessages(); } /* ── Message Helpers ────────────────────────────────────────── */ /* ── Action bars: copy (all messages) + regenerate (last AI only) ── */ const _COPY_SVG = ``; const _REGEN_SVG = ``; function _lastUserQuery() { const userRows = chatContainer.querySelectorAll('.message-row.user'); if (!userRows.length) return ''; const mc = userRows[userRows.length - 1].querySelector('.message-content'); if (!mc) return ''; return mc.dataset.raw != null ? mc.dataset.raw : mc.textContent; } const _ADD_CANVAS_SVG = ``; function _makeCopyBtn(getText) { const b = document.createElement('button'); b.className = 'msg-action-btn'; b.dataset.action = 'copy'; b.title = 'Copy'; b.innerHTML = _COPY_SVG; b.addEventListener('click', async () => { // Prefer the raw markdown (so pasting into the Canvas note keeps formatting). const contentEl = b.closest('.message-row.ai')?.querySelector('.message-content'); const text = (contentEl && contentEl.dataset.md) || getText() || ''; try { await navigator.clipboard.writeText(text); showToast('Copied!', 'success'); } catch (_) { showToast('Could not copy.', 'error'); } }); return b; } function _makeAddToCanvasBtn() { const b = document.createElement('button'); b.className = 'msg-action-btn'; b.dataset.action = 'add-canvas'; b.title = 'Add to Canvas'; b.innerHTML = _ADD_CANVAS_SVG; b.addEventListener('click', () => { const contentEl = b.closest('.message-row')?.querySelector('.message-content'); const md = (contentEl && contentEl.dataset.md) || (contentEl && contentEl.textContent) || ''; if (md) addMarkdownToCanvas(md); }); return b; } function _makeRegenWrap() { const wrap = document.createElement('div'); wrap.className = 'regen-wrap'; const btn = document.createElement('button'); btn.className = 'msg-action-btn'; btn.dataset.action = 'regenerate'; btn.title = 'Regenerate'; btn.innerHTML = _REGEN_SVG; const menu = document.createElement('div'); menu.className = 'regen-menu'; menu.innerHTML = ` `; wrap.appendChild(btn); wrap.appendChild(menu); btn.addEventListener('click', (e) => { e.stopPropagation(); const open = menu.classList.contains('show'); document.querySelectorAll('.regen-menu.show').forEach(m => m.classList.remove('show')); if (!open) menu.classList.add('show'); }); menu.querySelector('[data-regen="retry"]').addEventListener('click', (e) => { e.stopPropagation(); menu.classList.remove('show'); const q = _lastUserQuery(); if (q && !isSending && userInput) { userInput.value = q; sendMessage(); } }); menu.querySelector('[data-regen="customize"]').addEventListener('click', (e) => { e.stopPropagation(); menu.classList.remove('show'); const q = _lastUserQuery(); if (q && userInput) { userInput.value = q; userInput.style.height = '54px'; userInput.style.height = userInput.scrollHeight + 'px'; if (adaptiveBtn) adaptiveBtn.classList.toggle('has-text', q.length > 0); userInput.focus(); } }); return wrap; } function _fillActions(actionsEl, getText, withRegen) { if (!actionsEl) return; actionsEl.innerHTML = ''; actionsEl.style.display = 'flex'; actionsEl.classList.add('visible'); actionsEl.appendChild(_makeCopyBtn(getText)); if (withRegen) { actionsEl.appendChild(_makeAddToCanvasBtn()); actionsEl.appendChild(_makeRegenWrap()); } } // Keep the regenerate control on the most recent AI message only. function _refreshRegen() { const aiRows = [...chatContainer.querySelectorAll('.message-row.ai')]; aiRows.forEach((row, i) => { const actions = row.querySelector('.msg-actions'); if (!actions) return; const wrap = actions.querySelector('.regen-wrap'); const isLast = i === aiRows.length - 1; if (isLast && !wrap) actions.appendChild(_makeRegenWrap()); else if (!isLast && wrap) wrap.remove(); }); document.querySelectorAll('.regen-menu.show').forEach(m => m.classList.remove('show')); } function _docChipHtml(docName) { return `
${escapeHtml(docName)}
`; } function appendMessage(sender, text) { const rowDiv = document.createElement('div'); rowDiv.classList.add('message-row', sender); if (sender === 'ai') { rowDiv.innerHTML = `
${escapeHtml(text)}
`; chatContainer.appendChild(rowDiv); const contentEl = rowDiv.querySelector('.message-content'); _fillActions(rowDiv.querySelector('.msg-actions'), () => contentEl.textContent, true); _refreshRegen(); scrollToBottom(); return contentEl; } // User message — preserve newlines/indentation; strip attached-doc payload into a chip. const docRegex = /\[ATTACHED DOCUMENT(?:\s\(Name:\s([^\]]+)\))?\]\s*\n([\s\S]*?)\n\s*\[END DOCUMENT\]/; text = _stripBase64Noise(text); const match = text.match(docRegex); let cleanText = text; let chip = ''; if (match) { chip = _docChipHtml(match[1] || 'Document Attached'); cleanText = text.replace(docRegex, '').trim(); } rowDiv.innerHTML = `
${chip}
${escapeHtml(cleanText)}
`; const contentEl = rowDiv.querySelector('.message-content'); contentEl.dataset.raw = cleanText; _fillActions(rowDiv.querySelector('.msg-actions'), () => cleanText, false); chatContainer.appendChild(rowDiv); scrollToBottom(sender === 'user'); return contentEl; } // "Stick to bottom" scrolling. During streaming we only auto-scroll if the user // is already near the bottom — so they can freely scroll up to re-read earlier // parts while the model is still generating, without being yanked back down. let _stickToBottom = true; if (typeof chatContainer !== 'undefined' && chatContainer) { chatContainer.addEventListener('scroll', () => { const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight; _stickToBottom = distanceFromBottom < 120; }, { passive: true }); } // force = true ignores the stick check (used when the user sends a new message). function scrollToBottom(force) { if (force) _stickToBottom = true; if (_stickToBottom) chatContainer.scrollTop = chatContainer.scrollHeight; } function escapeHtml(text) { if (typeof text !== 'string') return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Replace raw image data-URIs and long base64 blobs with a short, friendly label // so a message bubble never shows a wall of gibberish "hash" to the student. function _stripBase64Noise(text) { if (typeof text !== 'string' || !text) return text; let t = text.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/g, '🖼️ [image]'); // Bare base64 runs (no data: prefix) longer than ~200 chars with no spaces. t = t.replace(/[A-Za-z0-9+/=]{200,}/g, '🖼️ [image]'); return t; } /* ── Renderer — Markdown + KaTeX + mhchem ───────────────────── */ function renderFinalContent(element, rawText) { if (!rawText) return; const blocks = []; let safeText = rawText; safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; }); safeText = safeText.replace(/\$[^\$\n]+?\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; }); safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; }); safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; }); let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText; blocks.forEach((block, i) => { html = html.replace(`%%LATEX_${i}%%`, block); }); element.innerHTML = html; if (typeof renderMathInElement !== 'undefined') { renderMathInElement(element, { delimiters: [ { left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }, { left: '\\(', right: '\\)', display: false }, { left: '\\[', right: '\\]', display: true }, ], throwOnError: false, }); } // Wrap tables in scrollable container for mobile element.querySelectorAll('table').forEach(table => { if (table.parentElement.classList.contains('table-scroll-wrap')) return; const wrap = document.createElement('div'); wrap.className = 'table-scroll-wrap'; table.parentNode.insertBefore(wrap, table); wrap.appendChild(table); }); } /* ── PWA ────────────────────────────────────────────────────── */ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => { }); let deferredPrompt = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; const dismissed = localStorage.getItem('stemcopilot_install_dismissed'); if (!dismissed && installBanner) installBanner.style.display = 'flex'; }); if (installBtn) installBtn.addEventListener('click', () => { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt.userChoice.then(() => { deferredPrompt = null; if (installBanner) installBanner.style.display = 'none'; }); } }); if (installDismiss) installDismiss.addEventListener('click', () => { if (installBanner) installBanner.style.display = 'none'; localStorage.setItem('stemcopilot_install_dismissed', '1'); }); /* ── Init ───────────────────────────────────────────────────── */ if (sidebar) sidebar.classList.add('collapsed'); try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('portrait').catch(() => { }); } catch (_) { } // Info icons on the Web/YouTube search items: show a tip without toggling the action. function _initAttachInfo() { const closeAll = () => document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip')); const toggle = (info) => { const wasOpen = info.classList.contains('show-tip'); closeAll(); if (!wasOpen) info.classList.add('show-tip'); }; // Capture-phase delegation: works for both static and dynamically-injected (mobile hero) // info icons, and intercepts the click before the parent toggle button's handler runs. document.addEventListener('click', (e) => { const info = e.target.closest && e.target.closest('.attach-info'); if (info) { e.preventDefault(); e.stopPropagation(); toggle(info); } else { closeAll(); } }, true); document.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { const info = e.target.closest && e.target.closest('.attach-info'); if (info) { e.preventDefault(); e.stopPropagation(); toggle(info); } } }, true); } window.addEventListener('load', () => { checkExistingSession(); _bindFeedbackSubmit(); _initCustomSelects(); _initAttachInfo(); // Bind bottom static attach elements setupAttachMenu( attachPlusBtn, attachMenu, webSearchToggle, ytSearchToggle, attachFileBtn, null, docInput, imagePreviewBar ); // Close menus on click/focus of static textareas const staticInputs = [userInput, document.getElementById('heroInput')].filter(Boolean); staticInputs.forEach(inp => { inp.addEventListener('click', () => closeAllMenus()); inp.addEventListener('focus', () => closeAllMenus()); }); }); /* ── Custom Select Dropdowns ────────────────────────────────── */ function _initCustomSelects() { const pairs = [ { btn: 'languageSelectBtn', list: 'languageSelectList', native: 'languageSelect' }, ]; pairs.forEach(({ btn, list, native }) => { const btnEl = document.getElementById(btn); const listEl = document.getElementById(list); const nativeEl = document.getElementById(native); if (!btnEl || !listEl) return; btnEl.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = listEl.classList.contains('show'); _closeAllCustomSelects(); if (!isOpen) { listEl.classList.add('show'); btnEl.classList.add('open'); } }); listEl.querySelectorAll('.custom-select-option').forEach(opt => { opt.addEventListener('click', () => { const val = opt.dataset.value; const label = opt.textContent; btnEl.querySelector('span').textContent = label; listEl.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('active')); opt.classList.add('active'); listEl.classList.remove('show'); btnEl.classList.remove('open'); if (nativeEl) { nativeEl.value = val; nativeEl.dispatchEvent(new Event('change')); } }); }); }); document.addEventListener('click', () => _closeAllCustomSelects()); } function _closeAllCustomSelects() { document.querySelectorAll('.custom-select-list.show').forEach(l => l.classList.remove('show')); document.querySelectorAll('.custom-select-btn.open').forEach(b => b.classList.remove('open')); } /* ── PWA Modal Utilities ───────────────────────────────────── */ function pwaConfirm(message) { return new Promise(resolve => { const overlay = document.getElementById('pwaConfirmOverlay'); const msgEl = document.getElementById('pwaConfirmMessage'); const okBtn = document.getElementById('pwaConfirmOk'); const cancelBtn = document.getElementById('pwaConfirmCancel'); msgEl.textContent = message; overlay.classList.add('show'); function cleanup(result) { overlay.classList.remove('show'); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); overlay.removeEventListener('click', onBg); document.removeEventListener('keydown', onKey); resolve(result); } function onOk() { cleanup(true); } function onCancel() { cleanup(false); } function onBg(e) { if (e.target === overlay) cleanup(false); } // Enter = confirm (Yes), Escape = cancel. function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); cleanup(true); } else if (e.key === 'Escape') { e.preventDefault(); cleanup(false); } } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onBg); document.addEventListener('keydown', onKey); setTimeout(() => okBtn.focus(), 50); }); } function pwaPrompt(message, defaultValue) { return new Promise(resolve => { const overlay = document.getElementById('pwaPromptOverlay'); const msgEl = document.getElementById('pwaPromptMessage'); const input = document.getElementById('pwaPromptInput'); const okBtn = document.getElementById('pwaPromptOk'); const cancelBtn = document.getElementById('pwaPromptCancel'); msgEl.textContent = message; input.value = defaultValue || ''; overlay.classList.add('show'); setTimeout(() => input.focus(), 50); function cleanup(result) { overlay.classList.remove('show'); okBtn.removeEventListener('click', onOk); cancelBtn.removeEventListener('click', onCancel); overlay.removeEventListener('click', onBg); input.removeEventListener('keydown', onKey); resolve(result); } function onOk() { cleanup(input.value); } function onCancel() { cleanup(null); } function onBg(e) { if (e.target === overlay) cleanup(null); } function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); cleanup(input.value); } } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onBg); input.addEventListener('keydown', onKey); }); } /* ════════════════════════════════════════════════════════════════ CANVAS / NOTES — simple Markdown notebook (edit + live preview) Notes are stored as Markdown text and rendered with the same marked + KaTeX engine as chat, so headings (###), lists, tables, and $math$ all render correctly. ════════════════════════════════════════════════════════════════ */ let canvasNotes = []; // list metadata: [{note_id, title, updated_at}] let canvasActiveId = null; // id of the note currently loaded in the editor let canvasSaveTimer = null; // debounce handle for autosave let canvasPreviewMode = false; // false = edit (textarea), true = rendered preview const _canvasEls = {}; function _canvasCache() { const ids = [ 'canvasPanel', 'canvasListView', 'canvasEditorView', 'canvasNotesList', 'canvasEmptyState', 'canvasTitleInput', 'canvasSaveStatus', 'canvasHeaderLabel', 'canvasMdEditor', 'canvasMdPreview', 'canvasPreviewToggle', 'canvasPreviewToggleLabel', 'canvasImageInput', 'canvasMdToolbar', ]; ids.forEach(id => { _canvasEls[id] = document.getElementById(id); }); } function _canvasUserId() { return currentUser ? currentUser.google_id : 'anonymous'; } function openCanvas() { if (!_canvasEls.canvasPanel) return; _canvasEls.canvasPanel.classList.add('open'); document.getElementById('appContainer')?.classList.add('canvas-open'); loadCanvasNotes(); _showCanvasList(); } function closeCanvas() { if (!_canvasEls.canvasPanel) return; _canvasEls.canvasPanel.classList.remove('open'); document.getElementById('appContainer')?.classList.remove('canvas-open'); } function _showCanvasList() { _canvasEls.canvasListView.style.display = ''; _canvasEls.canvasEditorView.style.display = 'none'; _canvasEls.canvasHeaderLabel.textContent = 'Canvas'; } function _showCanvasEditor() { _canvasEls.canvasListView.style.display = 'none'; _canvasEls.canvasEditorView.style.display = 'flex'; _canvasEls.canvasHeaderLabel.textContent = 'Note'; } function _canvasRelDate(ts) { if (!ts) return ''; const d = new Date(ts * 1000); const now = new Date(); if (d.toDateString() === now.toDateString()) return 'Today, ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' }); } function loadCanvasNotes() { fetch('/notes?user_id=' + encodeURIComponent(_canvasUserId())) .then(r => r.json()) .then(data => { canvasNotes = data.notes || []; renderCanvasNotes(); }) .catch(() => showToast('Could not load notes.', 'error')); } function renderCanvasNotes() { const listEl = _canvasEls.canvasNotesList; const emptyEl = _canvasEls.canvasEmptyState; if (!listEl) return; listEl.innerHTML = ''; if (!canvasNotes.length) { emptyEl.style.display = 'flex'; return; } emptyEl.style.display = 'none'; canvasNotes.forEach(note => { const card = document.createElement('div'); card.className = 'canvas-note-card'; card.innerHTML = `
${escapeHtml(note.title || 'Untitled note')}
${_canvasRelDate(note.updated_at)}
`; card.querySelector('.canvas-note-card-main').addEventListener('click', () => openNote(note.note_id)); card.querySelector('.canvas-note-card-del').addEventListener('click', async (e) => { e.stopPropagation(); const ok = await pwaConfirm('Delete this note? This cannot be undone.'); if (ok) deleteNote(note.note_id); }); listEl.appendChild(card); }); } // Legacy notes were stored as Quill HTML. Detect and convert to Markdown so they // open cleanly in the markdown editor. function _toMarkdown(content) { if (!content) return ''; const looksHtml = /<\/?(p|div|h[1-6]|ul|ol|li|strong|em|br|pre|blockquote|img|span|table)\b/i.test(content); if (looksHtml && typeof TurndownService !== 'undefined') { try { return new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' }).turndown(content); } catch (_) { return content; } } return content; } function openNote(noteId, after) { fetch('/notes/' + encodeURIComponent(noteId)) .then(r => r.json()) .then(note => { if (note.error) { showToast('Note not found.', 'error'); return; } canvasActiveId = noteId; _canvasEls.canvasTitleInput.value = note.title || ''; _canvasEls.canvasMdEditor.value = _toMarkdown(note.content || ''); _setPreviewMode(false); _canvasEls.canvasSaveStatus.textContent = ''; _showCanvasEditor(); if (typeof after === 'function') after(); }) .catch(() => showToast('Could not open note.', 'error')); } function newNote(after) { fetch('/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: _canvasUserId(), title: '', content: '' }) }) .then(r => r.json()) .then(data => { canvasNotes.unshift({ note_id: data.note_id, title: '', updated_at: data.updated_at }); openNote(data.note_id, after); }) .catch(() => showToast('Could not create note.', 'error')); } function _scheduleCanvasSave() { _canvasEls.canvasSaveStatus.textContent = 'Saving…'; clearTimeout(canvasSaveTimer); canvasSaveTimer = setTimeout(saveActiveNote, 800); } function saveActiveNote() { if (!canvasActiveId) return Promise.resolve(); const title = _canvasEls.canvasTitleInput.value.trim(); const content = _canvasEls.canvasMdEditor.value; return fetch('/notes/' + encodeURIComponent(canvasActiveId), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, content }) }) .then(r => r.json()) .then(data => { _canvasEls.canvasSaveStatus.textContent = 'Saved'; const n = canvasNotes.find(x => x.note_id === canvasActiveId); if (n) { n.title = title; n.updated_at = data.updated_at; } }) .catch(() => { _canvasEls.canvasSaveStatus.textContent = 'Save failed'; }); } function deleteNote(noteId) { fetch('/notes/' + encodeURIComponent(noteId), { method: 'DELETE' }) .then(() => { canvasNotes = canvasNotes.filter(x => x.note_id !== noteId); if (canvasActiveId === noteId) { canvasActiveId = null; _showCanvasList(); } renderCanvasNotes(); showToast('Note deleted.', 'success'); }) .catch(() => showToast('Could not delete note.', 'error')); } /* ── Preview toggle — renders markdown with the same engine as chat ── */ function _renderCanvasPreview() { const el = _canvasEls.canvasMdPreview; if (!el) return; el.innerHTML = ''; const md = _canvasEls.canvasMdEditor.value || ''; el.dataset.md = md; // kept so export can fall back to it renderFinalContent(el, md); } function _setPreviewMode(on) { canvasPreviewMode = on; if (on) _renderCanvasPreview(); _canvasEls.canvasMdEditor.style.display = on ? 'none' : ''; _canvasEls.canvasMdPreview.style.display = on ? '' : 'none'; _canvasEls.canvasMdToolbar.classList.toggle('preview-on', on); if (_canvasEls.canvasPreviewToggleLabel) _canvasEls.canvasPreviewToggleLabel.textContent = on ? 'Edit' : 'Preview'; } function _toggleCanvasPreview() { _setPreviewMode(!canvasPreviewMode); } /* ── Markdown quick-insert helpers ── */ function _mdInsert(kind) { const ta = _canvasEls.canvasMdEditor; if (!ta) return; if (canvasPreviewMode) _setPreviewMode(false); const start = ta.selectionStart, end = ta.selectionEnd; const sel = ta.value.slice(start, end); let before = '', after = '', placeholder = sel; switch (kind) { case 'bold': before = '**'; after = '**'; placeholder = sel || 'bold'; break; case 'italic': before = '*'; after = '*'; placeholder = sel || 'italic'; break; case 'h2': before = '\n## '; after = ''; placeholder = sel || 'Heading'; break; case 'ul': before = '\n- '; after = ''; placeholder = sel || 'item'; break; case 'ol': before = '\n1. '; after = ''; placeholder = sel || 'item'; break; case 'code': before = '\n```\n'; after = '\n```\n'; placeholder = sel || 'code'; break; case 'math': before = '\n$$\n'; after = '\n$$\n'; placeholder = sel || 'x^2 + y^2 = r^2'; break; case 'link': before = '['; after = '](https://)'; placeholder = sel || 'text'; break; default: return; } const insert = before + placeholder + after; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); const caret = start + before.length + placeholder.length; ta.focus(); ta.setSelectionRange(caret, caret); _scheduleCanvasSave(); } function _insertImageFile(file) { if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const ta = _canvasEls.canvasMdEditor; const md = `\n![${file.name || 'image'}](${e.target.result})\n`; const pos = ta.selectionStart; ta.value = ta.value.slice(0, pos) + md + ta.value.slice(pos); _scheduleCanvasSave(); }; reader.readAsDataURL(file); } /* ── Exports ── */ function _canvasDownload(filename, text, mime) { const blob = new Blob([text], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function _canvasTitle() { return (_canvasEls.canvasTitleInput.value || 'note').trim().replace(/[\\/:*?"<>|]/g, '') || 'note'; } // Read the note body from the editor; fall back to the live preview if the // textarea ref is somehow empty (e.g. while in preview mode on some browsers). function _canvasCurrentMarkdown() { const fromEditor = (_canvasEls.canvasMdEditor && _canvasEls.canvasMdEditor.value) || ''; if (fromEditor.trim()) return fromEditor; const prev = _canvasEls.canvasMdPreview; return (prev && prev.dataset && prev.dataset.md) || fromEditor; } function canvasExportMarkdown() { const md = _canvasCurrentMarkdown(); if (!md.trim()) { showToast('This note is empty — nothing to export.', 'error'); return; } _canvasDownload(_canvasTitle() + '.md', md, 'text/markdown'); } function canvasExportPdf() { if (typeof html2pdf === 'undefined') { showToast('PDF exporter not loaded.', 'error'); return; } const md = _canvasCurrentMarkdown(); if (!md.trim()) { showToast('This note is empty — nothing to export.', 'error'); return; } const title = _canvasTitle(); const wrapper = document.createElement('div'); // Render ON-SCREEN (high z-index) rather than behind the opaque app. The old // `z-index:-9999` placement produced blank PDFs because html2canvas captured // the painted (hidden) region as white. A high z-index guarantees the content // is actually painted; it's removed immediately after capture so the flash is // imperceptible. wrapper.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;width:794px;max-height:100vh;overflow:hidden;padding:36px;background:#ffffff;color:#111111;font-family:Montserrat,sans-serif;pointer-events:none;'; const h = document.createElement('h1'); h.textContent = _canvasEls.canvasTitleInput.value.trim() || 'Untitled note'; h.style.cssText = 'margin:0 0 18px;font-size:26px;color:#111111;'; const body = document.createElement('div'); body.className = 'message-content canvas-pdf-body'; body.style.color = '#111111'; renderFinalContent(body, md); wrapper.appendChild(h); wrapper.appendChild(body); document.body.appendChild(wrapper); html2pdf().set({ margin: 10, filename: title + '.pdf', image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff', scrollX: 0, scrollY: 0 }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } }).from(wrapper).save() .then(() => wrapper.remove()) .catch((err) => { wrapper.remove(); showToast('PDF export failed. Try Markdown export instead.', 'error'); console.error(err); }); } /* ── Add a chatbot answer (markdown) into the open note (or a new one) ── */ function addMarkdownToCanvas(md) { if (!md) return; openCanvas(); const append = () => { _showCanvasEditor(); const ta = _canvasEls.canvasMdEditor; const sep = ta.value.trim() ? '\n\n' : ''; ta.value = ta.value + sep + md; if (canvasPreviewMode) _renderCanvasPreview(); _scheduleCanvasSave(); showToast('Added to Canvas.', 'success'); }; if (canvasActiveId) { openNote(canvasActiveId, append); // reload the open note, then append } else { newNote(append); } } function _initCanvasUI() { _canvasCache(); const open = (e) => { if (e) e.preventDefault(); openCanvas(); }; document.getElementById('openCanvasBtn')?.addEventListener('click', open); document.getElementById('railCanvasBtn')?.addEventListener('click', open); document.getElementById('topbarCanvasBtn')?.addEventListener('click', open); document.getElementById('canvasCloseBtn')?.addEventListener('click', closeCanvas); document.getElementById('canvasNewNoteBtn')?.addEventListener('click', () => newNote()); document.getElementById('canvasEmptyNewBtn')?.addEventListener('click', () => newNote()); document.getElementById('canvasBackBtn')?.addEventListener('click', () => { // Save first, THEN reload the list from the server. Rendering the stale // local array here used to race the autosave and show the empty state // ("Create your first note") for a freshly-added, still-untitled note. const done = () => { _showCanvasList(); loadCanvasNotes(); }; const p = saveActiveNote(); if (p && typeof p.then === 'function') p.then(done); else done(); }); document.getElementById('canvasExportMdBtn')?.addEventListener('click', canvasExportMarkdown); document.getElementById('canvasExportPdfBtn')?.addEventListener('click', canvasExportPdf); document.getElementById('canvasDeleteBtn')?.addEventListener('click', async () => { if (!canvasActiveId) return; const ok = await pwaConfirm('Delete this note? This cannot be undone.'); if (ok) deleteNote(canvasActiveId); }); _canvasEls.canvasMdEditor?.addEventListener('input', _scheduleCanvasSave); _canvasEls.canvasTitleInput?.addEventListener('input', _scheduleCanvasSave); _canvasEls.canvasPreviewToggle?.addEventListener('click', _toggleCanvasPreview); _canvasEls.canvasMdToolbar?.querySelectorAll('.canvas-md-btn').forEach(btn => { btn.addEventListener('click', () => { const kind = btn.dataset.md; if (kind === 'image') _canvasEls.canvasImageInput?.click(); else _mdInsert(kind); }); }); _canvasEls.canvasImageInput?.addEventListener('change', (e) => { const f = e.target.files && e.target.files[0]; if (f) _insertImageFile(f); e.target.value = ''; }); } window.addEventListener('load', _initCanvasUI);