ChatGPT Conversation Pruner

缓解 ChatGPT 长对话场景下的前端性能问题

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 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         ChatGPT Conversation Pruner
// @namespace    chatgpt-conversation-pruner
// @version      2.3.5
// @description  缓解 ChatGPT 长对话场景下的前端性能问题
// @match        https://chatgpt.com/*
// @homepageURL  https://github.com/slhafzjw/ChatGPT-Conversation-Pruner
// @supportURL   https://github.com/slhafzjw/ChatGPT-Conversation-Pruner/issues
// @grant        none
// @run-at       document-start
// @noframes
// @author       slhaf
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    /**********************************************************
   * Debug & 对照实验开关(保留 v1.6 思路)
   **********************************************************/
    const DEBUG = true;

    // true = 临时禁用“首屏剪枝相关优化”(用于对照)
    // - 不隐藏 main
    // - 稳定态后不做首次 prune(但后续滚动回到底部仍可能触发 prune)
    const DISABLE_FIRST_SCREEN_PRUNE = false;

    const LOG = {
        log: (...a) => DEBUG && console.log('[Pruner]', ...a),
        warn: (...a) => DEBUG && console.warn('[Pruner]', ...a),
    };

    LOG.log('script loaded at', document.readyState, 'path=', location.pathname);

    /**********************************************************
   * 参数
   **********************************************************/
    const HIDE_BEYOND = 8;
    const BATCH_SIZE = 8;

    const LOAD_ROOT_MARGIN = '200px 0px 0px 0px';
    const BOTTOM_THRESHOLD = 10;

    const MAX_CACHE_PER_CONV = 300;

    // 稳定态判定:需要连续 N 次“turn 数量不变 + 最末 turn 高度不变”
    const STABLE_HITS_REQUIRED = 2;
    const STABLE_CHECK_INTERVAL = 200;

    // scrollRoot 仍可能在稳定后短时间内替换:做有限次重绑
    const REBIND_CHECK_MS = 450;
    const REBIND_TRIES = 2;

    // 同路由 DOM 重建看门狗(轻量)
    const DOM_WATCHDOG_INTERVAL = 600;

    /**********************************************************
   * 全局:仅保留 convKey -> removed DOM[] 的缓存
   **********************************************************/
    const GLOBAL_CACHE_KEY = '__CHATGPT_PRUNER_CACHE_MAP__';
    const GLOBAL_INSTANCE_KEY = '__CHATGPT_PRUNER_INSTANCE__';

    if (!window[GLOBAL_CACHE_KEY]) window[GLOBAL_CACHE_KEY] = new Map();
    const CACHE_MAP = window[GLOBAL_CACHE_KEY];

    // Cache entry shape:
    // { el: HTMLElement, gen: symbol }
    // gen is a per-instance DOM generation token to prevent duplicated caching across DOM rebuilds.

    /**********************************************************
   * Header badge
   **********************************************************/
    const HEADER_BADGE_KEY = '__CHATGPT_PRUNER_HEADER_BADGE__';

    function getModelSwitcherButton() {
        return document.querySelector('[data-testid="model-switcher-dropdown-button"]');
    }

    function getPageHeader() {
        return document.getElementById('page-header');
    }

    function ensureHeaderBadge() {
        const header = getPageHeader();
        const anchor = getModelSwitcherButton();
        if (!header || !anchor) return null;

        let badge = window[HEADER_BADGE_KEY];
        if (!badge || !badge.isConnected) {
            badge = document.createElement('span');
            badge.style.cssText = `
    position: absolute;
    font-size: 12px;
    line-height: 1;
    color: var(--token-text-tertiary);
    opacity: 0.9;
    white-space: nowrap;
    user-select: none;
    pointer-events: none;
    z-index: 30;
  `;
            badge.textContent = '• live: - | cached: -';
            header.appendChild(badge);
            window[HEADER_BADGE_KEY] = badge;
        }

        const a = anchor.getBoundingClientRect();
        const h = header.getBoundingClientRect();

        badge.style.left = `${a.right - h.left + 8}px`;
        badge.style.top = `${a.top - h.top + a.height / 2}px`;
        badge.style.transform = 'translateY(-50%)';

        return badge;
    }

    function removeHeaderBadge() {
        const el = window[HEADER_BADGE_KEY];
        if (el?.remove) el.remove();
        window[HEADER_BADGE_KEY] = null;
    }

    function updateHeaderBadge({ live, cached }) {
        const badge = ensureHeaderBadge();
        if (!badge) return;
        badge.textContent = `• live: ${live} | cached: ${cached}`;
        ensureHeaderBadge();
    }

    window.addEventListener('resize', () => {
        if (window[HEADER_BADGE_KEY]) {
            ensureHeaderBadge();
        }
    }, { passive: true });

    /**********************************************************
   * Helpers(无状态)
   **********************************************************/
    function isConversationPage() {
        return location.pathname.includes('/c/');
    }

    function getConvKey() {
        return location.pathname;
    }

    function getTurns() {
        return Array.from(
            document.querySelectorAll('article[data-testid^=conversation-turn]')
        );
    }

    function getConversationContainer() {
        const first = getTurns()[0];
        return first?.parentElement || document.querySelector('main') || document.body;
    }

    function isScrollable(el) {
        if (!el || el === document.body || el === document.documentElement) return false;
        const cs = getComputedStyle(el);
        const oy = cs.overflowY;
        if (oy !== 'auto' && oy !== 'scroll' && oy !== 'overlay') return false;
        return el.scrollHeight > el.clientHeight + 1;
    }

    function pickScrollableFallback() {
        // 从 thread 往上找最可能的滚动容器
        const start =
              document.querySelector('[data-scroll-root]') ||
              document.getElementById('thread') ||
              document.getElementById('main') ||
              document.body;

        let cur = start;
        while (cur && cur !== document.body && cur !== document.documentElement) {
            if (isScrollable(cur)) return cur;
            cur = cur.parentElement;
        }

        // 最后兜底:浏览器默认滚动元素
        return document.scrollingElement || document.documentElement;
    }

    function getScrollRoot() {
        // ✅ 新版是 data-scroll-root=""(没有 true)
        const explicit = document.querySelector('[data-scroll-root]');
        if (explicit && isScrollable(explicit)) return explicit;

        // explicit 可能存在但一开始还没撑开高度:也允许返回 explicit
        if (explicit) return explicit;

        return pickScrollableFallback();
    }


    function isAtBottom(threshold = BOTTOM_THRESHOLD) {
        const root = getScrollRoot();
        if (!root) {
            const se = document.scrollingElement || document.documentElement;
            return se.scrollHeight - se.scrollTop - window.innerHeight < threshold;
        }
        return root.scrollHeight - root.scrollTop - root.clientHeight < threshold;
    }

    function isVoiceActive() {
        return !!document.querySelector('[data-testid*=voice], [aria-label*=voice]');
    }

    function getCacheForKey(key) {
        if (!CACHE_MAP.has(key)) CACHE_MAP.set(key, []);
        return CACHE_MAP.get(key);
    }

    function cacheCountForGen(cache, gen) {
        if (!cache?.length) return 0;
        let n = 0;
        for (const it of cache) {
            if (it && it.gen === gen && it.el && !it.el.isConnected) n++;
        }
        return n;
    }

    /**********************************************************
   * 等待“对话 DOM 稳定态”
   **********************************************************/
    function waitForConversationStable(convKey, onReady, onProgress) {
        let lastCount = 0;
        let stableHits = 0;
        let lastHeight = 0;

        const t = setInterval(() => {
            if (getConvKey() !== convKey) {
                clearInterval(t);
                return;
            }

            const turns = getTurns();
            const root = getScrollRoot();
            const last = turns[turns.length - 1];

            if (!root || !last) {
                stableHits = 0;
                lastCount = turns.length;
                return;
            }

            const h = last.getBoundingClientRect().height;

            if (turns.length === lastCount && Math.abs(h - lastHeight) < 1) {
                stableHits++;
            } else {
                stableHits = 0;
            }

            lastCount = turns.length;
            lastHeight = h;

            onProgress && onProgress({ stableHits, turns: turns.length });

            if (stableHits >= STABLE_HITS_REQUIRED) {
                clearInterval(t);
                onReady();
            }
        }, STABLE_CHECK_INTERVAL);

        return () => clearInterval(t);
    }

    /**********************************************************
   * per-route instance
   **********************************************************/
    function createConversationInstance(convKey) {
        const cache = getCacheForKey(convKey);
        // Per-instance DOM generation token.
        // Any cached nodes from a previous instance (same convKey) are considered stale.
        let DOM_GEN = Symbol('chatgpt-pruner-dom-gen');

        let ACTIVE = true;
        let HISTORY_MODE = false;
        let PAUSE_PRUNE = false;
        let IS_LOADING = false;

        let SENTINEL = null;
                    // Sentinel lost usually means DOM rebuild.
                    resetDomGen('sentinel-lost');
        let IO = null;

        let SCROLL_ROOT = null;
        let SCROLL_HANDLER = null;

        let EARLY_HIT = false;
        let START_AT = performance.now();

        let rebindLeft = REBIND_TRIES;
        let rebindTimer = null;
        let stableCancel = null;

        const hideStyle = document.createElement('style');
        hideStyle.textContent = `main { visibility: hidden !important; }`;

        // 生成期剪枝触发器
        let lastTurnCount = getTurns().length;
        let lastSeenTurnId = null;

        // ✅ 新增:记录当前被 observe 的 container,供 DOM 重建检测
        let OBSERVED_CONTAINER = null;

        // ✅ 新增:看门狗 timer
        let watchdogTimer = null;

        function resetDomGen(reason) {
            DOM_GEN = Symbol('chatgpt-pruner-dom-gen');
            // Drop all previous-generation entries for this convKey.
            sanitizeCache();
            DEBUG && LOG.warn('DOM generation reset', { key: convKey, reason });
            refreshHeaderBadge();
        }

        function refreshHeaderBadge() {
            updateHeaderBadge({
                live: getTurns().length,
                cached: cacheCountForGen(cache, DOM_GEN),
            });
        }

        function destroy() {
            ACTIVE = false;

            try { growObserver.disconnect(); } catch {}

            if (watchdogTimer) {
                clearInterval(watchdogTimer);
                watchdogTimer = null;
            }

            try { stableCancel?.(); } catch {}
            stableCancel = null;

            try { IO?.disconnect(); } catch {}
            IO = null;

            try { SENTINEL?.remove(); } catch {}
            SENTINEL = null;

            if (SCROLL_HANDLER) {
                try { window.removeEventListener('scroll', SCROLL_HANDLER); } catch {}
                try { SCROLL_ROOT?.removeEventListener('scroll', SCROLL_HANDLER); } catch {}
            }
            SCROLL_HANDLER = null;
            SCROLL_ROOT = null;

            if (rebindTimer) {
                clearTimeout(rebindTimer);
                rebindTimer = null;
            }

            try { hideStyle.remove(); } catch {}

            removeHeaderBadge();
        }

        function sanitizeCache() {
            if (!cacheCountForGen(cache, DOM_GEN)) return;

            // Keep only entries for this instance's DOM generation.
            // Drop anything connected to DOM (already restored) or malformed.
            for (let i = cache.length - 1; i >= 0; i--) {
                const it = cache[i];
                const el = it?.el;
                if (!it || it.gen !== DOM_GEN || !el || el.isConnected) {
                    cache.splice(i, 1);
                }
            }

            if (cache.length > MAX_CACHE_PER_CONV) {
                cache.splice(0, cache.length - MAX_CACHE_PER_CONV);
            }
        }

        function clearHiddenTurns() {
            const turns = getTurns();
            for (const el of turns) {
                if (el?.style?.display === 'none') el.style.display = '';
            }
        }

        function ensureSentinel() {
            if (SENTINEL && SENTINEL.isConnected) return SENTINEL;

            const s = document.createElement('div');
            s.style.cssText = 'height:1px;width:1px;opacity:0;pointer-events:none';

            const first = getTurns()[0];
            if (first?.parentElement) {
                first.parentElement.insertBefore(s, first);
            } else {
                getConversationContainer().prepend(s);
            }

            SENTINEL = s;
            return s;
        }

        // ✅ 新增:DOM 重建安全剪枝(避免 cached 翻倍)
        function safePrune(reason) {
            if (!ACTIVE) return;
            if (DISABLE_FIRST_SCREEN_PRUNE) {
                refreshHeaderBadge();
                return;
            }
            if (isVoiceActive()) {
                refreshHeaderBadge();
                return;
            }

            // With DOM generation tagging, stale-cache duplication across route switches
            // is inherently prevented. Keep sanitize + prune as the "safe" path.
            sanitizeCache();
            clearHiddenTurns();
            prune();
        }

        function prune() {
            if (!ACTIVE || PAUSE_PRUNE || HISTORY_MODE) return;

            const turns = getTurns();
            if (turns.length <= HIDE_BEYOND) {
                refreshHeaderBadge();
                return;
            }

            const removeBefore = turns.length - HIDE_BEYOND;
            const voiceActive = isVoiceActive();

            if (DEBUG && removeBefore > 0) {
                LOG.log(
                    'prune',
                    voiceActive ? 'hide' : 'remove',
                    removeBefore,
                    'turns (readyState=',
                    document.readyState + ')',
                    'key=',
                    convKey
                );
            }

            if (voiceActive) {
                for (let i = 0; i < removeBefore; i++) {
                    const el = turns[i];
                    if (!el) continue;
                    el.style.display = 'none';
                }
                refreshHeaderBadge();
                return;
            }

            clearHiddenTurns();
            sanitizeCache();

            for (let i = 0; i < removeBefore; i++) {
                const el = turns[i];
                if (!el) continue;
                cache.push({ el, gen: DOM_GEN });
                el.remove();
            }

            sanitizeCache();
            refreshHeaderBadge();
        }

        function loadMoreHistory() {
            if (!ACTIVE) return;
            if (isVoiceActive()) return;

            HISTORY_MODE = true;
            PAUSE_PRUNE = true;
            IS_LOADING = true;

            sanitizeCache();

            const sentinel = ensureSentinel();
            const beforeTop = sentinel.getBoundingClientRect().top;

            let restored = 0;
            while (restored < BATCH_SIZE && cache.length) {
                const it = cache.pop();
                const el = it?.el;
                if (!it || it.gen !== DOM_GEN || !el || el.isConnected) {
                    continue;
                }
                sentinel.insertAdjacentElement('afterend', el);
                el.style.display = '';
                restored++;
            }

            requestAnimationFrame(() => {
                const afterTop = sentinel.getBoundingClientRect().top;
                const delta = afterTop - beforeTop;
                if (Math.abs(delta) > 1) window.scrollBy(0, delta);

                IS_LOADING = false;
                DEBUG && LOG.log('restored', restored, 'cache left', cache.length, 'key=', convKey);

                refreshHeaderBadge();
            });
        }

        function setupIntersectionObserver() {
            if (IO) IO.disconnect();

            const root = getScrollRoot();

            IO = new IntersectionObserver(entries => {
                const e = entries[0];
                if (!e?.isIntersecting) return;
                if (!ACTIVE || IS_LOADING) return;
                if (!cacheCountForGen(cache, DOM_GEN)) return;
                loadMoreHistory();
            }, {
                root,
                rootMargin: LOAD_ROOT_MARGIN,
                threshold: 0.01,
            });

            IO.observe(ensureSentinel());

            if (rebindLeft > 0) {
                const boundRoot = root;
                if (rebindTimer) clearTimeout(rebindTimer);
                rebindTimer = setTimeout(() => {
                    rebindTimer = null;
                    if (!ACTIVE) return;

                    const newRoot = getScrollRoot();
                    if (newRoot !== boundRoot) {
                        DEBUG && LOG.warn('scroll root changed, rebind IO', { key: convKey });
                        rebindLeft--;
                        setupIntersectionObserver();
                        watchScrollMode();
                    } else {
                        rebindLeft--;
                    }
                }, REBIND_CHECK_MS);
            }
        }

        function watchScrollMode() {
            const root = getScrollRoot();
            if (root === SCROLL_ROOT && SCROLL_HANDLER) return;

            if (SCROLL_HANDLER) {
                window.removeEventListener('scroll', SCROLL_HANDLER);
                SCROLL_ROOT?.removeEventListener('scroll', SCROLL_HANDLER);
            }

            SCROLL_ROOT = root;
            SCROLL_HANDLER = () => {
                if (!ACTIVE) return;
                if (isVoiceActive()) return;

                if (HISTORY_MODE && isAtBottom()) {
                    HISTORY_MODE = false;
                    PAUSE_PRUNE = false;
                    DEBUG && LOG.log('back to bottom → resume prune', 'key=', convKey);
                    prune();
                }
            };

            (SCROLL_ROOT || window).addEventListener('scroll', SCROLL_HANDLER, { passive: true });
        }

        const growObserver = new MutationObserver(() => {
            if (!ACTIVE || PAUSE_PRUNE || HISTORY_MODE || IS_LOADING) return;

            const turns = getTurns();
            const last = turns[turns.length - 1];

            // 条件 A:article 数量真的增加了(你原本就有)
            const countGrown = turns.length > lastTurnCount;

            // 条件 B:最后一个 article 被“定型”(turn-id 变化)
            const lastId = last?.getAttribute('data-turn-id');
            const idFinalized = lastId && lastId !== lastSeenTurnId;

            // 两个条件都不满足 → 不触发
            if (!countGrown && !idFinalized) return;

            // 更新状态
            lastTurnCount = turns.length;
            lastSeenTurnId = lastId;

            prune()
        });


        const earlyObserver = new MutationObserver(() => {
            if (!ACTIVE) return;
            if (!EARLY_HIT) {
                EARLY_HIT = true;
                DEBUG && LOG.log(
                    'first DOM mutation at',
                    Math.round(performance.now() - START_AT),
                    'ms',
                    'key=',
                    convKey
                );
            }
        });

        // ✅ 新增:同路由 DOM 重建看门狗(会触发 safePrune)
        function startDomWatchdog() {
            if (watchdogTimer) clearInterval(watchdogTimer);

            watchdogTimer = setInterval(() => {
                if (!ACTIVE) return;
                if (getConvKey() !== convKey) return;

                // 1) header 被 React 刷掉:确保徽章存在 + 位置刷新
                if (getPageHeader() && getModelSwitcherButton()) {
                    ensureHeaderBadge();
                }

                // 2) container 被替换:重绑 growObserver + 立刻 safePrune
                const nowContainer = getConversationContainer();
                if (nowContainer && nowContainer !== OBSERVED_CONTAINER) {
                    // Same route but DOM subtree was replaced: cached nodes from previous tree are invalid.
                    resetDomGen('container-replaced');
                    try { growObserver.disconnect(); } catch {}
                    try {
                        growObserver.observe(nowContainer, { childList: true, subtree: true });
                        OBSERVED_CONTAINER = nowContainer;
                        lastTurnCount = getTurns().length;
                        DEBUG && LOG.warn('container replaced → rebind growObserver', { key: convKey });
                    } catch {}

                    // DOM 树刚替换:立即安全剪一次,避免 live 暴涨但不 prune
                    safePrune('container-replaced');
                }

                // 3) sentinel 丢失:重建 sentinel 并重挂 IO,然后安全剪一次
                if (SENTINEL && !SENTINEL.isConnected) {
                    SENTINEL = null;
                    try { IO?.disconnect(); } catch {}
                    IO = null;
                    try { setupIntersectionObserver(); } catch {}
                    try { watchScrollMode(); } catch {}
                    DEBUG && LOG.warn('sentinel lost → rebuild IO', { key: convKey });

                    safePrune('sentinel-lost');
                }

                // 4) 定期刷新 badge(显示真实 live/cached)
                refreshHeaderBadge();
            }, DOM_WATCHDOG_INTERVAL);
        }

        function start() {
            if (!DISABLE_FIRST_SCREEN_PRUNE) {
                document.documentElement.appendChild(hideStyle);
                DEBUG && LOG.log('main hidden (waiting stable)', 'key=', convKey);
            }

            earlyObserver.observe(document.documentElement, { childList: true, subtree: true });

            stableCancel = waitForConversationStable(
                convKey,
                () => {
                    if (!ACTIVE) return;

                    const tStable = Math.round(performance.now() - START_AT);
                    DEBUG && LOG.log('conversation stable at', tStable, 'ms', 'key=', convKey);

                    if (!DISABLE_FIRST_SCREEN_PRUNE) {
                        hideStyle.remove();
                        const tRestore = Math.round(performance.now() - START_AT);
                        DEBUG && LOG.log('main restored at', tRestore, 'ms', 'key=', convKey);

                        requestAnimationFrame(() => {
                            requestAnimationFrame(() => {
                                DEBUG && LOG.log(
                                    'first visible frame at',
                                    Math.round(performance.now() - START_AT),
                                    'ms',
                                    'key=',
                                    convKey
                                );
                            });
                        });
                    }

                    sanitizeCache();
                    clearHiddenTurns();

                    if (!DISABLE_FIRST_SCREEN_PRUNE) {
                        prune();
                    } else {
                        DEBUG && LOG.warn('first screen prune disabled (experiment)', 'key=', convKey);
                    }

                    setupIntersectionObserver();
                    watchScrollMode();

                    try { earlyObserver.disconnect(); } catch {}

                    // 启用生成期剪枝监听(绑定当前 container)
                    const c = getConversationContainer();
                    OBSERVED_CONTAINER = c;
                    try {
                        growObserver.observe(c, { childList: true, subtree: true });
                    } catch {}

                    refreshHeaderBadge();

                    // ✅ 启动 DOM 重建看门狗
                    startDomWatchdog();
                },
                () => {}
            );
        }

        start();
        return { destroy };
    }

    /**********************************************************
   * SPA routing:切换时销毁旧实例,创建新实例
   **********************************************************/
    function applyRoute() {
        const old = window[GLOBAL_INSTANCE_KEY];
        if (old?.destroy) {
            try { old.destroy(); } catch {}
            window[GLOBAL_INSTANCE_KEY] = null;
        }

        if (!isConversationPage()) {
            removeHeaderBadge();
            return;
        }

        const key = getConvKey();
        DEBUG && LOG.log('route change →', key);

        window[GLOBAL_INSTANCE_KEY] = createConversationInstance(key);
    }

    function hookHistory(cb) {
        const _push = history.pushState;
        const _replace = history.replaceState;

        history.pushState = function () {
            _push.apply(this, arguments);
            cb();
        };

        history.replaceState = function () {
            _replace.apply(this, arguments);
            cb();
        };

        window.addEventListener('popstate', cb);
    }

    hookHistory(applyRoute);
    applyRoute();
})();