// ==UserScript== // @name Voyager (Userscript Edition, merged) // @namespace https://github.com/haliChina/Voyager-Userscript // @version 1.6.0-us3 // @description Make Your AI Chatbot Experience Truly Yours - Gemini / AI Studio / Claude / ChatGPT / Grok enhancement suite. Merged userscript port aligned with Nagi-ovo/voyager 1.6.0 and Tampermonkey practical fallbacks. // @author Nagi-ovo (original) / Userscript port / merged by Minis // @license GPL-3.0 // @homepageURL https://github.com/haliChina/Voyager-Userscript // @supportURL https://github.com/haliChina/Voyager-Userscript/issues // @match https://gemini.google.com/* // @match https://business.gemini.google/* // @match https://aistudio.google.com/* // @match https://aistudio.google.cn/* // @match https://claude.ai/* // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @match https://x.com/i/grok/* // @match https://grok.com/* // @updateURL https://raw.githubusercontent.com/haliChina/Voyager-Userscript/main/Voyager.user.js // @downloadURL https://raw.githubusercontent.com/haliChina/Voyager-Userscript/main/Voyager.user.js // @require https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js // @require https://cdn.jsdelivr.net/npm/temml@0.10.32/dist/temml.min.js // @resource katexCSS https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_registerMenuCommand // @grant GM_notification // @grant GM_setClipboard // @grant GM_download // @grant unsafeWindow // @run-at document-start // @connect * // @inject-into page // @noframes // ==/UserScript== /* eslint-disable */ /* * Voyager Userscript Edition * 全量迁移自 https://github.com/Nagi-ovo/voyager (GPL-3.0) * * 由于油猴脚本运行环境与浏览器扩展不同: * - chrome.storage.* → GM_getValue/GM_setValue * - chrome.runtime.getURL 资源 → 内联/CSS 变量 * - background service worker → 内联逻辑 * - extension popup → 浮动设置面板(右上角齿轮) * - Google Drive OAuth 同步 → 导入/导出 JSON 备份 */ (function (unsafeWindow) { 'use strict'; // 防止重复注入 if (unsafeWindow.__voyagerUserscriptLoaded) return; unsafeWindow.__voyagerUserscriptLoaded = true; const GV = {}; const document = unsafeWindow.document; const location = unsafeWindow.location; const navigator = unsafeWindow.navigator; /* ========================================================================= * SECTION 1: 常量与站点识别 * ========================================================================= */ const HOST = location.hostname.toLowerCase(); const PATH = location.pathname; const Site = { isGemini: HOST === 'gemini.google.com' || HOST === 'business.gemini.google', isAIStudio: HOST === 'aistudio.google.com' || HOST === 'aistudio.google.cn', isClaude: HOST === 'claude.ai', isChatGPT: HOST === 'chatgpt.com' || HOST === 'chat.openai.com', isGrok: HOST === 'grok.com' || (HOST === 'x.com' && PATH.startsWith('/i/grok')), platformId: (HOST === 'claude.ai') ? 'claude' : (HOST === 'chatgpt.com' || HOST === 'chat.openai.com') ? 'chatgpt' : (HOST === 'grok.com' || HOST === 'x.com') ? 'grok' : (HOST === 'gemini.google.com' || HOST === 'business.gemini.google') ? 'gemini' : (HOST === 'aistudio.google.com' || HOST === 'aistudio.google.cn') ? 'aistudio' : null, }; // 存储键(与原扩展保持兼容) const SK = { FOLDER_DATA: 'gvFolderData', FOLDER_DATA_AISTUDIO: 'gvFolderDataAIStudio', FOLDER_ENABLED: 'geminiFolderEnabled', FOLDER_HIDE_ARCHIVED: 'geminiFolderHideArchivedConversations', FOLDER_SEARCH_ENABLED: 'gvFolderSearchEnabled', FOLDER_SORT_MODE: 'gvFolderConversationSortMode', FOLDER_CONVERSATION_SORT_MODE: 'gvFolderConversationSortMode', FOLDER_FLOATING_MODE: 'geminiFolderFloatingModeEnabled', FOLDER_FLOATING_OPEN_ON_START: 'geminiFolderFloatingOpenOnStart', FOLDER_FLOATING_POS: 'geminiFolderFloatingPos', FOLDER_FLOATING_SIZE: 'geminiFolderFloatingSize', FOLDER_FLOATING_FAB_POS: 'geminiFolderFloatingFabPos', FOLDERS_COLLAPSED: 'gvFoldersCollapsed', FOLDERS_HIDDEN: 'gvFoldersHidden', GEMS_HIDDEN: 'gvGemsHidden', PROMPT_ITEMS: 'gvPromptItems', PROMPT_PANEL_LOCKED: 'gvPromptPanelLocked', PROMPT_PANEL_POS: 'gvPromptPanelPosition', PROMPT_TRIGGER_POS: 'gvPromptTriggerPosition', PROMPT_SELECTED_TAGS: 'gvPromptSelectedTags', PROMPT_CUSTOM_WEBSITES: 'gvPromptCustomWebsites', PROMPT_THEME: 'gvPromptTheme', PROMPT_INSERT_ON_CLICK: 'gvPromptInsertOnClick', PROMPT_VIEW_MODE: 'gvPromptViewMode', PROMPT_PANEL_VIEW: 'gvPromptPanelView', HIDE_PROMPT_MANAGER: 'gvHidePromptManager', TIMELINE_SCROLL_MODE: 'geminiTimelineScrollMode', TIMELINE_STYLE: 'geminiTimelineStyle', TIMELINE_HIDE: 'geminiTimelineHideContainer', TIMELINE_BAR_WIDTH: 'geminiTimelineBarWidth', TIMELINE_DRAGGABLE: 'geminiTimelineDraggable', TIMELINE_POSITION: 'geminiTimelinePosition', TIMELINE_STARRED: 'geminiTimelineStarredMessages', TIMELINE_STARRED_MESSAGES: 'geminiTimelineStarredMessages', TIMELINE_HIERARCHY: 'geminiTimelineHierarchy', TIMELINE_MARKER_LEVEL: 'geminiTimelineMarkerLevel', EXPORT_IMAGE_WIDTH: 'gvExportImageWidth', PERSISTENT_EXPORT_TOOLBAR: 'gvPersistentExportToolbarEnabled', CHAT_WIDTH: 'geminiChatWidth', CHAT_WIDTH_ENABLED: 'gvChatWidthEnabled', CHAT_FONT_SIZE: 'gvChatFontSize', CHAT_FONT_SIZE_ENABLED: 'gvChatFontSizeEnabled', CHAT_LINE_HEIGHT: 'gvChatLineHeight', CHAT_LINE_HEIGHT_ENABLED: 'gvChatLineHeightEnabled', CHAT_PARA_SPACING: 'gvChatParagraphSpacing', CHAT_PARA_SPACING_ENABLED: 'gvChatParagraphSpacingEnabled', SIDEBAR_WIDTH: 'geminiSidebarWidth', SIDEBAR_WIDTH_ENABLED: 'gvSidebarWidthEnabled', SIDEBAR_AUTOHIDE: 'gvSidebarAutoHide', SIDEBAR_AUTO_HIDE: 'gvSidebarAutoHide', SIDEBAR_FULL_HIDE: 'gvSidebarFullHide', INPUT_COLLAPSE: 'gvInputCollapseEnabled', INPUT_COLLAPSE_NOT_EMPTY: 'gvInputCollapseWhenNotEmpty', PREVENT_AUTO_SCROLL: 'gvPreventAutoScrollEnabled', CTRL_ENTER_SEND: 'gvCtrlEnterSend', QUOTE_REPLY_ENABLED: 'gvQuoteReplyEnabled', HIGHLIGHT_ENABLED: 'gvHighlightEnabled', HIGHLIGHT_DEFAULT_COLOR: 'gvHighlightDefaultColor', DEFAULT_MODEL: 'gvDefaultModel', DEFAULT_THINKING_LEVEL: 'gvDefaultThinkingLevel', DEFAULT_MODEL_AUTO_APPLY: 'gvDefaultModelAutoApply', VISUAL_EFFECT: 'gvVisualEffect', WATERMARK_ENABLED: 'geminiWatermarkRemoverEnabled', WATERMARK_DOWNLOAD_ENABLED: 'gvWatermarkDownloadEnabled', WATERMARK_PREVIEW_ENABLED: 'gvWatermarkPreviewEnabled', MERMAID_ENABLED: 'gvMermaidEnabled', MARKDOWN_PATCH_ENABLED: 'gvMarkdownPatchEnabled', FORMULA_COPY_ENABLED: 'gvFormulaCopyEnabled', FORMULA_COPY_FORMAT: 'gvFormulaCopyFormat', DEEP_RESEARCH_ENABLED: 'gvDeepResearchEnabled', CODEBLOCK_COLLAPSE: 'gvCodeBlockCollapseEnabled', CODE_BLOCK_COLLAPSE_ENABLED: 'gvCodeBlockCollapseEnabled', SHOW_TIMESTAMPS: 'gvShowMessageTimestamps', CLAUDE_TIMELINE_ENABLED: 'gvClaudeTimelineEnabled', CLAUDE_USAGE_ENABLED: 'gvClaudeUsageEnabled', CLAUDE_USAGE_POS: 'gvClaudeUsagePos', CLAUDE_USAGE_CACHE: 'gvClaudeUsageCache', USAGE_STATUS_ENABLED: 'gvUsageStatusEnabled', GV_USAGE_CACHE: 'gvUsageCache', GV_USAGE_POS: 'gvUsagePos', READING_WIDTH_CLAUDE: 'gvReadingWidthClaude', READING_WIDTH_CLAUDE_ENABLED: 'gvReadingWidthClaudeEnabled', READING_WIDTH_CHATGPT: 'gvReadingWidthChatGPT', READING_WIDTH_CHATGPT_ENABLED: 'gvReadingWidthChatGPTEnabled', CENTER_CONV_CHATGPT: 'gvCenterConversationChatGPT', LANGUAGE: 'language', THEME: 'gvTheme', ACCENT_COLOR: 'gvAccentColors', ACCENT_COLORS: 'gvAccentColors', }; const STORE_ALIASES = { geminiSidebarWidth: ['gvSidebarWidth'], geminiWatermarkRemoverEnabled: ['gvWatermarkRemoverEnabled'], gvWatermarkDownloadEnabled: ['gvWatermarkRemoverEnabled'], gvWatermarkPreviewEnabled: ['gvWatermarkRemoverEnabled'], gvCodeBlockCollapseEnabled: ['gvCodeblockCollapseEnabled'], }; /* ========================================================================= * SECTION 2: 存储层(GM_* 封装,带 localStorage 回退与跨实例同步) * ========================================================================= */ const Store = { _cache: new Map(), _listeners: new Map(), get(key, defaultValue) { try { if (this._cache.has(key)) return this._cache.get(key); let val; try { val = GM_getValue(key, undefined); } catch (e) { val = undefined; } if (val === undefined || val === null) { const aliases = STORE_ALIASES[key] || []; for (const alias of aliases) { try { val = GM_getValue(alias, undefined); } catch (e) { val = undefined; } if (val !== undefined && val !== null) break; const aliasLs = localStorage.getItem(alias); if (aliasLs !== null) { try { val = JSON.parse(aliasLs); } catch (e2) { val = aliasLs; } break; } } } if (val === undefined || val === null) { // 回退 localStorage const ls = localStorage.getItem(key); if (ls !== null) { try { val = JSON.parse(ls); } catch (e2) { val = ls; } } } if (val === undefined || val === null) val = defaultValue; this._cache.set(key, val); return val; } catch (e) { return defaultValue; } }, set(key, value) { this._cache.set(key, value); try { GM_setValue(key, value); } catch (e) {} try { localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); } catch (e) {} this._notify(key, value); // 跨标签页广播 try { localStorage.setItem('__gv_storage_event__', JSON.stringify({ key, value, t: Date.now() })); } catch (e) {} }, remove(key) { this._cache.delete(key); try { GM_deleteValue(key); } catch (e) {} try { localStorage.removeItem(key); } catch (e) {} this._notify(key, undefined); }, onChange(key, cb) { if (!this._listeners.has(key)) this._listeners.set(key, new Set()); this._listeners.get(key).add(cb); return () => this._listeners.get(key)?.delete(cb); }, _notify(key, value) { const cbs = this._listeners.get(key); if (cbs) cbs.forEach(cb => { try { cb(value); } catch (e) {} }); }, }; // 监听跨标签页 storage 事件 unsafeWindow.addEventListener('storage', (e) => { if (e.key === '__gv_storage_event__' && e.newValue) { try { const { key, value } = JSON.parse(e.newValue); Store._cache.set(key, value); Store._notify(key, value); } catch (err) {} } else if (e.key && e.key.indexOf('gv') === 0) { let val; try { val = e.newValue ? JSON.parse(e.newValue) : null; } catch (err) { val = e.newValue; } Store._cache.set(e.key, val); Store._notify(e.key, val); } }); /* ========================================================================= * SECTION 3: i18n 国际化(中英双语) * ========================================================================= */ const i18nDict = { zh: { settings: 'Voyager 设置', general: '通用', gemini: 'Gemini', claude: 'Claude', chatgpt: 'ChatGPT', enabled: '启用', disabled: '已禁用', save: '保存', cancel: '取消', close: '关闭', add: '添加', delete: '删除', edit: '编辑', search: '搜索', import: '导入', export: '导出', rename: '重命名', confirm: '确认', yes: '是', no: '否', on: '开', off: '关', folders: '文件夹管理', promptVault: '提示词库', timeline: '时间线导航', chatExport: '对话导出', mermaid: 'Mermaid 渲染', formulaCopy: '公式复制', markdownFix: 'Markdown 修复', watermark: '图片去水印', visualEffects: '视觉特效', quoteReply: '引用回复', preventScroll: '阻止自动滚动', inputCollapse: '输入框折叠', defaultModel: '默认模型', gemsHider: '隐藏最近项', chatWidth: '聊天宽度', deepResearch: '深度研究', claudeTimeline: 'Claude 时间线', claudeUsage: 'Claude 使用量', cjkFix: 'CJK 渲染修复', readingWidth: '阅读宽度', language: '语言', theme: '主题', light: '浅色', dark: '深色', auto: '自动', effectOff: '关闭', snow: '雪', rain: '雨', sakura: '樱花', foldersOrg: '将对话整理到两级文件夹', promptsVault: '保存并复用提示词', timelineNav: '可视化消息节点导航', exportChat: '导出对话为 JSON/Markdown', renderMermaid: '自动渲染流程图', copyFormula: '一键复制 LaTeX/MathML', fixBold: '修复 Gemini 破坏的加粗语法', removeWatermark: 'Gemini 图片无损去水印', effects: '雪/雨/樱花 全屏特效', quoteSelected: '选中文本引用回复', blockJump: '拦截回车发送时的页面跳跃', collapseInput: '输入区自动折叠展开', setDefault: '设置默认 AI 模型', hideRecent: '隐藏侧边栏 Recent 列表', adjustWidth: '调整聊天内容宽度', extractResearch: '提取深度研究思考过程与链接', claudeTimelineNav: 'Claude 对话侧边栏导航', claudeUsageBar: 'Claude 会话/周使用量追踪', fixCjk: '修复 Claude 中日韩字符渲染', comfyRead: '调整 Claude/ChatGPT 阅读宽度', promptPlaceholder: '搜索提示词...', addPrompt: '新建提示词', promptName: '名称', promptText: '内容', promptTags: '标签(逗号分隔)', noPrompts: '暂无提示词', clickToInsert: '点击插入', clickToCopy: '点击复制', newFolder: '新建文件夹', folderName: '文件夹名称', moveToFolder: '移动到文件夹', removeFromFolder: '移出文件夹', starred: '已星标', unstar: '取消星标', exportJson: '导出 JSON', exportMd: '导出 Markdown', exportedAt: '导出时间', turns: '轮次', user: '用户', assistant: '助手', copySuccess: '已复制到剪贴板', copyFailed: '复制失败', downloading: '下载中...', processing: '处理中...', done: '完成', error: '错误', backupExport: '导出全部数据', backupImport: '导入数据', backupImported: '数据导入成功', backupImportFailed: '导入失败', welcome: 'Voyager 已加载 — 点击右上角齿轮进行设置', }, en: { settings: 'Voyager Settings', general: 'General', gemini: 'Gemini', claude: 'Claude', chatgpt: 'ChatGPT', enabled: 'Enabled', disabled: 'Disabled', save: 'Save', cancel: 'Cancel', close: 'Close', add: 'Add', delete: 'Delete', edit: 'Edit', search: 'Search', import: 'Import', export: 'Export', rename: 'Rename', confirm: 'Confirm', yes: 'Yes', no: 'No', on: 'On', off: 'Off', folders: 'Folder Organization', promptVault: 'Prompt Vault', timeline: 'Timeline Navigation', chatExport: 'Chat Export', mermaid: 'Mermaid Rendering', formulaCopy: 'Formula Copy', markdownFix: 'Markdown Fix', watermark: 'Watermark Remover', visualEffects: 'Visual Effects', quoteReply: 'Quote Reply', preventScroll: 'Prevent Auto Scroll', inputCollapse: 'Input Collapse', defaultModel: 'Default Model', gemsHider: 'Hide Recent Items', chatWidth: 'Chat Width', deepResearch: 'Deep Research', claudeTimeline: 'Claude Timeline', claudeUsage: 'Claude Usage', cjkFix: 'CJK Render Fix', readingWidth: 'Reading Width', language: 'Language', theme: 'Theme', light: 'Light', dark: 'Dark', auto: 'Auto', effectOff: 'Off', snow: 'Snow', rain: 'Rain', sakura: 'Sakura', foldersOrg: 'Organize chats into two-level folders', promptsVault: 'Save and reuse prompts', timelineNav: 'Visual message node navigation', exportChat: 'Export conversations to JSON/Markdown', renderMermaid: 'Auto-render flowcharts', copyFormula: 'One-click copy LaTeX/MathML', fixBold: 'Fix Gemini broken bold syntax', removeWatermark: 'Lossless Gemini image watermark removal', effects: 'Snow/Rain/Sakura fullscreen effects', quoteSelected: 'Quote selected text in reply', blockJump: 'Block page jump on Enter send', collapseInput: 'Auto collapse/expand input area', setDefault: 'Set default AI model', hideRecent: 'Hide sidebar Recent list', adjustWidth: 'Adjust chat content width', extractResearch: 'Extract Deep Research thinking & links', claudeTimelineNav: 'Claude conversation sidebar navigation', claudeUsageBar: 'Claude session/weekly usage tracking', fixCjk: 'Fix Claude CJK character rendering', comfyRead: 'Adjust Claude/ChatGPT reading width', promptPlaceholder: 'Search prompts...', addPrompt: 'New prompt', promptName: 'Name', promptText: 'Content', promptTags: 'Tags (comma separated)', noPrompts: 'No prompts yet', clickToInsert: 'Click to insert', clickToCopy: 'Click to copy', newFolder: 'New folder', folderName: 'Folder name', moveToFolder: 'Move to folder', removeFromFolder: 'Remove from folder', starred: 'Starred', unstar: 'Unstar', exportJson: 'Export JSON', exportMd: 'Export Markdown', exportedAt: 'Exported at', turns: 'Turns', user: 'User', assistant: 'Assistant', copySuccess: 'Copied to clipboard', copyFailed: 'Copy failed', downloading: 'Downloading...', processing: 'Processing...', done: 'Done', error: 'Error', backupExport: 'Export all data', backupImport: 'Import data', backupImported: 'Data imported successfully', backupImportFailed: 'Import failed', welcome: 'Voyager loaded — click the gear icon to configure', }, }; function getLang() { let lang = Store.get(SK.LANGUAGE, null); if (!lang) { lang = (navigator.language || 'en').startsWith('zh') ? 'zh' : 'en'; } return lang; } function t(key) { const lang = getLang(); return (i18nDict[lang] && i18nDict[lang][key]) || i18nDict.en[key] || key; } /* ========================================================================= * SECTION 4: 通用工具函数 * ========================================================================= */ const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); const el = (tag, props = {}, children = []) => { const node = document.createElement(tag); Object.entries(props).forEach(([k, v]) => { if (k === 'class') node.className = v; else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v); else if (k === 'style') node.style.cssText = v; else if (k === 'dataset') Object.entries(v).forEach(([dk, dv]) => node.dataset[dk] = dv); else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v); else if (k === 'html') node.innerHTML = v; else if (v !== null && v !== undefined && v !== false) node.setAttribute(k, v); }); (Array.isArray(children) ? children : [children]).forEach(c => { if (c == null) return; node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); }); return node; }; const uid = () => 'gv_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const hashStr = (str) => { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0; return h.toString(36); }; const fnvHash = (str) => { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); }; const debounce = (fn, ms) => { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), ms); }; }; const waitFor = (sel, timeout = 10000, root = document) => new Promise((resolve) => { const found = $(sel, root); if (found) return resolve(found); const obs = new MutationObserver(() => { const f = $(sel, root); if (f) { obs.disconnect(); resolve(f); } }); obs.observe(root, { childList: true, subtree: true }); setTimeout(() => { obs.disconnect(); resolve(null); }, timeout); }); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const isDark = () => { return $('.theme-host.dark-theme') || document.body?.classList?.contains('dark-theme') || document.documentElement.classList.contains('dark') || document.body?.dataset?.theme === 'dark' || matchMedia('(prefers-color-scheme: dark)').matches; }; // 路由监听(SPA) function watchRoute(cb) { let lastUrl = location.href; const check = () => { if (location.href !== lastUrl) { lastUrl = location.href; cb(location.href); } }; unsafeWindow.addEventListener('popstate', check); unsafeWindow.addEventListener('hashchange', check); const wrap = (fn) => function (...args) { const r = fn.apply(this, args); check(); return r; }; const origPush = history.pushState, origReplace = history.replaceState; history.pushState = wrap(origPush); history.replaceState = wrap(origReplace); setInterval(check, 500); return () => { history.pushState = origPush; history.replaceState = origReplace; }; } function observeMutations(target, cb, opts = { childList: true, subtree: true }, debounceMs = 100) { const handler = debounceMs > 0 ? debounce(cb, debounceMs) : cb; const obs = new MutationObserver(handler); obs.observe(target, opts); return obs; } async function copyTextCompat(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch (e) {} try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return true; } } catch (e) {} const ta = el('textarea', { style: { position: 'fixed', left: '-9999px', top: '0', opacity: '0' } }, [text]); document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); return true; } function downloadBlobCompat(blob, filename) { const url = URL.createObjectURL(blob); try { if (typeof GM_download === 'function') { GM_download({ url, name: filename, saveAs: false, onload: () => URL.revokeObjectURL(url), onerror: () => URL.revokeObjectURL(url) }); return; } } catch (e) {} const a = el('a', { href: url, download: filename }); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function watermarkPreviewEnabled() { return Store.get(SK.WATERMARK_PREVIEW_ENABLED, Store.get(SK.WATERMARK_ENABLED, true)); } function watermarkDownloadEnabled() { return Store.get(SK.WATERMARK_DOWNLOAD_ENABLED, Store.get(SK.WATERMARK_ENABLED, true)); } /* ========================================================================= * SECTION 5: Toast 通知系统 * ========================================================================= */ let toastContainer = null; function ensureToastContainer() { if (toastContainer && document.body.contains(toastContainer)) return toastContainer; toastContainer = el('div', { id: 'gv-toast-container', style: { position: 'fixed', top: '16px', right: '16px', zIndex: '2147483646', display: 'flex', flexDirection: 'column', gap: '8px', pointerEvents: 'none', } }); (document.body || document.documentElement).appendChild(toastContainer); return toastContainer; } function toast(message, type = 'info', duration = 2500) { if (!document.body) { setTimeout(() => toast(message, type, duration), 100); return; } const colors = { info: { bg: '#1a73e8', fg: '#fff' }, success: { bg: '#1e8e3e', fg: '#fff' }, error: { bg: '#d93025', fg: '#fff' }, warning: { bg: '#f9ab00', fg: '#202124' }, }; const c = colors[type] || colors.info; const node = el('div', { class: 'gv-toast', style: { background: c.bg, color: c.fg, padding: '10px 16px', borderRadius: '8px', fontSize: '14px', fontFamily: 'inherit', boxShadow: '0 4px 12px rgba(0,0,0,0.2)', maxWidth: '340px', wordBreak: 'break-word', pointerEvents: 'auto', opacity: '0', transform: 'translateX(20px)', transition: 'all 0.25s ease', } }, [message]); ensureToastContainer().appendChild(node); requestAnimationFrame(() => { node.style.opacity = '1'; node.style.transform = 'translateX(0)'; }); setTimeout(() => { node.style.opacity = '0'; node.style.transform = 'translateX(20px)'; setTimeout(() => node.remove(), 300); }, duration); } GM_registerMenuCommand(t('settings'), () => SettingsPanel.open()); GV.Store = Store; GV.SK = SK; GV.Site = Site; GV.$ = $; GV.$$ = $$; GV.el = el; GV.uid = uid; GV.hashStr = hashStr; GV.fnvHash = fnvHash; GV.debounce = debounce; GV.waitFor = waitFor; GV.sleep = sleep; GV.isDark = isDark; GV.watchRoute = watchRoute; GV.observeMutations = observeMutations; GV.toast = toast; GV.t = t; GV.getLang = getLang; GV.unsafeWindow = unsafeWindow; /* ========================================================================= * SECTION 6: 全局样式与 KaTeX CSS * ========================================================================= */ function injectGlobalStyles() { // KaTeX 样式 try { const katexCSS = GM_getResourceText('katexCSS'); if (katexCSS) { const style = el('style', { id: 'gv-katex-css' }, [katexCSS]); (document.head || document.documentElement).appendChild(style); } } catch (e) {} // 使用 link 标签加载 KaTeX CSS 作为兜底 if (!$('#gv-katex-link')) { const link = el('link', { id: 'gv-katex-link', rel: 'stylesheet', href: 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css' }); (document.head || document.documentElement).appendChild(link); } GM_addStyle(` .gv-root { --gv-brand: #1a73e8; --gv-brand-fg: #fff; --gv-bg: #fff; --gv-fg: #1f1f1f; --gv-border: #e0e0e0; --gv-muted: #5f6368; } .gv-root.gv-dark { --gv-bg: #1e1e1e; --gv-fg: #e8eaed; --gv-border: #3c4043; --gv-muted: #9aa0a6; } #gv-settings-fab { position: fixed; top: 16px; right: 16px; z-index: 2147483645; width: 44px; height: 44px; border-radius: 50%; border: none; cursor: pointer; background: var(--gv-brand,#1a73e8); color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; transition: transform .2s, opacity .2s; opacity: .55; } #gv-settings-fab:hover { opacity: 1; transform: scale(1.08); } #gv-settings-overlay { position: fixed; inset: 0; z-index: 2147483646; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; font-family: 'Google Sans',Roboto,system-ui,sans-serif; } #gv-settings-panel { background: #fff; color: #1f1f1f; width: 560px; max-width: 94vw; max-height: 86vh; border-radius: 16px; box-shadow: 0 8px 40px rgba(0,0,0,.3); display: flex; flex-direction: column; overflow: hidden; } #gv-settings-panel.gv-dark { background: #1e1e1e; color: #e8eaed; } .gv-sp-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #e0e0e0; } .gv-dark .gv-sp-header { border-color: #3c4043; } .gv-sp-title { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; } .gv-sp-close { background: none; border: none; cursor: pointer; font-size: 22px; color: #5f6368; padding: 4px 8px; border-radius: 8px; } .gv-sp-close:hover { background: rgba(0,0,0,.06); } .gv-dark .gv-sp-close:hover { background: rgba(255,255,255,.08); } .gv-sp-tabs { display: flex; align-items: center; gap: 4px; padding: 6px 16px 0; border-bottom: 1px solid #e0e0e0; overflow-x: auto; overflow-y: hidden; min-height: 44px; } .gv-dark .gv-sp-tabs { border-color: #3c4043; } .gv-sp-tab { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; min-height: 38px; padding: 8px 14px 10px; border: none; background: transparent; cursor: pointer; border-radius: 0; font-size: 14px; line-height: 1.2; color: #5f6368; white-space: nowrap; } .gv-sp-tab.active { background: transparent !important; color: var(--gv-brand,#1a73e8); font-weight: 600; box-shadow: none !important; } .gv-sp-tab.active::after { content: ''; position: absolute; left: 12px; right: 12px; bottom: 0; height: 3px; border-radius: 999px 999px 0 0; background: var(--gv-brand,#1a73e8); pointer-events: none; } .gv-sp-body { overflow-y: auto; padding: 12px 22px 22px; } .gv-sp-section { display: none; } .gv-sp-section.active { display: block; } .gv-feature-row { display: flex; align-items: center; justify-content: space-between; padding: 11px 0; border-bottom: 1px solid rgba(0,0,0,.06); gap: 12px; } .gv-dark .gv-feature-row { border-color: rgba(255,255,255,.06); } .gv-feature-info { flex: 1; min-width: 0; } .gv-feature-name { font-size: 14px; font-weight: 500; } .gv-feature-desc { font-size: 12px; color: #5f6368; margin-top: 2px; line-height: 1.4; } .gv-dark .gv-feature-desc { color: #9aa0a6; } .gv-switch { position: relative; width: 40px; height: 22px; flex-shrink: 0; } .gv-switch input { opacity: 0; width: 0; height: 0; } .gv-switch-slider { position: absolute; inset: 0; background: #bbb; border-radius: 22px; cursor: pointer; transition: .25s; } .gv-switch-slider::before { content: ''; position: absolute; height: 16px; width: 16px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .25s; } .gv-switch input:checked + .gv-switch-slider { background: var(--gv-brand,#1a73e8); } .gv-switch input:checked + .gv-switch-slider::before { transform: translateX(18px); } .gv-sp-control { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .gv-sp-select, .gv-sp-range { font-size: 13px; padding: 5px 8px; border-radius: 6px; border: 1px solid #ddd; background: #fff; color: inherit; } .gv-dark .gv-sp-select, .gv-dark .gv-sp-range { border-color: #3c4043; background: #2a2a2a; } .gv-sp-range { width: 90px; } .gv-sp-btn { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--gv-brand,#1a73e8); background: var(--gv-brand,#1a73e8); color: #fff; cursor: pointer; font-size: 13px; font-weight: 500; } .gv-sp-btn.ghost { background: transparent; color: var(--gv-brand,#1a73e8); } .gv-sp-btn:hover { opacity: .9; } .gv-effect-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 8px; } .gv-effect-btn { padding: 10px 6px; border: 1px solid #ddd; border-radius: 10px; background: #fff; cursor: pointer; text-align: center; font-size: 13px; transition: .2s; } .gv-dark .gv-effect-btn { border-color: #3c4043; background: #2a2a2a; } .gv-effect-btn.active { border-color: var(--gv-brand,#1a73e8); background: rgba(26,115,232,.1); } .gv-effect-emoji { font-size: 22px; display: block; margin-bottom: 4px; } .gv-sp-footer { padding: 12px 22px; border-top: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; gap: 8px; } .gv-dark .gv-sp-footer { border-color: #3c4043; } .gv-sp-version { font-size: 12px; color: #5f6368; } .gv-pm-trigger { position: fixed; z-index: 2147483640; width: 40px; height: 40px; border-radius: 50%; background: var(--gv-brand,#1a73e8); color: #fff; border: none; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.25); display: flex; align-items: center; justify-content: center; transition: transform .2s; } .gv-pm-trigger:hover { transform: scale(1.08); } .gv-pm-panel { position: fixed; z-index: 2147483641; width: 340px; max-height: 520px; background: #fff; color: #1f1f1f; border-radius: 14px; box-shadow: 0 8px 32px rgba(0,0,0,.25); display: flex; flex-direction: column; overflow: hidden; font-family: 'Google Sans',Roboto,system-ui,sans-serif; } .gv-pm-panel.gv-dark { background: #1e1e1e; color: #e8eaed; } .gv-pm-header { padding: 12px 14px; border-bottom: 1px solid #e0e0e0; display: flex; align-items: center; justify-content: space-between; gap: 8px; } .gv-dark .gv-pm-header { border-color: #3c4043; } .gv-pm-title { font-weight: 600; font-size: 15px; } .gv-pm-search { padding: 8px 12px; } .gv-pm-search input { width: 100%; padding: 7px 10px; border-radius: 8px; border: 1px solid #ddd; background: #fff; color: inherit; font-size: 13px; box-sizing: border-box; } .gv-dark .gv-pm-search input { border-color: #3c4043; background: #2a2a2a; } .gv-pm-list { overflow-y: auto; flex: 1; padding: 0 8px 8px; } .gv-pm-item { padding: 8px 10px; border-radius: 8px; cursor: pointer; margin-bottom: 4px; border: 1px solid transparent; } .gv-pm-item:hover { background: rgba(0,0,0,.05); } .gv-dark .gv-pm-item:hover { background: rgba(255,255,255,.06); } .gv-pm-item-name { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gv-pm-item-preview { font-size: 11px; color: #5f6368; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gv-dark .gv-pm-item-preview { color: #9aa0a6; } .gv-pm-item-tags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } .gv-pm-tag { font-size: 10px; padding: 1px 6px; border-radius: 4px; background: rgba(26,115,232,.12); color: var(--gv-brand,#1a73e8); } .gv-pm-empty { text-align: center; color: #5f6368; padding: 24px; font-size: 13px; } .gv-pm-add-form { padding: 12px; border-top: 1px solid #e0e0e0; display: none; } .gv-dark .gv-pm-add-form { border-color: #3c4043; } .gv-pm-add-form.show { display: block; } .gv-pm-add-form input, .gv-pm-add-form textarea { width: 100%; box-sizing: border-box; padding: 7px 10px; border-radius: 8px; border: 1px solid #ddd; background: #fff; color: inherit; font-size: 13px; margin-bottom: 6px; font-family: inherit; } .gv-dark .gv-pm-add-form input, .gv-dark .gv-pm-add-form textarea { border-color: #3c4043; background: #2a2a2a; } .gv-pm-add-form textarea { resize: vertical; min-height: 60px; } .gv-pm-actions { display: flex; gap: 6px; } .gv-folder-container { font-family: 'Google Sans',Roboto,system-ui,sans-serif; } .gv-folder-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; } .gv-folder-header .title { font-size: 14px; font-weight: 600; } .gv-folder-list { max-height: 50vh; overflow-y: auto; } .gv-folder-item { } .gv-folder-item-header { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; cursor: pointer; } .gv-folder-item-header:hover { background: rgba(0,0,0,.05); } .gv-dark .gv-folder-item-header:hover { background: rgba(255,255,255,.06); } .gv-folder-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gv-folder-content { padding-left: 16px; } .gv-folder-conversation { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; cursor: pointer; } .gv-folder-conversation:hover { background: rgba(0,0,0,.05); } .gv-dark .gv-folder-conversation:hover { background: rgba(255,255,255,.06); } .gv-folder-conversation a { color: inherit; text-decoration: none; flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gv-folder-conversation.gv-starred a::before { content: '⭐ '; } .gv-timeline-bar { position: fixed; top: 50%; right: 18px; transform: translateY(-50%); z-index: 2147483630; width: 8px; height: 60vh; display: flex; align-items: center; } .gv-dark .gv-timeline-bar { } .gv-timeline-track { width: 100%; height: 100%; position: relative; } .gv-timeline-dot { position: absolute; left: 50%; transform: translateX(-50%); width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--gv-brand,#1a73e8); background: #fff; cursor: pointer; transition: transform .15s; } .gv-timeline-dot:hover { transform: translateX(-50%) scale(1.4); } .gv-timeline-dot.active { background: var(--gv-brand,#1a73e8); transform: translateX(-50%) scale(1.3); } .gv-timeline-dot.starred { background: #fbbc04; border-color: #fbbc04; } .gv-timeline-tooltip { position: fixed; z-index: 2147483631; background: #282828; color: #fff; padding: 6px 10px; border-radius: 6px; font-size: 12px; max-width: 280px; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 8px rgba(0,0,0,.3); } .gv-timeline-tooltip.show { opacity: 1; } .gv-selection-toolbar { position: fixed; z-index: 2147483635; display: flex; gap: 2px; background: #282828; border-radius: 8px; padding: 4px; box-shadow: 0 2px 10px rgba(0,0,0,.3); } .gv-selection-toolbar button { background: none; border: none; color: #fff; cursor: pointer; padding: 6px 8px; border-radius: 6px; display: flex; align-items: center; } .gv-selection-toolbar button:hover { background: rgba(255,255,255,.15); } .gv-input-collapsed { height: 48px !important; border-radius: 24px !important; width: auto !important; min-width: 200px !important; max-width: 600px !important; margin: 0 auto !important; display: flex !important; align-items: center !important; justify-content: center !important; cursor: pointer !important; } .gv-input-collapsed > *:not(.gv-collapse-placeholder) { visibility: hidden !important; opacity: 0 !important; width: 0 !important; height: 0 !important; position: absolute !important; pointer-events: none !important; } .gv-collapse-placeholder { display: none; align-items: center; gap: 8px; font-size: 14px; color: #5f6368; } .gv-input-collapsed > .gv-collapse-placeholder { display: flex !important; } .gv-mermaid-wrapper { margin: 12px 0; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; } .gv-mermaid-toggle { display: flex; gap: 4px; padding: 4px; background: #f5f5f5; border-bottom: 1px solid #e0e0e0; } .gv-mermaid-toggle button { padding: 4px 10px; border: none; background: none; cursor: pointer; border-radius: 6px; font-size: 12px; } .gv-mermaid-toggle button.active { background: var(--gv-brand,#1a73e8); color: #fff; } .gv-mermaid-diagram { padding: 16px; text-align: center; background: #fff; } .gv-mermaid-modal { position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.85); display: flex; align-items: center; justify-content: center; } .gv-mermaid-modal-content { max-width: 92vw; max-height: 92vh; overflow: auto; background: #fff; padding: 20px; border-radius: 8px; } .gv-copy-toast { position: fixed; z-index: 2147483644; padding: 6px 12px; border-radius: 6px; font-size: 13px; pointer-events: none; transition: opacity .2s; } .gv-usage-pill { position: fixed; z-index: 2147483636; background: #fff; color: #1f1f1f; border: 1px solid #e0e0e0; border-radius: 12px; padding: 8px 12px; font-size: 12px; box-shadow: 0 2px 10px rgba(0,0,0,.15); display: flex; flex-direction: column; gap: 4px; min-width: 160px; cursor: move; } .gv-dark .gv-usage-pill { background: #1e1e1e; color: #e8eaed; border-color: #3c4043; } .gv-usage-tier { font-weight: 600; font-size: 11px; color: var(--gv-brand,#1a73e8); text-transform: uppercase; } .gv-usage-metric { display: flex; flex-direction: column; gap: 2px; } .gv-usage-label { font-size: 10px; color: #5f6368; } .gv-dark .gv-usage-label { color: #9aa0a6; } .gv-usage-track { height: 4px; background: #e0e0e0; border-radius: 2px; overflow: hidden; } .gv-dark .gv-usage-track { background: #3c4043; } .gv-usage-fill { height: 100%; background: var(--gv-brand,#1a73e8); transition: width .3s; } .gv-usage-fill.high { background: #d93025; } .gv-usage-fill.mid { background: #f9ab00; } .gv-usage-pct { font-size: 11px; font-weight: 500; } .gv-export-toolbar { position: fixed; top: 16px; right: 68px; z-index: 2147483640; display: flex; gap: 6px; } .gv-export-toolbar button { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--gv-brand,#1a73e8); background: var(--gv-brand,#1a73e8); color: #fff; cursor: pointer; font-size: 13px; } .gv-export-toolbar button.ghost { background: transparent; color: var(--gv-brand,#1a73e8); } .gv-sidebar-section-hidden { max-height: 0 !important; overflow: hidden !important; opacity: 0 !important; margin: 0 !important; padding: 0 !important; pointer-events: none !important; } @media (max-width: 768px), (pointer: coarse) { #gv-settings-fab { top: auto !important; left: auto !important; bottom: calc(238px + env(safe-area-inset-bottom, 0px)) !important; right: calc(14px + env(safe-area-inset-right, 0px)) !important; width: 44px; height: 44px; opacity: .86; box-shadow: 0 4px 14px rgba(0,0,0,.32); } #gv-settings-overlay { align-items: flex-end; padding: 0; background: rgba(0,0,0,.38); } #gv-settings-panel { width: 100vw; max-width: 100vw; max-height: min(88vh, 720px); border-radius: 18px 18px 0 0; padding-bottom: env(safe-area-inset-bottom, 0px); } .gv-sp-header { padding: 14px 16px; } .gv-sp-tabs { padding: 4px 10px 0; gap: 6px; min-height: 42px; -webkit-overflow-scrolling: touch; } .gv-sp-tab { min-height: 38px; padding: 8px 12px 10px; font-size: 13px; } .gv-sp-tab.active::after { left: 10px; right: 10px; bottom: 0; height: 3px; } .gv-sp-body { padding: 10px 14px 18px; } .gv-feature-row { align-items: flex-start; padding: 12px 0; } .gv-feature-name { font-size: 14px; } .gv-feature-desc { font-size: 12px; } .gv-sp-control { flex-wrap: wrap; justify-content: flex-end; max-width: 46%; } .gv-sp-select { max-width: 150px; } .gv-sp-range { width: 110px; } .gv-effect-grid { grid-template-columns: repeat(2,1fr); } .gv-pm-trigger { width: 46px; height: 46px; bottom: calc(184px + env(safe-area-inset-bottom, 0px)) !important; right: calc(13px + env(safe-area-inset-right, 0px)) !important; left: auto !important; top: auto !important; box-shadow: 0 4px 14px rgba(0,0,0,.32); } .gv-pm-panel { left: 8px !important; right: 8px !important; top: auto !important; bottom: calc(8px + env(safe-area-inset-bottom, 0px)) !important; width: auto; max-height: min(72vh, 560px); border-radius: 16px; } .gv-pm-item { padding: 10px 12px; } .gv-pm-item-name { font-size: 14px; } .gv-folder-list { max-height: 38vh; -webkit-overflow-scrolling: touch; } .gv-timeline-bar { right: calc(8px + env(safe-area-inset-right, 0px)); width: 18px; height: 54vh; } .gv-timeline-dot { width: 14px; height: 14px; } .gv-timeline-tooltip { display: none; } .gv-selection-toolbar { transform: scale(1.08); transform-origin: top left; } .gv-export-toolbar { top: auto !important; left: auto !important; bottom: calc(292px + env(safe-area-inset-bottom, 0px)) !important; right: calc(14px + env(safe-area-inset-right, 0px)); flex-direction: column; } .gv-export-toolbar button { padding: 9px 10px; min-width: 44px; } .gv-usage-pill { left: auto !important; right: calc(10px + env(safe-area-inset-right, 0px)) !important; bottom: calc(292px + env(safe-area-inset-bottom, 0px)) !important; max-width: min(78vw, 260px); min-width: 138px; } .gv-input-collapsed { max-width: calc(100vw - 28px) !important; min-width: 160px !important; } .gv-mermaid-modal-content { max-width: 96vw; max-height: 88vh; padding: 12px; -webkit-overflow-scrolling: touch; } } `); } /* ========================================================================= * SECTION 7: 设置面板(替代扩展 popup) * ========================================================================= */ const SettingsPanel = { overlay: null, activeTab: 'general', featureDefs: [ // { key, nameKey, descKey, default, site, control: 'switch'|'select'|'range', options? } { key: SK.FOLDER_ENABLED, nameKey: 'folders', descKey: 'foldersOrg', def: true, site: 'gemini' }, { key: SK.TIMELINE_HIDE, nameKey: 'timeline', descKey: 'timelineNav', def: true, site: 'gemini', invert: true }, { key: SK.PROMPT_ITEMS, nameKey: 'promptVault', descKey: 'promptsVault', def: true, site: 'all', special: 'prompt' }, { key: SK.EXPORT_IMAGE_WIDTH, nameKey: 'chatExport', descKey: 'exportChat', def: true, site: 'gemini', special: 'export' }, { key: SK.MERMAID_ENABLED, nameKey: 'mermaid', descKey: 'renderMermaid', def: true, site: 'gemini' }, { key: SK.FORMULA_COPY_ENABLED, nameKey: 'formulaCopy', descKey: 'copyFormula', def: true, site: 'all', control: 'select', options: [['latex','LaTeX ($...$)'],['no-dollar','LaTeX (no $)'],['notion','Notion ($$)'],['unicodemath','Word MathML']], optKey: SK.FORMULA_COPY_FORMAT }, { key: SK.MARKDOWN_PATCH_ENABLED, nameKey: 'markdownFix', descKey: 'fixBold', def: true, site: 'gemini' }, { key: SK.WATERMARK_ENABLED, nameKey: 'watermark', descKey: 'removeWatermark', def: true, site: 'gemini' }, { key: SK.QUOTE_REPLY_ENABLED, nameKey: 'quoteReply', descKey: 'quoteSelected', def: true, site: 'gemini' }, { key: SK.PREVENT_AUTO_SCROLL, nameKey: 'preventScroll', descKey: 'blockJump', def: true, site: 'gemini' }, { key: SK.INPUT_COLLAPSE, nameKey: 'inputCollapse', descKey: 'collapseInput', def: false, site: 'gemini' }, { key: SK.DEFAULT_MODEL_AUTO_APPLY, nameKey: 'defaultModel', descKey: 'setDefault', def: true, site: 'gemini' }, { key: SK.GEMS_HIDDEN, nameKey: 'gemsHider', descKey: 'hideRecent', def: false, site: 'gemini', invert: true }, { key: SK.CHAT_WIDTH_ENABLED, nameKey: 'chatWidth', descKey: 'adjustWidth', def: false, site: 'gemini', control: 'range', rangeKey: SK.CHAT_WIDTH, min: 30, max: 100, step: 1, rangeDef: 70, suffix: '%' }, { key: SK.DEEP_RESEARCH_ENABLED, nameKey: 'deepResearch', descKey: 'extractResearch', def: true, site: 'gemini' }, { key: SK.CLAUDE_TIMELINE_ENABLED, nameKey: 'claudeTimeline', descKey: 'claudeTimelineNav', def: true, site: 'claude' }, { key: SK.CLAUDE_USAGE_ENABLED, nameKey: 'claudeUsage', descKey: 'claudeUsageBar', def: true, site: 'claude' }, { key: SK.READING_WIDTH_CLAUDE_ENABLED, nameKey: 'readingWidth', descKey: 'comfyRead', def: false, site: 'claude', control: 'range', rangeKey: SK.READING_WIDTH_CLAUDE, min: 600, max: 1600, step: 10, rangeDef: 768, suffix: 'px' }, { key: SK.READING_WIDTH_CHATGPT_ENABLED, nameKey: 'readingWidth', descKey: 'comfyRead', def: false, site: 'chatgpt', control: 'range', rangeKey: SK.READING_WIDTH_CHATGPT, min: 600, max: 1600, step: 10, rangeDef: 768, suffix: 'px' }, ], open() { if (this.overlay) { this.overlay.remove(); this.overlay = null; return; } this.render(); }, render() { const dark = isDark(); this.overlay = el('div', { id: 'gv-settings-overlay', class: dark ? 'gv-root gv-dark' : 'gv-root', onclick: (e) => { if (e.target === this.overlay) this.close(); } }); const tabs = [ ['general', t('general')], ['gemini', t('gemini')], ['claude', t('claude')], ['chatgpt', t('chatgpt')], ['effects', t('visualEffects')], ['data', t('backupExport')], ]; const panel = el('div', { id: 'gv-settings-panel', class: dark ? 'gv-dark' : '' }, [ el('div', { class: 'gv-sp-header' }, [ el('div', { class: 'gv-sp-title' }, ['⚙️ ', t('settings')]), el('button', { class: 'gv-sp-close', onclick: () => this.close() }, ['×']), ]), el('div', { class: 'gv-sp-tabs' }, tabs.map(([id, label]) => { const active = this.activeTab === id; return el('button', { class: 'gv-sp-tab', 'data-active': active ? 'true' : 'false', 'aria-selected': active ? 'true' : 'false', style: active ? { background: 'transparent', color: 'var(--gv-brand,#1a73e8)', fontWeight: '600', borderBottom: '3px solid var(--gv-brand,#1a73e8)', borderRadius: '0', boxShadow: 'none', } : { background: 'transparent', borderBottom: '3px solid transparent', borderRadius: '0', boxShadow: 'none' }, onclick: () => this.switchTab(id) }, [label]); })), el('div', { class: 'gv-sp-body' }, [ this.renderSection('general'), this.renderSection('gemini'), this.renderSection('claude'), this.renderSection('chatgpt'), this.renderEffects(), this.renderData(), ]), el('div', { class: 'gv-sp-footer' }, [ el('span', { class: 'gv-sp-version' }, ['Voyager Userscript v1.6.0']), el('div', { style: { display: 'flex', gap: '8px' } }, [ el('button', { class: 'gv-sp-btn ghost', onclick: () => this.close() }, [t('close')]), ]), ]), ]); this.overlay.appendChild(panel); document.body.appendChild(this.overlay); this.applyActiveSection(); }, close() { if (this.overlay) { this.overlay.remove(); this.overlay = null; } }, switchTab(id) { this.activeTab = id; this.applyActiveSection(); }, applyActiveSection() { const idx = this.tabIndex(this.activeTab); const tabs = $$('.gv-sp-tab', this.overlay); tabs.forEach((btn, i) => btn.classList.toggle('active', i === idx)); const sections = $$('.gv-sp-section', this.overlay); sections.forEach((s, i) => s.classList.toggle('active', i === idx)); }, tabIndex(id) { return { general:0, gemini:1, claude:2, chatgpt:3, effects:4, data:5 }[id] || 0; }, renderSection(site) { const features = this.featureDefs.filter(f => f.site === site || (site === 'general' && false)); let items = site === 'general' ? [ this.makeLangRow(), this.makeThemeRow(), ] : features.map(f => this.makeFeatureRow(f)); return el('div', { class: 'gv-sp-section' }, items); }, makeLangRow() { const cur = getLang(); return el('div', { class: 'gv-feature-row' }, [ el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t('language')]), ]), el('div', { class: 'gv-sp-control' }, [ el('select', { class: 'gv-sp-select', onchange: (e) => { Store.set(SK.LANGUAGE, e.target.value); toast(t('language') + ': ' + e.target.value, 'success'); setTimeout(() => { this.close(); this.open(); }, 600); } }, [ el('option', { value: 'zh', selected: cur === 'zh' }, ['中文']), el('option', { value: 'en', selected: cur === 'en' }, ['English']), ]), ]), ]); }, makeThemeRow() { const cur = Store.get(SK.THEME, 'auto'); const opts = [['auto', t('auto')], ['light', t('light')], ['dark', t('dark')]]; return el('div', { class: 'gv-feature-row' }, [ el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t('theme')]) ]), el('div', { class: 'gv-sp-control' }, [ el('select', { class: 'gv-sp-select', onchange: (e) => { Store.set(SK.THEME, e.target.value); } }, opts.map(([v, l]) => el('option', { value: v, selected: cur === v }, [l])) ), ]), ]); }, makeFeatureRow(f) { const checked = Store.get(f.key, f.def); const isOn = f.invert ? !checked : checked; const info = el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t(f.nameKey)]), el('div', { class: 'gv-feature-desc' }, [t(f.descKey)]), ]); let control; if (f.control === 'select') { const fmt = Store.get(f.optKey, 'latex'); control = el('div', { class: 'gv-sp-control' }, [ el('label', { class: 'gv-switch' }, [ el('input', { type: 'checkbox', checked: isOn, onchange: (e) => this.toggle(f, e.target.checked) }), el('span', { class: 'gv-switch-slider' }), ]), el('select', { class: 'gv-sp-select', onchange: (e) => { Store.set(f.optKey, e.target.value); } }, f.options.map(([v, l]) => el('option', { value: v, selected: fmt === v }, [l])) ), ]); } else if (f.control === 'range') { let val = Store.get(f.rangeKey, f.rangeDef); control = el('div', { class: 'gv-sp-control' }, [ el('label', { class: 'gv-switch' }, [ el('input', { type: 'checkbox', checked: isOn, onchange: (e) => this.toggle(f, e.target.checked) }), el('span', { class: 'gv-switch-slider' }), ]), el('input', { type: 'range', class: 'gv-sp-range', min: f.min, max: f.max, step: f.step, value: val, oninput: (e) => { Store.set(f.rangeKey, Number(e.target.value)); } }), el('span', { style: { fontSize: '12px', minWidth: '40px' } }, [String(val) + (f.suffix || '')]), ]); } else { control = el('div', { class: 'gv-sp-control' }, [ el('label', { class: 'gv-switch' }, [ el('input', { type: 'checkbox', checked: isOn, onchange: (e) => this.toggle(f, e.target.checked) }), el('span', { class: 'gv-switch-slider' }), ]), ]); } return el('div', { class: 'gv-feature-row' }, [info, control]); }, toggle(f, checked) { const val = f.invert ? !checked : checked; Store.set(f.key, val); toast(t(f.nameKey) + ': ' + (checked ? t('enabled') : t('disabled')), checked ? 'success' : 'info', 1500); }, renderEffects() { const cur = Store.get(SK.VISUAL_EFFECT, 'off'); const opts = [['off', '🚫', t('effectOff')], ['snow', '❄️', t('snow')], ['rain', '🌧️', t('rain')], ['sakura', '🌸', t('sakura')]]; const grid = el('div', { class: 'gv-effect-grid' }, opts.map(([v, emoji, label]) => el('button', { class: 'gv-effect-btn' + (cur === v ? ' active' : ''), onclick: (e) => { Store.set(SK.VISUAL_EFFECT, v); $$('.gv-effect-btn', this.overlay).forEach(b => b.classList.remove('active')); e.target.closest('.gv-effect-btn').classList.add('active'); VisualEffects.setMode(v); } }, [ el('span', { class: 'gv-effect-emoji' }, [emoji]), label ]) )); return el('div', { class: 'gv-sp-section' }, [ el('div', { class: 'gv-feature-row' }, [ el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t('visualEffects')]), el('div', { class: 'gv-feature-desc' }, [t('effects')]), ])]), grid, ]); }, renderData() { return el('div', { class: 'gv-sp-section' }, [ el('div', { class: 'gv-feature-row' }, [ el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t('backupExport')]), el('div', { class: 'gv-feature-desc' }, ['JSON ' + (getLang() === 'zh' ? '备份所有文件夹、提示词、时间线星标数据' : 'backup of all folders, prompts, timeline starred data')]), ]), el('div', { class: 'gv-sp-control' }, [ el('button', { class: 'gv-sp-btn', onclick: () => this.exportData() }, [t('export')]), ])]), el('div', { class: 'gv-feature-row' }, [ el('div', { class: 'gv-feature-info' }, [ el('div', { class: 'gv-feature-name' }, [t('backupImport')]), el('div', { class: 'gv-feature-desc' }, [getLang() === 'zh' ? '从 JSON 备份恢复数据' : 'Restore data from JSON backup']), ]), el('div', { class: 'gv-sp-control' }, [ el('button', { class: 'gv-sp-btn ghost', onclick: () => this.importData() }, [t('import')]), ])]), ]); }, exportData() { const data = {}; Object.values(SK).forEach(k => { const v = Store.get(k, undefined); if (v !== undefined) data[k] = v; }); const blob = new Blob([JSON.stringify({ voyagerBackup: true, version: '1.6.0', exportedAt: new Date().toISOString(), data }, null, 2)], { type: 'application/json' }); const a = el('a', { href: URL.createObjectURL(blob), download: `voyager-backup-${new Date().toISOString().slice(0,10)}.json` }); document.body.appendChild(a); a.click(); a.remove(); toast(t('done'), 'success'); }, importData() { const input = el('input', { type: 'file', accept: '.json', style: { display: 'none' } }); input.onchange = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(reader.result); const data = parsed.data || parsed; Object.entries(data).forEach(([k, v]) => Store.set(k, v)); toast(t('backupImported'), 'success'); setTimeout(() => { this.close(); this.open(); }, 800); } catch (err) { toast(t('backupImportFailed'), 'error'); } }; reader.readAsText(file); }; document.body.appendChild(input); input.click(); input.remove(); }, }; GV.SettingsPanel = SettingsPanel; /* ========================================================================= * SECTION 8: 视觉特效(雪 / 雨 / 樱花) * ========================================================================= */ const VisualEffects = { current: null, canvas: null, raf: null, particles: [], state: 'off', // 'off'|'active'|'draining' setMode(mode) { if (mode === this.current) return; if (mode === 'off') { this.disable(); return; } this.disable(); this.current = mode; this.enable(); }, enable() { if (!this.current || this.current === 'off') return; this.state = 'active'; this.createCanvas(); this.initParticles(); this.loop(); }, disable() { if (this.state === 'off') return; this.state = 'draining'; // 让现有粒子飘出后再清理 const check = () => { if (this.particles.length === 0 || this.particles.every(p => this.isOffscreen(p))) { this.cleanup(); } else if (this.state === 'draining') { this.raf = requestAnimationFrame(check); } }; setTimeout(check, 50); // 兜底 6s 强制清理 setTimeout(() => { if (this.state === 'draining') this.cleanup(); }, 6000); }, createCanvas() { if (this.canvas) return; this.canvas = el('canvas', { id: 'gv-effect-canvas', style: { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: '2147483647', } }); document.documentElement.appendChild(this.canvas); this.resize(); this._onResize = () => this.resize(); unsafeWindow.addEventListener('resize', this._onResize); this._onVis = () => { if (document.hidden) { if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } } else if (this.state !== 'off') { this.loop(); } }; document.addEventListener('visibilitychange', this._onVis); }, resize() { if (!this.canvas) return; this.canvas.width = unsafeWindow.innerWidth; this.canvas.height = unsafeWindow.innerHeight; }, initParticles() { const W = this.canvas.width, H = this.canvas.height; this.particles = []; const rnd = (a, b) => a + Math.random() * (b - a); if (this.current === 'snow') { const layers = [ { n: 100, r: [0.15, 0.45], s: [0.15, 0.4], o: [0.15, 0.35], d: [0.05, 0.2] }, { n: 80, r: [0.5, 1.0], s: [0.4, 1.0], o: [0.3, 0.6], d: [0.15, 0.45] }, { n: 60, r: [1.2, 2.5], s: [0.8, 1.6], o: [0.5, 0.8], d: [0.25, 0.6] }, ]; layers.forEach(L => { for (let i = 0; i < L.n; i++) this.particles.push({ x: rnd(0, W), y: rnd(0, H), radius: rnd(L.r[0], L.r[1]), opacity: rnd(L.o[0], L.o[1]), speedY: rnd(L.s[0], L.s[1]), drift: rnd(L.d[0], L.d[1]), driftFreq: rnd(0.0003, 0.0012), phase: rnd(0, Math.PI * 2), }); }); } else if (this.current === 'rain') { const layers = [ { n: 80, len: [6, 14], s: [3, 6], o: [0.06, 0.14], w: [0.3, 0.6] }, { n: 60, len: [14, 28], s: [7, 13], o: [0.12, 0.25], w: [0.5, 1.0] }, { n: 30, len: [26, 48], s: [12, 20], o: [0.2, 0.38], w: [0.8, 1.5] }, ]; layers.forEach((L, li) => { for (let i = 0; i < L.n; i++) this.particles.push({ x: rnd(0, W), y: rnd(0, H), length: rnd(L.len[0], L.len[1]), speed: rnd(L.s[0], L.s[1]), opacity: rnd(L.o[0], L.o[1]), lineWidth: rnd(L.w[0], L.w[1]), canSplash: li > 0, }); }); this.splashes = []; } else if (this.current === 'sakura') { const colors = ['#ffb7c5', '#ffd6e0', '#ff9eb5', '#ffc0cb']; for (let i = 0; i < 50; i++) this.particles.push({ x: rnd(0, W), y: rnd(0, H), size: rnd(6, 12), speedY: rnd(0.5, 1.5), drift: rnd(-1.5, 1.5), driftFreq: rnd(0.01, 0.03), rotation: rnd(0, Math.PI * 2), rotSpeed: rnd(-0.03, 0.03), phase: rnd(0, Math.PI * 2), color: colors[Math.floor(rnd(0, colors.length))], opacity: rnd(0.6, 0.95), }); } }, isOffscreen(p) { const H = this.canvas.height; return p.y > H + (p.radius || p.length || p.size || 20); }, loop() { if (!this.canvas || this.state === 'off') return; const ctx = this.canvas.getContext('2d'); const W = this.canvas.width, H = this.canvas.height; ctx.clearRect(0, 0, W, H); const time = performance.now(); const draining = this.state === 'draining'; if (this.current === 'snow') { this.particles.forEach(p => { if (!draining) { p.y += p.speedY; p.x += Math.sin(p.phase + time * p.driftFreq) * p.drift; if (p.y > H + p.radius) { p.y = -p.radius; p.x = Math.random() * W; } if (p.x < -10) p.x = W + 10; else if (p.x > W + 10) p.x = -10; } ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); ctx.fillStyle = `rgba(255,255,255,${p.opacity})`; ctx.fill(); }); if (draining) this.particles = this.particles.filter(p => p.y <= H + p.radius); } else if (this.current === 'rain') { const WIND_DX = Math.sin(0.14), WIND_DY = Math.cos(0.14); this.particles.forEach(p => { if (!draining) { p.y += p.speed; p.x += p.speed * WIND_DX; if (p.y > H) { if (p.canSplash && Math.random() < 0.35 && this.splashes.length < 24) { this.splashes.push({ x: p.x, y: H - 2, radius: 1, maxRadius: 3 + Math.random() * 5, opacity: 0.2 + Math.random() * 0.15 }); } p.y = -p.length; p.x = Math.random() * W; } } ctx.beginPath(); ctx.strokeStyle = `rgba(180,200,220,${p.opacity})`; ctx.lineWidth = p.lineWidth; ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + p.length * WIND_DX, p.y + p.length * WIND_DY); ctx.stroke(); }); if (this.splashes) { this.splashes.forEach(s => { s.radius += 0.4; s.opacity -= 0.01; ctx.beginPath(); ctx.strokeStyle = `rgba(180,200,220,${Math.max(0, s.opacity)})`; ctx.ellipse(s.x, s.y, s.radius, s.radius * 0.35, 0, 0, Math.PI * 2); ctx.stroke(); }); this.splashes = this.splashes.filter(s => s.opacity > 0 && s.radius < s.maxRadius); } if (draining) this.particles = this.particles.filter(p => p.y <= H + p.length); } else if (this.current === 'sakura') { this.particles.forEach(p => { if (!draining) { p.y += p.speedY; p.x += Math.sin(p.phase + time * p.driftFreq) * p.drift + p.drift * 0.3; p.rotation += p.rotSpeed; if (p.y > H + p.size) { p.y = -p.size; p.x = Math.random() * W; } } ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rotation); ctx.globalAlpha = p.opacity; ctx.fillStyle = p.color; this.drawPetal(ctx, p.size); ctx.restore(); }); if (draining) this.particles = this.particles.filter(p => p.y <= H + p.size); } if (this.particles.length === 0 && draining) { this.cleanup(); return; } this.raf = requestAnimationFrame(() => this.loop()); }, drawPetal(ctx, size) { ctx.beginPath(); ctx.moveTo(0, 0); ctx.bezierCurveTo(size * 0.4, -size * 0.3, size, -size * 0.3, size, 0); ctx.bezierCurveTo(size, size * 0.3, size * 0.4, size * 0.3, 0, 0); ctx.fill(); }, cleanup() { this.state = 'off'; if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } if (this.canvas) { this.canvas.remove(); this.canvas = null; } if (this._onResize) { unsafeWindow.removeEventListener('resize', this._onResize); this._onResize = null; } if (this._onVis) { document.removeEventListener('visibilitychange', this._onVis); this._onVis = null; } this.particles = []; this.splashes = []; this.current = null; }, start() { const mode = Store.get(SK.VISUAL_EFFECT, 'off'); if (mode && mode !== 'off') { this.current = mode; this.enable(); } Store.onChange(SK.VISUAL_EFFECT, (v) => this.setMode(v || 'off')); }, }; GV.VisualEffects = VisualEffects; /* ========================================================================= * SECTION 9: 提示词库 (Prompt Manager) — 跨站点 * ========================================================================= */ const PromptManager = { trigger: null, panel: null, list: null, searchInput: null, items: [], filtered: [], selectedTags: [], start() { this.items = Store.get(SK.PROMPT_ITEMS, []) || []; this.selectedTags = Store.get(SK.PROMPT_SELECTED_TAGS, []) || []; this.injectTrigger(); this.injectPanel(); Store.onChange(SK.PROMPT_ITEMS, (v) => { this.items = v || []; this.render(); }); }, injectTrigger() { if (this.trigger && document.body.contains(this.trigger)) return; this.trigger = el('button', { id: 'gv-pm-trigger', class: 'gv-pm-trigger', title: t('promptVault'), 'aria-label': 'Prompt Manager', style: { bottom: '88px', right: '24px' }, onclick: () => this.togglePanel(), }, ['💬']); document.body.appendChild(this.trigger); }, togglePanel() { if (!this.panel) return; this.panel.style.display = this.panel.style.display === 'none' ? 'flex' : 'none'; if (this.panel.style.display === 'flex') { this.searchInput.value = ''; this.render(); this.searchInput.focus(); } }, injectPanel() { if (this.panel && document.body.contains(this.panel)) return; const dark = isDark(); this.panel = el('div', { id: 'gv-pm-panel', class: 'gv-pm-panel' + (dark ? ' gv-dark' : ''), style: { display: 'none', bottom: '140px', right: '24px' } }, [ el('div', { class: 'gv-pm-header' }, [ el('div', { class: 'gv-pm-title' }, ['🗂️ ', t('promptVault')]), el('div', { style: { display: 'flex', gap: '4px' } }, [ el('button', { class: 'gv-sp-btn', style: { padding: '4px 8px', fontSize: '12px' }, onclick: () => this.toggleAddForm() }, ['+ ' + t('addPrompt')]), el('button', { class: 'gv-sp-close', style: { fontSize: '16px', padding: '2px 6px' }, onclick: () => this.togglePanel() }, ['×']), ]), ]), el('div', { class: 'gv-pm-search' }, [ this.searchInput = el('input', { type: 'search', placeholder: t('promptPlaceholder'), oninput: (e) => this.onSearch(e.target.value) }), ]), this.list = el('div', { class: 'gv-pm-list' }), this.makeAddForm(), ]); document.body.appendChild(this.panel); this.render(); }, makeAddForm() { const form = el('div', { class: 'gv-pm-add-form' }); const nameInput = el('input', { type: 'text', placeholder: t('promptName') }); const textInput = el('textarea', { placeholder: t('promptText') }); const tagsInput = el('input', { type: 'text', placeholder: t('promptTags') }); form.appendChild(nameInput); form.appendChild(textInput); form.appendChild(tagsInput); form.appendChild(el('div', { class: 'gv-pm-actions' }, [ el('button', { class: 'gv-sp-btn', onclick: () => { if (!textInput.value.trim()) { toast(t('promptText'), 'warning'); return; } this.add({ name: nameInput.value.trim() || textInput.value.trim().slice(0, 30), text: textInput.value, tags: tagsInput.value.split(',').map(s => s.trim()).filter(Boolean) }); nameInput.value = ''; textInput.value = ''; tagsInput.value = ''; form.classList.remove('show'); } }, [t('save')]), el('button', { class: 'gv-sp-btn ghost', onclick: () => form.classList.remove('show') }, [t('cancel')]), ])); return form; }, toggleAddForm() { const form = $('.gv-pm-add-form', this.panel); if (form) form.classList.toggle('show'); }, add(item) { item.id = fnvHash(Date.now() + '-' + Math.random()); item.createdAt = Date.now(); this.items.unshift(item); Store.set(SK.PROMPT_ITEMS, this.items); this.render(); toast(t('done'), 'success', 1200); }, remove(id) { this.items = this.items.filter(i => i.id !== id); Store.set(SK.PROMPT_ITEMS, this.items); this.render(); }, onSearch(q) { q = (q || '').toLowerCase().trim(); this.render(q); }, render(query = '') { if (!this.list) return; this.list.innerHTML = ''; let items = this.items; if (query) items = items.filter(i => (i.name || '').toLowerCase().includes(query) || (i.text || '').toLowerCase().includes(query) || (i.tags || []).some(tg => tg.toLowerCase().includes(query))); if (items.length === 0) { this.list.appendChild(el('div', { class: 'gv-pm-empty' }, [t('noPrompts')])); return; } const insertOnClick = Store.get(SK.PROMPT_INSERT_ON_CLICK, true); items.forEach(item => { const node = el('div', { class: 'gv-pm-item', title: insertOnClick ? t('clickToInsert') : t('clickToCopy'), onclick: (e) => { if (e.target.classList.contains('gv-pm-del')) return; if (insertOnClick) this.insertIntoInput(item.text); else { this.copyText(item.text); toast(t('copySuccess'), 'success', 1200); } }, }, [ el('div', { class: 'gv-pm-item-name' }, [item.name || item.text.slice(0, 30)]), el('div', { class: 'gv-pm-item-preview' }, [item.text.slice(0, 60)]), ]); if (item.tags && item.tags.length) { node.appendChild(el('div', { class: 'gv-pm-item-tags' }, item.tags.map(tg => el('span', { class: 'gv-pm-tag' }, [tg])))); } const del = el('button', { class: 'gv-pm-del', title: t('delete'), style: { position: 'absolute', top: '4px', right: '4px', background: 'none', border: 'none', cursor: 'pointer', color: '#5f6368', fontSize: '14px', opacity: '0' }, onclick: (e) => { e.stopPropagation(); this.remove(item.id); } }, ['🗑']); node.style.position = 'relative'; node.addEventListener('mouseenter', () => del.style.opacity = '1'); node.addEventListener('mouseleave', () => del.style.opacity = '0'); node.appendChild(del); this.list.appendChild(node); }); }, findChatInput() { const sels = [ '[data-testid="chat-input"][contenteditable="true"]', '#prompt-textarea[contenteditable="true"]', 'rich-textarea [contenteditable="true"]', 'div[contenteditable="true"][role="textbox"]', '.input-area textarea', 'textarea[placeholder*="Ask"]', 'textarea', ]; for (const s of sels) { const el = $(s); if (el && el.offsetParent !== null) return el; } return null; }, insertIntoInput(text) { const input = this.findChatInput(); if (!input) { this.copyText(text); toast(t('copySuccess'), 'success', 1200); return; } input.focus(); if (input.tagName === 'TEXTAREA') { const isEmpty = !input.value.trim(); input.value = (isEmpty ? '' : input.value + '\n\n') + text; input.dispatchEvent(new Event('input', { bubbles: true })); } else { // contenteditable try { document.execCommand('insertText', false, (input.textContent.trim() ? '\n\n' : '') + text); } catch (e) { const range = document.createRange(); range.selectNodeContents(input); range.collapse(false); range.insertNode(document.createTextNode(text)); input.dispatchEvent(new Event('input', { bubbles: true })); } } this.togglePanel(); }, copyText(text) { copyTextCompat(text).catch(() => this.fallbackCopy(text)); }, fallbackCopy(text) { const ta = el('textarea', { style: { position: 'fixed', opacity: '0' } }, [text]); document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); }, destroy() { this.trigger?.remove(); this.panel?.remove(); this.trigger = null; this.panel = null; }, }; GV.PromptManager = PromptManager; /* ========================================================================= * SECTION 10: 文件夹管理 (Folder Manager) — Gemini * ========================================================================= */ const FolderManager = { container: null, observer: null, data: { folders: [], folderContents: {} }, ROOT: '__root_conversations__', start() { if (!Store.get(SK.FOLDER_ENABLED, true)) return; if (Site.isAIStudio) this.dataKey = SK.FOLDER_DATA_AISTUDIO; else this.dataKey = SK.FOLDER_DATA; this.loadData(); this.injectContainer(); this.observer = observeMutations(document.body, () => this.refresh(), { childList: true, subtree: true }, 200); Store.onChange(this.dataKey, () => { this.loadData(); this.render(); }); watchRoute(() => setTimeout(() => this.refresh(), 300)); }, loadData() { const d = Store.get(this.dataKey, null); if (d && d.folders) { this.data = d; } else { this.data = { folders: [], folderContents: {} }; } this.ensureIntegrity(); }, save() { Store.set(this.dataKey, this.data); }, ensureIntegrity() { if (!this.data.folders) this.data.folders = []; if (!this.data.folderContents) this.data.folderContents = {}; if (!this.data.folderContents[this.ROOT]) this.data.folderContents[this.ROOT] = []; }, getSidebar() { const candidates = $$('[data-test-id="overflow-container"]'); const visible = candidates.filter(c => c.offsetParent !== null && c.offsetWidth > 50); return visible[0] || candidates[0]; }, getAnchor() { const sels = [ 'expandable-section[data-test-id="chats-expandable-section"]', '[data-test-id="all-conversations"]', '.chat-history', 'expandable-section[data-test-id="notebooks-expandable-section"]', ]; for (const s of sels) { const e = $(s); if (e) return e; } return null; }, injectContainer() { const tryInject = () => { const anchor = this.getAnchor(); if (!anchor || !anchor.parentElement) { setTimeout(tryInject, 1000); return; } if (this.container && document.body.contains(this.container)) { // 重新定位 if (this.container.parentElement !== anchor.parentElement) { anchor.parentElement.insertBefore(this.container, anchor); } return; } this.container = this.buildContainer(); anchor.parentElement.insertBefore(this.container, anchor); this.render(); }; tryInject(); }, buildContainer() { return el('div', { class: 'gv-folder-container', style: { padding: '8px 0' } }, [ el('div', { class: 'gv-folder-header' }, [ el('div', { class: 'title', style: { display: 'flex', alignItems: 'center', gap: '4px' } }, [ el('span', { class: 'gv-folder-toggle', style: { cursor: 'pointer', fontSize: '12px' }, onclick: () => this.toggleCollapse() }, ['▾']), '📂 ' + t('folders'), ]), el('div', { style: { display: 'flex', gap: '4px' } }, [ el('button', { class: 'gv-sp-btn', style: { padding: '3px 8px', fontSize: '12px' }, onclick: () => this.addFolder() }, ['+ ' + t('newFolder')]), ]), ]), el('div', { class: 'gv-folder-list' }), ]); }, toggleCollapse() { const list = $('.gv-folder-list', this.container); const toggle = $('.gv-folder-toggle', this.container); if (!list) return; const hidden = list.style.display === 'none'; list.style.display = hidden ? '' : 'none'; toggle.textContent = hidden ? '▾' : '▸'; }, addFolder() { const name = prompt(t('folderName')); if (!name) return; const folder = { id: uid(), name: name.trim(), parentId: null, isExpanded: true, createdAt: Date.now(), updatedAt: Date.now() }; this.data.folders.push(folder); if (!this.data.folderContents[folder.id]) this.data.folderContents[folder.id] = []; this.save(); this.render(); }, deleteFolder(id) { if (!confirm(t('delete') + '?')) return; // 把文件夹内对话移到根 const contents = this.data.folderContents[id] || []; this.data.folderContents[this.ROOT] = [...(this.data.folderContents[this.ROOT] || []), ...contents]; this.data.folders = this.data.folders.filter(f => f.id !== id); // 子文件夹移到根级 this.data.folders.filter(f => f.parentId === id).forEach(f => f.parentId = null); delete this.data.folderContents[id]; this.save(); this.render(); }, renameFolder(id) { const f = this.data.folders.find(f => f.id === id); if (!f) return; const name = prompt(t('rename'), f.name); if (name && name.trim()) { f.name = name.trim(); f.updatedAt = Date.now(); this.save(); this.render(); } }, // 从侧边栏扫描原生对话列表,同步标题 scanNativeConversations() { const items = $$('[data-test-id="conversation"]'); const links = $$('a[href*="/app/"], a[href*="/gem/"]'); const convs = []; const seen = new Set(); [...items, ...links].forEach(node => { const link = node.tagName === 'A' ? node : $('a[href*="/app/"], a[href*="/gem/"]', node); if (!link) return; const href = link.getAttribute('href') || ''; const id = this.extractConvId(href, node); if (!id || seen.has(id)) return; seen.add(id); const title = (link.textContent || '').trim() || (node.textContent || '').trim().slice(0, 60); convs.push({ conversationId: id, title, url: new URL(href, location.origin).href, isGem: href.includes('/gem/') }); }); return convs; }, extractConvId(href, node) { let m = href.match(/\/app\/([^/?#]+)/); if (m) return 'c_' + m[1]; m = href.match(/\/gem\/[^/]+\/([^/?#]+)/); if (m) return 'c_' + m[1]; // jslog 兜底 const jslog = node?.getAttribute('jslog') || ''; m = jslog.match(/[",\[]c_([a-f0-9]+)/); if (m) return 'c_' + m[1]; return null; }, addToFolder(folderId, conv) { // 从所有位置移除 Object.keys(this.data.folderContents).forEach(k => { this.data.folderContents[k] = (this.data.folderContents[k] || []).filter(c => c.conversationId !== conv.conversationId); }); if (!this.data.folderContents[folderId]) this.data.folderContents[folderId] = []; conv.addedAt = Date.now(); this.data.folderContents[folderId].push(conv); this.save(); this.render(); }, removeFromFolder(convId) { Object.keys(this.data.folderContents).forEach(k => { if (k === this.ROOT) return; this.data.folderContents[k] = (this.data.folderContents[k] || []).filter(c => c.conversationId !== convId); }); // 移到根 const conv = this.findConv(convId); if (conv) { if (!this.data.folderContents[this.ROOT]) this.data.folderContents[this.ROOT] = []; if (!this.data.folderContents[this.ROOT].some(c => c.conversationId === convId)) { this.data.folderContents[this.ROOT].push(conv); } } this.save(); this.render(); }, findConv(convId) { for (const k of Object.keys(this.data.folderContents)) { const c = (this.data.folderContents[k] || []).find(c => c.conversationId === convId); if (c) return c; } return null; }, toggleStar(convId) { const conv = this.findConv(convId); if (conv) { conv.starred = !conv.starred; this.save(); this.render(); } }, moveCurrentToFolder() { // 当前对话 const href = location.pathname + location.search; const id = this.extractConvId(href, null); if (!id) { toast('No conversation', 'warning'); return; } const options = this.data.folders.map(f => `${f.name}`).join('\n'); const idx = prompt(t('moveToFolder') + ':\n0: Root\n' + this.data.folders.map((f, i) => `${i + 1}: ${f.name}`).join('\n')); if (idx === null) return; const i = parseInt(idx, 10); const conv = this.findConv(id) || { conversationId: id, title: document.title.replace(/ - Gemini.*$/, '').slice(0, 60), url: location.href, addedAt: Date.now() }; const folderId = i === 0 ? this.ROOT : (this.data.folders[i - 1]?.id || this.ROOT); this.addToFolder(folderId, conv); toast(t('done'), 'success', 1200); }, render() { if (!this.container) return; const list = $('.gv-folder-list', this.container); if (!list) return; list.innerHTML = ''; // 同步原生标题 const native = this.scanNativeConversations(); const nativeMap = new Map(native.map(n => [n.conversationId, n])); Object.values(this.data.folderContents).forEach(arr => { arr.forEach(c => { if (!c.customTitle && nativeMap.has(c.conversationId)) { const n = nativeMap.get(c.conversationId); if (n.title && n.title !== c.title) c.title = n.title; } }); }); // 排序:starred 优先 const sortConvs = (arr) => arr.slice().sort((a, b) => (b.starred ? 1 : 0) - (a.starred ? 1 : 0) || (b.addedAt || 0) - (a.addedAt || 0)); const sortedFolders = this.data.folders.slice().sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || a.name.localeCompare(b.name, undefined, { numeric: true })); // 根级对话 const rootConvs = sortConvs(this.data.folderContents[this.ROOT] || []); rootConvs.forEach(c => list.appendChild(this.buildConvNode(c, this.ROOT))); // 文件夹 sortedFolders.forEach(f => list.appendChild(this.buildFolderNode(f, 0))); }, buildFolderNode(folder, level) { const maxDepth = 1; const children = this.data.folders.filter(f => f.parentId === folder.id); const convs = (this.data.folderContents[folder.id] || []).slice().sort((a, b) => (b.starred ? 1 : 0) - (a.starred ? 1 : 0) || (b.addedAt || 0) - (a.addedAt || 0)); const node = el('div', { class: 'gv-folder-item', dataset: { folderId: folder.id } }); const header = el('div', { class: 'gv-folder-item-header', style: { paddingLeft: (12 + level * 16) + 'px' } }, [ el('span', { style: { cursor: 'pointer', fontSize: '11px' }, onclick: () => { folder.isExpanded = !folder.isExpanded; this.save(); this.render(); } }, [folder.isExpanded ? '▾' : '▸']), el('span', {}, ['📁']), el('span', { class: 'gv-folder-name' }, [folder.name + (convs.length ? ` (${convs.length})` : '')]), el('button', { title: t('rename'), style: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '12px', color: '#5f6368' }, onclick: (e) => { e.stopPropagation(); this.renameFolder(folder.id); } }, ['✎']), el('button', { title: t('delete'), style: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '12px', color: '#5f6368' }, onclick: (e) => { e.stopPropagation(); this.deleteFolder(folder.id); } }, ['🗑']), ]); header.draggable = true; header.addEventListener('dragover', (e) => e.preventDefault()); header.addEventListener('drop', (e) => { e.preventDefault(); try { const data = JSON.parse(e.dataTransfer.getData('application/json')); if (data.type === 'conversation') { data.conversations.forEach(c => this.addToFolder(folder.id, c)); } } catch (err) {} }); node.appendChild(header); if (folder.isExpanded) { const content = el('div', { class: 'gv-folder-content' }); convs.forEach(c => content.appendChild(this.buildConvNode(c, folder.id))); if (level < maxDepth) children.forEach(cf => content.appendChild(this.buildFolderNode(cf, level + 1))); node.appendChild(content); } return node; }, buildConvNode(conv, folderId) { const node = el('div', { class: 'gv-folder-conversation' + (conv.starred ? ' gv-starred' : ''), dataset: { convId: conv.conversationId } }, [ el('a', { href: conv.url, title: conv.title }, [conv.title || conv.conversationId]), el('button', { title: conv.starred ? t('unstar') : t('starred'), style: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '12px' }, onclick: (e) => { e.preventDefault(); e.stopPropagation(); this.toggleStar(conv.conversationId); } }, [conv.starred ? '⭐' : '☆']), el('button', { title: t('removeFromFolder'), style: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '12px', color: '#5f6368' }, onclick: (e) => { e.preventDefault(); e.stopPropagation(); this.removeFromFolder(conv.conversationId); } }, ['×']), ]); node.draggable = true; node.addEventListener('dragstart', (e) => { e.dataTransfer.setData('application/json', JSON.stringify({ type: 'conversation', conversations: [conv], sourceFolderId: folderId })); }); return node; }, refresh() { if (!this.container || !document.body.contains(this.container)) { this.injectContainer(); } else { this.render(); } }, destroy() { this.observer?.disconnect(); this.container?.remove(); this.container = null; }, }; GV.FolderManager = FolderManager; /* ========================================================================= * SECTION 11: 时间线导航 (Timeline) — Gemini * ========================================================================= */ const Timeline = { bar: null, track: null, tooltip: null, markers: [], activeIdx: -1, observer: null, conversationId: null, scrollContainer: null, getUserTurnSelectors() { return [ '.user-query-bubble-with-background', '.user-query-bubble-container', '.user-query-container', 'user-query-content .user-query-bubble-with-background', 'div[aria-label="User message"]', 'article[data-author="user"]', 'article[data-turn="user"]', '[data-message-author-role="user"]', 'div[role="listitem"][data-user="true"]', ]; }, start() { this.buildUI(); this.observer = observeMutations(document.body, () => this.refresh(), { childList: true, subtree: true }, 300); watchRoute(() => setTimeout(() => this.refresh(), 400)); setTimeout(() => this.refresh(), 1000); Store.onChange(SK.TIMELINE_HIDE, () => this.updateVisibility()); }, buildUI() { if (this.bar && document.body.contains(this.bar)) return; this.bar = el('div', { class: 'gv-timeline-bar' }, [ el('div', { class: 'gv-timeline-track' }), ]); this.track = $('.gv-timeline-track', this.bar); this.tooltip = el('div', { class: 'gv-timeline-tooltip' }); document.body.appendChild(this.bar); document.body.appendChild(this.tooltip); this.updateVisibility(); }, updateVisibility() { const hidden = Store.get(SK.TIMELINE_HIDE, true); // 注意:原扩展 hideContainer 默认 false,这里用反向 this.bar.style.display = hidden ? 'none' : 'flex'; }, getConversationId() { const m = location.pathname.match(/\/(?:u\/\d+\/)?(?:app|gem)(?:\/|$)([^/?#]*)/); return m && m[1] ? hashStr(m[1]) : hashStr(location.pathname); }, getConversationTitle() { const title = document.title.replace(/\s*[-—]\s*Gemini.*$/i, '').trim(); if (title && !/^(gemini|google gemini)$/i.test(title)) return title; const item = $('mat-list-item.mdc-list-item--activated [mat-line], mat-list-item[aria-current="page"] [mat-line]'); return (item?.textContent || '').trim() || 'Conversation'; }, findScrollContainer() { const sels = this.getUserTurnSelectors(); for (const s of sels) { const first = $(s); if (first) { let p = first.parentElement; while (p && p !== document.body) { const style = getComputedStyle(p); if ((style.overflowY === 'auto' || style.overflowY === 'scroll') && p.scrollHeight > p.clientHeight) return p; p = p.parentElement; } } } return document.scrollingElement || document.documentElement; }, extractTurnText(node) { const clone = node.cloneNode(true); $$('.visually-hidden, .gv-fork-btn, .gv-fork-confirm', clone).forEach(e => e.remove()); let text = (clone.textContent || '').replace(/\s+/g, ' ').trim(); text = text.replace(/^(you said|you wrote|user message|your prompt|you asked)[:\s]*/i, ''); return text; }, refresh() { if (!Store.get(SK.TIMELINE_HIDE, true)) { /* hideContainer 未启用 */ } if (this.bar.style.display === 'none') return; const container = $('main') || document.body; const sels = this.getUserTurnSelectors(); let turns = []; for (const s of sels) { turns = $$(s, container); if (turns.length) break; } // 过滤嵌套 const turnSet = new Set(turns); turns = turns.filter(t => { let p = t.parentElement; while (p) { if (turnSet.has(p)) return false; p = p.parentElement; } return true; }); // 去重 const seen = new Set(); turns = turns.filter(t => { const key = this.extractTurnText(t).slice(0, 50) + '|' + t.offsetTop; if (seen.has(key)) return false; seen.add(key); return true; }); if (turns.length === 0) { this.renderMarkers([]); return; } this.scrollContainer = this.findScrollContainer(); this.conversationId = this.getConversationId(); const firstOffset = turns[0].offsetTop; const lastOffset = turns[turns.length - 1].offsetTop; const span = Math.max(1, lastOffset - firstOffset); const markers = turns.map((t, i) => ({ turnId: hashStr(this.conversationId + '_' + i + '_' + this.extractTurnText(t).slice(0, 30)), element: t, text: this.extractTurnText(t), ratio: i === 0 ? 0 : (t.offsetTop - firstOffset) / span, })); this.renderMarkers(markers); this.setupScrollSync(); }, renderMarkers(markers) { this.markers = markers; if (!this.track) return; this.track.innerHTML = ''; const starred = this.getStarred(); markers.forEach((m, i) => { const dot = el('button', { class: 'gv-timeline-dot' + (starred.includes(m.turnId) ? ' starred' : ''), style: { top: (m.ratio * 100) + '%' }, onclick: () => this.navigateTo(m), onmouseenter: () => this.showTooltip(m, dot), onmouseleave: () => this.hideTooltip(), }); dot.dataset.idx = i; m.dot = dot; this.track.appendChild(dot); }); this.updateActive(0); }, getStarred() { const all = Store.get(SK.TIMELINE_STARRED, { messages: {} }); const msgs = (all.messages || {})[this.conversationId] || []; return msgs.map(m => m.turnId); }, toggleStar(turnId) { const all = Store.get(SK.TIMELINE_STARRED, { messages: {} }); if (!all.messages) all.messages = {}; if (!all.messages[this.conversationId]) all.messages[this.conversationId] = []; const arr = all.messages[this.conversationId]; const idx = arr.findIndex(m => m.turnId === turnId); const marker = this.markers.find(m => m.turnId === turnId); if (idx >= 0) arr.splice(idx, 1); else arr.push({ turnId, content: marker?.text?.slice(0, 200) || '', conversationId: this.conversationId, conversationUrl: location.href, conversationTitle: this.getConversationTitle(), starredAt: Date.now() }); Store.set(SK.TIMELINE_STARRED, all); this.refresh(); toast(idx >= 0 ? t('unstar') : t('starred'), 'success', 1200); }, navigateTo(marker) { const target = marker.element; if (!target) return; const container = this.scrollContainer || document.scrollingElement; const rect = target.getBoundingClientRect(); const containerRect = container.getBoundingClientRect ? container.getBoundingClientRect() : { top: 0, height: window.innerHeight }; const top = rect.top - containerRect.top - (containerRect.height || window.innerHeight) * 0.3 + (container.scrollTop || 0); if (container === document.scrollingElement || container === document.documentElement) { window.scrollTo({ top, behavior: Store.get(SK.TIMELINE_SCROLL_MODE, 'flow') === 'flow' ? 'smooth' : 'auto' }); } else { container.scrollTo({ top, behavior: Store.get(SK.TIMELINE_SCROLL_MODE, 'flow') === 'flow' ? 'smooth' : 'auto' }); } }, setupScrollSync() { if (!this.scrollContainer) return; if (this._scrollHandler) this.scrollContainer.removeEventListener('scroll', this._scrollHandler); this._scrollHandler = debounce(() => this.updateActiveFromScroll(), 80); this.scrollContainer.addEventListener('scroll', this._scrollHandler); }, updateActiveFromScroll() { if (!this.scrollContainer || !this.markers.length) return; const scrollTop = this.scrollContainer.scrollTop || window.scrollY; const vh = (this.scrollContainer.clientHeight || window.innerHeight); const targetY = scrollTop + vh * 0.45; let nearest = 0, nearestDist = Infinity; this.markers.forEach((m, i) => { const top = m.element.offsetTop; const d = Math.abs(top - targetY); if (d < nearestDist) { nearestDist = d; nearest = i; } }); this.updateActive(nearest); }, updateActive(idx) { if (idx === this.activeIdx) return; this.activeIdx = idx; $$('.gv-timeline-dot', this.track).forEach((d, i) => d.classList.toggle('active', i === idx)); }, showTooltip(marker, dot) { this.tooltip.textContent = marker.text.slice(0, 200) + (marker.text.length > 200 ? '…' : ''); const rect = dot.getBoundingClientRect(); this.tooltip.style.left = (rect.left - this.tooltip.offsetWidth - 12) + 'px'; this.tooltip.style.top = (rect.top + rect.height / 2 - this.tooltip.offsetHeight / 2) + 'px'; this.tooltip.classList.add('show'); }, hideTooltip() { this.tooltip.classList.remove('show'); }, destroy() { this.observer?.disconnect(); this.bar?.remove(); this.tooltip?.remove(); this.bar = null; this.tooltip = null; }, }; GV.Timeline = Timeline; /* ========================================================================= * SECTION 12: 对话导出 (Export) — Gemini * ========================================================================= */ const Exporter = { toolbar: null, start() { this.injectToolbar(); observeMutations(document.body, () => this.ensureToolbar(), { childList: true, subtree: true }, 500); }, injectToolbar() { if (this.toolbar && document.body.contains(this.toolbar)) return; this.toolbar = el('div', { class: 'gv-export-toolbar' }, [ el('button', { title: t('exportJson'), onclick: () => this.exportChat('json') }, ['{}']), el('button', { title: t('exportMd'), onclick: () => this.exportChat('markdown') }, ['MD']), ]); document.body.appendChild(this.toolbar); }, ensureToolbar() { if (!this.toolbar || !document.body.contains(this.toolbar)) this.injectToolbar(); }, resolveConversationRoot() { const sels = ['#chat-history', 'infinite-scroller.chat-history', 'chat-window-content', 'main']; for (const s of sels) { const r = $(s); if (r && $$('*').length > 10) return r; } return $('main') || document.body; }, getUserTurnSelectors() { return Timeline.getUserTurnSelectors(); }, getAssistantSelectors() { return ['[aria-label="Gemini response"]', 'model-response', '.model-response', 'response-container', '.presented-response-container', '[data-message-author-role="assistant"]', 'article[data-author="assistant"]']; }, collectTurns() { const root = this.resolveConversationRoot(); const userSels = this.getUserTurnSelectors(); const asstSels = this.getAssistantSelectors(); let userTurns = []; for (const s of userSels) { userTurns = $$(s, root); if (userTurns.length) break; } let asstTurns = []; for (const s of asstSels) { asstTurns = $$(s, root); if (asstTurns.length) break; } // 过滤 deep-research 沉浸面板 userTurns = userTurns.filter(t => !t.closest('deep-research-immersive-panel')); asstTurns = asstTurns.filter(t => !t.closest('deep-research-immersive-panel')); // 按位置排序配对 const all = [...userTurns, ...asstTurns].sort((a, b) => { if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) return -1; return 1; }); const turns = []; let currentUser = null; all.forEach(node => { if (userTurns.includes(node)) { if (currentUser) turns.push({ user: currentUser, assistant: '' }); currentUser = this.extractText(node); } else { if (currentUser) { turns.push({ user: currentUser, assistant: this.extractText(node) }); currentUser = null; } } }); if (currentUser) turns.push({ user: currentUser, assistant: '' }); return turns; }, extractText(node) { const clone = node.cloneNode(true); $$('button, .buttons, .copy-button, script, style, [aria-hidden="true"]', clone).forEach(e => e.remove()); return (clone.innerText || clone.textContent || '').trim(); }, getTitle() { return Timeline.getConversationTitle(); }, async exportChat(format) { const turns = this.collectTurns(); if (turns.length === 0) { toast('No content', 'warning'); return; } const metadata = { url: location.href, exportedAt: new Date().toISOString(), title: this.getTitle(), count: turns.length }; if (format === 'json') this.downloadJSON(turns, metadata); else if (format === 'markdown') await this.downloadMarkdown(turns, metadata); }, downloadJSON(turns, metadata) { const data = { format: 'gemini-voyager.chat.v1', ...metadata, items: turns }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' }); this.triggerDownload(blob, this.filename('json')); toast(t('done'), 'success'); }, async downloadMarkdown(turns, metadata) { let md = `# ${metadata.title}\n\n**${t('exportedAt')}**: ${metadata.exportedAt}\n**${t('turns')}**: ${metadata.count}\n**Source**: [${metadata.title}](${metadata.url})\n\n---\n\n`; turns.forEach((turn, i) => { md += `## ${t('user')} ${i + 1}\n\n${turn.user}\n\n### ${t('assistant')}\n\n${turn.assistant}\n\n---\n\n`; }); md += `Source: ${metadata.url}\nExported at: ${metadata.exportedAt}\n`; const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); this.triggerDownload(blob, this.filename('md')); toast(t('done'), 'success'); }, triggerDownload(blob, filename) { downloadBlobCompat(blob, filename); }, filename(ext) { let title = this.getTitle().replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, '-').slice(0, 80); if (!title) title = `gemini-chat-${new Date().toISOString().slice(0, 10)}`; return `${title}.${ext}`; }, destroy() { this.toolbar?.remove(); this.toolbar = null; }, }; GV.Exporter = Exporter; /* ========================================================================= * SECTION 13: Mermaid 渲染 — Gemini * ========================================================================= */ const MermaidRenderer = { observer: null, initialized: false, loadFailed: false, KEYWORDS: ['graph','flowchart','sequenceDiagram','classDiagram','stateDiagram','erDiagram','gantt','pie','journey','gitGraph','mindmap','timeline','xychart-beta','sankey-beta','block-beta','architecture-beta','requirementDiagram','cqldiagram','quadrantChart','sequenceDiagram','%%'], start() { if (!Store.get(SK.MERMAID_ENABLED, true)) return; this.initMermaid(); this.observer = observeMutations(document.body, () => this.processCodeBlocks(), { childList: true, subtree: true, characterData: true }, 1000); setTimeout(() => this.processCodeBlocks(), 1500); Store.onChange(SK.MERMAID_ENABLED, (v) => { if (v) { this.start(); } else { this.observer?.disconnect(); } }); }, initMermaid() { if (this.initialized || this.loadFailed) return; try { if (typeof mermaid === 'undefined') { this.loadFailed = true; return; } mermaid.initialize({ startOnLoad: false, theme: isDark() ? 'dark' : 'default', securityLevel: 'antiscript', fontFamily: 'Google Sans, Roboto, sans-serif', logLevel: 5 }); this.initialized = true; } catch (e) { this.loadFailed = true; } }, isMermaidCode(code) { const trimmed = code.trim(); if (trimmed.length < 50) return false; const first = trimmed.split('\n')[0].trim(); if (!this.KEYWORDS.some(k => first.startsWith(k))) return false; const lines = trimmed.split('\n'); if (lines.length < 3) return false; const last = lines[lines.length - 1].trim(); if (['-->','---','-.','==>',':::','[','(','{','|','&',','].some(s => last.endsWith(s))) return false; return true; }, normalizeCode(code) { return code.replace(/[\u00A0\u2003\u2002\u2009\u3000]/g, ' ').replace(/[\u200B\u200C\u200D\uFEFF]/g, ''); }, processCodeBlocks() { if (!this.initialized) { this.initMermaid(); if (!this.initialized) return; } const blocks = $$('code[data-test-id="code-content"]'); blocks.forEach(codeEl => { if (codeEl.dataset.mermaidProcessing) return; const codeBlock = codeEl.closest('code-block'); if (!codeBlock) return; if (codeBlock.dataset.mermaidProcessed) return; // 语言标签 const labelEl = $('.code-block-decoration > :scope > span', codeBlock.parentElement) || $('.code-block-decoration span', codeBlock.parentElement); const label = (labelEl?.textContent || '').trim().toLowerCase(); const GENERIC = ['code','代码','snippet','text','']; let shouldRender = false; if (label === 'mermaid') shouldRender = true; else if (label && !GENERIC.includes(label)) shouldRender = false; // 具体编程语言,跳过 else shouldRender = this.isMermaidCode(codeEl.textContent || ''); if (!shouldRender) return; codeEl.dataset.mermaidProcessing = '1'; codeBlock.dataset.mermaidProcessed = '1'; this.render(codeEl, codeBlock); }); }, async render(codeEl, codeBlock) { const code = this.normalizeCode(codeEl.textContent || ''); const id = 'gv-mermaid-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6); try { const result = await mermaid.render(id, code); const svg = typeof result === 'string' ? result : result.svg; this.wrap(codeBlock, svg, code); } catch (e) { // 清理 mermaid 错误 SVG $$('[id^="d"]').forEach(n => { if (n.textContent.includes('Syntax error')) n.remove(); }); codeEl.dataset.mermaidProcessing = ''; codeBlock.dataset.mermaidProcessed = ''; } }, wrap(codeBlock, svg, code) { if (codeBlock.parentElement?.classList?.contains('gv-mermaid-wrapper')) return; const wrapper = el('div', { class: 'gv-mermaid-wrapper' }); const toggle = el('div', { class: 'gv-mermaid-toggle' }, [ el('button', { class: 'active', onclick: (e) => this.switchTab(e, true) }, ['📊 Diagram']), el('button', { onclick: (e) => this.switchTab(e, false) }, [' Code']), ]); const diagram = el('div', { class: 'gv-mermaid-diagram', html: svg }); diagram.addEventListener('click', () => this.openFullscreen(svg)); codeBlock.style.display = 'none'; codeBlock.parentElement.insertBefore(wrapper, codeBlock); wrapper.appendChild(toggle); wrapper.appendChild(diagram); wrapper.appendChild(codeBlock); }, switchTab(e, showDiagram) { const wrapper = e.target.closest('.gv-mermaid-wrapper'); if (!wrapper) return; const btns = $$('.gv-mermaid-toggle button', wrapper); btns[0].classList.toggle('active', showDiagram); btns[1].classList.toggle('active', !showDiagram); $('.gv-mermaid-diagram', wrapper).style.display = showDiagram ? '' : 'none'; $('code-block', wrapper).style.display = showDiagram ? 'none' : ''; }, openFullscreen(svg) { const modal = el('div', { class: 'gv-mermaid-modal', onclick: (e) => { if (e.target === modal) modal.remove(); } }, [ el('div', { class: 'gv-mermaid-modal-content', html: svg, style: { cursor: 'grab' } }), ]); document.body.appendChild(modal); const esc = (e) => { if (e.key === 'Escape') { modal.remove(); document.removeEventListener('keydown', esc); } }; document.addEventListener('keydown', esc); // 简单缩放 let scale = 1; const content = $('.gv-mermaid-modal-content', modal); modal.addEventListener('wheel', (e) => { e.preventDefault(); scale = Math.max(0.2, Math.min(5, scale + (e.deltaY < 0 ? 0.1 : -0.1))); content.style.transform = `scale(${scale})`; }); }, }; GV.MermaidRenderer = MermaidRenderer; /* ========================================================================= * SECTION 14: 公式复制 (Formula Copy) — 跨站点 * ========================================================================= */ const FormulaCopy = { start() { if (!Store.get(SK.FORMULA_COPY_ENABLED, true)) return; document.addEventListener('click', (e) => this.handleClick(e), true); Store.onChange(SK.FORMULA_COPY_ENABLED, (v) => { /* 实时生效 */ }); }, findMathElement(target) { const checks = [ () => target.closest('[data-math]'), () => { const c = target.closest('.math-inline, .math-block'); return c ? (c.querySelector('[data-math]') || c) : null; }, () => target.closest('ms-katex'), () => target.closest('.katex'), () => target.closest('.katex-display'), ]; for (const fn of checks) { const r = fn(); if (r) return r; } return null; }, extractLatex(element) { const fromAttr = element.getAttribute('data-math'); if (fromAttr) return fromAttr; const annot = element.querySelector('annotation[encoding="application/x-tex"]') || element.querySelector('annotation'); if (annot) return annot.textContent || ''; return null; }, isDisplayMode(element) { if (element.closest('.math-block')) return true; if (element.querySelector('math[display="block"]')) return true; return false; }, handleClick(e) { const mathEl = this.findMathElement(e.target); if (!mathEl) return; const latex = this.extractLatex(mathEl); if (!latex) return; e.stopPropagation(); const format = Store.get(SK.FORMULA_COPY_FORMAT, 'latex'); const display = this.isDisplayMode(mathEl); let text; if (format === 'unicodemath') { text = this.toWordMathML(latex, display); this.copyToClipboard(text, text, true); } else { text = this.wrapFormula(latex, format, display); this.copyToClipboard(text, null, false); } this.showToast(e.clientX, e.clientY, t('copySuccess')); }, wrapFormula(latex, format, display) { if (format === 'no-dollar') return latex; if (format === 'notion') return `$$${latex}$$`; return display ? `$$${latex}$$` : `$${latex}$`; }, toWordMathML(latex, display) { try { const clean = latex.replace(/^\$\$|\$\$$|^\$|\$$|^\\\(|\\\)$|^\\\[|\\\]$/g, ''); const mathml = temml.renderToString(clean, { displayMode: display, xml: true, throwOnError: true, colorIsTextColor: true, trust: false }); let cleaned = mathml.replace(/]*>[\s\S]*?<\/annotation>/g, '').replace(/]*>[\s\S]*?<\/annotation-xml>/g, '').replace(/<\/?semantics>/g, ''); if (!cleaned.includes('xmlns')) cleaned = cleaned.replace(' { if (node.nodeType === 1) { node.prefix = 'mml'; for (let i = 0; i < node.childNodes.length; i++) addPrefix(node.childNodes[i]); } }; addPrefix(doc.documentElement); const serialized = new XMLSerializer().serializeToString(doc.documentElement).replace(/<(\/?)(\w+)/g, '<$1mml:$2'); return `

