Universitas Scholarium — A Community of Scholars

Our mission is to create a world where every person has access to civilisation’s most remarkable minds.

Chiron teaching Achilles — the original tutor

AI Faculty·Study Great Minds

What would you like to study?
Membership & Admissions
Passes · Monthly membership · Symposia — available to all users
 Acta Scholarium — The Journal of the Universitas Scholarium
 The Faculty of the Universitas Scholarium
 The Museum of Lost Institutions
Universitas Scholarium

Simulacra are AI and can make mistakes. Please double-check your responses.

INVITE A SIMULACRUM
Add to your home screen — works offline and opens instantly.
`; const w = window.open('', '_blank'); if (w) { w.document.write(doc); w.document.close(); setTimeout(() => w.print(), 400); } } // Find a faculty entry across all departments and sections by id. // Returns { faculty, deptName } or null if not found. Used by the // URL-param handler and by handleCourseModuleClick to translate a // host_id string to the rich faculty object openChat expects. function findFacultyById(id) { if (!id) return null; for (const dept of DEPARTMENTS) { for (const f of (dept.faculty || [])) { if (f && f.id === id) return { faculty: f, deptName: dept.name }; } for (const sec of (dept.sections || [])) { for (const f of (sec.faculty || [])) { if (f && f.id === id) return { faculty: f, deptName: dept.name + ' · ' + sec.name }; } } } return null; } function openChat(f, deptName, courseCtx) { // ── Pentagon gate ─────────────────────────────────────── if (f.id.startsWith('pentagon_') && !PENTAGON_EMAILS.includes(userEmail)) { showPentagonDenied(); return; } // ── course_context: URL is source of truth, in-memory cache ── // _currentCourseCtx mirrors what's encoded in the URL for this active // chat. send() reads this directly. URL is updated via pushState so // refresh, share, and bookmark all preserve context. No sessionStorage, // no host_id binding — the variable lives and dies with the active chat. _currentCourseCtx = (courseCtx && courseCtx.slug && courseCtx.module_n != null) ? { ...courseCtx } : null; // Build the URL that reflects the current chat state and push it. // Skip pushState if the URL already matches (avoids redundant history // entries when the URL-param handler triggered this open). const _params = new URLSearchParams(); _params.set('scholar', f.id); if (_currentCourseCtx) { _params.set('course', _currentCourseCtx.slug); _params.set('module', String(_currentCourseCtx.module_n)); if (_currentCourseCtx.subunit_n != null) _params.set('subunit', String(_currentCourseCtx.subunit_n)); if (_currentCourseCtx.scenario_s != null) _params.set('scenario', String(_currentCourseCtx.scenario_s)); if (_currentCourseCtx.kind) _params.set('kind', _currentCourseCtx.kind); } const _targetUrl = '/?' + _params.toString(); if (window.location.pathname + window.location.search !== _targetUrl) { try { history.pushState({ scholar: f.id, courseCtx: _currentCourseCtx }, '', _targetUrl); } catch (_) {} } // Save current conversation before switching saveCurrentConversation(); currentId = f.id; history = []; exchangeCount = 0; organicReset(); // Reset symposia state symposiaActive = false; symposiaPanel = []; inviteCount = 0; inviteLock = false; updateInviteBtn(); const SYMPOSIA_TIERS = ['vestibulum','janua','atrium','palatium','domus_aurea','bibliotheca']; const invBtn = document.getElementById('invite-btn'); const pdfBtn = document.getElementById('status-pdf-btn'); if (invBtn) { invBtn.classList.remove('hidden'); } if (pdfBtn) { pdfBtn.classList.remove('hidden'); } document.getElementById('symposia-roster').classList.add('hidden'); document.getElementById('symposia-roster').innerHTML = ''; document.getElementById('chat-input').placeholder = '…'; // Reset advisory so it shows fresh for each new scholar document.getElementById('chat-name').textContent = f.name; document.getElementById('chat-dept').textContent = deptName; const msgs = document.getElementById('chat-messages'); msgs.innerHTML = ''; // Re-attach typing indicator const typing = document.createElement('div'); typing.className = 'typing'; typing.id = 'typing-indicator'; typing.innerHTML = ''; const restored = restoreConversation(f.id, msgs, typing); if (!restored) { // Opening card const opening = document.createElement('div'); opening.className = 'msg-opening'; opening.innerHTML = `
${f.name}
${f.opening}
`; msgs.appendChild(opening); msgs.appendChild(typing); } document.getElementById('chat-input').value = ''; document.getElementById('overlay').classList.add('open'); setTimeout(() => { if (restored) { msgs.scrollTop = msgs.scrollHeight; } else { msgs.scrollTop = 0; } document.getElementById('chat-input').focus(); }, 60); // Universal speak-first: fire [BEGIN] trigger so the simulacrum opens // the conversation. Only on FRESH chats — restored conversations already // have content. The 80ms delay lets the overlay UI settle before the // first send. Course contexts are picked up from sessionStorage in // send(); non-course contexts use the server-side speak-first directive. if (!restored) { setTimeout(() => triggerOpening(), 80); } // Initialise user (wait for DB id) then check status initUser().then(uid => { if (uid) checkUserStatus(); }); } async function checkUserStatus() { try { const res = await fetch(`${API_BASE}/user/${userId}`); const data = await res.json(); if (!data.user_id && !data.id) return; const id = data.id || data.user_id; const tier = data.tier || 'door'; userTier = tier; userEmail = data.email || null; // Symposia available to all users const inviteBtn = document.getElementById('invite-btn'); if (inviteBtn) inviteBtn.classList.remove('hidden'); const count = data.exchange_count || 0; exchangeCount = count; // Any tier at or over limit — show reminder + payment card. // tierLimit() reads from window.TIER_CONFIG (fetched from GET /tiers) // which is the authoritative backend map. If the config failed to // load, tierLimit() returns Infinity → upsell never fires → fail closed. const myLimit = tierLimit(tier); if (myLimit !== Infinity && count >= myLimit) { setTimeout(() => { showReturnUserUpsell(); }, 600); } } catch(e) { console.error('status check failed', e); } } function showReturnUserUpsell() { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); // Barbara's reminder — warm, not commercial const reminder = document.createElement('div'); reminder.className = 'msg assistant'; const span = document.createElement('span'); span.className = 'sentence visible'; span.textContent = 'Welcome back. You have used your Class Audit — I hope our conversation was useful. If you would like to continue, I have arranged a few options for you below.'; reminder.appendChild(span); msgs.insertBefore(reminder, typing); // Disable input — they need to pay first document.getElementById('chat-input').disabled = true; document.getElementById('send-btn').disabled = true; setTimeout(() => showUpsell(), 800); // keep this closing brace out — it was moved up } function closeChat() { // Save session memory before closing (fire-and-forget — don't block UI) if (userId && currentId && history.length >= 6) { // >= 3 exchanges = 6 messages fetch(`${API_BASE}/memory/save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId, faculty_id: currentId, history: history, }), }).catch(e => console.warn('memory save failed', e)); } document.getElementById('overlay').classList.remove('open'); currentId = null; history = []; exchangeCount = 0; emailCaptured = false; organicReset(); // ── Return to origin page if user arrived via deep-link ─────────── // When a user clicks Converse on a /scholar/, /who/, museum, // catalogue, or course-map page, the storefront forward at /index.html // stashes the original page URL into sessionStorage as 'chatReturnUrl'. // closeChat reads it and navigates back, freeing the user from the // /app/ home-view they perceive as 'the old site'. try { if (window.location.search.includes('scholar=')) { let returnUrl = null; try { returnUrl = sessionStorage.getItem('chatReturnUrl'); } catch (e) {} if (returnUrl) { try { sessionStorage.removeItem('chatReturnUrl'); } catch (e) {} window.location.href = returnUrl; return; } // Direct deep link with no known origin: go home rather than // stranding the user on the deprecated /app/ page. window.location.href = '/'; return; } } catch (e) { console.warn('closeChat return-redirect skipped:', e); } // Final fallback: if we're on /app/ (any variant), go home rather than // leaving the user on the deprecated page with a closed chat overlay. var p = window.location.pathname; if (p === '/app' || p === '/app/' || p === '/app.html') { window.location.href = '/'; } } // ══════════════════════════════════════════════════════════ // SEND + STREAMING RECEIVE // ══════════════════════════════════════════════════════════ async function send() { const input = document.getElementById('chat-input'); const text = input.value.trim(); if (!text || !currentId || _streamLock) return; // ── Authentication gate ───────────────────────────────── if (!userId) await initUser(); if (!userId) { showSignIn(); return; } cancelContinueBtn(); // Frontend exchange limit — read from authoritative backend tier // config. If config hasn't loaded, tierLimit() returns Infinity so // we allow the send (backend will still enforce). This fails open // on the send path (better UX) while checkUserStatus fails closed on // the upsell path (no false "you ran out" message). const myLimit = tierLimit(userTier); if (myLimit !== Infinity && exchangeCount >= myLimit) { showUpsell(); return; } input.value = ''; input.style.height = 'auto'; clearAttachment(); organicReset(); setBusy(true); // Append user bubble — but suppress for the synthetic course-opening trigger, // which the student should never see in the chat transcript. The flag is // set by triggerCourseOpening() and consumed here for one send. const _isOpeningTrigger = window._pendingOpeningTrigger === true; if (_isOpeningTrigger) { window._pendingOpeningTrigger = false; } else { appendBubble('user', text); } // Create empty assistant bubble — organic renderer will fill it const bubble = document.createElement('div'); bubble.className = 'msg assistant streaming'; const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); msgs.insertBefore(bubble, typing); let fullText = ''; let hasError = false; try { // Course context for this send is whatever openChat set when this // chat was opened. URL is the source of truth; _currentCourseCtx is // the in-memory cache. Per-turn: not consumed (stays valid for the // whole chat), not validated (already correct because openChat set it). const courseContext = _currentCourseCtx; const res = await fetch(`${API_BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ faculty_id: symposiaActive ? 'tesla' : currentId, message: text, history: history.slice(), user_id: userId || undefined, symposia_panel: symposiaActive ? symposiaPanel.slice() : undefined, file_data: _attachedFile || undefined, course_context: courseContext || undefined, }), }); // Auth required — show sign-in if (res.status === 401) { bubble.remove(); showSignIn(); setBusy(false); msgs.appendChild(typing); return; } // Pentagon restricted — access denied if (res.status === 403) { bubble.remove(); appendBubble('note', 'You do not have permission to access this department.'); setBusy(false); msgs.appendChild(typing); return; } // Door limit reached — show upsell if (res.status === 402) { bubble.remove(); showUpsell(); setBusy(false); msgs.appendChild(typing); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let leftover = ''; let stalled = false; while (true) { // Race: next chunk vs 30s stall timeout const chunk = reader.read(); const timer = new Promise(r => setTimeout(() => r('STALL'), 30000)); const result = await Promise.race([chunk, timer]); if (result === 'STALL') { stalled = true; break; } const { value, done } = result; if (done) break; const raw = leftover + decoder.decode(value, { stream: true }); const lines = raw.split('\n'); leftover = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const obj = JSON.parse(line.slice(6)); if (obj.chunk) { fullText += obj.chunk; organicPush(obj.chunk, bubble); } if (obj.error) { hasError = true; // Show Barbara's voice, not raw error bubble.classList.remove('streaming'); const errSpan = document.createElement('span'); errSpan.className = 'sentence visible'; errSpan.style.fontStyle = 'italic'; errSpan.style.opacity = '0.75'; errSpan.textContent = obj.error; bubble.appendChild(errSpan); } } catch {} } } await organicWait(); bubble.classList.remove('streaming'); history.push({ role: 'user', content: text }); history.push({ role: 'assistant', content: fullText }); if (history.length > 24) history = history.slice(-24); // Schedule continue button — immediate on stall, 8s delay otherwise if (!hasError) { scheduleContinueBtn(stalled ? 0 : 8000); } if (!hasError && fullText.trim()) { exchangeCount++; // Note: door tier is no longer granted chat access; nudge removed } } catch (err) { bubble.remove(); appendBubble('note', 'Could not reach the server — please try again.'); scheduleContinueBtn(); } setBusy(false); msgs.appendChild(typing); } function appendBubble(type, text) { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); const div = document.createElement('div'); div.className = `msg ${type}`; div.textContent = text; msgs.insertBefore(div, typing); msgs.scrollTop = msgs.scrollHeight; } // ── Email capture (removed — email collected at payment only) ── function showEmailCapture() { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); const card = document.createElement('div'); card.className = 'email-card'; card.id = 'email-card'; card.innerHTML = `

