Spaces:
Running
Running
| /* ============================================================ | |
| 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}, <span class="hero-name">${escapeHtml(name)}</span>?`; | |
| 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 = ` | |
| <div class="overlay-history-main"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> | |
| <span class="overlay-history-title">${escapeHtml(thread.title)}</span> | |
| </div> | |
| <button class="overlay-kebab-btn" title="Options">⋮</button> | |
| <div class="overlay-ctx-menu"> | |
| <button class="overlay-ctx-option" data-action="rename"> | |
| <svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> | |
| Rename | |
| </button> | |
| <button class="overlay-ctx-option danger" data-action="delete"> | |
| <svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> | |
| Delete | |
| </button> | |
| </div> | |
| `; | |
| // 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}, <span class="hero-name">${escapeHtml(name)}</span>?` : `${greeting}?`; | |
| const styleLabel = _PERSONA_LABELS[currentPersona] || 'Vidyut'; | |
| div.innerHTML = ` | |
| <img src="/assets/bot.png" alt="STEM Copilot" class="hero-bot-icon"> | |
| <h1 class="hero-title" id="heroTitle">${titleHtml}</h1> | |
| <div class="hero-input-wrap"> | |
| <div class="hero-image-preview" id="heroImagePreviewDynamic"> | |
| <div class="hero-image-preview-thumb" id="heroImagePreviewThumbDynamic"></div> | |
| <button class="hero-image-preview-remove" id="heroImagePreviewRemoveDynamic">×</button> | |
| </div> | |
| <div class="tool-progress-bar" id="heroToolProgressBarDynamic" style="display:none; padding: 0 16px;"> | |
| <div class="tool-progress-label" id="heroToolProgressLabelDynamic">Searching...</div> | |
| <div class="tool-progress-track"><div class="tool-progress-fill" id="heroToolProgressFillDynamic"></div></div> | |
| </div> | |
| <div class="hero-input-box" id="heroInputBoxDynamic"> | |
| <div class="attach-wrap" id="heroAttachWrapDynamic"> | |
| <button class="attach-plus-btn" id="heroAttachPlusBtnDynamic" title="Attach or search"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" | |
| stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> | |
| </svg> | |
| </button> | |
| <div class="attach-menu" id="heroAttachMenuDynamic"> | |
| <button class="attach-menu-item" id="heroWebSearchToggleDynamic" data-active="false"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> | |
| Web Search | |
| </button> | |
| <button class="attach-menu-item" id="heroYtSearchToggleDynamic" data-active="false"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> | |
| YouTube Video | |
| </button> | |
| <button class="attach-menu-item" id="heroAttachFileBtnDynamic"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> | |
| Add File / Image | |
| </button> | |
| </div> | |
| </div> | |
| <input type="file" id="heroImageInputDynamic" accept="image/*" style="display:none;"> | |
| <input type="file" id="heroDocInputDynamic" accept=".pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv" style="display:none;"> | |
| <textarea id="heroInputDynamic" placeholder="Ask STEM Copilot..." rows="1"></textarea> | |
| <div class="style-selector-wrap" id="heroStyleSelectorWrapDynamic"> | |
| <button class="style-selector-btn" id="heroStyleSelectorBtnDynamic" title="Teaching style"> | |
| <span id="heroStyleSelectorLabelDynamic">${styleLabel}</span> | |
| <svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9" /></svg> | |
| </button> | |
| <div class="style-dropdown" id="heroStyleDropdownDynamic"> | |
| ${['vidyut', 'nerd', 'noob', 'thoughtful', 'panic'].map(p => ` | |
| <div class="style-option ${currentPersona === p ? 'active' : ''}" data-persona="${p}"> | |
| <div class="style-option-name">${_PERSONA_LABELS[p]}</div> | |
| <div class="style-option-desc">${_personaDesc(p)}</div> | |
| </div>`).join('')} | |
| </div> | |
| </div> | |
| <button class="adaptive-action-btn" id="heroAdaptiveBtnDynamic" title="Voice input"> | |
| <svg class="icon-mic" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg> | |
| <svg class="icon-send" viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"></path></svg> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <svg class="thread-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> | |
| <span class="chat-title">${escapeHtml(thread.title)}</span> | |
| <button class="options-btn" onclick="toggleMenu(event, this)">⋮</button> | |
| <div class="options-menu"> | |
| <div class="option-item" onclick="renameChat(event, this)"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> | |
| Rename | |
| </div> | |
| <div class="option-item delete" onclick="deleteChat(event, this)"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> | |
| Delete | |
| </div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="chat-skeleton"> | |
| <div class="chat-skeleton-row right"><div class="chat-skeleton-bubble"></div></div> | |
| <div class="chat-skeleton-row left"><div class="chat-skeleton-bubble"></div></div> | |
| <div class="chat-skeleton-row right"><div class="chat-skeleton-bubble"></div></div> | |
| <div class="chat-skeleton-row left"><div class="chat-skeleton-bubble"></div></div> | |
| </div> | |
| `; | |
| 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 = ` | |
| <div class="image-chip-thumb" style="background-image:url(${dataUrl})"></div> | |
| <span class="image-chip-name">${escapeHtml(filename)}</span> | |
| <button class="image-chip-remove" title="Remove"> | |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | |
| </button> | |
| `; | |
| 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin: auto; display: block; color: var(--brand);"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> | |
| <polyline points="14 2 14 8 20 8"/> | |
| <line x1="16" y1="13" x2="8" y2="13"/> | |
| <line x1="16" y1="17" x2="8" y2="17"/> | |
| </svg> | |
| `; | |
| chip.innerHTML = ` | |
| <div class="image-chip-thumb" style="display: flex; align-items: center; justify-content: center; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); flex-shrink: 0; width: 32px; height: 32px; border-radius: 5px;"> | |
| ${docIcon} | |
| </div> | |
| <div style="display: flex; flex-direction: column; min-width: 0; justify-content: center; margin-left: 6px; margin-right: 6px;"> | |
| <span class="image-chip-name" style="max-width: 110px;">${escapeHtml(filename)}</span> | |
| ${statusText ? `<span style="font-size: 8px; color: var(--text-muted); line-height: 1.1;">${escapeHtml(statusText)}</span>` : ''} | |
| </div> | |
| <button class="image-chip-remove" title="Remove"> | |
| <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> | |
| </button> | |
| `; | |
| 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 = `<div class="message-content"> | |
| <img src="data:image/jpeg;base64,${pendingImage}" class="message-image" alt="Uploaded image"> | |
| <div class="user-text">${escapeHtml(text)}</div> | |
| </div> | |
| <div class="msg-actions"></div>`; | |
| 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 = ` | |
| <div class="ai-avatar pulsing" id="avatarThinking"></div> | |
| <div style="flex:1; min-width:0;"> | |
| <div class="thinking-indicator" id="currentThinking" style="padding-top:4px;"> | |
| <div class="thinking-dots"><span></span><span></span><span></span></div> | |
| </div> | |
| <div class="message-content" style="display:none;"></div> | |
| <div class="msg-actions" style="display:none;"></div> | |
| </div> | |
| `; | |
| 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 = `<div class="error-message">${escapeHtml(data.error)}</div>`; | |
| _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 = | |
| `<div class="inline-tool-progress"><span class="inline-tool-label">${toolLabel}</span><span class="inline-tool-track"><span class="inline-tool-fill"></span></span></div>`; | |
| } | |
| } 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 = '<div class="thinking-dots"><span></span><span></span><span></span></div>'; | |
| } | |
| } | |
| } | |
| 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 = '<div class="error-message">Connection lost. Please try again.</div>'; | |
| _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 = '<div class="error-message">Could not connect to the server.</div>'; | |
| _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 = `<svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`; | |
| const _REGEN_SVG = `<svg viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></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 = ` | |
| <button type="button" data-regen="retry"> | |
| <strong>Try again</strong><span>Resend the same question</span> | |
| </button> | |
| <button type="button" data-regen="customize"> | |
| <strong>Customize</strong><span>Edit your question, then send</span> | |
| </button>`; | |
| 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 `<div style="display:flex; align-items:center; gap:6px; background:rgba(255,255,255,0.06); padding:6px 10px; border-radius:8px; margin-bottom:6px; font-size:11px; width:fit-content; border: 1px solid rgba(255,255,255,0.1);"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--brand);"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> | |
| <span>${escapeHtml(docName)}</span> | |
| </div>`; | |
| } | |
| function appendMessage(sender, text) { | |
| const rowDiv = document.createElement('div'); | |
| rowDiv.classList.add('message-row', sender); | |
| if (sender === 'ai') { | |
| rowDiv.innerHTML = ` | |
| <div class="ai-avatar"></div> | |
| <div style="flex:1; min-width:0;"> | |
| <div class="message-content">${escapeHtml(text)}</div> | |
| <div class="msg-actions"></div> | |
| </div>`; | |
| 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 = ` | |
| <div class="message-content">${chip}<div class="user-text">${escapeHtml(cleanText)}</div></div> | |
| <div class="msg-actions"></div>`; | |
| 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); | |
| }); | |
| } | |