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