🎉 欢迎访问GreasyFork.Org 镜像站!本镜像站由公众号【爱吃馍】搭建,用于分享脚本。联系邮箱📮

Greasy fork 爱吃馍镜像

ChatGPT | TOC

Backend navigation with dynamic resizing, distinct icons, and bold-header support.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

🚀 安装遇到问题?关注公众号获取帮助

公众号二维码

扫码关注【爱吃馍】

回复【脚本】获取最新教程和防失联地址

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         ChatGPT | TOC
// @namespace    http://violentmonkey.net/
// @version      2.6
// @description  Backend navigation with dynamic resizing, distinct icons, and bold-header support.
// @author       Piknockyou (vibe-coded; see credits below)
// @license      MIT
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant        none
// @run-at       document-idle
// ==/UserScript==

/*
 * CREDITS & ATTRIBUTION:
 * This Userscript is heavily inspired by the "Scroll" extension by Asker Kurtelli.
 * Original Extension: https://github.com/asker-kurtelli/scroll
 *
 * DIFFERENCE IN ARCHITECTURE:
 * While the UI concept is derived from Scroll, this script utilizes a different backend approach.
 * Instead of scraping the DOM (which relies on messages being rendered), this script fetches
 * the conversation tree directly from ChatGPT's internal `backend-api`. This ensures the
 * Table of Contents is always 100% complete and accurate, bypassing ChatGPT's lazy-loading/virtualization
 * mechanisms that often hide messages from the DOM when they are off-screen.
 */

