ChatGPT | TOC

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

スクリプトをインストールするには、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    https://greasyfork.org/en/users/1462137-piknockyou
// @version      3.0
// @author       Piknockyou (vibe-coded; see credits below)
// @license      AGPL-3.0
// @description  Backend navigation with dynamic resizing, distinct icons, bold-header support, and persistent state.
// @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,
        debounceDelay: 1500,
        storageKeys: {
            isOpen: 'chatgpt-toc-isOpen'
        },
        colors: {
            bg: '#171717',
            border: '#333',
            text: '#ececec',
            subText: '#999',
            hover: '#2a2a2a',
            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 MANAGEMENT
    // =========================================================================
    function loadIsOpenState() {
        try {
            const stored = localStorage.getItem(CONFIG.storageKeys.isOpen);
            return stored !== null ? stored === 'true' : true;
        } catch (e) {
            return true;
        }
    }

    function saveIsOpenState(isOpen) {
        try {
            localStorage.setItem(CONFIG.storageKeys.isOpen, String(isOpen));
        } catch (e) {
            // Silently fail if localStorage is unavailable
        }
    }

    const state = {
        isOpen: loadIsOpenState(),
        accessToken: null,
        elements: {},
        expandedItems: new Set(),
        cachedItems: []
    };

    // =========================================================================
    // UI INITIALIZATION
    // =========================================================================
    function initUI() {
        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';
            saveIsOpenState(state.isOpen);
        };

        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;
        `;

        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) {
            // Silent fail
        }
        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;

        // Only show loading on initial load
        if (!contentDiv.textContent.includes('TOC (')) {
            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>`;
        }
    }

    // =========================================================================
    // DATA PROCESSING
    // =========================================================================
    function extractHeaders(text) {
        const headers = [];
        const lines = text.split('\n');

        for (const line of lines) {
            const trimmed = line.trim();
            if (!trimmed) continue;

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

            // Bold headers (**Header** or __Header__)
            const boldMatch = trimmed.match(/^(\*\*|__)(.+?)\1:?$/);
            if (boldMatch) {
                headers.push({
                    type: 'bold',
                    text: boldMatch[2].trim(),
                    level: 3
                });
            }
        }
        return headers;
    }

    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;
            if (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();
        state.cachedItems = thread;
        renderTOC(thread);
    }

    // =========================================================================
    // RENDERING
    // =========================================================================
    function renderTOC(items) {
        const container = document.createElement('div');

        // Header
        const header = document.createElement('div');
        header.style.cssText = 'padding-bottom:10px; border-bottom:1px solid #333; margin-bottom:10px; font-weight:700; font-size:14px; color:#fff;';
        header.textContent = `TOC (${items.length})`;
        container.appendChild(header);

        // Items
        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;
            `;
            row.onmouseenter = () => row.style.background = CONFIG.colors.hover;
            row.onmouseleave = () => row.style.background = 'transparent';
            row.onclick = () => scrollToMessage(item.id);

            // Arrow
            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);
                };
                arrowBox.onmouseenter = () => arrowBox.style.color = '#fff';
                arrowBox.onmouseleave = () => arrowBox.style.color = CONFIG.colors.subText;
            }
            row.appendChild(arrowBox);

            // Role icon
            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);

            // Title
            const titleSpan = document.createElement('span');
            const cleanTitle = item.text.split('\n')[0].replace(/[#*`_]/g, '').trim() || 'Message';
            titleSpan.textContent = cleanTitle;
            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);

            container.appendChild(row);

            // Subheaders
            if (hasHeaders && isExpanded) {
                const subContainer = document.createElement('div');
                subContainer.style.cssText = `
                    margin-left: 22px; 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) {
        if (state.expandedItems.has(id)) {
            state.expandedItems.delete(id);
        } else {
            state.expandedItems.add(id);
        }
        renderTOC(state.cachedItems);
    }

    // =========================================================================
    // NAVIGATION
    // =========================================================================
    function scrollToMessage(messageId) {
        const el = document.querySelector(`[data-message-id="${messageId}"]`);
        if (el) {
            smartScrollTo(el);
            flashElement(el);
        }
    }

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

        const candidates = messageEl.querySelectorAll('h1, h2, h3, h4, h5, h6, strong, b');
        const target = Array.from(candidates).find(el => {
            const elText = el.innerText.trim();
            return elText && (elText.includes(headerText) || headerText.includes(elText));
        });

        if (target) {
            smartScrollTo(target);
            flashElement(target);
        } else {
            smartScrollTo(messageEl);
            flashElement(messageEl);
        }
    }

    function smartScrollTo(element) {
        let scrollContainer = element.parentElement;

        while (scrollContainer && scrollContainer !== document.body) {
            const style = window.getComputedStyle(scrollContainer);
            const isScrollable = (style.overflowY === 'auto' || style.overflowY === 'scroll') &&
                                 scrollContainer.scrollHeight > scrollContainer.clientHeight;
            if (isScrollable) break;
            scrollContainer = scrollContainer.parentElement;
        }

        if (scrollContainer && scrollContainer !== document.body) {
            const containerRect = scrollContainer.getBoundingClientRect();
            const elementRect = element.getBoundingClientRect();
            const relativeTop = elementRect.top - containerRect.top;
            const targetPosition = scrollContainer.scrollTop + relativeTop - CONFIG.headerOffset;

            scrollContainer.scrollTo({
                top: Math.max(0, targetPosition),
                behavior: 'smooth'
            });
        } else {
            element.scrollIntoView({ behavior: 'instant', block: 'start' });
            setTimeout(() => {
                const scrollable = document.querySelector('[class*="react-scroll-to-bottom"]') ||
                                   document.querySelector('main');
                if (scrollable) {
                    scrollable.scrollBy({ top: -CONFIG.headerOffset, behavior: 'smooth' });
                }
            }, 50);
        }
    }

    function flashElement(el) {
        const originalTransition = el.style.transition;
        const originalBackground = el.style.backgroundColor;

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

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

    // =========================================================================
    // AUTO-REFRESH OBSERVER
    // =========================================================================
    function setupObserver() {
        let lastUrl = location.href;
        let debounceTimer = null;
        const knownMessageIds = new Set();

        const observer = new MutationObserver((mutations) => {
            // URL change detection
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                state.expandedItems.clear();
                knownMessageIds.clear();
                clearTimeout(debounceTimer);
                loadConversation();
                return;
            }

            // New message detection
            if (!getUUID()) return;

            let foundNew = false;
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;

                    const msgElements = [];
                    if (node.matches?.('[data-message-id]')) {
                        msgElements.push(node);
                    }
                    if (node.querySelectorAll) {
                        msgElements.push(...node.querySelectorAll('[data-message-id]'));
                    }

                    for (const el of msgElements) {
                        const id = el.getAttribute('data-message-id');
                        if (id && !knownMessageIds.has(id)) {
                            knownMessageIds.add(id);
                            foundNew = true;
                        }
                    }
                }
            }

            if (foundNew) {
                clearTimeout(debounceTimer);
                debounceTimer = setTimeout(loadConversation, CONFIG.debounceDelay);
            }
        });

        observer.observe(document.body, { subtree: true, childList: true });
    }

    // =========================================================================
    // INITIALIZATION
    // =========================================================================
    initUI();
    setTimeout(() => {
        loadConversation();
        setupObserver();
    }, CONFIG.debounceDelay);

})();