arnaucube
arnaucube/study-labPublic Self-hosted repository
study-lab/app.js
app.jsRaw
  1(() => {
  2  'use strict';
  3
  4  const STORAGE_KEY = 'study-pal:v1';
  5  const SETTINGS_KEY = 'study-pal:settings:v1';
  6  const USAGE_KEY = 'study-pal:usage:v1';
  7  const PDF_REQUEST_LIMIT = 50 * 1024 * 1024;
  8  // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17.
  9  const MODEL_PRICES = [
 10    { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },
 11    { match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 },
 12    { match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 },
 13    { match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 },
 14    { match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 },
 15    { match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 },
 16    { match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 },
 17    { match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 },
 18    { match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 },
 19    { match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 },
 20    { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
 21    { match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 }
 22  ];
 23  const DEFAULT_SETTINGS = {
 24    apiKey: '',
 25    model: 'gpt-5-mini',
 26    apiUrl: 'https://api.openai.com/v1/responses',
 27    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.'
 28  };
 29
 30  const $ = (selector, root = document) => root.querySelector(selector);
 31  const els = {
 32    welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
 33    prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
 34    nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
 35    branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
 36    selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
 37    apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'),
 38    keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),
 39    monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'),
 40    usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),
 41    usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'),
 42    usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), historyList: $('#history-list'),
 43    historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'),
 44    attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input')
 45  };
 46
 47  let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
 48  let workspace = loadWorkspace();
 49  let state = currentMap();
 50  let usageRecords = loadUsage();
 51  let activeRequest = null;
 52  let uploadingPDFs = false;
 53  let uploadTargetMapId = null;
 54  let selectedText = null;
 55  let saveTimer = 0;
 56  let toastTimer = 0;
 57
 58  function loadJSON(key, fallback) {
 59    try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; }
 60    catch { return { ...fallback }; }
 61  }
 62
 63  function loadUsage() {
 64    try {
 65      const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');
 66      return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
 67    } catch { return []; }
 68  }
 69
 70  function blankMap() {
 71    const now = Date.now();
 72    return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], activeId: null, createdAt: now, updatedAt: now };
 73  }
 74
 75  function titleFromNodes(nodes) {
 76    const first = nodes.find(node => !node.parentId) || nodes[0];
 77    if (!first) return 'Untitled map';
 78    const title = String(first.question).replace(/\s+/g, ' ').trim();
 79    return title.length > 54 ? `${title.slice(0, 54)}…` : title;
 80  }
 81
 82  function normalizeMap(value = {}) {
 83    const normalized = normalizeState(value);
 84    const attachments = Array.isArray(value.attachments) ? value.attachments
 85      .filter(file => file && file.id && file.name)
 86      .map(file => ({
 87        id: String(file.id), name: String(file.name), size: Number(file.size) || 0,
 88        createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low'
 89      })) : [];
 90    const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now();
 91    return {
 92      id: String(value.id || uid()),
 93      title: String(value.title || titleFromNodes(normalized.nodes)),
 94      titleCustom: Boolean(value.titleCustom),
 95      ...normalized,
 96      attachments,
 97      createdAt,
 98      updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt
 99    };
100  }
101
102  function loadWorkspace() {
103    let raw = null;
104    try { raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch {}
105    let maps = [];
106    let activeMapId = null;
107    if (raw && Array.isArray(raw.maps)) {
108      maps = raw.maps.filter(Boolean).map(normalizeMap);
109      activeMapId = raw.activeMapId;
110    } else if (raw && Array.isArray(raw.nodes) && raw.nodes.length) {
111      // Migrate the original single-map schema in place.
112      const migrated = normalizeMap(raw);
113      maps = [migrated];
114      activeMapId = migrated.id;
115    }
116    if (!maps.length) maps.push(blankMap());
117    if (!maps.some(map => map.id === activeMapId)) activeMapId = maps[0].id;
118    return { version: 2, maps, activeMapId };
119  }
120
121  function currentMap() {
122    return workspace.maps.find(map => map.id === workspace.activeMapId) || workspace.maps[0];
123  }
124
125  function normalizeState(value) {
126    const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
127      if (node.status !== 'loading') return node;
128      return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
129    }) : [];
130    const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
131    return { nodes, activeId };
132  }
133
134  function persistSoon() {
135    clearTimeout(saveTimer);
136    saveTimer = setTimeout(() => {
137      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); }
138      catch { showToast('Browser storage is full — conversations could not be saved'); }
139    }, 180);
140  }
141
142  function persistNow() {
143    clearTimeout(saveTimer);
144    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); return true; }
145    catch { showToast('Browser storage is full — conversations could not be saved'); return false; }
146  }
147
148  function touchMap(map = state) { map.updatedAt = Date.now(); }
149
150  function refreshMapTitle(map = state) {
151    if (!map.titleCustom) map.title = titleFromNodes(map.nodes);
152  }
153
154  function nodeById(id, map = state) { return map.nodes.find(node => node.id === id); }
155
156  function pathTo(id, map = state) {
157    const path = [];
158    const seen = new Set();
159    let node = nodeById(id, map);
160    while (node && !seen.has(node.id)) {
161      seen.add(node.id);
162      path.push(node);
163      node = nodeById(node.parentId, map);
164    }
165    return path.reverse();
166  }
167
168  function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
169
170  function uid() {
171    return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
172  }
173
174  function escapeHTML(value = '') {
175    return value.replace(/[&<>'"]/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]);
176  }
177
178  function inlineMarkdown(text) {
179    let output = escapeHTML(text);
180    output = output.replace(/`([^`]+)`/g, '<code>$1</code>');
181    output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
182    output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
183    output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
184    return output;
185  }
186
187  function renderMath(source, displayMode) {
188    if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`;
189    try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
190    catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; }
191  }
192
193  function markdown(source = '') {
194    const stash = [];
195    const token = html => `\u0000${stash.push(html) - 1}\u0000`;
196    let text = source.replace(/\r\n?/g, '\n');
197    text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`));
198    text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
199    text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
200    text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
201    text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false)));
202
203    const lines = text.split('\n');
204    const html = [];
205    let paragraph = [];
206    let list = null;
207    const flushParagraph = () => {
208      if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`);
209      paragraph = [];
210    };
211    const closeList = () => { if (list) html.push(`</${list}>`); list = null; };
212
213    for (const line of lines) {
214      const heading = line.match(/^(#{1,3})\s+(.+)/);
215      const bullet = line.match(/^\s*[-*+]\s+(.+)/);
216      const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
217      const quote = line.match(/^>\s?(.*)/);
218      const blockToken = line.match(/^\u0000(\d+)\u0000$/);
219      if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }
220      else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }
221      else if (bullet || numbered) {
222        flushParagraph();
223        const wanted = bullet ? 'ul' : 'ol';
224        if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; }
225        html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`);
226      } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); }
227      else if (!line.trim()) { flushParagraph(); closeList(); }
228      else paragraph.push(line);
229    }
230    flushParagraph(); closeList();
231    return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);
232  }
233
234  function answerHTML(node) {
235    if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>';
236    if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
237    return markdown(node.answer || '');
238  }
239
240  function renderConversation({ scroll = false } = {}) {
241    const path = pathTo(state.activeId);
242    els.welcome.hidden = path.length > 0;
243    els.conversation.hidden = path.length === 0;
244    const fragment = document.createDocumentFragment();
245    for (const node of path) {
246      const article = document.createElement('article');
247      article.className = 'message';
248      article.dataset.nodeId = node.id;
249      article.innerHTML = `
250        <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
251        <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div>
252        <div class="answer-actions">
253          <button class="mini-action copy-answer" type="button">Copy answer</button>
254          <button class="mini-action branch-here" type="button">Branch here</button>
255          ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
256        </div>`;
257      fragment.append(article);
258    }
259    els.conversation.replaceChildren(fragment);
260    renderBranchContext();
261    if (scroll && state.activeId) requestAnimationFrame(() => {
262      if (scroll === 'bottom') {
263        window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
264        return;
265      }
266      const answer = $(`.message[data-node-id="${CSS.escape(state.activeId)}"] .answer`, els.conversation);
267      answer?.scrollIntoView({ block: 'start', behavior: 'smooth' });
268    });
269  }
270
271  function renderTree() {
272    const ids = new Set(state.nodes.map(node => node.id));
273    const grouped = new Map();
274    for (const node of state.nodes) {
275      const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
276      if (!grouped.has(parentId)) grouped.set(parentId, []);
277      grouped.get(parentId).push(node);
278    }
279    const roots = grouped.get(null) || [];
280    const activePath = new Set(pathTo(state.activeId).map(node => node.id));
281    const build = nodes => {
282      const ul = document.createElement('ul');
283      for (const node of nodes) {
284        const li = document.createElement('li');
285        const button = document.createElement('button');
286        button.type = 'button';
287        button.className = `tree-node${node.id === state.activeId ? ' active' : ''}`;
288        button.dataset.nodeId = node.id;
289        button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
290        const children = grouped.get(node.id) || [];
291        button.innerHTML = `<span class="node-dot">${node.status === 'loading' ? '…' : (activePath.has(node.id) ? '●' : '○')}</span><span class="node-copy"><span class="node-title">${escapeHTML(node.question)}</span><span class="node-meta">${children.length} ${children.length === 1 ? 'reply' : 'branches'}</span></span>`;
292        li.append(button);
293        if (children.length) li.append(build(children));
294        ul.append(li);
295      }
296      return ul;
297    };
298    els.tree.replaceChildren(build(roots));
299    els.mapEmpty.hidden = state.nodes.length > 0;
300    els.tree.hidden = state.nodes.length === 0;
301    els.nodeCount.textContent = String(state.nodes.length);
302    let branchCount = 0;
303    for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
304    els.branchCount.textContent = String(branchCount);
305  }
306
307  function renderBranchContext() {
308    const active = nodeById(state.activeId);
309    const latest = state.nodes.at(-1);
310    const branching = active && latest && active.id !== latest.id;
311    els.branchContext.hidden = !branching;
312    if (branching) els.branchLabel.textContent = active.question;
313  }
314
315  function renderHistory() {
316    const maps = workspace.maps
317      .filter(map => map.nodes.length > 0 || map.attachments.length > 0)
318      .sort((a, b) => b.updatedAt - a.updatedAt);
319    const fragment = document.createDocumentFragment();
320    for (const map of maps) {
321      const row = document.createElement('div');
322      row.className = 'history-row';
323      const button = document.createElement('button');
324      button.type = 'button';
325      button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`;
326      button.dataset.mapId = map.id;
327      button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false');
328      const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
329      const pdfMeta = map.attachments.length ? ` · ${map.attachments.length} PDF${map.attachments.length === 1 ? '' : 's'}` : '';
330      button.innerHTML = `<span class="history-glyph" aria-hidden="true">${map.nodes.length || map.attachments.length ? '◇' : '+'}</span><span class="history-copy"><span class="history-title">${escapeHTML(map.title)}</span><span class="history-meta">${map.nodes.length} ${map.nodes.length === 1 ? 'node' : 'nodes'}${pdfMeta} · ${date}</span></span>`;
331      const rename = document.createElement('button');
332      rename.type = 'button';
333      rename.className = 'rename-map';
334      rename.dataset.mapId = map.id;
335      rename.setAttribute('aria-label', `Rename conversation: ${map.title}`);
336      rename.title = 'Rename this conversation';
337      rename.textContent = '✎';
338      const remove = document.createElement('button');
339      remove.type = 'button';
340      remove.className = 'delete-map';
341      remove.dataset.mapId = map.id;
342      remove.setAttribute('aria-label', `Delete conversation: ${map.title}`);
343      remove.title = 'Delete this conversation';
344      remove.textContent = '×';
345      row.append(button, rename, remove);
346      fragment.append(row);
347    }
348    els.historyList.replaceChildren(fragment);
349    els.historyEmpty.hidden = maps.length > 0;
350    els.historyList.hidden = maps.length === 0;
351  }
352
353  function formatFileSize(bytes) {
354    if (!bytes) return '';
355    if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
356    return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
357  }
358
359  function renderAttachments() {
360    const fragment = document.createDocumentFragment();
361    for (const file of state.attachments) {
362      const chip = document.createElement('span');
363      chip.className = 'attachment-chip';
364      chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Included with every question in this map`;
365      chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span><button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`;
366      fragment.append(chip);
367    }
368    if (uploadingPDFs && state.id === uploadTargetMapId) {
369      const status = document.createElement('span');
370      status.className = 'attachment-chip upload-status';
371      status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…';
372      fragment.append(status);
373    }
374    els.attachments.replaceChildren(fragment);
375    els.attachments.hidden = !state.attachments.length && !(uploadingPDFs && state.id === uploadTargetMapId);
376    els.uploadPDF.disabled = uploadingPDFs || Boolean(activeRequest);
377  }
378
379  function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); }
380
381  function updateStreamedNode(node) {
382    if (state.activeId !== node.id || !state.nodes.includes(node)) return;
383    const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
384    if (body) body.innerHTML = answerHTML(node);
385  }
386
387  function inputFor(map, parentId, question) {
388    const messages = [];
389    for (const node of pathTo(parentId, map)) {
390      messages.push({ role: 'user', content: node.question });
391      if (node.answer) messages.push({ role: 'assistant', content: node.answer });
392    }
393    const content = map.attachments.map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' }));
394    content.push({ type: 'input_text', text: question });
395    messages.push({ role: 'user', content: map.attachments.length ? content : question });
396    return messages;
397  }
398
399  function filesEndpoint() {
400    const url = new URL(settings.apiUrl);
401    if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.');
402    url.pathname = url.pathname.replace(/\/responses\/?$/, '/files');
403    url.search = '';
404    url.hash = '';
405    return url.toString();
406  }
407
408  async function uploadPDFs(fileList) {
409    const files = [...fileList];
410    if (!files.length) return;
411    if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; }
412    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key before uploading'); return; }
413
414    const valid = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
415    if (valid.length !== files.length) { showToast('Only PDF files can be added'); return; }
416    if (valid.some(file => file.size >= PDF_REQUEST_LIMIT)) { showToast('Each PDF must be smaller than 50 MB'); return; }
417    const existingBytes = state.attachments.reduce((total, file) => total + file.size, 0);
418    const addedBytes = valid.reduce((total, file) => total + file.size, 0);
419    if (existingBytes + addedBytes >= PDF_REQUEST_LIMIT) { showToast('PDFs in one map must total less than 50 MB'); return; }
420
421    const targetMap = state;
422    uploadingPDFs = true;
423    uploadTargetMapId = targetMap.id;
424    renderAttachments();
425    let uploaded = 0;
426    try {
427      const endpoint = filesEndpoint();
428      for (const file of valid) {
429        const form = new FormData();
430        form.append('purpose', 'user_data');
431        form.append('file', file, file.name);
432        const response = await fetch(endpoint, {
433          method: 'POST', headers: { 'Authorization': `Bearer ${settings.apiKey}` }, body: form
434        });
435        if (!response.ok) {
436          let message = `PDF upload failed (${response.status})`;
437          try { const data = await response.json(); message = data.error?.message || message; } catch {}
438          throw new Error(message);
439        }
440        const data = await response.json();
441        if (!data.id) throw new Error('The Files API did not return a file ID.');
442        targetMap.attachments.push({
443          id: String(data.id), name: String(data.filename || file.name),
444          size: Number(data.bytes) || file.size, createdAt: Date.now(), detail: 'low'
445        });
446        uploaded += 1;
447        touchMap(targetMap);
448        persistNow();
449        if (state === targetMap) renderAll();
450      }
451      showToast(`${uploaded} PDF${uploaded === 1 ? '' : 's'} added to this map`);
452    } catch (error) {
453      showToast(error.message || 'PDF upload failed');
454    } finally {
455      uploadingPDFs = false;
456      uploadTargetMapId = null;
457      els.pdfInput.value = '';
458      renderAll();
459    }
460  }
461
462  function removeAttachment(fileId) {
463    const file = state.attachments.find(candidate => candidate.id === fileId);
464    if (!file) return;
465    if (!confirm(`Remove “${file.name}” from this map? The uploaded copy is not deleted from the API provider.`)) return;
466    state.attachments = state.attachments.filter(candidate => candidate.id !== fileId);
467    touchMap();
468    persistNow();
469    renderAll();
470    showToast('PDF removed from this map');
471  }
472
473  async function ask(question, parentId = state.activeId) {
474    question = question.trim();
475    if (!question) return;
476    if (activeRequest) { showToast('Stop or finish the current response first'); return; }
477    if (uploadingPDFs) { showToast('Wait for the PDF upload to finish'); return; }
478    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; }
479
480    const requestMap = state;
481    const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() };
482    requestMap.nodes.push(node);
483    requestMap.activeId = node.id;
484    refreshMapTitle(requestMap);
485    touchMap(requestMap);
486    persistSoon();
487    renderAll({ scroll: true });
488    setBusy(true);
489    const controller = new AbortController();
490    activeRequest = controller;
491    let completedUsage = null;
492
493    try {
494      const response = await fetch(settings.apiUrl, {
495        method: 'POST',
496        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` },
497        body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(requestMap, parentId, question), stream: true }),
498        signal: controller.signal
499      });
500      if (!response.ok) {
501        let message = `API request failed (${response.status})`;
502        try { const data = await response.json(); message = data.error?.message || message; } catch {}
503        throw new Error(message);
504      }
505      if (!response.body) throw new Error('This browser did not provide a streaming response body.');
506      await consumeSSE(response.body, event => {
507        if (event.type === 'response.output_text.delta') node.answer += event.delta || '';
508        if (event.type === 'response.completed' || event.type === 'response.done') {
509          completedUsage = event.response?.usage || event.usage || completedUsage;
510        }
511        if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
512      }, () => updateStreamedNode(node));
513      node.status = 'complete';
514      if (completedUsage) recordUsage(node.id, settings.model, settings.apiUrl, completedUsage);
515    } catch (error) {
516      node.status = 'error';
517      node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
518    } finally {
519      activeRequest = null;
520      touchMap(requestMap);
521      persistSoon();
522      setBusy(false);
523      renderAll();
524    }
525  }
526
527  async function consumeSSE(stream, onEvent, onPaint) {
528    const reader = stream.getReader();
529    const decoder = new TextDecoder();
530    let buffer = '';
531    let paintQueued = false;
532    const queuePaint = () => {
533      if (paintQueued) return;
534      paintQueued = true;
535      setTimeout(() => { paintQueued = false; onPaint(); }, 70);
536    };
537    const processBlock = block => {
538      for (const line of block.split(/\r?\n/)) {
539        if (!line.startsWith('data:')) continue;
540        const data = line.slice(5).trim();
541        if (!data || data === '[DONE]') continue;
542        onEvent(JSON.parse(data));
543        queuePaint();
544      }
545    };
546    while (true) {
547      const { done, value } = await reader.read();
548      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
549      const blocks = buffer.split(/\r?\n\r?\n/);
550      buffer = blocks.pop() || '';
551      for (const block of blocks) processBlock(block);
552      if (done) break;
553    }
554    if (buffer.trim()) processBlock(buffer);
555    onPaint();
556  }
557
558  function setBusy(busy) {
559    els.prompt.disabled = busy;
560    els.send.textContent = busy ? '■' : '↑';
561    els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
562    els.uploadPDF.disabled = busy || uploadingPDFs;
563  }
564
565  function openSettings() {
566    els.apiKey.value = settings.apiKey || '';
567    els.model.value = settings.model;
568    els.apiUrl.value = settings.apiUrl;
569    els.systemPrompt.value = settings.systemPrompt;
570    els.settings.showModal();
571    setTimeout(() => els.apiKey.focus(), 50);
572  }
573
574  function showToast(message) {
575    els.toast.textContent = message;
576    els.toast.classList.add('show');
577    clearTimeout(toastTimer);
578    toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
579  }
580
581  function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); }
582
583  function updateThemeButton() {
584    const dark = document.documentElement.dataset.theme === 'dark';
585    const button = $('#theme-toggle');
586    button.querySelector('span').textContent = dark ? '☀' : '☾';
587    button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
588    button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
589  }
590
591  function toggleTheme() {
592    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
593    document.documentElement.dataset.theme = next;
594    try { localStorage.setItem('study-pal:theme', next); } catch {}
595    updateThemeButton();
596  }
597
598  function priceForModel(model) {
599    const key = String(model || '').toLowerCase();
600    return MODEL_PRICES.find(price => price.match.test(key)) || null;
601  }
602
603  function isOpenAIEndpoint(apiUrl) {
604    try { return new URL(apiUrl).hostname === 'api.openai.com'; }
605    catch { return false; }
606  }
607
608  function recordUsage(nodeId, model, apiUrl, rawUsage) {
609    if (usageRecords.some(record => record.nodeId === nodeId)) return;
610    const inputTokens = Number(rawUsage.input_tokens) || 0;
611    const outputTokens = Number(rawUsage.output_tokens) || 0;
612    const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0);
613    const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens;
614    const price = isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null;
615    const costUSD = price
616      ? (((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000
617      : null;
618    usageRecords.push({
619      id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'),
620      inputTokens, cachedTokens, outputTokens, totalTokens, costUSD,
621      rates: price ? { input: price.input, cached: price.cached, output: price.output } : null
622    });
623    // Keep bounded history while retaining roughly a year of normal personal use.
624    usageRecords = usageRecords.slice(-2000);
625    try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); }
626    catch { showToast('Usage was returned, but local usage history is full'); }
627    updateUsageButton();
628  }
629
630  function currentMonthRecords() {
631    const now = new Date();
632    return usageRecords.filter(record => {
633      const date = new Date(record.timestamp);
634      return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
635    });
636  }
637
638  function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); }
639
640  function formatTokens(value, compact = false) {
641    const number = Number(value) || 0;
642    if (!compact || number < 1000) return Math.round(number).toLocaleString();
643    if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`;
644    return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`;
645  }
646
647  function formatCost(value) {
648    if (!Number.isFinite(value)) return 'Unpriced';
649    if (value > 0 && value < .01) return '<$0.01';
650    return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 });
651  }
652
653  function updateUsageButton() {
654    const total = sum(currentMonthRecords(), 'totalTokens');
655    els.monthTokens.textContent = formatTokens(total, true);
656    const label = `${formatTokens(total)} tokens tracked this month`;
657    $('#open-usage').title = label;
658    $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`);
659  }
660
661  function usageRowsByModel(records) {
662    const groups = new Map();
663    for (const record of records) {
664      const model = String(record.model || 'unknown');
665      if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 });
666      const group = groups.get(model);
667      group.totalTokens += Number(record.totalTokens) || 0;
668      if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD;
669      else group.unpriced += 1;
670    }
671    return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens);
672  }
673
674  function renderUsage() {
675    const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp);
676    const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0);
677    const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length;
678    els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
679    els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens'));
680    els.usageCost.textContent = records.length && unpriced === records.length
681      ? 'Unpriced'
682      : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`;
683    els.usageRequests.textContent = formatTokens(records.length);
684    els.usageInput.textContent = formatTokens(sum(records, 'inputTokens'));
685    els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens'));
686    els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens'));
687
688    const groups = usageRowsByModel(records);
689    els.usageModels.innerHTML = groups.length ? groups.map(group => `
690      <div class="usage-row">
691        <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span>
692        <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span>
693        <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span>
694      </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>';
695
696    els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `
697      <div class="usage-row">
698        <span class="model-name">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
699        <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span>
700        <span class="row-cost">${formatCost(record.costUSD)}</span>
701      </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>';
702
703    els.usageNote.textContent = unpriced
704      ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.`
705      : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.';
706  }
707
708  function openUsage() {
709    renderUsage();
710    els.usageDialog.showModal();
711  }
712
713  function createNewMap() {
714    const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0);
715    const map = existingBlank || blankMap();
716    if (!existingBlank) workspace.maps.push(map);
717    workspace.activeMapId = map.id;
718    state = map;
719    persistNow();
720    renderAll();
721    closeHistoryOnSmallScreen();
722    window.scrollTo({ top: 0, behavior: 'smooth' });
723    els.prompt.focus();
724  }
725
726  function switchMap(mapId) {
727    const map = workspace.maps.find(candidate => candidate.id === mapId);
728    if (!map) { closeHistoryOnSmallScreen(); return; }
729    workspace.activeMapId = map.id;
730    state = map;
731    state.activeId = state.nodes.at(-1)?.id || null;
732    persistSoon();
733    hideSelectionMenu();
734    renderAll({ scroll: state.activeId ? 'bottom' : false });
735    closeHistoryOnSmallScreen();
736  }
737
738  function closeHistoryOnSmallScreen() {
739    if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open');
740    updateHistoryToggle();
741  }
742
743  function updateHistoryToggle() {
744    const small = matchMedia('(max-width: 1100px)').matches;
745    const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed');
746    els.historyToggle.setAttribute('aria-expanded', String(open));
747  }
748
749  function toggleHistory() {
750    if (matchMedia('(max-width: 1100px)').matches) {
751      document.body.classList.remove('history-collapsed');
752      document.body.classList.toggle('history-open');
753      if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open');
754    } else document.body.classList.toggle('history-collapsed');
755    updateHistoryToggle();
756  }
757
758  function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') {
759    if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; }
760    if (!confirm(message)) return false;
761    if (activeRequest) activeRequest.abort();
762    const map = blankMap();
763    workspace = { version: 2, maps: [map], activeMapId: map.id };
764    state = map;
765    persistNow();
766    renderAll();
767    showToast('All conversations cleared');
768    return true;
769  }
770
771  function deleteMap(mapId) {
772    const map = workspace.maps.find(candidate => candidate.id === mapId);
773    if (!map) return;
774    if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; }
775    const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : '';
776    if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return;
777    if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort();
778
779    workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id);
780    if (!workspace.maps.length) workspace.maps.push(blankMap());
781    if (state === map) {
782      const next = [...workspace.maps]
783        .filter(candidate => candidate.nodes.length || candidate.attachments.length)
784        .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0];
785      workspace.activeMapId = next.id;
786      state = next;
787      state.activeId = state.nodes.at(-1)?.id || null;
788    }
789    persistNow();
790    renderAll({ scroll: state.activeId ? 'bottom' : false });
791    showToast('Conversation deleted');
792  }
793
794  function renameMap(mapId) {
795    const map = workspace.maps.find(candidate => candidate.id === mapId);
796    if (!map) return;
797    const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title);
798    if (value === null) return;
799    const title = value.replace(/\s+/g, ' ').trim();
800    if (title) {
801      map.title = title.slice(0, 120);
802      map.titleCustom = true;
803    } else {
804      map.titleCustom = false;
805      refreshMapTitle(map);
806    }
807    touchMap(map);
808    persistNow();
809    renderHistory();
810    showToast(title ? 'Conversation renamed' : 'Automatic title restored');
811  }
812
813  function showSelectionMenu(text, nodeId, rect) {
814    selectedText = { text: text.slice(0, 5000), nodeId };
815    els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
816    els.selectionMenu.hidden = false;
817    const width = 330;
818    const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
819    const estimatedHeight = 235;
820    const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
821    Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
822  }
823
824  function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
825
826  els.composer.addEventListener('submit', event => {
827    event.preventDefault();
828    if (activeRequest) { activeRequest.abort(); return; }
829    const question = els.prompt.value;
830    els.prompt.value = '';
831    els.prompt.style.height = '';
832    ask(question);
833  });
834  els.prompt.addEventListener('keydown', event => {
835    if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
836  });
837  els.prompt.addEventListener('input', () => {
838    els.prompt.style.height = 'auto';
839    els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
840  });
841  document.addEventListener('click', event => {
842    const starter = event.target.closest('.starter');
843    if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
844    const historyItem = event.target.closest('.history-item');
845    if (historyItem) { switchMap(historyItem.dataset.mapId); return; }
846    const renameConversation = event.target.closest('.rename-map');
847    if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; }
848    const deleteConversation = event.target.closest('.delete-map');
849    if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; }
850    const removeFile = event.target.closest('.remove-attachment');
851    if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; }
852    const treeNode = event.target.closest('.tree-node');
853    if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll({ scroll: true }); document.body.classList.remove('map-open'); }
854    const branch = event.target.closest('.branch-here');
855    if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
856    const retry = event.target.closest('.retry-node');
857    if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
858    const copy = event.target.closest('.copy-answer');
859    if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
860  });
861  document.addEventListener('mouseup', event => {
862    if (event.target.closest('.selection-menu')) return;
863    requestAnimationFrame(() => {
864      const selection = getSelection();
865      const text = selection?.toString().trim();
866      if (!text || text.length < 2 || !selection.rangeCount) return;
867      const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
868      if (!answer || !answer.contains(selection.focusNode)) return;
869      const message = answer.closest('.message');
870      showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
871    });
872  });
873
874  $('#ask-selection').addEventListener('click', () => {
875    if (!selectedText) return;
876    const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
877    if (!action) { els.selectionCustom.focus(); return; }
878    const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
879    const parentId = selectedText.nodeId;
880    hideSelectionMenu();
881    getSelection()?.removeAllRanges();
882    ask(question, parentId);
883  });
884  els.selectionAction.addEventListener('change', () => {
885    els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
886    if (!els.selectionCustom.hidden) els.selectionCustom.focus();
887  });
888  $('#close-selection').addEventListener('click', hideSelectionMenu);
889  window.addEventListener('scroll', hideSelectionMenu, { passive: true });
890  $('#open-settings').addEventListener('click', openSettings);
891  $('#open-usage').addEventListener('click', openUsage);
892  $('#theme-toggle').addEventListener('click', toggleTheme);
893  $('#toggle-history').addEventListener('click', toggleHistory);
894  $('#study-pal-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); });
895  $('#new-map-sidebar').addEventListener('click', createNewMap);
896  els.uploadPDF.addEventListener('click', () => els.pdfInput.click());
897  els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files));
898  $('#close-settings').addEventListener('click', () => els.settings.close());
899  $('#close-usage').addEventListener('click', () => els.usageDialog.close());
900  $('#reset-usage').addEventListener('click', () => {
901    if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) {
902      usageRecords = [];
903      localStorage.removeItem(USAGE_KEY);
904      updateUsageButton();
905      renderUsage();
906      showToast('Local usage history reset');
907    }
908  });
909  $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; });
910  $('#settings-form').addEventListener('submit', event => {
911    event.preventDefault();
912    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() };
913    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
914    updateKeyStatus(); els.settings.close(); showToast('Settings saved');
915  });
916  $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); });
917  $('#clear-history').addEventListener('click', () => clearChatHistory());
918  $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); });
919  $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
920  $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); });
921  addEventListener('resize', updateHistoryToggle, { passive: true });
922
923  updateKeyStatus();
924  updateThemeButton();
925  updateUsageButton();
926  updateHistoryToggle();
927  state.activeId = state.nodes.at(-1)?.id || null;
928  renderAll({ scroll: state.activeId ? 'bottom' : false });
929})();