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  // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17.
  8  const MODEL_PRICES = [
  9    { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },
 10    { match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 },
 11    { match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 },
 12    { match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 },
 13    { match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 },
 14    { match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 },
 15    { match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 },
 16    { match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 },
 17    { match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 },
 18    { match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 },
 19    { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
 20    { match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 }
 21  ];
 22  const DEFAULT_SETTINGS = {
 23    apiKey: '',
 24    model: 'gpt-5-mini',
 25    apiUrl: 'https://api.openai.com/v1/responses',
 26    systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Build on the conversation context and explain assumptions.'
 27  };
 28
 29  const $ = (selector, root = document) => root.querySelector(selector);
 30  const els = {
 31    welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
 32    prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
 33    nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
 34    branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
 35    selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
 36    apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'),
 37    keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),
 38    monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'),
 39    usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),
 40    usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'),
 41    usageRecent: $('#usage-recent'), usageNote: $('#usage-note')
 42  };
 43
 44  let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
 45  let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null }));
 46  let usageRecords = loadUsage();
 47  let activeRequest = null;
 48  let selectedText = null;
 49  let saveTimer = 0;
 50  let toastTimer = 0;
 51
 52  function loadJSON(key, fallback) {
 53    try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; }
 54    catch { return { ...fallback }; }
 55  }
 56
 57  function loadUsage() {
 58    try {
 59      const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');
 60      return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
 61    } catch { return []; }
 62  }
 63
 64  function normalizeState(value) {
 65    const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
 66      if (node.status !== 'loading') return node;
 67      return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
 68    }) : [];
 69    const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
 70    return { nodes, activeId };
 71  }
 72
 73  function persistSoon() {
 74    clearTimeout(saveTimer);
 75    saveTimer = setTimeout(() => {
 76      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
 77      catch { showToast('Browser storage is full — this map could not be saved'); }
 78    }, 180);
 79  }
 80
 81  function nodeById(id) { return state.nodes.find(node => node.id === id); }
 82
 83  function pathTo(id) {
 84    const path = [];
 85    const seen = new Set();
 86    let node = nodeById(id);
 87    while (node && !seen.has(node.id)) {
 88      seen.add(node.id);
 89      path.push(node);
 90      node = nodeById(node.parentId);
 91    }
 92    return path.reverse();
 93  }
 94
 95  function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
 96
 97  function uid() {
 98    return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
 99  }
