Chub AI Thinking Block

Displays the internal reasoning process of thinking AI models on Chub.ai as a collapsible block above each response. Supports all major AI providers and automatically matches your Chub theme.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Chub AI Thinking Block
// @namespace    http://tampermonkey.net/
// @version      1.4
// @license      MIT
// @description  Displays the internal reasoning process of thinking AI models on Chub.ai as a collapsible block above each response. Supports all major AI providers and automatically matches your Chub theme.
// @author       You
// @match        *://chub.ai/*
// @grant        GM_addStyle
// @grant        unsafeWindow
// @connect      *
// ==/UserScript==
// jshint esversion: 11

(function () {
    'use strict';

    // Set to true to enable console logging for debugging
    const DEBUG = false;
    const LOG = (...args) => DEBUG && console.log('[Chub Think Block]', ...args);

    // ─── Live Block ───────────────────────────────────────────────────────────

    let liveBlock    = null;
    let pollInterval = null;

    function startInjectionPoller() {
        if (pollInterval) return;
        pollInterval = setInterval(() => {
            if (!liveBlock) { stopInjectionPoller(); return; }
            if (liveBlock.injected) { stopInjectionPoller(); return; }
            tryInjectLiveBlock();
        }, 80);
    }

    function stopInjectionPoller() {
        if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
    }

    // ─── Persistence ─────────────────────────────────────────────────────────

    function getChatId() {
        const m = location.pathname.match(/\/chats\/([a-zA-Z0-9_-]+)/);
        return m ? m[1] : null;
    }

    function storageKey(chatId) { return 'chubThink_' + chatId; }

    function loadSavedBlocks(chatId) {
        try { return JSON.parse(localStorage.getItem(storageKey(chatId)) || '[]'); }
        catch (_) { return []; }
    }

    function saveBlock(chatId, index, content) {
        const blocks = loadSavedBlocks(chatId);
        const i = blocks.findIndex(b => b.index === index);
        if (i >= 0) blocks[i].content = content;
        else blocks.push({ index, content });
        localStorage.setItem(storageKey(chatId), JSON.stringify(blocks));
    }

    // ─── Theme Sync ───────────────────────────────────────────────────────────
    //
    // Reads Chub's localStorage.theme and writes bridge variables onto :root.
    //
    // Mappings:
    //   em_color                        → --chub-think-color  (text)
    //   message_background_color        → --chub-think-bg     (dark mode bg)
    //   message_background_color_light  → --chub-think-bg     (light mode bg)
    //   link_color                      → --chub-think-accent (border/chevron/dot)
    //   font_size                       → --chub-think-font-size
    //   line_height                     → --chub-think-line-height

    function syncTheme() {
        let theme = {};
        try { theme = JSON.parse(localStorage.getItem('theme') || '{}'); } catch (_) {}

        const isLight  = theme.mode === 'light';
        const bg       = isLight ?
            (theme.message_background_color_light || 'rgba(219,218,218,0.9)') :
            (theme.message_background_color       || 'rgba(36,37,37,0.94)');
        const color    = theme.em_color    || '#A49694';
        const accent   = theme.link_color  || '#7D63FF';
        const fontSize = theme.font_size   || '1rem';
        const lineH    = theme.line_height || '1.5';

        const root = document.documentElement;
        root.style.setProperty('--chub-think-bg',          bg);
        root.style.setProperty('--chub-think-color',       color);
        root.style.setProperty('--chub-think-accent',      accent);
        root.style.setProperty('--chub-think-font-size',   fontSize);
        root.style.setProperty('--chub-think-line-height', lineH);
    }

    // ─── Styles ───────────────────────────────────────────────────────────────

    GM_addStyle(`
        .chub-think-block {
            font-family: inherit;
            font-size: var(--chub-think-font-size, 1rem);
            line-height: var(--chub-think-line-height, 1.5);
            margin: 0 0 4px 0;
            border-radius: 6px;
            border: 1px solid color-mix(in srgb, var(--chub-think-accent, #7D63FF) 25%, transparent);
            background: var(--chub-think-bg, rgba(36,37,37,0.94));
            color: var(--chub-think-color, #A49694);
            overflow: hidden;
            transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
        }
        .chub-think-block:hover {
            border-color: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 45%, transparent);
        }
        .chub-think-block.streaming {
            border-color: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 62%, transparent);
        }
        .chub-think-summary {
            display: flex;
            align-items: center;
            gap: 6px;
            padding: 5px 10px;
            cursor: pointer;
            list-style: none;
            user-select: none;
            font-size: 0.8em;
            font-weight: 600;
            letter-spacing: 0.03em;
            color: color-mix(in srgb, var(--chub-think-color, #A49694) 80%, transparent);
            background: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 7%, transparent);
            transition: background 0.15s ease, color 0.15s ease;
        }
        .chub-think-summary:hover {
            background: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 15%, transparent);
            color: var(--chub-think-color, #A49694);
        }
        .chub-think-summary::-webkit-details-marker { display: none; }
        .chub-think-summary::marker { display: none; }
        .chub-think-icon  { font-size: 0.9em; line-height: 1; flex-shrink: 0; opacity: 0.8; }
        .chub-think-title { flex: 1; }
        .chub-think-streaming-dot {
            display: inline-block;
            width: 5px; height: 5px;
            border-radius: 50%;
            background: var(--chub-think-accent, #7D63FF);
            margin-left: 2px;
            animation: chub-think-pulse 1.1s ease-in-out infinite;
            flex-shrink: 0;
        }
        .chub-think-streaming-dot.hidden { display: none; }
        @keyframes chub-think-pulse {
            0%, 100% { opacity: 0.2; transform: scale(0.8); }
            50%       { opacity: 0.9; transform: scale(1.15); }
        }
        .chub-think-chevron {
            font-size: 0.65em;
            transition: transform 0.2s ease;
            color: var(--chub-think-accent, #7D63FF);
            opacity: 0.7;
            flex-shrink: 0;
        }
        .chub-think-block[open] .chub-think-chevron { transform: rotate(90deg); }
        .chub-think-content {
            padding: 8px 12px;
            white-space: pre-wrap;
            word-break: break-word;
            max-height: 380px;
            overflow-y: auto;
            color: var(--chub-think-color, #A49694);
            border-top: 1px solid color-mix(in srgb, var(--chub-think-accent, #7D63FF) 10%, transparent);
            scrollbar-width: thin;
            scrollbar-color: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 30%, transparent) transparent;
        }
        .chub-think-content::-webkit-scrollbar       { width: 3px; }
        .chub-think-content::-webkit-scrollbar-track  { background: transparent; }
        .chub-think-content::-webkit-scrollbar-thumb  {
            background: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 30%, transparent);
            border-radius: 2px;
        }
        .chub-think-content::-webkit-scrollbar-thumb:hover {
            background: color-mix(in srgb, var(--chub-think-accent, #7D63FF) 55%, transparent);
        }
        .chub-think-word-count {
            font-size: 0.82em;
            color: color-mix(in srgb, var(--chub-think-color, #A49694) 50%, transparent);
            margin-left: 2px;
        }
    `);

    // ─── Block Construction ───────────────────────────────────────────────────

    function createLiveBlock() {
        const details = document.createElement('details');
        details.className = 'chub-think-block streaming';
        const summary = document.createElement('summary');
        summary.className = 'chub-think-summary';
        const icon  = Object.assign(document.createElement('span'), { className: 'chub-think-icon',  textContent: '💭' });
        const title = Object.assign(document.createElement('span'), { className: 'chub-think-title', textContent: 'Thinking…' });
        const dot   = Object.assign(document.createElement('span'), { className: 'chub-think-streaming-dot' });
        const wc    = Object.assign(document.createElement('span'), { className: 'chub-think-word-count' });
        const chev  = Object.assign(document.createElement('span'), { className: 'chub-think-chevron', textContent: '▶' });
        summary.append(icon, title, dot, wc, chev);
        const contentDiv = Object.assign(document.createElement('div'), { className: 'chub-think-content' });
        details.append(summary, contentDiv);

        // Auto-scroll is on by default. If the user scrolls up, pause it.
        // If they scroll back to the bottom, resume it.
        let autoScroll = true;
        contentDiv.addEventListener('scroll', () => {
            const atBottom = contentDiv.scrollHeight - contentDiv.scrollTop - contentDiv.clientHeight < 8;
            autoScroll = atBottom;
        });

        return { details, contentDiv, wordCountEl: wc, dotEl: dot, titleEl: title,
                 content: '', injected: false, container: null,
                 get autoScroll() { return autoScroll; } };
    }

    function buildStaticBlock(content) {
        const wc = content.trim().split(/\s+/).filter(Boolean).length;
        const details = document.createElement('details');
        details.className = 'chub-think-block';
        const summary = document.createElement('summary');
        summary.className = 'chub-think-summary';
        summary.innerHTML = `
            <span class="chub-think-icon">💭</span>
            <span class="chub-think-title">Thinking</span>
            <span class="chub-think-word-count">${wc} words</span>
            <span class="chub-think-chevron">▶</span>`;
        const cd = Object.assign(document.createElement('div'), { className: 'chub-think-content' });
        cd.textContent = content;
        details.append(summary, cd);
        return details;
    }

    function appendToLiveBlock(text) {
        if (!liveBlock) return;
        liveBlock.content += text;
        liveBlock.contentDiv.appendChild(document.createTextNode(text));
        if (liveBlock.details.open && liveBlock.autoScroll)
            liveBlock.contentDiv.scrollTop = liveBlock.contentDiv.scrollHeight;
    }

    function finalizeLiveBlock() {
        if (!liveBlock) return;
        const wc = liveBlock.content.trim().split(/\s+/).filter(Boolean).length;
        liveBlock.wordCountEl.textContent = wc + ' words';
        liveBlock.titleEl.textContent     = 'Thinking';
        liveBlock.dotEl.classList.add('hidden');
        liveBlock.details.classList.remove('streaming');
        const chatId = getChatId();
        if (chatId && liveBlock.container) {
            const all = Array.from(document.querySelectorAll('.msg-mkdn-container'));
            const idx = all.indexOf(liveBlock.container);
            if (idx >= 0) saveBlock(chatId, idx, liveBlock.content.trim());
        }
        LOG('Finalized (' + liveBlock.content.trim().split(/\s+/).length + ' words)');
        liveBlock = null;
        stopInjectionPoller();
    }

    function tryInjectLiveBlock() {
        if (!liveBlock || liveBlock.injected) return;
        const all = Array.from(document.querySelectorAll('.msg-mkdn-container'));
        for (let i = all.length - 1; i >= 0; i--) {
            if (!all[i].dataset.thinkInjected) {
                const c = all[i];
                c.parentNode.insertBefore(liveBlock.details, c);
                c.dataset.thinkInjected = 'true';
                liveBlock.injected  = true;
                liveBlock.container = c;
                stopInjectionPoller();
                LOG('Live block injected at index', i);
                return;
            }
        }
    }

    // ─── Restore Saved Blocks ─────────────────────────────────────────────────

    function restoreSavedBlocks() {
        const chatId = getChatId();
        if (!chatId) return;
        const saved = loadSavedBlocks(chatId);
        if (!saved.length) return;
        const containers = Array.from(document.querySelectorAll('.msg-mkdn-container'));
        LOG('Restoring', saved.length, 'saved blocks');
        for (const { index, content } of saved) {
            const c = containers[index];
            if (!c || c.dataset.thinkInjected) continue;
            c.parentNode.insertBefore(buildStaticBlock(content), c);
            c.dataset.thinkInjected = 'true';
        }
    }

    function markExistingContainers() {
        document.querySelectorAll('.msg-mkdn-container').forEach(el => {
            if (!el.dataset.thinkInjected) el.dataset.thinkInjected = 'skip';
        });
    }

    // ─── Endpoint Detection ───────────────────────────────────────────────────

    const KNOWN_AI_DOMAINS = [
        'openrouter.ai', 'generativelanguage.googleapis.com',
        'api.openai.com', 'api.anthropic.com',
        'api.together.xyz', 'api.together.ai',
        'api.mistral.ai', 'api.cohere.ai', 'api.cohere.com',
        'api.groq.com', 'api.deepseek.com', 'api.perplexity.ai',
        'api.fireworks.ai', 'api.replicate.com', 'inference.cerebras.ai',
        'api.novita.ai', 'api.x.ai',
        'bedrock-runtime.amazonaws.com', 'aiplatform.googleapis.com',
    ];
    const COMPLETION_PATH_RE = /\/(chat\/)?complet|\/generat|\/messages|\/stream/i;
    const STATIC_ASSET_RE   = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map)(\?|$)/i;

    function shouldIntercept(url, method, body) {
        if (method !== 'POST') return false;
        if (STATIC_ASSET_RE.test(url)) return false;
        try {
            const { hostname, pathname } = new URL(url);
            if (KNOWN_AI_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d))) return true;
            if (COMPLETION_PATH_RE.test(pathname)) return true;
            if (body && typeof body === 'string' && body.includes('"messages"') && body.includes('"role"')) return true;
        } catch (_) {}
        return false;
    }

    // ─── Chunk Parser ─────────────────────────────────────────────────────────

    const chunkDecoder = new TextDecoder();
    const chunkEncoder = new TextEncoder();
    let   lineBuffer       = '';
    let   inContentThink   = false; // true while streaming inside <think> in delta.content

    function ensureLiveBlock() {
        if (!liveBlock) { liveBlock = createLiveBlock(); startInjectionPoller(); }
    }

    /**
     * Extracts any <think> content from a delta.content string.
     * Handles tags split across chunks via the inContentThink state flag.
     *
     * Returns { remaining: string, modified: boolean }
     * Side-effect: appends extracted think text to the live block.
     */
    function extractFromContent(content) {
        let remaining = '';
        let modified  = false;

        if (inContentThink) {
            // We are mid-think — everything goes to the block until </think>
            const closeIdx = content.indexOf('</think>');
            if (closeIdx >= 0) {
                // Think block ends in this chunk
                ensureLiveBlock();
                appendToLiveBlock(content.slice(0, closeIdx));
                remaining = content.slice(closeIdx + 8);
                inContentThink = false;
            } else {
                // Still inside think block, whole chunk is think content
                ensureLiveBlock();
                appendToLiveBlock(content);
                remaining = '';
            }
            modified = true;
        } else {
            const openIdx = content.indexOf('<think>');
            if (openIdx >= 0) {
                // Think block starts in this chunk
                remaining = content.slice(0, openIdx); // text before <think> stays
                const afterOpen = content.slice(openIdx + 7);
                const closeIdx  = afterOpen.indexOf('</think>');
                if (closeIdx >= 0) {
                    // Both open and close in same chunk
                    ensureLiveBlock();
                    appendToLiveBlock(afterOpen.slice(0, closeIdx));
                    remaining += afterOpen.slice(closeIdx + 8);
                } else {
                    // No closing tag yet — stream the rest into think block
                    ensureLiveBlock();
                    appendToLiveBlock(afterOpen);
                    inContentThink = true;
                }
                modified = true;
            } else {
                remaining = content;
            }
        }

        return { remaining, modified };
    }

    /**
     * Processes one decoded SSE chunk.
     * Returns either the original Uint8Array (no modification needed)
     * or a new Uint8Array with <think> content stripped from delta.content.
     */
    function processChunk(uint8chunk) {
        const text = chunkDecoder.decode(uint8chunk, { stream: true });
        lineBuffer += text;
        const lines = lineBuffer.split('\n');
        lineBuffer  = lines.pop();

        let chunkModified = false;
        const outputLines = [];

        for (const line of lines) {
            if (line.startsWith('event:')) { outputLines.push(line); continue; }
            if (!line.startsWith('data:')) { outputLines.push(line); continue; }

            const jsonStr = line.slice(5).trim();
            if (jsonStr === '[DONE]') { outputLines.push(line); continue; }

            let parsed;
            try { parsed = JSON.parse(jsonStr); } catch (_) { outputLines.push(line); continue; }

            // ── Anthropic thinking block ──────────────────────────────────────
            if (parsed?.type === 'content_block_start' &&
                parsed?.content_block?.type === 'thinking') {
                ensureLiveBlock();
                if (parsed.content_block.thinking) appendToLiveBlock(parsed.content_block.thinking);
                outputLines.push(line);
                continue;
            }
            if (parsed?.type === 'content_block_delta' &&
                parsed?.delta?.type === 'thinking_delta' && parsed?.delta?.thinking) {
                ensureLiveBlock();
                appendToLiveBlock(parsed.delta.thinking);
                outputLines.push(line);
                continue;
            }

            // ── OpenAI-compatible explicit reasoning fields ────────────────────
            // These are already separate from content — no need to strip anything
            const delta = parsed?.choices?.[0]?.delta ?? {};
            if (delta.reasoning)         { ensureLiveBlock(); appendToLiveBlock(delta.reasoning); }
            if (delta.reasoning_content) { ensureLiveBlock(); appendToLiveBlock(delta.reasoning_content); }

            // ── Gemini thinking parts ─────────────────────────────────────────
            const parts = parsed?.candidates?.[0]?.content?.parts;
            if (Array.isArray(parts)) {
                for (const part of parts) {
                    if (part.thought && part.text) { ensureLiveBlock(); appendToLiveBlock(part.text); }
                }
            }

            // ── delta.content: scan for raw <think> tags ──────────────────────
            // Some models embed <think>...</think> directly in the content stream.
            // We extract the think portion, route it to the live block, and
            // replace delta.content with the remainder so Chub never sees the tags.
            if (typeof delta.content === 'string' && (inContentThink || delta.content.includes('<think>'))) {
                const { remaining, modified } = extractFromContent(delta.content);
                if (modified) {
                    parsed.choices[0].delta.content = remaining;
                    outputLines.push('data: ' + JSON.stringify(parsed));
                    chunkModified = true;
                    continue;
                }
            }

            outputLines.push(line);
        }

        if (!chunkModified) return uint8chunk;

        // Re-encode the modified lines back to bytes
        const outputText = outputLines.join('\n') + (text.endsWith('\n') ? '\n' : '');
        return chunkEncoder.encode(outputText);
    }

    // ─── Safe Headers ─────────────────────────────────────────────────────────

    function makeSafeHeaders(h) {
        const STRIP = new Set(['content-encoding', 'content-length', 'transfer-encoding']);
        const safe  = new Headers();
        h.forEach((v, k) => { if (!STRIP.has(k.toLowerCase())) safe.set(k, v); });
        return safe;
    }

    // ─── Fetch Intercept ──────────────────────────────────────────────────────

    const originalFetch = unsafeWindow.fetch;

    unsafeWindow.fetch = async function (input, init) {
        const url    = input instanceof Request ? input.url    : String(input);
        const method = (input instanceof Request ? input.method : (init?.method ?? 'GET')).toUpperCase();
        const body   = input instanceof Request ? null         : (init?.body ?? null);

        if (!shouldIntercept(url, method, body))
            return originalFetch.apply(unsafeWindow, [input, init]);

        const response = await originalFetch.apply(unsafeWindow, [input, init]);

        if (!response.ok || !response.body) return response;

        const ct = response.headers.get('content-type') || '';

        // Non-streaming JSON
        if (ct.includes('application/json') && !ct.includes('stream')) {
            response.clone().text().then(text => {
                try {
                    const p  = JSON.parse(text);
                    const r1 = p?.choices?.[0]?.message?.reasoning;
                    const r2 = p?.choices?.[0]?.message?.reasoning_content;
                    const tp = Array.isArray(p?.content) && p.content.find(x => x.type === 'thinking');
                    const rc = r1?.trim() || r2?.trim() || tp?.thinking?.trim();
                    if (rc) {
                        liveBlock = createLiveBlock();
                        appendToLiveBlock(rc);
                        startInjectionPoller();
                        setTimeout(finalizeLiveBlock, 500);
                        return;
                    }
                } catch (_) {}
                const m = text.match(/<think>([\s\S]*?)<\/think>/i);
                if (m?.[1]?.trim()) {
                    liveBlock = createLiveBlock();
                    appendToLiveBlock(m[1].trim());
                    startInjectionPoller();
                    setTimeout(finalizeLiveBlock, 500);
                }
            }).catch(() => {});
            return response;
        }

        // Streaming: TransformStream — inspect and potentially modify each chunk
        lineBuffer      = '';
        inContentThink  = false;

        const transform = new TransformStream({
            transform(chunk, controller) {
                try {
                    controller.enqueue(processChunk(chunk));
                } catch (_) {
                    controller.enqueue(chunk); // fallback: pass through unchanged
                }
            },
            flush() {
                if (liveBlock) setTimeout(finalizeLiveBlock, 150);
                else LOG('No reasoning content found.');
            }
        });

        response.body.pipeTo(transform.writable).catch(() => {});
        return new Response(transform.readable, {
            status: response.status, statusText: response.statusText,
            headers: makeSafeHeaders(response.headers),
        });
    };

    // ─── Init & Observer ──────────────────────────────────────────────────────

    const originalSetItem = localStorage.setItem.bind(localStorage);
    localStorage.setItem = function(key, value) {
        originalSetItem(key, value);
        if (key === 'theme') syncTheme();
    };

    // Restore original setItem if the script is disabled or page unloads,
    // so Chub's own localStorage writes are never broken
    window.addEventListener('unload', () => {
        localStorage.setItem = originalSetItem;
    });

    let chatObserver = null;

    function init() {
        const wait = setInterval(() => {
            if (!document.querySelector('.msg-mkdn-container')) return;
            clearInterval(wait);

            restoreSavedBlocks();
            markExistingContainers();
            syncTheme();

            if (chatObserver) { chatObserver.disconnect(); chatObserver = null; }

            const target = document.getElementById('chat-messages') || document.body;
            chatObserver = new MutationObserver(() => {
                if (liveBlock && !liveBlock.injected) tryInjectLiveBlock();
            });
            chatObserver.observe(target, { childList: true, subtree: true });

            LOG('Initialized for chat:', getChatId() || 'unknown');
        }, 200);
    }

    // ─── SPA Navigation ───────────────────────────────────────────────────────

    let lastPath = location.pathname;
    setInterval(() => {
        if (location.pathname !== lastPath) {
            lastPath = location.pathname;
            liveBlock = null;
            lineBuffer = '';
            inContentThink = false;
            stopInjectionPoller();
            if (chatObserver) { chatObserver.disconnect(); chatObserver = null; }
            setTimeout(init, 600);
        }
    }, 500);

    // ─── Boot ─────────────────────────────────────────────────────────────────

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        init();
    } else {
        document.addEventListener('DOMContentLoaded', init);
    }

})();