(() => {
'use strict';
const STORAGE_KEY = 'study-pal:v1';
const SETTINGS_KEY = 'study-pal:settings:v1';
const USAGE_KEY = 'study-pal:usage:v1';
const PDF_REQUEST_LIMIT = 50 * 1024 * 1024;
// USD per million tokens. Last checked against OpenAI Docs on 2026-08-17.
const MODEL_PRICES = [
{ match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },
{ match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 },
{ match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 },
{ match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 },
{ match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 },
{ match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 },
{ match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 },
{ match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 },
{ match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 },
{ match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 },
{ match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
{ match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 }
];
const DEFAULT_SETTINGS = {
apiKey: '',
model: 'gpt-5-mini',
apiUrl: 'https://api.openai.com/v1/responses',
systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Use mathematical language, assume graduate level knowledge. Build on the conversation context and explain assumptions.'
};
const $ = (selector, root = document) => root.querySelector(selector);
const els = {
welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'),
keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),
monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'),
usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),
usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'),
usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'),
historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'),
attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input')
};
let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
let workspace = loadWorkspace();
let state = currentMap();
let usageRecords = loadUsage();
let activeRequest = null;
let uploadingPDFs = false;
let uploadTargetMapId = null;
let selectedText = null;
let saveTimer = 0;
let toastTimer = 0;
function loadJSON(key, fallback) {
try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; }
catch { return { ...fallback }; }
}
function loadUsage() {
try {
const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');
return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
} catch { return []; }
}
function blankMap() {
const now = Date.now();
return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], activeId: null, createdAt: now, updatedAt: now };
}
function titleFromNodes(nodes) {
const first = nodes.find(node => !node.parentId) || nodes[0];
if (!first) return 'Untitled map';
const title = String(first.question).replace(/\s+/g, ' ').trim();
return title.length > 54 ? `${title.slice(0, 54)}…` : title;
}
function normalizeMap(value = {}) {
const normalized = normalizeState(value);
const attachments = Array.isArray(value.attachments) ? value.attachments
.filter(file => file && file.id && file.name)
.map(file => ({
id: String(file.id), name: String(file.name), size: Number(file.size) || 0,
createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low'
})) : [];
const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now();
return {
id: String(value.id || uid()),
title: String(value.title || titleFromNodes(normalized.nodes)),
titleCustom: Boolean(value.titleCustom),
...normalized,
attachments,
createdAt,
updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt
};
}
function loadWorkspace() {
let raw = null;
try { raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch {}
let maps = [];
let activeMapId = null;
if (raw && Array.isArray(raw.maps)) {
maps = raw.maps.filter(Boolean).map(normalizeMap);
activeMapId = raw.activeMapId;
} else if (raw && Array.isArray(raw.nodes) && raw.nodes.length) {
// Migrate the original single-map schema in place.
const migrated = normalizeMap(raw);
maps = [migrated];
activeMapId = migrated.id;
}
if (!maps.length) maps.push(blankMap());
if (!maps.some(map => map.id === activeMapId)) activeMapId = maps[0].id;
return { version: 2, maps, activeMapId };
}
function currentMap() {
return workspace.maps.find(map => map.id === workspace.activeMapId) || workspace.maps[0];
}
function normalizeState(value) {
const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
if (node.status !== 'loading') return node;
return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
}) : [];
const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
return { nodes, activeId };
}
function persistSoon() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); }
catch { showToast('Browser storage is full — conversations could not be saved'); }
}, 180);
}
function persistNow() {
clearTimeout(saveTimer);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); return true; }
catch { showToast('Browser storage is full — conversations could not be saved'); return false; }
}
function touchMap(map = state) { map.updatedAt = Date.now(); }
function refreshMapTitle(map = state) {
if (!map.titleCustom) map.title = titleFromNodes(map.nodes);
}
function nodeById(id, map = state) { return map.nodes.find(node => node.id === id); }
function pathTo(id, map = state) {
const path = [];
const seen = new Set();
let node = nodeById(id, map);
while (node && !seen.has(node.id)) {
seen.add(node.id);
path.push(node);
node = nodeById(node.parentId, map);
}
return path.reverse();
}
function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
function uid() {
return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function escapeHTML(value = '') {
return value.replace(/[&<>'"]/g, char => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[char]);
}
function inlineMarkdown(text) {
let output = escapeHTML(text);
output = output.replace(/`([^`]+)`/g, '$1');
output = output.replace(/\*\*([^*]+)\*\*/g, '$1');
output = output.replace(/(?$1');
output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1');
return output;
}
function renderMath(source, displayMode) {
if (!globalThis.katex) return `${escapeHTML(source)}`;
try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
catch { return `${escapeHTML(source)}`; }
}
function markdown(source = '') {
const stash = [];
const token = html => `\u0000${stash.push(html) - 1}\u0000`;
let text = source.replace(/\r\n?/g, '\n');
text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`
${escapeHTML(code.trimEnd())}`));
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
text = text.replace(/(? token(renderMath(formula.trim(), false)));
const lines = text.split('\n');
const html = [];
let paragraph = [];
let list = null;
const flushParagraph = () => {
if (paragraph.length) html.push(`${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '
')}
${inlineMarkdown(quote[1])}`); } else if (!line.trim()) { flushParagraph(); closeList(); } else paragraph.push(line); } flushParagraph(); closeList(); return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]); } function answerHTML(node) { if (node.status === 'loading' && !node.answer) return ''; if (node.status === 'error') return `
${escapeHTML(node.error || 'The request failed.')}
`; return markdown(node.answer || ''); } function renderConversation({ scroll = false } = {}) { const path = pathTo(state.activeId); els.welcome.hidden = path.length > 0; els.conversation.hidden = path.length === 0; const fragment = document.createDocumentFragment(); for (const node of path) { const article = document.createElement('article'); article.className = 'message'; article.dataset.nodeId = node.id; article.innerHTML = `No tracked requests this month.
'; els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `Completed requests will appear here.
'; els.usageNote.textContent = unpriced ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.` : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.'; } function openUsage() { renderUsage(); els.usageDialog.showModal(); } function createNewMap() { const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0); const map = existingBlank || blankMap(); if (!existingBlank) workspace.maps.push(map); workspace.activeMapId = map.id; state = map; persistNow(); renderAll(); closeHistoryOnSmallScreen(); window.scrollTo({ top: 0, behavior: 'smooth' }); els.prompt.focus(); } function switchMap(mapId) { const map = workspace.maps.find(candidate => candidate.id === mapId); if (!map) { closeHistoryOnSmallScreen(); return; } workspace.activeMapId = map.id; state = map; state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); hideSelectionMenu(); renderAll({ scroll: state.activeId ? 'bottom' : false }); closeHistoryOnSmallScreen(); } function closeHistoryOnSmallScreen() { if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open'); updateHistoryToggle(); } function updateHistoryToggle() { const small = matchMedia('(max-width: 1100px)').matches; const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed'); els.historyToggle.setAttribute('aria-expanded', String(open)); } function toggleHistory() { if (matchMedia('(max-width: 1100px)').matches) { document.body.classList.remove('history-collapsed'); document.body.classList.toggle('history-open'); if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open'); } else document.body.classList.toggle('history-collapsed'); updateHistoryToggle(); } function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') { if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; } if (!confirm(message)) return false; if (activeRequest) activeRequest.abort(); const map = blankMap(); workspace = { version: 2, maps: [map], activeMapId: map.id }; state = map; persistNow(); renderAll(); showToast('All conversations cleared'); return true; } function deleteMap(mapId) { const map = workspace.maps.find(candidate => candidate.id === mapId); if (!map) return; if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; } const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : ''; if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return; if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort(); workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id); if (!workspace.maps.length) workspace.maps.push(blankMap()); if (state === map) { const next = [...workspace.maps] .filter(candidate => candidate.nodes.length || candidate.attachments.length) .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0]; workspace.activeMapId = next.id; state = next; state.activeId = state.nodes.at(-1)?.id || null; } persistNow(); renderAll({ scroll: state.activeId ? 'bottom' : false }); showToast('Conversation deleted'); } function renameMap(mapId) { const map = workspace.maps.find(candidate => candidate.id === mapId); if (!map) return; const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title); if (value === null) return; const title = value.replace(/\s+/g, ' ').trim(); if (title) { map.title = title.slice(0, 120); map.titleCustom = true; } else { map.titleCustom = false; refreshMapTitle(map); } touchMap(map); persistNow(); renderHistory(); showToast(title ? 'Conversation renamed' : 'Automatic title restored'); } function showSelectionMenu(text, nodeId, rect) { selectedText = { text: text.slice(0, 5000), nodeId }; els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text; els.selectionMenu.hidden = false; const width = 330; const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); const estimatedHeight = 235; const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8); Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` }); } function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; } els.composer.addEventListener('submit', event => { event.preventDefault(); if (activeRequest) { activeRequest.abort(); return; } const question = els.prompt.value; els.prompt.value = ''; els.prompt.style.height = ''; ask(question); }); els.prompt.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); } }); els.prompt.addEventListener('input', () => { els.prompt.style.height = 'auto'; els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`; }); document.addEventListener('click', event => { const starter = event.target.closest('.starter'); if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); } const historyItem = event.target.closest('.history-item'); if (historyItem) { switchMap(historyItem.dataset.mapId); return; } const renameConversation = event.target.closest('.rename-map'); if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; } const deleteConversation = event.target.closest('.delete-map'); if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; } const removeFile = event.target.closest('.remove-attachment'); if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; } const treeNode = event.target.closest('.tree-node'); if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll({ scroll: true }); document.body.classList.remove('map-open'); } const branch = event.target.closest('.branch-here'); if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); } const retry = event.target.closest('.retry-node'); if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); } const copy = event.target.closest('.copy-answer'); if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); } }); document.addEventListener('mouseup', event => { if (event.target.closest('.selection-menu')) return; requestAnimationFrame(() => { const selection = getSelection(); const text = selection?.toString().trim(); if (!text || text.length < 2 || !selection.rangeCount) return; const answer = selection.anchorNode?.parentElement?.closest('.answer-body'); if (!answer || !answer.contains(selection.focusNode)) return; const message = answer.closest('.message'); showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect()); }); }); $('#ask-selection').addEventListener('click', () => { if (!selectedText) return; const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value; if (!action) { els.selectionCustom.focus(); return; } const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`; const parentId = selectedText.nodeId; hideSelectionMenu(); getSelection()?.removeAllRanges(); ask(question, parentId); }); els.selectionAction.addEventListener('change', () => { els.selectionCustom.hidden = els.selectionAction.value !== 'custom'; if (!els.selectionCustom.hidden) els.selectionCustom.focus(); }); $('#close-selection').addEventListener('click', hideSelectionMenu); window.addEventListener('scroll', hideSelectionMenu, { passive: true }); $('#open-settings').addEventListener('click', openSettings); $('#open-usage').addEventListener('click', openUsage); $('#theme-toggle').addEventListener('click', toggleTheme); $('#toggle-history').addEventListener('click', toggleHistory); $('#study-pal-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); }); $('#new-map-sidebar').addEventListener('click', createNewMap); els.uploadPDF.addEventListener('click', () => els.pdfInput.click()); els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files)); $('#close-settings').addEventListener('click', () => els.settings.close()); $('#close-usage').addEventListener('click', () => els.usageDialog.close()); $('#reset-usage').addEventListener('click', () => { if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) { usageRecords = []; localStorage.removeItem(USAGE_KEY); updateUsageButton(); renderUsage(); showToast('Local usage history reset'); } }); $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; }); $('#settings-form').addEventListener('submit', event => { event.preventDefault(); settings = { apiKey: els.apiKey.value.trim(), model: els.model.value.trim() || DEFAULT_SETTINGS.model, apiUrl: els.apiUrl.value.trim() || DEFAULT_SETTINGS.apiUrl, systemPrompt: els.systemPrompt.value.trim() }; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); els.settings.close(); showToast('Settings saved'); }); $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); }); $('#clear-history').addEventListener('click', () => clearChatHistory()); $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); }); $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; }); $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); }); addEventListener('resize', updateHistoryToggle, { passive: true }); updateKeyStatus(); updateThemeButton(); updateUsageButton(); updateHistoryToggle(); state.activeId = state.nodes.at(-1)?.id || null; renderAll({ scroll: state.activeId ? 'bottom' : false }); })();