100
101  function escapeHTML(value = '') {
102    return value.replace(/[&<>'"]/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]);
103  }
104
105  function inlineMarkdown(text) {
106    let output = escapeHTML(text);
107    output = output.replace(/`([^`]+)`/g, '<code>$1</code>');
108    output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
109    output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
110    output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
111    return output;
112  }
113
114  function renderMath(source, displayMode) {
115    if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`;
116    try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
117    catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; }
118  }
119
120  function markdown(source = '') {
121    const stash = [];
122    const token = html => `\u0000${stash.push(html) - 1}\u0000`;
123    let text = source.replace(/\r\n?/g, '\n');
124    text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`));
125    text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
126    text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
127    text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
128    text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false)));
129
130    const lines = text.split('\n');
131    const html = [];
132    let paragraph = [];
133    let list = null;
134    const flushParagraph = () => {
135      if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`);
136      paragraph = [];
137    };
138    const closeList = () => { if (list) html.push(`</${list}>`); list = null; };
139
140    for (const line of lines) {
141      const heading = line.match(/^(#{1,3})\s+(.+)/);
142      const bullet = line.match(/^\s*[-*+]\s+(.+)/);
143      const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
144      const quote = line.match(/^>\s?(.*)/);
145      const blockToken = line.match(/^\u0000(\d+)\u0000$/);
146      if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }
147      else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }
148      else if (bullet || numbered) {
149        flushParagraph();
150        const wanted = bullet ? 'ul' : 'ol';
151        if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; }
152        html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`);
153      } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); }
154      else if (!line.trim()) { flushParagraph(); closeList(); }
155      else paragraph.push(line);
156    }
157    flushParagraph(); closeList();
158    return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);
159  }
160
161  function answerHTML(node) {
162    if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>';
163    if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
164    return markdown(node.answer || '');
165  }
166
167  function renderConversation({ scroll = false } = {}) {
168    const path = pathTo(state.activeId);
169    els.welcome.hidden = path.length > 0;
170    els.conversation.hidden = path.length === 0;
171    const fragment = document.createDocumentFragment();
172    for (const node of path) {
173      const article = document.createElement('article');
174      article.className = 'message';
175      article.dataset.nodeId = node.id;
176      article.innerHTML = `
177        <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
178        <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div>
179        <div class="answer-actions">
180          <button class="mini-action copy-answer" type="button">Copy answer</button>
181          <button class="mini-action branch-here" type="button">Branch here</button>
182          ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
183        </div>`;
184      fragment.append(article);
185    }
186    els.conversation.replaceChildren(fragment);
187    renderBranchContext();
188    if (scroll) requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }));
189  }
190
191  function renderTree() {
192    const ids = new Set(state.nodes.map(node => node.id));
193    const grouped = new Map();
194    for (const node of state.nodes) {
195      const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
196      if (!grouped.has(parentId)) grouped.set(parentId, []);
197      grouped.get(parentId).push(node);
198    }
199    const roots = grouped.get(null) || [];
200    const activePath = new Set(pathTo(state.activeId).map(node => node.id));
201    const build = nodes => {
202      const ul = document.createElement('ul');
203      for (const node of nodes) {
204        const li = document.createElement('li');
205        const button = document.createElement('button');
206        button.type = 'button';
207        button.className = `tree-node${node.id === state.activeId ? ' active' : ''}`;
208        button.dataset.nodeId = node.id;
209        button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
210        const children = grouped.get(node.id) || [];
211        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>`;
212        li.append(button);
213        if (children.length) li.append(build(children));
214        ul.append(li);
215      }
216      return ul;
217    };
218    els.tree.replaceChildren(build(roots));
219    els.mapEmpty.hidden = state.nodes.length > 0;
220    els.tree.hidden = state.nodes.length === 0;
221    els.nodeCount.textContent = String(state.nodes.length);
222    let branchCount = 0;
223    for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
224    els.branchCount.textContent = String(branchCount);
225  }
226
227  function renderBranchContext() {
228    const active = nodeById(state.activeId);
229    const latest = state.nodes.at(-1);
230    const branching = active && latest && active.id !== latest.id;
231    els.branchContext.hidden = !branching;
232    if (branching) els.branchLabel.textContent = active.question;
233  }
234
235  function renderAll(options) { renderConversation(options); renderTree(); }
236
237  function updateStreamedNode(node) {
238    if (state.activeId !== node.id) return;
239    const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
240    if (body) body.innerHTML = answerHTML(node);
241  }
242
243  function inputFor(parentId, question) {
244    const messages = [];
245    for (const node of pathTo(parentId)) {
246      messages.push({ role: 'user', content: node.question });
247      if (node.answer) messages.push({ role: 'assistant', content: node.answer });
248    }
249    messages.push({ role: 'user', content: question });
250    return messages;
251  }
252
253  async function ask(question, parentId = state.activeId) {
254    question = question.trim();
255    if (!question) return;
256    if (activeRequest) { showToast('Stop or finish the current response first'); return; }
257    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; }
258
259    const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() };
260    state.nodes.push(node);
261    state.activeId = node.id;
262    persistSoon();
263    renderAll({ scroll: true });
264    setBusy(true);
265    const controller = new AbortController();
266    activeRequest = controller;
267    let completedUsage = null;
268
269    try {
270      const response = await fetch(settings.apiUrl, {
271        method: 'POST',
272        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` },
273        body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(parentId, question), stream: true }),
274        signal: controller.signal
275      });
276      if (!response.ok) {
277        let message = `API request failed (${response.status})`;
278        try { const data = await response.json(); message = data.error?.message || message; } catch {}
279        throw new Error(message);
280      }
281      if (!response.body) throw new Error('This browser did not provide a streaming response body.');
282      await consumeSSE(response.body, event => {
283        if (event.type === 'response.output_text.delta') node.answer += event.delta || '';
284        if (event.type === 'response.completed' || event.type === 'response.done') {
285          completedUsage = event.response?.usage || event.usage || completedUsage;
286        }
287        if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
288      }, () => updateStreamedNode(node));
289      node.status = 'complete';
290      if (completedUsage) recordUsage(node.id, settings.model, settings.apiUrl, completedUsage);
291    } catch (error) {
292      node.status = 'error';
293      node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
294    } finally {
295      activeRequest = null;
296      persistSoon();
297      setBusy(false);
298      renderAll();
299    }
300  }
301
302  async function consumeSSE(stream, onEvent, onPaint) {
303    const reader = stream.getReader();
304    const decoder = new TextDecoder();
305    let buffer = '';
306    let paintQueued = false;
307    const queuePaint = () => {
308      if (paintQueued) return;
309      paintQueued = true;
310      setTimeout(() => { paintQueued = false; onPaint(); }, 70);
311    };
312    const processBlock = block => {
313      for (const line of block.split(/\r?\n/)) {
314        if (!line.startsWith('data:')) continue;
315        const data = line.slice(5).trim();
316        if (!data || data === '[DONE]') continue;
317        onEvent(JSON.parse(data));
318        queuePaint();
319      }
320    };
321    while (true) {
322      const { done, value } = await reader.read();
323      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
324      const blocks = buffer.split(/\r?\n\r?\n/);
325      buffer = blocks.pop() || '';
326      for (const block of blocks) processBlock(block);
327      if (done) break;
328    }
329    if (buffer.trim()) processBlock(buffer);
330    onPaint();
331  }
332
333  function setBusy(busy) {
334    els.prompt.disabled = busy;
335    els.send.textContent = busy ? '■' : '↑';
336    els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
337  }
338
339  function openSettings() {
340    els.apiKey.value = settings.apiKey || '';
341    els.model.value = settings.model;
342    els.apiUrl.value = settings.apiUrl;
343    els.systemPrompt.value = settings.systemPrompt;
344    els.settings.showModal();
345    setTimeout(() => els.apiKey.focus(), 50);
346  }
347
348  function showToast(message) {
349    els.toast.textContent = message;
350    els.toast.classList.add('show');
351    clearTimeout(toastTimer);
352    toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
353  }
354
355  function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); }
356
357  function updateThemeButton() {
358    const dark = document.documentElement.dataset.theme === 'dark';
359    const button = $('#theme-toggle');
360    button.querySelector('span').textContent = dark ? '☀' : '☾';
361    button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
362    button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
363  }
364
365  function toggleTheme() {
366    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
367    document.documentElement.dataset.theme = next;
368    try { localStorage.setItem('study-pal:theme', next); } catch {}
369    updateThemeButton();
370  }
371
372  function priceForModel(model) {
373    const key = String(model || '').toLowerCase();
374    return MODEL_PRICES.find(price => price.match.test(key)) || null;
375  }
376
377  function isOpenAIEndpoint(apiUrl) {
378    try { return new URL(apiUrl).hostname === 'api.openai.com'; }
379    catch { return false; }
380  }
381
382  function recordUsage(nodeId, model, apiUrl, rawUsage) {
383    if (usageRecords.some(record => record.nodeId === nodeId)) return;
384    const inputTokens = Number(rawUsage.input_tokens) || 0;
385    const outputTokens = Number(rawUsage.output_tokens) || 0;
386    const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0);
387    const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens;
388    const price = isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null;
389    const costUSD = price
390      ? (((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000
391      : null;
392    usageRecords.push({
393      id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'),
394      inputTokens, cachedTokens, outputTokens, totalTokens, costUSD,
395      rates: price ? { input: price.input, cached: price.cached, output: price.output } : null
396    });
397    // Keep bounded history while retaining roughly a year of normal personal use.
398    usageRecords = usageRecords.slice(-2000);
399    try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); }
400    catch { showToast('Usage was returned, but local usage history is full'); }
401    updateUsageButton();
402  }
403
404  function currentMonthRecords() {
405    const now = new Date();
406    return usageRecords.filter(record => {
407      const date = new Date(record.timestamp);
408      return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
409    });
410  }
411
412  function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); }
413
414  function formatTokens(value, compact = false) {
415    const number = Number(value) || 0;
416    if (!compact || number < 1000) return Math.round(number).toLocaleString();
417    if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`;
418    return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`;
419  }
420
421  function formatCost(value) {
422    if (!Number.isFinite(value)) return 'Unpriced';
423    if (value > 0 && value < .01) return '<$0.01';
424    return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 });
425  }
426
427  function updateUsageButton() {
428    const total = sum(currentMonthRecords(), 'totalTokens');
429    els.monthTokens.textContent = formatTokens(total, true);
430    const label = `${formatTokens(total)} tokens tracked this month`;
431    $('#open-usage').title = label;
432    $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`);
433  }
434
435  function usageRowsByModel(records) {
436    const groups = new Map();
437    for (const record of records) {
438      const model = String(record.model || 'unknown');
439      if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 });
440      const group = groups.get(model);
441      group.totalTokens += Number(record.totalTokens) || 0;
442      if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD;
443      else group.unpriced += 1;
444    }
445    return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens);
446  }
447
448  function renderUsage() {
449    const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp);
450    const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0);
451    const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length;
452    els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
453    els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens'));
454    els.usageCost.textContent = records.length && unpriced === records.length
455      ? 'Unpriced'
456      : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`;
457    els.usageRequests.textContent = formatTokens(records.length);
458    els.usageInput.textContent = formatTokens(sum(records, 'inputTokens'));
459    els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens'));
460    els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens'));
461
462    const groups = usageRowsByModel(records);
463    els.usageModels.innerHTML = groups.length ? groups.map(group => `
464      <div class="usage-row">
465        <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span>
466        <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span>
467        <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span>
468      </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>';
469
470    els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `
471      <div class="usage-row">
472        <span class="model-name">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
473        <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span>
474        <span class="row-cost">${formatCost(record.costUSD)}</span>
475      </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>';
476
477    els.usageNote.textContent = unpriced
478      ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because the model or endpoint has no local price entry.`
479      : 'Estimate uses standard OpenAI text-token prices recorded when each request completed. Pricing table checked August 17, 2026.';
480  }
481
482  function openUsage() {
483    renderUsage();
484    els.usageDialog.showModal();
485  }
486
487  function showSelectionMenu(text, nodeId, rect) {
488    selectedText = { text: text.slice(0, 5000), nodeId };
489    els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
490    els.selectionMenu.hidden = false;
491    const width = 330;
492    const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
493    const estimatedHeight = 235;
494    const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
495    Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
496  }
497
498  function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
499
500  els.composer.addEventListener('submit', event => {
501    event.preventDefault();
502    if (activeRequest) { activeRequest.abort(); return; }
503    const question = els.prompt.value;
504    els.prompt.value = '';
505    els.prompt.style.height = '';
506    ask(question);
507  });
508  els.prompt.addEventListener('keydown', event => {
509    if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
510  });
511  els.prompt.addEventListener('input', () => {
512    els.prompt.style.height = 'auto';
513    els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
514  });
515  document.addEventListener('click', event => {
516    const starter = event.target.closest('.starter');
517    if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
518    const treeNode = event.target.closest('.tree-node');
519    if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll(); document.body.classList.remove('map-open'); scrollTo({ top: 0, behavior: 'smooth' }); }
520    const branch = event.target.closest('.branch-here');
521    if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
522    const retry = event.target.closest('.retry-node');
523    if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
524    const copy = event.target.closest('.copy-answer');
525    if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
526  });
527  document.addEventListener('mouseup', event => {
528    if (event.target.closest('.selection-menu')) return;
529    requestAnimationFrame(() => {
530      const selection = getSelection();
531      const text = selection?.toString().trim();
532      if (!text || text.length < 2 || !selection.rangeCount) return;
533      const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
534      if (!answer || !answer.contains(selection.focusNode)) return;
535      const message = answer.closest('.message');
536      showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
537    });
538  });
539
540  $('#ask-selection').addEventListener('click', () => {
541    if (!selectedText) return;
542    const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
543    if (!action) { els.selectionCustom.focus(); return; }
544    const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
545    const parentId = selectedText.nodeId;
546    hideSelectionMenu();
547    getSelection()?.removeAllRanges();
548    ask(question, parentId);
549  });
550  els.selectionAction.addEventListener('change', () => {
551    els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
552    if (!els.selectionCustom.hidden) els.selectionCustom.focus();
553  });
554  $('#close-selection').addEventListener('click', hideSelectionMenu);
555  window.addEventListener('scroll', hideSelectionMenu, { passive: true });
556  $('#open-settings').addEventListener('click', openSettings);
557  $('#open-usage').addEventListener('click', openUsage);
558  $('#theme-toggle').addEventListener('click', toggleTheme);
559  $('#close-settings').addEventListener('click', () => els.settings.close());
560  $('#close-usage').addEventListener('click', () => els.usageDialog.close());
561  $('#reset-usage').addEventListener('click', () => {
562    if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) {
563      usageRecords = [];
564      localStorage.removeItem(USAGE_KEY);
565      updateUsageButton();
566      renderUsage();
567      showToast('Local usage history reset');
568    }
569  });
570  $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; });
571  $('#settings-form').addEventListener('submit', event => {
572    event.preventDefault();
573    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() };
574    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
575    updateKeyStatus(); els.settings.close(); showToast('Settings saved');
576  });
577  $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); });
578  $('#new-session').addEventListener('click', () => {
579    if (!state.nodes.length || confirm('Start a new map? This will delete the current conversation tree from this browser.')) {
580      if (activeRequest) activeRequest.abort();
581      state = { nodes: [], activeId: null }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); renderAll();
582    }
583  });
584  $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); });
585  $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
586  $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; });
587
588  updateKeyStatus();
589  updateThemeButton();
590  updateUsageButton();
591  renderAll();
592})();