study-lab/app.js
app.jsRaw
1(() => {
2 'use strict';
3
4 const APP_PREFIX = 'study-lab';
5 const STORAGE_KEY = `${APP_PREFIX}:v1`;
6 const SETTINGS_KEY = `${APP_PREFIX}:settings:v1`;
7 const USAGE_KEY = `${APP_PREFIX}:usage:v1`;
8 const OPENROUTER_PRICES_KEY = `${APP_PREFIX}:openrouter-prices:v1`;
9 const TREE_ZOOM_KEY = `${APP_PREFIX}:tree-zoom`;
10 const THEME_KEY = `${APP_PREFIX}:theme`;
11 const PDF_REQUEST_LIMIT = 50 * 1024 * 1024;
12 const PRICE_CACHE_TTL = 24 * 60 * 60 * 1000;
13 const PROVIDERS = {
14 openai: {
15 label: 'OpenAI', apiUrl: 'https://api.openai.com/v1/responses', model: 'gpt-5-mini',
16 models: ['gpt-5-mini', 'gpt-5', 'gpt-4.1-mini', 'gpt-4.1']
17 },
18 openrouter: {
19 label: 'OpenRouter', apiUrl: 'https://openrouter.ai/api/v1/responses', model: 'openai/gpt-5-mini',
20 models: ['openai/gpt-5-mini', 'anthropic/claude-sonnet-4', 'google/gemini-2.5-pro', 'deepseek/deepseek-r1']
21 }
22 };
23 // USD per million tokens. Last checked against OpenAI Docs on 2026-08-17.
24 const MODEL_PRICES = [
25 { match: /^gpt-5\.6-terra(?:-|$)/, input: 2, cached: .2, output: 12 },
26 { match: /^gpt-5\.6-luna(?:-|$)/, input: .2, cached: .02, output: 1.2 },
27 { match: /^gpt-5\.6-sol(?:-|$)/, input: 5, cached: .5, output: 30 },
28 { match: /^gpt-5\.6$/, input: 5, cached: .5, output: 30 },
29 { match: /^gpt-5\.4-mini(?:-|$)/, input: .75, cached: .075, output: 4.5 },
30 { match: /^gpt-5\.4(?:-20\d{2}|$)/, input: 2.5, cached: .25, output: 15 },
31 { match: /^gpt-5-mini(?:-|$)/, input: .25, cached: .025, output: 2 },
32 { match: /^gpt-5-nano(?:-|$)/, input: .05, cached: .005, output: .4 },
33 { match: /^gpt-5(?:-20\d{2}|$)/, input: 1.25, cached: .125, output: 10 },
34 { match: /^gpt-4\.1-mini(?:-|$)/, input: .4, cached: .1, output: 1.6 },
35 { match: /^gpt-4\.1(?:-20\d{2}|$)/, input: 2, cached: .5, output: 8 },
36 { match: /^gpt-4o-mini(?:-|$)/, input: .15, cached: .075, output: .6 }
37 ];
38 const DEFAULT_SETTINGS = {
39 provider: 'openai',
40 apiKey: '',
41 model: 'gpt-5-mini',
42 apiUrl: 'https://api.openai.com/v1/responses',
43 profiles: {
44 openai: { apiKey: '', model: 'gpt-5-mini', apiUrl: 'https://api.openai.com/v1/responses' },
45 openrouter: { apiKey: '', model: 'openai/gpt-5-mini', apiUrl: 'https://openrouter.ai/api/v1/responses' }
46 },
47 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.'
48 };
49
50 const $ = (selector, root = document) => root.querySelector(selector);
51 const els = {
52 welcome: $('#welcome'), conversation: $('#conversation'), composer: $('#composer'),
53 prompt: $('#prompt'), send: $('#send'), tree: $('#tree'), mapEmpty: $('#map-empty'),
54 nodeCount: $('#node-count'), branchCount: $('#branch-count'), branchContext: $('#branch-context'),
55 branchLabel: $('#branch-label'), selectionMenu: $('#selection-menu'), selectionPreview: $('#selection-preview'),
56 selectionAction: $('#selection-action'), selectionCustom: $('#selection-custom'), settings: $('#settings-dialog'),
57 provider: $('#provider'), openaiApiKey: $('#openai-api-key'), openrouterApiKey: $('#openrouter-api-key'),
58 openaiModel: $('#openai-model'), openrouterModel: $('#openrouter-model'),
59 openaiModelOptions: $('#openai-model-options'), openrouterModelOptions: $('#openrouter-model-options'),
60 openaiApiUrl: $('#openai-api-url'), openrouterApiUrl: $('#openrouter-api-url'), systemPrompt: $('#system-prompt'),
61 keyStatus: $('#key-status'), toast: $('#toast'), usageDialog: $('#usage-dialog'),
62 monthTokens: $('#month-tokens'), usageMonth: $('#usage-month'), usageTotal: $('#usage-total'),
63 usageCost: $('#usage-cost'), usageRequests: $('#usage-requests'), usageInput: $('#usage-input'),
64 usageCached: $('#usage-cached'), usageOutput: $('#usage-output'), usageModels: $('#usage-models'),
65 usageRecent: $('#usage-recent'), usageNote: $('#usage-note'), usageDashboard: $('#usage-dashboard'), historyList: $('#history-list'),
66 historyEmpty: $('#history-empty'), historyToggle: $('#toggle-history'),
67 attachments: $('#attachments'), uploadPDF: $('#upload-pdf'), pdfInput: $('#pdf-input'),
68 activeConnection: $('#active-connection'), activeModel: $('#active-model'), activeProvider: $('#active-provider'),
69 conversationSearch: $('#conversation-search'), searchResults: $('#search-results'),
70 treeZoom: $('#tree-zoom'), shortcutsDialog: $('#shortcuts-dialog')
71 };
72
73 migrateLegacyStorage();
74 let settings = normalizeSettings(loadJSON(SETTINGS_KEY, DEFAULT_SETTINGS));
75 let settingsDraftProfiles = null;
76 let workspace = loadWorkspace();
77 let state = currentMap();
78 let usageRecords = loadUsage();
79 let openRouterPrices = loadOpenRouterPrices();
80 let activeRequest = null;
81 let uploadingPDFs = false;
82 let uploadTargetMapId = null;
83 let selectedText = null;
84 let saveTimer = 0;
85 let toastTimer = 0;
86 let searchTimer = 0;
87 let searchIndex = null;
88 let pendingG = false;
89 let pendingGTimer = 0;
90 let treeZoom = loadTreeZoom();
91
92 function loadJSON(key, fallback) {
93 try { return JSON.parse(localStorage.getItem(key) || 'null') || fallback; }
94 catch { return { ...fallback }; }
95 }
96
97 function normalizeSettings(value = {}) {
98 const provider = PROVIDERS[value.provider] ? value.provider : 'openai';
99 const profiles = {};
100 for (const [id, defaults] of Object.entries(PROVIDERS)) {
101 const saved = value.profiles?.[id] || {};
102 profiles[id] = {
103 apiKey: String(saved.apiKey || ''),
104 model: String(saved.model || defaults.model),
105 apiUrl: String(saved.apiUrl || defaults.apiUrl)
106 };
107 }
108 // Settings saved before providers were introduced belong to OpenAI.
109 if (!value.profiles) {
110 profiles.openai = {
111 apiKey: String(value.apiKey || ''),
112 model: String(value.model || PROVIDERS.openai.model),
113 apiUrl: String(value.apiUrl || PROVIDERS.openai.apiUrl)
114 };
115 }
116 const active = profiles[provider];
117 return {
118 provider, profiles, apiKey: active.apiKey, model: active.model, apiUrl: active.apiUrl,
119 systemPrompt: String(value.systemPrompt ?? DEFAULT_SETTINGS.systemPrompt)
120 };
121 }
122
123 function providerLabel(provider = settings.provider) {
124 return PROVIDERS[provider]?.label || 'API provider';
125 }
126
127 function requestHeaders(json = false, connection = settings) {
128 const headers = { 'Authorization': `Bearer ${connection.apiKey}` };
129 if (json) headers['Content-Type'] = 'application/json';
130 if (connection.provider === 'openrouter') headers['X-OpenRouter-Title'] = 'Study Lab';
131 return headers;
132 }
133
134 function migrateLegacyStorage() {
135 const legacyPrefix = ['study', 'pal'].join('-');
136 const suffixes = [':v1', ':settings:v1', ':usage:v1', ':theme', ':tree-zoom'];
137 for (const suffix of suffixes) {
138 const currentKey = `${APP_PREFIX}${suffix}`;
139 const legacyKey = `${legacyPrefix}${suffix}`;
140 try {
141 if (localStorage.getItem(currentKey) === null && localStorage.getItem(legacyKey) !== null) {
142 localStorage.setItem(currentKey, localStorage.getItem(legacyKey));
143 }
144 } catch {}
145 }
146 }
147
148 function loadUsage() {
149 try {
150 const records = JSON.parse(localStorage.getItem(USAGE_KEY) || '[]');
151 return Array.isArray(records) ? records.filter(record => record && Number.isFinite(record.timestamp)) : [];
152 } catch { return []; }
153 }
154
155 function loadOpenRouterPrices() {
156 try {
157 const prices = JSON.parse(localStorage.getItem(OPENROUTER_PRICES_KEY) || '{}');
158 return prices && typeof prices === 'object' && !Array.isArray(prices) ? prices : {};
159 } catch { return {}; }
160 }
161
162 function persistOpenRouterPrices() {
163 const recent = Object.entries(openRouterPrices)
164 .sort((a, b) => (Number(b[1]?.checkedAt) || 0) - (Number(a[1]?.checkedAt) || 0))
165 .slice(0, 40);
166 openRouterPrices = Object.fromEntries(recent);
167 try { localStorage.setItem(OPENROUTER_PRICES_KEY, JSON.stringify(openRouterPrices)); } catch {}
168 }
169
170 function loadTreeZoom() {
171 try { return Math.max(.8, Math.min(1.4, Number(localStorage.getItem(TREE_ZOOM_KEY)) || 1)); }
172 catch { return 1; }
173 }
174
175 function blankMap() {
176 const now = Date.now();
177 return { id: uid(), title: 'Untitled map', titleCustom: false, nodes: [], attachments: [], collapsedIds: [], activeId: null, createdAt: now, updatedAt: now };
178 }
179
180 function titleFromNodes(nodes) {
181 const first = nodes.find(node => !node.parentId) || nodes[0];
182 if (!first) return 'Untitled map';
183 const title = String(first.question).replace(/\s+/g, ' ').trim();
184 return title.length > 54 ? `${title.slice(0, 54)}…` : title;
185 }
186
187 function normalizeMap(value = {}) {
188 const normalized = normalizeState(value);
189 const attachments = Array.isArray(value.attachments) ? value.attachments
190 .filter(file => file && file.id && file.name)
191 .map(file => ({
192 id: String(file.id), name: String(file.name), size: Number(file.size) || 0,
193 createdAt: Number(file.createdAt) || Date.now(), detail: file.detail === 'high' ? 'high' : 'low',
194 provider: PROVIDERS[file.provider] ? file.provider : 'openai'
195 })) : [];
196 const createdAt = Number(value.createdAt) || Number(normalized.nodes[0]?.createdAt) || Date.now();
197 return {
198 id: String(value.id || uid()),
199 title: String(value.title || titleFromNodes(normalized.nodes)),
200 titleCustom: Boolean(value.titleCustom),
201 ...normalized,
202 attachments,
203 collapsedIds: Array.isArray(value.collapsedIds) ? [...new Set(value.collapsedIds.map(String))] : [],
204 createdAt,
205 updatedAt: Number(value.updatedAt) || Number(normalized.nodes.at(-1)?.createdAt) || createdAt
206 };
207 }
208
209 function loadWorkspace() {
210 let raw = null;
211 try { raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch {}
212 let maps = [];
213 let activeMapId = null;
214 if (raw && Array.isArray(raw.maps)) {
215 maps = raw.maps.filter(Boolean).map(normalizeMap);
216 activeMapId = raw.activeMapId;
217 } else if (raw && Array.isArray(raw.nodes) && raw.nodes.length) {
218 // Migrate the original single-map schema in place.
219 const migrated = normalizeMap(raw);
220 maps = [migrated];
221 activeMapId = migrated.id;
222 }
223 if (!maps.length) maps.push(blankMap());
224 if (!maps.some(map => map.id === activeMapId)) activeMapId = maps[0].id;
225 return { version: 2, maps, activeMapId };
226 }
227
228 function currentMap() {
229 return workspace.maps.find(map => map.id === workspace.activeMapId) || workspace.maps[0];
230 }
231
232 function normalizeState(value) {
233 const nodes = Array.isArray(value.nodes) ? value.nodes.filter(n => n && n.id && n.question).map(node => {
234 if (node.status !== 'loading') return node;
235 return { ...node, status: 'error', error: 'The page was closed before this response finished.' };
236 }) : [];
237 const activeId = nodes.some(n => n.id === value.activeId) ? value.activeId : (nodes.at(-1)?.id || null);
238 return { nodes, activeId };
239 }
240
241 function persistSoon() {
242 clearTimeout(saveTimer);
243 saveTimer = setTimeout(() => {
244 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); }
245 catch { showToast('Browser storage is full — conversations could not be saved'); }
246 }, 180);
247 }
248
249 function persistNow() {
250 clearTimeout(saveTimer);
251 try { localStorage.setItem(STORAGE_KEY, JSON.stringify(workspace)); return true; }
252 catch { showToast('Browser storage is full — conversations could not be saved'); return false; }
253 }
254
255 function touchMap(map = state) { map.updatedAt = Date.now(); searchIndex = null; }
256
257 function refreshMapTitle(map = state) {
258 if (!map.titleCustom) map.title = titleFromNodes(map.nodes);
259 }
260
261 function nodeById(id, map = state) { return map.nodes.find(node => node.id === id); }
262
263 function pathTo(id, map = state) {
264 const path = [];
265 const seen = new Set();
266 let node = nodeById(id, map);
267 while (node && !seen.has(node.id)) {
268 seen.add(node.id);
269 path.push(node);
270 node = nodeById(node.parentId, map);
271 }
272 return path.reverse();
273 }
274
275 function childrenOf(id) { return state.nodes.filter(node => node.parentId === id); }
276
277 function uid() {
278 return globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
279 }
280
281 function escapeHTML(value = '') {
282 return value.replace(/[&<>'"]/g, char => ({ '&':'&', '<':'<', '>':'>', "'":''', '"':'"' })[char]);
283 }
284
285 function inlineMarkdown(text) {
286 let output = escapeHTML(text);
287 output = output.replace(/`([^`]+)`/g, '<code>$1</code>');
288 output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
289 output = output.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, '<em>$1</em>');
290 output = output.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
291 return output;
292 }
293
294 function renderMath(source, displayMode) {
295 if (!globalThis.katex) return `<code class="math-fallback">${escapeHTML(source)}</code>`;
296 try { return katex.renderToString(source, { displayMode, throwOnError: false, strict: false, trust: false }); }
297 catch { return `<code class="math-fallback">${escapeHTML(source)}</code>`; }
298 }
299
300 function markdown(source = '') {
301 const stash = [];
302 const token = html => `\u0000${stash.push(html) - 1}\u0000`;
303 let text = source.replace(/\r\n?/g, '\n');
304 text = text.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (_, lang, code) => token(`<pre data-language="${escapeHTML(lang)}"><code>${escapeHTML(code.trimEnd())}</code></pre>`));
305 text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, formula) => token(renderMath(formula.trim(), true)));
306 text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_, formula) => token(renderMath(formula.trim(), true)));
307 text = text.replace(/\\\((.*?)\\\)/g, (_, formula) => token(renderMath(formula.trim(), false)));
308 text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_, formula) => token(renderMath(formula.trim(), false)));
309
310 const lines = text.split('\n');
311 const html = [];
312 let paragraph = [];
313 let list = null;
314 const flushParagraph = () => {
315 if (paragraph.length) html.push(`<p>${inlineMarkdown(paragraph.join('\n')).replace(/\n/g, '<br>')}</p>`);
316 paragraph = [];
317 };
318 const closeList = () => { if (list) html.push(`</${list}>`); list = null; };
319
320 for (const line of lines) {
321 const heading = line.match(/^(#{1,3})\s+(.+)/);
322 const bullet = line.match(/^\s*[-*+]\s+(.+)/);
323 const numbered = line.match(/^\s*\d+[.)]\s+(.+)/);
324 const quote = line.match(/^>\s?(.*)/);
325 const blockToken = line.match(/^\u0000(\d+)\u0000$/);
326 if (blockToken) { flushParagraph(); closeList(); html.push(stash[Number(blockToken[1])]); }
327 else if (heading) { flushParagraph(); closeList(); const level = heading[1].length; html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`); }
328 else if (bullet || numbered) {
329 flushParagraph();
330 const wanted = bullet ? 'ul' : 'ol';
331 if (list !== wanted) { closeList(); html.push(`<${wanted}>`); list = wanted; }
332 html.push(`<li>${inlineMarkdown((bullet || numbered)[1])}</li>`);
333 } else if (quote) { flushParagraph(); closeList(); html.push(`<blockquote>${inlineMarkdown(quote[1])}</blockquote>`); }
334 else if (!line.trim()) { flushParagraph(); closeList(); }
335 else paragraph.push(line);
336 }
337 flushParagraph(); closeList();
338 return html.join('').replace(/\u0000(\d+)\u0000/g, (_, index) => stash[Number(index)]);
339 }
340
341 function answerHTML(node) {
342 if (node.status === 'loading' && !node.answer) return '<span class="thinking" aria-label="Thinking"><i></i><i></i><i></i></span>';
343 if (node.status === 'error') return `<p class="error-text">${escapeHTML(node.error || 'The request failed.')}</p>`;
344 return markdown(node.answer || '');
345 }
346
347 function answerAttribution(node) {
348 if (!node.model || !node.provider || !node.answer) return '';
349 return `<div class="answer-attribution" title="Generated by ${escapeHTML(node.model)} through ${escapeHTML(providerLabel(node.provider))}"><span>${escapeHTML(node.model)}</span><small>${escapeHTML(providerLabel(node.provider))}</small></div>`;
350 }
351
352 function renderConversation({ scroll = false } = {}) {
353 const previousScroll = scroll ? null : { left: window.scrollX, top: window.scrollY };
354 const path = pathTo(state.activeId);
355 els.welcome.hidden = path.length > 0;
356 els.conversation.hidden = path.length === 0;
357 const fragment = document.createDocumentFragment();
358 for (const node of path) {
359 const article = document.createElement('article');
360 article.className = 'message';
361 article.dataset.nodeId = node.id;
362 article.innerHTML = `
363 <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
364 <div class="answer"><span class="answer-mark" aria-hidden="true">S</span><div class="answer-content"><div class="answer-body">${answerHTML(node)}</div>${answerAttribution(node)}</div></div>
365 <div class="answer-actions">
366 <button class="mini-action copy-answer" type="button">Copy answer</button>
367 <button class="mini-action branch-here" type="button">Branch here</button>
368 ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
369 </div>`;
370 fragment.append(article);
371 }
372 els.conversation.replaceChildren(fragment);
373 if (previousScroll && (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top)) {
374 window.scrollTo({ ...previousScroll, behavior: 'auto' });
375 }
376 renderBranchContext();
377 if (scroll && state.activeId) requestAnimationFrame(() => {
378 if (scroll === 'bottom') {
379 window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
380 return;
381 }
382 const targetId = state.activeId;
383 requestAnimationFrame(() => {
384 if (state.activeId !== targetId) return;
385 const message = $(`.message[data-node-id="${CSS.escape(targetId)}"]`, els.conversation);
386 if (!message) return;
387 const headerHeight = $('.topbar')?.getBoundingClientRect().height || 68;
388 const top = window.scrollY + message.getBoundingClientRect().top - headerHeight - 14;
389 window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
390 });
391 });
392 }
393
394 function renderTree() {
395 const ids = new Set(state.nodes.map(node => node.id));
396 const grouped = new Map();
397 for (const node of state.nodes) {
398 const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
399 if (!grouped.has(parentId)) grouped.set(parentId, []);
400 grouped.get(parentId).push(node);
401 }
402 const roots = grouped.get(null) || [];
403 const activePath = new Set(pathTo(state.activeId).map(node => node.id));
404 const collapsed = new Set(state.collapsedIds);
405 const build = nodes => {
406 const ul = document.createElement('ul');
407 for (const node of nodes) {
408 const li = document.createElement('li');
409 li.dataset.nodeId = node.id;
410 const wrap = document.createElement('div');
411 wrap.className = 'tree-node-wrap';
412 const button = document.createElement('button');
413 button.type = 'button';
414 button.className = `tree-node${node.id === state.activeId ? ' active' : ''}${activePath.has(node.id) ? ' on-path' : ''}`;
415 button.dataset.nodeId = node.id;
416 button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
417 const children = grouped.get(node.id) || [];
418 const isCollapsed = children.length > 0 && collapsed.has(node.id);
419 const toggle = document.createElement(children.length ? 'button' : 'span');
420 toggle.className = children.length ? 'toggle-subtree' : 'toggle-placeholder';
421 if (children.length) {
422 toggle.type = 'button';
423 toggle.dataset.nodeId = node.id;
424 toggle.setAttribute('aria-label', `${isCollapsed ? 'Expand' : 'Collapse'} branch: ${node.question}`);
425 toggle.setAttribute('aria-expanded', String(!isCollapsed));
426 toggle.textContent = isCollapsed ? '▸' : '▾';
427 }
428 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'}${isCollapsed ? ' · hidden' : ''}</span></span>`;
429 wrap.append(toggle, button);
430 li.append(wrap);
431 if (children.length && !isCollapsed) li.append(build(children));
432 ul.append(li);
433 }
434 return ul;
435 };
436 els.tree.replaceChildren(build(roots));
437 els.mapEmpty.hidden = state.nodes.length > 0;
438 els.tree.hidden = state.nodes.length === 0;
439 els.nodeCount.textContent = String(state.nodes.length);
440 let branchCount = 0;
441 for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
442 els.branchCount.textContent = String(branchCount);
443 }
444
445 function revealNode(map, nodeId) {
446 if (!nodeId) return;
447 const hidden = new Set(map.collapsedIds);
448 for (const node of pathTo(nodeId, map).slice(0, -1)) hidden.delete(node.id);
449 map.collapsedIds = [...hidden];
450 }
451
452 function toggleSubtree(nodeId, force) {
453 if (!nodeById(nodeId)) return;
454 const collapsed = new Set(state.collapsedIds);
455 const shouldCollapse = force ?? !collapsed.has(nodeId);
456 if (shouldCollapse) collapsed.add(nodeId);
457 else collapsed.delete(nodeId);
458 state.collapsedIds = [...collapsed];
459 persistSoon();
460 renderTree();
461 }
462
463 function setAllSubtrees(collapsed) {
464 if (!collapsed) state.collapsedIds = [];
465 else {
466 const parents = new Set(state.nodes.map(node => node.parentId).filter(Boolean));
467 state.collapsedIds = [...parents];
468 }
469 persistSoon();
470 renderTree();
471 }
472
473 function updateTreeZoom(delta = 0) {
474 treeZoom = Math.round(Math.max(.8, Math.min(1.4, treeZoom + delta)) * 10) / 10;
475 els.tree.style.fontSize = `${.76 * treeZoom}rem`;
476 els.tree.style.setProperty('--tree-indent', `${20 * treeZoom}px`);
477 els.tree.style.setProperty('--tree-dot-size', `${17 * treeZoom}px`);
478 els.tree.style.setProperty('--tree-node-pad-y', `${8 * treeZoom}px`);
479 els.treeZoom.textContent = `${Math.round(treeZoom * 100)}%`;
480 try { localStorage.setItem(TREE_ZOOM_KEY, String(treeZoom)); } catch {}
481 }
482
483 function renderBranchContext() {
484 const active = nodeById(state.activeId);
485 const latest = state.nodes.at(-1);
486 const branching = active && latest && active.id !== latest.id;
487 els.branchContext.hidden = !branching;
488 if (branching) els.branchLabel.textContent = active.question;
489 }
490
491 function renderHistory() {
492 const maps = workspace.maps
493 .filter(map => map.nodes.length > 0 || map.attachments.length > 0)
494 .sort((a, b) => b.updatedAt - a.updatedAt);
495 const fragment = document.createDocumentFragment();
496 for (const map of maps) {
497 const row = document.createElement('div');
498 row.className = 'history-row';
499 const button = document.createElement('button');
500 button.type = 'button';
501 button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`;
502 button.dataset.mapId = map.id;
503 button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false');
504 const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
505 const pdfMeta = map.attachments.length ? ` · ${map.attachments.length} PDF${map.attachments.length === 1 ? '' : 's'}` : '';
506 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>`;
507 const rename = document.createElement('button');
508 rename.type = 'button';
509 rename.className = 'rename-map';
510 rename.dataset.mapId = map.id;
511 rename.setAttribute('aria-label', `Rename conversation: ${map.title}`);
512 rename.title = 'Rename this conversation';
513 rename.textContent = '✎';
514 const remove = document.createElement('button');
515 remove.type = 'button';
516 remove.className = 'delete-map';
517 remove.dataset.mapId = map.id;
518 remove.setAttribute('aria-label', `Delete conversation: ${map.title}`);
519 remove.title = 'Delete this conversation';
520 remove.textContent = '×';
521 row.append(button, rename, remove);
522 fragment.append(row);
523 }
524 els.historyList.replaceChildren(fragment);
525 els.historyEmpty.hidden = maps.length > 0;
526 els.historyList.hidden = maps.length === 0;
527 if (els.conversationSearch.value.trim()) renderSearchResults();
528 else els.searchResults.hidden = true;
529 }
530
531 function buildSearchIndex() {
532 const entries = [];
533 for (const map of workspace.maps) {
534 if (!map.nodes.length && !map.attachments.length) continue;
535 entries.push({ mapId: map.id, nodeId: null, kind: 'Map', label: map.title, fields: [map.title] });
536 for (const node of map.nodes) {
537 entries.push({
538 mapId: map.id, nodeId: node.id, kind: 'Node', label: node.question,
539 fields: [node.question, node.answer || ''], mapTitle: map.title
540 });
541 }
542 for (const file of map.attachments) {
543 entries.push({ mapId: map.id, nodeId: null, kind: 'PDF', label: file.name, fields: [file.name], mapTitle: map.title });
544 }
545 }
546 searchIndex = entries;
547 return entries;
548 }
549
550 function searchExcerpt(fields, expression) {
551 for (const field of fields) {
552 const source = String(field || '').replace(/\s+/g, ' ').trim();
553 const index = source.search(expression);
554 if (index < 0) continue;
555 const start = Math.max(0, index - 45);
556 const end = Math.min(source.length, index + 115);
557 return `${start ? '…' : ''}${source.slice(start, end)}${end < source.length ? '…' : ''}`;
558 }
559 return '';
560 }
561
562 function renderSearchResults() {
563 const query = els.conversationSearch.value.trim();
564 if (!query) {
565 els.searchResults.hidden = true;
566 els.historyList.hidden = false;
567 els.historyEmpty.hidden = workspace.maps.some(map => map.nodes.length || map.attachments.length);
568 return;
569 }
570 const expression = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
571 const matches = [];
572 for (const entry of searchIndex || buildSearchIndex()) {
573 const excerpt = searchExcerpt(entry.fields, expression);
574 if (!excerpt) continue;
575 matches.push({ ...entry, excerpt });
576 if (matches.length >= 60) break;
577 }
578 els.searchResults.innerHTML = matches.length ? matches.map(entry => `
579 <button class="search-result" type="button" data-map-id="${escapeHTML(entry.mapId)}"${entry.nodeId ? ` data-node-id="${escapeHTML(entry.nodeId)}"` : ''}>
580 <span class="search-result-top"><strong>${escapeHTML(entry.label)}</strong><small>${escapeHTML(entry.kind)} · ${escapeHTML(entry.mapTitle || entry.label)}</small></span>
581 <span class="search-excerpt">${escapeHTML(entry.excerpt)}</span>
582 </button>`).join('') : `<p class="search-empty">No results for “${escapeHTML(query)}”.</p>`;
583 els.searchResults.hidden = false;
584 els.historyList.hidden = true;
585 els.historyEmpty.hidden = true;
586 }
587
588 function clearSearch() {
589 clearTimeout(searchTimer);
590 els.conversationSearch.value = '';
591 renderSearchResults();
592 renderHistory();
593 }
594
595 function formatFileSize(bytes) {
596 if (!bytes) return '';
597 if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
598 return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
599 }
600
601 function renderAttachments() {
602 const fragment = document.createDocumentFragment();
603 for (const file of state.attachments) {
604 const chip = document.createElement('span');
605 chip.className = 'attachment-chip';
606 const available = file.provider === settings.provider;
607 chip.classList.toggle('inactive-provider', !available);
608 chip.title = `${file.name}${file.size ? ` · ${formatFileSize(file.size)}` : ''} · Uploaded to ${providerLabel(file.provider)}${available ? ' · Included with every question' : ' · Not sent with the selected provider'}`;
609 chip.innerHTML = `<span class="pdf-badge" aria-hidden="true">PDF</span><span class="attachment-name">${escapeHTML(file.name)}</span>${available ? '' : `<span class="attachment-provider">${escapeHTML(providerLabel(file.provider))}</span>`}<button class="remove-attachment" type="button" data-file-id="${escapeHTML(file.id)}" aria-label="Remove ${escapeHTML(file.name)} from this map">×</button>`;
610 fragment.append(chip);
611 }
612 if (uploadingPDFs && state.id === uploadTargetMapId) {
613 const status = document.createElement('span');
614 status.className = 'attachment-chip upload-status';
615 status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…';
616 fragment.append(status);
617 }
618 els.attachments.replaceChildren(fragment);
619 els.attachments.hidden = !state.attachments.length && !(uploadingPDFs && state.id === uploadTargetMapId);
620 els.uploadPDF.disabled = uploadingPDFs || Boolean(activeRequest);
621 }
622
623 function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); }
624
625 function updateStreamedNode(node) {
626 if (state.activeId !== node.id || !state.nodes.includes(node)) return;
627 const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
628 if (body) {
629 const previousScroll = { left: window.scrollX, top: window.scrollY };
630 body.innerHTML = answerHTML(node);
631 if (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top) {
632 window.scrollTo({ ...previousScroll, behavior: 'auto' });
633 }
634 }
635 }
636
637 function inputFor(map, parentId, question, provider = settings.provider) {
638 const messages = [];
639 for (const node of pathTo(parentId, map)) {
640 if (provider === 'openrouter') {
641 messages.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: node.question }] });
642 if (node.answer) messages.push({
643 type: 'message', role: 'assistant', id: `msg_${String(node.id).replace(/[^a-zA-Z0-9_-]/g, '')}`,
644 status: 'completed', content: [{ type: 'output_text', text: node.answer, annotations: [] }]
645 });
646 } else {
647 messages.push({ role: 'user', content: node.question });
648 if (node.answer) messages.push({ role: 'assistant', content: node.answer });
649 }
650 }
651 const content = map.attachments
652 .filter(file => file.provider === provider)
653 .map(file => ({ type: 'input_file', file_id: file.id, detail: file.detail || 'low' }));
654 content.push({ type: 'input_text', text: question });
655 const structured = provider === 'openrouter' || content.length > 1;
656 messages.push(structured ? { type: 'message', role: 'user', content } : { role: 'user', content: question });
657 return messages;
658 }
659
660 function filesEndpoint(apiUrl = settings.apiUrl) {
661 const url = new URL(apiUrl);
662 if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.');
663 url.pathname = url.pathname.replace(/\/responses\/?$/, '/files');
664 url.search = '';
665 url.hash = '';
666 return url.toString();
667 }
668
669 async function uploadPDFs(fileList) {
670 const files = [...fileList];
671 if (!files.length) return;
672 if (activeRequest || uploadingPDFs) { showToast('Finish the current operation first'); return; }
673 if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key before uploading`); return; }
674
675 const valid = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
676 if (valid.length !== files.length) { showToast('Only PDF files can be added'); return; }
677 if (valid.some(file => file.size >= PDF_REQUEST_LIMIT)) { showToast('Each PDF must be smaller than 50 MB'); return; }
678 const existingBytes = state.attachments.filter(file => file.provider === settings.provider).reduce((total, file) => total + file.size, 0);
679 const addedBytes = valid.reduce((total, file) => total + file.size, 0);
680 if (existingBytes + addedBytes >= PDF_REQUEST_LIMIT) { showToast('PDFs in one map must total less than 50 MB'); return; }
681
682 const targetMap = state;
683 const connection = { ...settings };
684 uploadingPDFs = true;
685 uploadTargetMapId = targetMap.id;
686 renderAttachments();
687 let uploaded = 0;
688 try {
689 const endpoint = filesEndpoint(connection.apiUrl);
690 for (const file of valid) {
691 const form = new FormData();
692 if (connection.provider === 'openai') form.append('purpose', 'user_data');
693 form.append('file', file, file.name);
694 const response = await fetch(endpoint, {
695 method: 'POST', headers: requestHeaders(false, connection), body: form
696 });
697 if (!response.ok) {
698 let message = `PDF upload failed (${response.status})`;
699 try { const data = await response.json(); message = data.error?.message || message; } catch {}
700 throw new Error(message);
701 }
702 const data = await response.json();
703 if (!data.id) throw new Error('The Files API did not return a file ID.');
704 targetMap.attachments.push({
705 id: String(data.id), name: String(data.filename || file.name),
706 size: Number(data.bytes ?? data.size_bytes) || file.size, createdAt: Date.now(), detail: 'low',
707 provider: connection.provider
708 });
709 uploaded += 1;
710 touchMap(targetMap);
711 persistNow();
712 if (state === targetMap) renderAll();
713 }
714 showToast(`${uploaded} PDF${uploaded === 1 ? '' : 's'} added to this map`);
715 } catch (error) {
716 showToast(error.message || 'PDF upload failed');
717 } finally {
718 uploadingPDFs = false;
719 uploadTargetMapId = null;
720 els.pdfInput.value = '';
721 renderAll();
722 }
723 }
724
725 function removeAttachment(fileId) {
726 const file = state.attachments.find(candidate => candidate.id === fileId);
727 if (!file) return;
728 if (!confirm(`Remove “${file.name}” from this map? The uploaded copy is not deleted from the API provider.`)) return;
729 state.attachments = state.attachments.filter(candidate => candidate.id !== fileId);
730 touchMap();
731 persistNow();
732 renderAll();
733 showToast('PDF removed from this map');
734 }
735
736 async function ask(question, parentId = state.activeId) {
737 question = question.trim();
738 if (!question) return;
739 if (activeRequest) { showToast('Stop or finish the current response first'); return; }
740 if (uploadingPDFs) { showToast('Wait for the PDF upload to finish'); return; }
741 if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key to begin`); return; }
742
743 const requestMap = state;
744 const connection = { ...settings };
745 const node = {
746 id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now(),
747 provider: connection.provider, model: connection.model
748 };
749 requestMap.nodes.push(node);
750 requestMap.activeId = node.id;
751 revealNode(requestMap, node.id);
752 refreshMapTitle(requestMap);
753 touchMap(requestMap);
754 persistSoon();
755 renderAll({ scroll: true });
756 setBusy(true);
757 const controller = new AbortController();
758 activeRequest = controller;
759 let completedUsage = null;
760
761 try {
762 const response = await fetch(connection.apiUrl, {
763 method: 'POST',
764 headers: requestHeaders(true, connection),
765 body: JSON.stringify({ model: connection.model, instructions: connection.systemPrompt, input: inputFor(requestMap, parentId, question, connection.provider), stream: true }),
766 signal: controller.signal
767 });
768 if (!response.ok) {
769 let message = `API request failed (${response.status})`;
770 try { const data = await response.json(); message = data.error?.message || message; } catch {}
771 throw new Error(message);
772 }
773 if (!response.body) throw new Error('This browser did not provide a streaming response body.');
774 await consumeSSE(response.body, event => {
775 if (event.type === 'response.output_text.delta' || event.type === 'response.content_part.delta') node.answer += event.delta || '';
776 if (event.type === 'response.completed' || event.type === 'response.done') {
777 node.model = String(event.response?.model || node.model);
778 completedUsage = event.response?.usage || event.usage || completedUsage;
779 }
780 if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
781 }, () => updateStreamedNode(node));
782 node.status = 'complete';
783 if (completedUsage) await recordUsage(node.id, node.model || connection.model, connection.apiUrl, completedUsage, connection.provider);
784 } catch (error) {
785 node.status = 'error';
786 node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
787 } finally {
788 activeRequest = null;
789 touchMap(requestMap);
790 persistSoon();
791 setBusy(false);
792 renderAll();
793 }
794 }
795
796 async function consumeSSE(stream, onEvent, onPaint) {
797 const reader = stream.getReader();
798 const decoder = new TextDecoder();
799 let buffer = '';
800 let paintQueued = false;
801 const queuePaint = () => {
802 if (paintQueued) return;
803 paintQueued = true;
804 setTimeout(() => { paintQueued = false; onPaint(); }, 70);
805 };
806 const processBlock = block => {
807 for (const line of block.split(/\r?\n/)) {
808 if (!line.startsWith('data:')) continue;
809 const data = line.slice(5).trim();
810 if (!data || data === '[DONE]') continue;
811 onEvent(JSON.parse(data));
812 queuePaint();
813 }
814 };
815 while (true) {
816 const { done, value } = await reader.read();
817 buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
818 const blocks = buffer.split(/\r?\n\r?\n/);
819 buffer = blocks.pop() || '';
820 for (const block of blocks) processBlock(block);
821 if (done) break;
822 }
823 if (buffer.trim()) processBlock(buffer);
824 onPaint();
825 }
826
827 function setBusy(busy) {
828 els.prompt.disabled = busy;
829 els.send.textContent = busy ? '■' : '↑';
830 els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
831 els.uploadPDF.disabled = busy || uploadingPDFs;
832 }
833
834 function openSettings() {
835 settingsDraftProfiles = structuredClone(settings.profiles);
836 els.provider.value = settings.provider;
837 els.openaiApiKey.value = settingsDraftProfiles.openai.apiKey || '';
838 els.openrouterApiKey.value = settingsDraftProfiles.openrouter.apiKey || '';
839 els.openaiModel.value = settingsDraftProfiles.openai.model || PROVIDERS.openai.model;
840 els.openrouterModel.value = settingsDraftProfiles.openrouter.model || PROVIDERS.openrouter.model;
841 els.openaiApiUrl.value = settingsDraftProfiles.openai.apiUrl || PROVIDERS.openai.apiUrl;
842 els.openrouterApiUrl.value = settingsDraftProfiles.openrouter.apiUrl || PROVIDERS.openrouter.apiUrl;
843 fillModelOptions(els.openaiModelOptions, PROVIDERS.openai.models);
844 fillModelOptions(els.openrouterModelOptions, PROVIDERS.openrouter.models);
845 updateProviderCards();
846 els.systemPrompt.value = settings.systemPrompt;
847 els.settings.showModal();
848 setTimeout(() => (settings.provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey).focus(), 50);
849 }
850
851 function fillModelOptions(list, models) {
852 list.replaceChildren(...models.map(model => Object.assign(document.createElement('option'), { value: model })));
853 }
854
855 function captureProviderForms() {
856 if (!settingsDraftProfiles) return;
857 settingsDraftProfiles = {
858 openai: {
859 apiKey: els.openaiApiKey.value.trim(),
860 model: els.openaiModel.value.trim() || PROVIDERS.openai.model,
861 apiUrl: els.openaiApiUrl.value.trim() || PROVIDERS.openai.apiUrl
862 },
863 openrouter: {
864 apiKey: els.openrouterApiKey.value.trim(),
865 model: els.openrouterModel.value.trim() || PROVIDERS.openrouter.model,
866 apiUrl: els.openrouterApiUrl.value.trim() || PROVIDERS.openrouter.apiUrl
867 }
868 };
869 }
870
871 function updateProviderCards() {
872 document.querySelectorAll('[data-provider-config]').forEach(card => {
873 card.classList.toggle('active', card.dataset.providerConfig === els.provider.value);
874 });
875 }
876
877 function showToast(message) {
878 els.toast.textContent = message;
879 els.toast.classList.add('show');
880 clearTimeout(toastTimer);
881 toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
882 }
883
884 function updateKeyStatus() {
885 els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey));
886 els.activeModel.textContent = settings.model;
887 els.activeProvider.textContent = providerLabel();
888 els.activeConnection.title = `${providerLabel()} · ${settings.model} — open settings`;
889 els.activeConnection.setAttribute('aria-label', `Current model: ${settings.model} through ${providerLabel()}. Open API settings`);
890 const button = $('#open-settings');
891 button.title = `${providerLabel()} · ${settings.model}`;
892 button.setAttribute('aria-label', `API settings — ${providerLabel()}, ${settings.model}`);
893 }
894
895 function updateThemeButton() {
896 const dark = document.documentElement.dataset.theme === 'dark';
897 const button = $('#theme-toggle');
898 button.querySelector('span').textContent = dark ? '☀' : '☾';
899 button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
900 button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
901 }
902
903 function toggleTheme() {
904 const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
905 document.documentElement.dataset.theme = next;
906 try { localStorage.setItem(THEME_KEY, next); } catch {}
907 updateThemeButton();
908 }
909
910 function priceForModel(model) {
911 const key = String(model || '').toLowerCase();
912 return MODEL_PRICES.find(price => price.match.test(key)) || null;
913 }
914
915 function isOpenAIEndpoint(apiUrl) {
916 try { return new URL(apiUrl).hostname === 'api.openai.com'; }
917 catch { return false; }
918 }
919
920 async function openRouterPriceForModel(model) {
921 const key = String(model || '').trim();
922 if (!key.includes('/')) return null;
923 const cached = openRouterPrices[key];
924 const validCached = cached && Number.isFinite(Number(cached.input)) && Number.isFinite(Number(cached.output));
925 if (validCached && Date.now() - Number(cached.checkedAt) < PRICE_CACHE_TTL) return cached;
926 try {
927 const path = key.split('/').map(encodeURIComponent).join('/');
928 const response = await fetch(`https://openrouter.ai/api/v1/model/${path}`, {
929 headers: { 'Accept': 'application/json' }
930 });
931 if (!response.ok) return validCached ? cached : null;
932 const pricing = (await response.json()).data?.pricing;
933 const prompt = pricing?.prompt == null ? NaN : Number(pricing.prompt);
934 const completion = pricing?.completion == null ? NaN : Number(pricing.completion);
935 const cacheRead = pricing?.input_cache_read == null ? NaN : Number(pricing.input_cache_read);
936 if (!Number.isFinite(prompt) || !Number.isFinite(completion)) return validCached ? cached : null;
937 const price = {
938 input: prompt * 1_000_000,
939 cached: (Number.isFinite(cacheRead) ? cacheRead : prompt) * 1_000_000,
940 output: completion * 1_000_000,
941 request: Number(pricing?.request) || 0,
942 checkedAt: Date.now(),
943 source: 'openrouter'
944 };
945 openRouterPrices[key] = price;
946 persistOpenRouterPrices();
947 return price;
948 } catch { return validCached ? cached : null; }
949 }
950
951 async function recordUsage(nodeId, model, apiUrl, rawUsage, provider) {
952 if (usageRecords.some(record => record.nodeId === nodeId)) return;
953 const inputTokens = Number(rawUsage.input_tokens) || 0;
954 const outputTokens = Number(rawUsage.output_tokens) || 0;
955 const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0);
956 const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens;
957 const price = provider === 'openrouter'
958 ? await openRouterPriceForModel(model)
959 : (isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null);
960 const costUSD = price
961 ? ((((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000) + (Number(price.request) || 0)
962 : null;
963 usageRecords.push({
964 id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'),
965 inputTokens, cachedTokens, outputTokens, totalTokens, costUSD,
966 rates: price ? {
967 input: price.input, cached: price.cached, output: price.output,
968 request: Number(price.request) || 0, source: price.source || 'openai', checkedAt: price.checkedAt || Date.now()
969 } : null
970 });
971 // Keep bounded history while retaining roughly a year of normal personal use.
972 usageRecords = usageRecords.slice(-2000);
973 try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); }
974 catch { showToast('Usage was returned, but local usage history is full'); }
975 updateUsageButton();
976 }
977
978 function currentMonthRecords() {
979 const now = new Date();
980 return usageRecords.filter(record => {
981 const date = new Date(record.timestamp);
982 return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
983 });
984 }
985
986 function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); }
987
988 function formatTokens(value, compact = false) {
989 const number = Number(value) || 0;
990 if (!compact || number < 1000) return Math.round(number).toLocaleString();
991 if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`;
992 return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`;
993 }
994
995 function formatCost(value) {
996 if (!Number.isFinite(value)) return 'Unpriced';
997 if (value > 0 && value < .01) return '<$0.01';
998 return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 });
999 }
1000
1001 function updateUsageButton() {
1002 const total = sum(currentMonthRecords(), 'totalTokens');
1003 els.monthTokens.textContent = formatTokens(total, true);
1004 const label = `${formatTokens(total)} tokens tracked this month`;
1005 $('#open-usage').title = label;
1006 $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`);
1007 }
1008
1009 function usageRowsByModel(records) {
1010 const groups = new Map();
1011 for (const record of records) {
1012 const model = String(record.model || 'unknown');
1013 if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 });
1014 const group = groups.get(model);
1015 group.totalTokens += Number(record.totalTokens) || 0;
1016 if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD;
1017 else group.unpriced += 1;
1018 }
1019 return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens);
1020 }
1021
1022 function renderUsage() {
1023 const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp);
1024 const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0);
1025 const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length;
1026 els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
1027 els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens'));
1028 els.usageCost.textContent = records.length && unpriced === records.length
1029 ? 'Unpriced'
1030 : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`;
1031 els.usageRequests.textContent = formatTokens(records.length);
1032 els.usageInput.textContent = formatTokens(sum(records, 'inputTokens'));
1033 els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens'));
1034 els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens'));
1035
1036 const groups = usageRowsByModel(records);
1037 els.usageModels.innerHTML = groups.length ? groups.map(group => `
1038 <div class="usage-row">
1039 <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span>
1040 <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span>
1041 <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span>
1042 </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>';
1043
1044 els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `
1045 <div class="usage-row">
1046 <span class="model-name">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
1047 <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span>
1048 <span class="row-cost">${formatCost(record.costUSD)}</span>
1049 </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>';
1050
1051 els.usageNote.textContent = unpriced
1052 ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because its model price could not be resolved.`
1053 : 'Estimates use the rate recorded when each request completed: the local OpenAI table or OpenRouter\'s model-pricing API.';
1054 }
1055
1056 function openUsage() {
1057 renderUsage();
1058 const openRouter = settings.provider === 'openrouter';
1059 els.usageDashboard.href = openRouter ? 'https://openrouter.ai/activity' : 'https://platform.openai.com/usage';
1060 els.usageDashboard.textContent = `${openRouter ? 'OpenRouter' : 'OpenAI'} dashboard ↗`;
1061 els.usageDialog.showModal();
1062 }
1063
1064 function createNewMap() {
1065 const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0);
1066 const map = existingBlank || blankMap();
1067 if (!existingBlank) workspace.maps.push(map);
1068 workspace.activeMapId = map.id;
1069 state = map;
1070 els.conversationSearch.value = '';
1071 persistNow();
1072 renderAll();
1073 closeHistoryOnSmallScreen();
1074 window.scrollTo({ top: 0, behavior: 'smooth' });
1075 els.prompt.focus();
1076 }
1077
1078 function switchMap(mapId, nodeId = null) {
1079 const map = workspace.maps.find(candidate => candidate.id === mapId);
1080 if (!map) { closeHistoryOnSmallScreen(); return; }
1081 workspace.activeMapId = map.id;
1082 state = map;
1083 state.activeId = nodeId && nodeById(nodeId, state) ? nodeId : (state.nodes.at(-1)?.id || null);
1084 revealNode(state, state.activeId);
1085 persistSoon();
1086 hideSelectionMenu();
1087 renderAll({ scroll: state.activeId ? (nodeId ? true : 'bottom') : false });
1088 closeHistoryOnSmallScreen();
1089 }
1090
1091 function activateNode(nodeId, scroll = true) {
1092 if (!nodeById(nodeId)) return;
1093 state.activeId = nodeId;
1094 revealNode(state, nodeId);
1095 persistSoon();
1096 renderAll({ scroll });
1097 document.body.classList.remove('map-open');
1098 }
1099
1100 function visibleTreeNodeIds() {
1101 return [...els.tree.querySelectorAll('.tree-node')].map(button => button.dataset.nodeId);
1102 }
1103
1104 function moveVisibleNode(offset) {
1105 const ids = visibleTreeNodeIds();
1106 if (!ids.length) return;
1107 const current = Math.max(0, ids.indexOf(state.activeId));
1108 activateNode(ids[Math.max(0, Math.min(ids.length - 1, current + offset))]);
1109 }
1110
1111 function moveMap(offset) {
1112 const maps = workspace.maps
1113 .filter(map => map.nodes.length || map.attachments.length)
1114 .sort((a, b) => b.updatedAt - a.updatedAt);
1115 if (!maps.length) return;
1116 const current = Math.max(0, maps.findIndex(map => map.id === state.id));
1117 switchMap(maps[Math.max(0, Math.min(maps.length - 1, current + offset))].id);
1118 }
1119
1120 function focusConversationSearch() {
1121 document.body.classList.remove('history-collapsed', 'map-open');
1122 if (matchMedia('(max-width: 1100px)').matches) document.body.classList.add('history-open');
1123 updateHistoryToggle();
1124 els.conversationSearch.focus();
1125 els.conversationSearch.select();
1126 }
1127
1128 function handleVimNavigation(event) {
1129 if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
1130 const editable = event.target.closest?.('input, textarea, select, [contenteditable="true"]');
1131 if (editable) {
1132 if (event.key === 'Escape') {
1133 event.preventDefault();
1134 if (editable === els.conversationSearch) clearSearch();
1135 editable.blur();
1136 }
1137 return;
1138 }
1139 if ($('dialog[open]')) return;
1140 if (pendingG && event.key !== 'g') {
1141 clearTimeout(pendingGTimer);
1142 pendingG = false;
1143 }
1144 if (event.key === '?') { event.preventDefault(); els.shortcutsDialog.showModal(); return; }
1145 if (event.key === '/') { event.preventDefault(); focusConversationSearch(); return; }
1146 if (event.key === 'i') { event.preventDefault(); els.prompt.focus(); return; }
1147 if (event.key === 'j') { event.preventDefault(); moveVisibleNode(1); return; }
1148 if (event.key === 'k') { event.preventDefault(); moveVisibleNode(-1); return; }
1149 if (event.key === 'J') { event.preventDefault(); moveMap(1); return; }
1150 if (event.key === 'K') { event.preventDefault(); moveMap(-1); return; }
1151 if (event.key === 'h') {
1152 const parentId = nodeById(state.activeId)?.parentId;
1153 if (parentId) { event.preventDefault(); activateNode(parentId); }
1154 return;
1155 }
1156 if (event.key === 'l') {
1157 const children = childrenOf(state.activeId);
1158 if (!children.length) return;
1159 event.preventDefault();
1160 if (state.collapsedIds.includes(state.activeId)) toggleSubtree(state.activeId, false);
1161 else activateNode(children[0].id);
1162 return;
1163 }
1164 if (event.key === ' ') {
1165 if (childrenOf(state.activeId).length) { event.preventDefault(); toggleSubtree(state.activeId); }
1166 return;
1167 }
1168 if (event.key === 'G') {
1169 const ids = visibleTreeNodeIds();
1170 if (ids.length) { event.preventDefault(); activateNode(ids.at(-1)); }
1171 return;
1172 }
1173 if (event.key === 'g') {
1174 event.preventDefault();
1175 if (pendingG) {
1176 clearTimeout(pendingGTimer);
1177 pendingG = false;
1178 const ids = visibleTreeNodeIds();
1179 if (ids.length) activateNode(ids[0]);
1180 } else {
1181 pendingG = true;
1182 pendingGTimer = setTimeout(() => { pendingG = false; }, 900);
1183 }
1184 }
1185 }
1186
1187 function closeHistoryOnSmallScreen() {
1188 if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open');
1189 updateHistoryToggle();
1190 }
1191
1192 function updateHistoryToggle() {
1193 const small = matchMedia('(max-width: 1100px)').matches;
1194 const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed');
1195 els.historyToggle.setAttribute('aria-expanded', String(open));
1196 }
1197
1198 function toggleHistory() {
1199 if (matchMedia('(max-width: 1100px)').matches) {
1200 document.body.classList.remove('history-collapsed');
1201 document.body.classList.toggle('history-open');
1202 if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open');
1203 } else document.body.classList.toggle('history-collapsed');
1204 updateHistoryToggle();
1205 }
1206
1207 function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') {
1208 if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; }
1209 if (!confirm(message)) return false;
1210 if (activeRequest) activeRequest.abort();
1211 const map = blankMap();
1212 workspace = { version: 2, maps: [map], activeMapId: map.id };
1213 state = map;
1214 searchIndex = null;
1215 els.conversationSearch.value = '';
1216 persistNow();
1217 renderAll();
1218 showToast('All conversations cleared');
1219 return true;
1220 }
1221
1222 function deleteMap(mapId) {
1223 const map = workspace.maps.find(candidate => candidate.id === mapId);
1224 if (!map) return;
1225 if (uploadingPDFs && uploadTargetMapId === map.id) { showToast('Wait for this map’s PDF upload to finish'); return; }
1226 const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : '';
1227 if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return;
1228 if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort();
1229
1230 workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id);
1231 searchIndex = null;
1232 if (!workspace.maps.length) workspace.maps.push(blankMap());
1233 if (state === map) {
1234 const next = [...workspace.maps]
1235 .filter(candidate => candidate.nodes.length || candidate.attachments.length)
1236 .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0];
1237 workspace.activeMapId = next.id;
1238 state = next;
1239 state.activeId = state.nodes.at(-1)?.id || null;
1240 revealNode(state, state.activeId);
1241 }
1242 persistNow();
1243 renderAll({ scroll: state.activeId ? 'bottom' : false });
1244 showToast('Conversation deleted');
1245 }
1246
1247 function renameMap(mapId) {
1248 const map = workspace.maps.find(candidate => candidate.id === mapId);
1249 if (!map) return;
1250 const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title);
1251 if (value === null) return;
1252 const title = value.replace(/\s+/g, ' ').trim();
1253 if (title) {
1254 map.title = title.slice(0, 120);
1255 map.titleCustom = true;
1256 } else {
1257 map.titleCustom = false;
1258 refreshMapTitle(map);
1259 }
1260 touchMap(map);
1261 persistNow();
1262 renderHistory();
1263 showToast(title ? 'Conversation renamed' : 'Automatic title restored');
1264 }
1265
1266 function showSelectionMenu(text, nodeId, rect) {
1267 selectedText = { text: text.slice(0, 5000), nodeId };
1268 els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
1269 els.selectionMenu.hidden = false;
1270 const width = 330;
1271 const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
1272 const estimatedHeight = 235;
1273 const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
1274 Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
1275 }
1276
1277 function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
1278
1279 els.composer.addEventListener('submit', event => {
1280 event.preventDefault();
1281 if (activeRequest) { activeRequest.abort(); return; }
1282 const question = els.prompt.value;
1283 els.prompt.value = '';
1284 els.prompt.style.height = '';
1285 ask(question);
1286 });
1287 els.prompt.addEventListener('keydown', event => {
1288 if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
1289 });
1290 els.prompt.addEventListener('input', () => {
1291 els.prompt.style.height = 'auto';
1292 els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
1293 });
1294 document.addEventListener('click', event => {
1295 const starter = event.target.closest('.starter');
1296 if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
1297 const historyItem = event.target.closest('.history-item');
1298 if (historyItem) { switchMap(historyItem.dataset.mapId); return; }
1299 const searchResult = event.target.closest('.search-result');
1300 if (searchResult) {
1301 const { mapId, nodeId } = searchResult.dataset;
1302 clearSearch();
1303 switchMap(mapId, nodeId || null);
1304 return;
1305 }
1306 const renameConversation = event.target.closest('.rename-map');
1307 if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; }
1308 const deleteConversation = event.target.closest('.delete-map');
1309 if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; }
1310 const removeFile = event.target.closest('.remove-attachment');
1311 if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; }
1312 const subtreeToggle = event.target.closest('.toggle-subtree');
1313 if (subtreeToggle) { toggleSubtree(subtreeToggle.dataset.nodeId); return; }
1314 const treeNode = event.target.closest('.tree-node');
1315 if (treeNode) { activateNode(treeNode.dataset.nodeId); return; }
1316 const branch = event.target.closest('.branch-here');
1317 if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
1318 const retry = event.target.closest('.retry-node');
1319 if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
1320 const copy = event.target.closest('.copy-answer');
1321 if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
1322 });
1323 document.addEventListener('mouseup', event => {
1324 if (event.target.closest('.selection-menu')) return;
1325 requestAnimationFrame(() => {
1326 const selection = getSelection();
1327 const text = selection?.toString().trim();
1328 if (!text || text.length < 2 || !selection.rangeCount) return;
1329 const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
1330 if (!answer || !answer.contains(selection.focusNode)) return;
1331 const message = answer.closest('.message');
1332 showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
1333 });
1334 });
1335
1336 $('#ask-selection').addEventListener('click', () => {
1337 if (!selectedText) return;
1338 const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
1339 if (!action) { els.selectionCustom.focus(); return; }
1340 const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
1341 const parentId = selectedText.nodeId;
1342 hideSelectionMenu();
1343 getSelection()?.removeAllRanges();
1344 ask(question, parentId);
1345 });
1346 els.selectionAction.addEventListener('change', () => {
1347 els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
1348 if (!els.selectionCustom.hidden) els.selectionCustom.focus();
1349 });
1350 $('#close-selection').addEventListener('click', hideSelectionMenu);
1351 window.addEventListener('scroll', hideSelectionMenu, { passive: true });
1352 $('#open-settings').addEventListener('click', openSettings);
1353 els.activeConnection.addEventListener('click', openSettings);
1354 $('#open-usage').addEventListener('click', openUsage);
1355 $('#theme-toggle').addEventListener('click', toggleTheme);
1356 $('#toggle-history').addEventListener('click', toggleHistory);
1357 els.conversationSearch.addEventListener('input', () => {
1358 clearTimeout(searchTimer);
1359 searchTimer = setTimeout(renderSearchResults, 110);
1360 });
1361 els.conversationSearch.addEventListener('keydown', event => {
1362 if (event.key !== 'Enter') return;
1363 const first = $('.search-result', els.searchResults);
1364 if (first) { event.preventDefault(); first.click(); }
1365 });
1366 $('#study-lab-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); });
1367 $('#new-map-sidebar').addEventListener('click', createNewMap);
1368 els.uploadPDF.addEventListener('click', () => els.pdfInput.click());
1369 els.pdfInput.addEventListener('change', () => uploadPDFs(els.pdfInput.files));
1370 $('#close-settings').addEventListener('click', () => els.settings.close());
1371 $('#close-usage').addEventListener('click', () => els.usageDialog.close());
1372 $('#open-shortcuts').addEventListener('click', () => els.shortcutsDialog.showModal());
1373 $('#close-shortcuts').addEventListener('click', () => els.shortcutsDialog.close());
1374 $('#expand-all-nodes').addEventListener('click', () => setAllSubtrees(false));
1375 $('#collapse-all-nodes').addEventListener('click', () => setAllSubtrees(true));
1376 $('#zoom-out-tree').addEventListener('click', () => updateTreeZoom(-.1));
1377 $('#zoom-in-tree').addEventListener('click', () => updateTreeZoom(.1));
1378 $('#reset-usage').addEventListener('click', () => {
1379 if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) {
1380 usageRecords = [];
1381 localStorage.removeItem(USAGE_KEY);
1382 updateUsageButton();
1383 renderUsage();
1384 showToast('Local usage history reset');
1385 }
1386 });
1387 document.querySelectorAll('.toggle-key').forEach(button => button.addEventListener('click', event => {
1388 const input = document.getElementById(event.currentTarget.dataset.keyInput);
1389 const show = input.type === 'password';
1390 input.type = show ? 'text' : 'password';
1391 event.currentTarget.textContent = show ? 'Hide' : 'Show';
1392 event.currentTarget.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.id === 'openrouter-api-key' ? 'OpenRouter' : 'OpenAI'} API key`);
1393 }));
1394 els.provider.addEventListener('change', updateProviderCards);
1395 $('#settings-form').addEventListener('submit', event => {
1396 event.preventDefault();
1397 captureProviderForms();
1398 settings = normalizeSettings({ provider: els.provider.value, profiles: settingsDraftProfiles, systemPrompt: els.systemPrompt.value.trim() });
1399 localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
1400 settingsDraftProfiles = null;
1401 updateKeyStatus(); renderAttachments(); els.settings.close(); showToast(`${providerLabel()} settings saved`);
1402 });
1403 $('#forget-key').addEventListener('click', () => {
1404 const provider = els.provider.value;
1405 const input = provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey;
1406 input.value = '';
1407 settingsDraftProfiles[provider].apiKey = '';
1408 const profiles = structuredClone(settings.profiles);
1409 profiles[provider].apiKey = '';
1410 settings = normalizeSettings({ provider: settings.provider, profiles, systemPrompt: settings.systemPrompt });
1411 localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
1412 updateKeyStatus();
1413 showToast(`${providerLabel(provider)} API key removed`);
1414 });
1415 $('#clear-history').addEventListener('click', () => clearChatHistory());
1416 $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); persistSoon(); renderAll({ scroll: true }); });
1417 $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
1418 $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); });
1419 addEventListener('resize', updateHistoryToggle, { passive: true });
1420 document.addEventListener('keydown', handleVimNavigation);
1421
1422 updateKeyStatus();
1423 updateThemeButton();
1424 updateUsageButton();
1425 updateHistoryToggle();
1426 updateTreeZoom();
1427 state.activeId = state.nodes.at(-1)?.id || null;
1428 revealNode(state, state.activeId);
1429 renderAll();
1430})();