${serialized}

`; } catch (e) { return this.wrapFormula(latex, 'latex', display); } }, copyToClipboard(text, html, isMathML) { if (navigator.clipboard && navigator.clipboard.write) { const items = { 'text/plain': new Blob([text], { type: 'text/plain' }) }; if (html) items['text/html'] = new Blob([html], { type: 'text/html' }); navigator.clipboard.write([new ClipboardItem(items)]).catch(() => this.fallback(text)); } else this.fallback(text); }, fallback(text) { const ta = el('textarea', { style: { position: 'fixed', opacity: '0' } }, [text]); document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} ta.remove(); }, showToast(x, y, msg) { const node = el('div', { class: 'gv-copy-toast', style: { left: (x) + 'px', top: (y - 40) + 'px', background: '#1e8e3e', color: '#fff', opacity: '0' } }, [msg]); document.body.appendChild(node); requestAnimationFrame(() => { node.style.opacity = '1'; }); setTimeout(() => { node.style.opacity = '0'; setTimeout(() => node.remove(), 200); }, 1800); }, }; GV.FormulaCopy = FormulaCopy; /* ========================================================================= * SECTION 15: Markdown 修复 (Markdown Patcher) — Gemini * ========================================================================= */ const MarkdownPatcher = { observer: null, pending: new Set(), start() { if (!Store.get(SK.MARKDOWN_PATCH_ENABLED, true)) return; this.observer = new MutationObserver((muts) => { muts.forEach(m => m.addedNodes.forEach(n => { if (n.nodeType === 1) this.pending.add(n); })); if (this.pending.size) this.scheduleFix(); }); this.observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => this.fixAll(), 1500); }, scheduleFix: debounce(function () { MarkdownPatcher.pending.forEach(n => MarkdownPatcher.fixBrokenBold(n)); MarkdownPatcher.pending.clear(); }, 100), shouldSkip(node) { return node.closest('code, pre, code-block, .math-block, .math-inline, rich-textarea, [contenteditable="true"], [role="textbox"]'); }, fixAll() { $$('main, .conversation-container').forEach(c => this.fixBrokenBold(c)); }, fixBrokenBold(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (!node.textContent || !node.textContent.includes('**')) return NodeFilter.FILTER_REJECT; if (this.shouldSkip(node)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, }); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(n => this.fixNode(n)); nodes.forEach(n => this.fixCrossNode(n)); }, fixNode(textNode) { const text = textNode.textContent; const re = /\*\*([^\s].*?[^\s]|[^\s])\*\*/g; if (!re.test(text)) return; re.lastIndex = 0; const frag = document.createDocumentFragment(); let last = 0, m; while ((m = re.exec(text))) { if (m.index > last) frag.appendChild(document.createTextNode(text.slice(last, m.index))); const strong = el('strong', {}, [m[1]]); frag.appendChild(strong); last = m.index + m[0].length; } if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); textNode.parentNode.replaceChild(frag, textNode); }, fixCrossNode(textNode) { const text = textNode.textContent || ''; const startIdx = text.lastIndexOf('**'); if (startIdx === -1) return; let node = textNode.nextSibling, endNode = null; const middleNodes = []; let steps = 0; while (node && steps < 10) { if (node.nodeType === 3) { if (node.textContent.includes('**')) { endNode = node; break; } middleNodes.push(node); } else if (node.nodeType === 1 && node.hasAttribute('data-path-to-node')) { middleNodes.push(node); } else break; node = node.nextSibling; steps++; } if (!endNode) return; try { const strong = el('strong'); strong.appendChild(document.createTextNode(text.slice(startIdx + 2))); middleNodes.forEach(n => strong.appendChild(n)); const endText = endNode.textContent; const endIdx = endText.indexOf('**'); strong.appendChild(document.createTextNode(endText.slice(0, endIdx))); textNode.textContent = text.slice(0, startIdx); endNode.textContent = endText.slice(endIdx + 2); textNode.parentNode.insertBefore(strong, middleNodes[0] || endNode); } catch (e) {} }, }; GV.MarkdownPatcher = MarkdownPatcher; /* ========================================================================= * SECTION 16: 水印移除 (Watermark Remover / NanoBanana) — Gemini * ========================================================================= */ const WatermarkRemover = { observer: null, processed: new WeakSet(), downloadListenerInstalled: false, start() { if (!watermarkPreviewEnabled() && !watermarkDownloadEnabled()) return; this.observer = observeMutations(document.body, () => this.processImages(), { childList: true, subtree: true }, 800); setTimeout(() => this.processImages(), 1500); if (watermarkDownloadEnabled()) this.setupDownloadInterceptor(); Store.onChange(SK.WATERMARK_ENABLED, () => { if (!watermarkPreviewEnabled() && !watermarkDownloadEnabled()) this.observer?.disconnect(); }); Store.onChange(SK.WATERMARK_PREVIEW_ENABLED, (v) => { if (!v) this.observer?.disconnect(); else this.start(); }); Store.onChange(SK.WATERMARK_DOWNLOAD_ENABLED, (v) => { if (v) this.setupDownloadInterceptor(); }); }, processImages() { const imgs = $$('img[src*="googleusercontent.com"], img[src*="ggpht.com"]'); imgs.forEach(img => { if (this.processed.has(img)) return; const container = img.closest('generated-image, .generated-image-container'); if (!container) return; this.processed.add(img); this.attachIndicator(img); if (watermarkPreviewEnabled()) this.processPreview(img); }); }, attachIndicator(img) { const btn = img.closest('generated-image')?.querySelector('download-generated-image-button, button[data-test-id="download-generated-image-button"]'); if (!btn || btn.querySelector('.nanobanana-indicator')) return; if (getComputedStyle(btn).position === 'static') btn.style.position = 'relative'; btn.appendChild(el('span', { class: 'nanobanana-indicator', style: { position: 'absolute', top: '2px', right: '2px', pointerEvents: 'none', fontSize: '14px' } }, ['🍌'])); }, getOriginalUrl(src) { return src.replace(/=[swh]\d+(?:-[wh]\d+)*/, '=s0'); }, async processPreview(img) { try { const originalUrl = this.getOriginalUrl(img.src); const blob = await this.fetchImage(originalUrl); if (!blob) return; const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); this.removeWatermark(ctx, canvas.width, canvas.height); canvas.toBlob((processedBlob) => { if (processedBlob) { img.dataset.watermarkOriginalSrc = img.src; img.src = URL.createObjectURL(processedBlob); } }, 'image/png'); } catch (e) {} }, async fetchImage(url) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'blob', onload: (r) => resolve(r.response), onerror: () => resolve(null), }); }); }, // 水印位于右下角,通过反混合算法移除(简化版:用周围像素填充) removeWatermark(ctx, w, h) { const logoSize = Math.max(36, Math.min(96, Math.round(Math.min(w, h) * 0.07))); const margin = Math.round(logoSize * 0.67); const wx = w - margin - logoSize, wy = h - margin - logoSize; if (wx < 0 || wy < 0) return; // 采样水印区域上方和左侧的像素,用作背景覆盖 const patch = ctx.getImageData(wx, wy, logoSize, logoSize); const data = patch.data; // 用左侧列像素向右延伸覆盖 const leftCol = []; for (let y = 0; y < logoSize; y++) { const i = (y * logoSize) * 4; leftCol.push([data[i], data[i + 1], data[i + 2], data[i + 3]]); } for (let x = 0; x < logoSize; x++) { for (let y = 0; y < logoSize; y++) { const i = (y * logoSize + x) * 4; const src = leftCol[y]; // 与左侧颜色混合,逐渐过渡 const blend = Math.min(1, x / (logoSize * 0.4)); data[i] = data[i] * blend + src[0] * (1 - blend); data[i + 1] = data[i + 1] * blend + src[1] * (1 - blend); data[i + 2] = data[i + 2] * blend + src[2] * (1 - blend); } } ctx.putImageData(patch, wx, wy); }, setupDownloadInterceptor() { if (this.downloadListenerInstalled) return; this.downloadListenerInstalled = true; // 拦截下载按钮点击,提供去水印下载 document.addEventListener('click', (e) => { const btn = e.target.closest('download-generated-image-button, button[data-test-id="download-generated-image-button"]'); if (!btn || !watermarkDownloadEnabled()) return; const container = btn.closest('generated-image, .generated-image-container'); const img = container?.querySelector('img[src*="googleusercontent.com"], img[src*="ggpht.com"]'); if (!img) return; e.preventDefault(); e.stopPropagation(); this.downloadProcessed(img); }, true); }, async downloadProcessed(img) { toast(t('processing'), 'info', 1500); try { const url = img.dataset.watermarkOriginalSrc || this.getOriginalUrl(img.src); const blob = await this.fetchImage(url); if (!blob) { toast(t('error'), 'error'); return; } const bitmap = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0); this.removeWatermark(ctx, canvas.width, canvas.height); canvas.toBlob((processedBlob) => { if (!processedBlob) { toast(t('error'), 'error'); return; } downloadBlobCompat(processedBlob, `gemini-image-${Date.now()}.png`); toast(t('done'), 'success'); }, 'image/png'); } catch (err) { toast(t('error'), 'error'); } }, }; GV.WatermarkRemover = WatermarkRemover; /* ========================================================================= * SECTION 17: 引用回复 (Quote Reply) — Gemini * ========================================================================= */ const QuoteReply = { toolbar: null, currentRange: null, start() { if (!Store.get(SK.QUOTE_REPLY_ENABLED, true)) return; this.toolbar = el('div', { class: 'gv-selection-toolbar gv-hidden', style: { display: 'none' } }, [ el('button', { title: t('quoteReply'), onclick: () => this.doQuote() }, ['❝']), ]); document.body.appendChild(this.toolbar); const debounced = debounce(() => this.checkSelection(), 250); document.addEventListener('mouseup', debounced); document.addEventListener('keyup', (e) => { if (e.key === 'Shift' || e.key.startsWith('Arrow')) debounced(); }); document.addEventListener('scroll', () => { if (this.currentRange) this.updatePosition(); }, true); }, checkSelection() { const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) { this.hide(); return; } const range = sel.getRangeAt(0); const main = $('main'); if (!main || !main.contains(range.commonAncestorContainer)) { this.hide(); return; } if (range.commonAncestorContainer.parentElement?.closest('nav, [role="navigation"], .sidebar, .mat-drawer, [contenteditable="true"]')) { this.hide(); return; } this.currentRange = range.cloneRange(); this.show(); }, show() { this.toolbar.style.display = 'flex'; this.updatePosition(); }, hide() { this.toolbar.style.display = 'none'; this.currentRange = null; }, updatePosition() { if (!this.currentRange) return; const rect = this.currentRange.getBoundingClientRect(); const firstRect = this.currentRange.getClientRects()[0] || rect; const tb = this.toolbar.getBoundingClientRect(); this.toolbar.style.top = (firstRect.top - tb.height - 16) + 'px'; this.toolbar.style.left = (rect.left + rect.width / 2 - tb.width / 2) + 'px'; }, extractText(range) { const clone = range.cloneContents(); const div = el('div', {}, [clone]); // 处理数学公式 $$('.math-inline, .math-block, [data-math]', div).forEach(m => { const latex = m.getAttribute('data-math') || m.querySelector('annotation')?.textContent || ''; const isBlock = m.classList.contains('math-block'); m.replaceWith(document.createTextNode(isBlock ? `$$${latex}$$` : `$${latex}$`)); }); return div.innerText || div.textContent || ''; }, doQuote() { if (!this.currentRange) return; const text = this.extractText(this.currentRange); if (!text.trim()) return; const quote = text.split('\n').map(l => '> ' + l).join('\n') + '\n'; const input = PromptManager.findChatInput(); if (input) { input.focus(); const isEmpty = !input.textContent?.trim() && !input.value?.trim(); const sep = isEmpty ? '' : '\n\n'; try { document.execCommand('insertText', false, sep + quote); } catch (e) { const range = document.createRange(); range.selectNodeContents(input); range.collapse(false); range.insertNode(document.createTextNode(sep + quote)); input.dispatchEvent(new Event('input', { bubbles: true })); } } else { PromptManager.copyText(quote); toast(t('copySuccess'), 'success'); } this.hide(); window.getSelection()?.removeAllRanges(); }, }; GV.QuoteReply = QuoteReply; /* ========================================================================= * SECTION 18: 阻止自动滚动 (Prevent Auto Scroll) — Gemini(需 MAIN world) * ========================================================================= */ const PreventAutoScroll = { start() { if (!Store.get(SK.PREVENT_AUTO_SCROLL, true)) return; this.install(); Store.onChange(SK.PREVENT_AUTO_SCROLL, (v) => { if (v) this.install(); }); }, install() { if (unsafeWindow.__gvPreventAutoScrollInstalled) return; unsafeWindow.__gvPreventAutoScrollInstalled = true; const win = unsafeWindow; let nativeScrollAllowedUntil = Date.now() + 8000; let blockScrollUntil = 0; const isEnabled = () => Store.get(SK.PREVENT_AUTO_SCROLL, true); const shouldBlock = () => isEnabled() && Date.now() >= nativeScrollAllowedUntil && Date.now() < blockScrollUntil; const markSubmit = () => { blockScrollUntil = Date.now() + 120000; nativeScrollAllowedUntil = 0; }; const isChatScroll = (el) => { const sels = ['#chat-history', 'infinite-scroller.chat-history', '.chat-history-scroll-container', 'chat-window', 'chat-window-content', '.conversation-container']; return sels.some(s => el.matches?.(s)) || el.id === 'chat-history'; }; const isScrolledUp = (el) => el.scrollHeight - el.scrollTop - el.clientHeight > 150 && el.scrollHeight > el.clientHeight + 10; // 检测提交意图 document.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.altKey && !e.isComposing) { const target = e.target; if (target && target.closest('textarea, input, [contenteditable], [role="textbox"]')) { if (!Store.get(SK.CTRL_ENTER_SEND, false) || e.ctrlKey || e.metaKey) markSubmit(); } } }, true); document.addEventListener('click', (e) => { const btn = e.target.closest('button, [role="button"]'); if (btn) { const text = (btn.getAttribute('aria-label') || btn.getAttribute('data-test-id') || btn.textContent || '').toLowerCase(); if (/\b(send|submit|run|update)\b|发送|提交|傳送|送出|送信|전송|enviar|envoyer|senden|отправ|إرسال|运行|執行|実行|실행|更新/i.test(text)) markSubmit(); } }, true); // 路由变化允许原生滚动 const allowOnRoute = () => { nativeScrollAllowedUntil = Date.now() + 4000; }; win.addEventListener('popstate', allowOnRoute); win.addEventListener('hashchange', allowOnRoute); const wrap = (fn) => function (...a) { const r = fn.apply(this, a); allowOnRoute(); return r; }; const op = history.pushState; history.pushState = wrap(op); const or = history.replaceState; history.replaceState = wrap(or); // 拦截 scrollTo / scrollBy / scrollIntoView / scrollTop setter const origScrollTo = win.scrollTo; win.scrollTo = function (...a) { if (shouldBlock()) return; return origScrollTo.apply(this, a); }; const origScrollBy = win.scrollBy; win.scrollBy = function (...a) { if (shouldBlock()) return; return origScrollBy.apply(this, a); }; const ep = Element.prototype; const origElScrollTo = ep.scrollTo; ep.scrollTo = function (...a) { if (shouldBlock() && isChatScroll(this) && isScrolledUp(this)) return; return origElScrollTo.apply(this, a); }; const origElScrollBy = ep.scrollBy; ep.scrollBy = function (...a) { if (shouldBlock() && isChatScroll(this) && isScrolledUp(this)) return; return origElScrollBy.apply(this, a); }; const origScrollIntoView = ep.scrollIntoView; ep.scrollIntoView = function (...a) { if (shouldBlock() && isScrolledUp(this)) { // 记录滚动位置,调用后还原 const positions = []; let p = this.parentElement; while (p) { positions.push([p, p.scrollTop, p.scrollLeft]); p = p.parentElement; } const r = origScrollIntoView.apply(this, a); positions.forEach(([el, st, sl]) => { el.scrollTop = st; el.scrollLeft = sl; }); return r; } return origScrollIntoView.apply(this, a); }; // scrollTop setter const desc = Object.getOwnPropertyDescriptor(ep, 'scrollTop'); if (desc && desc.set) { Object.defineProperty(ep, 'scrollTop', { get: desc.get, set(v) { if (shouldBlock() && isChatScroll(this) && isScrolledUp(this) && v > this.scrollTop) return; desc.set.call(this, v); }, configurable: true, }); } }, }; GV.PreventAutoScroll = PreventAutoScroll; /* ========================================================================= * SECTION 19: 输入框折叠 (Input Collapse) — Gemini * ========================================================================= */ const InputCollapse = { observer: null, COLLAPSED: 'gv-input-collapsed', start() { if (!Store.get(SK.INPUT_COLLAPSE, false)) return; this.observer = observeMutations(document.body, () => this.process(), { childList: true, subtree: true }, 150); setTimeout(() => this.process(), 1000); Store.onChange(SK.INPUT_COLLAPSE, (v) => { if (!v) this.cleanup(); }); }, isExcludedPage() { return /^\/(?:u\/\d+\/)?app\/?$/.test(location.pathname) || /^\/(?:u\/\d+\/)?gems\/(?:create|edit)\/?/.test(location.pathname); }, getInputContainer() { const rt = $('rich-textarea'); if (!rt) return null; let p = rt.parentElement, depth = 0; while (p && depth < 8 && p.tagName !== 'MAIN' && p.tagName !== 'BODY' && !p.classList.contains('content-wrapper')) { const cs = getComputedStyle(p); if (cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent' && cs.display.includes('flex')) return p; p = p.parentElement; depth++; } return rt.parentElement?.parentElement || null; }, isInputEmpty(container) { const input = $('rich-textarea, textarea, [contenteditable="true"]', container); if (input && input.textContent?.trim()) return false; if ($('uploader-file-preview, .file-preview-wrapper', container)) return false; return true; }, process() { if (this.isExcludedPage()) return; const container = this.getInputContainer(); if (!container || container.dataset.gvProcessed) return; container.dataset.gvProcessed = '1'; container.classList.add('element-to-collapse'); this.ensurePlaceholder(container); container.addEventListener('click', () => this.expand(container)); container.addEventListener('focusin', () => { this.expand(container); if (this._collapseTimer) clearTimeout(this._collapseTimer); }); container.addEventListener('focusout', () => { this._collapseTimer = setTimeout(() => this.tryCollapse(container), 100); }); container.addEventListener('dragenter', (e) => { if (e.dataTransfer?.types?.includes('Files')) this.expand(container); }); this.tryCollapse(container); }, ensurePlaceholder(container) { if (container.querySelector('.gv-collapse-placeholder')) return; const ph = el('div', { class: 'gv-collapse-placeholder' }, ['💬 ', t('inputCollapse')]); container.appendChild(ph); }, tryCollapse(container) { setTimeout(() => { if (this.isExcludedPage()) { container.classList.remove(this.COLLAPSED); return; } if (container.contains(document.activeElement)) return; const allowNotEmpty = Store.get(SK.INPUT_COLLAPSE_NOT_EMPTY, false); if (allowNotEmpty || this.isInputEmpty(container)) container.classList.add(this.COLLAPSED); }, 150); }, expand(container) { container.classList.remove(this.COLLAPSED); const input = $('.ql-editor, [contenteditable], rich-textarea', container); if (input) setTimeout(() => input.focus(), 0); }, cleanup() { $$(`.${this.COLLAPSED}`).forEach(e => e.classList.remove(this.COLLAPSED)); }, }; GV.InputCollapse = InputCollapse; /* ========================================================================= * SECTION 20: 默认模型 (Default Model) — Gemini * ========================================================================= */ const DefaultModel = { observer: null, start() { if (!Store.get(SK.DEFAULT_MODEL_AUTO_APPLY, true)) return; this.injectStyles(); this.observer = observeMutations(document.body, () => this.injectStarButtons(), { childList: true, subtree: true }, 200); watchRoute(() => setTimeout(() => this.checkAndLock(), 600)); setTimeout(() => this.checkAndLock(), 1500); Store.onChange(SK.DEFAULT_MODEL_AUTO_APPLY, (v) => { if (!v) this.sweep(); }); }, injectStyles() { GM_addStyle(` .gv-default-star-btn { width: 20px; height: 20px; border-radius: 50%; border: none; background: none; cursor: pointer; opacity: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: opacity .15s; } [role="menuitemradio"], [role="menuitem"] { position: relative; } [role="menuitemradio"]:hover .gv-default-star-btn, [role="menuitem"]:hover .gv-default-star-btn { opacity: 1; } .gv-default-star-btn.is-default { opacity: 1; color: #fbbc04; } .gv-title-wrapper { display: flex; align-items: center; width: 100%; gap: 4px; } `); }, findSelectorButton() { const sels = ['[data-test-id="bard-mode-menu-button"]', 'button.input-area-switch', '.input-area-switch-label', '[data-test-id="model-selector"]', 'button[aria-haspopup="menu"].mat-mdc-menu-trigger']; for (const s of sels) { const b = $(s); if (b) return b; } return null; }, injectStarButtons() { const panels = $$('.mat-mdc-menu-panel[role="menu"], gem-menu'); panels.forEach(panel => { if (!panel.querySelector('[data-mode-id], .mode-title, .title-and-description')) return; const items = $$('[role="menuitemradio"], [role="menuitem"]', panel); items.forEach(item => { if (item.querySelector('.gv-default-star-btn')) return; if (item.getAttribute('aria-haspopup') === 'true') return; // 子菜单触发器 if (item.querySelector('[value="thinking_level"]')) return; const titleWrap = $('.title-and-description, .label-container', item) || item; let wrapper = $('.gv-title-wrapper', titleWrap); if (!wrapper) { wrapper = el('div', { class: 'gv-title-wrapper' }); while (titleWrap.firstChild) wrapper.appendChild(titleWrap.firstChild); titleWrap.appendChild(wrapper); } const modelId = item.getAttribute('data-mode-id') || this.extractModelId(item); const modelName = ($('.mode-title, .gds-title-m, .gds-label-l, .label', item)?.textContent || '').trim(); const defaultModel = Store.get(SK.DEFAULT_MODEL, null); const isDefault = defaultModel && (defaultModel.id === modelId || defaultModel === modelName); const star = el('button', { class: 'gv-default-star-btn' + (isDefault ? ' is-default' : ''), title: t('defaultModel'), onclick: (e) => { e.stopPropagation(); this.handleStar(modelId, modelName, star); } }, ['⭐']); wrapper.appendChild(star); }); }); }, extractModelId(item) { const jslog = item.getAttribute('jslog') || ''; const m = jslog.match(/[a-f0-9]{16}/g); return m ? m[m.length - 1] : ''; }, handleStar(modelId, modelName, star) { const defaultModel = Store.get(SK.DEFAULT_MODEL, null); if (defaultModel && (defaultModel.id === modelId || defaultModel === modelName)) { Store.remove(SK.DEFAULT_MODEL); star.classList.remove('is-default'); toast(t('defaultModel') + ': —', 'info', 1200); } else { Store.set(SK.DEFAULT_MODEL, { id: modelId, name: modelName }); star.classList.add('is-default'); toast(t('defaultModel') + ': ' + modelName, 'success', 1200); } // 重新注入更新其它按钮 setTimeout(() => this.injectStarButtons(), 100); }, isNewConversation() { return /^\/(?:u\/\d+\/)?(app|gem)(\/?$|\/[^/]+$)/.test(location.pathname); }, async checkAndLock() { if (!this.isNewConversation()) return; const defaultModel = Store.get(SK.DEFAULT_MODEL, null); if (!defaultModel) return; const btn = this.findSelectorButton(); if (!btn) return; // 快路径:已匹配 const lines = btn.innerText.trim().split('\n'); if (this.modelMatches(defaultModel, lines)) return; // 打开菜单尝试锁定 let failures = 0; const tryLock = async () => { if (failures >= 3) return; const b = this.findSelectorButton(); if (!b) return; b.click(); const panel = await waitFor('.mat-mdc-menu-panel[role="menu"], gem-menu', 1500); if (!panel) { failures++; setTimeout(tryLock, 500); return; } await sleep(100); const items = $$('[role="menuitemradio"], [role="menuitem"]', panel); const target = items.find(it => { const id = it.getAttribute('data-mode-id') || this.extractModelId(it); const name = ($('.mode-title, .gds-title-m, .gds-label-l, .label', it)?.textContent || '').trim(); return (defaultModel.id && id === defaultModel.id) || (defaultModel.name && name === defaultModel.name) || (typeof defaultModel === 'string' && name === defaultModel); }); if (target) { target.click(); setTimeout(() => { const input = PromptManager.findChatInput(); input?.focus(); }, 120); } else { failures++; document.body.click(); setTimeout(tryLock, 500); } }; setTimeout(tryLock, 1000); }, modelMatches(model, lines) { const name = typeof model === 'string' ? model : model.name; if (!name) return false; return lines.some(l => { const lt = l.trim().toLowerCase(); return lt && (lt.includes(name.toLowerCase()) || name.toLowerCase().includes(lt)); }); }, sweep() { $$('.gv-default-star-btn').forEach(b => b.remove()); }, }; GV.DefaultModel = DefaultModel; /* ========================================================================= * SECTION 21: 隐藏最近项 (Gems Hider) — Gemini * ========================================================================= */ const GemsHider = { observer: null, start() { this.observer = observeMutations(document.body, () => this.setup(), { childList: true, subtree: true }, 150); setTimeout(() => this.setup(), 1500); }, setup() { const configs = [ { id: 'gems', sel: '.gems-list-container', key: SK.GEMS_HIDDEN }, { id: 'folders', sel: '.gv-folder-container:not(.gv-aistudio)', req: '.gv-folder-header', key: SK.FOLDERS_HIDDEN }, ]; configs.forEach(cfg => { const targets = $$(cfg.sel); targets.forEach(t => { if (t.dataset.gvSidebarSectionHider) return; if (cfg.req && !$(cfg.req, t)) return; t.dataset.gvSidebarSectionHider = cfg.id; this.applyHidden(t, cfg); const btn = el('button', { title: t('gemsHider'), style: { position: 'absolute', top: '4px', right: '8px', background: 'none', border: 'none', cursor: 'pointer', opacity: '0', padding: '2px', fontSize: '14px' }, onclick: (e) => { e.stopPropagation(); this.toggle(t, cfg); } }, ['👁']); t.style.position = 'relative'; t.addEventListener('mouseenter', () => btn.style.opacity = '1'); t.addEventListener('mouseleave', () => btn.style.opacity = '0'); t.appendChild(btn); }); }); }, applyHidden(target, cfg) { const hidden = Store.get(cfg.key, false); target.classList.toggle('gv-sidebar-section-hidden', hidden); }, toggle(target, cfg) { const hidden = !Store.get(cfg.key, false); Store.set(cfg.key, hidden); target.classList.toggle('gv-sidebar-section-hidden', hidden); }, }; GV.GemsHider = GemsHider; /* ========================================================================= * SECTION 22: 聊天宽度 (Chat Width) — Gemini * ========================================================================= */ const ChatWidth = { styleEl: null, observer: null, start() { this.styleEl = el('style', { id: 'gemini-voyager-chat-width' }); document.head.appendChild(this.styleEl); this.apply(); this.observer = observeMutations($('main') || document.body, () => this.apply(), { childList: true, subtree: true }, 200); Store.onChange(SK.CHAT_WIDTH_ENABLED, () => this.apply()); Store.onChange(SK.CHAT_WIDTH, () => this.apply()); }, apply() { const enabled = Store.get(SK.CHAT_WIDTH_ENABLED, false); if (!enabled) { this.styleEl.textContent = ''; return; } let pct = Store.get(SK.CHAT_WIDTH, 70); if (pct > 100) pct = Math.min(100, Math.round((pct / 1200) * 100)); // 旧像素值迁移 pct = Math.max(30, Math.min(100, pct)); const sw = screen.availWidth || screen.width || 1920; const wv = Math.round((pct / 100) * sw) + 'px'; this.styleEl.textContent = ` .content-wrapper:has(chat-window), .main-content:has(chat-window), [role="main"]:has(chat-window), chat-window { max-width: none !important; } .user-query-bubble-container, .user-query-container, model-response, .model-response, response-container, .presented-response-container, [aria-label="Gemini response"] { max-width: ${wv} !important; width: min(100%, ${wv}) !important; margin: 0 auto !important; } .content-wrapper:has(chat-window) > *, .main-content:has(chat-window) > * { max-width: ${wv} !important; margin: 0 auto !important; } @supports not selector(:has(*)) { .content-wrapper, .main-content, [role="main"] { max-width: ${wv} !important; margin: 0 auto !important; } } `; }, }; GV.ChatWidth = ChatWidth; /* ========================================================================= * SECTION 23: 深度研究 (Deep Research) — Gemini * ========================================================================= */ const DeepResearch = { observer: null, start() { if (!Store.get(SK.DEEP_RESEARCH_ENABLED, true)) return; this.observer = observeMutations(document.body, () => this.injectMenuButton(), { childList: true, subtree: true }, 200); Store.onChange(SK.DEEP_RESEARCH_ENABLED, (v) => { if (!v) this.observer?.disconnect(); }); }, isDeepResearch() { return !!$('deep-research-immersive-panel'); }, injectMenuButton() { if (!this.isDeepResearch()) return; const panels = $$('.mat-mdc-menu-panel[role="menu"]'); panels.forEach(panel => { if (panel.querySelector('.gv-deep-research-download')) return; // 确认是 Deep Research 报告菜单 if (!panel.querySelector('[data-test-id="share-button-tooltip-container"]')) return; if (!panel.querySelector('[data-test-id="export-to-docs-button"], [data-test-id="copy-button"]')) return; const content = $('.mat-mdc-menu-content', panel); if (!content) return; const btn = el('button', { class: 'mat-mdc-menu-item gv-deep-research-download', style: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', background: 'none', border: 'none', width: '100%', padding: '10px 16px', fontFamily: 'inherit' }, onclick: () => this.exportThinking() }, ['⬇️ ' + t('exportMd')]); content.appendChild(btn); }); }, exportThinking() { const panels = $$('deep-research-immersive-panel thinking-panel'); if (panels.length === 0) { toast('No thinking content', 'warning'); return; } let md = `# Deep Research ${t('exportedAt')}: ${new Date().toISOString()}\n\n`; panels.forEach((panel, i) => { md += `## ${t('assistant')} ${i + 1}\n\n`; const containers = $$('.item-container', panel); containers.forEach(c => { const thought = $('.thought-item', c); if (thought) { const header = $('.thought-header', thought)?.textContent?.trim() || ''; const content = $('.gds-body-m.gds-italic:not(.thought-header)', thought)?.textContent?.trim() || ''; if (header) md += `### ${header}\n\n`; if (content) md += `${content}\n\n`; } const chips = $('browse-chip-list', c); if (chips) { const links = $$('browse-web-chip a[data-test-id="browse-chip-link"]', chips); if (links.length) { md += `**Links:**\n`; links.forEach(a => { const domain = $('[data-test-id="domain-name"]', a)?.textContent?.trim() || ''; const title = $('[data-test-id="title"]', a)?.textContent?.trim() || domain; md += `- [${title}](${a.href})\n`; }); md += '\n'; } } }); md += '---\n\n'; }); const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const a = el('a', { href: URL.createObjectURL(blob), download: `deep-research-${Date.now()}.md` }); document.body.appendChild(a); a.click(); a.remove(); toast(t('done'), 'success'); }, }; GV.DeepResearch = DeepResearch; /* ========================================================================= * SECTION 24: Claude 时间线 (Claude Timeline) * ========================================================================= */ const ClaudeTimeline = { bar: null, track: null, tooltip: null, observer: null, markers: [], activeIdx: -1, start() { if (!Store.get(SK.CLAUDE_TIMELINE_ENABLED, true)) return; this.buildUI(); this.observer = observeMutations(document.body, () => this.refresh(), { childList: true, subtree: true }, 300); watchRoute(() => setTimeout(() => this.refresh(), 400)); setTimeout(() => this.refresh(), 1500); Store.onChange(SK.CLAUDE_TIMELINE_ENABLED, (v) => { if (!v) this.destroy(); }); }, buildUI() { if (this.bar && document.body.contains(this.bar)) return; this.bar = el('div', { class: 'gv-timeline-bar', dataset: { gvClaudeTimeline: 'true' } }, [ el('div', { class: 'gv-timeline-track' }) ]); this.track = $('.gv-timeline-track', this.bar); this.tooltip = el('div', { id: 'claude-timeline-tooltip', class: 'gv-timeline-tooltip' }); document.body.appendChild(this.bar); document.body.appendChild(this.tooltip); }, getConversationId() { const m = location.pathname.match(/\/chat\/([^/?#]+)/); return m ? 'claude:conv:' + m[1] : 'claude:' + hashStr(location.origin + location.pathname); }, getScrollTarget(el) { let p = el.parentElement; while (p && p !== document.body) { const cs = getComputedStyle(p); if ((cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'overlay') && p.scrollHeight > p.clientHeight) return p; p = p.parentElement; } return window; }, refresh() { const turns = $$('[data-testid="user-message"]'); if (turns.length === 0) { this.renderMarkers([]); return; } const firstTop = turns[0].getBoundingClientRect().top + (this.scrollContainer?.scrollTop || window.scrollY); const lastTop = turns[turns.length - 1].getBoundingClientRect().top + (this.scrollContainer?.scrollTop || window.scrollY); const span = Math.max(1, lastTop - firstTop); const convId = this.getConversationId(); const markers = turns.map((t, i) => { const text = (t.textContent || '').replace(/\s+/g, ' ').trim(); const top = t.getBoundingClientRect().top + (this.scrollContainer?.scrollTop || window.scrollY); return { turnId: 'c-' + hashStr(text), element: t, text, ratio: i === 0 ? 0 : (top - firstTop) / span }; }); this.scrollContainer = this.getScrollTarget(turns[0]); this.conversationId = convId; this.renderMarkers(markers); this.setupScrollSync(); }, renderMarkers(markers) { this.markers = markers; if (!this.track) return; this.track.innerHTML = ''; const starred = this.getStarred(); markers.forEach((m, i) => { const dot = el('button', { class: 'gv-timeline-dot' + (starred.includes(m.turnId) ? ' starred' : ''), style: { top: (m.ratio * 100) + '%' }, onclick: () => this.navigateTo(m), onmouseenter: () => this.showTooltip(m, dot), onmouseleave: () => this.tooltip.classList.remove('show'), }); m.dot = dot; this.track.appendChild(dot); }); }, getStarred() { const all = Store.get(SK.TIMELINE_STARRED, { messages: {} }); const msgs = (all.messages || {})[this.conversationId] || []; return msgs.map(m => m.turnId); }, navigateTo(marker) { const target = marker.element; if (!target) return; target.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, setupScrollSync() { if (!this.scrollContainer) return; if (this._handler) (this.scrollContainer === window ? window : this.scrollContainer).removeEventListener('scroll', this._handler); this._handler = debounce(() => this.updateActive(), 80); (this.scrollContainer === window ? window : this.scrollContainer).addEventListener('scroll', this._handler); }, updateActive() { if (!this.markers.length) return; const vh = window.innerHeight; const center = (this.scrollContainer === window ? window.scrollY : this.scrollContainer.scrollTop) + vh * 0.45; let nearest = 0, dist = Infinity; this.markers.forEach((m, i) => { const top = m.element.getBoundingClientRect().top + (this.scrollContainer === window ? window.scrollY : this.scrollContainer.scrollTop); const d = Math.abs(top - center); if (d < dist) { dist = d; nearest = i; } }); if (nearest !== this.activeIdx) { this.activeIdx = nearest; $$('.gv-timeline-dot', this.track).forEach((d, i) => d.classList.toggle('active', i === nearest)); } }, showTooltip(marker, dot) { this.tooltip.textContent = marker.text.slice(0, 200) + (marker.text.length > 200 ? '…' : ''); const rect = dot.getBoundingClientRect(); this.tooltip.style.left = (rect.left - this.tooltip.offsetWidth - 12) + 'px'; this.tooltip.style.top = (rect.top + rect.height / 2 - this.tooltip.offsetHeight / 2) + 'px'; this.tooltip.classList.add('show'); }, destroy() { this.observer?.disconnect(); this.bar?.remove(); this.tooltip?.remove(); this.bar = null; this.tooltip = null; }, }; GV.ClaudeTimeline = ClaudeTimeline; /* ========================================================================= * SECTION 25: Claude 使用量 (Claude Usage) * ========================================================================= */ const ClaudeUsage = { pill: null, timer: null, observer: null, start() { if (!Store.get(SK.CLAUDE_USAGE_ENABLED, true)) return; this.ensurePill(); this.refresh(); this.timer = setInterval(() => this.refresh(), 5 * 60 * 1000); this.setupFetchInterceptor(); watchRoute(() => setTimeout(() => { this.maybeScrape(); this.refresh(); }, 500)); }, ensurePill() { if (this.pill && document.body.contains(this.pill)) return; this.pill = el('div', { id: 'gv-claude-usage-pill', class: 'gv-usage-pill', style: { bottom: '16px', left: '50%', transform: 'translateX(-50%)' } }); document.body.appendChild(this.pill); this.setupDrag(); }, setupDrag() { let dragging = false, sx, sy, px, py; this.pill.addEventListener('pointerdown', (e) => { if (e.target.closest('a')) return; dragging = true; sx = e.clientX; sy = e.clientY; const rect = this.pill.getBoundingClientRect(); px = rect.left; py = rect.top; this.pill.setPointerCapture(e.pointerId); }); this.pill.addEventListener('pointermove', (e) => { if (!dragging) return; const dx = e.clientX - sx, dy = e.clientY - sy; if (Math.abs(dx) + Math.abs(dy) > 3) { this.pill.style.left = (px + dx) + 'px'; this.pill.style.top = (py + dy) + 'px'; this.pill.style.bottom = 'auto'; this.pill.style.transform = 'none'; } }); this.pill.addEventListener('pointerup', (e) => { dragging = false; const rect = this.pill.getBoundingClientRect(); Store.set(SK.CLAUDE_USAGE_POS, { x: rect.left, y: rect.top }); this.pill.releasePointerCapture(e.pointerId); }); // 恢复位置 const pos = Store.get(SK.CLAUDE_USAGE_POS, null); if (pos) { this.pill.style.left = pos.x + 'px'; this.pill.style.top = pos.y + 'px'; this.pill.style.bottom = 'auto'; this.pill.style.transform = 'none'; } }, async refresh() { let snapshot = Store.get(SK.CLAUDE_USAGE_CACHE, null); // 尝试 API try { snapshot = await this.refreshFromApi() || snapshot; } catch (e) {} if (snapshot) { this.render(snapshot); this.saveCache(snapshot); } else { this.maybeScrape(); } }, async getOrgId() { // 先从 cookie 读 const m = document.cookie.match(/lastActiveOrg=([^;]+)/); if (m) return m[1]; const data = await this.gmFetch('https://claude.ai/api/organizations'); if (data && Array.isArray(data) && data[0]) return data[0].uuid; return null; }, gmFetch(url) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'Content-Type': 'application/json' }, credentials: 'include', onload: (r) => { try { resolve(JSON.parse(r.responseText)); } catch (e) { resolve(null); } }, onerror: () => resolve(null), }); }); }, async refreshFromApi() { const orgId = await this.getOrgId(); if (!orgId) return null; const usage = await this.gmFetch(`https://claude.ai/api/organizations/${orgId}/usage`); if (!usage) return null; const metrics = []; if (usage.five_hour) { metrics.push({ label: '5h', percent: Math.round((usage.five_hour.utilization || 0) * 100), resetsAt: usage.five_hour.resets_at }); } if (usage.seven_day) { metrics.push({ label: 'Week', percent: Math.round((usage.seven_day.utilization || 0) * 100), resetsAt: usage.seven_day.resets_at }); } return { metrics, updatedAt: Date.now() }; }, maybeScrape() { if (!location.hash.includes('settings/usage')) return; const text = document.body.innerText || ''; const percents = [...text.matchAll(/(\d+(?:\.\d+)?)\s*%\s*used/gi)].map(m => parseFloat(m[1])); if (percents.length === 0) return; const metrics = []; if (percents[0] != null) metrics.push({ label: '5h', percent: Math.round(percents[0]) }); if (percents[1] != null) metrics.push({ label: 'Week', percent: Math.round(percents[1]) }); const snapshot = { metrics, updatedAt: Date.now() }; this.render(snapshot); this.saveCache(snapshot); }, saveCache(snapshot) { Store.set(SK.CLAUDE_USAGE_CACHE, snapshot); }, render(snapshot) { if (!this.pill) return; this.pill.innerHTML = ''; const tier = el('span', { class: 'gv-usage-tier' }, ['Claude']); this.pill.appendChild(tier); (snapshot.metrics || []).forEach(m => { const cls = m.percent >= 90 ? 'high' : (m.percent >= 70 ? 'mid' : ''); const metric = el('div', { class: 'gv-usage-metric' }, [ el('span', { class: 'gv-usage-label' }, [m.label]), el('span', { class: 'gv-usage-track' }, [ el('span', { class: 'gv-usage-fill ' + cls, style: { width: Math.min(100, m.percent) + '%' } }) ]), el('span', { class: 'gv-usage-pct' }, [m.percent + '%' + (m.resetsAt ? ' (' + this.countdown(m.resetsAt) + ')' : '')]), ]); this.pill.appendChild(metric); }); const open = el('a', { class: 'gv-usage-open', href: '#settings/usage', style: { fontSize: '11px', color: 'var(--gv-brand,#1a73e8)', textDecoration: 'none', marginTop: '2px' }, onclick: (e) => { e.preventDefault(); location.hash = '#settings/usage'; } }, ['⚙️ Usage']); this.pill.appendChild(open); }, countdown(resetsAt) { const ms = (typeof resetsAt === 'number' ? resetsAt * 1000 : new Date(resetsAt).getTime()) - Date.now(); if (ms <= 0) return '0m'; const h = Math.floor(ms / 3600000), m = Math.floor((ms % 3600000) / 60000), d = Math.floor(h / 24); if (d > 0) return `${d}d ${h % 24}h`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; }, setupFetchInterceptor() { // 拦截 SSE 流中的 message_limit const origFetch = unsafeWindow.fetch; unsafeWindow.fetch = async function (...args) { const res = await origFetch.apply(this, args); try { const ct = res.headers.get('content-type') || ''; if (ct.includes('event-stream') && res.body) { const reader = res.clone().body.getReader(); const decoder = new TextDecoder(); (async () => { let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'message_limit' && data.windows) { const metrics = []; if (data.windows['5h']) metrics.push({ label: '5h', percent: Math.round((data.windows['5h'].utilization || 0) * 100), resetsAt: data.windows['5h'].resets_at }); if (data.windows['7d']) metrics.push({ label: 'Week', percent: Math.round((data.windows['7d'].utilization || 0) * 100), resetsAt: data.windows['7d'].resets_at }); const snapshot = { metrics, updatedAt: Date.now() }; Store.set(SK.CLAUDE_USAGE_CACHE, snapshot); ClaudeUsage.render(snapshot); } } catch (e) {} } } } })(); } } catch (e) {} return res; }; }, }; GV.ClaudeUsage = ClaudeUsage; /* ========================================================================= * SECTION 26: CJK 渲染修复 (Claude) * ========================================================================= */ const CJKFix = { start() { GM_addStyle(` .standard-markdown :where(p, li, ul, ol, blockquote, dd, dt, td, th), .font-claude-response-body { font-family: 'Anthropic Serif', Georgia, 'PingFang SC', 'Hiragino Sans', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif !important; font-weight: 400 !important; font-synthesis-weight: none !important; } `); }, }; GV.CJKFix = CJKFix; /* ========================================================================= * SECTION 27: 阅读宽度 (Claude / ChatGPT) * ========================================================================= */ const ReadingWidth = { claudeStyle: null, chatgptStyle: null, start() { this.claudeStyle = el('style', { id: 'gv-claude-reading-width' }); this.chatgptStyle = el('style', { id: 'gv-chatgpt-reading-width' }); document.head.appendChild(this.claudeStyle); document.head.appendChild(this.chatgptStyle); this.apply(); Store.onChange(SK.READING_WIDTH_CLAUDE_ENABLED, () => this.apply()); Store.onChange(SK.READING_WIDTH_CLAUDE, () => this.apply()); Store.onChange(SK.READING_WIDTH_CHATGPT_ENABLED, () => this.apply()); Store.onChange(SK.READING_WIDTH_CHATGPT, () => this.apply()); Store.onChange(SK.CENTER_CONV_CHATGPT, () => this.apply()); }, apply() { // Claude const cwEnabled = Store.get(SK.READING_WIDTH_CLAUDE_ENABLED, false); if (cwEnabled) { const cw = Store.get(SK.READING_WIDTH_CLAUDE, 768); document.body.classList.add('gv-plugin-claude-readable'); document.body.style.setProperty('--gv-plugin-reading-width', cw + 'px'); this.claudeStyle.textContent = ` .gv-plugin-claude-readable div[class*='max-w-3xl'] { max-width: var(--gv-plugin-reading-width, 768px) !important; } .gv-plugin-claude-readable div[class*='items-end']:has([data-testid='user-message']) { align-items: flex-start !important; } `; } else { document.body.classList.remove('gv-plugin-claude-readable'); this.claudeStyle.textContent = ''; } // ChatGPT const gwEnabled = Store.get(SK.READING_WIDTH_CHATGPT_ENABLED, false); if (gwEnabled) { const gw = Store.get(SK.READING_WIDTH_CHATGPT, 768); const center = Store.get(SK.CENTER_CONV_CHATGPT, false); document.body.classList.add('gv-plugin-chatgpt-readable'); document.body.dataset.gvCenterConversation = center ? 'true' : 'false'; document.body.style.setProperty('--gv-plugin-reading-width', gw + 'px'); this.chatgptStyle.textContent = ` .gv-plugin-chatgpt-readable [class*='--thread-content-max-width'] { --thread-content-max-width: var(--gv-plugin-reading-width, 768px) !important; max-width: var(--gv-plugin-reading-width, 768px) !important; } .gv-plugin-chatgpt-readable #thread-bottom-container { width: 100% !important; max-width: none !important; } .gv-plugin-chatgpt-readable[data-gv-center-conversation='true'] section[data-testid^='conversation-turn-'][data-message-author-role='user'], .gv-plugin-chatgpt-readable[data-gv-center-conversation='true'] section[data-testid^='conversation-turn-'][data-message-author-role='user'] > div { align-items: center !important; } `; } else { document.body.classList.remove('gv-plugin-chatgpt-readable'); this.chatgptStyle.textContent = ''; } }, }; GV.ReadingWidth = ReadingWidth; /* ========================================================================= * SECTION 28: 主初始化与站点分发 * ========================================================================= */ function addSettingsFab() { if ($('#gv-settings-fab')) return; const fab = el('button', { id: 'gv-settings-fab', title: t('settings'), onclick: () => SettingsPanel.open() }, ['⚙️']); document.body.appendChild(fab); } function initGemini() { FolderManager.start(); PromptManager.start(); Timeline.start(); Exporter.start(); MermaidRenderer.start(); FormulaCopy.start(); MarkdownPatcher.start(); WatermarkRemover.start(); QuoteReply.start(); PreventAutoScroll.start(); InputCollapse.start(); DefaultModel.start(); GemsHider.start(); ChatWidth.start(); DeepResearch.start(); VisualEffects.start(); } function initAIStudio() { PromptManager.start(); FormulaCopy.start(); MermaidRenderer.start(); FolderManager.start(); VisualEffects.start(); } function initClaude() { PromptManager.start(); FormulaCopy.start(); CJKFix.start(); ReadingWidth.start(); ClaudeTimeline.start(); ClaudeUsage.start(); VisualEffects.start(); } function initChatGPT() { PromptManager.start(); FormulaCopy.start(); ReadingWidth.start(); VisualEffects.start(); } function initGrok() { PromptManager.start(); VisualEffects.start(); } function bootstrap() { injectGlobalStyles(); addSettingsFab(); if (Site.isGemini) initGemini(); else if (Site.isAIStudio) initAIStudio(); else if (Site.isClaude) initClaude(); else if (Site.isChatGPT) initChatGPT(); else if (Site.isGrok) initGrok(); else { PromptManager.start(); FormulaCopy.start(); VisualEffects.start(); } // 欢迎提示 setTimeout(() => { toast(t('welcome'), 'info', 4000); }, 1500); // 暴露 API 供调试 unsafeWindow.__voyager = GV; } // 等待 DOM 就绪 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); } else { bootstrap(); } })(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);