AI Chat Navigation Sidebar (Export Plus)

Adds a floating navigation sidebar to ChatGPT and Gemini. Features include drag-and-drop, bookmarking, theme switching, precise export, and search filtering. Optimized with MutationObserver for performance and supports dark mode.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         AI Chat Navigation Sidebar (Export Plus)
// @name:en      AI Chat Navigation Sidebar (Export Plus)
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds a floating navigation sidebar to ChatGPT and Gemini. Features include drag-and-drop, bookmarking, theme switching, precise export, and search filtering. Optimized with MutationObserver for performance and supports dark mode.
// @description:en Adds a floating navigation sidebar to ChatGPT and Gemini. Features include drag-and-drop, bookmarking, theme switching, precise export, and search filtering. Optimized with MutationObserver for performance and supports dark mode.
// @author       RenZhe0228
// @license      MIT
// @match        https://chatgpt.com/*
// @match        https://gemini.google.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// ==/UserScript==

/* jshint esversion: 8 */
(function() {
    'use strict';

    // --- Utility Class ---
    const Utils = {
        debounce: (fn, delay) => {
            let timer;
            return (...args) => {
                clearTimeout(timer);
                timer = setTimeout(() => fn.apply(this, args), delay);
            };
        },
        storage: {
            get: (key, def) => (typeof GM_getValue !== 'undefined' ? GM_getValue(key, def) : JSON.parse(localStorage.getItem('ai-toc-' + key) || JSON.stringify(def))),
            set: (key, val) => (typeof GM_setValue !== 'undefined' ? GM_setValue(key, val) : localStorage.setItem('ai-toc-' + key, JSON.stringify(val)))
        },
        toast: (msg) => {
            const div = document.createElement('div');
            div.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fff;padding:8px 16px;border-radius:20px;z-index:10001;font-size:13px;backdrop-filter:blur(4px);pointer-events:none;`;
            div.textContent = msg;
            document.body.appendChild(div);
            setTimeout(() => { div.style.opacity = 0; setTimeout(() => div.remove(), 400); }, 2000);
        }
    };

    // --- Navigation Class ---
    class AIChatSideNav {
        constructor() {
            this.config = { symbol: '⌬', minW: 200, maxW: 300, len: 18 };
            this.state = {
                marks: new Set(Utils.storage.get('bookmarks', [])),
                isCollapsed: Utils.storage.get('collapsed', false),
                isWide: Utils.storage.get('wide', false),
                pos: Utils.storage.get('pos', { x: -1, y: 100 }),
                keyword: '',
                isDragging: false,
                offset: { x: 0, y: 0 },
                lastCount: 0
            };
            this.dom = {};
        }

        init() {
            this.injectCSS();
            this.render();
            this.bindEvents();
            this.setupObserver();
            this.scan(true);
        }

        injectCSS() {
            const css = `
                :root {
                    --at-bg: rgba(255, 255, 255, 0.9); --at-bd: #e2e8f0; --at-txt: #334155;
                    --at-h-bg: #f8fafc; --at-h-txt: #3b82f6; --at-act: #3b82f6; --at-shd: 0 4px 20px rgba(0,0,0,0.08);
                    --at-s-off: #cbd5e1; --at-s-on: #f59e0b;
                }
                @media (prefers-color-scheme: dark) {
                    :root {
                        --at-bg: rgba(28, 25, 23, 0.95); --at-bd: #f59e0b; --at-txt: #fef3c7;
                        --at-h-bg: #1c1917; --at-h-txt: #f59e0b; --at-act: #d97706; --at-shd: 0 4px 20px rgba(0,0,0,0.4);
                        --at-s-off: #57534e; --at-s-on: #f59e0b;
                    }
                }
                #ai-toc {
                    position: fixed; z-index: 9999; display: flex; flex-direction: column;
                    background: var(--at-bg); border: 1px solid var(--at-bd); color: var(--at-txt);
                    backdrop-filter: blur(12px); border-radius: 12px; box-shadow: var(--at-shd);
                    font-family: system-ui, sans-serif; transition: width 0.3s, opacity 0.3s;
                    max-height: 80vh;
                }
                /* Header & Footer: Fixed height, draggable cursor */
                #ai-head, #ai-foot { 
                    padding: 10px 12px; cursor: move; display: flex; justify-content: space-between; align-items: center; 
                    flex-shrink: 0; user-select: none;
                }
                #ai-head { border-bottom: 1px solid var(--at-bd); background: var(--at-h-bg); border-radius: 12px 12px 0 0; }
                #ai-foot { border-top: 1px solid var(--at-bd); border-radius: 0 0 12px 12px; font-size: 12px; }

                .ai-title { font-weight: 700; font-size: 16px; color: var(--at-h-txt); }
                .ai-ctrls { display: flex; gap: 8px; }
                .ai-btn { cursor: pointer; opacity: 0.6; transition: 0.2s; font-size: 14px; }
                .ai-btn:hover { opacity: 1; transform: scale(1.1); color: var(--at-act); }
                
                #ai-search { 
                    margin: 8px; padding: 4px 8px; border: 1px solid var(--at-bd); border-radius: 6px; 
                    background: transparent; color: var(--at-txt); font-size: 12px; outline: none; 
                    flex-shrink: 0;
                }
                #ai-search:focus { border-color: var(--at-act); }
                
                /* Body: Scrollable area */
                #ai-body { flex: 1; overflow-y: auto; padding: 4px 0; scrollbar-width: thin; min-height: 0; }
                #ai-body::-webkit-scrollbar { width: 3px; }
                #ai-body::-webkit-scrollbar-thumb { background: var(--at-bd); }
                
                .ai-item { padding: 6px 10px 6px 2px; cursor: pointer; display: flex; align-items: center; border-left: 3px solid transparent; transition: 0.15s; }
                .ai-item:hover { background: rgba(0,0,0,0.05); border-left-color: var(--at-act); padding-left: 8px; }
                .ai-item.mark { background: rgba(245, 158, 11, 0.05); border-left-color: var(--at-s-on); font-weight: 600; }
                .ai-star { width: 22px; text-align: center; color: var(--at-s-off); font-size: 12px; }
                .ai-item.mark .ai-star { color: var(--at-s-on); text-shadow: 0 0 4px var(--at-s-on); }
                .ai-txt { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; pointer-events: none; font-size: 12px; }
                
                .ai-wide { width: ${this.config.maxW}px !important; }
                .ai-norm { width: ${this.config.minW}px !important; }
                .ai-hide #ai-body, .ai-hide #ai-search, .ai-hide #ai-foot { display: none; }
                .ai-hide { width: auto !important; height: auto !important; }
            `;
            const s = document.createElement('style'); s.textContent = css; document.head.appendChild(s);
        }

        render() {
            const mk = (tag, cls, attr = {}) => {
                const el = document.createElement(tag); if (cls) el.className = cls;
                for (const [k, v] of Object.entries(attr)) el[k] = v; return el;
            };

            this.dom.root = mk('div', this.state.isWide ? 'ai-wide' : 'ai-norm', { id: 'ai-toc' });
            if (this.state.isCollapsed) this.dom.root.classList.add('ai-hide');
            
            // Head
            const head = mk('div', '', { id: 'ai-head' });
            const title = mk('div', 'ai-title', { textContent: this.config.symbol });
            const ctrls = mk('div', 'ai-ctrls');
            const btnWide = mk('span', 'ai-btn', { textContent: '↔', title: 'Toggle Width' });
            this.dom.btnFold = mk('span', 'ai-btn', { textContent: this.state.isCollapsed ? '◀' : '▼', title: 'Collapse' });
            ctrls.append(btnWide, this.dom.btnFold); head.append(title, ctrls);

            // Body & Search
            this.dom.search = mk('input', '', { id: 'ai-search', placeholder: 'Search...', type: 'text' });
            this.dom.body = mk('div', '', { id: 'ai-body' });
            
            // Foot
            const foot = mk('div', '', { id: 'ai-foot' });
            const jumpCtrls = mk('div', 'ai-ctrls');
            const btnTop = mk('span', 'ai-btn', { textContent: '⬆', title: 'Top' });
            const btnBot = mk('span', 'ai-btn', { textContent: '⬇', title: 'Bottom' });
            jumpCtrls.append(btnTop, btnBot);
            
            const exportBtn = mk('span', 'ai-btn', { textContent: '📋', title: 'Click: Copy TOC\nShift+Click: Export Chat' });
            foot.append(jumpCtrls, exportBtn);

            this.dom.root.append(head, this.dom.search, this.dom.body, foot);
            document.body.appendChild(this.dom.root);

            // Initial Position
            if (this.state.pos.x !== -1) {
                this.dom.root.style.left = this.state.pos.x + 'px';
                this.dom.root.style.top = this.state.pos.y + 'px';
                this.dom.root.style.right = 'auto';
            } else {
                this.dom.root.style.top = '100px'; this.dom.root.style.right = '20px';
            }

            // Bind Actions
            btnWide.onclick = () => this.toggleWidth();
            this.dom.btnFold.onclick = () => this.toggleCollapse();
            btnTop.onclick = () => this.dom.body.scrollTo({ top: 0, behavior: 'smooth' });
            btnBot.onclick = () => this.dom.body.scrollTo({ top: this.dom.body.scrollHeight, behavior: 'smooth' });
            exportBtn.onclick = (e) => this.handleExport(e);
        }

        bindEvents() {
            // Search
            this.dom.search.oninput = (e) => { this.state.keyword = e.target.value.toLowerCase(); this.scan(true); };

            // Drag Handlers (Head & Foot)
            const startDrag = (e) => {
                // Ignore button clicks
                if (e.target.closest('.ai-btn') || e.target.closest('#ai-search')) return;
                
                this.state.isDragging = true;
                this.state.offset.x = e.clientX - this.dom.root.offsetLeft;
                this.state.offset.y = e.clientY - this.dom.root.offsetTop;
                e.currentTarget.style.cursor = 'grabbing';
            };

            const head = this.dom.root.querySelector('#ai-head');
            const foot = this.dom.root.querySelector('#ai-foot');
            
            head.onmousedown = startDrag;
            foot.onmousedown = startDrag; // Enable dragging on footer

            document.onmousemove = (e) => {
                if (!this.state.isDragging) return;
                this.dom.root.style.left = (e.clientX - this.state.offset.x) + 'px';
                this.dom.root.style.top = (e.clientY - this.state.offset.y) + 'px';
                this.dom.root.style.right = 'auto';
            };

            document.onmouseup = () => {
                if (this.state.isDragging) {
                    this.state.isDragging = false;
                    head.style.cursor = 'move';
                    foot.style.cursor = 'move';
                    Utils.storage.set('pos', { x: this.dom.root.offsetLeft, y: this.dom.root.offsetTop });
                }
            };
        }

        setupObserver() {
            const debouncedScan = Utils.debounce(() => this.scan(), 500);
            this.observer = new MutationObserver(() => debouncedScan());
            this.observer.observe(document.body, { childList: true, subtree: true });
        }

        scan(force = false) {
            const sels = ['div[data-message-author-role="user"]', 'user-query', '.user-query-text'];
            const nodes = Array.from(document.querySelectorAll(sels.join(','))).filter(el => el.innerText.trim());
            if (!force && nodes.length === this.state.lastCount && !this.state.keyword) return;
            this.state.lastCount = nodes.length;

            this.dom.body.textContent = '';
            const frag = document.createDocumentFragment();
            nodes.forEach((node) => {
                const txt = node.innerText.replace(/\n/g, ' ').trim();
                if (this.state.keyword && !txt.toLowerCase().includes(this.state.keyword)) return;
                const item = document.createElement('div');
                item.className = 'ai-item' + (this.state.marks.has(txt) ? ' mark' : '');
                item.title = txt;
                const star = document.createElement('span'); star.className = 'ai-star'; star.textContent = '★';
                star.onclick = (e) => {
                    e.stopPropagation();
                    this.state.marks.has(txt) ? this.state.marks.delete(txt) : this.state.marks.add(txt);
                    Utils.storage.set('bookmarks', Array.from(this.state.marks)); this.scan(true);
                };
                const label = document.createElement('span'); label.className = 'ai-txt';
                label.textContent = txt.length > this.config.len ? txt.slice(0, this.config.len) + '..' : txt;
                item.oncontextmenu = (e) => { e.preventDefault(); navigator.clipboard.writeText(txt); Utils.toast('Copied to clipboard'); };
                item.onclick = () => node.scrollIntoView({ behavior: 'smooth', block: 'center' });
                item.append(star, label); frag.append(item);
            });
            this.dom.body.append(frag.childElementCount ? frag : Object.assign(document.createElement('div'), { className: 'ai-txt', style: 'padding:10px;text-align:center', textContent: 'Waiting...' }));
        }

        toggleCollapse() {
            this.state.isCollapsed = !this.state.isCollapsed;
            this.dom.root.classList.toggle('ai-hide');
            this.dom.btnFold.textContent = this.state.isCollapsed ? '+' : '−';
            Utils.storage.set('collapsed', this.state.isCollapsed);
        }

        toggleWidth() {
            this.state.isWide = !this.state.isWide;
            this.state.isWide ? this.dom.root.classList.replace('ai-norm', 'ai-wide') : this.dom.root.classList.replace('ai-wide', 'ai-norm');
            Utils.storage.set('wide', this.state.isWide);
        }

        handleExport(e) {
            e.stopPropagation();
            if (e.shiftKey) {
                const log = this.getChatLog();
                if (!log) return Utils.toast("No conversation found");
                navigator.clipboard.writeText(log).then(() => Utils.toast("✅ Full Chat Copied"));
            } else {
                const list = Array.from(this.dom.body.querySelectorAll('.ai-item')).map(n => n.title).join('\n');
                navigator.clipboard.writeText(list).then(() => Utils.toast("✅ TOC Copied"));
            }
        }

        getChatLog() {
            let log = [`=== Exported Chat (${new Date().toLocaleString()}) ===\n`];
            const isGPT = location.host.includes('chatgpt.com');
            const blocks = isGPT ? document.querySelectorAll('div[data-message-author-role]') : document.querySelectorAll('user-query, model-response');
            if (!blocks.length) return null;
            blocks.forEach(b => {
                const isUser = isGPT ? b.getAttribute('data-message-author-role') === 'user' : b.tagName.toLowerCase() === 'user-query';
                log.push(`${isUser ? '[User]' : '[AI]'}\n${b.innerText.trim()}\n-------------------`);
            });
            return log.join('\n\n');
        }
    }

    const app = new AIChatSideNav();
    window.addEventListener('load', () => app.init());
    let lastUrl = location.href;
    setInterval(() => { if (location.href !== lastUrl) { lastUrl = location.href; if (!document.getElementById('ai-toc')) app.init(); else app.scan(true); } }, 2000);

})();