Генерирует оглавление (TOC) для вопросов пользователя на страницах ИИ-чатов для удобного предпросмотра и перехода по клику.
// ==UserScript==
// @name AIChat-TOC
// @name:en AIChat-TOC
// @name:ja AIChat-TOC
// @name:ko AIChat-TOC
// @name:ru AIChat-TOC
// @name:de AIChat-TOC
// @version 1.1
// @description 为AI对话页内的用户对话生成TOC, 便于预览和点击跳转.
// @description:en Generates a TOC for user prompts in AI chat pages for easy previewing and clicking to jump.
// @description:ja AIチャットページ内のユーザーの質問に対してTOCを生成し、プレビューやクリックによるジャンプを容易にします。
// @description:ko AI 채팅 페이지의 사용자 프롬프트에 대한 TOC를 생성하여 미리보기 및 클릭 이동을 용이하게 합니다.
// @description:ru Генерирует оглавление (TOC) для вопросов пользователя на страницах ИИ-чатов для удобного предпросмотра и перехода по клику.
// @description:de Erstellt ein Inhaltsverzeichnis (TOC) für Benutzeranfragen in KI-Chat-Seiten zur einfachen Vorschau und zum Springen per Klick.
// @author ExcuseLme
// @namespace https://github.com/ExcuseLme
// @match *://chatgpt.com/*
// @match *://gemini.google.com/*
// @match *://claude.ai/*
// @match *://*.qianwen.com/*
// @match *://*.doubao.com/*
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CiAgPHJlY3QgeD0iMyIgeT0iNSIgd2lkdGg9IjE4IiBoZWlnaHQ9IjIiIHJ4PSIxIiBmaWxsPSIjNEE5MEUyIi8+CiAgPHJlY3QgeD0iMyIgeT0iMTAiIHdpZHRoPSIxMCIgaGVpZ2h0PSIyIiByeD0iMSIgZmlsbD0iIzRBOTBFMiIvPgogIDxyZWN0IHg9IjMiIHk9IjE1IiB3aWR0aD0iMTgiIGhlaWdodD0iMiIgcng9IjEiIGZpbGw9IiM0QTkwRTIiLz4KICA8ZWxsaXBzZSBjeD0iMTciIGN5PSIxNiIgcng9IjUiIHJ5PSI0IiBmaWxsPSIjNTBFM0MyIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iMSIvPgogIDxwb2x5Z29uIHBvaW50cz0iMTQuNSwxOSAxNCwyMiAxNy41LDE5LjgiIGZpbGw9IiM1MEUzQzIiIC8+Cjwvc3ZnPg==
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict'
/**
* [Layer 1] 通用 UI 配置层
* 存储所有 AI 平台共用的物理参数与样式常量
*/
const GLOBAL_CONFIG = {
ENABLE_LOG : true, // 是否开启日志
DEFAULT_COLLAPSE: false, // 是否默认折叠
WIDTH : '300px', // TOC宽度
MAX_TITLE_LENGTH: 100, // TOC条目预备字符串长度
REFRESH_DELAY : 300, // 节流延迟
LOG_PREFIX : '[AI-webApp-nav-index]', // 统一日志前缀
MIN_MEASURE_MS : 100 // 测量函数中触发测量结果输出的最低执行用时
}
/**
* [Layer 1.5] I18N 国际化配置
*/
const I18N_STRINGS = {
zh: {
untitled: '未命名对话',
query : '提问',
waiting : '等待首个提问...',
pin : '固定位置',
unpin : '解除固定',
jump : '跳转到回答处'
},
en: {
untitled: 'Untitled Chat',
query : 'Query',
waiting : 'Waiting for first query...',
pin : 'Pin Position',
unpin : 'Unpin Position',
jump : 'Jump to Answer'
},
ja: {
untitled: '無題の対話',
query : '質問',
waiting : '最初の質問を待っています...',
pin : '位置を固定',
unpin : '固定を解除',
jump : '回答へ移動'
},
ko: {
untitled: '제목 없는 대화',
query : '질문',
waiting : '첫 번째 질문 대기 중...',
pin : '위치 고정',
unpin : '고정 해제',
jump : '답변으로 이동'
},
ru: {
untitled: 'Безымянный чат',
query : 'Вопрос',
waiting : 'Ожидание первого вопроса...',
pin : 'Закрепить',
unpin : 'Открепить',
jump : 'Перейти к ответу'
},
de: {
untitled: 'Unbenannter Chat',
query : 'Anfrage',
waiting : 'Warten auf die erste Anfrage...',
pin : 'Position fixieren',
unpin : 'Fixierung lösen',
jump : 'Zur Antwort springen'
}
}
const LANG = (Object.keys(I18N_STRINGS).find(k => navigator.language.startsWith(k)) || 'en')
const T = I18N_STRINGS[LANG]
/**
* 获取并过滤当前脚本的调用栈
* @returns {[string]} 过滤后的栈帧数组
*/
function getFilteredStackTrace () {
// 1. 创建 Error 对象以获取当前堆栈快照
const stack = new Error().stack
if (!stack) {
return []
}
// 2. 将堆栈字符串按行拆分
// 第一行通常是 "Error",从第二行开始是具体的栈帧
const lines = stack.split('\n').slice(1)
// 根据脚本名称过滤得到来自自身脚本文件输出的日志
return lines.filter(line => line.includes('local-AI-WebApp'))
.map(line => line.replaceAll(
/(\s\(.*?(?=:\d{1,4}:\d{1,4}))|((?<=:\d{1,4}:\d{1,4})\)(?=,)?)/g,
'')) // 移除冗长的脚本路径
.map(line => line.trim())
}
if (!GLOBAL_CONFIG.ENABLE_LOG) {
const ignore = () => {}
unsafeWindow.logger = {
debug: ignore,
info : ignore,
log : ignore,
warn : ignore,
error: ignore
}
} else {
const proxyLogFun = (logFun) => {
return (msg) => {
logFun(`${GLOBAL_CONFIG.LOG_PREFIX} ${msg}`)
}
}
unsafeWindow.logger = {
debug: proxyLogFun(unsafeWindow.console.debug),
info : proxyLogFun(unsafeWindow.console.info),
log : proxyLogFun(unsafeWindow.console.log),
warn : proxyLogFun(unsafeWindow.console.warn),
error: proxyLogFun(unsafeWindow.console.error)
}
}
/**
* 如果该函数运行缓慢则输出用时统计
* @param tag 附带在日志输出中的关键字
* @param fun 被测量代码块
*/
const measureSlowly = (tag, fun) => {
const start = performance.now()
try {
return fun()
} finally {
const durationMS = (performance.now() - start).toFixed(1)
if (GLOBAL_CONFIG.MIN_MEASURE_MS <= durationMS) {
unsafeWindow.logger.log(`[measure] [${tag}]: ${durationMS}ms`)
}
}
}
/**
* [Layer 2] 站点适配器层 (Adapter Interface/Base)
*
* 适配器基类,定义所有 AI 平台适配器必须遵循的契约
*/
class BaseAdapter {
constructor () {
this.name = 'Base'
this.selectors = {}
this.routePattern = /.*/
}
shouldActivate (url) {
return false
}
getObservationTarget () {
return null
}
getConversationTitle () {
return T.untitled
}
getUserQueryElements () {
// 为了返回`NodeListOf<Element>`类型是代码更规范, 查询不可能存在的元素
return document.querySelectorAll(':not(*)')
}
extractItemTitle (element, index) {
return `${T.query} ${index + 1}`
}
scrollToItem (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollToItemBottom (element) {
(element.nextElementSibling || element).scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
/**
* ChatGPT 平台的具体实现
*/
class ChatGPTAdapter extends BaseAdapter {
constructor () {
super()
this.name = 'ChatGPT'
this.selectors = {
// 消息列表最近父元素
CHAT_HISTORY_CONTAINER: '#thread > div > div.flex > div.flex',
// 用户消息块特征
USER_QUERY: 'article[data-turn="user"]',
// LLM消息块特诊
LLM_QUERY: 'article[data-turn="assistant"]',
// 消息内容容器
CONTENT_WRAPPER: 'div.whitespace-pre-wrap'
}
// 匹配 /c/ 后面接 UUID 格式的路由
this.routePattern = /\/c\/[a-z0-9-]{36}/
}
shouldActivate (url) {
return this.routePattern.test(url)
}
getObservationTarget () {
return document.querySelector(this.selectors.CHAT_HISTORY_CONTAINER)
}
getConversationTitle () {
const fallbackTitle = T.untitled
try {
// 1. 从当前 URL 提取 UUID (36位: 8-4-4-4-12 格式)
const urlMatch = unsafeWindow.location.href.match(/[a-z0-9-]{36}/)
if (!urlMatch) {
console.error('[Search] URL 中未提取到有效的 UUID')
return fallbackTitle
}
const targetId = urlMatch[0]
// 2. 寻找匹配前缀 'cache/' 和后缀 '/conversation-history' 的目标 Key
let targetKey = null
for (let i = 0; i < unsafeWindow.localStorage.length; i++) {
const key = unsafeWindow.localStorage.key(i)
if (key.startsWith('cache/') && key.endsWith('/conversation-history')) {
targetKey = key
break // 根据需求,确定只有一条,直接跳出
}
}
if (!targetKey) {
console.warn('[Search] 未找到符合特征的 localStorage Key')
return fallbackTitle
}
// 3. 读取并解析数据
const rawData = window.localStorage.getItem(targetKey)
if (!rawData) {
return fallbackTitle
}
const data = JSON.parse(rawData)
// 4. 链路安全访问 (Optional Chaining)
// 结构: data.value.pages -> Array -> items -> Array -> id
const pages = data?.value?.pages || []
for (const page of pages) {
const items = page?.items || []
// 在当前 page 的 items 中查找 id 匹配的对象
const foundItem = items.find(item => item.id === targetId)
if (foundItem && foundItem.title) {
return foundItem.title
}
}
console.warn('[Search] 已找到存储对象,但未发现匹配 ID 的 Item')
return fallbackTitle
} catch (error) {
console.error('[Search] 解析过程中发生异常:', error)
return fallbackTitle
}
}
getUserQueryElements () {
return document.querySelectorAll(this.selectors.USER_QUERY)
}
extractItemTitle (element, index) {
const contentNode = element.querySelector(this.selectors.CONTENT_WRAPPER)
if (!contentNode) {
return `${T.query} ${index + 1}`
}
// 克隆节点以进行无损处理
const clone = contentNode.cloneNode(true)
// 移除 pre 代码块,避免 TOC 中出现大量乱码代码
clone.querySelectorAll('pre').forEach(pre => pre.remove())
let rawText = clone.innerText.trim().replace(/\s+/g, ' ')
if (rawText.length > GLOBAL_CONFIG.MAX_TITLE_LENGTH) {
return rawText.substring(0, GLOBAL_CONFIG.MAX_TITLE_LENGTH) + '...'
}
return rawText || `${T.query} ${index + 1}`
}
}
/**
* Gemini 平台的具体实现
*/
class GeminiAdapter extends BaseAdapter {
constructor () {
super()
this.name = 'Gemini'
this.selectors = {
HOME_IDENTIFIER : 'modular-zero-state', // 主页特有的自定义标签
CHAT_HISTORY_CONTAINER: '#chat-history > infinite-scroller.chat-history', // 消息列表容器
USER_QUERY : 'user-query', // 用户提问块容器
LLM_QUERY : 'model-response', // LLM提问块容器
TEXT_LINE : 'p.query-text-line', // 提问块内的文本行
CONVERSATION_TITLE : 'span[data-test-id="conversation-title"]' // 对话大标题
}
this.routePattern = /\/app\/[a-z0-9]{10,}/ // 对话页 URL 特征
}
/**
* 根据 URL 和页面特定元素判断是否应当显示 TOC
*/
shouldActivate (url) {
const isChatRoute = this.routePattern.test(url)
const isHome = !!document.querySelector(this.selectors.HOME_IDENTIFIER)
return isChatRoute && !isHome
}
/**
* 返回 MutationObserver 应该监听的根节点
*/
getObservationTarget () {
return document.querySelector(this.selectors.CHAT_HISTORY_CONTAINER)
}
/**
* 提取当前对话的标题
*/
getConversationTitle () {
const title = document.querySelector(this.selectors.CONVERSATION_TITLE)?.innerText || T.untitled
return title.trim()
}
/**
* 获取所有用户发出的消息 DOM 节点
*/
getUserQueryElements () {
return document.querySelectorAll(this.selectors.USER_QUERY)
}
/**
* 核心抽象逻辑:从复杂包裹的 DOM 树中深度提取文本
* 应对 Gemini 将一段提问拆分为多个 p 标签且可能混杂代码块的情况
*/
extractItemTitle (element, index) {
// 获取该消息块下所有的文本行
const pElements = element.querySelectorAll(this.selectors.TEXT_LINE)
// 1. 提取所有行的 innerText
// 2. 过滤空格并使用空格连接(防止多段落粘连)
let rawText = Array.from(pElements)
.map(p => p.innerText.trim())
.join(' ')
// 3. 将内部重复的空白字符压缩为单个空格
rawText = rawText.replace(/\s+/g, ' ')
// 4. 长度截断
if (rawText.length > GLOBAL_CONFIG.MAX_TITLE_LENGTH) {
return rawText.substring(0, GLOBAL_CONFIG.MAX_TITLE_LENGTH) + '...'
}
return rawText || `${T.query} ${index + 1}`
}
}
class ClaudeAdapter extends BaseAdapter {
constructor () {
super()
this.name = 'Claude'
this.selectors = {
CHAT_HISTORY_CONTAINER: 'div:has(> div[data-test-render-count])',
USER_QUERY : 'div[data-testid="user-message"]',
LLM_QUERY : 'div.font-claude-response',
CONTENT_WRAPPER : 'p',
CONVERSATION_TITLE : 'button[data-testid="chat-title-button"] > div > div'
}
this.routePattern = /\/chat\/[a-z0-9-]{36}/
}
shouldActivate (url) {
return this.routePattern.test(url)
}
getObservationTarget () {
return document.querySelector(this.selectors.CHAT_HISTORY_CONTAINER)
}
getConversationTitle () {
return document.querySelector(this.selectors.CONVERSATION_TITLE)?.innerText?.trim() || T.untitled
}
getUserQueryElements () {
return document.querySelectorAll(this.selectors.USER_QUERY)
}
extractItemTitle (element, index) {
const contentNode = element.querySelector(this.selectors.CONTENT_WRAPPER)
if (!contentNode) {
return `${T.query} ${index + 1}`
}
let rawText = contentNode.textContent.trim().replace(/\s+/g, ' ')
if (rawText.length > GLOBAL_CONFIG.MAX_TITLE_LENGTH) {
return rawText.substring(0, GLOBAL_CONFIG.MAX_TITLE_LENGTH) + '...'
}
return rawText || `${T.query} ${index + 1}`
}
scrollToItem (element) {
element.closest('div[data-test-render-count]').scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollToItemBottom (element) {
(element.closest('div[data-test-render-count]').nextElementSibling)
.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// class GrokAdapter extends BaseAdapter {
// constructor () {
// super()
// this.name = 'Grok'
// }
// }
// class DeepSeekAdapter extends BaseAdapter {
// constructor () {
// super()
// this.name = 'DeepSeek'
// }
// }
class QianwenAdapter extends BaseAdapter {
constructor () {
super()
this.name = 'Tongyi'
this.selectors = {
CHAT_HISTORY_CONTAINER: '#qwen-message-list-area > div[class*="scrollWrapper"] > div:not([class])',
USER_QUERY : 'div[class*="questionItem"]',
LLM_QUERY : 'div[class*="answerItem"]',
// TEXT_LINE : '', // 无需进一步进入USER_QUERY的内部取原文, 直接`.textContent`即可
CONVERSATION_TITLE: 'div[class*="!bg-option"]'
}
this.routePattern = /\/chat\/[a-z0-9]{32}/
}
shouldActivate (url) {
return this.routePattern.test(url)
}
getObservationTarget () {
return document.querySelector(this.selectors.CHAT_HISTORY_CONTAINER)
}
getConversationTitle () {
return document.querySelector(this.selectors.CONVERSATION_TITLE)?.textContent?.trim() || T.untitled
}
getUserQueryElements () {
return document.querySelectorAll(this.selectors.USER_QUERY)
}
extractItemTitle (element, index) {
if (!element) {
return `${T.query} ${index + 1}`
}
let rawText = element.textContent.trim().replace(/\s+/g, ' ')
if (rawText.length > GLOBAL_CONFIG.MAX_TITLE_LENGTH) {
return rawText.substring(0, GLOBAL_CONFIG.MAX_TITLE_LENGTH) + '...'
}
return rawText || `${T.query} ${index + 1}`
}
}
class DoubaoAdapter extends BaseAdapter {
constructor () {
super()
this.name = 'Doubao'
this.selectors = {
CHAT_HISTORY_CONTAINER: 'div:has(> div > div > div > div[data-testid="union_message"])',
USER_QUERY : 'div[data-testid="send_message"]',
LLM_QUERY : 'div[data-testid="receive_message"]',
TEXT_LINE : 'div > div[data-testid="message_content"]',
CONVERSATION_TITLE : 'div[class*="group/title"] > div.truncate'
}
this.routePattern = /\/chat\/\d{16,}/
}
shouldActivate (url) {
return this.routePattern.test(url)
}
getObservationTarget () {
return document.querySelector(this.selectors.CHAT_HISTORY_CONTAINER)
}
getConversationTitle () {
return document.querySelector(this.selectors.CONVERSATION_TITLE)?.textContent?.trim() || T.untitled
}
getUserQueryElements () {
return document.querySelectorAll(this.selectors.USER_QUERY)
}
extractItemTitle (element, index) {
const textLine = element.querySelector(this.selectors.TEXT_LINE)
if (!textLine) {
return `${T.query} ${index + 1}`
}
let rawText = textLine.textContent.trim().replace(/\s+/g, ' ')
if (rawText.length > GLOBAL_CONFIG.MAX_TITLE_LENGTH) {
return rawText.substring(0, GLOBAL_CONFIG.MAX_TITLE_LENGTH) + '...'
}
return rawText || `${T.query} ${index + 1}`
}
scrollToItem (element) {
(element.parentElement.parentElement.parentElement.parentElement.parentElement)
.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
scrollToItemBottom (element) {
(element.parentElement.parentElement.parentElement.parentElement.parentElement)
.nextElementSibling
.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
/**
* [Layer 2.5] 适配器工厂
*/
class AdapterFactory {
static getAdapter () {
const domainMap = {
'chatgpt.com' : ChatGPTAdapter,
'gemini.google.com': GeminiAdapter,
'claude.ai' : ClaudeAdapter,
// 'grok.com' : GrokAdapter,
// 'chat.deepseek.com': DeepSeekAdapter,
'qianwen.com': QianwenAdapter,
'doubao.com' : DoubaoAdapter
}
for (const [domain, AdapterClass] of Object.entries(domainMap)) {
if (location.hostname.includes(domain)) {
return new AdapterClass()
}
}
return null
}
}
/**
* [Layer 3] 核心框架层 (UniversalTOCManager)
* 负责 UI 渲染、状态持久化、事件循环,不关心具体的 DOM 结构
*/
class UniversalTOCManager {
constructor (adapter) {
if (!adapter) {
throw new Error('必须提供 Adapter 实例')
}
this.adapter = adapter
this.container = null
this.contentArea = null
this.observer = null
this.lastUrl = unsafeWindow.location.href
// 用于记录上次刷新时的内容摘要,防止无效重绘
this.lastContentHash = ''
// 折叠状态:只响应配置, 仅在当前会话中记忆, 不持久化
this.isCollapsed = GLOBAL_CONFIG.DEFAULT_COLLAPSE
// 持久化的pin状态
this.isPinned = GM_getValue(`${this.adapter.name}_toc_pinned`, false)
// 持久化的坐标信息
this.pos = GM_getValue(`${this.adapter.name}_toc_pos`, {
top : 115,
left: 15
})
this.dragState = { isDragging: false, startX: 0, startY: 0, initialX: 0, initialY: 0, hasMoved: false }
this.init()
}
init () {
measureSlowly('injectStyles', () => this.injectStyles())
measureSlowly('createContainer', () => this.createContainer())
measureSlowly('setupRouteListener', () => this.setupRouteListener())
measureSlowly('init.evaluateState', () => this.evaluateState('Initialization'))
}
/**
* 样式注入(保持原样,不改动任何视觉定义)
*/
injectStyles () {
const style = document.createElement('style')
// noinspection CssInvalidPropertyValue,CssUnusedSymbol
style.textContent = `
#AI-webApp-nav-index {
position: fixed;
left: ${this.pos.left}px;
top: ${this.pos.top}px;
user-select: none;
width: ${GLOBAL_CONFIG.WIDTH};
max-height: 455px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid #dadce0;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
z-index: 10000;
display: none;
overflow: hidden;
flex-direction: column;
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top left;
}
.toggle-btn { color: #5f6368; }
@media (prefers-color-scheme: dark) {
#AI-webApp-nav-index {
background: rgba(30, 31, 32, 0.95);
border-color: #444746;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.toggle-btn { background: #2D2E30 !important; color: #C4C7C5 !important; }
.toggle-btn:hover { background: #3F4042 !important; }
.pin-btn { color: #8E918F !important; }
.pin-btn.active { color: #8AB4F8 !important; }
.icon-wrapper-circle:hover { background: rgba(255, 255, 255, 0.1) !important; }
.collapsed .icon-wrapper-circle:hover { background: transparent !important; }
.g-idx-title { color: #E3E3E3 !important; }
.g-idx-item { color: #C4C7C5 !important; }
.g-idx-item:hover { background: #3F4042 !important; color: #E3E3E3 !important; }
.g-idx-num { background: #BBBCB6 !important; color: #1E1F20 !important; }
#AI-webApp-nav-index.collapsed {
background: #2D2E30 !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
color: #C4C7C5 !important;
}
}
#AI-webApp-nav-index.collapsed {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f1f3f4;
border: none;
display: flex !important;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
color: #5f6368;
}
.icon-wrapper-circle {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
flex-shrink: 0;
}
.icon-wrapper-circle:hover { background: rgba(0, 0, 0, 0.05); }
#AI-webApp-nav-index.collapsed:hover { transform: scale(1.05); }
.g-idx-content { overflow-y: auto; height: 100%; }
.toggle-btn {
position: sticky;
top: 0;
width: 100%;
flex-shrink: 0;
height: 40px;
background: #f1f3f4;
border-radius: 11px 11px 0 0;
display: flex;
align-items: center;
padding: 0 4px;
margin-top: -1px;
margin-left: -1px;
box-sizing: border-box;
cursor: pointer;
z-index: 11;
overflow: hidden;
transition: all 0.1s;
}
.g-idx-title {
margin-left: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
padding-right: 10px;
}
.toggle-btn:hover { background: #D3E3FD; }
.collapsed .toggle-btn {
position: static;
background: transparent !important;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
justify-content: center;
}
.collapsed .g-idx-title,
.collapsed .pin-btn,
.collapsed .pin-btn-wrapper { display: none; }
.collapsed .icon-wrapper-circle { width: 100%; height: 100%; margin: 0 !important; }
.pin-btn, .jump-bottom-btn {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #5f6368;
border-radius: 4px;
transition: transform 0.2s, color 0.2s;
}
.pin-btn {
transform: rotate(-45deg);
}
.pin-btn.active { color: #1A73E8; transform: rotate(0deg); }
.pin-btn svg, .jump-bottom-btn svg { width: 16px; height: 16px; fill: currentColor; }
.icon-arrow-svg {
width: 20px;
height: 20px;
fill: currentColor;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsed .icon-arrow-svg { transform: rotate(180deg); }
.g-idx-item {
border-radius: 8px;
padding: 0 5px;
margin: 5px 0;
font-size: 13px;
color: #3c4043;
cursor: pointer;
height: 36px;
line-height: 1.4;
display: flex;
align-items: center;
overflow: hidden;
box-sizing: border-box;
}
.g-idx-item-text {
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: normal;
word-break: break-all;
overflow: hidden;
}
.g-idx-num {
background: #D3E3FD;
color: #041e49;
font-weight: bold;
padding: 0 6px;
border-radius: 4px;
margin-right: 8px;
font-size: 11px;
height: 18px;
line-height: 18px;
flex-shrink: 0;
}
.g-idx-item:hover { background: #e8f0fe; color: #1A73E8; }
#AI-webApp-nav-index.collapsed .g-idx-content { display: none; }
`
document.head.appendChild(style)
}
createArrowIcon () {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', 'icon-arrow-svg')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z')
svg.appendChild(path)
return svg
}
createPinIcon () {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute(
'd',
'M16,9V4l1,0c0.55,0,1-0.45,1-1v0c0-0.55-0.45-1-1-1H7C6.45,2,6,2.45,6,3v0c0,0.55,0.45,1,1,1l1,0v5c0,1.66-1.34,3-3,3h0v2h5.97v7l1,1l1-1v-7H19v-2h0C17.34,12,16,10.66,16,9z')
svg.appendChild(path)
return svg
}
createScrollBottomIcon () {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('class', 'icon-jump-bottom-svg')
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', 'M5.88 4.12L12 10.24l6.12-6.12L20 5.54l-8 8-8-8zM4 17h16v2H4z')
svg.appendChild(path)
return svg
}
/**
* 图钉逻辑:控制是否允许自由拖拽
*/
setupPinLogic () {
this.pinBtn.addEventListener('mousedown', (e) => e.stopPropagation())
this.pinBtn.addEventListener('click', (e) => {
e.stopPropagation()
this.isPinned = !this.isPinned
this.pinBtn.classList.toggle('active', this.isPinned)
this.pinBtn.title = this.isPinned ? T.unpin : T.pin
// noinspection JSUnresolvedReference
GM_setValue(`${this.adapter.name}_toc_pinned`, this.isPinned)
})
}
/**
* 拖拽逻辑实现
*/
setupDraggable (handle) {
const onMouseMove = (e) => {
if (!this.dragState.isDragging || this.isPinned) {
return
}
const dx = e.clientX - this.dragState.startX
const dy = e.clientY - this.dragState.startY
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
this.dragState.hasMoved = true
this.pos.left = this.dragState.initialX + dx
this.pos.top = this.dragState.initialY + dy
this.container.style.left = `${this.pos.left}px`
this.container.style.top = `${this.pos.top}px`
}
}
const onMouseUp = (e) => {
if (this.dragState.isDragging) {
this.container.style.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
if (this.dragState.hasMoved) {
this.clampPosition()
this.container.style.left = `${this.pos.left}px`
this.container.style.top = `${this.pos.top}px`
// noinspection JSUnresolvedReference
GM_setValue(`${this.adapter.name}_toc_pos`, this.pos)
} else {
// 如果鼠标没有位移,则判定为点击切换折叠
const upEl = document.elementFromPoint(e.clientX, e.clientY)
if (handle.contains(upEl) || (this.isCollapsed && this.container.contains(upEl))) {
this.toggleCollapse()
}
}
}
this.dragState.isDragging = false
unsafeWindow.removeEventListener('mousemove', onMouseMove)
unsafeWindow.removeEventListener('mouseup', onMouseUp)
}
const onMouseDown = (e) => {
if (this.isPinned) {
}
this.dragState.isDragging = true
this.dragState.hasMoved = false
this.dragState.startX = e.clientX
this.dragState.startY = e.clientY
this.dragState.initialX = this.container.offsetLeft
this.dragState.initialY = this.container.offsetTop
this.container.style.transition = 'none'
unsafeWindow.addEventListener('mousemove', onMouseMove)
unsafeWindow.addEventListener('mouseup', onMouseUp)
}
handle.addEventListener('mousedown', onMouseDown)
this.container.addEventListener('mousedown', (e) => {
if (this.isCollapsed && e.target !== handle) {
onMouseDown(e)
}
})
}
/**
* 构建 UI 容器
*/
createContainer () {
this.container = document.createElement('div')
this.container.id = 'AI-webApp-nav-index'
if (this.isCollapsed) {
this.container.classList.add('collapsed')
}
const toggleBtn = document.createElement('div')
toggleBtn.className = 'toggle-btn'
const titleSpan = document.createElement('span')
titleSpan.className = 'g-idx-title'
const arrowWrapper = document.createElement('div')
arrowWrapper.className = 'icon-wrapper-circle'
arrowWrapper.appendChild(this.createArrowIcon())
const pinBtn = document.createElement('div')
pinBtn.className = `pin-btn ${this.isPinned ? 'active' : ''}`
pinBtn.appendChild(this.createPinIcon())
pinBtn.title = this.isPinned ? T.unpin : T.pin
const pinWrapper = document.createElement('div')
pinWrapper.className = 'icon-wrapper-circle pin-btn-wrapper'
pinWrapper.appendChild(pinBtn)
toggleBtn.appendChild(arrowWrapper)
toggleBtn.appendChild(titleSpan)
toggleBtn.appendChild(pinWrapper)
this.pinBtn = pinBtn
this.setupPinLogic()
this.setupDraggable(toggleBtn)
this.contentArea = document.createElement('div')
this.contentArea.className = 'g-idx-content'
this.container.appendChild(toggleBtn)
this.container.appendChild(this.contentArea)
document.body.appendChild(this.container)
}
/**
* 边界溢出修正
*/
clampPosition () {
const rect = this.container.getBoundingClientRect()
const maxLeft = Math.max(0, unsafeWindow.innerWidth - rect.width)
const maxTop = Math.max(0, unsafeWindow.innerHeight - rect.height)
this.pos.left = Math.max(0, Math.min(this.pos.left, maxLeft))
this.pos.top = Math.max(0, Math.min(this.pos.top, maxTop))
}
/**
* 折叠/展开切换
*/
toggleCollapse () {
this.isCollapsed = !this.isCollapsed
if (this.isCollapsed) {
this.container.classList.add('collapsed')
} else {
this.container.classList.remove('collapsed')
// 展开时尺寸剧变,需延迟修正位置以免溢出屏幕
setTimeout(() => {
this.clampPosition()
this.container.style.left = `${this.pos.left}px`
this.container.style.top = `${this.pos.top}px`
}, 300)
measureSlowly('toggleCollapse.refresh', () => this.refresh())
}
}
/**
* 状态评估逻辑:由外部事件触发,决定 TOC 的生死(显示/隐藏/刷新)
*/
evaluateState (reason) {
const url = unsafeWindow.location.href
if (url !== this.lastUrl) {
this.lastUrl = url
}
if (measureSlowly(`evaluateState.${this.adapter.name}.shouldActivate`, () => this.adapter.shouldActivate(url))) {
if (!this.activate()) {
measureSlowly('evaluateState.refresh', () => this.refresh())
}
} else {
this.deactivate()
}
}
/**
* @return {boolean} false:已经是'展开状态' true:成功从'折叠状态'切换到'展开状态'
*/
activate () {
if (this.container.style.display === 'flex') {
return false
}
this.container.style.display = 'flex'
this.startObservingMessages()
measureSlowly('activate.refresh', () => this.refresh())
return true
}
/**
* @return {boolean} false:已经是'折叠状态' true:成功从'展开状态'切换到'折叠状态'
*/
deactivate () {
if (this.container.style.display === 'none') {
return false
}
this.container.style.display = 'none'
this.isCollapsed = GLOBAL_CONFIG.DEFAULT_COLLAPSE
this.container.classList.toggle('collapsed', this.isCollapsed)
this.clearContent()
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
return true
}
clearContent () {
while (this.contentArea.firstChild) {
this.contentArea.removeChild(this.contentArea.firstChild)
}
}
/**
* 核心渲染逻辑:将 Adapter 提取的数据转化为 DOM
* @return {boolean} 是否尝试执行了刷新
*/
refresh () {
if (this.isCollapsed || this.container.style.display === 'none') {
return false
}
// 1. 获取对话大标题
const chatTitle = measureSlowly(
`refresh.${this.adapter.name}.getConversationTitle`, () => this.adapter.getConversationTitle())
const titleDisplay = this.container.querySelector('.g-idx-title')
if (titleDisplay) {
titleDisplay.innerText = chatTitle
}
// 2. 获取用户提问原始 DOM
const queries = measureSlowly(
`refresh.${this.adapter.name}.getUserQueryElements`, () => this.adapter.getUserQueryElements())
// --- 新增内容校验逻辑 ---
const currentTitles = Array.from(queries).map((query, i) =>
this.adapter.extractItemTitle(query, i)
)
const currentHash = JSON.stringify(currentTitles)
if (currentHash === this.lastContentHash && this.contentArea.children.length > 0) {
return false
}
this.lastContentHash = currentHash
// -----------------------
if (queries.length === 0) {
this.clearContent()
const tip = document.createElement('div')
tip.className = 'g-idx-item'
tip.style.color = '#70757a'
tip.textContent = T.waiting
this.contentArea.appendChild(tip)
return true
}
this.clearContent()
// 3. 循环渲染条目
currentTitles.forEach((title, i) => {
const query = queries[i]
const item = document.createElement('div')
item.className = 'g-idx-item'
const numSpan = document.createElement('span')
numSpan.className = 'g-idx-num'
numSpan.textContent = i + 1
const textSpan = document.createElement('span')
textSpan.className = 'g-idx-item-text'
textSpan.textContent = title
const bottomBtnWrapper = document.createElement('div')
bottomBtnWrapper.className = 'icon-wrapper-circle'
bottomBtnWrapper.style.marginRight = '4px'
const bottomBtn = document.createElement('div')
bottomBtn.className = 'jump-bottom-btn'
bottomBtn.title = T.jump
bottomBtn.appendChild(this.createScrollBottomIcon())
bottomBtnWrapper.appendChild(bottomBtn)
bottomBtnWrapper.onclick = (e) => {
e.stopPropagation()
measureSlowly(
`refresh.${this.adapter.name}.scrollToItemBottom`,
() => this.adapter.scrollToItemBottom(query))
}
item.appendChild(numSpan)
item.appendChild(textSpan)
item.appendChild(bottomBtnWrapper)
item.onclick = (e) => {
e.stopPropagation()
measureSlowly(`refresh.${this.adapter.name}.scrollToItem`, () => this.adapter.scrollToItem(query))
}
this.contentArea.appendChild(item)
})
// 4. 条目变动可能撑开容器,再次执行位置对齐
requestAnimationFrame(() => {
this.clampPosition()
this.container.style.left = `${this.pos.left}px`
this.container.style.top = `${this.pos.top}px`
})
return true
}
/**
* 监听 DOM 树变动实现自动更新
*/
startObservingMessages () {
if (this.observer) {
return
}
const target = measureSlowly(
`startObservingMessages.${this.adapter.name}.getObservationTarget`, () => this.adapter.getObservationTarget())
if (!target) {
setTimeout(() => this.startObservingMessages(), 1000)
return
}
let timer
this.observer = new MutationObserver((mutations) => {
// 简单过滤:仅在子节点发生增减时才触发刷新,优化性能
const hasRelevantChange = mutations.some(m => m.type === 'childList')
if (!hasRelevantChange) {
return
}
clearTimeout(timer)
timer = setTimeout(() => {
measureSlowly('startObservingMessages.MutationObserver.setTimeout', () => this.refresh())
}, GLOBAL_CONFIG.REFRESH_DELAY)
})
this.observer.observe(target, { childList: true, subtree: true })
}
/**
* 路由拦截:监听 pushState/replaceState
*/
setupRouteListener () {
const wrap = (type) => {
const orig = unsafeWindow.history[type]
return function () {
const res = orig.apply(this, arguments)
unsafeWindow.dispatchEvent(new Event(type.toLowerCase()))
return res
}
}
unsafeWindow.history.pushState = wrap('pushState')
unsafeWindow.history.replaceState = wrap('replaceState');
['pushstate', 'replacestate', 'popstate'].forEach(ev => {
unsafeWindow.addEventListener(ev, () => {
measureSlowly(`setupRouteListener.EventListener(${ev})`, () => this.evaluateState(ev))
})
})
// 兜底轮询,防止某些复杂的 SPA 状态变更未被捕捉
setInterval(() => {
measureSlowly('setupRouteListener.setInterval.(Polling)', () => this.evaluateState('Polling'))
}, 1500)
}
}
/**
* [Layer 4] 启动器
*/
const start = () => {
if (!document.body) {
setTimeout(start, 333)
return
}
// 实例化具体的站点适配器
const adapter = AdapterFactory.getAdapter()
if (adapter) {
new UniversalTOCManager(adapter)
} else {
unsafeWindow.logger.error('无匹配适配器')
}
}
start()
})()