您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Restores classic chat navigation in Google AI Studio, adding essential UI controls for precise, message-by-message browsing, and a powerful message index menu for efficient conversation navigation. This script operates entirely locally in your browser, does not collect any personal data, and makes no requests to external servers.
// ==UserScript== // @name QuickNav for Google AI Studio // @namespace http://tampermonkey.net/ // @version 18.15 // @description Restores classic chat navigation in Google AI Studio, adding essential UI controls for precise, message-by-message browsing, and a powerful message index menu for efficient conversation navigation. This script operates entirely locally in your browser, does not collect any personal data, and makes no requests to external servers. // @author Axl_script // @homepageURL https://greasyfork.org/en/scripts/548346-quicknav-for-google-ai-studio // @contributionURL https://nowpayments.io/embeds/donation-widget?api_key=0fe4e67c-64aa-4a74-b2d2-a91608b1ccc6 // @match https://aistudio.google.com/* // @grant none // @license MIT // @run-at document-idle // ==/UserScript== (function() { 'use strict'; const ChatNavigator = { allTurns: [], currentIndex: -1, menuFocusedIndex: -1, isDownButtonAtEndToggle: false, JUMP_DISTANCE: 5, scrollSyncTimeout: null, isScrollingProgrammatically: false, isQueueProcessing: false, isUnstickingFromBottom: false, originalScrollTop: 0, originalCurrentIndex: -1, loadingQueue: [], totalToLoad: 0, mainLoopInterval: null, activeObserver: null, observedElement: null, holdTimeout: null, holdInterval: null, init() { this.injectStyles(); this.setupScrollListener(); if (document.getElementById('quicknav-badge-floater')) return; const badgeFloater = document.createElement('div'); badgeFloater.id = 'quicknav-badge-floater'; const badgeIndex = document.createElement('div'); badgeIndex.id = 'quicknav-badge-index'; const badgePercentage = document.createElement('div'); badgePercentage.id = 'quicknav-badge-percentage'; badgeFloater.append(badgeIndex, badgePercentage); document.body.appendChild(badgeFloater); if (this.mainLoopInterval) clearInterval(this.mainLoopInterval); this.mainLoopInterval = setInterval(() => this.mainLoop(), 750); }, mainLoop() { const chatContainer = document.querySelector('ms-autoscroll-container'); if (chatContainer) { if (!this.activeObserver || this.observedElement !== chatContainer) { this.cleanupChatObserver(); this.initializeChatObserver(chatContainer); this.buildTurnIndex(); } } else { if (this.activeObserver) { this.cleanupChatObserver(); this.allTurns = []; this.currentIndex = -1; } } this.ensureUIState(); }, ensureUIState() { const targetNode = document.querySelector('ms-prompt-input-wrapper'); const uiExists = document.getElementById('chat-nav-container'); if (targetNode && !uiExists) { this.createAndInjectUI(targetNode); } else if (!targetNode && uiExists) { uiExists.remove(); const menu = document.getElementById('chat-nav-menu-container'); if (menu) menu.remove(); } }, initializeChatObserver(container) { this.observedElement = container; this.activeObserver = new MutationObserver(() => { this.buildTurnIndex(); }); this.activeObserver.observe(container, { childList: true, subtree: true }); }, cleanupChatObserver() { if (this.activeObserver) { this.activeObserver.disconnect(); } this.activeObserver = null; this.observedElement = null; }, getTurnType(turnElement) { const turnContainer = turnElement.querySelector('.chat-turn-container'); if (!turnContainer) return 'unknown'; if (turnContainer.classList.contains('user')) return 'user_prompt'; if (turnContainer.classList.contains('model')) { return turnElement.querySelector('ms-thought-chunk') ? 'model_thought' : 'model_response'; } return 'unknown'; }, buildTurnIndex() { const allFoundTurns = Array.from(document.querySelectorAll('ms-chat-turn')); const freshTurns = allFoundTurns.filter(turn => { const type = this.getTurnType(turn); return type === 'user_prompt' || type === 'model_response'; }); if (freshTurns.length !== this.allTurns.length || !this.arraysEqual(this.allTurns, freshTurns)) { const contentCache = new Map(); this.allTurns.forEach(turn => { if (turn.id && turn.cachedContent) { contentCache.set(turn.id, { content: turn.cachedContent, isFallback: turn.isFallbackContent }); } }); this.allTurns = freshTurns; this.allTurns.forEach(turn => { if (turn.id && contentCache.has(turn.id)) { const cachedData = contentCache.get(turn.id); turn.cachedContent = cachedData.content; turn.isFallbackContent = cachedData.isFallback; } else { turn.cachedContent = null; turn.isFallbackContent = false; } }); this.updateCounterDisplay(); if (this.currentIndex >= this.allTurns.length) this.synchronizeCurrentIndexFromView(); } else { this.updateCounterDisplay(); } }, arraysEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; }, setupScrollListener() { document.addEventListener('scroll', () => { if (this.isScrollingProgrammatically || this.isQueueProcessing || this.isUnstickingFromBottom) return; this.updateScrollPercentage(); clearTimeout(this.scrollSyncTimeout); this.scrollSyncTimeout = setTimeout(() => this.synchronizeCurrentIndexFromView(), 150); }, true); }, injectStyles() { if (document.getElementById('chat-nav-styles')) return; const styleSheet = document.createElement("style"); styleSheet.id = 'chat-nav-styles'; styleSheet.textContent = ` #chat-nav-container { display: flex; justify-content: center; align-items: center; gap: 12px; margin: 2px auto; width: 100%; box-sizing: border-box; } .counter-wrapper { position: relative; pointer-events: none; z-index: 9999; } .chat-nav-button, #chat-nav-counter { background-color: transparent; border: 1px solid var(--ms-on-surface-variant, #888888); transition: background-color 0.15s ease-in-out; pointer-events: auto; user-select: none; cursor: pointer; } .chat-nav-button { color: var(--ms-on-surface-variant, #888888); flex-shrink: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; } #nav-up, #nav-down { color: #8ab4f8; border-color: #8ab4f8; } #chat-nav-counter { font-family: 'Google Sans', sans-serif; font-size: 12px; padding: 4px 8px; border-radius: 8px; display: inline-flex; align-items: baseline; color: var(--ms-on-surface-variant, #888888); } .chat-nav-button:hover, .chat-nav-button:focus { background-color: var(--ms-surface-3, #F1F3F4); outline: none; } #nav-top:hover, #nav-bottom:hover, #nav-top:focus, #nav-bottom:focus { background-color: rgba(136, 136, 136, 0.15); } #nav-up:hover, #nav-down:hover, #nav-up:focus, #nav-down:focus, #chat-nav-counter:hover, #chat-nav-counter:focus { background-color: rgba(138, 180, 248, 0.15); outline: none; } .chat-nav-button:active { transform: scale(0.95); } #chat-nav-current-num.chat-nav-current-grey { color: var(--ms-on-surface-variant, #888888); } #chat-nav-current-num.chat-nav-current-blue { color: #8ab4f8; font-weight: 500; } #chat-nav-total-num { color: #8ab4f8; font-weight: 500; } .prompt-turn-highlight, .response-turn-highlight { position: relative; border-radius: 12px; } .prompt-turn-highlight::after, .response-turn-highlight::after { content: ""; display: table; clear: both; } .prompt-turn-highlight { box-shadow: 0 0 0 1px #9aa0a6 !important; } .response-turn-highlight { box-shadow: 0 0 0 1px #8ab4f8 !important; } #quicknav-badge-floater { position: fixed; z-index: 1000; visibility: hidden; display: flex; flex-direction: column; align-items: center; pointer-events: none; padding: 4px 7px; border-radius: 8px; font-family: 'Google Sans', sans-serif; } #quicknav-badge-index { font-size: 13px; font-weight: 500; line-height: 1.2; } #quicknav-badge-percentage { font-size: 10px; font-weight: 400; line-height: 1.2; border-top: 1px solid rgba(255, 255, 255, 0.3); margin-top: 3px; padding-top: 3px; } .prompt-badge-bg { background-color: #5f6368; color: #FFFFFF; } .response-badge-bg { background-color: #174ea6; color: #FFFFFF; } #chat-nav-menu-container { display: none; flex-direction: column; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 8px; background-color: #202124; border: 2px solid #8ab4f8; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.25); max-height: 90vh; z-index: 10000; width: clamp(300px, 60vw, 800px); pointer-events: auto; box-sizing: border-box; } #chat-nav-menu-container:focus { outline: none; } #chat-nav-menu { list-style: none; margin: 0; padding: 0 8px 8px 8px; overflow-y: auto; scroll-behavior: smooth; flex-grow: 1; border-radius: 0 0 10px 10px; background-color: #191919; } .chat-nav-menu-item { display: flex; align-items: center; color: #e8eaed; padding: 8px 12px; margin: 2px 0; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: 'Google Sans', sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background-color: #202124; } .chat-nav-menu-item:hover { background-color: #3c4043; } .chat-nav-menu-item.menu-item-focused { background-color: #5f6368; } .menu-item-number { font-weight: 500; margin-right: 8px; flex-shrink: 0; } .prompt-number-color { color: #9aa0a6; } .response-number-color { color: #8ab4f8; } .menu-item-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .prompt-item-bg { border-left: 3px solid #9aa0a6; margin-left: 32px; } .response-item-bg { border-left: 3px solid #8ab4f8; border-bottom: 1px solid #8ab4f8; } .chat-nav-menu-header { flex-shrink: 0; background-color: #202124; z-index: 1; display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid #8ab4f8; border-radius: 10px 10px 0 0; } .header-button { font-family: 'Google Sans', sans-serif; text-decoration: none; font-size: 12px; color: #e8eaed; background-color: #3c4043; padding: 4px 10px; border-radius: 16px; transition: background-color 0.15s ease-in-out, opacity 0.15s ease-in-out; border: none; cursor: pointer; } .header-button:hover:not(:disabled) { background-color: #5f6368; } .header-button:disabled { opacity: 0.5; cursor: not-allowed; } #chat-nav-menu::-webkit-scrollbar { width: 8px; } #chat-nav-menu::-webkit-scrollbar-track { background: #202124; } #chat-nav-menu::-webkit-scrollbar-thumb { background-color: #5f6368; border-radius: 4px; } #chat-nav-menu::-webkit-scrollbar-thumb:hover { background-color: #9aa0a6; } #chat-nav-loader-status { font-family: 'Google Sans', sans-serif; font-size: 12px; color: #9aa0a6; padding: 4px 10px; } `; document.head.appendChild(styleSheet); }, createAndInjectUI(targetNode) { const navContainer = document.createElement('div'); navContainer.id = 'chat-nav-container'; const pathTop = 'M12 4l-6 6 1.41 1.41L12 6.83l4.59 4.58L18 10z M12 12l-6 6 1.41 1.41L12 14.83l4.59 4.58L18 18z'; const pathUp = 'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z'; const pathDown = 'M12 16l-6-6 1.41-1.41L12 13.17l4.59-4.58L18 10z'; const pathBottom = 'M12 12l-6-6 1.41-1.41L12 9.17l4.59-4.58L18 6z M12 20l-6-6 1.41-1.41L12 17.17l4.59-4.58L18 14z'; const btnTop = this.createButton('nav-top', 'Go to first message (Home)', pathTop); const btnUp = this.createButton('nav-up', 'Navigate to previous message', pathUp); const counterWrapper = document.createElement('div'); counterWrapper.className = 'counter-wrapper'; const counter = document.createElement('span'); counter.id = 'chat-nav-counter'; counter.tabIndex = 0; counter.setAttribute('role', 'button'); counter.setAttribute('aria-haspopup', 'true'); counter.setAttribute('aria-expanded', 'false'); const currentNumSpan = document.createElement('span'); currentNumSpan.id = 'chat-nav-current-num'; counter.appendChild(currentNumSpan); const separatorSpan = document.createElement('span'); separatorSpan.id = 'chat-nav-separator'; separatorSpan.textContent = ' / '; counter.appendChild(separatorSpan); const totalNumSpan = document.createElement('span'); totalNumSpan.id = 'chat-nav-total-num'; counter.appendChild(totalNumSpan); const btnDown = this.createButton('nav-down', 'Navigate to next message', pathDown); const btnBottom = this.createButton('nav-bottom', 'Go to last message (End)', pathBottom); const menuContainer = document.createElement('div'); menuContainer.id = 'chat-nav-menu-container'; menuContainer.tabIndex = -1; menuContainer.setAttribute('role', 'menu'); counterWrapper.append(counter, menuContainer); navContainer.append(btnTop, btnUp, counterWrapper, btnDown, btnBottom); const parentContainer = targetNode.closest('footer'); if (parentContainer && parentContainer.parentNode) { parentContainer.parentNode.insertBefore(navContainer, parentContainer); this.attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer); this.updateCounterDisplay(); } }, createButton(id, ariaLabel, pathData) { const button = document.createElement('button'); button.id = id; button.className = 'chat-nav-button'; button.setAttribute('aria-label', ariaLabel); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute('height', '24px'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '24px'); svg.setAttribute('fill', 'currentColor'); const path = document.createElementNS("http://www.w3.org/2000/svg", 'path'); path.setAttribute('d', pathData); svg.appendChild(path); button.appendChild(svg); return button; }, setupHoldableButton(button, action) { const HOLD_DELAY = 300; const HOLD_INTERVAL = 100; let isHolding = false; const stopHold = () => { clearTimeout(this.holdTimeout); clearInterval(this.holdInterval); this.holdInterval = null; isHolding = false; }; button.addEventListener('click', (e) => { if (!isHolding) { action(); } stopHold(); }); button.addEventListener('mousedown', (e) => { if (e.button !== 0) return; isHolding = true; this.holdTimeout = setTimeout(() => { if (isHolding) { this.holdInterval = setInterval(action, HOLD_INTERVAL); } }, HOLD_DELAY); }); button.addEventListener('mouseup', stopHold); button.addEventListener('mouseleave', stopHold); }, attachNavigationLogic(btnTop, btnUp, btnDown, btnBottom, counter, menuContainer) { const lastIndex = () => this.allTurns.length - 1; this.setupHoldableButton(btnTop, () => this.scrollToAbsoluteTop()); this.setupHoldableButton(btnUp, async () => { this.synchronizeCurrentIndexFromView(); const last = lastIndex(); const scrollContainer = document.querySelector('ms-autoscroll-container'); if (this.currentIndex === last && last > -1 && scrollContainer && Math.abs(scrollContainer.scrollTop - (scrollContainer.scrollHeight - scrollContainer.clientHeight)) < 5) { this.isUnstickingFromBottom = true; this.isScrollingProgrammatically = true; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight - 50, behavior: 'auto' }); await new Promise(resolve => setTimeout(async () => { this.isUnstickingFromBottom = false; if (this.currentIndex > 0) { await this.navigateToIndex(this.currentIndex - 1, 'center'); } else { this.isScrollingProgrammatically = false; } resolve(); }, 150)); return; } await this.navigateToIndex(this.currentIndex - 1, 'center'); }); this.setupHoldableButton(btnDown, async () => { this.synchronizeCurrentIndexFromView(); const last = lastIndex(); if (this.currentIndex < last) { await this.navigateToIndex(this.currentIndex + 1, 'center'); } else { const scrollContainer = document.querySelector('ms-autoscroll-container'); if (!scrollContainer) return; if (this.isDownButtonAtEndToggle) { await this.navigateToIndex(last, 'center'); this.isDownButtonAtEndToggle = false; } else { this.isScrollingProgrammatically = true; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' }); setTimeout(() => { this.isScrollingProgrammatically = false; }, 800); this.isDownButtonAtEndToggle = true; } } }); this.setupHoldableButton(btnBottom, async () => { this.synchronizeCurrentIndexFromView(); const last = lastIndex(); if (this.currentIndex === last) { const scrollContainer = document.querySelector('ms-autoscroll-container'); if (scrollContainer) { this.isScrollingProgrammatically = true; scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' }); setTimeout(() => { this.isScrollingProgrammatically = false; }, 800); } } else { await this.navigateToIndex(last, 'center'); } }); counter.addEventListener('click', (e) => { e.stopPropagation(); this.toggleNavMenu(); }); counter.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); this.toggleNavMenu(); } }); menuContainer.addEventListener('keydown', (e) => { const items = menuContainer.querySelectorAll('.chat-nav-menu-item'); if (!items.length) return; let newIndex = this.menuFocusedIndex; let shouldUpdateFocus = true; switch (e.key) { case 'ArrowDown': e.preventDefault(); newIndex = (this.menuFocusedIndex + 1) % items.length; break; case 'ArrowUp': e.preventDefault(); newIndex = (this.menuFocusedIndex - 1 + items.length) % items.length; break; case 'PageDown': e.preventDefault(); newIndex = Math.min(items.length - 1, this.menuFocusedIndex + this.JUMP_DISTANCE); break; case 'PageUp': e.preventDefault(); newIndex = Math.max(0, this.menuFocusedIndex - this.JUMP_DISTANCE); break; case 'Home': e.preventDefault(); newIndex = 0; break; case 'End': e.preventDefault(); newIndex = items.length - 1; break; case 'Enter': e.preventDefault(); if (this.menuFocusedIndex !== -1) { items[this.menuFocusedIndex].click(); } shouldUpdateFocus = false; break; case 'Escape': e.preventDefault(); this.toggleNavMenu(); shouldUpdateFocus = false; break; default: shouldUpdateFocus = false; break; } if (shouldUpdateFocus && newIndex !== this.menuFocusedIndex) { this.updateMenuFocus(items, newIndex); } }); }, scrollToAbsoluteTop() { const scrollContainer = document.querySelector('ms-autoscroll-container'); if (!scrollContainer) return; this.isScrollingProgrammatically = true; scrollContainer.scrollTo({ top: 0, behavior: this.holdInterval ? 'auto' : 'smooth' }); if (this.allTurns.length > 0 && this.currentIndex !== 0) { this.updateHighlight(this.currentIndex, 0); this.currentIndex = 0; this.updateCounterDisplay(); } setTimeout(() => { this.isScrollingProgrammatically = false; }, this.holdInterval ? 50 : 800); }, waitForTurnToStabilize(turnElement, timeout = 1000) { return new Promise((resolve, reject) => { let lastRect = turnElement.getBoundingClientRect(); let stableChecks = 0; const STABLE_CHECKS_REQUIRED = 3; const CHECK_INTERVAL = 100; const intervalId = setInterval(() => { if (!turnElement || !turnElement.isConnected) { clearInterval(intervalId); clearTimeout(timeoutId); return reject(new Error('Target element was removed from DOM.')); } const currentRect = turnElement.getBoundingClientRect(); if (currentRect.top !== lastRect.top || currentRect.height !== lastRect.height) { lastRect = currentRect; stableChecks = 0; } else { stableChecks++; } if (stableChecks >= STABLE_CHECKS_REQUIRED) { clearInterval(intervalId); clearTimeout(timeoutId); resolve(); } }, CHECK_INTERVAL); const timeoutId = setTimeout(() => { clearInterval(intervalId); reject(new Error(`Element stabilization timed out.`)); }, timeout); }); }, async scrollToTurn(index, blockPosition = 'center') { const targetTurn = this.allTurns[index]; if (!targetTurn) { this.isScrollingProgrammatically = false; return; } this.isScrollingProgrammatically = true; const isSmooth = !this.isQueueProcessing && !this.holdInterval; const scrollContainer = document.querySelector('ms-autoscroll-container'); if (!scrollContainer) { this.isScrollingProgrammatically = false; return; } targetTurn.scrollIntoView({ behavior: 'auto', block: 'center' }); try { await this.waitForTurnToStabilize(targetTurn, 2000); let targetScrollTop; if (blockPosition === 'start') { targetScrollTop = targetTurn.offsetTop; } else if (blockPosition === 'end') { targetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight - targetTurn.offsetHeight); } else { // 'center' or default targetScrollTop = targetTurn.offsetTop - (scrollContainer.clientHeight * 0.3); } targetScrollTop = Math.max(0, targetScrollTop); targetScrollTop = Math.min(targetScrollTop, scrollContainer.scrollHeight - scrollContainer.clientHeight); scrollContainer.scrollTo({ top: targetScrollTop, behavior: isSmooth ? 'smooth' : 'auto' }); const timeoutDuration = isSmooth ? 800 : 50; await new Promise(resolve => setTimeout(resolve, timeoutDuration)); } catch (error) { console.warn('QuickNav:', error.message, 'Proceeding with final position.'); } finally { this.isScrollingProgrammatically = false; } }, async navigateToIndex(newIndex, blockPosition = 'center') { if (newIndex < 0 || newIndex >= this.allTurns.length) return; const oldIndex = this.currentIndex; if (newIndex < this.allTurns.length - 1) this.isDownButtonAtEndToggle = false; this.currentIndex = newIndex; this.updateHighlight(oldIndex, newIndex); this.updateCounterDisplay(); await this.scrollToTurn(newIndex, blockPosition); this.updateScrollPercentage(); }, updateScrollPercentage() { const floater = document.getElementById('quicknav-badge-floater'); if (!floater) return; if (this.currentIndex < 0) { floater.style.visibility = 'hidden'; return; } const currentTurn = this.allTurns[this.currentIndex]; if (!currentTurn) { floater.style.visibility = 'hidden'; return; } const rect = currentTurn.getBoundingClientRect(); const turnHeight = rect.height; const isVisible = rect.bottom > 8 && rect.top < window.innerHeight; if (!isVisible || turnHeight <= 0) { floater.style.visibility = 'hidden'; return; } floater.style.visibility = 'visible'; const floaterHeight = floater.offsetHeight || 40; const idealTop = (window.innerHeight / 2) - (floaterHeight / 2); const upperBound = Math.max(8, rect.top); const lowerBound = Math.min(window.innerHeight, rect.bottom) - floaterHeight - 4; let finalTop = idealTop; finalTop = Math.max(finalTop, upperBound); finalTop = Math.min(finalTop, lowerBound); floater.style.top = `${finalTop}px`; floater.style.left = `${rect.right - (floater.offsetWidth / 2)}px`; const percentageBadge = document.getElementById('quicknav-badge-percentage'); if (!percentageBadge) return; const viewportCenterY = window.innerHeight / 2; const distanceScrolled = viewportCenterY - rect.top; const percentage = (distanceScrolled / turnHeight) * 100; const clampedPercentage = Math.max(0, Math.min(percentage, 100)); percentageBadge.textContent = `${Math.round(clampedPercentage)}%`; }, synchronizeCurrentIndexFromView() { if (this.isUnstickingFromBottom || this.isScrollingProgrammatically || this.allTurns.length === 0) { if (this.allTurns.length === 0) { this.updateHighlight(this.currentIndex, -1); this.currentIndex = -1; } this.updateCounterDisplay(); return; } if (this.currentIndex > -1 && this.currentIndex < this.allTurns.length) { const currentTurn = this.allTurns[this.currentIndex]; const rect = currentTurn.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { this.updateScrollPercentage(); return; } } const focusPointY = window.innerHeight * 0.35; let bestMatch = { index: -1, delta: Infinity }; this.allTurns.forEach((turn, index) => { const rect = turn.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { const delta = Math.abs(rect.top - focusPointY); if (delta < bestMatch.delta) { bestMatch = { index, delta }; } } }); if (bestMatch.index !== -1 && this.currentIndex !== bestMatch.index) { this.updateHighlight(this.currentIndex, bestMatch.index); this.currentIndex = bestMatch.index; this.updateCounterDisplay(); this.updateScrollPercentage(); } }, updateHighlight(oldIndex, newIndex) { if (oldIndex > -1 && oldIndex < this.allTurns.length) { this.allTurns[oldIndex].classList.remove('prompt-turn-highlight', 'response-turn-highlight'); } const floater = document.getElementById('quicknav-badge-floater'); if (!floater) return; if (newIndex > -1 && newIndex < this.allTurns.length) { const newTurn = this.allTurns[newIndex]; const isPrompt = this.getTurnType(newTurn) === 'user_prompt'; newTurn.classList.add(isPrompt ? 'prompt-turn-highlight' : 'response-turn-highlight'); const badgeIndex = document.getElementById('quicknav-badge-index'); const badgeClass = isPrompt ? 'prompt-badge-bg' : 'response-badge-bg'; if (badgeIndex) { badgeIndex.textContent = newIndex + 1; } floater.className = badgeClass; floater.style.visibility = 'visible'; this.updateScrollPercentage(); } else { floater.style.visibility = 'hidden'; } }, updateCounterDisplay() { let currentNumSpan = document.getElementById('chat-nav-current-num'); let totalNumSpan = document.getElementById('chat-nav-total-num'); const counterContainer = document.getElementById('chat-nav-counter'); if (!currentNumSpan || !totalNumSpan || !counterContainer) { const counter = document.getElementById('chat-nav-counter'); if (counter) { const current = this.currentIndex > -1 ? this.currentIndex + 1 : '-'; counter.textContent = `${current} / ${this.allTurns.length}`; } return; } const current = this.currentIndex > -1 ? this.currentIndex + 1 : '-'; const total = this.allTurns.length; currentNumSpan.textContent = current; totalNumSpan.textContent = total; currentNumSpan.classList.remove('chat-nav-current-grey', 'chat-nav-current-blue'); if (this.currentIndex === total - 1 && total > 0) { currentNumSpan.classList.add('chat-nav-current-blue'); } else { currentNumSpan.classList.add('chat-nav-current-grey'); } }, toggleNavMenu() { const menuContainer = document.getElementById('chat-nav-menu-container'); const counter = document.getElementById('chat-nav-counter'); if (!menuContainer || !counter) return; const isVisible = menuContainer.style.display === 'flex'; if (isVisible) { menuContainer.style.display = 'none'; counter.setAttribute('aria-expanded', 'false'); document.removeEventListener('click', this.closeNavMenu, true); this.stopDynamicMenuLoading(); counter.focus(); } else { this.populateNavMenu(); menuContainer.style.display = 'flex'; counter.setAttribute('aria-expanded', 'true'); menuContainer.focus(); const items = menuContainer.querySelectorAll('.chat-nav-menu-item'); const initialFocusIndex = this.currentIndex > -1 ? this.currentIndex : 0; this.updateMenuFocus(items, initialFocusIndex, false); requestAnimationFrame(() => { const counterRect = counter.getBoundingClientRect(); const availableSpace = counterRect.top - 18; menuContainer.style.maxHeight = `${availableSpace}px`; if (this.menuFocusedIndex > -1 && this.menuFocusedIndex < items.length) { const menuList = document.getElementById('chat-nav-menu'); const focusedItem = items[this.menuFocusedIndex]; if (menuList && focusedItem) { menuList.scrollTop = focusedItem.offsetTop - menuList.offsetTop - (menuList.clientHeight / 2) + (focusedItem.clientHeight / 2); } } }); setTimeout(() => document.addEventListener('click', this.closeNavMenu, true), 0); } }, closeNavMenu(e) { const menuContainer = document.getElementById('chat-nav-menu-container'); if (menuContainer && !menuContainer.parentElement.contains(e.target) && menuContainer.style.display === 'flex') { this.toggleNavMenu(); } }, updateMenuFocus(items, newIndex, shouldScroll = true) { if (!items || items.length === 0 || newIndex < 0 || newIndex >= items.length) return; if (this.menuFocusedIndex > -1 && this.menuFocusedIndex < items.length) { items[this.menuFocusedIndex].classList.remove('menu-item-focused'); } items[newIndex].classList.add('menu-item-focused'); if (shouldScroll) { const menuList = document.getElementById('chat-nav-menu'); const focusedItem = items[newIndex]; if (menuList && focusedItem) { const itemRect = focusedItem.getBoundingClientRect(); const menuRect = menuList.getBoundingClientRect(); if (itemRect.bottom > menuRect.bottom) { menuList.scrollTop += itemRect.bottom - menuRect.bottom; } else if (itemRect.top < menuRect.top) { menuList.scrollTop -= menuRect.top - itemRect.top; } } } this.menuFocusedIndex = newIndex; }, getTextFromTurn(turn, fromDOMOnly = false) { turn.isFallbackContent = false; if (!fromDOMOnly) { const turnId = turn.id; if (turnId) { const scrollbarButton = document.getElementById(`scrollbar-item-${turnId.replace('turn-', '')}`); if (scrollbarButton && scrollbarButton.getAttribute('aria-label')) { const labelText = scrollbarButton.getAttribute('aria-label'); return { display: labelText, full: labelText, source: 'scrollbar' }; } } } const contentContainer = turn.querySelector('.turn-content'); if (contentContainer) { const clonedContainer = contentContainer.cloneNode(true); clonedContainer.querySelectorAll('ms-code-block').forEach(codeBlockElement => { const codeContent = codeBlockElement.querySelector('pre code'); if (codeContent) { const pre = document.createElement('pre'); pre.textContent = ` ${codeContent.textContent} `; codeBlockElement.parentNode.replaceChild(pre, codeBlockElement); } else { codeBlockElement.remove(); } }); clonedContainer.querySelectorAll('.author-label, .turn-separator, ms-thought-chunk').forEach(el => el.remove()); const text = clonedContainer.textContent?.trim().replace(/\s+/g, ' '); if (text) return { display: text, full: text, source: 'dom' }; } turn.isFallbackContent = true; return { display: '...', full: 'Could not extract content.', source: 'fallback' }; }, populateNavMenu() { const menuContainer = document.getElementById('chat-nav-menu-container'); if (!menuContainer) return; while (menuContainer.firstChild) { menuContainer.removeChild(menuContainer.firstChild); } const header = document.createElement('div'); header.className = 'chat-nav-menu-header'; const menuList = document.createElement('ul'); menuList.id = 'chat-nav-menu'; this.allTurns.forEach((turn, index) => { let displayContent; if (turn.cachedContent && !turn.isFallbackContent) { displayContent = turn.cachedContent; } else { displayContent = this.getTextFromTurn(turn); } const { display, full } = displayContent; const truncatedText = (display.length > 200) ? display.substring(0, 197) + '...' : display; const item = document.createElement('li'); item.className = 'chat-nav-menu-item'; item.setAttribute('role', 'menuitem'); const isPrompt = this.getTurnType(turn) === 'user_prompt'; item.classList.add(isPrompt ? 'prompt-item-bg' : 'response-item-bg'); const numberSpan = document.createElement('span'); numberSpan.className = `menu-item-number ${isPrompt ? 'prompt-number-color' : 'response-number-color'}`; numberSpan.textContent = `${index + 1}.`; const textSpan = document.createElement('span'); textSpan.className = 'menu-item-text'; textSpan.textContent = truncatedText; item.append(numberSpan, textSpan); item.title = full.replace(/\s+/g, ' '); item.addEventListener('click', () => { this.toggleNavMenu(); this.navigateToIndex(index); }); menuList.appendChild(item); }); const leftContainer = document.createElement('div'); leftContainer.style.display = 'flex'; leftContainer.style.alignItems = 'center'; const loadButton = document.createElement('button'); loadButton.id = 'chat-nav-load-button'; loadButton.className = 'header-button'; loadButton.textContent = 'Load All'; loadButton.title = 'Load full text for all messages'; loadButton.addEventListener('click', (e) => { e.stopPropagation(); this.startDynamicMenuLoading(); }); const statusIndicator = document.createElement('span'); statusIndicator.id = 'chat-nav-loader-status'; leftContainer.append(loadButton, statusIndicator); const donateButton = document.createElement('a'); donateButton.href = 'https://nowpayments.io/embeds/donation-widget?api_key=0fe4e67c-64aa-4a74-b2d2-a91608b1ccc6'; donateButton.target = '_blank'; donateButton.rel = 'noopener noreferrer'; donateButton.className = 'header-button'; donateButton.textContent = 'Donate ☕'; donateButton.title = 'Support the developer'; donateButton.addEventListener('click', e => e.stopPropagation()); header.appendChild(leftContainer); header.appendChild(donateButton); menuContainer.appendChild(header); menuContainer.appendChild(menuList); }, async forceScrollToTop(scrollContainer) { return new Promise(resolve => { this.isScrollingProgrammatically = true; const firstTurn = this.allTurns[0]; if (!firstTurn) { this.isScrollingProgrammatically = false; return resolve(); } let attempts = 0; const maxAttempts = 15; const attemptScroll = () => { attempts++; scrollContainer.scrollTop = 0; setTimeout(() => { if (scrollContainer.scrollTop < 50 || attempts >= maxAttempts) { firstTurn.scrollIntoView({ behavior: 'auto', block: 'start' }); setTimeout(() => { this.isScrollingProgrammatically = false; resolve(); }, 100); } else { attemptScroll(); } }, 150); }; attemptScroll(); }); }, async startDynamicMenuLoading() { if (this.isQueueProcessing) return; const loadButton = document.getElementById('chat-nav-load-button'); const scrollContainer = document.querySelector('ms-autoscroll-container'); if (!scrollContainer || !loadButton) return; this.originalCurrentIndex = this.currentIndex; this.originalScrollTop = scrollContainer.scrollTop; const menuItems = document.querySelectorAll('#chat-nav-menu .chat-nav-menu-item'); this.loadingQueue = this.allTurns .map((turn, index) => ({ turn, index, menuItem: menuItems[index] })) .filter(item => { const text = item.menuItem.title; return text.endsWith('...') || text === 'Could not extract content.' || !item.turn.cachedContent || item.turn.isFallbackContent; }); if (this.loadingQueue.length > 0) { this.totalToLoad = this.loadingQueue.length; loadButton.disabled = true; this.isQueueProcessing = true; await this.forceScrollToTop(scrollContainer); this.processLoadingQueue(); } else { const statusIndicator = document.getElementById('chat-nav-loader-status'); if (statusIndicator) statusIndicator.textContent = 'All loaded.'; } }, pollForContent(turn) { return new Promise((resolve, reject) => { const maxAttempts = 50; let attempts = 0; const interval = setInterval(() => { if (!this.isQueueProcessing) { clearInterval(interval); return reject(new Error('Loading stopped by user.')); } const content = this.getTextFromTurn(turn, true); if (content.source === 'dom') { clearInterval(interval); resolve(content); } else if (++attempts >= maxAttempts) { clearInterval(interval); reject(new Error('Content polling timed out.')); } }, 100); }); }, async processLoadingQueue() { const statusIndicator = document.getElementById('chat-nav-loader-status'); const menuItems = document.querySelectorAll('#chat-nav-menu .chat-nav-menu-item'); while (this.loadingQueue.length > 0 && this.isQueueProcessing) { const itemsProcessed = this.totalToLoad - this.loadingQueue.length; if (statusIndicator) statusIndicator.textContent = `Loading ${itemsProcessed + 1} of ${this.totalToLoad}...`; const itemToLoad = this.loadingQueue.shift(); const { turn, index, menuItem } = itemToLoad; const textSpan = menuItem.querySelector('.menu-item-text'); if (!turn || !textSpan) continue; this.updateMenuFocus(menuItems, index, true); try { await this.scrollToTurn(index, 'center'); const newContent = await this.pollForContent(turn); turn.cachedContent = newContent; turn.isFallbackContent = false; const truncatedText = (newContent.display.length > 200) ? newContent.display.substring(0, 197) + '...' : newContent.display; textSpan.textContent = truncatedText; menuItem.title = newContent.full.replace(/\s+/g, ' '); } catch (error) { console.error(`Failed to load item ${index + 1}:`, error.message); textSpan.textContent = '[Error]'; } } this.isQueueProcessing = false; const scrollContainer = document.querySelector('ms-autoscroll-container'); if (this.originalCurrentIndex > -1 && this.originalCurrentIndex < this.allTurns.length) { await this.navigateToIndex(this.originalCurrentIndex, 'center'); } else if (scrollContainer) { this.isScrollingProgrammatically = true; scrollContainer.scrollTo({ top: this.originalScrollTop, behavior: 'smooth' }); await new Promise(resolve => setTimeout(() => { this.isScrollingProgrammatically = false; resolve(); }, 800)); } const menuContainer = document.getElementById('chat-nav-menu-container'); const items = menuContainer ? menuContainer.querySelectorAll('.chat-nav-menu-item') : []; if (menuContainer && menuContainer.style.display === 'flex' && this.currentIndex > -1 && items.length > 0) { this.updateMenuFocus(items, this.currentIndex, true); } const loadButton = document.getElementById('chat-nav-load-button'); if (loadButton) loadButton.disabled = false; if (statusIndicator) statusIndicator.textContent = this.loadingQueue.length > 0 ? 'Stopped.' : 'Done.'; }, stopDynamicMenuLoading() { if (!this.isQueueProcessing) return; this.isQueueProcessing = false; const loadButton = document.getElementById('chat-nav-load-button'); if (loadButton) loadButton.disabled = false; const statusIndicator = document.getElementById('chat-nav-loader-status'); if (statusIndicator) statusIndicator.textContent = 'Stopped.'; } }; ChatNavigator.closeNavMenu = ChatNavigator.closeNavMenu.bind(ChatNavigator); ChatNavigator.init(); })();