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