Gemini 助手:支持对话大纲、提示词管理、模型锁定、标签页增强(状态显示/隐私模式/生成完成通知)、阅读历史恢复、双向锚点、自动加宽页面、中文输入修复,智能适配 Gemini 标准版/企业版/Genspark
// ==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.org/scripts/559089/1714656/background-keep-alive.js
// @require https://update.greasyfork.org/scripts/559176/1715343/domToolkit.js
// @license MIT
// ==/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>|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<boolean>} 是否恢复成功
*/
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();
}
})();