ChatGPT AutoCleaner v5

Bugfix & speed-up for ChatGPT: automatically cleans the conversation chat window by trimming old messages (both in DOM and React memory). Keeps only the latest N turns visible, preventing lag and memory bloat on long sessions. Includes manual “Clean now” button and skips auto-clean when the tab is hidden.

当前为 2025-08-16 提交的版本,查看 最新版本

// ==UserScript==
// @name         ChatGPT AutoCleaner v5
// @version      1.4
// @description  Bugfix & speed-up for ChatGPT: automatically cleans the conversation chat window by trimming old messages (both in DOM and React memory). Keeps only the latest N turns visible, preventing lag and memory bloat on long sessions. Includes manual “Clean now” button and skips auto-clean when the tab is hidden.
// @author       Aleksey Maximov <[email protected]>
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// @namespace    81e29c9d-b6e3-4210-b862-c93cb160f09a
// @license      MIT
// ==/UserScript==

/*
WHY THIS FIX EXISTS (read this once):

Problem:
- The ChatGPT web app keeps conversation history not only in the DOM, but also in React state.
- Multiple components hold their own `props.messages` (and hook-derivatives). Deleting DOM nodes alone does NOT free memory.
- During streaming the assistant produces many “draft” chunks (in_progress). Arrays grow → GC pressure → UI lag.

What this script changes:
- On a timer, it does TWO things:
  1) Trims old DOM turns (visual cleanup).
  2) Walks the React fiber tree, finds every `props.messages` array, and trims it IN PLACE to the last N.
     It also ALWAYS drops streaming drafts first (only final messages are kept).
- Adds a **Clean now** button to trigger a one-off trim even if auto-clean is disabled.
- Auto-clean is skipped when the tab is hidden (no background CPU churn).

Why you might see “duplicates”:
- Different components can own separate arrays. We re-find and de-duplicate by identity on every run, then trim each safely.

Result:
- Only the latest N messages are kept in both DOM and React memory; old objects become unreachable → GC can reclaim memory.
- This reduces lag on long sessions without intercepting network or patching app code.
*/



