Stem_Copilot / static /app.js
Krishna111111's picture
Audits, security checks
8552e0f
Raw
History Blame Contribute Delete
129 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: 'Mentor', nerd: 'Nerd', noob: 'Beginner', thoughtful: 'Thoughtful', panic: 'Panic' };
/* ── Session auth: attach the Bearer token to every same-origin API call ────
The server authenticates each request from this token (it no longer trusts a
client-supplied user_id). A 401 means the session is gone/expired β†’ reset to
the login screen. External requests (Google, CDNs) are left untouched. */
function _sessionToken() { try { return localStorage.getItem('sc_session') || ''; } catch (_) { return ''; } }
let _handling401 = false;
const _origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
let url = '';
try { url = typeof input === 'string' ? input : (input && input.url) || ''; } catch (_) { }
const isApi = url.startsWith('/') || url.startsWith(location.origin);
if (isApi) {
const tok = _sessionToken();
if (tok) {
init = Object.assign({}, init);
const h = new Headers(init.headers || {});
if (!h.has('Authorization')) h.set('Authorization', 'Bearer ' + tok);
init.headers = h;
}
}
return _origFetch(input, init).then(resp => {
if (isApi && resp.status === 401 && !_handling401) {
_handling401 = true;
try {
localStorage.removeItem('sc_session');
localStorage.removeItem('stemcopilot_user');
} catch (_) { }
location.reload();
}
return resp;
});
};
/* ── DOM References ─────────────────────────────────────────── */
const loginScreen = document.getElementById('loginScreen');
const byokScreen = document.getElementById('byokScreen');
const appContainer = document.getElementById('appContainer');
const sidebar = document.getElementById('sidebar');
const sidebarOverlay = document.getElementById('sidebarOverlay');
const sidebarRail = document.getElementById('sidebarRail');
const toggleSidebarBtn = document.getElementById('toggleSidebarBtn');
const chatHistoryList = document.getElementById('chatHistoryList');
const chatContainer = document.getElementById('chatContainer');
const welcomeScreen = document.getElementById('welcomeScreen');
const bottomInputContainer = document.getElementById('bottomInputContainer');
const userInput = document.getElementById('userInput');
const newChatBtn = document.getElementById('newChatBtn');
const stopBtn = document.getElementById('stopBtn');
const adaptiveBtn = document.getElementById('adaptiveBtn');
const heroInput = document.getElementById('heroInput');
const heroAdaptiveBtn = document.getElementById('heroAdaptiveBtn');
const heroUploadBtn = document.getElementById('heroUploadBtn');
const heroImageInput = document.getElementById('heroImageInput');
const heroTitle = document.getElementById('heroTitle');
const byokInput = document.getElementById('byokInput');
const byokSubmitBtn = document.getElementById('byokSubmitBtn');
const userProfileBtn = document.getElementById('userProfileBtn');
const userMenu = document.getElementById('userMenu');
const userAvatar = document.getElementById('userAvatar');
const userDisplayName = document.getElementById('userDisplayName');
const logoutBtn = document.getElementById('logoutBtn');
const railExpandBtn = document.getElementById('railExpandBtn');
const railNewChatBtn = document.getElementById('railNewChatBtn');
const railProfileBtn = document.getElementById('railProfileBtn');
const railAvatar = document.getElementById('railAvatar');
const settingsOverlay = document.getElementById('settingsOverlay');
const openSettingsBtn = document.getElementById('openSettingsBtn');
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
const usernameInput = document.getElementById('usernameInput');
const languageSelect = document.getElementById('languageSelect');
const profileInput = document.getElementById('profileInput');
const saveProfileBtn = document.getElementById('saveProfileBtn');
const settingsApiKeyInput = document.getElementById('settingsApiKeyInput');
const saveApiKeyBtn = document.getElementById('saveApiKeyBtn');
const settingsTavilyKeyInput = document.getElementById('settingsTavilyKeyInput');
const saveTavilyKeyBtn = document.getElementById('saveTavilyKeyBtn');
const byokTavilyInput = document.getElementById('byokTavilyInput');
const attachPlusBtn = document.getElementById('attachPlusBtn');
const attachMenu = document.getElementById('attachMenu');
const webSearchToggle = document.getElementById('webSearchToggle');
const ytSearchToggle = document.getElementById('ytSearchToggle');
const attachFileBtn = document.getElementById('attachFileBtn');
const docInput = document.getElementById('docInput');
const toolProgressBar = document.getElementById('toolProgressBar');
const toolProgressLabel = document.getElementById('toolProgressLabel');
const toolProgressFill = document.getElementById('toolProgressFill');
const imagePreviewBar = document.getElementById('imagePreviewBar');
const imagePreviewThumb = document.getElementById('imagePreviewThumb');
const imagePreviewName = document.getElementById('imagePreviewName');
const imagePreviewRemove = document.getElementById('imagePreviewRemove');
const installBanner = document.getElementById('installBanner');
const installBtn = document.getElementById('installBtn');
const installDismiss = document.getElementById('installDismiss');
const heroImagePreview = document.getElementById('heroImagePreview');
const heroImagePreviewThumb = document.getElementById('heroImagePreviewThumb');
const heroImagePreviewName = document.getElementById('heroImagePreviewName');
const heroImagePreviewRemove = document.getElementById('heroImagePreviewRemove');
const heroAttachPlusBtn = document.getElementById('heroAttachPlusBtn');
const heroAttachMenu = document.getElementById('heroAttachMenu');
const heroWebSearchToggle = document.getElementById('heroWebSearchToggle');
const heroYtSearchToggle = document.getElementById('heroYtSearchToggle');
const heroAttachFileBtn = document.getElementById('heroAttachFileBtn');
const heroDocInput = document.getElementById('heroDocInput');
const heroToolProgressBar = document.getElementById('heroToolProgressBar');
const heroToolProgressLabel = document.getElementById('heroToolProgressLabel');
const heroToolProgressFill = document.getElementById('heroToolProgressFill');
const styleSelectorBtn = document.getElementById('styleSelectorBtn');
const styleSelectorLabel = document.getElementById('styleSelectorLabel');
const styleDropdown = document.getElementById('styleDropdown');
// Mobile elements
const mobileTopbar = document.getElementById('mobileTopbar');
const topbarProfileBtn = document.getElementById('topbarProfileBtn');
const topbarAvatar = document.getElementById('topbarAvatar');
const topbarHistoryBtn = document.getElementById('topbarHistoryBtn');
const topbarNewChatBtn = document.getElementById('topbarNewChatBtn');
const accountOverlay = document.getElementById('accountOverlay');
const accountOverlayClose = document.getElementById('accountOverlayClose');
const historyOverlay = document.getElementById('historyOverlay');
const historyOverlayClose = document.getElementById('historyOverlayClose');
const overlayHistoryList = document.getElementById('overlayHistoryList');
const overlayHistoryEmpty = document.getElementById('overlayHistoryEmpty');
const overlaySettingsBtn = document.getElementById('overlaySettingsBtn');
const overlayLogoutBtn = document.getElementById('overlayLogoutBtn');
const overlayUserAvatar = document.getElementById('overlayUserAvatar');
const overlayUserName = document.getElementById('overlayUserName');
const overlayUserEmail = document.getElementById('overlayUserEmail');
// Login theme toggle
const loginThemeBtn = document.getElementById('loginThemeBtn');
// Settings theme buttons
const themeDarkBtn = document.getElementById('themeDarkBtn');
const themeLightBtn = document.getElementById('themeLightBtn');
/* ── Theme ──────────────────────────────────────────────────── */
function _initTheme() {
const saved = localStorage.getItem('stemcopilot_theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
_updateThemeColor(saved);
_syncThemeButtons(saved);
}
function _setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('stemcopilot_theme', theme);
_updateThemeColor(theme);
_syncThemeButtons(theme);
}
function _toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
_setTheme(current === 'dark' ? 'light' : 'dark');
}
function _updateThemeColor(theme) {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = theme === 'light' ? '#f8f9fa' : '#0a0a0a';
}
function _syncThemeButtons(theme) {
if (themeDarkBtn) themeDarkBtn.classList.toggle('active', theme === 'dark');
if (themeLightBtn) themeLightBtn.classList.toggle('active', theme === 'light');
}
_initTheme();
if (loginThemeBtn) loginThemeBtn.addEventListener('click', _toggleTheme);
if (themeDarkBtn) themeDarkBtn.addEventListener('click', () => _setTheme('dark'));
if (themeLightBtn) themeLightBtn.addEventListener('click', () => _setTheme('light'));
/* ── Toast ──────────────────────────────────────────────────── */
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.textContent = msg;
Object.assign(t.style, {
position: 'fixed', bottom: '80px', left: '50%', transform: 'translateX(-50%)',
background: type === 'error' ? '#ff4a4a' : type === 'success' ? '#2ea44f' : '#333',
color: '#fff', padding: '10px 20px', borderRadius: '10px', fontSize: '13px',
fontFamily: 'inherit', zIndex: '9999', boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
transition: 'opacity 0.3s', opacity: '0', maxWidth: '90vw', textAlign: 'center',
});
document.body.appendChild(t);
requestAnimationFrame(() => t.style.opacity = '1');
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 2500);
}
/* ── Feedback ───────────────────────────────────────────────── */
function openFeedbackInSettings() {
if (userMenu) userMenu.classList.remove('show');
_closeAllOverlays();
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const fbBtn = document.querySelector('.settings-nav-btn[data-tab="feedback"]');
const fbTab = document.getElementById('tab-feedback');
if (fbBtn) fbBtn.classList.add('active');
if (fbTab) fbTab.classList.add('active');
settingsOverlay.classList.add('show');
}
function _bindFeedbackSubmit() {
// ── Star rating ──
const stars = document.querySelectorAll('.fb-star');
const overallInput = document.getElementById('fbOverall');
stars.forEach(star => {
star.addEventListener('mouseover', () => {
const v = +star.dataset.val;
stars.forEach(s => s.classList.toggle('hover', +s.dataset.val <= v));
});
star.addEventListener('mouseleave', () => {
stars.forEach(s => s.classList.remove('hover'));
});
star.addEventListener('click', () => {
const v = +star.dataset.val;
if (overallInput) overallInput.value = v;
stars.forEach(s => s.classList.toggle('active', +s.dataset.val <= v));
});
});
// ── Emoji sub-ratings ──
['fbEaseEmojis', 'fbQualityEmojis'].forEach(id => {
const wrap = document.getElementById(id);
if (!wrap) return;
const field = document.getElementById(wrap.dataset.field);
wrap.querySelectorAll('.fb-emoji').forEach(emoji => {
emoji.addEventListener('click', () => {
wrap.querySelectorAll('.fb-emoji').forEach(e => e.classList.remove('active'));
emoji.classList.add('active');
if (field) field.value = emoji.dataset.val;
});
});
});
// ── Category pills ──
document.querySelectorAll('.fb-cat-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.fb-cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const catInput = document.getElementById('fbCategory');
if (catInput) catInput.value = btn.dataset.val;
});
});
// ── Image attachments ──
const fbImages = []; // [{filename, dataUrl, base64, type}]
const attachBtn = document.getElementById('fbAttachBtn');
const imageInput = document.getElementById('fbImageInput');
const chipsEl = document.getElementById('fbAttachChips');
if (attachBtn && imageInput) {
attachBtn.addEventListener('click', () => {
if (fbImages.length >= 5) { showToast('Maximum 5 screenshots allowed.', 'error'); return; }
imageInput.click();
});
imageInput.addEventListener('change', () => {
const remaining = 5 - fbImages.length;
Array.from(imageInput.files).slice(0, remaining).forEach(file => {
const reader = new FileReader();
reader.onload = e => {
const dataUrl = e.target.result;
const base64 = dataUrl.split(',')[1];
const entry = { filename: file.name, dataUrl, base64, type: file.type };
fbImages.push(entry);
_addFbChip(chipsEl, entry, fbImages);
};
reader.readAsDataURL(file);
});
imageInput.value = '';
});
}
// ── Submit ──
let _fbCooldown = false;
const btn = document.getElementById('submitFeedbackBtn');
if (!btn) return;
btn.addEventListener('click', () => {
if (_fbCooldown) { showToast('Please wait before sending again.', 'error'); return; }
const msg = (document.getElementById('feedbackMessage') || {}).value || '';
if (!msg.trim()) { document.getElementById('feedbackMessage').focus(); return; }
const overall = parseInt((document.getElementById('fbOverall') || {}).value || '0');
const ease = parseInt((document.getElementById('fbEase') || {}).value || '0');
const quality = parseInt((document.getElementById('fbQuality') || {}).value || '0');
const category = (document.getElementById('fbCategory') || {}).value || 'other';
btn.disabled = true;
btn.textContent = 'Sending...';
const attachments = fbImages.map(img => ({
filename: img.filename,
content: img.base64,
content_type: img.type,
}));
fetch('/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: currentUser ? currentUser.google_id : 'anonymous',
user_email: currentUser ? (currentUser.email || '') : '',
user_name: currentUser ? (currentUser.name || '') : '',
overall, ease, quality, category,
message: msg,
attachments: attachments.length ? attachments : null,
}),
})
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(() => {
showToast('Feedback sent. Thank you! πŸ™Œ', 'success');
// Reset form
document.getElementById('feedbackMessage').value = '';
stars.forEach(s => s.classList.remove('active'));
if (overallInput) overallInput.value = '0';
document.querySelectorAll('.fb-emoji').forEach(e => e.classList.remove('active'));
['fbEase','fbQuality'].forEach(id => { const el = document.getElementById(id); if (el) el.value = '0'; });
document.querySelectorAll('.fb-cat-btn').forEach((b, i) => { b.classList.toggle('active', i === 0); });
const catIn = document.getElementById('fbCategory'); if (catIn) catIn.value = 'bug';
fbImages.length = 0;
if (chipsEl) chipsEl.innerHTML = '';
// Cooldown
_fbCooldown = true;
_startFbCooldown(() => { _fbCooldown = false; });
})
.catch(() => showToast('Could not send feedback.', 'error'))
.finally(() => { btn.disabled = false; btn.textContent = 'Send Feedback'; });
});
}
function _addFbChip(container, entry, list) {
const chip = document.createElement('div');
chip.className = 'fb-attach-chip';
const img = document.createElement('img');
img.src = entry.dataUrl;
img.alt = entry.filename;
const removeBtn = document.createElement('button');
removeBtn.className = 'fb-attach-chip-remove';
removeBtn.innerHTML = 'Γ—';
removeBtn.addEventListener('click', () => {
const idx = list.indexOf(entry);
if (idx !== -1) list.splice(idx, 1);
chip.remove();
});
chip.appendChild(img);
chip.appendChild(removeBtn);
container.appendChild(chip);
}
function _startFbCooldown(onDone) {
const wrap = document.getElementById('fbCooldownWrap');
const bar = document.getElementById('fbCooldownBar');
const label = document.getElementById('fbCooldownLabel');
if (!wrap) { setTimeout(onDone, 60000); return; }
wrap.style.display = 'flex';
// Reset animation
if (bar) { bar.style.animation = 'none'; bar.offsetHeight; bar.style.animation = ''; }
let secs = 60;
const tick = setInterval(() => {
secs--;
if (label) label.textContent = `Wait ${secs}s before sending again`;
if (secs <= 0) {
clearInterval(tick);
wrap.style.display = 'none';
onDone();
}
}, 1000);
}
/* ── Key & History Cache Helpers ───────────────────────────── */
// Keys stored in localStorage so the backend can recover them after a restart.
function _getCachedOrKey() { try { return localStorage.getItem('sc_or_key') || ''; } catch(_) { return ''; } }
function _getCachedTvKey() { try { return localStorage.getItem('sc_tv_key') || ''; } catch(_) { return ''; } }
function _setCachedOrKey(k) { try { if (k) localStorage.setItem('sc_or_key', k); else localStorage.removeItem('sc_or_key'); } catch(_) {} }
function _setCachedTvKey(k) { try { if (k) localStorage.setItem('sc_tv_key', k); else localStorage.removeItem('sc_tv_key'); } catch(_) {} }
// Push locally-cached API keys back to the server (e.g. after a server restart
// wiped the DB), then call onDone.
function _restoreCachedKeys(onDone) {
const orKey = _getCachedOrKey();
const tvKey = _getCachedTvKey();
if (!orKey || !currentUser) { onDone(); return; }
const saves = [
fetch('/user/apikey', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: currentUser.google_id, key: orKey }),
}),
];
if (tvKey) {
saves.push(
fetch('/user/tavilykey', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: currentUser.google_id, key: tvKey }),
}).then(() => { hasTavilyKey = true; })
);
}
Promise.all(saves).then(onDone).catch(onDone);
}
// Thread list cache β€” keeps sidebar populated even if the server's DB is wiped.
function _saveThreadsCache() {
try { localStorage.setItem('sc_threads', JSON.stringify(threads.slice(0, 50))); } catch(_) {}
}
function _loadThreadsCache() {
try { const r = localStorage.getItem('sc_threads'); return r ? JSON.parse(r) : []; } catch(_) { return []; }
}
// Per-thread message cache β€” lets /history fall back to localStorage when the
// LangGraph checkpointer DB is empty (e.g. after a server restart).
function _saveMsgsCache(threadId, messages) {
try { localStorage.setItem('sc_msgs_' + threadId, JSON.stringify(messages.slice(-40))); } catch(_) {}
}
function _loadMsgsCache(threadId) {
try { const r = localStorage.getItem('sc_msgs_' + threadId); return r ? JSON.parse(r) : []; } catch(_) { return []; }
}
function _deleteMsgsCache(threadId) {
try { localStorage.removeItem('sc_msgs_' + threadId); } catch(_) {}
}
// Snapshot the current chat DOM into the message cache for the active thread.
function _cacheCurrentMessages() {
const messages = [];
chatContainer.querySelectorAll('.message-row').forEach(row => {
const isUser = row.classList.contains('user');
const isAi = row.classList.contains('ai');
if (!isUser && !isAi) return;
const contentEl = row.querySelector('.message-content');
if (!contentEl) return;
const content = isAi
? (contentEl.dataset.md || contentEl.textContent || '').trim()
: (contentEl.dataset.raw != null ? contentEl.dataset.raw : contentEl.textContent || '').trim();
if (content) messages.push({ role: isUser ? 'user' : 'assistant', content });
});
if (messages.length > 0) _saveMsgsCache(currentThreadId, messages);
}
/* ── Auth β€” Google Sign-In ──────────────────────────────────── */
let _gsiInitialized = false;
function initGoogleAuth() {
return new Promise((resolve) => {
if (_gsiInitialized) { resolve(); return; }
fetch('/auth/client_id').then(r => r.json()).then(data => {
if (!data.client_id) { showApp(); resolve(); return; }
const script = document.createElement('script');
script.src = 'https://accounts.google.com/gsi/client';
script.async = true; script.defer = true;
script.onload = () => {
google.accounts.id.initialize({
client_id: data.client_id,
callback: handleGoogleCredential,
auto_select: false, cancel_on_tap_outside: false,
});
_renderHiddenGoogleBtn();
_gsiInitialized = true;
resolve();
};
script.onerror = () => { showApp(); resolve(); };
document.head.appendChild(script);
}).catch(() => { showApp(); resolve(); });
});
}
function _renderHiddenGoogleBtn(cb) {
const container = document.getElementById('gsi-hidden-btn');
if (!container) return;
container.innerHTML = '';
google.accounts.id.renderButton(container, {
type: 'standard', theme: 'filled_black', size: 'large',
text: 'signin_with', shape: 'pill', width: 240,
});
setTimeout(() => { container.style.pointerEvents = 'auto'; if (cb) cb(); }, 150);
}
function triggerGoogleSignIn() {
const tryClick = () => {
const realBtn = document.querySelector('#gsi-hidden-btn [role="button"]');
if (realBtn) realBtn.click();
else _renderHiddenGoogleBtn(() => { const btn = document.querySelector('#gsi-hidden-btn [role="button"]'); if (btn) btn.click(); });
};
if (_gsiInitialized) tryClick();
else initGoogleAuth().then(tryClick);
}
function handleGoogleCredential(response) {
fetch('/auth/google', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: response.credential }),
}).then(r => { if (!r.ok) throw new Error('Auth failed'); return r.json(); })
.then(data => {
if (data.error) { showToast('Login failed: ' + data.error, 'error'); return; }
currentUser = data.user;
if (data.token) { try { localStorage.setItem('sc_session', data.token); } catch (_) { } }
localStorage.setItem('stemcopilot_user', JSON.stringify(currentUser));
currentUsername = currentUser.name;
localStorage.setItem('stemcopilot_username', currentUsername);
hasTavilyKey = !!data.has_tavily_key;
if (!data.has_api_key) {
const cached = _getCachedOrKey();
if (cached) _restoreCachedKeys(showApp); else showByok();
} else showApp();
}).catch(() => showToast('Login failed. Check your connection.', 'error'));
}
function checkExistingSession() {
const saved = localStorage.getItem('stemcopilot_user');
if (saved) {
currentUser = JSON.parse(saved);
currentUsername = currentUser.name;
fetch('/auth/me?user_id=' + encodeURIComponent(currentUser.google_id))
.then(r => r.json())
.then(data => {
if (data.error) { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); currentUser = null; initGoogleAuth(); return; }
currentUser = data.user;
hasTavilyKey = !!data.has_tavily_key;
if (!data.has_api_key) {
const cached = _getCachedOrKey();
if (cached) _restoreCachedKeys(showApp); else showByok();
} else showApp();
}).catch(() => initGoogleAuth());
} else initGoogleAuth();
}
/* ── BYOK ───────────────────────────────────────────────────── */
function showByok() {
loginScreen.style.display = 'none';
byokScreen.style.display = 'flex';
appContainer.style.display = 'none';
}
if (byokSubmitBtn) byokSubmitBtn.addEventListener('click', () => {
const key = byokInput.value.trim();
if (!key) { byokInput.focus(); return; }
const tavilyKey = byokTavilyInput ? byokTavilyInput.value.trim() : '';
fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(() => {
_setCachedOrKey(key);
if (tavilyKey) {
return fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: tavilyKey }) })
.then(() => { hasTavilyKey = true; _setCachedTvKey(tavilyKey); });
}
})
.then(() => showApp())
.catch(() => showToast('Failed to save key.', 'error'));
});
// Enter in either BYOK field triggers "Start Learning".
[byokInput, byokTavilyInput].forEach(inp => {
if (inp) inp.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); byokSubmitBtn?.click(); }
});
});
/* ── Show Main App ──────────────────────────────────────────── */
function showApp() {
loginScreen.style.display = 'none';
byokScreen.style.display = 'none';
appContainer.style.display = 'flex';
if (currentUser) {
if (userDisplayName) userDisplayName.textContent = currentUser.name || 'Student';
const pic = currentUser.picture || '';
if (pic) {
if (userAvatar) userAvatar.src = pic;
if (railAvatar) railAvatar.src = pic;
if (topbarAvatar) topbarAvatar.src = pic;
if (overlayUserAvatar) overlayUserAvatar.src = pic;
}
if (overlayUserName) overlayUserName.textContent = currentUser.name || 'Student';
if (overlayUserEmail) overlayUserEmail.textContent = currentUser.email || '';
fetch('/auth/me?user_id=' + encodeURIComponent(currentUser.google_id))
.then(r => r.json())
.then(data => {
if (data.user) {
if (data.user.student_profile && profileInput) profileInput.value = data.user.student_profile;
if (data.user.openrouter_key && settingsApiKeyInput) settingsApiKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
if (data.user.tavily_key && settingsTavilyKeyInput) settingsTavilyKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’';
}
hasTavilyKey = !!data.has_tavily_key;
_applyWebSearchAvailability();
}).catch(() => { });
}
if (usernameInput) usernameInput.value = currentUsername;
if (languageSelect) languageSelect.value = currentLanguage;
_syncStyleLabel();
const userId = currentUser ? currentUser.google_id : '';
fetch('/threads?user_id=' + encodeURIComponent(userId))
.then(r => r.json())
.then(data => {
const serverThreads = data.threads || [];
// If the server returned results, use them (and update the cache).
// If the server's DB was wiped (empty), fall back to the local cache.
const source = serverThreads.length > 0 ? serverThreads : _loadThreadsCache();
source.forEach(t => threads.push({ id: t.id, title: t.title }));
if (serverThreads.length > 0) _saveThreadsCache();
renderHistory();
})
.catch(() => {
_loadThreadsCache().forEach(t => threads.push({ id: t.id, title: t.title }));
renderHistory();
});
if (window.innerWidth <= 768) closeSidebar();
enterHeroMode();
}
/* ── Hero / Bottom Input Switching ──────────────────────────── */
function enterHeroMode() {
isHeroMode = true;
bottomInputContainer.style.display = 'none';
const old = document.getElementById('welcomeScreen');
if (old) old.remove();
chatContainer.innerHTML = '';
chatContainer.appendChild(createWelcomeScreen());
_updateHeroTitle();
}
const _HERO_GREETINGS = [
'What should we study today',
'Ready to learn something new',
'What topic are you curious about',
'Let\'s tackle a concept together',
'What can I help you understand',
'Pick a subject and let\'s dive in',
'Got a question? Fire away',
];
function _updateHeroTitle() {
const el = document.getElementById('heroTitle');
if (!el) return;
const name = currentUsername || (currentUser ? currentUser.name : '');
const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)];
if (name) el.innerHTML = `${greeting}, <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('sc_session');
localStorage.removeItem('stemcopilot_user');
localStorage.removeItem('stemcopilot_username');
currentUser = null;
location.reload();
}
});
});
function _renderOverlayHistory() {
if (!overlayHistoryList) return;
overlayHistoryList.innerHTML = '';
if (threads.length === 0) {
if (overlayHistoryEmpty) overlayHistoryEmpty.style.display = 'flex';
return;
}
if (overlayHistoryEmpty) overlayHistoryEmpty.style.display = 'none';
threads.forEach(thread => {
const item = document.createElement('div');
item.className = `overlay-history-item ${thread.id === currentThreadId ? 'active' : ''}`;
item.innerHTML = `
<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(); _saveThreadsCache(); _renderOverlayHistory(); renderHistory(); })
.catch(() => showToast('Failed to rename.', 'error'));
}
});
});
// Delete
item.querySelector('[data-action="delete"]').addEventListener('click', (e) => {
e.stopPropagation();
ctx.classList.remove('show');
pwaConfirm('Delete this chat?').then(yes => {
if (yes) {
fetch('/thread/' + thread.id, { method: 'DELETE' })
.then(() => {
const idx = threads.findIndex(t => t.id === thread.id);
if (idx !== -1) threads.splice(idx, 1);
_saveThreadsCache();
_deleteMsgsCache(thread.id);
if (thread.id === currentThreadId) startNewChat();
_renderOverlayHistory();
renderHistory();
})
.catch(() => showToast('Failed to delete.', 'error'));
}
});
});
overlayHistoryList.appendChild(item);
});
}
function _closeAllCtxMenus() {
document.querySelectorAll('.overlay-ctx-menu.show').forEach(m => m.classList.remove('show'));
}
document.addEventListener('click', () => _closeAllCtxMenus());
/* ── Create Welcome Screen (Dynamic) ───────────────────────── */
function createWelcomeScreen() {
const div = document.createElement('div');
div.className = 'hero-welcome';
div.id = 'welcomeScreen';
const name = currentUsername || (currentUser ? currentUser.name : '');
const greeting = _HERO_GREETINGS[Math.floor(Math.random() * _HERO_GREETINGS.length)];
const titleHtml = name ? `${greeting}, <span class="hero-name">${escapeHtml(name)}</span>?` : `${greeting}?`;
const styleLabel = _PERSONA_LABELS[currentPersona] || 'Mentor';
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>
<span class="attach-menu-label">Web Search</span>
</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 && !_isMobile()) { e.preventDefault(); userInput.value = dynInput.value; sendMessage(); }
});
dynInput.addEventListener('click', () => closeAllMenus());
dynInput.addEventListener('focus', () => closeAllMenus());
// Style selector
if (dynStyleBtn) {
dynStyleBtn.addEventListener('click', (e) => { e.stopPropagation(); dynStyleDropdown.classList.toggle('show'); dynStyleBtn.classList.toggle('open'); });
}
if (dynStyleDropdown) {
dynStyleDropdown.querySelectorAll('.style-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
_setPersona(opt.dataset.persona);
dynStyleDropdown.classList.remove('show');
dynStyleBtn.classList.remove('open');
dynStyleLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Mentor';
dynStyleDropdown.querySelectorAll('.style-option').forEach(o => o.classList.toggle('active', o.dataset.persona === currentPersona));
});
});
}
return div;
}
function _personaDesc(p) {
const descs = {
vidyut: 'Calm, clear, step-by-step master teacher. Makes hard concepts obvious.',
nerd: 'Deep, rigorous, first-principles. Goes beyond the textbook.',
noob: 'Patient, step-by-step. Explains like you\'re seeing it for the first time.',
thoughtful: 'Connects science to the real world. Every formula has a story.',
panic: 'Exam tomorrow? Concise bullets, key formulas, no fluff.',
};
return descs[p] || '';
}
function _showHeroImagePreview(previewEl) {
// kept for compatibility β€” chip rendering is handled in handleImageFile
if (previewEl) previewEl.classList.add('visible');
}
/* ── Chat History ───────────────────────────────────────────── */
function addThreadToSidebar(id, title) {
threads.unshift({ id, title });
_saveThreadsCache();
renderHistory();
}
function renderHistory() {
if (!chatHistoryList) return;
chatHistoryList.innerHTML = '';
threads.forEach(thread => {
const item = document.createElement('div');
item.className = `history-item ${thread.id === currentThreadId ? 'active' : ''}`;
item.setAttribute('data-thread-id', thread.id);
item.innerHTML = `
<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');
let messages = data.messages || [];
if (messages.length === 0) {
// Server's checkpointer DB may have been wiped β€” fall back to local cache.
messages = _loadMsgsCache(threadId);
} else {
// Keep the local cache fresh for next time.
_saveMsgsCache(threadId, messages.map(m => ({
role: m.role,
content: Array.isArray(m.content)
? m.content.filter(p => p.type === 'text').map(p => p.text).join('')
: m.content,
})));
}
messages.forEach(msg => {
const sender = msg.role === 'user' ? 'user' : 'ai';
let textContent = msg.content;
if (Array.isArray(msg.content)) textContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('');
const el = appendMessage(sender, textContent);
if (sender === 'ai') renderFinalContent(el, textContent);
});
scrollToBottom(true);
requestAnimationFrame(() => chatContainer.classList.remove('history-loading'));
}).catch(() => {
// Network error β€” show whatever we have cached.
chatContainer.innerHTML = '';
chatContainer.classList.add('history-loading');
_loadMsgsCache(threadId).forEach(msg => {
const sender = msg.role === 'user' ? 'user' : 'ai';
const el = appendMessage(sender, msg.content);
if (sender === 'ai') renderFinalContent(el, msg.content);
});
scrollToBottom(true);
requestAnimationFrame(() => chatContainer.classList.remove('history-loading'));
});
}
/* ── Options Menu ───────────────────────────────────────────── */
function toggleMenu(e, btn) {
e.stopPropagation(); closeAllMenus();
btn.nextElementSibling.classList.add('show');
btn.classList.add('menu-open');
const item = btn.closest('.history-item');
if (item) item.classList.add('menu-active');
}
document.addEventListener('click', closeAllMenus);
function closeAllMenus() {
document.querySelectorAll('.options-menu.show').forEach(m => m.classList.remove('show'));
document.querySelectorAll('.options-btn.menu-open').forEach(b => b.classList.remove('menu-open'));
document.querySelectorAll('.history-item.menu-active').forEach(i => i.classList.remove('menu-active'));
if (userMenu) userMenu.classList.remove('show');
// Close ALL style dropdowns β€” static and dynamic hero ones
document.querySelectorAll('.style-dropdown.show').forEach(d => d.classList.remove('show'));
document.querySelectorAll('.style-selector-btn.open').forEach(b => b.classList.remove('open'));
// Close ALL attach menus
document.querySelectorAll('.attach-menu.show').forEach(m => m.classList.remove('show'));
document.querySelectorAll('.attach-plus-btn.open').forEach(b => b.classList.remove('open'));
// Close the regenerate ("Try again / Customize") popup
document.querySelectorAll('.regen-menu.show').forEach(m => m.classList.remove('show'));
// Close any open info tooltip
document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip'));
}
function deleteChat(e, optionEl) {
e.stopPropagation();
const item = optionEl.closest('.history-item');
const threadId = item.getAttribute('data-thread-id');
const idx = threads.findIndex(t => t.id === threadId);
if (idx !== -1) threads.splice(idx, 1);
_saveThreadsCache();
_deleteMsgsCache(threadId);
item.style.opacity = '0';
setTimeout(() => item.remove(), 200);
fetch('/thread/' + threadId, { method: 'DELETE' });
}
function renameChat(e, optionEl) {
e.stopPropagation();
const item = optionEl.closest('.history-item');
const titleSpan = item.querySelector('.chat-title');
const threadId = item.getAttribute('data-thread-id');
closeAllMenus();
const currentTitle = titleSpan.innerText;
const input = document.createElement('input');
input.type = 'text'; input.value = currentTitle; input.className = 'rename-input';
titleSpan.replaceWith(input); input.focus();
input.selectionStart = input.selectionEnd = input.value.length;
function saveRename() {
const newTitle = input.value.trim() || 'Untitled Chat';
titleSpan.innerText = newTitle; input.replaceWith(titleSpan);
const thread = threads.find(t => t.id === threadId);
if (thread) thread.title = newTitle;
_saveThreadsCache();
fetch('/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ thread_id: threadId, title: newTitle }) });
}
input.addEventListener('blur', saveRename);
input.addEventListener('keydown', evt => {
if (evt.key === 'Enter') saveRename();
if (evt.key === 'Escape') { titleSpan.innerText = currentTitle; input.replaceWith(titleSpan); }
});
input.addEventListener('click', evt => evt.stopPropagation());
}
/* ── User Menu & Settings ───────────────────────────────────── */
if (userProfileBtn) userProfileBtn.addEventListener('click', (e) => { e.stopPropagation(); userMenu.classList.toggle('show'); });
if (openSettingsBtn) openSettingsBtn.addEventListener('click', () => { userMenu.classList.remove('show'); settingsOverlay.classList.add('show'); });
if (settingsCloseBtn) settingsCloseBtn.addEventListener('click', () => settingsOverlay.classList.remove('show'));
if (settingsOverlay) settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) settingsOverlay.classList.remove('show'); });
if (logoutBtn) logoutBtn.addEventListener('click', () => { pwaConfirm('Do you want to log out?').then(yes => { if (yes) { localStorage.removeItem('sc_session'); localStorage.removeItem('stemcopilot_user'); localStorage.removeItem('stemcopilot_username'); currentUser = null; location.reload(); } }); });
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
const tab = document.getElementById('tab-' + btn.dataset.tab);
if (tab) tab.classList.add('active');
});
});
if (usernameInput) usernameInput.addEventListener('input', () => { currentUsername = usernameInput.value.trim(); localStorage.setItem('stemcopilot_username', currentUsername); });
if (languageSelect) languageSelect.addEventListener('change', () => { currentLanguage = languageSelect.value; localStorage.setItem('stemcopilot_language', currentLanguage); });
if (saveApiKeyBtn) saveApiKeyBtn.addEventListener('click', () => {
const key = settingsApiKeyInput.value.trim();
if (!key || key.startsWith('β€’β€’')) return;
if (!currentUser) return;
saveApiKeyBtn.disabled = true; saveApiKeyBtn.textContent = 'Saving...';
fetch('/user/apikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(() => { _setCachedOrKey(key); settingsApiKeyInput.value = 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’'; showToast('API key saved!', 'success'); })
.catch(() => showToast('Failed to save API key.', 'error'))
.finally(() => { saveApiKeyBtn.disabled = false; saveApiKeyBtn.textContent = 'Save Key'; });
});
if (saveTavilyKeyBtn) saveTavilyKeyBtn.addEventListener('click', () => {
const key = settingsTavilyKeyInput.value.trim();
// Allow saving an empty value to clear/disable web search; ignore the masked placeholder.
if (key.startsWith('β€’β€’')) return;
if (!currentUser) return;
saveTavilyKeyBtn.disabled = true; saveTavilyKeyBtn.textContent = 'Saving...';
fetch('/user/tavilykey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, key: key }) })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then((data) => {
hasTavilyKey = !!data.has_tavily_key;
_setCachedTvKey(hasTavilyKey ? key : '');
_applyWebSearchAvailability();
if (settingsTavilyKeyInput) settingsTavilyKeyInput.value = hasTavilyKey ? 'β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’' : '';
showToast(hasTavilyKey ? 'Tavily key saved! Web search enabled.' : 'Tavily key cleared. Web search disabled.', 'success');
})
.catch(() => showToast('Failed to save Tavily key.', 'error'))
.finally(() => { saveTavilyKeyBtn.disabled = false; saveTavilyKeyBtn.textContent = 'Save Key'; });
});
if (saveProfileBtn) saveProfileBtn.addEventListener('click', () => {
if (!currentUser) return;
fetch('/user/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: currentUser.google_id, profile: profileInput.value }) })
.then(r => { if (!r.ok) throw new Error(); showToast('Profile saved!', 'success'); })
.catch(() => showToast('Failed to save profile.', 'error'));
});
/* ── Teaching Style Selector ────────────────────────────────── */
function _setPersona(persona) {
currentPersona = persona;
localStorage.setItem('stemcopilot_persona', currentPersona);
_syncStyleLabel();
_syncStyleDropdown();
}
function _syncStyleLabel() {
if (styleSelectorLabel) styleSelectorLabel.textContent = _PERSONA_LABELS[currentPersona] || 'Mentor';
}
function _syncStyleDropdown() {
if (!styleDropdown) return;
styleDropdown.querySelectorAll('.style-option').forEach(opt => opt.classList.toggle('active', opt.dataset.persona === currentPersona));
}
if (styleSelectorBtn) {
styleSelectorBtn.addEventListener('click', (e) => { e.stopPropagation(); styleDropdown.classList.toggle('show'); styleSelectorBtn.classList.toggle('open'); });
}
if (styleDropdown) {
styleDropdown.querySelectorAll('.style-option').forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
_setPersona(opt.dataset.persona);
styleDropdown.classList.remove('show');
styleSelectorBtn.classList.remove('open');
});
});
}
_syncStyleLabel();
_syncStyleDropdown();
/* ── Adaptive Mic/Send Button ───────────────────────────────── */
function _bindAdaptiveBtn(btn, inputEl) {
if (!btn || !inputEl) return;
// Watch input to toggle mic ↔ send
inputEl.addEventListener('input', () => {
btn.classList.toggle('has-text', inputEl.value.trim().length > 0);
});
// Mic / Send / Voice logic
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
btn.addEventListener('click', () => {
const hasText = inputEl.value.trim().length > 0;
if (hasText) {
// Act as send
if (inputEl === userInput) sendMessage();
else { userInput.value = inputEl.value; sendMessage(); }
return;
}
// Act as mic
if (!SpeechRecognition) { showToast('Voice input not supported in this browser.', 'error'); return; }
if (btn.classList.contains('listening')) {
if (recognition) recognition.stop();
btn.classList.remove('listening');
return;
}
recognition = new SpeechRecognition();
recognition.lang = 'en-IN';
recognition.interimResults = true;
recognition.continuous = false;
btn.classList.add('listening');
recognition.onresult = (event) => {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) transcript += event.results[i][0].transcript;
inputEl.value = transcript;
inputEl.dispatchEvent(new Event('input'));
};
recognition.onend = () => btn.classList.remove('listening');
recognition.onerror = () => btn.classList.remove('listening');
recognition.start();
});
}
// Bind bottom input adaptive button
_bindAdaptiveBtn(adaptiveBtn, userInput);
// Bind hero adaptive button (static HTML version)
_bindAdaptiveBtn(heroAdaptiveBtn, heroInput);
/* ── Image Upload ───────────────────────────────────────────── */
function _makeChip(dataUrl, filename, onRemove) {
const chip = document.createElement('div');
chip.className = 'image-chip';
chip.innerHTML = `
<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);
}
// Upload a document with one automatic retry on a transient network error
// (flaky mobile data was surfacing as a one-off "Failed to fetch"). 60s timeout.
function _postUploadDoc(formData, retriesLeft) {
const ctrl = new AbortController();
// Mobile networks + larger PDFs need a generous window, otherwise the abort
// surfaces to the user as a confusing "Failed to fetch".
const timer = setTimeout(() => ctrl.abort(), 120000);
return fetch('/upload-doc', { method: 'POST', body: formData, signal: ctrl.signal })
.then(res => {
clearTimeout(timer);
if (!res.ok) {
return res.json()
.then(d => { throw new Error(d.error || 'Failed to parse file'); })
.catch(() => { throw new Error('Failed to parse file'); });
}
return res.json();
})
.catch(err => {
clearTimeout(timer);
const transient = err && (err.name === 'TypeError' || err.name === 'AbortError'
|| /failed to fetch|network|load failed/i.test(err.message || ''));
if (retriesLeft > 0 && transient) {
return new Promise(r => setTimeout(r, 800)).then(() => _postUploadDoc(formData, retriesLeft - 1));
}
throw err;
});
}
function handleDocFile(file, targetPreviewEl) {
_clearImage();
pendingDocText = '';
pendingDocName = file.name;
pendingDocBytes = '';
const base64Reader = new FileReader();
base64Reader.onload = (e) => {
pendingDocBytes = e.target.result.split(',')[1];
};
base64Reader.readAsDataURL(file);
const container = targetPreviewEl || (isHeroMode ? (document.getElementById('heroImagePreviewDynamic') || heroImagePreview) : imagePreviewBar);
if (!container) return;
if (container === imagePreviewBar) {
const heroPrev = document.getElementById('heroImagePreviewDynamic') || heroImagePreview;
if (heroPrev) {
heroPrev.innerHTML = '';
heroPrev.classList.remove('visible');
}
} else {
if (imagePreviewBar) {
imagePreviewBar.innerHTML = '';
imagePreviewBar.style.display = 'none';
}
}
const ext = file.name.split('.').pop().toLowerCase();
const isText = ['txt', 'md', 'py', 'js', 'ts', 'csv', 'json', 'html', 'css'].includes(ext) || file.type.startsWith('text/');
if (isText) {
const reader = new FileReader();
reader.onload = (e) => {
pendingDocText = e.target.result;
container.innerHTML = '';
container.appendChild(_makeDocChip(file.name, () => {
_clearDoc();
container.innerHTML = '';
if (container === imagePreviewBar) container.style.display = 'none';
else container.classList.remove('visible');
}, 'Attached'));
if (container === imagePreviewBar) container.style.display = 'flex';
else container.classList.add('visible');
};
reader.readAsText(file);
} else {
container.innerHTML = '';
container.appendChild(_makeDocChip(file.name, () => {
_clearDoc();
container.innerHTML = '';
if (container === imagePreviewBar) container.style.display = 'none';
else container.classList.remove('visible');
}, 'Extracting text...'));
if (container === imagePreviewBar) container.style.display = 'flex';
else container.classList.add('visible');
const formData = new FormData();
formData.append('file', file);
_postUploadDoc(formData, 2)
.then(data => {
if (data.error) throw new Error(data.error);
pendingDocText = data.text;
container.innerHTML = '';
container.appendChild(_makeDocChip(file.name, () => {
_clearDoc();
container.innerHTML = '';
if (container === imagePreviewBar) container.style.display = 'none';
else container.classList.remove('visible');
}, 'Extracted successfully'));
})
.catch(err => {
showToast(err.message || 'Failed to extract text from document.', 'error');
container.innerHTML = '';
container.appendChild(_makeDocChip(file.name, () => {
_clearDoc();
container.innerHTML = '';
if (container === imagePreviewBar) container.style.display = 'none';
else container.classList.remove('visible');
}, 'Extraction failed'));
});
}
}
function handleAttachedFile(file, targetPreviewEl) {
if (!file) return;
if (file.type.startsWith('image/')) {
handleImageFile(file, targetPreviewEl);
} else {
handleDocFile(file, targetPreviewEl);
}
}
function _syncToggles() {
const webToggles = [
webSearchToggle,
document.getElementById('heroWebSearchToggle'),
document.getElementById('heroWebSearchToggleDynamic')
].filter(Boolean);
const ytToggles = [
ytSearchToggle,
document.getElementById('heroYtSearchToggle'),
document.getElementById('heroYtSearchToggleDynamic')
].filter(Boolean);
webToggles.forEach(el => {
el.classList.toggle('active', isWebSearchEnabled);
el.setAttribute('data-active', isWebSearchEnabled ? 'true' : 'false');
});
ytToggles.forEach(el => {
el.classList.toggle('active', isYtSearchEnabled);
el.setAttribute('data-active', isYtSearchEnabled ? 'true' : 'false');
});
_updateSearchModeChip();
}
// Show a clear, dismissible chip above the input whenever Web Search is on, so
// students get visible confirmation the mode is active (and can turn it off).
function _updateSearchModeChip() {
document.querySelectorAll('.search-mode-chip').forEach(c => c.remove());
if (!isWebSearchEnabled) return;
// Anchor the chip directly above whichever input box is currently visible.
const inputBox = isHeroMode
? (document.getElementById('heroInputBoxDynamic') || document.getElementById('heroInputBox'))
: document.getElementById('inputBox');
if (!inputBox || !inputBox.parentElement) return;
// Outer row matches the input box's centered width so the pill lines up with
// the input's left edge; inner element is the actual pill.
const row = document.createElement('div');
row.className = 'search-mode-chip';
row.innerHTML = `
<span class="search-mode-chip-pill">
<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>
<span>Web Search is on</span>
<button class="search-mode-chip-x" title="Turn off web search" aria-label="Turn off web search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</span>`;
row.querySelector('.search-mode-chip-x').addEventListener('click', (e) => {
e.stopPropagation();
isWebSearchEnabled = false;
_syncToggles();
_updateInputPlaceholder();
});
inputBox.parentElement.insertBefore(row, inputBox);
}
function _applyWebSearchAvailability() {
// Web search requires a Tavily API key. Without one, grey out the toggles.
const webToggles = [
webSearchToggle,
document.getElementById('heroWebSearchToggle'),
document.getElementById('heroWebSearchToggleDynamic')
].filter(Boolean);
webToggles.forEach(el => {
el.classList.toggle('web-search-disabled', !hasTavilyKey);
el.title = hasTavilyKey ? '' : 'Add a Tavily API key in Settings to enable web search';
});
// If the key was removed while web search was on, turn it off.
if (!hasTavilyKey && isWebSearchEnabled) {
isWebSearchEnabled = false;
_syncToggles();
_updateInputPlaceholder();
}
}
function _updateInputPlaceholder() {
let placeholder = 'Ask STEM Copilot...';
if (isWebSearchEnabled) {
placeholder = 'Search the web';
} else if (isYtSearchEnabled) {
placeholder = 'Ask video';
}
const inputs = [
userInput,
document.getElementById('heroInput'),
document.getElementById('heroInputDynamic')
].filter(Boolean);
inputs.forEach(inp => {
inp.placeholder = placeholder;
});
}
function setupAttachMenu(plusBtn, menuEl, webSearchToggleEl, ytSearchToggleEl, attachFileBtnEl, imageInputEl, docInputEl, previewEl) {
if (!plusBtn || !menuEl) return;
plusBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = menuEl.classList.contains('show');
closeAllMenus();
if (!isOpen) {
menuEl.classList.add('show');
plusBtn.classList.add('open');
}
});
if (webSearchToggleEl) {
webSearchToggleEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!hasTavilyKey) {
showToast('Add a Tavily API key in Settings to enable web search.', 'info');
menuEl.classList.remove('show');
plusBtn.classList.remove('open');
return;
}
isWebSearchEnabled = !isWebSearchEnabled;
if (isWebSearchEnabled) isYtSearchEnabled = false;
_syncToggles();
_updateInputPlaceholder();
menuEl.classList.remove('show');
plusBtn.classList.remove('open');
});
}
if (ytSearchToggleEl) {
ytSearchToggleEl.addEventListener('click', (e) => {
e.stopPropagation();
isYtSearchEnabled = !isYtSearchEnabled;
if (isYtSearchEnabled) isWebSearchEnabled = false;
_syncToggles();
_updateInputPlaceholder();
menuEl.classList.remove('show');
plusBtn.classList.remove('open');
});
}
if (attachFileBtnEl && docInputEl) {
attachFileBtnEl.addEventListener('click', (e) => {
e.stopPropagation();
menuEl.classList.remove('show');
plusBtn.classList.remove('open');
docInputEl.accept = "image/*,.pdf,.doc,.docx,.txt,.md,.py,.js,.ts,.csv";
docInputEl.click();
});
}
if (docInputEl) {
docInputEl.addEventListener('change', () => {
if (docInputEl.files[0]) {
handleAttachedFile(docInputEl.files[0], previewEl);
}
});
}
}
/* ── Chat & Streaming ───────────────────────────────────────── */
let currentAbortController = null;
let currentStreamReader = null;
let currentStreamStopped = false;
let currentRenderTimer = null;
function _showStopBtn() {
if (adaptiveBtn) adaptiveBtn.style.display = 'none';
if (stopBtn) { stopBtn.style.display = 'flex'; stopBtn.classList.add('visible'); }
}
function _showAdaptiveBtn() {
if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('visible'); }
if (adaptiveBtn) adaptiveBtn.style.display = 'flex';
}
if (stopBtn) {
stopBtn.addEventListener('click', () => {
currentStreamStopped = true;
if (currentRenderTimer) { clearTimeout(currentRenderTimer); currentRenderTimer = null; }
if (currentStreamReader) { try { currentStreamReader.cancel(); } catch (_) { } currentStreamReader = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
isSending = false;
_showAdaptiveBtn();
const ct = document.getElementById('currentThinking');
if (ct) ct.style.display = 'none';
document.querySelectorAll('.ai-avatar.pulsing').forEach(el => el.classList.remove('pulsing'));
document.querySelectorAll('.message-content.cursor').forEach(el => el.classList.remove('cursor'));
});
}
// On phones, Enter should insert a newline (send is done with the send button).
// On desktop, Enter still sends (Shift+Enter = newline).
function _isMobile() { return window.matchMedia('(max-width: 768px)').matches; }
if (userInput) {
userInput.addEventListener('input', function () { this.style.height = '54px'; this.style.height = this.scrollHeight + 'px'; });
userInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !_isMobile()) { e.preventDefault(); sendMessage(); } });
}
function sendMessage() {
const text = userInput.value.trim();
if (!text || isSending) return;
if (isHeroMode) exitHeroMode();
isSending = true;
if (pendingImage) {
const rowDiv = document.createElement('div');
rowDiv.classList.add('message-row', 'user');
rowDiv.innerHTML = `<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(true);
_showStopBtn();
const thinkingEl = rowDiv.querySelector('#currentThinking');
const contentEl = rowDiv.querySelector('.message-content');
const actionsEl = rowDiv.querySelector('.msg-actions');
let rawText = '';
let firstToken = true;
currentStreamStopped = false;
currentRenderTimer = null;
const RENDER_INTERVAL = 120;
function scheduleRender() {
if (currentRenderTimer || currentStreamStopped) return;
currentRenderTimer = setTimeout(() => {
currentRenderTimer = null;
if (!currentStreamStopped) { renderFinalContent(contentEl, rawText); scrollToBottom(); }
}, RENDER_INTERVAL);
}
// Search mode is sent as a structured field the server trusts β€” no more
// injecting a "[System Instruction: ...]" prefix into the message body.
const searchMode = isWebSearchEnabled ? 'web' : 'none';
isWebSearchEnabled = false;
isYtSearchEnabled = false;
_syncToggles();
_updateInputPlaceholder();
const payload = {
message: text, thread_id: currentThreadId,
persona: currentPersona, language: currentLanguage,
username: currentUsername,
search_mode: searchMode,
image: imageData,
doc_text: docData,
doc_name: docName,
doc_bytes: docBytes,
};
currentAbortController = new AbortController();
fetch('/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), signal: currentAbortController.signal,
}).then(response => {
const reader = response.body.getReader();
currentStreamReader = reader;
const decoder = new TextDecoder();
let buffer = '';
function read() {
reader.read().then(({ done, value }) => {
if (done || currentStreamStopped) {
if (!currentStreamStopped) finishStream(thinkingEl, contentEl, rawText, currentRenderTimer, actionsEl);
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (currentStreamStopped) return;
if (!line.startsWith('data: ')) continue;
const pl = line.substring(6);
if (pl === '[DONE]') { finishStream(thinkingEl, contentEl, rawText, currentRenderTimer, actionsEl); currentStreamStopped = true; return; }
try {
const data = JSON.parse(pl);
if (data.error) {
thinkingEl.style.display = 'none';
contentEl.style.display = 'block';
contentEl.innerHTML = `<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');
contentEl.dataset.md = rawText || ''; // raw markdown β€” used by "Add to Canvas"
renderFinalContent(contentEl, rawText);
isSending = false;
_showAdaptiveBtn();
const row = thinkingEl.closest('.message-row');
if (row) { const avatar = row.querySelector('.ai-avatar'); if (avatar) avatar.classList.remove('pulsing'); }
if (actionsEl && rawText) {
_fillActions(actionsEl, () => rawText, true);
_refreshRegen();
}
// Persist the conversation in localStorage so history survives a server restart.
_cacheCurrentMessages();
}
/* ── Message Helpers ────────────────────────────────────────── */
/* ── Action bars: copy (all messages) + regenerate (last AI only) ── */
const _COPY_SVG = `<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;
}
const _ADD_CANVAS_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M12 8v6M9 11h6"/></svg>`;
function _makeCopyBtn(getText) {
const b = document.createElement('button');
b.className = 'msg-action-btn';
b.dataset.action = 'copy';
b.title = 'Copy';
b.innerHTML = _COPY_SVG;
b.addEventListener('click', async () => {
// Prefer the raw markdown (so pasting into the Canvas note keeps formatting).
const contentEl = b.closest('.message-row.ai')?.querySelector('.message-content');
const text = (contentEl && contentEl.dataset.md) || getText() || '';
try {
await navigator.clipboard.writeText(text);
showToast('Copied!', 'success');
} catch (_) {
showToast('Could not copy.', 'error');
}
});
return b;
}
function _makeAddToCanvasBtn() {
const b = document.createElement('button');
b.className = 'msg-action-btn';
b.dataset.action = 'add-canvas';
b.title = 'Add to Canvas';
b.innerHTML = _ADD_CANVAS_SVG;
b.addEventListener('click', () => {
const contentEl = b.closest('.message-row')?.querySelector('.message-content');
const md = (contentEl && contentEl.dataset.md) || (contentEl && contentEl.textContent) || '';
if (md) addMarkdownToCanvas(md);
});
return b;
}
function _makeRegenWrap() {
const wrap = document.createElement('div');
wrap.className = 'regen-wrap';
const btn = document.createElement('button');
btn.className = 'msg-action-btn';
btn.dataset.action = 'regenerate';
btn.title = 'Regenerate';
btn.innerHTML = _REGEN_SVG;
const menu = document.createElement('div');
menu.className = 'regen-menu';
menu.innerHTML = `
<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(_makeAddToCanvasBtn());
actionsEl.appendChild(_makeRegenWrap());
}
}
// Keep the regenerate control on the most recent AI message only.
function _refreshRegen() {
const aiRows = [...chatContainer.querySelectorAll('.message-row.ai')];
aiRows.forEach((row, i) => {
const actions = row.querySelector('.msg-actions');
if (!actions) return;
const wrap = actions.querySelector('.regen-wrap');
const isLast = i === aiRows.length - 1;
if (isLast && !wrap) actions.appendChild(_makeRegenWrap());
else if (!isLast && wrap) wrap.remove();
});
document.querySelectorAll('.regen-menu.show').forEach(m => m.classList.remove('show'));
}
function _docChipHtml(docName) {
return `<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\]/;
text = _stripBase64Noise(text);
const match = text.match(docRegex);
let cleanText = text;
let chip = '';
if (match) {
chip = _docChipHtml(match[1] || 'Document Attached');
cleanText = text.replace(docRegex, '').trim();
}
rowDiv.innerHTML = `
<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(sender === 'user');
return contentEl;
}
// "Stick to bottom" scrolling. During streaming we only auto-scroll if the user
// is already near the bottom β€” so they can freely scroll up to re-read earlier
// parts while the model is still generating, without being yanked back down.
let _stickToBottom = true;
if (typeof chatContainer !== 'undefined' && chatContainer) {
chatContainer.addEventListener('scroll', () => {
const distanceFromBottom = chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight;
_stickToBottom = distanceFromBottom < 120;
}, { passive: true });
}
// force = true ignores the stick check (used when the user sends a new message).
function scrollToBottom(force) {
if (force) _stickToBottom = true;
if (_stickToBottom) chatContainer.scrollTop = chatContainer.scrollHeight;
}
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Replace raw image data-URIs and long base64 blobs with a short, friendly label
// so a message bubble never shows a wall of gibberish "hash" to the student.
function _stripBase64Noise(text) {
if (typeof text !== 'string' || !text) return text;
let t = text.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+/g, 'πŸ–ΌοΈ [image]');
// Bare base64 runs (no data: prefix) longer than ~200 chars with no spaces.
t = t.replace(/[A-Za-z0-9+/=]{200,}/g, 'πŸ–ΌοΈ [image]');
return t;
}
/* ── Renderer β€” Markdown + KaTeX + mhchem ───────────────────── */
function renderFinalContent(element, rawText) {
if (!rawText) return;
const blocks = [];
let safeText = rawText;
safeText = safeText.replace(/\$\$[\s\S]*?\$\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; });
safeText = safeText.replace(/\$[^\$\n]+?\$/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; });
safeText = safeText.replace(/\\\[[\s\S]*?\\\]/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; });
safeText = safeText.replace(/\\\([\s\S]*?\\\)/g, m => { blocks.push(m); return `%%LATEX_${blocks.length - 1}%%`; });
let html = typeof marked !== 'undefined' ? marked.parse(safeText) : safeText;
blocks.forEach((block, i) => { html = html.replace(`%%LATEX_${i}%%`, block); });
element.innerHTML = html;
if (typeof renderMathInElement !== 'undefined') {
renderMathInElement(element, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
],
throwOnError: false,
});
}
// Wrap tables in scrollable container for mobile
element.querySelectorAll('table').forEach(table => {
if (table.parentElement.classList.contains('table-scroll-wrap')) return;
const wrap = document.createElement('div');
wrap.className = 'table-scroll-wrap';
table.parentNode.insertBefore(wrap, table);
wrap.appendChild(table);
});
}
/* ── PWA ────────────────────────────────────────────────────── */
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => { });
let deferredPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); deferredPrompt = e;
const dismissed = localStorage.getItem('stemcopilot_install_dismissed');
if (!dismissed && installBanner) installBanner.style.display = 'flex';
});
if (installBtn) installBtn.addEventListener('click', () => {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => { deferredPrompt = null; if (installBanner) installBanner.style.display = 'none'; });
}
});
if (installDismiss) installDismiss.addEventListener('click', () => {
if (installBanner) installBanner.style.display = 'none';
localStorage.setItem('stemcopilot_install_dismissed', '1');
});
/* ── Init ───────────────────────────────────────────────────── */
if (sidebar) sidebar.classList.add('collapsed');
try { if (screen.orientation && screen.orientation.lock) screen.orientation.lock('portrait').catch(() => { }); } catch (_) { }
// Info icons on the Web/YouTube search items: show a tip without toggling the action.
function _initAttachInfo() {
const closeAll = () =>
document.querySelectorAll('.attach-info.show-tip').forEach(el => el.classList.remove('show-tip'));
const toggle = (info) => {
const wasOpen = info.classList.contains('show-tip');
closeAll();
if (!wasOpen) info.classList.add('show-tip');
};
// Capture-phase delegation: works for both static and dynamically-injected (mobile hero)
// info icons, and intercepts the click before the parent toggle button's handler runs.
document.addEventListener('click', (e) => {
const info = e.target.closest && e.target.closest('.attach-info');
if (info) {
e.preventDefault();
e.stopPropagation();
toggle(info);
} else {
closeAll();
}
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
const info = e.target.closest && e.target.closest('.attach-info');
if (info) { e.preventDefault(); e.stopPropagation(); toggle(info); }
}
}, true);
}
window.addEventListener('load', () => {
checkExistingSession();
_bindFeedbackSubmit();
_initCustomSelects();
_initAttachInfo();
// Bind bottom static attach elements
setupAttachMenu(
attachPlusBtn,
attachMenu,
webSearchToggle,
ytSearchToggle,
attachFileBtn,
null,
docInput,
imagePreviewBar
);
// Close menus on click/focus of static textareas
const staticInputs = [userInput, document.getElementById('heroInput')].filter(Boolean);
staticInputs.forEach(inp => {
inp.addEventListener('click', () => closeAllMenus());
inp.addEventListener('focus', () => closeAllMenus());
});
});
/* ── Custom Select Dropdowns ────────────────────────────────── */
function _initCustomSelects() {
const pairs = [
{ btn: 'languageSelectBtn', list: 'languageSelectList', native: 'languageSelect' },
];
pairs.forEach(({ btn, list, native }) => {
const btnEl = document.getElementById(btn);
const listEl = document.getElementById(list);
const nativeEl = document.getElementById(native);
if (!btnEl || !listEl) return;
btnEl.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = listEl.classList.contains('show');
_closeAllCustomSelects();
if (!isOpen) {
listEl.classList.add('show');
btnEl.classList.add('open');
}
});
listEl.querySelectorAll('.custom-select-option').forEach(opt => {
opt.addEventListener('click', () => {
const val = opt.dataset.value;
const label = opt.textContent;
btnEl.querySelector('span').textContent = label;
listEl.querySelectorAll('.custom-select-option').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
listEl.classList.remove('show');
btnEl.classList.remove('open');
if (nativeEl) { nativeEl.value = val; nativeEl.dispatchEvent(new Event('change')); }
});
});
});
document.addEventListener('click', () => _closeAllCustomSelects());
}
function _closeAllCustomSelects() {
document.querySelectorAll('.custom-select-list.show').forEach(l => l.classList.remove('show'));
document.querySelectorAll('.custom-select-btn.open').forEach(b => b.classList.remove('open'));
}
/* ── PWA Modal Utilities ───────────────────────────────────── */
function pwaConfirm(message) {
return new Promise(resolve => {
const overlay = document.getElementById('pwaConfirmOverlay');
const msgEl = document.getElementById('pwaConfirmMessage');
const okBtn = document.getElementById('pwaConfirmOk');
const cancelBtn = document.getElementById('pwaConfirmCancel');
msgEl.textContent = message;
overlay.classList.add('show');
function cleanup(result) {
overlay.classList.remove('show');
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
overlay.removeEventListener('click', onBg);
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup(true); }
function onCancel() { cleanup(false); }
function onBg(e) { if (e.target === overlay) cleanup(false); }
// Enter = confirm (Yes), Escape = cancel.
function onKey(e) {
if (e.key === 'Enter') { e.preventDefault(); cleanup(true); }
else if (e.key === 'Escape') { e.preventDefault(); cleanup(false); }
}
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onBg);
document.addEventListener('keydown', onKey);
setTimeout(() => okBtn.focus(), 50);
});
}
function pwaPrompt(message, defaultValue) {
return new Promise(resolve => {
const overlay = document.getElementById('pwaPromptOverlay');
const msgEl = document.getElementById('pwaPromptMessage');
const input = document.getElementById('pwaPromptInput');
const okBtn = document.getElementById('pwaPromptOk');
const cancelBtn = document.getElementById('pwaPromptCancel');
msgEl.textContent = message;
input.value = defaultValue || '';
overlay.classList.add('show');
setTimeout(() => input.focus(), 50);
function cleanup(result) {
overlay.classList.remove('show');
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
overlay.removeEventListener('click', onBg);
input.removeEventListener('keydown', onKey);
resolve(result);
}
function onOk() { cleanup(input.value); }
function onCancel() { cleanup(null); }
function onBg(e) { if (e.target === overlay) cleanup(null); }
function onKey(e) { if (e.key === 'Enter') { e.preventDefault(); cleanup(input.value); } }
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onBg);
input.addEventListener('keydown', onKey);
});
}
/* ════════════════════════════════════════════════════════════════
CANVAS / NOTES β€” simple Markdown notebook (edit + live preview)
Notes are stored as Markdown text and rendered with the same
marked + KaTeX engine as chat, so headings (###), lists, tables,
and $math$ all render correctly.
════════════════════════════════════════════════════════════════ */
let canvasNotes = []; // list metadata: [{note_id, title, updated_at}]
let canvasActiveId = null; // id of the note currently loaded in the editor
let canvasSaveTimer = null; // debounce handle for autosave
let canvasPreviewMode = false; // false = edit (textarea), true = rendered preview
const _canvasEls = {};
function _canvasCache() {
const ids = [
'canvasPanel', 'canvasListView', 'canvasEditorView', 'canvasNotesList',
'canvasEmptyState', 'canvasTitleInput', 'canvasSaveStatus', 'canvasHeaderLabel',
'canvasMdEditor', 'canvasMdPreview', 'canvasPreviewToggle', 'canvasPreviewToggleLabel',
'canvasImageInput', 'canvasMdToolbar',
];
ids.forEach(id => { _canvasEls[id] = document.getElementById(id); });
}
function _canvasUserId() {
return currentUser ? currentUser.google_id : 'anonymous';
}
function openCanvas() {
if (!_canvasEls.canvasPanel) return;
_canvasEls.canvasPanel.classList.add('open');
document.getElementById('appContainer')?.classList.add('canvas-open');
loadCanvasNotes();
_showCanvasList();
}
function closeCanvas() {
if (!_canvasEls.canvasPanel) return;
_canvasEls.canvasPanel.classList.remove('open');
document.getElementById('appContainer')?.classList.remove('canvas-open');
}
function _showCanvasList() {
_canvasEls.canvasListView.style.display = '';
_canvasEls.canvasEditorView.style.display = 'none';
_canvasEls.canvasHeaderLabel.textContent = 'Canvas';
}
function _showCanvasEditor() {
_canvasEls.canvasListView.style.display = 'none';
_canvasEls.canvasEditorView.style.display = 'flex';
_canvasEls.canvasHeaderLabel.textContent = 'Note';
}
function _canvasRelDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
const now = new Date();
if (d.toDateString() === now.toDateString())
return 'Today, ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
}
function loadCanvasNotes() {
fetch('/notes?user_id=' + encodeURIComponent(_canvasUserId()))
.then(r => r.json())
.then(data => { canvasNotes = data.notes || []; renderCanvasNotes(); })
.catch(() => showToast('Could not load notes.', 'error'));
}
function renderCanvasNotes() {
const listEl = _canvasEls.canvasNotesList;
const emptyEl = _canvasEls.canvasEmptyState;
if (!listEl) return;
listEl.innerHTML = '';
if (!canvasNotes.length) { emptyEl.style.display = 'flex'; return; }
emptyEl.style.display = 'none';
canvasNotes.forEach(note => {
const card = document.createElement('div');
card.className = 'canvas-note-card';
card.innerHTML = `
<div class="canvas-note-card-main">
<div class="canvas-note-card-title">${escapeHtml(note.title || 'Untitled note')}</div>
<div class="canvas-note-card-date">${_canvasRelDate(note.updated_at)}</div>
</div>
<button class="canvas-note-card-del" title="Delete note">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
</button>`;
card.querySelector('.canvas-note-card-main').addEventListener('click', () => openNote(note.note_id));
card.querySelector('.canvas-note-card-del').addEventListener('click', async (e) => {
e.stopPropagation();
const ok = await pwaConfirm('Delete this note? This cannot be undone.');
if (ok) deleteNote(note.note_id);
});
listEl.appendChild(card);
});
}
// Legacy notes were stored as Quill HTML. Detect and convert to Markdown so they
// open cleanly in the markdown editor.
function _toMarkdown(content) {
if (!content) return '';
const looksHtml = /<\/?(p|div|h[1-6]|ul|ol|li|strong|em|br|pre|blockquote|img|span|table)\b/i.test(content);
if (looksHtml && typeof TurndownService !== 'undefined') {
try { return new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' }).turndown(content); }
catch (_) { return content; }
}
return content;
}
function openNote(noteId, after) {
fetch('/notes/' + encodeURIComponent(noteId))
.then(r => r.json())
.then(note => {
if (note.error) { showToast('Note not found.', 'error'); return; }
canvasActiveId = noteId;
_canvasEls.canvasTitleInput.value = note.title || '';
_canvasEls.canvasMdEditor.value = _toMarkdown(note.content || '');
_setPreviewMode(false);
_canvasEls.canvasSaveStatus.textContent = '';
_showCanvasEditor();
if (typeof after === 'function') after();
})
.catch(() => showToast('Could not open note.', 'error'));
}
function newNote(after) {
fetch('/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: _canvasUserId(), title: '', content: '' })
})
.then(r => r.json())
.then(data => {
canvasNotes.unshift({ note_id: data.note_id, title: '', updated_at: data.updated_at });
openNote(data.note_id, after);
})
.catch(() => showToast('Could not create note.', 'error'));
}
function _scheduleCanvasSave() {
_canvasEls.canvasSaveStatus.textContent = 'Saving…';
clearTimeout(canvasSaveTimer);
canvasSaveTimer = setTimeout(saveActiveNote, 800);
}
function saveActiveNote() {
if (!canvasActiveId) return Promise.resolve();
const title = _canvasEls.canvasTitleInput.value.trim();
const content = _canvasEls.canvasMdEditor.value;
return fetch('/notes/' + encodeURIComponent(canvasActiveId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
})
.then(r => r.json())
.then(data => {
_canvasEls.canvasSaveStatus.textContent = 'Saved';
const n = canvasNotes.find(x => x.note_id === canvasActiveId);
if (n) { n.title = title; n.updated_at = data.updated_at; }
})
.catch(() => { _canvasEls.canvasSaveStatus.textContent = 'Save failed'; });
}
function deleteNote(noteId) {
fetch('/notes/' + encodeURIComponent(noteId), { method: 'DELETE' })
.then(() => {
canvasNotes = canvasNotes.filter(x => x.note_id !== noteId);
if (canvasActiveId === noteId) { canvasActiveId = null; _showCanvasList(); }
renderCanvasNotes();
showToast('Note deleted.', 'success');
})
.catch(() => showToast('Could not delete note.', 'error'));
}
/* ── Preview toggle β€” renders markdown with the same engine as chat ── */
function _renderCanvasPreview() {
const el = _canvasEls.canvasMdPreview;
if (!el) return;
el.innerHTML = '';
const md = _canvasEls.canvasMdEditor.value || '';
el.dataset.md = md; // kept so export can fall back to it
renderFinalContent(el, md);
}
function _setPreviewMode(on) {
canvasPreviewMode = on;
if (on) _renderCanvasPreview();
_canvasEls.canvasMdEditor.style.display = on ? 'none' : '';
_canvasEls.canvasMdPreview.style.display = on ? '' : 'none';
_canvasEls.canvasMdToolbar.classList.toggle('preview-on', on);
if (_canvasEls.canvasPreviewToggleLabel)
_canvasEls.canvasPreviewToggleLabel.textContent = on ? 'Edit' : 'Preview';
}
function _toggleCanvasPreview() { _setPreviewMode(!canvasPreviewMode); }
/* ── Markdown quick-insert helpers ── */
function _mdInsert(kind) {
const ta = _canvasEls.canvasMdEditor;
if (!ta) return;
if (canvasPreviewMode) _setPreviewMode(false);
const start = ta.selectionStart, end = ta.selectionEnd;
const sel = ta.value.slice(start, end);
let before = '', after = '', placeholder = sel;
switch (kind) {
case 'bold': before = '**'; after = '**'; placeholder = sel || 'bold'; break;
case 'italic': before = '*'; after = '*'; placeholder = sel || 'italic'; break;
case 'h2': before = '\n## '; after = ''; placeholder = sel || 'Heading'; break;
case 'ul': before = '\n- '; after = ''; placeholder = sel || 'item'; break;
case 'ol': before = '\n1. '; after = ''; placeholder = sel || 'item'; break;
case 'code': before = '\n```\n'; after = '\n```\n'; placeholder = sel || 'code'; break;
case 'math': before = '\n$$\n'; after = '\n$$\n'; placeholder = sel || 'x^2 + y^2 = r^2'; break;
case 'link': before = '['; after = '](https://)'; placeholder = sel || 'text'; break;
default: return;
}
const insert = before + placeholder + after;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
const caret = start + before.length + placeholder.length;
ta.focus();
ta.setSelectionRange(caret, caret);
_scheduleCanvasSave();
}
function _insertImageFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const ta = _canvasEls.canvasMdEditor;
const md = `\n![${file.name || 'image'}](${e.target.result})\n`;
const pos = ta.selectionStart;
ta.value = ta.value.slice(0, pos) + md + ta.value.slice(pos);
_scheduleCanvasSave();
};
reader.readAsDataURL(file);
}
/* ── Exports ── */
function _canvasDownload(filename, text, mime) {
const blob = new Blob([text], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function _canvasTitle() {
return (_canvasEls.canvasTitleInput.value || 'note').trim().replace(/[\\/:*?"<>|]/g, '') || 'note';
}
// Read the note body from the editor; fall back to the live preview if the
// textarea ref is somehow empty (e.g. while in preview mode on some browsers).
function _canvasCurrentMarkdown() {
const fromEditor = (_canvasEls.canvasMdEditor && _canvasEls.canvasMdEditor.value) || '';
if (fromEditor.trim()) return fromEditor;
const prev = _canvasEls.canvasMdPreview;
return (prev && prev.dataset && prev.dataset.md) || fromEditor;
}
function canvasExportMarkdown() {
const md = _canvasCurrentMarkdown();
if (!md.trim()) { showToast('This note is empty β€” nothing to export.', 'error'); return; }
_canvasDownload(_canvasTitle() + '.md', md, 'text/markdown');
}
function canvasExportPdf() {
if (typeof html2pdf === 'undefined') { showToast('PDF exporter not loaded.', 'error'); return; }
const md = _canvasCurrentMarkdown();
if (!md.trim()) { showToast('This note is empty β€” nothing to export.', 'error'); return; }
const title = _canvasTitle();
const wrapper = document.createElement('div');
// Render ON-SCREEN (high z-index) rather than behind the opaque app. The old
// `z-index:-9999` placement produced blank PDFs because html2canvas captured
// the painted (hidden) region as white. A high z-index guarantees the content
// is actually painted; it's removed immediately after capture so the flash is
// imperceptible.
wrapper.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;width:794px;max-height:100vh;overflow:hidden;padding:36px;background:#ffffff;color:#111111;font-family:Montserrat,sans-serif;pointer-events:none;';
const h = document.createElement('h1');
h.textContent = _canvasEls.canvasTitleInput.value.trim() || 'Untitled note';
h.style.cssText = 'margin:0 0 18px;font-size:26px;color:#111111;';
const body = document.createElement('div');
body.className = 'message-content canvas-pdf-body';
body.style.color = '#111111';
renderFinalContent(body, md);
wrapper.appendChild(h); wrapper.appendChild(body);
document.body.appendChild(wrapper);
html2pdf().set({
margin: 10, filename: title + '.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff', scrollX: 0, scrollY: 0 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
}).from(wrapper).save()
.then(() => wrapper.remove())
.catch((err) => { wrapper.remove(); showToast('PDF export failed. Try Markdown export instead.', 'error'); console.error(err); });
}
/* ── Add a chatbot answer (markdown) into the open note (or a new one) ── */
function addMarkdownToCanvas(md) {
if (!md) return;
openCanvas();
const append = () => {
_showCanvasEditor();
const ta = _canvasEls.canvasMdEditor;
const sep = ta.value.trim() ? '\n\n' : '';
ta.value = ta.value + sep + md;
if (canvasPreviewMode) _renderCanvasPreview();
_scheduleCanvasSave();
showToast('Added to Canvas.', 'success');
};
if (canvasActiveId) {
openNote(canvasActiveId, append); // reload the open note, then append
} else {
newNote(append);
}
}
function _initCanvasUI() {
_canvasCache();
const open = (e) => { if (e) e.preventDefault(); openCanvas(); };
document.getElementById('openCanvasBtn')?.addEventListener('click', open);
document.getElementById('railCanvasBtn')?.addEventListener('click', open);
document.getElementById('topbarCanvasBtn')?.addEventListener('click', open);
document.getElementById('canvasCloseBtn')?.addEventListener('click', closeCanvas);
document.getElementById('canvasNewNoteBtn')?.addEventListener('click', () => newNote());
document.getElementById('canvasEmptyNewBtn')?.addEventListener('click', () => newNote());
document.getElementById('canvasBackBtn')?.addEventListener('click', () => {
// Save first, THEN reload the list from the server. Rendering the stale
// local array here used to race the autosave and show the empty state
// ("Create your first note") for a freshly-added, still-untitled note.
const done = () => { _showCanvasList(); loadCanvasNotes(); };
const p = saveActiveNote();
if (p && typeof p.then === 'function') p.then(done); else done();
});
document.getElementById('canvasExportMdBtn')?.addEventListener('click', canvasExportMarkdown);
document.getElementById('canvasExportPdfBtn')?.addEventListener('click', canvasExportPdf);
document.getElementById('canvasDeleteBtn')?.addEventListener('click', async () => {
if (!canvasActiveId) return;
const ok = await pwaConfirm('Delete this note? This cannot be undone.');
if (ok) deleteNote(canvasActiveId);
});
_canvasEls.canvasMdEditor?.addEventListener('input', _scheduleCanvasSave);
_canvasEls.canvasTitleInput?.addEventListener('input', _scheduleCanvasSave);
_canvasEls.canvasPreviewToggle?.addEventListener('click', _toggleCanvasPreview);
_canvasEls.canvasMdToolbar?.querySelectorAll('.canvas-md-btn').forEach(btn => {
btn.addEventListener('click', () => {
const kind = btn.dataset.md;
if (kind === 'image') _canvasEls.canvasImageInput?.click();
else _mdInsert(kind);
});
});
_canvasEls.canvasImageInput?.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0];
if (f) _insertImageFile(f);
e.target.value = '';
});
}
window.addEventListener('load', _initCanvasUI);