(function() {
    'use strict';

    // --- CONFIGURATION ---
    const CONFIG = {
        defaultWidth: '300px',
        headerOffset: 80,
        colors: {
            bg: '#171717',
            border: '#333',
            text: '#ececec',
            subText: '#999',
            hover: '#2a2a2a',
            active: '#333',
            userIcon: '#999',
            aiIcon: '#10a37f'
        },
        icons: {
            user: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
            ai: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
            arrowRight: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
            arrowDown: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
        }
    };

    // --- STATE ---
    const state = {
        isOpen: true,
        accessToken: null,
        currentUuid: null,
        elements: {},
        expandedItems: new Set()
    };

    // --- UI SETUP ---
    function initUI() {
        // Toggle Button
        const toggle = document.createElement('div');
        toggle.innerHTML = '☰';
        toggle.style.cssText = `
            position: fixed; top: 10px; right: 10px;
            width: 40px; height: 40px;
            background: ${CONFIG.colors.bg}; color: ${CONFIG.colors.text};
            border: 1px solid ${CONFIG.colors.border}; border-radius: 8px;
            z-index: 10000; display: flex; align-items: center; justify-content: center;
            cursor: pointer; font-size: 20px; user-select: none;
            box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        `;

        toggle.onclick = () => {
            state.isOpen = !state.isOpen;
            sidebar.style.display = state.isOpen ? 'block' : 'none';
        };

        // Sidebar Panel
        const sidebar = document.createElement('div');
        sidebar.style.cssText = `
            position: fixed; top: 60px; right: 10px;
            width: ${CONFIG.defaultWidth}; max-height: calc(100vh - 80px);
            background: ${CONFIG.colors.bg}; border: 1px solid ${CONFIG.colors.border};
            border-radius: 12px; z-index: 10000;
            display: ${state.isOpen ? 'block' : 'none'};
            overflow-y: auto; overflow-x: hidden;
            box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
            font-family: Söhne, ui-sans-serif, system-ui, -apple-system, sans-serif;
            font-size: 13px; color: ${CONFIG.colors.text};
            resize: horizontal; direction: rtl; /* Resize handle on left */
        `;

        // Content Container (Reset direction to LTR)
        const content = document.createElement('div');
        content.id = 'toc-content';
        content.style.cssText = `direction: ltr; padding: 10px;`;
        content.innerHTML = '<div style="padding:10px; color:#888;">Initializing...</div>';

        sidebar.appendChild(content);
        document.body.appendChild(toggle);
        document.body.appendChild(sidebar);

        state.elements = { toggle, sidebar, content };
    }

    // --- API LOGIC ---
    async function getAccessToken() {
        try {
            const resp = await fetch('/api/auth/session');
            if (resp.ok) {
                const data = await resp.json();
                return data.accessToken;
            }
        } catch (e) { console.error(e); }
        return null;
    }

    function getUUID() {
        const match = window.location.pathname.match(/\/c\/([a-f0-9-]{36})/);
        return match ? match[1] : null;
    }

    async function loadConversation() {
        const contentDiv = state.elements.content;
        const uuid = getUUID();

        if (!uuid) {
            contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">No Conversation ID.</div>';
            return;
        }

        if (!state.accessToken) state.accessToken = await getAccessToken();
        if (!state.accessToken) return; // Silent fail, retry later

        contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">Loading...</div>';

        try {
            const response = await fetch(`/backend-api/conversation/${uuid}`, {
                headers: {
                    'Authorization': `Bearer ${state.accessToken}`,
                    'Content-Type': 'application/json'
                }
            });

            if (!response.ok) throw new Error(`API Error: ${response.status}`);
            const data = await response.json();
            processData(data);

        } catch (err) {
            contentDiv.innerHTML = `<div style="padding:10px; color:#f87171;">Error: ${err.message}</div>`;
        }
    }

    // --- HEADER PARSING (BOLD + MARKDOWN) ---
    function extractHeaders(text) {
        const headers = [];
        const lines = text.split('\n');

        lines.forEach((line, index) => {
            const trimmed = line.trim();
            if (!trimmed) return;

            // 1. Standard Markdown Headers (# Header)
            const mdMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
            if (mdMatch) {
                headers.push({
                    type: 'markdown',
                    text: mdMatch[2].trim(),
                    level: mdMatch[1].length
                });
                return;
            }

            // 2. Bold Headers (**Header** or __Header__)
            // Must be the start of the line or the whole line
            const boldMatch = trimmed.match(/^(\*\*|__)(.+?)\1:?$/);
            if (boldMatch) {
                headers.push({
                    type: 'bold',
                    text: boldMatch[2].trim(),
                    level: 3 // Treat bold lines roughly like H3
                });
            }
        });
        return headers;
    }

    // --- DATA PROCESSING ---
    function processData(data) {
        if (!data.mapping || !data.current_node) return;

        const thread = [];
        let currId = data.current_node;

        while (currId) {
            const node = data.mapping[currId];
            if (!node) break;
            const msg = node.message;

            // Strict Filter: No System, No Internal Tool Calls
            if (msg && msg.content && msg.content.parts && msg.content.parts.length > 0) {
                const isSystem = msg.author.role === 'system';
                const isInternal = msg.recipient && msg.recipient !== 'all';

                if (!isSystem && !isInternal) {
                    let text = '';
                    if (typeof msg.content.parts[0] === 'string') {
                        text = msg.content.parts[0];
                    } else if (msg.content.content_type === 'code') {
                        text = "Code Block";
                    }

                    if (text.trim()) {
                        thread.push({
                            id: msg.id,
                            role: msg.author.role,
                            text: text,
                            headers: extractHeaders(text)
                        });
                    }
                }
            }
            currId = node.parent;
        }
        thread.reverse();
        renderTOC(thread);
    }

    // --- RENDERING ---
    function renderTOC(items) {
        const container = document.createElement('div');
        // Main Title
        container.innerHTML = `<div style="padding-bottom:10px; border-bottom:1px solid #333; margin-bottom:10px; font-weight:700; font-size:14px; color:#fff;">TOC (${items.length})</div>`;

        items.forEach((item) => {
            const hasHeaders = item.headers.length > 0;
            const isExpanded = state.expandedItems.has(item.id);
            const isUser = item.role === 'user';

            // --- MAIN ROW ---
            const row = document.createElement('div');
            row.style.cssText = `
                padding: 6px 4px; border-radius: 6px; margin-bottom: 2px;
                display: flex; align-items: center; gap: 6px;
                cursor: pointer; transition: background 0.1s;
                min-width: 0; /* Important for flex truncation */
            `;

            // 1. Expand/Collapse Arrow (Or Spacer)
            const arrowBox = document.createElement('div');
            arrowBox.style.cssText = `
                width: 16px; height: 16px; flex-shrink: 0;
                display: flex; align-items: center; justify-content: center;
                color: ${CONFIG.colors.subText};
            `;
            if (hasHeaders) {
                arrowBox.innerHTML = isExpanded ? CONFIG.icons.arrowDown : CONFIG.icons.arrowRight;
                arrowBox.onclick = (e) => {
                    e.stopPropagation();
                    toggleExpand(item.id, items);
                };
                arrowBox.onmouseenter = () => arrowBox.style.color = '#fff';
                arrowBox.onmouseleave = () => arrowBox.style.color = CONFIG.colors.subText;
            }
            row.appendChild(arrowBox);

            // 2. Role Icon (User vs AI)
            const iconBox = document.createElement('div');
            iconBox.style.cssText = `
                width: 16px; height: 16px; flex-shrink: 0;
                color: ${isUser ? CONFIG.colors.userIcon : CONFIG.colors.aiIcon};
            `;
            iconBox.innerHTML = isUser ? CONFIG.icons.user : CONFIG.icons.ai;
            row.appendChild(iconBox);

            // 3. Message Title (Dynamic Truncation)
            const titleSpan = document.createElement('span');
            // Remove markdown symbols for clean display
            const cleanTitle = item.text.split('\n')[0].replace(/[#*`_]/g, '').trim();
            titleSpan.textContent = cleanTitle || "Message";
            titleSpan.style.cssText = `
                flex: 1;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                font-weight: ${isUser ? '400' : '500'};
                color: ${isUser ? '#bbb' : '#fff'};
                font-size: 13px;
            `;
            row.appendChild(titleSpan);

            // Row Interaction
            row.onmouseenter = () => row.style.background = CONFIG.colors.hover;
            row.onmouseleave = () => row.style.background = 'transparent';
            row.onclick = () => scrollToMessage(item.id);

            container.appendChild(row);

            // --- SUBHEADERS ---
            if (hasHeaders && isExpanded) {
                const subContainer = document.createElement('div');
                subContainer.style.cssText = `
                    margin-left: 22px; /* Align under icon */
                    border-left: 1px solid ${CONFIG.colors.border};
                    padding-left: 4px; margin-bottom: 4px;
                `;

                item.headers.forEach(h => {
                    const subRow = document.createElement('div');
                    subRow.textContent = h.text;
                    subRow.title = h.text;
                    subRow.style.cssText = `
                        padding: 4px 8px; cursor: pointer; font-size: 12px;
                        color: ${CONFIG.colors.subText};
                        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                        border-radius: 4px;
                    `;

                    subRow.onmouseenter = () => {
                        subRow.style.color = CONFIG.colors.text;
                        subRow.style.background = CONFIG.colors.hover;
                    };
                    subRow.onmouseleave = () => {
                        subRow.style.color = CONFIG.colors.subText;
                        subRow.style.background = 'transparent';
                    };

                    subRow.onclick = (e) => {
                        e.stopPropagation();
                        scrollToHeader(item.id, h.text);
                    };

                    subContainer.appendChild(subRow);
                });
                container.appendChild(subContainer);
            }
        });

        state.elements.content.innerHTML = '';
        state.elements.content.appendChild(container);
    }

    function toggleExpand(id, items) {
        if (state.expandedItems.has(id)) {
            state.expandedItems.delete(id);
        } else {
            state.expandedItems.add(id);
        }
        renderTOC(items);
    }

    // --- NAVIGATION LOGIC ---

    function scrollToMessage(messageId) {
        const el = document.querySelector(`[data-message-id="${messageId}"]`);
        if (el) {
            smartScrollTo(el);
            flashElement(el);
        } else {
            alert("Message not rendered in DOM (virtual scrolling). Scroll manually.");
        }
    }

    function scrollToHeader(messageId, headerText) {
        const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
        if (!messageEl) return;

        // Extended Query: specific headers AND bold tags
        const candidates = Array.from(messageEl.querySelectorAll('h1, h2, h3, h4, h5, h6, strong, b'));

        // Find best match (loose matching to handle markdown parsing diffs)
        const target = candidates.find(el => {
            const elText = el.innerText.trim();
            // Match if one contains the other
            return elText && (elText.includes(headerText) || headerText.includes(elText));
        });

        if (target) {
            smartScrollTo(target);
            flashElement(target);
        } else {
            // Fallback: Scroll to message top
            smartScrollTo(messageEl);
            flashElement(messageEl);
        }
    }

    function smartScrollTo(element) {
        const scrollContainer = document.querySelector('main [class*="overflow-y-auto"]');
        if (!scrollContainer) {
            element.scrollIntoView({ behavior: 'smooth', block: 'center' });
            return;
        }

        const elRect = element.getBoundingClientRect();
        const containerRect = scrollContainer.getBoundingClientRect();
        const currentScroll = scrollContainer.scrollTop;

        // Math: Current Scroll + Relative Element Top - Header Offset
        const targetTop = currentScroll + (elRect.top - containerRect.top) - CONFIG.headerOffset;

        scrollContainer.scrollTo({
            top: targetTop,
            behavior: 'smooth'
        });
    }

    function flashElement(el) {
        const originalTrans = el.style.transition;
        const originalBg = el.style.backgroundColor;

        el.style.transition = 'background-color 0.4s ease';
        el.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';

        setTimeout(() => {
            el.style.backgroundColor = originalBg;
            setTimeout(() => {
                el.style.transition = originalTrans;
            }, 400);
        }, 600);
    }

    // --- INIT ---
    initUI();
    setTimeout(loadConversation, 1500);

    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            state.expandedItems.clear();
            loadConversation();
        }
    }).observe(document.body, { subtree: true, childList: true });

})();