/* ============================================================ 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: 'Guru', nerd: 'Nerd', noob: 'Beginner', thoughtful: 'Thoughtful', panic: 'Panic' }; /* ── 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); } /* ── 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; 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) 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('stemcopilot_user'); currentUser = null; initGoogleAuth(); return; } currentUser = data.user; hasTavilyKey = !!data.has_tavily_key; if (!data.has_api_key) 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(() => { // Optionally save the Tavily key, then enter the app regardless. 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; }); } }) .then(() => showApp()) .catch(() => showToast('Failed to save key.', 'error')); }); /* ── 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 => { data.threads.forEach(t => threads.push({ id: t.id, title: t.title })); renderHistory(); }) .catch(() => { }); 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('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(); _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); 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] || 'Vidyut'; 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) { 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] || 'Vidyut'; 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 }); 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'); data.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(); 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')); } 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); 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; 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('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(() => { 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; _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] || 'Vidyut'; } 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); } 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); fetch('/upload-doc', { method: 'POST', body: formData }) .then(res => { if (!res.ok) { return res.json().then(data => { throw new Error(data.error || 'Failed to parse file'); }).catch(() => { throw new Error('Failed to parse file'); }); } return res.json(); }) .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'); }); } 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 and ask...'; } else if (isYtSearchEnabled) { placeholder = 'Paste YouTube link...'; } 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')); }); } 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) { 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(); _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); } let finalMessage = text; if (isWebSearchEnabled) { finalMessage = `[System Instruction: Web Search is enabled. Please search the web using the web_search tool if needed to answer the user's query.]\nUser Query: ${text}`; } else if (isYtSearchEnabled) { finalMessage = `[System Instruction: YouTube Video Search is enabled. Please extract the YouTube URL or ID from the user's query and retrieve its transcript using the yt_transcript tool before responding.]\nUser Query: ${text}`; } isWebSearchEnabled = false; isYtSearchEnabled = false; _syncToggles(); _updateInputPlaceholder(); const payload = { message: finalMessage, thread_id: currentThreadId, persona: currentPersona, language: currentLanguage, username: currentUsername, user_id: currentUser ? currentUser.google_id : '', 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'); 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(); } } /* ── 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; } 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', () => { navigator.clipboard.writeText(getText() || '') .then(() => showToast('Copied!', 'success')) .catch(() => showToast('Could not copy.', 'error')); }); 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(_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\]/; 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(); return contentEl; } function scrollToBottom() { chatContainer.scrollTop = chatContainer.scrollHeight; } function escapeHtml(text) { if (typeof text !== 'string') return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /* ── 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 toggle = (info) => { const wasOpen = info.classList.contains('show-tip'); document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip')); if (!wasOpen) info.classList.add('show-tip'); }; document.querySelectorAll('.attach-info').forEach(info => { info.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggle(info); }); info.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); toggle(info); } }); }); document.addEventListener('click', (e) => { if (!(e.target.closest && e.target.closest('.attach-info'))) { document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip')); } }); } 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); resolve(result); } function onOk() { cleanup(true); } function onCancel() { cleanup(false); } function onBg(e) { if (e.target === overlay) cleanup(false); } okBtn.addEventListener('click', onOk); cancelBtn.addEventListener('click', onCancel); overlay.addEventListener('click', onBg); }); } 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); }); }