// ==UserScript==
// @name ChatGPT 大纲生成器
// @namespace http://tampermonkey.net/
// @version 1.1
// @description 为ChatGPT对话生成右侧大纲视图,提取问题前10个字作为标题
// @author Y.V
// @license AGPL-3.0-or-later
// @match https://chatgpt.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com
// @grant none
// @require https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==
(function () {
'use strict';
/**
* ChatGPT大纲生成器类
*/
class ChatGPTOutlineGenerator {
constructor() {
this.outlineContainer = null;
this.toggleButton = null;
this.styleElement = null;
this.cssStyles = `
.outline-container {
position: fixed;
top: 70px;
right: 20px;
width: 280px;
max-height: calc(100vh - 100px);
background-color: rgba(247, 247, 248, 0.85);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
z-index: 1000;
overflow-y: auto;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
font-family: 'Söhne', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, 'Noto Sans', sans-serif;
opacity: 0.95;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.outline-container:hover {
opacity: 1;
background-color: rgba(247, 247, 248, 0.98);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.dark .outline-container {
background-color: rgba(52, 53, 65, 0.85);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
}
.dark .outline-container:hover {
background-color: rgba(52, 53, 65, 0.98);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
.outline-header {
padding: 16px;
font-weight: 600;
font-size: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: inherit;
border-radius: 12px 12px 0 0;
z-index: 2;
}
.dark .outline-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: #ececf1;
}
.outline-title {
display: flex;
align-items: center;
gap: 8px;
}
.outline-title-icon {
color: #10a37f;
}
.outline-items {
padding: 8px 0;
}
.outline-item {
padding: 10px 16px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
border-left: 3px solid transparent;
display: flex;
align-items: center;
margin: 2px 0;
border-radius: 0 4px 4px 0;
}
.outline-item:hover {
background-color: rgba(0, 0, 0, 0.05);
transform: translateX(2px);
}
.dark .outline-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.outline-item.active {
border-left-color: #10a37f;
background-color: rgba(16, 163, 127, 0.1);
font-weight: 500;
}
.outline-item-icon {
margin-right: 10px;
color: #10a37f;
transition: transform 0.2s ease;
}
.outline-item:hover .outline-item-icon {
transform: scale(1.1);
}
.outline-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
line-height: 1.4;
}
.dark .outline-item-text {
color: #ececf1;
}
.outline-toggle {
position: fixed;
top: 70px;
right: 20px;
width: 42px;
height: 42px;
border-radius: 50%;
background-color: rgba(16, 163, 127, 0.9);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 1001;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.outline-toggle:hover {
transform: scale(1.08);
background-color: #10a37f;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.outline-toggle svg {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
}
.outline-toggle:hover svg {
transform: rotate(90deg);
}
.outline-close {
cursor: pointer;
opacity: 0.7;
transition: all 0.2s ease;
padding: 4px;
border-radius: 4px;
}
.outline-close:hover {
opacity: 1;
background-color: rgba(0, 0, 0, 0.05);
}
.dark .outline-close:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.outline-empty {
padding: 20px 16px;
text-align: center;
color: #888;
font-style: italic;
font-size: 14px;
}
.dark .outline-empty {
color: #aaa;
}
@media (max-width: 1400px) {
.outline-container {
width: 250px;
}
}
@media (max-width: 1200px) {
.outline-container {
width: 220px;
}
}
@media (max-width: 768px) {
.outline-container {
display: none;
}
}`;
}
/**
* 初始化大纲生成器
*/
init() {
// 等待页面加载完成
if (!document.querySelector('main')) {
setTimeout(() => this.init(), 1000);
return;
}
this.addStyles();
this.outlineContainer = this.createOutlineContainer();
this.toggleButton = this.createToggleButton();
// 初始化大纲
setTimeout(() => this.generateOutlineItems(), 1000);
// 设置初始暗黑模式状态
this.outlineContainer.classList.toggle('dark', this.detectDarkMode());
// 监听暗黑模式变化
this.observeDarkModeChanges();
// 监听新消息
this.observeNewMessages();
// 监听滚动以高亮当前可见的消息
this.observeScroll();
}
/**
* 添加样式到页面
*/
addStyles() {
this.styleElement = document.createElement('style');
this.styleElement.textContent = this.cssStyles;
document.head.appendChild(this.styleElement);
}
/**
* 创建大纲容器
* @returns {HTMLElement} 大纲容器元素
*/
createOutlineContainer() {
const container = document.createElement('div');
container.className = 'outline-container';
container.innerHTML = `
<div class="outline-header">
<div class="outline-title">
<span class="outline-title-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span>对话大纲</span>
</div>
<span class="outline-close">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<div class="outline-items"></div>`;
document.body.appendChild(container);
// 添加关闭事件
container.querySelector('.outline-close').addEventListener('click', () => {
container.style.display = 'none';
this.toggleButton.style.display = 'flex';
});
return container;
}
/**
* 创建切换按钮
* @returns {HTMLElement} 切换按钮元素
*/
createToggleButton() {
const button = document.createElement('div');
button.className = 'outline-toggle';
button.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4 12H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M4 18H20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>`;
document.body.appendChild(button);
// 添加点击事件
button.addEventListener('click', () => {
this.outlineContainer.style.display = 'block';
button.style.display = 'none';
// 重新生成大纲,确保最新状态
this.generateOutlineItems();
});
return button;
}
/**
* 提取问题文本的前16个字符
* @param {string} text 问题文本
* @returns {string} 提取后的标题
*/
extractQuestionTitle(text) {
// 去除空白字符
const trimmed = text.trim();
// 如果文本为空,返回默认文本
if (!trimmed) return "空白问题";
// 提取前16个字符,如果不足16个则全部返回
return trimmed.length > 16 ? trimmed.substring(0, 16) + '...' : trimmed;
}
/**
* 生成大纲项
*/
generateOutlineItems() {
const outlineItems = this.outlineContainer.querySelector('.outline-items');
outlineItems.innerHTML = '';
// 获取所有用户消息
const userMessages = document.querySelectorAll('[data-message-author-role="user"]');
if (userMessages.length === 0) {
outlineItems.innerHTML = '<div class="outline-empty">暂无对话内容</div>';
return;
}
userMessages.forEach((message, index) => {
const messageText = message.querySelector('.whitespace-pre-wrap')?.textContent || '';
const title = this.extractQuestionTitle(messageText);
const item = document.createElement('div');
item.className = 'outline-item';
item.dataset.index = index;
item.dataset.messageId = message.id || `message-${index}`;
item.innerHTML = `
<span class="outline-item-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" stroke="currentColor" fill="currentColor"/>
</svg>
</span>
<span class="outline-item-text">${index + 1}. ${title}</span>`;
// 添加点击事件
item.addEventListener('click', () => this.handleItemClick(item, message));
outlineItems.appendChild(item);
});
// 检查是否有可见的消息,并高亮对应的大纲项
this.highlightVisibleItem();
}
/**
* 处理大纲项点击事件
* @param {HTMLElement} item 点击的大纲项
* @param {HTMLElement} message 对应的消息元素
*/
handleItemClick(item, message) {
// 滚动到对应的消息
message.scrollIntoView({ behavior: 'smooth', block: 'center' });
// 高亮当前项
this.highlightItem(item);
// 添加临时高亮效果
message.style.transition = 'background-color 0.5s';
message.style.backgroundColor = 'rgba(16, 163, 127, 0.1)';
setTimeout(() => {
message.style.backgroundColor = '';
}, 1500);
}
/**
* 高亮指定的大纲项
* @param {HTMLElement} item 要高亮的大纲项
*/
highlightItem(item) {
document.querySelectorAll('.outline-item').forEach(el => {
el.classList.remove('active');
});
item.classList.add('active');
}
/**
* 监听页面滚动,高亮当前可见的消息对应的大纲项
*/
observeScroll() {
let scrollTimer = null;
window.addEventListener('scroll', () => {
// 使用防抖技术减少滚动事件处理频率
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
this.highlightVisibleItem();
}, 100);
});
}
/**
* 高亮当前可见的消息对应的大纲项
*/
highlightVisibleItem() {
const userMessages = document.querySelectorAll('[data-message-author-role="user"]');
if (!userMessages.length) return;
// 找到当前视口中最靠近顶部的消息
let closestMessage = null;
let closestDistance = Infinity;
const viewportHeight = window.innerHeight;
const viewportMiddle = viewportHeight / 2;
userMessages.forEach(message => {
const rect = message.getBoundingClientRect();
// 计算消息中心点到视口中心的距离
const distance = Math.abs((rect.top + rect.bottom) / 2 - viewportMiddle);
// 如果消息在视口内且距离更近
if (rect.top < viewportHeight && rect.bottom > 0 && distance < closestDistance) {
closestDistance = distance;
closestMessage = message;
}
});
if (closestMessage) {
// 找到对应的大纲项并高亮
const index = Array.from(userMessages).indexOf(closestMessage);
const outlineItem = this.outlineContainer.querySelector(`.outline-item[data-index="${index}"]`);
if (outlineItem) {
this.highlightItem(outlineItem);
}
}
}
/**
* 检测暗黑模式
* @returns {boolean} 是否为暗黑模式
*/
detectDarkMode() {
return document.documentElement.classList.contains('dark');
}
/**
* 监听暗黑模式变化
*/
observeDarkModeChanges() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'class') {
const isDarkMode = this.detectDarkMode();
this.outlineContainer.classList.toggle('dark', isDarkMode);
}
});
});
observer.observe(document.documentElement, { attributes: true });
}
/**
* 监听新消息
*/
observeNewMessages() {
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.querySelector('[data-message-author-role="user"]') ||
node.hasAttribute && node.hasAttribute('data-message-author-role'))) {
shouldUpdate = true;
break;
}
}
}
});
if (shouldUpdate) {
setTimeout(() => this.generateOutlineItems(), 500); // 延迟执行,确保DOM已更新
}
});
// 监听整个聊天容器
const chatContainer = document.querySelector('main');
if (chatContainer) {
observer.observe(chatContainer, { childList: true, subtree: true });
}
}
}
/**
* 插件管理器类 - 用于管理多个插件
*/
class PluginManager {
constructor() {
this.plugins = [];
}
/**
* 注册插件
* @param {Object} plugin 插件实例
*/
register(plugin) {
this.plugins.push(plugin);
return this;
}
/**
* 初始化所有插件
*/
initAll() {
this.plugins.forEach(plugin => {
if (typeof plugin.init === 'function') {
setTimeout(() => plugin.init(), 1500); // 延迟启动,确保页面已加载
}
});
}
}
/**
* 创建并启动插件管理器
*/
$(document).ready(() => {
// 创建插件管理器
const pluginManager = new PluginManager();
// 注册大纲生成器插件
pluginManager.register(new ChatGPTOutlineGenerator());
// 初始化所有插件
pluginManager.initAll();
});
})();