ChatGPT 大纲生成器

为ChatGPT对话生成右侧大纲视图,提取问题前10个字作为标题

目前為 2025-03-16 提交的版本,檢視 最新版本

// ==UserScript==
// @name         ChatGPT 大纲生成器
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  为ChatGPT对话生成右侧大纲视图,提取问题前10个字作为标题
// @author       Y.V
// @license      AGPL-3.0-or-later
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=openai.com
// @grant        none
// @require      https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==

(function () {
    'use strict';

    /**
     * ChatGPT大纲生成器类
     */
    class ChatGPTOutlineGenerator {
        constructor() {
            this.outlineContainer = null;
            this.toggleButton = null;
            this.styleElement = null;
            this.cssStyles = `
        .outline-container {
            position: fixed;
            top: 70px;
            right: 20px;
            width: 280px;
            max-height: calc(100vh - 100px);
            background-color: rgba(247, 247, 248, 0.85);
            border-radius: 12px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
            z-index: 1000;
            overflow-y: auto;
            transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
            font-family: 'Söhne', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif;
            opacity: 0.95;
            backdrop-filter: blur(5px);
            -webkit-backdrop-filter: blur(5px);
        }

        .outline-container:hover {
            opacity: 1;
            background-color: rgba(247, 247, 248, 0.98);
            box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
            transform: translateY(-2px);
        }

        .dark .outline-container {
            background-color: rgba(52, 53, 65, 0.85);
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
        }

        .dark .outline-container:hover {
            background-color: rgba(52, 53, 65, 0.98);
            box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
        }

        .outline-header {
            padding: 16px;
            font-weight: 600;
            font-size: 16px;
            border-bottom: 1px solid rgba(0, 0, 0, 0.05);
            display: flex;
            justify-content: space-between;
            align-items: center;
            position: sticky;
            top: 0;
            background: inherit;
            border-radius: 12px 12px 0 0;
            z-index: 2;
        }

        .dark .outline-header {
            border-bottom: 1px solid rgba(255, 255, 255, 0.05);
            color: #ececf1;
        }

        .outline-title {
            display: flex;
            align-items: center;
            gap: 8px;
        }

        .outline-title-icon {
            color: #10a37f;
        }

        .outline-items {
            padding: 8px 0;
        }

        .outline-item {
            padding: 10px 16px;
            cursor: pointer;
            transition: all 0.2s ease;
            font-size: 14px;
            border-left: 3px solid transparent;
            display: flex;
            align-items: center;
            margin: 2px 0;
            border-radius: 0 4px 4px 0;
        }

        .outline-item:hover {
            background-color: rgba(0, 0, 0, 0.05);
            transform: translateX(2px);
        }

        .dark .outline-item:hover {
            background-color: rgba(255, 255, 255, 0.05);
        }

        .outline-item.active {
            border-left-color: #10a37f;
            background-color: rgba(16, 163, 127, 0.1);
            font-weight: 500;
        }

        .outline-item-icon {
            margin-right: 10px;
            color: #10a37f;
            transition: transform 0.2s ease;
        }

        .outline-item:hover .outline-item-icon {
            transform: scale(1.1);
        }

        .outline-item-text {
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
            flex: 1;
            line-height: 1.4;
        }

        .dark .outline-item-text {
            color: #ececf1;
        }

        .outline-toggle {
            position: fixed;
            top: 70px;
            right: 20px;
            width: 42px;
            height: 42px;
            border-radius: 50%;
            background-color: rgba(16, 163, 127, 0.9);
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            z-index: 1001;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
            transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
        }

        .outline-toggle:hover {
            transform: scale(1.08);
            background-color: #10a37f;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
        }

        .outline-toggle svg {
            width: 20px;
            height: 20px;
            transition: transform 0.3s ease;
        }

        .outline-toggle:hover svg {
            transform: rotate(90deg);
        }

        .outline-close {
            cursor: pointer;
            opacity: 0.7;
            transition: all 0.2s ease;
            padding: 4px;
            border-radius: 4px;
        }

        .outline-close:hover {
            opacity: 1;
            background-color: rgba(0, 0, 0, 0.05);
        }

        .dark .outline-close:hover {
            background-color: rgba(255, 255, 255, 0.1);
        }

        .outline-empty {
            padding: 20px 16px;
            text-align: center;
            color: #888;
            font-style: italic;
            font-size: 14px;
        }

        .dark .outline-empty {
            color: #aaa;
        }

        @media (max-width: 1400px) {
            .outline-container {
                width: 250px;
            }
        }

        @media (max-width: 1200px) {
            .outline-container {
                width: 220px;
            }
        }

        @media (max-width: 768px) {
            .outline-container {
                display: none;
            }
        }`;
        }

        /**
         * 初始化大纲生成器
         */
        init() {
            // 等待页面加载完成
            if (!document.querySelector('main')) {
                setTimeout(() => this.init(), 1000);
                return;
            }

            this.addStyles();
            this.outlineContainer = this.createOutlineContainer();
            this.toggleButton = this.createToggleButton();

            // 初始化大纲
            setTimeout(() => this.generateOutlineItems(), 1000);

            // 设置初始暗黑模式状态
            this.outlineContainer.classList.toggle('dark', this.detectDarkMode());

            // 监听暗黑模式变化
            this.observeDarkModeChanges();

            // 监听新消息
            this.observeNewMessages();

            // 监听滚动以高亮当前可见的消息
            this.observeScroll();
        }

        /**
         * 添加样式到页面
         */
        addStyles() {
            this.styleElement = document.createElement('style');
            this.styleElement.textContent = this.cssStyles;
            document.head.appendChild(this.styleElement);
        }

        /**
         * 创建大纲容器
         * @returns {HTMLElement} 大纲容器元素
         */
        createOutlineContainer() {
            const container = document.createElement('div');
            container.className = 'outline-container';
            container.innerHTML = `
            <div class="outline-header">
                <div class="outline-title">
                    <span class="outline-title-icon">
                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                            <path d="M4 6H20M4 12H20M4 18H14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                        </svg>
                    </span>
                    <span>对话大纲</span>
                </div>
                <span class="outline-close">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                        <path d="M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                        <path d="M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
                    </svg>
                </span>
            </div>
            <div class="outline-items"></div>`;
            document.body.appendChild(container);

            // 添加关闭事件
            container.querySelector('.outline-close').addEventListener('click', () => {
                container.style.display = 'none';
                this.toggleButton.style.display = 'flex';
            });

            return container;
        }

        /**
         * 创建切换按钮
         * @returns {HTMLElement} 切换按钮元素
         */
        createToggleButton() {
            const button = document.createElement('div');
            button.className = 'outline-toggle';
            button.innerHTML = `
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                <path d="M4 6H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
                <path d="M4 12H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
                <path d="M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
            </svg>`;
            document.body.appendChild(button);

            // 添加点击事件
            button.addEventListener('click', () => {
                this.outlineContainer.style.display = 'block';
                button.style.display = 'none';

                // 重新生成大纲,确保最新状态
                this.generateOutlineItems();
            });

            return button;
        }

        /**
         * 提取问题文本的前16个字符
         * @param {string} text 问题文本
         * @returns {string} 提取后的标题
         */
        extractQuestionTitle(text) {
            // 去除空白字符
            const trimmed = text.trim();

            // 如果文本为空,返回默认文本
            if (!trimmed) return "空白问题";

            // 提取前16个字符,如果不足16个则全部返回
            return trimmed.length > 16 ? trimmed.substring(0, 16) + '...' : trimmed;
        }

        /**
         * 生成大纲项
         */
        generateOutlineItems() {
            const outlineItems = this.outlineContainer.querySelector('.outline-items');
            outlineItems.innerHTML = '';

            // 获取所有用户消息
            const userMessages = document.querySelectorAll('[data-message-author-role="user"]');

            if (userMessages.length === 0) {
                outlineItems.innerHTML = '<div class="outline-empty">暂无对话内容</div>';
                return;
            }

            userMessages.forEach((message, index) => {
                const messageText = message.querySelector('.whitespace-pre-wrap')?.textContent || '';
                const title = this.extractQuestionTitle(messageText);

                const item = document.createElement('div');
                item.className = 'outline-item';
                item.dataset.index = index;
                item.dataset.messageId = message.id || `message-${index}`;
                item.innerHTML = `
                <span class="outline-item-icon">
                    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                        <path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" stroke="currentColor" fill="currentColor"/>
                    </svg>
                </span>
                <span class="outline-item-text">${index + 1}. ${title}</span>`;

                // 添加点击事件
                item.addEventListener('click', () => this.handleItemClick(item, message));

                outlineItems.appendChild(item);
            });

            // 检查是否有可见的消息,并高亮对应的大纲项
            this.highlightVisibleItem();
        }

        /**
    * 处理大纲项点击事件
    * @param {HTMLElement} item 点击的大纲项
    * @param {HTMLElement} message 对应的消息元素
    */
        handleItemClick(item, message) {
            // 滚动到对应的消息
            message.scrollIntoView({ behavior: 'smooth', block: 'center' });

            // 高亮当前项
            this.highlightItem(item);

            // 添加临时高亮效果
            message.style.transition = 'background-color 0.5s';
            message.style.backgroundColor = 'rgba(16, 163, 127, 0.1)';
            setTimeout(() => {
                message.style.backgroundColor = '';
            }, 1500);
        }

        /**
         * 高亮指定的大纲项
         * @param {HTMLElement} item 要高亮的大纲项
         */
        highlightItem(item) {
            document.querySelectorAll('.outline-item').forEach(el => {
                el.classList.remove('active');
            });
            item.classList.add('active');
        }

        /**
         * 监听页面滚动,高亮当前可见的消息对应的大纲项
         */
        observeScroll() {
            let scrollTimer = null;
            window.addEventListener('scroll', () => {
                // 使用防抖技术减少滚动事件处理频率
                if (scrollTimer) clearTimeout(scrollTimer);
                scrollTimer = setTimeout(() => {
                    this.highlightVisibleItem();
                }, 100);
            });
        }

        /**
         * 高亮当前可见的消息对应的大纲项
         */
        highlightVisibleItem() {
            const userMessages = document.querySelectorAll('[data-message-author-role="user"]');
            if (!userMessages.length) return;

            // 找到当前视口中最靠近顶部的消息
            let closestMessage = null;
            let closestDistance = Infinity;
            const viewportHeight = window.innerHeight;
            const viewportMiddle = viewportHeight / 2;

            userMessages.forEach(message => {
                const rect = message.getBoundingClientRect();
                // 计算消息中心点到视口中心的距离
                const distance = Math.abs((rect.top + rect.bottom) / 2 - viewportMiddle);

                // 如果消息在视口内且距离更近
                if (rect.top < viewportHeight && rect.bottom > 0 && distance < closestDistance) {
                    closestDistance = distance;
                    closestMessage = message;
                }
            });

            if (closestMessage) {
                // 找到对应的大纲项并高亮
                const index = Array.from(userMessages).indexOf(closestMessage);
                const outlineItem = this.outlineContainer.querySelector(`.outline-item[data-index="${index}"]`);
                if (outlineItem) {
                    this.highlightItem(outlineItem);
                }
            }
        }

        /**
         * 检测暗黑模式
         * @returns {boolean} 是否为暗黑模式
         */
        detectDarkMode() {
            return document.documentElement.classList.contains('dark');
        }

        /**
         * 监听暗黑模式变化
         */
        observeDarkModeChanges() {
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    if (mutation.attributeName === 'class') {
                        const isDarkMode = this.detectDarkMode();
                        this.outlineContainer.classList.toggle('dark', isDarkMode);
                    }
                });
            });

            observer.observe(document.documentElement, { attributes: true });
        }

        /**
         * 监听新消息
         */
        observeNewMessages() {
            const observer = new MutationObserver((mutations) => {
                let shouldUpdate = false;

                mutations.forEach(mutation => {
                    if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                        for (const node of mutation.addedNodes) {
                            if (node.nodeType === Node.ELEMENT_NODE &&
                                (node.querySelector('[data-message-author-role="user"]') ||
                                    node.hasAttribute && node.hasAttribute('data-message-author-role'))) {
                                shouldUpdate = true;
                                break;
                            }
                        }
                    }
                });

                if (shouldUpdate) {
                    setTimeout(() => this.generateOutlineItems(), 500); // 延迟执行,确保DOM已更新
                }
            });

            // 监听整个聊天容器
            const chatContainer = document.querySelector('main');
            if (chatContainer) {
                observer.observe(chatContainer, { childList: true, subtree: true });
            }
        }
    }

    /**
     * 插件管理器类 - 用于管理多个插件
     */
    class PluginManager {
        constructor() {
            this.plugins = [];
        }

        /**
         * 注册插件
         * @param {Object} plugin 插件实例
         */
        register(plugin) {
            this.plugins.push(plugin);
            return this;
        }

        /**
         * 初始化所有插件
         */
        initAll() {
            this.plugins.forEach(plugin => {
                if (typeof plugin.init === 'function') {
                    setTimeout(() => plugin.init(), 1500); // 延迟启动,确保页面已加载
                }
            });
        }
    }

    /**
     * 创建并启动插件管理器
     */
    $(document).ready(() => {
        // 创建插件管理器
        const pluginManager = new PluginManager();

        // 注册大纲生成器插件
        pluginManager.register(new ChatGPTOutlineGenerator());

        // 初始化所有插件
        pluginManager.initAll();
    });
})();