diff --git a/index.json b/index.json index 4eac122..e97afdb 100644 --- a/index.json +++ b/index.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-07-28T03:17:39Z", + "updated_at": "2026-08-12T03:45:39Z", "scripts": [ { "id": "codex-context-used-meter", @@ -241,6 +241,27 @@ "homepage": "", "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/Codex%20Model%20Matrix.js", "sha256": "0a25838a887699f8c8b72efc67f64d9df045697f6e1555b856fb10a9baa8f075" + }, + { + "id": "chatgpt-long-conversation-navigator", + "name": "ChatGPT 长对话双侧导航与预览", + "description": "为 ChatGPT/Codex Windows 桌面端提供完整会话提问索引和聊天/任务回答章节索引,支持严格章节识别、最多两层主要标题、长回答章节门槛、虚拟历史懒加载预览、精确跳转、流式性能优化、原生边栏避让与热重载。", + "version": "6.5.0", + "author": "kdazhy", + "tags": [ + "chatgpt", + "codex", + "conversation", + "navigation", + "outline", + "preview", + "productivity", + "ui", + "windows" + ], + "homepage": "https://github.com/kdazhy/CodexPlusPlusScriptMarket", + "script_url": "https://raw.githubusercontent.com/BigPizzaV3/CodexPlusPlusScriptMarket/main/scripts/chatgpt-long-conversation-navigator.js", + "sha256": "097cf0cbeb8cc1f440542e3d752044fc835a7455b36457efb43baa18a14e597d" } ] } diff --git a/scripts/chatgpt-long-conversation-navigator.js b/scripts/chatgpt-long-conversation-navigator.js new file mode 100644 index 0000000..7d6dfe9 --- /dev/null +++ b/scripts/chatgpt-long-conversation-navigator.js @@ -0,0 +1,2092 @@ +// ==UserScript== +// @name ChatGPT 长对话双侧导航与预览(Codex++) +// @namespace https://github.com/kdazhy +// @version 6.5.0 +// @description 为 ChatGPT Windows 桌面端提供完整会话提问索引、聊天/任务回答章节索引、精确跳转和动态布局避让。 +// @author kdazhy +// @match https://chatgpt.com/* +// @match https://chat.openai.com/* +// @run-at document-idle +// @grant none +// ==/UserScript== + +(() => { + 'use strict'; + + const VERSION = '6.5.0'; + const INSTALL_KEY = '__codexPlusChatConversationNavigator'; + const LOCK_ID = 'cgpt-codex-navigator-lock'; + const HOST_ID = 'cgpt-codex-navigator-v6-host'; + const LEGACY_STYLE_ID = 'cgpt-codex-navigator-v6-legacy-shield'; + + const CONFIG = { + right: 10, + collapsedWidth: 54, + expandedWidth: 430, + chapterCollapsedWidth: 52, + chapterExpandedWidth: 350, + chapterGapFromAnswer: 16, + chapterObstructionGap: 8, + rowHeight: 38, + maxShellHeightRatio: 0.72, + + normalWidth: 8, + near2Width: 11, + near1Width: 17, + activeWidth: 25, + hoverWidth: 38, + + animationMs: 190, + smoothScroll: true, + + titleMaxChars: 72, + chapterTitleMaxChars: 70, + fallbackChapterMinChars: 700, + fallbackChapterMaxChars: 82, + fallbackChapterMinGapPx: 28, + fallbackChapterMaxCount: 12, + chapterMaxVisibleCount: 8, + chapterMinVisibleCount: 3, + chapterMinAnswerChars: 1200, + readingAnchorRatio: 0.30, + scanDebounceMs: 180, + scanIdleTimeoutMs: 260, + routePollMs: 900, + jumpCorrectionMs: 650, + layoutPollMs: 120, + layoutSettleMs: 720, + }; + + // ========================================================= + // 1. Codex++ 热重载与 DOM 单例 + // ========================================================= + // Codex++ 的“重新加载用户脚本”会再次 evaluate 当前文件,因此先销毁旧实例。 + const previous = window[INSTALL_KEY]; + if (previous && typeof previous.destroy === 'function') { + try { + previous.destroy(); + } catch (error) { + console.warn('[ChatGPT Navigator v6] 清理旧实例失败,将强制接管。', error); + } + } + + function installDomLock() { + document.getElementById(LOCK_ID)?.remove(); + const lock = document.createElement('meta'); + lock.id = LOCK_ID; + lock.dataset.version = VERSION; + lock.dataset.hostId = HOST_ID; + document.documentElement.appendChild(lock); + } + + // ========================================================= + // 2. 清理 / 屏蔽已知旧版本 + // ========================================================= + + function installLegacyShield() { + let style = document.getElementById(LEGACY_STYLE_ID); + if (!style) { + style = document.createElement('style'); + style.id = LEGACY_STYLE_ID; + document.documentElement.appendChild(style); + } + + style.textContent = ` + #cgpt-nav-root, + #cgpt-codex-nav-root-v2, + #cgpt-codex-nav-tooltip-v2, + #cgpt-codex-nav-root-v3, + #cgpt-codex-navigator-v4-host, + #cgpt-codex-navigator-v5-host { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + } + `; + } + + function removeStaleHosts() { + document + .querySelectorAll([ + '#cgpt-codex-navigator-v4-host', + '#cgpt-codex-navigator-v5-host', + `#${HOST_ID}`, + ].join(',')) + .forEach(el => el.remove()); + } + + removeStaleHosts(); + installDomLock(); + installLegacyShield(); + + // ========================================================= + // 3. 状态 + // ========================================================= + + const state = { + host: null, + shadow: null, + shell: null, + body: null, + count: null, + chapterShell: null, + chapterBody: null, + chapterCount: null, + chapterContext: null, + + items: [], + previewCache: new Map(), + activeIndex: -1, + hoverIndex: -1, + chapters: [], + chapterRows: [], + activeChapterIndex: -1, + hoverChapterIndex: -1, + chapterVisualActiveIndex: -2, + chapterVisualHoverIndex: -2, + chapterMetrics: null, + viewportMetrics: null, + viewportMetricsDirty: true, + conversationRoot: null, + scrollRoot: null, + + currentRouteKey: '', + destroyed: false, + scanCount: 0, + scanTimer: null, + scanIdleHandle: null, + lateScanTimer: null, + navigationScanTimer: null, + jumpCorrectionTimer: null, + scrollRAF: null, + mutationObserver: null, + resizeObserver: null, + layoutTimer: null, + layoutBurstUntil: 0, + lastChapterRight: null, + routeTimer: null, + domReadyHandler: null, + initialized: false, + }; + + // ========================================================= + // 4. ChatGPT 消息提取 + // ========================================================= + + function normalizeText(text) { + return (text || '') + .replace(/\u00A0/g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n{2,}/g, '\n') + .trim(); + } + + function oneLine(text) { + return normalizeText(text).replace(/\s*\n\s*/g, ' '); + } + + function truncate(text, maxChars = CONFIG.titleMaxChars) { + const chars = Array.from(text); + return chars.length > maxChars + ? chars.slice(0, maxChars).join('') + '…' + : text; + } + + function getConversationRoot() { + const candidates = [ + ...document.querySelectorAll([ + '[data-thread-find-target="conversation"]', + '[data-testid="conversation-turn-list"]', + 'main', + ].join(',')), + ]; + + let best = null; + let bestCount = -1; + + for (const candidate of candidates) { + const count = candidate.querySelectorAll([ + '[data-user-message-bubble]', + '[data-message-author-role="user"]', + '[data-testid="user-message"]', + '[data-message-role="user"]', + ].join(',')).length; + + if (count > bestCount) { + best = candidate; + bestCount = count; + } + } + + return best || document.body || document.documentElement; + } + + function getUserMessageNodes(root = getConversationRoot()) { + const selector = [ + '[data-user-message-bubble]', + '[data-message-author-role="user"]', + '[data-testid="user-message"]', + '[data-message-role="user"]', + ].join(','); + + return [...root.querySelectorAll(selector)].filter(node => { + const parentUserNode = node.parentElement?.closest(selector); + return !parentUserNode || !root.contains(parentUserNode); + }); + } + + function getScrollTarget(messageNode) { + return ( + messageNode.closest('[data-local-conversation-user-anchor]') || + messageNode.closest('[data-turn-key]') || + messageNode.closest('[data-content-search-turn-key]') || + messageNode.closest('[data-testid^="conversation-turn-"]') || + messageNode.closest('[data-thread-find-target^="message"]') || + messageNode.closest('[data-message-id]') || + messageNode.closest('article') || + messageNode + ); + } + + // “任务”消息通常有独立 user anchor;“聊天”fallback turn 会把问答放在同一 turn。 + // 回答关联必须使用更窄的用户单元,不能复用可能包住整轮回答的跳转目标。 + function getMessageBoundary(messageNode) { + return ( + messageNode.closest('[data-local-conversation-user-anchor]') || + messageNode.closest('[data-content-search-unit-key]') || + messageNode.closest('[data-testid="user-message"]') || + messageNode.closest('[data-message-id]') || + messageNode + ); + } + + function getNavigationId(messageNode, boundary, target) { + for (const node of [boundary, messageNode, target]) { + const id = node?.getAttribute?.('data-content-search-unit-key'); + if (id) return id; + } + return ''; + } + + function extractMessageText(node) { + const preferred = + node.querySelector('.whitespace-pre-wrap') || + node.querySelector('[class*="whitespace-pre-wrap"]') || + node.querySelector('[data-message-content]') || + node.querySelector('[data-testid="user-message"]') || + node.querySelector('[class*="markdown"]') || + node; + + const text = oneLine(preferred.innerText || preferred.textContent || ''); + if (text) return text; + + const attachmentCount = node.querySelectorAll([ + 'img', + '[data-testid*="attachment"]', + '[data-message-attachment]', + ].join(',')).length; + + return attachmentCount ? `附件消息(${attachmentCount} 个附件)` : ''; + } + + function isNodeBetween(node, start, end) { + if (!node?.isConnected || !start?.isConnected || start.contains(node)) return false; + + const afterStart = Boolean( + start.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING + ); + if (!afterStart) return false; + + return !end || Boolean( + node.compareDocumentPosition(end) & Node.DOCUMENT_POSITION_FOLLOWING + ); + } + + function findAssistantRoot(conversationRoot, start, end) { + if (!conversationRoot || !start) return null; + + const selectorGroups = [ + '[data-markdown-text-style="assistant-message"]', + '[data-message-author-role="assistant"] .markdown, [data-message-author-role="assistant"] [class*="markdown"]', + '.markdown, [class*="MarkdownRoot"]', + ]; + + for (const selector of selectorGroups) { + const candidate = [...conversationRoot.querySelectorAll(selector)] + .find(node => + !node.closest('[data-user-message-bubble], [data-local-conversation-user-anchor]') && + isNodeBetween(node, start, end) + ); + if (candidate) return candidate; + } + + return [...conversationRoot.querySelectorAll('[data-content-search-unit-key]')] + .find(unit => { + if (!isNodeBetween(unit, start, end)) return false; + return [...unit.querySelectorAll('h4.sr-only')] + .some(label => /ChatGPT\s*说|assistant/i.test(label.textContent || '')); + }) || null; + } + + function getHeadingLevel(heading) { + const match = heading?.tagName?.match(/^H([1-6])$/i); + return match ? Number(match[1]) : 2; + } + + function isSemanticChapterHeading(node) { + if ( + !isVisibleChapterTarget(node) || + node.closest?.('pre, blockquote, ul, ol, table') + ) { + return false; + } + + const text = oneLine(node.innerText || node.textContent || ''); + return text.length >= 2 && text.length <= CONFIG.chapterTitleMaxChars; + } + + function isVisibleChapterTarget(node) { + if ( + !node?.isConnected || + node.matches?.('.sr-only, [aria-hidden="true"]') || + node.closest?.('[data-user-message-bubble], [data-local-conversation-user-anchor]') + ) { + return false; + } + + const rect = node.getBoundingClientRect(); + return rect.width > 2 && rect.height > 2; + } + + function isNumberedChapterTitle(text) { + return /^(?:第\s*[0-9一二三四五六七八九十百千万]+\s*(?:章|节|部分|步|阶段)(?=$|[\s::.。、-])|[一二三四五六七八九十百千万]+[、..。]\s*|[((]\s*[一二三四五六七八九十百千万0-9]+\s*[))]\s*|[0-9]{1,3}\s*[.)、.::]\s*)/.test(text); + } + + function isStandaloneEmphasis(node, text) { + const children = [...(node.children || [])].filter(child => !child.matches('br')); + const emphasis = children.filter(child => child.matches('strong, b, em, i, mark')); + if (children.length !== 1 || emphasis.length !== 1) return false; + + const emphasisText = oneLine(emphasis[0].innerText || emphasis[0].textContent || ''); + return ( + Boolean(emphasisText) && + emphasisText === text && + !/[。!?.!?::]$/.test(text) + ); + } + + function isFallbackChapterCandidate(node) { + if (!isVisibleChapterTarget(node)) return false; + + const tagName = node.tagName?.toUpperCase(); + if ( + !['P', 'DIV'].includes(tagName) || + node.matches('blockquote, ul, ol, pre, table, code') || + node.closest('blockquote, ul, ol, pre, table, code') + ) { + return false; + } + + const text = oneLine(node.innerText || node.textContent || ''); + if ( + text.length < 3 || + text.length > CONFIG.fallbackChapterMaxChars + ) { + return false; + } + + if (/[。!?.!?::,,;;]$/.test(text)) return false; + + return isNumberedChapterTitle(text) || isStandaloneEmphasis(node, text); + } + + function toChapter(target, level = 2, synthetic = false) { + const fullText = oneLine(target.innerText || target.textContent || ''); + return { + heading: target, + level, + synthetic, + fullText, + title: truncate(fullText, CONFIG.chapterTitleMaxChars), + }; + } + + function buildFallbackChapters(root) { + const rootText = oneLine(root.innerText || root.textContent || ''); + if (rootText.length < CONFIG.fallbackChapterMinChars) { + return []; + } + + const candidates = [...root.querySelectorAll([ + ':scope > p', + ':scope > div', + ].join(','))].filter(isFallbackChapterCandidate); + + const chapters = []; + let lastTop = -Infinity; + for (const candidate of candidates) { + const top = candidate.getBoundingClientRect().top; + if (chapters.length && top - lastTop < CONFIG.fallbackChapterMinGapPx) continue; + chapters.push(toChapter(candidate, chapters.length ? 3 : 2, true)); + lastTop = top; + if (chapters.length >= CONFIG.fallbackChapterMaxCount) break; + } + + return chapters.filter(chapter => chapter.fullText); + } + + function limitChapterLevels(chapters) { + const allowedLevels = [...new Set(chapters.map(chapter => chapter.level))] + .sort((left, right) => left - right) + .slice(0, 2); + const allowed = new Set(allowedLevels); + return chapters + .filter(chapter => allowed.has(chapter.level)) + .slice(0, CONFIG.chapterMaxVisibleCount); + } + + function buildChapterModel(item) { + const root = item?.assistantRoot; + if (!root?.isConnected) return []; + + const rootText = oneLine(root.innerText || root.textContent || ''); + if (rootText.length < CONFIG.chapterMinAnswerChars) { + return []; + } + + const semanticChapters = [...root.querySelectorAll('h1, h2, h3, h4, h5, h6')] + .filter(isSemanticChapterHeading) + .map(heading => toChapter(heading, getHeadingLevel(heading))) + .filter(chapter => chapter.fullText); + const limitedSemanticChapters = limitChapterLevels(semanticChapters); + + if (limitedSemanticChapters.length) { + return limitedSemanticChapters.length >= CONFIG.chapterMinVisibleCount + ? limitedSemanticChapters + : []; + } + + const fallbackChapters = limitChapterLevels(buildFallbackChapters(root)); + return fallbackChapters.length >= CONFIG.chapterMinVisibleCount + ? fallbackChapters + : []; + } + + function findScrollRoot(node) { + let current = node?.parentElement || null; + + while (current && current !== document.documentElement) { + const style = getComputedStyle(current); + const canScroll = /(auto|scroll|overlay)/.test(style.overflowY); + if (canScroll && current.clientHeight > 0 && current.scrollHeight > current.clientHeight + 8) { + return current; + } + current = current.parentElement; + } + + return document.scrollingElement || document.documentElement; + } + + function getViewportMetrics() { + if (state.viewportMetrics && !state.viewportMetricsDirty) { + return state.viewportMetrics; + } + + const root = state.scrollRoot; + if (!root || root === document.body || root === document.documentElement || root === document.scrollingElement) { + state.viewportMetrics = { + top: 0, + bottom: window.innerHeight, + height: window.innerHeight, + }; + state.viewportMetricsDirty = false; + return state.viewportMetrics; + } + + const rect = root.getBoundingClientRect(); + const top = Math.max(0, rect.top); + const bottom = Math.min(window.innerHeight, rect.bottom); + state.viewportMetrics = { + top, + bottom, + height: Math.max(1, bottom - top), + }; + state.viewportMetricsDirty = false; + return state.viewportMetrics; + } + + function getScrollOffset(root = state.scrollRoot) { + if (!root || root === document.body || root === document.documentElement || root === document.scrollingElement) { + return document.scrollingElement?.scrollTop || window.scrollY || 0; + } + return root.scrollTop || 0; + } + + function captureChapterMetrics() { + if (!state.chapters.length || !state.scrollRoot) { + state.chapterMetrics = null; + return; + } + + const tops = state.chapters.map(chapter => { + const heading = chapter.heading; + return heading?.isConnected + ? heading.getBoundingClientRect().top + : null; + }); + + state.chapterMetrics = { + root: state.scrollRoot, + scrollTop: getScrollOffset(), + tops, + }; + } + + function getRouteKey() { + const activeThread = document.querySelector([ + '[data-app-action-sidebar-thread-id][aria-current="page"]', + '[data-app-action-sidebar-thread-id][data-state="active"]', + '[data-app-action-sidebar-thread-id][aria-selected="true"]', + ].join(',')); + + const threadId = activeThread?.getAttribute('data-app-action-sidebar-thread-id') || ''; + const conversationId = + document.querySelector('[data-above-composer-conversation-id]') + ?.getAttribute('data-above-composer-conversation-id') || + document.querySelector('[data-conversation-id]')?.getAttribute('data-conversation-id') || + ''; + + return `${location.href}|${threadId}|${conversationId}`; + } + + // ========================================================= + // 5. UI + // ========================================================= + + function createUI() { + state.host?.remove(); + + const host = document.createElement('div'); + host.id = HOST_ID; + + Object.assign(host.style, { + position: 'fixed', + inset: '0', + width: '0', + height: '0', + zIndex: '2147483647', + pointerEvents: 'none', + }); + + const shadow = host.attachShadow({ mode: 'open' }); + + shadow.innerHTML = ` + + + + + + `; + + (document.body || document.documentElement).appendChild(host); + + state.host = host; + state.shadow = shadow; + state.shell = shadow.getElementById('shell'); + state.body = shadow.getElementById('body'); + state.count = shadow.getElementById('count'); + state.chapterShell = shadow.getElementById('chapter-shell'); + state.chapterBody = shadow.getElementById('chapter-body'); + state.chapterCount = shadow.getElementById('chapter-count'); + state.chapterContext = shadow.getElementById('chapter-context'); + + state.shell.addEventListener('pointerleave', () => { + state.hoverIndex = -1; + updateVisualState(false); + }); + + state.chapterShell.addEventListener('pointerleave', () => { + state.hoverChapterIndex = -1; + updateChapterVisualState(false); + }); + } + + // ========================================================= + // 6. 严格一对一渲染 + // ========================================================= + + function getNativeNavigationEntries() { + const seen = new Set(); + return [...document.querySelectorAll('[data-thread-user-message-navigation-item-id]')] + .map((control, index) => ({ + control, + index, + navigationId: control.getAttribute('data-thread-user-message-navigation-item-id') || '', + })) + .filter(entry => { + if (!entry.navigationId || seen.has(entry.navigationId)) return false; + seen.add(entry.navigationId); + return true; + }); + } + + function mergeWithNativeNavigation(loadedItems) { + const nativeEntries = getNativeNavigationEntries(); + if (!nativeEntries.length) return loadedItems; + + const loadedById = new Map(); + for (const item of loadedItems) { + if (!item.navigationId) continue; + loadedById.set(item.navigationId, item); + state.previewCache.set(item.navigationId, { + fullText: item.fullText, + title: item.title, + }); + } + + const used = new Set(); + const merged = nativeEntries.map((entry, index) => { + const loaded = loadedById.get(entry.navigationId); + if (loaded) { + used.add(loaded); + return { + ...loaded, + nativeControl: entry.control, + loaded: true, + previewKnown: true, + }; + } + + const cached = state.previewCache.get(entry.navigationId); + const placeholder = `第 ${index + 1} 个提问(点击载入预览)`; + return { + node: null, + target: null, + boundary: null, + assistantRoot: null, + navigationId: entry.navigationId, + nativeControl: entry.control, + loaded: false, + previewKnown: Boolean(cached), + fullText: cached?.fullText || placeholder, + title: cached?.title || placeholder, + }; + }); + + // 旧网页结构或 fallback chat 可能没有对应原生控件,仍保留已识别的真实消息。 + for (const item of loadedItems) { + if (!used.has(item)) merged.push(item); + } + + return merged; + } + + function render() { + if (!state.shell || !state.body) return; + + const n = state.items.length; + state.shell.dataset.empty = String(n === 0); + state.body.replaceChildren(); + + state.count.textContent = `${n} 个提问`; + + if (!n) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = '暂未检测到用户提问'; + state.body.appendChild(empty); + return; + } + + const fragment = document.createDocumentFragment(); + + state.items.forEach((item, index) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'nav-row'; + row.dataset.index = String(index); + row.dataset.navigationId = item.navigationId || ''; + row.dataset.loaded = String(item.loaded !== false); + row.dataset.previewKnown = String(item.previewKnown !== false); + row.title = item.fullText; + row.setAttribute('aria-label', `${index + 1}. ${item.title}`); + row.style.setProperty('--entry-delay', `${Math.min(index, 12) * 12}ms`); + + const titleWrap = document.createElement('span'); + titleWrap.className = 'nav-title-wrap'; + + const num = document.createElement('span'); + num.className = 'nav-num'; + num.textContent = String(index + 1).padStart(2, '0'); + + const title = document.createElement('span'); + title.className = 'nav-title'; + title.textContent = item.title; + + titleWrap.append(num, title); + + const lineCell = document.createElement('span'); + lineCell.className = 'nav-line-cell'; + + // 每条提问始终只有这一根真实线段。 + const line = document.createElement('span'); + line.className = 'nav-line'; + lineCell.appendChild(line); + + row.append(titleWrap, lineCell); + + row.addEventListener('pointerenter', () => { + state.hoverIndex = index; + updateVisualState(true); + }); + + row.addEventListener('click', () => scrollToItem(index)); + + fragment.appendChild(row); + }); + + state.body.appendChild(fragment); + + assertInvariant(); + updateVisualState(false); + } + + function assertInvariant() { + const itemCount = state.items.length; + const rowCount = state.shadow.querySelectorAll('#body .nav-row').length; + const lineCount = state.shadow.querySelectorAll('#body .nav-row > .nav-line-cell > .nav-line').length; + const titleCount = state.shadow.querySelectorAll('#body .nav-row > .nav-title-wrap > .nav-title').length; + + const ok = + itemCount === rowCount && + itemCount === lineCount && + itemCount === titleCount; + + if (!ok) { + console.error(`[ChatGPT Navigator v${VERSION}] 渲染不变量失败`, { + itemCount, + lineCount, + rowCount, + titleCount + }); + } else { + console.debug(`[ChatGPT Navigator v${VERSION}] invariant OK`, { + itemCount, + lineCount, + rowCount, + titleCount + }); + } + } + + function updateVisualState(scrollHoveredRow = false) { + if (!state.shadow) return; + + state.shadow.querySelectorAll('#body .nav-row').forEach(row => { + const i = Number(row.dataset.index); + const distance = state.hoverIndex >= 0 + ? Math.abs(i - state.hoverIndex) + : Infinity; + + row.classList.toggle('row-active', i === state.activeIndex); + row.classList.toggle('row-hover', i === state.hoverIndex); + row.classList.toggle('near-1', distance === 1); + row.classList.toggle('near-2', distance === 2); + }); + + if (scrollHoveredRow && state.hoverIndex >= 0) { + requestAnimationFrame(() => { + state.shadow + .querySelector(`#body .nav-row[data-index="${state.hoverIndex}"]`) + ?.scrollIntoView({ block: 'nearest' }); + }); + } + } + + function getReadingAnchorY() { + const viewport = getViewportMetrics(); + return viewport.top + viewport.height * CONFIG.readingAnchorRatio; + } + + function getActiveChapterIndexFromViewport() { + if (!state.chapters.length) return -1; + + const anchorY = getReadingAnchorY(); + const metrics = state.chapterMetrics; + if ( + !metrics || + metrics.root !== state.scrollRoot || + metrics.tops.length !== state.chapters.length + ) { + captureChapterMetrics(); + } + + const chapterMetrics = state.chapterMetrics; + const scrollDelta = chapterMetrics + ? getScrollOffset() - chapterMetrics.scrollTop + : 0; + let active = 0; + + for (let index = 0; index < state.chapters.length; index += 1) { + const top = chapterMetrics?.tops[index]; + if (top == null) continue; + if (top - scrollDelta <= anchorY) active = index; + else break; + } + + return active; + } + + function refreshChapters(forceRender = false) { + const item = state.items[state.activeIndex]; + const nextChapters = buildChapterModel(item); + const changed = + nextChapters.length !== state.chapters.length || + nextChapters.some((chapter, index) => + chapter.heading !== state.chapters[index]?.heading || + chapter.level !== state.chapters[index]?.level || + chapter.fullText !== state.chapters[index]?.fullText + ); + + state.chapters = nextChapters; + state.chapterMetrics = null; + state.activeChapterIndex = getActiveChapterIndexFromViewport(); + + if (changed || forceRender) renderChapters(); + else updateChapterVisualState(false); + + captureChapterMetrics(); + updateChapterPosition(); + } + + function renderChapters() { + if (!state.chapterShell || !state.chapterBody) return; + + const item = state.items[state.activeIndex]; + const count = state.chapters.length; + state.chapterShell.dataset.empty = String(count === 0); + state.chapterRows = []; + state.chapterVisualActiveIndex = -2; + state.chapterVisualHoverIndex = -2; + state.chapterBody.replaceChildren(); + state.chapterCount.textContent = `${count} 章`; + state.chapterContext.textContent = item ? truncate(item.fullText, 30) : ''; + + if (!count) { + state.chapterMetrics = null; + return; + } + + const fragment = document.createDocumentFragment(); + state.chapters.forEach((chapter, index) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'nav-row'; + row.dataset.index = String(index); + row.dataset.level = String(chapter.level); + row.title = chapter.fullText; + row.setAttribute('aria-label', `${index + 1}. ${chapter.title}`); + row.style.setProperty('--entry-delay', `${Math.min(index, 12) * 12}ms`); + + const titleWrap = document.createElement('span'); + titleWrap.className = 'nav-title-wrap'; + + const num = document.createElement('span'); + num.className = 'nav-num'; + num.textContent = String(index + 1).padStart(2, '0'); + + const title = document.createElement('span'); + title.className = `nav-title level-${chapter.level}`; + title.textContent = chapter.title; + titleWrap.append(num, title); + + const lineCell = document.createElement('span'); + lineCell.className = 'nav-line-cell'; + const line = document.createElement('span'); + line.className = 'nav-line'; + lineCell.appendChild(line); + row.append(titleWrap, lineCell); + + row.addEventListener('pointerenter', () => { + state.hoverChapterIndex = index; + updateChapterVisualState(true); + }); + row.addEventListener('click', () => scrollToChapter(index)); + state.chapterRows.push(row); + fragment.appendChild(row); + }); + + state.chapterBody.appendChild(fragment); + updateChapterVisualState(false); + } + + function updateChapterVisualState(scrollHoveredRow = false) { + if (!state.shadow) return; + + const previousActive = state.chapterVisualActiveIndex; + const previousHover = state.chapterVisualHoverIndex; + const affected = new Set([ + previousActive, + state.activeChapterIndex, + previousHover, + state.hoverChapterIndex, + ]); + + for (const hoverIndex of [previousHover, state.hoverChapterIndex]) { + if (hoverIndex < 0) continue; + affected.add(hoverIndex - 2); + affected.add(hoverIndex - 1); + affected.add(hoverIndex + 1); + affected.add(hoverIndex + 2); + } + + for (const index of affected) { + const row = state.chapterRows[index]; + if (!row) continue; + const distance = state.hoverChapterIndex >= 0 + ? Math.abs(index - state.hoverChapterIndex) + : Infinity; + + row.classList.toggle('row-active', index === state.activeChapterIndex); + row.classList.toggle('row-hover', index === state.hoverChapterIndex); + row.classList.toggle('near-1', distance === 1); + row.classList.toggle('near-2', distance === 2); + } + + state.chapterVisualActiveIndex = state.activeChapterIndex; + state.chapterVisualHoverIndex = state.hoverChapterIndex; + + if (scrollHoveredRow && state.hoverChapterIndex >= 0) { + requestAnimationFrame(() => { + state.chapterRows[state.hoverChapterIndex] + ?.scrollIntoView({ block: 'nearest' }); + }); + } + } + + function getAnswerContentLeft(item) { + for (const node of [item?.assistantRoot, item?.target]) { + if (!node?.isConnected) continue; + const rect = node.getBoundingClientRect(); + if (Number.isFinite(rect.left) && rect.width > 0) return rect.left; + } + return null; + } + + function getLeftObstructionRight(answerLeft) { + let obstructionRight = 4; + const candidates = document.querySelectorAll( + 'aside, nav[aria-label], [data-testid*="sidebar"]' + ); + + for (const node of candidates) { + const rect = node.getBoundingClientRect(); + const style = getComputedStyle(node); + const visible = + style.display !== 'none' && + style.visibility !== 'hidden' && + rect.width > 48 && + rect.height > window.innerHeight * 0.45; + + if (visible && rect.left < 24 && rect.right < answerLeft) { + obstructionRight = Math.max(obstructionRight, rect.right); + } + } + + return obstructionRight; + } + + function updateChapterPosition() { + if (!state.chapterShell || !state.chapters.length) return; + + const item = state.items[state.activeIndex]; + const answerLeft = getAnswerContentLeft(item); + if (answerLeft == null) return; + + const anchorX = Math.max( + CONFIG.chapterCollapsedWidth + 4, + answerLeft - CONFIG.chapterGapFromAnswer + ); + const obstructionRight = getLeftObstructionRight(answerLeft); + const availableWidth = Math.floor( + anchorX - obstructionRight - CONFIG.chapterObstructionGap + ); + + state.chapterShell.style.setProperty( + '--chapter-available-width', + `${Math.max(CONFIG.chapterCollapsedWidth, availableWidth)}px` + ); + state.chapterShell.dataset.constrained = String( + availableWidth < CONFIG.chapterCollapsedWidth + ); + + if (availableWidth < CONFIG.chapterCollapsedWidth) return; + + const right = Math.max(4, window.innerWidth - anchorX); + if ( + state.lastChapterRight === null || + Math.abs(state.lastChapterRight - right) >= 0.5 + ) { + state.lastChapterRight = right; + state.chapterShell.style.right = `${right}px`; + } + } + + function scheduleChapterPositionBurst() { + if (state.destroyed) return; + state.layoutBurstUntil = performance.now() + CONFIG.layoutSettleMs; + if (state.layoutTimer) return; + + const tick = () => { + state.layoutTimer = null; + updateChapterPosition(); + if (!state.destroyed && performance.now() < state.layoutBurstUntil) { + state.layoutTimer = setTimeout(tick, CONFIG.layoutPollMs); + } + }; + + tick(); + } + + function findLayoutRoots() { + const item = state.items[state.activeIndex]; + return [...new Set([ + document.documentElement, + document.querySelector('main, [role="main"]'), + state.conversationRoot, + item?.assistantRoot, + ...document.querySelectorAll('aside, nav[aria-label], [data-testid*="sidebar"]'), + ].filter(Boolean))]; + } + + function observeLayout() { + state.resizeObserver?.disconnect(); + if (typeof ResizeObserver !== 'function') return; + + state.resizeObserver = new ResizeObserver(() => { + state.viewportMetricsDirty = true; + state.chapterMetrics = null; + scheduleChapterPositionBurst(); + }); + + for (const root of findLayoutRoots()) { + try { + state.resizeObserver.observe(root); + } catch {} + } + } + + // ========================================================= + // 7. 跳转 / 当前阅读位置 + // ========================================================= + + function getNativeNavigationControl(item) { + if (item?.nativeControl?.isConnected) return item.nativeControl; + + const navigationId = item?.navigationId; + if (!navigationId) return null; + + return [...document.querySelectorAll('[data-thread-user-message-navigation-item-id]')] + .find(control => + control.getAttribute('data-thread-user-message-navigation-item-id') === navigationId + ) || null; + } + + function alignTargetToStart(target, behavior = 'auto') { + if (!target?.isConnected) return; + + target.scrollIntoView({ + behavior, + block: 'start', + inline: 'nearest', + }); + } + + function alignItemToStart(item, behavior = 'auto') { + alignTargetToStart(item?.target, behavior); + } + + function scrollToItem(index) { + const item = state.items[index]; + const nativeControl = getNativeNavigationControl(item); + + if (!item || (!item.target?.isConnected && !nativeControl?.isConnected)) { + scheduleScan(); + return; + } + + clearTimeout(state.jumpCorrectionTimer); + clearTimeout(state.navigationScanTimer); + + if (nativeControl?.isConnected) { + nativeControl.click(); + } else { + alignItemToStart(item, CONFIG.smoothScroll ? 'smooth' : 'auto'); + } + + setActive(index); + + // 未挂载的虚拟历史项交给 Codex 原生控件载入;随后只做两次低频重扫和一次落点校准。 + if (!item.target?.isConnected) { + scheduleScan(80); + state.navigationScanTimer = setTimeout(() => { + state.navigationScanTimer = null; + scheduleScan(0); + }, 520); + } + + // 平滑滚动期间内容高度可能继续变化;结束后用用户消息锚点再校准一次。 + state.jumpCorrectionTimer = setTimeout(() => { + const currentItem = item.navigationId + ? state.items.find(candidate => candidate.navigationId === item.navigationId) + : item; + if (!currentItem?.target?.isConnected) return; + + const viewport = getViewportMetrics(); + const rect = currentItem.target.getBoundingClientRect(); + const scrollMarginTop = Number.parseFloat( + getComputedStyle(currentItem.target).scrollMarginTop + ) || 0; + const expectedTop = viewport.top + scrollMarginTop; + + if (Math.abs(rect.top - expectedTop) > 6) { + alignItemToStart(currentItem, 'auto'); + } + }, CONFIG.jumpCorrectionMs); + } + + function scrollToChapter(index) { + const chapter = state.chapters[index]; + if (!chapter?.heading?.isConnected) { + scheduleScan(0); + return; + } + + clearTimeout(state.jumpCorrectionTimer); + alignTargetToStart( + chapter.heading, + CONFIG.smoothScroll ? 'smooth' : 'auto' + ); + + state.jumpCorrectionTimer = setTimeout(() => { + if (!chapter.heading?.isConnected) return; + + const viewport = getViewportMetrics(); + const rect = chapter.heading.getBoundingClientRect(); + const scrollMarginTop = Number.parseFloat( + getComputedStyle(chapter.heading).scrollMarginTop + ) || 0; + const expectedTop = viewport.top + scrollMarginTop; + + if (Math.abs(rect.top - expectedTop) > 6) { + alignTargetToStart(chapter.heading, 'auto'); + } + }, CONFIG.jumpCorrectionMs); + + state.activeChapterIndex = index; + updateChapterVisualState(false); + } + + function navigateRelative(delta) { + if (!state.items.length) return; + + let index = state.activeIndex; + if (index < 0) index = 0; + + index = Math.max( + 0, + Math.min(state.items.length - 1, index + delta) + ); + + scrollToItem(index); + } + + function updateActiveFromViewport() { + if (!state.items.length) { + setActive(-1); + return; + } + + const anchorY = getReadingAnchorY(); + let activeIndex = state.items.findIndex(item => item.target?.isConnected); + if (activeIndex < 0) { + updateVisualState(false); + return; + } + + for (let index = 0; index < state.items.length; index += 1) { + const target = state.items[index].target; + if (!target?.isConnected) continue; + if (target.getBoundingClientRect().top <= anchorY) activeIndex = index; + else break; + } + + setActive(activeIndex); + } + + function setActive(index) { + if (state.activeIndex === index) return; + state.activeIndex = index; + updateVisualState(false); + refreshChapters(true); + observeLayout(); + scheduleChapterPositionBurst(); + } + + // ========================================================= + // 8. 扫描 / SPA + // ========================================================= + + function scanMessages() { + if (state.destroyed) return; + state.scanCount += 1; + + const previousActiveRoot = state.items[state.activeIndex]?.assistantRoot || null; + + let uiRecreated = false; + if (!state.host?.isConnected) { + createUI(); + uiRecreated = true; + } + + const conversationRoot = getConversationRoot(); + const nodes = getUserMessageNodes(conversationRoot); + const seenBoundaries = new Set(); + const loadedItems = []; + + for (const node of nodes) { + const target = getScrollTarget(node); + const boundary = getMessageBoundary(node); + if (!target || !boundary || seenBoundaries.has(boundary)) continue; + + const fullText = extractMessageText(node); + if (!fullText) continue; + + seenBoundaries.add(boundary); + + loadedItems.push({ + node, + target, + boundary, + navigationId: getNavigationId(node, boundary, target), + loaded: true, + previewKnown: true, + fullText, + title: truncate(fullText), + }); + } + + loadedItems.forEach((item, index) => { + item.assistantRoot = findAssistantRoot( + conversationRoot, + item.boundary, + loadedItems[index + 1]?.boundary || null + ); + }); + + const nextItems = mergeWithNativeNavigation(loadedItems); + + const changed = + nextItems.length !== state.items.length || + nextItems.some((item, i) => + item.target !== state.items[i]?.target || + item.navigationId !== state.items[i]?.navigationId || + item.loaded !== state.items[i]?.loaded || + item.fullText !== state.items[i]?.fullText + ); + + state.conversationRoot = conversationRoot; + const nextScrollRoot = findScrollRoot( + nextItems.find(item => item.target?.isConnected)?.target || conversationRoot + ); + if (nextScrollRoot !== state.scrollRoot) { + state.viewportMetricsDirty = true; + state.chapterMetrics = null; + } + state.scrollRoot = nextScrollRoot; + state.items = nextItems; + + if (changed || uiRecreated) render(); + updateActiveFromViewport(); + refreshChapters(uiRecreated); + if ( + uiRecreated || + previousActiveRoot !== (state.items[state.activeIndex]?.assistantRoot || null) + ) { + observeLayout(); + } + scheduleChapterPositionBurst(); + } + + function scheduleScan(delay = CONFIG.scanDebounceMs) { + if (state.destroyed) return; + + if (state.scanTimer || state.scanIdleHandle !== null) { + if (delay > 0) return; + clearTimeout(state.scanTimer); + if (state.scanIdleHandle !== null && typeof cancelIdleCallback === 'function') { + cancelIdleCallback(state.scanIdleHandle); + } + state.scanIdleHandle = null; + } + + state.scanTimer = setTimeout(() => { + state.scanTimer = null; + if (typeof requestIdleCallback === 'function') { + state.scanIdleHandle = requestIdleCallback(() => { + state.scanIdleHandle = null; + scanMessages(); + }, { timeout: CONFIG.scanIdleTimeoutMs }); + } else { + scanMessages(); + } + }, delay); + } + + function nodeMatchesOrContains(node, selector) { + return node?.nodeType === Node.ELEMENT_NODE && ( + node.matches(selector) || node.querySelector(selector) + ); + } + + function observeDOM() { + state.mutationObserver?.disconnect(); + + state.mutationObserver = new MutationObserver(mutations => { + const scanSelector = [ + '[data-user-message-bubble]', + '[data-message-author-role="user"]', + '[data-testid="user-message"]', + '[data-message-role="user"]', + '[data-thread-user-message-navigation-item-id]', + '[data-markdown-text-style="assistant-message"]', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + ].join(','); + + const needsScan = mutations.some(mutation => { + if (mutation.type === 'characterData') { + return Boolean(mutation.target.parentElement?.closest([ + '[data-user-message-bubble]', + '[data-message-author-role="user"]', + '[data-testid="user-message"]', + '[data-message-role="user"]', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + ].join(','))); + } + + if (mutation.type === 'childList') { + return [...mutation.addedNodes, ...mutation.removedNodes] + .some(node => nodeMatchesOrContains(node, scanSelector)); + } + + if (mutation.type !== 'attributes') return false; + if (mutation.attributeName === 'aria-hidden') return true; + if (mutation.attributeName === 'class') { + return Boolean(mutation.target.closest?.([ + '[data-user-message-bubble]', + '[data-markdown-text-style="assistant-message"] h1', + '[data-markdown-text-style="assistant-message"] h2', + '[data-markdown-text-style="assistant-message"] h3', + '[data-markdown-text-style="assistant-message"] h4', + '[data-markdown-text-style="assistant-message"] h5', + '[data-markdown-text-style="assistant-message"] h6', + ].join(','))); + } + return !['style', 'data-state'].includes(mutation.attributeName); + }); + if (needsScan) scheduleScan(); + + const layoutChanged = mutations.some(mutation => + mutation.type === 'attributes' && + ['class', 'style', 'data-state'].includes(mutation.attributeName) && + Boolean(mutation.target.closest?.( + 'aside, nav[aria-label], main, [role="main"], [data-testid*="sidebar"]' + )) + ); + if (layoutChanged) scheduleChapterPositionBurst(); + }); + + state.mutationObserver.observe(document.documentElement, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: [ + 'data-message-author-role', + 'data-message-role', + 'data-user-message-bubble', + 'data-turn-key', + 'data-content-search-turn-key', + 'data-conversation-id', + 'data-above-composer-conversation-id', + 'data-testid', + 'data-thread-find-target', + 'data-app-action-sidebar-thread-id', + 'aria-current', + 'aria-selected', + 'data-state', + 'data-markdown-text-style', + 'class', + 'style', + 'aria-hidden', + ], + }); + } + + function onAnyScroll(event) { + if (state.destroyed) return; + if (event?.composedPath?.().includes(state.host)) return; + if (state.scrollRAF) return; + + state.scrollRAF = requestAnimationFrame(() => { + state.scrollRAF = null; + updateActiveFromViewport(); + const chapterIndex = getActiveChapterIndexFromViewport(); + if (chapterIndex !== state.activeChapterIndex) { + state.activeChapterIndex = chapterIndex; + updateChapterVisualState(false); + } + }); + } + + function onResize() { + state.viewportMetricsDirty = true; + state.chapterMetrics = null; + onAnyScroll(); + observeLayout(); + scheduleChapterPositionBurst(); + } + + function onVisibilityChange() { + if (!document.hidden) { + scheduleScan(0); + observeLayout(); + scheduleChapterPositionBurst(); + } + } + + function onKeyDown(event) { + const target = event.target; + const tag = target?.tagName?.toLowerCase(); + + const editing = + tag === 'input' || + tag === 'textarea' || + target?.isContentEditable; + + if (editing) return; + + if ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + event.key === 'ArrowUp' + ) { + event.preventDefault(); + navigateRelative(-1); + } + + if ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + event.key === 'ArrowDown' + ) { + event.preventDefault(); + navigateRelative(1); + } + } + + function watchRoute() { + state.currentRouteKey = getRouteKey(); + clearInterval(state.routeTimer); + + state.routeTimer = setInterval(() => { + if (state.destroyed) return; + + if (!state.host?.isConnected) { + createUI(); + render(); + renderChapters(); + } + + const nextRouteKey = getRouteKey(); + if (nextRouteKey === state.currentRouteKey) return; + + state.currentRouteKey = nextRouteKey; + state.items = []; + state.previewCache.clear(); + state.activeIndex = -1; + state.hoverIndex = -1; + state.chapters = []; + state.chapterRows = []; + state.activeChapterIndex = -1; + state.hoverChapterIndex = -1; + state.chapterMetrics = null; + state.viewportMetricsDirty = true; + state.chapterVisualActiveIndex = -2; + state.chapterVisualHoverIndex = -2; + state.lastChapterRight = null; + + render(); + renderChapters(); + + scheduleScan(0); + clearTimeout(state.lateScanTimer); + state.lateScanTimer = setTimeout(() => scheduleScan(0), 450); + }, CONFIG.routePollMs); + } + + // ========================================================= + // 9. 初始化 + // ========================================================= + + function init() { + if (state.destroyed || state.initialized) return; + state.initialized = true; + + createUI(); + + scanMessages(); + observeDOM(); + observeLayout(); + watchRoute(); + scheduleChapterPositionBurst(); + + window.addEventListener('scroll', onAnyScroll, { + passive: true, + capture: true, + }); + + window.addEventListener('resize', onResize, { passive: true }); + + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('visibilitychange', onVisibilityChange); + + console.info(`[ChatGPT Navigator v${VERSION}] initialized`); + } + + function destroy() { + if (state.destroyed) return; + state.destroyed = true; + + clearTimeout(state.scanTimer); + clearTimeout(state.lateScanTimer); + clearTimeout(state.navigationScanTimer); + clearTimeout(state.jumpCorrectionTimer); + clearTimeout(state.layoutTimer); + clearInterval(state.routeTimer); + if (state.scrollRAF) cancelAnimationFrame(state.scrollRAF); + if (state.scanIdleHandle !== null && typeof cancelIdleCallback === 'function') { + cancelIdleCallback(state.scanIdleHandle); + } + + state.mutationObserver?.disconnect(); + state.resizeObserver?.disconnect(); + window.removeEventListener('scroll', onAnyScroll, true); + window.removeEventListener('resize', onResize); + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('visibilitychange', onVisibilityChange); + if (state.domReadyHandler) { + document.removeEventListener('DOMContentLoaded', state.domReadyHandler); + } + + state.host?.remove(); + document.getElementById(LOCK_ID)?.remove(); + document.getElementById(LEGACY_STYLE_ID)?.remove(); + + if (window[INSTALL_KEY] === api) { + delete window[INSTALL_KEY]; + } + + console.info(`[ChatGPT Navigator v${VERSION}] destroyed`); + } + + const api = { + version: VERSION, + rescan: () => scheduleScan(0), + diagnostics: () => ({ + scans: state.scanCount, + items: state.items.length, + loadedItems: state.items.filter(item => item.loaded !== false).length, + chapters: state.chapters.length, + }), + destroy, + }; + + window[INSTALL_KEY] = api; + + if (document.readyState === 'loading') { + state.domReadyHandler = init; + document.addEventListener('DOMContentLoaded', state.domReadyHandler, { once: true }); + } else { + init(); + } +})();