GPT Branch Tree Navigator (Preview + Jump)

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

اعتبارا من 21-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.

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

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         GPT Branch Tree Navigator (Preview + Jump)
// @namespace    jiaoling.tools.gpt.tree
// @version      1.4.2
// @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 = {
    PANEL_WIDTH: 360,
    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,        // DOM 监听防抖
    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]' // 新增:直接抓取包含 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 injectStyle = (css) => {
    try { GM_addStyle(css); } catch (_) {
      const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);
    }
  };

  injectStyle(`
    :root{--gtt-cur:#fa8c16;}
    #gtt-panel{
      position:fixed;top:64px;right:12px;z-index:999999;width:${CONFIG.PANEL_WIDTH}px;
      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 6px 6px 8px;border-radius:8px;margin:2px 0;cursor:pointer;position:relative}
    .gtt-node:hover{background:rgba(127,127,255,.08)}
    .gtt-node .badge{display:inline-block;font-size:10px;padding:2px 6px;border-radius:999px;border:1px solid var(--gtt-bd,#d0d7de);margin-right:6px;opacity:.75}
    .gtt-node .meta{opacity:.7;font-size:11px;margin-left:6px}
    .gtt-node .pv{display:inline-block;opacity:.9;margin-left:6px;white-space:nowrap;max-width:calc(100% - 90px);overflow:hidden;text-overflow:ellipsis}
    .gtt-children{margin-left:14px;border-left:1px dashed var(--gtt-bd,#d0d7de);padding-left:8px}
    .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 $ = (s, r=document) => r.querySelector(s);
  const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
  const hash = (s) => { let h=0; for (let i=0;i<s.length;i++) h=((h<<5)-h + s.charCodeAt(i))|0; return (h>>>0).toString(36); };
  const normalize = (s)=> (s||'').replace(/\u200b/g,'').replace(/\s+/g,' ').trim();
  const normalizeForPreview = (s)=> (s||'').replace(/\u200b/g,'').replace(/\r\n?/g,'\n');
  const HTML_ESC = { "&":"&amp;", "<":"&lt;", ">":"&gt;", "\"":"&quot;", "'":"&#39;" };
  const escapeHtml = (str='')=> str.replace(/[&<>'"]/g, (ch)=> HTML_ESC[ch] || ch);
  const escapeAttr = (str='')=> escapeHtml(str).replace(/`/g,'&#96;');
  const formatInline = (txt='')=>{
    let out = escapeHtml(txt);
    out = out.replace(/`([^`]+)`/g, (_m, code)=>`<code>${code}</code>`);
    out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, label, url)=>`<a href="${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 renderMarkdownLite = (raw='')=>{
    const 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){
        const cls = codeLang ? ` class="lang-${escapeAttr(codeLang)}"` : '';
        const body = codeBuffer.map(escapeHtml).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}>${formatInline(heading[2])}</h${level}>`;
        continue;
      }
      const listItem = line.match(/^\s*[-*+]\s+(.*)$/);
      if (listItem){
        if (!inList){ html += '<ul>'; inList = true; }
        html += `<li>${formatInline(listItem[1])}</li>`;
        continue;
      }
      flushList();
      html += `<p>${formatInline(line)}</p>`;
    }
    flushCode();
    flushList();
    return html;
  };
  const makeSig = (role, text)=> (role||'assistant') + '|' + hash(normalize(text).slice(0, CONFIG.SIG_TEXT_LEN));
  const getConversationId = () => (location.pathname.match(/\/c\/([a-z0-9-]{10,})/i)||[])[1]||null;
  const rafIdle = (fn, ms=CONFIG.RENDER_IDLE_MS) => setTimeout(fn, ms);
  const debounce = (fn, ms) => { let t; return (...args)=>{ clearTimeout(t); t=setTimeout(()=>fn(...args), ms); } };

  /** ================= 状态持久化 ================= **/
  const defaults = {
    minimized: false,
    hidden: false,
    pos: null // {left, top} 若为 null 使用默认 right 固定
  };
  let prefs = loadPrefs();

  function loadPrefs(){
    try{
      const raw = localStorage.getItem(CONFIG.LS_KEY) || localStorage.getItem('gtt_prefs_v2');
      const obj = raw ? JSON.parse(raw) : {};
      // v2->v3 兼容:无 pos 字段
      return { ...defaults, ...obj };
    }catch{ return {...defaults}; }
  }
  function savePrefs(){ try{ localStorage.setItem(CONFIG.LS_KEY, JSON.stringify(prefs)); }catch{} }

  /** ================= 鉴权(更稳健) ================= **/
  let LAST_AUTH = null;   // {Authorization}
  let FETCH_PATCHED = false;
  const origFetch = window.fetch;
  if (!FETCH_PATCHED){
    FETCH_PATCHED = true;
    window.fetch = async (...args)=>{
      const [input, init] = args;
      try{
        const req = (input instanceof Request) ? input : null;
        const hdrs = req ? Object.fromEntries(req.headers.entries()) : (init?.headers || {});
        const auth = hdrs?.authorization || hdrs?.Authorization;
        if (auth && !LAST_AUTH){ LAST_AUTH = { Authorization: auth }; }
      }catch(_) {}
      const res = await origFetch(...args);
      try{
        const url = typeof input==='string' ? input : input?.url || '';
        if (/\/backend-api\/conversation\//.test(url)) {
          const clone = res.clone(); const json = await clone.json();
          if (json?.mapping) { LAST_MAPPING = json.mapping; buildTreeFromMapping(LAST_MAPPING); }
        }
      }catch(_) {}
      return res;
    };
  }

  async function ensureAuth(){
    if (LAST_AUTH?.Authorization) return LAST_AUTH;
    try{
      const r = await origFetch('/api/auth/session', { credentials:'include' });
      if (r.ok){
        const j = await r.json();
        if (j?.accessToken){ LAST_AUTH = { Authorization: `Bearer ${j.accessToken}` }; return LAST_AUTH; }
      }
    }catch(_){ }
    return LAST_AUTH || {};
  }
  const withAuthHeaders = (extra={}) => ({ ...(LAST_AUTH||{}), ...extra });

  /** ================= 面板 + FAB ================= **/
  function ensureFab(){
    if ($('#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 ($('#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);

    // 绑定交互
    $('#gtt-btn-min').addEventListener('click', ()=> setMinimized(!prefs.minimized));
    $('#gtt-btn-hide').addEventListener('click', ()=> setHidden(true));
    $('#gtt-btn-refresh').addEventListener('click', ()=>rebuildTree({forceFetch:true, hard:true}));
    $('#gtt-btn-collapse').addEventListener('click', toggleCollapseAll);
    $('#gtt-md-close').addEventListener('click', closeModal);

    const inputSearch = $('#gtt-search');
    const onSearch = debounce((e)=>{
      const q = (typeof e==='string'? e : (e?.target?.value||'')).trim().toLowerCase();
      $$('#gtt-tree .gtt-node').forEach(n=>{ n.style.display = n.textContent.toLowerCase().includes(q) ? '' : 'none'; });
    }, 120);
    inputSearch.addEventListener('input', onSearch);

    // 初始化偏好
    // 双击标题栏=最小化/还原
    $('#gtt-header').addEventListener('dblclick', ()=> setMinimized(!prefs.minimized));

    // 键盘:/ 聚焦搜索;Esc 关闭模态/清搜索
    document.addEventListener('keydown', (e)=>{
      if (e.key === '/' && !e.metaKey && !e.ctrlKey){ e.preventDefault(); inputSearch.focus(); }
      if (e.key === 'Escape'){
        if ($('#gtt-modal').style.display==='flex') closeModal(); else { inputSearch.value=''; inputSearch.dispatchEvent(new Event('input')); }
      }
    });

    // 拖拽移动
    enableDrag($('#gtt-panel'), $('#gtt-drag'));

    // 应用最小化/隐藏/位置状态
    setMinimized(prefs.minimized, /*silent*/true);
    setHidden(prefs.hidden, /*silent*/true);
    applyPositionFromPrefs();
  }

  function applyPositionFromPrefs(){
    const panel = $('#gtt-panel'); if (!panel) return;
    if (prefs.pos){
      panel.style.left = prefs.pos.left + 'px';
      panel.style.top = prefs.pos.top + 'px';
      panel.style.right = 'auto';
    }
  }

  function enableDrag(panel, handle){
    let dragging=false, sx=0, sy=0, sl=0, st=0;
    handle.addEventListener('mousedown', (e)=>{
      dragging=true; sx=e.clientX; sy=e.clientY; const r = panel.getBoundingClientRect(); sl=r.left; st=r.top;
      panel.style.right = 'auto';
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp, { once:true });
    });
    function onMove(e){ if (!dragging) return; const l = sl + (e.clientX - sx); const t = st + (e.clientY - sy); panel.style.left = Math.max(8, l) + 'px'; panel.style.top = Math.max(8, t) + 'px'; }
    function onUp(){ dragging=false; document.removeEventListener('mousemove', onMove); const r = panel.getBoundingClientRect(); prefs.pos = { left: Math.round(r.left), top: Math.round(r.top) }; savePrefs(); }
  }

  function setHidden(v, silent=false){
    const panel = $('#gtt-panel'); const fab = $('#gtt-fab');
    if (!panel || !fab) return;
    if (v){ panel.style.display='none'; fab.style.display='inline-flex'; }
    else { panel.style.display='flex'; fab.style.display='none'; }
    prefs.hidden = !!v; if (!silent) savePrefs();
  }

  function setMinimized(v, silent=false){
    const panel = $('#gtt-panel'); if (!panel) return;
    panel.classList.toggle('gtt-min', !!v);
    $('#gtt-btn-min').textContent = v ? '还原' : '最小化';
    prefs.minimized = !!v; if (!silent) savePrefs();
  }

  /** ================= 数据:mapping / 线性回退 ================= **/
  let LAST_MAPPING = null;
  let DOM_BY_SIG = new Map();  // 签名 -> 元素
  let DOM_BY_ID = new Map();   // messageId -> 元素
  let CURRENT_BRANCH_IDS = new Set();
  let CURRENT_BRANCH_SIGS = new Set();
  let CURRENT_BRANCH_LEAF_ID = null;
  let CURRENT_BRANCH_LEAF_SIG = null;
  let fetchCtl = { token: 0 };

  async function fetchMapping(){
    const myTok = ++fetchCtl.token;
    await ensureAuth();
    const cid = getConversationId(); if (!cid) return null;
    const {get:getUrls} = CONFIG.ENDPOINTS(cid);
    for (const u of getUrls){
      try{
        const r = await origFetch(u, { credentials:'include', headers: withAuthHeaders() });
        if (myTok !== fetchCtl.token) return null; // 过期
        if (r.ok){
          const j = await r.json();
          if (j?.mapping){ return j.mapping; }
        }
      }catch(_err){ }
    }
    return null;
  }

  function harvestLinearNodes(){
    const blocks = $$(CONFIG.SELECTORS.messageBlocks);
    const out = [];
    const ids = new Set();
    const sigs = new Set();
    DOM_BY_SIG = new Map();
    DOM_BY_ID = new Map();
    for (const el of blocks){
      const textEl = $(CONFIG.SELECTORS.messageText, el) || el;
      const raw = (textEl?.innerText || '').trim();
      const 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 || $("[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(text.slice(0,80)));
      const sig = makeSig(role, text);
      const rec = {id, role, text, sig, _el: el};
      out.push(rec);
      DOM_BY_SIG.set(sig, el);
      ids.add(id);
      sigs.add(sig);
      if (messageId) DOM_BY_ID.set(messageId, el);
    }
    CURRENT_BRANCH_IDS = ids;
    CURRENT_BRANCH_SIGS = sigs;
    if (out.length){
      const leaf = out[out.length - 1];
      CURRENT_BRANCH_LEAF_ID = leaf?.id || null;
      CURRENT_BRANCH_LEAF_SIG = leaf?.sig || null;
    }else{
      CURRENT_BRANCH_LEAF_ID = null;
      CURRENT_BRANCH_LEAF_SIG = null;
    }
    applyCurrentBranchHighlight();
    return out;
  }

  /** ================= 构树与渲染 ================= **/
  const preview = (t, n=CONFIG.PREVIEW_MAX_CHARS)=>{ const s=normalize(t); return s.length>n ? s.slice(0,n)+'…' : s; };

  // 识别“工具/系统”角色
  function isToolishRole(role){ return role === 'tool' || role === 'system' || role === 'function'; }
  function getRecText(rec){ const parts = rec?.message?.content?.parts ?? []; return Array.isArray(parts) ? parts.join('\n') : (typeof parts === 'string' ? parts : ''); }
  function isVisibleRec(rec){ if (!rec) return false; const role = rec?.message?.author?.role || 'assistant'; if (isToolishRole(role)) return false; const text = getRecText(rec); return !!normalize(text); }
  function visibleParentId(mapping, id){ let cur = id, guard = 0; while (guard++ < 4096){ const p = mapping[cur]?.parent; if (p == null) return null; const pr = mapping[p]; if (isVisibleRec(pr)) return p; cur = p; } 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 = normalize(getRecText(rec)); const sig = makeSig(role, text); if (!seen.has(sig)){ seen.add(sig); out.push(cid); } } return out; }

  function buildTreeFromMapping(mapping){
    const byId = mapping;
    const visibleIds = Object.keys(byId).filter(id => isVisibleRec(byId[id]));

    const parentMap = new Map();
    for (const vid of visibleIds){ parentMap.set(vid, visibleParentId(byId, vid)); }

    const childrenMap = new Map(visibleIds.map(id => [id, []]));
    for (const vid of visibleIds){ const p = parentMap.get(vid); if (p && childrenMap.has(p)) childrenMap.get(p).push(vid); }
    for (const [pid, arr] of childrenMap.entries()){ childrenMap.set(pid, dedupBySig(arr, byId)); }

    const roots = visibleIds.filter(id => parentMap.get(id) == null);

    function foldSameRoleChain(startId){
      let cur = startId;
      let rec = byId[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(makeSig(role, curText));
        }
        const kids = childrenMap.get(cur) || [];
        if (kids.length !== 1) break;
        const k = kids[0];
        const kRec = byId[k];
        const kRole = kRec?.message?.author?.role || 'assistant';
        const kText = getRecText(kRec);
        if (kRole === role && kText && text){
          text = (text + '\n' + kText).trim();
          cur = k;
          rec = kRec;
          continue;
        }
        break;
      }
      return { id: cur, role, text, ids: chainIds, sigs: chainSigs };
    }

    const toNode = (id) => {
      const folded = foldSameRoleChain(id);
      const curId = folded.id;
      const curRole = folded.role;
      const curText = folded.text;
      const kidIds = childrenMap.get(curId) || [];
      const childrenNodes = kidIds.map(toNode).filter(Boolean);
      const sig = makeSig(curRole, curText);
      const chainIds = (folded.ids && folded.ids.length) ? folded.ids : [curId];
      const chainSigs = (folded.sigs && folded.sigs.length) ? folded.sigs : [sig];
      return { id: curId, role: curRole, text: curText, sig, chainIds, chainSigs, children: childrenNodes };
    };

    const tree = roots.map(toNode).filter(Boolean);
    renderTreeGradually($('#gtt-tree'), tree);
  }

  function buildTreeFromLinear(linear){
    const nodes=[]; for (let i=0;i<linear.length;i++){
      const cur=linear[i];
      if (cur.role==='user'){
        const nxt=linear[i+1];
        const pair={id:cur.id, role:'user', text:cur.text, sig:cur.sig, children:[]};
        if (nxt && nxt.role==='assistant'){ pair.children.push({id:nxt.id, role:'assistant', text:nxt.text, sig:nxt.sig, children:[]}); }
        nodes.push(pair);
      }else{ nodes.push({id:cur.id, role:'assistant', text:cur.text, sig:cur.sig, children:[]}); }
    }
    renderTreeGradually($('#gtt-tree'), nodes);
  }

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

    const queue = [];
    const pushList = (nodes, parent)=>{ for (const n of nodes){ queue.push({ node:n, 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 badge = document.createElement('span'); badge.className='badge'; badge.textContent = node.role==='user'? 'U' : (node.role||'·');
      const title = document.createElement('span'); title.textContent = node.role==='user' ? '用户' : '助手';
      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);
      item.append(badge,title,meta,pv); item.addEventListener('click', ()=>jumpTo(node));
      return item;
    };

    // 根容器
    const rootDiv = document.createElement('div');
    container.appendChild(rootDiv);

    // 将根节点入队
    pushList(treeData, rootDiv);

    const step = () => {
      let cnt = 0;
      while (cnt < 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);
        }
        cnt++;
      }
      if (queue.length){ rafIdle(step); } else { targetEl.appendChild(container); updateStats(stats.total); applyCurrentBranchHighlight(targetEl); }
    };
    step();
  }

  function applyCurrentBranchHighlight(rootEl){
    const treeRoot = rootEl || $('#gtt-tree');
    if (!treeRoot) return;

    const nodeEls = treeRoot.querySelectorAll('.gtt-node');
    const connectorEls = treeRoot.querySelectorAll('.gtt-children');
    nodeEls.forEach(el => { el.classList.remove('gtt-current', 'gtt-current-leaf'); });
    connectorEls.forEach(el => el.classList.remove('gtt-current-line'));

    const hasBranch = (CURRENT_BRANCH_IDS?.size || 0) > 0 || (CURRENT_BRANCH_SIGS?.size || 0) > 0;
    if (!hasBranch) return;

    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 && CURRENT_BRANCH_IDS.has(id);
      const matchesSig = sig && CURRENT_BRANCH_SIGS.has(sig);
      const matchesChainId = chainIds ? chainIds.some(cid => CURRENT_BRANCH_IDS.has(cid)) : false;
      const matchesChainSig = chainSigs ? chainSigs.some(cs => CURRENT_BRANCH_SIGS.has(cs)) : false;
      const isCurrent = matchesId || matchesSig || matchesChainId || matchesChainSig;
      if (!isCurrent) return;
      el.classList.add('gtt-current');
      const isLeaf = (
        (CURRENT_BRANCH_LEAF_ID && (id === CURRENT_BRANCH_LEAF_ID || (chainIds && chainIds.includes(CURRENT_BRANCH_LEAF_ID)))) ||
        (CURRENT_BRANCH_LEAF_SIG && (sig === CURRENT_BRANCH_LEAF_SIG || (chainSigs && chainSigs.includes(CURRENT_BRANCH_LEAF_SIG))))
      );
      if (isLeaf){
        el.classList.add('gtt-current-leaf');
      }
      const parent = el.parentElement;
      if (parent?.classList?.contains('gtt-children')){
        parent.classList.add('gtt-current-line');
      }
    });
  }

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

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

  /** ================= 跳转 ================= **/
  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){
    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 = normalize(text).slice(0,120);
    if (!snippet) return null;
    const blocks = $$(CONFIG.SELECTORS.messageBlocks);
    let best=null, score=-1;
    for (const el of blocks){
      const textEl = $(CONFIG.SELECTORS.messageText, el) || el;
      const t = normalize(textEl?.innerText || '');
      const idx = t.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){
    $('#gtt-md-body').innerHTML = renderMarkdownLite(text);
    $('#gtt-md-title').textContent = reason || '节点预览(未能定位到页面元素,已为你展示文本)';
    $('#gtt-modal').style.display = 'flex';
  }
  function closeModal(){ $('#gtt-modal').style.display='none'; $('#gtt-md-body').innerHTML=''; }

  async function jumpTo(node){
    // 1) 直接用 messageId 命中
    let target = DOM_BY_ID.get(node.id);
    if (target && target.isConnected) return scrollToEl(target);

    // 2) 用内容签名命中(修复:mapping.id 与 DOM hash 不一致时)
    const sig = node.sig || makeSig(node.role, node.text);
    target = DOM_BY_SIG.get(sig);
    if (target && target.isConnected) return scrollToEl(target);

    // 3) 文本回退匹配
    target = locateByText(node.text);
    if (target) return scrollToEl(target);

    // 4) 仍未定位,但不武断声明“未在该分支”
    openModal(node.text || '(无文本)', '节点预览(未能定位到页面元素,已为你展示文本)');
  }

  /** ================= 监听 & 路由感知 ================= **/
  const mo = new MutationObserver(debounce(()=>{ harvestLinearNodes(); }, CONFIG.OBS_DEBOUNCE_MS));

  function hookHistory(){
    const _push = history.pushState; const _replace = history.replaceState;
    function fire(){ window.dispatchEvent(new Event('gtt:locationchange')); }
    history.pushState = function(){ const r = _push.apply(this, arguments); fire(); return r; };
    history.replaceState = function(){ const r = _replace.apply(this, arguments); fire(); return r; };
    window.addEventListener('popstate', fire);
  }

  function boot(){
    ensureFab(); ensurePanel();
    rebuildTree();
    mo.observe(document.body, {childList:true, subtree:true});

    hookHistory();
    let last = location.pathname;
    const onLocChange = async ()=>{
      if (location.pathname !== last){ last = location.pathname; await rebuildTree({forceFetch:true, hard:true}); }
    };
    window.addEventListener('gtt:locationchange', onLocChange);
    window.addEventListener('popstate', onLocChange);

    // 快捷键 Alt+T 切隐藏;Alt+M 最小化
    document.addEventListener('keydown', (e)=>{
      if (!e.altKey) return;
      if (e.key === 't' || e.key === 'T'){ e.preventDefault(); setHidden(!prefs.hidden); }
      if (e.key === 'm' || e.key === 'M'){ e.preventDefault(); setMinimized(!prefs.minimized); }
    });
  }

  async function rebuildTree(opts={}){
    ensureFab(); ensurePanel();
    if (opts.hard){ LAST_MAPPING=null; }
    harvestLinearNodes(); // 先收集一次,确保 DOM_BY_SIG/ID 准备就绪
    if (opts.forceFetch || !LAST_MAPPING){ LAST_MAPPING = await fetchMapping(); }
    if (LAST_MAPPING) buildTreeFromMapping(LAST_MAPPING); else buildTreeFromLinear(harvestLinearNodes());
  }

  // 初始等待 main 出现
  const t = setInterval(()=>{ if (document.querySelector('main')){ clearInterval(t); boot(); } }, 300);

})();