StemGraph_AI / static /app.js
Krishna111111's picture
round-robin removed; static models; fix; ux changes..
bc7a752
Raw
History Blame Contribute Delete
99.2 kB
/* ============================================================
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">&#8942;</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">&times;</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)">&#8942;</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);
});
}