您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
紧凑导航 + 实时定位;修复边界误判;底部纯箭头按钮;回到顶部/到底部单击即用;禁用面板内双击选中;快捷键 Command+↑/↓ 与 Alt+[ / Alt+];修复竞态条件和流式输出检测问题。
// ==UserScript== // @name ChatGPT 对话导航 // @namespace http://tampermonkey.net/ // @version 2.7 // @description 紧凑导航 + 实时定位;修复边界误判;底部纯箭头按钮;回到顶部/到底部单击即用;禁用面板内双击选中;快捷键 Command+↑/↓ 与 Alt+[ / Alt+];修复竞态条件和流式输出检测问题。 // @author schweigen // @license MIT // @match https://chatgpt.com/* // @match https://chatgpt.com/?model=* // @match https://chatgpt.com/?temporary-chat=* // @match https://chatgpt.com/c/* // @match https://chatgpt.com/g/* // @match https://chatgpt.com/share/* // @grant GM_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @run-at document-end // ==/UserScript== (function () { 'use strict'; const CONFIG = { maxPreviewLength: 12, animation: 250, refreshInterval: 2000, forceRefreshInterval: 10000, anchorOffset: 8 }; const BOUNDARY_EPS = 28; const DEBUG = false; // 全局调试函数,用户可在控制台调用 window.chatGptNavDebug = { forceRefresh: () => { console.log('ChatGPT Navigation: 手动强制刷新'); TURN_SELECTOR = null; const ui = document.getElementById('cgpt-compact-nav')?._ui; if (ui) scheduleRefresh(ui); else console.log('导航面板未找到'); }, showCurrentSelector: () => { console.log('当前使用的选择器:', TURN_SELECTOR || '无'); console.log('当前对话数量:', qsTurns().length); }, testAllSelectors: () => { const originalSelector = TURN_SELECTOR; TURN_SELECTOR = null; qsTurns(); // 这会触发调试输出 TURN_SELECTOR = originalSelector; }, getCurrentTurns: () => { const turns = qsTurns(); console.log('当前检测到的对话元素:', turns); return turns; }, checkOverlap: () => { const panels = document.querySelectorAll('#cgpt-compact-nav'); const styles = document.querySelectorAll('#cgpt-compact-nav-style'); console.log(`找到 ${panels.length} 个导航面板`); console.log(`找到 ${styles.length} 个样式节点`); console.log(`键盘事件已绑定: ${!!window.__cgptKeysBound}`); console.log(`正在启动中: ${__cgptBooting}`); if (panels.length > 1) { console.warn('检测到重叠面板!清理中...'); panels.forEach((panel, index) => { if (index > 0) { panel.remove(); console.log(`已删除重复面板 ${index}`); } }); } return { panels: panels.length, styles: styles.length, keysBound: !!window.__cgptKeysBound, booting: __cgptBooting }; }, testObserver: () => { const nav = document.getElementById('cgpt-compact-nav'); if (!nav || !nav._ui || !nav._ui._mo) { console.log('MutationObserver 未找到'); return false; } const mo = nav._ui._mo; const target = nav._ui._moTarget; console.log('MutationObserver 状态:'); console.log('- 目标容器:', target); console.log('- 观察者存在:', !!mo); console.log('- 当前对话数量:', qsTurns().length); console.log('- 当前选择器:', TURN_SELECTOR || '无'); // 临时启用DEBUG模式进行测试 const oldDebug = DEBUG; window.DEBUG_TEMP = true; console.log('已临时启用DEBUG模式,请尝试发送一条消息,然后查看控制台输出'); setTimeout(() => { window.DEBUG_TEMP = false; console.log('DEBUG模式已关闭'); }, 30000); return true; } }; GM_registerMenuCommand("重置问题栏位置", resetPanelPosition); function resetPanelPosition() { const nav = document.getElementById('cgpt-compact-nav'); if (nav) { nav.style.top = '60px'; nav.style.right = '10px'; nav.style.left = 'auto'; nav.style.bottom = 'auto'; const originalBg = nav.style.background; nav.style.background = 'rgba(0, 255, 0, 0.2)'; setTimeout(() => { nav.style.background = originalBg; }, 500); } } let pending = false, rafId = null, idleId = null; let forceRefreshTimer = null; let lastTurnCount = 0; let TURN_SELECTOR = null; let scrollTicking = false; let currentActiveId = null; let __cgptBooting = false; let refreshTimer = 0; // 新的尾随去抖定时器 function scheduleRefresh(ui, { delay = 80, force = false } = {}) { if (force) { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = 0; } run(); return; } if (refreshTimer) clearTimeout(refreshTimer); refreshTimer = setTimeout(run, delay); function run() { refreshTimer = 0; pending = false; // 旧标志直接归零,防止误伤 try { const oldCount = cacheIndex.length; refreshIndex(ui); const newCount = cacheIndex.length; // 如果刷新期间 turn 数变化,再来一次"收尾"(防抖窗口内很常见) if (newCount !== oldCount) { setTimeout(() => { refreshIndex(ui); scheduleActiveUpdateNow(); }, 120); } else { scheduleActiveUpdateNow(); } } catch (e) { if (DEBUG || window.DEBUG_TEMP) console.error('scheduleRefresh error:', e); } } } function init() { if (document.getElementById('cgpt-compact-nav')) return; const checkContentLoaded = () => { const turns = document.querySelectorAll('article[data-testid^="conversation-turn-"], [data-testid^="conversation-turn-"], div[data-message-id]'); return turns.length > 0; }; const boot = () => { // 二次校验:已有面板或正在启动就直接退出 if (document.getElementById('cgpt-compact-nav')) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 面板已存在,跳过创建'); return; } if (__cgptBooting) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 正在启动中,跳过重复创建'); return; } __cgptBooting = true; try { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 开始创建面板'); const ui = createPanel(); wirePanel(ui); observeChat(ui); bindActiveTracking(); watchSendEvents(ui); // 新增这一行 scheduleRefresh(ui); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 面板创建完成'); } finally { __cgptBooting = false; } }; if (checkContentLoaded()) boot(); else { const observer = new MutationObserver(() => { if (checkContentLoaded()) { observer.disconnect(); boot(); } }); observer.observe(document.body, { childList: true, subtree: true }); } } let currentUrl = location.href; function detectUrlChange() { if (location.href !== currentUrl) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: URL变化,清理旧实例', currentUrl, '->', location.href); currentUrl = location.href; const oldNav = document.getElementById('cgpt-compact-nav'); if (oldNav) { if (oldNav._ui) { // 清理定时器 if (oldNav._ui._forceRefreshTimer) { clearInterval(oldNav._ui._forceRefreshTimer); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 已清理定时器'); } // 断开MutationObserver if (oldNav._ui._mo) { try { oldNav._ui._mo.disconnect(); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 已断开MutationObserver'); } catch (e) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 断开MutationObserver失败', e); } } } oldNav.remove(); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 已移除旧面板'); } // 重置"正在启动"标志,避免新页面被卡住 __cgptBooting = false; // 重置键盘事件绑定标志,允许新页面重新绑定 window.__cgptKeysBound = false; lastTurnCount = 0; TURN_SELECTOR = null; // 同时重置选择器缓存 setTimeout(init, 100); } } window.addEventListener('popstate', detectUrlChange); const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function (...args) { originalPushState.apply(this, args); setTimeout(detectUrlChange, 0); }; history.replaceState = function (...args) { originalReplaceState.apply(this, args); setTimeout(detectUrlChange, 0); }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); function qsTurns(root = document) { if (TURN_SELECTOR) return Array.from(root.querySelectorAll(TURN_SELECTOR)); const selectors = [ // 原有选择器 'article[data-testid^="conversation-turn-"]', '[data-testid^="conversation-turn-"]', 'div[data-message-id]', 'div[class*="group"][data-testid]', // 新增备用选择器 '[data-testid*="conversation-turn"]', '[data-testid*="message-"]', 'div[class*="turn"]', 'div[class*="message"]', 'div[class*="group"] div[data-message-author-role]', 'div[class*="conversation"] > div', '[class*="chat"] > div', '[role="presentation"] > div', 'main div[class*="group"]', 'main div[data-testid]' ]; if (DEBUG || window.DEBUG_TEMP) { console.log('ChatGPT Navigation Debug: 检测对话选择器'); for (const selector of selectors) { const els = root.querySelectorAll(selector); console.log(`- ${selector}: ${els.length} 个元素`); if (els.length > 0) { console.log(' 样本元素:', els[0]); } } } for (const selector of selectors) { const els = root.querySelectorAll(selector); if (els.length) { TURN_SELECTOR = selector; if (DEBUG || window.DEBUG_TEMP) console.log(`ChatGPT Navigation: 使用选择器 ${selector}, 找到 ${els.length} 个对话`); return Array.from(els); } } if (DEBUG || window.DEBUG_TEMP) { console.log('ChatGPT Navigation Debug: 所有预设选择器都失效,尝试智能检测'); console.log('页面中的所有可能对话元素:'); const potentialElements = [ ...root.querySelectorAll('div[class*="group"]'), ...root.querySelectorAll('div[data-message-id]'), ...root.querySelectorAll('article'), ...root.querySelectorAll('[data-testid]'), ...root.querySelectorAll('div[role="presentation"]') ]; console.log('潜在元素数量:', potentialElements.length); } // 增强的fallback检测 const fallbackSelectors = [ 'div[class*="group"], div[data-message-id]', 'div[class*="turn"], div[class*="message"]', 'main > div > div', '[role="presentation"] > div' ]; for (const fallbackSelector of fallbackSelectors) { const candidates = [...root.querySelectorAll(fallbackSelector)].filter(el => { // 检查是否包含消息相关的内容 return ( el.querySelector('div[data-message-author-role]') || el.querySelector('[data-testid*="user"]') || el.querySelector('[data-testid*="assistant"]') || el.querySelector('[data-author]') || el.querySelector('.markdown') || el.querySelector('.prose') || el.querySelector('.whitespace-pre-wrap') || (el.textContent && el.textContent.trim().length > 10) ); }); if (candidates.length > 0) { if (DEBUG || window.DEBUG_TEMP) console.log(`ChatGPT Navigation: Fallback选择器 ${fallbackSelector} 找到 ${candidates.length} 个候选对话`); return candidates; } } if (DEBUG) console.log('ChatGPT Navigation: 所有检测方法均失效'); return []; } function getTextPreview(el) { if (!el) return ''; const text = (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim(); if (!text) return '...'; let width = 0, result = ''; for (let i = 0; i < text.length; i++) { const char = text[i]; const charWidth = /[\u4e00-\u9fa5]/.test(char) ? 2 : 1; if (width + charWidth > CONFIG.maxPreviewLength) { result += '…'; break; } result += char; width += charWidth; } return result || text.slice(0, CONFIG.maxPreviewLength) || '...'; } function buildIndex() { const turns = qsTurns(); if (!turns.length) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 没有找到任何对话元素'); return []; } if (DEBUG) console.log(`ChatGPT Navigation: 开始分析 ${turns.length} 个对话元素`); let u = 0, a = 0; const list = []; for (let i = 0; i < turns.length; i++) { const el = turns[i]; el.setAttribute('data-cgpt-turn', '1'); const attrTestId = el.getAttribute('data-testid') || ''; const isUser = !!( el.querySelector('[data-message-author-role="user"]') || el.querySelector('.text-message[data-author="user"]') || attrTestId.includes('user') ); const isAssistant = !!( el.querySelector('[data-message-author-role="assistant"]') || el.querySelector('.text-message[data-author="assistant"]') || attrTestId.includes('assistant') ); if (DEBUG && i < 3) { console.log(`ChatGPT Navigation Debug - 元素 ${i}:`, { element: el, testId: attrTestId, isUser, isAssistant, userSelectors: { authorRole: !!el.querySelector('[data-message-author-role="user"]'), textMessage: !!el.querySelector('.text-message[data-author="user"]'), testIdMatch: attrTestId.includes('user') }, assistantSelectors: { authorRole: !!el.querySelector('[data-message-author-role="assistant"]'), textMessage: !!el.querySelector('.text-message[data-author="assistant"]'), testIdMatch: attrTestId.includes('assistant') } }); } let block = null; if (isUser) { block = el.querySelector('[data-message-author-role="user"] .whitespace-pre-wrap, [data-message-author-role="user"] div[data-message-content-part], [data-message-author-role="user"] .prose, div[data-message-author-role="user"] p, .text-message[data-author="user"]'); } else if (isAssistant) { block = el.querySelector('.deep-research-result, .border-token-border-sharp .markdown, [data-message-author-role="assistant"] .markdown, [data-message-author-role="assistant"] .prose, [data-message-author-role="assistant"] div[data-message-content-part], div[data-message-author-role="assistant"] p, .text-message[data-author="assistant"]'); } else { if (DEBUG && i < 5) console.log(`ChatGPT Navigation: 元素 ${i} 角色识别失败`); continue; } const preview = getTextPreview(block); if (!preview) { if (DEBUG && i < 5) console.log(`ChatGPT Navigation: 元素 ${i} 无法提取预览文本`); continue; } if (!el.id) el.id = `cgpt-turn-${i + 1}`; const role = isUser ? 'user' : 'assistant'; const seq = isUser ? ++u : ++a; list.push({ id: el.id, idx: i, role, preview, seq }); } if (DEBUG) console.log(`ChatGPT Navigation: 成功识别 ${list.length} 个对话 (用户: ${u}, 助手: ${a})`); return list; } function createPanel() { // 样式去重:避免重复插入样式 const styleId = 'cgpt-compact-nav-style'; let style = document.getElementById(styleId); if (!style) { style = document.createElement('style'); style.id = styleId; style.textContent = ` #cgpt-compact-nav { position: fixed; top: 60px; right: 10px; width: auto; min-width: 80px; max-width: 210px; z-index: 2147483647 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-size: 13px; pointer-events: auto; background: transparent; -webkit-user-select:none; user-select:none; -webkit-tap-highlight-color: transparent; } #cgpt-compact-nav * { -webkit-user-select:none; user-select:none; } .compact-header { display:flex; align-items:center; justify-content:space-between; padding:4px 8px; margin-bottom:4px; background:transparent; border-radius:6px; pointer-events:auto; cursor:move; box-shadow:0 1px 3px rgba(0,0,0,.08); min-width:100px; } .compact-title { font-size:11px; font-weight:600; color: rgb(147, 51, 234); display:flex; align-items:center; gap:3px; } .compact-title svg { width:12px; height:12px; opacity:.5; } .compact-toggle { background:rgba(255,255,255,.9); border:1px solid rgba(0,0,0,.15); color:rgba(0,0,0,.6); cursor:pointer; width:26px; height:26px; display:flex; align-items:center; justify-content:center; border-radius:4px; transition:all .2s; font-size:20px; font-weight:bold; line-height:1; } .compact-toggle:hover { background:rgba(0,0,0,.1); color:rgba(0,0,0,.8); border-color:rgba(0,0,0,.25); } .compact-refresh { background:rgba(255,255,255,.9); border:1px solid rgba(0,0,0,.15); color:rgba(0,0,0,.6); cursor:pointer; width:26px; height:26px; display:flex; align-items:center; justify-content:center; border-radius:4px; transition:all .2s; font-size:14px; font-weight:bold; line-height:1; margin-left:4px; } .compact-refresh:hover { background:rgba(0,0,0,.1); color:rgba(0,0,0,.8); border-color:rgba(0,0,0,.25); } .toggle-text { display:block; font-family:monospace; } .compact-list { max-height:400px; overflow-y:auto; overflow-x:hidden; padding:0; pointer-events:auto; display:flex; flex-direction:column; gap:8px; } .compact-list::-webkit-scrollbar { width:3px; } .compact-list::-webkit-scrollbar-thumb { background:rgba(0,0,0,.2); border-radius:2px; } .compact-list::-webkit-scrollbar-thumb:hover { background:rgba(0,0,0,.3); } .compact-item { display:block; padding:3px 8px; margin:0; border-radius:4px; cursor:pointer; transition:all .15s ease; font-size:12px; line-height:1.4; min-height:20px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; pointer-events:auto; background:rgba(255,255,255,.85); box-shadow:0 1px 2px rgba(0,0,0,.05); width:auto; min-width:60px; max-width:190px; } .compact-item:hover { background:rgba(255,255,255,.95); transform:translateX(2px); box-shadow:0 2px 4px rgba(0,0,0,.1); } .compact-item.user { color: rgb(74, 222, 128); border-left:2px solid rgba(74,222,128,.4); } .compact-item.assistant { color: rgb(96, 165, 250); border-left:2px solid rgba(96,165,250,.4); } .compact-item.active { outline:2px solid rgba(147,51,234,.5); background: rgba(147,51,234,.07); } .compact-text { display:inline-block; } .compact-number { display:inline-block; margin-right:4px; font-weight:600; opacity:.7; font-size:11px; } .compact-empty { padding:10px; text-align:center; color:#999; font-size:11px; background:rgba(255,255,255,.85); border-radius:6px; pointer-events:auto; min-height:20px; line-height:1.4; } /* 底部导航条 */ .compact-footer { margin-top:6px; display:flex; gap:6px; } .nav-btn { flex:1 1 auto; padding:6px 8px; font-size:14px; border-radius:6px; border:1px solid rgba(0,0,0,.15); background:rgba(255,255,255,.9); cursor:pointer; box-shadow:0 1px 2px rgba(0,0,0,.05); line-height:1; } .nav-btn:hover { background:rgba(0,0,0,.06); } .nav-btn:active { transform: translateY(1px); } /* 上下箭头为淡紫色且 75% 透明(alpha=0.25) */ .nav-btn.arrow { background: rgba(147, 51, 234, 0.25); border-color: rgba(147, 51, 234, 0.35); } .nav-btn.arrow:hover { background: rgba(147, 51, 234, 0.35); } /* 移动端 */ @media (max-width: 768px) { #cgpt-compact-nav { right:5px; max-width:160px; } .compact-item { font-size:11px; padding:2px 5px; min-height:18px; } .nav-btn { padding:5px 6px; font-size:13px; } } .highlight-pulse { animation: pulse 1.5s ease-out; } @keyframes pulse { 0% { background-color: rgba(255,243,205,0); } 20% { background-color: rgba(255,243,205,1); } 100% { background-color: rgba(255,243,205,0); } } `; document.head.appendChild(style); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 已创建样式'); } else { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 样式已存在,跳过创建'); } // 启动前清理多余面板(保险丝) const existingPanels = document.querySelectorAll('#cgpt-compact-nav'); if (existingPanels.length > 0) { if (DEBUG || window.DEBUG_TEMP) console.log(`ChatGPT Navigation: 发现 ${existingPanels.length} 个已存在的面板,清理中...`); existingPanels.forEach((panel, index) => { if (index > 0) { // 保留第一个,删除其他 panel.remove(); if (DEBUG || window.DEBUG_TEMP) console.log(`ChatGPT Navigation: 已删除重复面板 ${index}`); } }); // 如果已经有面板存在,直接返回现有的 if (existingPanels.length > 0) { const existingNav = existingPanels[0]; if (existingNav._ui) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 返回已存在的面板'); return existingNav._ui; } } } const nav = document.createElement('div'); nav.id = 'cgpt-compact-nav'; nav.innerHTML = ` <div class="compact-header"> <div style="display: flex; align-items: center; gap: 4px;"> <button class="compact-toggle" type="button" title="收起/展开"><span class="toggle-text">−</span></button> <button class="compact-refresh" type="button" title="刷新对话列表">⟳</button> </div> <div class="compact-title" aria-live="polite" aria-atomic="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/> </svg> <span>问题栏</span> </div> </div> <div class="compact-list" role="listbox" aria-label="对话项"></div> <div class="compact-footer"> <button class="nav-btn" type="button" id="cgpt-nav-top" title="回到顶部">⤒</button> <button class="nav-btn arrow" type="button" id="cgpt-nav-prev" title="上一条(Command+↑ / Alt+[)">↑</button> <button class="nav-btn arrow" type="button" id="cgpt-nav-next" title="下一条(Command+↓ / Alt+])">↓</button> <button class="nav-btn" type="button" id="cgpt-nav-bottom" title="回到底部">⤓</button> </div> `; document.body.appendChild(nav); enableDrag(nav); // 禁用面板内双击与文本选中 nav.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); }, { capture: true }); nav.addEventListener('selectstart', (e) => { e.preventDefault(); }, { capture: true }); nav.addEventListener('mousedown', (e) => { if (e.detail > 1) { e.preventDefault(); } }, { capture: true }); nav._ui = { nav }; return { nav }; } function enableDrag(nav) { const header = nav.querySelector('.compact-header'); let isDragging = false, startX, startY, startLeft, startTop; header.addEventListener('mousedown', (e) => { if (e.target.closest('.compact-toggle')) return; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = nav.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX, dy = e.clientY - startY; nav.style.left = `${startLeft + dx}px`; nav.style.top = `${startTop + dy}px`; nav.style.right = 'auto'; }); document.addEventListener('mouseup', () => { isDragging = false; }); } let cacheIndex = []; function renderList(ui) { const list = ui.nav.querySelector('.compact-list'); if (!list) return; const next = cacheIndex; if (!next.length) { list.innerHTML = `<div class="compact-empty">暂无对话</div>`; return; } list.innerHTML = ''; for (const item of next) { const node = document.createElement('div'); node.className = `compact-item ${item.role}`; node.dataset.id = item.id; node.innerHTML = `<span class="compact-number">${item.idx + 1}.</span><span class="compact-text" title="${escapeAttr(item.preview)}">${escapeHtml(item.preview)}</span>`; node.setAttribute('draggable', 'false'); list.appendChild(node); } if (!list._eventBound) { list.addEventListener('click', (e) => { const item = e.target.closest('.compact-item'); if (!item) return; const el = document.getElementById(item.dataset.id); if (el) { setActiveTurn(item.dataset.id); scrollToTurn(el); } }); list._eventBound = true; } scheduleActiveUpdateNow(); } function refreshIndex(ui) { const next = buildIndex(); if (DEBUG) console.log('ChatGPT Navigation: turns', next.length); lastTurnCount = next.length; cacheIndex = next; renderList(ui); } function getScrollRoot(start) { let el = start || null; while (el && el !== document.documentElement && el !== document.body) { const s = getComputedStyle(el); if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 1) return el; el = el.parentElement; } const doc = document.scrollingElement || document.documentElement; const candidates = [ document.querySelector('[data-testid="conversation-turns"]')?.parentElement, document.querySelector('main[role="main"]'), doc ]; for (const c of candidates) { if (!c) continue; const s = getComputedStyle(c); if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && c.scrollHeight > c.clientHeight + 1) return c; } return doc; } function getFixedHeaderHeight() { const h = document.querySelector('header, [data-testid="top-nav"]'); if (!h) return 0; const r = h.getBoundingClientRect(); return Math.max(0, r.height) + 12; } function findTurnAnchor(root) { if (!root) return null; const selectors = [ '[data-message-author-role] .whitespace-pre-wrap', '[data-message-content-part]', '.deep-research-result .markdown', '.border-token-border-sharp .markdown', '[data-message-author-role] .markdown', '[data-message-author-role] .prose', '.text-message', 'article .markdown', '.prose p', 'p','li','pre','code','blockquote' ]; for (const s of selectors) { const n = root.querySelector(s); if (n && n.offsetParent !== null && n.offsetHeight > 0) return n; } return root; } function scrollToTurn(el) { const anchor = findTurnAnchor(el) || el; const margin = Math.max(0, getFixedHeaderHeight()); try { anchor.style.scrollMarginTop = margin + 'px'; requestAnimationFrame(() => { anchor.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth' }); postScrollNudge(el); }); } catch { const scroller = getScrollRoot(anchor); const scRect = scroller.getBoundingClientRect ? scroller.getBoundingClientRect() : { top: 0 }; const isWindow = (scroller === document.documentElement || scroller === document.body); const base = isWindow ? window.scrollY : scroller.scrollTop; const top = base + anchor.getBoundingClientRect().top - scRect.top - margin; if (isWindow) window.scrollTo({ top, behavior: 'smooth' }); else scroller.scrollTo({ top, behavior: 'smooth' }); postScrollNudge(el); } el.classList.add('highlight-pulse'); anchor.classList.add('highlight-pulse'); setTimeout(() => { el.classList.remove('highlight-pulse'); anchor.classList.remove('highlight-pulse'); }, 1600); } function postScrollNudge(targetEl) { let tries = 0; const step = () => { tries++; const y = getAnchorY(); const r = targetEl.getBoundingClientRect(); const diff = r.top - y; if (diff > 1 && tries <= 6) { const scroller = getScrollRoot(targetEl); const isWindow = (scroller === document.documentElement || scroller === document.body); if (isWindow) window.scrollBy(0, diff + 1); else scroller.scrollBy({ top: diff + 1 }); requestAnimationFrame(step); } else { scheduleActiveUpdateNow(); } }; requestAnimationFrame(step); } function wirePanel(ui) { const toggleBtn = ui.nav.querySelector('.compact-toggle'); const refreshBtn = ui.nav.querySelector('.compact-refresh'); if (toggleBtn) { toggleBtn.addEventListener('click', () => { const list = ui.nav.querySelector('.compact-list'); const toggleText = toggleBtn.querySelector('.toggle-text'); const isHidden = list.getAttribute('data-hidden') === '1'; if (isHidden) { list.style.visibility = 'visible'; list.style.height = ''; list.style.overflow = ''; list.setAttribute('data-hidden', '0'); toggleText.textContent = '−'; } else { list.style.visibility = 'hidden'; list.style.height = '0'; list.style.overflow = 'hidden'; list.setAttribute('data-hidden', '1'); toggleText.textContent = '+'; } }); } if (refreshBtn) { refreshBtn.addEventListener('click', (e) => { if (e.shiftKey) { // Shift+点击 = 强制重新扫描 if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 强制重新扫描 (清除缓存选择器)'); TURN_SELECTOR = null; // 重置选择器缓存 refreshBtn.style.background = 'rgba(255, 0, 0, 0.2)'; setTimeout(() => { refreshBtn.style.background = ''; }, 300); } scheduleRefresh(ui); }); // 添加右键菜单功能 refreshBtn.addEventListener('contextmenu', (e) => { e.preventDefault(); if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 右键强制重新扫描'); TURN_SELECTOR = null; refreshBtn.style.background = 'rgba(255, 0, 0, 0.2)'; setTimeout(() => { refreshBtn.style.background = ''; }, 300); scheduleRefresh(ui); }); // 更新提示文本 refreshBtn.title = "刷新对话列表 (Shift+点击 或 右键 = 强制重新扫描)"; } // 底部按钮 const prevBtn = ui.nav.querySelector('#cgpt-nav-prev'); const nextBtn = ui.nav.querySelector('#cgpt-nav-next'); const topBtn = ui.nav.querySelector('#cgpt-nav-top'); const bottomBtn = ui.nav.querySelector('#cgpt-nav-bottom'); if (prevBtn) prevBtn.addEventListener('click', () => jumpActiveBy(-1)); if (nextBtn) nextBtn.addEventListener('click', () => jumpActiveBy(+1)); if (topBtn) topBtn.addEventListener('click', () => jumpToEdge('top')); if (bottomBtn) bottomBtn.addEventListener('click', () => jumpToEdge('bottom')); // 键盘事件只绑定一次:避免重复绑定 if (!window.__cgptKeysBound) { const onKeydown = (e) => { // Alt+[ / Alt+] if (e.altKey && (e.key === '[' || e.key === ']')) { jumpActiveBy(e.key === ']' ? +1 : -1); e.preventDefault(); } // Command+↑ / Command+↓(metaKey) if (e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const t = e.target; const isEditable = t && ((t.tagName === 'INPUT') || (t.tagName === 'TEXTAREA') || (t.isContentEditable)); if (!isEditable) { jumpActiveBy(e.key === 'ArrowDown' ? +1 : -1); e.preventDefault(); } } // Alt+/ 面板显隐 if (e.altKey && e.key === '/') { const list = ui.nav.querySelector('.compact-list'); const toggleText = ui.nav.querySelector('.compact-toggle .toggle-text'); const isHidden = list.getAttribute('data-hidden') === '1'; if (isHidden) { list.style.visibility = 'visible'; list.style.height = ''; list.style.overflow = ''; list.setAttribute('data-hidden', '0'); if (toggleText) toggleText.textContent = '−'; } else { list.style.visibility = 'hidden'; list.style.height = '0'; list.style.overflow = 'hidden'; list.setAttribute('data-hidden', '1'); if (toggleText) toggleText.textContent = '+'; } e.preventDefault(); } }; document.addEventListener('keydown', onKeydown, { passive: false }); window.__cgptKeysBound = true; if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 已绑定键盘事件'); } else { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 键盘事件已存在,跳过绑定'); } } // 改为不依赖缓存索引,单击立即滚动 function jumpToEdge(which) { const turns = qsTurns(); if (turns && turns.length) { const el = which === 'top' ? turns[0] : turns[turns.length - 1]; if (!el.id) el.id = `cgpt-turn-edge-${which}`; setActiveTurn(el.id); scrollToTurn(el); return; } const sc = getScrollRoot(document.body); const isWindow = (sc === document.documentElement || sc === document.body || sc === (document.scrollingElement || document.documentElement)); const top = which === 'top' ? 0 : Math.max(0, (isWindow ? document.body.scrollHeight : sc.scrollHeight) - (isWindow ? window.innerHeight : sc.clientHeight)); if (isWindow) window.scrollTo({ top, behavior: 'smooth' }); else sc.scrollTo({ top, behavior: 'smooth' }); scheduleActiveUpdateNow(); } function getTurnsContainer() { const nodes = qsTurns(); if (!nodes.length) { // 如果没有找到对话节点,尝试找到可能的对话容器 const potentialContainers = [ document.querySelector('[data-testid="conversation-turns"]'), document.querySelector('main[role="main"]'), document.querySelector('main'), document.querySelector('[role="main"]'), document.querySelector('div[class*="conversation"]'), document.querySelector('div[class*="chat"]'), document.body ].filter(Boolean); if (DEBUG && potentialContainers.length > 1) { console.log('ChatGPT Navigation: 没有找到对话,使用备用容器:', potentialContainers[0]); } return potentialContainers[0] || document.body; } // 找到包含所有对话节点的最小公共父元素 let a = nodes[0]; while (a) { if (nodes.every(n => a.contains(n))) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 对话容器:', a); return a; } a = a.parentElement; } return document.body; } function observeChat(ui) { const target = document.body; // 用稳定祖先,避免容器被重建后失联 const mo = new MutationObserver((muts) => { // 只要涉及消息区域的变更,就触发去抖刷新 for (const mut of muts) { const t = mut.target && mut.target.nodeType === 1 ? mut.target : null; if (!t) continue; // 尽量廉价地判断:在主区域/turn/markdown/消息块内的任何变更都算 if ( t.closest('[data-testid="conversation-turns"]') || t.closest('[data-message-author-role]') || t.closest('[data-testid*="conversation-turn"]') || t.closest('[data-message-id]') || t.closest('.markdown') || t.closest('.prose') ) { // 避免 selector 过期:每次真正刷新前,清掉缓存 TURN_SELECTOR = null; scheduleRefresh(ui, { delay: 80 }); return; } } }); mo.observe(target, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['data-message-author-role', 'data-testid', 'data-message-id', 'class'] }); ui._mo = mo; ui._moTarget = target; // 定期兜底(10s 一次,别等 30s) if (forceRefreshTimer) clearInterval(forceRefreshTimer); forceRefreshTimer = setInterval(() => { TURN_SELECTOR = null; scheduleRefresh(ui, { force: true }); }, 10000); ui._forceRefreshTimer = forceRefreshTimer; } function bindActiveTracking() { document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true }); window.addEventListener('resize', onAnyScroll, { passive: true }); scheduleActiveUpdateNow(); } function startBurstRefresh(ui, ms = 6000, step = 160) { const end = Date.now() + ms; const STOP_BTN = '[data-testid="stop-button"]'; // 生成中按钮 const tick = () => { scheduleRefresh(ui, { force: true }); if (Date.now() < end && document.querySelector(STOP_BTN)) { setTimeout(tick, step); } }; tick(); } function watchSendEvents(ui) { // 点击发送按钮 document.addEventListener('click', (e) => { if (e.target && e.target.closest && e.target.closest('[data-testid="send-button"]')) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 检测到发送按钮点击,启动突发刷新'); startBurstRefresh(ui); } }, true); // ⌘/Ctrl + Enter 发送 document.addEventListener('keydown', (e) => { const t = e.target; if (!t) return; const isTextarea = t.tagName === 'TEXTAREA' || t.isContentEditable; if (isTextarea && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 检测到快捷键发送,启动突发刷新'); startBurstRefresh(ui); } }, true); // 回到前台时强制跑一次 document.addEventListener('visibilitychange', () => { if (!document.hidden) { if (DEBUG || window.DEBUG_TEMP) console.log('ChatGPT Navigation: 页面重新可见,强制刷新'); scheduleRefresh(ui, { force: true }); } }); } function onAnyScroll() { if (scrollTicking) return; scrollTicking = true; requestAnimationFrame(() => { updateActiveFromAnchor(); scrollTicking = false; }); } function scheduleActiveUpdateNow() { requestAnimationFrame(updateActiveFromAnchor); } function getAnchorY() { const h = getFixedHeaderHeight(); return Math.max(0, Math.min(window.innerHeight - 20, h + CONFIG.anchorOffset)); } function updateActiveFromAnchor() { if (!cacheIndex.length) return; const y = getAnchorY(); const xs = [Math.floor(window.innerWidth * 0.40), Math.floor(window.innerWidth * 0.60)]; let activeEl = null; for (const x of xs) { const stack = (document.elementsFromPoint ? document.elementsFromPoint(x, y) : []); if (!stack || !stack.length) continue; for (const el of stack) { if (!el) continue; if (el.id === 'cgpt-compact-nav' || (el.closest && el.closest('#cgpt-compact-nav'))) continue; const t = el.closest && el.closest('[data-cgpt-turn="1"]'); if (t) { activeEl = t; break; } } if (activeEl) break; } const nearNext = findNearNextTop(y, BOUNDARY_EPS); if (nearNext) activeEl = nearNext; if (!activeEl) { const turns = qsTurns(); for (const t of turns) { const r = t.getBoundingClientRect(); if (r.bottom >= y) { activeEl = t; break; } } if (!activeEl && turns.length) activeEl = turns[0]; } if (activeEl) setActiveTurn(activeEl.id); } function findNearNextTop(y, eps) { for (const item of cacheIndex) { const el = document.getElementById(item.id); if (!el) continue; const r = el.getBoundingClientRect(); const d = r.top - y; if (d >= 0 && d <= eps) return el; if (r.top > y + eps) break; } return null; } function setActiveTurn(id) { if (!id || currentActiveId === id) return; currentActiveId = id; const list = document.querySelector('#cgpt-compact-nav .compact-list'); if (!list) return; list.querySelectorAll('.compact-item.active').forEach(n => n.classList.remove('active')); const n = list.querySelector(`.compact-item[data-id="${id}"]`); if (n) { n.classList.add('active'); const r = n.getBoundingClientRect(); const lr = list.getBoundingClientRect(); if (r.top < lr.top) list.scrollTop += (r.top - lr.top - 4); else if (r.bottom > lr.bottom) list.scrollTop += (r.bottom - lr.bottom + 4); } } function jumpActiveBy(delta) { if (!cacheIndex.length) return; let idx = cacheIndex.findIndex(x => x.id === currentActiveId); if (idx < 0) { updateActiveFromAnchor(); idx = cacheIndex.findIndex(x => x.id === currentActiveId); if (idx < 0) idx = 0; } const nextIdx = Math.max(0, Math.min(cacheIndex.length - 1, idx + delta)); const id = cacheIndex[nextIdx].id; const el = document.getElementById(id); if (el) { setActiveTurn(id); scrollToTurn(el); } } function escapeHtml(s) { return (s || '').replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); } function escapeAttr(s) { return escapeHtml(s).replace(/"/g, '"'); } window.requestIdleCallback ||= (cb, opt = {}) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 0 }), opt.timeout || 1); window.cancelIdleCallback ||= (id) => clearTimeout(id); })();