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 DEFAULT_SETTINGS = {
  7    apiKey: '',
  8    model: 'gpt-5-mini',
  9    apiUrl: 'https://api.openai.com/v1/responses',
 10    systemPrompt: 'You are a patient technical tutor. Use clear Markdown and LaTeX where useful. Build on the conversation context and explain assumptions.'
 11  };
 12
 13  const $ = (selector, root = document) => root.querySelector(selector);
 14  const els = {
 15    welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
 16    prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
 17    nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
 18    branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
 19    selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
 20    apiKey: $('#api-key'), model: $('#model'), apiUrl: $('#api-url'), systemPrompt: $('#system-prompt'),
 21    keyStatus: $('#key-status'), toast: $('#toast')
 22  };
 23
 24  let settings = loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS);
 25  let state = normalizeState(loadJSON(STORAGE_KEY, { nodes: [], activeId: null }));
 26  let activeRequest = null;
 27  let selectedText = null;
 28  let saveTimer = 0;
 29  let toastTimer = 0;
 30
 31  function loadJSON(key, fallback) {
 32    try { return { ...fallback, ...JSON.parse(localStorage.getItem(key) || '{}') }; }
 33    catch { return { ...fallback }; }
 34  }
 35
 36  function normalizeState(value) {
 37    const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
 38      if (node.status !== 'loading') return node;
 39      return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
 40    }) : [];
 41    const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
 42    return { nodes, activeId };
 43  }
 44
 45  function persistSoon() {
 46    clearTimeout(saveTimer);
 47    saveTimer = setTimeout(() => {
 48      try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); }
 49      catch { showToast('Browser storage is full — this map could not be saved'); }
 50    }, 180);
 51  }
 52
 53  function nodeById(id) { return state.nodes.find(node => node.id === id); }
 54
 55  function pathTo(id) {
 56    const path = [];
 57    const seen = new Set();
 58    let node = nodeById(id);
 59    while (node && !seen.has(node.id)) {
 60      seen.add(node.id);
 61      path.push(node);
 62      node = nodeById(node.parentId);
 63    }
 64    return path.reverse();
 65  }
 66
 67  function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
 68
 69  function uid() {
 70    return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
 71  }
 72
 73  function escapeHTML(value = '') {
 74    return value.replace(/[&<>'"]/g, char => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[char]);
 75  }
 76
 77  function inlineMarkdown(text) {
 78    let output = escapeHTML(text);
 79    output = output.replace(/`([^`]+)`/g, '<code>$1</code>');
 80    output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
 81    output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
 82    output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
 83    return output;
 84  }
 85
 86  function renderMath(source, displayMode) {
 87    if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`;
 88    try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
 89    catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; }
 90  }
 91
 92  function markdown(source = '') {
 93    const stash = [];
 94    const token = html => `\u0000${stash.push(html) - 1}\u0000`;
 95    let text = source.replace(/\r\n?/g, '\n');
 96    text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`));
 97    text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
 98    text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
 99    text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
100    text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false)));
101
102    const lines = text.split('\n');
103    const html = [];
104    let paragraph = [];
105    let list = null;
106    const flushParagraph = () => {
107      if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`);
108      paragraph = [];
109    };
110    const closeList = () => { if (list) html.push(`</${list}>`); list = null; };
111
112    for (const line of lines) {
113      const heading = line.match(/^(#{1,3})\s+(.+)/);
114      const bullet = line.match(/^\s*[-*+]\s+(.+)/);
115      const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
116      const quote = line.match(/^>\s?(.*)/);
117      const blockToken = line.match(/^\u0000(\d+)\u0000$/);
118      if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }
119      else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }
120      else if (bullet || numbered) {
121        flushParagraph();
122        const wanted = bullet ? 'ul' : 'ol';
123        if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; }
124        html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`);
125      } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); }
126      else if (!line.trim()) { flushParagraph(); closeList(); }
127      else paragraph.push(line);
128    }
129    flushParagraph(); closeList();
130    return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);
131  }
132
133  function answerHTML(node) {
134    if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>';
135    if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
136    return markdown(node.answer || '');
137  }
138
139  function renderConversation({ scroll = false } = {}) {
140    const path = pathTo(state.activeId);
141    els.welcome.hidden = path.length > 0;
142    els.conversation.hidden = path.length === 0;
143    const fragment = document.createDocumentFragment();
144    for (const node of path) {
145      const article = document.createElement('article');
146      article.className = 'message';
147      article.dataset.nodeId = node.id;
148      article.innerHTML = `
149        <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
150        <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-body">${answerHTML(node)}</div></div>
151        <div class="answer-actions">
152          <button class="mini-action copy-answer" type="button">Copy answer</button>
153          <button class="mini-action branch-here" type="button">Branch here</button>
154          ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
155        </div>`;
156      fragment.append(article);
157    }
158    els.conversation.replaceChildren(fragment);
159    renderBranchContext();
160    if (scroll) requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }));
161  }
162
163  function renderTree() {
164    const ids = new Set(state.nodes.map(node => node.id));
165    const grouped = new Map();
166    for (const node of state.nodes) {
167      const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
168      if (!grouped.has(parentId)) grouped.set(parentId, []);
169      grouped.get(parentId).push(node);
170    }
171    const roots = grouped.get(null) || [];
172    const activePath = new Set(pathTo(state.activeId).map(node => node.id));
173    const build = nodes => {
174      const ul = document.createElement('ul');
175      for (const node of nodes) {
176        const li = document.createElement('li');
177        const button = document.createElement('button');
178        button.type = 'button';
179        button.className = `tree-node${node.id === state.activeId ? ' active' : ''}`;
180        button.dataset.nodeId = node.id;
181        button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
182        const children = grouped.get(node.id) || [];
183        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>`;
184        li.append(button);
185        if (children.length) li.append(build(children));
186        ul.append(li);
187      }
188      return ul;
189    };
190    els.tree.replaceChildren(build(roots));
191    els.mapEmpty.hidden = state.nodes.length > 0;
192    els.tree.hidden = state.nodes.length === 0;
193    els.nodeCount.textContent = String(state.nodes.length);
194    let branchCount = 0;
195    for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
196    els.branchCount.textContent = String(branchCount);
197  }
198
199  function renderBranchContext() {
200    const active = nodeById(state.activeId);
201    const latest = state.nodes.at(-1);
202    const branching = active && latest && active.id !== latest.id;
203    els.branchContext.hidden = !branching;
204    if (branching) els.branchLabel.textContent = active.question;
205  }
206
207  function renderAll(options) { renderConversation(options); renderTree(); }
208
209  function updateStreamedNode(node) {
210    if (state.activeId !== node.id) return;
211    const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
212    if (body) body.innerHTML = answerHTML(node);
213  }
214
215  function inputFor(parentId, question) {
216    const messages = [];
217    for (const node of pathTo(parentId)) {
218      messages.push({ role: 'user', content: node.question });
219      if (node.answer) messages.push({ role: 'assistant', content: node.answer });
220    }
221    messages.push({ role: 'user', content: question });
222    return messages;
223  }
224
225  async function ask(question, parentId = state.activeId) {
226    question = question.trim();
227    if (!question) return;
228    if (activeRequest) { showToast('Stop or finish the current response first'); return; }
229    if (!settings.apiKey) { openSettings(); showToast('Add an OpenAI API key to begin'); return; }
230
231    const node = { id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now() };
232    state.nodes.push(node);
233    state.activeId = node.id;
234    persistSoon();
235    renderAll({ scroll: true });
236    setBusy(true);
237    const controller = new AbortController();
238    activeRequest = controller;
239
240    try {
241      const response = await fetch(settings.apiUrl, {
242        method: 'POST',
243        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` },
244        body: JSON.stringify({ model: settings.model, instructions: settings.systemPrompt, input: inputFor(parentId, question), stream: true }),
245        signal: controller.signal
246      });
247      if (!response.ok) {
248        let message = `API request failed (${response.status})`;
249        try { const data = await response.json(); message = data.error?.message || message; } catch {}
250        throw new Error(message);
251      }
252      if (!response.body) throw new Error('This browser did not provide a streaming response body.');
253      await consumeSSE(response.body, event => {
254        if (event.type === 'response.output_text.delta') node.answer += event.delta || '';
255        if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
256      }, () => updateStreamedNode(node));
257      node.status = 'complete';
258    } catch (error) {
259      node.status = 'error';
260      node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
261    } finally {
262      activeRequest = null;
263      persistSoon();
264      setBusy(false);
265      renderAll();
266    }
267  }
268
269  async function consumeSSE(stream, onEvent, onPaint) {
270    const reader = stream.getReader();
271    const decoder = new TextDecoder();
272    let buffer = '';
273    let paintQueued = false;
274    const queuePaint = () => {
275      if (paintQueued) return;
276      paintQueued = true;
277      setTimeout(() => { paintQueued = false; onPaint(); }, 70);
278    };
279    while (true) {
280      const { done, value } = await reader.read();
281      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
282      const blocks = buffer.split(/\r?\n\r?\n/);
283      buffer = blocks.pop() || '';
284      for (const block of blocks) {
285        for (const line of block.split(/\r?\n/)) {
286          if (!line.startsWith('data:')) continue;
287          const data = line.slice(5).trim();
288          if (!data || data === '[DONE]') continue;
289          onEvent(JSON.parse(data));
290          queuePaint();
291        }
292      }
293      if (done) break;
294    }
295    onPaint();
296  }
297
298  function setBusy(busy) {
299    els.prompt.disabled = busy;
300    els.send.textContent = busy ? '■' : '↑';
301    els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
302  }
303
304  function openSettings() {
305    els.apiKey.value = settings.apiKey || '';
306    els.model.value = settings.model;
307    els.apiUrl.value = settings.apiUrl;
308    els.systemPrompt.value = settings.systemPrompt;
309    els.settings.showModal();
310    setTimeout(() => els.apiKey.focus(), 50);
311  }
312
313  function showToast(message) {
314    els.toast.textContent = message;
315    els.toast.classList.add('show');
316    clearTimeout(toastTimer);
317    toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
318  }
319
320  function updateKeyStatus() { els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey)); }
321
322  function updateThemeButton() {
323    const dark = document.documentElement.dataset.theme === 'dark';
324    const button = $('#theme-toggle');
325    button.querySelector('span').textContent = dark ? '☀' : '☾';
326    button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
327    button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
328  }
329
330  function toggleTheme() {
331    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
332    document.documentElement.dataset.theme = next;
333    try { localStorage.setItem('study-pal:theme', next); } catch {}
334    updateThemeButton();
335  }
336
337  function showSelectionMenu(text, nodeId, rect) {
338    selectedText = { text: text.slice(0, 5000), nodeId };
339    els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
340    els.selectionMenu.hidden = false;
341    const width = 330;
342    const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
343    const estimatedHeight = 235;
344    const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
345    Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
346  }
347
348  function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
349
350  els.composer.addEventListener('submit', event => {
351    event.preventDefault();
352    if (activeRequest) { activeRequest.abort(); return; }
353    const question = els.prompt.value;
354    els.prompt.value = '';
355    els.prompt.style.height = '';
356    ask(question);
357  });
358  els.prompt.addEventListener('keydown', event => {
359    if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
360  });
361  els.prompt.addEventListener('input', () => {
362    els.prompt.style.height = 'auto';
363    els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
364  });
365  document.addEventListener('click', event => {
366    const starter = event.target.closest('.starter');
367    if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
368    const treeNode = event.target.closest('.tree-node');
369    if (treeNode) { state.activeId = treeNode.dataset.nodeId; persistSoon(); renderAll(); document.body.classList.remove('map-open'); scrollTo({ top: 0, behavior: 'smooth' }); }
370    const branch = event.target.closest('.branch-here');
371    if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
372    const retry = event.target.closest('.retry-node');
373    if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
374    const copy = event.target.closest('.copy-answer');
375    if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
376  });
377  document.addEventListener('mouseup', event => {
378    if (event.target.closest('.selection-menu')) return;
379    requestAnimationFrame(() => {
380      const selection = getSelection();
381      const text = selection?.toString().trim();
382      if (!text || text.length < 2 || !selection.rangeCount) return;
383      const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
384      if (!answer || !answer.contains(selection.focusNode)) return;
385      const message = answer.closest('.message');
386      showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
387    });
388  });
389
390  $('#ask-selection').addEventListener('click', () => {
391    if (!selectedText) return;
392    const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
393    if (!action) { els.selectionCustom.focus(); return; }
394    const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
395    const parentId = selectedText.nodeId;
396    hideSelectionMenu();
397    getSelection()?.removeAllRanges();
398    ask(question, parentId);
399  });
400  els.selectionAction.addEventListener('change', () => {
401    els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
402    if (!els.selectionCustom.hidden) els.selectionCustom.focus();
403  });
404  $('#close-selection').addEventListener('click', hideSelectionMenu);
405  window.addEventListener('scroll', hideSelectionMenu, { passive: true });
406  $('#open-settings').addEventListener('click', openSettings);
407  $('#theme-toggle').addEventListener('click', toggleTheme);
408  $('#close-settings').addEventListener('click', () => els.settings.close());
409  $('#toggle-key').addEventListener('click', event => { const show = els.apiKey.type === 'password'; els.apiKey.type = show ? 'text' : 'password'; event.target.textContent = show ? 'Hide' : 'Show'; });
410  $('#settings-form').addEventListener('submit', event => {
411    event.preventDefault();
412    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() };
413    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
414    updateKeyStatus(); els.settings.close(); showToast('Settings saved');
415  });
416  $('#forget-key').addEventListener('click', () => { settings.apiKey = ''; els.apiKey.value = ''; localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); updateKeyStatus(); showToast('API key removed'); });
417  $('#new-session').addEventListener('click', () => {
418    if (!state.nodes.length || confirm('Start a new map? This will delete the current conversation tree from this browser.')) {
419      if (activeRequest) activeRequest.abort();
420      state = { nodes: [], activeId: null }; localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); renderAll();
421    }
422  });
423  $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; persistSoon(); renderAll({ scroll: true }); });
424  $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
425  $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; });
426
427  updateKeyStatus();
428  updateThemeButton();
429  renderAll();
430})();