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