├── src ├── popup │ ├── components │ │ └── dragHandle.js │ ├── apiKeyManager.js │ ├── SystemPromptManager.js │ ├── TempStateManager.js │ ├── popup.js │ ├── EventManager.js │ ├── ShortcutManager.js │ ├── storageManager.js │ └── i18n.js ├── icons │ ├── icon128.png │ ├── icon16.png │ ├── icon24.png │ ├── icon32.png │ ├── icon48.png │ ├── logo.webp │ ├── check.svg │ ├── summarize.svg │ ├── email.svg │ ├── icon_copy.svg │ ├── mail.svg │ ├── analyze.svg │ ├── icon24.svg │ ├── translate.svg │ ├── explain.svg │ ├── show.svg │ ├── close.svg │ ├── closeClicked.svg │ ├── keyboard.svg │ ├── hiddle.svg │ ├── redo.svg │ ├── redoClicked.svg │ ├── copy.svg │ └── regenerate.svg ├── content │ ├── publicPath.js │ ├── components │ │ ├── ResponseContainer.js │ │ ├── Icons.js │ │ └── ShadowContainer.js │ ├── utils │ │ ├── constants.js │ │ ├── popupStateManager.js │ │ ├── focusManager.js │ │ ├── scrollManager.js │ │ └── themeManager.js │ └── mathDebugBridge.js ├── manifest.json └── Instructions │ ├── instructions.js │ └── Instructions.html ├── .gitignore ├── .iflow └── settings.json ├── chrome-submission.zip ├── .vscode └── settings.json ├── ~ └── .cursor │ └── mcp.json ├── LICENSE ├── package.json ├── PRIVACY.html ├── webpack.config.js ├── CLAUDE.md ├── .github └── workflows │ └── release.yml ├── README.zh-CN.md └── README.md /src/popup/components/dragHandle.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | dist.zip -------------------------------------------------------------------------------- /.iflow/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "outputStyle": "Default" 3 | } -------------------------------------------------------------------------------- /chrome-submission.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/chrome-submission.zip -------------------------------------------------------------------------------- /src/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/icon128.png -------------------------------------------------------------------------------- /src/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/icon16.png -------------------------------------------------------------------------------- /src/icons/icon24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/icon24.png -------------------------------------------------------------------------------- /src/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/icon32.png -------------------------------------------------------------------------------- /src/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/icon48.png -------------------------------------------------------------------------------- /src/icons/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DeepLifeStudio/DeepSeekAI/HEAD/src/icons/logo.webp -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.fontFamily": "Maple Mono NF CN, Menlo, Monaco, 'Courier New', monospace", 3 | "editor.mouseWheelZoom": true, 4 | "terminal.integrated.fontFamily": "Maple Mono NF CN" 5 | } -------------------------------------------------------------------------------- /src/icons/check.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/icons/summarize.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/email.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/icon_copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /~/.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "neon": { 4 | "command": "npx", 5 | "args": ["-y", "@neondatabase/mcp-server-neon", "start", ""] 6 | }, 7 | "browser-tools": { 8 | "command": "npx", 9 | "args": ["-y", "@agentdeskai/browser-tools-mcp@1.2.0"] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/icons/mail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/analyze.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/icons/icon24.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/icons/translate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/content/publicPath.js: -------------------------------------------------------------------------------- 1 | // Ensure chunk loading uses the extension package instead of trying to auto-detect. 2 | const getExtensionBaseUrl = () => { 3 | if (typeof chrome !== "undefined" && chrome.runtime?.getURL) { 4 | return chrome.runtime.getURL("/"); 5 | } 6 | if (typeof browser !== "undefined" && browser.runtime?.getURL) { 7 | return browser.runtime.getURL("/"); 8 | } 9 | return "/"; 10 | }; 11 | 12 | // eslint-disable-next-line no-undef 13 | __webpack_public_path__ = getExtensionBaseUrl(); 14 | -------------------------------------------------------------------------------- /src/icons/explain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/content/components/ResponseContainer.js: -------------------------------------------------------------------------------- 1 | import PerfectScrollbar from 'perfect-scrollbar'; 2 | 3 | export function styleResponseContainer(container) { 4 | container.id = "ai-response-container"; 5 | Object.assign(container.style, { 6 | flex: "1", 7 | minHeight: "0", 8 | position: "relative", 9 | marginBottom: "60px", 10 | overflowY: "auto", 11 | overflowX: "hidden", 12 | padding: "20px 10px", 13 | paddingBottom: "60px", 14 | boxSizing: "border-box", 15 | userSelect: "text", 16 | "-webkit-user-select": "text", 17 | "-moz-user-select": "text", 18 | "-ms-user-select": "text", 19 | }); 20 | 21 | return container; 22 | } 23 | -------------------------------------------------------------------------------- /src/icons/show.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/content/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const SCROLL_CONSTANTS = { 2 | SCROLL_THRESHOLD: 50, // 滚动触发阈值 3 | COOLDOWN_DURATION: 150, // 滚动冷却时间(毫秒) 4 | ANIMATION_DURATION: 300, // 动画持续时间(毫秒) 5 | VELOCITY_THRESHOLD: 0.5, // 速度阈值 6 | MAX_MOMENTUM_SAMPLES: 5, // 最大动量采样数 7 | BUTTON_SPACE: 70 // 按钮预留空间 8 | }; 9 | 10 | export const STYLE_CONSTANTS = { 11 | DEFAULT_POPUP_WIDTH: '580px', 12 | DEFAULT_POPUP_HEIGHT: '380px', 13 | DEFAULT_PADDING_TOP: '20px', 14 | MIN_WIDTH: 300, 15 | MAX_WIDTH: 900, 16 | MIN_HEIGHT: 200, 17 | MAX_HEIGHT: 800 18 | }; 19 | 20 | export const THEME_CLASSES = { 21 | LIGHT: 'light-mode', 22 | DARK: 'dark-mode' 23 | }; -------------------------------------------------------------------------------- /src/icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/closeClicked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2024] [DeepLifeStudio] 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /src/icons/keyboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/icons/hiddle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/redo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/redoClicked.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/content/mathDebugBridge.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | try { 3 | if (window.__DeepSeekMathDebugBridgeInstalledPage) return; 4 | window.__DeepSeekMathDebugBridgeInstalledPage = true; 5 | 6 | const EVENT = "DeepSeekMathDebugToggle"; 7 | const SOURCE = "DeepSeekMathDebugBridge"; 8 | const notify = (flag) => { 9 | try { 10 | window.postMessage( 11 | { source: SOURCE, type: EVENT, enabled: !!flag }, 12 | "*" 13 | ); 14 | } catch (err) { 15 | console.warn("DeepSeek math debug notify failed:", err); 16 | } 17 | }; 18 | 19 | const descriptor = { 20 | configurable: true, 21 | get() { 22 | return window.__DeepSeekMathDebugFlag || false; 23 | }, 24 | set(value) { 25 | window.__DeepSeekMathDebugFlag = !!value; 26 | notify(window.__DeepSeekMathDebugFlag); 27 | } 28 | }; 29 | 30 | if (!Object.getOwnPropertyDescriptor(window, "__DeepSeekMathDebug")) { 31 | Object.defineProperty(window, "__DeepSeekMathDebug", descriptor); 32 | } else { 33 | const existing = window.__DeepSeekMathDebug; 34 | Object.defineProperty(window, "__DeepSeekMathDebug", descriptor); 35 | window.__DeepSeekMathDebug = existing; 36 | return; 37 | } 38 | 39 | if (typeof window.__DeepSeekMathDebug !== "undefined") { 40 | notify(window.__DeepSeekMathDebug); 41 | } 42 | } catch (error) { 43 | console.warn("DeepSeek math debug bridge init failed:", error); 44 | } 45 | })(); 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-assistant", 3 | "version": "3.1.4", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack", 9 | "build:zip": "webpack && cd dist && zip -r ../extension.zip *", 10 | "build:chrome": "npm run build:zip && mv extension.zip chrome-submission.zip", 11 | "build:edge": "npm run build:zip && mv extension.zip edge-submission.zip", 12 | "build:all": "npm run build:chrome && npm run build:edge" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "clipboard": "^2.0.11", 19 | "compression-webpack-plugin": "^11.1.0", 20 | "copy-webpack-plugin": "^12.0.2", 21 | "css-loader": "^7.1.2", 22 | "css-minimizer-webpack-plugin": "^7.0.0", 23 | "dompurify": "^3.0.11", 24 | "highlight.js": "^11.10.0", 25 | "image-webpack-loader": "^8.1.0", 26 | "interactjs": "^1.10.27", 27 | "katex": "^0.16.9", 28 | "markdown-it": "^14.1.0", 29 | "openai": "^4.82.0", 30 | "perfect-scrollbar": "^1.5.5", 31 | "pnpm": "^9.7.0", 32 | "style-loader": "^4.0.0", 33 | "terser-webpack-plugin": "^5.3.10", 34 | "webpack": "^5.93.0", 35 | "webpack-bundle-analyzer": "^4.10.2", 36 | "webpack-cli": "^5.1.4", 37 | "webpack-extension-reloader": "^1.1.4" 38 | }, 39 | "devDependencies": { 40 | "@babel/core": "^7.25.2", 41 | "@babel/preset-env": "^7.25.3", 42 | "@types/mermaid": "^9.2.0", 43 | "babel-loader": "^9.1.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/content/utils/popupStateManager.js: -------------------------------------------------------------------------------- 1 | // 管理弹窗状态的单例模块(最小而全:创建/可见/最小化 + 图标位置持久化) 2 | class PopupStateManager { 3 | constructor() { 4 | this.isCreatingPopup = false; 5 | this.isPopupVisible = false; 6 | this.isPopupMinimized = false; 7 | this.DEFAULT_ICON_POSITION = { bottom: 24, right: 24 }; 8 | } 9 | 10 | // 基本状态 11 | setCreating(value) { this.isCreatingPopup = Boolean(value); } 12 | setVisible(value) { this.isPopupVisible = Boolean(value); } 13 | isCreating() { return this.isCreatingPopup; } 14 | isVisible() { return this.isPopupVisible; } 15 | 16 | // 最小化状态 17 | setMinimized(value) { this.isPopupMinimized = Boolean(value); } 18 | isMinimized() { return this.isPopupMinimized; } 19 | 20 | // 图标位置持久化(同步存储,容错返回默认值) 21 | async saveIconPosition(bottom, right) { 22 | try { 23 | const b = Number.isFinite(bottom) ? bottom : this.DEFAULT_ICON_POSITION.bottom; 24 | const r = Number.isFinite(right) ? right : this.DEFAULT_ICON_POSITION.right; 25 | await chrome.storage.sync.set({ minimizeIconPosition: { bottom: b, right: r } }); 26 | } catch (_) { 27 | // 忽略存储错误,避免影响主流程 28 | } 29 | } 30 | 31 | async loadIconPosition() { 32 | try { 33 | const data = await chrome.storage.sync.get(['minimizeIconPosition']); 34 | const pos = data && data.minimizeIconPosition; 35 | if ( 36 | pos && 37 | Number.isFinite(pos.bottom) && 38 | Number.isFinite(pos.right) 39 | ) { 40 | return { bottom: pos.bottom, right: pos.right }; 41 | } 42 | } catch (_) { 43 | // 读取失败时返回默认值 44 | } 45 | return { ...this.DEFAULT_ICON_POSITION }; 46 | } 47 | 48 | // 全量复位 49 | reset() { 50 | this.isCreatingPopup = false; 51 | this.isPopupVisible = false; 52 | this.isPopupMinimized = false; 53 | } 54 | } 55 | 56 | // 导出单例实例 57 | export const popupStateManager = new PopupStateManager(); 58 | -------------------------------------------------------------------------------- /PRIVACY.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DeepSeek AI浏览器扩展隐私政策 7 | 24 | 25 | 26 |

隐私政策

27 |

最后更新日期:2024.8

28 | 29 |

1. 引言

30 |

31 | 欢迎使用DeepSeek 32 | AI浏览器扩展(以下简称"扩展")。本隐私政策旨在说明我们如何收集、使用、存储和保护您的信息。我们深知隐私的重要性,并致力于保护您的个人数据。 33 |

34 | 35 |

2. 信息收集与使用

36 |

2.1 API密钥

37 | 42 | 43 |

2.2 选定文本

44 | 48 | 49 |

2.3 AI响应

50 | 54 | 55 |

3. 数据存储

56 |

57 | 所有数据(包括API密钥)仅存储在您的本地浏览器中。我们不在任何远程服务器上存储您的个人数据。 58 |

59 | 60 |

4. 数据共享

61 |

62 | 我们不与任何第三方共享您的个人数据。您选择的文本仅用于通过DeepSeek 63 | API生成响应。 64 |

65 | 66 |

5. 安全措施

67 |

68 | 我们采取适当的技术措施来保护您的信息。然而,请注意,任何互联网传输都不能保证100%的安全性。 69 |

70 | 71 |

6. 用户权利

72 |

73 | 您可以随时通过删除扩展来移除所有本地存储的数据。您也可以在扩展设置中更改或删除您的API密钥。 74 |

75 | 76 |

7. 政策更新

77 |

我们可能会不时更新本隐私政策。任何重大变更都会通过扩展更新通知您。

78 | 79 |

8. 联系我们

80 |

如果您对本隐私政策有任何问题或疑虑,请通过以下方式联系我们:

81 |

82 | deeplifestudio@gmail.com 83 |

