Claude Chat TOC Navigator

Adds a Table of Contents to navigate between responses in Claude chat

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Claude Chat TOC Navigator
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Adds a Table of Contents to navigate between responses in Claude chat
// @author       You
// @match        https://*.anthropic.com/*
// @match        *://claude.ai/*
// @icon         https://claude.ai/favicon.ico
// @grant        none
// @license      MIT 
// ==/UserScript==

(function() {
    'use strict';

    // Config
    const config = {
        updateInterval: 1000, // Check for new messages every 1 second
        tocWidth: '250px',
        tocMaxHeight: '80vh',
        accentColor: '#6952dc',
        togglerSize: '36px',
        animationSpeed: '0.3s',
        darkMode: {
            tocBgColor: '#1e1e1e',
            tocBorderColor: '#333',
            textColor: '#e0e0e0',
            userBgColor: '#2a2a2a',
            claudeBgColor: '#2d2540',
            userBorderColor: '#444',
            claudeBorderColor: '#4e3d80'
        },
        lightMode: {
            tocBgColor: '#f8f9fa',
            tocBorderColor: '#ddd',
            textColor: '#333',
            userBgColor: '#eef0f3',
            claudeBgColor: '#f4f1fe',
            userBorderColor: '#d0d5dd',
            claudeBorderColor: '#d1c7f9'
        }
    };

    // Check if dark mode is enabled
    function isDarkMode() {
        // Check if the page has a dark background
        return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ||
            document.body.classList.contains('dark-mode') ||
            getComputedStyle(document.body).backgroundColor.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i)?.slice(1).map(Number).reduce((a, b) => a + b) < 382;
    }

    // Get theme based on current color scheme
    function getTheme() {
        return isDarkMode() ? config.darkMode : config.lightMode;
    }

    // Wait for the chat content to load
    function waitForChat() {
        const interval = setInterval(() => {
            // Different DOM selectors for claude.ai vs anthropic.com
            if (document.querySelector('.flex-1.flex.flex-col.gap-3') ||
                document.querySelector('.conversation-container') ||
                document.querySelector('.message-container')) {
                clearInterval(interval);
                initTOC();

                // Listen for theme changes
                window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme);

                // Additional listener for custom theme toggles
                const observer = new MutationObserver((mutations) => {
                    mutations.forEach((mutation) => {
                        if (mutation.attributeName === 'class' ||
                            mutation.attributeName === 'data-theme' ||
                            mutation.type === 'attributes') {
                            updateTheme();
                        }
                    });
                });

                observer.observe(document.body, {
                    attributes: true,
                    attributeFilter: ['class', 'data-theme']
                });
            }
        }, 500);
    }

    // Update theme for TOC elements
    function updateTheme() {
        const tocContainer = document.getElementById('claude-toc-container');
        if (!tocContainer) return;

        const theme = getTheme();

        // Update container
        tocContainer.style.background = theme.tocBgColor;
        tocContainer.style.borderColor = theme.tocBorderColor;
        tocContainer.style.color = theme.textColor;

        // Update header
        const header = tocContainer.querySelector('h3');
        if (header) {
            header.style.borderBottom = `1px solid ${theme.tocBorderColor}`;
        }

        // Force TOC update to refresh entry styles
        updateTOC(true);
    }

    // Initialize the TOC
    function initTOC() {
        const theme = getTheme();

        // Create TOC container
        const tocContainer = document.createElement('div');
        tocContainer.id = 'claude-toc-container';
        tocContainer.style.cssText = `
            position: fixed;
            top: 70px;
            right: -${config.tocWidth};
            width: ${config.tocWidth};
            max-height: ${config.tocMaxHeight};
            background: ${theme.tocBgColor};
            border-left: 1px solid ${theme.tocBorderColor};
            border-bottom: 1px solid ${theme.tocBorderColor};
            border-radius: 0 0 0 8px;
            overflow-y: auto;
            z-index: 1000;
            transition: right ${config.animationSpeed} ease;
            box-shadow: -2px 2px 10px rgba(0, 0, 0, 0.1);
            padding: 10px;
            color: ${theme.textColor};
        `;

        // Create TOC toggler with Claude logo
        const tocToggler = document.createElement('div');
        tocToggler.id = 'claude-toc-toggler';
        tocToggler.innerHTML = `
            <svg width="20" height="20" viewBox="0 0 139 34" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
                <path d="M18.07 30.79c-5.02 0-8.46-2.8-10.08-7.11a19.2 19.2 0 0 1-1.22-7.04C6.77 9.41 10 4.4 17.16 4.4c4.82 0 7.78 2.1 9.48 7.1h2.06l-.28-6.9c-2.88-1.86-6.48-2.81-10.87-2.81-6.16 0-11.41 2.77-14.34 7.74A16.77 16.77 0 0 0 1 18.2c0 5.53 2.6 10.42 7.5 13.15a17.51 17.51 0 0 0 8.74 2.06c4.78 0 8.57-.91 11.93-2.5l.87-7.62h-2.1c-1.26 3.48-2.76 5.57-5.25 6.68-1.22.55-2.76.83-4.62.83Z"/>
            </svg>
        `;
        tocToggler.style.cssText = `
            position: fixed;
            top: 70px;
            right: 0;
            width: ${config.togglerSize};
            height: ${config.togglerSize};
            background: ${config.accentColor};
            border-radius: 8px 0 0 8px;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            z-index: 1001;
            color: white;
            box-shadow: -2px 2px 5px rgba(0, 0, 0, 0.2);
        `;

        // Create TOC header
        const tocHeader = document.createElement('div');
        tocHeader.innerHTML = `<h3 style="margin: 0 0 10px 0; padding-bottom: 5px; border-bottom: 1px solid ${theme.tocBorderColor};">Table of Contents</h3>`;

        // Create TOC content
        const tocContent = document.createElement('div');
        tocContent.id = 'claude-toc-content';
        tocContent.style.cssText = `
            display: flex;
            flex-direction: column;
            gap: 8px;
        `;

        // Assemble TOC
        tocContainer.appendChild(tocHeader);
        tocContainer.appendChild(tocContent);

        // Add to document
        document.body.appendChild(tocToggler);
        document.body.appendChild(tocContainer);

        // Toggle TOC visibility
        let tocVisible = false;
        tocToggler.addEventListener('click', () => {
            tocVisible = !tocVisible;
            tocContainer.style.right = tocVisible ? '0' : `-${config.tocWidth}`;

            // Use Claude logo consistently, just rotate it when panel is open
            tocToggler.style.transform = tocVisible ? 'rotate(180deg)' : 'rotate(0deg)';

            // Update theme when toggling (in case it changed)
            if (tocVisible) {
                const theme = getTheme();
                tocContainer.style.background = theme.tocBgColor;
                tocContainer.style.borderColor = theme.tocBorderColor;
                tocContainer.style.color = theme.textColor;

                // Also update header border color
                tocContainer.querySelector('h3').style.borderBottom = `1px solid ${theme.tocBorderColor}`;

                // Refresh TOC entries to apply current theme
                updateTOC();
            }
        });

        // Start monitoring for messages
        updateTOC();
        setInterval(updateTOC, config.updateInterval);
    }

    // Update the TOC content
    function updateTOC(forceRefresh = false) {
        const tocContent = document.getElementById('claude-toc-content');
        if (!tocContent) return;

        // Get all messages
        let messages = [];

        // User messages (questions) - support both claude.ai and anthropic.com
        const userSelectors = [
            '.group.relative.inline-flex.gap-2.bg-bg-300.rounded-xl', // anthropic.com
            '.user-message', // alternative
            '[data-message-author="user"]', // claude.ai
            '.message.user' // another alternative
        ];

        let userMessages = [];
        for (const selector of userSelectors) {
            const elements = document.querySelectorAll(selector);
            if (elements.length > 0) {
                userMessages = Array.from(elements);
                break;
            }
        }

        userMessages.forEach((msg, index) => {
            let textElement = msg.querySelector('[data-testid="user-message"]') ||
                              msg.querySelector('.message-content') ||
                              msg;

            const textContent = textElement?.textContent?.trim();
            if (textContent) {
                messages.push({
                    type: 'user',
                    content: truncateText(textContent, 50),
                    element: msg,
                    index: index
                });
            }
        });

        // Claude responses - support both claude.ai and anthropic.com
        const claudeSelectors = [
            '.group.relative.-tracking-\\[0\\.015em\\]', // anthropic.com
            '.claude-message', // alternative
            '[data-message-author="assistant"]', // claude.ai
            '.message.assistant' // another alternative
        ];

        let claudeMessages = [];
        for (const selector of claudeSelectors) {
            const elements = document.querySelectorAll(selector);
            if (elements.length > 0) {
                claudeMessages = Array.from(elements);
                break;
            }
        }

        claudeMessages.forEach((msg, index) => {
            // Get the first paragraph of Claude's response
            let textElement = msg.querySelector('.whitespace-pre-wrap.break-words') ||
                              msg.querySelector('.message-content') ||
                              msg.querySelector('p');

            const textContent = textElement?.textContent?.trim();
            if (textContent) {
                messages.push({
                    type: 'claude',
                    content: truncateText(textContent, 50),
                    element: msg,
                    index: index
                });
            }
        });

        // Sort messages by their position in the DOM
        messages.sort((a, b) => {
            const posA = getElementPosition(a.element);
            const posB = getElementPosition(b.element);
            return posA - posB;
        });

        // Create TOC entries
        const currentEntries = tocContent.querySelectorAll('.toc-entry');
        if (currentEntries.length !== messages.length || forceRefresh) {
            // Clear and rebuild TOC if message count changed
            tocContent.innerHTML = '';

            const theme = getTheme();

            messages.forEach((msg, index) => {
                const entry = document.createElement('div');
                entry.className = 'toc-entry';
                entry.dataset.index = index;
                entry.dataset.type = msg.type;

                // Styled based on message type
                const bgColor = msg.type === 'user' ? theme.userBgColor : theme.claudeBgColor;
                const borderColor = msg.type === 'user' ? theme.userBorderColor : theme.claudeBorderColor;

                entry.style.cssText = `
                    padding: 8px 10px;
                    border-radius: 6px;
                    background-color: ${bgColor};
                    border-left: 3px solid ${borderColor};
                    cursor: pointer;
                    font-size: 13px;
                    font-weight: normal;
                    color: ${theme.textColor};
                    transition: all 0.2s ease;
                    margin-bottom: 6px;
                `;

                // Add sequential number and icon prefix based on message type
                const msgNumber = index + 1;
                const prefix = msg.type === 'user' ?
                    `<span style="font-weight: bold; margin-right: 5px;">${msgNumber}.</span> 👤 ` :
                    `<span style="font-weight: bold; margin-right: 5px;">${msgNumber}.</span> <svg width="14" height="14" viewBox="0 0 139 34" fill="currentColor" style="display: inline-block; vertical-align: middle; margin-right: 3px;"><path d="M18.07 30.79c-5.02 0-8.46-2.8-10.08-7.11a19.2 19.2 0 0 1-1.22-7.04C6.77 9.41 10 4.4 17.16 4.4c4.82 0 7.78 2.1 9.48 7.1h2.06l-.28-6.9c-2.88-1.86-6.48-2.81-10.87-2.81-6.16 0-11.41 2.77-14.34 7.74A16.77 16.77 0 0 0 1 18.2c0 5.53 2.6 10.42 7.5 13.15a17.51 17.51 0 0 0 8.74 2.06c4.78 0 8.57-.91 11.93-2.5l.87-7.62h-2.1c-1.26 3.48-2.76 5.57-5.25 6.68-1.22.55-2.76.83-4.62.83Z"/></svg> `;

                entry.innerHTML = `${prefix}${msg.content}`;

                // Add click event to scroll to message
                entry.addEventListener('click', () => {
                    msg.element.scrollIntoView({ behavior: 'smooth', block: 'start' });

                    // Highlight the message briefly
                    highlightElement(msg.element);
                });

                // Hover effect
                entry.addEventListener('mouseenter', () => {
                    entry.style.transform = 'translateX(3px)';
                    entry.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
                    entry.style.fontWeight = 'bold';
                });

                entry.addEventListener('mouseleave', () => {
                    entry.style.transform = 'translateX(0)';
                    entry.style.boxShadow = 'none';
                    entry.style.fontWeight = 'normal';
                });

                tocContent.appendChild(entry);
            });
        }
    }

    // Helper function to get element's vertical position
    function getElementPosition(element) {
        return element.getBoundingClientRect().top + window.scrollY;
    }

    // Helper function to truncate text
    function truncateText(text, maxLength) {
        if (text.length <= maxLength) return text;
        return text.substr(0, maxLength) + '...';
    }

    // Helper function to highlight an element briefly
    function highlightElement(element) {
        const originalBackground = element.style.background;
        const originalTransition = element.style.transition;

        element.style.transition = 'background-color 0.5s ease';
        element.style.backgroundColor = isDarkMode()
            ? 'rgba(105, 82, 220, 0.3)'
            : 'rgba(105, 82, 220, 0.15)';

        element.scrollIntoView({ behavior: 'smooth', block: 'start' });

        setTimeout(() => {
            element.style.backgroundColor = originalBackground;
            setTimeout(() => {
                element.style.transition = originalTransition;
            }, 500);
        }, 1000);
    }

    // Start the script
    waitForChat();
})();