ChatFold

Provide collapse/expand controls for ChatGPT conversations along with a top toolbar, supporting dark mode and bilingual switching (Chinese and English).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         ChatFold
// @namespace    https://example.com
// @version      1.0.2
// @description  Provide collapse/expand controls for ChatGPT conversations along with a top toolbar, supporting dark mode and bilingual switching (Chinese and English).
// @author       Zhaoyang-Song
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_addStyle
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  /*************** i18n ***************/
  const I18N = {
    zh: {
      appName: 'ChatFold',
      toolbarHint: '提示:每条消息右上角可单独折叠/展开',
      collapseAssistant: '折叠助手',
      collapseUser: '折叠用户',
      collapseAll: '全部折叠',
      expandAll: '全部展开',
      threshold: '阈值',
      autoCollapseByLen: '按长度自动折叠',
      btnCollapse: '折叠',
      btnExpand: '展开',
      btnTitleToggle: '折叠/展开此消息',
      btnTitleCollapse: '折叠此消息',
      btnTitleExpand: '展开此消息',
      roleUser: '用户',
      roleAssistant: '助手',
      langLabel: '语言',
      langZh: '中文',
      langEn: 'English',
      compact: '折叠工具条',
      expandToolbar: '展开工具条',

      // NEW
      floatingToggleShow: '显示工具条',
      floatingToggleHide: '隐藏工具条',
    },
    en: {
      appName: 'ChatFold',
      toolbarHint: 'Tip: Use the per-message button to collapse/expand.',
      collapseAssistant: 'Collapse Assistant',
      collapseUser: 'Collapse User',
      collapseAll: 'Collapse All',
      expandAll: 'Expand All',
      threshold: 'Threshold',
      autoCollapseByLen: 'Auto-collapse by length',
      btnCollapse: 'Collapse',
      btnExpand: 'Expand',
      btnTitleToggle: 'Collapse/Expand this message',
      btnTitleCollapse: 'Collapse this message',
      btnTitleExpand: 'Expand this message',
      roleUser: 'user',
      roleAssistant: 'assistant',
      langLabel: 'Language',
      langZh: '中文',
      langEn: 'English',
      compact: 'Collapse Toolbar',
      expandToolbar: 'Expand Toolbar',

      // NEW
      floatingToggleShow: 'Show Toolbar',
      floatingToggleHide: 'Hide Toolbar',
    }
  };
  const LANG_KEY = 'cgpt_i18n_lang';
  const TOOLBAR_MINI_KEY = 'cgpt_toolbar_mini';
  const TOOLBAR_FLOAT_OPEN_KEY = 'cgpt_toolbar_float_open'; // NEW

  function detectDefaultLang(){
    const saved = localStorage.getItem(LANG_KEY);
    if (saved && I18N[saved]) return saved;
    const nav = (navigator.language || '').toLowerCase();
    return nav.startsWith('zh') ? 'zh' : 'en';
  }
  let currentLang = detectDefaultLang();
  function setLang(lang){
    if (!I18N[lang]) return;
    currentLang = lang;
    localStorage.setItem(LANG_KEY, lang);
    refreshAllUILabels();
  }
  function t(key){
    const pack = I18N[currentLang] || I18N.en;
    return pack[key] ?? key;
  }

  /*************** 选择器与参数 ***************/
  const SELECTORS = {
    messageBlocks: '[data-message-author-role]',
    fallbackBlocks: 'main .group, main .text-base',
    topMounts: ['div[data-testid="conversation-turns"]', 'main', 'body'],
  };

  const UI = {
    toolbarId: 'cgpt-collapse-toolbar',
    toolbarCompactId: 'cgpt-collapse-toolbar-compact',
    toolbarFloatToggleId: 'cgpt-collapse-toolbar-float-toggle', // NEW(右下角第二个按钮)
    toolbarFloatingClass: 'cgpt-toolbar-floating',               // NEW(浮动工具条样式类)

    btnClass: 'cgpt-btn',
    iconClass: 'cgpt-icon',
    collapsedClass: 'cgpt-collapsed',
    controlBtnClass: 'cgpt-collapse-toggle',
    controlBtnBottomClass: 'cgpt-collapse-toggle-bottom',
    summaryClass: 'cgpt-summary-line',
    gradientClass: 'cgpt-fade-gradient',
    defaultPreviewLen: 60,
    autoCollapseThresholdDefault: 600,
  };

  /*************** 样式 ***************/
  GM_addStyle(`
    :root{
      --cg-blur: 10px;
      --cg-radius-xl: 14px;
      --cg-radius-lg: 10px;
      --cg-shadow: 0 8px 20px rgba(0,0,0,.08);
      --cg-shadow-strong: 0 10px 30px rgba(0,0,0,.12);

      --cg-bg: rgba(255,255,255,.85);
      --cg-border:#e5e7eb;
      --cg-btn-bg:#ffffff;
      --cg-btn-bg-hover:#f5f5f5;
      --cg-btn-bg-active:#efefef;
      --cg-text:#111827;
      --cg-muted:#6b7280;
      --cg-focus:#10a37f;
      --cg-chip:#f3f4f6;

      --cg-fade-from: rgba(255,255,255,0);
      --cg-fade-to: rgba(255,255,255,.92);
    }
    html.dark, [data-theme="dark"]{
      --cg-bg: rgba(26,26,26,.72);
      --cg-border:#2f2f2f;
      --cg-btn-bg:#1f1f1f;
      --cg-btn-bg-hover:#2a2a2a;
      --cg-btn-bg-active:#333333;
      --cg-text:#e5e7eb;
      --cg-muted:#a1a1aa;
      --cg-focus:#10a37f;
      --cg-chip:#232323;

      --cg-fade-from: rgba(31,31,31,0);
      --cg-fade-to: rgba(31,31,31,.92);
    }
    @media (prefers-color-scheme: dark){
      :root{
        --cg-bg: rgba(26,26,26,.72);
        --cg-border:#2f2f2f;
        --cg-btn-bg:#1f1f1f;
        --cg-btn-bg-hover:#2a2a2a;
        --cg-btn-bg-active:#333333;
        --cg-text:#e5e7eb;
        --cg-muted:#a1a1aa;
        --cg-focus:#10a37f;
        --cg-chip:#232323;

        --cg-fade-from: rgba(31,31,31,0);
        --cg-fade-to: rgba(31,31,31,.92);
      }
    }

    /* 顶部工具条:玻璃化卡片 */
    #${UI.toolbarId}{
      position: sticky; top: 0; z-index: 9999;
      display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
      padding: 10px 12px; margin: 8px 0 10px;
      background: var(--cg-bg); backdrop-filter: blur(var(--cg-blur));
      border: 1px solid var(--cg-border); border-radius: var(--cg-radius-xl);
      box-shadow: var(--cg-shadow);
      color: var(--cg-text); font-size: 12px; line-height: 1.2;
    }

    /* NEW:浮动工具条(显示在当前位置:右下角上方) */
    #${UI.toolbarId}.${UI.toolbarFloatingClass}{
      position: fixed !important;
      right: 16px !important;
      bottom: 62px !important;  /* 给右下角按钮留空间 */
      top: auto !important;
      margin: 0 !important;
      max-width: min(92vw, 980px);
      z-index: 9999 !important;
    }

    /* 迷你胶囊(右下角:展开工具条) */
    #${UI.toolbarCompactId}{
      position: fixed; right: 16px; bottom: 16px; z-index: 9999;
      display: flex; align-items: center; gap: 8px;
      padding: 8px 10px;
      background: var(--cg-bg); backdrop-filter: blur(var(--cg-blur));
      border: 1px solid var(--cg-border); border-radius: 999px;
      box-shadow: var(--cg-shadow-strong);
      color: var(--cg-text); font-size: 12px; cursor: pointer;
      user-select: none;
    }
    #${UI.toolbarCompactId}:hover{ transform: translateY(-1px); }
    #${UI.toolbarCompactId}:active{ transform: translateY(0); }

    /* NEW:右下角“显示/隐藏工具条”按钮(与迷你胶囊外观一致) */
    #${UI.toolbarFloatToggleId}{
      position: fixed; right: 16px; bottom: 16px; z-index: 9999;
      display: none; align-items: center; gap: 8px;
      padding: 8px 10px;
      background: var(--cg-bg); backdrop-filter: blur(var(--cg-blur));
      border: 1px solid var(--cg-border); border-radius: 999px;
      box-shadow: var(--cg-shadow-strong);
      color: var(--cg-text); font-size: 12px; cursor: pointer;
      user-select: none;
    }
    #${UI.toolbarFloatToggleId}:hover{ transform: translateY(-1px); }
    #${UI.toolbarFloatToggleId}:active{ transform: translateY(0); }

    /* 统一控件高度与对齐 */
    :root{ --cg-control-h: 32px; }

    .${UI.btnClass}{
      border: 1px solid var(--cg-border);
      height: var(--cg-control-h);
      padding: 0 10px;
      border-radius: var(--cg-radius-lg);
      cursor: pointer; background: var(--cg-btn-bg);
      color: var(--cg-text);
      display: inline-flex; align-items: center; gap: 6px;
      line-height: 1;
      box-sizing: border-box;
      transition: background .15s ease, transform .05s ease, box-shadow .15s ease;
    }
    .${UI.btnClass}:hover{ background: var(--cg-btn-bg-hover); }
    .${UI.btnClass}:active{ background: var(--cg-btn-bg-active); transform: translateY(1px); }
    .${UI.btnClass}:focus-visible{
      outline: 2px solid var(--cg-focus);
      outline-offset: 2px;
    }

    .${UI.iconClass}{
      width: 14px; height: 14px; display: inline-block;
      line-height: 0; vertical-align: middle;
    }

    .cgpt-brand{
      display: inline-flex; align-items: center; gap: 8px;
      padding: 6px 10px; border-radius: 999px;
      background: var(--cg-chip); border: 1px solid var(--cg-border);
      margin-right: 6px; user-select: none;
    }
    .cgpt-dot{
      width: 10px; height: 10px; border-radius: 999px; background: var(--cg-focus);
      box-shadow: 0 0 0 3px rgba(16,163,127,.15);
    }

    /* 消息按钮:移出内容区域,避免遮挡文字 */
    .${UI.controlBtnClass},
    .${UI.controlBtnBottomClass}{
      position: absolute;
      width: 26px; height: 26px; border-radius: 999px;
      border: 1px solid var(--cg-border);
      background: var(--cg-btn-bg); color: var(--cg-text);
      cursor: pointer; opacity: .92; z-index: 6;
      display: inline-flex; align-items: center; justify-content: center;
      transition: background .15s ease, transform .05s ease;
      box-shadow: var(--cg-shadow);
    }
    .${UI.controlBtnClass}{ right: -10px; top: -10px; }
    .${UI.controlBtnBottomClass}{ right: -10px; bottom: -10px; }

    .${UI.controlBtnClass}:hover,
    .${UI.controlBtnBottomClass}:hover{ background: var(--cg-btn-bg-hover); }
    .${UI.controlBtnClass}:active,
    .${UI.controlBtnBottomClass}:active{ background: var(--cg-btn-bg-active); transform: scale(.98); }
    .${UI.controlBtnClass}:focus-visible,
    .${UI.controlBtnBottomClass}:focus-visible{
      outline: 2px solid var(--cg-focus);
      outline-offset: 2px;
    }

    .${UI.collapsedClass}{
      position: relative;
      max-height: 48px !important; overflow: hidden !important;
      border-radius: 12px; border: 1px dashed var(--cg-border);
      padding-top: 26px;
    }
    .${UI.gradientClass}{
      content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
      background: linear-gradient(to bottom, var(--cg-fade-from), var(--cg-fade-to));
      pointer-events: none; z-index: 3;
    }
    .${UI.summaryClass}{
      position: absolute; left: 12px; top: 8px; right: 56px;
      color: var(--cg-muted); font-size: 12px; white-space: nowrap;
      overflow: hidden; text-overflow: ellipsis;
      pointer-events: none; z-index: 2;
    }

    .cgpt-input,
    #cgpt-lang-select{
      border: 1px solid var(--cg-border);
      background: var(--cg-btn-bg);
      color: var(--cg-text);
      border-radius: var(--cg-radius-lg);
      height: var(--cg-control-h);
      padding: 0 8px;
      line-height: 1;
      display: inline-flex; align-items: center;
      box-sizing: border-box;
    }
    #cgpt-lang-select { padding-right: 26px; }
    input[type="number"].cgpt-input{ text-align: left; }

    .cgpt-group{
      display: inline-flex;
      align-items: center;
      gap: 6px;
      height: var(--cg-control-h);
    }

    .cgpt-input:focus-visible, #cgpt-lang-select:focus-visible{
      outline: 2px solid var(--cg-focus);
      outline-offset: 2px;
    }

    @media (prefers-reduced-motion: reduce){
      .${UI.btnClass}, .${UI.controlBtnClass}, .${UI.controlBtnBottomClass}, #${UI.toolbarCompactId}, #${UI.toolbarFloatToggleId}{
        transition: none !important;
      }
    }

    :root{ --cg-font-size: 14px; }
    #cgpt-toolbar, .cgpt-toolbar{
      font-size: var(--cg-font-size);
      line-height: 1;
      -webkit-text-size-adjust: 100%;
      text-size-adjust: 100%;
    }
    #cgpt-toolbar *, .cgpt-toolbar *{ font-size: inherit; }
    #cgpt-lang-select,
    #cgpt-lang-select option,
    .cgpt-input,
    input[type="number"].cgpt-input{
      font-size: inherit;
      line-height: 1;
    }
  `);

  /*************** 工具函数 ***************/
  const sleep = (ms)=>new Promise(r=>setTimeout(r,ms));
  const throttle = (fn, wait)=>{
    let t=null, last=0;
    return (...args)=>{
      const now = Date.now();
      if (now-last >= wait){ last = now; fn(...args); }
      else { clearTimeout(t); t=setTimeout(()=>{ last=Date.now(); fn(...args); }, wait-(now-last)); }
    };
  };
  const hash = (str) => {
    let h = 5381; for (let i=0;i<str.length;i++){ h=((h<<5)+h)+str.charCodeAt(i); }
    return (h>>>0).toString(36);
  };

  const getRole = (el)=>{
    const role = el.getAttribute('data-message-author-role');
    if (role) return role;
    const text = el.textContent?.toLowerCase() || '';
    if (/user/.test(text)) return 'user';
    if (/assistant|gpt/.test(text)) return 'assistant';
    return 'unknown';
  };

  const getTextFromBlock = (el)=>{
    const t = el.querySelector('.markdown, .prose')?.innerText
      || el.querySelector('[data-message-author-role] div')?.innerText
      || el.innerText
      || '';
    return (t || '').trim();
  };

  const getMsgId = (el)=>{
    const exist = el.getAttribute('data-message-id') || el.id;
    if (exist) return exist;
    const text = getTextFromBlock(el);
    const role = getRole(el);
    const id = 'msg_' + role + '_' + hash(text).slice(0,8);
    el.setAttribute('data-message-id', id);
    return id;
  };

  const state = {
    getCollapsed(id){ return sessionStorage.getItem('cgpt_c_'+id)==='1'; },
    setCollapsed(id,v){ sessionStorage.setItem('cgpt_c_'+id, v?'1':'0'); },
    getThreshold(){
      const v = localStorage.getItem('cgpt_auto_thresh');
      return v ? parseInt(v,10) : UI.autoCollapseThresholdDefault;
    },
    setThreshold(n){ localStorage.setItem('cgpt_auto_thresh', String(n)); },
    getToolbarMini(){
      return localStorage.getItem(TOOLBAR_MINI_KEY) === '1';
    },
    setToolbarMini(v){
      localStorage.setItem(TOOLBAR_MINI_KEY, v?'1':'0');
    },

    // NEW:浮动工具条开关(右下角按钮控制)
    getToolbarFloatOpen(){
      return localStorage.getItem(TOOLBAR_FLOAT_OPEN_KEY) === '1';
    },
    setToolbarFloatOpen(v){
      localStorage.setItem(TOOLBAR_FLOAT_OPEN_KEY, v?'1':'0');
    }
  };

  /*************** SVG 图标 ***************/
  const icons = {
    bot: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M7 10a5 5 0 0110 0v2h1a2 2 0 012 2v3a3 3 0 01-3 3H7a3 3 0 01-3-3v-3a2 2 0 012-2h1v-2z" stroke="currentColor" stroke-width="1.5"/><circle cx="10" cy="12" r="1" fill="currentColor"/><circle cx="14" cy="12" r="1" fill="currentColor"/></svg>`,
    user: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M12 12a5 5 0 100-10 5 5 0 000 10zM3 21a9 9 0 1118 0v1H3v-1z" stroke="currentColor" stroke-width="1.5"/></svg>`,
    collapseAll: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M7 15l5-5 5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
    expandAll: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M7 9l5 5 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
    wand: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M4 20l9-9m3-6l1 2 2 1-2 1-1 2-1-2-2-1 2-1 1-2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>`,
    globe: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M12 21a9 9 0 100-18 9 9 0 000 18zM2.5 12h19M12 2.5c3 3 3 16 0 19M12 2.5c-3 3-3 16 0 19" stroke="currentColor" stroke-width="1.2"/></svg>`,
    chip: `<span class="cgpt-dot"></span>`,
    chevronUp: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M7 14l5-5 5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
    chevronDown: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M7 10l5 5 5-5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
    minus: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
    plus: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>`,
    panel: `<svg viewBox="0 0 24 24" fill="none" class="${UI.iconClass}" aria-hidden="true"><rect x="3" y="4" width="18" height="14" rx="3" stroke="currentColor" stroke-width="1.5"/><path d="M8 4v14" stroke="currentColor" stroke-width="1.5"/></svg>`,
  };

  /*************** 渲染与控制(消息) ***************/
  function renderMessage(el, forceCollapsed){
    if (!el.__cgpt_inited){
      el.__cgpt_inited = true;

      const toggle = ()=>{
        const id = getMsgId(el);
        const collapsed = !state.getCollapsed(id);
        state.setCollapsed(id, collapsed);
        updateMessageUI(el);
      };

      const btnTop = document.createElement('button');
      btnTop.className = UI.controlBtnClass;
      btnTop.setAttribute('aria-label', t('btnTitleToggle'));
      btnTop.innerHTML = icons.chevronUp;
      btnTop.addEventListener('click', toggle);

      const btnBottom = document.createElement('button');
      btnBottom.className = UI.controlBtnBottomClass;
      btnBottom.setAttribute('aria-label', t('btnTitleToggle'));
      btnBottom.innerHTML = icons.chevronUp;
      btnBottom.addEventListener('click', toggle);

      const summary = document.createElement('div');
      summary.className = UI.summaryClass;
      summary.style.display = 'none';

      const fade = document.createElement('div');
      fade.className = UI.gradientClass;
      fade.style.display = 'none';

      if (!el.style.position || el.style.position === 'static'){
        el.style.position = 'relative';
      }

      el.appendChild(btnTop);
      el.appendChild(btnBottom);
      el.appendChild(summary);
      el.appendChild(fade);

      el.__cgpt_btn = btnTop;
      el.__cgpt_btnBottom = btnBottom;
      el.__cgpt_summary = summary;
      el.__cgpt_fade = fade;
    }

    if (typeof forceCollapsed === 'boolean'){
      const id = getMsgId(el);
      state.setCollapsed(id, forceCollapsed);
    }
    updateMessageUI(el);
  }

  function localizedRole(role){
    if (role === 'user') return t('roleUser');
    if (role === 'assistant') return t('roleAssistant');
    return role;
  }

  function updateMessageUI(el){
    const id = getMsgId(el);
    const collapsed = state.getCollapsed(id);
    const roleRaw = getRole(el);
    const text = getTextFromBlock(el);
    const role = localizedRole(roleRaw);
    const preview = `${role}|${text.replace(/\s+/g,' ').slice(0, UI.defaultPreviewLen)}${text.length>UI.defaultPreviewLen?'…':''}`;

    const btnTop = el.__cgpt_btn || el.querySelector(`.${UI.controlBtnClass}`);
    const btnBottom = el.__cgpt_btnBottom || el.querySelector(`.${UI.controlBtnBottomClass}`);
    const summary = el.__cgpt_summary || el.querySelector(`.${UI.summaryClass}`);
    const fade = el.__cgpt_fade || el.querySelector(`.${UI.gradientClass}`);

    if (summary) summary.textContent = preview;

    if (collapsed){
      el.classList.add(UI.collapsedClass);
      if (btnTop){ btnTop.innerHTML = icons.chevronDown; btnTop.setAttribute('title', t('btnTitleExpand')); }
      if (btnBottom){ btnBottom.style.display = 'none'; }
      if (summary) summary.style.display = 'block';
      if (fade) fade.style.display = 'block';
    }else{
      el.classList.remove(UI.collapsedClass);
      if (btnTop){ btnTop.innerHTML = icons.chevronUp; btnTop.setAttribute('title', t('btnTitleCollapse')); }
      if (btnBottom){
        btnBottom.style.display = 'inline-flex';
        btnBottom.innerHTML = icons.chevronUp;
        btnBottom.setAttribute('title', t('btnTitleCollapse'));
      }
      if (summary) summary.style.display = 'none';
      if (fade) fade.style.display = 'none';
    }
  }

  function setCollapsed(el, collapsed){
    const id = getMsgId(el);
    state.setCollapsed(id, collapsed);
    updateMessageUI(el);
  }

  function queryAllMessageBlocks(){
    const primary = Array.from(document.querySelectorAll(SELECTORS.messageBlocks));
    if (primary.length) return primary;
    const fallback = Array.from(document.querySelectorAll(SELECTORS.fallbackBlocks))
      .filter(el=>{
        const t = (el.innerText||'').trim();
        return t.length>0 && el.offsetHeight>20;
      });
    return fallback;
  }

  const initPerMessageControls = throttle(()=>{
    queryAllMessageBlocks().forEach(el=>renderMessage(el));
  }, 350);

  /*************** 顶栏工具条 ***************/
  let toolbarRefs = {
    bar: null,
    mini: null,
    floatToggle: null, // NEW
    brand: null,
    btnCollapseAssistant: null,
    btnCollapseUser: null,
    btnCollapseAll: null,
    btnExpandAll: null,
    thresholdLabelNode: null,
    thresholdInput: null,
    btnAuto: null,
    hint: null,
    langLabel: null,
    langSelect: null,
    compactBtn: null,
  };

  function mkBtn(text, iconSvg, onClick){
    const b = document.createElement('button');
    b.className = UI.btnClass;
    b.innerHTML = `${iconSvg || ''}<span>${text}</span>`;
    b.addEventListener('click', onClick);
    return b;
  }

  function autoCollapseByLength(threshold){
    queryAllMessageBlocks().forEach(el=>{
      const text = getTextFromBlock(el);
      if (text.length >= threshold) setCollapsed(el, true);
    });
  }

  function bulkCollapseAll(flag){
    queryAllMessageBlocks().forEach(el=>setCollapsed(el, flag));
  }

  function bulkCollapseByRole(role, flag){
    queryAllMessageBlocks().forEach(el=>{
      if (getRole(el)===role) setCollapsed(el, flag);
    });
  }

  // ===== NEW:检测顶部工具条是否可见 + 右下角按钮控制“当前位置显示/隐藏工具条” =====
  let barInView = true;

  function isElementInViewport(el){
    if (!el) return false;
    const rect = el.getBoundingClientRect();
    const vw = window.innerWidth || document.documentElement.clientWidth;
    const vh = window.innerHeight || document.documentElement.clientHeight;
    // 与视口有交集即认为可见(简单且稳健)
    return rect.bottom > 0 && rect.right > 0 && rect.left < vw && rect.top < vh;
  }

  const updateBarInView = throttle(()=>{
    const bar = toolbarRefs.bar;
    if (!bar) return;
    barInView = isElementInViewport(bar);
    refreshFloatingToggleVisibility();
  }, 120);

  function applyFloatingMode(open){
    const bar = toolbarRefs.bar;
    if (!bar) return;

    // mini 模式优先:如果已经 mini,就不允许浮动显示
    if (state.getToolbarMini()){
      state.setToolbarFloatOpen(false);
      bar.classList.remove(UI.toolbarFloatingClass);
      return;
    }

    if (open){
      bar.classList.add(UI.toolbarFloatingClass);
      bar.style.display = 'flex';
    } else {
      bar.classList.remove(UI.toolbarFloatingClass);
      bar.style.display = 'flex'; // 仍回到原位置显示(是否能看到取决于滚动)
    }
    refreshFloatingToggleText();
    refreshFloatingToggleVisibility();
  }

  function refreshFloatingToggleText(){
    const btn = toolbarRefs.floatToggle;
    if (!btn) return;
    const open = state.getToolbarFloatOpen();
    btn.innerHTML = `${icons.panel}<span>${open ? t('floatingToggleHide') : t('floatingToggleShow')}</span>`;
    btn.setAttribute('aria-label', open ? t('floatingToggleHide') : t('floatingToggleShow'));
  }

  function refreshFloatingToggleVisibility(){
    const btn = toolbarRefs.floatToggle;
    if (!btn) return;

    // 不干扰你原有“迷你胶囊”:mini 显示时,本按钮完全隐藏
    if (state.getToolbarMini()){
      btn.style.display = 'none';
      return;
    }

    // 规则:
    // - 顶部工具条在视口内:隐藏(因为不需要)
    // - 顶部工具条不在视口内:显示按钮
    // - 如果浮动工具条已打开:按钮也必须显示(用于关闭)
    const open = state.getToolbarFloatOpen();
    const shouldShow = open || !barInView;
    btn.style.display = shouldShow ? 'flex' : 'none';
  }
  // ===== NEW 结束 =====

  function mountToolbar(){
    if (document.getElementById(UI.toolbarId) || document.getElementById(UI.toolbarCompactId) || document.getElementById(UI.toolbarFloatToggleId)) return;

    let mount = null;
    for (const sel of SELECTORS.topMounts){
      const el = document.querySelector(sel);
      if (el){ mount = el; break; }
    }
    if (!mount) return;

    // 迷你泡泡(原有功能:用于“折叠工具条”后的展开)
    const mini = document.createElement('div');
    mini.id = UI.toolbarCompactId;
    mini.setAttribute('role', 'button');
    mini.setAttribute('aria-label', t('expandToolbar'));
    mini.innerHTML = `${icons.panel}<span>${t('expandToolbar')}</span>`;
    mini.style.display = state.getToolbarMini() ? 'flex' : 'none';
    mini.addEventListener('click', ()=>{
      state.setToolbarMini(false);
      refreshToolbarVisibility();
      updateBarInView(); // NEW:状态刷新
    });
    document.body.appendChild(mini);

    // NEW:右下角“显示/隐藏工具条”按钮(仅当顶部工具条滚出视口时出现)
    const floatToggle = document.createElement('div');
    floatToggle.id = UI.toolbarFloatToggleId;
    floatToggle.setAttribute('role', 'button');
    floatToggle.style.display = 'none';
    floatToggle.addEventListener('click', ()=>{
      const next = !state.getToolbarFloatOpen();
      state.setToolbarFloatOpen(next);
      applyFloatingMode(next);
    });
    document.body.appendChild(floatToggle);

    // 完整工具条
    const bar = document.createElement('div');
    bar.id = UI.toolbarId;

    const brand = document.createElement('div');
    brand.className = 'cgpt-brand';
    brand.innerHTML = `${icons.chip}<span class="cgpt-brand-name">${t('appName')}</span>`;

    const langLabel = document.createElement('span');
    langLabel.id = 'cgpt-lang-label';
    langLabel.style.margin = '0 2px 0 6px';
    langLabel.style.opacity = '.75';

    const langSelect = document.createElement('select');
    langSelect.id = 'cgpt-lang-select';
    const optZh = document.createElement('option');
    optZh.value = 'zh'; optZh.textContent = I18N.zh.langZh;
    const optEn = document.createElement('option');
    optEn.value = 'en'; optEn.textContent = I18N.en.langEn;
    langSelect.appendChild(optZh); langSelect.appendChild(optEn);
    langSelect.value = currentLang;
    langSelect.addEventListener('change', ()=>setLang(langSelect.value));

    const btnCollapseAssistant = mkBtn(t('collapseAssistant'), icons.bot, ()=>bulkCollapseByRole('assistant', true));
    const btnCollapseUser = mkBtn(t('collapseUser'), icons.user, ()=>bulkCollapseByRole('user', true));
    const btnCollapseAll = mkBtn(t('collapseAll'), icons.collapseAll, ()=>bulkCollapseAll(true));
    const btnExpandAll = mkBtn(t('expandAll'), icons.expandAll, ()=>bulkCollapseAll(false));

    const thresholdLabelNode = document.createElement('span');
    thresholdLabelNode.style.marginLeft = '8px';
    const thresholdInput = document.createElement('input');
    thresholdInput.type = 'number';
    thresholdInput.min = '50';
    thresholdInput.step = '50';
    thresholdInput.value = String(state.getThreshold());
    thresholdInput.className = 'cgpt-input';
    thresholdInput.style.width = '96px';
    thresholdInput.title = t('autoCollapseByLen');

    const btnAuto = mkBtn(t('autoCollapseByLen'), icons.wand, ()=>{
      const n = parseInt(thresholdInput.value,10) || UI.autoCollapseThresholdDefault;
      state.setThreshold(n);
      autoCollapseByLength(n);
    });

    const compactBtn = mkBtn(t('compact'), icons.minus, ()=>{
      state.setToolbarMini(true);
      // NEW:mini 时关闭浮动显示,避免冲突
      state.setToolbarFloatOpen(false);
      applyFloatingMode(false);
      refreshToolbarVisibility();
    });

    const hint = document.createElement('span');
    hint.style.marginLeft = '6px';
    hint.style.opacity = '.65';

    bar.appendChild(brand);

    const langWrap = document.createElement('span');
    langWrap.className = UI.btnClass;
    langWrap.style.display = 'inline-flex';
    langWrap.style.alignItems = 'center';
    langWrap.style.gap = '6px';
    langWrap.style.paddingRight = '8px';
    const globe = document.createElement('span');
    globe.innerHTML = icons.globe;
    globe.style.marginLeft = '8px';
    langWrap.appendChild(globe);
    langWrap.appendChild(langLabel);
    langWrap.appendChild(langSelect);
    bar.appendChild(langWrap);

    bar.appendChild(btnCollapseAssistant);
    bar.appendChild(btnCollapseUser);
    bar.appendChild(btnCollapseAll);
    bar.appendChild(btnExpandAll);

    const threshWrap = document.createElement('span');
    threshWrap.className = 'cgpt-group';

    const knobMinus = document.createElement('button');
    knobMinus.className = UI.btnClass;
    knobMinus.innerHTML = icons.minus;
    knobMinus.addEventListener('click', ()=>{
      const cur = parseInt(thresholdInput.value||'0',10);
      thresholdInput.value = String(Math.max(50, cur-50));
    });
    const knobPlus = document.createElement('button');
    knobPlus.className = UI.btnClass;
    knobPlus.innerHTML = icons.plus;
    knobPlus.addEventListener('click', ()=>{
      const cur = parseInt(thresholdInput.value||'0',10);
      thresholdInput.value = String(cur+50);
    });

    threshWrap.appendChild(thresholdLabelNode);
    threshWrap.appendChild(thresholdInput);
    threshWrap.appendChild(knobMinus);
    threshWrap.appendChild(knobPlus);
    bar.appendChild(threshWrap);

    bar.appendChild(btnAuto);
    bar.appendChild(compactBtn);
    bar.appendChild(hint);

    toolbarRefs = {
      bar, mini, floatToggle, brand,
      btnCollapseAssistant, btnCollapseUser, btnCollapseAll, btnExpandAll,
      thresholdLabelNode, thresholdInput, btnAuto, hint, langLabel, langSelect, compactBtn
    };
    refreshToolbarTexts();

    mount.insertBefore(bar, mount.firstChild);

    // NEW:根据持久化状态恢复浮动模式(不改变你原有功能,只是恢复新增功能的状态)
    applyFloatingMode(state.getToolbarFloatOpen());

    refreshToolbarVisibility();
    updateBarInView();
  }

  function refreshToolbarTexts(){
    if (!toolbarRefs.bar) return;
    const brandName = toolbarRefs.brand.querySelector('.cgpt-brand-name');
    if (brandName) brandName.textContent = t('appName');

    toolbarRefs.langLabel.textContent = `${t('langLabel')}:`;
    toolbarRefs.btnCollapseAssistant.innerHTML = `${icons.bot}<span>${t('collapseAssistant')}</span>`;
    toolbarRefs.btnCollapseUser.innerHTML = `${icons.user}<span>${t('collapseUser')}</span>`;
    toolbarRefs.btnCollapseAll.innerHTML = `${icons.collapseAll}<span>${t('collapseAll')}</span>`;
    toolbarRefs.btnExpandAll.innerHTML = `${icons.expandAll}<span>${t('expandAll')}</span>`;
    toolbarRefs.thresholdLabelNode.textContent = ` ${t('threshold')}:`;
    toolbarRefs.btnAuto.innerHTML = `${icons.wand}<span>${t('autoCollapseByLen')}</span>`;
    toolbarRefs.hint.textContent = t('toolbarHint');
    toolbarRefs.thresholdInput.title = t('autoCollapseByLen');
    toolbarRefs.compactBtn.innerHTML = `${icons.minus}<span>${t('compact')}</span>`;

    if (toolbarRefs.mini){
      toolbarRefs.mini.innerHTML = `${icons.panel}<span>${t('expandToolbar')}</span>`;
      toolbarRefs.mini.setAttribute('aria-label', t('expandToolbar'));
    }

    // NEW:右下角“显示/隐藏工具条”按钮文字随语言变化
    refreshFloatingToggleText();
  }

  function refreshToolbarVisibility(){
    const mini = state.getToolbarMini();

    // mini 模式:顶部工具条隐藏,右下角“展开工具条”显示
    if (toolbarRefs.bar) toolbarRefs.bar.style.display = mini ? 'none' : 'flex';
    if (toolbarRefs.mini) toolbarRefs.mini.style.display = mini ? 'flex' : 'none';

    // NEW:mini 模式下,隐藏“显示/隐藏工具条”按钮
    refreshFloatingToggleVisibility();
  }

  function refreshAllUILabels(){
    refreshToolbarTexts();
    queryAllMessageBlocks().forEach(el=>updateMessageUI(el));
  }

  /*************** 性能优化版 DOM 监听 ***************/
  const pendingNodes = new Set();
  let flushScheduled = false;

  function scheduleFlush(){
    if (flushScheduled) return;
    flushScheduled = true;

    const runner = ()=>{
      flushScheduled = false;

      mountToolbar();

      if (pendingNodes.size){
        for (const n of pendingNodes){
          if (!n || n.nodeType !== 1) continue;

          if (n.matches?.(SELECTORS.messageBlocks)) renderMessage(n);
          n.querySelectorAll?.(SELECTORS.messageBlocks)?.forEach(el=>renderMessage(el));

          if (!document.querySelector(SELECTORS.messageBlocks)){
            n.querySelectorAll?.(SELECTORS.fallbackBlocks)?.forEach(el=>{
              const tt = (el.innerText||'').trim();
              if (tt.length>0 && el.offsetHeight>20) renderMessage(el);
            });
          }
        }
        pendingNodes.clear();
      } else {
        initPerMessageControls();
      }

      // NEW:DOM 更新后刷新顶部工具条可见性判断
      updateBarInView();
    };

    if (window.requestIdleCallback){
      window.requestIdleCallback(runner, {timeout: 800});
    } else {
      setTimeout(runner, 120);
    }
  }

  function observe(){
    const obs = new MutationObserver((muts)=>{
      for (const m of muts){
        if (m.addedNodes && m.addedNodes.length){
          m.addedNodes.forEach(n=>pendingNodes.add(n));
        }
      }
      scheduleFlush();
    });
    obs.observe(document.documentElement, {childList:true, subtree:true});

    // NEW:捕获所有滚动(包括内部可滚动容器),让“顶部消失检测”稳定工作
    document.addEventListener('scroll', updateBarInView, {capture:true, passive:true});
    window.addEventListener('resize', updateBarInView, {passive:true});
  }

  /*************** 启动 ***************/
  (async function start(){
    for (let i=0;i<30;i++){
      mountToolbar();
      initPerMessageControls();
      updateBarInView(); // NEW
      if (document.querySelector('main')) break;
      await sleep(200);
    }
    observe();
  })();

})();