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 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'), exportPdf: $('#export-pdf')
  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 => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', "'":'&#39;', '"':'&quot;' })[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    els.exportPdf.hidden = path.length === 0;
 462    const fragment = document.createDocumentFragment();
 463    for (const node of path) {
 464      const article = document.createElement('article');
 465      article.className = 'message';
 466      article.dataset.nodeId = node.id;
 467      article.innerHTML = `
 468        <div class="question-row"><div class="question-bubble">${escapeHTML(node.question)}</div></div>
 469        <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>
 470        <div class="answer-actions">
 471          <button class="mini-action copy-answer" type="button">Copy answer</button>
 472          <button class="mini-action branch-here" type="button">Branch here</button>
 473          ${node.status === 'error' ? '<button class="mini-action retry-node" type="button">Retry</button>' : ''}
 474        </div>`;
 475      fragment.append(article);
 476    }
 477    els.conversation.replaceChildren(fragment);
 478    if (previousScroll && (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top)) {
 479      window.scrollTo({ ...previousScroll, behavior: 'auto' });
 480    }
 481    renderBranchContext();
 482    if (scroll && state.activeId) requestAnimationFrame(() => {
 483      if (scroll === 'bottom') {
 484        window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
 485        return;
 486      }
 487      const targetId = state.activeId;
 488      requestAnimationFrame(() => {
 489        if (state.activeId !== targetId) return;
 490        const message = $(`.message[data-node-id="${CSS.escape(targetId)}"]`, els.conversation);
 491        if (!message) return;
 492        const headerHeight = $('.topbar')?.getBoundingClientRect().height || 68;
 493        const top = window.scrollY + message.getBoundingClientRect().top - headerHeight - 14;
 494        window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
 495      });
 496    });
 497  }
 498
 499  function renderTree() {
 500    const ids = new Set(state.nodes.map(node => node.id));
 501    const grouped = new Map();
 502    for (const node of state.nodes) {
 503      const parentId = node.parentId && ids.has(node.parentId) ? node.parentId : null;
 504      if (!grouped.has(parentId)) grouped.set(parentId, []);
 505      grouped.get(parentId).push(node);
 506    }
 507    const roots = grouped.get(null) || [];
 508    const activePath = new Set(pathTo(state.activeId).map(node => node.id));
 509    const collapsed = new Set(state.collapsedIds);
 510    const build = nodes => {
 511      const ul = document.createElement('ul');
 512      for (const node of nodes) {
 513        const li = document.createElement('li');
 514        li.dataset.nodeId = node.id;
 515        const wrap = document.createElement('div');
 516        wrap.className = 'tree-node-wrap';
 517        const button = document.createElement('button');
 518        button.type = 'button';
 519        button.className = `tree-node${node.id === state.activeId ? ' active' : ''}${activePath.has(node.id) ? ' on-path' : ''}`;
 520        button.dataset.nodeId = node.id;
 521        button.setAttribute('aria-current', node.id === state.activeId ? 'true' : 'false');
 522        const children = grouped.get(node.id) || [];
 523        const isCollapsed = children.length > 0 && collapsed.has(node.id);
 524        const toggle = document.createElement(children.length ? 'button' : 'span');
 525        toggle.className = children.length ? 'toggle-subtree' : 'toggle-placeholder';
 526        if (children.length) {
 527          toggle.type = 'button';
 528          toggle.dataset.nodeId = node.id;
 529          toggle.setAttribute('aria-label', `${isCollapsed ? 'Expand' : 'Collapse'} branch: ${node.question}`);
 530          toggle.setAttribute('aria-expanded', String(!isCollapsed));
 531          toggle.textContent = isCollapsed ? '▸' : '▾';
 532        }
 533        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>`;
 534        wrap.append(toggle, button);
 535        li.append(wrap);
 536        if (children.length && !isCollapsed) li.append(build(children));
 537        ul.append(li);
 538      }
 539      return ul;
 540    };
 541    els.tree.replaceChildren(build(roots));
 542    els.mapEmpty.hidden = state.nodes.length > 0;
 543    els.tree.hidden = state.nodes.length === 0;
 544    els.nodeCount.textContent = String(state.nodes.length);
 545    let branchCount = 0;
 546    for (const children of grouped.values()) branchCount += Math.max(0, children.length - 1);
 547    els.branchCount.textContent = String(branchCount);
 548  }
 549
 550  function revealNode(map, nodeId) {
 551    if (!nodeId) return;
 552    const hidden = new Set(map.collapsedIds);
 553    for (const node of pathTo(nodeId, map).slice(0, -1)) hidden.delete(node.id);
 554    map.collapsedIds = [...hidden];
 555  }
 556
 557  function toggleSubtree(nodeId, force) {
 558    if (!nodeById(nodeId)) return;
 559    const collapsed = new Set(state.collapsedIds);
 560    const shouldCollapse = force ?? !collapsed.has(nodeId);
 561    if (shouldCollapse) collapsed.add(nodeId);
 562    else collapsed.delete(nodeId);
 563    state.collapsedIds = [...collapsed];
 564    persistSoon();
 565    renderTree();
 566  }
 567
 568  function setAllSubtrees(collapsed) {
 569    if (!collapsed) state.collapsedIds = [];
 570    else {
 571      const parents = new Set(state.nodes.map(node => node.parentId).filter(Boolean));
 572      state.collapsedIds = [...parents];
 573    }
 574    persistSoon();
 575    renderTree();
 576  }
 577
 578  function updateTreeZoom(delta = 0) {
 579    treeZoom = Math.round(Math.max(.8, Math.min(1.4, treeZoom + delta)) * 10) / 10;
 580    els.tree.style.fontSize = `${.76 * treeZoom}rem`;
 581    els.tree.style.setProperty('--tree-indent', `${20 * treeZoom}px`);
 582    els.tree.style.setProperty('--tree-dot-size', `${17 * treeZoom}px`);
 583    els.tree.style.setProperty('--tree-node-pad-y', `${8 * treeZoom}px`);
 584    els.treeZoom.textContent = `${Math.round(treeZoom * 100)}%`;
 585    try { localStorage.setItem(TREE_ZOOM_KEY, String(treeZoom)); } catch {}
 586  }
 587
 588  function renderBranchContext() {
 589    const active = nodeById(state.activeId);
 590    const latest = state.nodes.at(-1);
 591    const branching = active && latest && active.id !== latest.id;
 592    els.branchContext.hidden = !branching;
 593    if (branching) els.branchLabel.textContent = active.question;
 594  }
 595
 596  function renderHistory() {
 597    const maps = workspace.maps
 598      .filter(map => map.nodes.length > 0 || map.attachments.length > 0)
 599      .sort((a, b) => b.updatedAt - a.updatedAt);
 600    const fragment = document.createDocumentFragment();
 601    for (const map of maps) {
 602      const row = document.createElement('div');
 603      row.className = 'history-row';
 604      const button = document.createElement('button');
 605      button.type = 'button';
 606      button.className = `history-item${map.id === workspace.activeMapId ? ' active' : ''}`;
 607      button.dataset.mapId = map.id;
 608      button.setAttribute('aria-current', map.id === workspace.activeMapId ? 'page' : 'false');
 609      const date = new Date(map.updatedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
 610      const fileMeta = map.attachments.length ? ` · ${map.attachments.length} file${map.attachments.length === 1 ? '' : 's'}` : '';
 611      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>`;
 612      const rename = document.createElement('button');
 613      rename.type = 'button';
 614      rename.className = 'rename-map';
 615      rename.dataset.mapId = map.id;
 616      rename.setAttribute('aria-label', `Rename conversation: ${map.title}`);
 617      rename.title = 'Rename this conversation';
 618      rename.textContent = '✎';
 619      const remove = document.createElement('button');
 620      remove.type = 'button';
 621      remove.className = 'delete-map';
 622      remove.dataset.mapId = map.id;
 623      remove.setAttribute('aria-label', `Delete conversation: ${map.title}`);
 624      remove.title = 'Delete this conversation';
 625      remove.textContent = '×';
 626      row.append(button, rename, remove);
 627      fragment.append(row);
 628    }
 629    els.historyList.replaceChildren(fragment);
 630    els.historyEmpty.hidden = maps.length > 0;
 631    els.historyList.hidden = maps.length === 0;
 632    if (els.conversationSearch.value.trim()) renderSearchResults();
 633    else els.searchResults.hidden = true;
 634  }
 635
 636  function buildSearchIndex() {
 637    const entries = [];
 638    for (const map of workspace.maps) {
 639      if (!map.nodes.length && !map.attachments.length) continue;
 640      entries.push({ mapId: map.id, nodeId: null, kind: 'Map', label: map.title, fields: [map.title] });
 641      for (const node of map.nodes) {
 642        entries.push({
 643          mapId: map.id, nodeId: node.id, kind: 'Node', label: node.question,
 644          fields: [node.question, node.answer || ''], mapTitle: map.title
 645        });
 646      }
 647      for (const file of map.attachments) {
 648        entries.push({ mapId: map.id, nodeId: null, kind: file.kind === 'markdown' ? 'Markdown' : 'PDF', label: file.name, fields: [file.name], mapTitle: map.title });
 649      }
 650    }
 651    searchIndex = entries;
 652    return entries;
 653  }
 654
 655  function searchExcerpt(fields, expression) {
 656    for (const field of fields) {
 657      const source = String(field || '').replace(/\s+/g, ' ').trim();
 658      const index = source.search(expression);
 659      if (index < 0) continue;
 660      const start = Math.max(0, index - 45);
 661      const end = Math.min(source.length, index + 115);
 662      return `${start ? '…' : ''}${source.slice(start, end)}${end < source.length ? '…' : ''}`;
 663    }
 664    return '';
 665  }
 666
 667  function renderSearchResults() {
 668    const query = els.conversationSearch.value.trim();
 669    if (!query) {
 670      els.searchResults.hidden = true;
 671      els.historyList.hidden = false;
 672      els.historyEmpty.hidden = workspace.maps.some(map => map.nodes.length || map.attachments.length);
 673      return;
 674    }
 675    const expression = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
 676    const matches = [];
 677    for (const entry of searchIndex || buildSearchIndex()) {
 678      const excerpt = searchExcerpt(entry.fields, expression);
 679      if (!excerpt) continue;
 680      matches.push({ ...entry, excerpt });
 681      if (matches.length >= 60) break;
 682    }
 683    els.searchResults.innerHTML = matches.length ? matches.map(entry => `
 684      <button class="search-result" type="button" data-map-id="${escapeHTML(entry.mapId)}"${entry.nodeId ? ` data-node-id="${escapeHTML(entry.nodeId)}"` : ''}>
 685        <span class="search-result-top"><strong>${escapeHTML(entry.label)}</strong><small>${escapeHTML(entry.kind)} · ${escapeHTML(entry.mapTitle || entry.label)}</small></span>
 686        <span class="search-excerpt">${escapeHTML(entry.excerpt)}</span>
 687      </button>`).join('') : `<p class="search-empty">No results for “${escapeHTML(query)}”.</p>`;
 688    els.searchResults.hidden = false;
 689    els.historyList.hidden = true;
 690    els.historyEmpty.hidden = true;
 691  }
 692
 693  function clearSearch() {
 694    clearTimeout(searchTimer);
 695    els.conversationSearch.value = '';
 696    renderSearchResults();
 697    renderHistory();
 698  }
 699
 700  function formatFileSize(bytes) {
 701    if (!bytes) return '';
 702    if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
 703    return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
 704  }
 705
 706  function renderAttachments() {
 707    const fragment = document.createDocumentFragment();
 708    for (const file of state.attachments) {
 709      const chip = document.createElement('span');
 710      chip.className = 'attachment-chip';
 711      const available = file.provider === settings.provider;
 712      chip.classList.toggle('inactive-provider', !available);
 713      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'}`;
 714      const badge = file.kind === 'markdown' ? 'MD' : 'PDF';
 715      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>`;
 716      fragment.append(chip);
 717    }
 718    if (uploadingFiles && state.id === uploadTargetMapId) {
 719      const status = document.createElement('span');
 720      status.className = 'attachment-chip upload-status';
 721      status.innerHTML = '<span class="upload-spinner" aria-hidden="true"></span> Uploading…';
 722      fragment.append(status);
 723    }
 724    els.attachments.replaceChildren(fragment);
 725    els.attachments.hidden = !state.attachments.length && !(uploadingFiles && state.id === uploadTargetMapId);
 726    els.uploadFile.disabled = uploadingFiles || Boolean(activeRequest);
 727  }
 728
 729  function renderAll(options) { renderConversation(options); renderTree(); renderHistory(); renderAttachments(); }
 730
 731  function updateStreamedNode(node) {
 732    if (state.activeId !== node.id || !state.nodes.includes(node)) return;
 733    const body = $(`.message[data-node-id="${CSS.escape(node.id)}"] .answer-body`, els.conversation);
 734    if (body) {
 735      const previousScroll = { left: window.scrollX, top: window.scrollY };
 736      body.innerHTML = answerHTML(node);
 737      if (window.scrollX !== previousScroll.left || window.scrollY !== previousScroll.top) {
 738        window.scrollTo({ ...previousScroll, behavior: 'auto' });
 739      }
 740    }
 741  }
 742
 743  function questionForRequest(question, concise = false) {
 744    return concise ? `${question}\n\n${SHORT_MODE_INSTRUCTION}` : question;
 745  }
 746
 747  function inputFor(map, parentId, question, provider = settings.provider, concise = false) {
 748    const messages = [];
 749    for (const node of pathTo(parentId, map)) {
 750      if (provider === 'openrouter') {
 751        messages.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: node.question }] });
 752        if (node.answer) messages.push({
 753          type: 'message', role: 'assistant', id: `msg_${String(node.id).replace(/[^a-zA-Z0-9_-]/g, '')}`,
 754          status: 'completed', content: [{ type: 'output_text', text: node.answer, annotations: [] }]
 755        });
 756      } else {
 757        messages.push({ role: 'user', content: node.question });
 758        if (node.answer) messages.push({ role: 'assistant', content: node.answer });
 759      }
 760    }
 761    const content = map.attachments
 762      .filter(file => file.provider === provider)
 763      .map(file => file.kind === 'markdown'
 764        ? { type: 'input_file', file_id: file.id }
 765        : { type: 'input_file', file_id: file.id, detail: file.detail || 'low' });
 766    const requestQuestion = questionForRequest(question, concise);
 767    content.push({ type: 'input_text', text: requestQuestion });
 768    const structured = provider === 'openrouter' || content.length > 1;
 769    messages.push(structured ? { type: 'message', role: 'user', content } : { role: 'user', content: requestQuestion });
 770    return messages;
 771  }
 772
 773  function filesEndpoint(apiUrl = settings.apiUrl) {
 774    const url = new URL(apiUrl);
 775    if (!/\/responses\/?$/.test(url.pathname)) throw new Error('The configured endpoint is not a Responses API URL, so its Files endpoint cannot be derived.');
 776    url.pathname = url.pathname.replace(/\/responses\/?$/, '/files');
 777    url.search = '';
 778    url.hash = '';
 779    return url.toString();
 780  }
 781
 782  async function uploadFiles(fileList) {
 783    const files = [...fileList];
 784    if (!files.length) return;
 785    if (activeRequest || uploadingFiles) { showToast('Finish the current operation first'); return; }
 786    if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key before uploading`); return; }
 787
 788    const valid = files.filter(file => /\.(pdf|md)$/i.test(file.name));
 789    if (valid.length !== files.length) { showToast('Only PDF and Markdown (.md) files can be added'); return; }
 790    if (valid.some(file => file.size >= FILE_REQUEST_LIMIT)) { showToast('Each file must be smaller than 50 MB'); return; }
 791    const existingBytes = state.attachments.filter(file => file.provider === settings.provider).reduce((total, file) => total + file.size, 0);
 792    const addedBytes = valid.reduce((total, file) => total + file.size, 0);
 793    if (existingBytes + addedBytes >= FILE_REQUEST_LIMIT) { showToast('Files in one map must total less than 50 MB'); return; }
 794
 795    const targetMap = state;
 796    const connection = { ...settings };
 797    uploadingFiles = true;
 798    uploadTargetMapId = targetMap.id;
 799    renderAttachments();
 800    let uploaded = 0;
 801    try {
 802      const endpoint = filesEndpoint(connection.apiUrl);
 803      for (const file of valid) {
 804        const form = new FormData();
 805        if (connection.provider === 'openai') form.append('purpose', 'user_data');
 806        form.append('file', file, file.name);
 807        const response = await fetch(endpoint, {
 808          method: 'POST', headers: requestHeaders(false, connection), body: form
 809        });
 810        if (!response.ok) {
 811          let message = `File upload failed (${response.status})`;
 812          try { const data = await response.json(); message = data.error?.message || message; } catch {}
 813          throw new Error(message);
 814        }
 815        const data = await response.json();
 816        if (!data.id) throw new Error('The Files API did not return a file ID.');
 817        targetMap.attachments.push({
 818          id: String(data.id), name: String(data.filename || file.name),
 819          size: Number(data.bytes ?? data.size_bytes) || file.size, createdAt: Date.now(), detail: 'low',
 820          kind: file.name.toLowerCase().endsWith('.md') ? 'markdown' : 'pdf',
 821          provider: connection.provider
 822        });
 823        uploaded += 1;
 824        touchMap(targetMap);
 825        persistNow();
 826        if (state === targetMap) renderAll();
 827      }
 828      showToast(`${uploaded} file${uploaded === 1 ? '' : 's'} added to this map`);
 829    } catch (error) {
 830      showToast(error.message || 'File upload failed');
 831    } finally {
 832      uploadingFiles = false;
 833      uploadTargetMapId = null;
 834      els.fileInput.value = '';
 835      renderAll();
 836    }
 837  }
 838
 839  function removeAttachment(fileId) {
 840    const file = state.attachments.find(candidate => candidate.id === fileId);
 841    if (!file) return;
 842    if (!confirm(`Remove “${file.name}” from this map? The uploaded copy is not deleted from the API provider.`)) return;
 843    state.attachments = state.attachments.filter(candidate => candidate.id !== fileId);
 844    touchMap();
 845    persistNow();
 846    renderAll();
 847    showToast('File removed from this map');
 848  }
 849
 850  async function ask(question, parentId = state.activeId) {
 851    question = question.trim();
 852    if (!question) return;
 853    if (activeRequest) { showToast('Stop or finish the current response first'); return; }
 854    if (uploadingFiles) { showToast('Wait for the file upload to finish'); return; }
 855    if (!settings.apiKey) { openSettings(); showToast(`Add a ${providerLabel()} API key to begin`); return; }
 856
 857    const requestMap = state;
 858    const connection = { ...settings };
 859    const concise = shortMode;
 860    const node = {
 861      id: uid(), parentId: parentId || null, question, answer: '', status: 'loading', createdAt: Date.now(),
 862      provider: connection.provider, model: connection.model
 863    };
 864    requestMap.nodes.push(node);
 865    requestMap.activeId = node.id;
 866    revealNode(requestMap, node.id);
 867    refreshMapTitle(requestMap);
 868    touchMap(requestMap);
 869    persistSoon();
 870    renderAll({ scroll: true });
 871    setBusy(true);
 872    const controller = new AbortController();
 873    activeRequest = controller;
 874    let completedUsage = null;
 875
 876    try {
 877      const response = await fetch(connection.apiUrl, {
 878        method: 'POST',
 879        headers: requestHeaders(true, connection),
 880        body: JSON.stringify({ model: connection.model, instructions: connection.systemPrompt, input: inputFor(requestMap, parentId, question, connection.provider, concise), stream: true }),
 881        signal: controller.signal
 882      });
 883      if (!response.ok) {
 884        let message = `API request failed (${response.status})`;
 885        try { const data = await response.json(); message = data.error?.message || message; } catch {}
 886        throw new Error(message);
 887      }
 888      if (!response.body) throw new Error('This browser did not provide a streaming response body.');
 889      await consumeSSE(response.body, event => {
 890        if (event.type === 'response.output_text.delta' || event.type === 'response.content_part.delta') node.answer += event.delta || '';
 891        if (event.type === 'response.completed' || event.type === 'response.done') {
 892          node.model = String(event.response?.model || node.model);
 893          completedUsage = event.response?.usage || event.usage || completedUsage;
 894        }
 895        if (event.type === 'response.failed') throw new Error(event.response?.error?.message || 'The model response failed.');
 896      }, () => updateStreamedNode(node));
 897      node.status = 'complete';
 898      if (completedUsage) await recordUsage(node.id, node.model || connection.model, connection.apiUrl, completedUsage, connection.provider);
 899    } catch (error) {
 900      node.status = 'error';
 901      node.error = error.name === 'AbortError' ? 'Response stopped.' : error.message;
 902    } finally {
 903      activeRequest = null;
 904      touchMap(requestMap);
 905      persistSoon();
 906      setBusy(false);
 907      renderAll();
 908    }
 909  }
 910
 911  async function consumeSSE(stream, onEvent, onPaint) {
 912    const reader = stream.getReader();
 913    const decoder = new TextDecoder();
 914    let buffer = '';
 915    let paintQueued = false;
 916    const queuePaint = () => {
 917      if (paintQueued) return;
 918      paintQueued = true;
 919      setTimeout(() => { paintQueued = false; onPaint(); }, 70);
 920    };
 921    const processBlock = block => {
 922      for (const line of block.split(/\r?\n/)) {
 923        if (!line.startsWith('data:')) continue;
 924        const data = line.slice(5).trim();
 925        if (!data || data === '[DONE]') continue;
 926        onEvent(JSON.parse(data));
 927        queuePaint();
 928      }
 929    };
 930    while (true) {
 931      const { done, value } = await reader.read();
 932      buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
 933      const blocks = buffer.split(/\r?\n\r?\n/);
 934      buffer = blocks.pop() || '';
 935      for (const block of blocks) processBlock(block);
 936      if (done) break;
 937    }
 938    if (buffer.trim()) processBlock(buffer);
 939    onPaint();
 940  }
 941
 942  function setBusy(busy) {
 943    els.prompt.disabled = busy;
 944    els.shortMode.disabled = busy;
 945    els.send.textContent = busy ? '■' : '↑';
 946    els.send.setAttribute('aria-label', busy ? 'Stop response' : 'Send message');
 947    els.uploadFile.disabled = busy || uploadingFiles;
 948  }
 949
 950  function openSettings() {
 951    settingsDraft = structuredClone({
 952      activeModelId: settings.activeModelId, apiKeys: settings.apiKeys, models: settings.models
 953    });
 954    els.openaiApiKey.value = settingsDraft.apiKeys.openai || '';
 955    els.openrouterApiKey.value = settingsDraft.apiKeys.openrouter || '';
 956    try { els.openaiAdminKey.value = sessionStorage.getItem(OPENAI_ADMIN_SESSION_KEY) || ''; }
 957    catch { els.openaiAdminKey.value = ''; }
 958    fillModelOptions(els.openaiModelOptions, PROVIDERS.openai.models);
 959    fillModelOptions(els.openrouterModelOptions, PROVIDERS.openrouter.models);
 960    renderModelCards();
 961    els.systemPrompt.value = settings.systemPrompt;
 962    els.settings.showModal();
 963    setTimeout(() => (settings.provider === 'openrouter' ? els.openrouterApiKey : els.openaiApiKey).focus(), 50);
 964  }
 965
 966  function fillModelOptions(list, models) {
 967    list.replaceChildren(...models.map(model => Object.assign(document.createElement('option'), { value: model })));
 968  }
 969
 970  function renderModelCards() {
 971    if (!settingsDraft) return;
 972    els.modelConfigs.innerHTML = settingsDraft.models.map((connection, index) => {
 973      const defaults = PROVIDERS[connection.provider];
 974      const active = connection.id === settingsDraft.activeModelId;
 975      return `<section class="model-config${active ? ' active' : ''}" data-model-card data-model-id="${escapeHTML(connection.id)}" data-provider="${connection.provider}">
 976        <div class="model-config-heading">
 977          <div><span class="provider-logo">${connection.provider === 'openrouter' ? 'OR' : 'OAI'}</span><h4>Model ${index + 1}</h4></div>
 978          <label class="default-model"><input class="default-model-radio" type="radio" name="default-model" value="${escapeHTML(connection.id)}"${active ? ' checked' : ''}> Use by default</label>
 979        </div>
 980        <div class="model-fields">
 981          <label>Provider
 982            <select class="model-provider">
 983              <option value="openai"${connection.provider === 'openai' ? ' selected' : ''}>OpenAI</option>
 984              <option value="openrouter"${connection.provider === 'openrouter' ? ' selected' : ''}>OpenRouter</option>
 985            </select>
 986          </label>
 987          <label>Model
 988            <input class="model-name-input" type="text" value="${escapeHTML(connection.model)}" placeholder="${escapeHTML(defaults.model)}" list="${connection.provider}-model-options" spellcheck="false" required>
 989          </label>
 990        </div>
 991        <div class="model-price" aria-live="polite">Checking pricing…</div>
 992        <details class="provider-advanced">
 993          <summary>Endpoint</summary>
 994          <label>Responses API endpoint
 995            <input class="model-api-url" type="url" value="${escapeHTML(connection.apiUrl)}" spellcheck="false" required>
 996          </label>
 997        </details>
 998        <button class="remove-model" type="button"${settingsDraft.models.length === 1 ? ' disabled' : ''} aria-label="Remove model ${index + 1}">Remove model</button>
 999      </section>`;
1000    }).join('');
1001    updateAllModelPrices();
1002  }
1003
1004  function captureSettingsForm() {
1005    if (!settingsDraft) return;
1006    settingsDraft.apiKeys = {
1007      openai: els.openaiApiKey.value.trim(), openrouter: els.openrouterApiKey.value.trim()
1008    };
1009    settingsDraft.models = [...els.modelConfigs.querySelectorAll('[data-model-card]')].map(card => {
1010      const provider = $('.model-provider', card).value;
1011      return {
1012        id: card.dataset.modelId, provider,
1013        model: $('.model-name-input', card).value.trim() || PROVIDERS[provider].model,
1014        apiUrl: $('.model-api-url', card).value.trim() || PROVIDERS[provider].apiUrl
1015      };
1016    });
1017    settingsDraft.activeModelId = $('input[name="default-model"]:checked', els.modelConfigs)?.value
1018      || settingsDraft.models[0]?.id;
1019    try {
1020      const adminKey = els.openaiAdminKey.value.trim();
1021      if (adminKey) sessionStorage.setItem(OPENAI_ADMIN_SESSION_KEY, adminKey);
1022      else sessionStorage.removeItem(OPENAI_ADMIN_SESSION_KEY);
1023    } catch {}
1024  }
1025
1026  function updateModelCardSelection() {
1027    const selected = $('input[name="default-model"]:checked', els.modelConfigs)?.value;
1028    els.modelConfigs.querySelectorAll('[data-model-card]').forEach(card => {
1029      card.classList.toggle('active', card.dataset.modelId === selected);
1030    });
1031  }
1032
1033  function showToast(message) {
1034    els.toast.textContent = message;
1035    els.toast.classList.add('show');
1036    clearTimeout(toastTimer);
1037    toastTimer = setTimeout(() => els.toast.classList.remove('show'), 2200);
1038  }
1039
1040  function updateKeyStatus() {
1041    els.keyStatus.classList.toggle('ready', Boolean(settings.apiKey));
1042    els.activeModel.textContent = settings.model;
1043    els.activeProvider.textContent = providerLabel();
1044    els.activeConnection.title = `${providerLabel()} · ${settings.model} — open settings`;
1045    els.activeConnection.setAttribute('aria-label', `Current model: ${settings.model} through ${providerLabel()}. Open API settings`);
1046    const button = $('#open-settings');
1047    button.title = `${providerLabel()} · ${settings.model}`;
1048    button.setAttribute('aria-label', `API settings — ${providerLabel()}, ${settings.model}`);
1049  }
1050
1051  function updateThemeButton() {
1052    const dark = document.documentElement.dataset.theme === 'dark';
1053    const button = $('#theme-toggle');
1054    button.querySelector('span').textContent = dark ? '☀' : '☾';
1055    button.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`);
1056    button.title = `Switch to ${dark ? 'light' : 'dark'} theme`;
1057  }
1058
1059  function toggleTheme() {
1060    const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
1061    document.documentElement.dataset.theme = next;
1062    try { localStorage.setItem(THEME_KEY, next); } catch {}
1063    updateThemeButton();
1064  }
1065
1066  function priceForModel(model) {
1067    const key = String(model || '').toLowerCase();
1068    return MODEL_PRICES.find(price => price.match.test(key)) || null;
1069  }
1070
1071  function formatModelRate(value) {
1072    if (!Number.isFinite(value)) return 'Unavailable';
1073    return value.toLocaleString(undefined, {
1074      style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 6
1075    });
1076  }
1077
1078  async function updateModelPrice(card) {
1079    if (!card) return;
1080    const preview = $('.model-price', card);
1081    const provider = $('.model-provider', card)?.value;
1082    const model = $('.model-name-input', card)?.value.trim();
1083    if (!preview) return;
1084    preview.classList.remove('unavailable');
1085    if (!model) { preview.textContent = 'Enter a model name to see pricing.'; return; }
1086    preview.textContent = provider === 'openrouter' ? 'Checking OpenRouter pricing…' : 'Checking pricing…';
1087    const price = provider === 'openrouter' ? await openRouterPriceForModel(model) : priceForModel(model);
1088    if (!card.isConnected || $('.model-provider', card)?.value !== provider || $('.model-name-input', card)?.value.trim() !== model) return;
1089    if (!price) {
1090      preview.textContent = 'Pricing unavailable for this model.';
1091      preview.classList.add('unavailable');
1092      return;
1093    }
1094    preview.classList.remove('unavailable');
1095    preview.innerHTML = `<span>Input <strong>${formatModelRate(price.input)}</strong></span><span>Output <strong>${formatModelRate(price.output)}</strong></span><small>per 1M tokens</small>`;
1096  }
1097
1098  function updateAllModelPrices() {
1099    els.modelConfigs.querySelectorAll('[data-model-card]').forEach(card => updateModelPrice(card));
1100  }
1101
1102  function isOpenAIEndpoint(apiUrl) {
1103    try { return new URL(apiUrl).hostname === 'api.openai.com'; }
1104    catch { return false; }
1105  }
1106
1107  async function openRouterPriceForModel(model) {
1108    const key = String(model || '').trim();
1109    if (!key.includes('/')) return null;
1110    const cached = openRouterPrices[key];
1111    const validCached = cached && Number.isFinite(Number(cached.input)) && Number.isFinite(Number(cached.output));
1112    if (validCached && Date.now() - Number(cached.checkedAt) < PRICE_CACHE_TTL) return cached;
1113    try {
1114      const path = key.split('/').map(encodeURIComponent).join('/');
1115      const response = await fetch(`https://openrouter.ai/api/v1/model/${path}`, {
1116        headers: { 'Accept': 'application/json' }
1117      });
1118      if (!response.ok) return validCached ? cached : null;
1119      const rawPricing = (await response.json()).data?.pricing;
1120      const pricing = Array.isArray(rawPricing) ? rawPricing[0] : rawPricing;
1121      const prompt = pricing?.prompt == null ? NaN : Number(pricing.prompt);
1122      const completion = pricing?.completion == null ? NaN : Number(pricing.completion);
1123      const cacheRead = pricing?.input_cache_read == null ? NaN : Number(pricing.input_cache_read);
1124      if (!Number.isFinite(prompt) || !Number.isFinite(completion)) return validCached ? cached : null;
1125      const price = {
1126        input: prompt * 1_000_000,
1127        cached: (Number.isFinite(cacheRead) ? cacheRead : prompt) * 1_000_000,
1128        output: completion * 1_000_000,
1129        request: Number(pricing?.request) || 0,
1130        checkedAt: Date.now(),
1131        source: 'openrouter'
1132      };
1133      openRouterPrices[key] = price;
1134      persistOpenRouterPrices();
1135      return price;
1136    } catch { return validCached ? cached : null; }
1137  }
1138
1139  async function recordUsage(nodeId, model, apiUrl, rawUsage, provider) {
1140    if (usageRecords.some(record => record.nodeId === nodeId)) return;
1141    const inputTokens = Number(rawUsage.input_tokens) || 0;
1142    const outputTokens = Number(rawUsage.output_tokens) || 0;
1143    const cachedTokens = Math.min(inputTokens, Number(rawUsage.input_tokens_details?.cached_tokens) || 0);
1144    const totalTokens = Number(rawUsage.total_tokens) || inputTokens + outputTokens;
1145    const price = provider === 'openrouter'
1146      ? await openRouterPriceForModel(model)
1147      : (isOpenAIEndpoint(apiUrl) ? priceForModel(model) : null);
1148    const costUSD = price
1149      ? ((((inputTokens - cachedTokens) * price.input) + (cachedTokens * price.cached) + (outputTokens * price.output)) / 1_000_000) + (Number(price.request) || 0)
1150      : null;
1151    usageRecords.push({
1152      id: uid(), nodeId, timestamp: Date.now(), model: String(model || 'unknown'),
1153      inputTokens, cachedTokens, outputTokens, totalTokens, costUSD,
1154      rates: price ? {
1155        input: price.input, cached: price.cached, output: price.output,
1156        request: Number(price.request) || 0, source: price.source || 'openai', checkedAt: price.checkedAt || Date.now()
1157      } : null
1158    });
1159    // Keep bounded history while retaining roughly a year of normal personal use.
1160    usageRecords = usageRecords.slice(-2000);
1161    try { localStorage.setItem(USAGE_KEY, JSON.stringify(usageRecords)); }
1162    catch { showToast('Usage was returned, but local usage history is full'); }
1163    updateUsageButton();
1164  }
1165
1166  function currentMonthRecords() {
1167    const now = new Date();
1168    return usageRecords.filter(record => {
1169      const date = new Date(record.timestamp);
1170      return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
1171    });
1172  }
1173
1174  function sum(records, key) { return records.reduce((total, record) => total + (Number(record[key]) || 0), 0); }
1175
1176  function formatTokens(value, compact = false) {
1177    const number = Number(value) || 0;
1178    if (!compact || number < 1000) return Math.round(number).toLocaleString();
1179    if (number < 1_000_000) return `${(number / 1000).toFixed(number < 10_000 ? 1 : 0)}K`;
1180    return `${(number / 1_000_000).toFixed(number < 10_000_000 ? 1 : 0)}M`;
1181  }
1182
1183  function formatCost(value) {
1184    if (!Number.isFinite(value)) return 'Unpriced';
1185    if (value > 0 && value < .01) return '<$0.01';
1186    return value.toLocaleString(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: value < 1 ? 4 : 2 });
1187  }
1188
1189  function formatDetailedCost(value) {
1190    if (!Number.isFinite(value)) return 'Unpriced';
1191    if (value === 0) return '$0.00';
1192    const decimalPlaces = value > 0 && value < .01 ? 10 : (value < 1 ? 4 : 2);
1193    return value.toLocaleString(undefined, {
1194      style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: decimalPlaces
1195    });
1196  }
1197
1198  function updateUsageButton() {
1199    const total = sum(currentMonthRecords(), 'totalTokens');
1200    els.monthTokens.textContent = formatTokens(total, true);
1201    const label = `${formatTokens(total)} tokens tracked this month`;
1202    $('#open-usage').title = label;
1203    $('#open-usage').setAttribute('aria-label', `${label}. Open API usage details`);
1204  }
1205
1206  function usageRowsByModel(records) {
1207    const groups = new Map();
1208    for (const record of records) {
1209      const model = String(record.model || 'unknown');
1210      if (!groups.has(model)) groups.set(model, { model, totalTokens: 0, costUSD: 0, unpriced: 0 });
1211      const group = groups.get(model);
1212      group.totalTokens += Number(record.totalTokens) || 0;
1213      if (Number.isFinite(record.costUSD)) group.costUSD += record.costUSD;
1214      else group.unpriced += 1;
1215    }
1216    return [...groups.values()].sort((a, b) => b.totalTokens - a.totalTokens);
1217  }
1218
1219  function renderUsage() {
1220    const records = currentMonthRecords().sort((a, b) => b.timestamp - a.timestamp);
1221    const totalCost = records.reduce((total, record) => total + (Number.isFinite(record.costUSD) ? record.costUSD : 0), 0);
1222    const unpriced = records.filter(record => !Number.isFinite(record.costUSD)).length;
1223    els.usageMonth.textContent = new Date().toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
1224    els.usageTotal.textContent = formatTokens(sum(records, 'totalTokens'));
1225    els.usageCost.textContent = records.length && unpriced === records.length
1226      ? 'Unpriced'
1227      : `${formatCost(totalCost)}${unpriced ? ' +' : ''}`;
1228    els.usageRequests.textContent = formatTokens(records.length);
1229    els.usageInput.textContent = formatTokens(sum(records, 'inputTokens'));
1230    els.usageCached.textContent = formatTokens(sum(records, 'cachedTokens'));
1231    els.usageOutput.textContent = formatTokens(sum(records, 'outputTokens'));
1232
1233    const groups = usageRowsByModel(records);
1234    els.usageModels.innerHTML = groups.length ? groups.map(group => `
1235      <div class="usage-row">
1236        <span class="model-name" title="${escapeHTML(group.model)}">${escapeHTML(group.model)}</span>
1237        <span class="row-tokens">${formatTokens(group.totalTokens)} tokens</span>
1238        <span class="row-cost">${group.unpriced ? 'Partly unpriced' : formatCost(group.costUSD)}</span>
1239      </div>`).join('') : '<p class="usage-empty">No tracked requests this month.</p>';
1240
1241    els.usageRecent.innerHTML = records.length ? records.slice(0, 12).map(record => `
1242      <div class="usage-row">
1243        <span class="recent-request-copy">
1244          <span class="request-date">${new Date(record.timestamp).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</span>
1245          <span class="model-name" title="${escapeHTML(record.model || 'unknown')}">${escapeHTML(record.model || 'unknown')}</span>
1246        </span>
1247        <span class="row-tokens">${formatTokens(record.totalTokens)} tokens</span>
1248        <span class="row-cost">${formatDetailedCost(record.costUSD)}</span>
1249      </div>`).join('') : '<p class="usage-empty">Completed requests will appear here.</p>';
1250
1251    els.usageNote.textContent = unpriced
1252      ? `${unpriced} request${unpriced === 1 ? '' : 's'} could not be estimated because its model price could not be resolved.`
1253      : 'Estimates use the rate recorded when each request completed: the local OpenAI table or OpenRouter\'s model-pricing API.';
1254  }
1255
1256  function monthStartUnix() {
1257    const now = new Date();
1258    return Math.floor(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) / 1000);
1259  }
1260
1261  async function responseError(response, fallback) {
1262    try {
1263      const data = await response.json();
1264      return data.error?.message || data.message || fallback;
1265    } catch { return fallback; }
1266  }
1267
1268  async function fetchOpenAIActualSpend(adminKey) {
1269    let page = '';
1270    let total = 0;
1271    let pages = 0;
1272    do {
1273      const params = new URLSearchParams({
1274        start_time: String(monthStartUnix()),
1275        end_time: String(Math.floor(Date.now() / 1000) + 1),
1276        bucket_width: '1d', limit: '31'
1277      });
1278      if (page) params.set('page', page);
1279      const response = await fetch(`https://api.openai.com/v1/organization/costs?${params}`, {
1280        headers: { 'Authorization': `Bearer ${adminKey}`, 'Content-Type': 'application/json' }
1281      });
1282      if (!response.ok) throw new Error(await responseError(response, `OpenAI billing request failed (${response.status})`));
1283      const data = await response.json();
1284      for (const bucket of data.data || []) {
1285        for (const result of bucket.results || []) {
1286          if (result.amount?.currency && result.amount.currency !== 'usd') continue;
1287          total += Number(result.amount?.value) || 0;
1288        }
1289      }
1290      page = data.has_more ? String(data.next_page || '') : '';
1291      pages += 1;
1292    } while (page && pages < 10);
1293    return total;
1294  }
1295
1296  async function fetchOpenRouterActualSpend(apiKey) {
1297    const response = await fetch('https://openrouter.ai/api/v1/key', {
1298      headers: { 'Authorization': `Bearer ${apiKey}`, 'Accept': 'application/json' }
1299    });
1300    if (!response.ok) throw new Error(await responseError(response, `OpenRouter billing request failed (${response.status})`));
1301    const value = Number((await response.json()).data?.usage_monthly);
1302    if (!Number.isFinite(value)) throw new Error('OpenRouter did not return month-to-date usage for this key.');
1303    return value;
1304  }
1305
1306  function setActualSpendState(provider, state, detail = '') {
1307    const cost = provider === 'openai' ? els.actualOpenAICost : els.actualOpenRouterCost;
1308    const status = provider === 'openai' ? els.actualOpenAIStatus : els.actualOpenRouterStatus;
1309    status.classList.toggle('error', state === 'error');
1310    if (state === 'loading') { cost.textContent = '…'; status.textContent = 'Contacting provider…'; }
1311    else if (state === 'missing') { cost.textContent = '—'; status.textContent = detail; }
1312    else if (state === 'error') { cost.textContent = '—'; status.textContent = detail; }
1313    else { cost.textContent = formatCost(detail); status.textContent = 'Retrieved just now'; }
1314  }
1315
1316  async function refreshActualSpend() {
1317    const requestId = ++actualSpendRequest;
1318    const refreshButton = $('#refresh-actual-spend');
1319    const openRouterKey = settings.apiKeys.openrouter;
1320    let openAIAdminKey = '';
1321    try { openAIAdminKey = sessionStorage.getItem(OPENAI_ADMIN_SESSION_KEY) || ''; } catch {}
1322    const tasks = [];
1323    const totals = {};
1324
1325    if (openAIAdminKey) {
1326      setActualSpendState('openai', 'loading');
1327      tasks.push(fetchOpenAIActualSpend(openAIAdminKey)
1328        .then(value => { totals.openai = value; if (requestId === actualSpendRequest) setActualSpendState('openai', 'ready', value); })
1329        .catch(error => { if (requestId === actualSpendRequest) setActualSpendState('openai', 'error', error.message); }));
1330    } else setActualSpendState('openai', 'missing', 'Add an Admin API key in Settings');
1331
1332    if (openRouterKey) {
1333      setActualSpendState('openrouter', 'loading');
1334      tasks.push(fetchOpenRouterActualSpend(openRouterKey)
1335        .then(value => { totals.openrouter = value; if (requestId === actualSpendRequest) setActualSpendState('openrouter', 'ready', value); })
1336        .catch(error => { if (requestId === actualSpendRequest) setActualSpendState('openrouter', 'error', error.message); }));
1337    } else setActualSpendState('openrouter', 'missing', 'Add an OpenRouter API key in Settings');
1338
1339    refreshButton.disabled = true;
1340    els.actualCombinedCost.textContent = '…';
1341    els.actualCombinedStatus.textContent = 'Retrieving available totals…';
1342    await Promise.all(tasks);
1343    if (requestId !== actualSpendRequest) return;
1344    refreshButton.disabled = false;
1345    const available = Object.values(totals);
1346    els.actualCombinedCost.textContent = available.length ? formatCost(available.reduce((sum, value) => sum + value, 0)) : '—';
1347    els.actualCombinedStatus.textContent = available.length === 2 ? 'Both providers' : available.length ? 'One provider available' : 'No provider totals available';
1348  }
1349
1350  function openUsage() {
1351    renderUsage();
1352    els.usageDialog.showModal();
1353    refreshActualSpend();
1354  }
1355
1356  function createNewMap() {
1357    const existingBlank = workspace.maps.find(map => map.nodes.length === 0 && map.attachments.length === 0);
1358    const map = existingBlank || blankMap();
1359    if (!existingBlank) workspace.maps.push(map);
1360    workspace.activeMapId = map.id;
1361    state = map;
1362    els.conversationSearch.value = '';
1363    persistNow();
1364    renderAll();
1365    closeHistoryOnSmallScreen();
1366    window.scrollTo({ top: 0, behavior: 'smooth' });
1367    els.prompt.focus();
1368  }
1369
1370  function switchMap(mapId, nodeId = null) {
1371    const map = workspace.maps.find(candidate => candidate.id === mapId);
1372    if (!map) { closeHistoryOnSmallScreen(); return; }
1373    workspace.activeMapId = map.id;
1374    state = map;
1375    state.activeId = nodeId && nodeById(nodeId, state) ? nodeId : (state.nodes.at(-1)?.id || null);
1376    revealNode(state, state.activeId);
1377    persistSoon();
1378    hideSelectionMenu();
1379    renderAll({ scroll: state.activeId ? (nodeId ? true : 'bottom') : false });
1380    closeHistoryOnSmallScreen();
1381  }
1382
1383  function activateNode(nodeId, scroll = true) {
1384    if (!nodeById(nodeId)) return;
1385    state.activeId = nodeId;
1386    revealNode(state, nodeId);
1387    persistSoon();
1388    renderAll({ scroll });
1389    document.body.classList.remove('map-open');
1390  }
1391
1392  function visibleTreeNodeIds() {
1393    return [...els.tree.querySelectorAll('.tree-node')].map(button => button.dataset.nodeId);
1394  }
1395
1396  function moveVisibleNode(offset) {
1397    const ids = visibleTreeNodeIds();
1398    if (!ids.length) return;
1399    const current = Math.max(0, ids.indexOf(state.activeId));
1400    activateNode(ids[Math.max(0, Math.min(ids.length - 1, current + offset))]);
1401  }
1402
1403  function moveMap(offset) {
1404    const maps = workspace.maps
1405      .filter(map => map.nodes.length || map.attachments.length)
1406      .sort((a, b) => b.updatedAt - a.updatedAt);
1407    if (!maps.length) return;
1408    const current = Math.max(0, maps.findIndex(map => map.id === state.id));
1409    switchMap(maps[Math.max(0, Math.min(maps.length - 1, current + offset))].id);
1410  }
1411
1412  function focusConversationSearch() {
1413    document.body.classList.remove('history-collapsed', 'map-open');
1414    if (matchMedia('(max-width: 1100px)').matches) document.body.classList.add('history-open');
1415    updateHistoryToggle();
1416    els.conversationSearch.focus();
1417    els.conversationSearch.select();
1418  }
1419
1420  function handleVimNavigation(event) {
1421    if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
1422    const editable = event.target.closest?.('input, textarea, select, [contenteditable="true"]');
1423    if (editable) {
1424      if (event.key === 'Escape') {
1425        event.preventDefault();
1426        if (editable === els.conversationSearch) clearSearch();
1427        editable.blur();
1428      }
1429      return;
1430    }
1431    if ($('dialog[open]')) return;
1432    if (pendingG && event.key !== 'g') {
1433      clearTimeout(pendingGTimer);
1434      pendingG = false;
1435    }
1436    if (event.key === '?') { event.preventDefault(); els.shortcutsDialog.showModal(); return; }
1437    if (event.key === '/') { event.preventDefault(); focusConversationSearch(); return; }
1438    if (event.key === 'i') { event.preventDefault(); els.prompt.focus(); return; }
1439    if (event.key === 'j') { event.preventDefault(); moveVisibleNode(1); return; }
1440    if (event.key === 'k') { event.preventDefault(); moveVisibleNode(-1); return; }
1441    if (event.key === 'J') { event.preventDefault(); moveMap(1); return; }
1442    if (event.key === 'K') { event.preventDefault(); moveMap(-1); return; }
1443    if (event.key === 'h') {
1444      const parentId = nodeById(state.activeId)?.parentId;
1445      if (parentId) { event.preventDefault(); activateNode(parentId); }
1446      return;
1447    }
1448    if (event.key === 'l') {
1449      const children = childrenOf(state.activeId);
1450      if (!children.length) return;
1451      event.preventDefault();
1452      if (state.collapsedIds.includes(state.activeId)) toggleSubtree(state.activeId, false);
1453      else activateNode(children[0].id);
1454      return;
1455    }
1456    if (event.key === ' ') {
1457      if (childrenOf(state.activeId).length) { event.preventDefault(); toggleSubtree(state.activeId); }
1458      return;
1459    }
1460    if (event.key === 'G') {
1461      const ids = visibleTreeNodeIds();
1462      if (ids.length) { event.preventDefault(); activateNode(ids.at(-1)); }
1463      return;
1464    }
1465    if (event.key === 'g') {
1466      event.preventDefault();
1467      if (pendingG) {
1468        clearTimeout(pendingGTimer);
1469        pendingG = false;
1470        const ids = visibleTreeNodeIds();
1471        if (ids.length) activateNode(ids[0]);
1472      } else {
1473        pendingG = true;
1474        pendingGTimer = setTimeout(() => { pendingG = false; }, 900);
1475      }
1476    }
1477  }
1478
1479  function closeHistoryOnSmallScreen() {
1480    if (matchMedia('(max-width: 1100px)').matches) document.body.classList.remove('history-open');
1481    updateHistoryToggle();
1482  }
1483
1484  function updateHistoryToggle() {
1485    const small = matchMedia('(max-width: 1100px)').matches;
1486    const open = small ? document.body.classList.contains('history-open') : !document.body.classList.contains('history-collapsed');
1487    els.historyToggle.setAttribute('aria-expanded', String(open));
1488  }
1489
1490  function toggleHistory() {
1491    if (matchMedia('(max-width: 1100px)').matches) {
1492      document.body.classList.remove('history-collapsed');
1493      document.body.classList.toggle('history-open');
1494      if (document.body.classList.contains('history-open')) document.body.classList.remove('map-open');
1495    } else document.body.classList.toggle('history-collapsed');
1496    updateHistoryToggle();
1497  }
1498
1499  function clearChatHistory(message = 'Clear all saved conversations? Your API key and usage records will be kept. Uploaded API files will not be deleted.') {
1500    if (!workspace.maps.some(map => map.nodes.length || map.attachments.length)) { showToast('Chat history is already empty'); return false; }
1501    if (!confirm(message)) return false;
1502    if (activeRequest) activeRequest.abort();
1503    const map = blankMap();
1504    workspace = { version: 2, maps: [map], activeMapId: map.id };
1505    state = map;
1506    searchIndex = null;
1507    els.conversationSearch.value = '';
1508    persistNow();
1509    renderAll();
1510    showToast('All conversations cleared');
1511    return true;
1512  }
1513
1514  function deleteMap(mapId) {
1515    const map = workspace.maps.find(candidate => candidate.id === mapId);
1516    if (!map) return;
1517    if (uploadingFiles && uploadTargetMapId === map.id) { showToast('Wait for this map’s file upload to finish'); return; }
1518    const remoteNote = map.attachments.length ? ' Uploaded API files will not be deleted.' : '';
1519    if (!confirm(`Delete “${map.title}” and its entire conversation? This cannot be undone.${remoteNote}`)) return;
1520    if (activeRequest && map.nodes.some(node => node.status === 'loading')) activeRequest.abort();
1521
1522    workspace.maps = workspace.maps.filter(candidate => candidate.id !== map.id);
1523    searchIndex = null;
1524    if (!workspace.maps.length) workspace.maps.push(blankMap());
1525    if (state === map) {
1526      const next = [...workspace.maps]
1527        .filter(candidate => candidate.nodes.length || candidate.attachments.length)
1528        .sort((a, b) => b.updatedAt - a.updatedAt)[0] || workspace.maps[0];
1529      workspace.activeMapId = next.id;
1530      state = next;
1531      state.activeId = state.nodes.at(-1)?.id || null;
1532      revealNode(state, state.activeId);
1533    }
1534    persistNow();
1535    renderAll({ scroll: state.activeId ? 'bottom' : false });
1536    showToast('Conversation deleted');
1537  }
1538
1539  function renameMap(mapId) {
1540    const map = workspace.maps.find(candidate => candidate.id === mapId);
1541    if (!map) return;
1542    const value = prompt('Rename this conversation. Leave blank to restore its automatic title.', map.title);
1543    if (value === null) return;
1544    const title = value.replace(/\s+/g, ' ').trim();
1545    if (title) {
1546      map.title = title.slice(0, 120);
1547      map.titleCustom = true;
1548    } else {
1549      map.titleCustom = false;
1550      refreshMapTitle(map);
1551    }
1552    touchMap(map);
1553    persistNow();
1554    renderHistory();
1555    showToast(title ? 'Conversation renamed' : 'Automatic title restored');
1556  }
1557
1558  function showSelectionMenu(text, nodeId, rect) {
1559    selectedText = { text: text.slice(0, 5000), nodeId };
1560    els.selectionPreview.textContent = text.length > 240 ? `${text.slice(0, 240)}…` : text;
1561    els.selectionMenu.hidden = false;
1562    const width = 330;
1563    const left = Math.max(12, Math.min(innerWidth - width - 12, rect.left + rect.width / 2 - width / 2));
1564    const estimatedHeight = 235;
1565    const top = rect.bottom + estimatedHeight < innerHeight ? rect.bottom + 10 : Math.max(10, rect.top - estimatedHeight - 8);
1566    Object.assign(els.selectionMenu.style, { left: `${left}px`, top: `${top}px` });
1567  }
1568
1569  function hideSelectionMenu() { els.selectionMenu.hidden = true; selectedText = null; }
1570
1571  function exportCurrentBranch() {
1572    if (!pathTo(state.activeId).length) { showToast('Select a branch to export'); return; }
1573    const previousTitle = document.title;
1574    document.title = `${state.title || 'Study Lab'} — selected branch`;
1575    addEventListener('afterprint', () => { document.title = previousTitle; }, { once: true });
1576    window.print();
1577  }
1578
1579  els.composer.addEventListener('submit', event => {
1580    event.preventDefault();
1581    if (activeRequest) { activeRequest.abort(); return; }
1582    const question = els.prompt.value;
1583    els.prompt.value = '';
1584    els.prompt.style.height = '';
1585    ask(question);
1586  });
1587  els.prompt.addEventListener('keydown', event => {
1588    if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); els.composer.requestSubmit(); }
1589  });
1590  els.prompt.addEventListener('input', () => {
1591    els.prompt.style.height = 'auto';
1592    els.prompt.style.height = `${Math.min(els.prompt.scrollHeight, 180)}px`;
1593  });
1594  els.shortMode.checked = shortMode;
1595  els.shortMode.addEventListener('change', () => {
1596    shortMode = els.shortMode.checked;
1597    localStorage.setItem(SHORT_MODE_KEY, String(shortMode));
1598  });
1599  document.addEventListener('click', event => {
1600    const starter = event.target.closest('.starter');
1601    if (starter) { els.prompt.value = starter.dataset.prompt; els.prompt.focus(); }
1602    const historyItem = event.target.closest('.history-item');
1603    if (historyItem) { switchMap(historyItem.dataset.mapId); return; }
1604    const searchResult = event.target.closest('.search-result');
1605    if (searchResult) {
1606      const { mapId, nodeId } = searchResult.dataset;
1607      clearSearch();
1608      switchMap(mapId, nodeId || null);
1609      return;
1610    }
1611    const renameConversation = event.target.closest('.rename-map');
1612    if (renameConversation) { renameMap(renameConversation.dataset.mapId); return; }
1613    const deleteConversation = event.target.closest('.delete-map');
1614    if (deleteConversation) { deleteMap(deleteConversation.dataset.mapId); return; }
1615    const removeFile = event.target.closest('.remove-attachment');
1616    if (removeFile) { removeAttachment(removeFile.dataset.fileId); return; }
1617    const subtreeToggle = event.target.closest('.toggle-subtree');
1618    if (subtreeToggle) { toggleSubtree(subtreeToggle.dataset.nodeId); return; }
1619    const treeNode = event.target.closest('.tree-node');
1620    if (treeNode) { activateNode(treeNode.dataset.nodeId); return; }
1621    const branch = event.target.closest('.branch-here');
1622    if (branch) { state.activeId = branch.closest('.message').dataset.nodeId; persistSoon(); renderAll(); els.prompt.focus(); }
1623    const retry = event.target.closest('.retry-node');
1624    if (retry) { const old = nodeById(retry.closest('.message').dataset.nodeId); state.activeId = old.parentId; ask(old.question, old.parentId); }
1625    const copy = event.target.closest('.copy-answer');
1626    if (copy) { const node = nodeById(copy.closest('.message').dataset.nodeId); navigator.clipboard.writeText(node.answer).then(() => showToast('Answer copied')); }
1627  });
1628  document.addEventListener('mouseup', event => {
1629    if (event.target.closest('.selection-menu')) return;
1630    requestAnimationFrame(() => {
1631      const selection = getSelection();
1632      const text = selection?.toString().trim();
1633      if (!text || text.length < 2 || !selection.rangeCount) return;
1634      const answer = selection.anchorNode?.parentElement?.closest('.answer-body');
1635      if (!answer || !answer.contains(selection.focusNode)) return;
1636      const message = answer.closest('.message');
1637      showSelectionMenu(text, message.dataset.nodeId, selection.getRangeAt(0).getBoundingClientRect());
1638    });
1639  });
1640
1641  $('#ask-selection').addEventListener('click', () => {
1642    if (!selectedText) return;
1643    const action = els.selectionAction.value === 'custom' ? els.selectionCustom.value.trim() : els.selectionAction.value;
1644    if (!action) { els.selectionCustom.focus(); return; }
1645    const question = `${action}\n\nSelected passage:\n> ${selectedText.text.replace(/\n/g, '\n> ')}`;
1646    const parentId = selectedText.nodeId;
1647    hideSelectionMenu();
1648    getSelection()?.removeAllRanges();
1649    ask(question, parentId);
1650  });
1651  els.selectionAction.addEventListener('change', () => {
1652    els.selectionCustom.hidden = els.selectionAction.value !== 'custom';
1653    if (!els.selectionCustom.hidden) els.selectionCustom.focus();
1654  });
1655  $('#close-selection').addEventListener('click', hideSelectionMenu);
1656  window.addEventListener('scroll', hideSelectionMenu, { passive: true });
1657  $('#open-settings').addEventListener('click', openSettings);
1658  els.activeConnection.addEventListener('click', openSettings);
1659  $('#open-usage').addEventListener('click', openUsage);
1660  $('#refresh-actual-spend').addEventListener('click', refreshActualSpend);
1661  $('#theme-toggle').addEventListener('click', toggleTheme);
1662  els.exportPdf.addEventListener('click', exportCurrentBranch);
1663  $('#toggle-history').addEventListener('click', toggleHistory);
1664  els.conversationSearch.addEventListener('input', () => {
1665    clearTimeout(searchTimer);
1666    searchTimer = setTimeout(renderSearchResults, 110);
1667  });
1668  els.conversationSearch.addEventListener('keydown', event => {
1669    if (event.key !== 'Enter') return;
1670    const first = $('.search-result', els.searchResults);
1671    if (first) { event.preventDefault(); first.click(); }
1672  });
1673  $('#study-lab-home').addEventListener('click', event => { event.preventDefault(); createNewMap(); });
1674  $('#new-map-sidebar').addEventListener('click', createNewMap);
1675  els.uploadFile.addEventListener('click', () => els.fileInput.click());
1676  els.fileInput.addEventListener('change', () => uploadFiles(els.fileInput.files));
1677  $('#close-settings').addEventListener('click', () => els.settings.close());
1678  els.settings.addEventListener('close', () => { settingsDraft = null; });
1679  $('#close-usage').addEventListener('click', () => els.usageDialog.close());
1680  $('#open-shortcuts').addEventListener('click', () => els.shortcutsDialog.showModal());
1681  $('#close-shortcuts').addEventListener('click', () => els.shortcutsDialog.close());
1682  $('#expand-all-nodes').addEventListener('click', () => setAllSubtrees(false));
1683  $('#collapse-all-nodes').addEventListener('click', () => setAllSubtrees(true));
1684  $('#zoom-out-tree').addEventListener('click', () => updateTreeZoom(-.1));
1685  $('#zoom-in-tree').addEventListener('click', () => updateTreeZoom(.1));
1686  $('#reset-usage').addEventListener('click', () => {
1687    if (!usageRecords.length || confirm('Reset the locally tracked API usage history? This cannot be undone.')) {
1688      usageRecords = [];
1689      localStorage.removeItem(USAGE_KEY);
1690      updateUsageButton();
1691      renderUsage();
1692      showToast('Local usage history reset');
1693    }
1694  });
1695  document.querySelectorAll('.toggle-key').forEach(button => button.addEventListener('click', event => {
1696    const input = document.getElementById(event.currentTarget.dataset.keyInput);
1697    const show = input.type === 'password';
1698    input.type = show ? 'text' : 'password';
1699    event.currentTarget.textContent = show ? 'Hide' : 'Show';
1700    const keyName = input.id === 'openrouter-api-key' ? 'OpenRouter' : input.id === 'openai-admin-key' ? 'OpenAI Admin' : 'OpenAI';
1701    event.currentTarget.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${keyName} API key`);
1702  }));
1703  $('#add-model').addEventListener('click', () => {
1704    captureSettingsForm();
1705    const id = uid();
1706    settingsDraft.models.push({ id, provider: 'openai', model: PROVIDERS.openai.model, apiUrl: PROVIDERS.openai.apiUrl });
1707    renderModelCards();
1708    $('.model-name-input', els.modelConfigs.lastElementChild)?.focus();
1709  });
1710  els.modelConfigs.addEventListener('change', event => {
1711    if (event.target.matches('.default-model-radio')) { updateModelCardSelection(); return; }
1712    if (!event.target.matches('.model-provider')) return;
1713    const card = event.target.closest('[data-model-card]');
1714    const previousProvider = card.dataset.provider;
1715    const provider = event.target.value;
1716    captureSettingsForm();
1717    const connection = settingsDraft.models.find(model => model.id === card.dataset.modelId);
1718    if (connection.model === PROVIDERS[previousProvider].model) connection.model = PROVIDERS[provider].model;
1719    if (connection.apiUrl === PROVIDERS[previousProvider].apiUrl) connection.apiUrl = PROVIDERS[provider].apiUrl;
1720    connection.provider = provider;
1721    renderModelCards();
1722  });
1723  els.modelConfigs.addEventListener('input', event => {
1724    if (!event.target.matches('.model-name-input')) return;
1725    const card = event.target.closest('[data-model-card]');
1726    const preview = $('.model-price', card);
1727    if (preview) preview.textContent = 'Checking pricing…';
1728    clearTimeout(modelPriceTimer);
1729    modelPriceTimer = setTimeout(() => updateModelPrice(card), 350);
1730  });
1731  els.modelConfigs.addEventListener('click', event => {
1732    const button = event.target.closest('.remove-model');
1733    if (!button || button.disabled) return;
1734    const id = button.closest('[data-model-card]').dataset.modelId;
1735    captureSettingsForm();
1736    settingsDraft.models = settingsDraft.models.filter(model => model.id !== id);
1737    if (settingsDraft.activeModelId === id) settingsDraft.activeModelId = settingsDraft.models[0].id;
1738    renderModelCards();
1739  });
1740  $('#settings-form').addEventListener('submit', event => {
1741    event.preventDefault();
1742    captureSettingsForm();
1743    settings = normalizeSettings({ ...settingsDraft, systemPrompt: els.systemPrompt.value.trim() });
1744    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
1745    settingsDraft = null;
1746    updateKeyStatus(); renderAttachments(); els.settings.close(); showToast(`${providerLabel()} settings saved`);
1747  });
1748  $('#clear-history').addEventListener('click', () => clearChatHistory());
1749  $('#clear-branch').addEventListener('click', () => { state.activeId = state.nodes.at(-1)?.id || null; revealNode(state, state.activeId); persistSoon(); renderAll({ scroll: true }); });
1750  $('#collapse-map').addEventListener('click', () => { document.body.classList.remove('map-open'); document.body.classList.add('map-collapsed'); $('#expand-map').hidden = false; });
1751  $('#expand-map').addEventListener('click', () => { document.body.classList.remove('map-collapsed', 'history-open'); document.body.classList.add('map-open'); $('#expand-map').hidden = true; updateHistoryToggle(); });
1752  addEventListener('resize', updateHistoryToggle, { passive: true });
1753  document.addEventListener('keydown', handleVimNavigation);
1754
1755  const installButton = $('#install-app');
1756  window.addEventListener('beforeinstallprompt', event => {
1757    event.preventDefault();
1758    installPrompt = event;
1759    installButton.hidden = false;
1760  });
1761  window.addEventListener('appinstalled', () => {
1762    installPrompt = null;
1763    installButton.hidden = true;
1764    showToast('Study Lab installed');
1765  });
1766  installButton.addEventListener('click', async () => {
1767    if (!installPrompt) return;
1768    installPrompt.prompt();
1769    await installPrompt.userChoice;
1770    installPrompt = null;
1771    installButton.hidden = true;
1772  });
1773
1774  if ('serviceWorker' in navigator) {
1775    window.addEventListener('load', () => {
1776      navigator.serviceWorker.register('./service-worker.js').catch(error => {
1777        console.warn('Study Lab service worker registration failed:', error);
1778      });
1779    });
1780  }
1781
1782  updateKeyStatus();
1783  updateThemeButton();
1784  updateUsageButton();
1785  updateHistoryToggle();
1786  updateTreeZoom();
1787  state.activeId = state.nodes.at(-1)?.id || null;
1788  revealNode(state, state.activeId);
1789  renderAll();
1790})();