(function () {
  'use strict';

  // ---------- UI ----------
  function injectUI() {
    if (document.getElementById("chatgpt-cleaner-panel")) return;

    const defaults = { leaveOnly: 5, intervalSec: 10, enabled: false };
    const stored = {
      leaveOnly: parseInt(localStorage.getItem("chatgpt-leaveOnly")) || defaults.leaveOnly,
      intervalSec: parseInt(localStorage.getItem("chatgpt-intervalSec")) || defaults.intervalSec,
      enabled: localStorage.getItem("chatgpt-enabled") !== "false"
    };

    const container = document.createElement("div");
    container.id = "chatgpt-cleaner-wrapper";
    Object.assign(container.style, {
      position: "fixed", bottom: "8px", right: "8px", zIndex: 9999, fontFamily: "sans-serif"
    });

    const toggleButton = document.createElement("button");
    toggleButton.textContent = "⚙";
    Object.assign(toggleButton.style, {
      background: stored.enabled ? "#444" : "red", color: "#fff", border: "none",
      borderRadius: "4px", padding: "2px 6px", cursor: "pointer", fontSize: "14px"
    });
    toggleButton.title = "Toggle cleaner panel";

    const panel = document.createElement("div");
    panel.id = "chatgpt-cleaner-panel";
    Object.assign(panel.style, {
      display: "none", marginTop: "4px", background: "#222", color: "#fff",
      padding: "10px 12px 10px 10px", borderRadius: "6px", fontSize: "12px",
      boxShadow: "0 0 6px rgba(0,0,0,0.5)", border: "1px solid #555", position: "relative", opacity: "0.95"
    });

    panel.innerHTML = `
      <div id="chatgpt-close" style="position:absolute;top:0px;right:2px;font-size:16px;font-weight:bold;color:#ccc;cursor:pointer;">✖</div>
      <label>
        Keep <input id="chatgpt-keep-count" type="number" value="${stored.leaveOnly}" min="1"
        style="width:52px;min-width:52px;padding:2px 6px 2px 4px;font-size:12px;background:#111;color:#fff;border:1px solid #555;box-sizing:border-box;"> messages
      </label>
      <br>
      <label>
        Interval <input id="chatgpt-interval" type="number" value="${stored.intervalSec}" min="2"
        style="width:52px;min-width:52px;padding:2px 6px 2px 4px;font-size:12px;background:#111;color:#fff;border:1px solid #555;box-sizing:border-box;"> sec
      </label>
      <br>
      <label><input type="checkbox" id="chatgpt-enabled" ${stored.enabled ? "checked" : ""}> Auto-clean enabled</label>
      <br>
      <button id="chatgpt-clean-now" style="
        margin-top:6px;background:#008000;color:#fff;border:none;border-radius:4px;
        padding:2px 8px;cursor:pointer;font-size:12px;">Clean now</button>
    `;

    toggleButton.onclick = () => { panel.style.display = "block"; toggleButton.style.display = "none"; };
    container.appendChild(toggleButton);
    container.appendChild(panel);
    document.body.appendChild(container);

    const countInput = panel.querySelector("#chatgpt-keep-count");
    const intervalInput = panel.querySelector("#chatgpt-interval");
    const enabledCheckbox = panel.querySelector("#chatgpt-enabled");
    const cleanNowBtn = panel.querySelector("#chatgpt-clean-now");
    const closeBtn = panel.querySelector("#chatgpt-close");

    let leaveOnly = stored.leaveOnly;
    let intervalMs = Math.max(2000, stored.intervalSec * 1000);
    let enabled = stored.enabled;
    let intervalId = null;

    // ---------- React fiber helpers ----------
    const reactKeyOf = el =>
      el && Object.getOwnPropertyNames(el).find(n => n.startsWith("__reactFiber$")) || "__reactFiber";

    function getRootFiber() {
      try {
        const nodes = [...document.querySelectorAll('[data-testid^="conversation-turn-"]')];
        for (let i = nodes.length - 1; i >= 0; i--) {
          let n = nodes[i];
          while (n) {
            const f = n[reactKeyOf(n)];
            if (f) { let r = f; while (r.return) r = r.return; return r; }
            n = n.parentElement;
          }
        }
        const rootEl = document.getElementById("__next") || document.body.firstElementChild;
        const f = rootEl && rootEl[reactKeyOf(rootEl)];
        if (!f) return null;
        let r = f; while (r.return) r = r.return; return r;
      } catch { return null; }
    }

    const looksMsg = o => o && typeof o === "object" && (o.content && (o.author || o.role));
    function pickMsgArray(x) {
      if (!x) return null;
      if (Array.isArray(x) && x.some(looksMsg)) return { arr: x, key: "<array>" };
      const v = x.messages;
      if (Array.isArray(v) && v.some(looksMsg)) return { arr: v, key: "messages" };
      return null;
    }

    function collectPropsMessagesArrays() {
      const root = getRootFiber();
      if (!root) return [];
      const found = [];
      const seenFibers = new Set();
      const stack = [root];
      while (stack.length) {
        const f = stack.pop();
        if (!f || seenFibers.has(f)) continue;
        seenFibers.add(f);
        try {
          // props
          const mp = f.memoizedProps;
          const pa = pickMsgArray(mp);
          if (pa && pa.key === "messages") found.push(pa.arr);

          // class state object
          const ms = f.memoizedState;
          if (ms && !ms?.next && typeof ms === "object") {
            const sa = pickMsgArray(ms);
            if (sa && sa.key === "messages") found.push(sa.arr);
          }

          // hooks list
          let h = f.memoizedState, guard = 0;
          while (h && h.next !== undefined && guard++ < 5000) {
            const a1 = pickMsgArray(h.memoizedState);
            if (a1 && a1.key === "messages") found.push(a1.arr);
            const a2 = pickMsgArray(h.baseState);
            if (a2 && a2.key === "messages") found.push(a2.arr);
            h = h.next;
          }
        } catch { /* ignore */ }
        if (f.child) stack.push(f.child);
        if (f.sibling) stack.push(f.sibling);
        if (f.alternate) stack.push(f.alternate);
      }
      // dedupe by identity
      const uniq = [];
      const seenArr = new Set();
      for (const a of found) if (!seenArr.has(a)) { seenArr.add(a); uniq.push(a); }
      return uniq;
    }

    // ---------- trimming ----------
    function safeReplaceArray(arr, kept) {
      try {
        arr.splice(0, arr.length, ...kept);
        return true;
      } catch (e) {
        console.warn("safeReplaceArray: failed to replace array", e);
        return false;
      }
    }

    function trimMessagesArray(arr, keep, dropDraftsFlag = true) {
      const MIN_KEEP = 1;
      const KEEP_SYSTEM = true;

      if (!Array.isArray(arr) || !arr.length) return { before: 0, after: 0 };
      if (Object.isFrozen(arr)) {
        // console.debug("trimMessagesArray: skipped frozen array, length =", arr.length);
        return { before: arr.length, after: arr.length, skipped: true };
      }

      const before = arr.length;
      const first = arr[0];
      const isSystem = !!(first && (first.author?.role === 'system' || first.role === 'system'));
      const systemMsg = (KEEP_SYSTEM && isSystem) ? first : null;

      let finals = dropDraftsFlag
        ? arr.filter(m => m && typeof m === 'object' &&
            (m.status === 'finished_successfully' || m.end_turn === true || m.status !== 'in_progress'))
        : arr.slice();

      if (finals.length === 0) finals = [arr[arr.length - 1]];
      let kept = finals.length > keep ? finals.slice(-keep) : finals;

      if (systemMsg && kept[0] !== systemMsg) kept = [systemMsg, ...kept];
      if (!kept.length) kept = [arr[arr.length - 1]];
      if (kept.length < MIN_KEEP && arr.length) kept = [arr[arr.length - 1]];

      const ok = safeReplaceArray(arr, kept);
      const after = ok ? arr.length : before;

      // console.debug(`trimMessagesArray: trimmed ${before} → ${after} (keep=${keep})`);

      return { before, after, skipped: !ok };
    }

    // ---------- main cleaner ----------
    function cleanOldMessages(manual = false) {
      if (!manual) {
        if (!enabled) return;
        if (document.hidden) return;
      }
      try {
        if (manual) {
          console.log("[ChatGPT AutoCleaner] Manual clean triggered");
        }
        // 1) Trim DOM (visual only)
        const all = document.querySelectorAll('[data-testid^="conversation-turn-"]');
        if (all.length) {
          const lastAttr = all[all.length - 1].getAttribute("data-testid");
          const last = parseInt(lastAttr?.split("-")[2]);
          if (!isNaN(last)) {
            all.forEach(item => {
              const idx = parseInt(item.getAttribute("data-testid")?.split("-")[2]);
              if (!isNaN(idx) && idx < last - leaveOnly) item.remove();
            });
          }
        }

        // 2) Trim props.messages
        const arrays = collectPropsMessagesArrays();
        arrays.forEach(arr => { void trimMessagesArray(arr, leaveOnly, true); });
      } catch (e) {
        console.warn("cleanOldMessages error:", e);
      }
    }

    function startCleaner() {
      if (intervalId) clearInterval(intervalId);
      intervalId = setInterval(() => cleanOldMessages(false), intervalMs);
      console.log(`[ChatGPT AutoCleaner] Started with interval ${intervalMs} ms, keeping last ${leaveOnly} messages`);
    }

    // ---------- handlers ----------
    enabledCheckbox.onchange = () => {
      enabled = enabledCheckbox.checked;
      localStorage.setItem("chatgpt-enabled", enabled);
      toggleButton.style.background = enabled ? "#444" : "red";
    };

    countInput.oninput = () => {
      const val = parseInt(countInput.value);
      if (!isNaN(val) && val > 0) {
        leaveOnly = val;
        localStorage.setItem("chatgpt-leaveOnly", val);
      }
    };

    intervalInput.oninput = () => {
      const val = parseInt(intervalInput.value);
      if (!isNaN(val) && val > 1) {
        intervalMs = Math.max(2000, val * 1000);
        localStorage.setItem("chatgpt-intervalSec", val);
        startCleaner();
      }
    };

    cleanNowBtn.onclick = () => {
      cleanOldMessages(true); // manual run regardless of enabled/visibility
      panel.style.display = "none";
      toggleButton.style.display = "inline-block";
    };

    closeBtn.onclick = () => {
      panel.style.display = "none";
      toggleButton.style.display = "inline-block";
    };

    startCleaner();
  }

  if (document.readyState === "complete" || document.readyState === "interactive") {
    injectUI();
  } else {
    window.addEventListener("DOMContentLoaded", injectUI);
  }

  const observer = new MutationObserver(() => {
    if (!document.getElementById("chatgpt-cleaner-wrapper")) injectUI();
  });
  observer.observe(document.body, { childList: true, subtree: true });
})();