I'd love to keep talking — what's your email, so I know where to find you again?

By providing your email you agree to receive occasional communications from the Universitas Scholarium, including newsletters and product updates. You may unsubscribe at any time.

`; msgs.insertBefore(card, typing); msgs.scrollTop = msgs.scrollHeight; setTimeout(() => { const inp = document.getElementById('email-input'); if (inp) { inp.focus(); inp.addEventListener('keydown', e => { if (e.key === 'Enter') submitEmail(); }); } }, 400); } async function submitEmail() { const inp = document.getElementById('email-input'); if (!inp) return; const email = inp.value.trim(); if (!email || !email.includes('@')) { inp.style.borderColor = '#c0392b'; inp.focus(); return; } // Replace card with confirmation const card = document.getElementById('email-card'); if (card) { card.innerHTML = `

✓  Thank you.

`; } try { const res = await fetch(`${API_BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const data = await res.json(); if (data.user_id) { userId = data.user_id; setStoredUserId(data.user_id, true); exchangeCount = data.exchange_count || exchangeCount; emailCaptured = true; } } catch (e) { console.error('register failed', e); } } // ── Nudge ─────────────────────────────────────────────────── function appendNudge(text) { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); const div = document.createElement('div'); div.className = 'msg nudge'; div.textContent = text; msgs.insertBefore(div, typing); msgs.scrollTop = msgs.scrollHeight; } // ── Inline payment card ───────────────────────────────────── const STRIPE_PK = 'pk_live_51QHeSXLWfmkiJ4inANwW7YmWiFd1ZVALM6ig7bc6pi1nJrfCoIE56FQ4QnNR9w5QIgI5nO3dKMLZAk26jEqsPm0L000GC7yqXd'; // Upsell card: purchasable tiers are built from window.TIER_CONFIG at // render time (see getUpsellTiers()), not hardcoded here. This keeps // the set of offers in lockstep with the backend PURCHASABLE_TIERS // constant. Metadata that isn't in the backend TIERS map (user-facing // label, price string, descriptive blurb) lives in TIER_METADATA below. const TIER_METADATA = { introductory: { label: 'Introductory', desc: '50 exchanges' }, membership: { label: 'Membership', desc: 'Full faculty access' }, // Metadata for legacy tiers (never shown in upsell — backend marks // them purchasable: false — but kept here so grandfathered users see // the right label in other surfaces like the admin console). standard: { label: 'Standard', desc: '100 exchanges' }, full: { label: 'Full', desc: '150 exchanges' }, vestibulum: { label: 'Vestibulum', desc: '300 exchanges' }, janua: { label: 'Janua', desc: '600 exchanges' }, atrium: { label: 'Atrium', desc: '1,000 exchanges' }, palatium: { label: 'Palatium', desc: '2,000 exchanges' }, domus_aurea: { label: 'Domus Aurea', desc: '3,000 exchanges' }, bibliotheca: { label: 'Bibliotheca', desc: 'Unlimited' }, door: { label: 'Free Access', desc: '1,000 exchanges/month (free)' }, }; // Format a price in cents into the "$4.99" or "$19.99/mo" display string. function formatTierPrice(cents, monthly) { const dollars = (cents / 100).toFixed(2).replace(/\.00$/, ''); return '$' + dollars + (monthly ? '/mo' : ''); } // Build the upsell list from the authoritative backend config. // Returns [] if config not loaded — caller MUST handle empty list // gracefully (it means "no purchasable tiers available right now"). function getUpsellTiers() { if (!window.TIER_CONFIG) return []; return window.TIER_CONFIG.purchasable.map(key => { const t = window.TIER_CONFIG.tiers[key]; const meta = TIER_METADATA[key] || { label: key, desc: '' }; return { key: key, label: meta.label, price: formatTierPrice(t.price_usd, t.monthly), desc: meta.desc, monthly: t.monthly, }; }); } // All code below reads purchasable tiers via getUpsellTiers() rather // than a module-scope const, so it always reflects the latest backend // config (including any live updates mid-session). let selectedTier = null; // resolved lazily on first use (see showUpsell) let stripeInstance = null; let cardElement = null; // in-chat upsell card element let directCardNumber = null; // direct payment modal card number element function showUpsell() { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); // Disable input document.getElementById('chat-input').disabled = true; document.getElementById('send-btn').disabled = true; const card = document.createElement('div'); card.className = 'payment-card'; card.id = 'payment-card'; card.innerHTML = `

