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.
当前为
// ==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 }); })();