Greasy Fork is available in English.
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.
// ==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);
}
})();