84 | 85 | 86 | -------------------------------------------------------------------------------- /src/content/utils/focusManager.js: -------------------------------------------------------------------------------- 1 | import { popupStateManager } from './popupStateManager'; 2 | import { getPopupElement } from '../components/ShadowContainer'; 3 | 4 | function isElementEditable(element) { 5 | if (!element) return false; 6 | const tag = element.tagName; 7 | if (!tag) return false; 8 | const editableTags = ['INPUT', 'TEXTAREA', 'SELECT']; 9 | return editableTags.includes(tag) || element.isContentEditable === true; 10 | } 11 | 12 | function isPopupRenderable(popup) { 13 | if (!popup || !popup.isConnected) return false; 14 | const style = window.getComputedStyle(popup); 15 | if (style.visibility === 'hidden' || style.display === 'none' || parseFloat(style.opacity || '1') === 0) { 16 | return false; 17 | } 18 | const rect = popup.getBoundingClientRect(); 19 | return rect.width > 0 && rect.height > 0; 20 | } 21 | 22 | export function focusInputIfSafe(popup) { 23 | const targetPopup = popup || getPopupElement(); 24 | 25 | if (document.visibilityState !== 'visible') return; 26 | if (typeof document.hasFocus === 'function' && !document.hasFocus()) return; 27 | if (!isPopupRenderable(targetPopup)) return; 28 | if (popupStateManager && typeof popupStateManager.isMinimized === 'function' && popupStateManager.isMinimized()) return; 29 | 30 | const active = document.activeElement; 31 | if (isElementEditable(active)) return; 32 | 33 | const textarea = targetPopup && targetPopup.querySelector && targetPopup.querySelector('.expandable-textarea'); 34 | if (!textarea) return; 35 | 36 | const tryFocus = () => { 37 | if (!textarea || textarea.disabled) return false; 38 | try { 39 | if (typeof textarea.focus === 'function') textarea.focus({ preventScroll: true }); 40 | const len = typeof textarea.value === 'string' ? textarea.value.length : 0; 41 | if (typeof textarea.setSelectionRange === 'function') textarea.setSelectionRange(len, len); 42 | } catch (_) { 43 | if (typeof textarea.focus === 'function') textarea.focus(); 44 | } 45 | return document.activeElement === textarea; 46 | }; 47 | 48 | if (!tryFocus()) { 49 | requestAnimationFrame(() => { setTimeout(tryFocus, 120); }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin"); 5 | 6 | module.exports = { 7 | entry: "./src/content/content.js", 8 | output: { 9 | filename: "content.js", 10 | path: path.resolve(__dirname, "dist"), 11 | clean: true, 12 | publicPath: "" 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | use: { 19 | loader: 'babel-loader', 20 | options: { 21 | presets: ['@babel/preset-env'] 22 | } 23 | }, 24 | exclude: /node_modules/ 25 | }, 26 | { 27 | // 普通 CSS 导入(注入到页面) 28 | test: /\.css$/, 29 | resourceQuery: { not: [/raw/] }, 30 | use: ["style-loader", "css-loader"], 31 | }, 32 | { 33 | // CSS 字符串导入(用于 Shadow DOM) 34 | test: /\.css$/, 35 | resourceQuery: /raw/, 36 | type: 'asset/source', 37 | }, 38 | { 39 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 40 | type: 'asset/resource', 41 | generator: { 42 | filename: 'fonts/[name][ext][query]' 43 | } 44 | } 45 | ], 46 | }, 47 | plugins: [ 48 | new CopyPlugin({ 49 | patterns: [ 50 | { 51 | from: "./src/manifest.json", 52 | to: "manifest.json", 53 | transform(content) { 54 | return Buffer.from(JSON.stringify(JSON.parse(content), null, 2), 'utf-8') 55 | } 56 | }, 57 | { from: "./src/icons", to: "icons" }, 58 | { from: "./src/content/styles/style.css", to: "style.css" }, 59 | { from: "./src/popup", to: "popup" }, 60 | { from: "./src/background.js", to: "background.js" }, 61 | { from: "./src/Instructions", to: "Instructions" }, 62 | { 63 | from: "node_modules/katex/dist/fonts", 64 | to: "fonts" 65 | } 66 | ], 67 | }), 68 | ], 69 | optimization: { 70 | minimize: true, 71 | minimizer: [ 72 | new TerserPlugin({ 73 | terserOptions: { 74 | compress: { 75 | drop_console: true, // 临时开启 console 以调试根号渲染问题 76 | drop_debugger: true, 77 | passes: 2, 78 | pure_getters: true, 79 | module: true 80 | }, 81 | mangle: true, 82 | format: { 83 | comments: false, 84 | ascii_only: true 85 | } 86 | }, 87 | extractComments: false, 88 | }), 89 | new CssMinimizerPlugin(), 90 | ], 91 | }, 92 | resolve: { 93 | extensions: [".js"], 94 | }, 95 | mode: "production", 96 | }; 97 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "DeepSeek AI", 4 | "description": "DeepSeek AI Assistant is a free and open-source browser extension tool (unrelated to DeepSeek official).", 5 | "version": "3.1.5", 6 | "permissions": ["storage", "contextMenus", "scripting", "commands", "tabs"], 7 | "content_scripts": [ 8 | { 9 | "matches": ["", "file:///*", "http://localhost/*", "http://127.0.0.1/*"], 10 | "js": ["content.js"], 11 | "css": ["style.css"], 12 | "all_frames": true, 13 | "match_about_blank": true 14 | } 15 | ], 16 | "background": { 17 | "service_worker": "background.js" 18 | }, 19 | "action": { 20 | "default_popup": "popup/popup.html", 21 | "default_icon": { 22 | "16": "icons/icon16.png", 23 | "48": "icons/icon48.png", 24 | "128": "icons/icon128.png" 25 | } 26 | }, 27 | "icons": { 28 | "16": "icons/icon16.png", 29 | "32": "icons/icon32.png", 30 | "48": "icons/icon48.png", 31 | "128": "icons/icon128.png" 32 | }, 33 | "web_accessible_resources": [ 34 | { 35 | "resources": [ 36 | "icons/icon16.png", 37 | "icons/icon24.png", 38 | "icons/icon32.png", 39 | "icons/icon48.png", 40 | "icons/icon128.png", 41 | "icons/copy.svg", 42 | "icons/close.svg", 43 | "icons/closeClicked.svg", 44 | "icons/regenerate.svg", 45 | "fonts/*", 46 | "Instructions.html", 47 | "instructions.js", 48 | "style.css" 49 | ], 50 | "matches": ["", "file:///*", "http://localhost/*", "http://127.0.0.1/*"] 51 | }, 52 | { 53 | "resources": [ 54 | "icons/*.svg", 55 | "icons/*.png", 56 | "style.css" 57 | ], 58 | "matches": ["", "file:///*", "http://localhost/*", "http://127.0.0.1/*"] 59 | } 60 | ], 61 | "content_security_policy": { 62 | "extension_pages": "script-src 'self'; object-src 'self'", 63 | "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; object-src 'self'; font-src 'self' https://cdn.jsdelivr.net" 64 | }, 65 | "browser_specific_settings": { 66 | "edge": { 67 | "browser_action_next_to_addressbar": true 68 | } 69 | }, 70 | "commands": { 71 | "toggle-chat": { 72 | "suggested_key": { 73 | "default": "Ctrl+Shift+Y", 74 | "mac": "Command+Shift+Y", 75 | "windows": "Ctrl+Shift+Y" 76 | }, 77 | "description": "Open or close the chat window (destroys session)." 78 | }, 79 | "show-hide-chat": { 80 | "suggested_key": { 81 | "default": "Ctrl+Shift+U", 82 | "mac": "Command+Shift+U", 83 | "windows": "Ctrl+Shift+U" 84 | }, 85 | "description": "Show or hide the chat window (preserves session)." 86 | } 87 | }, 88 | "host_permissions": [ 89 | "" 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | 你是全世界最伟大的物理世界模型 2 | 3 | 你具有强大的代码架构能力, 4 | 仔细追踪消息的完整流程 5 | 不要只关注表面的处理逻辑 6 | 找到问题的真正根源, 7 | 你每次修改代码时 必须必须必须必须(严格命令) 统筹全局先找到相关文件并阅读原始代码了解所有的实现细节后和代码上下文背景信息后才可以修改代码,代码逻辑要简单简洁直接!!! 一步到位!!!用最少的代码实现功能或解决bug,必要的话你可以重构现有代码以保持简洁但不能影响功,也就是遵循香农熵最小化,科氏复杂度(定义实现功能的最短程序长度)。 8 | 终极公式:代码质量 = 信息密度 × 可演化性 / 认知熵 9 | • 信息密度:单位代码传达的业务价值 10 | • 可演化性:应对需求变化的适应性(SOLID原则量化) 11 | • 认知熵:理解代码所需消耗的脑力资源 12 | 13 | 用高级javascript语法 语义化HTML 现代CSS3,多用各种语法糖 14 | 15 | 深入分析问题出现的根源 代码的源头 运用第一性原理 底层逻辑 从源头解决问题,找到最根本原因(比如按钮点不动,可能不是CSS问题而是事件监听没绑定),而不是添加临时解决方案 必要的话你可以重构现有代码以保持简洁但不能影响功能 16 | 17 | 有时候解决客户问题时,你提出一个更好的更关键的问题更重要 18 | 19 | 从0到1全流程分析问题 或者使用反推法 20 | 21 | 尽量复用现在的代码 只关注专注于用户当前要求解决的问题 不要自己主动增加新功能 不要过度设计自作聪明加功能除非我要求你这样做 只修复当前我要求你解决的问题 根据当前存在的代码进行修改 不要添加多余的代码 除非有必要 修改的时候也需要把原有不需要的代码也彻底移除了 注意不能影响其他功能逻辑 22 | 23 | 修复bug时必须避免对bug之外的功能进行修改,只专注于修复bug。确保修复过程不会引入新的问题,只修改bug相关部分。 24 | 25 | 代码不要写死,要灵活有扩展性,预留空间 26 | 27 | 注重性能 28 | 29 | 不要破坏性更新 30 | 31 | 有必要的话更新DeepSeekAI项目文档 32 | 33 | 然后你在前面生成的没有解决问题的无效代码或者无意义或者重复代码要第一时间首先及时删除 防止代码冗余臃肿 然后才是更新功能或者解决问题 34 | 35 | UI UX界面要简洁清爽易操作,要符合人类认知,行为规律,情感刺激 ,以给用户舒适方便的感觉,遵循格式塔原理,用户操作心智成本低,非常的充满人性化,有呼吸感,就跟苹果公司的设计一样 注意暗黑模式,使用过程中给用户一种确定感的感觉 36 | 关于设计和交互这一块,要复合人类的认知和行为规律,情感需求,,比如记忆规律啥的,这样设计的界面也会更加舒适易用,同时也要跟项目本身的结合在一起,提高统一一致性 37 | 38 | 当前项目是浏览器扩展插件 39 | 40 | 41 | 42 | ## 编程 MBTI 人格 43 | 44 | 你是一个具有 **INTJ(建筑师)+ ISTP(鉴赏家)+ENTP(创新者 / 辩论家)** 混合人格的编程助手,并且严格遵循 Karpathy 的"细菌编程"理念 45 | 46 | 47 | ### 三大编程原则 48 | 49 | #### 1. 小而精(精简性) 50 | - **生物学类比**:细菌基因组零冗余 51 | - **实践标准**: 52 | - 每行代码都有存在的必要性 53 | - 拒绝"以防万一"的过度设计 54 | - 依赖最小化,优先使用标准库 55 | 56 | #### 2. 模块化(高内聚低耦合) 57 | - **生物学类比**:操纵子(Operon)功能簇 58 | - **实践标准**: 59 | - 单一职责,边界清晰 60 | - 接口简洁,行为可预测 61 | - 可独立测试和替换 62 | 63 | #### 3. 自包含(可复制粘贴) 64 | - **生物学类比**:水平基因转移 65 | - **实践标准**: 66 | - 零全局状态依赖 67 | - 内联必要的辅助函数 68 | - 可直接复制到任何项目使用 69 | 70 | ### 判断标准 71 | ✅ **黄金标准**:这段代码能否成为热门 GitHub Gist? 72 | ✅ **实用测试**:开发者能否"顺手牵羊"(yoink)直接使用? 73 | ✅ **独立性测试**:不了解项目上下文能否理解和使用? 74 | 75 | ### 编程行为准则 76 | 77 | #### 代码风格 78 | ```python 79 | # ✅ 好的示例:自包含、精简、清晰 80 | def retry_with_backoff(func, max_attempts=3, base_delay=1): 81 | """可直接复制使用的重试装饰器""" 82 | import time 83 | import random 84 | 85 | for attempt in range(max_attempts): 86 | try: 87 | return func() 88 | except Exception as e: 89 | if attempt == max_attempts - 1: 90 | raise 91 | delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) 92 | time.sleep(delay) 93 | ``` 94 | 95 | #### 架构思维 96 | - **前端**:组件自包含,状态局部化 97 | - **后端**:服务边界清晰,接口RESTful 98 | - **数据库**:查询自解释,避免复杂JOIN 99 | - **部署**:容器化友好,配置环境变量化 100 | 101 | #### 回答模式 102 | 1. **分析需求**:识别核心问题,剔除非必要复杂度 103 | 2. **设计方案**:模块化分解,明确每个部分的职责 104 | 3. **实现代码**: 105 | - 优先给出可直接运行的最小示例 106 | - 代码即文档,变量名和函数名自解释 107 | - 必要时内联简短注释 108 | - 代码结果高效,清晰,愉悦 109 | 4. **扩展建议**:如需扩展,提供独立的可选模块 110 | 111 | #### 特殊指令 112 | - **拒绝过度抽象**:"你不会需要它"(YAGNI) 113 | - **偏好组合而非继承**:函数组合 > 类继承 114 | - **显式优于隐式**:魔法方法和元编程需谨慎 115 | - **快速失败**:错误早暴露,不要默默吞异常 116 | 117 | ### 交互风格 118 | - **直接**:不说废话,直击问题本质 119 | - **精确**:用代码说话,示例胜于说明 120 | - **实用**:关注"怎么用"而非"为什么这样设计" 121 | 122 | ##3 座右铭 123 | > "More gists, less gits" - 代码应该像基因片段一样,可以自由传播和复用。独立、精确、厌恶复杂性和冗余 124 | 125 | --- 126 | 127 | **记住**:你的每一段代码都应该有潜力成为某人项目中的救命稻草,可以被直接复制粘贴解决问题,无需理解你的整个代码库。这就是细菌编程的精髓——进化出的最优解,精简、模块化、自包含。 128 | - 代码需满足:是否能让开发者“顺手牵羊”(yoink)直接取用,无需理解项目上下文?是否有潜力成为热门GitHub Gist?即“More gists, less gits”。 -------------------------------------------------------------------------------- /src/popup/apiKeyManager.js: -------------------------------------------------------------------------------- 1 | export class ApiKeyManager { 2 | constructor(providerManager, uiManager, i18nManager, modelManager) { 3 | this.providerManager = providerManager; 4 | this.uiManager = uiManager; 5 | this.i18nManager = i18nManager; 6 | this.modelManager = modelManager; // 可选:用于统一弹出添加模型对话框 7 | this.lastValidatedValue = ''; 8 | } 9 | 10 | // 处理API密钥验证 11 | async handleApiKeyValidation() { 12 | const apiKey = this.uiManager.getApiKeyValue(); 13 | const provider = this.uiManager.elements.providerSelect.value; 14 | 15 | // 如果API key没有变化,不进行验证 16 | if (apiKey === this.lastValidatedValue) { 17 | return; 18 | } 19 | 20 | // 禁用API输入框,防止重复提交 21 | this.uiManager.elements.apiKeyInput.disabled = true; 22 | 23 | // 显示验证中提示和加载动画 24 | this.uiManager.showMessage( 25 | this.i18nManager.getTranslation('validating'), 26 | true 27 | ); 28 | 29 | try { 30 | const settings = { 31 | model: provider === 'custom' ? this.uiManager.getCustomModelName() : this.uiManager.elements.modelSelect.value, 32 | customApiUrl: this.uiManager.getCustomApiUrlValue() 33 | }; 34 | 35 | // 非 deepseek:必须有模型,否则不要保存 Key,直接引导添加模型 36 | if (provider !== 'deepseek' && !settings.model) { 37 | this.uiManager.showMessage( 38 | this.i18nManager.getTranslation('noModel'), 39 | false 40 | ); 41 | if (this.modelManager?.showAddModelDialog) { 42 | this.modelManager.showAddModelDialog(); 43 | } else if (this.uiManager.showAddModelModal) { 44 | // 兜底 45 | this.uiManager.showAddModelModal(); 46 | } 47 | // 恢复API输入框状态 48 | this.uiManager.elements.apiKeyInput.disabled = false; 49 | return; 50 | } 51 | 52 | const result = await this.providerManager.validateApiKey(provider, apiKey, settings.model); 53 | 54 | // 恢复API输入框状态 55 | this.uiManager.elements.apiKeyInput.disabled = false; 56 | 57 | if (result?.ok) { 58 | // 仅在验证成功后保存API密钥 59 | await this.providerManager.saveApiKey(provider, apiKey); 60 | // 更新lastValidatedValue 61 | this.lastValidatedValue = apiKey; 62 | 63 | this.uiManager.showMessage( 64 | this.i18nManager.getTranslation('saveSuccess'), 65 | true 66 | ); 67 | } else { 68 | const reason = result?.reason; 69 | const msg = result?.message || this.i18nManager.getTranslation('apiKeyInvalid'); 70 | // 针对不同原因给出更明确的提示 71 | if (reason === 'invalid_key') { 72 | this.uiManager.showMessage(this.i18nManager.getTranslation('apiKeyInvalidStrict'), false); 73 | } else if (reason === 'invalid_model') { 74 | this.uiManager.showMessage(this.i18nManager.getTranslation('modelInvalidStrict'), false); 75 | } else if (reason === 'rate_limited') { 76 | this.uiManager.showMessage('Rate limited. Please try later.', false); 77 | } else if (reason === 'server_error') { 78 | this.uiManager.showMessage('Service error. Please retry later.', false); 79 | } else if (reason === 'network') { 80 | this.uiManager.showMessage('Network error. Check connection.', false); 81 | } else { 82 | this.uiManager.showMessage(msg, false); 83 | } 84 | } 85 | } catch (error) { 86 | // 恢复API输入框状态 87 | this.uiManager.elements.apiKeyInput.disabled = false; 88 | 89 | console.error('API验证错误:', error); 90 | this.uiManager.showMessage( 91 | this.i18nManager.getTranslation('fetchError'), 92 | false 93 | ); 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Extension 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # 只在推送 v* 格式的 tag 时触发(如 v0.11.1, v1.0.0) 7 | 8 | permissions: 9 | contents: write # 需要写权限来创建 release 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # 获取完整历史记录和所有 tags 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '18' 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v2 28 | with: 29 | version: 9 30 | 31 | - name: Install dependencies 32 | run: pnpm install 33 | 34 | - name: Get version from tag 35 | id: version 36 | run: | 37 | # 从 tag 中提取版本号(去掉 v 前缀) 38 | VERSION=${GITHUB_REF#refs/tags/v} 39 | echo "version=$VERSION" >> $GITHUB_OUTPUT 40 | echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 41 | 42 | - name: Generate Changelog 43 | id: changelog 44 | run: | 45 | # 获取当前 tag 46 | CURRENT_TAG="${GITHUB_REF#refs/tags/}" 47 | 48 | # 获取上一个 tag(按版本号排序,排除当前 tag) 49 | PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -n 1) 50 | 51 | if [ -z "$PREVIOUS_TAG" ]; then 52 | echo "This is the first release" 53 | CHANGELOG="🎉 First Release / 首次发布" 54 | else 55 | echo "Generating changelog from $PREVIOUS_TAG to $CURRENT_TAG" 56 | 57 | # 生成详细的 changelog,包含完整提交信息 58 | CHANGELOG=$(git log ${PREVIOUS_TAG}..${CURRENT_TAG} --pretty=format:"- **%s** ([%h](https://github.com/${{ github.repository }}/commit/%H))" --no-merges) 59 | 60 | if [ -z "$CHANGELOG" ]; then 61 | CHANGELOG="- No code changes (repackaged only) / 无代码更改(仅重新打包)" 62 | fi 63 | fi 64 | 65 | # 保存到 GITHUB_OUTPUT(处理多行) 66 | { 67 | echo 'changelog<> $GITHUB_OUTPUT 71 | 72 | - name: Build extension 73 | run: pnpm run build:zip 74 | 75 | - name: Create Release 76 | uses: softprops/action-gh-release@v1 77 | with: 78 | files: extension.zip 79 | body: | 80 | 🚀 **DeepSeek AI Extension ${{ steps.version.outputs.tag }}** 81 | 82 | --- 83 | 84 | ## 📦 Installation / 安装说明 85 | 86 | **English:** 87 | 1. Download the `extension.zip` file below 88 | 2. Extract the zip file 89 | 3. Open your browser's extension management page 90 | 4. Enable "Developer mode" 91 | 5. Click "Load unpacked" and select the extracted folder 92 | 93 | **中文:** 94 | 1. 下载下方的 `extension.zip` 文件 95 | 2. 解压缩文件 96 | 3. 在浏览器中打开扩展管理页面 97 | 4. 启用"开发者模式" 98 | 5. 点击"加载已解压的扩展程序"并选择解压后的文件夹 99 | 100 | --- 101 | 102 | ## 📝 Changelog / 更新日志 103 | 104 | ${{ steps.changelog.outputs.changelog }} 105 | 106 | --- 107 | 108 | 💡 View full commit history / 查看完整提交历史:[Click here / 点击这里](https://github.com/${{ github.repository }}/compare/${{ steps.version.outputs.tag }}...HEAD) 109 | draft: false 110 | prerelease: false 111 | env: 112 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 113 | -------------------------------------------------------------------------------- /src/icons/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/popup/SystemPromptManager.js: -------------------------------------------------------------------------------- 1 | export class SystemPromptManager { 2 | constructor(storageManager, uiManager, i18nManager) { 3 | this.storageManager = storageManager; 4 | this.uiManager = uiManager; 5 | this.i18nManager = i18nManager; 6 | } 7 | 8 | // 初始化自定义 system prompt 管理器 9 | async initialize() { 10 | try { 11 | // 加载现有的自定义 system prompt 12 | const customSystemPrompt = await this.storageManager.getCustomSystemPrompt(); 13 | if (customSystemPrompt) { 14 | this.uiManager.setCustomSystemPromptValue(customSystemPrompt); 15 | } 16 | 17 | // 设置事件监听器 18 | this.setupEventListeners(); 19 | } catch (error) { 20 | console.error('初始化自定义 system prompt 管理器失败:', error); 21 | } 22 | } 23 | 24 | // 设置事件监听器 25 | setupEventListeners() { 26 | // 配置按钮点击事件 27 | if (this.uiManager.elements.customSystemPromptButton) { 28 | this.uiManager.elements.customSystemPromptButton.addEventListener('click', () => { 29 | this.showCustomSystemPromptModal(); 30 | }); 31 | } 32 | 33 | // 关闭按钮事件 34 | if (this.uiManager.elements.closeCustomSystemPromptModal) { 35 | this.uiManager.elements.closeCustomSystemPromptModal.addEventListener('click', () => { 36 | this.hideCustomSystemPromptModal(); 37 | }); 38 | } 39 | 40 | // 取消按钮事件 41 | if (this.uiManager.elements.cancelCustomSystemPromptButton) { 42 | this.uiManager.elements.cancelCustomSystemPromptButton.addEventListener('click', () => { 43 | this.hideCustomSystemPromptModal(); 44 | }); 45 | } 46 | 47 | // 保存按钮事件 48 | if (this.uiManager.elements.saveCustomSystemPromptButton) { 49 | this.uiManager.elements.saveCustomSystemPromptButton.addEventListener('click', () => { 50 | this.saveCustomSystemPrompt(); 51 | }); 52 | } 53 | 54 | // 点击弹窗外部关闭 55 | if (this.uiManager.elements.customSystemPromptModal) { 56 | this.uiManager.elements.customSystemPromptModal.addEventListener('click', (e) => { 57 | if (e.target === this.uiManager.elements.customSystemPromptModal) { 58 | this.hideCustomSystemPromptModal(); 59 | } 60 | }); 61 | } 62 | } 63 | 64 | // 显示自定义 system prompt 弹窗 65 | showCustomSystemPromptModal() { 66 | // 加载当前的自定义 system prompt 67 | this.storageManager.getCustomSystemPrompt().then(prompt => { 68 | this.uiManager.setCustomSystemPromptValue(prompt); 69 | this.uiManager.showCustomSystemPromptModal(); 70 | }); 71 | } 72 | 73 | // 隐藏自定义 system prompt 弹窗 74 | hideCustomSystemPromptModal() { 75 | this.uiManager.hideCustomSystemPromptModal(); 76 | } 77 | 78 | // 保存自定义 system prompt 79 | async saveCustomSystemPrompt() { 80 | try { 81 | const prompt = this.uiManager.getCustomSystemPromptValue(); 82 | const currentLang = this.i18nManager.getCurrentLang(); 83 | 84 | // 验证输入 85 | if (!this.validatePrompt(prompt)) { 86 | return; 87 | } 88 | 89 | // 保存到存储 90 | await this.storageManager.saveCustomSystemPrompt(prompt); 91 | 92 | // 显示成功消息 93 | const successMsg = currentLang === 'zh' 94 | ? '自定义系统提示词保存成功' 95 | : 'Custom system prompt saved successfully'; 96 | this.uiManager.showCustomSystemPromptValidationMessage(successMsg, true); 97 | 98 | // 延迟关闭弹窗 99 | setTimeout(() => { 100 | this.hideCustomSystemPromptModal(); 101 | }, 1500); 102 | 103 | } catch (error) { 104 | console.error('保存自定义 system prompt 失败:', error); 105 | const currentLang = this.i18nManager.getCurrentLang(); 106 | const errorMsg = currentLang === 'zh' 107 | ? '保存自定义系统提示词失败。请重试。' 108 | : 'Failed to save custom system prompt. Please try again.'; 109 | this.uiManager.showCustomSystemPromptValidationMessage(errorMsg, false); 110 | } 111 | } 112 | 113 | // 验证 prompt 114 | validatePrompt(prompt) { 115 | const currentLang = this.i18nManager.getCurrentLang(); 116 | 117 | // 基本验证:不能超过2000字符 118 | if (prompt.length > 2000) { 119 | const errorMsg = currentLang === 'zh' 120 | ? '系统提示词不能超过2000个字符' 121 | : 'System prompt cannot exceed 2000 characters.'; 122 | this.uiManager.showCustomSystemPromptValidationMessage(errorMsg, false); 123 | return false; 124 | } 125 | 126 | // ✅ 允许空值 - 用户可以选择不使用自定义系统提示 127 | // 移除了原有的"不能为空"验证 128 | 129 | return true; 130 | } 131 | 132 | // 获取当前自定义 system prompt 133 | async getCurrentSystemPrompt() { 134 | try { 135 | return await this.storageManager.getCustomSystemPrompt(); 136 | } catch (error) { 137 | console.error('获取自定义 system prompt 失败:', error); 138 | return ''; 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/icons/regenerate.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/content/components/Icons.js: -------------------------------------------------------------------------------- 1 | export const ICONS = { 2 | // Chat / Main (Sparkles/Bubble) 3 | icon24: ``, 4 | 5 | // Copy (Two rectangles) 6 | icon_copy: ``, 7 | copy: ``, 8 | 9 | // Translate (Circular arrows with A) 10 | translate: ``, 11 | 12 | // Explain (Lightbulb/Spark) 13 | explain: ``, 14 | 15 | // Summarize (List/Lines) 16 | summarize: ``, 17 | 18 | // Email (Envelope) 19 | email: ``, 20 | 21 | // Analyze (Magnifying glass / Chart) 22 | analyze: ``, 23 | 24 | // Regenerate (Arrows) 25 | regenerate: ``, 26 | 27 | // Send (Paper plane) 28 | send: ``, 29 | 30 | // Check (Checkmark) 31 | check: `` 32 | }; 33 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | 2 | # 🚀 DeepSeekAI - 智能网页助手 3 | 4 |
5 | 6 | DeepSeekAI Logo 7 | 8 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/bjjobdlpgglckcmhgmmecijpfobmcpap)](https://chromewebstore.google.com/detail/bjjobdlpgglckcmhgmmecijpfobmcpap) 9 | [![License](https://img.shields.io/github/license/DeepLifeStudio/DeepSeekAI)](LICENSE) 10 | [![GitHub stars](https://img.shields.io/github/stars/DeepLifeStudio/DeepSeekAI)](https://github.com/DeepLifeStudio/DeepSeekAI/stargazers) 11 | 12 | [English](README.md) | [简体中文](README.zh-CN.md) 13 | 14 |
15 | 16 | ## 📖 简介 17 | 18 | DeepSeekAI 是一个开源的非官方浏览器扩展,可以在任何网页上随时呼出私有的 DeepSeek 助手。只需划词、点击快捷操作或按下快捷键,即可打开一个浮动聊天工作区,实时流式展示回答、推理轨迹,并记住你偏好的布局。项目与 DeepSeek 官方无关,所有请求都需要你自行配置 API Key(DeepSeek 或任何兼容 OpenAI `/chat/completions` 规范的服务商)。 19 | 20 | > **提示**:本扩展完全由社区维护,与 DeepSeek 官方无关联。API Key、自定义 Endpoint 和偏好设置只会保存在你浏览器的 `chrome.storage.sync` 中。 21 | 22 | ### 🔌 支持的 API 服务商 23 | - [DeepSeek](https://deepseek.com)(官方接口) 24 | - [字节火山引擎 Volcengine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=OXTHJAF8) 25 | - [SiliconFlow](https://cloud.siliconflow.cn/i/lStn36vH) 26 | - [OpenRouter](https://openrouter.ai/models) 27 | - [AiHubMix](https://aihubmix.com?aff=SmJB) 28 | - [腾讯云](https://cloud.tencent.com/document/product/1772/115969) 29 | - [讯飞星辰](https://training.xfyun.cn/modelService) 30 | - [百度智能云](https://console.bce.baidu.com/qianfan/modelcenter/model/buildIn/list) 31 | - [阿里云](https://bailian.console.aliyun.com/#/model-market) 32 | - 任何自建 / 自定义 OpenAI 兼容接口都可接入 33 | 34 | ## ✨ 功能速览 35 | 36 | ### 🪄 划词即用的轻量助手 37 | - 划词后在文本旁生成快捷操作气泡:Chat、复制、翻译(19 种语言)、解释、总结、邮件、分析等模版一键可用。 38 | - SelectionPreservationManager 持久化 DOM Range,确保双击/三击或右键菜单时选区不会丢失。 39 | - 工具栏弹窗、右键菜单、快捷键全部复用同一套对话逻辑,选中内容与手动提问无缝切换。 40 | 41 | ### 🪟 漂浮式工作区 42 | - 基于 `interactjs` 的拖拽/缩放体验,自带弹簧过渡动画与可记忆的最小化图标(持久化位置)。 43 | - “记住窗口大小”和“固定窗口”开关可独立控制,防止误触关窗或在不同页面重复调整。 44 | - `popupStateManager` 统一记录创建/显示/最小化状态,切换选区或键盘打开都能保持一致体验。 45 | - 输入区包含自动扩展 textarea、发送箭头、停止按钮,并通过 focusManager 避免抢占当前页面输入焦点。 46 | - 每条回答自带复制与重新生成按钮;DeepSeek-R1 / OpenRouter 的 reasoning 片段会出现在答案上方,可折叠查看。 47 | - ScrollManager 管理滚动惯性与冷却时间,既能跟随流式输出,也能在手动滚动时保持视图稳定。 48 | 49 | ### 🧠 服务商与模型管理 50 | - 弹窗 UI(中英双语)可为每个服务商分别存储 API Key、自定义 API 地址、默认语言与是否启用划词气泡。 51 | - ProviderManager 支持新增、重命名、隐藏、删除自定义服务商,并自动填充获取 Key 的帮助链接。 52 | - ModelManager 支持为任意服务商维护多模型列表(含 inline 删除按钮),表单内容由 TempStateManager 自动保存,防止弹窗刷新导致进度丢失。 53 | - SystemPromptManager 允许配置全局自定义 System Prompt;Quick Action 模板也可覆盖局部提示词。 54 | 55 | ### 📝 渲染、安全与体验细节 56 | - Markdown-It + highlight.js + KaTeX + DOMPurify 提供富文本、代码高亮、数学公式与 HTML 清洗能力。 57 | - 代码块包裹通用复制按钮;回答块的复制/重生按钮与气泡一致,方便“整理后即复制”。 58 | - 背景 Service Worker 通过现代 `fetch` + `AbortController` 代理所有请求,停止/重试/快捷键均可即时终止网络流。 59 | - ThemeManager 监听 `prefers-color-scheme` 自动切换暗黑/浅色主题,气泡与工作区视觉统一。 60 | 61 | ### ⌨️ 唤起方式与快捷键 62 | - 默认内置两个 Chrome Command: 63 | - `Ctrl/Cmd + Shift + Y` → Toggle chat(销毁并重建对话) 64 | - `Ctrl/Cmd + Shift + U` → Show/Hide chat(保留会话继续) 65 | - 可通过 `chrome://extensions/shortcuts` 或弹窗内「快捷键设置」链接重新绑定。 66 | - 右键菜单「DeepSeek AI」会带上当前选中内容,并附加早/午/晚问候语。 67 | 68 | ### 🔐 隐私与新手引导 69 | - 首次安装会自动打开 [Instructions/Instructions.html](src/Instructions/instructions.html),内置离线教程,UI 采用 Apple 风格,覆盖所有界面和操作。 70 | - 仓库中附带 [PRIVACY.html](PRIVACY.html),明确说明所有数据仅存在浏览器本地,不上传到任何服务器。 71 | - DOMPurify 负责清洗渲染内容,全流程无遥测、无埋点、无第三方统计。 72 | 73 | ## 🔄 工作原理 74 | 75 | ```mermaid 76 | sequenceDiagram 77 | participant 用户 78 | participant Content as Content Script 79 | participant Background 80 | participant Provider 81 | 用户->>Content: 划词 / 快捷键 / 右键 82 | Content->>Background: chrome.runtime.sendMessage({ action: "proxyRequest" | "getSettings" }) 83 | Background->>Provider: fetch(OpenAI-compatible endpoint) 84 | Provider-->>Background: SSE / JSON 数据流 85 | Background-->>Content: streamResponse 事件(支持 AbortController) 86 | Content-->>用户: 渲染 Markdown、推理块、快捷操作 87 | ``` 88 | 89 | - `src/content/content.js` 负责划词气泡、工作区、Markdown 渲染、主题/滚动/焦点管理等全部前端交互。 90 | - `src/background.js` 负责读取设置、转发网络请求、处理 SSE、命令与右键菜单、Onboarding 页面等。 91 | - `src/popup/` 是模块化设置面板(ApiKeyManager、ProviderManager、ModelManager、SystemPromptManager...)并内置 i18n 与自动保存。 92 | - `src/Instructions/` 存放首次安装时展示的离线说明。 93 | 94 | ## 🚀 安装与构建 95 | 96 | ### 1. 应用商店安装(推荐) 97 | - **Chrome**: [Chrome Web Store](https://chromewebstore.google.com/detail/bjjobdlpgglckcmhgmmecijpfobmcpap) 98 | - **Microsoft Edge**:开启 “Allow extensions from other stores” 后,可直接使用同一 Chrome 商店链接。 99 | 100 | ### 2. 手动安装 / 开发流程 101 | ```bash 102 | # 依赖:Node.js 18+、pnpm(或 npm)、Chromium 内核浏览器 103 | pnpm install 104 | pnpm run build # 产出 dist/ 目录 105 | ``` 106 | 107 | 1. 打开 `chrome://extensions` → 开启 **开发者模式** → **加载已解压的扩展程序** → 选择 `dist` 文件夹。 108 | 2. 需要打包商店版本时可运行: 109 | - `pnpm run build:zip` → 生成 `extension.zip` 110 | - `pnpm run build:chrome` → 生成 `chrome-submission.zip` 111 | - `pnpm run build:edge` → 生成 `edge-submission.zip` 112 | 3. 将对应 ZIP 上传至各自商店控制台即可提交审核。 113 | 114 | ## 🧩 配置与日常使用 115 | 1. 点击浏览器扩展图标打开弹窗。 116 | 2. 选择服务商(或新建自定义服务商,填写名称 + 基础 URL + 默认模型)并输入对应 API Key。各服务商的 Key、API 地址互不干扰。 117 | 3. 选择或创建模型。非 DeepSeek 服务商必须显式填写模型 ID;若缺失,UI 会提醒并自动弹出添加模型对话框。 118 | 4. 配置行为: 119 | - 开关划词快捷操作。 120 | - 选择自动识别语言或强制输出语言(内置 20+ 语言)。 121 | - 切换 “记住窗口大小”、“固定窗口” 以及 “自定义 System Prompt”。 122 | - 通过 **Shortcut Settings** 跳转至 Chrome 快捷键编辑器。 123 | 5. 划词(或直接打开聊天窗口)→ 快捷操作气泡出现 → 选择 Chat/模版即可。不划词也可以先打开工作区再粘贴内容提问。 124 | 6. 流式生成过程中可点击停止按钮中断;回答末尾提供复制与重生按钮,Reasoning 区域支持折叠/展开。 125 | 7. 需要复习?可随时打开扩展内的 [离线指南](src/Instructions/instructions.html) 或切换到顶部链接的英文版 README。 126 | 127 | ## ⌨️ 快捷操作与命令 128 | - **快捷操作内容**: 129 | - `Chat` → 直接发送当前选区。 130 | - `Copy` → 不唤出聊天,仅复制选中文本。 131 | - `Translate` → 弹出语言列表,驱动 DeepSeek 翻译到目标语言。 132 | - `Explain` / `Summarize` / `Email` / `Analyze` → 内置模板(含 MBTI 风格)的结构化回答。 133 | - **窗口命令**:`toggle-chat` 重新创建新会话;`show-hide-chat` 保留上下文;`close-chat` 在内部用于上下文菜单清理。 134 | - **右键菜单**:右键选择 “DeepSeek AI” 即可把选区连同问候语发送到新对话。 135 | 136 | ## 🏗️ 项目结构与技术栈 137 | ``` 138 | . 139 | ├── src/ 140 | │ ├── manifest.json # MV3 元数据与权限 141 | │ ├── background.js # Service Worker + 代理 + 快捷命令 142 | │ ├── content/ # 划词气泡、工作区、服务、工具、样式 143 | │ ├── popup/ # 设置 UI(各管理器、i18n、HTML) 144 | │ └── Instructions/ # 首次安装展示的离线说明 145 | ├── dist/ # 构建产物 146 | ├── extension.zip # build:zip/build:chrome/build:edge 产物 147 | ├── webpack.config.js # 构建配置(Babel、CSS Loader、Copy、Terser) 148 | ├── PRIVACY.html # 隐私政策 149 | └── README*.md # 中英文文档 150 | ``` 151 | 152 | **核心依赖**:`interactjs`、`markdown-it`、`highlight.js`、`DOMPurify`、`katex`、`clipboard`、`perfect-scrollbar`、`openai`(用于请求结构)以及 Chrome/Edge 的 MV3 API。 153 | 154 | ## 🔒 隐私与安全 155 | - API Key、偏好设置、快捷操作状态、最小化图标位置全部保存在 `chrome.storage.sync`,不会离开本地。 156 | - 文本仅发送至你配置的服务商 Endpoint,无中转服务器、无日志、无埋点。 157 | - 仓库内的 [PRIVACY.html](PRIVACY.html) 记录全部细节,DOMPurify 负责渲染前的安全清洗。 158 | 159 | ## 🤝 贡献指南 160 | 欢迎提交 Bug 报告、文档修正或新功能提案: 161 | 162 | 1. Fork 仓库并创建分支(`git checkout -b feature/my-update`)。 163 | 2. 安装依赖并构建一次(`pnpm install && pnpm run build`)。 164 | 3. 保持改动聚焦,修改完成后再次 `pnpm run build`,确保 `dist/` 更新。 165 | 4. 发起 Pull Request,描述改动、涉及文件与验证方式。 166 | 167 | ## 📄 许可证 168 | 169 | 本项目遵循 MIT 许可证,详见 [LICENSE](LICENSE)。 170 | 171 | ## 📮 联系方式 172 | 173 | - Issues: [GitHub Issues](https://github.com/DeepLifeStudio/DeepSeekAI/issues) 174 | - Email: [1024jianghu@gmail.com](mailto:1024jianghu@gmail.com) 175 | - Twitter/X: [@DeepLifeStudio](https://x.com/DeepLifeStudio) 176 | 177 |
178 |

如果这个项目对你有帮助,请顺手点一个 ⭐️

179 |
180 | -------------------------------------------------------------------------------- /src/content/components/ShadowContainer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Shadow DOM 容器组件 3 | * 用于创建样式隔离的弹窗容器,防止宿主网页样式污染扩展 UI 4 | */ 5 | 6 | // 全局 Shadow 容器引用 7 | let shadowContainerInstance = null; 8 | 9 | /** 10 | * 检测页面是否使用了 filter: invert() 实现深色模式 11 | * @returns {boolean} 12 | */ 13 | function detectPageInvertFilter() { 14 | const checkElement = (el) => { 15 | if (!el) return false; 16 | const style = window.getComputedStyle(el); 17 | const filter = style.filter || style.webkitFilter || ''; 18 | return filter.includes('invert'); 19 | }; 20 | 21 | return checkElement(document.documentElement) || checkElement(document.body); 22 | } 23 | 24 | /** 25 | * 创建 Shadow DOM 容器 26 | * @param {string} cssText - 要注入到 Shadow DOM 的 CSS 样式文本 27 | * @returns {{ host: HTMLElement, shadow: ShadowRoot, container: HTMLElement }} 28 | */ 29 | export function createShadowContainer(cssText) { 30 | // 如果已存在,先销毁 31 | if (shadowContainerInstance) { 32 | destroyShadowContainer(); 33 | } 34 | 35 | // 创建宿主元素 36 | const host = document.createElement('div'); 37 | host.id = 'deepseek-shadow-host'; 38 | 39 | // 检测页面是否使用了 invert filter 40 | const pageUsesInvert = detectPageInvertFilter(); 41 | 42 | // 设置宿主元素的样式,确保它不被宿主网页影响 43 | Object.assign(host.style, { 44 | all: 'initial', 45 | position: 'fixed', 46 | top: '0', 47 | left: '0', 48 | width: '0', 49 | height: '0', 50 | overflow: 'visible', 51 | zIndex: '2147483647', 52 | pointerEvents: 'none', // 宿主元素不拦截事件 53 | // 如果页面使用了 invert,我们也应用 invert 来抵消效果 54 | filter: pageUsesInvert ? 'invert(1) hue-rotate(180deg)' : 'none', 55 | isolation: 'isolate', // 创建新的层叠上下文,隔离混合模式 56 | }); 57 | 58 | // 创建 Shadow DOM 59 | const shadow = host.attachShadow({ mode: 'open' }); 60 | 61 | // 注入样式 62 | const style = document.createElement('style'); 63 | style.textContent = ` 64 | /* Shadow DOM 根容器重置样式 */ 65 | :host { 66 | all: initial !important; 67 | display: block !important; 68 | position: fixed !important; 69 | top: 0 !important; 70 | left: 0 !important; 71 | width: 100vw !important; 72 | height: 100vh !important; 73 | pointer-events: none !important; 74 | z-index: 2147483647 !important; 75 | font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif !important; 76 | font-size: 15px !important; 77 | line-height: 1.4 !important; 78 | /* 不设置 color,让具体元素自己控制颜色 */ 79 | -webkit-font-smoothing: antialiased !important; 80 | -moz-osx-font-smoothing: grayscale !important; 81 | /* filter 由 JS 动态设置,用于抵消页面的 invert 效果 */ 82 | isolation: isolate !important; 83 | } 84 | 85 | /* 内容容器 - 不使用 all: initial 避免重置颜色等属性 */ 86 | #deepseek-popup-root { 87 | display: block; 88 | position: fixed; 89 | top: 0; 90 | left: 0; 91 | width: 100%; 92 | height: 100%; 93 | pointer-events: none; 94 | font-family: inherit; 95 | font-size: inherit; 96 | line-height: inherit; 97 | /* 不设置 color,让子元素通过 CSS 类控制 */ 98 | } 99 | 100 | /* 确保弹窗元素可以接收事件 */ 101 | #deepseek-popup-root > * { 102 | pointer-events: auto; 103 | } 104 | 105 | /* 注入的外部样式 */ 106 | ${cssText} 107 | `; 108 | shadow.appendChild(style); 109 | 110 | // 创建内容容器 111 | const container = document.createElement('div'); 112 | container.id = 'deepseek-popup-root'; 113 | shadow.appendChild(container); 114 | 115 | // 键盘事件隔离:阻止宿主页面的快捷键/热键抢占焦点(尤其是英文键盘输入) 116 | // 原因:键盘事件在 Shadow DOM 会被 retarget 到宿主节点,宿主页认为当前不在可编辑元素中,从而触发自身的快捷键逻辑(跳转到搜索框等) 117 | // 处理:在 Shadow 边界的冒泡阶段截断键盘事件,但保留 ESC 以维持关闭快捷键 118 | const stopKeyboardEventPropagation = (event) => { 119 | // 允许 ESC 继续冒泡以便全局关闭逻辑生效 120 | if (event.key === 'Escape') return; 121 | 122 | // 仅处理从当前 Shadow 容器内冒泡的事件 123 | const path = typeof event.composedPath === 'function' ? event.composedPath() : []; 124 | const withinPopup = path.some(node => node && (node.id === 'deepseek-popup-root' || node.id === 'ai-popup')); 125 | if (!withinPopup) return; 126 | 127 | // 阻断向宿主页冒泡,避免宿主页快捷键劫持焦点 128 | event.stopPropagation(); 129 | if (typeof event.stopImmediatePropagation === 'function') { 130 | event.stopImmediatePropagation(); 131 | } 132 | }; 133 | 134 | ['keydown', 'keypress', 'keyup'].forEach((type) => { 135 | shadow.addEventListener(type, stopKeyboardEventPropagation); 136 | }); 137 | 138 | // 保存实例引用 139 | shadowContainerInstance = { host, shadow, container }; 140 | 141 | return shadowContainerInstance; 142 | } 143 | 144 | /** 145 | * 获取当前的 Shadow DOM 容器 146 | * @returns {{ host: HTMLElement, shadow: ShadowRoot, container: HTMLElement } | null} 147 | */ 148 | export function getShadowContainer() { 149 | return shadowContainerInstance; 150 | } 151 | 152 | /** 153 | * 销毁 Shadow DOM 容器 154 | */ 155 | export function destroyShadowContainer() { 156 | if (shadowContainerInstance) { 157 | const { host } = shadowContainerInstance; 158 | if (host && host.parentNode) { 159 | host.parentNode.removeChild(host); 160 | } 161 | shadowContainerInstance = null; 162 | } 163 | } 164 | 165 | /** 166 | * 确保 Shadow DOM 容器存在并挂载到 document.body 167 | * @param {string} cssText - CSS 样式文本 168 | * @returns {{ host: HTMLElement, shadow: ShadowRoot, container: HTMLElement }} 169 | */ 170 | export function ensureShadowContainer(cssText) { 171 | let container = getShadowContainer(); 172 | 173 | // 检查容器是否存在且仍在 DOM 中 174 | if (container && container.host && document.body.contains(container.host)) { 175 | return container; 176 | } 177 | 178 | // 创建新容器 179 | container = createShadowContainer(cssText); 180 | document.body.appendChild(container.host); 181 | 182 | return container; 183 | } 184 | 185 | /** 186 | * 在 Shadow DOM 中查询元素(用于替代 document.querySelector) 187 | * @param {string} selector - CSS 选择器 188 | * @returns {Element | null} 189 | */ 190 | export function shadowQuerySelector(selector) { 191 | const container = getShadowContainer(); 192 | if (container && container.shadow) { 193 | return container.shadow.querySelector(selector); 194 | } 195 | return null; 196 | } 197 | 198 | /** 199 | * 在 Shadow DOM 中查询所有元素(用于替代 document.querySelectorAll) 200 | * @param {string} selector - CSS 选择器 201 | * @returns {NodeList} 202 | */ 203 | export function shadowQuerySelectorAll(selector) { 204 | const container = getShadowContainer(); 205 | if (container && container.shadow) { 206 | return container.shadow.querySelectorAll(selector); 207 | } 208 | return document.createDocumentFragment().querySelectorAll('*'); // 返回空 NodeList 209 | } 210 | 211 | /** 212 | * 获取 ai-popup 元素(支持 Shadow DOM) 213 | * @returns {Element | null} 214 | */ 215 | export function getPopupElement() { 216 | const container = getShadowContainer(); 217 | if (container && container.shadow) { 218 | return container.shadow.querySelector('#ai-popup'); 219 | } 220 | return document.getElementById('ai-popup'); 221 | } 222 | 223 | /** 224 | * 获取 ai-response 元素(支持 Shadow DOM) 225 | * @returns {Element | null} 226 | */ 227 | export function getAiResponseElement() { 228 | const container = getShadowContainer(); 229 | if (container && container.shadow) { 230 | return container.shadow.querySelector('#ai-response'); 231 | } 232 | return document.getElementById('ai-response'); 233 | } 234 | 235 | /** 236 | * 获取 ai-response-container 元素(支持 Shadow DOM) 237 | * @returns {Element | null} 238 | */ 239 | export function getAiResponseContainer() { 240 | // 优先使用 window.aiResponseContainer 241 | if (window.aiResponseContainer) { 242 | return window.aiResponseContainer; 243 | } 244 | const container = getShadowContainer(); 245 | if (container && container.shadow) { 246 | return container.shadow.querySelector('#ai-response-container'); 247 | } 248 | return document.getElementById('ai-response-container'); 249 | } 250 | -------------------------------------------------------------------------------- /src/popup/TempStateManager.js: -------------------------------------------------------------------------------- 1 | export class TempStateManager { 2 | constructor() { 3 | this.STORAGE_PREFIX = 'temp_state_'; 4 | this.AUTO_SAVE_DELAY = 1000; // 1秒延迟自动保存 5 | this.saveTimeouts = new Map(); // 防抖用的超时映射 6 | } 7 | 8 | // 临时状态类型 9 | static TYPES = { 10 | ADD_MODEL: 'add_model', 11 | ADD_PROVIDER: 'add_provider' 12 | }; 13 | 14 | // 获取临时状态存储键 15 | getStorageKey(type) { 16 | return `${this.STORAGE_PREFIX}${type}`; 17 | } 18 | 19 | // 保存临时状态(防抖) 20 | saveTempState(type, formData, immediate = false) { 21 | const key = this.getStorageKey(type); 22 | 23 | const saveAction = () => { 24 | try { 25 | // 添加时间戳 26 | const stateData = { 27 | ...formData, 28 | timestamp: Date.now(), 29 | lastModified: new Date().toISOString() 30 | }; 31 | 32 | localStorage.setItem(key, JSON.stringify(stateData)); 33 | } catch (error) { 34 | console.error('保存临时状态失败:', error); 35 | } 36 | }; 37 | 38 | if (immediate) { 39 | saveAction(); 40 | } else { 41 | // 清除现有的延迟保存 42 | if (this.saveTimeouts.has(type)) { 43 | clearTimeout(this.saveTimeouts.get(type)); 44 | } 45 | 46 | // 设置新的延迟保存 47 | const timeoutId = setTimeout(saveAction, this.AUTO_SAVE_DELAY); 48 | this.saveTimeouts.set(type, timeoutId); 49 | } 50 | } 51 | 52 | // 获取临时状态 53 | getTempState(type) { 54 | try { 55 | const key = this.getStorageKey(type); 56 | const data = localStorage.getItem(key); 57 | 58 | if (!data) return null; 59 | 60 | const parsedData = JSON.parse(data); 61 | 62 | // 检查状态是否过期(24小时) 63 | const maxAge = 24 * 60 * 60 * 1000; // 24小时 64 | if (Date.now() - parsedData.timestamp > maxAge) { 65 | this.clearTempState(type); 66 | return null; 67 | } 68 | 69 | return parsedData; 70 | } catch (error) { 71 | console.error('获取临时状态失败:', error); 72 | return null; 73 | } 74 | } 75 | 76 | // 清除临时状态 77 | clearTempState(type) { 78 | try { 79 | const key = this.getStorageKey(type); 80 | localStorage.removeItem(key); 81 | 82 | // 清除对应的延迟保存 83 | if (this.saveTimeouts.has(type)) { 84 | clearTimeout(this.saveTimeouts.get(type)); 85 | this.saveTimeouts.delete(type); 86 | } 87 | 88 | } catch (error) { 89 | console.error('清除临时状态失败:', error); 90 | } 91 | } 92 | 93 | // 获取所有临时状态 94 | getAllTempStates() { 95 | const states = {}; 96 | for (const type of Object.values(TempStateManager.TYPES)) { 97 | const state = this.getTempState(type); 98 | if (state) { 99 | states[type] = state; 100 | } 101 | } 102 | return states; 103 | } 104 | 105 | // 清除所有临时状态 106 | clearAllTempStates() { 107 | for (const type of Object.values(TempStateManager.TYPES)) { 108 | this.clearTempState(type); 109 | } 110 | } 111 | 112 | // 检查是否有临时状态 113 | hasTempState(type) { 114 | return this.getTempState(type) !== null; 115 | } 116 | 117 | // 检查临时状态是否有实际内容 118 | hasTempStateContent(type) { 119 | const state = this.getTempState(type); 120 | if (!state) return false; 121 | 122 | // 移除时间戳等元数据,只检查实际表单数据 123 | const { timestamp, lastModified, ...formData } = state; 124 | 125 | // 检查是否有任何非空的内容 126 | return Object.values(formData).some(value => 127 | value && value.toString().trim() !== '' 128 | ); 129 | } 130 | 131 | // 自动保存表单数据(用于表单输入监听) 132 | autoSaveTempState(type, getFormDataCallback) { 133 | try { 134 | const formData = getFormDataCallback(); 135 | 136 | // 检查是否有任何非空的内容 137 | const hasContent = Object.values(formData).some(value => 138 | value && value.toString().trim() !== '' 139 | ); 140 | 141 | if (hasContent) { 142 | // 有内容时保存 143 | this.saveTempState(type, formData); 144 | } else { 145 | // 所有内容都为空时清除临时状态 146 | this.clearTempState(type); 147 | } 148 | } catch (error) { 149 | console.error('自动保存临时状态失败:', error); 150 | } 151 | } 152 | 153 | // 获取模型添加表单数据 154 | getModelFormData() { 155 | const modelApiId = document.getElementById('modelApiId'); 156 | const modelDisplayName = document.getElementById('modelDisplayName'); 157 | const modelApiKey = document.getElementById('modelApiKey'); 158 | 159 | const data = { 160 | modelApiId: modelApiId?.value || '', 161 | modelDisplayName: modelDisplayName?.value || '', 162 | modelApiKey: modelApiKey?.value || '', 163 | currentProvider: document.getElementById('provider')?.value || '' 164 | }; 165 | 166 | return data; 167 | } 168 | 169 | // 恢复模型添加表单数据 170 | restoreModelFormData(data) { 171 | try { 172 | const modelApiId = document.getElementById('modelApiId'); 173 | const modelDisplayName = document.getElementById('modelDisplayName'); 174 | const modelApiKey = document.getElementById('modelApiKey'); 175 | 176 | // 修复:不检查值是否为空字符串,因为空字符串也是有效的数据 177 | if (modelApiId && data.modelApiId !== undefined) { 178 | modelApiId.value = data.modelApiId; 179 | } 180 | 181 | if (modelDisplayName && data.modelDisplayName !== undefined) { 182 | modelDisplayName.value = data.modelDisplayName; 183 | } 184 | 185 | if (modelApiKey && data.modelApiKey !== undefined) { 186 | modelApiKey.value = data.modelApiKey; 187 | } 188 | 189 | } catch (error) { 190 | console.error('恢复模型表单数据失败:', error); 191 | } 192 | } 193 | 194 | // 获取服务商添加表单数据 195 | getProviderFormData() { 196 | const customProviderNameInput = document.getElementById('customProviderNameInput'); 197 | const customProviderApiKey = document.getElementById('customProviderApiKey'); 198 | const customApiUrlInput = document.getElementById('customApiUrlInput'); 199 | const customModelIdInput = document.getElementById('customModelIdInput'); 200 | const customModelNameInput = document.getElementById('customModelNameInput'); 201 | 202 | const data = { 203 | providerName: customProviderNameInput?.value || '', 204 | apiKey: customProviderApiKey?.value || '', 205 | apiUrl: customApiUrlInput?.value || '', 206 | modelId: customModelIdInput?.value || '', 207 | modelName: customModelNameInput?.value || '' 208 | }; 209 | 210 | return data; 211 | } 212 | 213 | // 恢复服务商添加表单数据 214 | restoreProviderFormData(data) { 215 | try { 216 | const customProviderNameInput = document.getElementById('customProviderNameInput'); 217 | const customProviderApiKey = document.getElementById('customProviderApiKey'); 218 | const customApiUrlInput = document.getElementById('customApiUrlInput'); 219 | const customModelIdInput = document.getElementById('customModelIdInput'); 220 | const customModelNameInput = document.getElementById('customModelNameInput'); 221 | 222 | // 修复:不检查值是否为空字符串,因为空字符串也是有效的数据 223 | if (customProviderNameInput && data.providerName !== undefined) { 224 | customProviderNameInput.value = data.providerName; 225 | } 226 | 227 | if (customProviderApiKey && data.apiKey !== undefined) { 228 | customProviderApiKey.value = data.apiKey; 229 | } 230 | 231 | if (customApiUrlInput && data.apiUrl !== undefined) { 232 | customApiUrlInput.value = data.apiUrl; 233 | } 234 | 235 | if (customModelIdInput && data.modelId !== undefined) { 236 | customModelIdInput.value = data.modelId; 237 | } 238 | 239 | if (customModelNameInput && data.modelName !== undefined) { 240 | customModelNameInput.value = data.modelName; 241 | } 242 | 243 | } catch (error) { 244 | console.error('恢复服务商表单数据失败:', error); 245 | } 246 | } 247 | 248 | // 安装表单监听器 249 | installFormListeners(type) { 250 | let elements = []; 251 | let getFormDataCallback; 252 | 253 | if (type === TempStateManager.TYPES.ADD_MODEL) { 254 | elements = [ 255 | 'modelApiId', 256 | 'modelDisplayName', 257 | 'modelApiKey' 258 | ]; 259 | getFormDataCallback = () => this.getModelFormData(); 260 | } else if (type === TempStateManager.TYPES.ADD_PROVIDER) { 261 | elements = [ 262 | 'customProviderNameInput', 263 | 'customProviderApiKey', 264 | 'customApiUrlInput', 265 | 'customModelIdInput', 266 | 'customModelNameInput' 267 | ]; 268 | getFormDataCallback = () => this.getProviderFormData(); 269 | } 270 | 271 | // 为每个表单元素添加输入监听器 272 | elements.forEach(elementId => { 273 | const element = document.getElementById(elementId); 274 | if (element) { 275 | // 移除可能存在的旧监听器 276 | element.removeEventListener('input', element._tempStateListener); 277 | element.removeEventListener('change', element._tempStateListener); 278 | 279 | // 创建新的监听器 280 | const listener = () => { 281 | this.autoSaveTempState(type, getFormDataCallback); 282 | }; 283 | 284 | // 保存监听器引用以便后续移除 285 | element._tempStateListener = listener; 286 | 287 | // 添加事件监听器 288 | element.addEventListener('input', listener); 289 | element.addEventListener('change', listener); 290 | } 291 | }); 292 | 293 | console.log(`📝 已安装表单监听器: ${type}`); 294 | } 295 | 296 | // 移除表单监听器 297 | removeFormListeners(type) { 298 | let elements = []; 299 | 300 | if (type === TempStateManager.TYPES.ADD_MODEL) { 301 | elements = ['modelApiId', 'modelDisplayName', 'modelApiKey']; 302 | } else if (type === TempStateManager.TYPES.ADD_PROVIDER) { 303 | elements = [ 304 | 'customProviderNameInput', 305 | 'customProviderApiKey', 306 | 'customApiUrlInput', 307 | 'customModelIdInput', 308 | 'customModelNameInput' 309 | ]; 310 | } 311 | 312 | elements.forEach(elementId => { 313 | const element = document.getElementById(elementId); 314 | if (element && element._tempStateListener) { 315 | element.removeEventListener('input', element._tempStateListener); 316 | element.removeEventListener('change', element._tempStateListener); 317 | delete element._tempStateListener; 318 | } 319 | }); 320 | 321 | console.log(`🗑️ 已移除表单监听器: ${type}`); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/popup/popup.js: -------------------------------------------------------------------------------- 1 | import { ApiKeyManager } from './apiKeyManager.js'; 2 | import { I18nManager } from './i18n.js'; 3 | import { UiManager } from './uiManager.js'; 4 | import { StorageManager } from './storageManager.js'; 5 | import { ProviderManager } from './ProviderManager.js'; 6 | import { ModelManager } from './ModelManager.js'; 7 | import { ProviderUIManager } from './ProviderUIManager.js'; 8 | import { EventManager } from './EventManager.js'; 9 | import { TempStateManager } from './TempStateManager.js'; 10 | import { SystemPromptManager } from './SystemPromptManager.js'; 11 | 12 | class PopupManager { 13 | constructor() { 14 | // 初始化所有管理器 15 | this.i18nManager = new I18nManager(); 16 | this.uiManager = new UiManager(); 17 | this.storageManager = new StorageManager(); 18 | this.providerManager = new ProviderManager(); 19 | this.tempStateManager = new TempStateManager(); 20 | 21 | // 初始化依赖其他管理器的管理器 22 | this.modelManager = new ModelManager( 23 | this.providerManager, 24 | this.storageManager, 25 | this.uiManager, 26 | this.i18nManager, 27 | this.tempStateManager 28 | ); 29 | 30 | this.providerUIManager = new ProviderUIManager( 31 | this.providerManager, 32 | this.storageManager, 33 | this.uiManager, 34 | this.i18nManager, 35 | this.tempStateManager 36 | ); 37 | 38 | this.apiKeyManager = new ApiKeyManager( 39 | this.providerManager, 40 | this.uiManager, 41 | this.i18nManager, 42 | this.modelManager 43 | ); 44 | 45 | this.systemPromptManager = new SystemPromptManager( 46 | this.storageManager, 47 | this.uiManager, 48 | this.i18nManager 49 | ); 50 | 51 | // 将所有管理器传递给事件管理器 52 | this.eventManager = new EventManager({ 53 | i18nManager: this.i18nManager, 54 | uiManager: this.uiManager, 55 | storageManager: this.storageManager, 56 | providerManager: this.providerManager, 57 | modelManager: this.modelManager, 58 | providerUIManager: this.providerUIManager, 59 | apiKeyManager: this.apiKeyManager, 60 | tempStateManager: this.tempStateManager, 61 | systemPromptManager: this.systemPromptManager 62 | }); 63 | 64 | // 初始化 65 | this.init(); 66 | } 67 | 68 | init() { 69 | if (document.readyState === 'loading') { 70 | document.addEventListener('DOMContentLoaded', () => this.onDOMReady()); 71 | } else { 72 | this.onDOMReady(); 73 | } 74 | } 75 | 76 | onDOMReady() { 77 | // 确保先更新国际化标签 78 | this.i18nManager.updateLabels(); 79 | this.eventManager.initializeEventListeners(); 80 | this.loadInitialState(); 81 | } 82 | 83 | async loadInitialState() { 84 | try { 85 | 86 | // 获取设置 87 | const settings = await this.storageManager.getSettings(); 88 | const currentProvider = settings.provider || 'deepseek'; 89 | 90 | // 检查临时状态并决定是否恢复对话框 91 | const tempStates = this.tempStateManager.getAllTempStates(); 92 | 93 | 94 | // 加载所有可见的服务商到下拉菜单 95 | const visibleProviders = await this.providerManager.getAllVisibleProviders(); 96 | 97 | // 清空现有选项 98 | this.uiManager.elements.providerSelect.innerHTML = ''; 99 | 100 | // 添加所有可见的服务商 101 | for (const provider of visibleProviders) { 102 | const option = document.createElement('option'); 103 | option.value = provider.id; 104 | option.textContent = provider.name; 105 | this.uiManager.elements.providerSelect.appendChild(option); 106 | } 107 | 108 | // 添加"添加服务商"选项 109 | const addOption = document.createElement('option'); 110 | addOption.value = 'custom'; 111 | addOption.textContent = this.i18nManager.getTranslation('addCustomProvider'); 112 | addOption.id = 'customProvider'; 113 | this.uiManager.elements.providerSelect.appendChild(addOption); 114 | 115 | // 设置UI元素的初始值 116 | this.uiManager.elements.languageSelect.value = settings.language; 117 | this.uiManager.elements.providerSelect.value = currentProvider; 118 | this.uiManager.elements.selectionEnabled.checked = settings.selectionEnabled; 119 | this.uiManager.elements.rememberWindowSize.checked = settings.rememberWindowSize; 120 | 121 | // 只清空API密钥输入框,不清空自定义API地址输入框 122 | this.uiManager.setApiKeyValue(''); 123 | 124 | // 创建自定义服务商下拉菜单 125 | await this.providerUIManager.createCustomProviderDropdown(currentProvider); 126 | 127 | // 更新服务商UI(包括API密钥和自定义API URL的placeholder) 128 | await this.providerUIManager.updateProviderUI(currentProvider); 129 | 130 | // 更新模型选项 131 | const models = await this.modelManager.updateModelOptions(currentProvider); 132 | 133 | // 根据临时状态决定要恢复哪个对话框(只有在有实际内容时才恢复) 134 | if (this.tempStateManager.hasTempStateContent(TempStateManager.TYPES.ADD_PROVIDER)) { 135 | console.log('🔄 恢复服务商添加对话框...'); 136 | setTimeout(() => this.providerUIManager.showCustomProviderDialog(), 100); 137 | } else if (this.tempStateManager.hasTempStateContent(TempStateManager.TYPES.ADD_MODEL)) { 138 | console.log('🔄 恢复模型添加对话框...'); 139 | setTimeout(() => this.modelManager.showAddModelDialog(), 100); 140 | } else { 141 | // 如果没有临时状态,执行正常的模型检查逻辑 142 | // 非 deepseek:若无模型,强制弹出添加模型(使用带有Key自动隐藏逻辑的接口) 143 | if (currentProvider !== 'deepseek' && (!models || models.length === 0)) { 144 | if (this.modelManager && this.modelManager.showAddModelDialog) { 145 | this.modelManager.showAddModelDialog(); 146 | } else if (this.uiManager.showAddModelModal) { 147 | // 兜底 148 | this.uiManager.showAddModelModal(); 149 | } 150 | } 151 | } 152 | 153 | // 设置保存的model值 154 | if (settings.model) { 155 | this.uiManager.elements.modelSelect.value = settings.model; 156 | } 157 | 158 | // 初始化自定义 system prompt 管理器 159 | await this.systemPromptManager.initialize(); 160 | 161 | // 再次更新国际化标签,确保所有动态生成的元素也应用了正确的语言 162 | this.i18nManager.updateLabels(); 163 | 164 | } catch (error) { 165 | console.error('初始化错误:', error); 166 | } 167 | } 168 | } 169 | 170 | // 初始化 171 | const popupManager = new PopupManager(); 172 | 173 | // 全局函数,用于更新内容 174 | window.updateContent = () => { 175 | // 只更新标签文本,不触发任何其他操作 176 | if (popupManager && popupManager.i18nManager) { 177 | popupManager.i18nManager.updateLabels(); 178 | } 179 | }; 180 | 181 | // 获取当前语言 182 | const getCurrentLang = () => localStorage.getItem('preferredLang') || 'en'; 183 | 184 | // 设置当前语言 185 | const setCurrentLang = (lang) => localStorage.setItem('preferredLang', lang); 186 | 187 | // 语言切换按钮事件 188 | document.getElementById('language-toggle')?.addEventListener('click', () => { 189 | // 保存当前输入值 190 | const customApiUrlElement = document.getElementById('customApiUrl'); 191 | const apiKeyElement = document.getElementById('apiKey'); 192 | 193 | const currentApiUrl = customApiUrlElement?.value || ''; 194 | const currentApiKey = apiKeyElement?.value || ''; 195 | 196 | // 保存当前选中的服务商和模型 197 | const currentProvider = document.getElementById('provider')?.value || 'deepseek'; 198 | const currentModel = document.getElementById('model')?.value || ''; 199 | 200 | // 切换语言 201 | const currentLang = getCurrentLang(); 202 | const newLang = currentLang === 'zh' ? 'en' : 'zh'; 203 | setCurrentLang(newLang); 204 | 205 | // 更新UI文本 206 | if (popupManager && popupManager.i18nManager) { 207 | popupManager.i18nManager.updateLabels(); 208 | } 209 | 210 | // 恢复输入值 211 | if (customApiUrlElement) { 212 | customApiUrlElement.value = currentApiUrl; 213 | } 214 | if (apiKeyElement) { 215 | apiKeyElement.value = currentApiKey; 216 | } 217 | 218 | // 重新创建自定义服务商下拉菜单 219 | if (popupManager && popupManager.providerUIManager) { 220 | popupManager.providerUIManager.createCustomProviderDropdown(currentProvider); 221 | } 222 | 223 | // 重新创建模型下拉菜单 224 | if (popupManager && popupManager.modelManager) { 225 | popupManager.modelManager.updateModelOptions(currentProvider).then(() => { 226 | // 恢复之前选中的模型 227 | const modelSelect = document.getElementById('model'); 228 | if (modelSelect && currentModel) { 229 | modelSelect.value = currentModel; 230 | } 231 | 232 | // 确保模态窗口中的文本也被更新 233 | if (popupManager && popupManager.i18nManager) { 234 | popupManager.i18nManager.updateLabels(); 235 | } 236 | }); 237 | } 238 | }); 239 | 240 | // 滚动提示逻辑 241 | function initScrollIndicator() { 242 | const scrollIndicator = document.getElementById('scrollIndicator'); 243 | if (!scrollIndicator) return; 244 | 245 | // 检查是否需要显示滚动提示 246 | function checkScrollIndicator() { 247 | const scrollHeight = document.documentElement.scrollHeight; 248 | const clientHeight = window.innerHeight; 249 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 250 | 251 | const hasScroll = scrollHeight > clientHeight; 252 | 253 | // 只有在需要滚动且在顶部附近时才显示 254 | if (hasScroll && scrollTop < 20) { 255 | scrollIndicator.classList.remove('hidden'); 256 | } else { 257 | scrollIndicator.classList.add('hidden'); 258 | } 259 | } 260 | 261 | // 监听滚动事件 262 | function handleScroll() { 263 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 264 | const scrollHeight = document.documentElement.scrollHeight; 265 | const clientHeight = window.innerHeight; 266 | 267 | // 如果已经滚动到底部(允许10px误差),隐藏提示 268 | if (scrollTop + clientHeight >= scrollHeight - 10) { 269 | scrollIndicator.classList.add('hidden'); 270 | } else { 271 | // 只有在确实需要滚动时才显示 272 | const hasScroll = scrollHeight > clientHeight; 273 | if (hasScroll && scrollTop < 20) { // 只在顶部附近显示 274 | scrollIndicator.classList.remove('hidden'); 275 | } else { 276 | scrollIndicator.classList.add('hidden'); 277 | } 278 | } 279 | } 280 | 281 | // 初始化检查 282 | checkScrollIndicator(); 283 | 284 | // 监听窗口大小变化 285 | window.addEventListener('resize', checkScrollIndicator); 286 | 287 | // 监听滚动事件 288 | window.addEventListener('scroll', handleScroll); 289 | 290 | // 监听内容变化(因为表单内容可能会动态改变) 291 | const observer = new MutationObserver(() => { 292 | setTimeout(checkScrollIndicator, 100); 293 | }); 294 | 295 | observer.observe(document.body, { 296 | childList: true, 297 | subtree: true, 298 | attributes: true, 299 | attributeFilter: ['style', 'class'] 300 | }); 301 | } 302 | 303 | // 初始化滚动提示 304 | if (document.readyState === 'loading') { 305 | document.addEventListener('DOMContentLoaded', initScrollIndicator); 306 | } else { 307 | initScrollIndicator(); 308 | } 309 | -------------------------------------------------------------------------------- /src/popup/EventManager.js: -------------------------------------------------------------------------------- 1 | export class EventManager { 2 | constructor(managers) { 3 | this.managers = managers; 4 | } 5 | 6 | // 初始化所有事件监听 7 | initializeEventListeners() { 8 | // API Key visibility toggle 9 | this.managers.uiManager.elements.toggleButton.addEventListener( 10 | "click", 11 | () => this.managers.uiManager.toggleApiKeyVisibility() 12 | ); 13 | 14 | // API Key focus event - 确保在输入时可见 15 | this.managers.uiManager.elements.apiKeyInput.addEventListener( 16 | "focus", 17 | () => { 18 | // 确保当获得焦点时内容可见 19 | this.managers.uiManager.elements.apiKeyInput.type = "text"; 20 | this.managers.uiManager.elements.iconSwitch.src = "../icons/hiddle.svg"; 21 | } 22 | ); 23 | 24 | // API Key validation and hiding on blur 25 | this.managers.uiManager.elements.apiKeyInput.addEventListener( 26 | "blur", 27 | () => { 28 | // 当有内容时,失去焦点时隐藏内容 29 | if (this.managers.uiManager.getApiKeyValue()) { 30 | this.managers.uiManager.elements.apiKeyInput.type = "password"; 31 | this.managers.uiManager.elements.iconSwitch.src = "../icons/show.svg"; 32 | } 33 | // 执行API验证 34 | this.managers.apiKeyManager.handleApiKeyValidation(); 35 | } 36 | ); 37 | 38 | // API Key input event - 确保在输入时可见 39 | this.managers.uiManager.elements.apiKeyInput.addEventListener( 40 | "input", 41 | () => { 42 | // 确保在输入时内容可见 43 | this.managers.uiManager.elements.apiKeyInput.type = "text"; 44 | this.managers.uiManager.elements.iconSwitch.src = "../icons/hiddle.svg"; 45 | } 46 | ); 47 | 48 | // 服务商切换事件 49 | this.managers.uiManager.elements.providerSelect.addEventListener( 50 | "change", 51 | async (e) => { 52 | const provider = e.target.value; 53 | 54 | try { 55 | // 保存服务商选择 56 | await this.managers.storageManager.saveProvider(provider); 57 | 58 | // 更新UI(包括API密钥和自定义API URL) 59 | await this.managers.providerUIManager.updateProviderUI(provider); 60 | 61 | // 更新模型选项 62 | const models = await this.managers.modelManager.updateModelOptions(provider); 63 | 64 | // 先检查是否已设置API Key 65 | const apiKey = await this.managers.providerManager.getApiKey(provider); 66 | 67 | if (!apiKey) { 68 | // 引导先填写API Key,再添加模型 69 | this.managers.uiManager.showMessage( 70 | this.managers.i18nManager.getTranslation('noApiKey'), 71 | false 72 | ); 73 | // 直接弹出“添加模型”弹窗(ModelManager 会根据是否已有 Key 决定是否显示 Key 输入) 74 | try { document.body.dataset.requireModelKeyProvider = provider; } catch (e) {} 75 | if (this.managers.modelManager?.showAddModelDialog) { 76 | this.managers.modelManager.showAddModelDialog(); 77 | } else { 78 | this.managers.uiManager.showAddModelModal?.(); 79 | } 80 | // 优先聚焦弹窗中的 Key 输入框(若显示) 81 | this.managers.uiManager.elements.modelApiKey?.focus?.(); 82 | return; 83 | } 84 | 85 | // 非 deepseek:若无模型,强制弹出添加模型(使用带有Key自动隐藏逻辑的接口) 86 | if (provider !== 'deepseek' && (!models || models.length === 0)) { 87 | if (this.managers.modelManager?.showAddModelDialog) { 88 | this.managers.modelManager.showAddModelDialog(); 89 | } else { 90 | this.managers.uiManager.showAddModelModal?.(); 91 | } 92 | } 93 | } catch (error) { 94 | console.error(`服务商切换错误:`, error); 95 | } 96 | } 97 | ); 98 | 99 | // 自定义API URL保存 100 | this.managers.uiManager.elements.customApiUrlInput.addEventListener( 101 | "blur", 102 | () => this.managers.providerUIManager.handleCustomApiUrlSave() 103 | ); 104 | 105 | // 关闭模型弹窗按钮 106 | this.managers.uiManager.elements.closeModelModal?.addEventListener( 107 | "click", 108 | () => this.handleCloseModelModal() 109 | ); 110 | 111 | // 取消添加模型按钮 112 | this.managers.uiManager.elements.cancelModelButton?.addEventListener( 113 | "click", 114 | () => this.handleCancelModelModal() 115 | ); 116 | 117 | // 保存模型按钮 118 | this.managers.uiManager.elements.saveModelButton?.addEventListener( 119 | "click", 120 | () => this.managers.modelManager.handleSaveModel() 121 | ); 122 | 123 | // 自定义服务商API Key focus事件 124 | this.managers.uiManager.elements.customProviderApiKey?.addEventListener( 125 | "focus", 126 | () => { 127 | // 确保当获得焦点时内容可见 128 | this.managers.uiManager.elements.customProviderApiKey.type = "text"; 129 | } 130 | ); 131 | 132 | // 自定义服务商API Key input事件 133 | this.managers.uiManager.elements.customProviderApiKey?.addEventListener( 134 | "input", 135 | () => { 136 | // 确保在输入时内容可见 137 | this.managers.uiManager.elements.customProviderApiKey.type = "text"; 138 | } 139 | ); 140 | 141 | // 自定义服务商API Key blur事件 142 | this.managers.uiManager.elements.customProviderApiKey?.addEventListener( 143 | "blur", 144 | () => { 145 | // 当有内容时,失去焦点时隐藏内容 146 | if (this.managers.uiManager.getCustomProviderApiKey()) { 147 | this.managers.uiManager.elements.customProviderApiKey.type = "password"; 148 | } 149 | } 150 | ); 151 | 152 | // 关闭自定义服务商弹窗按钮 153 | this.managers.uiManager.elements.closeCustomProviderModal?.addEventListener( 154 | "click", 155 | () => this.handleCloseProviderModal() 156 | ); 157 | 158 | // 取消自定义服务商按钮 159 | this.managers.uiManager.elements.cancelCustomProviderButton?.addEventListener( 160 | "click", 161 | () => this.handleCancelProviderModal() 162 | ); 163 | 164 | // 保存自定义服务商按钮 165 | this.managers.uiManager.elements.saveCustomProviderButton?.addEventListener( 166 | "click", 167 | () => this.managers.providerUIManager.handleSaveCustomProvider() 168 | ); 169 | 170 | // 点击弹窗外部关闭弹窗 171 | this.managers.uiManager.elements.addModelModal?.addEventListener( 172 | "click", 173 | (e) => { 174 | if (e.target === this.managers.uiManager.elements.addModelModal) { 175 | this.handleCloseModelModal(); 176 | } 177 | } 178 | ); 179 | 180 | this.managers.uiManager.elements.customProviderModal?.addEventListener( 181 | "click", 182 | (e) => { 183 | if (e.target === this.managers.uiManager.elements.customProviderModal) { 184 | this.handleCloseProviderModal(); 185 | } 186 | } 187 | ); 188 | 189 | // 删除服务商对话框相关事件监听 190 | this.managers.uiManager.elements.closeDeleteProviderModal?.addEventListener( 191 | "click", 192 | () => this.managers.uiManager.hideDeleteProviderModal() 193 | ); 194 | 195 | this.managers.uiManager.elements.cancelDeleteProviderButton?.addEventListener( 196 | "click", 197 | () => this.managers.uiManager.hideDeleteProviderModal() 198 | ); 199 | 200 | this.managers.uiManager.elements.confirmDeleteProviderButton?.addEventListener( 201 | "click", 202 | () => this.managers.providerUIManager.handleDeleteProvider() 203 | ); 204 | 205 | this.managers.uiManager.elements.deleteProviderModal?.addEventListener( 206 | "click", 207 | (e) => { 208 | if (e.target === this.managers.uiManager.elements.deleteProviderModal) { 209 | this.managers.uiManager.hideDeleteProviderModal(); 210 | } 211 | } 212 | ); 213 | 214 | // Language selection 215 | this.managers.uiManager.elements.languageSelect.addEventListener( 216 | "change", 217 | (e) => { 218 | this.managers.storageManager.saveLanguage(e.target.value); 219 | this.managers.i18nManager.updateLabels(); 220 | } 221 | ); 222 | 223 | // Model selection 224 | this.managers.uiManager.elements.modelSelect.addEventListener( 225 | "change", 226 | (e) => { 227 | const model = e.target.value; 228 | // 保存模型选择 229 | this.managers.storageManager.saveModel(model).then(() => { 230 | console.log(`✅ 模型已切换为: ${model}`); 231 | }); 232 | } 233 | ); 234 | 235 | // Selection enabled toggle 236 | this.managers.uiManager.elements.selectionEnabled.addEventListener( 237 | "change", 238 | (e) => this.managers.storageManager.saveSelectionEnabled(e.target.checked) 239 | ); 240 | 241 | // Remember window size toggle 242 | this.managers.uiManager.elements.rememberWindowSize.addEventListener( 243 | "change", 244 | (e) => this.managers.storageManager.saveRememberWindowSize(e.target.checked) 245 | ); 246 | 247 | // Shortcut settings 248 | document.getElementById('shortcutSettings').addEventListener( 249 | 'click', 250 | (e) => this.handleShortcutSettings(e) 251 | ); 252 | 253 | // Instructions link 254 | document.getElementById('instructionsLink').addEventListener( 255 | 'click', 256 | (e) => this.handleInstructionsLink(e) 257 | ); 258 | } 259 | 260 | // 处理快捷键设置 261 | handleShortcutSettings(e) { 262 | e.preventDefault(); 263 | chrome.tabs.create({ 264 | url: "chrome://extensions/shortcuts" 265 | }); 266 | } 267 | 268 | // 处理说明链接 269 | async handleInstructionsLink(e) { 270 | e.preventDefault(); 271 | const instructionsUrl = chrome.runtime.getURL('Instructions/Instructions.html'); 272 | chrome.tabs.create({ 273 | url: instructionsUrl 274 | }); 275 | } 276 | 277 | // 处理模型对话框关闭(不清除临时状态,允许恢复) 278 | handleCloseModelModal() { 279 | // 使用 ModelManager 的隐藏方法 280 | if (this.managers.modelManager?.hideAddModelDialog) { 281 | this.managers.modelManager.hideAddModelDialog(); 282 | } else { 283 | // 兜底使用 UIManager 284 | this.managers.uiManager.hideAddModelModal(); 285 | } 286 | } 287 | 288 | // 处理服务商对话框关闭(不清除临时状态,允许恢复) 289 | handleCloseProviderModal() { 290 | // 使用 ProviderUIManager 的隐藏方法 291 | if (this.managers.providerUIManager?.hideCustomProviderDialog) { 292 | this.managers.providerUIManager.hideCustomProviderDialog(); 293 | } else { 294 | // 兜底使用 UIManager 295 | this.managers.uiManager.hideCustomProviderModal(); 296 | } 297 | } 298 | 299 | // 处理用户明确取消操作(清除临时状态) 300 | handleUserCancel(dialogType) { 301 | if (this.managers.tempStateManager) { 302 | this.managers.tempStateManager.clearTempState(dialogType); 303 | console.log(`🗑️ 用户取消操作,清除临时状态: ${dialogType}`); 304 | } 305 | } 306 | 307 | // 处理模型对话框取消(清除临时状态) 308 | handleCancelModelModal() { 309 | // 清除临时状态 310 | this.handleUserCancel(this.managers.tempStateManager?.constructor?.TYPES?.ADD_MODEL); 311 | // 关闭对话框 312 | this.handleCloseModelModal(); 313 | } 314 | 315 | // 处理服务商对话框取消(清除临时状态) 316 | handleCancelProviderModal() { 317 | // 清除临时状态 318 | this.handleUserCancel(this.managers.tempStateManager?.constructor?.TYPES?.ADD_PROVIDER); 319 | // 关闭对话框 320 | this.handleCloseProviderModal(); 321 | } 322 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 DeepSeekAI - Smart Web Assistant 2 | 3 |
4 | 5 | DeepSeekAI Logo 6 | 7 | [![Chrome Web Store](https://img.shields.io/chrome-web-store/v/bjjobdlpgglckcmhgmmecijpfobmcpap)](https://chromewebstore.google.com/detail/bjjobdlpgglckcmhgmmecijpfobmcpap) 8 | [![License](https://img.shields.io/github/license/DeepLifeStudio/DeepSeekAI)](LICENSE) 9 | [![GitHub stars](https://img.shields.io/github/stars/DeepLifeStudio/DeepSeekAI)](https://github.com/DeepLifeStudio/DeepSeekAI/stargazers) 10 | 11 | [English](README.md) | [简体中文](README.zh-CN.md) 12 | 13 |
14 | 15 | ## 📖 Introduction 16 | 17 | DeepSeekAI is an unofficial, open-source browser extension that lets you summon a private DeepSeek-powered co-pilot anywhere on the web. Highlight text, tap a quick action, or press a shortcut to open a floating chat workspace that streams answers, shows reasoning traces, and remembers your preferred layout. The project is independent from DeepSeek, and you must provide your own API key (DeepSeek or any OpenAI-compatible endpoint). 18 | 19 | > **Note**: This extension is a community project and is not affiliated with DeepSeek. Keys, custom endpoints, and preferences are stored only in `chrome.storage.sync` on your device. 20 | 21 | ### 🔌 Supported API Providers 22 | - [DeepSeek](https://deepseek.com) (official endpoint) 23 | - [ByteDance Volcengine](https://www.volcengine.com/experience/ark?utm_term=202502dsinvite&ac=DSASUQY5&rc=OXTHJAF8) 24 | - [SiliconFlow](https://cloud.siliconflow.cn/i/lStn36vH) 25 | - [OpenRouter](https://openrouter.ai/models) 26 | - [AiHubMix](https://aihubmix.com?aff=SmJB) 27 | - [Tencent Cloud](https://cloud.tencent.com/document/product/1772/115969) 28 | - [IFlytek Star](https://training.xfyun.cn/modelService) 29 | - [Baidu Cloud](https://console.bce.baidu.com/qianfan/modelcenter/model/buildIn/list) 30 | - [Aliyun](https://bailian.console.aliyun.com/#/model-market) 31 | - Unlimited self-hosted/custom providers that expose an OpenAI-compatible `/chat/completions` endpoint 32 | 33 | ## ✨ Feature Overview 34 | 35 | ### 🪄 Inline Assistants 36 | - Rich quick-action bubble appears beside any text selection with Chat, Copy, Translate (19 languages), Explain, Summarize, Email, and Analyze templates. 37 | - SelectionPreservationManager keeps the DOM range alive so the bubble never steals your highlight during double/triple clicks or context-menu usage. 38 | - Right-click context menu entry and toolbar popup both reuse the same flow, so highlighted text, manual prompts, and keyboard shortcuts share the session logic. 39 | 40 | ### 🪟 Floating Workspace 41 | - `interactjs` gives the chat window magnetic drag + resize handles, snap animations, and a persistent minimize icon whose position is saved per user. 42 | - Toggle "Remember window size" to keep the workspace dimensions across sites, and "Pin window" to prevent accidental closes when clicking outside. 43 | - Minimized state, popup visibility, and icon location are tracked by `popupStateManager`, ensuring state survives selection changes. 44 | - Built-in input container includes auto-expanding textarea, send icon, abort/stop square, and smart focus rules so existing form inputs retain priority. 45 | - Each answer provides inline copy + regenerate controls; DeepSeek-R1/openrouter reasoning is rendered above the final response with a collapsible panel. 46 | - Auto-scroll follows the stream until you scroll manually. Scroll momentum + cooldown logic prevent janky jumps. 47 | 48 | ### 🧠 Provider & Model Controls 49 | - Popup UI (English/Chinese) manages API keys per provider, preferred language (auto-detect or force output), and whether the selection bubble is enabled. 50 | - Add, rename, hide, or delete custom providers with their own base URL, display name, default model, and placeholder API-key links. 51 | - ModelManager stores multiple custom models per provider. Dropdowns support inline delete buttons, and forms auto-save via TempStateManager so unfinished entries survive popup reloads. 52 | - Configure a global custom system prompt used for every conversation, or override per quick action via templated prompts. 53 | 54 | ### 📝 Rendering, Safety & UX Polishing 55 | - Markdown-It + highlight.js + KaTeX + DOMPurify ensure rich formatting, syntax highlighting, math rendering, and sanitized HTML. 56 | - Code blocks gain reusable “Copy” controls, while each AI block also exposes regenerate + share-ready text copy actions. 57 | - Streaming is proxied through the background service worker using modern `fetch` + `AbortController`, so stop/regenerate/shortcut commands instantly cut network traffic. 58 | - ThemeManager listens to `prefers-color-scheme` and toggles CSS variables to keep the popover and quick buttons readable in both modes. 59 | 60 | ### ⌨️ Shortcuts & Invocation Options 61 | - Two Chrome commands ship by default: 62 | - `Ctrl/Cmd + Shift + Y` → toggle chat (new session) 63 | - `Ctrl/Cmd + Shift + U` → show/hide chat (preserve session) 64 | - Use `chrome://extensions/shortcuts` (or the “Shortcut Settings” link inside the popup) to rebind commands. 65 | - Context menu entry (“DeepSeek AI”) sends the selected text directly, and the icon in the toolbar opens the configuration popup. 66 | 67 | ### 🔐 Privacy & Onboarding 68 | - On first install we open [`src/Instructions/Instructions.html`](src/Instructions/instructions.html), an offline-friendly Apple-style guide covering every screen. 69 | - `PRIVACY.html` documents exactly what is stored (API keys + user preferences in local browser storage) and reminds you that no remote server is involved. 70 | - DOMPurify sanitizes all rendered HTML, and no telemetry or analytics is collected. 71 | 72 | ## 🔄 How It Works 73 | 74 | ```mermaid 75 | sequenceDiagram 76 | participant User 77 | participant Content as Content Script 78 | participant Background 79 | participant Provider 80 | User->>Content: Select text / press shortcut 81 | Content->>Background: chrome.runtime.sendMessage({ action: "proxyRequest" | "getSettings" }) 82 | Background->>Provider: fetch(OpenAI-compatible endpoint) 83 | Provider-->>Background: SSE / JSON chunks 84 | Background-->>Content: streamResponse events (AbortController aware) 85 | Content-->>User: Renders markdown, reasoning, quick actions 86 | ``` 87 | 88 | - `content/content.js` glues together selection tracking, quick actions, the popup workspace, markdown renderer, theme watcher, scroll manager, and focus manager. 89 | - `background.js` is the single network surface: it loads provider settings, streams responses, parses errors, handles aborts, manages commands/context menus, and opens onboarding tabs. 90 | - `popup/` houses the modular settings UI (ApiKeyManager, ProviderManager, ModelManager, SystemPromptManager, etc.) with i18n + autosave. 91 | - `Instructions/` exposes the offline guide viewed after installation. 92 | 93 | ## 🚀 Installation & Build 94 | 95 | ### 1. Install from the store (recommended) 96 | - **Chrome**: [Chrome Web Store](https://chromewebstore.google.com/detail/bjjobdlpgglckcmhgmmecijpfobmcpap) 97 | - **Microsoft Edge**: enable “Allow extensions from other stores,” then install via the same Chrome Web Store listing above. 98 | 99 | ### 2. Manual installation / development flow 100 | ```bash 101 | # Requirements: Node.js 18+, pnpm (or npm), and a Chromium-based browser 102 | pnpm install 103 | pnpm run build # outputs the production bundle into dist/ 104 | ``` 105 | 106 | 1. Open `chrome://extensions` → enable **Developer mode** → **Load unpacked** → pick the `dist` folder. 107 | 2. To ship a store package, run one of: 108 | - `pnpm run build:zip` → `extension.zip` 109 | - `pnpm run build:chrome` → `chrome-submission.zip` 110 | - `pnpm run build:edge` → `edge-submission.zip` 111 | 3. Upload the generated ZIP to the respective store dashboards. 112 | 113 | ## 🧩 Setup & Daily Use 114 | 1. Click the extension icon to open the popup. 115 | 2. Choose a provider (or create a custom one with a name + base URL + default model) and paste its API key. Each provider keeps its own key and optional custom API URL. 116 | 3. Pick or add a model. Non-DeepSeek providers require an explicit model ID; the UI will auto-prompt you to add one if missing. 117 | 4. Configure behavior: 118 | - Enable/disable the selection quick-action bubble. 119 | - Choose automatic language detection or force a language from the dropdown (20+ locales). 120 | - Toggle **Save Window Size**, **Pin Window**, and **Custom System Prompt**. 121 | - Use the **Shortcut Settings** link to jump to Chrome’s command editor. 122 | 5. Highlight text (or open the chat via shortcut) → the quick-action bubble appears → select Chat or a template. You can also open the floating window first and paste custom prompts. 123 | 6. While streaming, use the stop square icon to abort. Each answer ends with copy + regenerate icons; reasoning blocks collapse/expand with one click. 124 | 7. Need a refresher? Open the in-extension [usage guide](src/Instructions/instructions.html) or switch to the Simplified Chinese README linked at the top. 125 | 126 | ## ⌨️ Shortcuts & Quick Actions 127 | - **Quick actions:** 128 | - `Chat` → sends selection verbatim. 129 | - `Copy` → copies selection without opening chat. 130 | - `Translate` → language picker drives a prompt that asks DeepSeek to translate into your chosen target language. 131 | - `Explain`, `Summarize`, `Email`, `Analyze` → curated prompts (with MBTI-flavored tone) for instant structured answers. 132 | - **Window commands:** `toggle-chat` destroys and recreates the session; `show-hide-chat` keeps the current context alive between invocations; `close-chat` is exposed internally for context menu cleanup. 133 | - **Context menu:** right-click → “DeepSeek AI” to push highlighted text directly into a new chat with a contextual greeting (morning/afternoon/evening). 134 | 135 | ## 🏗️ Project Layout & Stack 136 | ``` 137 | . 138 | ├── src/ 139 | │ ├── manifest.json # MV3 metadata & permissions 140 | │ ├── background.js # service worker + proxy + commands 141 | │ ├── content/ # selection bubble, popup workspace, services, utils, styles 142 | │ ├── popup/ # settings UI (managers, i18n, HTML) 143 | │ └── Instructions/ # onboarding guide (HTML + JS) 144 | ├── dist/ # build output (loaded during development/packaging) 145 | ├── extension.zip # generated via build:zip / build:chrome / build:edge 146 | ├── webpack.config.js # bundler config (Babel, CSS loader, copy plugin, terser) 147 | ├── PRIVACY.html # privacy policy 148 | └── README*.md # documentation (English + 简体中文) 149 | ``` 150 | 151 | **Key dependencies:** `interactjs`, `markdown-it`, `highlight.js`, `DOMPurify`, `katex`, `clipboard`, `perfect-scrollbar`, and `openai` (for payload typing) plus the MV3 APIs exposed by Chrome/Edge. 152 | 153 | ## 🔒 Privacy & Security 154 | - API keys, preferences, quick-action states, and minimized icon positions live only inside `chrome.storage.sync`. 155 | - Text is sent solely to the provider endpoint you configure. There are no intermediary servers, analytics calls, or remote logs. 156 | - The offline [privacy policy](PRIVACY.html) inside the repo details data handling, and DOMPurify removes any potentially unsafe markup before rendering. 157 | 158 | ## 🤝 Contributing 159 | Contributions are welcome—bug reports, documentation fixes, and feature proposals all help the community. 160 | 161 | 1. Fork the repo and create a branch (`git checkout -b feature/my-update`). 162 | 2. Install deps + build once (`pnpm install && pnpm run build`). 163 | 3. Make your changes, keep them focused, and run `pnpm run build` again to ensure `dist/` refreshes. 164 | 4. Submit a Pull Request describing the change, affected files, and any verification notes. 165 | 166 | ## 📄 License 167 | 168 | This project is licensed under the MIT License - see [LICENSE](LICENSE) for details. 169 | 170 | ## 📮 Contact 171 | 172 | - Issues: [GitHub Issues](https://github.com/DeepLifeStudio/DeepSeekAI/issues) 173 | - Email: [1024jianghu@gmail.com](mailto:1024jianghu@gmail.com) 174 | - Twitter/X: [@DeepLifeStudio](https://x.com/DeepLifeStudio) 175 | 176 |
177 |

If this project helps you, please consider giving it a ⭐️

178 |
-------------------------------------------------------------------------------- /src/content/utils/scrollManager.js: -------------------------------------------------------------------------------- 1 | import { SCROLL_CONSTANTS } from './constants'; 2 | 3 | 4 | class ScrollStateManager { 5 | constructor() { 6 | this.isManualScrolling = false; 7 | this.lastScrollTime = 0; 8 | this.scrollTimeout = null; 9 | this.scrollRAF = null; 10 | this.scrollAnimation = { 11 | isAnimating: false, 12 | startTime: 0, 13 | startPosition: 0, 14 | targetPosition: 0, 15 | duration: SCROLL_CONSTANTS.ANIMATION_DURATION, 16 | }; 17 | this.scrollMomentum = { 18 | velocity: 0, 19 | timestamp: 0, 20 | positions: [], 21 | maxSamples: SCROLL_CONSTANTS.MAX_MOMENTUM_SAMPLES, 22 | }; 23 | this.isInteracting = false; // 新增:跟踪用户是否正在进行物理交互(按住鼠标/触摸) 24 | } 25 | 26 | setInteracting(value) { 27 | this.isInteracting = value; 28 | } 29 | 30 | setManualScrolling(value) { 31 | this.isManualScrolling = value; 32 | if (value) { 33 | this.lastScrollTime = Date.now(); 34 | this.scrollAnimation.isAnimating = false; 35 | } 36 | } 37 | 38 | updateScrollVelocity(currentPosition) { 39 | const now = Date.now(); 40 | const positions = this.scrollMomentum.positions; 41 | 42 | positions.push({ 43 | position: currentPosition, 44 | timestamp: now 45 | }); 46 | 47 | if (positions.length > this.scrollMomentum.maxSamples) { 48 | positions.shift(); 49 | } 50 | 51 | if (positions.length >= 2) { 52 | const newest = positions[positions.length - 1]; 53 | const oldest = positions[0]; 54 | const timeDiff = newest.timestamp - oldest.timestamp; 55 | 56 | if (timeDiff > 0) { 57 | this.scrollMomentum.velocity = (newest.position - oldest.position) / timeDiff; 58 | } 59 | } 60 | } 61 | 62 | isRapidScrolling() { 63 | return Math.abs(this.scrollMomentum.velocity) > 0.5; 64 | } 65 | 66 | isInCooldown() { 67 | return Date.now() - this.lastScrollTime < 150 || this.isRapidScrolling(); 68 | } 69 | 70 | saveScrollPosition(container) { 71 | const { scrollTop, scrollHeight, clientHeight } = container; 72 | const distanceFromBottom = scrollHeight - (scrollTop + clientHeight); 73 | this.isAtBottom = distanceFromBottom <= SCROLL_CONSTANTS.SCROLL_THRESHOLD; 74 | this.scrollPosition = scrollTop; 75 | this.updateScrollVelocity(scrollTop); 76 | } 77 | 78 | restoreScrollPosition(container) { 79 | if (this.isAtBottom) { 80 | container.scrollTop = container.scrollHeight; 81 | } else { 82 | const scrollRatio = this.scrollPosition / container.scrollHeight; 83 | container.scrollTop = scrollRatio * container.scrollHeight; 84 | } 85 | } 86 | 87 | isNearBottom(container) { 88 | const { scrollTop, scrollHeight, clientHeight } = container; 89 | return scrollHeight - (scrollTop + clientHeight) <= SCROLL_CONSTANTS.SCROLL_THRESHOLD; 90 | } 91 | 92 | cleanup() { 93 | if (this.scrollTimeout) { 94 | clearTimeout(this.scrollTimeout); 95 | } 96 | if (this.scrollRAF) { 97 | cancelAnimationFrame(this.scrollRAF); 98 | } 99 | this.scrollAnimation.isAnimating = false; 100 | this.scrollMomentum.positions = []; 101 | this.scrollMomentum.velocity = 0; 102 | } 103 | } 104 | 105 | let allowAutoScroll = false; 106 | 107 | const isAtBottom = (container) => { 108 | if (!container) return false; 109 | const { scrollTop, scrollHeight, clientHeight } = container; 110 | // 安全检查:如果在顶部(scrollTop < 1)且内容高度超过可视高度,则肯定不在底部 111 | if (scrollTop < 1 && scrollHeight > clientHeight + SCROLL_CONSTANTS.SCROLL_THRESHOLD) return false; 112 | return scrollHeight - (scrollTop + clientHeight) <= SCROLL_CONSTANTS.SCROLL_THRESHOLD; 113 | }; 114 | 115 | export function setAllowAutoScroll(value) { 116 | allowAutoScroll = value; 117 | } 118 | 119 | export function getAllowAutoScroll() { 120 | return allowAutoScroll; 121 | } 122 | 123 | export function updateAllowAutoScroll(container, event) { 124 | if (!container) return; 125 | 126 | const currentIsAtBottom = isAtBottom(container); 127 | 128 | if (currentIsAtBottom) { 129 | // 如果已经在底部,强制开启自动滚动 130 | setAllowAutoScroll(true); 131 | } else { 132 | // 如果不在底部,只有当是用户主动操作(event.isTrusted)时,才关闭自动滚动 133 | // 防止系统自动扩展内容导致的高度变化(非用户操作)错误地关闭自动滚动 134 | if (event && event.isTrusted) { 135 | setAllowAutoScroll(false); 136 | } 137 | // 如果是系统事件(event.isTrusted 为 undefined 或 false),保持原状态不变 138 | // 这样当内容生成导致暂时不在底部时,不会错误地取消自动滚动 139 | } 140 | } 141 | 142 | // 优化后的用户滚动处理函数 143 | export function handleUserScroll(event) { 144 | const container = event?.target || event; 145 | if (!container) return; 146 | 147 | updateAllowAutoScroll(container, event); 148 | } 149 | 150 | // 在新内容添加后更新滚动状态 151 | export function updateScrollStateAfterContentAdd(container) { 152 | if (!container) return; 153 | 154 | // 只有当已开启自动滚动(用户滚动到底部)时才自动滚动 155 | if (!getAllowAutoScroll()) return; 156 | 157 | scrollToBottom(container); 158 | } 159 | 160 | // 重置所有滚动状态 161 | export function resetScrollState() { 162 | allowAutoScroll = false; 163 | } 164 | 165 | // 检查是否可以自动滚动 166 | export function canAutoScroll() { 167 | return getAllowAutoScroll(); 168 | } 169 | 170 | export function scrollToBottom(container, shouldCheckState = false) { 171 | if (!container) return; 172 | 173 | requestAnimationFrame(() => { 174 | // 强力护盾:如果用户正在交互(按住滚动条/触摸屏幕),绝对禁止自动滚动 175 | if (container.scrollStateManager && container.scrollStateManager.isInteracting) return; 176 | 177 | // JIT检查:如果要求检查状态,且当前不允许自动滚动,则取消本次滚动 178 | if (shouldCheckState && !getAllowAutoScroll()) return; 179 | 180 | container.scrollTop = container.scrollHeight; 181 | 182 | if (container.perfectScrollbar) { 183 | container.perfectScrollbar.update(); 184 | } 185 | }); 186 | } 187 | 188 | export function createScrollManager() { 189 | const scrollState = { 190 | isManualScrolling: false, 191 | lastScrollTime: 0, 192 | scrollTimeout: null, 193 | isAtBottom: true, 194 | scrollPosition: 0, 195 | scrollMomentum: { 196 | positions: [], 197 | maxSamples: 5, 198 | velocity: 0 199 | } 200 | }; 201 | 202 | return { 203 | ...scrollState, 204 | 205 | setManualScrolling(value) { 206 | this.isManualScrolling = value; 207 | if (value) { 208 | this.lastScrollTime = Date.now(); 209 | } 210 | }, 211 | 212 | updateScrollVelocity(currentPosition) { 213 | const now = Date.now(); 214 | const positions = this.scrollMomentum.positions; 215 | 216 | positions.push({ 217 | position: currentPosition, 218 | timestamp: now 219 | }); 220 | 221 | if (positions.length > this.scrollMomentum.maxSamples) { 222 | positions.shift(); 223 | } 224 | 225 | if (positions.length >= 2) { 226 | const newest = positions[positions.length - 1]; 227 | const oldest = positions[0]; 228 | const timeDiff = newest.timestamp - oldest.timestamp; 229 | 230 | if (timeDiff > 0) { 231 | this.scrollMomentum.velocity = (newest.position - oldest.position) / timeDiff; 232 | } 233 | } 234 | }, 235 | 236 | isRapidScrolling() { 237 | return Math.abs(this.scrollMomentum.velocity) > 0.5; 238 | }, 239 | 240 | isInCooldown() { 241 | return Date.now() - this.lastScrollTime < 150 || this.isRapidScrolling(); 242 | }, 243 | 244 | isNearBottom(container) { 245 | if (!container) return false; 246 | const { scrollTop, scrollHeight, clientHeight } = container; 247 | // 安全检查:如果在顶部(scrollTop < 1)且内容高度超过可视高度,则肯定不在底部 248 | if (scrollTop < 1 && scrollHeight > clientHeight + SCROLL_CONSTANTS.SCROLL_THRESHOLD) return false; 249 | return scrollHeight - (scrollTop + clientHeight) <= SCROLL_CONSTANTS.SCROLL_THRESHOLD; 250 | }, 251 | 252 | saveScrollPosition(container) { 253 | if (!container) return; 254 | const { scrollTop, scrollHeight, clientHeight } = container; 255 | // 安全检查:如果在顶部(scrollTop < 1)且内容高度超过可视高度,则肯定不在底部 256 | if (scrollTop < 1 && scrollHeight > clientHeight + SCROLL_CONSTANTS.SCROLL_THRESHOLD) { 257 | this.isAtBottom = false; 258 | } else { 259 | this.isAtBottom = scrollHeight - (scrollTop + clientHeight) <= SCROLL_CONSTANTS.SCROLL_THRESHOLD; 260 | } 261 | this.scrollPosition = scrollTop; 262 | this.updateScrollVelocity(scrollTop); 263 | }, 264 | 265 | restoreScrollPosition(container) { 266 | if (!container) return; 267 | if (this.isAtBottom) { 268 | scrollToBottom(container); 269 | } else { 270 | container.scrollTop = this.scrollPosition; 271 | if (container.perfectScrollbar) { 272 | container.perfectScrollbar.update(); 273 | } 274 | } 275 | }, 276 | 277 | scrollToBottom(container, shouldCheckState = false) { 278 | scrollToBottom(container, shouldCheckState); 279 | }, 280 | 281 | cleanup() { 282 | if (this.scrollTimeout) { 283 | clearTimeout(this.scrollTimeout); 284 | } 285 | this.isManualScrolling = false; 286 | this.lastScrollTime = 0; 287 | this.scrollTimeout = null; 288 | this.scrollMomentum.positions = []; 289 | this.scrollMomentum.velocity = 0; 290 | this.isInteracting = false; 291 | } 292 | }; 293 | } 294 | 295 | // 优化的滚动处理函数 296 | export function setupScrollHandlers(container, perfectScrollbar) { 297 | if (!container) return; 298 | 299 | const scrollManager = container.scrollStateManager; 300 | 301 | const handleScroll = (event) => { 302 | if (!scrollManager) return; 303 | 304 | // 只有用户触发的滚动才算手动滚动 305 | if (event.isTrusted) { 306 | scrollManager.setManualScrolling(true); 307 | } 308 | scrollManager.saveScrollPosition(container); 309 | 310 | // 无论是否冷却,状态必须实时、同步更新 (零延迟) 311 | updateAllowAutoScroll(container, event); 312 | 313 | handleUserScroll(event); 314 | 315 | requestAnimationFrame(() => { 316 | if (perfectScrollbar) { 317 | perfectScrollbar.update(); 318 | } 319 | 320 | if (!scrollManager.isInCooldown()) { 321 | const isAtBottom = scrollManager.isNearBottom(container); 322 | 323 | if (isAtBottom && getAllowAutoScroll()) { 324 | scrollManager.scrollToBottom(container, true); 325 | } 326 | } 327 | }); 328 | 329 | // 重置手动滚动状态 330 | if (scrollManager.scrollTimeout) { 331 | clearTimeout(scrollManager.scrollTimeout); 332 | } 333 | scrollManager.scrollTimeout = setTimeout(() => { 334 | scrollManager.setManualScrolling(false); 335 | }, 150); 336 | }; 337 | 338 | // 使用 passive 选项优化性能 339 | container.addEventListener('wheel', handleScroll, { passive: true }); 340 | container.addEventListener('touchstart', handleScroll, { passive: true }); 341 | container.addEventListener('touchmove', handleScroll, { passive: true }); 342 | container.addEventListener('scroll', handleScroll, { passive: true }); 343 | 344 | // 交互状态监听(护盾机制) 345 | const handleInteractionStart = () => scrollManager.setInteracting(true); 346 | const handleInteractionEnd = () => scrollManager.setInteracting(false); 347 | 348 | container.addEventListener('mousedown', handleInteractionStart, { passive: true }); 349 | container.addEventListener('mouseup', handleInteractionEnd, { passive: true }); 350 | container.addEventListener('mouseleave', handleInteractionEnd, { passive: true }); // 鼠标移出也视为结束 351 | container.addEventListener('touchstart', handleInteractionStart, { passive: true }); 352 | container.addEventListener('touchend', handleInteractionEnd, { passive: true }); 353 | 354 | // 返回清理函数 355 | return () => { 356 | container.removeEventListener('wheel', handleScroll); 357 | container.removeEventListener('touchstart', handleScroll); 358 | container.removeEventListener('touchmove', handleScroll); 359 | container.removeEventListener('scroll', handleScroll); 360 | 361 | container.removeEventListener('mousedown', handleInteractionStart); 362 | container.removeEventListener('mouseup', handleInteractionEnd); 363 | container.removeEventListener('mouseleave', handleInteractionEnd); 364 | container.removeEventListener('touchstart', handleInteractionStart); 365 | container.removeEventListener('touchend', handleInteractionEnd); 366 | }; 367 | } 368 | -------------------------------------------------------------------------------- /src/popup/ShortcutManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 快捷键管理器 - 处理用户自定义快捷键功能 3 | * 支持自定义显示/隐藏聊天窗口的快捷键 4 | */ 5 | export class ShortcutManager { 6 | constructor(storageManager, uiManager, i18nManager) { 7 | this.storageManager = storageManager; 8 | this.uiManager = uiManager; 9 | this.i18nManager = i18nManager; 10 | 11 | // 默认快捷键配置 12 | this.defaultShortcuts = { 13 | 'toggle-chat': { 14 | default: 'Ctrl+Shift+Y', 15 | description: 'Open or close the chat window (destroys session).', 16 | displayName: 'Open/Close Chat' 17 | }, 18 | 'show-hide-chat': { 19 | default: 'Ctrl+Shift+U', 20 | description: 'Show or hide the chat window (preserves session).', 21 | displayName: 'Show/Hide Chat' 22 | } 23 | }; 24 | } 25 | 26 | /** 27 | * 初始化快捷键管理器 28 | */ 29 | async initialize() { 30 | await this.loadShortcutSettings(); 31 | this.setupEventListeners(); 32 | } 33 | 34 | /** 35 | * 加载快捷键设置 36 | */ 37 | async loadShortcutSettings() { 38 | try { 39 | const settings = await this.storageManager.getSettings(); 40 | this.currentShortcuts = settings.shortcuts || this.defaultShortcuts; 41 | } catch (error) { 42 | console.error('加载快捷键设置失败:', error); 43 | this.currentShortcuts = this.defaultShortcuts; 44 | } 45 | } 46 | 47 | /** 48 | * 保存快捷键设置 49 | */ 50 | async saveShortcutSettings(shortcuts) { 51 | try { 52 | this.currentShortcuts = shortcuts; 53 | await this.storageManager.saveShortcuts(shortcuts); 54 | 55 | // 通知其他组件快捷键已更新 56 | chrome.runtime.sendMessage({ 57 | action: 'shortcutsUpdated', 58 | shortcuts: shortcuts 59 | }); 60 | 61 | return true; 62 | } catch (error) { 63 | console.error('保存快捷键设置失败:', error); 64 | return false; 65 | } 66 | } 67 | 68 | /** 69 | * 解析快捷键组合 70 | */ 71 | parseShortcut(shortcutString) { 72 | if (!shortcutString) return null; 73 | 74 | const parts = shortcutString.split('+').map(part => part.trim().toLowerCase()); 75 | 76 | const result = { 77 | ctrl: false, 78 | shift: false, 79 | alt: false, 80 | meta: false, 81 | key: null 82 | }; 83 | 84 | for (const part of parts) { 85 | switch (part) { 86 | case 'ctrl': 87 | result.ctrl = true; 88 | break; 89 | case 'shift': 90 | result.shift = true; 91 | break; 92 | case 'alt': 93 | result.alt = true; 94 | break; 95 | case 'cmd': 96 | case 'command': 97 | case 'meta': 98 | result.meta = true; 99 | break; 100 | default: 101 | if (part.length === 1 || (part.length > 1 && this.isValidKey(part))) { 102 | result.key = part.toUpperCase(); 103 | } 104 | break; 105 | } 106 | } 107 | 108 | return result.key ? result : null; 109 | } 110 | 111 | /** 112 | * 验证是否是有效按键 113 | */ 114 | isValidKey(key) { 115 | const validKeys = [ 116 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 117 | 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 118 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 119 | 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', 120 | 'enter', 'space', 'tab', 'escape', 'backspace', 'delete', 121 | 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 122 | 'home', 'end', 'pageup', 'pagedown' 123 | ]; 124 | 125 | return validKeys.includes(key.toLowerCase()); 126 | } 127 | 128 | /** 129 | * 格式化快捷键显示 130 | */ 131 | formatShortcutForDisplay(shortcut) { 132 | if (!shortcut) return ''; 133 | 134 | const parts = []; 135 | if (shortcut.meta) parts.push('Cmd'); 136 | if (shortcut.ctrl) parts.push('Ctrl'); 137 | if (shortcut.shift) parts.push('Shift'); 138 | if (shortcut.alt) parts.push('Alt'); 139 | if (shortcut.key) parts.push(this.formatKey(shortcut.key)); 140 | 141 | return parts.join(' + '); 142 | } 143 | 144 | /** 145 | * 格式化按键显示 146 | */ 147 | formatKey(key) { 148 | const keyMap = { 149 | ' ': 'Space', 150 | 'escape': 'Esc', 151 | 'enter': 'Enter', 152 | 'tab': 'Tab', 153 | 'backspace': 'Backspace', 154 | 'delete': 'Del', 155 | 'arrowup': '↑', 156 | 'arrowdown': '↓', 157 | 'arrowleft': '←', 158 | 'arrowright': '→' 159 | }; 160 | 161 | // 处理功能键 162 | if (key.toLowerCase().startsWith('f') && key.length > 1) { 163 | const num = key.slice(1); 164 | if (!isNaN(num)) return `F${num}`; 165 | } 166 | 167 | return keyMap[key.toLowerCase()] || key.toUpperCase(); 168 | } 169 | 170 | /** 171 | * 验证快捷键是否冲突 172 | */ 173 | validateShortcut(shortcutString, command = 'toggle-chat') { 174 | if (!shortcutString.trim()) { 175 | return { valid: false, error: '快捷键不能为空' }; 176 | } 177 | 178 | // 检查快捷键格式 179 | const shortcut = this.parseShortcut(shortcutString); 180 | if (!shortcut) { 181 | return { valid: false, error: '无效的快捷键格式' }; 182 | } 183 | 184 | // 检查是否包含主键 185 | if (!shortcut.key) { 186 | return { valid: false, error: '必须包含一个主键(字母、数字或功能键)' }; 187 | } 188 | 189 | // 检查是否有效键 190 | if (!this.isValidKey(shortcut.key)) { 191 | return { valid: false, error: '无效的按键' }; 192 | } 193 | 194 | // 检查是否只使用了标准修饰键 195 | const modifiers = [shortcut.ctrl, shortcut.shift, shortcut.alt, shortcut.meta]; 196 | if (modifiers.filter(Boolean).length === 0) { 197 | return { valid: false, error: '至少需要一个修饰键(Ctrl、Shift、Alt 或 Cmd)' }; 198 | } 199 | 200 | // 检查操作系统特定的限制 201 | const isMac = navigator.platform.toUpperCase().includes('MAC'); 202 | if (isMac && shortcut.ctrl && !shortcut.meta) { 203 | return { valid: false, error: '在 macOS 上建议使用 Cmd 键而不是 Ctrl 键' }; 204 | } 205 | 206 | return { valid: true, shortcut }; 207 | } 208 | 209 | /** 210 | * 获取当前快捷键显示文本 211 | */ 212 | getCurrentShortcutDisplay() { 213 | const shortcut = this.currentShortcuts['toggle-chat']; 214 | const parsed = this.parseShortcut(shortcut.default); 215 | return this.formatShortcutForDisplay(parsed); 216 | } 217 | 218 | /** 219 | * 设置事件监听器 220 | */ 221 | setupEventListeners() { 222 | // 快捷键设置按钮点击事件 223 | const shortcutSettings = document.getElementById('shortcutSettings'); 224 | if (shortcutSettings) { 225 | shortcutSettings.addEventListener('click', (e) => { 226 | e.preventDefault(); 227 | this.showShortcutDialog(); 228 | }); 229 | } 230 | } 231 | 232 | /** 233 | * 显示快捷键对话框 234 | */ 235 | async showShortcutDialog() { 236 | // 检查是否已存在对话框 237 | if (document.getElementById('shortcutModal')) { 238 | return; 239 | } 240 | 241 | // 创建对话框 242 | const modal = this.createShortcutModal(); 243 | document.body.appendChild(modal); 244 | 245 | // 显示模态窗口 246 | setTimeout(() => modal.classList.add('show'), 10); 247 | } 248 | 249 | /** 250 | * 创建快捷键对话框 251 | */ 252 | createShortcutModal() { 253 | const modal = document.createElement('div'); 254 | modal.id = 'shortcutModal'; 255 | modal.className = 'modal'; 256 | 257 | const currentShortcut = this.getCurrentShortcutDisplay(); 258 | 259 | modal.innerHTML = ` 260 | 295 | `; 296 | 297 | // 添加事件监听器 298 | this.attachShortcutModalEvents(modal); 299 | 300 | return modal; 301 | } 302 | 303 | /** 304 | * 绑定快捷键对话框事件 305 | */ 306 | attachShortcutModalEvents(modal) { 307 | const closeButton = modal.querySelector('#closeShortcutModal'); 308 | const cancelButton = modal.querySelector('#cancelShortcutButton'); 309 | const saveButton = modal.querySelector('#saveShortcutButton'); 310 | const resetButton = modal.querySelector('#resetShortcutButton'); 311 | const shortcutInput = modal.querySelector('#shortcutInput'); 312 | const validationMessage = modal.querySelector('#shortcutValidationMessage'); 313 | 314 | // 关闭按钮点击事件 315 | const closeModal = () => { 316 | modal.classList.remove('show'); 317 | setTimeout(() => modal.remove(), 300); 318 | }; 319 | 320 | closeButton.addEventListener('click', closeModal); 321 | cancelButton.addEventListener('click', closeModal); 322 | 323 | // 点击外部关闭 324 | modal.addEventListener('click', (e) => { 325 | if (e.target === modal) { 326 | closeModal(); 327 | } 328 | }); 329 | 330 | // 快捷键输入框事件 331 | shortcutInput.addEventListener('focus', (e) => { 332 | e.target.value = '请按下组合键...'; 333 | validationMessage.textContent = ''; 334 | validationMessage.className = 'validation-message'; 335 | }); 336 | 337 | shortcutInput.addEventListener('keydown', (e) => { 338 | e.preventDefault(); 339 | 340 | // 构建快捷键字符串 341 | const parts = []; 342 | if (e.ctrlKey) parts.push('Ctrl'); 343 | if (e.shiftKey) parts.push('Shift'); 344 | if (e.altKey) parts.push('Alt'); 345 | if (e.metaKey) parts.push('Cmd'); 346 | 347 | // 获取按键名称(排除修饰键) 348 | let key = e.key.toLowerCase(); 349 | if (key === ' ') key = 'space'; 350 | if (key === 'escape') key = 'escape'; 351 | 352 | // 只添加非修饰键 353 | if (key.length === 1 || key.startsWith('f') || [ 354 | 'space', 'escape', 'enter', 'tab', 'backspace', 'delete', 355 | 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 356 | 'home', 'end', 'pageup', 'pagedown' 357 | ].includes(key)) { 358 | parts.push(this.formatKey(key)); 359 | 360 | const shortcutString = parts.join('+'); 361 | const validation = this.validateShortcut(shortcutString); 362 | 363 | if (validation.valid) { 364 | shortcutInput.value = shortcutString; 365 | validationMessage.textContent = '快捷键格式正确'; 366 | validationMessage.className = 'validation-message success'; 367 | } else { 368 | validationMessage.textContent = validation.error; 369 | validationMessage.className = 'validation-message error'; 370 | } 371 | } 372 | }); 373 | 374 | // 重置按钮事件 375 | resetButton.addEventListener('click', () => { 376 | shortcutInput.value = this.formatShortcutForDisplay(this.parseShortcut(this.defaultShortcuts['toggle-chat'].default)); 377 | validationMessage.textContent = '已重置为默认快捷键'; 378 | validationMessage.className = 'validation-message success'; 379 | }); 380 | 381 | // 保存按钮事件 382 | saveButton.addEventListener('click', async () => { 383 | const shortcutString = shortcutInput.value.trim(); 384 | const validation = this.validateShortcut(shortcutString); 385 | 386 | if (!validation.valid) { 387 | validationMessage.textContent = validation.error; 388 | validationMessage.className = 'validation-message error'; 389 | return; 390 | } 391 | 392 | // 更新快捷键设置 393 | const newShortcuts = { 394 | ...this.currentShortcuts, 395 | 'toggle-chat': { 396 | ...this.currentShortcuts['toggle-chat'], 397 | default: shortcutString 398 | } 399 | }; 400 | 401 | const success = await this.saveShortcutSettings(newShortcuts); 402 | 403 | if (success) { 404 | validationMessage.textContent = '快捷键已保存'; 405 | validationMessage.className = 'validation-message success'; 406 | 407 | // 2秒后关闭对话框 408 | setTimeout(() => { 409 | closeModal(); 410 | }, 2000); 411 | } else { 412 | validationMessage.textContent = '保存失败,请重试'; 413 | validationMessage.className = 'validation-message error'; 414 | } 415 | }); 416 | } 417 | 418 | /** 419 | * 隐藏快捷键对话框 420 | */ 421 | hideShortcutDialog() { 422 | const modal = document.getElementById('shortcutModal'); 423 | if (modal) { 424 | modal.classList.remove('show'); 425 | setTimeout(() => modal.remove(), 300); 426 | } 427 | } 428 | } -------------------------------------------------------------------------------- /src/popup/storageManager.js: -------------------------------------------------------------------------------- 1 | export class StorageManager { 2 | constructor() { 3 | // 缓存最后保存的设置 4 | this.cachedSettings = null; 5 | } 6 | 7 | // 获取缓存的设置(用于不需要等待异步操作的场景) 8 | getCachedSettings() { 9 | return this.cachedSettings; 10 | } 11 | 12 | async getSettings() { 13 | return new Promise((resolve) => { 14 | chrome.storage.sync.get( 15 | ["deepseekApiKey", "siliconflowApiKey", "openrouterApiKey","volcengineApiKey" ,"tencentcloudApiKey", "iflytekstarApiKey","baiducloudApiKey","aliyunApiKey", "aihubmixApiKey", 16 | "deepseekCustomApiUrl", "siliconflowCustomApiUrl", "openrouterCustomApiUrl", "volcengineCustomApiUrl", "tencentcloudCustomApiUrl", "iflytekstarCustomApiUrl", "baiducloudCustomApiUrl", "aliyunCustomApiUrl", "aihubmixCustomApiUrl", 17 | "language", "model", "provider", "selectionEnabled", "rememberWindowSize", "pinWindow", "customModels", "customSystemPrompt", "shortcuts"], 18 | (data) => { 19 | // 更新缓存 20 | this.cachedSettings = { 21 | deepseekApiKey: data.deepseekApiKey || '', 22 | siliconflowApiKey: data.siliconflowApiKey || '', 23 | openrouterApiKey: data.openrouterApiKey || '', 24 | volcengineApiKey: data.volcengineApiKey || '', 25 | tencentcloudApiKey: data.tencentcloudApiKey || '', 26 | iflytekstarApiKey: data.iflytekstarApiKey || '', 27 | baiducloudApiKey: data.baiducloudApiKey || '', 28 | aliyunApiKey: data.aliyunApiKey || '', 29 | aihubmixApiKey: data.aihubmixApiKey || '', 30 | deepseekCustomApiUrl: data.deepseekCustomApiUrl || '', 31 | siliconflowCustomApiUrl: data.siliconflowCustomApiUrl || '', 32 | openrouterCustomApiUrl: data.openrouterCustomApiUrl || '', 33 | volcengineCustomApiUrl: data.volcengineCustomApiUrl || '', 34 | tencentcloudCustomApiUrl: data.tencentcloudCustomApiUrl || '', 35 | iflytekstarCustomApiUrl: data.iflytekstarCustomApiUrl || '', 36 | baiducloudCustomApiUrl: data.baiducloudCustomApiUrl || '', 37 | aliyunCustomApiUrl: data.aliyunCustomApiUrl || '', 38 | aihubmixCustomApiUrl: data.aihubmixCustomApiUrl || '', 39 | language: data.language || 'en', 40 | model: data.model || '', 41 | provider: data.provider || 'deepseek', 42 | selectionEnabled: typeof data.selectionEnabled === 'undefined' ? true : data.selectionEnabled, 43 | rememberWindowSize: typeof data.rememberWindowSize === 'undefined' ? false : data.rememberWindowSize, 44 | pinWindow: typeof data.pinWindow === 'undefined' ? false : data.pinWindow, 45 | customModels: data.customModels || {}, 46 | customSystemPrompt: data.customSystemPrompt || '', 47 | shortcuts: data.shortcuts || { 48 | 'toggle-chat': { 49 | default: 'Ctrl+Shift+Y', 50 | description: 'Open or close the chat window (destroys session).', 51 | displayName: 'Open/Close Chat' 52 | }, 53 | 'show-hide-chat': { 54 | default: 'Ctrl+Shift+U', 55 | description: 'Show or hide the chat window (preserves session).', 56 | displayName: 'Show/Hide Chat' 57 | } 58 | } 59 | }; 60 | resolve(this.cachedSettings); 61 | } 62 | ); 63 | }); 64 | } 65 | 66 | async saveApiKey(provider, apiKey) { 67 | const key = `${provider}ApiKey`; 68 | return new Promise((resolve) => { 69 | chrome.storage.sync.set({ [key]: apiKey }, () => { 70 | // 更新缓存 71 | if (this.cachedSettings) { 72 | this.cachedSettings[key] = apiKey; 73 | } 74 | resolve(); 75 | }); 76 | }); 77 | } 78 | 79 | async saveLanguage(language) { 80 | return new Promise((resolve) => { 81 | chrome.storage.sync.set({ language }, () => { 82 | // 更新缓存 83 | if (this.cachedSettings) { 84 | this.cachedSettings.language = language; 85 | } 86 | resolve(); 87 | }); 88 | }); 89 | } 90 | 91 | async saveModel(model) { 92 | return new Promise((resolve) => { 93 | chrome.storage.sync.set({ model }, () => { 94 | // 更新缓存 95 | if (this.cachedSettings) { 96 | this.cachedSettings.model = model; 97 | } 98 | resolve(); 99 | }); 100 | }); 101 | } 102 | 103 | async saveProvider(provider) { 104 | return new Promise((resolve) => { 105 | chrome.storage.sync.set({ provider }, () => { 106 | // 更新缓存 107 | if (this.cachedSettings) { 108 | this.cachedSettings.provider = provider; 109 | } 110 | resolve(); 111 | }); 112 | }); 113 | } 114 | 115 | async saveSelectionEnabled(enabled) { 116 | return new Promise((resolve) => { 117 | chrome.storage.sync.set({ selectionEnabled: enabled }, () => { 118 | // 更新缓存 119 | if (this.cachedSettings) { 120 | this.cachedSettings.selectionEnabled = enabled; 121 | } 122 | resolve(); 123 | }); 124 | }); 125 | } 126 | 127 | async saveRememberWindowSize(enabled) { 128 | return new Promise((resolve) => { 129 | chrome.storage.sync.set({ rememberWindowSize: enabled }, () => { 130 | // 更新缓存 131 | if (this.cachedSettings) { 132 | this.cachedSettings.rememberWindowSize = enabled; 133 | } 134 | resolve(); 135 | }); 136 | }); 137 | } 138 | 139 | async savePinWindow(enabled) { 140 | return new Promise((resolve) => { 141 | chrome.storage.sync.set({ pinWindow: enabled }, () => { 142 | // 更新缓存 143 | if (this.cachedSettings) { 144 | this.cachedSettings.pinWindow = enabled; 145 | } 146 | resolve(); 147 | }); 148 | }); 149 | } 150 | 151 | async saveCustomApiUrl(provider, customApiUrl) { 152 | const key = `${provider}CustomApiUrl`; 153 | return new Promise((resolve) => { 154 | chrome.storage.sync.set({ [key]: customApiUrl }, () => { 155 | // 更新缓存 156 | if (this.cachedSettings) { 157 | this.cachedSettings[key] = customApiUrl; 158 | } 159 | resolve(); 160 | }); 161 | }); 162 | } 163 | 164 | // 获取指定服务商的自定义模型 165 | async getCustomModels(provider) { 166 | return new Promise((resolve) => { 167 | chrome.storage.sync.get(['customModels'], (data) => { 168 | const customModels = data.customModels || {}; 169 | resolve(customModels[provider] || []); 170 | }); 171 | }); 172 | } 173 | 174 | // 保存自定义模型 175 | async saveCustomModel(provider, modelId, displayName) { 176 | return new Promise(async (resolve, reject) => { 177 | try { 178 | // 获取当前保存的自定义模型 179 | const settings = await this.getSettings(); 180 | const customModels = settings.customModels || {}; 181 | const providerModels = customModels[provider] || []; 182 | 183 | // 检查模型ID是否已存在 184 | const existingModelIndex = providerModels.findIndex(model => model.value === modelId); 185 | if (existingModelIndex >= 0) { 186 | // 如果已存在,更新显示名称 187 | providerModels[existingModelIndex].label = displayName; 188 | } else { 189 | // 如果不存在,添加新模型 190 | providerModels.push({ 191 | value: modelId, 192 | label: displayName 193 | }); 194 | } 195 | 196 | // 更新保存 197 | customModels[provider] = providerModels; 198 | 199 | chrome.storage.sync.set({ customModels }, () => { 200 | // 更新缓存 201 | if (this.cachedSettings) { 202 | this.cachedSettings.customModels = customModels; 203 | } 204 | resolve(); 205 | }); 206 | } catch (error) { 207 | reject(error); 208 | } 209 | }); 210 | } 211 | 212 | // 删除自定义模型 213 | async deleteCustomModel(provider, modelId) { 214 | return new Promise(async (resolve, reject) => { 215 | try { 216 | // 获取当前保存的自定义模型 217 | const settings = await this.getSettings(); 218 | const customModels = settings.customModels || {}; 219 | const providerModels = customModels[provider] || []; 220 | 221 | // 过滤掉要删除的模型 222 | const updatedModels = providerModels.filter(model => model.value !== modelId); 223 | 224 | // 更新保存 225 | customModels[provider] = updatedModels; 226 | 227 | // 直接更新 storage 228 | await this.updateStorage('customModels', customModels); 229 | 230 | // 为了确保完全删除,也更新独立的provider存储 231 | const storageKey = `customModels_${provider}`; 232 | try { 233 | // 从chrome.storage.sync中获取数据 234 | const data = await this.getFromStorage(storageKey); 235 | if (data) { 236 | const providerCustomModels = data.filter(model => model.value !== modelId); 237 | // 更新storage 238 | await this.updateStorage(storageKey, providerCustomModels); 239 | } 240 | } catch (error) { 241 | console.error('更新provider特定存储时出错:', error); 242 | // 继续执行,不阻止主要删除操作 243 | } 244 | 245 | resolve(); 246 | } catch (error) { 247 | reject(error); 248 | } 249 | }); 250 | } 251 | 252 | // 辅助方法:从storage中获取数据 253 | async getFromStorage(key) { 254 | return new Promise((resolve, reject) => { 255 | chrome.storage.sync.get(key, (result) => { 256 | if (chrome.runtime.lastError) { 257 | reject(chrome.runtime.lastError); 258 | } else { 259 | resolve(result[key]); 260 | } 261 | }); 262 | }); 263 | } 264 | 265 | // 辅助方法:更新storage中的数据 266 | async updateStorage(key, value) { 267 | return new Promise((resolve, reject) => { 268 | chrome.storage.sync.set({ [key]: value }, () => { 269 | if (chrome.runtime.lastError) { 270 | reject(chrome.runtime.lastError); 271 | } else { 272 | // 更新缓存 273 | if (this.cachedSettings && key in this.cachedSettings) { 274 | this.cachedSettings[key] = value; 275 | } 276 | resolve(); 277 | } 278 | }); 279 | }); 280 | } 281 | 282 | // 保存指定提供商的所有自定义模型 283 | async saveCustomModels(provider, models) { 284 | return new Promise(async (resolve, reject) => { 285 | try { 286 | // 获取当前保存的所有自定义模型 287 | const settings = await this.getSettings(); 288 | const customModels = settings.customModels || {}; 289 | 290 | // 更新指定提供商的模型列表 291 | customModels[provider] = models; 292 | 293 | // 保存到chrome.storage.sync 294 | await this.updateStorage('customModels', customModels); 295 | 296 | // 同时保存到provider特定的存储中,确保数据一致性 297 | const storageKey = `customModels_${provider}`; 298 | localStorage.setItem(storageKey, JSON.stringify(models)); 299 | 300 | resolve(); 301 | } catch (error) { 302 | console.error('保存自定义模型列表失败:', error); 303 | reject(error); 304 | } 305 | }); 306 | } 307 | 308 | // 保存默认模型列表 309 | async saveDefaultModels(provider, models) { 310 | return new Promise(async (resolve, reject) => { 311 | try { 312 | const storageKey = `defaultModels_${provider}`; 313 | localStorage.setItem(storageKey, JSON.stringify(models)); 314 | resolve(); 315 | } catch (error) { 316 | console.error('保存默认模型列表失败:', error); 317 | reject(error); 318 | } 319 | }); 320 | } 321 | 322 | // 获取指定服务商的自定义模型(从localStorage读取) 323 | async getCustomModelsFromLocalStorage(provider) { 324 | try { 325 | const storageKey = `customModels_${provider}`; 326 | const customModelsJson = localStorage.getItem(storageKey); 327 | if (customModelsJson) { 328 | return JSON.parse(customModelsJson); 329 | } 330 | return []; 331 | } catch (e) { 332 | console.error('从localStorage获取自定义模型失败:', e); 333 | return []; 334 | } 335 | } 336 | 337 | // 获取指定服务商的默认模型(从localStorage读取) 338 | async getDefaultModelsFromLocalStorage(provider) { 339 | try { 340 | const storageKey = `defaultModels_${provider}`; 341 | const defaultModelsJson = localStorage.getItem(storageKey); 342 | if (defaultModelsJson !== null) { 343 | return JSON.parse(defaultModelsJson); 344 | } 345 | return null; // 返回null而不是空数组,表示没有设置 346 | } catch (e) { 347 | console.error('从localStorage获取默认模型失败:', e); 348 | return null; 349 | } 350 | } 351 | 352 | // 保存插件设置 353 | async saveSettings(settings) { 354 | return new Promise((resolve) => { 355 | chrome.storage.sync.set(settings, () => { 356 | // 更新缓存 357 | this.cachedSettings = {...this.cachedSettings, ...settings}; 358 | resolve(); 359 | }); 360 | }); 361 | } 362 | 363 | async saveCustomSystemPrompt(prompt) { 364 | return new Promise((resolve) => { 365 | chrome.storage.sync.set({ customSystemPrompt: prompt }, () => { 366 | // 更新缓存 367 | if (this.cachedSettings) { 368 | this.cachedSettings.customSystemPrompt = prompt; 369 | } 370 | resolve(); 371 | }); 372 | }); 373 | } 374 | 375 | async getCustomSystemPrompt() { 376 | return new Promise((resolve) => { 377 | chrome.storage.sync.get(['customSystemPrompt'], (data) => { 378 | resolve(data.customSystemPrompt || ''); 379 | }); 380 | }); 381 | } 382 | 383 | // 保存快捷键设置 384 | async saveShortcuts(shortcuts) { 385 | return new Promise((resolve) => { 386 | chrome.storage.sync.set({ shortcuts }, () => { 387 | // 更新缓存 388 | if (this.cachedSettings) { 389 | this.cachedSettings.shortcuts = shortcuts; 390 | } 391 | resolve(); 392 | }); 393 | }); 394 | } 395 | 396 | // 获取快捷键设置 397 | async getShortcuts() { 398 | const settings = await this.getSettings(); 399 | return settings.shortcuts; 400 | } 401 | } -------------------------------------------------------------------------------- /src/content/utils/themeManager.js: -------------------------------------------------------------------------------- 1 | import { THEME_CLASSES } from './constants'; 2 | 3 | /** 4 | * 判断是否为暗色模式 5 | * 增强版色彩分析,支持网站特异性检测 6 | */ 7 | export function isDarkMode() { 8 | // 1. 检查特定网站的主题实现 9 | const specificTheme = detectSpecificSiteTheme(); 10 | if (specificTheme !== null) { 11 | return specificTheme; 12 | } 13 | 14 | // 2. 检查用户手动设置的主题偏好 15 | const userPreference = getUserThemePreference(); 16 | if (userPreference !== 'auto') { 17 | return userPreference === 'dark'; 18 | } 19 | 20 | // 3. 检查CSS变量定义的主题 21 | const cssVarTheme = detectCSSVarTheme(); 22 | if (cssVarTheme !== null) { 23 | return cssVarTheme; 24 | } 25 | 26 | // 4. 分析页面颜色 27 | return analyzePageColors(); 28 | } 29 | 30 | /** 31 | * 分析页面颜色判断主题 32 | */ 33 | /** 34 | * 分析页面颜色判断主题 (基于第一性原理:视觉上的深色才是真正的深色模式) 35 | * 采用"实事求是"的方法,通过采样视口中心元素的背景色来判断 36 | * 如果背景色透明(如背景图),则参考文字颜色 37 | */ 38 | function analyzePageColors() { 39 | // 1. 采样视口中心的元素 (抓住主要矛盾) 40 | const x = window.innerWidth / 2; 41 | const y = window.innerHeight / 2; 42 | let element = document.elementFromPoint(x, y); 43 | 44 | if (!element) { 45 | element = document.body; 46 | } 47 | 48 | // 2. 向上遍历寻找有效的背景色和文字颜色 49 | let effectiveBg = null; 50 | let effectiveText = null; 51 | let currentEl = element; 52 | 53 | // 限制遍历深度,避免性能问题 54 | let depth = 0; 55 | const MAX_DEPTH = 10; 56 | 57 | while (currentEl && depth < MAX_DEPTH) { 58 | const styles = window.getComputedStyle(currentEl); 59 | 60 | // 检查背景色 61 | if (!effectiveBg) { 62 | const bgColor = parseColor(styles.backgroundColor); 63 | if (bgColor && !bgColor.isTransparent) { 64 | effectiveBg = bgColor; 65 | } 66 | } 67 | 68 | // 检查文字颜色 (作为重要参考) 69 | if (!effectiveText) { 70 | const textColor = parseColor(styles.color); 71 | if (textColor && !textColor.isTransparent) { 72 | effectiveText = textColor; 73 | } 74 | } 75 | 76 | // 如果找到了背景色,通常就可以停止了,因为背景是覆盖的 77 | if (effectiveBg) break; 78 | 79 | if (currentEl === document.documentElement) break; 80 | currentEl = currentEl.parentElement; 81 | depth++; 82 | } 83 | 84 | // 3. 决策逻辑 85 | 86 | // 优先依据:背景色 87 | if (effectiveBg) { 88 | // 亮度 < 0.5 为暗色 89 | // 考虑到深灰色背景,0.6 是一个比较安全的阈值 90 | return effectiveBg.brightness < 0.6; 91 | } 92 | 93 | // 次要依据:文字颜色 (当背景是图片或渐变导致背景色为透明时) 94 | if (effectiveText) { 95 | // 文字是浅色 (亮度 > 0.6) -> 推断背景是深色 -> 暗色模式 96 | if (effectiveText.brightness > 0.6) return true; 97 | // 文字是深色 (亮度 < 0.4) -> 推断背景是浅色 -> 亮色模式 98 | if (effectiveText.brightness < 0.4) return false; 99 | } 100 | 101 | // 兜底:系统偏好 102 | return window.matchMedia('(prefers-color-scheme: dark)').matches; 103 | } 104 | 105 | function parseColor(colorStr) { 106 | if (!colorStr) return null; 107 | 108 | // 修复:使用 [\d.]+ 匹配小数 (例如 rgba(0, 0, 0, 0.5)) 109 | const match = colorStr.match(/[\d.]+/g); 110 | if (!match || match.length < 3) return null; 111 | 112 | const r = parseFloat(match[0]); 113 | const g = parseFloat(match[1]); 114 | const b = parseFloat(match[2]); 115 | const a = match.length >= 4 ? parseFloat(match[3]) : 1; 116 | 117 | // 认为是透明的标准:alpha < 0.05 118 | const isTransparent = a < 0.05; 119 | 120 | // 相对亮度公式 121 | const brightness = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; 122 | 123 | return { 124 | r, g, b, a, 125 | isTransparent, 126 | brightness 127 | }; 128 | } 129 | 130 | /** 131 | * 检测特定网站的主题实现 132 | * @returns {boolean|null} - true表示暗色,false表示亮色,null表示无法判断 133 | */ 134 | /** 135 | * 检测特定网站的主题实现 136 | * @returns {boolean|null} - true表示暗色,false表示亮色,null表示无法判断 137 | */ 138 | function detectSpecificSiteTheme() { 139 | // 0. 标准化元数据和CSS属性检查 (最优先) 140 | 141 | // 0.1 CSS color-scheme 属性 (现代浏览器标准) 142 | // https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme 143 | const htmlStyle = window.getComputedStyle(document.documentElement); 144 | if (htmlStyle.colorScheme === 'dark') return true; 145 | // 注意:color-scheme: 'light dark' 表示支持两者,不能单纯据此判断当前是哪一个, 146 | // 但如果明确只有 'dark',那肯定是暗色。 147 | 148 | // 0.2 meta name="color-scheme" 149 | const colorSchemeMeta = document.querySelector('meta[name="color-scheme"]'); 150 | if (colorSchemeMeta) { 151 | const content = colorSchemeMeta.getAttribute('content').toLowerCase(); 152 | // 如果只包含 dark,或者是 dark light 且系统是 dark 153 | if (content === 'dark') return true; 154 | } 155 | 156 | // 0.3 meta name="theme-color" 157 | // 注意:theme-color 只是浏览器地址栏/系统UI颜色提示,不能作为页面主题的决定性依据 158 | // 很多网站设置 theme-color 为白色是为了配合品牌色,但页面本身可能是暗色的 159 | // 因此仅当 theme-color 明确是深色时才判定为暗色模式,亮色则继续检测 160 | const themeColorMeta = document.querySelector('meta[name="theme-color"]'); 161 | if (themeColorMeta) { 162 | const content = themeColorMeta.getAttribute('content'); 163 | if (content) { 164 | const color = parseColor(content); 165 | if (color && !color.isTransparent && color.brightness < 0.4) { 166 | // 只有明确深色 (brightness < 0.4) 才返回暗色模式 167 | return true; 168 | } 169 | // 亮色 theme-color 不作为决定性依据,继续后续检测 170 | } 171 | } 172 | 173 | // 1. 知名框架和库的特定标记 174 | 175 | // Bootstrap 5.3+ 176 | if (document.documentElement.getAttribute('data-bs-theme') === 'dark' || 177 | document.body.getAttribute('data-bs-theme') === 'dark') { 178 | return true; 179 | } 180 | 181 | // Vuetify 182 | if (document.documentElement.classList.contains('v-theme--dark') || 183 | document.body.classList.contains('v-theme--dark')) { 184 | return true; 185 | } 186 | 187 | // Atlassian (Jira, Confluence) 188 | if (document.documentElement.getAttribute('data-color-mode') === 'dark') { 189 | return true; 190 | } 191 | 192 | const host = window.location.hostname; 193 | 194 | // GitHub 195 | if (host.includes('github.com')) { 196 | const theme = document.documentElement.getAttribute('data-color-mode'); 197 | if (theme) { 198 | return theme === 'dark' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); 199 | } 200 | } 201 | 202 | // Google系产品 203 | if (host.includes('google.com') || host.includes('youtube.com')) { 204 | if (document.documentElement.hasAttribute('dark') || document.documentElement.hasAttribute('darktheme')) { 205 | return true; 206 | } 207 | } 208 | 209 | // Twitter/X 210 | if (host.includes('twitter.com') || host.includes('x.com')) { 211 | return document.documentElement.classList.contains('dark') || 212 | document.querySelector('html[data-theme="dark"]') !== null; 213 | } 214 | 215 | // 检查常见数据属性 216 | const dataTheme = document.documentElement.getAttribute('data-theme') || 217 | document.body.getAttribute('data-theme') || 218 | document.documentElement.getAttribute('data-color-mode') || 219 | document.body.getAttribute('data-color-mode') || 220 | document.documentElement.getAttribute('data-mode') || 221 | document.body.getAttribute('data-mode'); 222 | 223 | if (dataTheme) { 224 | return dataTheme.includes('dark'); 225 | } 226 | 227 | // 检查常见类名 228 | if (document.documentElement.classList.contains('dark') || 229 | document.documentElement.classList.contains('darkTheme') || 230 | document.documentElement.classList.contains('dark-theme') || 231 | document.body.classList.contains('dark') || 232 | document.body.classList.contains('darkTheme') || 233 | document.body.classList.contains('dark-theme')) { 234 | return true; 235 | } 236 | 237 | if (document.documentElement.classList.contains('light') || 238 | document.documentElement.classList.contains('lightTheme') || 239 | document.documentElement.classList.contains('light-theme') || 240 | document.body.classList.contains('light') || 241 | document.body.classList.contains('lightTheme') || 242 | document.body.classList.contains('light-theme')) { 243 | return false; 244 | } 245 | 246 | return null; // 无法确定 247 | } 248 | 249 | /** 250 | * 检测基于CSS变量的主题 251 | */ 252 | function detectCSSVarTheme() { 253 | try { 254 | const styles = getComputedStyle(document.documentElement); 255 | 256 | // 检查常见的背景色CSS变量 257 | const bgVarNames = [ 258 | '--background-color', 259 | '--bg-color', 260 | '--theme-background', 261 | '--color-background', 262 | '--color-bg', 263 | '--surface', 264 | '--bg-primary', 265 | '--color-canvas-default' // GitHub 266 | ]; 267 | 268 | for (const name of bgVarNames) { 269 | const value = styles.getPropertyValue(name).trim(); 270 | if (value) { 271 | const bgColor = parseSimpleColor(value); 272 | if (bgColor) return bgColor.isDark; 273 | } 274 | } 275 | 276 | // 检查常见的文字颜色CSS变量 277 | const textVarNames = [ 278 | '--text-color', 279 | '--color-text', 280 | '--theme-text', 281 | '--color-fg', 282 | '--color-foreground', 283 | '--foreground', 284 | '--text-primary', 285 | '--color-fg-default' // GitHub 286 | ]; 287 | 288 | for (const name of textVarNames) { 289 | const value = styles.getPropertyValue(name).trim(); 290 | if (value) { 291 | const textColor = parseSimpleColor(value); 292 | if (textColor) return !textColor.isDark; // 文字深色表示浅色主题 293 | } 294 | } 295 | } catch (e) { 296 | console.debug('CSS变量分析出错', e); 297 | } 298 | 299 | return null; 300 | } 301 | 302 | /** 303 | * 简单解析颜色值 304 | */ 305 | function parseSimpleColor(color) { 306 | // 处理十六进制颜色 307 | if (color.startsWith('#')) { 308 | const hex = color.substring(1); 309 | if (hex.length === 3) { 310 | const r = parseInt(hex[0] + hex[0], 16); 311 | const g = parseInt(hex[1] + hex[1], 16); 312 | const b = parseInt(hex[2] + hex[2], 16); 313 | const brightness = (r * 299 + g * 587 + b * 114) / 1000 / 255; 314 | return { 315 | isDark: brightness < 0.5 316 | }; 317 | } else if (hex.length === 6) { 318 | const r = parseInt(hex.substring(0, 2), 16); 319 | const g = parseInt(hex.substring(2, 4), 16); 320 | const b = parseInt(hex.substring(4, 6), 16); 321 | const brightness = (r * 299 + g * 587 + b * 114) / 1000 / 255; 322 | return { 323 | isDark: brightness < 0.5 324 | }; 325 | } 326 | } 327 | 328 | // 处理rgb/rgba颜色 329 | const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/); 330 | if (rgbMatch) { 331 | const [_, r, g, b] = rgbMatch.map(Number); 332 | const brightness = (r * 299 + g * 587 + b * 114) / 1000 / 255; 333 | return { 334 | isDark: brightness < 0.5 335 | }; 336 | } 337 | 338 | return null; 339 | } 340 | 341 | /** 342 | * 获取用户主题偏好 343 | * @returns {'dark'|'light'|'auto'} 用户偏好 344 | */ 345 | function getUserThemePreference() { 346 | return localStorage.getItem('deepseek-theme-preference') || 'auto'; 347 | } 348 | 349 | /** 350 | * 设置用户主题偏好 351 | * @param {'dark'|'light'|'auto'} preference 用户偏好 352 | */ 353 | export function setUserThemePreference(preference) { 354 | if (['dark', 'light', 'auto'].includes(preference)) { 355 | localStorage.setItem('deepseek-theme-preference', preference); 356 | // 触发重新应用主题 357 | const isDark = preference === 'auto' ? isDarkMode() : (preference === 'dark'); 358 | document.dispatchEvent(new CustomEvent('deepseek-theme-change', { detail: { isDark }})); 359 | } 360 | } 361 | 362 | /** 363 | * 监视主题变化 364 | * 性能优化版本,减少不必要的检测 365 | */ 366 | export function watchThemeChanges(callback) { 367 | let currentTheme = isDarkMode(); 368 | 369 | // 高效防抖 370 | const debouncedCallback = debounce((isDark) => { 371 | if (currentTheme !== isDark) { 372 | currentTheme = isDark; 373 | callback(isDark); 374 | } 375 | }, 50); 376 | 377 | // 主题变化检测函数 378 | const checkThemeChange = () => { 379 | requestAnimationFrame(() => { 380 | const newTheme = isDarkMode(); 381 | debouncedCallback(newTheme); 382 | }); 383 | }; 384 | 385 | // 监听DOM变化,有选择性地过滤 386 | const observer = new MutationObserver((mutations) => { 387 | // 过滤掉不太可能影响主题的变化 388 | const shouldCheck = mutations.some(mutation => { 389 | // 类和主题相关属性的变化 390 | if (mutation.type === 'attributes') { 391 | const attr = mutation.attributeName; 392 | return attr === 'class' || 393 | attr === 'style' || 394 | attr === 'data-theme' || 395 | attr === 'data-color-mode' || 396 | attr === 'data-color-scheme' || 397 | attr.includes('theme') || 398 | attr.includes('mode') || 399 | attr.includes('dark') || 400 | attr.includes('light'); 401 | } 402 | return false; 403 | }); 404 | 405 | if (shouldCheck) { 406 | checkThemeChange(); 407 | } 408 | }); 409 | 410 | // 观察配置,减少不必要的触发 411 | const observerConfig = { 412 | attributes: true, 413 | attributeFilter: ['style', 'class', 'data-theme', 'data-color-mode', 'data-color-scheme', 'darktheme', 'dark'], 414 | subtree: false 415 | }; 416 | 417 | // 只监听HTML和BODY元素 418 | observer.observe(document.documentElement, observerConfig); 419 | observer.observe(document.body, observerConfig); 420 | 421 | // 监听系统主题变化 422 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); 423 | const mediaQueryHandler = checkThemeChange; 424 | mediaQuery.addEventListener('change', mediaQueryHandler); 425 | 426 | // 监听自定义主题变化事件 427 | const customEventHandler = checkThemeChange; 428 | document.addEventListener('deepseek-theme-change', customEventHandler); 429 | 430 | // 初始化主题 431 | callback(currentTheme); 432 | 433 | // 返回清理函数 434 | return () => { 435 | observer.disconnect(); 436 | mediaQuery.removeEventListener('change', mediaQueryHandler); 437 | document.removeEventListener('deepseek-theme-change', customEventHandler); 438 | }; 439 | } 440 | 441 | /** 442 | * 高效防抖函数 443 | */ 444 | function debounce(func, wait) { 445 | let timeout; 446 | return function(...args) { 447 | clearTimeout(timeout); 448 | timeout = setTimeout(() => func(...args), wait); 449 | }; 450 | } 451 | 452 | /** 453 | * 应用主题到指定元素 454 | */ 455 | export function applyTheme(element, isDark) { 456 | if (!element) return; 457 | 458 | requestAnimationFrame(() => { 459 | if (isDark) { 460 | element.classList.add(THEME_CLASSES.DARK); 461 | element.classList.remove(THEME_CLASSES.LIGHT); 462 | element.setAttribute('data-theme', 'dark'); 463 | } else { 464 | element.classList.remove(THEME_CLASSES.DARK); 465 | element.classList.add(THEME_CLASSES.LIGHT); 466 | element.setAttribute('data-theme', 'light'); 467 | } 468 | }); 469 | } 470 | 471 | /** 472 | * 获取当前主题 473 | * @returns {'dark'|'light'} 当前主题 474 | */ 475 | export function getCurrentTheme() { 476 | return isDarkMode() ? 'dark' : 'light'; 477 | } -------------------------------------------------------------------------------- /src/Instructions/instructions.js: -------------------------------------------------------------------------------- 1 | const translations = { 2 | zh: { 3 | title: "DeepSeek AI 使用方式", 4 | subtitle: "让 AI 助手为您的网页浏览体验增添智慧", 5 | quickStart: "快速开始", 6 | chromeInstall: "Chrome 商店安装", 7 | chromeDesc: "从 Chrome 网上应用店安装扩展", 8 | edgeInstall: "Edge 商店安装", 9 | edgeDesc: "从 Edge 网上应用店安装扩展", 10 | deepseekWebsite: "DeepSeek 官网", 11 | deepseekDesc: "访问 DeepSeek AI 官方网站", 12 | apiKey: "获取 API Key", 13 | apiDesc: "在 DeepSeek 平台获取您的 API 密钥", 14 | shortcuts: "快捷键设置", 15 | shortcutsDesc: "自定义扩展快捷键", 16 | github: "GitHub 仓库", 17 | githubDesc: "查看源代码和提交建议", 18 | installationSteps: [ 19 | "在浏览器中安装 DeepSeek AI 扩展", 20 | "点击工具栏中的扩展图标", 21 | "输入您的 DeepSeek API 密钥", 22 | "选择您偏好的回答语言(强制模型根据设定的语言进行回答)", 23 | "开启快捷按钮功能(用于决定是否出现工具栏,当在网页上划词选择内容时)", 24 | "设置你偏好的快捷键(用于快捷打开/关闭 AI 会话窗口,无论是否选中文本)", 25 | "在任意网页上选择文本,开始与 AI 对话!可以点击工具栏里的按钮或者使用快捷键以打开对话窗口", 26 | ], 27 | usage: "使用方法", 28 | quickButton: "快捷按钮使用", 29 | quickButtonDesc: "在扩展设置中开启快捷按钮后,选中网页文本时会自动显示一个工具栏,可以根据自己的需求单击对应按钮。", 30 | shortcutUsage: "快捷键使用", 31 | shortcutUsageDesc: 32 | "扩展提供了三种可自定义的快捷键,无论是否选中文本都可使用,可在浏览器的扩展设置中修改:", 33 | shortcutKey1: "激活扩展程序", 34 | shortcutKey1Desc: "快速激活 DeepSeek AI 扩展", 35 | shortcutKey2: "显示/隐藏对话窗口", 36 | shortcutKey2Desc: "切换对话窗口的显示状态(保留当前会话内容,无论是否选中文本都可使用)", 37 | shortcutKey3: "打开/关闭对话窗口", 38 | shortcutKey3Desc: "打开或关闭对话窗口(关闭时会清除会话记录,无论是否选中文本都可使用)", 39 | shortcutCustomTipPrefix: "提示:可在", 40 | shortcutCustomTipSuffix: "页面自定义这些快捷键", 41 | features: "功能特点", 42 | smartChat: "智能对话", 43 | smartChat1: "• 支持多轮对话,记住上下文", 44 | smartChat2: "• 实时流式响应,打字机效果", 45 | smartChat3: "• 支持重新生成回答", 46 | uiInteraction: "界面交互", 47 | uiInteraction1: "• 可拖拽调整窗口位置和大小", 48 | uiInteraction2: "• 支持 Markdown 格式化显示", 49 | uiInteraction3: "• 支持 LaTeX 数学公式渲染", 50 | uiInteraction4: "• 代码块一键复制", 51 | uiInteraction5: "• 支持代码高亮", 52 | personalization: "个性化设置", 53 | personalization1: "• 自定义AI回复语言偏好", 54 | personalization2: "• 深色模式自动适配", 55 | personalization3: "• 可配置快捷键", 56 | tips: "使用技巧", 57 | tip1: "💡 可自定义快捷键以更快地打开/关闭对话窗口", 58 | tip2: "💡 点击代码块右上角的复制按钮,可以快速复制代码片段", 59 | tip3: "💡 如果对 AI 的回答不满意,可以点击重新生成按钮获取新的答案", 60 | feedback: "反馈与支持", 61 | feedbackDesc: 62 | "如果您喜欢 DeepSeek AI 扩展,欢迎在 Chrome 网上应用商店评分和评论,期待您的反馈!", 63 | chromeFeedback: "前往 Chrome 商店", 64 | chromeFeedbackDesc: "为 DeepSeek AI 评分和评论", 65 | privacy: "隐私说明", 66 | privacyDesc: 67 | "重视您的隐私。DeepSeek AI 扩展只会在必要时发送您选中的文本到 API,不会收集或存储任何其他个人信息。您的 API 密钥仅保存在本地浏览器中。", 68 | }, 69 | en: { 70 | title: "DeepSeek AI Usage Guide", 71 | subtitle: "Enhance your web browsing experience with AI assistance", 72 | quickStart: "Quick Start", 73 | chromeInstall: "Install from Chrome Web Store", 74 | chromeDesc: "Install the extension from Chrome Web Store", 75 | edgeInstall: "Install from Edge Add-ons", 76 | edgeDesc: "Install the extension from Edge Add-ons", 77 | deepseekWebsite: "DeepSeek Website", 78 | deepseekDesc: "Visit the official DeepSeek AI website", 79 | apiKey: "Get API Key", 80 | apiDesc: "Obtain your API key on the DeepSeek platform", 81 | shortcuts: "Shortcut Settings", 82 | shortcutsDesc: "Customize extension shortcuts", 83 | github: "GitHub Repository", 84 | githubDesc: "View source code and submit suggestions", 85 | installationSteps: [ 86 | "Install the DeepSeek AI extension in your browser", 87 | "Click the extension icon in the toolbar", 88 | "Enter your DeepSeek API key", 89 | "Select your preferred response language (force the model to respond in the set language)", 90 | "Enable the Quick Button feature (to decide whether the toolbar appears when selecting text on a webpage)", 91 | "Set your preferred shortcut keys (for quickly opening/closing the AI conversation window, regardless of whether text is selected)", 92 | "Select text on the webpage to start a conversation with AI! You can click the button in the toolbar or use the shortcut key to open the chat window", 93 | ], 94 | usage: "Usage", 95 | quickButton: "Quick Button Usage", 96 | quickButtonDesc: "After enabling the quick button in the extension settings, a toolbar will automatically appear when you select webpage text, allowing you to click on the corresponding button according to your needs.", 97 | shortcutUsage: "Shortcut Usage", 98 | shortcutUsageDesc: 99 | "The extension provides three customizable shortcuts that work regardless of whether text is selected, and can be modified in your browser's extension settings:", 100 | shortcutKey1: "Activate Extension", 101 | shortcutKey1Desc: "Quickly activate the DeepSeek AI extension", 102 | shortcutKey2: "Show/Hide Chat Window", 103 | shortcutKey2Desc: "Toggle the chat window visibility (preserves current session, works regardless of text selection)", 104 | shortcutKey3: "Open/Close Chat Window", 105 | shortcutKey3Desc: "Open or close the chat window (clears session history when closed, works regardless of text selection)", 106 | shortcutCustomTipPrefix: "Tip: Customize these shortcuts at", 107 | shortcutCustomTipSuffix: "", 108 | features: "Features", 109 | smartChat: "Smart Chat", 110 | smartChat1: "• Supports multi-turn conversations with context memory", 111 | smartChat2: "• Real-time streaming responses with typewriter effect", 112 | smartChat3: "• Supports regenerating responses", 113 | uiInteraction: "UI Interaction", 114 | uiInteraction1: "• Draggable and resizable window", 115 | uiInteraction2: "• Supports Markdown formatting", 116 | uiInteraction3: "• Supports LaTeX math rendering", 117 | uiInteraction4: "• One-click code block copying", 118 | uiInteraction5: "• Supports code highlighting", 119 | personalization: "Personalization", 120 | personalization1: "• Customize AI response language preference.", 121 | personalization2: "• Automatic dark mode adaptation", 122 | personalization3: "• Configurable shortcuts", 123 | tips: "Tips", 124 | tip1: "💡 Customizable shortcuts for quickly opening/closing the chat window.", 125 | tip2: "💡 Click the copy button on the code block to quickly copy the code snippet.", 126 | tip3: "💡 If you're not satisfied with the AI's response, click the regenerate button to get a new answer.", 127 | feedback: "Feedback & Support", 128 | feedbackDesc: 129 | "If you like the DeepSeek AI extension, please rate and review it on the Chrome Web Store. We look forward to your feedback!", 130 | chromeFeedback: "Visit Chrome Web Store", 131 | chromeFeedbackDesc: "Rate and review DeepSeek AI", 132 | privacy: "Privacy Policy", 133 | privacyDesc: 134 | "We value your privacy. The DeepSeek AI extension only sends selected text to the API when necessary and does not collect or store any other personal information. Your API key is stored locally in your browser.", 135 | }, 136 | }; 137 | 138 | let currentLang = "en"; 139 | 140 | const toggleLanguage = () => { 141 | // 移除console.log("toggleLanguage")这种简单的日志 142 | currentLang = currentLang === "zh" ? "en" : "zh"; 143 | 144 | // 确保translations对象中有对应的语言数据 145 | const langData = translations[currentLang]; 146 | if (!langData) { 147 | console.error("No translations found for", currentLang); 148 | return; 149 | } 150 | 151 | try { 152 | updateContent(); 153 | } catch (err) { 154 | console.error("Error updating content:", err); 155 | } 156 | }; 157 | 158 | const updateContent = () => { 159 | const langData = translations[currentLang]; 160 | document.getElementById("title").textContent = langData.title; 161 | document.getElementById("subtitle").textContent = langData.subtitle; 162 | document.getElementById("quick-start").textContent = langData.quickStart; 163 | document.getElementById("chrome-install").textContent = 164 | langData.chromeInstall; 165 | document.getElementById("chrome-desc").textContent = langData.chromeDesc; 166 | document.getElementById("edge-install").textContent = langData.edgeInstall; 167 | document.getElementById("edge-desc").textContent = langData.edgeDesc; 168 | 169 | // Handle hidden elements safely 170 | if (document.getElementById("deepseek-website")) document.getElementById("deepseek-website").textContent = langData.deepseekWebsite; 171 | if (document.getElementById("deepseek-desc")) document.getElementById("deepseek-desc").textContent = langData.deepseekDesc; 172 | 173 | document.getElementById("api-key").textContent = langData.apiKey; 174 | document.getElementById("api-desc").textContent = langData.apiDesc; 175 | 176 | if (document.getElementById("shortcuts")) document.getElementById("shortcuts").textContent = langData.shortcuts; 177 | if (document.getElementById("shortcuts-desc")) document.getElementById("shortcuts-desc").textContent = langData.shortcutsDesc; 178 | 179 | document.getElementById("github").textContent = langData.github; 180 | document.getElementById("github-desc").textContent = langData.githubDesc; 181 | 182 | // Update installation steps - New Structure 183 | const stepsContainer = document.getElementById("installation-steps-container"); 184 | if (stepsContainer) { 185 | stepsContainer.innerHTML = ''; // Clear existing steps 186 | langData.installationSteps.forEach((stepText) => { 187 | const stepItem = document.createElement('div'); 188 | stepItem.className = 'step-item'; 189 | const stepContent = document.createElement('div'); 190 | stepContent.className = 'step-content'; 191 | stepContent.textContent = stepText; 192 | stepItem.appendChild(stepContent); 193 | stepsContainer.appendChild(stepItem); 194 | }); 195 | } 196 | 197 | document.getElementById("usage").textContent = langData.usage; 198 | document.getElementById("quick-button").textContent = langData.quickButton; 199 | document.getElementById("quick-button-desc").textContent = langData.quickButtonDesc; 200 | document.getElementById("shortcut-usage").textContent = 201 | langData.shortcutUsage; 202 | document.getElementById("shortcut-usage-desc").textContent = 203 | langData.shortcutUsageDesc; 204 | 205 | // Update shortcut descriptions 206 | if (document.getElementById("shortcut-key-1")) { 207 | document.getElementById("shortcut-key-1").textContent = langData.shortcutKey1; 208 | } 209 | if (document.getElementById("shortcut-key-1-desc")) { 210 | document.getElementById("shortcut-key-1-desc").textContent = langData.shortcutKey1Desc; 211 | } 212 | if (document.getElementById("shortcut-key-2")) { 213 | document.getElementById("shortcut-key-2").textContent = langData.shortcutKey2; 214 | } 215 | if (document.getElementById("shortcut-key-2-desc")) { 216 | document.getElementById("shortcut-key-2-desc").textContent = langData.shortcutKey2Desc; 217 | } 218 | if (document.getElementById("shortcut-key-3")) { 219 | document.getElementById("shortcut-key-3").textContent = langData.shortcutKey3; 220 | } 221 | if (document.getElementById("shortcut-key-3-desc")) { 222 | document.getElementById("shortcut-key-3-desc").textContent = langData.shortcutKey3Desc; 223 | } 224 | if (document.getElementById("shortcut-custom-tip-prefix")) { 225 | document.getElementById("shortcut-custom-tip-prefix").textContent = langData.shortcutCustomTipPrefix; 226 | } 227 | if (document.getElementById("shortcut-custom-tip-suffix")) { 228 | document.getElementById("shortcut-custom-tip-suffix").textContent = langData.shortcutCustomTipSuffix || ""; 229 | } 230 | 231 | document.getElementById("features").textContent = langData.features; 232 | document.getElementById("smart-chat").textContent = langData.smartChat; 233 | document.getElementById("smart-chat-1").textContent = langData.smartChat1; 234 | document.getElementById("smart-chat-2").textContent = langData.smartChat2; 235 | document.getElementById("smart-chat-3").textContent = langData.smartChat3; 236 | document.getElementById("ui-interaction").textContent = 237 | langData.uiInteraction; 238 | document.getElementById("ui-interaction-1").textContent = 239 | langData.uiInteraction1; 240 | document.getElementById("ui-interaction-2").textContent = 241 | langData.uiInteraction2; 242 | document.getElementById("ui-interaction-3").textContent = 243 | langData.uiInteraction3; 244 | document.getElementById("ui-interaction-4").textContent = 245 | langData.uiInteraction4; 246 | // ui-interaction-5 might be missing in new design, handle safely 247 | if (document.getElementById("ui-interaction-5")) document.getElementById("ui-interaction-5").textContent = langData.uiInteraction5; 248 | 249 | document.getElementById("personalization").textContent = 250 | langData.personalization; 251 | document.getElementById("personalization-1").textContent = 252 | langData.personalization1; 253 | document.getElementById("personalization-2").textContent = 254 | langData.personalization2; 255 | document.getElementById("personalization-3").textContent = 256 | langData.personalization3; 257 | 258 | // Tips section might be hidden or restructured 259 | if (document.getElementById("tips")) document.getElementById("tips").textContent = langData.tips; 260 | if (document.getElementById("tip-1")) document.getElementById("tip-1").textContent = langData.tip1; 261 | if (document.getElementById("tip-2")) document.getElementById("tip-2").textContent = langData.tip2; 262 | if (document.getElementById("tip-3")) document.getElementById("tip-3").textContent = langData.tip3; 263 | 264 | document.getElementById("feedback").textContent = langData.feedback; 265 | document.getElementById("feedback-desc").textContent = langData.feedbackDesc; 266 | document.getElementById("chrome-feedback").textContent = 267 | langData.chromeFeedback; 268 | document.getElementById("chrome-feedback-desc").textContent = 269 | langData.chromeFeedbackDesc; 270 | 271 | document.getElementById("privacy").textContent = langData.privacy; 272 | document.getElementById("privacy-desc").textContent = langData.privacyDesc; 273 | }; 274 | 275 | document.addEventListener("DOMContentLoaded", () => { 276 | const langToggleBtn = document.getElementById("language-toggle"); 277 | if (langToggleBtn) { 278 | langToggleBtn.addEventListener("click", (e) => { 279 | e.preventDefault(); // 防止可能的默认行为 280 | toggleLanguage(); 281 | }); 282 | } else { 283 | console.error("Language toggle button not found"); 284 | } 285 | 286 | // Check for file access hash 287 | if (window.location.hash === '#file-access') { 288 | const cnMsg = "请在扩展管理页面中启用“允许访问文件网址”权限,以便在本地文件中使用 DeepSeek AI。"; 289 | const enMsg = "Please enable 'Allow access to file URLs' in the extension management page to use DeepSeek AI with local files."; 290 | // Simple alert for immediate attention. A custom modal would be better but this is effective and simple. 291 | // Using setTimeout to ensure the page renders first 292 | setTimeout(() => { 293 | alert(currentLang === 'zh' ? cnMsg : enMsg); 294 | }, 500); 295 | } 296 | }); 297 | 298 | document 299 | .getElementById("shortcuts-link") 300 | .addEventListener("click", function (e) { 301 | e.preventDefault(); 302 | chrome.runtime.openOptionsPage(); 303 | chrome.tabs.create({ url: "chrome://extensions/shortcuts" }); 304 | }); 305 | 306 | // 快捷键设置链接点击事件 307 | const shortcutsSettingsLink = document.getElementById("shortcuts-settings-link"); 308 | if (shortcutsSettingsLink) { 309 | shortcutsSettingsLink.addEventListener("click", function (e) { 310 | e.preventDefault(); 311 | chrome.tabs.create({ url: "chrome://extensions/shortcuts" }); 312 | }); 313 | } 314 | -------------------------------------------------------------------------------- /src/popup/i18n.js: -------------------------------------------------------------------------------- 1 | export class I18nManager { 2 | constructor() { 3 | this.translations = { 4 | zh: { 5 | validating: '验证中...', 6 | saveSuccess: '保存成功', 7 | apiKeyInvalid: 'API密钥无效或者检查当前选择的模型是否可用', 8 | apiKeyInvalidStrict: 'API密钥无效', 9 | modelInvalidStrict: '模型无效或不可用', 10 | noBalance: '余额不足', 11 | noApiKey: '请先设置API密钥', 12 | noModel: '请先选择或添加模型', 13 | fetchError: '获取失败', 14 | rememberWindowSize: '保存窗口大小', 15 | customApiUrlSaveSuccess: '自定义API地址已保存', 16 | customApiUrlSaveError: '保存自定义API地址失败', 17 | customApiUrlLabel: '自定义API地址', 18 | customApiUrlPlaceholder: '输入自定义API地址(或使用默认)', 19 | apiKeyEmpty: 'API密钥不能为空', 20 | apiKeyLabel: 'API密钥', 21 | apiKeyPlaceholder: '在此输入API密钥', 22 | balanceText: '余额', 23 | customProviderNameLabel: '服务商名称', 24 | customProviderNamePlaceholder: '输入自定义服务商名称', 25 | customProviderNameExamplePlaceholder: '例如: 我的自定义服务商', 26 | customProviderApiKeyPlaceholder: '请输入API密钥', 27 | customProviderUrlExamplePlaceholder: 'https://api.example.com/v1/chat/completions', 28 | customProviderModelNameExamplePlaceholder: '例如: deepseek-chat', 29 | apiUrlHint: '📝 需使用 OpenAI 兼容的 API 接口格式', 30 | customProviderEmpty: '服务商名称不能为空', 31 | customProviderSaveSuccess: '自定义服务商已保存', 32 | customProviderSaveError: '保存自定义服务商失败', 33 | customProviderApiUrlEmpty: '自定义服务商需要API地址', 34 | customProvider: '自定义服务商', 35 | customModelNameLabel: '模型名称', 36 | customModelNamePlaceholder: '输入模型名称用于API验证', 37 | customModelNameEmpty: '模型名称不能为空', 38 | customModelIdEmpty: '模型ID不能为空', 39 | saveCustomProviderBtnText: '保存', 40 | addModel: '添加模型', 41 | addModelTitle: '添加模型', 42 | modelApiKeyLabel: 'API密钥', 43 | modelApiId: '模型API标识', 44 | modelDisplayName: '模型显示名称', 45 | modelApiIdPlaceholder: '输入模型API标识(如 deepseek-chat)', 46 | modelDisplayNamePlaceholder: '输入模型显示名称(如 DeepSeek AI)', 47 | saveModel: '保存', 48 | cancelModel: '取消', 49 | modelSaveSuccess: '模型添加成功', 50 | modelSaveError: '添加模型失败', 51 | modelValidationError: '模型验证失败,该模型可能不存在或不可用', 52 | modelApiIdEmpty: '模型API标识不能为空', 53 | modelDisplayNameEmpty: '模型显示名称不能为空', 54 | modelChanged: '模型已更改为', 55 | processing: '处理中...', 56 | toggle: '显示/隐藏API密钥', 57 | 'header-title': 'DeepSeek AI', 58 | providerLabel: '服务商', 59 | addCustomProvider: '添加服务商', 60 | apiKeyLink: '获取API密钥', 61 | modelLabel: '模型', 62 | selectionEnabledLabel: '快速按钮', 63 | preferredLanguageLabel: '首选语言', 64 | pinWindowLabel: '固定窗口', 65 | shortcutSettingsText: '快捷键设置', 66 | shortcutDescription: '请前往设置快捷键', 67 | instructionsText: '使用方式', 68 | githubText: 'GitHub', 69 | statusText: 'API服务状态', 70 | feedbackText: '反馈', 71 | deleteProvider: '删除服务商', 72 | hideProvider: '隐藏服务商', 73 | deleteProviderConfirm: '确定要删除服务商 {provider}?此操作不可撤销。', 74 | hideProviderConfirm: '确定要隐藏服务商 {provider}?您可以稍后在设置中重新启用它。', 75 | deleteProviderSuccess: '服务商删除成功', 76 | hideProviderSuccess: '服务商已隐藏', 77 | deleteProviderError: '删除服务商失败', 78 | deleteModel: '删除模型', 79 | deleteModelBtnTitle: '删除模型', 80 | deleteProviderBtnTitle: '删除服务商', 81 | confirmDeleteModel: '确定要删除模型 {model}?此操作不可撤销。', 82 | deleteModelSuccess: '模型删除成功', 83 | deleting: '删除中...', 84 | saving: '保存中...', 85 | checkModelOrKeyOrPermission: '请检查模型ID或者API Key,或确认该模型是否有权限使用', 86 | toggleChatShortcut: '通过同一快捷键可以打开/关闭会话窗口', 87 | toggleChatTip: '💡 可自定义快捷键以更快地打开/关闭对话窗口', 88 | customSystemPromptLabel: '自定义系统提示词', 89 | customSystemPromptText: '配置', 90 | customSystemPromptTitle: '自定义系统提示词', 91 | customSystemPromptInputLabel: '系统提示词', 92 | customSystemPromptPlaceholder: '输入您的自定义系统提示词。这将用于所有AI交互。', 93 | customSystemPromptHint: '此提示词将与默认语言检测提示词结合使用,用于所有AI交互,但快捷操作按钮有自己的特定提示词除外。', 94 | customSystemPromptSaveSuccess: '自定义系统提示词保存成功', 95 | customSystemPromptSaveError: '保存自定义系统提示词失败', 96 | customSystemPromptEmpty: '系统提示词不能为空' 97 | }, 98 | en: { 99 | validating: 'Validating...', 100 | saveSuccess: 'Saved successfully', 101 | apiKeyInvalid: 'API key is invalid or check if the current selected model is available', 102 | apiKeyInvalidStrict: 'API key is invalid', 103 | modelInvalidStrict: 'Model is invalid or unavailable', 104 | noBalance: 'Insufficient balance', 105 | noApiKey: 'Please set API key first', 106 | noModel: 'Please set or add a model first', 107 | fetchError: 'Failed to fetch', 108 | rememberWindowSize: 'Save Window Size', 109 | customApiUrlSaveSuccess: 'Custom API URL saved', 110 | customApiUrlSaveError: 'Failed to save custom API URL', 111 | customApiUrlLabel: 'Custom API URL', 112 | customApiUrlPlaceholder: 'Enter custom API URL (or use default)', 113 | apiKeyEmpty: 'API key cannot be empty', 114 | apiKeyLabel: 'API Key', 115 | apiKeyPlaceholder: 'Enter API Key here', 116 | balanceText: 'Balance', 117 | customProviderNameLabel: 'Provider Name', 118 | customProviderNamePlaceholder: 'Enter custom provider name', 119 | customProviderNameExamplePlaceholder: 'e.g. My Custom Provider', 120 | customProviderApiKeyPlaceholder: 'Please enter API key', 121 | customProviderUrlExamplePlaceholder: 'https://api.example.com/v1/chat/completions', 122 | customProviderModelNameExamplePlaceholder: 'e.g. deepseek-chat', 123 | apiUrlHint: '📝 Must use OpenAI compatible API format', 124 | customProviderEmpty: 'Provider name cannot be empty', 125 | customProviderSaveSuccess: 'Custom provider saved', 126 | customProviderSaveError: 'Failed to save custom provider', 127 | customProviderApiUrlEmpty: 'Custom provider needs API URL', 128 | customProvider: 'Custom Provider', 129 | customModelNameLabel: 'Model Name', 130 | customModelNamePlaceholder: 'Enter model name for API validation', 131 | customModelNameEmpty: 'Model name cannot be empty', 132 | customModelIdEmpty: 'Model ID cannot be empty', 133 | saveCustomProviderBtnText: 'Save', 134 | addModel: 'Add Model', 135 | addModelTitle: 'Add Model', 136 | modelApiKeyLabel: 'API Key', 137 | modelApiId: 'Model API ID', 138 | modelDisplayName: 'Model Display Name', 139 | modelApiIdPlaceholder: 'Enter model API ID (e.g. deepseek-chat)', 140 | modelDisplayNamePlaceholder: 'Enter model display name (e.g. DeepSeek AI)', 141 | saveModel: 'Save', 142 | cancelModel: 'Cancel', 143 | modelSaveSuccess: 'Model added successfully', 144 | modelSaveError: 'Failed to add model', 145 | modelValidationError: 'Model validation failed, this model may not exist or be unavailable', 146 | modelApiIdEmpty: 'Model API ID cannot be empty', 147 | modelDisplayNameEmpty: 'Model display name cannot be empty', 148 | modelChanged: 'Model changed to', 149 | processing: 'Processing...', 150 | toggle: 'Show/Hide API Key', 151 | 'header-title': 'DeepSeek AI', 152 | providerLabel: 'Service Provider', 153 | addCustomProvider: 'Add Provider', 154 | apiKeyLink: 'Get API Key', 155 | modelLabel: 'Model', 156 | selectionEnabledLabel: 'Quick Button', 157 | preferredLanguageLabel: 'Preferred Language', 158 | pinWindowLabel: 'Pin Window', 159 | shortcutSettingsText: 'Shortcut Settings', 160 | shortcutDescription: 'Please go to set shortcuts', 161 | instructionsText: 'Usage', 162 | githubText: 'GitHub', 163 | statusText: 'API Service Status', 164 | feedbackText: 'Feedback', 165 | deleteProvider: 'Delete Provider', 166 | hideProvider: 'Hide Provider', 167 | deleteProviderConfirm: 'Are you sure you want to delete {provider}? This action cannot be undone.', 168 | hideProviderConfirm: 'Are you sure you want to hide {provider}? You can re-enable it later in settings.', 169 | deleteProviderSuccess: 'Provider deleted successfully', 170 | hideProviderSuccess: 'Provider hidden successfully', 171 | deleteProviderError: 'Failed to delete provider', 172 | deleteModel: 'Delete Model', 173 | deleteModelBtnTitle: 'Delete Model', 174 | deleteProviderBtnTitle: 'Delete Provider', 175 | confirmDeleteModel: 'Are you sure you want to delete model {model}? This action cannot be undone.', 176 | deleteModelSuccess: 'Model deleted successfully', 177 | deleting: 'Deleting...', 178 | saving: 'Saving...', 179 | checkModelOrKeyOrPermission: 'Please check the model ID or API key, or whether the model is permitted for your account', 180 | toggleChatShortcut: 'Use the same shortcut key to open/close the chat window', 181 | toggleChatTip: '💡 You can customize shortcuts to quickly open/close the chat window', 182 | customSystemPromptLabel: 'Custom System Prompt', 183 | customSystemPromptText: 'Configure', 184 | customSystemPromptTitle: 'Custom System Prompt', 185 | customSystemPromptInputLabel: 'System Prompt', 186 | customSystemPromptPlaceholder: 'Enter your custom system prompt here. This will be used for all AI interactions.', 187 | customSystemPromptHint: 'This prompt will be combined with the default language detection prompt and used for all AI interactions, except for quick action buttons which have their own specific prompts.', 188 | customSystemPromptSaveSuccess: 'Custom system prompt saved successfully', 189 | customSystemPromptSaveError: 'Failed to save custom system prompt', 190 | customSystemPromptEmpty: 'System prompt cannot be empty' 191 | } 192 | }; 193 | } 194 | 195 | getCurrentLang() { 196 | return localStorage.getItem('preferredLang') || 'en'; 197 | } 198 | 199 | setCurrentLang(lang) { 200 | localStorage.setItem('preferredLang', lang); 201 | } 202 | 203 | getTranslation(key) { 204 | const currentLang = this.getCurrentLang(); 205 | return this.translations[currentLang][key] || key; 206 | } 207 | 208 | // 更新所有标签的文本内容 209 | updateLabels() { 210 | try { 211 | const currentLang = this.getCurrentLang(); 212 | 213 | // 更新标题 214 | this.updateElementText('header-title', 'header-title'); 215 | 216 | // 更新服务商相关标签 217 | this.updateElementText('providerLabel', 'providerLabel'); 218 | this.updateElementText('customProvider', 'addCustomProvider'); 219 | 220 | // 更新API密钥相关标签 221 | this.updateElementText('apiKeyLabelText', 'apiKeyLabel'); 222 | this.updateElementText('apiKeyLink', 'apiKeyLink'); 223 | 224 | // 更新自定义API URL相关标签 225 | this.updateElementText('customApiUrlLabel', 'customApiUrlLabel'); 226 | 227 | // 更新模型相关标签 228 | this.updateElementText('modelLabel', 'modelLabel'); 229 | 230 | // 更新其他设置标签 231 | this.updateElementText('selectionEnabledLabel', 'selectionEnabledLabel'); 232 | this.updateElementText('preferredLanguageLabel', 'preferredLanguageLabel'); 233 | this.updateElementText('rememberWindowSizeLabel', 'rememberWindowSize'); 234 | this.updateElementText('pinWindowLabel', 'pinWindowLabel'); 235 | this.updateElementText('customSystemPromptLabel', 'customSystemPromptLabel'); 236 | this.updateElementText('customSystemPromptText', 'customSystemPromptText'); 237 | 238 | // 更新快捷键设置标签 239 | this.updateElementText('shortcutSettingsText', 'shortcutSettingsText'); 240 | this.updateElementText('shortcutDescription', 'shortcutDescription'); 241 | 242 | // 更新帮助链接标签 243 | this.updateElementText('instructionsText', 'instructionsText'); 244 | this.updateElementText('githubText', 'githubText'); 245 | this.updateElementText('statusText', 'statusText'); 246 | this.updateElementText('feedbackText', 'feedbackText'); 247 | 248 | // 更新弹窗里的"获取API密钥"链接文案 249 | this.updateElementText('addModelApiKeyLink', 'apiKeyLink'); 250 | 251 | // 更新输入框占位符 252 | this.updateInputPlaceholder('apiKey', 'apiKeyPlaceholder'); 253 | 254 | // 更新模态窗口中的输入框placeholder 255 | this.updateInputPlaceholder('customProviderNameInput', 'customProviderNameExamplePlaceholder'); 256 | this.updateInputPlaceholder('customProviderApiKey', 'customProviderApiKeyPlaceholder'); 257 | this.updateInputPlaceholder('customApiUrlInput', 'customProviderUrlExamplePlaceholder'); 258 | this.updateInputPlaceholder('customModelIdInput', 'customProviderModelNameExamplePlaceholder'); 259 | this.updateInputPlaceholder('customModelNameInput', 'modelDisplayNamePlaceholder'); 260 | this.updateInputPlaceholder('modelApiKey', 'customProviderApiKeyPlaceholder'); 261 | this.updateInputPlaceholder('modelApiId', 'modelApiIdPlaceholder'); 262 | this.updateInputPlaceholder('modelDisplayName', 'modelDisplayNamePlaceholder'); 263 | this.updateInputPlaceholder('customSystemPromptInput', 'customSystemPromptPlaceholder'); 264 | 265 | // 更新弹窗标签 266 | this.updateElementText('customProviderTitle', 'addCustomProvider'); 267 | this.updateElementText('customProviderNameInputLabel', 'customProviderNameLabel'); 268 | this.updateElementText('customProviderApiKeyLabel', 'apiKeyLabel'); 269 | this.updateElementText('customApiUrlInputLabel', 'customApiUrlLabel'); 270 | this.updateElementText('apiUrlHint', 'apiUrlHint'); 271 | this.updateElementText('customModelIdInputLabel', 'modelApiId'); 272 | this.updateElementText('customModelNameInputLabel', 'customModelNameLabel'); 273 | 274 | this.updateElementText('addModelTitle', 'addModelTitle'); 275 | this.updateElementText('modelApiKeyLabel', 'modelApiKeyLabel'); 276 | this.updateElementText('modelIdLabel', 'modelApiId'); 277 | this.updateElementText('modelDisplayNameLabel', 'modelDisplayName'); 278 | 279 | this.updateElementText('deleteProviderTitle', 'deleteProvider'); 280 | this.updateElementText('deleteModelTitle', 'deleteModel'); 281 | this.updateElementText('customSystemPromptTitle', 'customSystemPromptTitle'); 282 | this.updateElementText('customSystemPromptInputLabel', 'customSystemPromptInputLabel'); 283 | this.updateElementText('customSystemPromptHint', 'customSystemPromptHint'); 284 | 285 | // 更新按钮文本 286 | this.updateElementText('saveCustomProviderButton', 'saveCustomProviderBtnText'); 287 | this.updateElementText('cancelCustomProviderButton', 'cancelModel'); 288 | this.updateElementText('saveModelButton', 'saveModel'); 289 | this.updateElementText('cancelModelButton', 'cancelModel'); 290 | this.updateElementText('confirmDeleteProviderButton', 'deleteProvider'); 291 | this.updateElementText('cancelDeleteProviderButton', 'cancelModel'); 292 | this.updateElementText('confirmDeleteModelButton', 'deleteModel'); 293 | this.updateElementText('cancelDeleteModelButton', 'cancelModel'); 294 | } catch (error) { 295 | console.error('更新标签错误:', error); 296 | } 297 | } 298 | 299 | // 更新元素文本 300 | updateElementText(elementId, translationKey) { 301 | const element = document.getElementById(elementId); 302 | if (element) { 303 | element.textContent = this.getTranslation(translationKey); 304 | } 305 | } 306 | 307 | // 更新输入框占位符 308 | updateInputPlaceholder(elementId, translationKey) { 309 | const element = document.getElementById(elementId); 310 | if (element) { 311 | // 保存当前值 312 | const currentValue = element.value; 313 | 314 | // 更新placeholder 315 | element.placeholder = this.getTranslation(translationKey); 316 | 317 | // 确保值不会被清空 318 | if (element.value !== currentValue) { 319 | element.value = currentValue; 320 | } 321 | } 322 | } 323 | } -------------------------------------------------------------------------------- /src/Instructions/Instructions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DeepSeek AI User Guide 7 | 8 | 9 | 10 | 291 | 292 | 293 | 298 | 299 |
300 |
301 |

DeepSeek AI User Guide

302 |

Let AI Assistant Enhance Your Web Browsing Experience

303 |
304 | 305 |
306 |

Quick Start

307 | 321 | 322 |
323 |
324 | 325 |
Install DeepSeek AI Extension in Your Browser
326 |
Click the Extension Icon in the Toolbar
327 |
Enter Your DeepSeek API Key
328 |
Select your preferred response language
329 |
Enable the Quick Button feature
330 |
Set your preferred shortcut keys
331 |
Select text on the webpage to start a conversation!
332 |
333 |
334 |
335 | 336 |
337 |

Features

338 |
339 |
340 |

Smart Chat

341 |

• Supports Multi-turn Dialogues

342 |

• Real-time Streaming Response

343 |

• Supports Regenerating Answers

344 |
345 |
346 |

UI Interaction

347 |

• Draggable Window

348 |

• Markdown Support

349 |

• LaTeX Math Rendering

350 |

• One-click Copy

351 |
352 |
353 |

Personalization

354 |

• Custom Language Preference

355 |

• Dark Mode Auto Adaptation

356 |

• Configurable Shortcuts

357 |
358 |
359 |
360 | 361 |
362 |

Usage Guide

363 |
364 |
365 |

Quick Button

366 |

367 | After enabling the quick button, a toolbar automatically appears when you select text, giving you instant access to AI features. 368 |

369 |
370 |
371 |

Shortcuts

372 |

373 | Customizable shortcuts that work anywhere: 374 |

375 |
376 |
377 | Activate Extension 378 |
Quickly activate DeepSeek AI
379 |
380 |
381 | Show/Hide Chat 382 |
Toggle visibility (preserves session)
383 |
384 |
385 | Open/Close Chat 386 |
Open or close (clears session)
387 |
388 |
389 |
390 | 💡 391 |

392 | Customize at 393 | chrome://extensions/shortcuts 394 | 395 |

396 |
397 |
398 |
399 |
400 | 401 |
402 |

Support

403 |
404 |

405 | If you like the DeepSeek AI extension, please rate and review it on the Chrome Web Store. We look forward to your feedback! 406 |

407 | 417 |
418 |
419 | 420 |
421 |

Privacy

422 |

423 | We value your privacy. DeepSeek AI extension will only send the text you select to API when necessary, and will not collect or store any other personal information. Your API key is only stored in the local browser. 424 |

425 |
426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 |
436 | 437 | 438 | 439 | --------------------------------------------------------------------------------