AIChat-TOC

AIチャットページ内のユーザーの質問に対してTOCを生成し、プレビューやクリックによるジャンプを容易にします。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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()
})()