(() => {
'use strict';
const STORAGE_KEY = 'study-pal:v1';
const SETTINGS_KEY = 'study-pal:settings:v1';
const USAGE_KEY = 'study-pal:usage:v1';
// 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. 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')
};
let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null }));
let usageRecords = loadUsage();
let activeRequest = 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 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(state)); }
catch { showToast('Browser storage is full — this map could not be saved'); }
}, 180);
}
function nodeById(id) { return state.nodes.find(node => node.id === id); }
function pathTo(id) {
const path = [];
const seen = new Set();
let node = nodeById(id);
while (node && !seen.has(node.id)) {
seen.add(node.id);
path.push(node);
node = nodeById(node.parentId);
}
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 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 treeNode = event.target.closest('.tree-node'); if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll(); document.body.classList.remove('map-open'); scrollTo({ top: 0, behavior: 'smooth' }); } 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); $('#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'); }); $('#new-session').addEventListener('click', () => { if (!state.nodes.length || confirm('Start a new map? This will delete the current conversation tree from this browser.')) { if (activeRequest) activeRequest.abort(); state = { nodes: [], activeId: null }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); renderAll(); } }); $('#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'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; }); updateKeyStatus(); updateThemeButton(); updateUsageButton(); renderAll(); })();