// ==UserScript== // @name gemini-helper // @namespace http://tampermonkey.net/ // @version 1.8.2 // @description Gemini 助手:支持对话大纲、提示词管理、模型锁定、标签页增强(状态显示/隐私模式/生成完成通知)、阅读历史恢复、双向锚点、自动加宽页面、中文输入修复,智能适配 Gemini 标准版/企业版/Genspark // @description:en Gemini Helper: Supports outline navigation, prompt management, model locking, tab enhancements (status display/privacy mode/completion notification), reading history, bidirectional anchor, auto page width, Chinese input fix, smart adaptation for Gemini Standard/Enterprise/Genspark // @author urzeye // @homepage https://github.com/urzeye // @note 参考 https://linux.do/t/topic/925110 的代码与UI布局拓展实现 // @match https://gemini.google.com/* // @match https://business.gemini.google/* // @match https://www.genspark.ai/agents* // @match https://genspark.ai/agents* // @icon https://raw.githubusercontent.com/gist/urzeye/8d1d3afbbcd0193dbc8a2019b1ba54d3/raw/f7113d329a259963ed1b1ab8cb981e8f635d4cea/gemini.svg // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant window.focus // @run-at document-idle // @supportURL https://github.com/urzeye/tampermonkey-scripts/issues // @homepageURL https://github.com/urzeye/tampermonkey-scripts // @require https://update.greasyfork.icu/scripts/559089/1714656/background-keep-alive.js // @require https://update.greasyfork.icu/scripts/559176/1715343/domToolkit.js // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/558318/gemini-helper.user.js // @updateURL https://update.greasyfork.icu/scripts/558318/gemini-helper.meta.js // ==/UserScript== (function () { 'use strict'; // 防止重复初始化 if (window.geminiHelperInitialized) { return; } window.geminiHelperInitialized = true; // ==================== 设置项与多语言 ==================== const SETTING_KEYS = { CLEAR_TEXTAREA_ON_SEND: 'gemini_business_clear_on_send', LANGUAGE: 'gemini_language', PAGE_WIDTH: 'gemini_page_width', OUTLINE: 'gemini_outline_settings', TAB_ORDER: 'gemini_tab_order', MODEL_LOCK: 'gemini_model_lock', PROMPTS_SETTINGS: 'gemini_prompts_settings', READING_HISTORY: 'gemini_reading_history_settings', TAB_SETTINGS: 'gemini_tab_settings', }; // 默认 Tab 顺序 const DEFAULT_TAB_ORDER = ['prompts', 'outline', 'settings']; const DEFAULT_PROMPTS_SETTINGS = {enabled: true}; const DEFAULT_READING_HISTORY_SETTINGS = { persistence: true, autoRestore: false, cleanupDays: 30 }; const DEFAULT_TAB_SETTINGS = { openInNewTab: true, // 新标签页打开新对话 autoRenameTab: true, // 自动重命名标签页 renameInterval: 3, // 检测频率(秒) showStatus: true, // 显示生成状态图标 (⏳/✅) showNotification: false, // 发送桌面通知 autoFocus: false, // 生成完成后自动将窗口置顶 privacyMode: false, // 隐私模式 privacyTitle: 'Google', // 隐私模式下的伪装标题 titleFormat: '{status}{title}-{model}' // 自定义标题格式,支持 {status}、{title}、{model} }; // Tab 定义(用于渲染和显示) const TAB_DEFINITIONS = { 'prompts': {id: 'prompts', labelKey: 'tabPrompts', icon: '📝'}, 'outline': {id: 'outline', labelKey: 'tabOutline', icon: '📑'}, 'settings': {id: 'settings', labelKey: 'tabSettings', icon: '⚙️'} }; const I18N = { 'zh-CN': { panelTitle: 'Gemini 助手', tabPrompts: '提示词', tabSettings: '设置', searchPlaceholder: '搜索提示词...', addPrompt: '添加新提示词', allCategory: '全部', manageCategory: '⚙ 管理', currentPrompt: '当前提示词:', scrollTop: '顶部', scrollBottom: '底部', refresh: '刷新', collapse: '收起', edit: '编辑', delete: '删除', copy: '复制', drag: '拖动', save: '保存', cancel: '取消', add: '添加', anchorPoint: '锚点', updateAnchor: '更新锚点', title: '标题', category: '分类', categoryPlaceholder: '例如:编程、翻译', content: '提示词内容', editPrompt: '编辑提示词', addNewPrompt: '添加新提示词', fillTitleContent: '请填写标题和内容', promptUpdated: '提示词已更新', promptAdded: '提示词已添加', deleted: '已删除', copied: '已复制到剪贴板', cleared: '已清除内容', refreshed: '已刷新', orderUpdated: '已更新排序', inserted: '已插入提示词', scrolling: '页面正在滚动,请稍后...', noTextarea: '未找到输入框,请点击输入框后重试', confirmDelete: '确定删除?', // 设置面板 settingsTitle: '通用设置', clearOnSendLabel: '发送后自动修复中文输入', clearOnSendDesc: '发送消息后插入零宽字符,修复下次输入首字母问题(仅 Gemini Business)', settingOn: '开', settingOff: '关', // 模型锁定 modelLockTitle: '模型锁定', modelLockLabel: '自动锁定模型', modelLockDesc: '进入页面后自动切换到指定模型', modelKeywordLabel: '模型关键字', modelKeywordPlaceholder: '例如:3 Pro', modelKeywordDesc: '用于匹配目标模型名称', // 分类管理 categoryManage: '分类管理', categoryEmpty: '暂无分类,添加提示词时会自动创建分类', rename: '重命名', newCategoryName: '请输入新的分类名称:', categoryRenamed: '分类已重命名', confirmDeleteCategory: '确定删除该分类吗?关联的提示词将移至"未分类"', categoryDeleted: '分类已删除', // 语言设置 languageLabel: '界面语言', languageDesc: '设置面板显示语言,即时生效', languageAuto: '跟随系统', languageZhCN: '简体中文', languageZhTW: '繁體中文', languageEn: 'English', // 页面宽度设置 pageWidthLabel: '页面宽度', pageWidthDesc: '调整聊天页面的宽度,即时生效', enablePageWidth: '启用页面加宽', widthValue: '宽度值', widthUnit: '单位', unitPx: '像素 (px)', unitPercent: '百分比 (%)', // 标签页设置 tabSettingsTitle: '标签页设置', openNewTabLabel: '新标签页打开新对话', openNewTabDesc: '在面板顶部添加按钮,点击后在新标签页打开新对话', newTabTooltip: '新标签页开启对话', autoRenameTabLabel: '自动重命名标签页', autoRenameTabDesc: '将浏览器标签页名称改为当前对话名称', renameIntervalLabel: '检测频率', renameIntervalDesc: '检测对话名称变化的间隔时间', secondsSuffix: '秒', showStatusLabel: '显示生成状态', showStatusDesc: '在标签页标题中显示生成状态图标(⏳/✅)', showNotificationLabel: '发送桌面通知', showNotificationDesc: '生成完成时发送系统通知(目前仅 Gemini Business 有效)', autoFocusLabel: '自动窗口置顶', autoFocusDesc: '生成完成时自动将窗口带回前台(目前仅 Gemini Business 有效)', privacyModeLabel: '隐私模式', privacyModeDesc: '隐藏真实对话标题,显示伪装标题(双击面板标题可快速切换)', privacyTitleLabel: '伪装标题', privacyTitlePlaceholder: '如:Google、工作文档', titleFormatLabel: '标题格式', titleFormatDesc: '自定义标题格式,支持占位符:{status}、{title}、{model}', notificationTitle: '✅ {site} 生成完成', notificationBody: '点击查看结果', // 大纲功能 tabOutline: '大纲', outlineEmpty: '暂无大纲内容', outlineRefresh: '刷新', outlineSettings: '大纲设置', enableOutline: '启用大纲', outlineMaxLevel: '显示标题级别', outlineLevelAll: '全部 (1-6级)', outlineLevel1: '仅 1 级', outlineLevel2: '至 2 级', outlineLevel3: '至 3 级', // 刷新按钮提示 refreshPrompts: '刷新提示词', refreshOutline: '刷新大纲', refreshSettings: '刷新设置', jumpToAnchor: '返回跳转前位置', anchorUpdated: '锚点已更新', // 大纲高级工具栏 outlineScrollBottom: '滚动到底部', outlineScrollTop: '滚动到顶部', outlineExpandAll: '展开全部', outlineCollapseAll: '折叠全部', outlineSearch: '搜索大纲...', outlineSearchResult: '个结果', outlineLevelHint: '级标题', // Tab 顺序设置 tabOrderSettings: '界面排版', tabOrderDesc: '调整面板 Tab 的显示顺序', moveUp: '上移', moveDown: '下移', // 阅读导航设置 readingNavigationSettings: '阅读导航', readingHistorySettings: '阅读历史', readingHistoryPersistence: '启用阅读历史', readingHistoryPersistenceDesc: '自动记录阅读位置,下次打开时恢复', autoRestore: '自动跳转', autoRestoreDesc: '打开页面时自动跳转到上次位置', readingHistoryCleanup: '历史保留时间', readingHistoryCleanupDesc: '只保留最近几天的阅读进度 (-1 为永久)', daysSuffix: '天', cleanupInfinite: '永久', restoredPosition: '已恢复上次阅读位置', cleanupDone: '已清理过期数据', // 大纲高级设置 outlineAutoUpdateLabel: '对话期间自动更新大纲', outlineAutoUpdateDesc: 'AI 生成内容时自动刷新目录结构', outlineUpdateIntervalLabel: '更新检测间隔 (秒)', outlineIntervalUpdated: '间隔已设为 {val} 秒', // 页面显示设置 pageDisplaySettings: '页面显示', // 其他设置 otherSettingsTitle: '其他设置', showCollapsedAnchorLabel: '折叠面板显示锚点', showCollapsedAnchorDesc: '当面板收起时,在侧边浮动条中显示锚点按钮', preventAutoScrollLabel: '防止自动滚动', preventAutoScrollDesc: '当 AI 生成长内容时,阻止页面自动滚动到底部,方便阅读上文', // 界面排版开关 disableOutline: '禁用大纲', togglePrompts: '启用/禁用提示词' }, 'zh-TW': { panelTitle: 'Gemini 助手', tabPrompts: '提示詞', tabSettings: '設置', searchPlaceholder: '搜尋提示詞...', addPrompt: '新增提示詞', allCategory: '全部', manageCategory: '⚙ 管理', currentPrompt: '當前提示詞:', scrollTop: '頂部', scrollBottom: '底部', refresh: '刷新', collapse: '收起', edit: '編輯', delete: '刪除', copy: '複製', drag: '拖動', save: '保存', cancel: '取消', add: '新增', title: '標題', category: '分類', categoryPlaceholder: '例如:程式設計、翻譯', content: '提示詞內容', editPrompt: '編輯提示詞', addNewPrompt: '新增提示詞', fillTitleContent: '請填寫標題和內容', promptUpdated: '提示詞已更新', promptAdded: '提示詞已新增', deleted: '已刪除', copied: '已複製到剪貼簿', cleared: '已清除內容', refreshed: '已刷新', orderUpdated: '已更新排序', inserted: '已插入提示詞', scrolling: '頁面正在捲動,請稍後...', noTextarea: '未找到輸入框,請點擊輸入框後重試', confirmDelete: '確定刪除?', // 設置面板 settingsTitle: '通用設置', clearOnSendLabel: '發送後自動修復中文輸入', clearOnSendDesc: '發送訊息後插入零寬字元,修復下次輸入首字母問題(僅 Gemini Business)', settingOn: '開', settingOff: '關', // 模型鎖定 modelLockTitle: '模型鎖定', modelLockLabel: '自動鎖定模型', modelLockDesc: '進入頁面後自動切換到指定模型', modelKeywordLabel: '模型關鍵字', modelKeywordPlaceholder: '例如:3 Pro', modelKeywordDesc: '用於匹配目標模型名稱', // 分類管理 categoryManage: '分類管理', categoryEmpty: '暫無分類,新增提示詞時會自動建立分類', rename: '重新命名', newCategoryName: '請輸入新的分類名稱:', categoryRenamed: '分類已重新命名', confirmDeleteCategory: '確定刪除該分類嗎?關聯的提示詞將移至「未分類」', categoryDeleted: '分類已刪除', // 語言設置 languageLabel: '介面語言', languageDesc: '設定面板顯示語言,即時生效', languageAuto: '跟隨系統', languageZhCN: '简体中文', languageZhTW: '繁體中文', languageEn: 'English', // 頁面寬度設置 pageWidthLabel: '頁面寬度', pageWidthDesc: '調整聊天頁面的寬度,即時生效', enablePageWidth: '啟用頁面加寬', widthValue: '寬度值', widthUnit: '單位', unitPx: '像素 (px)', unitPercent: '百分比 (%)', // 標籤頁設置 tabSettingsTitle: '標籤頁設置', openNewTabLabel: '新分頁開啟新對話', openNewTabDesc: '在面板頂部新增按鈕,點擊後在新分頁開啟新對話', newTabTooltip: '新分頁開啟對話', autoRenameTabLabel: '自動重新命名標籤頁', autoRenameTabDesc: '將瀏覽器標籤頁名稱改為當前對話名稱', renameIntervalLabel: '檢測頻率', renameIntervalDesc: '檢測對話名稱變化的間隔時間', secondsSuffix: '秒', showStatusLabel: '顯示生成狀態', showStatusDesc: '在標籤頁標題中顯示生成狀態圖示(⏳/✅)', showNotificationLabel: '傳送桌面通知', showNotificationDesc: '生成完成時傳送系统通知(僅 Gemini Business 有效)', autoFocusLabel: '自動視窗置頂', autoFocusDesc: '生成完成時自動將視窗帶回前台(僅 Gemini Business 有效)', privacyModeLabel: '隱私模式', privacyModeDesc: '隱藏真實對話標題,顯示偽裝標題(雙擊面板標題可快速切換)', privacyTitleLabel: '偽裝標題', privacyTitlePlaceholder: '如:Google、工作文件', titleFormatLabel: '標題格式', titleFormatDesc: '自訂標題格式,支援佔位符:{status}、{title}、{model}', notificationTitle: '✅ {site} 生成完成', notificationBody: '點擊查看結果', // 大綱功能 tabOutline: '大綱', outlineEmpty: '暫無大綱內容', outlineRefresh: '刷新', outlineSettings: '大綱設置', enableOutline: '啟用大綱', outlineMaxLevel: '顯示標題級別', outlineLevelAll: '全部 (1-6級)', outlineLevel1: '僅 1 級', outlineLevel2: '至 2 級', outlineLevel3: '至 3 級', // 刷新按鈕提示 refreshPrompts: '刷新提示詞', refreshOutline: '刷新大綱', refreshSettings: '刷新設置', // 大綱高級工具欄 outlineScrollBottom: '滾動到底部', outlineScrollTop: '滾動到頂部', outlineExpandAll: '展開全部', outlineCollapseAll: '折疊全部', outlineSearch: '搜尋大綱...', outlineSearchResult: '個結果', outlineLevelHint: '級標題', // Tab 顺序设置 tabOrderSettings: '介面排版', tabOrderDesc: '調整面板 Tab 的顯示順序', moveUp: '上移', moveDown: '下移', // 阅读导航設置 readingNavigationSettings: '閱讀導航', readingHistorySettings: '閱讀歷史', readingHistoryPersistence: '啟用閱讀歷史', readingHistoryPersistenceDesc: '自動記錄閱讀位置,下次開啟時恢復', autoRestore: '自動跳轉', autoRestoreDesc: '開啟頁面時自動跳轉到上次位置', readingHistoryCleanup: '歷史保留時間', readingHistoryCleanupDesc: '只保留最近幾天的閱讀進度 (-1 為永久)', daysSuffix: '天', cleanupInfinite: '永久', restoredPosition: '已恢復上次閱讀位置', cleanupDone: '已清理過期數據', // 大綱高級設置 outlineAutoUpdateLabel: '對話期間自動更新大綱', outlineAutoUpdateDesc: 'AI 生成內容時自動刷新目錄結構', outlineUpdateIntervalLabel: '更新檢測間隔 (秒)', outlineIntervalUpdated: '間隔已設為 {val} 秒', // 頁面顯示設置 pageDisplaySettings: '頁面顯示', // 其他設置 otherSettingsTitle: '其他設置', showCollapsedAnchorLabel: '折疊面板顯示錨點', showCollapsedAnchorDesc: '當面板收起時,在側邊浮動條中顯示錨點按鈕', preventAutoScrollLabel: '防止自動滾動', preventAutoScrollDesc: '當 AI 生成長內容時,阻止頁面自動滾動到底部,方便閱讀上文', // 介面排版開關 disableOutline: '禁用大綱', togglePrompts: '啟用/禁用提示詞' }, 'en': { panelTitle: 'Gemini Helper', tabPrompts: 'Prompts', tabSettings: 'Settings', searchPlaceholder: 'Search prompts...', addPrompt: 'Add New Prompt', allCategory: 'All', manageCategory: '⚙ Manage', currentPrompt: 'Current: ', scrollTop: 'Top', scrollBottom: 'Bottom', refresh: 'Refresh', collapse: 'Collapse', edit: 'Edit', delete: 'Delete', copy: 'Copy', drag: 'Drag', save: 'Save', cancel: 'Cancel', add: 'Add', title: 'Title', category: 'Category', categoryPlaceholder: 'e.g., Coding, Translation', content: 'Prompt Content', editPrompt: 'Edit Prompt', addNewPrompt: 'Add New Prompt', fillTitleContent: 'Please fill in title and content', promptUpdated: 'Prompt updated', promptAdded: 'Prompt added', deleted: 'Deleted', copied: 'Copied to clipboard', cleared: 'Content cleared', refreshed: 'Refreshed', orderUpdated: 'Order updated', inserted: 'Prompt inserted', scrolling: 'Page is scrolling, please wait...', noTextarea: 'Input not found, please click the input area first', confirmDelete: 'Delete this prompt?', // Settings panel settingsTitle: 'General Settings', clearOnSendLabel: 'Auto-fix Chinese input after send', clearOnSendDesc: 'Insert zero-width char after send to fix first letter issue (Gemini Business only)', settingOn: 'ON', settingOff: 'OFF', // Model Lock modelLockTitle: 'Model Lock', modelLockLabel: 'Auto Lock Model', modelLockDesc: 'Automatically switch to specified model upon entry', modelKeywordLabel: 'Model Keyword', modelKeywordPlaceholder: 'e.g., 3 Pro', modelKeywordDesc: 'Used to match target model name', // Category management categoryManage: 'Category Management', categoryEmpty: 'No categories yet. Categories are created when you add prompts.', rename: 'Rename', newCategoryName: 'Enter new category name:', categoryRenamed: 'Category renamed', confirmDeleteCategory: 'Delete this category? Associated prompts will be moved to "Uncategorized"', categoryDeleted: 'Category deleted', // Language settings languageLabel: 'Language', languageDesc: 'Set panel display language, takes effect immediately', languageAuto: 'Auto', languageZhCN: '简体中文', languageZhTW: '繁體中文', languageEn: 'English', // Page width settings pageWidthLabel: 'Page Width', pageWidthDesc: 'Adjust chat page width, takes effect immediately', enablePageWidth: 'Enable Page Widening', widthValue: 'Width Value', widthUnit: 'Unit', unitPx: 'Pixels (px)', unitPercent: 'Percentage (%)', // Tab Settings tabSettingsTitle: 'Tab Settings', openNewTabLabel: 'Open New Chat in New Tab', openNewTabDesc: 'Add a button to the panel header to open a new chat in a new tab', newTabTooltip: 'New Chat in New Tab', autoRenameTabLabel: 'Auto Rename Tab', autoRenameTabDesc: 'Change browser tab title to current conversation name', renameIntervalLabel: 'Detection Interval', renameIntervalDesc: 'Interval for detecting conversation name changes', secondsSuffix: 's', showStatusLabel: 'Show Status', showStatusDesc: 'Display generation status icon in tab title (⏳/✅)', showNotificationLabel: 'Desktop Notification', showNotificationDesc: 'Send system notification when generation completes (Gemini Business only)', autoFocusLabel: 'Auto Focus Window', autoFocusDesc: 'Bring window to front when generation completes (Gemini Business only)', privacyModeLabel: 'Privacy Mode', privacyModeDesc: 'Hide real conversation title, show decoy title (double-click panel header to toggle)', privacyTitleLabel: 'Decoy Title', privacyTitlePlaceholder: 'e.g., Google, Work Document', titleFormatLabel: 'Title Format', titleFormatDesc: 'Custom title format, supports placeholders: {status}, {title}, {model}', notificationTitle: '✅ {site} Generation Complete', notificationBody: 'Click to view results', tabOutline: 'Outline', outlineEmpty: 'No outline content', outlineRefresh: 'Refresh', outlineSettings: 'Outline Settings', enableOutline: 'Enable Outline', outlineMaxLevel: 'Heading Levels', outlineLevelAll: 'All (1-6)', outlineLevel1: 'Level 1 only', outlineLevel2: 'Up to Level 2', outlineLevel3: 'Up to Level 3', // Refresh button hints refreshPrompts: 'Refresh Prompts', refreshOutline: 'Refresh Outline', refreshSettings: 'Refresh Settings', // Outline advanced toolbar outlineScrollBottom: 'Scroll to bottom', outlineScrollTop: 'Scroll to top', outlineExpandAll: 'Expand all', outlineCollapseAll: 'Collapse all', outlineSearch: 'Search outline...', outlineSearchResult: 'result(s)', outlineLevelHint: 'headings', // Tab Order Settings tabOrderSettings: 'Interface Layout', tabOrderDesc: 'Adjust the display order of panel tabs', moveUp: 'Move Up', moveDown: 'Move Down', // Reading Navigation Settings readingNavigationSettings: 'Reading Navigation', anchorSettings: 'Reading History', anchorPersistence: 'Enable Reading History', anchorPersistenceDesc: 'Automatically remember reading position', anchorAutoRestore: 'Auto-Resume', anchorAutoRestoreDesc: 'Jump to last position on load', anchorCleanup: 'Retention Period', anchorCleanupDesc: 'Keep reading progress for days (-1 for infinite)', daysSuffix: 'Days', cleanupInfinite: 'Infinite', restoredPosition: 'Resumed last position', cleanupDone: 'Expired data cleaned', // Outline Advanced Settings outlineAutoUpdateLabel: 'Auto-update outline during conversation', outlineAutoUpdateDesc: 'Automatically refresh outline when AI generates content', outlineUpdateIntervalLabel: 'Update interval (seconds)', outlineIntervalUpdated: 'Interval set to {val} seconds', // Page Display Settings pageDisplaySettings: 'Page Display', // Other Settings otherSettingsTitle: 'Other Settings', showCollapsedAnchorLabel: 'Show anchor when collapsed', showCollapsedAnchorDesc: 'Display anchor button in sidebar when panel is collapsed', preventAutoScrollLabel: 'Prevent auto-scroll', preventAutoScrollDesc: 'Stop page from auto-scrolling to bottom during AI generation', // Interface Toggle disableOutline: 'Disable Outline', togglePrompts: 'Toggle Prompts' } }; // ============= 默认提示词库 ============= const DEFAULT_PROMPTS = [ { id: 'default_1', title: '代码优化', content: '请帮我优化以下代码,提高性能和可读性:\n\n', category: '编程' }, { id: 'default_2', title: '翻译助手', content: '请将以下内容翻译成中文,保持专业术语的准确性:\n\n', category: '翻译' }, ]; // ============= 页面宽度默认配置 ============= const DEFAULT_WIDTH_SETTINGS = { 'gemini': {enabled: false, value: '70', unit: '%'}, 'gemini-business': {enabled: false, value: '1600', unit: 'px'}, 'genspark': {enabled: false, value: '70', unit: '%'} }; // ============= 大纲功能默认配置 ============= const DEFAULT_OUTLINE_SETTINGS = { enabled: true, maxLevel: 6, // 显示到几级标题 (1-6) autoUpdate: true, updateInterval: 3 }; // 语言检测函数(支持手动设置) function detectLanguage() { // 优先使用用户手动设置的语言 const savedLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'auto'); if (savedLang !== 'auto' && I18N[savedLang]) { return savedLang; } // 自动检测 const lang = navigator.language || navigator.userLanguage || 'en'; if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK') || lang.startsWith('zh-Hant')) { return 'zh-TW'; } if (lang.startsWith('zh')) { return 'zh-CN'; } return 'en'; } // ==================== 站点适配器模式 (Site Adapter Pattern) ==================== /** * 站点适配器基类 * 添加新站点时,继承此类并实现所有抽象方法 */ class SiteAdapter { constructor() { this.textarea = null; } /** * 检测当前页面是否匹配该站点 * @returns {boolean} */ match() { throw new Error('必须实现 match()'); } /** * 返回站点标识符(用于配置存储) * @returns {string} */ getSiteId() { throw new Error('必须实现 getSiteId()'); } /** * 返回站点显示名称 * @returns {string} */ getName() { throw new Error('必须实现 getName()'); } /** * 获取当前会话ID (用于锚点持久化) * @returns {string} Session ID */ getSessionId() { // 优化实现:先去除 URL 中的查询参数 (?及后面内容),再获取最后一段 const urlWithoutQuery = window.location.href.split('?')[0]; const parts = urlWithoutQuery.split('/').filter(p => p); return parts.length > 0 ? parts[parts.length - 1] : 'default'; } /** * 是否支持在新标签页打开新对话 * @returns {boolean} */ supportsNewTab() { return true; } /** * 获取新标签页打开的 URL * @returns {string} */ getNewTabUrl() { return window.location.origin; } /** * 是否支持标签页重命名 * @returns {boolean} */ supportsTabRename() { return true; } /** * 获取当前会话/对话名称(用于标签页重命名) * @returns {string|null} */ getSessionName() { // 默认实现:尝试从 document.title 中提取 const title = document.title; if (title) { // 去除站点名称后缀,如 "对话标题 - Gemini" const parts = title.split(' - '); if (parts.length > 1) { return parts.slice(0, -1).join(' - ').trim(); } return title.trim(); } return null; } /** * 判断当前是否处于新对话页面(未发起任何对话) * 新对话页面不应使用旧会话标题更新标签页、不应记录阅读历史 * @returns {boolean} */ isNewConversation() { return false; } /** * 检测 AI 是否正在生成响应 * @returns {boolean} */ isGenerating() { // 默认实现:子类应覆盖此方法 return false; } /** * 获取当前使用的模型名称 * @returns {string|null} */ getModelName() { // 默认实现:子类应覆盖此方法 return null; } /** * 获取网络监控配置(用于后台任务完成检测) * 子类可覆盖此方法提供站点特定的配置 * @returns {{ * urlPatterns: string[], // 要监控的 URL 模式(包含匹配) * silenceThreshold: number // 静默判定时间(毫秒) * }|null} 返回 null 表示不启用网络监控 */ getNetworkMonitorConfig() { return null; } /** * 返回站点主题色 * @returns {{primary: string, secondary: string}} */ getThemeColors() { throw new Error('必须实现 getThemeColors()'); } /** * 返回需要加宽的CSS选择器列表 * @returns {Array<{selector: string, property: string}>} */ getWidthSelectors() { return []; } /** * 返回输入框选择器列表 * @returns {string[]} */ getTextareaSelectors() { return []; } /** * 获取提交按钮选择器,可以匹配ID、类名、属性等选择器 * * @returns 提交按钮选择器 */ getSubmitButtonSelectors() { return []; } /** * 查找输入框元素 * 默认实现:遍历选择器查找 * @returns {HTMLElement|null} */ findTextarea() { for (const selector of this.getTextareaSelectors()) { const elements = document.querySelectorAll(selector); for (const element of elements) { if (this.isValidTextarea(element)) { this.textarea = element; return element; } } } return null; } /** * 验证输入框是否有效 * @param {HTMLElement} element * @returns {boolean} */ isValidTextarea(element) { return element.offsetParent !== null; } /** * 向输入框插入内容 * @param {string} content * @returns {Promise|boolean} */ insertPrompt(content) { throw new Error('必须实现 insertPrompt()'); } /** * 清空输入框内容 */ clearTextarea() { if (this.textarea) { this.textarea.value = ''; this.textarea.dispatchEvent(new Event('input', {bubbles: true})); } } /** * 获取滚动容器 * @returns {HTMLElement} */ getScrollContainer() { // 使用 DOMToolkit 查找滚动容器,传入站点特定选择器 return DOMToolkit.findScrollContainer({ selectors: [ '.chat-mode-scroller', 'main', '[role="main"]', '.conversation-container', '.chat-container' ] }); } /** * 获取当前视口中可见的锚点元素信息 (用于精准定位) * @returns {Object|null} { selector, offset, index } */ getVisibleAnchorElement() { const container = this.getScrollContainer(); if (!container) return null; const scrollTop = container.scrollTop; const selectors = this.getChatContentSelectors(); if (!selectors.length) return null; // 查找所有候选元素 const candidates = Array.from(container.querySelectorAll(selectors.join(', '))); if (!candidates.length) return null; let bestElement = null; for (let i = 0; i < candidates.length; i++) { const el = candidates[i]; const top = el.offsetTop; // 策略:找到最后一个"顶部"位于视口上方(或刚露出)的元素 = 用户当前正在阅读的起始元素 if (top <= scrollTop + 100) { bestElement = el; } else { // 后续元素都在视口下方,停止 break; } } if (!bestElement && candidates.length > 0) bestElement = candidates[0]; if (bestElement) { const offset = scrollTop - bestElement.offsetTop; let selector = ''; let id = bestElement.getAttribute('data-message-id') || bestElement.id; if (id) { selector = `[data-message-id="${id}"]`; if (!bestElement.matches(selector)) selector = `#${id}`; return {type: 'selector', selector: selector, offset: offset}; } else { const globalIndex = candidates.indexOf(bestElement); if (globalIndex !== -1) { // 增强:记录文本指纹,防止历史加载导致索引偏移 const textSignature = (bestElement.textContent || '').trim().substring(0, 50); return {type: 'index', index: globalIndex, offset: offset, textSignature: textSignature}; } } } return null; } /** * 根据保存的锚点信息恢复滚动 * @param {Object} anchorData * @returns {boolean} 是否成功恢复 */ restoreScroll(anchorData) { const container = this.getScrollContainer(); if (!container || !anchorData) return false; let targetElement = null; if (anchorData.type === 'selector' && anchorData.selector) { targetElement = container.querySelector(anchorData.selector); } else if (anchorData.type === 'index' && typeof anchorData.index === 'number') { const selectors = this.getChatContentSelectors(); const candidates = Array.from(container.querySelectorAll(selectors.join(', '))); // 优先尝试使用索引 if (candidates[anchorData.index]) { targetElement = candidates[anchorData.index]; // 如果有文本指纹,进行校验 if (anchorData.textSignature) { const currentText = (targetElement.textContent || '').trim().substring(0, 50); // 如果文本不匹配,说明索引可能偏移了(例如加载了历史消息) // 此时尝试全列表搜索 if (currentText !== anchorData.textSignature) { // console.log('Anchor index mismatch, searching by text signature...'); const found = candidates.find(c => (c.textContent || '').trim().substring(0, 50) === anchorData.textSignature); if (found) targetElement = found; } } } else { // 索引越界(可能消息被删了?),尝试文本搜索 if (anchorData.textSignature) { const found = candidates.find(c => (c.textContent || '').trim().substring(0, 50) === anchorData.textSignature); if (found) targetElement = found; } } } if (targetElement) { const targetTop = targetElement.offsetTop + (anchorData.offset || 0); container.scrollTo({top: targetTop, behavior: 'instant'}); return true; } return false; } /** * 页面加载完成后执行 * @param {Object} options - 配置项 { clearOnInit: boolean, lockModel: boolean } */ afterPropertiesSet(options = {}) { const {modelLockConfig} = options; // 默认初始化逻辑:如果有模型锁定配置且启用,尝试锁定模型 if (modelLockConfig && modelLockConfig.enabled) { console.log(`[${this.getName()}] Triggering auto model lock:`, modelLockConfig.keyword); this.lockModel(modelLockConfig.keyword); } } /** * 判断是否应该将样式注入到指定的 Shadow Host 中 * 用于解决 Shadow DOM 样式污染问题 */ shouldInjectIntoShadow(host) { return true; } /** * 获取对话历史容器的选择器 * @returns {string} CSS 选择器 */ getResponseContainerSelector() { return ''; } /** * 获取聊天内容元素的选择器列表 * 用于 MutationObserver 检测新消息,配合滚动锁定功能 * @returns {string[]} CSS 选择器列表 */ getChatContentSelectors() { return []; } /** * 从页面提取大纲(标题列表) * @param {number} maxLevel 最大标题级别 (1-6) * @returns {Array<{level: number, text: string, element: Element|null}>} */ extractOutline(maxLevel = 6) { return []; } /** * 是否支持滚动锁定功能 * @returns {boolean} */ supportsScrollLock() { return false; // 默认不支持,除非子类明确声明 } // ============= 新对话监听 ============= /** * 获取“新对话”按钮的选择器列表 * @returns {string[]} */ getNewChatButtonSelectors() { return []; } /** * 绑定新对话触发事件(点击按钮或快捷键) * @param {Function} callback - 触发时的回调函数 */ bindNewChatListeners(callback) { // 1. 快捷键监听 (Ctrl + Shift + O) document.addEventListener('keydown', (e) => { if (e.ctrlKey && e.shiftKey && (e.key === 'o' || e.key === 'O')) { console.log(`[${this.getName()}] New chat shortcut detected.`); // 给予一点延迟等待页面响应 setTimeout(callback, 500); } }); // 2. 按钮点击监听 document.addEventListener('click', (e) => { const selectors = this.getNewChatButtonSelectors(); if (selectors.length === 0) return; // 使用 composedPath() 以支持 Shadow DOM 中的元素匹配 const path = e.composedPath(); for (const target of path) { if (target === document || target === window) break; for (const selector of selectors) { if (target.matches && target.matches(selector)) { console.log(`[${this.getName()}] New chat button clicked.`); setTimeout(callback, 500); return; } } } }, true); // 使用捕获阶段确保捕获 } // ============= 模型锁定功能(抽象接口) ============= /** * 获取默认的模型锁定设置(每个站点可覆盖) * @returns {{ enabled: boolean, keyword: string }} */ getDefaultLockSettings() { return {enabled: false, keyword: ''}; } /** * 获取模型锁定配置 * 子类需要覆盖此方法提供具体配置 * @param {string} keyword - 目标模型关键字(由设置传入) * @returns {{ * targetModelKeyword: string, // 目标模型名称关键字(用于匹配) * selectorButtonSelectors: string[], // 模型选择器按钮的 CSS 选择器列表 * menuItemSelector: string, // 菜单项的 CSS 选择器 * checkInterval: number, // 检查间隔(毫秒) * maxAttempts: number, // 最大尝试次数 * menuRenderDelay: number // 菜单渲染等待时间(毫秒) * }|null} */ getModelSwitcherConfig(keyword) { return null; } /** /** * 通用模型锁定实现 * 基于 getModelSwitcherConfig() 返回的配置执行锁定逻辑 * @param {string} keyword - 目标模型关键字 * @param {Function} onSuccess 成功后的回调(可选) */ lockModel(keyword, onSuccess = null) { const config = this.getModelSwitcherConfig(keyword); if (!config) return; const { targetModelKeyword, selectorButtonSelectors, menuItemSelector, checkInterval = 1500, maxAttempts = 20, menuRenderDelay = 500 } = config; let attempts = 0; let isSelecting = false; // 辅助函数:标准化文本(小写 + 去空) const normalize = str => (str || '').toLowerCase().trim(); const target = normalize(targetModelKeyword); const timer = setInterval(() => { attempts++; if (attempts > maxAttempts) { console.warn(`Gemini Helper: Model lock timed out for "${targetModelKeyword}"`); clearInterval(timer); return; } if (isSelecting) return; // 1. 查找模型选择器按钮 const selectorBtn = this.findElementBySelectors(selectorButtonSelectors); if (!selectorBtn) return; // 2. 检查当前是否已经是目标模型(不区分大小写) const currentText = selectorBtn.textContent || selectorBtn.innerText || ''; if (normalize(currentText).includes(target)) { console.log(`Gemini Helper: Model is already locked to "${targetModelKeyword}"`); clearInterval(timer); if (onSuccess) onSuccess(); return; } // 3. 标记正在选择 isSelecting = true; // 4. 点击展开菜单 selectorBtn.click(); // 5. 等待菜单渲染后查找并点击目标项 setTimeout(() => { const menuItems = this.findAllElementsBySelector(menuItemSelector); // 如果找到了菜单项,说明菜单已渲染 if (menuItems.length > 0) { let found = false; for (const item of menuItems) { const itemText = item.textContent || item.innerText || ''; // 不区分大小写匹配 if (normalize(itemText).includes(target)) { item.click(); found = true; clearInterval(timer); console.log(`Gemini Helper: Switched to model "${targetModelKeyword}"`); // 延迟关闭菜单面板 setTimeout(() => { document.body.click(); if (onSuccess) onSuccess(); }, 100); break; } } if (!found) { // 菜单已打开但没有找到目标模型,停止重试以避免死循环闪烁 console.warn(`Gemini Helper: Target model "${targetModelKeyword}" not found in menu. Aborting.`); clearInterval(timer); // 关键:停止定时器 document.body.click(); // 关闭菜单 isSelecting = false; } } else { // 菜单可能未渲染或选择器不匹配,允许重试(直到超时) isSelecting = false; document.body.click(); // 尝试关闭以重置状态 } }, menuRenderDelay); }, checkInterval); } /** * 通过选择器列表查找单个元素(支持 Shadow DOM) * @param {string[]} selectors * @returns {Element|null} */ findElementBySelectors(selectors) { // 使用 DOMToolkit 进行 Shadow DOM 穿透查找 return DOMToolkit.query(selectors, {shadow: true}); } /** * 通过选择器查找所有元素(支持 Shadow DOM) * @param {string} selector * @returns {Element[]} */ findAllElementsBySelector(selector) { // 使用 DOMToolkit 进行 Shadow DOM 穿透查找(返回所有匹配) return DOMToolkit.query(selector, {all: true, shadow: true}); } } /** * Gemini 适配器(gemini.google.com) */ class GeminiAdapter extends SiteAdapter { match() { return window.location.hostname.includes('gemini.google') && !window.location.hostname.includes('business.gemini.google'); } getSiteId() { return 'gemini'; } getName() { return 'Gemini'; } getThemeColors() { return {primary: '#4285f4', secondary: '#34a853'}; } getNewTabUrl() { return 'https://gemini.google.com/app'; } isNewConversation() { const path = window.location.pathname; return path === '/app' || path === '/app/'; } getSessionName() { // 从侧边栏活动对话标题获取 const titleEl = document.querySelector('.conversation-title'); if (titleEl) { const name = titleEl.textContent?.trim(); if (name) return name; } // 回退到基类默认实现(从 document.title 提取) return super.getSessionName(); } getNewChatButtonSelectors() { return [ '.new-chat-button', '.chat-history-new-chat-button', '[aria-label="New chat"]', '[aria-label="新对话"]', '[aria-label="发起新对话"]', '[data-testid="new-chat-button"]', '[data-test-id="new-chat-button"]', '[data-test-id="expanded-button"]', // 临时对话按钮 '[data-test-id="temp-chat-button"]', 'button[aria-label="临时对话"]' ]; } getWidthSelectors() { return [ {selector: '.conversation-container', property: 'max-width'}, {selector: '.input-area-container', property: 'max-width'}, // 用户消息右对齐 { selector: 'user-query', property: 'max-width', value: '100%', noCenter: true, extraCss: 'display: flex !important; justify-content: flex-end !important;' }, { selector: '.user-query-container', property: 'max-width', value: '100%', noCenter: true, extraCss: 'justify-content: flex-end !important;' } ]; } getTextareaSelectors() { return [ 'div[contenteditable="true"].ql-editor', 'div[contenteditable="true"]', '[role="textbox"]', '[aria-label*="Enter a prompt"]' ]; } getSubmitButtonSelectors() { return [ 'button[aria-label*="Send"]', 'button[aria-label*="发送"]', '.send-button', '[data-testid*="send"]' ]; } isValidTextarea(element) { // 必须是可见的 contenteditable 元素 if (element.offsetParent === null) return false; const isContentEditable = element.getAttribute('contenteditable') === 'true'; const isTextbox = element.getAttribute('role') === 'textbox'; // 排除脚本自身的 UI if (element.closest('#gemini-helper-panel')) return false; return (isContentEditable || isTextbox) || element.classList.contains('ql-editor'); } insertPrompt(content) { const editor = this.textarea; if (!editor) return false; editor.focus(); try { // 先全选 document.execCommand('selectAll', false, null); // 然后插入新内容 const success = document.execCommand('insertText', false, content); if (!success) { throw new Error('execCommand returned false'); } } catch (e) { // 降级方案:直接替换内容,不叠加 editor.textContent = content; editor.dispatchEvent(new Event('input', {bubbles: true})); editor.dispatchEvent(new Event('change', {bubbles: true})); } return true; } clearTextarea() { if (this.textarea) { this.textarea.focus(); document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } } getResponseContainerSelector() { return 'infinite-scroller.chat-history'; } getChatContentSelectors() { return [ '.model-response-container', 'model-response', '.response-container', '[data-message-id]', 'message-content' ]; } extractOutline(maxLevel = 6) { const outline = []; const container = document.querySelector(this.getResponseContainerSelector()); if (!container) return outline; // Gemini 使用标准的 h1-h6 标签,带有 data-path-to-node 属性 const headingSelectors = []; for (let i = 1; i <= maxLevel; i++) { headingSelectors.push(`h${i}`); } const headings = container.querySelectorAll(headingSelectors.join(', ')); headings.forEach(heading => { const level = parseInt(heading.tagName.charAt(1), 10); if (level <= maxLevel) { outline.push({ level, text: heading.textContent.trim(), element: heading }); } }); return outline; } /** * 检测 AI 是否正在生成响应 * Gemini 标准版:检查输入框右下角是否显示停止图标 * @returns {boolean} */ isGenerating() { // 检查是否存在 fonticon="stop" 的 mat-icon(停止按钮) const stopIcon = document.querySelector('mat-icon[fonticon="stop"]'); if (stopIcon && stopIcon.offsetParent !== null) { return true; } return false; } /** * 获取当前使用的模型名称 * Gemini 标准版:从页面 UI 中提取模型名称 * @returns {string|null} */ getModelName() { // 从 .input-area-switch-label 的第一个 span 获取模型名称 const switchLabel = document.querySelector('.input-area-switch-label'); if (switchLabel) { const firstSpan = switchLabel.querySelector('span'); if (firstSpan && firstSpan.textContent) { const text = firstSpan.textContent.trim(); if (text.length > 0 && text.length <= 20) { return text; } } } return null; } // ============= 网络监控配置(用于后台任务完成检测) ============= /** * Gemini 普通版的网络监控配置 * 由于浏览器对后台标签页的 DOM 渲染节流,需要通过 Hook Fetch 从网络层检测任务完成 */ getNetworkMonitorConfig() { return { // 注意:不要使用 batchexecute,它是通用 RPC 方法,会在后台频繁调用 urlPatterns: ['BardFrontendService', 'StreamGenerate'], silenceThreshold: 3000 }; } // ============= 模型锁定配置 ============= getDefaultLockSettings() { return {enabled: false, keyword: ''}; } getModelSwitcherConfig(keyword) { return { targetModelKeyword: keyword, // 尝试匹配 Gemini 普通版的模型选择器 selectorButtonSelectors: [ '.input-area-switch-label', '.model-selector', '[data-test-id="model-selector"]', '[aria-label*="model"]', 'button[aria-haspopup="menu"]' ], menuItemSelector: '.mode-title, [role="menuitem"], [role="option"]', checkInterval: 1000, maxAttempts: 15, menuRenderDelay: 300 }; } } /** * Gemini Business 适配器(business.gemini.google) */ class GeminiBusinessAdapter extends SiteAdapter { match() { return window.location.hostname.includes('business.gemini.google'); } getSiteId() { return 'gemini-business'; } getName() { return 'Enterprise'; } getThemeColors() { return {primary: '#4285f4', secondary: '#34a853'}; } getNewTabUrl() { return 'https://business.gemini.google'; } supportsTabRename() { return true; } isNewConversation() { return !window.location.pathname.includes('/session/'); } // 排除侧边栏 (mat-sidenav, mat-drawer) 中的 Shadow DOM shouldInjectIntoShadow(host) { if (host.closest('mat-sidenav') || host.closest('mat-drawer') || host.closest('[class*="bg-sidebar"]')) return false; return true; } getNewChatButtonSelectors() { return ['.chat-button.list-item', 'button[aria-label="New chat"]', 'button[aria-label="新对话"]']; } getWidthSelectors() { // 辅助函数:生成带 scoped globalSelector 的配置 // noCenter: 不添加 margin-left/right: auto(用于容器类元素) const config = (selector, value, extraCss, noCenter = false) => ({ selector, globalSelector: `mat-sidenav-content ${selector}`, // 全局样式只针对主内容区 property: 'max-width', value, extraCss, noCenter }); return [ // 容器强制 100%,不需要居中(它们应该填充可用空间) config('mat-sidenav-content', '100%', undefined, true), config('.main.chat-mode', '100%', undefined, true), // 内容区域跟随配置(需要居中) config('ucs-summary'), config('ucs-conversation'), config('ucs-search-bar'), config('.summary-container.expanded'), config('.conversation-container'), // 输入框容器:不居中,使用 left/right 定位 config('.input-area-container', undefined, 'left: 0 !important; right: 0 !important;', true) ]; } getTextareaSelectors() { return [ 'div.ProseMirror', '.ProseMirror', '[contenteditable="true"]:not([type="search"])', '[role="textbox"]', 'textarea:not([type="search"])' ]; } getSubmitButtonSelectors() { return [ 'button[aria-label*="Submit"]', 'button[aria-label*="提交"]', '.send-button', '[data-testid*="send"]' ]; } isValidTextarea(element) { // 排除搜索框 if (element.type === 'search') return false; if (element.classList.contains('main-input')) return false; if (element.getAttribute('aria-label')?.includes('搜索')) return false; if (element.placeholder?.includes('搜索')) return false; // 排除脚本自己的 UI if (element.classList.contains('prompt-search-input')) return false; if (element.id === 'prompt-search') return false; if (element.closest('#gemini-helper-panel')) return false; // 必须是 contenteditable 或者 ProseMirror const isVisible = element.offsetParent !== null; const isContentEditable = element.getAttribute('contenteditable') === 'true'; const isProseMirror = element.classList.contains('ProseMirror'); return isVisible && (isContentEditable || isProseMirror || element.tagName === 'TEXTAREA'); } findTextarea() { // 使用 DOMToolkit.query + filter 在 Shadow DOM 中查找 // filter 参数实现了 isValidTextarea 的验证逻辑 const element = DOMToolkit.query(this.getTextareaSelectors(), { shadow: true, filter: (el) => this.isValidTextarea(el) }); if (element) { this.textarea = element; return element; } return super.findTextarea(); } insertPrompt(content) { return new Promise((resolve) => { const tryInsert = () => { // 重新获取一下,以防切页面后元素失效 const editor = this.textarea || this.findTextarea(); if (!editor) { console.warn('GeminiBusinessAdapter: Editor not found during insert.'); resolve(false); return; } this.textarea = editor; // 更新引用 editor.click(); editor.focus(); // 等待一小段时间后尝试插入 setTimeout(() => { try { // 先全选 document.execCommand('selectAll', false, null); // 插入新内容 const success = document.execCommand('insertText', false, content); if (!success) throw new Error('execCommand returned false'); resolve(true); } catch (e) { // 方法2: 直接操作 DOM (降级方案) let p = editor.querySelector('p'); if (!p) { p = document.createElement('p'); editor.appendChild(p); } p.textContent = content; // 触发各种事件以通知 ProseMirror 更新 const inputEvent = new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: content }); editor.dispatchEvent(inputEvent); editor.dispatchEvent(new Event('change', {bubbles: true})); // 尝试触发 keyup 事件 editor.dispatchEvent(new KeyboardEvent('keyup', {bubbles: true})); resolve(true); } }, 100); }; if (this.textarea && document.body.contains(this.textarea)) { tryInsert(); } else { // 轮询等待元素出现 let attempts = 0; const maxAttempts = 15; const checkInterval = setInterval(() => { attempts++; if (this.findTextarea()) { clearInterval(checkInterval); tryInsert(); } else if (attempts >= maxAttempts) { clearInterval(checkInterval); resolve(false); } }, 500); } }); } clearTextarea() { if (this.textarea) { this.textarea.focus(); document.execCommand('selectAll', false, null); // 插入零宽空格替换旧内容(修复中文输入首字母问题) document.execCommand('insertText', false, '\u200B'); } } // 普通清空(不插入零宽字符) clearTextareaNormal() { if (this.textarea) { this.textarea.focus(); document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } } afterPropertiesSet(options = {}) { // 保存配置状态供其他方法使用 this.clearOnInit = options.clearOnInit; // 1. 调用基类通用逻辑(处理模型锁定) super.afterPropertiesSet(options); // 2. 处理企业版特有的初始化清除(如果未启用模型锁定或模型已锁定,这里先执行一次以防万一) // 注意:如果 trigger 了 lockModel,lockModel 回调里会再次执行。 if (this.clearOnInit) { this.clearTextarea(); } } // 覆盖 lockModel 以处理锁定后的清理 lockModel(keyword, onSuccess = null) { super.lockModel(keyword, () => { // 执行传入的回调 if (onSuccess) onSuccess(); // 执行企业版特定的清理:锁定模型后,重新插入零宽字符修复中文输入 // 这里的延迟是为了等待 UI 刷新(切换模型会导致输入框重建或重置) if (this.clearOnInit) { setTimeout(() => this.clearTextarea(), 300); } }); } /** * 检测 AI 是否正在生成响应 * Gemini Business:检查 Shadow DOM 中的 "Stop" 按钮或 loading 指示器 * @returns {boolean} */ isGenerating() { // 递归在 Shadow DOM 中搜索 const findInShadow = (root, depth = 0) => { if (depth > 10) return false; // 检查当前层级 const stopButton = root.querySelector( 'button[aria-label*="Stop"], button[aria-label*="停止"], ' + '[data-test-id="stop-button"], .stop-button, md-icon-button[aria-label*="Stop"]' ); if (stopButton && stopButton.offsetParent !== null) { return true; } const spinner = root.querySelector( 'mat-spinner, md-spinner, .loading-spinner, [role="progressbar"], ' + '.generating-indicator, .response-loading' ); if (spinner && spinner.offsetParent !== null) { return true; } // 递归搜索 Shadow DOM const elements = root.querySelectorAll('*'); for (const el of elements) { if (el.shadowRoot) { if (findInShadow(el.shadowRoot, depth + 1)) { return true; } } } return false; }; return findInShadow(document); } /** * 获取当前使用的模型名称 * Gemini Business:从 Shadow DOM 中提取模型名称 * @returns {string|null} */ getModelName() { // 递归在 Shadow DOM 中搜索模型选择器 const findInShadow = (root, depth = 0) => { if (depth > 10) return null; // 检查模型选择器 const modelSelectors = [ '#model-selector-menu-anchor', '.action-model-selector', '.model-selector', '[data-test-id="model-selector"]', '.current-model' ]; for (const selector of modelSelectors) { const el = root.querySelector(selector); if (el && el.textContent) { const text = el.textContent.trim(); // 提取模型关键字(支持带版本号的如"2.5 Pro",也支持不带版本号的如"自动") const modelMatch = text.match(/(\d+\.?\d*\s*)?(Pro|Flash|Ultra|Nano|Gemini|auto|自动)/i); if (modelMatch) { return modelMatch[0].trim(); } if (text.length <= 20 && text.length > 0) { return text; } } } // 递归搜索 Shadow DOM const elements = root.querySelectorAll('*'); for (const el of elements) { if (el.shadowRoot) { const result = findInShadow(el.shadowRoot, depth + 1); if (result) return result; } } return null; }; return findInShadow(document); } // ============= 模型锁定配置 ============= getDefaultLockSettings() { return {enabled: true, keyword: '3 Pro'}; } getModelSwitcherConfig(keyword) { return { targetModelKeyword: keyword || '3 Pro', selectorButtonSelectors: ['#model-selector-menu-anchor', '.action-model-selector'], menuItemSelector: 'md-menu-item', checkInterval: 1500, maxAttempts: 20, menuRenderDelay: 500 }; } getResponseContainerSelector() { // Gemini Business 使用 Shadow DOM,返回空字符串表示需要特殊处理 return ''; } getChatContentSelectors() { return [ '.model-response-container', '.message-content', '[data-message-id]', // 常见消息标识 'ucs-conversation-message', // 企业版特定 '.conversation-message' ]; } extractOutline(maxLevel = 6) { const outline = []; // 在 Shadow DOM 中递归查找所有标题 this.findHeadingsInShadowDOM(document, outline, maxLevel, 0); return outline; } // 在 Shadow DOM 中递归查找标题 findHeadingsInShadowDOM(root, outline, maxLevel, depth) { if (depth > 15) return; // 在当前层级查找标题(h1-h6) if (root !== document) { const headingSelector = Array.from({length: maxLevel}, (_, i) => `h${i + 1}`).join(', '); try { const headings = root.querySelectorAll(headingSelector); headings.forEach(heading => { // 只匹配包含 data-markdown-start-index 的标题(排除 logo 等非 AI 回复内容) // 标题内可能包含多个 span,需要遍历所有 span 并拼接文本 const spans = heading.querySelectorAll('span[data-markdown-start-index]'); if (spans.length > 0) { const level = parseInt(heading.tagName[1], 10); const text = Array.from(spans).map(s => s.textContent.trim()).join(''); if (text) { outline.push({level, text, element: heading}); } } }); } catch (e) { // 忽略选择器错误 } } // 递归查找 Shadow DOM const allElements = root.querySelectorAll('*'); for (const el of allElements) { if (el.shadowRoot) { this.findHeadingsInShadowDOM(el.shadowRoot, outline, maxLevel, depth + 1); } } } } /** * Genspark 适配器(genspark.ai) */ class GensparkAdapter extends SiteAdapter { match() { return window.location.hostname.includes('genspark.ai'); } getSiteId() { return 'genspark'; } getName() { return 'Genspark'; } getThemeColors() { return {primary: '#667eea', secondary: '#764ba2'}; } getNewTabUrl() { return 'https://www.genspark.ai'; } isNewConversation() { const path = window.location.pathname; return path === '/' || path === '/agents' || path === '/agents/'; } getWidthSelectors() { // Genspark 暂时不实现加宽,预留接口 return []; } getTextareaSelectors() { return [ 'textarea[name="query"]', 'textarea.search-input', '.textarea-wrapper textarea', 'textarea[placeholder*="Message"]' ]; } getSubmitButtonSelectors() { return [ 'button[aria-label*="Send"]', 'button[aria-label*="发送"]', '.send-button', '[data-testid*="send"]' ]; } getChatContentSelectors() { return [ '.message-content', '.markdown-body', '[data-testid="chat-message"]' ]; } insertPrompt(content) { if (!this.textarea) return false; const currentContent = this.textarea.value.trim(); this.textarea.value = currentContent ? (content + '\n\n' + currentContent) : (content + '\n\n'); this.adjustTextareaHeight(); this.textarea.dispatchEvent(new Event('input', {bubbles: true})); this.textarea.focus(); return true; } adjustTextareaHeight() { if (this.textarea) { this.textarea.style.height = 'auto'; this.textarea.style.height = Math.min(this.textarea.scrollHeight, 200) + 'px'; } } clearTextarea() { if (this.textarea) { this.textarea.value = ''; this.textarea.dispatchEvent(new Event('input', {bubbles: true})); this.adjustTextareaHeight(); } } supportsScrollLock() { return false; } } /** * 标签页重命名管理器 * 根据当前对话名称自动更新浏览器标签页标题 */ class TabRenameManager { constructor(adapter, settings, i18nFunc = null) { this.adapter = adapter; this.settings = settings; this.t = i18nFunc || ((key) => key); this.lastSessionName = null; this.intervalId = null; this.networkMonitor = null; this.isRunning = false; // AI 生成状态(简化的状态机) // 'idle' | 'generating' | 'completed' this._aiState = 'idle'; this._lastAiState = 'idle'; } /** * 启动自动重命名 */ start() { if (this.isRunning) return; if (!this.adapter.supportsTabRename()) return; this.isRunning = true; this.updateTabName(); // 启动网络监控(用于后台检测) this._networkConfig = this.adapter.getNetworkMonitorConfig?.(); if (typeof NetworkMonitor !== 'undefined' && this._networkConfig) { this._initNetworkMonitor(); } // 定时更新标签页标题 const intervalMs = (this.settings.tabSettings?.renameInterval || 5) * 1000; this.intervalId = setInterval(() => this.updateTabName(), intervalMs); } /** * 初始化网络监控 */ _initNetworkMonitor() { if (this.networkMonitor || !this._networkConfig) return; this.networkMonitor = new NetworkMonitor({ urlPatterns: this._networkConfig.urlPatterns, silenceThreshold: this._networkConfig.silenceThreshold || 3000, onStart: () => this._setAiState('generating'), onComplete: () => this._onAiComplete() }); this.networkMonitor.start(); } /** * 设置 AI 状态 */ _setAiState(state) { this._lastAiState = this._aiState; this._aiState = state; } /** * AI 任务完成处理(由 NetworkMonitor 触发) */ _onAiComplete() { const wasGenerating = this._aiState === 'generating'; this._setAiState('completed'); // 只在后台且之前正在生成时触发通知 if (wasGenerating && document.hidden) { this._sendCompletionNotification(); } // 强制更新标签页标题 this.updateTabName(true); } /** * 发送完成通知 */ _sendCompletionNotification() { const tabSettings = this.settings.tabSettings || {}; if (tabSettings.showNotification && typeof GM_notification !== 'undefined') { GM_notification({ title: this.t('notificationTitle').replace('{site}', this.adapter.getName()), text: this.lastSessionName || this.t('notificationBody'), timeout: 5000, onclick: () => window.focus() }); } if (tabSettings.autoFocus) { window.focus(); } } /** * 获取当前是否正在生成 */ _isGenerating() { // 如果已确认完成,返回 false if (this._aiState === 'completed') return false; // 否则结合网络状态和 DOM 检测 return this._aiState === 'generating' || this.adapter.isGenerating(); } /** * 停止网络监控 */ _stopNetworkMonitor() { if (this.networkMonitor) { this.networkMonitor.stop(); this.networkMonitor = null; } } /** * 停止自动重命名 */ stop() { if (!this.isRunning) return; this.isRunning = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this._stopNetworkMonitor(); } /** * 更新检测频率 */ setInterval(intervalSeconds) { if (!this.isRunning) return; const intervalMs = intervalSeconds * 1000; if (this.intervalId) { clearInterval(this.intervalId); } this.intervalId = setInterval(() => this.updateTabName(), intervalMs); } /** * 切换隐私模式 */ togglePrivacyMode() { const tabSettings = this.settings.tabSettings || {}; tabSettings.privacyMode = !tabSettings.privacyMode; this.settings.tabSettings = tabSettings; this.updateTabName(true); return tabSettings.privacyMode; } /** * 更新标签页名称 */ updateTabName(force = false) { if (!this.adapter.supportsTabRename()) return; const tabSettings = this.settings.tabSettings || {}; // 隐私模式 if (tabSettings.privacyMode) { document.title = tabSettings.privacyTitle || 'Google'; return; } // 获取会话名称(防止读取被污染的 title) const sessionName = this._getCleanSessionName(tabSettings); // 检查生成状态 const isGenerating = this._isGenerating(); // DOM 检测的状态变更通知(仅用于没有网络监控的站点) if (this._lastAiState === 'generating' && !isGenerating && document.hidden && this._aiState !== 'completed') { this._sendCompletionNotification(); } this._lastAiState = isGenerating ? 'generating' : 'idle'; // 构建标题 const statusPrefix = (tabSettings.showStatus !== false) ? (isGenerating ? '⏳ ' : '✅ ') : ''; const format = tabSettings.titleFormat || '{status}{title}'; const modelName = format.includes('{model}') ? (this.adapter.getModelName() || '') : ''; let finalTitle = format .replace('{status}', statusPrefix) .replace('{title}', sessionName || this.adapter.getName()) .replace('{model}', modelName ? `[${modelName}] ` : '') .replace(/\s+/g, ' ') .trim(); if (finalTitle && (force || finalTitle !== document.title)) { document.title = finalTitle; } } /** * 获取干净的会话名称(过滤被污染的标题) */ _getCleanSessionName(tabSettings) { // 新对话页面:清除旧会话标题,避免使用之前的标题 if (this.adapter.isNewConversation()) { this.lastSessionName = null; return null; } let sessionName = this.adapter.getSessionName(); // 检测污染 const isPolluted = (name) => { if (!name) return false; if (/^[⏳✅]/.test(name)) return true; if (/\[[\w\s.]+\]/.test(name)) return true; if (name === (tabSettings.privacyTitle || 'Google')) return true; return false; }; if (isPolluted(sessionName)) { sessionName = this.lastSessionName; } else if (sessionName && sessionName !== this.lastSessionName) { this.lastSessionName = sessionName; } return this.lastSessionName; } /** * 获取当前状态 */ isActive() { return this.isRunning; } } /** * 站点注册表 * 管理所有站点适配器,提供统一的访问接口 */ class SiteRegistry { constructor() { this.adapters = []; this.currentAdapter = null; } // 注册适配器 register(adapter) { this.adapters.push(adapter); } // 检测并返回匹配的适配器 detect() { for (const adapter of this.adapters) { if (adapter.match()) { this.currentAdapter = adapter; return adapter; } } return null; } // 获取当前适配器 getCurrent() { return this.currentAdapter; } } // ==================== 核心逻辑 ==================== // HTML 创建函数 (使用 DOMToolkit) function createElement(tag, properties = {}, textContent = '') { return DOMToolkit.create(tag, properties, textContent); } // 清空元素内容 (使用 DOMToolkit) function clearElement(element) { DOMToolkit.clear(element); } /** * 页面宽度样式管理器 * 负责动态注入和移除页面宽度样式 */ /** * 页面宽度样式管理器 * 负责动态注入和移除页面宽度样式,支持 Shadow DOM */ class WidthStyleManager { constructor(siteAdapter, widthConfig) { this.siteAdapter = siteAdapter; this.widthConfig = widthConfig; this.styleElement = null; this.processedShadowRoots = new WeakSet(); this.observer = null; this.shadowCheckInterval = null; } apply() { // 1. 处理主文档样式 if (this.styleElement) { this.styleElement.remove(); this.styleElement = null; } const css = this.generateCSS(); if (this.widthConfig && this.widthConfig.enabled) { this.styleElement = document.createElement('style'); this.styleElement.id = 'gemini-helper-width-styles'; this.styleElement.textContent = css; document.head.appendChild(this.styleElement); // 启动 Shadow DOM 注入逻辑 this.startShadowInjection(css); } else { // 如果禁用了,也要清理 Shadow DOM 中的样式 this.stopShadowInjection(); this.clearShadowStyles(); } } generateCSS() { const globalWidth = `${this.widthConfig.value}${this.widthConfig.unit}`; const selectors = this.siteAdapter.getWidthSelectors(); return selectors.map((config) => { const {selector, globalSelector, property, value, extraCss, noCenter} = config; const params = { finalWidth: value || globalWidth, targetSelector: globalSelector || selector, // 优先使用全局特定选择器 property, extra: extraCss || '', centerCss: noCenter ? '' : 'margin-left: auto !important; margin-right: auto !important;' }; return `${params.targetSelector} { ${params.property}: ${params.finalWidth} !important; ${params.centerCss} ${params.extra} }`; }).join('\n'); } updateConfig(widthConfig) { this.widthConfig = widthConfig; this.apply(); } // ============= Shadow DOM 支持 ============= startShadowInjection(css) { // Shadow CSS 需要重新生成,因为不能使用带 ancestor 的 globalSelector // Shadow DOM 内部必须使用原始 selector,但包含同样的样式规则 const shadowCss = this.generateShadowCSS(); // 立即执行一次全量检查 this.injectToAllShadows(shadowCss); // 使用定时器定期检查 if (this.shadowCheckInterval) clearInterval(this.shadowCheckInterval); this.shadowCheckInterval = setInterval(() => { this.injectToAllShadows(shadowCss); }, 1000); } generateShadowCSS() { const globalWidth = `${this.widthConfig.value}${this.widthConfig.unit}`; const selectors = this.siteAdapter.getWidthSelectors(); return selectors.map((config) => { const {selector, property, value, extraCss, noCenter} = config; // Shadow DOM 中只使用原始 selector (不带父级限定),靠 JS 过滤来保证安全 const finalWidth = value || globalWidth; const extra = extraCss || ''; const centerCss = noCenter ? '' : 'margin-left: auto !important; margin-right: auto !important;'; return `${selector} { ${property}: ${finalWidth} !important; ${centerCss} ${extra} }`; }).join('\n'); } stopShadowInjection() { if (this.shadowCheckInterval) { clearInterval(this.shadowCheckInterval); this.shadowCheckInterval = null; } } injectToAllShadows(css) { if (!document.body) return; const siteAdapter = this.siteAdapter; const processedShadowRoots = this.processedShadowRoots; // 使用 DOMToolkit.walkShadowRoots 遍历所有 Shadow Root DOMToolkit.walkShadowRoots((shadowRoot, host) => { // 检查是否应该注入到该 Shadow DOM(通过 Adapter 过滤,例如排除侧边栏) if (host && !siteAdapter.shouldInjectIntoShadow(host)) { return; } // 使用 DOMToolkit.cssToShadow 注入样式 DOMToolkit.cssToShadow(shadowRoot, css, 'gemini-helper-width-shadow-style'); processedShadowRoots.add(shadowRoot); }); } clearShadowStyles() { if (!document.body) return; const processedShadowRoots = this.processedShadowRoots; // 使用 DOMToolkit.walkShadowRoots 遍历所有 Shadow Root DOMToolkit.walkShadowRoots((shadowRoot) => { const style = shadowRoot.getElementById('gemini-helper-width-shadow-style'); if (style) style.remove(); processedShadowRoots.delete(shadowRoot); }); } } // ==================== 滚动锁定管理器 ==================== /** * 滚动锁定管理器 * 通过劫持原生滚动 API 和 MutationObserver 修正来实现防自动滚动 */ class ScrollLockManager { constructor(siteAdapter) { this.siteAdapter = siteAdapter; this.enabled = false; this.originalApis = null; this.observer = null; this.cleanupInterval = null; this.lastScrollY = window.scrollY; } setEnabled(enabled) { if (this.enabled === enabled) return; this.enabled = enabled; if (enabled) { this.enable(); } else { this.disable(); } } enable() { console.log('Gemini Helper: Enabling Scroll Lock System'); this.hijackApis(); this.startObserver(); this.startScrollListener(); } disable() { console.log('Gemini Helper: Disabling Scroll Lock System'); this.restoreApis(); this.stopObserver(); this.stopScrollListener(); } hijackApis() { if (this.originalApis) return; // 已经劫持 // 保存原始 API this.originalApis = { scrollIntoView: Element.prototype.scrollIntoView, scrollTo: window.scrollTo, // 保存属性描述符以便恢复 scrollTopDescriptor: Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop') || Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop') }; const self = this; // 1. 劫持 Element.prototype.scrollIntoView Element.prototype.scrollIntoView = function (options) { // 检查是否包含绕过锁定的标志 (即使是 boolean or object) const shouldBypass = options && typeof options === 'object' && options.__bypassLock; if (self.enabled && self.shouldBlockScroll() && !shouldBypass) { // console.log('Gemini Helper: Blocked scrollIntoView'); return; } // 移除自定义属性以防传给原生 API 报错(虽然通常不会) if (shouldBypass) { // 克隆 options 以免修改原对象,或者直接删除 key // 原生 scrollIntoView 会忽略未知属性 } return self.originalApis.scrollIntoView.call(this, options); }; // 2. 劫持 window.scrollTo window.scrollTo = function (x, y) { // 有时 y 可能是 options 对象 let targetY = y; if (typeof x === 'object' && x !== null) { targetY = x.top; } // 只有当向下大幅滚动时才拦截 (防止系统自动拉到底) // 阈值设为 50px,避免误杀微小调整 if (self.enabled && self.shouldBlockScroll() && typeof targetY === 'number' && targetY > window.scrollY + 50) { // console.log('Gemini Helper: Blocked window.scrollTo (Auto-scroll attempt)'); return; } return self.originalApis.scrollTo.apply(this, arguments); }; // 3. 劫持 scrollTop setter (许多框架通过设置 scrollTop 来滚动) if (this.originalApis.scrollTopDescriptor) { Object.defineProperty(Element.prototype, 'scrollTop', { get: function () { return self.originalApis.scrollTopDescriptor.get ? self.originalApis.scrollTopDescriptor.get.call(this) : this.files; // fallback (impossible normally) }, set: function (value) { if (self.enabled && self.shouldBlockScroll() && value > this.scrollTop + 50) { // console.log('Gemini Helper: Blocked scrollTop setter'); return; } if (self.originalApis.scrollTopDescriptor.set) { self.originalApis.scrollTopDescriptor.set.call(this, value); } }, configurable: true }); } } restoreApis() { if (!this.originalApis) return; Element.prototype.scrollIntoView = this.originalApis.scrollIntoView; window.scrollTo = this.originalApis.scrollTo; if (this.originalApis.scrollTopDescriptor) { Object.defineProperty(Element.prototype, 'scrollTop', this.originalApis.scrollTopDescriptor); } this.originalApis = null; } // 判断是否应该阻止滚动 // 核心逻辑:虽然功能开启,但如果用户已经滚到底部了,我们其实应该允许跟随(就像终端一样) // 不过根据用户需求,既然叫 "防止自动滚动",还是激进一点:只要开启就尽量阻止非用户触发的大幅向下滚动 shouldBlockScroll() { // 只有当我们不在底部时,才强力阻止?或者一直阻止? // 为了最好的体验:如果用户已经在底部,应该允许新内容把页面撑长,但不应该发生"跳跃" // 用户的脚本逻辑很简单:开启就阻止。我们保持一致。 return true; } startScrollListener() { // 记录用户最后滚动位置,用于自动修正 const onScroll = () => { // 如果是用户手动滚动(或者未被劫持的滚动),更新位置 // 这里很难区分,但我们主要通过 MutationObserver 来回滚异常位置 if (this.enabled) { // 只有在未被拦截的情况下,我们才认为这是"合法"的位置更新 // 在 scroll 事件中很难拦截,只能事后修正 // 这里我们只更新 lastScrollY,具体修正在 Observer 中 this.lastScrollY = window.scrollY; } }; window.addEventListener('scroll', onScroll, {passive: true}); this.onScrollHandler = onScroll; } stopScrollListener() { if (this.onScrollHandler) { window.removeEventListener('scroll', this.onScrollHandler); this.onScrollHandler = null; } } startObserver() { // 监听 DOM 变化,如果发现非用户意图的滚动跳变,强制回滚 this.observer = new MutationObserver((mutations) => { if (!this.enabled) return; let hasNewContent = false; const contentSelectors = this.siteAdapter.getChatContentSelectors(); if (contentSelectors.length === 0) return; mutations.forEach(mutation => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { // 检查是否有新消息节点 for (const node of mutation.addedNodes) { if (node.nodeType === 1) { // Element // 使用适配器提供的选择器判断 for (const sel of contentSelectors) { if (node.matches && node.matches(sel) || (node.querySelector && node.querySelector(sel))) { hasNewContent = true; break; } } } if (hasNewContent) break; } } }); if (hasNewContent) { // 如果有新内容插入,立刻检查滚动位置是否发生了非预期的改变 // 这里的逻辑是:如果当前位置比记录的 lastScrollY 大了很多,说明发生了自动滚动 // 我们强制滚回去 const currentScroll = window.scrollY; // 阈值 100px if (currentScroll > this.lastScrollY + 100) { // console.log('Gemini Helper: Detected unblocked auto-scroll, changing back.'); window.scrollTo(this.lastScrollY, 0); // 使用原始 API 已经被劫持,这里需要 bypass 吗? // 实际上我们的劫持逻辑里 window.scrollTo 会调用 apply(this, arguments), // 但我们的劫持逻辑是阻止"向下"滚动。如果是"向上"回滚 (current > last, so set to last is moving up),是被允许的。 // 稍微解释:lastScrollY 是 1000,current 是 2000。window.scrollTo(1000) 是向上,允许。 // 所以直接调用 window.scrollTo 即可。 } } }); this.observer.observe(document.body, { childList: true, subtree: true }); // 定时器保底 this.cleanupInterval = setInterval(() => { if (this.enabled) { const current = window.scrollY; if (current > this.lastScrollY + 200) { // 大幅跳变,回滚 window.scrollTo(this.lastScrollY, 0); } else { // 小幅变动,认为是合法阅读,更新基准(防止页面慢慢变长后滚不下去) this.lastScrollY = current; } } }, 500); } stopObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } } } // ==================== 核心管理类 ==================== /** * 滚动管理器 * 抽象不同站点的滚动容器差异 */ class ScrollManager { constructor(siteAdapter) { this.siteAdapter = siteAdapter; } get container() { // 确保获取的是最新的容器实例 return this.siteAdapter.getScrollContainer(); } get scrollTop() { return this.container ? this.container.scrollTop : 0; } set scrollTop(val) { if (this.container) this.container.scrollTop = val; } get scrollHeight() { return this.container ? this.container.scrollHeight : 0; } get clientHeight() { return this.container ? this.container.clientHeight : 0; } scrollTo(options) { if (this.container) { try { this.container.scrollTo(options); } catch (e) { // 兼容部分旧浏览器不支持 options 对象 if (options.top !== undefined) { this.container.scrollTop = options.top; } } } } // 检查是否在底部区域 isAtBottom(threshold = 100) { const c = this.container; if (!c) return false; return c.scrollHeight - c.scrollTop - c.clientHeight <= threshold; } } /** * 阅读进度管理器 (Auto-Resume) * 负责自动保存和恢复阅读位置 */ class ReadingProgressManager { constructor(settings, scrollManager, i18nFunc) { this.settings = settings; // 引用传递,保持最新 this.scrollManager = scrollManager; this.t = i18nFunc; this.lastSaveTime = 0; this.isRecording = false; // 默认为 false,通过 startRecording 开启 } startRecording() { if (this.isRecording) return; this.isRecording = true; this.scrollHandler = () => this.handleScroll(); // 监听真正的滚动容器(各站点通过 SiteAdapter 适配) const container = this.scrollManager.container; if (container) { container.addEventListener('scroll', this.scrollHandler, {passive: true}); this.listeningContainer = container; // 保存引用以便移除 } // 同时保留 window 监听作为兜底(某些站点可能用 window 滚动) window.addEventListener('scroll', this.scrollHandler, {capture: true, passive: true}); } stopRecording() { if (!this.isRecording) return; this.isRecording = false; if (this.scrollHandler) { // 移除容器监听 if (this.listeningContainer) { this.listeningContainer.removeEventListener('scroll', this.scrollHandler); this.listeningContainer = null; } // 移除 window 监听 window.removeEventListener('scroll', this.scrollHandler, {capture: true}); this.scrollHandler = null; } } handleScroll() { if (!this.settings || !this.settings.readingHistory || !this.settings.readingHistory.persistence) return; const now = Date.now(); if (now - this.lastSaveTime > 1000) { this.saveProgress(); this.lastSaveTime = now; } } getKey() { // 使用 siteAdapter 提供的统一 Session ID,保持 Key 简洁且与其他功能逻辑一致 const sessionId = this.scrollManager.siteAdapter.getSessionId(); const siteId = this.scrollManager.siteAdapter.getSiteId(); return `${siteId}:${sessionId}`; } saveProgress() { if (!this.isRecording) return; // 新对话页面不记录阅读历史 if (this.scrollManager.siteAdapter.isNewConversation()) return; const scrollTop = this.scrollManager.scrollTop; if (scrollTop < 0) return; const key = this.getKey(); // 获取基于内容的锚点信息 (增强准确性) let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { anchorInfo = this.scrollManager.siteAdapter.getVisibleAnchorElement(); } } catch (err) { // console.error('Error getting visible anchor element:', err); } const data = { top: scrollTop, ts: Date.now(), ...((anchorInfo) ? anchorInfo : {}) }; const allData = GM_getValue('gemini_reading_progress', {}); allData[key] = data; GM_setValue('gemini_reading_progress', allData); } /** * 恢复阅读进度 (包含智能回溯逻辑) * @param {Function} showToastFunc - 用于显示进度提示的回调 * @returns {Promise} 是否恢复成功 */ async restoreProgress(showToastFunc) { if (!this.settings.readingHistory.autoRestore) return false; const key = this.getKey(); const allData = GM_getValue('gemini_reading_progress', {}); const data = allData[key]; if (!data) return false; // scrollManager.container 是 getter,每次访问自动获取最新容器 const scrollContainer = this.scrollManager.container; if (!scrollContainer) return false; // 智能回溯恢复逻辑 return new Promise((resolve) => { let historyLoadAttempts = 0; const maxHistoryLoadAttempts = 5; let lastScrollHeight = 0; // 用于检测历史是否加载成功 const tryScroll = (attempts = 0) => { if (attempts > 30) { // 超过最大尝试次数,使用像素位置作为最终降级 if (data.top !== undefined && scrollContainer.scrollHeight >= data.top) { this.scrollManager.scrollTo({top: data.top, behavior: 'instant'}); this.restoredTop = data.top; resolve(true); } else { resolve(false); } return; } // 1. 尝试基于内容的精准恢复 let contentRestored = false; try { if (data.type && this.scrollManager.siteAdapter.restoreScroll) { contentRestored = this.scrollManager.siteAdapter.restoreScroll(data); } } catch (err) { console.error('Error restoring content anchor:', err); } if (contentRestored) { // 内容恢复成功 this.restoredTop = scrollContainer.scrollTop; resolve(true); return; } // 2. 内容恢复失败,需要尝试加载更多历史 const currentScrollHeight = scrollContainer.scrollHeight; const heightChanged = currentScrollHeight !== lastScrollHeight; lastScrollHeight = currentScrollHeight; // 判断是否需要/可以继续加载历史 const hasContentAnchor = data.type && (data.textSignature || data.selector); const needsMoreHistory = hasContentAnchor || (data.top !== undefined && currentScrollHeight < data.top); const canLoadMore = historyLoadAttempts < maxHistoryLoadAttempts; if (needsMoreHistory && canLoadMore) { // 触发历史加载 if (showToastFunc) showToastFunc(`正在加载历史会话 (${historyLoadAttempts + 1}/${maxHistoryLoadAttempts})...`); // 滚动到顶部触发懒加载 this.scrollManager.scrollTo({top: 0, behavior: 'instant'}); historyLoadAttempts++; // 等待页面加载新内容 setTimeout(() => tryScroll(attempts + 1), 2000); } else if (data.top !== undefined && currentScrollHeight >= data.top) { // 没有内容锚点或已用尽回溯机会,但像素位置可用 this.scrollManager.scrollTo({top: data.top, behavior: 'instant'}); this.restoredTop = data.top; resolve(true); } else if (!canLoadMore && hasContentAnchor) { // 回溯机会用尽但仍有内容锚点,尝试最后一次快速重试 setTimeout(() => tryScroll(attempts + 1), 500); } else { // 无法恢复 resolve(false); } }; tryScroll(); }); } // 清理逻辑 cleanup() { const lastRun = GM_getValue('gemini_progress_cleanup_last_run', 0); const now = Date.now(); if (now - lastRun < 24 * 60 * 60 * 1000) return; // 每天一次 const days = this.settings.readingHistory.cleanupDays || 7; if (days === -1) return; const expireTime = days * 24 * 60 * 60 * 1000; const allData = GM_getValue('gemini_reading_progress', {}); let changed = false; Object.keys(allData).forEach(k => { if (now - allData[k].ts > expireTime) { delete allData[k]; changed = true; } }); if (changed) GM_setValue('gemini_reading_progress', allData); GM_setValue('gemini_progress_cleanup_last_run', now); } } /** * 智能锚点管理器 (Smart Session Anchor) * 负责会话内的临时跳转锚点 */ /** * 智能锚点管理器 (Smart Session Anchor) * 负责会话内的临时跳转锚点 */ class AnchorManager { constructor(scrollManager, i18nFunc) { this.scrollManager = scrollManager; this.t = i18nFunc; // 双位置交换:类似 git switch - this.previousAnchor = null; // 上一个位置(跳转前) this.currentAnchor = null; // 当前锚点(跳转目标) this.onAnchorChange = null; // UI 更新回调 } // 设置回调 bindUI(callback) { this.onAnchorChange = callback; } // 获取当前位置的完整锚点信息 _captureCurrentPosition() { let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { anchorInfo = this.scrollManager.siteAdapter.getVisibleAnchorElement(); } } catch (err) { } return { top: this.scrollManager.scrollTop, ts: Date.now(), ...anchorInfo }; } // 记录锚点 (跳转前调用,保存当前位置) setAnchor(top) { let anchorInfo = {}; try { if (this.scrollManager.siteAdapter.getVisibleAnchorElement) { anchorInfo = this.scrollManager.siteAdapter.getVisibleAnchorElement(); } } catch (err) { } // 保存当前位置为"上一个锚点" this.previousAnchor = { top: top, ts: Date.now(), ...anchorInfo }; if (this.onAnchorChange) this.onAnchorChange(true); } // 跳转到锚点(同时实现位置交换,支持来回跳转) backToAnchor() { if (!this.previousAnchor) return false; const scrollContainer = this.scrollManager.container; if (!scrollContainer) return false; // 1. 先保存当前位置(跳转后可以再跳回来) const currentPos = this._captureCurrentPosition(); // 2. 尝试跳转到 previousAnchor let jumped = false; // 2.1 尝试基于内容的精准恢复 try { if (this.previousAnchor.type && this.scrollManager.siteAdapter.restoreScroll) { jumped = this.scrollManager.siteAdapter.restoreScroll(this.previousAnchor); } } catch (err) { console.error('Error restoring anchor:', err); } // 2.2 降级:像素位置 if (!jumped && this.previousAnchor.top !== undefined) { this.scrollManager.scrollTo({top: this.previousAnchor.top, behavior: 'smooth'}); jumped = true; } if (jumped) { // 3. 交换位置:实现来回跳转 // 原来的 previousAnchor 变成 currentAnchor(备用) // 刚才的位置变成新的 previousAnchor(下次跳回去) this.currentAnchor = this.previousAnchor; this.previousAnchor = currentPos; } return jumped; } // 检查是否有锚点 hasAnchor() { return this.previousAnchor !== null; } // 重置锚点(用于会话切换) reset() { this.previousAnchor = null; this.currentAnchor = null; if (this.onAnchorChange) this.onAnchorChange(false); } } /** * 通用大纲管理器 * 负责大纲的 UI 渲染、交互和状态管理 * 数据源由外部适配器提供 */ class OutlineManager { constructor(config) { this.container = config.container; this.settings = config.settings; this.onSettingsChange = config.onSettingsChange; this.onJumpBefore = config.onJumpBefore; // 跳转前回调,用于保存锚点 this.t = config.i18n || ((k) => k); this.state = { tree: null, treeKey: '', minLevel: 1, expandLevel: this.settings.outline?.maxLevel || 6, levelCounts: {}, isAllExpanded: false, rawOutline: [], // 搜索相关状态 searchQuery: '', searchLevelManual: false, // 标记用户是否在搜索时手动调整了层级 searchResults: null, // 存储搜索匹配信息 { matchedIds: Set, relevantIds: Set } preSearchState: null, // 搜索前的状态快照 }; // 自动更新相关 this.observer = null; this.updateDebounceTimer = null; this.isActive = false; // 标记 Tab 是否激活 this.init(); } init() { this.createUI(); this.updateAutoUpdateState(); } setActive(active) { this.isActive = active; this.updateAutoUpdateState(); } updateAutoUpdateState() { // 只有当:大纲功能开启 AND 自动更新开启 AND Tab处于激活状态 时才启用 Observer const shouldEnable = this.settings.outline?.enabled && this.settings.outline?.autoUpdate && this.isActive; if (shouldEnable) { this.startObserver(); } else { this.stopObserver(); } } startObserver() { if (this.observer) return; // 找到聊天记录容器作为观察目标 // 既然我们增加了 getChatContentSelectors,也许可以用那个? // 但对于大纲来说,只要 DOM 变了就可能产生新标题。观察 body 可能最稳妥但性能最差。 // 观察聊天容器是折中方案。 // 复用 SiteAdapter 的 getScrollContainer 得到的通常是主滚动容器, // 或者用 getResponseContainerSelector // 鉴于 Gemini Business 返回空,我们尝试观察 document.body,加上防抖,性能应该可控。 this.observer = new MutationObserver(() => { this.triggerAutoUpdate(); }); this.observer.observe(document.body, { childList: true, subtree: true, characterData: true // 标题文字变化也要检测 }); console.log('Gemini Helper: Outline Auto-Update Started'); } stopObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; console.log('Gemini Helper: Outline Auto-Update Stopped'); } if (this.updateDebounceTimer) { clearTimeout(this.updateDebounceTimer); this.updateDebounceTimer = null; } } triggerAutoUpdate() { const interval = (this.settings.outline?.updateInterval || 5) * 1000; // 如果已经在等待更新,不需要重置定时器(这是 throttle/debounce 的关键区别) // 我们希望:只要有请求,就确保在未来某个时刻执行,但不要频繁执行 // 策略:如果 timer 存在,说明已经安排了更新,什么都不做(让它在原定时间触发) // 只有 timer 不存在时,才设置一个新的 if (!this.updateDebounceTimer) { this.updateDebounceTimer = setTimeout(() => { this.executeAutoUpdate(); }, interval); } } executeAutoUpdate() { if (this.updateDebounceTimer) { clearTimeout(this.updateDebounceTimer); this.updateDebounceTimer = null; } // 触发更新回调(在 GeminiHelper 中定义,实际调用 refreshOutline) if (this.config && this.config.onAutoUpdate) { this.config.onAutoUpdate(); } // 发送自定义事件通知外部刷新 window.dispatchEvent(new CustomEvent('gemini-helper-outline-auto-refresh')); } createUI() { const container = this.container; clearElement(container); const content = createElement('div', {className: 'outline-content'}); // 固定工具栏 const toolbar = createElement('div', {className: 'outline-fixed-toolbar'}); // 第一行:按钮和搜索占位 const row1 = createElement('div', {className: 'outline-toolbar-row'}); // 滚动按钮 const scrollBtn = createElement('button', { className: 'outline-toolbar-btn', id: 'outline-scroll-btn', title: this.t('outlineScrollBottom') }, '⬇'); scrollBtn.addEventListener('click', () => this.scrollList()); row1.appendChild(scrollBtn); // 展开/折叠按钮 const expandBtn = createElement('button', { className: 'outline-toolbar-btn', id: 'outline-expand-btn', title: this.t('outlineExpandAll') }, '⊕'); expandBtn.addEventListener('click', () => this.toggleExpandAll()); row1.appendChild(expandBtn); // 搜索框区域 const searchWrapper = createElement('div', {className: 'outline-search-wrapper'}); const searchInput = createElement('input', { type: 'text', className: 'outline-search-input', placeholder: this.t('outlineSearch'), value: this.state.searchQuery }); const clearBtn = createElement('button', { className: 'outline-search-clear hidden', title: this.t('clear') }, '×'); // 搜索事件处理 let debounceTimer; searchInput.addEventListener('input', (e) => { const val = e.target.value; clearBtn.classList.toggle('hidden', !val); clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { this.handleSearch(val.trim()); }, 300); }); searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; clearBtn.classList.add('hidden'); this.handleSearch(''); searchInput.blur(); } }); clearBtn.addEventListener('click', () => { searchInput.value = ''; clearBtn.classList.add('hidden'); this.handleSearch(''); searchInput.focus(); }); searchWrapper.appendChild(searchInput); searchWrapper.appendChild(clearBtn); row1.appendChild(searchWrapper); toolbar.appendChild(row1); // 第二行:层级滑块 const row2 = createElement('div', {className: 'outline-toolbar-row'}); const sliderContainer = createElement('div', {className: 'outline-level-slider-container'}); // 层级节点 const dotsContainer = createElement('div', {className: 'outline-level-dots', id: 'outline-level-dots'}); const levelLine = createElement('div', {className: 'outline-level-line'}); const levelProgress = createElement('div', { className: 'outline-level-progress', id: 'outline-level-progress' }); levelLine.appendChild(levelProgress); dotsContainer.appendChild(levelLine); // 创建 6 个层级节点(0 表示不展开,1-6 表示层级) for (let i = 0; i <= 6; i++) { const dot = createElement('div', { className: `outline-level-dot ${i <= (this.state.expandLevel) ? 'active' : ''}`, 'data-level': i }); const tooltip = createElement('div', {className: 'outline-level-dot-tooltip'}); if (i === 0) { tooltip.textContent = '⊖'; // 不展开 } else { tooltip.textContent = `H${i}: 0`; } dot.appendChild(tooltip); dot.addEventListener('click', () => this.setLevel(i)); dotsContainer.appendChild(dot); } sliderContainer.appendChild(dotsContainer); row2.appendChild(sliderContainer); toolbar.appendChild(row2); content.appendChild(toolbar); // 搜索结果统计条 (插入在工具栏和列表之间) const resultBar = createElement('div', { className: 'outline-result-bar hidden', id: 'outline-result-bar' }); content.appendChild(resultBar); // 大纲列表包装器(可滚动) const listWrapper = createElement('div', {className: 'outline-list-wrapper', id: 'outline-list-wrapper'}); const list = createElement('div', {className: 'outline-list', id: 'outline-list'}); listWrapper.appendChild(list); content.appendChild(listWrapper); container.appendChild(content); } // 刷新数据 update(outlineData) { const listContainer = document.getElementById('outline-list'); if (!listContainer) return; clearElement(listContainer); if (!outlineData || outlineData.length === 0) { listContainer.appendChild(createElement('div', {className: 'outline-empty'}, this.t('outlineEmpty'))); return; } // 保存原始大纲 this.state.rawOutline = outlineData; // 统计各层级数量 this.state.levelCounts = {}; outlineData.forEach(item => { this.state.levelCounts[item.level] = (this.state.levelCounts[item.level] || 0) + 1; }); this.updateTooltips(); // 智能缩进:检测最高层级 const minLevel = Math.min(...outlineData.map(item => item.level)); this.state.minLevel = minLevel; // 在重构树之前,捕获当前的折叠状态 const currentStateMap = {}; if (this.state.tree) { this.captureTreeState(this.state.tree, currentStateMap); } // 构建树形结构 const outlineKey = outlineData.map(i => i.text).join('|'); let isNewTree = false; // 只要 key 变了,或者是首次构建,都重新构建树 // 注意:实时更新时 key 会不断变化,所以必须每次都重建树以包含新节点 // 但我们需要保持用户的折叠状态 if (this.state.treeKey !== outlineKey || !this.state.tree) { this.state.tree = this.buildTree(outlineData, minLevel); this.state.treeKey = outlineKey; isNewTree = true; } const tree = this.state.tree; // 恢复折叠状态 if (Object.keys(currentStateMap).length > 0) { this.restoreTreeState(tree, currentStateMap); // 对于新增加的节点(在 currentStateMap 中找不到的),应用默认折叠逻辑 // 这里需要一个递归函数只处理未初始化的节点吗? // 实际上 restoreTreeState 只恢复旧的。新节点默认在 buildTree 中可能是 collapsed: false (我们在 buildTree 里初始化为 false) // 我们需要根据 expandLevel 来初始化新节点。 // 简单的做法:先全部应用默认 expandLevel,再用 restore 覆盖旧的? // 或者:restore 之后,对剩下的新节点做处理? // 改进策略: // 1. 先按默认规则初始化所有节点(基于 expandLevel) const displayLevel = this.state.expandLevel ?? 6; this.initializeCollapsedState(tree, displayLevel < minLevel ? minLevel : displayLevel); // 2. 再恢复用户之前的操作(覆盖默认) this.restoreTreeState(tree, currentStateMap); } else if (isNewTree && !this.state.searchQuery) { // 首次加载,无旧状态 const displayLevel = this.state.expandLevel ?? 6; this.initializeCollapsedState(tree, displayLevel < minLevel ? minLevel : displayLevel); } // 如果在搜索模式,需要重新应用搜索标记 if (this.state.searchQuery) { this.performSearch(this.state.searchQuery, false); // false = 不触发额外刷新 } // 渲染 this.refreshCurrent(); } // 处理搜索输入 handleSearch(query) { if (!query) { // === 结束搜索 === // 1. 清理搜索状态 this.state.searchQuery = ''; this.state.searchResults = null; this.state.searchLevelManual = false; // 2. 隐藏结果条 const resultBar = document.getElementById('outline-result-bar'); if (resultBar) resultBar.classList.add('hidden'); // 3. 恢复折叠状态 if (this.state.tree) { // 3.1 先重置为全局设定的层级状态(兜底) const displayLevel = this.state.expandLevel ?? 6; this.clearForceExpandedState(this.state.tree, displayLevel); // 3.2 如果有搜索前的状态快照,则恢复它(覆盖默认状态) if (this.state.preSearchState) { this.restoreTreeState(this.state.tree, this.state.preSearchState); this.state.preSearchState = null; // 恢复后清除快照 } } this.refreshCurrent(); return; } // === 开始或更新搜索 === // 如果是从无搜索状态进入搜索状态,保存当前快照 if (!this.state.searchQuery && this.state.tree) { this.state.preSearchState = {}; this.captureTreeState(this.state.tree, this.state.preSearchState); // Fix Issue 2: 搜索前重置所有状态(折叠所有 + 清除手动展开标记) // 这样搜索结果就只展示匹配的路径,不会受之前手动展开的干扰 this.clearForceExpandedState(this.state.tree, 0); } this.state.searchQuery = query; this.state.searchLevelManual = false; // 重置手动层级标记 this.performSearch(query); this.refreshCurrent(); } // 执行搜索计算 performSearch(query, updateUI = true) { if (!this.state.tree) return; const normalize = (str) => str.toLowerCase(); const normalizedQuery = normalize(query); let matchCount = 0; // 递归标记树 // 返回值: { isMatch: boolean, hasMatchedDescendant: boolean } const traverse = (nodes) => { let hasAnyMatch = false; nodes.forEach(node => { const isMatch = normalize(node.text).includes(normalizedQuery); if (isMatch) matchCount++; node.isMatch = isMatch; if (node.children && node.children.length > 0) { const childResult = traverse(node.children); node.hasMatchedDescendant = childResult; } else { node.hasMatchedDescendant = false; } // 如果有匹配子项,自动展开 if (node.hasMatchedDescendant) { node.collapsed = false; // node.forceExpanded = true; // 可选:是否强制标记为展开? 暂时不需要,只要 collapsed=false 即可 } if (isMatch || node.hasMatchedDescendant) { hasAnyMatch = true; } }); return hasAnyMatch; }; traverse(this.state.tree); // 更新结果条 if (updateUI) { const resultBar = document.getElementById('outline-result-bar'); if (resultBar) { resultBar.textContent = `${matchCount} ${this.t('outlineSearchResult')}`; resultBar.classList.remove('hidden'); } } } // 内部刷新(用于交互更新) refreshCurrent() { const listContainer = document.getElementById('outline-list'); if (this.state.tree && listContainer) { clearElement(listContainer); // 确定当前的显示层级上限 // 如果在搜索模式且未手动调整,显示所有层级 (Infinity) // 否则使用设定的 expandLevel let displayLevel; if (this.state.searchQuery && !this.state.searchLevelManual) { displayLevel = 100; // 足够大以显示所有 } else { displayLevel = this.state.expandLevel ?? 6; } if (displayLevel < this.state.minLevel) { displayLevel = this.state.minLevel; } this.renderItems(listContainer, this.state.tree, this.state.minLevel, displayLevel); } } // 构建树形结构 buildTree(outline, minLevel) { const tree = []; const stack = []; outline.forEach((item, index) => { const relativeLevel = item.level - minLevel + 1; const node = { ...item, relativeLevel, index, children: [], collapsed: false }; // 找到父节点 while (stack.length > 0 && stack[stack.length - 1].relativeLevel >= relativeLevel) { stack.pop(); } if (stack.length === 0) { tree.push(node); } else { stack[stack.length - 1].children.push(node); } stack.push(node); }); return tree; } // 渲染大纲项 renderItems(container, items, minLevel, displayLevel, parentCollapsed = false, parentForceExpanded = false) { items.forEach(item => { const hasChildren = item.children && item.children.length > 0; const isTopLevel = item.level === minLevel; let shouldShow; // 计算可见性 const isLevelAllowed = item.level <= displayLevel || parentForceExpanded; if (isTopLevel) { // 顶层节点逻辑 if (this.state.searchQuery) { // Fix: 搜索模式下严控顶层显示,无论是否有手动层级操作 // 确保 Expand All 不会将不相关的顶层节点展示出来 shouldShow = item.isMatch || item.hasMatchedDescendant; } else { // 普通模式:只需存在即可 shouldShow = true; } } else { // 非顶层节点 const isRelevant = !this.state.searchQuery || (item.isMatch || item.hasMatchedDescendant || parentForceExpanded); // 注意:parentForceExpanded 意味着父级被手动点开了,此时应该显示子级(即使不匹配) // 综合判断 if (this.state.searchQuery && !this.state.searchLevelManual) { // 纯搜索模式:相关即显示,忽略层级 // 但如果 parentForceExpanded,也显示 shouldShow = isRelevant && !parentCollapsed; } else if (this.state.searchQuery && this.state.searchLevelManual) { // 搜索且有层级限制 // 必须相关 AND 层级允许 shouldShow = isRelevant && isLevelAllowed && !parentCollapsed; } else { // 普通模式 shouldShow = isLevelAllowed && !parentCollapsed; } } // 最终修正:如果父级折叠了,那肯定看不到 if (parentCollapsed) shouldShow = false; const itemEl = createElement('div', { className: `outline-item outline-level-${item.relativeLevel}`, 'data-index': item.index, 'data-level': item.relativeLevel }); const isExpanded = hasChildren && !item.collapsed; const toggle = createElement('span', { className: `outline-item-toggle ${hasChildren ? (isExpanded ? 'expanded' : '') : 'invisible'}` }, '▸'); if (hasChildren) { toggle.addEventListener('click', (e) => { e.stopPropagation(); item.collapsed = !item.collapsed; if (!item.collapsed) { item.forceExpanded = true; } toggle.classList.toggle('expanded', !item.collapsed); this.refreshCurrent(); }); } itemEl.appendChild(toggle); const textEl = createElement('span', {className: 'outline-item-text'}); // 高亮处理 if (this.state.searchQuery && item.isMatch) { try { const query = this.state.searchQuery; const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escapedQuery})`, 'gi'); const parts = item.text.split(regex); clearElement(textEl); parts.forEach(part => { if (part.toLowerCase() === query.toLowerCase()) { const mark = document.createElement('mark'); mark.textContent = part; mark.style.backgroundColor = 'rgba(255, 235, 59, 0.5)'; mark.style.color = 'inherit'; mark.style.padding = '0'; mark.style.borderRadius = '2px'; textEl.appendChild(mark); } else { textEl.appendChild(document.createTextNode(part)); } }); } catch (e) { textEl.textContent = item.text; } } else { textEl.textContent = item.text; } itemEl.appendChild(textEl); itemEl.addEventListener('click', () => { let targetElement = item.element; // 1. 检查元素是否有效 if (!targetElement || !targetElement.isConnected) { // 尝试重新查找 // 简单的重新查找策略:在文档中根据文本内容找一个最相似的 H? 标签 // 这是一个兜底,Gemini 动态渲染可能会导致元素重建 const headings = document.querySelectorAll(`h${item.level}`); for (const h of headings) { if (h.textContent.trim() === item.text) { targetElement = h; break; } } } if (targetElement && targetElement.isConnected) { // 跳转前回调(用于保存当前位置为锚点) if (this.onJumpBefore) { this.onJumpBefore(); } // 传入 __bypassLock: true 以绕过 ScrollLockManager 的拦截 // 恢复 behavior: 'smooth',因为我们已经处理了元素重新查找,应该可以兼容 targetElement.scrollIntoView({behavior: 'smooth', block: 'center', __bypassLock: true}); targetElement.classList.add('outline-highlight'); setTimeout(() => targetElement.classList.remove('outline-highlight'), 2000); } else { console.warn('Gemini Helper: Outline item element lost and not found:', item.text); } }); if (!shouldShow) { itemEl.classList.add('outline-hidden'); } container.appendChild(itemEl); if (hasChildren) { const childParentCollapsed = item.collapsed || parentCollapsed; this.renderItems( container, item.children, minLevel, displayLevel, childParentCollapsed, item.forceExpanded || parentForceExpanded ); } }); } // 初始化树的折叠状态 initializeCollapsedState(items, displayLevel) { items.forEach(item => { if (item.children && item.children.length > 0) { const allChildrenHidden = item.children.every(child => child.level > displayLevel); item.collapsed = allChildrenHidden; this.initializeCollapsedState(item.children, displayLevel); } else { item.collapsed = false; } }); } // 滚动列表 scrollList() { const wrapper = document.getElementById('outline-list-wrapper'); const btn = document.getElementById('outline-scroll-btn'); if (!wrapper || !btn) return; const isAtBottom = wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 10; if (isAtBottom) { wrapper.scrollTo({top: 0, behavior: 'smooth'}); btn.textContent = '⬇'; btn.title = this.t('outlineScrollBottom'); } else { wrapper.scrollTo({top: wrapper.scrollHeight, behavior: 'smooth'}); btn.textContent = '⬆'; btn.title = this.t('outlineScrollTop'); } } // 展开/折叠全部 toggleExpandAll() { const btn = document.getElementById('outline-expand-btn'); if (!btn) return; if (this.state.isAllExpanded) { const minLevel = this.state.minLevel || 1; this.setLevel(minLevel); } else { const maxActualLevel = Math.max(...Object.keys(this.state.levelCounts).map(Number), 1); this.setLevel(maxActualLevel); } } // 设置层级 setLevel(level) { this.state.expandLevel = level; // 更新外部设置 if (this.settings.outline) { this.settings.outline.maxLevel = level; if (this.onSettingsChange) this.onSettingsChange(); } // 清除强制展开状态 if (this.state.tree) { this.clearForceExpandedState(this.state.tree, level); } // 更新 UI const dots = document.querySelectorAll('.outline-level-dot'); dots.forEach(dot => { const dotLevel = parseInt(dot.dataset.level, 10); dot.classList.toggle('active', dotLevel <= level); }); const progress = document.getElementById('outline-level-progress'); if (progress) { progress.style.width = `${(level / 6) * 100}%`; } // 如果在搜索状态下调整了 Slider,标记为手动 if (this.state.searchQuery) { this.state.searchLevelManual = true; this.refreshCurrent(); } else { // 非搜索状态,这里可能不需要 refreshCurrent,因为 updateTooltips 或其他地方可能触发? // 原有逻辑似乎没有显式调用 refreshCurrent,可能是 toggleExpnadAll 调用的? // 不,setLevel 是被点击调用的。所以必须刷新。 this.refreshCurrent(); } const btn = document.getElementById('outline-expand-btn'); const maxActualLevel = Math.max(...Object.keys(this.state.levelCounts).map(Number), 1); if (btn) { if (level >= maxActualLevel) { btn.textContent = '⊖'; btn.title = this.t('outlineCollapseAll'); this.state.isAllExpanded = true; } else { btn.textContent = '⊕'; btn.title = this.t('outlineExpandAll'); this.state.isAllExpanded = false; } } this.refreshCurrent(); } // 清除强制展开状态 clearForceExpandedState(items, displayLevel) { items.forEach(item => { item.forceExpanded = false; if (item.children && item.children.length > 0) { const allChildrenHidden = item.children.every(child => child.level > displayLevel); item.collapsed = allChildrenHidden; this.clearForceExpandedState(item.children, displayLevel); } else { item.collapsed = false; } }); } // 更新提示 updateTooltips() { const dots = document.querySelectorAll('.outline-level-dot'); dots.forEach(dot => { const level = parseInt(dot.dataset.level, 10); const tooltip = dot.querySelector('.outline-level-dot-tooltip'); if (tooltip && level > 0) { const count = this.state.levelCounts[level] || 0; tooltip.textContent = `H${level}: ${count}`; } }); } // 捕获树的状态(expanded/collapsed) captureTreeState(nodes, stateMap) { nodes.forEach(node => { // 使用 level + text 作为 key // 注意:如果有完全相同的标题在同一级,可能会冲突,但在当前场景下可以接受 const key = `${node.level}_${node.text}`; stateMap[key] = { collapsed: node.collapsed, forceExpanded: node.forceExpanded }; if (node.children && node.children.length > 0) { this.captureTreeState(node.children, stateMap); } }); } // 恢复树的状态 restoreTreeState(nodes, stateMap) { nodes.forEach(node => { const key = `${node.level}_${node.text}`; const state = stateMap[key]; if (state) { node.collapsed = state.collapsed; // 只有当明确标记为 forceExpanded 时才恢复它 if (state.forceExpanded !== undefined) { node.forceExpanded = state.forceExpanded; } } if (node.children && node.children.length > 0) { this.restoreTreeState(node.children, stateMap); } }); } } /** * 设置管理器 * 负责所有设置的加载、保存和默认值合并 */ class SettingsManager { /** * 加载设置 * @param {SiteRegistry} registry 站点注册表 * @param {SiteAdapter} currentAdapter 当前适配器 * @returns {Object} 完整的设置对象 */ load(registry, currentAdapter) { const widthSettings = GM_getValue(SETTING_KEYS.PAGE_WIDTH, DEFAULT_WIDTH_SETTINGS); const outlineSettings = GM_getValue(SETTING_KEYS.OUTLINE, DEFAULT_OUTLINE_SETTINGS); const promptsSettings = GM_getValue(SETTING_KEYS.PROMPTS_SETTINGS, DEFAULT_PROMPTS_SETTINGS); const tabOrder = GM_getValue(SETTING_KEYS.TAB_ORDER, DEFAULT_TAB_ORDER); // 加载模型锁定设置(按站点隔离,但一次性加载所有站点的配置) const savedModelLockSettings = GM_getValue(SETTING_KEYS.MODEL_LOCK, {}); const mergedModelLockConfig = {}; // 兼容旧的单一适配器模式(防御性代码) const currentSiteId = currentAdapter ? currentAdapter.getSiteId() : 'unknown'; // 遍历所有注册的适配器,合并默认配置和保存的配置 if (registry && registry.adapters) { registry.adapters.forEach(adapter => { const siteId = adapter.getSiteId(); const defaults = adapter.getDefaultLockSettings(); mergedModelLockConfig[siteId] = {...defaults, ...(savedModelLockSettings[siteId] || {})}; }); } else if (currentAdapter) { const defaults = currentAdapter.getDefaultLockSettings(); mergedModelLockConfig[currentSiteId] = {...defaults, ...(savedModelLockSettings[currentSiteId] || {})}; } // 确保大纲设置有默认值 (合并默认配置与保存的配置) const mergedOutlineSettings = {...DEFAULT_OUTLINE_SETTINGS, ...outlineSettings}; return { clearTextareaOnSend: GM_getValue(SETTING_KEYS.CLEAR_TEXTAREA_ON_SEND, false), // 默认关闭 modelLockConfig: mergedModelLockConfig, pageWidth: widthSettings[currentSiteId] || DEFAULT_WIDTH_SETTINGS[currentSiteId], outline: mergedOutlineSettings, prompts: promptsSettings, tabOrder: tabOrder, preventAutoScroll: GM_getValue('gemini_prevent_auto_scroll', false), showCollapsedAnchor: GM_getValue('gemini_show_collapsed_anchor', true), tabSettings: {...DEFAULT_TAB_SETTINGS, ...GM_getValue(SETTING_KEYS.TAB_SETTINGS, {})}, readingHistory: {...DEFAULT_READING_HISTORY_SETTINGS, ...GM_getValue(SETTING_KEYS.READING_HISTORY, {})} }; } /** * 保存设置 * @param {Object} settings 当前设置对象 * @param {SiteAdapter} currentAdapter 当前适配器 */ save(settings, currentAdapter) { GM_setValue(SETTING_KEYS.CLEAR_TEXTAREA_ON_SEND, settings.clearTextareaOnSend); // 保存模型锁定设置(保存整个字典) GM_setValue(SETTING_KEYS.MODEL_LOCK, settings.modelLockConfig); // 保存标签页设置 GM_setValue(SETTING_KEYS.TAB_SETTINGS, settings.tabSettings); // 保存页面宽度设置 const allWidthSettings = GM_getValue(SETTING_KEYS.PAGE_WIDTH, DEFAULT_WIDTH_SETTINGS); if (currentAdapter) { allWidthSettings[currentAdapter.getSiteId()] = settings.pageWidth; } GM_setValue(SETTING_KEYS.PAGE_WIDTH, allWidthSettings); // 保存大纲设置 GM_setValue(SETTING_KEYS.OUTLINE, settings.outline); // 保存提示词设置 GM_setValue(SETTING_KEYS.PROMPTS_SETTINGS, settings.prompts); // 保存 Tab 顺序 GM_setValue(SETTING_KEYS.TAB_ORDER, settings.tabOrder); // 保存防滚动设置 GM_setValue('gemini_prevent_auto_scroll', settings.preventAutoScroll); // 保存阅读历史设置 GM_setValue(SETTING_KEYS.READING_HISTORY, settings.readingHistory); } } /** * Gemini 助手核心类 * 管理提示词、设置和 UI 界面 */ class GeminiHelper { constructor(siteRegistry) { this.prompts = this.loadPrompts(); this.registry = siteRegistry; // 保持 siteAdapter 引用以便兼容旧代码,指向当前匹配的站点 this.siteAdapter = siteRegistry.getCurrent(); this.selectedPrompt = null; this.isCollapsed = false; this.isScrolling = false; // 滚动状态锁 this.anchorScrollTop = null; // 阅读锚点位置 this.lang = detectLanguage(); // 当前语言 this.i18n = I18N[this.lang]; // 当前语言文本 this.settingsManager = new SettingsManager(); this.settings = this.loadSettings(); // 加载设置 // 初始化当前 Tab:优先使用设置的第一个 Tab this.currentTab = this.settings.tabOrder && this.settings.tabOrder.length > 0 ? this.settings.tabOrder[0] : 'prompts'; // 兜底:如果首个 Tab 被禁用,则回退到 safe tab const isOutlineDisabled = this.currentTab === 'outline' && !this.settings.outline?.enabled; const isPromptsDisabled = this.currentTab === 'prompts' && !this.settings.prompts?.enabled; if (isOutlineDisabled || isPromptsDisabled) { // 尝试找一个可用的 tab const availableTab = this.settings.tabOrder.find(t => { if (t === 'outline') return this.settings.outline?.enabled; if (t === 'prompts') return this.settings.prompts?.enabled; return true; // settings always enabled }); this.currentTab = availableTab || 'settings'; } // 初始化核心功能管理器 this.scrollManager = new ScrollManager(this.siteAdapter); this.readingProgressManager = new ReadingProgressManager(this.settings, this.scrollManager, (k) => this.t(k)); this.anchorManager = new AnchorManager(this.scrollManager, (k) => this.t(k)); // 绑定锚点状态变化更新 UI this.anchorManager.bindUI((hasAnchor) => this.updateAnchorButtonState(hasAnchor)); // 初始化滚动锁定管理器 this.scrollLockManager = new ScrollLockManager(this.siteAdapter); // 根据设置初始化状态,前提是当前站点支持 if (this.settings.preventAutoScroll && this.siteAdapter.supportsScrollLock()) { this.scrollLockManager.setEnabled(true); } this.outlineManager = null; this.init(); } // 获取翻译文本 t(key) { return this.i18n[key] || key; } loadPrompts() { const saved = GM_getValue('universal_prompts', null); if (!saved) { GM_setValue('universal_prompts', DEFAULT_PROMPTS); return DEFAULT_PROMPTS; } return saved; } savePrompts() { GM_setValue('universal_prompts', this.prompts); } // 加载设置 loadSettings() { return this.settingsManager.load(this.registry, this.siteAdapter); } // 保存设置 saveSettings() { this.settingsManager.save(this.settings, this.siteAdapter); } addPrompt(prompt) { prompt.id = 'custom_' + Date.now(); this.prompts.push(prompt); this.savePrompts(); this.refreshPromptList(); this.refreshCategories(); } updatePrompt(id, updatedPrompt) { const index = this.prompts.findIndex(p => p.id === id); if (index !== -1) { this.prompts[index] = {...this.prompts[index], ...updatedPrompt}; this.savePrompts(); this.refreshPromptList(); this.refreshCategories(); } } deletePrompt(id) { this.prompts = this.prompts.filter(p => p.id !== id); this.savePrompts(); this.refreshPromptList(); this.refreshCategories(); } getCategories() { const categories = new Set(); this.prompts.forEach(p => { if (p.category) categories.add(p.category); }); return Array.from(categories); } init() { this.createStyles(); this.createUI(); this.bindEvents(); // 初始化锚点按钮状态(初始时没有锚点,应置灰) this.updateAnchorButtonState(false); this.siteAdapter.findTextarea(); // 对于 Gemini Business,根据设置决定是否在初始化时插入零宽字符 const currentSiteId = this.siteAdapter.getSiteId(); const adapterOptions = { clearOnInit: this.siteAdapter instanceof GeminiBusinessAdapter ? this.settings.clearTextareaOnSend : false, modelLockConfig: this.settings.modelLockConfig[currentSiteId] // 传递当前站点的配置 }; // 绑定新对话监听 (点击按钮或快捷键) this.siteAdapter.bindNewChatListeners(() => { console.log('Gemini Helper: New chat detected, re-initializing...'); // 重新加载配置并执行初始化逻辑 this.settings = this.loadSettings(); const currentSiteId = this.siteAdapter.getSiteId(); const adapterOptions = { clearOnInit: this.siteAdapter instanceof GeminiBusinessAdapter ? this.settings.clearTextareaOnSend : false, modelLockConfig: this.settings.modelLockConfig[currentSiteId] }; this.siteAdapter.afterPropertiesSet(adapterOptions); // 重新应用滚动锁定状态 if (this.scrollLockManager) { this.scrollLockManager.siteAdapter = this.siteAdapter; // 确保适配器更新 this.scrollLockManager.setEnabled(this.settings.preventAutoScroll); } // 重新应用宽度样式 (防止页面重置) if (this.widthStyleManager) { this.widthStyleManager.apply(); } }); this.siteAdapter.afterPropertiesSet(adapterOptions); // 初始化时执行锚点恢复和清理 if (this.settings.readingHistory.persistence) { // 延迟触发以确保页面加载完成 setTimeout(() => { this.restoreReadingProgress(); this.cleanupReadingHistory(); }, 2000); } // 创建并应用页面宽度样式 this.widthStyleManager = new WidthStyleManager(this.siteAdapter, this.settings.pageWidth); this.widthStyleManager.apply(); // 初始化标签页重命名管理器 this.tabRenameManager = new TabRenameManager(this.siteAdapter, this.settings, (key) => this.t(key)); if (this.settings.tabSettings?.autoRenameTab) { this.tabRenameManager.start(); } // 监听自定义大纲自动刷新事件 window.addEventListener('gemini-helper-outline-auto-refresh', () => { this.refreshOutline(); }); // 如果初始 Tab 是大纲,立即刷新内容 if (this.currentTab === 'outline') { // 稍微延迟一下,确保 DOM 已经就绪 setTimeout(() => this.refreshOutline(), 500); } } createStyles() { const existingStyle = document.getElementById('gemini-helper-styles'); if (existingStyle) existingStyle.remove(); const colors = this.siteAdapter.getThemeColors(); const gradient = `linear-gradient(135deg, ${colors.primary} 0%, ${colors.secondary} 100%)`; const style = document.createElement('style'); style.id = 'gemini-helper-styles'; style.textContent = ` /* 主面板样式 */ #gemini-helper-panel { position: fixed; top: 50%; right: 20px; transform: translateY(-50%); width: 320px; height: 80vh; min-height: 600px; background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15); z-index: 999999; display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; transition: all 0.3s ease; border: 1px solid #e0e0e0; } #gemini-helper-panel.collapsed { display: none; } .prompt-panel-header { padding: 16px; background: ${gradient}; color: white; border-radius: 12px 12px 0 0; display: flex; justify-content: space-between; align-items: center; cursor: move; user-select: none; } .prompt-panel-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 6px; white-space: nowrap; flex-shrink: 0; } .site-indicator { font-size: 10px; padding: 2px 5px; background: rgba(255,255,255,0.2); border-radius: 4px; margin-left: 4px; white-space: nowrap; } .prompt-panel-controls { display: flex; gap: 8px; } .prompt-panel-btn { background: rgba(255,255,255,0.2); border: none; color: white; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; font-size: 14px; } .prompt-panel-btn:hover { background: rgba(255,255,255,0.3); transform: scale(1.1); } .prompt-search-bar { padding: 12px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; } .prompt-search-input { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; transition: all 0.2s; box-sizing: border-box; } .prompt-search-input:focus { outline: none; border-color: ${colors.primary}; } .prompt-categories { padding: 8px 12px; display: flex; gap: 6px; flex-wrap: wrap; background: white; border-bottom: 1px solid #e5e7eb; } .category-tag { padding: 4px 10px; background: #f3f4f6; border-radius: 12px; font-size: 12px; color: #4b5563; cursor: pointer; transition: all 0.2s; border: 1px solid transparent; } .category-tag:hover { background: #e5e7eb; } .category-tag.active { background: ${colors.primary}; color: white; border-color: ${colors.primary}; } .prompt-list { flex: 1; overflow-y: auto; padding: 8px; } .prompt-item { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; margin-bottom: 8px; cursor: pointer; transition: all 0.2s; position: relative; } .prompt-item:hover { border-color: ${colors.primary}; box-shadow: 0 4px 12px rgba(66,133,244,0.15); transform: translateY(-2px); } .prompt-item.selected { background: linear-gradient(135deg, #e8f0fe 0%, #f1f8e9 100%); border-color: ${colors.primary}; } .prompt-item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .prompt-item-title { font-weight: 600; font-size: 14px; color: #1f2937; flex: 1; } .prompt-item-category { font-size: 11px; padding: 2px 6px; background: #f3f4f6; border-radius: 4px; color: #6b7280; } .prompt-item-content { font-size: 13px; color: #6b7280; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .prompt-item-actions { position: absolute; top: 8px; right: 8px; display: none; gap: 4px; } .prompt-item:hover .prompt-item-actions { display: flex; } .prompt-action-btn { width: 24px; height: 24px; border: none; background: white; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.1); font-size: 12px; } .prompt-action-btn:hover { background: #f3f4f6; transform: scale(1.1); } .prompt-item.dragging { opacity: 0.5; } .add-prompt-btn { margin: 12px; padding: 10px; background: ${gradient}; color: white; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 6px; } .add-prompt-btn:hover { transform: translateY(-2px); } /* 模态框 */ .prompt-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000000; animation: fadeIn 0.2s; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .prompt-modal-content { background: white; border-radius: 12px; width: 90%; max-width: 500px; padding: 24px; animation: slideUp 0.3s; } @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .prompt-modal-header { font-size: 18px; font-weight: 600; margin-bottom: 20px; color: #1f2937; } .prompt-form-group { margin-bottom: 16px; } .prompt-form-label { display: block; font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 6px; } .prompt-form-input, .prompt-form-textarea { width: 100%; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; transition: all 0.2s; box-sizing: border-box; } .prompt-form-textarea { min-height: 100px; resize: vertical; font-family: inherit; } .prompt-form-input:focus, .prompt-form-textarea:focus { outline: none; border-color: ${colors.primary}; } .prompt-modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; } .prompt-modal-btn { padding: 8px 16px; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; border: none; } .prompt-modal-btn.primary { background: ${gradient}; color: white; } .prompt-modal-btn.secondary { background: #f3f4f6; color: #4b5563; } /* 选中的提示词显示栏 */ .selected-prompt-bar { position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%); background: ${gradient}; color: white; padding: 8px 16px; border-radius: 20px; font-size: 13px; display: none; align-items: center; gap: 8px; box-shadow: 0 4px 12px rgba(66,133,244,0.3); z-index: 999998; animation: slideInUp 0.3s; } @keyframes slideInUp { from { transform: translate(-50%, 20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } .selected-prompt-bar.show { display: flex; } .selected-prompt-text { max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .clear-prompt-btn { background: rgba(255,255,255,0.2); border: none; color: white; width: 20px; height: 20px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; } .quick-prompt-btn { width: 44px; height: 44px; background: ${gradient}; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-size: 18px; cursor: pointer; box-shadow: 0 4px 12px rgba(66,133,244,0.3); border: none; transition: transform 0.3s; } .quick-prompt-btn:hover { transform: scale(1.1); } /* 快捷按钮组(收起时显示) */ .quick-btn-group { position: fixed; bottom: 120px; right: 30px; display: flex; flex-direction: column; gap: 10px; z-index: 999997; transition: opacity 0.3s; } .quick-btn-group.hidden { display: none; } .hidden { display: none !important; } .outline-hidden { display: none !important; } .prompt-toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); background: #10b981; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000001; animation: toastSlideIn 0.3s; } @keyframes toastSlideIn { from { transform: translate(-50%, -20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } } /* 快捷跳转按钮组(面板内) */ .scroll-nav-container { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid #e5e7eb; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-radius: 0 0 12px 12px; justify-content: center; } .scroll-nav-btn { flex: 1; max-width: 120px; height: 32px; border-radius: 8px; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; color: white; gap: 4px; background: ${gradient}; box-shadow: 0 2px 6px rgba(0,0,0,0.15); transition: transform 0.2s, box-shadow 0.2s; } .scroll-nav-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); } .scroll-nav-btn.icon-only { flex: 0 0 32px; width: 32px; border-radius: 50%; padding: 0; } .scroll-nav-btn.icon-only span { display: inline-block; transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); } .scroll-nav-btn.icon-only:hover span { transform: rotate(360deg) scale(1.2); } /* 分类管理按钮 */ .category-manage-btn { padding: 4px 8px; background: transparent; border: 1px dashed #9ca3af; border-radius: 12px; font-size: 12px; color: #6b7280; cursor: pointer; transition: all 0.2s; margin-left: 4px; } .category-manage-btn:hover { background: #f3f4f6; border-color: #6b7280; color: #374151; } /* 分类管理弹窗 */ .category-modal-content { max-height: 400px; } .category-list { max-height: 280px; overflow-y: auto; margin: 16px 0; } .category-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; background: #f9fafb; border-radius: 8px; margin-bottom: 8px; transition: all 0.2s; } .category-item:hover { background: #f3f4f6; } .category-item-info { display: flex; align-items: center; gap: 12px; flex: 1; } .category-item-name { font-weight: 500; color: #1f2937; font-size: 14px; } .category-item-count { font-size: 12px; color: #6b7280; background: #e5e7eb; padding: 2px 8px; border-radius: 10px; } .category-item-actions { display: flex; gap: 8px; } .category-action-btn { padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: all 0.2s; } .category-action-btn.rename { background: #dbeafe; color: #1d4ed8; } .category-action-btn.rename:hover { background: #bfdbfe; } .category-action-btn.delete { background: #fee2e2; color: #dc2626; } .category-action-btn.delete:hover { background: #fecaca; } .category-empty { text-align: center; color: #9ca3af; padding: 40px 0; font-size: 14px; } /* Tab 切换栏 */ .prompt-panel-tabs { display: flex; background: #f9fafb; border-bottom: 1px solid #e5e7eb; } .prompt-panel-tab { flex: 1; padding: 10px 16px; background: transparent; border: none; font-size: 13px; font-weight: 500; color: #6b7280; cursor: pointer; transition: all 0.2s; border-bottom: 2px solid transparent; } .prompt-panel-tab:hover { color: #374151; background: #f3f4f6; } .prompt-panel-tab.active { color: ${colors.primary}; border-bottom-color: ${colors.primary}; background: white; } /* 面板内容区 */ .prompt-panel-content { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 280px; } .prompt-panel-content.hidden { display: none; } /* 设置面板样式 - 合并优化 */ .settings-content { padding: 16px; overflow-y: auto; flex: 1; scrollbar-width: none; -ms-overflow-style: none; } .settings-content::-webkit-scrollbar { display: none; } .settings-section { margin-bottom: 24px; } .settings-section-title { font-size: 12px; font-weight: 600; color: #6b7280; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; padding-left: 4px; border-bottom: none; } .setting-item { display: flex; justify-content: space-between; align-items: center; padding: 12px; background: #f9fafb; border-radius: 8px; margin-bottom: 8px; border: 1px solid #f3f4f6; transition: all 0.2s; } .setting-item:hover { border-color: linear-gradient(135deg, #4285f4 0%, #34a853 100%); background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.02); } .setting-item-info { flex: 1; margin-right: 12px; min-width: 0; display: flex; flex-direction: column; justify-content: center; } .setting-item-label { font-size: 14px; font-weight: 500; color: #374151; margin-bottom: 2px; white-space: nowrap; } .setting-item-desc { font-size: 12px; color: #9ca3af; line-height: 1.3; } .setting-controls { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .setting-select { padding: 6px 8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; color: #374151; background: white; outline: none; transition: all 0.2s; height: 32px; box-sizing: border-box; min-width: 100px; } .setting-select:focus { border-color: #4285f4; box-shadow: 0 0 0 2px rgba(66,133,244,0.1); } .setting-toggle { width: 44px; height: 24px; background: #d1d5db; border-radius: 12px; position: relative; cursor: pointer; transition: all 0.3s; flex-shrink: 0; } .setting-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: white; border-radius: 50%; transition: all 0.3s; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .setting-toggle.active { background: #4285f4; } /* 默认蓝色,会被JS覆盖 */ .setting-toggle.active::after { left: 22px; } /* 大纲面板样式 */ .outline-content { display: flex; flex-direction: column; flex: 1; min-height: 200px; user-select: none; overflow: hidden; } /* 大纲固定工具栏 */ .outline-fixed-toolbar { padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; } .outline-toolbar-row { display: flex; align-items: center; gap: 8px; } .outline-toolbar-btn { width: 28px; height: 28px; border: 1px solid #d1d5db; border-radius: 6px; background: white; color: #6b7280; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all 0.2s; flex-shrink: 0; } .outline-toolbar-btn:hover { border-color: ${colors.primary}; color: ${colors.primary}; background: #f0f9ff; } .outline-toolbar-btn.active { border-color: ${colors.primary}; color: white; background: ${colors.primary}; } .outline-search-input { flex: 1; height: 28px; padding: 0 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; color: #374151; outline: none; transition: all 0.2s; } .outline-search-input:focus { border-color: ${colors.primary}; box-shadow: 0 0 0 2px rgba(66,133,244,0.1); } .outline-search-input::placeholder { color: #9ca3af; } .outline-search-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; border: none; background: #d1d5db; color: white; border-radius: 50%; cursor: pointer; font-size: 10px; line-height: 16px; text-align: center; } .outline-search-clear:hover { background: #9ca3af; } .outline-search-wrapper { position: relative; flex: 1; display: flex; align-items: center; } .outline-search-result { font-size: 12px; color: #6b7280; margin-left: 8px; white-space: nowrap; } .outline-result-bar { padding: 6px 12px; background: #eff6ff; color: #1d4ed8; font-size: 12px; border-bottom: 1px solid #dbeafe; text-align: center; flex-shrink: 0; transition: all 0.3s; } /* 层级滑块 */ .outline-level-slider-container { display: flex; align-items: center; gap: 6px; width: 100%; } .outline-level-slider { flex: 1; height: 4px; -webkit-appearance: none; appearance: none; background: #e5e7eb; border-radius: 2px; outline: none; cursor: pointer; } .outline-level-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: ${colors.primary}; cursor: pointer; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .outline-level-slider::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: ${colors.primary}; cursor: pointer; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.2); } .outline-level-dots { display: flex; justify-content: space-between; align-items: center; position: relative; flex: 1; height: 24px; } .outline-level-dot { width: 12px; height: 12px; border-radius: 50%; background: #d1d5db; cursor: pointer; transition: all 0.2s; position: relative; z-index: 2; border: 2px solid white; box-shadow: 0 1px 2px rgba(0,0,0,0.1); } .outline-level-dot:hover { background: ${colors.primary}; transform: scale(1.2); } .outline-level-dot.active { background: ${colors.primary}; } .outline-level-dot-tooltip { position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: #374151; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; white-space: nowrap; opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none; margin-bottom: 4px; } .outline-level-dot:hover .outline-level-dot-tooltip { opacity: 1; visibility: visible; } .outline-level-line { position: absolute; left: 10px; right: 10px; top: 50%; height: 4px; background: #e5e7eb; transform: translateY(-50%); z-index: 1; border-radius: 2px; } .outline-level-progress { position: absolute; left: 0; top: 0; height: 100%; background: ${colors.primary}; border-radius: 2px; transition: width 0.2s; } /* 大纲列表区 */ .outline-list-wrapper { flex: 1; overflow-y: auto; padding: 8px 12px; } .outline-list { display: flex; flex-direction: column; gap: 2px; } .outline-item { padding: 6px 10px 6px 10px; border-radius: 6px; cursor: pointer; background: transparent; border: 1px solid transparent; font-size: 13px; color: #374151; transition: all 0.15s; display: flex; align-items: center; position: relative; } .outline-item:hover { background: #f3f4f6; } .outline-item.highlight { background: #dbeafe; border-color: ${colors.primary}; } .outline-item-toggle { width: 24px; min-width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; color: #9ca3af; cursor: pointer; transition: all 0.2s ease; font-size: 16px; flex-shrink: 0; margin-right: 2px; box-sizing: border-box; border-radius: 4px; } .outline-item-toggle:hover { color: ${colors.primary}; background-color: rgba(0,0,0,0.05); } .outline-item-toggle.expanded { transform: rotate(90deg); color: ${colors.primary}; } .outline-item-toggle.invisible { opacity: 0; cursor: default; pointer-events: none; visibility: visible !important; display: inline-flex !important; } .outline-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 24px; } .outline-item.collapsed-children { display: none; } /* 大纲层级缩进 - 箭头跟随缩进,文字保持左对齐 */ .outline-level-1 { padding-left: 10px; font-weight: 600; font-size: 14px; } .outline-level-2 { padding-left: 28px; font-weight: 500; } .outline-level-3 { padding-left: 46px; } .outline-level-4 { padding-left: 64px; font-size: 12px; } .outline-level-5 { padding-left: 82px; font-size: 12px; color: #6b7280; } .outline-level-6 { padding-left: 100px; font-size: 12px; color: #9ca3af; } .outline-empty { text-align: center; color: #9ca3af; padding: 40px 20px; font-size: 14px; } /* 大纲高亮效果 */ .outline-highlight { animation: outlineHighlight 2s ease-out; } @keyframes outlineHighlight { 0% { background: rgba(66, 133, 244, 0.3); } 100% { background: transparent; } } `; document.head.appendChild(style); } createUI() { const existingPanel = document.getElementById('gemini-helper-panel'); const existingBar = document.querySelector('.selected-prompt-bar'); const existingBtnGroup = document.getElementById('quick-btn-group'); if (existingPanel) existingPanel.remove(); if (existingBar) existingBar.remove(); if (existingBtnGroup) existingBtnGroup.remove(); const panel = createElement('div', {id: 'gemini-helper-panel'}); // Header const header = createElement('div', {className: 'prompt-panel-header'}); const title = createElement('div', {className: 'prompt-panel-title'}); title.appendChild(createElement('span', {}, '✨')); title.appendChild(createElement('span', {}, this.t('panelTitle'))); title.appendChild(createElement('span', {className: 'site-indicator'}, this.siteAdapter.getName())); const controls = createElement('div', {className: 'prompt-panel-controls'}); const refreshBtn = createElement('button', { className: 'prompt-panel-btn', id: 'refresh-prompts', title: this.t('refreshPrompts') }, '⟳'); refreshBtn.addEventListener('click', () => { refreshBtn.classList.add('loading'); // 根据当前 Tab 智能刷新 if (this.currentTab === 'outline') { this.refreshOutline(); this.showToast(this.t('refreshed')); } else if (this.currentTab === 'prompts') { this.refreshPromptList(); this.showToast(this.t('refreshed')); } else { this.showToast(this.t('refreshed')); } setTimeout(() => refreshBtn.classList.remove('loading'), 500); }); const toggleBtn = createElement('button', { className: 'prompt-panel-btn', id: 'toggle-panel', title: this.t('collapse') }, '−'); // 注意:toggleBtn 的事件监听在 bindEvents 中统一绑定,避免重复绑定 // 新建标签页按钮 // 新标签页按钮 (只有在设置开启且站点支持时显示) if (this.settings.tabSettings?.openInNewTab && this.siteAdapter.supportsNewTab()) { const newTabBtn = createElement('button', { className: 'prompt-panel-btn', id: 'new-tab-btn', title: this.t('newTabTooltip'), style: 'margin-right: 2px;' }, '+'); newTabBtn.addEventListener('click', () => { const url = this.siteAdapter.getNewTabUrl(); if (url) { window.open(url, '_blank'); } }); controls.appendChild(newTabBtn); } controls.appendChild(refreshBtn); controls.appendChild(toggleBtn); header.appendChild(title); header.appendChild(controls); // 双击面板标题切换隐私模式 (Boss Key) title.style.cursor = 'pointer'; title.addEventListener('dblclick', () => { if (this.tabRenameManager) { const isPrivate = this.tabRenameManager.togglePrivacyMode(); this.saveSettings(); // 同步设置面板中的隐私模式开关状态 const privacyToggle = document.getElementById('toggle-privacy-mode'); if (privacyToggle) { privacyToggle.classList.toggle('active', isPrivate); } // 同步伪装标题输入框的禁用状态 const privacyTitleItem = privacyToggle?.closest('.setting-item')?.nextElementSibling; if (privacyTitleItem && privacyTitleItem.classList.contains('setting-item')) { const privacyTitleInput = privacyTitleItem.querySelector('input'); if (privacyTitleInput) { privacyTitleInput.disabled = !isPrivate; privacyTitleItem.style.opacity = isPrivate ? '1' : '0.5'; privacyTitleItem.style.pointerEvents = isPrivate ? 'auto' : 'none'; } } this.showToast(isPrivate ? '🔒 隐私模式已开启' : '🔓 隐私模式已关闭'); } }); // Tab 栏 const tabs = createElement('div', {className: 'prompt-panel-tabs'}); // 根据设置的顺序渲染 Tab const tabOrder = this.settings.tabOrder || DEFAULT_TAB_ORDER; // 确保所有 Tab 都存在(防止新版本新增 Tab 或配置丢失) const allTabs = new Set([...tabOrder, ...DEFAULT_TAB_ORDER]); // 过滤掉未定义的 Tab ID const validTabs = Array.from(allTabs).filter(id => TAB_DEFINITIONS[id]); validTabs.forEach(tabId => { const def = TAB_DEFINITIONS[tabId]; // 特殊处理:如果大纲被禁用,添加 hidden 类,但仍然渲染(为了保持 DOM 结构一致性,或者稍后在 switchTab 处理可见性) // 这里稍微调整逻辑:创建 button,初始 class 根据状态决定 let className = 'prompt-panel-tab'; if (this.currentTab === tabId) className += ' active'; // 大纲特殊显隐逻辑 if (tabId === 'outline' && !this.settings.outline?.enabled) { className += ' hidden'; } // 提示词特殊显隐逻辑 if (tabId === 'prompts' && !this.settings.prompts?.enabled) { className += ' hidden'; } const btn = createElement('button', { className: className, 'data-tab': tabId, id: `${tabId}-tab` }); // 添加图标和文本 btn.appendChild(createElement('span', {style: 'margin-right: 6px;'}, def.icon)); btn.appendChild(document.createTextNode(this.t(def.labelKey))); btn.addEventListener('click', () => this.switchTab(tabId)); tabs.appendChild(btn); }); panel.appendChild(header); panel.appendChild(tabs); // 内容容器需按固定顺序创建(DOM 结构不受 Tab 顺序影响,只影响 Tab 按钮顺序) // 1. 提示词面板内容区 const promptsContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'prompts' ? '' : ' hidden'}`, id: 'prompts-content' }); const searchBar = createElement('div', {className: 'prompt-search-bar'}); const searchInput = createElement('input', { className: 'prompt-search-input', id: 'prompt-search', type: 'text', placeholder: this.t('searchPlaceholder') }); searchBar.appendChild(searchInput); const categories = createElement('div', {className: 'prompt-categories', id: 'prompt-categories'}); const list = createElement('div', {className: 'prompt-list', id: 'prompt-list'}); const addBtn = createElement('button', {className: 'add-prompt-btn', id: 'add-prompt'}); addBtn.appendChild(createElement('span', {}, '+')); addBtn.appendChild(createElement('span', {}, this.t('addPrompt'))); promptsContent.appendChild(searchBar); promptsContent.appendChild(categories); promptsContent.appendChild(list); promptsContent.appendChild(addBtn); // 2. 大纲面板内容区 const outlineContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'outline' ? '' : ' hidden'}`, id: 'outline-content' }); // 初始化大纲管理器 this.outlineManager = new OutlineManager({ container: outlineContent, settings: this.settings, onSettingsChange: () => this.saveSettings(), onJumpBefore: () => this.anchorManager.setAnchor(this.scrollManager.scrollTop), i18n: (k) => this.t(k) }); // 3. 设置面板内容区 const settingsContent = createElement('div', { className: `prompt-panel-content${this.currentTab === 'settings' ? '' : ' hidden'}`, id: 'settings-content' }); this.createSettingsContent(settingsContent); panel.appendChild(promptsContent); panel.appendChild(outlineContent); panel.appendChild(settingsContent); document.body.appendChild(panel); // 选中提示词悬浮条 const selectedBar = createElement('div', {className: 'selected-prompt-bar', style: 'user-select: none;'}); selectedBar.appendChild(createElement('span', {style: 'user-select: none;'}, this.t('currentPrompt'))); selectedBar.appendChild(createElement('span', { className: 'selected-prompt-text', id: 'selected-prompt-text', style: 'user-select: none;' })); const clearBtn = createElement('button', {className: 'clear-prompt-btn', id: 'clear-prompt'}, '×'); selectedBar.appendChild(clearBtn); document.body.appendChild(selectedBar); const quickBtnGroup = createElement('div', {className: 'quick-btn-group hidden', id: 'quick-btn-group'}); const quickBtn = createElement('button', {className: 'quick-prompt-btn', title: this.t('panelTitle')}, '✨'); const quickScrollTop = createElement('button', { className: 'quick-prompt-btn', title: this.t('scrollTop') }, '⬆'); const quickAnchor = createElement('button', { className: 'quick-prompt-btn', id: 'quick-anchor-btn', title: '暂无锚点', style: (this.settings.showCollapsedAnchor ? 'display: flex;' : 'display: none;') + ' opacity: 0.4; cursor: default;' }, '⚓'); const quickScrollBottom = createElement('button', { className: 'quick-prompt-btn', title: this.t('scrollBottom') }, '⬇'); quickBtn.addEventListener('click', () => { this.togglePanel(); }); quickScrollTop.addEventListener('click', () => this.scrollToTop()); quickAnchor.addEventListener('click', () => this.handleAnchorClick()); quickScrollBottom.addEventListener('click', () => this.scrollToBottom()); quickBtnGroup.appendChild(quickScrollTop); quickBtnGroup.appendChild(quickAnchor); quickBtnGroup.appendChild(quickBtn); quickBtnGroup.appendChild(quickScrollBottom); document.body.appendChild(quickBtnGroup); // 快捷跳转按钮组 - 放在面板底部 const scrollNavContainer = createElement('div', { className: 'scroll-nav-container', id: 'scroll-nav-container' }); const scrollTopBtn = createElement('button', { className: 'scroll-nav-btn', id: 'scroll-top-btn', title: this.t('scrollTop') }); scrollTopBtn.appendChild(createElement('span', {}, '⬆')); scrollTopBtn.appendChild(createElement('span', {}, this.t('scrollTop'))); const anchorBtn = createElement('button', { className: 'scroll-nav-btn icon-only', id: 'scroll-anchor-btn', title: '暂无锚点', style: 'opacity: 0.4; cursor: default;' }); anchorBtn.appendChild(createElement('span', {}, '⚓')); // anchorBtn.appendChild(createElement('span', {}, this.t('anchorPoint'))); const scrollBottomBtn = createElement('button', { className: 'scroll-nav-btn', id: 'scroll-bottom-btn', title: this.t('scrollBottom') }); scrollBottomBtn.appendChild(createElement('span', {}, '⬇')); scrollBottomBtn.appendChild(createElement('span', {}, this.t('scrollBottom'))); scrollTopBtn.addEventListener('click', () => this.scrollToTop()); anchorBtn.addEventListener('click', () => this.handleAnchorClick()); scrollBottomBtn.addEventListener('click', () => this.scrollToBottom()); scrollNavContainer.appendChild(scrollTopBtn); scrollNavContainer.appendChild(anchorBtn); scrollNavContainer.appendChild(scrollBottomBtn); panel.appendChild(scrollNavContainer); this.refreshCategories(); this.refreshPromptList(); // 初始化锚点按钮状态 setTimeout(() => this.updateAnchorButtonState(this.anchorManager.hasAnchor()), 0); } // Tab 切换 switchTab(tabName) { this.currentTab = tabName; // 更新 Tab 激活状态 document.querySelectorAll('.prompt-panel-tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === tabName); }); // 切换内容区 document.getElementById('prompts-content')?.classList.toggle('hidden', tabName !== 'prompts'); document.getElementById('outline-content')?.classList.toggle('hidden', tabName !== 'outline'); document.getElementById('settings-content')?.classList.toggle('hidden', tabName !== 'settings'); // 通知 OutlineManager 激活状态(用于控制自动更新显隐) if (this.outlineManager) { this.outlineManager.setActive(tabName === 'outline'); } // 更新刷新按钮的提示 const refreshBtn = document.getElementById('refresh-prompts'); if (refreshBtn) { const titleMap = { 'prompts': this.t('refreshPrompts'), 'outline': this.t('refreshOutline'), 'settings': this.t('refreshSettings') }; refreshBtn.title = titleMap[tabName] || this.t('refresh'); } // 切换到大纲时自动刷新 if (tabName === 'outline') { this.refreshOutline(); } } // 刷新大纲 refreshOutline() { if (!this.settings.outline?.enabled) return; const outline = this.siteAdapter.extractOutline(6); if (this.outlineManager) { this.outlineManager.update(outline); } } // 创建可折叠区域辅助方法 createCollapsibleSection(title, content, options = {}) { const {defaultExpanded = false} = options; const section = createElement('div', {className: 'settings-section'}); // 标题栏(可点击折叠/展开) const header = createElement('div', { className: 'settings-section-title', style: 'cursor: pointer; display: flex; justify-content: space-between; align-items: center; user-select: none;' }); const headerLeft = createElement('div', {style: 'display: flex; align-items: center; gap: 6px;'}); // 箭头 const arrow = createElement('span', { style: 'font-size: 10px; color: #9ca3af; transition: transform 0.2s; display: inline-block;', className: 'collapse-arrow' }, '▶'); const headerTitle = createElement('span', {}, title); headerLeft.appendChild(arrow); headerLeft.appendChild(headerTitle); header.appendChild(headerLeft); // 如果有右侧元素(如开关状态提示等),可以扩展 options 传入,这里暂时留空 section.appendChild(header); // 内容容器 const contentContainer = createElement('div', { className: 'settings-accordion-content', style: `display: ${defaultExpanded ? 'block' : 'none'}; padding-top: 8px; animation: slideDown 0.2s;` }); contentContainer.appendChild(content); // 切换折叠状态 let isExpanded = defaultExpanded; const updateState = () => { contentContainer.style.display = isExpanded ? 'block' : 'none'; arrow.style.transform = isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'; }; // 初始化状态 if (defaultExpanded) arrow.style.transform = 'rotate(90deg)'; header.addEventListener('click', () => { isExpanded = !isExpanded; updateState(); }); section.appendChild(contentContainer); return section; } // 创建设置面板内容 createSettingsContent(container) { const content = createElement('div', {className: 'settings-content'}); // 1. 语言设置 (保持在顶部) const langSection = createElement('div', {className: 'settings-section'}); langSection.appendChild(createElement('div', {className: 'settings-section-title'}, this.t('settingsTitle'))); const langItem = createElement('div', {className: 'setting-item'}); const langInfo = createElement('div', {className: 'setting-item-info'}); langInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('languageLabel'))); langInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('languageDesc'))); const langSelect = createElement('select', {className: 'setting-select', id: 'select-language'}); const currentLang = GM_getValue(SETTING_KEYS.LANGUAGE, 'auto'); [ {value: 'auto', label: this.t('languageAuto')}, {value: 'zh-CN', label: this.t('languageZhCN')}, {value: 'zh-TW', label: this.t('languageZhTW')}, {value: 'en', label: this.t('languageEn')} ].forEach(opt => { const option = createElement('option', {value: opt.value}, opt.label); if (opt.value === currentLang) option.selected = true; langSelect.appendChild(option); }); langSelect.addEventListener('change', () => { GM_setValue(SETTING_KEYS.LANGUAGE, langSelect.value); this.lang = detectLanguage(); this.i18n = I18N[this.lang]; this.createStyles(); this.createUI(); this.bindEvents(); this.switchTab('settings'); this.showToast(langSelect.value === 'auto' ? this.t('languageAuto') : langSelect.options[langSelect.selectedIndex].text); }); langItem.appendChild(langInfo); langItem.appendChild(langSelect); langSection.appendChild(langItem); content.appendChild(langSection); // 2. 模型锁定设置 (可折叠) let lockSection = null; if (this.registry && this.registry.adapters) { const adaptersWithLock = this.registry.adapters; if (adaptersWithLock.length > 0) { const lockContainer = createElement('div', {}); // 为每个站点生成配置行 adaptersWithLock.forEach(adapter => { const siteId = adapter.getSiteId(); const siteConfig = this.settings.modelLockConfig[siteId] || adapter.getDefaultLockSettings(); const row = createElement('div', { className: 'site-lock-row', style: 'display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f3f4f6;' }); const leftCol = createElement('div', {style: 'display: flex; align-items: center; flex: 1; gap: 12px;'}); const nameLabel = createElement('div', {style: 'font-size: 14px; font-weight: 500; color: #374151; min-width: 80px;'}, adapter.getName()); const toggle = createElement('div', { className: 'setting-toggle' + (siteConfig.enabled ? ' active' : ''), style: 'transform: scale(0.8);' }); leftCol.appendChild(nameLabel); leftCol.appendChild(toggle); const rightCol = createElement('div', {}); const keywordInput = createElement('input', { type: 'text', className: 'prompt-input-title', value: siteConfig.keyword || '', placeholder: this.t('modelKeywordPlaceholder'), style: 'width: 80px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px; text-align: center;' }); const updateState = () => { keywordInput.disabled = !siteConfig.enabled; keywordInput.style.opacity = siteConfig.enabled ? '1' : '0.5'; keywordInput.style.cursor = siteConfig.enabled ? 'text' : 'not-allowed'; toggle.className = 'setting-toggle' + (siteConfig.enabled ? ' active' : ''); }; updateState(); toggle.addEventListener('click', (e) => { e.stopPropagation(); siteConfig.enabled = !siteConfig.enabled; this.settings.modelLockConfig[siteId] = siteConfig; updateState(); this.saveSettings(); if (siteId === this.siteAdapter.getSiteId() && siteConfig.enabled) { this.siteAdapter.lockModel(siteConfig.keyword); } }); keywordInput.addEventListener('change', () => { siteConfig.keyword = keywordInput.value.trim(); this.settings.modelLockConfig[siteId] = siteConfig; this.saveSettings(); }); rightCol.appendChild(keywordInput); row.appendChild(leftCol); row.appendChild(rightCol); lockContainer.appendChild(row); }); lockSection = this.createCollapsibleSection(this.t('modelLockTitle'), lockContainer); } } // 3. 页面宽度设置 (可折叠) const widthContainer = createElement('div', {}); // 启用开关 const enableWidthItem = createElement('div', {className: 'setting-item'}); const enableWidthInfo = createElement('div', {className: 'setting-item-info'}); enableWidthInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('enablePageWidth'))); enableWidthInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('pageWidthDesc'))); const enableToggle = createElement('div', { className: 'setting-toggle' + (this.settings.pageWidth && this.settings.pageWidth.enabled ? ' active' : ''), id: 'toggle-page-width' }); enableToggle.addEventListener('click', () => { this.settings.pageWidth.enabled = !this.settings.pageWidth.enabled; enableToggle.classList.toggle('active', this.settings.pageWidth.enabled); this.saveSettings(); if (this.widthStyleManager) { this.widthStyleManager.updateConfig(this.settings.pageWidth); } this.showToast(this.settings.pageWidth.enabled ? this.t('settingOn') : this.t('settingOff')); }); enableWidthItem.appendChild(enableWidthInfo); enableWidthItem.appendChild(enableToggle); widthContainer.appendChild(enableWidthItem); // 值设置 const widthValueItem = createElement('div', {className: 'setting-item'}); const widthValueInfo = createElement('div', {className: 'setting-item-info'}); widthValueInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('widthValue'))); const widthControls = createElement('div', {className: 'setting-controls'}); const widthInput = createElement('input', { type: 'number', className: 'setting-select', id: 'width-value-input', value: this.settings.pageWidth ? this.settings.pageWidth.value : '70', style: 'width: 65px !important; min-width: 65px !important; text-align: right;' }); const unitSelect = createElement('select', { className: 'setting-select', id: 'width-unit-select', style: 'width: 65px;' }); ['%', 'px'].forEach(unit => { const option = createElement('option', {value: unit}, unit); if (this.settings.pageWidth && this.settings.pageWidth.unit === unit) option.selected = true; unitSelect.appendChild(option); }); const validateAndSave = () => { let val = parseFloat(widthInput.value); const unit = unitSelect.value; if (unit === '%') { if (val > 100) val = 100; if (val < 10) val = 10; } else { if (val < 400) val = 400; } if (val !== parseFloat(widthInput.value)) widthInput.value = val; this.settings.pageWidth.value = val.toString(); this.settings.pageWidth.unit = unit; this.saveSettings(); if (this.widthStyleManager) this.widthStyleManager.updateConfig(this.settings.pageWidth); }; let timeout; widthInput.addEventListener('input', () => { if (widthInput.value.length > 5) widthInput.value = widthInput.value.slice(0, 5); if (unitSelect.value === '%' && parseFloat(widthInput.value) > 100) widthInput.value = '100'; else if (unitSelect.value === 'px' && parseFloat(widthInput.value) <= 100) widthInput.value = '1200'; clearTimeout(timeout); timeout = setTimeout(validateAndSave, 500); }); widthInput.addEventListener('change', validateAndSave); unitSelect.addEventListener('change', () => { if (unitSelect.value === '%' && parseFloat(widthInput.value) > 100) widthInput.value = '70'; else if (unitSelect.value === 'px' && parseFloat(widthInput.value) <= 100) widthInput.value = '1200'; validateAndSave(); this.showToast(`${this.t('widthValue')}: ${widthInput.value}${unitSelect.value}`); }); widthControls.appendChild(widthInput); widthControls.appendChild(unitSelect); widthValueItem.appendChild(widthValueInfo); widthValueItem.appendChild(widthControls); widthContainer.appendChild(widthValueItem); // 防止自动滚动(从其他设置移入) const scrollLockItem = createElement('div', {className: 'setting-item'}); const scrollLockInfo = createElement('div', {className: 'setting-item-info'}); scrollLockInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('preventAutoScrollLabel'))); scrollLockInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('preventAutoScrollDesc'))); const scrollLockToggle = createElement('div', { className: 'setting-toggle' + (this.settings.preventAutoScroll ? ' active' : ''), id: 'toggle-scroll-lock' }); scrollLockToggle.addEventListener('click', () => { this.settings.preventAutoScroll = !this.settings.preventAutoScroll; scrollLockToggle.classList.toggle('active', this.settings.preventAutoScroll); this.saveSettings(); if (this.scrollLockManager) { this.scrollLockManager.setEnabled(this.settings.preventAutoScroll); } this.showToast(this.settings.preventAutoScroll ? this.t('settingOn') : this.t('settingOff')); }); scrollLockItem.appendChild(scrollLockInfo); scrollLockItem.appendChild(scrollLockToggle); widthContainer.appendChild(scrollLockItem); const widthSection = this.createCollapsibleSection(this.t('pageDisplaySettings'), widthContainer); // 4. 界面排版 (可折叠) const layoutContainer = createElement('div', {}); const tabDesc = createElement('div', { className: 'setting-item-desc', style: 'padding: 0 12px 8px 12px; margin-bottom: 4px;' }, this.t('tabOrderDesc')); layoutContainer.appendChild(tabDesc); const currentOrder = this.settings.tabOrder || DEFAULT_TAB_ORDER; const validOrder = currentOrder.filter(id => TAB_DEFINITIONS[id]); validOrder.forEach((tabId, index) => { const def = TAB_DEFINITIONS[tabId]; const item = createElement('div', {className: 'setting-item'}); const info = createElement('div', {className: 'setting-item-info'}); info.appendChild(createElement('div', {className: 'setting-item-label'}, this.t(def.labelKey))); const controls = createElement('div', {className: 'setting-controls'}); // 特殊处理:如果是大纲 Tab,在排序按钮旁边添加开关 if (tabId === 'outline') { const outlineToggle = createElement('div', { className: 'setting-toggle' + (this.settings.outline?.enabled ? ' active' : ''), id: 'toggle-outline-inline', style: 'transform: scale(0.8); margin-right: 12px;', title: this.t('enableOutline') // 添加提示 }); outlineToggle.addEventListener('click', (e) => { e.stopPropagation(); this.settings.outline.enabled = !this.settings.outline.enabled; outlineToggle.title = this.settings.outline.enabled ? this.t('disableOutline') : this.t('enableOutline'); outlineToggle.classList.toggle('active', this.settings.outline.enabled); this.saveSettings(); const outlineTab = document.getElementById('outline-tab'); if (outlineTab) outlineTab.classList.toggle('hidden', !this.settings.outline.enabled); if (!this.settings.outline.enabled && this.currentTab === 'outline') this.switchTab('settings'); // 更新自动更新状态 if (this.outlineManager) { this.outlineManager.updateAutoUpdateState(); } this.showToast(this.settings.outline.enabled ? this.t('settingOn') : this.t('settingOff')); }); controls.appendChild(outlineToggle); } // 特殊处理:如果是提示词 Tab,在排序按钮旁边添加开关 if (tabId === 'prompts') { const promptsToggle = createElement('div', { className: 'setting-toggle' + (this.settings.prompts?.enabled ? ' active' : ''), id: 'toggle-prompts-inline', style: 'transform: scale(0.8); margin-right: 12px;', title: this.t('togglePrompts') }); promptsToggle.addEventListener('click', (e) => { e.stopPropagation(); this.settings.prompts.enabled = !this.settings.prompts.enabled; promptsToggle.classList.toggle('active', this.settings.prompts.enabled); this.saveSettings(); const promptsTab = document.getElementById('prompts-tab'); if (promptsTab) promptsTab.classList.toggle('hidden', !this.settings.prompts.enabled); if (!this.settings.prompts.enabled && this.currentTab === 'prompts') this.switchTab('settings'); this.showToast(this.settings.prompts.enabled ? this.t('settingOn') : this.t('settingOff')); }); controls.appendChild(promptsToggle); } // 大纲高级设置(如果是在大纲 Tab 行) if (tabId === 'outline') { // 插入大纲高级设置的可折叠区域到下面(或者作为子项) // 为保持 UI 简洁,我们可以在点击大纲 toggle 时不做额外展示,而是有一个专门的“大纲高级设置”区域 // 由于这里是排序拖拽区,不适合放太多配置。 // 决定:在排序列表下方新增一个独立的大纲设置区域 } const upBtn = createElement('button', { className: 'prompt-panel-btn', style: 'background: #f3f4f6; color: #4b5563; width: 32px; height: 32px; font-size: 16px; margin-right: 4px; border: 1px solid #e5e7eb;', title: this.t('moveUp') }); upBtn.textContent = '⬆'; upBtn.disabled = index === 0; const downBtn = createElement('button', { className: 'prompt-panel-btn', style: 'background: #f3f4f6; color: #4b5563; width: 32px; height: 32px; font-size: 16px; border: 1px solid #e5e7eb;', title: this.t('moveDown') }); downBtn.textContent = '⬇'; downBtn.disabled = index === validOrder.length - 1; [upBtn, downBtn].forEach(btn => { if (btn.disabled) { btn.style.opacity = '0.4'; btn.style.cursor = 'not-allowed'; btn.style.background = '#f3f4f6'; } else { btn.style.opacity = '1'; btn.style.cursor = 'pointer'; btn.onmouseover = () => { btn.style.background = '#e5e7eb'; btn.style.color = '#111827'; }; btn.onmouseout = () => { btn.style.background = '#f3f4f6'; btn.style.color = '#4b5563'; }; } }); upBtn.addEventListener('click', () => { if (index > 0) { const newOrder = [...validOrder]; [newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]; this.settings.tabOrder = newOrder; this.saveSettings(); this.createUI(); this.bindEvents(); this.switchTab('settings'); } }); downBtn.addEventListener('click', () => { if (index < validOrder.length - 1) { const newOrder = [...validOrder]; [newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]; this.settings.tabOrder = newOrder; this.saveSettings(); this.createUI(); this.bindEvents(); this.switchTab('settings'); } }); controls.appendChild(upBtn); controls.appendChild(downBtn); item.appendChild(info); item.appendChild(controls); layoutContainer.appendChild(item); }); const layoutSection = this.createCollapsibleSection(this.t('tabOrderSettings'), layoutContainer); // 4.5 阅读历史设置 (新增独立版块) const anchorContainer = createElement('div', {}); // 持久化开关 const anchorPersistenceItem = createElement('div', {className: 'setting-item'}); const anchorPersistenceInfo = createElement('div', {className: 'setting-item-info'}); anchorPersistenceInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('readingHistoryPersistence'))); anchorPersistenceInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('readingHistoryPersistenceDesc'))); const anchorPersistenceToggle = createElement('div', { className: 'setting-toggle' + (this.settings.readingHistory.persistence ? ' active' : ''), id: 'toggle-anchor-persistence' }); // 自动恢复开关 const anchorAutoRestoreItem = createElement('div', {className: 'setting-item'}); const anchorAutoRestoreInfo = createElement('div', {className: 'setting-item-info'}); anchorAutoRestoreInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('autoRestore'))); anchorAutoRestoreInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('autoRestoreDesc'))); const anchorAutoRestoreToggle = createElement('div', { className: 'setting-toggle' + (this.settings.readingHistory.autoRestore ? ' active' : ''), id: 'toggle-anchor-auto-restore' }); // 清理时间设置 const anchorCleanupItem = createElement('div', {className: 'setting-item'}); const anchorCleanupInfo = createElement('div', {className: 'setting-item-info'}); anchorCleanupInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('readingHistoryCleanup'))); anchorCleanupInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('readingHistoryCleanupDesc'))); const anchorCleanupControls = createElement('div', {className: 'setting-controls'}); const anchorCleanupInput = createElement('select', {className: 'setting-select'}); // 填充清理选项 const cleanupOptions = [ {val: 1, label: `1 ${this.t('daysSuffix')}`}, {val: 3, label: `3 ${this.t('daysSuffix')}`}, {val: 7, label: `7 ${this.t('daysSuffix')}`}, {val: 30, label: `30 ${this.t('daysSuffix')}`}, {val: 90, label: `90 ${this.t('daysSuffix')}`}, {val: -1, label: this.t('cleanupInfinite')} ]; cleanupOptions.forEach(opt => { const option = createElement('option', {value: opt.val}, opt.label); if (this.settings.readingHistory.cleanupDays == opt.val) option.selected = true; anchorCleanupInput.appendChild(option); }); // 联动逻辑函数 const updateDependency = (enabled) => { if (enabled) { anchorAutoRestoreItem.style.opacity = '1'; anchorAutoRestoreItem.style.pointerEvents = 'auto'; anchorCleanupItem.style.opacity = '1'; anchorCleanupItem.style.pointerEvents = 'auto'; } else { anchorAutoRestoreItem.style.opacity = '0.5'; anchorAutoRestoreItem.style.pointerEvents = 'none'; anchorCleanupItem.style.opacity = '0.5'; anchorCleanupItem.style.pointerEvents = 'none'; } }; // 初始化联动 updateDependency(this.settings.readingHistory.persistence); anchorPersistenceToggle.addEventListener('click', () => { this.settings.readingHistory.persistence = !this.settings.readingHistory.persistence; anchorPersistenceToggle.classList.toggle('active', this.settings.readingHistory.persistence); this.saveSettings(); updateDependency(this.settings.readingHistory.persistence); this.showToast(this.settings.readingHistory.persistence ? this.t('settingOn') : this.t('settingOff')); }); anchorAutoRestoreToggle.addEventListener('click', () => { this.settings.readingHistory.autoRestore = !this.settings.readingHistory.autoRestore; anchorAutoRestoreToggle.classList.toggle('active', this.settings.readingHistory.autoRestore); this.saveSettings(); this.showToast(this.settings.readingHistory.autoRestore ? this.t('settingOn') : this.t('settingOff')); }); anchorCleanupInput.addEventListener('change', () => { this.settings.readingHistory.cleanupDays = parseInt(anchorCleanupInput.value); this.saveSettings(); this.showToast(`${this.t('readingHistoryCleanup')}: ${anchorCleanupInput.options[anchorCleanupInput.selectedIndex].text}`); }); anchorPersistenceItem.appendChild(anchorPersistenceInfo); anchorPersistenceItem.appendChild(anchorPersistenceToggle); anchorAutoRestoreItem.appendChild(anchorAutoRestoreInfo); anchorAutoRestoreItem.appendChild(anchorAutoRestoreToggle); anchorCleanupControls.appendChild(anchorCleanupInput); anchorCleanupItem.appendChild(anchorCleanupInfo); anchorCleanupItem.appendChild(anchorCleanupControls); anchorContainer.appendChild(anchorPersistenceItem); anchorContainer.appendChild(anchorAutoRestoreItem); anchorContainer.appendChild(anchorCleanupItem); // 折叠面板显示锚点(从其他设置移入) const showAnchorItem = createElement('div', {className: 'setting-item'}); const showAnchorInfo = createElement('div', {className: 'setting-item-info'}); showAnchorInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('showCollapsedAnchorLabel'))); showAnchorInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('showCollapsedAnchorDesc'))); const showAnchorToggle = createElement('div', { className: 'setting-toggle' + (this.settings.showCollapsedAnchor ? ' active' : ''), id: 'toggle-show-collapsed-anchor' }); showAnchorToggle.addEventListener('click', () => { this.settings.showCollapsedAnchor = !this.settings.showCollapsedAnchor; showAnchorToggle.classList.toggle('active', this.settings.showCollapsedAnchor); this.saveSettings(); // 实时更新UI GM_setValue('gemini_show_collapsed_anchor', this.settings.showCollapsedAnchor); const quickAnchor = document.getElementById('quick-anchor-btn'); if (quickAnchor) { quickAnchor.style.display = this.settings.showCollapsedAnchor ? 'flex' : 'none'; } this.showToast(this.settings.showCollapsedAnchor ? this.t('settingOn') : this.t('settingOff')); }); showAnchorItem.appendChild(showAnchorInfo); showAnchorItem.appendChild(showAnchorToggle); anchorContainer.appendChild(showAnchorItem); const anchorSection = this.createCollapsibleSection(this.t('readingNavigationSettings'), anchorContainer); // 5. 大纲详细设置 (高级配置) const outlineSettingsContainer = createElement('div', {}); // 自动更新开关 const autoUpdateItem = createElement('div', {className: 'setting-item'}); const autoUpdateInfo = createElement('div', {className: 'setting-item-info'}); autoUpdateInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('outlineAutoUpdateLabel'))); autoUpdateInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('outlineAutoUpdateDesc'))); const autoUpdateToggle = createElement('div', { className: 'setting-toggle' + (this.settings.outline.autoUpdate ? ' active' : ''), id: 'toggle-outline-auto-update' }); autoUpdateToggle.addEventListener('click', () => { this.settings.outline.autoUpdate = !this.settings.outline.autoUpdate; autoUpdateToggle.classList.toggle('active', this.settings.outline.autoUpdate); this.saveSettings(); if (this.outlineManager) this.outlineManager.updateAutoUpdateState(); this.showToast(this.settings.outline.autoUpdate ? this.t('settingOn') : this.t('settingOff')); }); autoUpdateItem.appendChild(autoUpdateInfo); autoUpdateItem.appendChild(autoUpdateToggle); outlineSettingsContainer.appendChild(autoUpdateItem); // 更新间隔 const updateIntervalItem = createElement('div', {className: 'setting-item'}); const updateIntervalInfo = createElement('div', {className: 'setting-item-info'}); updateIntervalInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('outlineUpdateIntervalLabel'))); const updateIntervalControls = createElement('div', {className: 'setting-controls'}); const updateIntervalInput = createElement('input', { type: 'number', className: 'setting-select', value: this.settings.outline.updateInterval, style: 'width: 60px !important; text-align: center;', min: 1 }); updateIntervalInput.addEventListener('change', () => { let val = parseInt(updateIntervalInput.value, 10); if (val < 1) val = 1; // 最小 1 秒 updateIntervalInput.value = val; this.settings.outline.updateInterval = val; this.saveSettings(); // OutlineManager 在触发下一次更新时会自动使用新间隔 this.showToast(this.t('outlineIntervalUpdated').replace('{val}', val)); }); updateIntervalControls.appendChild(updateIntervalInput); updateIntervalItem.appendChild(updateIntervalInfo); updateIntervalItem.appendChild(updateIntervalControls); outlineSettingsContainer.appendChild(updateIntervalItem); const outlineSettingsSection = this.createCollapsibleSection(this.t('outlineSettings'), outlineSettingsContainer, {defaultExpanded: false}); // 6. 标签页设置 (折叠面板) const tabSettingsContainer = createElement('div', {}); // 6.1 新标签页打开开关 if (this.siteAdapter.supportsNewTab()) { const newTabItem = createElement('div', {className: 'setting-item'}); const newTabInfo = createElement('div', {className: 'setting-item-info'}); newTabInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('openNewTabLabel'))); newTabInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('openNewTabDesc'))); const newTabToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.openInNewTab ? ' active' : ''), id: 'toggle-new-tab' }); newTabToggle.addEventListener('click', () => { this.settings.tabSettings.openInNewTab = !this.settings.tabSettings.openInNewTab; newTabToggle.classList.toggle('active', this.settings.tabSettings.openInNewTab); this.saveSettings(); this.createUI(); this.bindEvents(); if (this.currentTab === 'settings') { this.switchTab('settings'); } this.showToast(this.settings.tabSettings.openInNewTab ? this.t('settingOn') : this.t('settingOff')); }); newTabItem.appendChild(newTabInfo); newTabItem.appendChild(newTabToggle); tabSettingsContainer.appendChild(newTabItem); } // 6.2 自动重命名标签页开关 (仅支持的站点显示) if (this.siteAdapter.supportsTabRename()) { const renameTabItem = createElement('div', {className: 'setting-item'}); const renameTabInfo = createElement('div', {className: 'setting-item-info'}); renameTabInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('autoRenameTabLabel'))); renameTabInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('autoRenameTabDesc'))); const renameTabToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.autoRenameTab ? ' active' : ''), id: 'toggle-auto-rename-tab' }); renameTabItem.appendChild(renameTabInfo); renameTabItem.appendChild(renameTabToggle); tabSettingsContainer.appendChild(renameTabItem); // 6.3 检测频率 const intervalItem = createElement('div', {className: 'setting-item'}); const intervalInfo = createElement('div', {className: 'setting-item-info'}); intervalInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('renameIntervalLabel'))); intervalInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('renameIntervalDesc'))); const intervalControls = createElement('div', {className: 'setting-controls'}); const intervalSelect = createElement('select', { className: 'setting-select', id: 'select-rename-interval' }); const intervalOptions = [1, 3, 5, 10, 30, 60]; intervalOptions.forEach(val => { const option = createElement('option', {value: val}, `${val} ${this.t('secondsSuffix')}`); if (this.settings.tabSettings?.renameInterval === val) option.selected = true; intervalSelect.appendChild(option); }); intervalSelect.addEventListener('change', () => { this.settings.tabSettings.renameInterval = parseInt(intervalSelect.value); this.saveSettings(); if (this.tabRenameManager && this.tabRenameManager.isActive()) { this.tabRenameManager.setInterval(this.settings.tabSettings.renameInterval); } this.showToast(`${this.t('renameIntervalLabel')}: ${intervalSelect.value}${this.t('secondsSuffix')}`); }); intervalControls.appendChild(intervalSelect); intervalItem.appendChild(intervalInfo); intervalItem.appendChild(intervalControls); tabSettingsContainer.appendChild(intervalItem); // 定义状态更新函数 const updateIntervalState = () => { const isEnabled = this.settings.tabSettings.autoRenameTab; intervalSelect.disabled = !isEnabled; intervalItem.style.opacity = isEnabled ? '1' : '0.5'; intervalItem.style.pointerEvents = isEnabled ? 'auto' : 'none'; }; // 初始化状态 updateIntervalState(); // 绑定开关点击事件 renameTabToggle.addEventListener('click', () => { this.settings.tabSettings.autoRenameTab = !this.settings.tabSettings.autoRenameTab; renameTabToggle.classList.toggle('active', this.settings.tabSettings.autoRenameTab); this.saveSettings(); // 更新检测频率项状态 updateIntervalState(); // 启动/停止 TabRenameManager if (this.tabRenameManager) { if (this.settings.tabSettings.autoRenameTab) { this.tabRenameManager.start(); } else { this.tabRenameManager.stop(); } } this.showToast(this.settings.tabSettings.autoRenameTab ? this.t('settingOn') : this.t('settingOff')); }); } // 6.4 显示生成状态 (showStatus) if (this.siteAdapter.supportsTabRename()) { const showStatusItem = createElement('div', {className: 'setting-item'}); const showStatusInfo = createElement('div', {className: 'setting-item-info'}); showStatusInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('showStatusLabel'))); showStatusInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('showStatusDesc'))); const showStatusToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.showStatus !== false ? ' active' : ''), id: 'toggle-show-status' }); showStatusToggle.addEventListener('click', () => { this.settings.tabSettings.showStatus = !this.settings.tabSettings.showStatus; showStatusToggle.classList.toggle('active', this.settings.tabSettings.showStatus); this.saveSettings(); if (this.tabRenameManager) this.tabRenameManager.updateTabName(true); this.showToast(this.settings.tabSettings.showStatus ? this.t('settingOn') : this.t('settingOff')); }); showStatusItem.appendChild(showStatusInfo); showStatusItem.appendChild(showStatusToggle); tabSettingsContainer.appendChild(showStatusItem); } // 6.5 标题格式 (titleFormat) if (this.siteAdapter.supportsTabRename()) { const formatItem = createElement('div', {className: 'setting-item'}); const formatInfo = createElement('div', {className: 'setting-item-info'}); formatInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('titleFormatLabel'))); formatInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('titleFormatDesc'))); const formatInput = createElement('input', { type: 'text', className: 'prompt-input-title', value: this.settings.tabSettings?.titleFormat || '{status}{title}', style: 'width: 130px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px;' }); formatInput.addEventListener('change', () => { this.settings.tabSettings.titleFormat = formatInput.value.trim() || '{status}{title}'; this.saveSettings(); if (this.tabRenameManager) this.tabRenameManager.updateTabName(true); }); formatItem.appendChild(formatInfo); formatItem.appendChild(formatInput); tabSettingsContainer.appendChild(formatItem); } // 6.6 发送桌面通知 (showNotification) if (this.siteAdapter.supportsTabRename()) { const notificationItem = createElement('div', {className: 'setting-item'}); const notificationInfo = createElement('div', {className: 'setting-item-info'}); notificationInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('showNotificationLabel'))); notificationInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('showNotificationDesc'))); const notificationToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.showNotification ? ' active' : ''), id: 'toggle-show-notification' }); notificationToggle.addEventListener('click', () => { this.settings.tabSettings.showNotification = !this.settings.tabSettings.showNotification; notificationToggle.classList.toggle('active', this.settings.tabSettings.showNotification); this.saveSettings(); this.showToast(this.settings.tabSettings.showNotification ? this.t('settingOn') : this.t('settingOff')); }); notificationItem.appendChild(notificationInfo); notificationItem.appendChild(notificationToggle); tabSettingsContainer.appendChild(notificationItem); } // 6.7 自动窗口置顶 (autoFocus) if (this.siteAdapter.supportsTabRename()) { const autoFocusItem = createElement('div', {className: 'setting-item'}); const autoFocusInfo = createElement('div', {className: 'setting-item-info'}); autoFocusInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('autoFocusLabel'))); autoFocusInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('autoFocusDesc'))); const autoFocusToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.autoFocus ? ' active' : ''), id: 'toggle-auto-focus' }); autoFocusToggle.addEventListener('click', () => { this.settings.tabSettings.autoFocus = !this.settings.tabSettings.autoFocus; autoFocusToggle.classList.toggle('active', this.settings.tabSettings.autoFocus); this.saveSettings(); this.showToast(this.settings.tabSettings.autoFocus ? this.t('settingOn') : this.t('settingOff')); }); autoFocusItem.appendChild(autoFocusInfo); autoFocusItem.appendChild(autoFocusToggle); tabSettingsContainer.appendChild(autoFocusItem); } // 6.8 隐私模式 (privacyMode) if (this.siteAdapter.supportsTabRename()) { const privacyItem = createElement('div', {className: 'setting-item'}); const privacyInfo = createElement('div', {className: 'setting-item-info'}); privacyInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('privacyModeLabel'))); privacyInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('privacyModeDesc'))); const privacyToggle = createElement('div', { className: 'setting-toggle' + (this.settings.tabSettings?.privacyMode ? ' active' : ''), id: 'toggle-privacy-mode' }); privacyItem.appendChild(privacyInfo); privacyItem.appendChild(privacyToggle); tabSettingsContainer.appendChild(privacyItem); // 6.9 伪装标题输入框 (privacyTitle) const privacyTitleItem = createElement('div', {className: 'setting-item'}); const privacyTitleInfo = createElement('div', {className: 'setting-item-info'}); privacyTitleInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('privacyTitleLabel'))); const privacyTitleInput = createElement('input', { type: 'text', className: 'prompt-input-title', value: this.settings.tabSettings?.privacyTitle || 'Google', placeholder: this.t('privacyTitlePlaceholder'), style: 'width: 100px; padding: 4px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px;' }); privacyTitleInput.addEventListener('change', () => { this.settings.tabSettings.privacyTitle = privacyTitleInput.value.trim() || 'Google'; this.saveSettings(); if (this.settings.tabSettings.privacyMode && this.tabRenameManager) { this.tabRenameManager.updateTabName(true); } }); privacyTitleItem.appendChild(privacyTitleInfo); privacyTitleItem.appendChild(privacyTitleInput); tabSettingsContainer.appendChild(privacyTitleItem); // 定义状态更新函数(类似 renameInterval 的处理方式) const updatePrivacyTitleState = () => { const isEnabled = this.settings.tabSettings.privacyMode; privacyTitleInput.disabled = !isEnabled; privacyTitleItem.style.opacity = isEnabled ? '1' : '0.5'; privacyTitleItem.style.pointerEvents = isEnabled ? 'auto' : 'none'; }; // 初始化状态 updatePrivacyTitleState(); // 绑定隐私模式开关点击事件 privacyToggle.addEventListener('click', () => { this.settings.tabSettings.privacyMode = !this.settings.tabSettings.privacyMode; privacyToggle.classList.toggle('active', this.settings.tabSettings.privacyMode); this.saveSettings(); if (this.tabRenameManager) this.tabRenameManager.updateTabName(true); // 更新伪装标题项状态 updatePrivacyTitleState(); this.showToast(this.settings.tabSettings.privacyMode ? '🔒 ' + this.t('settingOn') : '🔓 ' + this.t('settingOff')); }); } const tabSettingsSection = this.createCollapsibleSection(this.t('tabSettingsTitle'), tabSettingsContainer, {defaultExpanded: false}); // 7. 其他设置 (折叠面板) - 仅保留站点特定功能 const otherSettingsContainer = createElement('div', {}); // Gemini Business 专属设置 if (this.siteAdapter instanceof GeminiBusinessAdapter) { const clearItem = createElement('div', {className: 'setting-item'}); const clearInfo = createElement('div', {className: 'setting-item-info'}); clearInfo.appendChild(createElement('div', {className: 'setting-item-label'}, this.t('clearOnSendLabel'))); clearInfo.appendChild(createElement('div', {className: 'setting-item-desc'}, this.t('clearOnSendDesc'))); const toggle = createElement('div', { className: 'setting-toggle' + (this.settings.clearTextareaOnSend ? ' active' : ''), id: 'toggle-clear-on-send' }); toggle.addEventListener('click', () => { this.settings.clearTextareaOnSend = !this.settings.clearTextareaOnSend; toggle.classList.toggle('active', this.settings.clearTextareaOnSend); this.saveSettings(); this.showToast(this.settings.clearTextareaOnSend ? this.t('settingOn') : this.t('settingOff')); }); clearItem.appendChild(clearInfo); clearItem.appendChild(toggle); otherSettingsContainer.appendChild(clearItem); } const otherSettingsSection = this.createCollapsibleSection(this.t('otherSettingsTitle'), otherSettingsContainer, {defaultExpanded: false}); // ========== 统一管理分类顺序 ========== // 1. 通用设置(语言)- 已在上方添加 // 2. 标签页设置 if (tabSettingsSection) content.appendChild(tabSettingsSection); // 3. 阅读导航 content.appendChild(anchorSection); // 4. 大纲设置 content.appendChild(outlineSettingsSection); // 5. 页面显示 content.appendChild(widthSection); // 6. 模型锁定 if (lockSection) content.appendChild(lockSection); // 7. 界面排版 content.appendChild(layoutSection); // 8. 其他设置 content.appendChild(otherSettingsSection); container.appendChild(content); } togglePanel() { const panel = document.getElementById('gemini-helper-panel'); const quickBtnGroup = document.getElementById('quick-btn-group'); const toggleBtn = document.getElementById('toggle-panel'); this.isCollapsed = !this.isCollapsed; if (this.isCollapsed) { panel.classList.add('collapsed'); if (quickBtnGroup) quickBtnGroup.classList.remove('hidden'); if (toggleBtn) toggleBtn.textContent = '+'; } else { panel.classList.remove('collapsed'); if (quickBtnGroup) quickBtnGroup.classList.add('hidden'); if (toggleBtn) toggleBtn.textContent = '−'; } } // ==================== Auto-Resume & Anchor Logic ==================== // 恢复阅读历史 (Auto-Resume) async restoreReadingProgress() { // 将 showToast 传给 manager 以显示加载进度 const success = await this.readingProgressManager.restoreProgress((msg) => this.showToast(msg)); const onRestorationComplete = () => { // 延迟一点开启记录,避开惯性滚动等干扰,确保后续的用户滚动能被正确记录 setTimeout(() => { this.readingProgressManager.startRecording(); }, 500); }; if (success) { // 恢复成功,获取恢复的位置设为“初始锚点” const restoredTop = this.readingProgressManager.restoredTop; if (restoredTop !== undefined) { this.anchorManager.setAnchor(restoredTop); } this.showToast(this.t('restoredPosition')); } // 无论成功失败,最后都开启记录 onRestorationComplete(); } // 清理过期阅读历史 cleanupReadingHistory() { this.readingProgressManager.cleanup(); } // 锚点按钮点击 (Back functionality) handleAnchorClick() { if (this.anchorManager.hasAnchor()) { this.anchorManager.backToAnchor(); this.showToast(this.t('jumpToAnchor')); } else { this.showToast('暂无阅读锚点 (点击顶部/底部按钮可自动生成)'); } } // 更新锚点按钮状态 (UI) updateAnchorButtonState(hasAnchor) { [document.getElementById('quick-anchor-btn'), document.getElementById('scroll-anchor-btn')].forEach(btn => { if (btn) { if (hasAnchor) { btn.style.opacity = '1'; btn.style.cursor = 'pointer'; btn.title = this.t('jumpToAnchor'); } else { btn.style.opacity = '0.4'; btn.style.cursor = 'default'; btn.title = "暂无锚点"; } } }); } // 滚动到页面顶部 scrollToTop() { // 点击去顶部时,自动记录当前位置为锚点 this.anchorManager.setAnchor(this.scrollManager.scrollTop); this.scrollManager.scrollTo({top: 0, behavior: 'smooth'}); } // 滚动到页面底部 scrollToBottom() { // 点击去底部时,自动记录当前位置为锚点 this.anchorManager.setAnchor(this.scrollManager.scrollTop); this.scrollManager.scrollTo({top: this.scrollManager.scrollHeight, behavior: 'smooth'}); } refreshCategories() { const container = document.getElementById('prompt-categories'); if (!container) return; const categories = this.getCategories(); clearElement(container); container.appendChild(createElement('span', { className: 'category-tag active', 'data-category': 'all' }, this.t('allCategory'))); categories.forEach(cat => { container.appendChild(createElement('span', {className: 'category-tag', 'data-category': cat}, cat)); }); // 添加分类管理按钮 const manageBtn = createElement('button', { className: 'category-manage-btn', title: this.t('categoryManage') }, this.t('manageCategory')); manageBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showCategoryModal(); }); container.appendChild(manageBtn); } // 显示分类管理弹窗 showCategoryModal() { const categories = this.getCategories(); const modal = createElement('div', {className: 'prompt-modal'}); const modalContent = createElement('div', {className: 'prompt-modal-content category-modal-content'}); const modalHeader = createElement('div', {className: 'prompt-modal-header'}, this.t('categoryManage')); modalContent.appendChild(modalHeader); const categoryList = createElement('div', {className: 'category-list'}); if (categories.length === 0) { categoryList.appendChild(createElement('div', {className: 'category-empty'}, this.t('categoryEmpty'))); } else { categories.forEach(cat => { const count = this.prompts.filter(p => p.category === cat).length; const item = createElement('div', {className: 'category-item'}); const info = createElement('div', {className: 'category-item-info'}); info.appendChild(createElement('span', {className: 'category-item-name'}, cat)); info.appendChild(createElement('span', {className: 'category-item-count'}, `${count} 个提示词`)); const actions = createElement('div', {className: 'category-item-actions'}); const renameBtn = createElement('button', {className: 'category-action-btn rename'}, this.t('rename')); const deleteBtn = createElement('button', {className: 'category-action-btn delete'}, this.t('delete')); renameBtn.addEventListener('click', () => { const newName = window.prompt(this.t('newCategoryName'), cat); if (newName && newName.trim() && newName !== cat) { this.renameCategory(cat, newName.trim()); modal.remove(); this.showCategoryModal(); } }); deleteBtn.addEventListener('click', () => { if (confirm(this.t('confirmDeleteCategory'))) { this.deleteCategory(cat); modal.remove(); this.showCategoryModal(); } }); actions.appendChild(renameBtn); actions.appendChild(deleteBtn); item.appendChild(info); item.appendChild(actions); categoryList.appendChild(item); }); } modalContent.appendChild(categoryList); const btnGroup = createElement('div', {className: 'prompt-modal-btns'}); const closeBtn = createElement('button', {className: 'prompt-modal-btn secondary'}, this.t('cancel')); closeBtn.addEventListener('click', () => modal.remove()); btnGroup.appendChild(closeBtn); modalContent.appendChild(btnGroup); modal.appendChild(modalContent); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); document.body.appendChild(modal); } // 重命名分类 renameCategory(oldName, newName) { this.prompts.forEach(p => { if (p.category === oldName) { p.category = newName; } }); this.savePrompts(); this.refreshCategories(); this.refreshPromptList(); this.showToast(`分类已重命名为"${newName}"`); } // 删除分类(将关联提示词移至"未分类") deleteCategory(name) { this.prompts.forEach(p => { if (p.category === name) { p.category = '未分类'; } }); this.savePrompts(); this.refreshCategories(); this.refreshPromptList(); this.showToast(`分类"${name}"已删除`); } refreshPromptList(filter = '') { const container = document.getElementById('prompt-list'); if (!container) return; const activeCategory = document.querySelector('.category-tag.active')?.dataset.category || 'all'; let filteredPrompts = this.prompts; if (activeCategory !== 'all') filteredPrompts = filteredPrompts.filter(p => p.category === activeCategory); if (filter) filteredPrompts = filteredPrompts.filter(p => p.title.toLowerCase().includes(filter.toLowerCase()) || p.content.toLowerCase().includes(filter.toLowerCase())); clearElement(container); if (filteredPrompts.length === 0) { container.appendChild(createElement('div', {style: 'text-align: center; padding: 20px; color: #9ca3af;'}, '暂无提示词')); return; } filteredPrompts.forEach((prompt, index) => { const item = createElement('div', { className: 'prompt-item', draggable: 'false', style: 'user-select: none;' }); item.dataset.promptId = prompt.id; item.dataset.index = index; if (this.selectedPrompt?.id === prompt.id) item.classList.add('selected'); const itemHeader = createElement('div', {className: 'prompt-item-header'}); itemHeader.appendChild(createElement('div', {className: 'prompt-item-title'}, prompt.title)); itemHeader.appendChild(createElement('span', {className: 'prompt-item-category'}, prompt.category || '未分类')); const itemContent = createElement('div', {className: 'prompt-item-content'}, prompt.content); const itemActions = createElement('div', {className: 'prompt-item-actions'}); const dragBtn = createElement('button', { className: 'prompt-action-btn drag-prompt', 'data-id': prompt.id, title: '拖动排序' }, '☰'); dragBtn.style.cursor = 'grab'; // 仅当按下拖拽按钮时才允许拖动 dragBtn.addEventListener('mousedown', () => { item.setAttribute('draggable', 'true'); // 监听全局鼠标释放,恢复不可拖动 const upHandler = () => { item.setAttribute('draggable', 'false'); window.removeEventListener('mouseup', upHandler); }; window.addEventListener('mouseup', upHandler); }); itemActions.appendChild(dragBtn); itemActions.appendChild(createElement('button', { className: 'prompt-action-btn copy-prompt', 'data-id': prompt.id, title: '复制' }, '📋')); itemActions.appendChild(createElement('button', { className: 'prompt-action-btn edit-prompt', 'data-id': prompt.id, title: '编辑' }, '✏')); itemActions.appendChild(createElement('button', { className: 'prompt-action-btn delete-prompt', 'data-id': prompt.id, title: '删除' }, '🗑')); item.appendChild(itemHeader); item.appendChild(itemContent); item.appendChild(itemActions); item.addEventListener('click', (e) => { if (!e.target.closest('.prompt-item-actions')) this.selectPrompt(prompt, item); }); // 拖拽事件处理 item.addEventListener('dragstart', (e) => { item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', item.innerHTML); }); item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const draggingItem = container.querySelector('.dragging'); if (draggingItem && draggingItem !== item) { const rect = item.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; if (e.clientY < midpoint) { container.insertBefore(draggingItem, item); } else { container.insertBefore(draggingItem, item.nextSibling); } } }); item.addEventListener('dragend', () => { item.classList.remove('dragging'); item.setAttribute('draggable', 'false'); // 拖拽结束立即恢复 this.updatePromptOrder(); }); container.appendChild(item); }); } // 更新提示词顺序 updatePromptOrder() { const container = document.getElementById('prompt-list'); const items = Array.from(container.querySelectorAll('.prompt-item')); const newOrder = items.map(item => item.dataset.promptId); // 重新排列 prompts 数组 const orderedPrompts = []; newOrder.forEach(id => { const prompt = this.prompts.find(p => p.id === id); if (prompt) orderedPrompts.push(prompt); }); this.prompts = orderedPrompts; this.savePrompts(); this.showToast(this.t('orderUpdated')); } selectPrompt(prompt, itemElement) { if (this.isScrolling) { this.showToast(this.t('scrolling')); return; } this.selectedPrompt = prompt; document.querySelectorAll('.prompt-item').forEach(item => item.classList.remove('selected')); itemElement.classList.add('selected'); // 显示当前提示词悬浮条 const selectedBar = document.querySelector('.selected-prompt-bar'); const selectedText = document.getElementById('selected-prompt-text'); if (selectedBar && selectedText) { selectedText.textContent = prompt.title; selectedBar.classList.add('show'); } this.insertPromptToTextarea(prompt.content); this.showToast(`${this.t('inserted')}: ${prompt.title}`); } insertPromptToTextarea(promptContent) { if (this.isScrolling) { this.showToast('页面正在滚动,请稍后再选择提示词'); return; } const promiseOrResult = this.siteAdapter.insertPrompt(promptContent); // 处理异步返回 (Gemini Business 是异步的) if (promiseOrResult instanceof Promise) { promiseOrResult.then(success => { if (!success) { this.showToast('未找到输入框,请点击输入框后重试'); // 再次尝试查找 this.siteAdapter.findTextarea(); } }); } else if (!promiseOrResult) { this.showToast('未找到输入框,请点击输入框后重试'); this.siteAdapter.findTextarea(); } } clearSelectedPrompt() { this.selectedPrompt = null; document.querySelector('.selected-prompt-bar')?.classList.remove('show'); document.querySelectorAll('.prompt-item').forEach(item => item.classList.remove('selected')); } showEditModal(prompt = null) { const isEdit = prompt !== null; const modal = createElement('div', {className: 'prompt-modal'}); const modalContent = createElement('div', {className: 'prompt-modal-content'}); const modalHeader = createElement('div', {className: 'prompt-modal-header'}, isEdit ? this.t('editPrompt') : this.t('addNewPrompt')); const titleGroup = createElement('div', {className: 'prompt-form-group'}); titleGroup.appendChild(createElement('label', {className: 'prompt-form-label'}, this.t('title'))); const titleInput = createElement('input', { className: 'prompt-form-input', type: 'text', value: isEdit ? prompt.title : '' }); titleGroup.appendChild(titleInput); const categoryGroup = createElement('div', {className: 'prompt-form-group'}); categoryGroup.appendChild(createElement('label', {className: 'prompt-form-label'}, this.t('category'))); const categoryInput = createElement('input', { className: 'prompt-form-input', type: 'text', value: isEdit ? (prompt.category || '') : '', placeholder: this.t('categoryPlaceholder') }); categoryGroup.appendChild(categoryInput); const contentGroup = createElement('div', {className: 'prompt-form-group'}); contentGroup.appendChild(createElement('label', {className: 'prompt-form-label'}, this.t('content'))); const contentTextarea = createElement('textarea', {className: 'prompt-form-textarea'}); contentTextarea.value = isEdit ? prompt.content : ''; contentGroup.appendChild(contentTextarea); const modalActions = createElement('div', {className: 'prompt-modal-actions'}); const cancelBtn = createElement('button', {className: 'prompt-modal-btn secondary'}, this.t('cancel')); const saveBtn = createElement('button', {className: 'prompt-modal-btn primary'}, isEdit ? this.t('save') : this.t('add')); modalActions.appendChild(cancelBtn); modalActions.appendChild(saveBtn); modalContent.appendChild(modalHeader); modalContent.appendChild(titleGroup); modalContent.appendChild(categoryGroup); modalContent.appendChild(contentGroup); modalContent.appendChild(modalActions); modal.appendChild(modalContent); document.body.appendChild(modal); cancelBtn.addEventListener('click', () => modal.remove()); saveBtn.addEventListener('click', () => { const title = titleInput.value.trim(); const content = contentTextarea.value.trim(); if (!title || !content) { alert(this.t('fillTitleContent')); return; } if (isEdit) { this.updatePrompt(prompt.id, {title, category: categoryInput.value.trim(), content}); this.showToast(this.t('promptUpdated')); } else { this.addPrompt({title, category: categoryInput.value.trim(), content}); this.showToast(this.t('promptAdded')); } modal.remove(); }); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); } showToast(message) { const toast = createElement('div', {className: 'prompt-toast'}, message); document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'toastSlideIn 0.3s reverse'; setTimeout(() => toast.remove(), 300); }, 2000); } findElementByComposedPath(e) { if (!e) return null; // 获取事件的完整传播路径(兼容没有 composedPath 的浏览器) const path = typeof e.composedPath === 'function' ? e.composedPath() : (e.path || []); // 获取提交按钮选择器数组并合并成 selector 字符串 const selectors = (this.siteAdapter && typeof this.siteAdapter.getSubmitButtonSelectors === 'function') ? this.siteAdapter.getSubmitButtonSelectors() : []; const combinedSelector = selectors.length ? selectors.join(', ') : ''; if (!combinedSelector) return null; // 查找路径中第一个符合条件的元素 const foundElement = path.find(element => element && element instanceof Element && typeof element.matches === 'function' && element.matches(combinedSelector) ); return foundElement || null; } bindEvents() { const searchInput = document.getElementById('prompt-search'); if (searchInput) searchInput.addEventListener('input', (e) => this.refreshPromptList(e.target.value)); const categories = document.getElementById('prompt-categories'); if (categories) { categories.addEventListener('click', (e) => { if (e.target.classList.contains('category-tag')) { document.querySelectorAll('.category-tag').forEach(tag => tag.classList.remove('active')); e.target.classList.add('active'); this.refreshPromptList(document.getElementById('prompt-search')?.value || ''); } }); } document.getElementById('add-prompt')?.addEventListener('click', () => this.showEditModal()); document.getElementById('prompt-list')?.addEventListener('click', (e) => { if (e.target.classList.contains('edit-prompt')) { const prompt = this.prompts.find(p => p.id === e.target.dataset.id); if (prompt) this.showEditModal(prompt); } else if (e.target.classList.contains('delete-prompt')) { if (confirm(this.t('confirmDelete'))) { this.deletePrompt(e.target.dataset.id); this.showToast(this.t('deleted')); } } else if (e.target.classList.contains('copy-prompt')) { const prompt = this.prompts.find(p => p.id === e.target.dataset.id); if (prompt) { navigator.clipboard.writeText(prompt.content).then(() => { this.showToast(this.t('copied')); }).catch(() => { // 降级方案 const textarea = document.createElement('textarea'); textarea.value = prompt.content; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); this.showToast(this.t('copied')); }); } } }); document.getElementById('clear-prompt')?.addEventListener('click', () => { this.clearSelectedPrompt(); // 针对 Gemini Business,根据设置决定是否用零宽字符清空 if (this.siteAdapter instanceof GeminiBusinessAdapter) { if (this.settings.clearTextareaOnSend) { this.siteAdapter.clearTextarea(); // 插入零宽字符 } else { this.siteAdapter.clearTextareaNormal(); // 普通清空 } } else { // 其他适配器调用各自的 clearTextarea 方法 this.siteAdapter.clearTextarea(); } this.showToast(this.t('cleared')); }); this.makeDraggable(); // 2. 按钮点击监听 document.addEventListener('click', (e) => { // 委托适配器检查是否为输入框,自动更新引用 if (this.siteAdapter.isValidTextarea(e.target)) { this.siteAdapter.textarea = e.target; } else { const closest = e.target.closest('[contenteditable="true"], .ProseMirror, textarea'); if (closest && this.siteAdapter.isValidTextarea(closest)) { this.siteAdapter.textarea = closest; } } // 检测是否点击了发送按钮 const found = this.findElementByComposedPath(e); let matched = !!found; // 如果 composedPath 没命中,尝试使用 closest 回退(兼容 Shadow DOM 之外的情况) if (!matched && e && e.target && typeof e.target.closest === 'function') { const selectors = (this.siteAdapter && typeof this.siteAdapter.getSubmitButtonSelectors === 'function') ? this.siteAdapter.getSubmitButtonSelectors() : []; const combined = selectors.length ? selectors.join(', ') : ''; if (combined) { try { matched = !!e.target.closest(combined); } catch (err) { matched = false; } } } if (matched) { // 如果有选中的提示词,清除悬浮条 if (this.selectedPrompt) { setTimeout(() => { this.clearSelectedPrompt(); }, 100); } // 针对 Gemini Business:无论是否使用提示词,发送后都修复中文输入 if (this.siteAdapter instanceof GeminiBusinessAdapter && this.settings.clearTextareaOnSend) { setTimeout(() => { this.siteAdapter.clearTextarea(); }, 200); } } }); // 3. 回车键发送监听 document.addEventListener('keydown', (e) => { // 仅处理 Enter 键(不带 Shift 修饰符,避免干扰换行操作) if (e.key !== 'Enter' || e.shiftKey) return; // 使用 composedPath 检查事件源是否来自输入框(兼容 Shadow DOM) const path = typeof e.composedPath === 'function' ? e.composedPath() : (e.path || []); const isFromTextarea = path.some(element => element && element instanceof Element && this.siteAdapter.isValidTextarea(element) ); if (!isFromTextarea) return; // 清理逻辑 if (this.selectedPrompt) { setTimeout(() => { this.clearSelectedPrompt(); }, 100); } // 针对 Gemini Business:无论是否使用提示词,发送后都修复中文输入 if (this.siteAdapter instanceof GeminiBusinessAdapter && this.settings.clearTextareaOnSend) { setTimeout(() => { this.siteAdapter.clearTextarea(); }, 200); } }, true); // 使用捕获阶段确保在 Shadow DOM 场景下也能捕获 document.getElementById('toggle-panel')?.addEventListener('click', () => this.togglePanel()); this.makeDraggable(); // 初始化 URL 监听 (处理 SPA 页面跳转) this.initUrlChangeObserver(); } initUrlChangeObserver() { let lastUrl = window.location.href; const checkUrl = () => { const currentUrl = window.location.href; if (currentUrl !== lastUrl) { lastUrl = currentUrl; // URL 变化时,先停止录制(防止错误覆盖新会话的持久化数据) this.readingProgressManager.stopRecording(); // 重置内存中的锚点状态 this.anchorScrollTop = null; this.anchorManager.reset(); // 会话切换时立即更新标签页标题 if (this.tabRenameManager && this.settings.tabSettings?.autoRenameTab) { // 清除缓存的会话名称,强制从新会话获取 this.tabRenameManager.lastSessionName = null; // 多次尝试更新,因为 Gemini 可能需要时间来更新页面标题 [300, 800, 1500].forEach(delay => { setTimeout(() => { this.tabRenameManager.updateTabName(true); }, delay); }); } // 给予页面渲染一点时间后尝试恢复 setTimeout(() => { this.restoreReadingProgress(); // 针对 Gemini Business:切换会话后修复中文输入 if (this.siteAdapter instanceof GeminiBusinessAdapter && this.settings.clearTextareaOnSend) { // 切换会话后 textarea 引用可能失效,需要重新查找 this.siteAdapter.findTextarea(); this.siteAdapter.clearTextarea(); } }, 1500); } }; // 1. 监听 popstate (后退/前进) window.addEventListener('popstate', checkUrl); // 2. Monkey patch pushState/replaceState const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function () { originalPushState.apply(this, arguments); checkUrl(); }; history.replaceState = function () { originalReplaceState.apply(this, arguments); checkUrl(); }; // 3. 定时器兜底 (防止某些框架绕过 history API) setInterval(checkUrl, 1000); } makeDraggable() { const panel = document.getElementById('gemini-helper-panel'); const header = panel?.querySelector('.prompt-panel-header'); if (!panel || !header) return; let isDragging = false, currentX, currentY, initialX, initialY, xOffset = 0, yOffset = 0; header.addEventListener('mousedown', (e) => { if (e.target.closest('.prompt-panel-controls')) return; e.preventDefault(); // 阻止文本选中 initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; isDragging = true; // 拖动时禁止全局文本选中 document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (isDragging) { e.preventDefault(); currentX = e.clientX - initialX; currentY = e.clientY - initialY; xOffset = currentX; yOffset = currentY; panel.style.transform = `translate(${currentX}px, ${currentY}px)`; } }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; // 恢复文本选中 document.body.style.userSelect = ''; } }); } } function init() { try { console.log('Gemini Helper: Initializing...'); // 初始化站点注册表 const siteRegistry = new SiteRegistry(); siteRegistry.register(new GeminiBusinessAdapter()); // 优先检测 siteRegistry.register(new GeminiAdapter()); siteRegistry.register(new GensparkAdapter()); const currentAdapter = siteRegistry.detect(); if (!currentAdapter) { console.log('Gemini Helper: 未匹配到当前站点,跳过初始化。'); return; } console.log(`Gemini Helper: 已匹配站点 - ${currentAdapter.getName()}`); setTimeout(() => { try { console.log('Gemini Helper: Creating instance...'); window.geminiHelper = new GeminiHelper(siteRegistry); console.log('Gemini Helper: Instance created successfully.'); } catch (error) { console.error('Gemini Helper: 启动失败 (Constructor Error)', error); } }, 2000); } catch (e) { console.error('Gemini Helper: 初始化失败 (Init Error)', e); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();