Greasy Fork 还支持 简体中文。

ChatGPT Question Sidebar Navigation

A lightweight, feature-rich question sidebar for ChatGPT with resizing, dark mode, hover-previews, pinning, and state persistence.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name        ChatGPT Question Sidebar Navigation
// @namespace   vanilla-js-enhanced-fixed
// @version     2.3.0
// @description A lightweight, feature-rich question sidebar for ChatGPT with resizing, dark mode, hover-previews, pinning, and state persistence.
// @match       https://chatgpt.com/*
// @grant       GM_addStyle
// @license     MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- 1. STYLING ---
    const styles = `
        :root {
            --q-nav-bg: #fff;
            --q-nav-text: #333;
            --q-nav-text-secondary: #555;
            --q-nav-border: #e5e5e5;
            --q-nav-hover-bg: #f0f0f0;
            --q-nav-active-bg: #e7f3ff;
            --q-nav-active-text: #1a73e8;
            --q-nav-pin-color: #f6ad55;
            --q-nav-scrollbar-thumb: #ccc;
            --q-nav-scrollbar-track: #f1f1f1;
        }

        html.dark #q-nav-container, html.dark #q-nav-tooltip {
            --q-nav-bg: #2a2a2a;
            --q-nav-text: #f0f0f0;
            --q-nav-text-secondary: #bbb;
            --q-nav-border: #444;
            --q-nav-hover-bg: #3a3a3a;
            --q-nav-active-bg: #1a3c5f;
            --q-nav-active-text: #6ea7f1;
            --q-nav-pin-color: #f6ad55;
            --q-nav-scrollbar-thumb: #555;
            --q-nav-scrollbar-track: #333;
        }

        #q-nav-container {
            position: fixed;
            top: 10vh;
            right: 16px;
            height: auto;
            max-height: 70vh;
            background: var(--q-nav-bg);
            border: 1px solid var(--q-nav-border);
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
            padding: 12px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
            font-size: 14px;
            z-index: 9999;
            transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
            color: var(--q-nav-text);
            display: flex;
            flex-direction: column;
            min-width: 180px;
            max-width: 50vw;
        }

        #q-nav-container.q-nav-hidden {
            transform: translateX(calc(100% - 20px));
            opacity: 0.4;
        }

        #q-nav-container.q-nav-hidden:hover {
            transform: translateX(0);
            opacity: 1;
            box-shadow: 0 6px 20px rgba(0,0,0,0.2);
        }

        #q-nav-resizer {
            position: absolute;
            left: -5px;
            top: 0;
            width: 10px;
            height: 100%;
            cursor: col-resize;
            z-index: 10000;
        }

        #q-nav-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding-bottom: 10px;
            border-bottom: 1px solid var(--q-nav-border);
            font-weight: 600;
            user-select: none;
        }

        #q-nav-toggle {
            cursor: pointer;
            padding: 2px;
        }

        #q-nav-list-wrapper {
            overflow-y: auto;
            margin-top: 10px;
            scrollbar-width: thin;
            scrollbar-color: var(--q-nav-scrollbar-thumb) var(--q-nav-scrollbar-track);
        }
        #q-nav-list-wrapper::-webkit-scrollbar { width: 6px; }
        #q-nav-list-wrapper::-webkit-scrollbar-track { background: var(--q-nav-scrollbar-track); border-radius: 3px; }
        #q-nav-list-wrapper::-webkit-scrollbar-thumb { background: var(--q-nav-scrollbar-thumb); border-radius: 3px; }
        #q-nav-list-wrapper::-webkit-scrollbar-thumb:hover { background: #888; }

        .q-nav-section-header {
            font-size: 12px;
            font-weight: bold;
            color: var(--q-nav-text-secondary);
            margin: 10px 0 5px;
            padding: 0 5px;
            text-transform: uppercase;
        }
        .q-nav-section-header:first-child { margin-top: 0; }
        .q-nav-section-divider {
            border: 0;
            border-top: 1px solid var(--q-nav-border);
            margin: 10px 0;
        }

        #q-nav-pinned-list, #q-nav-questions-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }

        #q-nav-list-wrapper li {
            position: relative;
            display: flex;
            align-items: center;
            padding: 8px 24px 8px 5px;
            cursor: pointer;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            border-radius: 4px;
            color: var(--q-nav-text-secondary);
        }

        #q-nav-list-wrapper li:hover {
            background-color: var(--q-nav-hover-bg);
        }

        #q-nav-list-wrapper li.q-nav-active {
            background-color: var(--q-nav-active-bg);
            color: var(--q-nav-active-text);
            font-weight: 500;
        }

        .q-nav-pin-icon {
            position: absolute;
            right: 5px;
            top: 50%;
            transform: translateY(-50%);
            opacity: 0;
            transition: opacity 0.15s;
            fill: var(--q-nav-text-secondary);
        }

        #q-nav-list-wrapper li:hover .q-nav-pin-icon {
            opacity: 0.6;
        }
        .q-nav-pin-icon:hover {
            opacity: 1 !important;
            fill: var(--q-nav-pin-color) !important;
        }
        .q-nav-pinned .q-nav-pin-icon {
            opacity: 1;
            fill: var(--q-nav-pin-color);
        }

        #q-nav-tooltip {
            position: fixed;
            opacity: 0;
            transition: opacity 0.2s ease-in-out;
            background: var(--q-nav-bg);
            border: 1px solid var(--q-nav-border);
            border-radius: 6px;
            padding: 8px 12px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            max-width: 400px;
            font-size: 13px;
            z-index: 10001;
            pointer-events: none;
            white-space: pre-wrap;
            line-height: 1.5;
            color: var(--q-nav-text);
        }
    `;

    const ICONS = {
        open: '👁️',
        closed: '👁️‍🗨️',
        pin: `<svg class="q-nav-pin-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>`
    };

    let sidebarElement = null;
    let tooltipElement = null;
    let questionElements = [];
    let activeIndex = -1;
    let scrollContainer = null;
    let isResizing = false;
    let pageObserver = null;
    let themeObserver = null;
    let currentPath = location.pathname;

    let settings = {
        isOpen: JSON.parse(localStorage.getItem('qNavSettings_isOpen')) ?? true,
        width: localStorage.getItem('qNavSettings_width') || '250px'
    };

    function saveSettings() {
        localStorage.setItem('qNavSettings_isOpen', JSON.stringify(settings.isOpen));
        localStorage.setItem('qNavSettings_width', settings.width);
    }

    function getConversationId() {
        try {
            return location.pathname.split('/c/')[1].split('/')[0];
        } catch (e) {
            return null;
        }
    }

    function loadPinnedItems(convoId) {
        if (!convoId) return [];
        return JSON.parse(localStorage.getItem(`qNavPinned_${convoId}`)) || [];
    }

    function savePinnedItems(convoId, items) {
        if (!convoId) return;
        localStorage.setItem(`qNavPinned_${convoId}`, JSON.stringify(items));
    }

    function throttle(func, limit) {
        let inThrottle;
        return function(...args) {
            if (!inThrottle) {
                func.apply(this, args);
                inThrottle = true;
                setTimeout(() => inThrottle = false, limit);
            }
        };
    }

    function getChatContainer() {
        return document.querySelector("main .flex.flex-col.text-sm");
    }

    function queryQuestionElements() {
        const container = getChatContainer();
        if (!container) return [];
        return Array.from(container.querySelectorAll('div[data-message-author-role="user"]'));
    }

    function createSidebar() {
        if (document.getElementById('q-nav-container')) return;

        GM_addStyle(styles);

        sidebarElement = document.createElement('div');
        sidebarElement.id = 'q-nav-container';
        document.body.appendChild(sidebarElement);

        sidebarElement.innerHTML = `
            <div id="q-nav-resizer"></div>
            <div id="q-nav-header">
                <span>📄 Questions</span>
                <span id="q-nav-toggle" title="Toggle Sidebar"></span>
            </div>
            <div id="q-nav-list-wrapper">
                <div id="q-nav-pinned-section" style="display: none;">
                    <div class="q-nav-section-header">Pinned</div>
                    <ul id="q-nav-pinned-list"></ul>
                    <hr class="q-nav-section-divider" />
                </div>
                <div id="q-nav-questions-section" style="display: none;">
                     <div class="q-nav-section-header">Questions</div>
                     <ul id="q-nav-questions-list"></ul>
                </div>
            </div>
        `;

        tooltipElement = document.createElement('div');
        tooltipElement.id = 'q-nav-tooltip';
        document.body.appendChild(tooltipElement);

        sidebarElement.style.width = settings.width;
        if (!settings.isOpen) sidebarElement.classList.add('q-nav-hidden');
        sidebarElement.querySelector('#q-nav-toggle').innerHTML = settings.isOpen ? ICONS.open : ICONS.closed;

        addEventListeners();
        checkDarkMode();
    }

    function updateSidebar() {
        if (!sidebarElement) return;

        const convoId = getConversationId();
        const pinnedItems = loadPinnedItems(convoId);

        const pinnedList = sidebarElement.querySelector('#q-nav-pinned-list');
        const questionsList = sidebarElement.querySelector('#q-nav-questions-list');
        const pinnedSection = sidebarElement.querySelector('#q-nav-pinned-section');
        const questionsSection = sidebarElement.querySelector('#q-nav-questions-section');

        pinnedList.innerHTML = '';
        questionsList.innerHTML = '';

        questionElements = queryQuestionElements();
        const questionData = questionElements.map((el, index) => {
            const textContent = el.querySelector('.whitespace-pre-wrap')?.innerText.trim() || `Question ${index + 1}`;
            const answerEl = el.closest('article[data-turn-id]')?.nextElementSibling?.querySelector('[data-message-author-role="assistant"] .markdown.prose');
            const previewText = answerEl ? answerEl.innerText.trim().substring(0, 250) + (answerEl.innerText.length > 250 ? '...' : '') : '';
            return { el, index, textContent, previewText };
        });

        const pinnedData = [];
        const unpinnedData = [];

        questionData.forEach(item => {
            if (pinnedItems.includes(item.textContent)) {
                pinnedData.push(item);
            } else {
                unpinnedData.push(item);
            }
        });

        const renderItem = (item, isPinned) => {
            const listItem = document.createElement('li');
            listItem.textContent = item.textContent;
            listItem.dataset.index = item.index;
            listItem.dataset.text = item.textContent;
            listItem.dataset.preview = item.previewText;
            listItem.title = item.textContent;
            listItem.innerHTML = `${item.textContent}${ICONS.pin}`;

            if (isPinned) {
                listItem.classList.add('q-nav-pinned');
                pinnedList.appendChild(listItem);
            } else {
                questionsList.appendChild(listItem);
            }
        };

        pinnedData.forEach(item => renderItem(item, true));
        unpinnedData.forEach(item => renderItem(item, false));

        pinnedSection.style.display = pinnedData.length > 0 ? 'block' : 'none';
        questionsSection.style.display = unpinnedData.length > 0 ? 'block' : 'none';

        updateActiveHighlight();
    }

    function handleSidebarInteraction(event) {
        const target = event.target;

        if (target.closest('.q-nav-pin-icon')) {
            event.stopPropagation();
            const listItem = target.closest('li');
            const text = listItem.dataset.text;
            const convoId = getConversationId();
            let pinnedItems = loadPinnedItems(convoId);

            if (pinnedItems.includes(text)) {
                pinnedItems = pinnedItems.filter(item => item !== text);
            } else {
                pinnedItems.push(text);
            }
            savePinnedItems(convoId, pinnedItems);
            updateSidebar();
        } else if (target.closest('li')) {
            if (isResizing) return;
            const index = parseInt(target.closest('li').dataset.index, 10);
            if (!isNaN(index) && questionElements[index]) {
                questionElements[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
                updateActiveHighlight(index);
            }
        }
    }

    const throttledUpdateActiveHighlight = throttle(updateActiveHighlight, 100);

    function updateActiveHighlight(forceIndex = null) {
        if (!sidebarElement || !scrollContainer) return;

        let newActiveIndex = -1;

        if (forceIndex !== null) {
            newActiveIndex = forceIndex;
        } else {
            const threshold = scrollContainer.getBoundingClientRect().top + 100;
            for (let i = questionElements.length - 1; i >= 0; i--) {
                if (questionElements[i].getBoundingClientRect().top <= threshold) {
                    newActiveIndex = i;
                    break;
                }
            }
            if (scrollContainer.scrollHeight - scrollContainer.scrollTop <= scrollContainer.clientHeight + 5) {
                newActiveIndex = questionElements.length - 1;
            }
        }

        if (newActiveIndex !== activeIndex) {
            activeIndex = newActiveIndex;
            const listItems = sidebarElement.querySelectorAll('#q-nav-list-wrapper li');
            let activeLi = null;
            listItems.forEach(li => {
                const isActive = parseInt(li.dataset.index) === activeIndex;
                li.classList.toggle('q-nav-active', isActive);
                if (isActive) activeLi = li;
            });

            if (activeLi) {
                activeLi.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
            }
        }
    }

    function addEventListeners() {
        const toggle = sidebarElement.querySelector('#q-nav-toggle');
        const resizer = sidebarElement.querySelector('#q-nav-resizer');
        const listWrapper = sidebarElement.querySelector('#q-nav-list-wrapper');

        toggle.addEventListener('click', () => {
            settings.isOpen = !settings.isOpen;
            sidebarElement.classList.toggle('q-nav-hidden');
            toggle.innerHTML = settings.isOpen ? ICONS.open : ICONS.closed;
            saveSettings();
        });

        resizer.addEventListener('mousedown', (e) => {
            e.preventDefault();
            isResizing = true;
            document.body.style.cursor = 'col-resize';
            document.body.style.userSelect = 'none';

            const startX = e.clientX;
            const startWidth = sidebarElement.offsetWidth;

            const doDrag = (dragEvent) => {
                const newWidth = startWidth - (dragEvent.clientX - startX);
                if (newWidth > 180 && newWidth < window.innerWidth * 0.5) {
                    settings.width = `${newWidth}px`;
                    sidebarElement.style.width = settings.width;
                }
            };
            const stopDrag = () => {
                document.removeEventListener('mousemove', doDrag);
                document.removeEventListener('mouseup', stopDrag);
                document.body.style.cursor = '';
                document.body.style.userSelect = '';
                saveSettings();
                setTimeout(() => { isResizing = false; }, 100);
            };
            document.addEventListener('mousemove', doDrag);
            document.addEventListener('mouseup', stopDrag);
        });

        listWrapper.addEventListener('click', handleSidebarInteraction);

        listWrapper.addEventListener('mouseover', e => {
            const li = e.target.closest('li');
            if (li && li.dataset.preview) {
                tooltipElement.textContent = li.dataset.preview;
                tooltipElement.style.opacity = '1';
            }
        });
        listWrapper.addEventListener('mouseout', () => {
            tooltipElement.style.opacity = '0';
        });
        listWrapper.addEventListener('mousemove', e => {
            if (tooltipElement.style.opacity === '1') {
                const tooltipRect = tooltipElement.getBoundingClientRect();
                let x = e.clientX + 15;
                let y = e.clientY + 15;

                if (x + tooltipRect.width > window.innerWidth - 10) {
                    x = e.clientX - tooltipRect.width - 15;
                }
                if (y + tooltipRect.height > window.innerHeight - 10) {
                    y = e.clientY - tooltipRect.height - 15;
                }

                tooltipElement.style.left = `${x}px`;
                tooltipElement.style.top = `${y}px`;
            }
        });

        scrollContainer = getChatContainer()?.parentElement;
        if (scrollContainer) {
            scrollContainer.addEventListener('scroll', throttledUpdateActiveHighlight);
        }
    }

    function checkDarkMode() {
        const isDark = document.documentElement.classList.contains('dark');
        sidebarElement?.classList.toggle('q-nav-dark', isDark);
        tooltipElement?.classList.toggle('q-nav-dark', isDark);
    }

    function initialize() {
        const chatContainer = getChatContainer();
        if (chatContainer && queryQuestionElements().length > 0) {
            if (!sidebarElement) {
                createSidebar();
            }
            updateSidebar();

            if (!pageObserver) {
                pageObserver = new MutationObserver(throttle(updateSidebar, 500));
                pageObserver.observe(chatContainer, { childList: true, subtree: true });
            }
        } else {
            destroy();
        }
    }

    function destroy() {
        if (sidebarElement) {
            sidebarElement.remove();
            sidebarElement = null;
        }
        if (tooltipElement) {
            tooltipElement.remove();
            tooltipElement = null;
        }
        if (pageObserver) {
            pageObserver.disconnect();
            pageObserver = null;
        }
        if (scrollContainer) {
            scrollContainer.removeEventListener('scroll', throttledUpdateActiveHighlight);
            scrollContainer = null;
        }
        questionElements = [];
        activeIndex = -1;
    }

    setInterval(() => {
        const newPath = location.pathname;
        const chatContainer = getChatContainer();

        if (newPath !== currentPath) {
            currentPath = newPath;
            destroy();
            setTimeout(initialize, 2000);
        } else if (!sidebarElement && chatContainer && queryQuestionElements().length > 0) {
            initialize();
        } else if (sidebarElement && !chatContainer) {
            destroy();
        }
    }, 500);

    themeObserver = new MutationObserver(checkDarkMode);
    themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
})();