Access to Universitas Scholarium tutorials and courses requires a patron subscription. Language courses at the Latinum Press, Centaurus Press publications, and the Acta Scholarium remain freely available to all registered members.

Become a Patron `; msgs.insertBefore(card, typing); msgs.scrollTop = msgs.scrollHeight; } function selectTier(idx) { selectedTier = getUpsellTiers()[idx]; getUpsellTiers().forEach((_, i) => { const btn = document.getElementById(`tier-btn-${i}`); if (btn) btn.classList.toggle('active', i === idx); }); const payBtn = document.getElementById('pay-btn'); if (payBtn) payBtn.textContent = `Pay ${selectedTier.label} — continue`; } async function processPayment() { const payBtn = document.getElementById('pay-btn'); const errDiv = document.getElementById('card-errors'); if (!payBtn) return; payBtn.disabled = true; payBtn.textContent = 'Redirecting to payment…'; if (errDiv) errDiv.textContent = ''; try { // 1. Get email and register if needed const emailInput = document.getElementById('payment-email'); const email = (emailInput?.value || '').trim(); if (!email || !email.includes('@')) { throw new Error('Please enter a valid email address.'); } if (!userId) { const regRes = await fetch(`${API_BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); const regData = await regRes.json(); if (regData.error) throw new Error(regData.error); userId = regData.user_id; userTier = regData.tier || 'door'; exchangeCount = regData.exchange_count || 0; setStoredUserId(userId, true); // payment = always remember } if (!userId) { alert('Could not create account. Please try again.'); return; } // 2. Create Stripe Checkout session (handles both subscription + one-time) const checkoutRes = await fetch(`${API_BASE}/create-checkout-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tier_id: selectedTier.key, user_id: userId, email }), }); const checkoutData = await checkoutRes.json(); if (checkoutData.error) throw new Error(checkoutData.error); // 3. Redirect to Stripe Checkout window.location.href = checkoutData.url; } catch (err) { if (errDiv) errDiv.textContent = err.message; if (payBtn) { payBtn.disabled = false; payBtn.textContent = `Pay ${selectedTier.label} — continue`; } } } // ══════════════════════════════════════════════════════════ // STRIPE PAYMENT function setBusy(busy) { _streamLock = busy; document.getElementById('send-btn').disabled = busy; setTyping(busy); // show dots while waiting for first chunk } function setTyping(show) { const t = document.getElementById('typing-indicator'); if (t) t.className = show ? 'typing visible' : 'typing'; if (show) { const msgs = document.getElementById('chat-messages'); if (msgs) msgs.scrollTop = msgs.scrollHeight; } } // ══════════════════════════════════════════════════════════ // EVENTS // ══════════════════════════════════════════════════════════ document.getElementById('chat-close').addEventListener('click', closeChat); document.getElementById('overlay').addEventListener('click', e => { if (e.target === document.getElementById('overlay')) closeChat(); }); document.getElementById('send-btn').addEventListener('click', send); // ── Mic / STT ──────────────────────────────────────────────────────── // Record audio via MediaRecorder; POST blob to /api/transcribe; drop the // returned text into the chat input for the student to edit and send. // State machine: idle -> recording -> processing -> idle (function(){ let _micRecorder = null; let _micStream = null; let _micChunks = []; let _micState = 'idle'; let _micAutoStopTimer = null; // Hard cap on a single recording. Auto-stops at this duration to prevent // runaway recording (forgotten / walked-away) which would still get // billed by OpenAI per file duration regardless of speech content. // 90s is comfortably above any normal chat utterance and most viva-style // answers; cost cap per accidental record is ~$0.0045. const MAX_RECORDING_SECONDS = 90; const micBtn = document.getElementById('mic-btn'); if (!micBtn) return; function setState(next) { // Clear the auto-stop timer whenever we leave the recording state, // whether by manual stop, auto-stop, or any error path. Defensive — // the manual-stop branch also clears below, but this guarantees the // timer can never outlive the recording session. if (next !== 'recording' && _micAutoStopTimer) { clearTimeout(_micAutoStopTimer); _micAutoStopTimer = null; } _micState = next; micBtn.classList.remove('recording', 'processing'); if (next === 'recording') { micBtn.classList.add('recording'); micBtn.innerHTML = '■'; // ■ stop square micBtn.title = 'Stop and transcribe'; } else if (next === 'processing') { micBtn.classList.add('processing'); micBtn.innerHTML = '…'; // … ellipsis micBtn.title = 'Transcribing…'; micBtn.disabled = true; } else { micBtn.innerHTML = '🎤'; // 🎤 micBtn.title = 'Speak instead of type (auto-stops after 90s)'; micBtn.disabled = false; } } micBtn.addEventListener('click', async function() { if (_micState === 'idle') { // Start recording if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { alert('This browser does not support microphone recording.'); return; } try { _micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { alert('Microphone access is required to use voice input. Please allow it in your browser settings and try again.'); return; } _micChunks = []; try { _micRecorder = new MediaRecorder(_micStream); } catch (err) { // Some browsers reject default MIME — try webm explicitly try { _micRecorder = new MediaRecorder(_micStream, { mimeType: 'audio/webm' }); } catch (err2) { alert('Recording failed to start in this browser. Please try typing instead.'); _micStream.getTracks().forEach(t => t.stop()); _micStream = null; return; } } _micRecorder.ondataavailable = e => { if (e.data && e.data.size > 0) _micChunks.push(e.data); }; _micRecorder.onstop = handleStop; _micRecorder.start(); setState('recording'); // Arm the safety auto-stop. If the user walks away or forgets, // this fires and we transcribe whatever was captured (bounded cost). _micAutoStopTimer = setTimeout(function(){ if (_micState === 'recording') { try { _micRecorder.stop(); } catch (e) {} setState('processing'); } }, MAX_RECORDING_SECONDS * 1000); } else if (_micState === 'recording') { // Manual stop — also clears the auto-stop timer via setState below. try { _micRecorder.stop(); } catch (e) {} setState('processing'); } }); async function handleStop() { // Release the microphone if (_micStream) { _micStream.getTracks().forEach(t => t.stop()); _micStream = null; } const mime = (_micRecorder && _micRecorder.mimeType) || 'audio/webm'; const blob = new Blob(_micChunks, { type: mime }); _micChunks = []; // user_id comes from the global `userId` (declared at module scope earlier // in this file, set on auth success). Falsy means anon / not signed in. if (!userId) await initUser(); if (!userId) { alert('Please sign in to use voice input.'); setState('idle'); return; } try { const ext = mime.includes('mp4') ? 'mp4' : (mime.includes('ogg') ? 'ogg' : 'webm'); const fd = new FormData(); fd.append('audio', blob, 'recording.' + ext); fd.append('user_id', userId); const r = await fetch('/api/transcribe', { method: 'POST', body: fd }); if (!r.ok) { let msg = 'Could not transcribe audio. Please try typing instead.'; if (r.status === 503) msg = 'Voice input is not configured on the server.'; alert(msg); setState('idle'); return; } const data = await r.json(); const text = (data && data.text) ? data.text.trim() : ''; if (!text) { alert('Did not catch any speech. Please try again.'); setState('idle'); return; } // Append to existing input contents (so student can keep typed prefix) const inp = document.getElementById('chat-input'); const existing = inp.value || ''; inp.value = existing ? existing.replace(/\s+$/, '') + ' ' + text : text; // Trigger input event so the textarea auto-resizes if a handler is wired inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); // Place caret at end try { inp.setSelectionRange(inp.value.length, inp.value.length); } catch (e) {} setState('idle'); } catch (e) { console.error('transcribe failed', e); alert('Could not transcribe audio. Please try typing instead.'); setState('idle'); } } })(); document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); document.getElementById('chat-input').addEventListener('input', function() { this.style.height = 'auto'; this.style.height = Math.min(this.scrollHeight, 120) + 'px'; // Dismiss continue button on first keystroke cancelContinueBtn(); }); // ══════════════════════════════════════════════════════════ // INIT // ══════════════════════════════════════════════════════════ // SYMPOSIA // ══════════════════════════════════════════════════════════ function updateRoster() { const roster = document.getElementById('symposia-roster'); const visible = symposiaPanel.filter(id => id !== 'tesla'); if (visible.length === 0) { roster.classList.add('hidden'); return; } roster.classList.remove('hidden'); roster.innerHTML = visible.map(id => { const removeBtn = `×`; return `${id}${removeBtn}`; }).join(''); } function updateInviteBtn() { const btn = document.getElementById('invite-btn'); if (!btn) return; // Count scholars only (not Tesla) const scholarCount = symposiaPanel.filter(id => id !== 'tesla').length; if (scholarCount >= 4) { btn.textContent = 'Room full'; btn.disabled = true; } else { btn.textContent = '+ Invite'; btn.disabled = false; } } function removeScholar(scholarId) { if (scholarId === 'tesla') return; // cannot remove chair const idx = symposiaPanel.indexOf(scholarId); if (idx === -1) return; symposiaPanel.splice(idx, 1); updateRoster(); updateInviteBtn(); appendAtmosphere(`${scholarId} has left the room.`); // If only Tesla remains — deactivate symposia if (symposiaPanel.length <= 1) { symposiaActive = false; symposiaPanel = []; updateRoster(); updateInviteBtn(); document.getElementById('chat-input').placeholder = '…'; } } function appendAtmosphere(text) { const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); const el = document.createElement('div'); el.className = 'msg-atmosphere'; el.textContent = text; msgs.insertBefore(el, typing); msgs.scrollTop = msgs.scrollHeight; } // ── Arrival streaming helper ────────────────────────────── async function streamArrival(payload) { if (_streamLock) return; // prevent concurrent stream corruption const msgs = document.getElementById('chat-messages'); const typing = document.getElementById('typing-indicator'); // Reset organic renderer state — _fullText accumulates globally and must // be cleared between streamArrival calls or previous text is re-rendered _fullText = ''; _bubble = null; _rendered = false; const bubble = document.createElement('div'); bubble.className = 'msg assistant streaming'; msgs.insertBefore(bubble, typing); setBusy(true); let fullText = ''; try { const res = await fetch(`${API_BASE}/chat/symposia/arrive`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, user_id: userId || undefined }), }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let leftover = ''; let stalled = false; while (true) { const chunk = reader.read(); const timer = new Promise(r => setTimeout(() => r('STALL'), 30000)); const result = await Promise.race([chunk, timer]); if (result === 'STALL') { stalled = true; break; } const { value, done } = result; if (done) break; const raw = leftover + decoder.decode(value, { stream: true }); const lines = raw.split('\n'); leftover = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const obj = JSON.parse(line.slice(6)); if (obj.chunk) { fullText += obj.chunk; organicPush(obj.chunk, bubble); } } catch {} } } await organicWait(); bubble.classList.remove('streaming'); history.push({ role: 'assistant', content: fullText }); if (history.length > 24) history = history.slice(-24); scheduleContinueBtn(stalled ? 0 : 8000); } catch(err) { bubble.textContent = 'The simulacrum could not be reached. Please try again.'; bubble.classList.remove('streaming'); scheduleContinueBtn(0); } setBusy(false); msgs.scrollTop = msgs.scrollHeight; } // ── Invite flow ─────────────────────────────────────────── async function inviteScholar(scholarId, scholarName) { if (inviteLock) return; inviteLock = true; closePicker(); // Immediate feedback — user knows the click worked appendAtmosphere(`You have invited ${scholarName} and they will arrive shortly.`); if (!symposiaActive) { // First invite — activate Tesla as chair first // Include the scholar the user was already chatting with symposiaActive = true; symposiaPanel = ['tesla', currentId]; // Shift input placeholder into symposia mode document.getElementById('chat-input').placeholder = 'Reply to one or all, or type "continue"…'; appendAtmosphere('The Symposium is assembling…'); updateRoster(); updateInviteBtn(); await streamArrival({ type: 'tesla_activate', panel: symposiaPanel.slice(), existing_id: currentId, history: history.slice(), }); } // Now add the invited scholar symposiaPanel.push(scholarId); appendAtmosphere(`${scholarName} has been reached — reading the conversation…`); updateRoster(); updateInviteBtn(); await streamArrival({ type: 'scholar_arrive', arriving_id: scholarId, panel: symposiaPanel.slice(), history: history.slice(), }); inviteLock = false; } // ══════════════════════════════════════════════════════════ // PICKER // ══════════════════════════════════════════════════════════ let pickerDept = null; // null = dept list; string = scholar list for that dept function exportChatPDF() { const msgs = document.getElementById('chat-messages'); if (!msgs) return; const name = document.getElementById('chat-name')?.textContent || 'Chat'; const dept = document.getElementById('chat-dept')?.textContent || ''; const bubbles = msgs.querySelectorAll('.msg'); if (!bubbles.length) return; let html = ''; bubbles.forEach(b => { const isUser = b.classList.contains('user'); const speaker = isUser ? 'You' : name; const text = b.innerText.trim(); if (text) html += '

