Gemini 助手:支持会话管理(分类/搜索/标签)、对话大纲、提示词管理、模型锁定、面板状态控制、标签页增强(状态显示/隐私模式/生成完成通知)、阅读历史恢复、双向锚点、自动加宽页面、中文输入修复、智能暗色模式适配,适配 Gemini 标准版/企业版
// ==UserScript==
// @name gemini-helper
// @namespace http://tampermonkey.net/
// @version 1.9.2
// @description Gemini 助手:支持会话管理(分类/搜索/标签)、对话大纲、提示词管理、模型锁定、面板状态控制、标签页增强(状态显示/隐私模式/生成完成通知)、阅读历史恢复、双向锚点、自动加宽页面、中文输入修复、智能暗色模式适配,适配 Gemini 标准版/企业版
// @description:en Gemini Helper: Supports conversation management (folders/search/tags), 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 dark mode, adaptation for Gemini/Gemini Enterprise
// @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/1718116/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',
CONVERSATIONS: 'gemini_conversations',
};
// 默认 Tab 顺序(settings 已移到 header 按钮,不参与排序)
const DEFAULT_TAB_ORDER = ['prompts', 'outline', 'conversations'];
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}
};
// 默认会话数据结构
const DEFAULT_CONVERSATION_DATA = {
folders: [{ id: 'inbox', name: '📥 收件箱', icon: '📥', isDefault: true }],
tags: [], // 标签定义数组 { id, name, color }
conversations: {}, // 会话数据,key 为 conversationId
lastUsedFolderId: 'inbox',
};
// 预设标签颜色 (29色 - 中国传统色精选 - 优化对比度)
const TAG_COLORS = [
'#ff461f', // 朱砂
'#e35c64', // 桃夭
'#db5a6b', // 海棠红
'#f2481b', // 榴花红
'#9d2933', // 胭脂
'#ffa631', // 杏黄
'#d6a01d', // 姜黄
'#f0c239', // 缃色
'#d9b611', // 秋香色
'#8cc540', // 柳绿
'#0eb83a', // 葱绿
'#227d51', // 官绿
'#789262', // 竹青
'#29b7cb', // 湖蓝
'#177cb0', // 靛蓝
'#1685a9', // 石青
'#4b5cc4', // 宝蓝
'#2e4e7e', // 藏蓝
'#b088d1', // 丁香
'#b359ab', // 雪青
'#8d4bbb', // 紫罗兰
'#4c221b', // 紫檀
'#a88462', // 驼色
'#ca6924', // 琥珀
'#845a33', // 赭石
'#75878a', // 苍色
'#57c3c2', // 天水碧
'#ce97a8', // 藕荷
'#5d513c', // 墨灰
'#9b95c9', // 长春花 (新增第30色)
];
// Tab 定义(用于渲染和显示)
const TAB_DEFINITIONS = {
prompts: { id: 'prompts', labelKey: 'tabPrompts', icon: '✏️' },
outline: { id: 'outline', labelKey: 'tabOutline', icon: '📋' },
conversations: { id: 'conversations', labelKey: 'tabConversations', 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: '通用设置',
panelSettingsTitle: '面板设置',
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 级',
// 刷新按钮提示
togglePrompts: '显示/隐藏提示词',
toggleConversations: '显示/隐藏会话管理',
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 生成长内容时,阻止页面自动滚动到底部,方便阅读上文',
// 界面排版开关
defaultPanelStateLabel: '默认显示面板',
defaultPanelStateDesc: '刷新页面后面板默认保持展开状态',
autoHidePanelLabel: '自动隐藏面板',
autoHidePanelDesc: '点击面板外部(如左侧侧边栏、聊天区、输入框)时自动隐藏',
// 界面排版开关
disableOutline: '禁用大纲',
togglePrompts: '启用/禁用提示词',
toggleConversations: '启用/禁用会话',
// 会话功能
tabConversations: '会话',
conversationsEmpty: '暂无会话数据',
conversationsEmptyHint: '点击上方同步按钮从侧边栏导入会话',
conversationsSync: '同步会话',
conversationsSyncing: '正在同步...',
conversationsSynced: '同步完成',
conversationsAddFolder: '新建文件夹',
conversationsRename: '重命名',
conversationsDelete: '删除',
conversationsDeleteConfirm: '确定删除此文件夹吗?其中的会话将移到收件箱。',
conversationsFolderCreated: '文件夹已创建',
conversationsFolderRenamed: '文件夹已重命名',
conversationsFolderDeleted: '文件夹已删除',
conversationsCannotDeleteDefault: '无法删除默认文件夹',
conversationsIcon: '图标',
conversationsFolderName: '名称',
conversationsFolderNamePlaceholder: '输入文件夹名称',
cancel: '取消',
confirm: '确定',
conversationsSyncEmpty: '未找到会话',
conversationsSyncNoChange: '无新会话',
justNow: '刚刚',
minutesAgo: '分钟前',
hoursAgo: '小时前',
daysAgo: '天前',
conversationsSelectFolder: '选择同步目标文件夹',
conversationsMoveTo: '移动到...',
conversationsMoved: '已移动到',
conversationsSyncDeleteTitle: '同步删除',
conversationsSyncDeleteMsg: '检测到 {count} 个会话已在云端删除,是否同步删除本地记录?',
conversationsDeleted: '已移除',
// 会话设置
conversationsSettingsTitle: '会话设置',
conversationsSyncDeleteLabel: '删除时同步删除云端',
conversationsSyncDeleteDesc: '删除本地会话记录时,同时从 {site} 云端删除',
conversationsSyncRenameLabel: '重命名时同步云端',
conversationsSyncRenameDesc: '修改会话标题时,同时在 {site} 侧边栏更新标题',
conversationsCustomIcon: '自定义图标',
batchSelected: '已选 {n} 个',
batchMove: '移动',
batchDelete: '删除',
batchExit: '退出',
conversationsRefresh: '刷新会话列表',
conversationsSearchPlaceholder: '搜索会话...',
conversationsSearchResult: '个结果',
conversationsNoSearchResult: '未找到匹配结果',
conversationsSetTags: '设置标签',
conversationsNewTag: '新建标签',
conversationsTagName: '标签名称',
conversationsTagColor: '标签颜色',
conversationsFilterByTags: '按标签筛选',
conversationsClearTags: '清除筛选',
conversationsTagCreated: '标签已创建',
conversationsTagUpdated: '标签已更新',
conversationsTagDeleted: '标签已删除',
conversationsTagExists: '标签名称已存在',
conversationsUpdateTag: '更新标签',
conversationsNoTags: '暂无标签',
conversationsManageTags: '管理标签',
conversationsPin: '置顶📌',
conversationsUnpin: '取消置顶',
conversationsPinned: '已置顶',
conversationsUnpinned: '已取消置顶',
conversationsFilterPinned: '筛选置顶',
conversationsClearAll: '清除所有筛选',
conversationsBatchMode: '批量操作',
},
'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: '啟用/禁用提示詞',
toggleConversations: '啟用/禁用會話',
// 會話功能
tabConversations: '會話',
conversationsEmpty: '暫無會話數據',
conversationsEmptyHint: '點擊上方同步按鈕從側邊欄導入會話',
conversationsSync: '同步會話',
conversationsSyncing: '正在同步...',
conversationsSynced: '同步完成',
conversationsAddFolder: '新建資料夾',
conversationsRename: '重命名',
conversationsDelete: '刪除',
conversationsDeleteConfirm: '確定刪除此資料夾嗎?其中的會話將移到收件箱。',
conversationsFolderCreated: '資料夾已創建',
conversationsFolderRenamed: '資料夾已重命名',
conversationsFolderDeleted: '資料夾已刪除',
conversationsCannotDeleteDefault: '無法刪除預設資料夾',
conversationsIcon: '圖標',
conversationsFolderName: '名稱',
conversationsFolderNamePlaceholder: '輸入資料夾名稱',
cancel: '取消',
confirm: '確定',
conversationsSyncEmpty: '未找到會話',
conversationsSyncNoChange: '無新會話',
justNow: '剛剛',
minutesAgo: '分鐘前',
hoursAgo: '小時前',
daysAgo: '天前',
conversationsSelectFolder: '選擇同步目標資料夾',
conversationsMoveTo: '移動到...',
conversationsMoved: '已移動到',
conversationsSyncDeleteTitle: '同步刪除',
conversationsSyncDeleteMsg: '檢測到 {count} 個會話已在雲端刪除,是否同步刪除本地記錄?',
conversationsDeleted: '已移除',
// 會話設置
conversationsSettingsTitle: '會話設置',
conversationsSyncDeleteLabel: '刪除時同步刪除雲端',
conversationsSyncDeleteDesc: '刪除本地會話記錄時,同時從 {site} 雲端刪除',
conversationsSyncRenameLabel: '重命名時同步雲端',
conversationsSyncRenameDesc: '修改會話標題時,同時在 {site} 側邊欄更新標題',
conversationsCustomIcon: '自定義圖示',
batchSelected: '已選 {n} 個',
batchMove: '移動',
batchDelete: '刪除',
batchExit: '退出',
conversationsRefresh: '刷新會話列表',
conversationsSearchPlaceholder: '搜尋會話...',
conversationsSearchResult: '個結果',
conversationsNoSearchResult: '未找到匹配結果',
conversationsSetTags: '設定標籤',
conversationsNewTag: '新建標籤',
conversationsTagName: '標籤名稱',
conversationsTagColor: '標籤顏色',
conversationsFilterByTags: '按標籤篩選',
conversationsClearTags: '清除篩選',
conversationsTagCreated: '標籤已建立',
conversationsTagUpdated: '標籤已更新',
conversationsTagDeleted: '標籤已刪除',
conversationsTagExists: '標籤名稱已存在',
conversationsUpdateTag: '更新標籤',
conversationsNoTags: '暫無標籤',
conversationsManageTags: '管理標籤',
conversationsPin: '置頂📌',
conversationsUnpin: '取消置頂',
conversationsPinned: '已置頂',
conversationsUnpinned: '已取消置頂',
conversationsFilterPinned: '篩選置頂',
conversationsClearAll: '清除所有篩選',
conversationsBatchMode: '批次操作',
},
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',
toggleConversations: 'Toggle Conversations',
// Conversations
tabConversations: 'Conversations',
conversationsEmpty: 'No conversations yet',
conversationsEmptyHint: 'Click sync button above to import from sidebar',
conversationsSync: 'Sync',
conversationsSyncing: 'Syncing...',
conversationsSynced: 'Synced',
conversationsAddFolder: 'New Folder',
conversationsRename: 'Rename',
conversationsDelete: 'Delete',
conversationsDeleteConfirm: 'Delete this folder? Conversations will be moved to Inbox.',
conversationsFolderCreated: 'Folder created',
conversationsFolderRenamed: 'Folder renamed',
conversationsFolderDeleted: 'Folder deleted',
conversationsCannotDeleteDefault: 'Cannot delete default folder',
conversationsIcon: 'Icon',
conversationsFolderName: 'Name',
conversationsFolderNamePlaceholder: 'Enter folder name',
cancel: 'Cancel',
confirm: 'Confirm',
conversationsSyncEmpty: 'No conversations found',
conversationsSyncNoChange: 'No new conversations',
justNow: 'Just now',
minutesAgo: 'm ago',
hoursAgo: 'h ago',
daysAgo: 'd ago',
conversationsSelectFolder: 'Select sync folder',
conversationsMoveTo: 'Move to...',
conversationsMoved: 'Moved to',
conversationsSyncDeleteTitle: 'Sync Deletion',
conversationsSyncDeleteMsg: '{count} conversation(s) have been deleted from cloud. Remove local records?',
conversationsDeleted: 'Removed',
// Conversation settings
conversationsSettingsTitle: 'Conversation Settings',
conversationsSyncDeleteLabel: 'Sync delete to cloud',
conversationsSyncDeleteDesc: 'When deleting local record, also delete from {site} cloud',
conversationsSyncRenameLabel: 'Sync rename to cloud',
conversationsSyncRenameDesc: 'When renaming conversation, also update title in {site} sidebar',
conversationsCustomIcon: 'Custom Icon',
batchSelected: 'Selected {n}',
batchMove: 'Move',
batchDelete: 'Delete',
batchExit: 'Exit',
conversationsRefresh: 'Refresh List',
conversationsSearchPlaceholder: 'Search conversations...',
conversationsSearchResult: 'result(s)',
conversationsNoSearchResult: 'No matching results',
conversationsSetTags: 'Set Tags',
conversationsNewTag: 'New Tag',
conversationsTagName: 'Tag Name',
conversationsTagColor: 'Tag Color',
conversationsFilterByTags: 'Filter by Tags',
conversationsClearTags: 'Clear Filter',
conversationsTagCreated: 'Tag Created',
conversationsTagUpdated: 'Tag Updated',
conversationsTagDeleted: 'Tag Deleted',
conversationsTagExists: 'Tag name already exists',
conversationsUpdateTag: 'Update Tag',
conversationsNoTags: 'No Tags',
conversationsManageTags: 'Manage Tags',
conversationsSetTags: 'Set Tags',
conversationsNewTag: 'New Tag',
conversationsTagName: 'Tag Name',
conversationsTagColor: 'Tag Color',
conversationsFilterByTags: 'Filter by Tags',
conversationsClearTags: 'Clear Filter',
conversationsTagCreated: 'Tag Created',
conversationsTagUpdated: 'Tag Updated',
conversationsTagDeleted: 'Tag Deleted',
conversationsNoTags: 'No Tags',
conversationsManageTags: 'Manage Tags',
conversationsPin: 'Pin📌',
conversationsUnpin: 'Unpin',
conversationsPinned: 'Pinned',
conversationsUnpinned: 'Unpinned',
conversationsFilterPinned: 'Filter Pinned',
conversationsClearAll: 'Clear all filters',
conversationsBatchMode: 'Batch Mode',
},
};
// ============= 默认提示词库 =============
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;
}
/**
* 获取侧边栏会话列表
* 子类应覆盖此方法从站点 DOM 提取会话数据
* @returns {Array<{id: string, title: string, url: string, isActive: boolean}>}
*/
getConversationList() {
return [];
}
/**
* 获取侧边栏可滚动容器
* 子类应覆盖此方法返回侧边栏的可滚动容器元素
* @returns {Element|null}
*/
getSidebarScrollContainer() {
return null;
}
/**
* 获取会话观察器配置(用于侧边栏实时监听)
* 子类应覆盖此方法提供站点特定的配置
* @returns {{
* selector: string, // 会话元素 CSS 选择器
* shadow: boolean, // 是否需要 Shadow DOM 穿透
* extractInfo: function(Element): Object|null, // 从元素提取会话信息
* getTitleElement: function(Element): Element // 获取标题元素(用于监听变化)
* }|null} 返回 null 表示不支持
*/
getConversationObserverConfig() {
return null;
}
/**
* 滚动加载全部会话
* 模拟滚动侧边栏到底部,直到所有会话都加载完成
* @returns {Promise<void>}
*/
async loadAllConversations() {
const container = this.getSidebarScrollContainer();
if (!container) return;
let lastCount = 0;
let stableRounds = 0;
const maxStableRounds = 3; // 连续3次无新增则停止
while (stableRounds < maxStableRounds) {
container.scrollTop = container.scrollHeight;
await new Promise((r) => setTimeout(r, 500));
// 使用 DOMToolkit 穿透 Shadow DOM 查询会话数量
const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true }) || [];
const currentCount = conversations.length;
if (currentCount === lastCount) {
stableRounds++;
} else {
lastCount = currentCount;
stableRounds = 0;
}
}
}
/**
* 检测 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: [
'infinite-scroller.chat-history', // Gemini 主对话滚动容器(精确匹配,避免与侧边栏混淆)
'.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/';
}
/**
* 从侧边栏提取会话列表
* @returns {Array<{id: string, title: string, url: string, isActive: boolean}>}
*/
getConversationList() {
const items = DOMToolkit.query('.conversation', { all: true }) || [];
return Array.from(items)
.map((el) => {
// 从 jslog 属性中提取会话 ID (Use safer regex that allows dashes/underscores)
const jslog = el.getAttribute('jslog') || '';
const idMatch = jslog.match(/\["c_([^"]+)"/);
const id = idMatch ? idMatch[1] : '';
const title = el.textContent?.trim() || '';
return {
id: id,
title: title,
url: id ? `https://gemini.google.com/app/${id}` : '',
isActive: el.classList.contains('selected'),
};
})
.filter((c) => c.id); // 过滤掉没有 ID 的项
}
/**
* 获取侧边栏可滚动容器
* @returns {Element|null}
*/
getSidebarScrollContainer() {
return DOMToolkit.query('infinite-scroller[scrollable="true"]') || DOMToolkit.query('infinite-scroller');
}
/**
* 获取会话观察器配置(用于侧边栏实时监听)
*/
getConversationObserverConfig() {
return {
selector: '.conversation',
shadow: false,
extractInfo: (el) => {
const jslog = el.getAttribute('jslog') || '';
const idMatch = jslog.match(/\["c_([^"]+)"/);
const id = idMatch ? idMatch[1] : '';
if (!id) return null;
return {
id,
title: el.textContent?.trim() || '',
url: `https://gemini.google.com/app/${id}`,
};
},
getTitleElement: (el) => el,
};
}
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/');
}
/**
* 获取当前会话名称(用于标签页重命名)
* 从 Shadow DOM 中的侧边栏获取当前活动会话的标题
* @returns {string|null}
*/
getSessionName() {
// DOMToolkit 在 Shadow DOM 穿透时,复杂后代选择器可能不生效
// 所以遍历所有会话,找到活动的那个
const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true });
for (const conv of conversations) {
const button = conv.querySelector('button.list-item') || conv.querySelector('button');
if (!button) continue;
// 检查是否为活动会话
const isActive = button.classList.contains('selected') || button.classList.contains('active') || button.getAttribute('aria-selected') === 'true';
if (isActive) {
const titleEl = button.querySelector('.conversation-title');
if (titleEl) {
const name = titleEl.textContent?.trim();
if (name) return name;
}
}
}
// 回退到基类默认实现(从 document.title 提取)
return super.getSessionName();
}
/**
* 获取当前的团队
*/
getCurrentCid() {
const currentPath = window.location.pathname;
const cidMatch = currentPath.match(/\/home\/cid\/([^\/]+)/);
return cidMatch ? cidMatch[1] : '';
}
/**
* 从侧边栏提取会话列表
* @returns {Array<{id: string, title: string, url: string, isActive: boolean}>}
*/
getConversationList() {
// 1. 获取当前 Team ID (CID)
let cid = this.getCurrentCid();
// 2. 查找会话列表
// 注意:DOMToolkit 在 Shadow DOM 穿透时,后代选择器可能不生效
// 所以使用简单选择器 + 后续过滤来排除智能体
const items = DOMToolkit.query('.conversation', { all: true, shadow: true });
return Array.from(items)
.map((el) => {
// 注意:DOMToolkit.query 使用 parent 参数,不是 root
const button = el.querySelector('button.list-item') || el.querySelector('button');
if (!button) return null;
// 从操作菜单按钮 ID 提取 Session ID
// 会话格式: menu-8823153884416423953 (纯数字)
// 智能体格式: menu-deep_research (包含字母/下划线)
const menuBtn = button.querySelector('.conversation-action-menu-button');
let id = '';
if (menuBtn && menuBtn.id && menuBtn.id.startsWith('menu-')) {
id = menuBtn.id.replace('menu-', '');
}
// 关键过滤:真正的会话 ID 是纯数字,智能体 ID 包含字母
// 例如:会话 ID = "452535969834780805",智能体 ID = "deep_research"
if (!id || !/^\d+$/.test(id)) return null;
// 获取标题
const titleEl = button.querySelector('.conversation-title');
const title = titleEl ? titleEl.textContent.trim() : '';
const isActive = button.classList.contains('selected') || button.classList.contains('active') || button.getAttribute('aria-selected') === 'true';
// 构建完整 URL
// 格式: https://business.gemini.google/home/cid/{cid}/r/session/{id}
let url = `https://business.gemini.google/session/${id}`; // 默认(如果没 cid)
if (cid) {
url = `https://business.gemini.google/home/cid/${cid}/r/session/${id}`;
}
return {
id: id,
title: title,
url: url,
isActive: isActive,
cid: cid,
};
})
.filter((c) => c); // 过滤掉 null
}
/**
* 获取侧边栏可滚动容器
* @returns {Element|null}
*/
getSidebarScrollContainer() {
return DOMToolkit.query('.conversation-list', { shadow: true }) || DOMToolkit.query('mat-sidenav', { shadow: true });
}
/**
* 获取会话观察器配置(用于侧边栏实时监听)
*/
getConversationObserverConfig() {
const self = this;
return {
selector: '.conversation',
shadow: true,
extractInfo: (el) => {
const button = el.querySelector('button.list-item') || el.querySelector('button');
if (!button) return null;
const menuBtn = button.querySelector('.conversation-action-menu-button');
if (!menuBtn || !menuBtn.id?.startsWith('menu-')) return null;
const id = menuBtn.id.replace('menu-', '');
if (!/^\d+$/.test(id)) return null; // 排除智能体
const titleEl = button.querySelector('.conversation-title');
const title = titleEl?.textContent?.trim() || '';
const cid = self.getCurrentCid();
let url = `https://business.gemini.google/home/cid/${cid}/r/session/${id}`;
return { id, title, url, cid };
},
getTitleElement: (el) => {
const button = el.querySelector('button.list-item') || el.querySelector('button');
return button?.querySelector('.conversation-title') || el;
},
};
}
/**
* 加载所有会话
* 通过点击"展开"按钮来加载更多会话,而不是滚动
* @returns {Promise<void>}
*/
async loadAllConversations() {
let expandedCount = 0;
const maxIterations = 20; // 防止无限循环
for (let i = 0; i < maxIterations; i++) {
// 查找所有按钮(穿透 Shadow DOM)
const allBtns = DOMToolkit.query('button.show-more', { all: true, shadow: true }) || [];
// 过滤出未展开的按钮(icon 没有 more-visible class)
const expandBtns = allBtns.filter((btn) => {
const icon = btn.querySelector('.show-more-icon');
// 已展开的按钮 icon 有 more-visible class
return icon && !icon.classList.contains('more-visible');
});
if (expandBtns.length === 0) {
break; // 没有更多需要展开的按钮
}
// 点击所有展开按钮
for (const btn of expandBtns) {
btn.click();
expandedCount++;
}
// 等待会话加载
await new Promise((r) => setTimeout(r, 300));
}
if (expandedCount > 0) {
console.log(`[GeminiBusinessAdapter] 展开了 ${expandedCount} 个会话分组`);
}
}
// 排除侧边栏 (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 (sessionName && !isPolluted(sessionName)) {
this.lastSessionName = sessionName;
return sessionName;
}
// 否则返回缓存的标题(可能为 null)
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);
}
/**
* 全局 Toast 提示函数
* @param {string} message 提示信息
* @param {number} duration 显示时长 (ms)
*/
function showToast(message, duration = 2000) {
const existing = document.querySelector('.gemini-toast');
if (existing) existing.remove();
const toast = createElement('div', { className: 'gemini-toast' }, message);
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastSlideIn 0.3s reverse';
setTimeout(() => toast.remove(), 300);
}, duration);
}
/**
* 页面宽度样式管理器
* 负责动态注入和移除页面宽度样式
*/
/**
* 页面宽度样式管理器
* 负责动态注入和移除页面宽度样式,支持 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 渲染、文件夹管理和交互
* Phase 1: 骨架版本,仅显示占位内容
*/
class ConversationManager {
constructor(config) {
this.container = config.container;
this.settings = config.settings;
this.siteAdapter = config.siteAdapter;
this.t = config.i18n || ((k) => k);
this.isActive = false;
this.data = null; // 会话数据
this.expandedFolderId = null; // 记忆当前展开的文件夹(手风琴模式,只展开一个)
this.selectedIds = new Set(); // 批量选中的会话 ID
this.batchMode = false; // 批量模式开关
this.searchQuery = ''; // 搜索关键词
this.searchResult = null; // 搜索结果 { folderMatches, conversationMatches, totalCount }
this.init();
}
init() {
this.loadData();
this.createUI();
this.startSidebarObserver();
}
/**
* 启动侧边栏实时监听
* 使用 DOMToolkit.each 监听新会话添加
*/
startSidebarObserver() {
if (this.sidebarObserverStop) return; // 已经在监听
// 获取适配器提供的配置
const config = this.siteAdapter.getConversationObserverConfig();
if (!config) return; // 站点不支持侧边栏监听
// 保存配置供其他方法使用
this.observerConfig = config;
// 延迟启动函数(等待侧边栏 DOM 加载完成)
const startObserver = (retryCount = 0) => {
const maxRetries = 5; // 最多重试5次
const retryDelay = 1000; // 每次重试间隔1秒
// 确定监听起点:始终使用最精确的容器,让 Observer 能监听 Shadow DOM 内部的变化
const sidebarContainer = this.siteAdapter.getSidebarScrollContainer() || document;
// 对于需要 Shadow DOM 穿透的站点,检查侧边栏容器是否已加载
// 如果返回的是 document,说明没找到特定容器,可能还要等待 (除非原本就是 document)
if (config.shadow && retryCount < maxRetries) {
const foundContainer = this.siteAdapter.getSidebarScrollContainer();
if (!foundContainer) {
// 侧边栏还未加载,延迟重试
setTimeout(() => startObserver(retryCount + 1), retryDelay);
return;
}
}
// 保存当前从属的容器,用于后续存活检测 (Zombie Check)
this.observerContainer = sidebarContainer;
// 侧边栏已加载或达到最大重试次数,开始监听
this.sidebarObserverStop = DOMToolkit.each(
config.selector,
(el, isNew) => {
// 尝试提取 ID,如果失败则重试(因为新会话可能属性延迟生成)
const tryAdd = (retries = 5) => {
const info = config.extractInfo(el);
if (info?.id) {
// 仅对新发现的元素尝试添加到数据(如果是全新的会话)
if (isNew && !this.data.conversations[info.id]) {
// 自动添加新会话到当前选中文件夹
const folderId = this.data.lastUsedFolderId || 'inbox';
this.data.conversations[info.id] = {
id: info.id,
siteId: this.siteAdapter.getSiteId(),
cid: info.cid || null,
title: info.title || 'New Conversation',
url: info.url,
folderId: folderId,
createdAt: Date.now(),
updatedAt: Date.now(),
};
this.saveData();
// 轻量级更新计数(避免重建整个 UI 丢失展开状态)
this.updateFolderCount(folderId);
}
// 对所有会话(无论新旧)启动标题变更监听
this.monitorConversationTitle(el, info.id);
} else if (retries > 0) {
setTimeout(() => tryAdd(retries - 1), 500);
}
};
tryAdd();
},
{ parent: sidebarContainer, shadow: config.shadow },
);
};
// 启动观察器
startObserver();
// 补充:仅对 Shadow DOM 站点启用轮询(Observer 可能因 DOM 复用/替换而失效)
// 普通站点的 Observer 工作正常,无需轮询
if (config.shadow) {
this.pollNewConversations();
}
}
/**
* 检查侧边栏监听器是否仍然有效 (Zombie Check)
* 如果容器被销毁(Detached),则重启监听器
*/
checkObserverStatus() {
// 如果监听器已停止,不需要检查
if (!this.sidebarObserverStop) return;
// 如果容器存在但已失去连接 (isConnected === false),说明变成了僵尸监听器
if (this.observerContainer && !this.observerContainer.isConnected) {
console.log('Gemini Helper: Sidebar container detached. Restarting observer...');
this.stopSidebarObserver();
// 给予一点延迟等待新容器就绪
setTimeout(() => this.startSidebarObserver(), 500);
}
}
/**
* 轮询检测新会话
* 作为 MutationObserver 的补充机制
*/
pollNewConversations() {
if (this.pollInterval) return; // 已在轮询
this.pollInterval = setInterval(() => {
if (!this.observerConfig) return;
const config = this.observerConfig;
const elements = DOMToolkit.query(config.selector, { all: true, shadow: config.shadow });
elements.forEach((el) => {
const info = config.extractInfo(el);
if (info?.id && !this.data.conversations[info.id]) {
// 发现未记录的会话
const folderId = this.data.lastUsedFolderId || 'inbox';
this.data.conversations[info.id] = {
id: info.id,
siteId: this.siteAdapter.getSiteId(),
cid: info.cid || null,
title: info.title || 'New Conversation',
url: info.url,
folderId: folderId,
createdAt: Date.now(),
updatedAt: Date.now(),
};
this.saveData();
this.updateFolderCount(folderId);
// 启动标题监听
this.monitorConversationTitle(el, info.id);
}
});
}, 3000);
}
/**
* 停止轮询
*/
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
/**
* 监听会话标题变化
* 使用共享的 watchMultiple 减少 Observer 数量
* @param {HTMLElement} el 会话元素
* @param {string} id 会话ID
*/
monitorConversationTitle(el, id) {
// 防止重复监听
if (el.dataset.ghTitleObserver) return;
el.dataset.ghTitleObserver = 'true';
// 使用配置获取正确的标题元素
const titleEl = this.observerConfig?.getTitleElement?.(el) || el;
// 确保共享 watcher 已初始化
if (!this.titleWatcher) {
const container = this.siteAdapter.getSidebarScrollContainer() || document.body;
this.titleWatcher = DOMToolkit.watchMultiple(container, { debounce: 500 });
}
// 添加到共享监听器
this.titleWatcher.add(titleEl, () => {
// 每次回调时重新从元素提取 ID,确保 ID 匹配
const currentInfo = this.observerConfig?.extractInfo?.(el);
const currentId = currentInfo?.id;
if (!currentId || currentId !== id) {
// ID 不匹配则跳过(防止元素被复用时错误更新)
return;
}
const currentTitle = titleEl.textContent?.trim();
const stored = this.data.conversations[currentId];
if (currentTitle && stored && stored.title !== currentTitle) {
console.log(`[Gemini Helper] Title changed for ${currentId}: "${stored.title}" -> "${currentTitle}". Updating local copy.`);
// 更新本地数据的标题
stored.title = currentTitle;
stored.updatedAt = Date.now();
this.saveData();
// 刷新 UI (更新显示)
this.createUI();
}
});
}
/**
* 停止侧边栏监听
*/
stopSidebarObserver() {
if (this.sidebarObserverStop) {
this.sidebarObserverStop();
this.sidebarObserverStop = null;
}
// 清理容器引用
this.observerContainer = null;
// 清理共享的标题监听器
if (this.titleWatcher) {
this.titleWatcher.stop();
this.titleWatcher = null;
}
// 清理轮询
this.stopPolling();
}
/**
* 轻量级更新文件夹计数(不重建 UI)
* 同时刷新已展开文件夹的会话列表
*/
updateFolderCount(folderId) {
const folderItem = this.container?.querySelector(`.conversations-folder-item[data-folder-id="${folderId}"]`);
if (folderItem) {
// 获取当前 CID(仅 Gemini Business 有效)
const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
const count = Object.values(this.data.conversations).filter((c) => c.folderId === folderId && this.matchesCid(c, currentCid)).length;
const countSpan = folderItem.querySelector('.conversations-folder-count');
if (countSpan) countSpan.textContent = `(${count})`;
// 如果该文件夹已展开,同时刷新会话列表
if (folderItem.classList.contains('expanded')) {
const conversationList = this.container?.querySelector(`.conversations-list[data-folder-id="${folderId}"]`);
if (conversationList) {
this.renderConversationList(folderId, conversationList);
}
}
}
}
/**
* 激活会话 Tab 时调用
*/
activate() {
this.isActive = true;
this.syncConversations(null, true); // 切换进来时静默同步一次
this.createUI();
}
/**
* 停用会话 Tab 时调用
*/
deactivate() {
this.isActive = false;
}
/**
* 获取全局存储键
* 注意:文件夹和标签全局共用,会话通过 cid 字段区分不同团队
*/
getStorageKey() {
return SETTING_KEYS.CONVERSATIONS;
}
/**
* 加载会话数据
*/
loadData() {
const key = this.getStorageKey();
const saved = GM_getValue(key, null);
if (saved) {
this.data = { ...DEFAULT_CONVERSATION_DATA, ...saved };
} else {
this.data = JSON.parse(JSON.stringify(DEFAULT_CONVERSATION_DATA));
}
}
/**
* 保存会话数据
*/
saveData() {
const key = this.getStorageKey();
GM_setValue(key, this.data);
}
/**
* 上移文件夹
* @param {string} folderId 文件夹 ID
*/
moveFolderUp(folderId) {
const index = this.data.folders.findIndex((f) => f.id === folderId);
// index 0 是收件箱(固定),index 1 是第一个可移动的
if (index <= 1) return;
// 与上一个交换位置
[this.data.folders[index - 1], this.data.folders[index]] = [this.data.folders[index], this.data.folders[index - 1]];
this.saveData();
this.createUI();
}
/**
* 下移文件夹
* @param {string} folderId 文件夹 ID
*/
moveFolderDown(folderId) {
const index = this.data.folders.findIndex((f) => f.id === folderId);
if (index <= 0 || index >= this.data.folders.length - 1) return;
// 与下一个交换位置
[this.data.folders[index], this.data.folders[index + 1]] = [this.data.folders[index + 1], this.data.folders[index]];
this.saveData();
this.createUI();
}
/**
* 创建文件夹
* @param {string} name 文件夹名称
* @param {string} icon 图标 emoji
* @returns {object} 新创建的文件夹
*/
createFolder(name, icon = '📁') {
const folder = {
id: 'folder_' + Date.now(),
name: `${icon} ${name}`,
icon: icon,
isDefault: false,
};
this.data.folders.push(folder);
this.saveData();
return folder;
}
/**
* 重命名文件夹
* @param {string} folderId 文件夹 ID
* @param {string} newName 新名称
* @param {string} newIcon 新图标
*/
renameFolder(folderId, newName, newIcon = null) {
const folder = this.data.folders.find((f) => f.id === folderId);
if (folder && !folder.isDefault) {
folder.name = newIcon ? `${newIcon} ${newName}` : newName;
if (newIcon) folder.icon = newIcon;
this.saveData();
return true;
}
return false;
}
/**
* 删除文件夹
* @param {string} folderId 文件夹 ID
* @returns {boolean} 是否删除成功
*/
deleteFolder(folderId) {
const folder = this.data.folders.find((f) => f.id === folderId);
if (!folder || folder.isDefault) {
showToast(this.t('conversationsCannotDeleteDefault') || '无法删除默认文件夹');
return false;
}
// 将文件夹内的会话移到收件箱
Object.values(this.data.conversations).forEach((conv) => {
if (conv.folderId === folderId) {
conv.folderId = 'inbox';
}
});
this.data.folders = this.data.folders.filter((f) => f.id !== folderId);
this.saveData();
return true;
}
/**
* 从侧边栏同步会话(增量)
* @param {string} targetFolderId 可选,指定目标文件夹
* @param {boolean} silent 是否静默同步(不显示 Toast)
* @param {boolean} checkForDeletions 是否检查并删除失效会话(仅全量同步时启用)
*/
syncConversations(targetFolderId = null, silent = false, checkForDeletions = false) {
const sidebarItems = this.siteAdapter.getConversationList();
if (!sidebarItems || sidebarItems.length === 0) {
if (!silent) showToast(this.t('conversationsSyncEmpty') || '未找到会话');
return;
}
// 获取当前 CID(仅 Gemini Business 有效)
const currentCid = sidebarItems[0]?.cid || null;
// 检查是否有已保存的会话(初次同步判断)
// 注意:需要按当前 CID 过滤,避免其他团队的数据干扰判断
const existingConvCount = Object.values(this.data.conversations).filter((c) => this.matchesCid(c, currentCid)).length;
const isFirstSync = existingConvCount === 0;
// 初次同步且未指定目标文件夹:弹窗让用户选择
if (isFirstSync && !targetFolderId) {
if (!silent) {
this.showFolderSelectDialog((selectedFolderId) => {
this.syncConversations(selectedFolderId, false);
});
}
return;
}
let newCount = 0;
let updatedCount = 0;
const now = Date.now();
const folderId = targetFolderId || this.data.lastUsedFolderId || 'inbox';
sidebarItems.forEach((item) => {
// Key 始终用 sessionId(cid 和 siteId 存储在对象属性中)
const storageKey = item.id;
const existing = this.data.conversations[storageKey];
if (existing) {
// 更新已有会话的标题(可能被用户修改)
if (existing.title !== item.title) {
existing.title = item.title;
existing.updatedAt = now;
updatedCount++;
}
// 确保 siteId 和 cid 是最新的
if (!existing.siteId) existing.siteId = this.siteAdapter.getSiteId();
if (item.cid && !existing.cid) existing.cid = item.cid;
} else {
// 新会话:添加到指定文件夹
this.data.conversations[storageKey] = {
id: item.id,
siteId: this.siteAdapter.getSiteId(), // 记录所属站点
cid: item.cid || null, // 记录所属团队(Gemini Business)
title: item.title,
url: item.url,
folderId: folderId,
createdAt: now,
updatedAt: now,
};
newCount++;
}
});
// 记住用户选择
if (targetFolderId) {
this.data.lastUsedFolderId = targetFolderId;
}
// 有变更才保存和刷新
if (newCount > 0 || updatedCount > 0) {
this.saveData();
this.createUI();
}
// 检查已删除的会话(仅检查当前站点+CID 下的会话)
if (checkForDeletions) {
// 远程会话的 ID 集合
const remoteIds = new Set(sidebarItems.map((item) => item.id));
// 本地当前站点+CID 的会话 ID(通过对象属性过滤)
const localIdsForCurrentContext = Object.entries(this.data.conversations)
.filter(([, conv]) => this.matchesCid(conv, currentCid))
.map(([key]) => key);
// 找出本地有但远程没有的(当前站点+CID 范围内)
const missingIds = localIdsForCurrentContext.filter((id) => !remoteIds.has(id));
if (missingIds.length > 0) {
const msg = (this.t('conversationsSyncDeleteMsg') || '检测到 {count} 个会话已在云端删除,是否同步删除本地记录?').replace('{count}', missingIds.length);
this.showConfirmDialog(this.t('conversationsSyncDeleteTitle') || '同步删除', msg, () => {
missingIds.forEach((id) => delete this.data.conversations[id]);
this.saveData();
this.createUI();
showToast(`${this.t('conversationsDeleted') || '已移除'} ${missingIds.length}`);
});
}
}
if (!silent) {
if (newCount > 0 || updatedCount > 0) {
showToast(`${this.t('conversationsSynced') || '同步完成'}:+${newCount} ↻${updatedCount}`);
} else {
showToast(this.t('conversationsSyncNoChange') || '无新会话');
}
}
}
/**
* 检查会话是否属于当前站点和团队
* @param {Object} conv 会话对象
* @param {string|null} currentCid 当前团队 ID (Gemini Business)
* @returns {boolean}
*/
matchesCid(conv, currentCid) {
// 1. 首先检查站点匹配
const currentSiteId = this.siteAdapter.getSiteId();
// 如果会话有 siteId 且不匹配当前站点,排除
if (conv.siteId && conv.siteId !== currentSiteId) {
return false;
}
// 2. 检查 CID 匹配
// 如果当前无 CID(非 Gemini Business 或无团队),显示无 CID 的会话和旧数据
if (!currentCid) return !conv.cid;
// 如果会话没有 cid(旧数据),显示它
if (!conv.cid) return true;
// 否则严格匹配 CID
return conv.cid === currentCid;
}
/**
* 显示文件夹选择对话框
*/
showFolderSelectDialog(onSelect) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsSelectFolder') || '选择同步目标文件夹'));
// 文件夹列表
const list = createElement('div', { className: 'conversations-folder-select-list' });
this.data.folders.forEach((folder) => {
const item = createElement(
'div',
{
className: 'conversations-folder-select-item',
'data-folder-id': folder.id,
},
`${folder.icon} ${folder.name}`,
);
item.addEventListener('click', () => {
overlay.remove();
onSelect(folder.id);
});
list.appendChild(item);
});
dialog.appendChild(list);
// 取消按钮
const btns = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
cancelBtn.addEventListener('click', () => overlay.remove());
btns.appendChild(cancelBtn);
dialog.appendChild(btns);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
/**
* 显示确认对话框
*/
showConfirmDialog(title, message, onConfirm) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, title));
const msgDiv = createElement('div', { className: 'conversations-dialog-message' }, message);
dialog.appendChild(msgDiv);
// 按钮
const btns = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
cancelBtn.addEventListener('click', () => overlay.remove());
btns.appendChild(cancelBtn);
const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定');
confirmBtn.addEventListener('click', () => {
overlay.remove();
onConfirm();
});
btns.appendChild(confirmBtn);
dialog.appendChild(btns);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
/**
* 创建会话面板 UI
*/
createUI() {
const container = this.container;
clearElement(container);
const content = createElement('div', { className: 'conversations-content' });
// 工具栏
const toolbar = createElement('div', { className: 'conversations-toolbar' });
// 1. 同步目标选择 (左侧)
const folderSelect = createElement('select', {
className: 'conversations-folder-select',
id: 'conversations-folder-select',
title: this.t('conversationsSelectFolder') || 'Select folder',
});
this.data.folders.forEach((folder) => {
// 截断过长的文件夹名称,避免下拉菜单溢出
const truncatedName = folder.name.length > 20 ? folder.name.slice(0, 20) + '...' : folder.name;
const option = createElement('option', { value: folder.id, title: folder.name }, truncatedName);
if (folder.id === (this.data.lastUsedFolderId || 'inbox')) {
option.selected = true;
}
folderSelect.appendChild(option);
});
folderSelect.addEventListener('change', () => {
this.data.lastUsedFolderId = folderSelect.value;
this.saveData();
});
toolbar.appendChild(folderSelect);
// 定义局部 helper 创建 SVG
const createSVG = (pathData) => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'currentColor');
svg.setAttribute('width', '18');
svg.setAttribute('height', '18');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', pathData);
svg.appendChild(path);
return svg;
};
const SYNC_PATH =
'M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z';
const HOURGLASS_PATH = 'M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6zm10 14.5V20H8v-3.5l4-4 4 4zm-4-5l-4-4V4h8v3.5l-4 4z';
const CHECK_BOX_PATH = 'M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z';
// 2. 同步按钮 (紧跟下拉框)
const syncBtn = createElement('button', {
className: 'conversations-toolbar-btn sync',
id: 'conversations-sync-btn',
title: this.t('conversationsSync'),
style: 'display: flex; align-items: center; justify-content: center;',
});
syncBtn.appendChild(createSVG(SYNC_PATH));
syncBtn.addEventListener('click', async () => {
syncBtn.disabled = true;
clearElement(syncBtn);
syncBtn.appendChild(createSVG(HOURGLASS_PATH));
await this.siteAdapter.loadAllConversations();
this.syncConversations(folderSelect.value, false, true);
syncBtn.disabled = false;
clearElement(syncBtn);
syncBtn.appendChild(createSVG(SYNC_PATH));
});
toolbar.appendChild(syncBtn);
// 3. 新建文件夹按钮 (右侧,仅图标)
const addFolderBtn = createElement('button', {
className: 'conversations-toolbar-btn add-folder',
title: this.t('conversationsAddFolder') || 'New Folder',
style: 'display: flex; align-items: center; justify-content: center;',
});
addFolderBtn.appendChild(createSVG('M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z'));
addFolderBtn.addEventListener('click', () => this.showCreateFolderDialog());
toolbar.appendChild(addFolderBtn);
// 4. 批量模式按钮
const batchModeBtn = createElement('button', {
className: 'conversations-toolbar-btn batch-mode' + (this.batchMode ? ' active' : ''),
title: this.t('conversationsBatchMode') || '批量操作',
id: 'conversations-batch-mode-btn',
style: 'display: flex; align-items: center; justify-content: center;',
});
batchModeBtn.appendChild(createSVG(CHECK_BOX_PATH));
batchModeBtn.addEventListener('click', () => this.toggleBatchMode());
toolbar.appendChild(batchModeBtn);
content.appendChild(toolbar);
// 搜索栏
const searchBar = createElement('div', { className: 'conversations-search-bar' });
const searchWrapper = createElement('div', { className: 'conversations-search-wrapper' });
const searchInput = createElement('input', {
type: 'text',
className: 'conversations-search-input',
id: 'conversations-search-input',
placeholder: this.t('conversationsSearchPlaceholder') || '搜索会话...',
value: this.searchQuery || '',
});
// 注入 placeholder 防选中样式
const placeholderStyle = document.createElement('style');
placeholderStyle.textContent = `
.conversations-search-input::-webkit-input-placeholder { user-select: none; }
.conversations-search-input::placeholder { user-select: none; }
`;
searchWrapper.appendChild(placeholderStyle);
// 搜索输入防抖处理
let searchTimeout = null;
searchInput.addEventListener('input', () => {
updateClearBtn();
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.handleSearch(searchInput.value.trim());
}, 150);
});
// Input Group (Input Only)
const inputGroup = createElement('div', { className: 'conversations-search-input-group' });
inputGroup.appendChild(searchInput);
searchWrapper.appendChild(inputGroup);
// 置顶筛选按钮
const pinFilterBtn = createElement(
'div',
{
className: 'conversations-pin-filter-btn' + (this.filterPinned ? ' active' : ''),
title: this.t('conversationsFilterPinned') || '筛选置顶',
style: 'user-select: none;',
},
'📌',
);
pinFilterBtn.addEventListener('click', () => {
this.filterPinned = !this.filterPinned;
pinFilterBtn.classList.toggle('active', this.filterPinned);
this.handleSearch(this.searchQuery || '');
updateClearBtn();
});
searchWrapper.appendChild(pinFilterBtn);
// 标签筛选按钮
const isTagFiltering = this.filterTagIds && this.filterTagIds.size > 0;
const tagFilterBtn = createElement(
'div',
{
className: 'conversations-tag-search-btn' + (this.data.tags && this.data.tags.length > 0 ? '' : ' empty') + (isTagFiltering ? ' active' : ''),
title: this.t('conversationsFilterByTags') || '按标签筛选',
style: 'user-select: none;',
},
'🏷️',
);
// ... tag filter event listener ...
tagFilterBtn.addEventListener('click', (e) => {
// ... existing implementation ...
e.stopPropagation();
const existingMenu = document.querySelector('.conversations-tag-filter-menu');
if (existingMenu) {
existingMenu.remove();
return;
}
const menu = createElement('div', { className: 'conversations-tag-filter-menu', 'data-trigger': 'search-filter' });
const list = createElement('div', { className: 'conversations-tag-filter-list' }); // Scrollable area
// 清除选项
if (this.filterTagIds && this.filterTagIds.size > 0) {
const clearItem = createElement('div', { className: 'conversations-tag-filter-item' });
clearItem.textContent = this.t('conversationsClearTags') || '清除筛选';
clearItem.addEventListener('click', (e) => {
e.stopPropagation();
this.filterTagIds.clear();
tagFilterBtn.classList.remove('active');
menu.remove();
this.handleSearch(this.searchQuery);
updateClearBtn();
});
list.appendChild(clearItem);
}
if (this.data.tags) {
this.data.tags.forEach((tag) => {
const item = createElement('div', { className: 'conversations-tag-filter-item' });
if (this.filterTagIds && this.filterTagIds.has(tag.id)) {
item.classList.add('selected');
}
const dot = createElement('span', { className: 'conversations-tag-dot', style: `background-color: ${tag.color}` });
item.appendChild(dot);
// Tag Name Span
const nameSpan = createElement('span');
nameSpan.textContent = tag.name;
item.appendChild(nameSpan);
item.addEventListener('click', (e) => {
e.stopPropagation();
if (!this.filterTagIds) this.filterTagIds = new Set();
if (this.filterTagIds.has(tag.id)) {
this.filterTagIds.delete(tag.id);
item.classList.remove('selected');
} else {
this.filterTagIds.add(tag.id);
item.classList.add('selected');
}
if (this.filterTagIds.size > 0) tagFilterBtn.classList.add('active');
else tagFilterBtn.classList.remove('active');
this.handleSearch(this.searchQuery);
updateClearBtn();
});
list.appendChild(item);
});
} else {
const emptyItem = createElement('div', { className: 'conversations-tag-filter-item', style: 'color:#9ca3af; cursor:default;' });
emptyItem.textContent = this.t('conversationsNoTags') || '暂无标签';
list.appendChild(emptyItem);
}
menu.appendChild(list);
// Footer Area
const footer = createElement('div', { className: 'conversations-tag-filter-footer' });
const manageItem = createElement('div', { className: 'conversations-tag-filter-item conversations-tag-filter-action' });
manageItem.textContent = this.t('conversationsManageTags') || '管理标签';
manageItem.addEventListener('click', () => {
menu.remove();
this.showTagManagerDialog();
});
footer.appendChild(manageItem);
menu.appendChild(footer);
// IMPORTANT: Append to wrapper for relative positioning
searchWrapper.appendChild(menu);
// Click outside to close
const closeMenu = (e) => {
if (!menu.contains(e.target) && e.target !== tagFilterBtn) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
setTimeout(() => document.addEventListener('click', closeMenu), 0);
});
searchWrapper.appendChild(tagFilterBtn);
// 清空按钮 (Global Clear) - Moved to far right
// Re-use clearBtn but change its element type/class logic
const clearBtn = createElement(
'div',
{
className: 'conversations-search-clear', // Style updated in CSS
id: 'conversations-search-clear',
title: this.t('conversationsClearAll') || '清除所有筛选',
},
'×',
);
clearBtn.addEventListener('click', () => {
if (clearBtn.classList.contains('disabled')) return;
searchInput.value = '';
if (this.filterTagIds) this.filterTagIds.clear();
this.filterPinned = false;
tagFilterBtn.classList.remove('active');
pinFilterBtn.classList.remove('active');
this.handleSearch('');
updateClearBtn();
});
searchWrapper.appendChild(clearBtn);
// Update clear button visibility helper
const updateClearBtn = () => {
const hasText = searchInput.value.length > 0;
const hasTags = this.filterTagIds && this.filterTagIds.size > 0;
const hasPinFilter = this.filterPinned;
const hasFilter = hasText || hasTags || hasPinFilter;
// Always visible but disabled if no filter
clearBtn.classList.toggle('disabled', !hasFilter);
};
// Check initial state
updateClearBtn();
searchBar.appendChild(searchWrapper);
// 搜索结果计数条
const resultBar = createElement('div', {
className: 'conversations-result-bar',
id: 'conversations-result-bar',
});
if (this.searchQuery && this.searchResult) {
resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || '个结果'}`;
resultBar.classList.add('visible');
}
searchBar.appendChild(resultBar);
content.appendChild(searchBar);
// 文件夹列表
const folderList = this.createFolderListUI();
content.appendChild(folderList);
// 底部批量操作栏(仅批量模式下显示)
if (this.batchMode) {
const batchBar = createElement('div', { className: 'conversations-batch-bar', id: 'conversations-batch-bar' });
// 根据选中数量决定是否显示
batchBar.style.display = this.selectedIds.size > 0 ? 'flex' : 'none';
const batchInfo = createElement(
'span',
{ className: 'conversations-batch-info', id: 'conversations-batch-info' },
(this.t('batchSelected') || '已选 {n} 个').replace('{n}', this.selectedIds.size),
);
batchBar.appendChild(batchInfo);
const batchBtns = createElement('div', { className: 'conversations-batch-btns' });
const batchMoveBtn = createElement('button', { className: 'conversations-batch-btn' }, '📂 ' + (this.t('batchMove') || '移动'));
batchMoveBtn.addEventListener('click', () => this.batchMove());
batchBtns.appendChild(batchMoveBtn);
const batchDeleteBtn = createElement('button', { className: 'conversations-batch-btn danger' }, '🗑️ ' + (this.t('batchDelete') || '删除'));
batchDeleteBtn.addEventListener('click', () => this.batchDelete());
batchBtns.appendChild(batchDeleteBtn);
const batchCancelBtn = createElement('button', { className: 'conversations-batch-btn cancel' }, this.t('batchExit') || '退出');
batchCancelBtn.addEventListener('click', () => this.clearSelection());
batchBtns.appendChild(batchCancelBtn);
batchBar.appendChild(batchBtns);
content.appendChild(batchBar);
}
container.appendChild(content);
}
/**
* 创建文件夹列表 UI
*/
createFolderListUI() {
const container = createElement('div', { className: 'conversations-folder-list' });
if (!this.data || !this.data.folders || this.data.folders.length === 0) {
const empty = createElement('div', { className: 'conversations-empty' }, this.t('conversationsEmpty'));
container.appendChild(empty);
return container;
}
// 搜索模式下的过滤逻辑
const isSearching = !!this.searchResult;
const { folderMatches, conversationMatches, conversationFolderMap } = this.searchResult || {};
// 计算搜索时哪些文件夹有匹配的会话(需要展开父级)
const foldersWithMatchedConversations = new Set();
if (isSearching && conversationFolderMap) {
conversationFolderMap.forEach((folderId) => {
foldersWithMatchedConversations.add(folderId);
});
}
let hasVisibleItems = false;
this.data.folders.forEach((folder, index) => {
// 搜索过滤:判断文件夹是否应该显示
if (isSearching) {
const folderDirectMatch = folderMatches?.has(folder.id);
const hasMatchedChildren = foldersWithMatchedConversations.has(folder.id);
if (!folderDirectMatch && !hasMatchedChildren) {
return; // 跳过不匹配的文件夹
}
}
hasVisibleItems = true;
// 文件夹项
const folderItem = this.createFolderItem(folder, index);
container.appendChild(folderItem);
// 搜索时:如果有匹配的会话则自动展开,否则只显示文件夹
const hasMatchedConvs = isSearching && foldersWithMatchedConversations.has(folder.id);
const shouldExpand = isSearching ? hasMatchedConvs : this.expandedFolderId === folder.id;
// 会话列表容器
const conversationList = createElement('div', {
className: 'conversations-list',
'data-folder-id': folder.id,
style: shouldExpand ? 'display: block;' : 'display: none;',
});
container.appendChild(conversationList);
// 如果需要展开,渲染会话列表
if (shouldExpand) {
folderItem.classList.add('expanded');
this.renderConversationList(folder.id, conversationList);
}
// 绑定展开逻辑(非搜索模式下或搜索结果中点击可切换)
folderItem.addEventListener('click', (e) => {
if (e.target.closest('button')) return; // 避免点击按钮触发
// 折叠其他文件夹,并更新记忆
container.querySelectorAll('.conversations-folder-item.expanded').forEach((el) => {
if (el !== folderItem) {
el.classList.remove('expanded');
const otherList = container.querySelector(`.conversations-list[data-folder-id="${el.dataset.folderId}"]`);
if (otherList) otherList.style.display = 'none';
}
});
const isExpanded = folderItem.classList.toggle('expanded');
// 记忆展开状态
this.expandedFolderId = isExpanded ? folder.id : null;
if (isExpanded) {
// 刷新计数(确保与实际会话数一致,按站点+CID 过滤)
const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
const count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, currentCid)).length;
const countSpan = folderItem.querySelector('.conversations-folder-count');
if (countSpan) countSpan.textContent = `(${count})`;
this.renderConversationList(folder.id, conversationList);
conversationList.style.display = 'block';
} else {
conversationList.style.display = 'none';
}
});
});
// 搜索无结果显示
if (isSearching && !hasVisibleItems) {
const noResult = createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoSearchResult') || '未找到匹配结果');
container.appendChild(noResult);
}
return container;
}
/**
* 创建单个文件夹项
* @param {number} index 文件夹在数组中的索引
*/
createFolderItem(folder, index) {
// 使用 CSS 变量以支持暗色模式
const bgVar = folder.isDefault ? 'var(--gh-folder-bg-default)' : `var(--gh-folder-bg-${index % 8})`;
const item = createElement('div', {
className: 'conversations-folder-item' + (folder.isDefault ? ' default' : ''),
'data-folder-id': folder.id,
style: `background: ${bgVar};`,
});
// 文件夹信息(图标 + 名称)
const folderName = folder.name.replace(folder.icon, '').trim();
const info = createElement('div', { className: 'conversations-folder-info' });
// 全选复选框(仅批量模式下显示)
if (this.batchMode) {
// 获取当前 CID(仅 Gemini Business 有效)
const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
// 搜索模式下只处理匹配的会话(同时按 CID 过滤)
let conversationsInFolder = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, currentCid));
if (this.searchResult) {
conversationsInFolder = conversationsInFolder.filter((c) => this.searchResult.conversationMatches?.has(c.id));
}
const allSelected = conversationsInFolder.length > 0 && conversationsInFolder.every((c) => this.selectedIds.has(c.id));
const someSelected = !allSelected && conversationsInFolder.some((c) => this.selectedIds.has(c.id));
const checkbox = createElement('input', {
type: 'checkbox',
className: 'conversations-folder-checkbox',
});
checkbox.checked = allSelected;
if (someSelected) checkbox.indeterminate = true;
checkbox.addEventListener('click', (e) => e.stopPropagation());
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
// 全选(仅匹配项)
conversationsInFolder.forEach((c) => this.selectedIds.add(c.id));
} else {
// 全不选(仅匹配项)
conversationsInFolder.forEach((c) => this.selectedIds.delete(c.id));
}
this.createUI(); // 使用 createUI 重绘以更新状态
});
info.appendChild(checkbox);
}
info.appendChild(createElement('span', { className: 'conversations-folder-icon', style: 'user-select: none;' }, folder.icon));
// 文件夹名称(支持搜索高亮)
const nameSpan = createElement('span', {
className: 'conversations-folder-name',
title: folderName,
});
if (this.searchQuery && this.searchResult?.folderMatches?.has(folder.id)) {
nameSpan.appendChild(this.highlightText(folderName, this.searchQuery));
} else {
nameSpan.textContent = folderName;
}
info.appendChild(nameSpan);
// 上下排序按钮(悬浮时在名称区域右侧显示,不占空间)
if (!folder.isDefault) {
const orderBtns = createElement('div', { className: 'conversations-folder-order-btns', style: 'user-select: none;' });
const upBtn = createElement(
'button',
{
className: 'conversations-folder-order-btn',
title: this.t('moveUp') || '上移',
},
'↑',
);
if (index <= 1) upBtn.disabled = true;
upBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!upBtn.disabled) this.moveFolderUp(folder.id);
});
const downBtn = createElement(
'button',
{
className: 'conversations-folder-order-btn',
title: this.t('moveDown') || '下移',
},
'↓',
);
if (index >= this.data.folders.length - 1) downBtn.disabled = true;
downBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (!downBtn.disabled) this.moveFolderDown(folder.id);
});
orderBtns.appendChild(upBtn);
orderBtns.appendChild(downBtn);
info.appendChild(orderBtns);
}
item.appendChild(info);
// 右侧控制区域(计数 + 菜单按钮)
const controls = createElement('div', { className: 'conversations-folder-controls' });
// 获取当前 CID(仅 Gemini Business 有效)- 复用上面的变量或重新获取
const cidForCount = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
// 会话计数(搜索模式下显示匹配数量,同时按 CID 过滤)
let count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, cidForCount)).length;
if (this.searchResult) {
count = Object.values(this.data.conversations).filter((c) => c.folderId === folder.id && this.matchesCid(c, cidForCount) && this.searchResult.conversationMatches?.has(c.id)).length;
}
controls.appendChild(createElement('span', { className: 'conversations-folder-count' }, `(${count})`));
// 操作菜单按钮(始终渲染以保持对齐,默认文件夹隐藏)
const menuBtn = createElement('button', { className: 'conversations-folder-menu-btn', style: 'user-select: none;' }, '⋯');
if (folder.isDefault) {
menuBtn.style.visibility = 'hidden';
menuBtn.style.pointerEvents = 'none'; // 避免阻挡点击
} else {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showFolderMenu(folder, menuBtn);
});
}
controls.appendChild(menuBtn);
item.appendChild(controls);
return item;
}
/**
* 获取侧边栏会话顺序
* @returns {Array<string>} 会话 ID 数组,按侧边栏 DOM 顺序排列
*/
getSidebarConversationOrder() {
const config = this.siteAdapter.getConversationObserverConfig?.();
if (!config) return [];
const elements = DOMToolkit.query(config.selector, { all: true, shadow: config.shadow });
return Array.from(elements)
.map((el) => config.extractInfo?.(el)?.id)
.filter(Boolean);
}
/**
* 渲染文件夹下的会话列表
*/
renderConversationList(folderId, container) {
clearElement(container);
// 获取当前 CID(仅 Gemini Business 有效)
const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
// 获取该文件夹下的会话(按 CID 过滤)
let conversations = Object.values(this.data.conversations).filter((c) => c.folderId === folderId && this.matchesCid(c, currentCid));
// 搜索模式下过滤不匹配的会话
const isSearching = !!this.searchResult;
if (isSearching) {
const { conversationMatches } = this.searchResult;
conversations = conversations.filter((c) => conversationMatches?.has(c.id));
}
if (conversations.length === 0) {
const empty = createElement('div', { className: 'conversations-list-empty' }, this.t('conversationsEmpty') || '暂无会话');
container.appendChild(empty);
return;
}
// 获取侧边栏顺序
const sidebarOrder = this.getSidebarConversationOrder();
// 排序:置顶优先,其余按侧边栏顺序
conversations.sort((a, b) => {
// 置顶优先
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
// 按侧边栏顺序
const indexA = sidebarOrder.indexOf(a.id);
const indexB = sidebarOrder.indexOf(b.id);
// 不在侧边栏的放到最后
if (indexA === -1 && indexB === -1) return (b.updatedAt || 0) - (a.updatedAt || 0);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
conversations.forEach((conv) => {
const item = this.createConversationItem(conv);
container.appendChild(item);
});
}
/**
* 创建单个会话项
*/
createConversationItem(conv) {
const item = createElement('div', { className: 'conversations-item', 'data-id': conv.id });
// New Layout: Flex Column
// Row 1: Content (Title + Checkbox) ----- Actions (Time + Menu)
// Row 2: Tags
// Container for Row 1
// Checkbox
if (this.batchMode) {
const checkbox = createElement('input', {
type: 'checkbox',
className: 'conversations-item-checkbox',
});
checkbox.checked = this.selectedIds.has(conv.id);
checkbox.addEventListener('click', (e) => e.stopPropagation());
checkbox.addEventListener('change', () => {
if (checkbox.checked) this.selectedIds.add(conv.id);
else this.selectedIds.delete(conv.id);
this.updateBatchActionBar();
});
item.appendChild(checkbox);
}
// Title
const title = createElement('span', {
className: 'conversations-item-title',
title: conv.title,
style: 'user-select: none;',
});
// 置顶标识
const displayTitle = conv.pinned ? `📌 ${conv.title || '无标题'}` : conv.title || '无标题';
if (this.searchQuery && this.searchResult?.conversationMatches?.has(conv.id)) {
if (conv.pinned) title.appendChild(document.createTextNode('📌 '));
title.appendChild(this.highlightText(conv.title || '无标题', this.searchQuery));
} else {
title.textContent = displayTitle;
}
title.addEventListener('click', (e) => {
e.stopPropagation();
if (this.batchMode) {
const checkbox = item.querySelector('.conversations-item-checkbox');
if (checkbox) {
checkbox.click();
}
return;
}
// 尝试在侧边栏中查找并点击(支持 Shadow DOM 穿透)
// 方法1: 通过 jslog 属性查找(Gemini 标准版)
let sidebarItem = DOMToolkit.query(`.conversation[jslog*="${conv.id}"]`, { shadow: true });
// 方法2: 遍历所有会话元素,通过菜单按钮 ID 匹配(Gemini Business)
// 注意:closest() 在 Shadow DOM 中可能失效,所以需要遍历
if (!sidebarItem) {
const conversations = DOMToolkit.query('.conversation', { all: true, shadow: true });
for (const convEl of conversations) {
const menuBtn = convEl.querySelector(`#menu-${conv.id}`) || convEl.querySelector(`.conversation-action-menu-button[id="menu-${conv.id}"]`);
if (menuBtn) {
sidebarItem = convEl;
break;
}
}
}
if (sidebarItem) {
const btn = sidebarItem.querySelector('button.list-item') || sidebarItem.querySelector('button');
if (btn) btn.click();
else sidebarItem.click();
} else if (conv.url) {
window.location.href = conv.url;
}
});
item.appendChild(title);
// Tags (Insert after title)
if (conv.tagIds && conv.tagIds.length > 0 && this.data.tags) {
const tagList = createElement('div', { className: 'conversations-tag-list', style: 'user-select: none;' });
conv.tagIds.forEach((tagId) => {
const tagDef = this.data.tags.find((t) => t.id === tagId);
if (tagDef) {
const tagEl = createElement(
'span',
{
className: 'conversations-tag',
style: `background-color: ${tagDef.color || '#9ca3af'}`,
},
tagDef.name,
);
tagList.appendChild(tagEl);
}
});
if (tagList.children.length > 0) {
item.appendChild(tagList);
}
}
// Right side of Top Row: Time + Menu
const metaContainer = createElement('div', { className: 'conversations-item-meta' });
const time = createElement('span', { className: 'conversations-item-time' }, this.formatTime(conv.updatedAt));
metaContainer.appendChild(time);
const menuBtn = createElement('button', { className: 'conversations-item-menu-btn', style: 'user-select: none;' }, '⋯');
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showConversationMenu(conv, menuBtn);
});
metaContainer.appendChild(menuBtn);
item.appendChild(metaContainer);
return item;
}
/**
* 显示会话操作菜单
*/
showConversationMenu(conv, anchorEl) {
// 移除已有菜单
document.querySelectorAll('.conversations-item-menu').forEach((m) => m.remove());
const menu = createElement('div', { className: 'conversations-item-menu' });
// 重命名
const renameBtn = createElement('button', {}, this.t('conversationsRename') || '重命名');
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.showRenameConversationDialog(conv);
});
menu.appendChild(renameBtn);
// 置顶/取消置顶
const pinText = conv.pinned ? this.t('conversationsUnpin') || '取消置顶' : this.t('conversationsPin') || '📌 置顶';
const pinBtn = createElement('button', {}, pinText);
pinBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.toggleConversationPin(conv);
});
menu.appendChild(pinBtn);
// 设置标签
const tagBtn = createElement('button', {}, this.t('conversationsSetTags') || '设置标签');
tagBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.showTagManagerDialog(conv);
});
menu.appendChild(tagBtn);
// 移动到...
const moveBtn = createElement('button', {}, this.t('conversationsMoveTo') || '移动到...');
moveBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.showMoveToFolderDialog(conv);
});
menu.appendChild(moveBtn);
// 删除
const deleteBtn = createElement('button', { className: 'danger' }, this.t('conversationsDelete') || '删除');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.confirmDeleteConversation(conv);
});
menu.appendChild(deleteBtn);
// 定位菜单
const rect = anchorEl.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 4}px`;
menu.style.right = `${window.innerWidth - rect.right}px`;
document.body.appendChild(menu);
// 点击外部关闭
const closeHandler = (e) => {
if (!menu.contains(e.target) && e.target !== anchorEl) {
menu.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
}
/**
* 切换会话置顶状态
*/
toggleConversationPin(conv) {
const stored = this.data.conversations[conv.id];
if (!stored) return;
stored.pinned = !stored.pinned;
stored.updatedAt = Date.now();
this.saveData();
// 刷新 UI
this.createUI();
// 显示提示
const message = stored.pinned ? this.t('conversationsPinned') || '已置顶' : this.t('conversationsUnpinned') || '已取消置顶';
showToast(message);
}
/**
* 显示重命名会话对话框
*/
showRenameConversationDialog(conv) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
// 标题
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsRename') || '重命名'));
// 输入框区域
const inputSection = createElement('div', { className: 'conversations-dialog-section' });
inputSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || '名称'));
const nameInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-input',
value: conv.title || '',
placeholder: this.t('conversationsFolderNamePlaceholder') || '输入会话标题',
});
inputSection.appendChild(nameInput);
dialog.appendChild(inputSection);
// 按钮
const buttons = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
cancelBtn.addEventListener('click', () => overlay.remove());
const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定');
confirmBtn.addEventListener('click', () => {
const newTitle = nameInput.value.trim();
if (newTitle && newTitle !== conv.title) {
this.renameConversation(conv.id, newTitle);
}
overlay.remove();
});
buttons.appendChild(cancelBtn);
buttons.appendChild(confirmBtn);
dialog.appendChild(buttons);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 聚焦并全选
nameInput.focus();
nameInput.select();
// ESC 关闭
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') overlay.remove();
if (e.key === 'Enter') confirmBtn.click();
});
}
/**
* 重命名会话
*/
renameConversation(convId, newTitle) {
const conv = this.data.conversations[convId];
if (!conv) return;
const oldTitle = conv.title;
conv.title = newTitle;
conv.updatedAt = Date.now();
this.saveData();
this.createUI();
showToast(this.t('conversationsFolderRenamed') || '已重命名');
// 根据设置决定是否同步云端
if (this.settings?.conversations?.syncRenameToCloud) {
this.syncRenameToCloud(convId, newTitle, oldTitle);
}
}
/**
* 同步重命名到云端(侧边栏)
*/
syncRenameToCloud(convId, newTitle, oldTitle) {
// 尝试在侧边栏找到对应会话并触发重命名
const sidebarItem = DOMToolkit.query(`.conversation[jslog*="${convId}"]`);
if (sidebarItem) {
// 尝试模拟右键菜单或编辑操作
// 由于侧边栏结构复杂,这里暂时只打印提示
console.log(`[ConversationManager] 云端同步重命名:${oldTitle} -> ${newTitle}`);
// TODO: 实现实际的侧边栏重命名操作
}
}
/**
* 确认删除会话
*/
confirmDeleteConversation(conv) {
this.showConfirmDialog(this.t('conversationsDelete') || '删除', `确定删除会话 "${conv.title}" 吗?`, () => this.deleteConversation(conv.id));
}
/**
* 删除会话
*/
deleteConversation(convId) {
const conv = this.data.conversations[convId];
if (!conv) return;
delete this.data.conversations[convId];
this.saveData();
this.createUI();
showToast(this.t('conversationsDeleted') || '已删除');
// 根据设置决定是否同步云端删除
if (this.settings?.conversations?.syncDeleteToCloud) {
this.syncDeleteToCloud(convId);
}
}
/**
* 同步删除到云端(侧边栏)
*/
syncDeleteToCloud(convId) {
// 尝试在侧边栏找到对应会话并触发删除
const sidebarItem = DOMToolkit.query(`.conversation[jslog*="${convId}"]`);
if (sidebarItem) {
console.log(`[ConversationManager] 云端同步删除会话:${convId}`);
// TODO: 实现实际的侧边栏删除操作
}
}
/**
* 更新底部批量操作栏状态
*/
updateBatchActionBar() {
const batchBar = document.getElementById('conversations-batch-bar');
const batchInfo = document.getElementById('conversations-batch-info');
if (!batchBar || !batchInfo) return;
const count = this.selectedIds.size;
if (count > 0) {
batchBar.style.display = 'flex';
batchInfo.textContent = (this.t('batchSelected') || '已选 {n} 个').replace('{n}', count);
} else {
batchBar.style.display = 'none';
}
}
/**
* 切换批量模式
*/
toggleBatchMode() {
this.batchMode = !this.batchMode;
if (!this.batchMode) {
this.selectedIds.clear();
}
this.createUI();
// 恢复之前展开的文件夹
if (this.expandedFolderId) {
const folderHeader = this.container.querySelector(`.conversations-folder-header[data-folder-id="${this.expandedFolderId}"]`);
if (folderHeader) folderHeader.click();
}
}
/**
* 清除选中状态并退出批量模式
*/
clearSelection() {
this.selectedIds.clear();
this.batchMode = false;
this.createUI();
// 恢复之前展开的文件夹
if (this.expandedFolderId) {
const folderHeader = this.container.querySelector(`.conversations-folder-header[data-folder-id="${this.expandedFolderId}"]`);
if (folderHeader) folderHeader.click();
}
}
/**
* 批量移动会话
*/
batchMove() {
if (this.selectedIds.size === 0) return;
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, `移动 ${this.selectedIds.size} 个会话到...`));
// 搜索框 + 新建文件夹按钮
const searchRow = createElement('div', {
style: 'display: flex; gap: 8px; margin-bottom: 8px; align-items: center;',
});
const searchInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-search',
placeholder: '搜索文件夹...',
style: 'flex: 1; padding: 8px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; box-sizing: border-box; font-size: 13px;',
});
const addFolderBtn = createElement('button', {
className: 'conversations-dialog-add-folder-btn',
title: this.t('conversationsAddFolder') || '新建文件夹',
style: 'width: 36px; height: 36px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; background: var(--gh-bg, white); cursor: pointer; display: flex; align-items: center; justify-content: center;',
});
const addSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
addSvg.setAttribute('viewBox', '0 0 24 24');
addSvg.setAttribute('fill', 'var(--gh-text-secondary, #6b7280)');
addSvg.setAttribute('width', '18');
addSvg.setAttribute('height', '18');
const addPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
addPath.setAttribute('d', 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z');
addSvg.appendChild(addPath);
addFolderBtn.appendChild(addSvg);
addFolderBtn.addEventListener('click', () => {
overlay.remove();
this.showCreateFolderDialog();
});
searchRow.appendChild(searchInput);
searchRow.appendChild(addFolderBtn);
dialog.appendChild(searchRow);
// 文件夹列表容器
const list = createElement('div', { className: 'conversations-folder-select-list' });
// 渲染列表函数
const renderList = (filter = '') => {
clearElement(list);
this.data.folders.forEach((folder) => {
const folderName = folder.name.replace(folder.icon, '').trim();
if (filter && !folderName.toLowerCase().includes(filter.toLowerCase())) return;
const item = createElement('div', { className: 'conversations-folder-select-item' }, `${folder.icon} ${folderName}`);
item.addEventListener('click', () => {
// 批量移动
this.selectedIds.forEach((convId) => {
if (this.data.conversations[convId]) {
this.data.conversations[convId].folderId = folder.id;
this.data.conversations[convId].updatedAt = Date.now();
}
});
this.saveData();
overlay.remove();
showToast(`已移动 ${this.selectedIds.size} 个会话到 ${folder.name}`);
this.clearSelection();
this.createUI();
});
list.appendChild(item);
});
};
// 初始渲染
renderList();
// 搜索事件
searchInput.addEventListener('input', (e) => {
renderList(e.target.value);
});
dialog.appendChild(list);
// 取消按钮
const btns = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
cancelBtn.addEventListener('click', () => overlay.remove());
btns.appendChild(cancelBtn);
dialog.appendChild(btns);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
searchInput.focus();
}
/**
* 批量删除会话
*/
batchDelete() {
if (this.selectedIds.size === 0) return;
this.showConfirmDialog('批量删除', `确定删除选中的 ${this.selectedIds.size} 个会话吗?`, () => {
const count = this.selectedIds.size;
this.selectedIds.forEach((convId) => {
delete this.data.conversations[convId];
});
this.saveData();
showToast(`已删除 ${count} 个会话`);
this.clearSelection();
this.createUI();
});
}
/**
* 创建标签
* @param {string} name 标签名称
* @param {string} color 标签颜色
*/
createTag(name, color) {
if (!this.data.tags) this.data.tags = [];
// Check duplicate
const exists = this.data.tags.some((t) => t.name.toLowerCase() === name.toLowerCase());
if (exists) {
showToast(this.t('conversationsTagExists') || '标签名称已存在');
return null;
}
const tag = {
id: 'tag_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5),
name,
color,
};
this.data.tags.push(tag);
this.saveData();
return tag;
}
/**
* 更新标签
* @param {string} tagId 标签ID
* @param {string} name 标签名称
* @param {string} color 标签颜色
*/
updateTag(tagId, name, color) {
if (!this.data.tags) return null;
// Check duplicate (exclude self)
const exists = this.data.tags.some((t) => t.id !== tagId && t.name.toLowerCase() === name.toLowerCase());
if (exists) {
showToast(this.t('conversationsTagExists') || '标签名称已存在');
return null;
}
const tag = this.data.tags.find((t) => t.id === tagId);
if (tag) {
tag.name = name;
tag.color = color;
this.saveData();
}
return tag;
}
/**
* 删除标签
* @param {string} tagId 标签ID
*/
deleteTag(tagId) {
if (!this.data.tags) return;
// 1. 删除标签定义
this.data.tags = this.data.tags.filter((t) => t.id !== tagId);
// 2. 从所有会话中移除该标签引用
Object.values(this.data.conversations).forEach((conv) => {
if (conv.tagIds) {
conv.tagIds = conv.tagIds.filter((id) => id !== tagId);
if (conv.tagIds.length === 0) delete conv.tagIds;
}
});
this.saveData();
}
/**
* 设置会话标签
* @param {string} convId 会话ID
* @param {Array<string>} tagIds 标签ID数组
*/
setConversationTags(convId, tagIds) {
if (this.data.conversations[convId]) {
if (tagIds && tagIds.length > 0) {
this.data.conversations[convId].tagIds = tagIds;
} else {
delete this.data.conversations[convId].tagIds;
}
this.saveData();
}
}
/**
* 显示移动到文件夹对话框
*/
showMoveToFolderDialog(conv) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, this.t('conversationsMoveTo') || '移动到...'));
// 搜索框 + 新建文件夹按钮
const searchRow = createElement('div', {
style: 'display: flex; gap: 8px; margin-bottom: 8px; align-items: center;',
});
const searchInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-search',
placeholder: '搜索文件夹...',
style: 'flex: 1; padding: 8px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; box-sizing: border-box; font-size: 13px;',
});
const addFolderBtn = createElement('button', {
className: 'conversations-dialog-add-folder-btn',
title: this.t('conversationsAddFolder') || '新建文件夹',
style: 'width: 36px; height: 36px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; background: var(--gh-bg, white); cursor: pointer; display: flex; align-items: center; justify-content: center;',
});
const addSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
addSvg.setAttribute('viewBox', '0 0 24 24');
addSvg.setAttribute('fill', 'var(--gh-text-secondary, #6b7280)');
addSvg.setAttribute('width', '18');
addSvg.setAttribute('height', '18');
const addPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
addPath.setAttribute('d', 'M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 8h-3v3h-2v-3h-3v-2h3V9h2v3h3v2z');
addSvg.appendChild(addPath);
addFolderBtn.appendChild(addSvg);
addFolderBtn.addEventListener('click', () => {
overlay.remove();
this.showCreateFolderDialog();
});
searchRow.appendChild(searchInput);
searchRow.appendChild(addFolderBtn);
dialog.appendChild(searchRow);
// 文件夹列表
const list = createElement('div', { className: 'conversations-folder-select-list' });
// 渲染列表函数
const renderList = (filter = '') => {
clearElement(list);
this.data.folders.forEach((folder) => {
// 排除当前所在文件夹
if (folder.id === conv.folderId) return;
const folderName = folder.name.replace(folder.icon, '').trim();
if (filter && !folderName.toLowerCase().includes(filter.toLowerCase())) return;
const item = createElement(
'div',
{
className: 'conversations-folder-select-item',
'data-folder-id': folder.id,
},
`${folder.icon} ${folderName}`,
);
item.addEventListener('click', () => {
// 移动会话
this.data.conversations[conv.id].folderId = folder.id;
this.data.conversations[conv.id].updatedAt = Date.now();
this.saveData();
this.createUI();
overlay.remove();
showToast((this.t('conversationsMoved') || '已移动到') + ` ${folder.name}`);
});
list.appendChild(item);
});
};
// 初始渲染
renderList();
// 搜索事件
searchInput.addEventListener('input', (e) => {
renderList(e.target.value);
});
dialog.appendChild(list);
// 取消按钮
const btns = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
cancelBtn.addEventListener('click', () => overlay.remove());
btns.appendChild(cancelBtn);
dialog.appendChild(btns);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
searchInput.focus();
}
/**
* 格式化时间显示
*/
formatTime(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) return this.t('justNow') || '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + (this.t('minutesAgo') || '分钟前');
if (diff < 86400000) return Math.floor(diff / 3600000) + (this.t('hoursAgo') || '小时前');
if (diff < 604800000) return Math.floor(diff / 86400000) + (this.t('daysAgo') || '天前');
return date.toLocaleDateString();
}
/**
* 显示文件夹操作菜单
*/
showFolderMenu(folder, anchorEl) {
// 移除已有菜单
document.querySelectorAll('.conversations-folder-menu').forEach((m) => m.remove());
const menu = createElement('div', { className: 'conversations-folder-menu' });
const renameBtn = createElement('button', {}, this.t('conversationsRename') || '重命名');
renameBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.showRenameFolderDialog(folder);
});
const deleteBtn = createElement('button', { style: 'color: #ef4444;' }, this.t('conversationsDelete') || '删除');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
this.confirmDeleteFolder(folder);
});
menu.appendChild(renameBtn);
menu.appendChild(deleteBtn);
// 定位菜单
const rect = anchorEl.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${rect.left}px`;
document.body.appendChild(menu);
// 点击外部关闭
const closeMenu = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
setTimeout(() => document.addEventListener('click', closeMenu), 0);
}
/**
* 显示新建文件夹对话框
*/
showCreateFolderDialog() {
this.showFolderDialog({
title: this.t('conversationsAddFolder') || '新建文件夹',
icon: '📁',
name: '',
onConfirm: (name, icon) => {
if (name.trim()) {
this.createFolder(name.trim(), icon);
this.createUI(); // 刷新 UI
showToast(this.t('conversationsFolderCreated') || '文件夹已创建');
}
},
});
}
/**
* 显示重命名文件夹对话框
*/
showRenameFolderDialog(folder) {
const currentName = folder.name.replace(folder.icon, '').trim();
this.showFolderDialog({
title: this.t('conversationsRename') || '重命名文件夹',
icon: folder.icon,
name: currentName,
onConfirm: (name, icon) => {
if (name.trim()) {
this.renameFolder(folder.id, name.trim(), icon);
this.createUI(); // 刷新 UI
showToast(this.t('conversationsFolderRenamed') || '文件夹已重命名');
}
},
});
}
/**
* 确认删除文件夹
*/
confirmDeleteFolder(folder) {
this.showConfirmDialog(this.t('conversationsDelete') || '删除', this.t('conversationsDeleteConfirm') || `确定删除文件夹 "${folder.name}" 吗?其中的会话将移到收件箱。`, () => {
if (this.deleteFolder(folder.id)) {
this.createUI(); // 刷新 UI
showToast(this.t('conversationsFolderDeleted') || '文件夹已删除');
}
});
}
/**
* 通用文件夹对话框(新建/重命名复用)
*/
showFolderDialog({ title, icon, name, onConfirm }) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog' });
// 标题
dialog.appendChild(createElement('div', { className: 'conversations-dialog-title' }, title));
// Emoji 选择器
const emojiSection = createElement('div', { className: 'conversations-dialog-section' });
emojiSection.appendChild(createElement('label', {}, this.t('conversationsIcon') || '图标'));
const emojiPicker = this.createEmojiPicker(icon);
emojiSection.appendChild(emojiPicker);
dialog.appendChild(emojiSection);
// 名称输入
const nameSection = createElement('div', { className: 'conversations-dialog-section' });
nameSection.appendChild(createElement('label', {}, this.t('conversationsFolderName') || '名称'));
const nameInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-input',
value: name,
placeholder: this.t('conversationsFolderNamePlaceholder') || '输入文件夹名称',
});
nameSection.appendChild(nameInput);
dialog.appendChild(nameSection);
// 按钮
const buttons = createElement('div', { className: 'conversations-dialog-buttons' });
const cancelBtn = createElement('button', { className: 'conversations-dialog-btn cancel' }, this.t('cancel') || '取消');
const confirmBtn = createElement('button', { className: 'conversations-dialog-btn confirm' }, this.t('confirm') || '确定');
cancelBtn.addEventListener('click', () => overlay.remove());
confirmBtn.addEventListener('click', () => {
const customInput = emojiPicker.querySelector('input');
const selectedIcon = customInput ? customInput.value : emojiPicker.querySelector('.selected')?.textContent || icon;
onConfirm(nameInput.value, selectedIcon);
overlay.remove();
});
buttons.appendChild(cancelBtn);
buttons.appendChild(confirmBtn);
dialog.appendChild(buttons);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 聚焦输入框
nameInput.focus();
// 点击遮罩关闭 (智能行为:有输入则保存,无输入则关闭)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
const name = nameInput.value.trim();
// 这里我们复用 confirmBtn 的逻辑,因为 confirmBtn 里也只是调用 onConfirm
// 但我们需要判断是否有效。
// 用户的要求:输入了->新建/编辑;没有输入->关闭
if (name) {
confirmBtn.click();
} else {
overlay.remove();
}
}
});
// ESC 关闭,Enter 确定
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') overlay.remove();
if (e.key === 'Enter') confirmBtn.click();
});
}
/**
* 创建 Emoji 选择器 (增强版)
*/
createEmojiPicker(selectedEmoji = '📁') {
const container = createElement('div', {
className: 'conversations-emoji-picker',
style: 'display: flex; flex-direction: column; gap: 8px;',
});
// 1. 自定义输入区域
const customRow = createElement('div', {
className: 'conversations-emoji-custom-row',
style: 'display: flex; align-items: center; gap: 8px; padding: 4px; background: var(--gh-bg-secondary, #f9fafb); border-radius: 4px; border: 1px solid var(--gh-border, #e5e7eb);',
});
const customLabel = createElement('span', { style: 'font-size: 12px; color: var(--gh-text-secondary, #6b7280); flex-shrink: 0;' }, this.t('conversationsCustomIcon') || '自定义:');
const customInput = createElement('input', {
type: 'text',
className: 'conversations-emoji-custom-input',
value: selectedEmoji,
maxLength: 4, // 稍微放宽长度
placeholder: '☺',
style: 'width: 60px; text-align: center; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px; padding: 2px; font-size: 16px;',
});
customRow.appendChild(customLabel);
customRow.appendChild(customInput);
container.appendChild(customRow);
// 2. 预设列表区域
const listContainer = createElement('div', {
className: 'conversations-emoji-list',
style: 'display: grid; grid-template-columns: repeat(8, 1fr); gap: 4px; max-height: 120px; overflow-y: auto; padding: 2px; scrollbar-width: none; -ms-overflow-style: none;',
});
// Hide scrollbar style
const hideScrollStyle = document.createElement('style');
hideScrollStyle.textContent = `.conversations-emoji-list::-webkit-scrollbar { display: none; }`;
listContainer.appendChild(hideScrollStyle);
// 扩充的预设 Emoji 库 (64个)
const presetEmojis = [
// 📂 基础文件夹
'📁',
'📂',
'📥',
'🗂️',
'📊',
'📈',
'📉',
'📋',
// 💼 办公/工作
'💼',
'📅',
'📌',
'📎',
'📝',
'✒️',
'🔍',
'💡',
// 💻 编程/技术
'💻',
'⌨️',
'🖥️',
'🖱️',
'🐛',
'🔧',
'🔨',
'⚙️',
// 🤖 AI/机器人
'🤖',
'👾',
'🧠',
'⚡',
'🔥',
'✨',
'🎓',
'📚',
// 🎨 创意/艺术
'🎨',
'🎭',
'🎬',
'🎹',
'🎵',
'📷',
'🖌️',
'🖍️',
// 🏠 生活/日常
'🏠',
'🛒',
'✈️',
'🎮',
'⚽',
'🍔',
'☕',
'❤️',
// 🌈 颜色/标记
'🔴',
'🟠',
'🟡',
'🟢',
'🔵',
'🟣',
'⚫',
'⚪',
// ⭐ 其他
'⭐',
'🌟',
'🎉',
'🔒',
'🔑',
'🚫',
'✅',
'❓',
];
// 选中状态管理
let currentSelectedBtn = null;
presetEmojis.forEach((emoji) => {
const btn = createElement(
'button',
{
className: 'conversations-emoji-btn' + (emoji === selectedEmoji ? ' selected' : ''),
style: 'width: 24px; height: 24px; padding: 0; display: flex; align-items: center; justify-content: center; border: none; background: transparent; cursor: pointer; border-radius: 4px; font-size: 16px;',
},
emoji,
);
if (emoji === selectedEmoji) currentSelectedBtn = btn;
btn.addEventListener('click', (e) => {
e.preventDefault(); // 防止触发表单提交等意外行为
// 更新按钮选中状态
if (currentSelectedBtn) {
currentSelectedBtn.classList.remove('selected');
currentSelectedBtn.style.backgroundColor = 'transparent';
}
btn.classList.add('selected');
btn.style.backgroundColor = '#dbeafe'; // 浅蓝背景表示选中
currentSelectedBtn = btn;
// 同步到自定义输入框
customInput.value = emoji;
// 标记为用户手动选择的 (通过 class,供外部获取值时优先使用输入框的值)
customInput.classList.add('selected');
});
// Hover 效果
btn.onmouseenter = () => {
if (!btn.classList.contains('selected')) btn.style.backgroundColor = 'var(--gh-hover, #f3f4f6)';
};
btn.onmouseleave = () => {
if (!btn.classList.contains('selected')) btn.style.backgroundColor = 'transparent';
};
listContainer.appendChild(btn);
});
container.appendChild(listContainer);
// 自定义输入监听
customInput.addEventListener('input', (e) => {
let val = e.target.value;
// 简单的 Emoji 校验:利用 Unicode 属性 \p{Extended_Pictographic}
const emojiRegex = /[^\p{Extended_Pictographic}\u200d\ufe0f]/gu;
if (val && emojiRegex.test(val)) {
val = val.replace(emojiRegex, '');
e.target.value = val;
}
// 清除按钮选中状态,因为现在是自定义的
if (currentSelectedBtn) {
currentSelectedBtn.classList.remove('selected');
currentSelectedBtn.style.backgroundColor = 'transparent';
currentSelectedBtn = null;
}
// 尝试反向匹配:如果输入的内容刚好在预设里,把那个按钮高亮
const matchBtn = Array.from(listContainer.children).find((b) => b.textContent === val);
if (matchBtn) {
matchBtn.classList.add('selected');
matchBtn.style.backgroundColor = '#dbeafe';
currentSelectedBtn = matchBtn;
}
// 给 input 加个标记类
customInput.classList.add('selected');
});
// 初始高亮颜色
if (currentSelectedBtn) {
currentSelectedBtn.style.backgroundColor = '#dbeafe';
}
return container;
}
/**
* 设置激活状态
* 激活时刷新所有文件夹计数和展开的文件夹
*/
setActive(active) {
const wasActive = this.isActive;
this.isActive = active;
// 从非激活变为激活时,刷新所有文件夹计数和展开的文件夹
if (!wasActive && active) {
this.refreshAllFolderCounts();
}
}
/**
* 刷新所有文件夹的计数和展开的文件夹会话列表
*/
refreshAllFolderCounts() {
if (!this.data || !this.data.folders) return;
this.data.folders.forEach((folder) => {
this.updateFolderCount(folder.id);
});
}
/**
* 刷新会话列表
*/
refresh() {
this.loadData();
this.createUI();
}
/**
* 处理搜索输入
* @param {string} query 搜索关键词
*/
handleSearch(query) {
this.searchQuery = query;
if (!query && (!this.filterTagIds || this.filterTagIds.size === 0) && !this.filterPinned) {
// 清空搜索时重置(无关键词、无标签筛选、无置顶筛选)
this.searchResult = null;
this.refreshAfterSearch();
return;
}
// 执行搜索
this.searchResult = this.performSearch(query);
this.refreshAfterSearch();
}
/**
* 执行搜索
* @param {string} query 搜索关键词
* @returns {{ folderMatches: Set, conversationMatches: Set, conversationFolderMap: Map, totalCount: number }}
*/
performSearch(query) {
const lowerQuery = query.toLowerCase();
const folderMatches = new Set(); // 直接匹配的文件夹 ID
const conversationMatches = new Set(); // 匹配的会话 ID
const conversationFolderMap = new Map(); // 会话 ID -> 所属文件夹 ID(用于展开父级)
// 获取当前 CID(仅 Gemini Business 有效)
const currentCid = this.siteAdapter.getCurrentCid ? this.siteAdapter.getCurrentCid() : null;
// 1. 遍历文件夹,匹配名称
if (this.data && this.data.folders && lowerQuery) {
this.data.folders.forEach((folder) => {
if (folder.name.toLowerCase().includes(lowerQuery)) {
folderMatches.add(folder.id);
}
});
}
// 2. 遍历会话,匹配标题(按 CID 过滤)
if (this.data && this.data.conversations) {
Object.values(this.data.conversations).forEach((conv) => {
// 先按 CID 过滤
if (!this.matchesCid(conv, currentCid)) return;
// 逻辑整合:关键词 AND 标签 AND 置顶
const matchQuery = !lowerQuery || (conv.title && conv.title.toLowerCase().includes(lowerQuery));
const matchTags = !this.filterTagIds || this.filterTagIds.size === 0 || (conv.tagIds && conv.tagIds.some((id) => this.filterTagIds.has(id)));
const matchPinned = !this.filterPinned || conv.pinned;
if (matchQuery && matchTags && matchPinned) {
conversationMatches.add(conv.id);
conversationFolderMap.set(conv.id, conv.folderId);
}
});
}
return {
folderMatches,
conversationMatches,
conversationFolderMap,
totalCount: folderMatches.size + conversationMatches.size,
};
}
/**
* 搜索后刷新 UI(不重建整个面板,只更新列表和结果条)
*/
refreshAfterSearch() {
// 更新结果条
const resultBar = document.getElementById('conversations-result-bar');
if (resultBar) {
if (this.searchResult) {
resultBar.textContent = `${this.searchResult.totalCount} ${this.t('conversationsSearchResult') || '个结果'}`;
resultBar.classList.add('visible');
} else {
resultBar.textContent = '';
resultBar.classList.remove('visible');
}
}
// 重建文件夹列表(带搜索过滤)
const container = this.container?.querySelector('.conversations-content');
const oldFolderList = container?.querySelector('.conversations-folder-list');
if (container && oldFolderList) {
const newFolderList = this.createFolderListUI();
container.replaceChild(newFolderList, oldFolderList);
}
}
/**
* 高亮文本中的关键词
* @param {string} text 原始文本
* @param {string} query 搜索关键词
* @returns {DocumentFragment} 带高亮的文档片段
*/
highlightText(text, query) {
const fragment = document.createDocumentFragment();
if (!query) {
fragment.appendChild(document.createTextNode(text));
return fragment;
}
try {
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
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 2px';
mark.style.borderRadius = '2px';
fragment.appendChild(mark);
} else {
fragment.appendChild(document.createTextNode(part));
}
});
} catch (e) {
fragment.appendChild(document.createTextNode(text));
}
return fragment;
}
/**
* 显示标签管理对话框
*/
showTagManagerDialog(conv = null) {
const overlay = createElement('div', { className: 'conversations-dialog-overlay' });
const dialog = createElement('div', { className: 'conversations-dialog conversations-dialog-tag-manager' });
// 标题
// 标题栏 (含关闭按钮)
const titleRow = createElement('div', { className: 'conversations-dialog-title', style: 'display:flex; justify-content:space-between; align-items:center;' });
titleRow.textContent = this.t('conversationsManageTags') || '管理标签';
const closeIcon = createElement(
'span',
{
className: 'conversations-close-icon',
style: 'cursor:pointer; padding:4px; font-size:20px; color:#9ca3af; line-height:1; width:24px; height:24px; display:flex; align-items:center; justify-content:center; border-radius:4px;',
title: this.t('close') || '关闭',
},
'×',
);
closeIcon.addEventListener('click', () => overlay.remove());
closeIcon.addEventListener('mouseenter', () => (closeIcon.style.backgroundColor = 'var(--gh-hover, #f3f4f6)'));
closeIcon.addEventListener('mouseleave', () => (closeIcon.style.backgroundColor = 'transparent'));
titleRow.appendChild(closeIcon);
dialog.appendChild(titleRow);
const content = createElement('div', { className: 'conversations-dialog-content' });
// 标签列表容器 (隐藏滚动条)
const listContainer = createElement('div', {
className: 'conversations-tag-manager-list',
style: 'scrollbar-width: none; -ms-overflow-style: none;', // Firefox, IE
});
// 注入隐藏 scrollbar 的样式 (Chrome/Safari)
const hideScrollStyle = document.createElement('style');
hideScrollStyle.textContent = `.conversations-tag-manager-list::-webkit-scrollbar { display: none; }`;
listContainer.appendChild(hideScrollStyle);
const renderList = () => {
clearElement(listContainer);
if (!this.data.tags || this.data.tags.length === 0) {
listContainer.appendChild(createElement('div', { className: 'conversations-empty' }, this.t('conversationsNoTags') || '暂无标签'));
return;
}
this.data.tags.forEach((tag) => {
const item = createElement('div', { className: 'conversations-tag-manager-item' });
// 左侧:勾选框(如果有会话上下文)+ 预览
const left = createElement('div', { style: 'display:flex; align-items:center; gap:8px;' });
let checkbox = null;
if (conv) {
checkbox = createElement('input', { type: 'checkbox' });
checkbox.checked = conv.tagIds && conv.tagIds.includes(tag.id);
checkbox.addEventListener('change', () => {
let newTags = conv.tagIds || [];
if (checkbox.checked) {
if (!newTags.includes(tag.id)) newTags.push(tag.id);
} else {
newTags = newTags.filter((id) => id !== tag.id);
}
this.setConversationTags(conv.id, newTags);
this.saveData();
const list = this.container.querySelector(`.conversations-list[data-folder-id="${conv.folderId}"]`);
if (list) this.renderConversationList(conv.folderId, list);
});
checkbox.addEventListener('click', (e) => e.stopPropagation()); // 防止点击 checkbox 触发行点击
left.appendChild(checkbox);
}
const preview = createElement(
'span',
{
className: 'conversations-tag-preview',
style: `background-color: ${tag.color}`,
},
tag.name,
);
left.appendChild(preview);
item.appendChild(left);
// 右侧:编辑/删除按钮
const actions = createElement('div', { className: 'conversations-tag-actions' });
// 编辑逻辑简化:点击填充到底部输入框,暂不实现行内编辑
const editBtn = createElement(
'button',
{
className: 'conversations-tag-btn edit',
title: this.t('edit'),
},
'✎',
);
editBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 防止触发行点击
nameInput.value = tag.name;
updateColorSelection(tag.color);
editingId = tag.id;
addBtn.textContent = this.t('conversationsUpdateTag') || '更新标签';
});
actions.appendChild(editBtn);
const delBtn = createElement(
'button',
{
className: 'conversations-tag-btn delete',
title: this.t('delete'),
},
'×',
);
delBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 防止触发行点击
if (confirm(this.t('confirmDelete') || '确定删除?')) {
this.deleteTag(tag.id);
renderList();
// 刷新所有可见的会话列表
this.container.querySelectorAll('.conversations-list').forEach((list) => {
const fid = list.dataset.folderId;
if (fid) this.renderConversationList(fid, list);
});
}
});
actions.appendChild(delBtn);
item.appendChild(actions);
// 整行点击切换 checkbox(仅在有会话上下文时)
if (conv && checkbox) {
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change')); // 触发 change 事件更新数据
});
}
listContainer.appendChild(item);
});
};
content.appendChild(listContainer);
// 新建/编辑区域
const formSection = createElement('div', { className: 'conversations-dialog-section', style: 'border-top:1px solid #eee; padding-top:10px;' });
let editingId = null;
const nameInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-input',
placeholder: this.t('conversationsTagName') || '标签名称',
style: 'flex:1; margin-bottom: 8px;',
});
// Enter 提交
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addBtn.click();
});
formSection.appendChild(nameInput);
const colorPicker = createElement('div', { className: 'conversations-color-picker' });
let selectedColor = TAG_COLORS[0];
// 1. 渲染 30 色预设网格
const updateColorSelection = (color, source = 'click') => {
if (!color.startsWith('#')) color = '#' + color;
selectedColor = color;
// 更新 Hex 输入框
if (source !== 'input') {
hexInput.value = color;
hexInput.style.borderColor = '#ddd'; // Reset error state
}
// 更新选中状态
// 检查是否在预设中
const presetMatch = Array.from(colorPicker.children).find((c) => c.dataset.color && c.dataset.color.toLowerCase() === color.toLowerCase());
Array.from(colorPicker.children).forEach((c) => c.classList.remove('selected'));
if (presetMatch) {
presetMatch.classList.add('selected');
// 重置自定义按钮
customBtnInner.style.background = 'conic-gradient(from 180deg, red, yellow, lime, aqua, blue, magenta, red)';
customBtn.classList.remove('active-custom');
} else {
// 自定义颜色选中
customBtnInner.style.background = color;
customBtn.classList.add('active-custom');
}
};
TAG_COLORS.forEach((color) => {
const colorItem = createElement('div', {
className: 'conversations-color-item',
style: `background-color: ${color}`,
'data-color': color,
});
if (color === selectedColor) colorItem.classList.add('selected');
colorItem.addEventListener('click', () => updateColorSelection(color));
colorPicker.appendChild(colorItem);
});
formSection.appendChild(colorPicker);
// 2. 自定义颜色行 (彩虹按钮 + Hex 输入框)
const customRow = createElement('div', {
style: 'display: flex; align-items: center; gap: 12px; margin-top: 12px; padding: 0 4px;',
});
// 彩虹按钮容器
const customBtn = createElement('div', {
className: 'conversations-color-item custom-btn-wrapper',
title: '自定义颜色',
style: 'position: relative; cursor: pointer; border: 2px solid transparent; width: 32px; height: 32px; border-radius: 50%; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);',
});
// 选中样式 CSS (通过 class 控制 border)
const customBtnStyle = document.createElement('style');
customBtnStyle.textContent = `
.active-custom { border-color: #666 !important; transform: scale(1.1); }
`;
customRow.appendChild(customBtnStyle);
const customBtnInner = createElement('div', {
style: 'width: 100%; height: 100%; background: conic-gradient(from 180deg, red, yellow, lime, aqua, blue, magenta, red);',
});
const nativePicker = createElement('input', {
type: 'color',
style: 'position: absolute; left: -50%; top: -50%; width: 200%; height: 200%; opacity: 0; cursor: pointer;',
});
nativePicker.addEventListener('input', (e) => updateColorSelection(e.target.value, 'picker'));
customBtn.appendChild(customBtnInner);
customBtn.appendChild(nativePicker);
customRow.appendChild(customBtn);
// Hex 输入区域
const hexWrapper = createElement('div', {
style: 'display: flex; align-items: center; gap: 8px; flex: 1;',
});
hexWrapper.appendChild(createElement('span', { style: 'font-size: 13px; color: #666;' }, 'HEX:'));
const hexInput = createElement('input', {
type: 'text',
className: 'conversations-dialog-input',
value: selectedColor,
placeholder: '#RRGGBB',
style: 'flex: 1; font-family: monospace; text-transform: uppercase;',
});
hexInput.addEventListener('input', (e) => {
const val = e.target.value;
// 正则校验: #后面跟3或6位16进制字符
const hexRegex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
if (hexRegex.test(val)) {
hexInput.style.borderColor = '#ddd'; // Valid
// 补全3位到6位
let expandVal = val;
if (val.length === 4) {
expandVal = '#' + val[1] + val[1] + val[2] + val[2] + val[3] + val[3];
}
updateColorSelection(expandVal, 'input');
} else {
hexInput.style.borderColor = '#ef4444'; // Invalid
}
});
// 失去焦点时如果无效则恢复
hexInput.addEventListener('blur', () => {
if (hexInput.style.borderColor === 'rgb(239, 68, 68)' || hexInput.style.borderColor === '#ef4444') {
hexInput.value = selectedColor;
hexInput.style.borderColor = '#ddd';
}
});
hexWrapper.appendChild(hexInput);
customRow.appendChild(hexWrapper);
formSection.appendChild(customRow);
// 初始化颜色选择状态
updateColorSelection(selectedColor, 'init');
const addBtn = createElement(
'button',
{
className: 'conversations-dialog-btn confirm',
style: 'width:100%; margin-top:8px;',
},
this.t('conversationsNewTag') || '新建标签',
);
addBtn.addEventListener('click', () => {
const name = nameInput.value.trim();
if (!name) return;
let result;
if (editingId) {
result = this.updateTag(editingId, name, selectedColor);
} else {
result = this.createTag(name, selectedColor);
}
if (result) {
// Success
if (editingId) {
editingId = null;
addBtn.textContent = this.t('conversationsNewTag') || '新建标签';
}
nameInput.value = '';
// Reset color selection? Maybe keep it.
renderList();
// 刷新所有可见的会话列表 (因为标签修改会影响所有使用了该标签的会话)
this.container.querySelectorAll('.conversations-list').forEach((list) => {
const fid = list.dataset.folderId;
if (fid) this.renderConversationList(fid, list);
});
}
// If result is null, validation failed (toast already shown), keep input for user to fix
});
formSection.appendChild(addBtn);
content.appendChild(formSection);
dialog.appendChild(content);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// 渲染列表
renderList();
// 点击遮罩关闭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
// ESC 关闭
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') overlay.remove();
});
// Focus input
nameInput.focus();
}
}
/**
* 通用大纲管理器
* 负责大纲的 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);
let tabOrder = GM_getValue(SETTING_KEYS.TAB_ORDER, DEFAULT_TAB_ORDER);
// 兼容老用户:确保所有默认 Tab 都在 tabOrder 中
// 如果有新增的 Tab(如 conversations),自动添加到 settings 之前
const missingTabs = DEFAULT_TAB_ORDER.filter((tab) => !tabOrder.includes(tab));
if (missingTabs.length > 0) {
const settingsIndex = tabOrder.indexOf('settings');
if (settingsIndex !== -1) {
// 在 settings 之前插入缺失的 Tab
tabOrder = [...tabOrder.slice(0, settingsIndex), ...missingTabs, ...tabOrder.slice(settingsIndex)];
} else {
// 如果没有 settings,直接追加
tabOrder = [...tabOrder, ...missingTabs];
}
// 保存更新后的 tabOrder
GM_setValue(SETTING_KEYS.TAB_ORDER, tabOrder);
}
// 加载模型锁定设置(按站点隔离,但一次性加载所有站点的配置)
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, {}) },
conversations: { enabled: true },
defaultPanelState: GM_getValue('gemini_default_panel_state', true),
autoHidePanel: GM_getValue('gemini_default_auto_hide', false),
};
}
/**
* 保存设置
* @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);
// 保存会话设置
if (settings.conversations) {
GM_setValue('gemini_conversations_settings', settings.conversations);
}
GM_setValue('gemini_default_panel_state', settings.defaultPanelState);
GM_setValue('gemini_default_auto_hide', settings.autoHidePanel);
}
}
/**
* Gemini 助手核心类
* 管理提示词、设置和 UI 界面
*/
class GeminiHelper {
constructor(siteRegistry) {
this.prompts = this.loadPrompts();
this.registry = siteRegistry;
// 保持 siteAdapter 引用以便兼容旧代码,指向当前匹配的站点
this.siteAdapter = siteRegistry.getCurrent();
this.selectedPrompt = null;
this.isScrolling = false; // 滚动状态锁
this.anchorScrollTop = null; // 阅读锚点位置
this.lang = detectLanguage(); // 当前语言
this.i18n = I18N[this.lang]; // 当前语言文本
this.settingsManager = new SettingsManager();
this.settings = this.loadSettings(); // 加载设置
// 根据设置初始化面板折叠状态 (默认显示面板 -> !collapsed)
this.isCollapsed = !this.settings.defaultPanelState;
// 初始化当前 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.monitorTheme();
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...');
// 使用当前内存中的设置重新应用配置(无需重新加载)
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 = `
/* CSS Variables Definition (Global Scope) */
:root {
--gh-bg: #ffffff;
--gh-bg-secondary: #f9fafb;
--gh-text: #1f2937;
--gh-text-secondary: #6b7280;
--gh-border: #e5e7eb;
--gh-hover: #f3f4f6;
--gh-shadow: 0 10px 40px rgba(0,0,0,0.15);
--gh-input-bg: #ffffff;
--gh-input-border: #d1d5db;
--gh-active-bg: #e5e7eb;
--gh-danger: #ef4444;
--gh-gradient: ${gradient};
/* Semantic Variables */
--gh-header-bg: ${gradient};
--gh-folder-bg-default: #e0f2fe;
--gh-folder-bg-expanded: #c7d2fe;
--gh-border-active: #6366f1;
--gh-tag-active-bg: ${colors.primary};
/* Folder Preset Colors */
--gh-folder-bg-0: #fef9e7;
--gh-folder-bg-1: #fdf2f8;
--gh-folder-bg-2: #eff6ff;
--gh-folder-bg-3: #ecfdf5;
--gh-folder-bg-4: #faf5ff;
--gh-folder-bg-5: #fefce8;
--gh-folder-bg-6: #ecfeff;
--gh-folder-bg-7: #fdf4ff;
}
body[data-gh-mode="dark"] {
--gh-bg: #1e1e1e;
--gh-bg-secondary: #2d2d2d;
--gh-text: #e3e3e3;
--gh-text-secondary: #a0a0a0;
--gh-border: #444444;
--gh-hover: #333333;
--gh-shadow: 0 10px 40px rgba(0,0,0,0.4);
--gh-input-bg: #2f2f2f;
--gh-input-border: #555555;
--gh-active-bg: #404040;
/* Dark Mode Semantic Overrides */
--gh-header-bg: #1e1e1e;
--gh-folder-bg-default: rgba(66, 133, 244, 0.15);
--gh-folder-bg-expanded: rgba(66, 133, 244, 0.3);
--gh-border-active: #818cf8;
--gh-tag-active-bg: rgba(66, 133, 244, 0.6);
/* Folder Preset Colors (Dark Mode Translucent) */
--gh-folder-bg-0: rgba(253, 224, 71, 0.15);
--gh-folder-bg-1: rgba(244, 114, 182, 0.15);
--gh-folder-bg-2: rgba(96, 165, 250, 0.15);
--gh-folder-bg-3: rgba(52, 211, 153, 0.15);
--gh-folder-bg-4: rgba(167, 139, 250, 0.15);
--gh-folder-bg-5: rgba(253, 224, 71, 0.1);
--gh-folder-bg-6: rgba(34, 211, 238, 0.15);
--gh-folder-bg-7: rgba(232, 121, 249, 0.15);
}
/* 主面板样式 */
#gemini-helper-panel {
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
width: 320px;
height: 80vh;
min-height: 600px;
background: var(--gh-bg, 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 var(--gh-border, #e0e0e0);
}
#gemini-helper-panel.collapsed { display: none; }
.prompt-panel-header {
padding: 16px;
background: var(--gh-header-bg);
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 var(--gh-border, #e5e7eb); background: var(--gh-bg-secondary, #f9fafb); }
.prompt-search-input {
width: 100%; padding: 8px 12px; border: 1px solid var(--gh-input-border, #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: var(--gh-bg, white); border-bottom: 1px solid var(--gh-border, #e5e7eb); }
.category-tag {
padding: 4px 10px; background: var(--gh-hover, #f3f4f6); border-radius: 12px; font-size: 12px; color: #4b5563;
cursor: pointer; transition: all 0.2s; border: 1px solid transparent;
}
.category-tag:hover { background: var(--gh-border, #e5e7eb); }
.category-tag.active {
background: var(--gh-tag-active-bg); color: white; border-color: var(--gh-tag-active-bg);
}
.prompt-list { flex: 1; overflow-y: auto; padding: 8px; }
.prompt-item {
background: var(--gh-bg, white); border: 1px solid var(--gh-border, #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: var(--gh-text, #1f2937); flex: 1; }
.prompt-item-category { font-size: 11px; padding: 2px 6px; background: var(--gh-hover, #f3f4f6); border-radius: 4px; color: var(--gh-text-secondary, #6b7280); }
.prompt-item-content { font-size: 13px; color: var(--gh-text-secondary, #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: var(--gh-bg, 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: var(--gh-hover, #f3f4f6); transform: scale(1.1); }
.prompt-item.dragging { opacity: 0.5; }
.add-prompt-btn {
margin: 12px; padding: 10px; background: var(--gh-header-bg);
color: white; border: 1px solid rgba(255,255,255,0.2); 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: var(--gh-bg, 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: var(--gh-text, #1f2937); }
.prompt-form-group { margin-bottom: 16px; }
.prompt-form-label { display: block; font-size: 14px; font-weight: 500; color: var(--gh-text, #374151); margin-bottom: 6px; }
.prompt-form-input, .prompt-form-textarea {
width: 100%; padding: 8px 12px; border: 1px solid var(--gh-input-border, #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: var(--gh-header-bg); color: white; border: 1px solid rgba(255,255,255,0.2); }
.prompt-modal-btn.secondary { background: var(--gh-hover, #f3f4f6); color: #4b5563; }
/* 选中的提示词显示栏 */
.selected-prompt-bar {
position: fixed; bottom: 120px; left: 50%; transform: translateX(-50%);
background: var(--gh-header-bg);
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: var(--gh-header-bg);
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; }
.gemini-toast {
position: fixed !important; top: 32px !important; left: 50% !important; transform: translateX(-50%) !important;
background: var(--gh-header-bg); /* 品牌渐变色 -> 动态主题色 */
color: white; /* 渐变色背景通常较深,配白字 */
padding: 10px 24px; border-radius: 9999px; font-size: 14px; font-weight: 500;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
z-index: 1000001 !important; animation: toastSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid rgba(255, 255, 255, 0.15); /* 增加一点白色内描边提升精致感 */
display: flex; align-items: center; justify-content: center; gap: 8px;
pointer-events: none;
}
@keyframes toastSlideIn {
from { transform: translate(-50%, -20px) scale(0.95); opacity: 0; }
to { transform: translate(-50%, 0) scale(1); opacity: 1; }
}
/* 快捷跳转按钮组(面板内) */
.scroll-nav-container {
display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--gh-border, #e5e7eb);
background: var(--gh-bg-secondary);
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: var(--gh-header-bg);
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);
}
/* ========== 会话面板样式 ========== */
.conversations-content {
display: flex; flex-direction: column; flex: 1; min-height: 200px;
overflow-x: hidden; /* 隐藏横向滚动条 */
}
.conversations-toolbar {
display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid var(--gh-border, #e5e7eb); flex-shrink: 0;
}
.conversations-toolbar-btn {
padding: 6px 12px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 8px; background: var(--gh-bg-secondary, #f9fafb);
font-size: 13px; color: var(--gh-text, #374151); cursor: pointer; transition: all 0.2s;
display: flex; align-items: center; gap: 4px;
}
.conversations-toolbar-btn:hover { background: var(--gh-hover, #f3f4f6); border-color: #9ca3af; }
.conversations-toolbar-btn.sync { padding: 6px 10px; }
.conversations-toolbar-btn.batch-mode.active { background: var(--gh-border-active); color: white; border-color: var(--gh-border-active); }
.conversations-toolbar-btn:disabled { opacity: 0.6; cursor: wait; }
.conversations-folder-select {
padding: 6px 10px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 8px;
background: var(--gh-bg-secondary, #f9fafb); font-size: 13px; color: var(--gh-text, #374151); cursor: pointer;
flex: 1; min-width: 0; max-width: 150px;
}
.conversations-folder-select:focus { outline: none; border-color: var(--gh-border-active); }
.conversations-folder-list {
flex: 1; overflow-y: auto; padding: 8px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.conversations-folder-list::-webkit-scrollbar { display: none; } /* Chrome */
.conversations-folder-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; margin-bottom: 4px; border-radius: 8px;
background: var(--gh-bg-secondary, #f9fafb); cursor: pointer; transition: all 0.2s;
flex-wrap: wrap; /* 允许换行,会话列表在下方 */
}
.conversations-folder-item:hover { background: var(--gh-hover, #f3f4f6); }
.conversations-folder-item.default { background: var(--gh-folder-bg-default); }
.conversations-folder-item.expanded {
background: var(--gh-folder-bg-expanded) !important; /* 更深的紫蓝色 */
border: 2px solid var(--gh-border-active); /* 明显的边框 */
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.25);
border-radius: 8px 8px 0 0; /* 展开时上方圆角 */
}
.conversations-folder-info {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
position: relative; /* 为绝对定位的箭头提供参考 */
}
.conversations-folder-icon {
font-size: 18px; width: 24px; height: 24px;
display: flex; align-items: center; justify-content: center;
line-height: 1; flex-shrink: 0;
}
.conversations-folder-name {
font-size: 14px; font-weight: 500; color: var(--gh-text, #1f2937);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
user-select: none; /* 禁止选中 */
}
.conversations-folder-count { font-size: 12px; color: var(--gh-text-secondary, #6b7280); flex-shrink: 0; user-select: none; }
.conversations-folder-menu-btn {
width: 24px; height: 24px; border: none; background: transparent;
color: var(--gh-text-secondary, #6b7280); cursor: pointer; border-radius: 4px; font-size: 14px;
}
.conversations-folder-menu-btn:hover { background: var(--gh-border, #e5e7eb); }
.conversations-folder-controls {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
}
.conversations-folder-order-btns {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; gap: 2px;
opacity: 0; transition: opacity 0.2s;
background: linear-gradient(to right, transparent, currentColor 8px); /* 渐变遮罩 */
background: inherit; padding-left: 8px;
}
.conversations-folder-item:hover .conversations-folder-order-btns {
opacity: 1;
}
.conversations-folder-order-btn {
width: 20px; height: 20px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 4px;
background: var(--gh-bg, white); color: var(--gh-text-secondary, #6b7280); cursor: pointer; font-size: 11px;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.conversations-folder-order-btn:hover:not(:disabled) {
background: var(--gh-hover, #f3f4f6); border-color: #9ca3af; color: var(--gh-text, #374151);
}
.conversations-folder-order-btn:disabled {
opacity: 0.3; cursor: default;
}
.conversations-folder-menu {
background: var(--gh-bg, white); border: 1px solid var(--gh-border, #e5e7eb); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000002; padding: 4px;
min-width: 100px;
}
.conversations-folder-menu button {
display: block; width: 100%; padding: 8px 12px; border: none; background: none;
text-align: left; font-size: 13px; color: var(--gh-text, #374151); cursor: pointer; border-radius: 4px;
}
.conversations-folder-menu button:hover { background: var(--gh-hover, #f3f4f6); }
.conversations-empty {
text-align: center; padding: 40px 20px; color: #9ca3af; font-size: 14px;
}
/* 搜索栏样式 */
.conversations-search-bar {
padding: 8px 12px;
border-bottom: 1px solid var(--gh-border, #e5e7eb);
background: var(--gh-bg-secondary, #f9fafb);
}
.conversations-search-wrapper {
display: flex;
align-items: center;
gap: 0;
position: relative; /* For dropdown menu */
}
/* Has Filter State: Remove Tag Btn Radius */
.conversations-search-wrapper.has-filter .conversations-tag-search-btn {
border-radius: 0;
border-right: none;
}
.conversations-search-input-group {
flex: 1;
position: relative;
height: 36px;
min-width: 0;
}
.conversations-search-input {
width: 100%;
height: 100%;
padding: 0 12px; /* Symmetric padding */
border: 1px solid var(--gh-input-border, #d1d5db);
border-radius: 8px 0 0 8px;
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s;
}
.conversations-search-input:focus {
outline: none;
border-color: var(--gh-border-active);
z-index: 1;
position: relative;
}
/* Pin Button */
.conversations-pin-filter-btn {
cursor: pointer; width: 36px; height: 36px; color: #9ca3af; font-size: 14px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--gh-input-border, #d1d5db); border-left: none;
background: var(--gh-bg, white); box-sizing: border-box; transition: all 0.2s;
}
.conversations-pin-filter-btn:hover { background: var(--gh-hover, #f3f4f6); color: var(--gh-text, #374151); }
.conversations-pin-filter-btn.active {
color: var(--gh-border-active); background: var(--gh-folder-bg-default);
box-shadow: inset 0 0 0 1px #818cf8;
}
.conversations-result-bar {
text-align: center;
padding: 6px;
color: #3b82f6;
font-size: 13px;
background: #eff6ff;
display: none;
}
.conversations-result-bar.visible { display: block; }
/* 标签样式 */
.conversations-tag {
display: inline-block; padding: 2px 6px; border-radius: 4px;
font-size: 11px; margin-right: 4px; margin-top: 4px;
color: white; background-color: #9ca3af; line-height: 1.2;
/* Fix 1: Max width and overflow */
max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
}
.conversations-tag-list {
margin-top: 2px; display: flex; flex-wrap: wrap; gap: 4px; border-top: 1px dashed #eee; padding-top: 2px;
}
.conversations-tag-list:empty { display: none; }
/* 标签筛选按钮 */
.conversations-tag-search-btn {
cursor: pointer; width: 36px; height: 36px; color: #9ca3af; font-size: 14px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--gh-input-border, #d1d5db); border-left: none; border-radius: 0; /* Always 0 radius/square */
background: var(--gh-bg, white); box-sizing: border-box; transition: all 0.2s;
}
.conversations-tag-search-btn:hover { background: var(--gh-hover, #f3f4f6); color: var(--gh-text, #374151); }
.conversations-tag-search-btn.active {
color: var(--gh-border-active); background: var(--gh-folder-bg-default);
box-shadow: inset 0 0 0 1px #818cf8; /* Fix 7: Distinct active border/shadow */
}
.conversations-tag-search-btn.empty { opacity: 0.5; }
/* Clear Button - Restyled at far right */
.conversations-search-clear {
cursor: pointer; width: 36px; height: 36px; color: #9ca3af; font-size: 18px;
display: flex; align-items: center; justify-content: center;
border: 1px solid var(--gh-input-border, #d1d5db); border-left: none; border-radius: 0 8px 8px 0;
background: var(--gh-bg, white); box-sizing: border-box; transition: all 0.2s;
user-select: none;
}
.conversations-search-clear:hover { background: #fef2f2; color: #ef4444; }
.conversations-search-clear.disabled {
opacity: 0.3; cursor: default; background: var(--gh-bg-secondary, #f9fafb); pointer-events: none;
}
/* Removed old inner clear button styles */
/* 标签筛选菜单 */
.conversations-tag-filter-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 200px;
background: var(--gh-bg, white);
border: 1px solid var(--gh-border, #e5e7eb);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeIn 0.2s;
}
.conversations-tag-filter-list {
overflow-y: auto; flex: 1; padding: 4px; display: flex; flex-direction: column; gap: 2px;
}
.conversations-tag-filter-footer {
padding: 4px; border-top: 1px solid #eee; background: var(--gh-bg-secondary, #f9fafb); flex-shrink: 0;
}
.conversations-tag-filter-item {
display: flex; align-items: center; gap: 8px; padding: 8px;
cursor: pointer; border-radius: 6px; font-size: 13px; color: var(--gh-text, #374151);
/* Fix overflow */
width: 100%; box-sizing: border-box; overflow: hidden;
}
.conversations-tag-filter-item span:not(.conversations-tag-dot) {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;
}
.conversations-tag-filter-item:hover { background: var(--gh-hover, #f3f4f6); }
.conversations-tag-filter-item.selected { background: var(--gh-folder-bg-default); color: #2563eb; font-weight: 500; }
/* Checkmark for selected */
.conversations-tag-filter-item.selected::after {
content: '✓'; margin-left: auto; font-size: 14px; font-weight: bold;
}
/* Ensure dot doesn't stretch */
.conversations-tag-dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0;
border: 1px solid rgba(0,0,0,0.05); /* Subtle border */
}
.conversations-tag-filter-divider { height: 1px; background: #eee; margin: 4px 0; flex-shrink: 0; }
.conversations-tag-filter-action { color: var(--gh-border-active); font-weight: 500; justify-content: center; }
/* 标签管理弹窗 */
.conversations-tag-manager-list {
max-height: 250px; overflow-y: auto; border: 1px solid var(--gh-border, #e5e7eb); border-radius: 4px; margin-bottom: 12px; padding: 4px;
}
.conversations-tag-manager-item {
display: flex; align-items: center; justify-content: space-between;
padding: 8px; border-bottom: 1px solid var(--gh-hover, #f3f4f6);
}
.conversations-tag-manager-item:last-child { border-bottom: none; }
.conversations-tag-manager-item:hover { background: var(--gh-bg-secondary, #f9fafb); }
.conversations-tag-preview {
padding: 2px 8px; border-radius: 4px; font-size: 12px; color: white;
/* Fix overflow */
max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; display: inline-block;
}
.conversations-tag-actions { display: flex; gap: 4px; flex-shrink: 0; }
.conversations-tag-btn {
width: 24px; height: 24px; border: none; background: transparent; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #9ca3af; border-radius: 4px; transition: all 0.2s;
}
.conversations-tag-btn:hover { background: var(--gh-border, #e5e7eb); color: var(--gh-text, #374151); }
.conversations-tag-btn:hover { background: #fee2e2; color: #ef4444; }
.conversations-tag-btn.edit:hover { background: #e0f2fe; color: #3b82f6; }
/* 颜色选择器 */
.conversations-color-picker {
display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; margin: 12px 0;
}
.conversations-color-item {
width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
border: 2px solid transparent; transition: transform 0.1s;
}
.conversations-color-item:hover { transform: scale(1.1); }
.conversations-color-item.selected { border-color: var(--gh-text, #374151); transform: scale(1.1); box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.conversations-result-bar {
text-align: center;
padding: 6px;
color: var(--gh-border-active);
font-size: 13px;
background: var(--gh-folder-bg-default);
border-radius: 4px;
margin-top: 8px;
display: none;
}
.conversations-result-bar.visible { display: block; }
/* 会话列表样式 */
.conversations-list {
width: 100%; /* 占满父容器宽度 */
padding: 8px 12px;
background: var(--gh-bg-secondary);
border: 2px solid var(--gh-border-active);
border-top: none;
border-radius: 0 0 8px 8px;
margin-top: -4px; /* 与文件夹项视觉连接 */
margin-bottom: 4px;
max-height: 300px;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.conversations-list::-webkit-scrollbar { display: none; } /* Chrome */
.conversations-list-empty {
padding: 12px; color: #9ca3af; font-size: 13px; text-align: center;
}
.conversations-item {
display: flex; align-items: center;
padding: 8px 12px;
margin-bottom: 4px; /* Card Layout: Spacing */
border-radius: 6px; /* Card Layout: Radius */
background: var(--gh-bg, white); /* Card Layout: White Background */
cursor: pointer;
transition: all 0.2s;
gap: 8px;
position: relative;
/* border-bottom removed for card style */
}
.conversations-item:hover {
background: var(--gh-hover, #f3f4f6); /* Slightly darker on hover */
}
/* Rounded Blue Bar on Hover */
.conversations-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 4px;
height: 80%;
background-color: #428cf1;
border-radius: 0 4px 4px 0; /* 右侧圆角,左侧贴边 */
transition: transform 0.2s;
}
.conversations-item:hover::before {
transform: translateY(-50%) scaleY(1);
}
.conversations-item-title {
flex: 1;
min-width: 0;
font-size: 14px; color: var(--gh-text, #374151);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Tags Container */
.conversations-tag-list {
display: flex; gap: 4px; border: none; padding: 0; margin: 0;
flex-shrink: 0;
max-width: 35%;
overflow: hidden;
}
.conversations-item-meta {
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
margin-left: auto;
}
.conversations-item-time {
font-size: 11px; color: #9ca3af; flex-shrink: 0;
}
.conversations-item-menu-btn {
width: 20px; height: 20px; border: none; background: transparent;
color: #9ca3af; cursor: pointer; border-radius: 4px; font-size: 12px;
opacity: 0; transition: opacity 0.2s; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
}
.conversations-item:hover .conversations-item-menu-btn { opacity: 1; }
.conversations-item-menu-btn:hover { background: var(--gh-border, #e5e7eb); color: var(--gh-text, #374151); }
.conversations-item-menu {
background: var(--gh-bg, white); border: 1px solid var(--gh-border, #e5e7eb); border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000002; padding: 4px;
min-width: 120px;
}
.conversations-item-menu button {
display: block; width: 100%; padding: 8px 12px; border: none; background: none;
text-align: left; font-size: 13px; color: var(--gh-text, #374151); cursor: pointer; border-radius: 4px;
}
.conversations-item-menu button:hover { background: var(--gh-hover, #f3f4f6); }
.conversations-item-menu button.danger { color: #dc2626; }
.conversations-item-menu button.danger:hover { background: #fef2f2; }
/* 复选框样式 */
.conversations-folder-checkbox {
margin-right: 8px; width: 16px; height: 16px; cursor: pointer;
accent-color: #4b5563; flex-shrink: 0;
}
.conversations-item-checkbox {
width: 16px; height: 16px; margin-right: 8px; cursor: pointer;
accent-color: #4b5563; flex-shrink: 0;
}
/* 底部批量操作栏 */
.conversations-batch-bar {
position: sticky; bottom: 0; left: 0; right: 0;
background: var(--gh-bg, white);
padding: 8px 12px; display: flex; align-items: center; justify-content: space-between;
border-radius: 8px; margin-top: 8px;
border: 1px solid var(--gh-border, #e5e7eb);
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
}
.conversations-batch-info {
color: var(--gh-text, #374151); font-size: 13px; font-weight: 500;
}
.conversations-batch-btns {
display: flex; gap: 8px;
}
.conversations-batch-btn {
padding: 4px 10px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 6px;
font-size: 12px; cursor: pointer; transition: all 0.2s;
background: var(--gh-hover, #f3f4f6); color: var(--gh-text, #374151);
}
.conversations-batch-btn:hover { background: var(--gh-border, #e5e7eb); border-color: #9ca3af; }
.conversations-batch-btn.danger { background: #fee2e2; color: #dc2626; border-color: #fecaca; }
.conversations-batch-btn.danger:hover { background: #fecaca; border-color: #f87171; }
.conversations-batch-btn.cancel { background: transparent; border: none; color: var(--gh-text-secondary, #6b7280); }
.conversations-batch-btn.cancel:hover { background: var(--gh-hover, #f3f4f6); color: var(--gh-text, #374151); border: none; }
/* 会话对话框样式 */
.conversations-dialog-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 1000003;
display: flex; align-items: center; justify-content: center;
}
.conversations-dialog {
background: var(--gh-bg, white); border-radius: 12px; padding: 20px; min-width: 320px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.conversations-dialog-title {
font-size: 16px; font-weight: 600; color: var(--gh-text, #1f2937); margin-bottom: 16px;
}
.conversations-dialog-message {
font-size: 14px; color: #4b5563; margin-bottom: 20px; line-height: 1.5;
white-space: pre-line;
}
.conversations-dialog-section {
margin-bottom: 16px;
}
.conversations-dialog-section label {
display: block; font-size: 13px; color: var(--gh-text-secondary, #6b7280); margin-bottom: 8px;
}
.conversations-dialog-input {
width: 100%; padding: 10px 12px; border: 1px solid var(--gh-input-border, #d1d5db); border-radius: 8px;
font-size: 14px; box-sizing: border-box;
}
.conversations-dialog-input:focus {
outline: none; border-color: #4285f4; box-shadow: 0 0 0 2px rgba(66,133,244,0.1);
}
.conversations-dialog-buttons {
display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px;
}
.conversations-dialog-btn {
padding: 8px 16px; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all 0.2s;
}
.conversations-dialog-btn.cancel {
border: 1px solid var(--gh-input-border, #d1d5db); background: var(--gh-bg, white); color: var(--gh-text, #374151);
}
.conversations-dialog-btn.cancel:hover { background: var(--gh-hover, #f3f4f6); }
.conversations-dialog-btn.confirm {
border: 1px solid rgba(255,255,255,0.2); background: var(--gh-header-bg); color: white;
}
.conversations-dialog-btn.confirm:hover { opacity: 0.9; }
/* 文件夹选择列表 */
.conversations-folder-select-list {
max-height: 250px; overflow-y: auto; margin: 12px 0;
}
.conversations-folder-select-item {
padding: 12px 16px; border-radius: 8px; cursor: pointer;
transition: all 0.2s; font-size: 14px;
}
.conversations-folder-select-item:hover {
background: var(--gh-hover, #f3f4f6);
}
/* Emoji 选择器 */
.conversations-emoji-picker {
display: flex; flex-wrap: wrap; gap: 4px;
}
.conversations-emoji-btn {
width: 36px; height: 36px; border: 1px solid var(--gh-border, #e5e7eb); border-radius: 8px;
background: var(--gh-bg-secondary, #f9fafb); font-size: 18px; cursor: pointer; transition: all 0.2s;
}
.conversations-emoji-btn:hover { background: var(--gh-hover, #f3f4f6); border-color: var(--gh-input-border, #d1d5db); }
.conversations-emoji-btn.selected {
background: #e0e7ff; border-color: #4285f4; box-shadow: 0 0 0 2px rgba(66,133,244,0.2);
}
/* 分类管理按钮 */
.category-manage-btn {
padding: 4px 8px; background: transparent; border: 1px dashed #9ca3af; border-radius: 12px;
font-size: 12px; color: var(--gh-text-secondary, #6b7280); cursor: pointer; transition: all 0.2s; margin-left: 4px;
}
.category-manage-btn:hover { background: var(--gh-hover, #f3f4f6); border-color: var(--gh-text-secondary, #6b7280); color: var(--gh-text, #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: var(--gh-bg-secondary, #f9fafb); border-radius: 8px; margin-bottom: 8px; transition: all 0.2s;
}
.category-item:hover { background: var(--gh-hover, #f3f4f6); }
.category-item-info { display: flex; align-items: center; gap: 12px; flex: 1; }
.category-item-name { font-weight: 500; color: var(--gh-text, #1f2937); font-size: 14px; }
.category-item-count { font-size: 12px; color: var(--gh-text-secondary, #6b7280); background: var(--gh-border, #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: var(--gh-bg-secondary, #f9fafb); border-bottom: 1px solid var(--gh-border, #e5e7eb);
}
.prompt-panel-tab {
flex: 1; padding: 10px 16px; background: transparent; border: none;
font-size: 13px; font-weight: 500; color: var(--gh-text-secondary, #6b7280); cursor: pointer;
transition: all 0.2s; border-bottom: 2px solid transparent;
}
.prompt-panel-tab:hover { color: var(--gh-text, #374151); background: var(--gh-hover, #f3f4f6); }
.prompt-panel-tab.active {
color: ${colors.primary}; border-bottom-color: ${colors.primary}; background: var(--gh-bg, 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: var(--gh-text-secondary, #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: var(--gh-bg-secondary, #f9fafb); border-radius: 8px; margin-bottom: 8px;
border: 1px solid var(--gh-hover, #f3f4f6); transition: all 0.2s;
}
.setting-item:hover { border-color: linear-gradient(135deg, #4285f4 0%, #34a853 100%); background: var(--gh-bg, 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: var(--gh-text, #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 var(--gh-input-border, #d1d5db); border-radius: 6px; font-size: 13px;
color: var(--gh-text, #374151); background: var(--gh-bg, 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: var(--gh-input-border, #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: var(--gh-bg, 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: var(--gh-bg-secondary, #f9fafb); border-bottom: 1px solid var(--gh-border, #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 var(--gh-input-border, #d1d5db); border-radius: 6px;
background: var(--gh-bg, white); color: var(--gh-text-secondary, #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 var(--gh-input-border, #d1d5db); border-radius: 6px;
font-size: 13px; color: var(--gh-text, #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: var(--gh-input-border, #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: var(--gh-text-secondary, #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: var(--gh-border, #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 var(--gh-bg);
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 var(--gh-bg);
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: var(--gh-input-border, #d1d5db);
cursor: pointer; transition: all 0.2s; position: relative; z-index: 2;
border: 2px solid var(--gh-bg); 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: var(--gh-text, #374151); color: var(--gh-bg, 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: var(--gh-border, #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: var(--gh-text, #374151); transition: all 0.15s;
display: flex; align-items: center; position: relative;
}
.outline-item:hover { background: var(--gh-hover, #f3f4f6); }
.outline-item.highlight { background: var(--gh-folder-bg-expanded); 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: var(--gh-text-secondary, #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);
}
/**
* 启动主题监听器 (Auto Dark Mode)
*/
monitorTheme() {
const panel = document.getElementById('gemini-helper-panel');
if (!panel) return;
const checkTheme = () => {
const dataTheme = document.body.dataset.theme || document.documentElement.dataset.theme;
const isDarkTheme = dataTheme === 'dark';
const bodyClass = document.body.className;
const hasDarkClass = /\bdark\b|\bdark-mode\b|\bdark-theme\b/i.test(bodyClass);
const isDark = isDarkTheme || hasDarkClass;
if (isDark) {
document.body.dataset.ghMode = 'dark';
} else {
delete document.body.dataset.ghMode;
}
};
checkTheme();
if (!this.themeObserver) {
this.themeObserver = new MutationObserver((mutations) => {
// 避免循环触发:如果变动的是 data-gh-mode,则忽略(虽然 attributeFilter 已经排除了,但在某些浏览器/复杂场景下可能需要)
// 由于我们只监听 class 和 data-theme, 修改 data-gh-mode 不会触发此 Observer。
checkTheme();
});
this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme'] });
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
}
}
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',
className: this.isCollapsed ? 'collapsed' : '',
});
// 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')));
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();
showToast(this.t('refreshed'));
} else if (this.currentTab === 'prompts') {
this.refreshPromptList();
showToast(this.t('refreshed'));
} else if (this.currentTab === 'conversations') {
// 只刷新 UI 显示,不执行侧边栏同步
this.conversationManager?.createUI();
showToast(this.t('refreshed'));
} else {
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'),
},
this.isCollapsed ? '+' : '−', // 根据初始状态设置图标
);
// 注意: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);
}
// 设置按钮(固定在header,不占用Tab位置)
const settingsBtn = createElement(
'button',
{
className: 'prompt-panel-btn',
id: 'settings-btn',
title: this.t('tabSettings'),
},
'⚙',
);
settingsBtn.addEventListener('click', () => {
if (this.currentTab === 'settings') {
// 已在设置页,返回上一个 Tab(默认提示词)
this.switchTab(this.previousTab || 'prompts');
} else {
// 记住当前 Tab,进入设置
this.previousTab = this.currentTab;
this.switchTab('settings');
}
});
controls.appendChild(settingsBtn);
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';
}
}
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';
}
// 会话特殊显隐逻辑
if (tabId === 'conversations' && this.settings.conversations?.enabled === false) {
className += ' hidden';
}
// 设置 Tab 不在这里渲染(已移动到 header 按钮)
if (tabId === 'settings') return;
const btn = createElement('button', {
className: className,
'data-tab': tabId,
id: `${tabId}-tab`,
});
// 图标 + 文字
btn.appendChild(createElement('span', { style: 'margin-right: 4px;' }, def.icon));
btn.appendChild(document.createTextNode(this.t(def.labelKey)));
// 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 conversationsContent = createElement('div', {
className: `prompt-panel-content${this.currentTab === 'conversations' ? '' : ' hidden'}`,
id: 'conversations-content',
});
// 初始化会话管理器
this.conversationManager = new ConversationManager({
container: conversationsContent,
settings: this.settings,
siteAdapter: this.siteAdapter,
i18n: (k) => this.t(k),
});
// 4. 设置面板内容区
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(conversationsContent);
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' + (this.isCollapsed ? '' : ' 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', {}, '⚓'));
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);
});
// 更新设置按钮激活状态
const settingsBtn = document.getElementById('settings-btn');
if (settingsBtn) {
settingsBtn.classList.toggle('active', tabName === 'settings');
}
// 切换内容区
document.getElementById('prompts-content')?.classList.toggle('hidden', tabName !== 'prompts');
document.getElementById('outline-content')?.classList.toggle('hidden', tabName !== 'outline');
document.getElementById('conversations-content')?.classList.toggle('hidden', tabName !== 'conversations');
document.getElementById('settings-content')?.classList.toggle('hidden', tabName !== 'settings');
// 通知 OutlineManager 激活状态(用于控制自动更新显隐)
if (this.outlineManager) {
this.outlineManager.setActive(tabName === 'outline');
}
// 通知 ConversationManager 激活状态
if (this.conversationManager) {
this.conversationManager.setActive(tabName === 'conversations');
}
// 更新刷新按钮的提示
const refreshBtn = document.getElementById('refresh-prompts');
if (refreshBtn) {
const titleMap = {
prompts: this.t('refreshPrompts'),
outline: this.t('refreshOutline'),
conversations: this.t('conversationsRefresh'),
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');
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 var(--gh-hover, #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: var(--gh-text, #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);
}
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();
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);
}
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;
// 过滤掉 settings(已移到 header 按钮,不参与排序)
const validOrder = currentOrder.filter((id) => TAB_DEFINITIONS[id] && id !== 'settings');
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();
}
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');
showToast(this.settings.prompts.enabled ? this.t('settingOn') : this.t('settingOff'));
});
controls.appendChild(promptsToggle);
}
// 特殊处理:如果是会话 Tab,在排序按钮旁边添加开关
if (tabId === 'conversations') {
// 确保 conversations 设置对象存在
if (!this.settings.conversations) {
this.settings.conversations = { enabled: true };
}
const conversationsToggle = createElement('div', {
className: 'setting-toggle' + (this.settings.conversations?.enabled !== false ? ' active' : ''),
id: 'toggle-conversations-inline',
style: 'transform: scale(0.8); margin-right: 12px;',
title: this.t('toggleConversations'),
});
conversationsToggle.addEventListener('click', (e) => {
e.stopPropagation();
this.settings.conversations.enabled = !this.settings.conversations.enabled;
conversationsToggle.classList.toggle('active', this.settings.conversations.enabled);
this.saveSettings();
const conversationsTab = document.getElementById('conversations-tab');
if (conversationsTab) conversationsTab.classList.toggle('hidden', !this.settings.conversations.enabled);
if (!this.settings.conversations.enabled && this.currentTab === 'conversations') this.switchTab('settings');
showToast(this.settings.conversations.enabled ? this.t('settingOn') : this.t('settingOff'));
});
controls.appendChild(conversationsToggle);
}
const upBtn = createElement('button', {
className: 'prompt-panel-btn',
style: 'background: var(--gh-hover, #f3f4f6); color: #4b5563; width: 32px; height: 32px; font-size: 16px; margin-right: 4px; border: 1px solid var(--gh-border, #e5e7eb);',
title: this.t('moveUp'),
});
upBtn.textContent = '⬆';
upBtn.disabled = index === 0;
const downBtn = createElement('button', {
className: 'prompt-panel-btn',
style: 'background: var(--gh-hover, #f3f4f6); color: #4b5563; width: 32px; height: 32px; font-size: 16px; border: 1px solid var(--gh-border, #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 = 'var(--gh-hover, #f3f4f6)';
} else {
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
btn.onmouseover = () => {
btn.style.background = 'var(--gh-border, #e5e7eb)';
btn.style.color = '#111827';
};
btn.onmouseout = () => {
btn.style.background = 'var(--gh-hover, #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);
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();
showToast(this.settings.readingHistory.autoRestore ? this.t('settingOn') : this.t('settingOff'));
});
anchorCleanupInput.addEventListener('change', () => {
this.settings.readingHistory.cleanupDays = parseInt(anchorCleanupInput.value);
this.saveSettings();
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);
// showCollapsedAnchor has been moved to Panel Settings
anchorContainer.appendChild(anchorPersistenceItem);
anchorContainer.appendChild(anchorAutoRestoreItem);
anchorContainer.appendChild(anchorCleanupItem);
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();
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 在触发下一次更新时会自动使用新间隔
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 });
// 5.5 面板设置
const panelSettingsContainer = createElement('div', {});
// 5.5.1 默认显示面板开关
const defaultPanelStateItem = createElement('div', { className: 'setting-item' });
const defaultPanelStateInfo = createElement('div', { className: 'setting-item-info' });
defaultPanelStateInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('defaultPanelStateLabel')));
defaultPanelStateInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('defaultPanelStateDesc')));
const defaultPanelStateToggle = createElement('div', {
className: 'setting-toggle' + (this.settings.defaultPanelState ? ' active' : ''),
id: 'toggle-default-panel-state',
});
defaultPanelStateToggle.addEventListener('click', () => {
this.settings.defaultPanelState = !this.settings.defaultPanelState;
defaultPanelStateToggle.classList.toggle('active', this.settings.defaultPanelState);
this.saveSettings();
showToast(this.settings.defaultPanelState ? this.t('settingOn') : this.t('settingOff'));
});
defaultPanelStateItem.appendChild(defaultPanelStateInfo);
defaultPanelStateItem.appendChild(defaultPanelStateToggle);
panelSettingsContainer.appendChild(defaultPanelStateItem);
// 5.5.2 自动隐藏面板开关
const autoHidePanelItem = createElement('div', { className: 'setting-item' });
const autoHidePanelInfo = createElement('div', { className: 'setting-item-info' });
autoHidePanelInfo.appendChild(createElement('div', { className: 'setting-item-label' }, this.t('autoHidePanelLabel')));
autoHidePanelInfo.appendChild(createElement('div', { className: 'setting-item-desc' }, this.t('autoHidePanelDesc')));
const autoHidePanelToggle = createElement('div', {
className: 'setting-toggle' + (this.settings.autoHidePanel ? ' active' : ''),
id: 'toggle-auto-hide-panel',
});
autoHidePanelToggle.addEventListener('click', () => {
this.settings.autoHidePanel = !this.settings.autoHidePanel;
autoHidePanelToggle.classList.toggle('active', this.settings.autoHidePanel);
this.saveSettings();
showToast(this.settings.autoHidePanel ? this.t('settingOn') : this.t('settingOff'));
});
autoHidePanelItem.appendChild(autoHidePanelInfo);
autoHidePanelItem.appendChild(autoHidePanelToggle);
panelSettingsContainer.appendChild(autoHidePanelItem);
// 5.5.3 折叠面板显示锚点
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';
}
showToast(this.settings.showCollapsedAnchor ? this.t('settingOn') : this.t('settingOff'));
});
showAnchorItem.appendChild(showAnchorInfo);
showAnchorItem.appendChild(showAnchorToggle);
panelSettingsContainer.appendChild(showAnchorItem);
const panelSettingsSection = this.createCollapsibleSection(this.t('panelSettingsTitle'), panelSettingsContainer, { 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');
}
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);
}
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();
}
}
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);
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();
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();
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();
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();
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 });
// 7.5. 面板可见性设置 (添加到通用设置/其他设置中,这里选择添加到"界面排版"更合适,或者单独的通用设置区域)
// 根据用户描述"在通用设置里",我们找一个合适的位置。
// 之前的 otherSettingsSection 标题是 "其他设置",我们可以把 面板可见性 加到这里,或者 layoutSection "界面排版"
// 考虑到这是界面行为,放在 layoutSection 或者一个新的 "通用设置" 区域比较好。
// 但现有的 layoutSection 是 Tab 顺序。
// 让我们把它加到 otherSettingsSection (其他设置) 作为一个子项,或者在 createSettingsContent 开头创建一个新的 General Section。
// 鉴于 otherSettingsSection 包含 "折叠面板显示锚点" 等,放在这里比较合适。
// 1. 通用设置(语言)- 已在上方添加
// 2. 面板设置 (New)
content.appendChild(panelSettingsSection);
// 3. 标签页设置
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) => showToast(msg));
const onRestorationComplete = () => {
// 延迟一点开启记录,避开惯性滚动等干扰,确保后续的用户滚动能被正确记录
setTimeout(() => {
this.readingProgressManager.startRecording();
}, 500);
};
if (success) {
// 恢复成功,获取恢复的位置设为“初始锚点”
const restoredTop = this.readingProgressManager.restoredTop;
if (restoredTop !== undefined) {
this.anchorManager.setAnchor(restoredTop);
}
showToast(this.t('restoredPosition'));
}
// 无论成功失败,最后都开启记录
onRestorationComplete();
}
// 清理过期阅读历史
cleanupReadingHistory() {
this.readingProgressManager.cleanup();
}
// 锚点按钮点击 (Back functionality)
handleAnchorClick() {
if (this.anchorManager.hasAnchor()) {
this.anchorManager.backToAnchor();
showToast(this.t('jumpToAnchor'));
} else {
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();
showToast(`分类已重命名为"${newName}"`);
}
// 删除分类(将关联提示词移至"未分类")
deleteCategory(name) {
this.prompts.forEach((p) => {
if (p.category === name) {
p.category = '未分类';
}
});
this.savePrompts();
this.refreshCategories();
this.refreshPromptList();
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();
showToast(this.t('orderUpdated'));
}
selectPrompt(prompt, itemElement) {
if (this.isScrolling) {
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);
showToast(`${this.t('inserted')}: ${prompt.title}`);
}
insertPromptToTextarea(promptContent) {
if (this.isScrolling) {
showToast('页面正在滚动,请稍后再选择提示词');
return;
}
const promiseOrResult = this.siteAdapter.insertPrompt(promptContent);
// 处理异步返回 (Gemini Business 是异步的)
if (promiseOrResult instanceof Promise) {
promiseOrResult.then((success) => {
if (!success) {
showToast('未找到输入框,请点击输入框后重试');
// 再次尝试查找
this.siteAdapter.findTextarea();
}
});
} else if (!promiseOrResult) {
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 });
showToast(this.t('promptUpdated'));
} else {
this.addPrompt({ title, category: categoryInput.value.trim(), content });
showToast(this.t('promptAdded'));
}
modal.remove();
});
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
}
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);
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(() => {
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);
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();
}
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', (e) => {
e.stopPropagation(); // 阻止冒泡,避免触发 auto-hide
this.togglePanel();
});
// 4. 全局点击监听(处理自动隐藏)
document.addEventListener('click', (e) => {
// 如果是自动隐藏开启,且面板是展开的
if (this.settings.autoHidePanel && !this.isCollapsed) {
const panel = document.getElementById('gemini-helper-panel');
const toggleBtn = document.getElementById('toggle-panel');
const quickBtnGroup = document.getElementById('quick-btn-group');
// 检查点击目标是否在面板外部
// 注意:需要排除 toggleBtn 和 quickBtnGroup,以及 panel 本身
// 同时排除面板内的任何元素
if (panel && !panel.contains(e.target) && !toggleBtn?.contains(e.target) && !quickBtnGroup?.contains(e.target)) {
// 额外的安全检查:确保不是点击了面板内的弹出层(如 modal)
// 通常 modal 是直接挂在 body 上的,所以如果 modal 打开时,点击 modal 内容不应该隐藏
// 但是 modal 通常覆盖全屏,点击 modal 遮罩通过 modal 自己的逻辑关闭。
// 这里主要关注点击页面其他部分。
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)
// 同时用于检测 Sidebar Observer 的存活状态
setInterval(() => {
checkUrl();
// 周期性检查 Observer 是否存活 (Zombie Check)
if (this.conversationManager) {
this.conversationManager.checkObserverStatus();
}
}, 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();
}
})();