GPT Branch Tree Navigator (Preview + Jump)

树状分支 + 预览 + 一键跳转;支持最小化/隐藏与悬浮按钮恢复;快捷键 Alt+T / Alt+M;/ 聚焦搜索、Esc 关闭;拖拽移动面板;渐进式渲染;Markdown 预览;防抖监听;修复:当前分支已渲染却被误判为“未在该分支”。

// ==UserScript==
// @name         GPT Branch Tree Navigator (Preview + Jump)
// @namespace    jiaoling.tools.gpt.tree
// @version      1.5.1
// @description  树状分支 + 预览 + 一键跳转;支持最小化/隐藏与悬浮按钮恢复;快捷键 Alt+T / Alt+M;/ 聚焦搜索、Esc 关闭;拖拽移动面板;渐进式渲染;Markdown 预览;防抖监听;修复:当前分支已渲染却被误判为“未在该分支”。
// @author       Jiaoling
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(() => {
  "use strict";

  /** ================= 配置 ================= **/
  const CONFIG = Object.freeze({
    PANEL_WIDTH_MIN: 320,
    PANEL_WIDTH_VW: 32,
    PANEL_WIDTH_MAX: 520,
    PREVIEW_MAX_CHARS: 200,
    HIGHLIGHT_MS: 1400,
    SCROLL_OFFSET: 80,
    LS_KEY: 'gtt_prefs_v3',
    RENDER_CHUNK: 120,
    RENDER_IDLE_MS: 12,
    OBS_DEBOUNCE_MS: 250,
    SIG_TEXT_LEN: 200,
    SELECTORS: {
      scrollRoot: 'main',
      messageBlocks: [
        '[data-message-author-role]',
        'article:has(.markdown)',
        'main [data-testid^="conversation-turn"]',
        'main .group.w-full',
        'main [data-message-id]'
      ].join(','),
      messageText: [
        '.markdown', '.prose',
        '[data-message-author-role] .whitespace-pre-wrap',
        '[data-message-author-role]'
      ].join(','),
    },
    ENDPOINTS: (cid) => ({
      get: [
        `/backend-api/conversation/${cid}`,
        `/backend-api/conversation/${cid}/`,
      ]
    })
  });

  /** ================= 样式 ================= **/
  const Style = {
    inject(css) {
      try { GM_addStyle(css); }
      catch (_) {
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
      }
    }
  };

  Style.inject(`
    :root{--gtt-cur:#fa8c16;}
    #gtt-panel{
      position:fixed;top:64px;right:12px;z-index:999999;
      width:clamp(${CONFIG.PANEL_WIDTH_MIN}px, ${CONFIG.PANEL_WIDTH_VW}vw, min(${CONFIG.PANEL_WIDTH_MAX}px, calc(100vw - 24px)));
      max-width:min(${CONFIG.PANEL_WIDTH_MAX}px, calc(100vw - 24px));
      max-height:calc(100vh - 84px);display:flex;flex-direction:column;overflow:hidden;
      border-radius:12px;border:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-bg,#fff);
      box-shadow:0 8px 28px rgba(0,0,0,.18);font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Arial;
      user-select:none
    }
    #gtt-header{display:flex;gap:8px;align-items:center;padding:10px;border-bottom:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-hd,#f6f8fa)}
    #gtt-header .title{font-weight:700;flex:1;cursor:move}
    #gtt-header .btn{border:1px solid var(--gtt-bd,#d0d7de);background:#fff;cursor:pointer;padding:4px 8px;border-radius:8px;font-size:12px}
    #gtt-body{display:flex;flex-direction:column;min-height:0}
    #gtt-search{margin:8px 10px;padding:6px 8px;border:1px solid var(--gtt-bd,#d0d7de);border-radius:8px;width:calc(100% - 20px);outline:none;background:var(--gtt-bg,#fff)}
    #gtt-pref{display:flex;gap:10px;align-items:center;padding:0 10px 8px;color:#555;flex-wrap:wrap}
    #gtt-tree{overflow:auto;padding:8px 6px 10px}
    .gtt-node{padding:6px 8px;border-radius:8px;margin:2px 0;cursor:pointer;position:relative;display:flex;flex-direction:column;gap:2px}
    .gtt-node:hover{background:rgba(127,127,255,.08)}
    .gtt-node .head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
    .gtt-node .badge{display:inline-flex;align-items:center;justify-content:center;font-size:10px;padding:1px 5px;border-radius:6px;border:1px solid var(--gtt-bd,#d0d7de);opacity:.75;min-width:18px}
    .gtt-node .title{font-weight:600;word-break:break-word;flex:1 1 auto}
    .gtt-node .meta{opacity:.65;font-size:10px;margin-left:auto;white-space:nowrap}
    .gtt-node .pv{display:block;opacity:.88;margin:0;white-space:normal;word-break:break-word}
    .gtt-children{margin-left:12px;border-left:1px dashed var(--gtt-bd,#d0d7de);padding-left:6px}
    .gtt-hidden{display:none!important}
    .gtt-highlight{outline:3px solid rgba(88,101,242,.65)!important;transition:outline-color .6s ease}
    .gtt-node.gtt-current{background:rgba(250,140,22,.12);border-left:2px solid var(--gtt-cur,#fa8c16);padding-left:10px}
    .gtt-node.gtt-current .badge{border-color:var(--gtt-cur,#fa8c16);color:var(--gtt-cur,#fa8c16);opacity:1}
    .gtt-node.gtt-current-leaf{box-shadow:0 0 0 2px rgba(250,140,22,.24) inset}
    .gtt-children.gtt-current-line{border-left:2px dashed var(--gtt-cur,#fa8c16)}

    #gtt-panel.gtt-min #gtt-body{display:none}

    #gtt-modal{position:fixed;inset:0;z-index:1000000;background:rgba(0,0,0,.42);display:none;align-items:center;justify-content:center}
    #gtt-modal .card{max-width:880px;max-height:80vh;overflow:auto;background:var(--gtt-bg,#fff);border:1px solid var(--gtt-bd,#d0d7de);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.25)}
    #gtt-modal .hd{display:flex;align-items:center;gap:8px;padding:10px;border-bottom:1px solid var(--gtt-bd,#d0d7de);background:var(--gtt-hd,#f6f8fa)}
    #gtt-modal .bd{padding:12px 16px;font-size:14px;line-height:1.65;overflow-x:auto}
    #gtt-modal .bd p{margin:0 0 10px}
    #gtt-modal .bd h1,#gtt-modal .bd h2,#gtt-modal .bd h3,#gtt-modal .bd h4,#gtt-modal .bd h5,#gtt-modal .bd h6{margin:18px 0 10px;font-weight:600}
    #gtt-modal .bd pre{background:rgba(99,110,123,.08);padding:10px 12px;border-radius:8px;margin:12px 0;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px;line-height:1.55;white-space:pre;overflow:auto}
    #gtt-modal .bd code{background:rgba(99,110,123,.2);padding:1px 4px;border-radius:4px;font-family:SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:13px}
    #gtt-modal .bd pre code{background:transparent;padding:0}
    #gtt-modal .bd ul{margin:0 0 12px 18px;padding:0 0 0 12px}
    #gtt-modal .bd li{margin:4px 0}
    #gtt-modal .btn{border:1px solid var(--gtt-bd,#d0d7de);background:#fff;cursor:pointer;padding:4px 8px;border-radius:8px;font-size:12px}

    #gtt-fab{
      position:fixed;right:12px;bottom:16px;z-index:999999;display:none;align-items:center;gap:8px;
      padding:8px 12px;border-radius:999px;border:1px solid var(--gtt-bd,#d0d7de);
      background:var(--gtt-bg,#fff);box-shadow:0 8px 28px rgba(0,0,0,.18);cursor:pointer
    }
    #gtt-fab .dot{width:8px;height:8px;border-radius:50%;background:#5865f2}
    #gtt-fab .txt{font-weight:600}

    @media (prefers-color-scheme: dark){
      :root{--gtt-bg:#0b0e14;--gtt-hd:#0f131a;--gtt-bd:#2b3240;--gtt-cur:#f59b4c;color-scheme:dark}
      #gtt-header .btn,#gtt-modal .btn,#gtt-fab{background:#0b0e14;color:#d1d7e0}
      .gtt-node:hover{background:rgba(120,152,255,.12)}
      .gtt-node.gtt-current{background:rgba(250,140,22,.18)}
    }
  `);

  /** ================= 工具 ================= **/
  const DOM = {
    query(selector, root = document) { return root.querySelector(selector); },
    queryAll(selector, root = document) { return Array.from(root.querySelectorAll(selector)); },
  };

  const Text = {
    normalize(value) {
      return (value || '').replace(/\u200b/g, '').replace(/\s+/g, ' ').trim();
    },
    normalizeForPreview(value) {
      return (value || '').replace(/\u200b/g, '').replace(/\r\n?/g, '\n');
    }
  };

  const Hash = {
    of(value) {
      const input = value || '';
      let h = 0;
      for (let i = 0; i < input.length; i++) {
        h = ((h << 5) - h + input.charCodeAt(i)) | 0;
      }
      return (h >>> 0).toString(36);
    }
  };

  const HTML = {
    ESCAPES: { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" },
    escape(value = '') {
      return value.replace(/[&<>'"]/g, ch => HTML.ESCAPES[ch] || ch);
    },
    escapeAttr(value = '') {
      return HTML.escape(value).replace(/`/g, '&#96;');
    },
    formatInline(text = '') {
      let out = HTML.escape(text);
      out = out.replace(/`([^`]+)`/g, (_m, code) => `<code>${code}</code>`);
      out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url) => `<a href="${HTML.escapeAttr(url)}" target="_blank" rel="noreferrer noopener">${label}</a>`);
      const codeHolders = [];
      out = out.replace(/<code>[^<]*<\/code>/g, (match) => {
        codeHolders.push(match);
        return `\uFFF0${codeHolders.length - 1}\uFFF1`;
      });
      out = out.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
      out = out.replace(/__([^_\n]+)__/g, '<strong>$1</strong>');
      out = out.replace(/(\s|^)\*([^*\n]+)\*(?=\s|[\.,!?:;\)\]\}“”"'`]|$)/g, (_m, pre, body) => `${pre}<em>${body}</em>`);
      out = out.replace(/(\s|^)_(?!_)([^_\n]+)_(?=\s|[\.,!?:;\)\]\}“”"'`]|$)/g, (_m, pre, body) => `${pre}<em>${body}</em>`);
      out = out.replace(/\uFFF0(\d+)\uFFF1/g, (_m, idx) => codeHolders[Number(idx)]);
      return out;
    }
  };

  const Markdown = {
    renderLite(raw = '') {
      const text = Text.normalizeForPreview(raw || '').trimEnd();
      if (!text) return '<p>(空)</p>';
      const lines = text.split('\n');
      let html = '';
      let inList = false;
      let codeBuffer = null;
      let codeLang = '';
      const flushList = () => { if (inList) { html += '</ul>'; inList = false; } };
      const flushCode = () => {
        if (!codeBuffer) return;
        const cls = codeLang ? ` class="lang-${HTML.escapeAttr(codeLang)}"` : '';
        const body = codeBuffer.map(HTML.escape).join('\n');
        html += `<pre><code${cls}>${body}</code></pre>`;
        codeBuffer = null;
        codeLang = '';
      };
      for (const line of lines) {
        const trimmed = line.trim();
        if (/^```/.test(trimmed)) {
          if (codeBuffer) {
            flushCode();
          } else {
            flushList();
            codeBuffer = [];
            codeLang = trimmed.slice(3).trim();
          }
          continue;
        }
        if (codeBuffer) {
          codeBuffer.push(line);
          continue;
        }
        if (!trimmed) {
          flushList();
          html += '<br>';
          continue;
        }
        const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
        if (heading) {
          flushList();
          const level = heading[1].length;
          html += `<h${level}>${HTML.formatInline(heading[2])}</h${level}>`;
          continue;
        }
        const listItem = line.match(/^\s*[-*+]\s+(.*)$/);
        if (listItem) {
          if (!inList) {
            html += '<ul>';
            inList = true;
          }
          html += `<li>${HTML.formatInline(listItem[1])}</li>`;
          continue;
        }
        flushList();
        html += `<p>${HTML.formatInline(line)}</p>`;
      }
      flushCode();
      flushList();
      return html;
    }
  };

  const Timing = {
    rafIdle(fn, ms = CONFIG.RENDER_IDLE_MS) { return setTimeout(fn, ms); },
    debounce(fn, wait) {
      let timer;
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), wait);
      };
    }
  };

  const Location = {
    getConversationId() {
      const match = location.pathname.match(/\/c\/([a-z0-9-]{10,})/i) || [];
      return match[1] || null;
    }
  };

  const Signature = {
    create(role, text) {
      return (role || 'assistant') + '|' + Hash.of(Text.normalize(text).slice(0, CONFIG.SIG_TEXT_LEN));
    }
  };

  /** ================= 偏好 ================= **/
  const Prefs = (() => {
    const defaults = { minimized: false, hidden: false, pos: null };

    function load() {
      try {
        const raw = localStorage.getItem(CONFIG.LS_KEY) || localStorage.getItem('gtt_prefs_v2');
        const parsed = raw ? JSON.parse(raw) : {};
        return { ...defaults, ...parsed };
      } catch (_) {
        return { ...defaults };
      }
    }

    let state = load();

    function save() {
      try { localStorage.setItem(CONFIG.LS_KEY, JSON.stringify(state)); }
      catch (_) { /* ignore */ }
    }

    function set(key, value, { silent = false } = {}) {
      state = { ...state, [key]: value };
      if (!silent) save();
    }

    function assign(patch, { silent = false } = {}) {
      state = { ...state, ...patch };
      if (!silent) save();
    }

    return {
      defaults,
      snapshot: () => ({ ...state }),
      get: (key) => state[key],
      set,
      assign,
      save,
    };
  })();

  /** ================= 授权 ================= **/
  const Auth = (() => {
    const origFetch = window.fetch.bind(window);
    let lastAuth = null;
    let patched = false;

    function extractAuthHeaders(input, init) {
      try {
        if (input instanceof Request) {
          const headers = Object.fromEntries(input.headers.entries());
          return headers.authorization || headers.Authorization || null;
        }
        const headers = init?.headers;
        if (headers instanceof Headers) {
          return headers.get('authorization') || headers.get('Authorization');
        }
        if (headers && typeof headers === 'object') {
          return headers.authorization || headers.Authorization || null;
        }
      } catch (_) {
        return null;
      }
      return null;
    }

    function rememberAuth(authHeader) {
      if (!authHeader || lastAuth) return;
      lastAuth = { Authorization: authHeader };
    }

    async function ensureAuth() {
      if (lastAuth?.Authorization) return lastAuth;
      try {
        const res = await origFetch('/api/auth/session', { credentials: 'include' });
        if (res.ok) {
          const data = await res.json();
          if (data?.accessToken) {
            lastAuth = { Authorization: `Bearer ${data.accessToken}` };
            return lastAuth;
          }
        }
      } catch (_) {
        /* ignore */
      }
      return lastAuth || {};
    }

    function withHeaders(extra = {}) {
      return { ...(lastAuth || {}), ...extra };
    }

    function patch(onMapping) {
      if (patched) return;
      patched = true;
      window.fetch = async (...args) => {
        const [input, init] = args;
        const authHeader = extractAuthHeaders(input, init);
        if (authHeader) rememberAuth(authHeader);
        const response = await origFetch(...args);
        try {
          const url = typeof input === 'string' ? input : (input?.url || '');
          if (/\/backend-api\/conversation\//.test(url)) {
            const clone = response.clone();
            const json = await clone.json();
            if (json?.mapping) {
              onMapping(json.mapping);
            }
          }
        } catch (_) {
          /* ignore parsing errors */
        }
        return response;
      };
    }

    return { ensureAuth, withHeaders, patch, origFetch };
  })();

  /** ================= 树状态 ================= **/
  const TreeState = {
    mapping: null,
    domBySig: new Map(),
    domById: new Map(),
    currentBranchIds: new Set(),
    currentBranchSigs: new Set(),
    currentBranchLeafId: null,
    currentBranchLeafSig: null,
  };

  /** ================= 模态 & 跳转 ================= **/
  const Navigator = (() => {
    const SCROLLABLE_VALUES = new Set(['auto', 'scroll', 'overlay']);

    function findScrollContainer(el) {
      const rootSel = CONFIG.SELECTORS?.scrollRoot;
      if (rootSel) {
        const root = document.querySelector(rootSel);
        if (root && root.contains(el) && root.scrollHeight > root.clientHeight + 8) {
          return root;
        }
      }
      let cur = el?.parentElement;
      while (cur && cur !== document.body) {
        const style = getComputedStyle(cur);
        if ((SCROLLABLE_VALUES.has(style.overflowY) || SCROLLABLE_VALUES.has(style.overflow)) && cur.scrollHeight > cur.clientHeight + 8) {
          return cur;
        }
        cur = cur.parentElement;
      }
      return document.scrollingElement || document.documentElement;
    }

    function scrollToEl(el) {
      if (!el) return;
      const container = findScrollContainer(el);
      if (container && container !== document.body && container !== document.documentElement) {
        const rect = el.getBoundingClientRect();
        const parentRect = container.getBoundingClientRect();
        const offset = rect.top - parentRect.top + container.scrollTop - CONFIG.SCROLL_OFFSET;
        container.scrollTo({ top: offset, behavior: 'smooth' });
      } else {
        const offset = el.getBoundingClientRect().top + window.scrollY - CONFIG.SCROLL_OFFSET;
        window.scrollTo({ top: offset, behavior: 'smooth' });
      }
      el.classList.add('gtt-highlight');
      setTimeout(() => el.classList.remove('gtt-highlight'), CONFIG.HIGHLIGHT_MS);
    }

    function locateByText(text) {
      const snippet = Text.normalize(text).slice(0, 120);
      if (!snippet) return null;
      const blocks = DOM.queryAll(CONFIG.SELECTORS.messageBlocks);
      let best = null;
      let score = -1;
      for (const el of blocks) {
        const textEl = DOM.query(CONFIG.SELECTORS.messageText, el) || el;
        const normalized = Text.normalize(textEl?.innerText || '');
        const idx = normalized.indexOf(snippet);
        if (idx >= 0) {
          const sc = 3000 - idx + Math.min(120, snippet.length);
          if (sc > score) {
            score = sc;
            best = el;
          }
        }
      }
      return best;
    }

    function openModal(text, reason) {
      const body = DOM.query('#gtt-md-body');
      const title = DOM.query('#gtt-md-title');
      const modal = DOM.query('#gtt-modal');
      if (!body || !title || !modal) return;
      body.innerHTML = Markdown.renderLite(text);
      title.textContent = reason || '节点预览(未能定位到页面元素,已为你展示文本)';
      modal.style.display = 'flex';
    }

    function closeModal() {
      const modal = DOM.query('#gtt-modal');
      const body = DOM.query('#gtt-md-body');
      if (modal) modal.style.display = 'none';
      if (body) body.innerHTML = '';
    }

    async function jumpTo(node) {
      if (!node) return;
      let target = TreeState.domById.get(node.id);
      if (target && target.isConnected) return scrollToEl(target);
      const sig = node.sig || Signature.create(node.role, node.text);
      target = TreeState.domBySig.get(sig);
      if (target && target.isConnected) return scrollToEl(target);
      target = locateByText(node.text);
      if (target) return scrollToEl(target);
      openModal(node.text || '(无文本)', '节点预览(未能定位到页面元素,已为你展示文本)');
    }

    return { jumpTo, openModal, closeModal };
  })();

  /** ================= 面板 ================= **/
  const Panel = (() => {
    function ensureFab() {
      if (DOM.query('#gtt-fab')) return;
      const fab = document.createElement('div');
      fab.id = 'gtt-fab';
      fab.innerHTML = `<span class="dot"></span><span class="txt">GPT Tree</span>`;
      fab.addEventListener('click', () => setHidden(false));
      document.body.appendChild(fab);
    }

    function ensurePanel() {
      if (DOM.query('#gtt-panel')) return;
      const panel = document.createElement('div');
      panel.id = 'gtt-panel';
      panel.innerHTML = `
        <div id="gtt-header">
          <div class="title" id="gtt-drag">GPT Tree</div>
          <button class="btn" id="gtt-btn-min" title="最小化/还原(Alt+M)">最小化</button>
          <button class="btn" id="gtt-btn-refresh">刷新</button>
          <button class="btn" id="gtt-btn-collapse">折叠</button>
          <button class="btn" id="gtt-btn-hide" title="隐藏(Alt+T)">隐藏</button>
        </div>
        <div id="gtt-body">
          <input id="gtt-search" placeholder="搜索节点(文本/角色)… / 聚焦,Esc 清除">
          <div id="gtt-pref">
            <span style="opacity:.65" id="gtt-stats"></span>
          </div>
          <div id="gtt-tree"></div>
        </div>
        <div id="gtt-modal">
          <div class="card">
            <div class="hd">
              <div style="font-weight:700;flex:1" id="gtt-md-title">节点预览</div>
              <button class="btn" id="gtt-md-close">关闭</button>
            </div>
            <div class="bd" id="gtt-md-body"></div>
          </div>
        </div>
      `;
      document.body.appendChild(panel);
      bindPanel(panel);
      applyState(panel);
    }

    function bindPanel(panel) {
      const btnMin = DOM.query('#gtt-btn-min', panel);
      const btnHide = DOM.query('#gtt-btn-hide', panel);
      const btnRefresh = DOM.query('#gtt-btn-refresh', panel);
      const btnCollapse = DOM.query('#gtt-btn-collapse', panel);
      const btnCloseModal = DOM.query('#gtt-md-close', panel);
      const header = DOM.query('#gtt-header', panel);
      const dragHandle = DOM.query('#gtt-drag', panel);
      const inputSearch = DOM.query('#gtt-search', panel);

      if (btnMin) btnMin.addEventListener('click', () => setMinimized(!Prefs.get('minimized')));
      if (btnHide) btnHide.addEventListener('click', () => setHidden(true));
      if (btnRefresh) btnRefresh.addEventListener('click', () => Lifecycle.rebuild({ forceFetch: true, hard: true }));
      if (btnCollapse) btnCollapse.addEventListener('click', toggleCollapseAll);
      if (btnCloseModal) btnCloseModal.addEventListener('click', Navigator.closeModal);
      if (header) header.addEventListener('dblclick', () => setMinimized(!Prefs.get('minimized')));

      if (inputSearch) {
        const handleSearch = Timing.debounce((e) => {
          const query = (typeof e === 'string' ? e : (e?.target?.value || '')).trim().toLowerCase();
          DOM.queryAll('#gtt-tree .gtt-node').forEach(node => {
            node.style.display = node.textContent.toLowerCase().includes(query) ? '' : 'none';
          });
        }, 120);
        inputSearch.addEventListener('input', handleSearch);
      }

      if (dragHandle) enableDrag(panel, dragHandle);
    }

    function applyState(panel) {
      setMinimized(Prefs.get('minimized'), { silent: true });
      setHidden(Prefs.get('hidden'), { silent: true });
      applyPosition(panel);
    }

    function applyPosition(panel = DOM.query('#gtt-panel')) {
      if (!panel) return;
      const pos = Prefs.get('pos');
      if (pos) {
        panel.style.left = `${pos.left}px`;
        panel.style.top = `${pos.top}px`;
        panel.style.right = 'auto';
      }
    }

    function rememberPosition(panel) {
      if (!panel) return;
      const rect = panel.getBoundingClientRect();
      Prefs.set('pos', { left: Math.round(rect.left), top: Math.round(rect.top) });
    }

    function enableDrag(panel, handle) {
      let dragging = false;
      let startX = 0;
      let startY = 0;
      let startLeft = 0;
      let startTop = 0;

      handle.addEventListener('mousedown', (e) => {
        dragging = true;
        startX = e.clientX;
        startY = e.clientY;
        const rect = panel.getBoundingClientRect();
        startLeft = rect.left;
        startTop = rect.top;
        panel.style.right = 'auto';
        const onMove = (ev) => {
          if (!dragging) return;
          const left = startLeft + (ev.clientX - startX);
          const top = startTop + (ev.clientY - startY);
          panel.style.left = `${Math.max(8, left)}px`;
          panel.style.top = `${Math.max(8, top)}px`;
        };
        const onUp = () => {
          dragging = false;
          document.removeEventListener('mousemove', onMove);
          rememberPosition(panel);
        };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp, { once: true });
      });
    }

    function toggleCollapseAll() {
      DOM.queryAll('.gtt-children').forEach(el => el.classList.toggle('gtt-hidden'));
    }

    function setHidden(value, { silent = false } = {}) {
      const panel = DOM.query('#gtt-panel');
      const fab = DOM.query('#gtt-fab');
      if (!panel || !fab) return;
      if (value) {
        panel.style.display = 'none';
        fab.style.display = 'inline-flex';
      } else {
        panel.style.display = 'flex';
        fab.style.display = 'none';
      }
      Prefs.set('hidden', !!value, { silent });
    }

    function setMinimized(value, { silent = false } = {}) {
      const panel = DOM.query('#gtt-panel');
      const btn = DOM.query('#gtt-btn-min');
      if (!panel || !btn) return;
      panel.classList.toggle('gtt-min', !!value);
      btn.textContent = value ? '还原' : '最小化';
      Prefs.set('minimized', !!value, { silent });
    }

    function updateStats(total) {
      const el = DOM.query('#gtt-stats');
      if (el) el.textContent = total ? `节点:${total}` : '';
    }

    return {
      ensure: () => { ensureFab(); ensurePanel(); },
      ensureFab,
      ensurePanel,
      setHidden,
      setMinimized,
      toggleCollapseAll,
      updateStats,
      applyPosition,
    };
  })();

  /** ================= 分支高亮 ================= **/
  const BranchHighlighter = (() => {
    function clear(rootEl) {
      const nodeEls = rootEl.querySelectorAll('.gtt-node');
      const connectorEls = rootEl.querySelectorAll('.gtt-children');
      nodeEls.forEach(el => el.classList.remove('gtt-current', 'gtt-current-leaf'));
      connectorEls.forEach(el => el.classList.remove('gtt-current-line'));
    }

    function apply(rootEl = DOM.query('#gtt-tree')) {
      if (!rootEl) return;
      clear(rootEl);
      const hasBranch = (TreeState.currentBranchIds.size || TreeState.currentBranchSigs.size);
      if (!hasBranch) return;
      const nodeEls = rootEl.querySelectorAll('.gtt-node');
      nodeEls.forEach(el => {
        const id = el.dataset?.nodeId;
        const sig = el.dataset?.sig;
        const chainIds = Array.isArray(el._chainIds) ? el._chainIds : null;
        const chainSigs = Array.isArray(el._chainSigs) ? el._chainSigs : null;
        const matchesId = id && TreeState.currentBranchIds.has(id);
        const matchesSig = sig && TreeState.currentBranchSigs.has(sig);
        const matchesChainId = chainIds ? chainIds.some(cid => TreeState.currentBranchIds.has(cid)) : false;
        const matchesChainSig = chainSigs ? chainSigs.some(cs => TreeState.currentBranchSigs.has(cs)) : false;
        const isCurrent = matchesId || matchesSig || matchesChainId || matchesChainSig;
        if (!isCurrent) return;
        el.classList.add('gtt-current');
        const isLeaf = (
          (TreeState.currentBranchLeafId && (id === TreeState.currentBranchLeafId || (chainIds && chainIds.includes(TreeState.currentBranchLeafId)))) ||
          (TreeState.currentBranchLeafSig && (sig === TreeState.currentBranchLeafSig || (chainSigs && chainSigs.includes(TreeState.currentBranchLeafSig))))
        );
        if (isLeaf) el.classList.add('gtt-current-leaf');
        const parent = el.parentElement;
        if (parent?.classList?.contains('gtt-children')) {
          parent.classList.add('gtt-current-line');
        }
      });
    }

    return { apply };
  })();

  /** ================= 构树 ================= **/
  const Tree = (() => {
    function preview(text, limit = CONFIG.PREVIEW_MAX_CHARS) {
      const normalized = Text.normalize(text);
      return normalized.length > limit ? `${normalized.slice(0, limit)}…` : normalized;
    }

    function isToolishRole(role) {
      return role === 'tool' || role === 'system' || role === 'function';
    }

    function getRecText(rec) {
      const parts = rec?.message?.content?.parts ?? [];
      if (Array.isArray(parts)) return parts.join('\n');
      if (typeof parts === 'string') return parts;
      return '';
    }

    function isVisibleRec(rec) {
      if (!rec) return false;
      const role = rec?.message?.author?.role || 'assistant';
      if (isToolishRole(role)) return false;
      const text = getRecText(rec);
      return !!Text.normalize(text);
    }

    function visibleParentId(mapping, id) {
      let cur = id;
      let guard = 0;
      while (guard++ < 4096) {
        const parentId = mapping[cur]?.parent;
        if (parentId == null) return null;
        const parentRec = mapping[parentId];
        if (isVisibleRec(parentRec)) return parentId;
        cur = parentId;
      }
      return null;
    }

    function dedupBySig(ids, mapping) {
      const seen = new Set();
      const out = [];
      for (const cid of ids) {
        const rec = mapping[cid];
        if (!rec) continue;
        const role = rec?.message?.author?.role || 'assistant';
        const text = Text.normalize(getRecText(rec));
        const sig = Signature.create(role, text);
        if (!seen.has(sig)) {
          seen.add(sig);
          out.push(cid);
        }
      }
      return out;
    }

    function foldSameRoleChain(startId, mapping, childrenMap) {
      let cur = startId;
      let rec = mapping[cur];
      const role = rec?.message?.author?.role || 'assistant';
      let text = getRecText(rec);
      let guard = 0;
      const chainIds = [];
      const chainSigs = [];
      while (rec && guard++ < 4096) {
        const curText = getRecText(rec);
        if (curText) {
          chainIds.push(cur);
          chainSigs.push(Signature.create(role, curText));
        }
        const kids = childrenMap.get(cur) || [];
        if (kids.length !== 1) break;
        const kidId = kids[0];
        const kidRec = mapping[kidId];
        const kidRole = kidRec?.message?.author?.role || 'assistant';
        const kidText = getRecText(kidRec);
        if (kidRole === role && kidText && text) {
          text = `${text}\n${kidText}`.trim();
          cur = kidId;
          rec = kidRec;
          continue;
        }
        break;
      }
      return { id: cur, role, text, chainIds, chainSigs };
    }

    function mappingToTree(mapping) {
      const visibleIds = Object.keys(mapping).filter(id => isVisibleRec(mapping[id]));
      const parentMap = new Map();
      for (const vid of visibleIds) {
        parentMap.set(vid, visibleParentId(mapping, vid));
      }
      const childrenMap = new Map(visibleIds.map(id => [id, []]));
      for (const vid of visibleIds) {
        const parentId = parentMap.get(vid);
        if (parentId && childrenMap.has(parentId)) {
          childrenMap.get(parentId).push(vid);
        }
      }
      for (const [pid, arr] of childrenMap.entries()) {
        childrenMap.set(pid, dedupBySig(arr, mapping));
      }
      const roots = visibleIds.filter(id => parentMap.get(id) == null);

      const toNode = (id) => {
        const folded = foldSameRoleChain(id, mapping, childrenMap);
        const currentId = folded.id;
        const currentRole = folded.role;
        const currentText = folded.text;
        const sig = Signature.create(currentRole, currentText);
        const chainIds = folded.chainIds?.length ? folded.chainIds : [currentId];
        const chainSigs = folded.chainSigs?.length ? folded.chainSigs : [sig];
        const children = (childrenMap.get(currentId) || []).map(toNode).filter(Boolean);
        return { id: currentId, role: currentRole, text: currentText, sig, chainIds, chainSigs, children };
      };

      return roots.map(toNode).filter(Boolean);
    }

    function linearToTree(linear) {
      const nodes = [];
      for (let i = 0; i < linear.length; i++) {
        const current = linear[i];
        if (current.role === 'user') {
          const next = linear[i + 1];
          const pair = { id: current.id, role: 'user', text: current.text, sig: current.sig, children: [] };
          if (next && next.role === 'assistant') {
            pair.children.push({ id: next.id, role: 'assistant', text: next.text, sig: next.sig, children: [] });
          }
          nodes.push(pair);
        } else {
          nodes.push({ id: current.id, role: 'assistant', text: current.text, sig: current.sig, children: [] });
        }
      }
      return nodes;
    }

    function renderTreeGradually(targetEl, treeData) {
      targetEl.innerHTML = '';
      const stats = { total: 0 };
      const container = document.createDocumentFragment();
      const queue = [];

      const pushList = (nodes, parent) => { for (const node of nodes) queue.push({ node, parent }); };

      const createItem = (node) => {
        const item = document.createElement('div');
        item.className = 'gtt-node';
        item.dataset.nodeId = node.id;
        item.dataset.sig = node.sig;
        item.title = `${node.id}\n\n${node.text || ''}`;
        if (node.chainIds) item._chainIds = node.chainIds;
        if (node.chainSigs) item._chainSigs = node.chainSigs;
        const head = document.createElement('div');
        head.className = 'head';
        const badge = document.createElement('span');
        badge.className = 'badge';
        badge.textContent = node.role === 'user'
          ? 'U'
          : (node.role === 'assistant' ? 'A' : (node.role || '·'));
        const title = document.createElement('span');
        title.className = 'title';
        title.textContent = node.role === 'user' ? '用户' : 'Asst';
        const meta = document.createElement('span');
        meta.className = 'meta';
        meta.textContent = node.children?.length ? `×${node.children.length}` : '';
        const pv = document.createElement('span');
        pv.className = 'pv';
        pv.textContent = preview(node.text);
        head.append(badge, title);
        if (meta.textContent) head.append(meta);
        item.append(head, pv);
        item.addEventListener('click', () => Navigator.jumpTo(node));
        return item;
      };

      const rootDiv = document.createElement('div');
      container.appendChild(rootDiv);
      pushList(treeData, rootDiv);

      const step = () => {
        let count = 0;
        while (count < CONFIG.RENDER_CHUNK && queue.length) {
          const { node, parent } = queue.shift();
          const item = createItem(node);
          parent.appendChild(item);
          stats.total++;
          if (node.children?.length) {
            const kids = document.createElement('div');
            kids.className = 'gtt-children';
            parent.appendChild(kids);
            pushList(node.children, kids);
          }
          count++;
        }
        if (queue.length) {
          Timing.rafIdle(step);
        } else {
          targetEl.appendChild(container);
          Panel.updateStats(stats.total);
          BranchHighlighter.apply(targetEl);
        }
      };

      step();
    }

    function harvestLinearNodes() {
      const blocks = DOM.queryAll(CONFIG.SELECTORS.messageBlocks);
      const result = [];
      const ids = new Set();
      const sigs = new Set();
      const domBySig = new Map();
      const domById = new Map();

      for (const el of blocks) {
        const textEl = DOM.query(CONFIG.SELECTORS.messageText, el) || el;
        const raw = (textEl?.innerText || '').trim();
        const text = Text.normalize(raw);
        if (!text) continue;
        let role = el.getAttribute('data-message-author-role');
        if (!role) role = el.querySelector('.markdown,.prose') ? 'assistant' : 'user';
        const messageId = el.getAttribute('data-message-id') || el.dataset?.messageId || DOM.query('[data-message-id]', el)?.getAttribute('data-message-id') || (el.id?.startsWith('conversation-turn-') ? el.id.split('conversation-turn-')[1] : null);
        const id = messageId ? messageId : (`lin-${Hash.of(text.slice(0, 80))}`);
        const sig = Signature.create(role, text);
        const record = { id, role, text, sig, _el: el };
        result.push(record);
        domBySig.set(sig, el);
        ids.add(id);
        sigs.add(sig);
        if (messageId) domById.set(messageId, el);
      }

      TreeState.domBySig = domBySig;
      TreeState.domById = domById;
      TreeState.currentBranchIds = ids;
      TreeState.currentBranchSigs = sigs;
      if (result.length) {
        const leaf = result[result.length - 1];
        TreeState.currentBranchLeafId = leaf?.id || null;
        TreeState.currentBranchLeafSig = leaf?.sig || null;
      } else {
        TreeState.currentBranchLeafId = null;
        TreeState.currentBranchLeafSig = null;
      }

      BranchHighlighter.apply();
      return result;
    }

    function buildFromMapping(mapping) {
      const treeEl = DOM.query('#gtt-tree');
      if (!treeEl) return;
      const treeData = mappingToTree(mapping);
      renderTreeGradually(treeEl, treeData);
    }

    function buildFromLinear(linear) {
      const treeEl = DOM.query('#gtt-tree');
      if (!treeEl) return;
      const treeData = linearToTree(linear);
      renderTreeGradually(treeEl, treeData);
    }

    return { harvestLinearNodes, buildFromMapping, buildFromLinear };
  })();

  /** ================= 数据层 ================= **/
  const Data = (() => {
    const fetchCtl = { token: 0 };

    async function fetchMapping() {
      const currentToken = ++fetchCtl.token;
      await Auth.ensureAuth();
      const cid = Location.getConversationId();
      if (!cid) return null;
      const { get: urls } = CONFIG.ENDPOINTS(cid);
      for (const url of urls) {
        try {
          const response = await Auth.origFetch(url, { credentials: 'include', headers: Auth.withHeaders() });
          if (currentToken !== fetchCtl.token) return null;
          if (response.ok) {
            const json = await response.json();
            if (json?.mapping) return json.mapping;
          }
        } catch (_) {
          /* ignore network errors */
        }
      }
      return null;
    }

    return { fetchMapping };
  })();

  /** ================= 监听 ================= **/
  const Observers = (() => {
    const observer = new MutationObserver(Timing.debounce(() => {
      Tree.harvestLinearNodes();
    }, CONFIG.OBS_DEBOUNCE_MS));

    function start() {
      observer.observe(document.body, { childList: true, subtree: true });
    }

    function stop() {
      observer.disconnect();
    }

    return { start, stop };
  })();

  /** ================= 路由感知 ================= **/
  const Router = (() => {
    function hook(onChange) {
      const origPush = history.pushState;
      const origReplace = history.replaceState;
      function fire() { window.dispatchEvent(new Event('gtt:locationchange')); }
      history.pushState = function () {
        const result = origPush.apply(this, arguments);
        fire();
        return result;
      };
      history.replaceState = function () {
        const result = origReplace.apply(this, arguments);
        fire();
        return result;
      };
      window.addEventListener('popstate', fire);
      window.addEventListener('gtt:locationchange', onChange);
      window.addEventListener('popstate', onChange);
    }

    return { hook };
  })();

  /** ================= 键盘 ================= **/
  const Keyboard = (() => {
    function bind() {
      document.addEventListener('keydown', (e) => {
        const searchInput = DOM.query('#gtt-search');
        if (e.key === '/' && !e.metaKey && !e.ctrlKey) {
          e.preventDefault();
          searchInput?.focus();
        }
        if (e.key === 'Escape') {
          const modal = DOM.query('#gtt-modal');
          if (modal?.style?.display === 'flex') {
            Navigator.closeModal();
          } else if (searchInput) {
            searchInput.value = '';
            searchInput.dispatchEvent(new Event('input'));
          }
        }
        if (!e.altKey) return;
        if (e.key === 't' || e.key === 'T') {
          e.preventDefault();
          Panel.setHidden(!Prefs.get('hidden'));
        }
        if (e.key === 'm' || e.key === 'M') {
          e.preventDefault();
          Panel.setMinimized(!Prefs.get('minimized'));
        }
      });
    }

    return { bind };
  })();

  /** ================= 生命周期 ================= **/
  const Lifecycle = (() => {
    async function rebuild(opts = {}) {
      Panel.ensure();
      if (opts.hard) TreeState.mapping = null;
      const linearNodes = Tree.harvestLinearNodes();
      if (opts.forceFetch || !TreeState.mapping) {
        const mapping = await Data.fetchMapping();
        if (mapping) {
          TreeState.mapping = mapping;
          Tree.buildFromMapping(mapping);
          return;
        }
      }
      if (TreeState.mapping) {
        Tree.buildFromMapping(TreeState.mapping);
      } else {
        Tree.buildFromLinear(linearNodes);
      }
    }

    function handleMappingFromFetch(mapping) {
      if (!mapping) return;
      TreeState.mapping = mapping;
      Panel.ensure();
      Tree.buildFromMapping(mapping);
    }

    function boot() {
      Panel.ensureFab();
      Panel.ensurePanel();
      Observers.start();
      Router.hook(async () => {
        await rebuild({ forceFetch: true, hard: true });
      });
      Keyboard.bind();
      rebuild();
    }

    return { rebuild, handleMappingFromFetch, boot };
  })();

  /** ================= 启动 ================= **/
  Auth.patch(Lifecycle.handleMappingFromFetch);

  const readyTimer = setInterval(() => {
    if (document.querySelector('main')) {
      clearInterval(readyTimer);
      Lifecycle.boot();
    }
  }, 300);

})();