Greasy Fork is available in English.

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
    }

})();