GPT Branch Tree Navigator (Preview + Jump)

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

Від 29.10.2025. Дивіться остання версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);

})();