// ==UserScript==
// @name Chat TOC
// @description Add a draggable Table of Contents for common AI websites.
// @updateURL https://raw.githubusercontent.com/EricWvi/chat-toc/main/chat-toc.user.js
// @downloadURL https://raw.githubusercontent.com/EricWvi/chat-toc/main/chat-toc.user.js
// @version 1.8.0
// @author Eric Wang
// @namespace ChatTOC
// @copyright 2025, Eric Wang (https://github.com/EricWvi)
// @license MIT
// @match https://github.com/copilot
// @match https://github.com/copilot/*
// @match https://chatgpt.com
// @match https://chatgpt.com/*
// @match https://gemini.google.com
// @match https://gemini.google.com/*
// @match https://www.kimi.com
// @match https://www.kimi.com/*
// @match https://claude.ai
// @match https://claude.ai/new
// @match https://claude.ai/chat/*
// @match https://chat.deepseek.com
// @match https://chat.deepseek.com/*
// @match https://chat.qwen.ai
// @match https://chat.qwen.ai/c/*
// @match https://yuanbao.tencent.com
// @match https://yuanbao.tencent.com/chat/*
// @match https://chat.minimaxi.com
// @match https://chat.minimaxi.com/*
// @match https://www.doubao.com/chat
// @match https://www.doubao.com/chat/*
// @match https://chatglm.cn
// @match https://chatglm.cn/*
// @match https://www.tongyi.com/qianwen
// @match https://www.tongyi.com/qianwen/*
// ==/UserScript==
(function () {
'use strict';
let tocContainer = null;
let isVisible = true;
let lastMessageCount = 0;
let lastMessageTexts = [];
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let tocIsUpdating = false;
let currentQuestionIndex = -1;
let currentQuestionUpdateInterval = null;
function getElementsByXPath(xpath) {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
const elements = [];
for (let i = 0; i < result.snapshotLength; i++) {
elements.push(result.snapshotItem(i));
}
return elements;
}
// Define strategies for different hosts
const strategies = {
'chatgpt.com': function () {
return [...document.querySelectorAll('article')]
.filter((_, idx) => idx % 2 == 0)
.map(article => article.querySelector('div'));
},
'gemini.google.com': function () {
return [...document.querySelectorAll('user-query-content')];
},
'kimi.com': function () {
return [...document.querySelectorAll('[class*="user-content"]')];
},
'chat.qwen.ai': function () {
return [...document.querySelectorAll('[class*="user-message-text-content"]')];
},
'chatglm.cn': function () {
return [...document.querySelectorAll('[class*="conversation"][class*="question"] .question-text-style')];
},
'tongyi.com': function () {
return [...document.querySelectorAll('[class*="questionItem"]')];
},
'yuanbao.tencent.com': function () {
return [...document.querySelectorAll('[class*="agent-chat__bubble--human"] .agent-chat__bubble__content')];
},
'chat.minimaxi.com': function () {
return [...document.querySelectorAll("#chat-card-list > div")].filter((_, idx) => idx % 2 == 0);
},
'claude.ai': function () {
return [...document.querySelectorAll('[data-testid="user-message"]')];
},
'github.com': function () {
return [...document.querySelectorAll('[class*="UserMessage"][class*="ChatMessage"]')];
},
'chat.deepseek.com': function () {
return [...getElementsByXPath(`//*[@id="root"]/div/div/div[2]/div[3]/div/div[2]/div/div/div[1]/div`)]
.filter((_, idx) => idx % 2 == 1);
},
'doubao.com': function () {
return [...getElementsByXPath(`//*[@id="root"]/div[1]/div/div[3]/div/main/div/div/div[2]/div/div[1]/div/div/div[2]/div`)]
.filter((_, idx) => idx % 2 == 0);
},
'default': function () {
return [];
}
};
// Create CSS styles with @media queries for theme support
function createThemeStyles() {
return `
/* Light theme (default) */
:root {
--toc-bg: #ffffff;
--toc-header-bg: #f6f8fa;
--toc-border: #d0d7de;
--toc-text: #24292f;
--toc-text-secondary: #24292f;
--toc-text-muted: #656d76;
--toc-hover-bg: #f6f8fa;
--toc-highlight-bg: #0969da20;
--toc-highlight-border: #0969da;
--toc-scrollbar-track: #f6f8fa;
--toc-scrollbar-thumb: #d0d7de;
--toc-scrollbar-thumb-hover: #afb8c1;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--toc-bg: #0d1117;
--toc-header-bg: #161b22;
--toc-border: #30363d;
--toc-text: #f0f6fc;
--toc-text-secondary: #e6edf3;
--toc-text-muted: #7d8590;
--toc-hover-bg: #21262d;
--toc-highlight-bg: #1f6feb20;
--toc-highlight-border: #1f6feb;
--toc-scrollbar-track: #161b22;
--toc-scrollbar-thumb: #30363d;
--toc-scrollbar-thumb-hover: #484f58;
}
}
`;
}
function toTrustedHtml(htmlString) {
let trustedHtmlString = htmlString;
if (window.trustedTypes && window.trustedTypes.createPolicy) {
// Create a policy that returns the input string as a TrustedHTML object.
// The policy name ('bypass-toc') must be unique within the page,
// but it doesn't have to be listed in the CSP for this to work in a Userscript.
const policy = trustedTypes.createPolicy('bypass-toc', {
createHTML: (string) => string
});
// Use the policy to convert your string into a TrustedHTML object
trustedHtmlString = policy.createHTML(htmlString);
}
return trustedHtmlString;
}
// Save position to localStorage
function savePosition(x, y) {
localStorage.setItem('chat-toc-position', JSON.stringify({ x, y }));
}
// Load position from localStorage
function loadPosition() {
const saved = localStorage.getItem('chat-toc-position');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
return { x: window.innerWidth - 320, y: 80 };
}
}
return { x: window.innerWidth - 320, y: 80 };
}
// Set up drag functionality
function setupDragFunctionality() {
const header = document.getElementById('toc-header');
if (!header || !tocContainer) return;
header.style.cursor = 'move';
header.style.userSelect = 'none';
function startDrag(e) {
// Don't start drag if clicking on the toggle button
if (e.target.id === 'toc-toggle') return;
isDragging = true;
const rect = tocContainer.getBoundingClientRect();
dragOffset.x = e.clientX - rect.left;
dragOffset.y = e.clientY - rect.top;
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
}
function drag(e) {
if (!isDragging) return;
let newX = e.clientX - dragOffset.x;
let newY = e.clientY - dragOffset.y;
// Keep within viewport bounds
const containerRect = tocContainer.getBoundingClientRect();
const maxX = window.innerWidth - containerRect.width;
const maxY = window.innerHeight - containerRect.height;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
tocContainer.style.left = newX + 'px';
tocContainer.style.top = newY + 'px';
tocContainer.style.right = 'auto'; // Remove right positioning
}
function stopDrag() {
if (isDragging) {
isDragging = false;
const rect = tocContainer.getBoundingClientRect();
savePosition(rect.left, rect.top);
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
}
}
header.addEventListener('mousedown', startDrag);
}
// Create the TOC container
function createTOC() {
if (tocContainer) {
tocContainer.remove();
}
const position = loadPosition();
tocContainer = document.createElement('div');
tocContainer.id = 'tm-chat-toc';
const tocHtmlContent = `
`;
tocContainer.innerHTML = toTrustedHtml(tocHtmlContent);
// Add styles using CSS custom properties
const styles = createThemeStyles() + `
#tm-chat-toc {
position: fixed;
left: ${position.x}px;
top: ${position.y}px;
width: 300px;
max-height: 60vh;
background: var(--toc-bg);
border: 1px solid var(--toc-border);
border-radius: 8px;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
#tm-chat-toc.chat-toc-collapse {
width: 120px;
}
#toc-header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
background: var(--toc-header-bg);
border-bottom: 1px solid var(--toc-border);
border-radius: 8px 8px 0 0;
cursor: move;
user-select: none;
}
#toc-header:active {
cursor: grabbing;
}
#toc-header h3 {
margin: 0;
color: var(--toc-text);
font-size: 14px;
font-weight: 600;
pointer-events: none;
}
#toc-toggle {
background: none;
border: none;
color: var(--toc-text-muted);
cursor: pointer;
font-size: 16px;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
pointer-events: auto;
}
#toc-toggle:hover {
color: var(--toc-text);
}
#chat-toc-content {
max-height: calc(60vh - 50px);
overflow-y: auto;
padding: 10px 0;
box-sizing: border-box;
}
#chat-toc-content.hidden {
display: none;
}
#toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-item {
display: flex;
text-align: left;
padding: 8px 15px;
cursor: pointer;
border-bottom: 1px solid var(--toc-border);
color: var(--toc-text-secondary);
font-size: 12px;
line-height: 1.4;
transition: background-color 0.15s ease;
}
.toc-item:hover {
background: var(--toc-hover-bg);
}
.toc-item.current {
background: var(--toc-highlight-bg);
border-left: 3px solid var(--toc-highlight-border);
padding-left: 12px;
font-weight: 600;
}
.toc-item:last-child {
border-bottom: none;
}
.toc-item-number {
color: var(--toc-text-muted);
font-weight: 600;
margin-right: 8px;
white-space: nowrap;
}
.toc-item-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Scrollbar styling */
#chat-toc-content::-webkit-scrollbar {
width: 6px;
}
#chat-toc-content::-webkit-scrollbar-track {
background: var(--toc-scrollbar-track);
}
#chat-toc-content::-webkit-scrollbar-thumb {
background: var(--toc-scrollbar-thumb);
border-radius: 3px;
}
#chat-toc-content::-webkit-scrollbar-thumb:hover {
background: var(--toc-scrollbar-thumb-hover);
}
/* Drag indicator */
#toc-header::before {
content: "⋮⋮";
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
color: var(--toc-text-muted);
font-size: 10px;
line-height: 1;
letter-spacing: -1px;
opacity: 0.5;
}
`;
// Remove existing styles and add new ones
const existingStyles = document.getElementById('chat-toc-styles');
if (existingStyles) {
existingStyles.remove();
}
const styleElement = document.createElement('style');
styleElement.id = 'chat-toc-styles';
styleElement.textContent = styles;
document.head.appendChild(styleElement);
document.body.appendChild(tocContainer);
// Set up drag functionality
setupDragFunctionality();
// Add toggle functionality
const toggleButton = document.getElementById('toc-toggle');
const tocContent = document.getElementById('chat-toc-content');
toggleButton.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent triggering drag
isVisible = !isVisible;
if (isVisible) {
tocContent.classList.remove('hidden');
toggleButton.textContent = '−';
} else {
tocContent.classList.add('hidden');
toggleButton.textContent = '+';
}
const toc = document.getElementById('tm-chat-toc');
if (!toc || !tocContainer) return;
// Convert to a number (removing "px")
let leftStr = window.getComputedStyle(toc).left;
let leftValue = parseInt(leftStr, 10);
if (isVisible) {
leftValue -= 180;
toc.style.left = leftValue + "px";
toc.classList.remove('chat-toc-collapse');
} else {
leftValue += 180;
toc.style.left = leftValue + "px";
toc.classList.add('chat-toc-collapse');
}
});
// Handle window resize to keep TOC in bounds
window.addEventListener('resize', () => {
const rect = tocContainer.getBoundingClientRect();
const maxX = window.innerWidth - rect.width;
const maxY = window.innerHeight - rect.height;
let newX = Math.max(0, Math.min(rect.left, maxX));
let newY = Math.max(0, Math.min(rect.top, maxY));
tocContainer.style.left = newX + 'px';
tocContainer.style.top = newY + 'px';
savePosition(newX, newY);
});
}
// Extract text from a message element
function extractMessageText(messageElement) {
// Try to find the text content, avoiding code blocks and other elements
const textElements = messageElement.querySelectorAll('p, div:not([class*="code"]):not([class*="Code"])');
let text = '';
for (const element of textElements) {
const elementText = element.textContent.trim();
if (elementText && !element.closest('[class*="code"], [class*="Code"], pre')) {
text += elementText + ' ';
}
}
// Fallback to full text content if no specific elements found
if (!text.trim()) {
text = messageElement.textContent.trim();
}
return text.trim();
}
// Find the current question based on viewport position
function findCurrentQuestion() {
// Use the message IDs we created instead of extractor strategies
const userMessages = [];
let index = 0;
while (true) {
const messageId = `user-message-${index}`;
const messageElement = document.getElementById(messageId);
if (!messageElement) break;
userMessages.push(messageElement);
index++;
}
if (userMessages.length === 0) return -1;
const viewportHeight = window.innerHeight;
const threshold = viewportHeight * 0.3;
// Find the last message that appears above or at 30% of viewport
for (let i = userMessages.length - 1; i >= 0; i--) {
const messageRect = userMessages[i].getBoundingClientRect();
if (messageRect.top <= threshold) {
return i;
}
}
// If no message is at or above 30%, return the first message
return 0;
} // Update the current question highlight in TOC
function updateCurrentQuestion() {
// Prevent updates while TOC is being rebuilt
if (tocIsUpdating) {
return;
}
const newCurrentIndex = findCurrentQuestion();
const tocItems = document.querySelectorAll('.toc-item');
// Ensure the new index is valid for the current TOC
const validIndex = (newCurrentIndex >= 0 && newCurrentIndex < tocItems.length) ? newCurrentIndex : -1;
if (validIndex !== currentQuestionIndex) {
currentQuestionIndex = validIndex;
// Update TOC highlighting
tocItems.forEach((item, index) => {
if (index === currentQuestionIndex) {
item.classList.add('current');
} else {
item.classList.remove('current');
}
});
// Auto-scroll TOC to show current item
if (currentQuestionIndex >= 0 && currentQuestionIndex < tocItems.length) {
const currentItem = tocItems[currentQuestionIndex];
const tocContent = document.getElementById('chat-toc-content');
if (currentItem && tocContent) {
const itemRect = currentItem.getBoundingClientRect();
const contentRect = tocContent.getBoundingClientRect();
// Check if item is outside visible area
const itemTop = currentItem.offsetTop;
const contentScrollTop = tocContent.scrollTop;
const contentHeight = tocContent.clientHeight;
if (itemTop < contentScrollTop || itemTop > contentScrollTop + contentHeight - currentItem.offsetHeight) {
// Scroll to center the current item
tocContent.scrollTop = itemTop - contentHeight / 2 + currentItem.offsetHeight / 2;
}
}
}
}
}
// Check if TOC needs to be updated
function needsUpdate(userMessages) {
if (userMessages.length !== lastMessageCount) {
return true;
}
// Check if any message text has changed
for (let i = 0; i < userMessages.length; i++) {
const currentText = userMessages[i].innerText.trim();
if (currentText !== lastMessageTexts[i]) {
return true;
}
}
return false;
}
// Update the TOC with current messages
function updateTOC() {
// Determine which strategy to use
const host = window.location.hostname.replace(/^www\./, '');
const extractor = strategies[host] || strategies['default'];
const userMessages = extractor();
const tocList = document.getElementById('toc-list');
if (!tocList) return;
// Check if update is actually needed
if (!needsUpdate(userMessages)) {
return;
}
console.log('Updating TOC - message count changed from', lastMessageCount, 'to', userMessages.length);
// Set flag to prevent updateCurrentQuestion from running during TOC rebuild
tocIsUpdating = true;
// Reset current question index when TOC content changes
currentQuestionIndex = -1;
// Update tracking variables
lastMessageCount = userMessages.length;
lastMessageTexts = [];
tocList.innerHTML = toTrustedHtml('');
userMessages.forEach((message, index) => {
// Add an ID to the message for jumping
const messageId = `user-message-${index}`;
message.id = messageId;
// Extract message text
const messageText = message.innerText.trim();
lastMessageTexts.push(messageText);
if (!messageText) return;
// Create TOC item
const listItem = document.createElement('li');
listItem.className = 'toc-item';
listItem.innerHTML = toTrustedHtml(`${index + 1}.`);
const textSpan = document.createElement('span');
textSpan.className = 'toc-item-text';
textSpan.title = messageText;
textSpan.textContent = messageText; // This treats HTML as literal text
listItem.appendChild(textSpan);
// Add click handler to jump to message
listItem.addEventListener('click', () => {
const targetMessage = document.getElementById(messageId);
if (targetMessage) {
targetMessage.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
// Highlight the message briefly
targetMessage.style.backgroundColor = 'var(--toc-highlight-bg)';
setTimeout(() => {
targetMessage.style.backgroundColor = '';
}, 2000);
}
});
tocList.appendChild(listItem);
});
// Use setTimeout to ensure DOM rendering is complete before clearing flag and updating highlights
setTimeout(() => {
tocIsUpdating = false;
updateCurrentQuestion();
}, 0);
}
// Initialize the TOC
function initTOC() {
createTOC();
updateTOC();
}
// Watch for theme changes (simplified since CSS handles theme detection)
function setupThemeWatcher() {
// Only listen for system theme changes to potentially trigger layout updates
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
// Theme change is handled automatically by CSS
// No need to recreate the TOC, just trigger a small update if needed
console.log('System theme changed');
});
}
}
// Wait for the page to load and then initialize
function waitForMessages() {
console.log("Chat TOC script")
const checkForMessages = () => {
// Determine which strategy to use
const host = window.location.hostname.replace(/^www\./, '');
const extractor = strategies[host] || strategies['default'];
const userMessages = extractor();
if (userMessages.length > 0) {
initTOC();
setupThemeWatcher();
// Set up interval for current question tracking
if (currentQuestionUpdateInterval) {
clearInterval(currentQuestionUpdateInterval);
}
currentQuestionUpdateInterval = setInterval(() => {
updateCurrentQuestion();
}, 500); // Update every 500 milliseconds
// Set up a mutation observer to update TOC when new messages are added
const observer = new MutationObserver(() => {
// Debounce the update to avoid excessive calls
clearTimeout(observer.timeout);
observer.timeout = setTimeout(() => {
updateTOC();
}, 1000); // Increased debounce time
});
// Observe the chat container for changes
const chatContainer = document.body;
if (chatContainer) {
observer.observe(chatContainer, {
childList: true,
subtree: true
});
}
} else {
// Keep checking every 2 seconds if no messages found yet
setTimeout(checkForMessages, 2000);
}
};
checkForMessages();
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', waitForMessages);
} else {
waitForMessages();
}
})();