' + speaker + ': ' + text.replace(/\n'; }); const w = window.open('', '_blank'); w.document.write('' + name + ' — Universitas Scholarium' + '' + '

' + name + (dept ? ' — ' + dept : '') + '

' + html + '

Universitas Scholarium · universitas-scholarium.org

' + ''); w.document.close(); setTimeout(() => w.print(), 400); } function openPicker() { pickerDept = null; document.getElementById('picker-search').value = ''; pickerShowDepts(); document.getElementById('picker-overlay').classList.add('open'); setTimeout(() => document.getElementById('picker-search').focus(), 200); } function closePicker() { document.getElementById('picker-overlay').classList.remove('open'); } function pickerShowDepts() { const query = document.getElementById('picker-search').value.trim().toLowerCase(); const body = document.getElementById('picker-body'); if (query.length > 0) { // Search mode — flat list across all depts pickerShowSearch(query, body); return; } body.innerHTML = ''; DEPARTMENTS.forEach(dept => { const item = document.createElement('div'); item.className = 'picker-dept-item'; item.innerHTML = `${dept.name}`; item.addEventListener('click', () => pickerShowScholars(dept)); body.appendChild(item); }); } function pickerShowScholars(dept) { pickerDept = dept.name; const body = document.getElementById('picker-body'); body.innerHTML = ''; const back = document.createElement('div'); back.className = 'picker-back'; back.innerHTML = `◀ ${dept.name}`; back.addEventListener('click', () => { pickerDept = null; pickerShowDepts(); }); body.appendChild(back); const allScholars = (dept.faculty && dept.faculty.length) ? dept.faculty : (dept.sections || []).flatMap(s => s.faculty || []); allScholars.forEach(s => pickerRenderScholar(s, dept.name, body)); } function pickerShowSearch(query, body) { body.innerHTML = ''; DEPARTMENTS.forEach(dept => { const allScholars = (dept.faculty && dept.faculty.length) ? dept.faculty : (dept.sections || []).flatMap(s => s.faculty || []); allScholars.forEach(s => { const hit = s.name.toLowerCase().includes(query) || (s.field || '').toLowerCase().includes(query); if (hit) pickerRenderScholar(s, dept.name, body); }); }); if (!body.children.length) { body.innerHTML = '
No scholars found.
'; } } function pickerRenderScholar(s, deptName, container) { const alreadyPresent = symposiaPanel.includes(s.id) || s.id === 'tesla'; const item = document.createElement('div'); item.className = 'picker-scholar-item' + (alreadyPresent ? ' dimmed' : ''); item.innerHTML = `
${s.name}
${s.field || ''}
${deptName}
`; if (!alreadyPresent) { item.addEventListener('click', () => inviteScholar(s.id, s.name)); } container.appendChild(item); } // ══════════════════════════════════════════════════════════ // SPEECH INPUT // ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════ // APP INSTALL (PWA) // ══════════════════════════════════════════════════════════ (function initInstall() { if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); // Never show if already running as installed app if (window.matchMedia('(display-mode: standalone)').matches) return; let deferredPrompt = null; const banner = document.getElementById('install-banner'); const bannerBtn = document.getElementById('install-btn'); const dismiss = document.getElementById('install-dismiss'); // Header button: only shown when browser confirms install is actually possible window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; if (bannerBtn) bannerBtn.textContent = 'Install Now'; }); // Header button click: trigger install if ready, else show banner with guidance window.toggleInstallPanel = function() { if (banner) banner.classList.toggle('visible'); }; window.triggerInstall = async function() { if (deferredPrompt) { // Native install prompt available — trigger it deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; deferredPrompt = null; if (outcome === 'accepted') { // Actually installed — hide everything if (banner) banner.classList.remove('visible'); } } else { // Prompt not ready yet — show banner with guidance if (banner) { const t = banner.querySelector('.install-banner-text'); if (t) t.textContent = 'Open this site in Chrome and use the Add to Home Screen button, or use your browser\'s menu to add to home screen.'; if (bannerBtn) bannerBtn.style.display = 'none'; banner.classList.add('visible'); } } }; // Banner install button if (bannerBtn) bannerBtn.addEventListener('click', triggerInstall); // Dismiss: close banner ONLY — header button stays visible if (dismiss) dismiss.addEventListener('click', () => { if (banner) banner.classList.remove('visible'); // Note: do NOT hide headerBtn here — user dismissed the panel, not the button }); // Close banner on outside click document.addEventListener('click', (e) => { if (!banner || !banner.classList.contains('visible')) return; if (!banner.contains(e.target) && headerBtn && !headerBtn.contains(e.target)) banner.classList.remove('visible'); }); // iOS Safari: no beforeinstallprompt — show Share instructions const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream; const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); if (isIOS && isSafari) { window.triggerInstall = function() { if (banner) { const t = banner.querySelector('.install-banner-text'); if (t) t.textContent = 'To install: tap the Share button ↑ then "Add to Home Screen".'; if (bannerBtn) bannerBtn.style.display = 'none'; banner.classList.toggle('visible'); } }; } })(); // ══════════════════════════════════════════════════════════ // Wordmark click is now handled natively by the canonical header's // (see /header.css?v=2 and the
markup). No JS handler needed. window.addEventListener('popstate', () => route(location.pathname)); // Only render homepage if we're actually on the home path const _skipHome = location.pathname.startsWith('/dept/') || new URLSearchParams(location.search).get('dept'); if (!_skipHome) { renderHomepage(); } // ── /?admit=N auto-open hook (routed from /pricing page) ── (function handleAdmitParam() { const params = new URLSearchParams(window.location.search); const admit = params.get('admit'); if (admit === null) return; const tierIndex = parseInt(admit, 10); { const _t = getUpsellTiers(); if (isNaN(tierIndex) || tierIndex < 0 || tierIndex >= _t.length) return; } // Clean the URL so reloads don't re-trigger history.replaceState(null, '', window.location.pathname); // Wait for the next tick so any layout settles, then open the payment modal setTimeout(() => { if (typeof showPaymentDirect === 'function') { showPaymentDirect(tierIndex); } }, 50); })(); // route() now called inside initUser().then() to ensure userEmail is set // Fetch user tier on page load so invite button shows immediately for subscribers // ── Sign-in (email recovery) ────────────────────────────── function showPentagonDenied() { document.getElementById('pentagon-denied').style.display = 'flex'; } function showSignIn(e) { if (e) e.preventDefault(); document.getElementById('signin-overlay').style.display = 'flex'; document.getElementById('signin-email').value = ''; document.getElementById('signin-email').style.display = ''; const btn = document.querySelector('#signin-overlay button'); if (btn) btn.style.display = ''; const errDiv = document.getElementById('signin-error'); errDiv.textContent = ''; errDiv.style.color = '#c0392b'; setTimeout(() => document.getElementById('signin-email').focus(), 100); } function hideSignIn() { document.getElementById('signin-overlay').style.display = 'none'; } async function doSignIn() { const email = (document.getElementById('signin-email').value || '').trim(); const errDiv = document.getElementById('signin-error'); const remember = document.getElementById('signin-remember')?.checked ?? true; if (!email || !email.includes('@')) { errDiv.textContent = 'Please enter a valid email address.'; return; } errDiv.style.color = 'var(--ink-mid)'; errDiv.textContent = 'Sending sign-in link…'; try { const res = await fetch(`${API_BASE}/auth/send-link`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, remember }), }); const data = await res.json(); if (data.error) { errDiv.style.color = '#c0392b'; errDiv.textContent = data.error; return; } errDiv.style.color = 'var(--gold)'; errDiv.textContent = 'A sign-in link has been sent to your email. Check your inbox.'; document.getElementById('signin-email').style.display = 'none'; document.querySelector('#signin-overlay button').style.display = 'none'; // ── Poll for cross-device magic-link verification ── // Email WebViews set cookies in their own sandbox, not here. // Poll server until the token is verified, then log in this tab. if (data.poll_token) { let attempts = 0; const maxAttempts = 180; const pollInterval = setInterval(async () => { attempts++; if (attempts > maxAttempts) { clearInterval(pollInterval); return; } try { const pr = await fetch(`${API_BASE}/auth/poll?poll_token=${encodeURIComponent(data.poll_token)}`); const pd = await pr.json(); if (pd.status === 'verified') { clearInterval(pollInterval); setStoredUserId(pd.user_id, remember); userId = pd.user_id; userTier = pd.tier || 'door'; userEmail = pd.email || null; exchangeCount = 0; hideSignIn(); updateSignInLink(); checkUserStatus(); errDiv.textContent = ''; // Re-route to the current path to pick up auth state route(location.pathname); } else if (pd.status === 'expired') { clearInterval(pollInterval); } } catch(e) { /* network blip — keep trying */ } }, 5000); } } catch(e) { errDiv.style.color = '#c0392b'; errDiv.textContent = 'Could not connect to server.'; } } function updateSignInLink() { const link = document.querySelector('header .us-signin'); if (!link) return; if (userId) { link.textContent = 'SIGN OUT'; link.onclick = function(e) { e.preventDefault(); doSignOut(); }; } else { link.textContent = 'LOG IN'; link.onclick = function(e) { showSignIn(e); }; } } function doSignOut() { // Save current conversation before clearing user state saveCurrentConversation(); clearStoredUserId(); // Do NOT delete us_conversations — they are user-scoped and persist across sign-in/out userId = null; userTier = 'door'; exchangeCount = 0; updateSignInLink(); // Close any open chat const overlay = document.getElementById('overlay'); if (overlay) overlay.classList.remove('open'); currentId = null; history = []; } let _deepLinkOpened = false; initUser().then(uid => { if (uid) { checkUserStatus(); loadStoredConversations(); } updateSignInLink(); // Handle ?dept= param — navigate to dept within SPA after auth resolves const _deptParam = new URLSearchParams(window.location.search).get('dept'); if (_deptParam) { history.replaceState(null, '', '/'); navigate('/dept/' + _deptParam); return; } // A deep-linked scholar chat (opened synchronously by the deep-link IIFE // below) owns the view; the async router must not clobber it back to home. if (!_deepLinkOpened) route(location.pathname); }); // Handle return from Stripe Checkout const urlParams = new URLSearchParams(window.location.search); if (urlParams.get('payment') === 'success') { // Clean URL window.history.replaceState({}, '', '/app'); // Re-check user status after brief delay (webhook may take a moment) setTimeout(() => { if (userId) checkUserStatus(); // Re-enable chat document.getElementById('chat-input').disabled = false; document.getElementById('send-btn').disabled = false; // Remove payment card if visible const payCard = document.getElementById('payment-card'); if (payCard) payCard.innerHTML = '

✓  Welcome back. Where were we?

'; }, 2000); } // ── Deep-link: open chat from URL params ────────────────────── // URL is the source of truth. Parse scholar + optional course context. // Pass through to openChat, which sets _currentCourseCtx and (if the URL // is already correct) skips redundant pushState. (function() { const scholarParam = urlParams.get('scholar'); if (!scholarParam) return; // Optional course context from URL params const courseSlug = urlParams.get('course'); const moduleN = urlParams.get('module'); let courseCtx = null; if (courseSlug && moduleN) { courseCtx = { slug: courseSlug, module_n: parseInt(moduleN, 10), }; const subunitN = urlParams.get('subunit'); if (subunitN) courseCtx.subunit_n = parseInt(subunitN, 10); const scenarioS = urlParams.get('scenario'); if (scenarioS) courseCtx.scenario_s = parseInt(scenarioS, 10); const kind = urlParams.get('kind'); if (kind) courseCtx.kind = kind; } const found = findFacultyById(scholarParam); if (found) { _deepLinkOpened = true; openChat(found.faculty, found.deptName, courseCtx); return; } })(); // ══════════════════════════════════════════════════════════ // FILE ATTACHMENT // ══════════════════════════════════════════════════════════ let _attachedFile = null; // { name, type, mediaType, content (base64 or text) } function handleFileSelect(input) { const file = input.files[0]; if (!file) return; const MAX_BYTES = 5 * 1024 * 1024; // 5MB if (file.size > MAX_BYTES) { alert('File is too large. Maximum size is 5MB.'); input.value = ''; return; } const ext = file.name.split('.').pop().toLowerCase(); const textTypes = ['txt', 'md', 'csv']; const docTypes = ['pdf']; const imgTypes = ['png', 'jpg', 'jpeg', 'webp']; const reader = new FileReader(); if (textTypes.includes(ext)) { reader.readAsText(file); reader.onload = () => { _attachedFile = { name: file.name, type: 'text', content: reader.result }; showAttachmentChip(file.name); }; } else if (docTypes.includes(ext)) { reader.readAsDataURL(file); reader.onload = () => { const b64 = reader.result.split(',')[1]; _attachedFile = { name: file.name, type: 'document', mediaType: 'application/pdf', content: b64 }; showAttachmentChip(file.name); }; } else if (imgTypes.includes(ext)) { reader.readAsDataURL(file); reader.onload = () => { const b64 = reader.result.split(',')[1]; const mt = file.type || 'image/jpeg'; _attachedFile = { name: file.name, type: 'image', mediaType: mt, content: b64 }; showAttachmentChip(file.name); }; } else { // DOCX/ODT/RTF — not yet supported client-side; inform user alert('For .docx, .odt, and .rtf files, please copy and paste the text content directly into the chat.'); input.value = ''; return; } input.value = ''; // reset so same file can be re-selected } function showAttachmentChip(name) { const chip = document.getElementById('attachment-chip'); document.getElementById('attachment-chip-name').textContent = name; chip.classList.add('visible'); } function clearAttachment() { _attachedFile = null; const chip = document.getElementById('attachment-chip'); chip.classList.remove('visible'); document.getElementById('attachment-chip-name').textContent = ''; }