├── .nojekyll ├── CNAME ├── icons ├── icon16.png ├── icon48.png ├── icon128.png └── icon.iconset │ ├── icon_16x16.png │ ├── icon_32x32.png │ ├── icon_128x128.png │ ├── icon_16x16@2x.png │ ├── icon_256x256.png │ ├── icon_32x32@2x.png │ ├── icon_512x512.png │ ├── icon_128x128@2x.png │ ├── icon_256x256@2x.png │ └── icon_512x512@2x.png ├── .gitignore ├── statics └── image.png ├── .gitmodules ├── vercel.json ├── styles ├── components │ ├── code.css │ ├── input.css │ ├── reasoning.css │ ├── chat-container.css │ ├── sidebar.css │ ├── image-preview.css │ ├── webpage-menu.css │ ├── image-tag.css │ ├── toast.css │ ├── context-menu.css │ ├── preferences.css │ ├── message.css │ ├── api-settings.css │ ├── chat-list.css │ └── settings.css ├── base │ ├── reset.css │ └── variables.css ├── main.css └── utils │ └── animations.css ├── src ├── utils │ ├── api-url.js │ ├── theme.js │ ├── scroll.js │ ├── viewport.js │ ├── ui.js │ ├── i18n.js │ ├── image.js │ ├── reading-progress.js │ └── chat-manager.js ├── components │ ├── context-menu.js │ ├── webpage-menu.js │ ├── api-card.js │ └── chat-list.js └── services │ └── chat.js ├── PRIVACY.md ├── .github └── workflows │ └── release.yml ├── manifest.json ├── manifest.firefox.json ├── _locales ├── zh_CN │ └── messages.json ├── zh_TW │ └── messages.json └── en │ └── messages.json ├── README_CN.md ├── README.md └── index.html /.nojekyll: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | cerebr.yym68686.top -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon48.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .wrangler 3 | node_modules 4 | package-lock.json 5 | package.json -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon128.png -------------------------------------------------------------------------------- /statics/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/statics/image.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "htmd"] 2 | path = htmd 3 | url = https://github.com/yym68686/htmd.git 4 | -------------------------------------------------------------------------------- /icons/icon.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_16x16.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_32x32.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_128x128.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_256x256.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /icons/icon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yym68686/Cerebr/HEAD/icons/icon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "**/*", 6 | "use": "@vercel/static" 7 | } 8 | ], 9 | "cleanUrls": true, 10 | "trailingSlash": false 11 | } -------------------------------------------------------------------------------- /styles/components/code.css: -------------------------------------------------------------------------------- 1 | @import '../../htmd/styles/code.css'; 2 | 3 | /* 允许代码块内的文本选择 */ 4 | .ai-message pre, 5 | .ai-message code { 6 | -webkit-touch-callout: text; 7 | -webkit-user-select: text; 8 | -khtml-user-select: text; 9 | -moz-user-select: text; 10 | -ms-user-select: text; 11 | user-select: text; 12 | } 13 | 14 | /* 复用右上角语言标签区域:hover/click 提示可复制 */ 15 | .ai-message pre.code-copy-on-label::before { 16 | cursor: pointer; 17 | } 18 | 19 | .ai-message pre.code-copy-on-label:hover::before, 20 | .ai-message pre.code-copy-on-label:focus-within::before { 21 | opacity: 0.85; 22 | } 23 | -------------------------------------------------------------------------------- /styles/base/reset.css: -------------------------------------------------------------------------------- 1 | /* 基础样式 */ 2 | body { 3 | margin: 0; 4 | padding: env(safe-area-inset-top) 0 env(safe-area-inset-bottom) 0; 5 | height: 100vh; 6 | height: -webkit-fill-available; 7 | display: flex; 8 | flex-direction: column; 9 | background-color: var(--cerebr-bg-color); 10 | color: var(--cerebr-text-color); 11 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 12 | overflow: hidden; 13 | backdrop-filter: blur(var(--cerebr-blur-radius)); 14 | -webkit-backdrop-filter: blur(var(--cerebr-blur-radius)); 15 | position: fixed; 16 | top: 0; 17 | left: 0; 18 | right: 0; 19 | bottom: 0; 20 | } 21 | 22 | :focus-visible { 23 | outline: 2px solid var(--cerebr-focus-border-color); 24 | outline-offset: 2px; 25 | } 26 | -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | @import './base/variables.css'; 3 | @import './base/reset.css'; 4 | 5 | /* Utils */ 6 | @import './utils/animations.css'; 7 | 8 | /* Components */ 9 | @import './components/image-tag.css'; 10 | @import './components/settings.css'; 11 | @import './components/reasoning.css'; 12 | @import './components/chat-list.css'; 13 | @import './components/message.css'; 14 | @import './components/code.css'; 15 | @import './components/sidebar.css'; 16 | @import './components/context-menu.css'; 17 | @import './components/input.css'; 18 | @import './components/image-preview.css'; 19 | @import './components/api-settings.css'; 20 | @import './components/preferences.css'; 21 | @import './components/webpage-menu.css'; 22 | @import './components/chat-container.css'; 23 | @import './components/toast.css'; 24 | 25 | @import '../htmd/styles/math.css'; 26 | @import '../htmd/styles/table.css'; 27 | -------------------------------------------------------------------------------- /src/utils/api-url.js: -------------------------------------------------------------------------------- 1 | export function normalizeChatCompletionsUrl(value) { 2 | if (typeof value !== 'string') return ''; 3 | const raw = value.trim(); 4 | if (!raw) return ''; 5 | 6 | const normalizePath = (pathname) => { 7 | const withoutTrailingSlash = (pathname || '/').replace(/\/+$/, ''); 8 | const base = withoutTrailingSlash === '/' ? '' : withoutTrailingSlash; 9 | if (base.endsWith('/chat/completions')) return base || '/chat/completions'; 10 | if (base.endsWith('/v1')) return `${base}/chat/completions`; 11 | 12 | const hasV1Segment = /(^|\/)v1(\/|$)/.test((base || '/') + '/'); 13 | if (!hasV1Segment) return `${base}/v1/chat/completions` || '/v1/chat/completions'; 14 | 15 | return base || '/'; 16 | }; 17 | 18 | try { 19 | const url = new URL(raw); 20 | url.pathname = normalizePath(url.pathname); 21 | return url.toString(); 22 | } catch { 23 | const withoutTrailingSlash = raw.replace(/\/+$/, ''); 24 | if (withoutTrailingSlash.endsWith('/chat/completions')) return withoutTrailingSlash; 25 | if (withoutTrailingSlash.endsWith('/v1')) return `${withoutTrailingSlash}/chat/completions`; 26 | if (!/\/v1(\/|$)/.test(withoutTrailingSlash)) return `${withoutTrailingSlash}/v1/chat/completions`; 27 | return withoutTrailingSlash; 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/utils/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 设置主题的工具函数 3 | * @param {boolean} isDark - 是否为深色主题 4 | * @param {Object} config - 配置对象 5 | * @param {HTMLElement} config.root - 根元素(通常是document.documentElement) 6 | * @param {HTMLInputElement} config.themeSwitch - 主题切换开关元素 7 | * @param {Function} config.saveTheme - 保存主题的回调函数 8 | */ 9 | export function setTheme(isDark, { root, themeSwitch, saveTheme }) { 10 | // 移除现有的主题类 11 | root.classList.remove('dark-theme', 'light-theme'); 12 | 13 | // 添加新的主题类 14 | root.classList.add(isDark ? 'dark-theme' : 'light-theme'); 15 | 16 | // 更新开关状态 17 | if (themeSwitch) { 18 | themeSwitch.checked = isDark; 19 | } 20 | 21 | // 保存主题设置 22 | if (saveTheme) { 23 | saveTheme(isDark ? 'dark' : 'light'); 24 | } 25 | 26 | // 更新 Mermaid 主题并重新渲染 27 | if (window.mermaid) { 28 | window.mermaid.initialize({ 29 | theme: isDark ? 'dark' : 'default' 30 | }); 31 | 32 | // 重新渲染所有图表 33 | if (window.renderMermaidDiagrams) { 34 | window.renderMermaidDiagrams(); 35 | } 36 | } 37 | 38 | // // 更新浏览器 UI 颜色 39 | updateThemeColor(isDark); 40 | } 41 | 42 | // 更新主题颜色 43 | function updateThemeColor(isDark) { 44 | const themeColorMeta = document.getElementById('theme-color-meta'); 45 | if (themeColorMeta) { 46 | themeColorMeta.content = isDark ? '#262B33' : '#ffffff'; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /styles/components/input.css: -------------------------------------------------------------------------------- 1 | /* 输入区域样式 */ 2 | #input-container { 3 | padding: 0; 4 | background-color: var(--cerebr-input-bg); 5 | display: flex; 6 | align-items: flex-start; 7 | position: fixed; 8 | bottom: 0; 9 | left: 0; 10 | right: 0; 11 | flex-shrink: 0; 12 | padding-bottom: env(safe-area-inset-bottom); 13 | z-index: 100; 14 | min-height: 48px; 15 | } 16 | 17 | #message-input { 18 | flex: 1; 19 | padding: 12px; 20 | border: none; 21 | background-color: transparent; 22 | color: var(--cerebr-text-color); 23 | font-size: var(--cerebr-fs-14); 24 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 25 | outline: none; 26 | resize: none; 27 | box-sizing: border-box; 28 | min-height: 24px; 29 | max-height: 200px; 30 | line-height: 1.5; 31 | white-space: pre-wrap; 32 | word-break: break-word; 33 | overflow-wrap: break-word; 34 | overflow-y: auto; 35 | } 36 | 37 | #message-input:empty::before { 38 | content: attr(placeholder); 39 | color: var(--cerebr-text-color); 40 | opacity: 0.5; 41 | cursor: text; 42 | font-family: 'Menlo', 'Monaco', 'Courier New', monospace; 43 | } 44 | 45 | #message-input:focus { 46 | outline: none; 47 | } 48 | 49 | #message-input br { 50 | display: none; 51 | } 52 | 53 | #message-input br:first-child { 54 | display: block; 55 | } 56 | -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Cerebr 隐私权政策 2 | 3 | *最后更新日期:2024年2月* 4 | 5 | ## 1. 引言 6 | 7 | 欢迎使用 Cerebr("我们","我们的"或"本扩展")。本隐私权政策旨在说明我们如何收集、使用、存储和保护您的信息。使用我们的服务即表示您同意本隐私权政策中描述的数据处理程序。 8 | 9 | ## 2. 信息收集 10 | 11 | ### 2.1 主动提供的信息 12 | - API密钥和配置信息 13 | - 用户偏好设置(如界面主题、快捷键设置等) 14 | - 聊天记录和对话内容 15 | 16 | ### 2.2 自动收集的信息 17 | - 基本的使用统计信息 18 | - 错误日志(用于改进服务质量) 19 | - 当前访问的网页URL(仅在您主动使用网页问答功能时) 20 | 21 | ## 3. 信息使用 22 | 23 | 我们收集的信息将用于: 24 | - 提供和改进我们的AI助手服务 25 | - 保存您的个性化设置 26 | - 维护和优化扩展程序性能 27 | - 排查技术问题 28 | - 改进用户体验 29 | 30 | ## 4. 数据存储 31 | 32 | ### 4.1 本地存储 33 | - 所有用户数据主要存储在您的本地浏览器中 34 | - API密钥和配置信息使用Chrome的安全存储机制 35 | - 聊天历史记录保存在本地存储中 36 | 37 | ### 4.2 第三方服务 38 | - 当您使用AI功能时,您的查询将被发送到您配置的AI服务提供商(如OpenAI、Anthropic等) 39 | - 我们不会存储或转发您的API密钥到除您指定的服务提供商之外的任何第三方 40 | 41 | ## 5. 数据安全 42 | 43 | 我们采取以下措施保护您的数据: 44 | - 所有敏感数据(如API密钥)均使用安全的加密存储 45 | - 仅在必要时访问网页内容 46 | - 不收集或存储任何个人身份信息 47 | - 不与第三方共享用户数据 48 | 49 | ## 6. 用户权利 50 | 51 | 您拥有以下权利: 52 | - 随时查看和删除您的聊天历史 53 | - 清除所有存储的数据 54 | - 修改或删除您的API配置 55 | - 选择是否启用特定功能 56 | 57 | ## 7. 数据删除 58 | 59 | 您可以通过以下方式删除您的数据: 60 | - 使用扩展程序内的"清除数据"功能 61 | - 在Chrome扩展管理页面中删除扩展程序 62 | - 使用快捷键(Windows: Alt+X / Mac: Ctrl+X)清空聊天记录 63 | 64 | ## 8. 政策更新 65 | 66 | 我们可能会不时更新本隐私权政策。当我们进行重大更改时,我们将通过扩展程序通知您。继续使用我们的服务即表示您接受更新后的政策。 67 | 68 | ## 9. 联系我们 69 | 70 | 如果您对本隐私权政策有任何问题或建议,请通过以下方式联系我们: 71 | - GitHub Issues: https://github.com/yym68686/Cerebr/issues 72 | - 电子邮件:yym68686@outlook.com 73 | 74 | ## 10. 合规性声明 75 | 76 | 本扩展程序遵守: 77 | - Chrome网上应用店开发者计划政策 78 | - 通用数据保护条例(GDPR) 79 | - 加州消费者隐私法案(CCPA) -------------------------------------------------------------------------------- /styles/components/reasoning.css: -------------------------------------------------------------------------------- 1 | /* 深度思考模块样式 */ 2 | .reasoning-wrapper { 3 | margin: 0 0 12px 0; 4 | padding: 0; 5 | border: none; 6 | } 7 | 8 | .reasoning-toggle { 9 | cursor: pointer; 10 | background: transparent; 11 | color: var(--cerebr-reasoning-text-color); 12 | font-size: 0.9em; 13 | padding: 6px 12px; 14 | border: none; 15 | border-radius: 4px; 16 | user-select: none; 17 | transition: all 0.2s ease; 18 | width: auto; 19 | display: inline-block; 20 | } 21 | 22 | .reasoning-toggle:hover { 23 | background: var(--cerebr-toggle-hover-bg); 24 | color: #333; 25 | } 26 | 27 | .reasoning-content { 28 | cursor: pointer; 29 | background-color: var(--cerebr-reasoning-bg); 30 | border-radius: 6px; 31 | font-size: 0.95em; 32 | color: var(--cerebr-reasoning-text-color); 33 | line-height: 1.5; 34 | overflow: hidden; 35 | transition: all 0.2s ease; 36 | padding: 8px 12px; 37 | } 38 | 39 | .reasoning-content:hover { 40 | background-color: var(--cerebr-reasoning-hover-bg); 41 | } 42 | 43 | .reasoning-placeholder { 44 | display: none; 45 | font-size: 0.9em; 46 | color: var(--cerebr-reasoning-text-color); 47 | } 48 | 49 | .reasoning-text { 50 | word-break: break-word; 51 | transition: all 0.2s ease; 52 | } 53 | 54 | /* 折叠状态 */ 55 | .reasoning-content.collapsed { 56 | padding: 6px 12px; 57 | } 58 | 59 | .reasoning-content.collapsed .reasoning-placeholder { 60 | display: block; 61 | } 62 | 63 | .reasoning-content.collapsed .reasoning-text { 64 | display: none; 65 | } -------------------------------------------------------------------------------- /styles/components/chat-container.css: -------------------------------------------------------------------------------- 1 | /* 聊天容器样式 */ 2 | #chat-container { 3 | flex: 1; 4 | overflow-y: scroll; 5 | padding: 15px; 6 | padding-top: calc(15px + env(safe-area-inset-top)); 7 | padding-bottom: calc(60px + env(safe-area-inset-bottom) + var(--chat-bottom-extra-padding, 0px)); 8 | scrollbar-width: none; 9 | -ms-overflow-style: none; 10 | min-height: 100%; 11 | -webkit-overflow-scrolling: touch; 12 | transform: translateZ(0); 13 | scroll-behavior: auto; 14 | overscroll-behavior-y: contain; 15 | position: relative; 16 | height: 100%; 17 | box-sizing: border-box; 18 | margin-top: var(--chat-top-margin, 0px); 19 | transition: margin-top 0.3s ease; 20 | } 21 | 22 | #chat-container::-webkit-scrollbar { 23 | display: none; 24 | } 25 | 26 | .keyboard-visible #chat-container { 27 | padding-bottom: calc(60px + env(safe-area-inset-bottom) + var(--keyboard-height, 0px) + var(--chat-bottom-extra-padding, 0px)); 28 | } 29 | 30 | .chat-switch-placeholder { 31 | height: 100%; 32 | min-height: 160px; 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | gap: 10px; 38 | color: var(--cerebr-text-color-secondary); 39 | user-select: none; 40 | } 41 | 42 | .chat-switch-spinner { 43 | width: 18px; 44 | height: 18px; 45 | border: 2px solid transparent; 46 | border-top-color: currentColor; 47 | border-radius: 50%; 48 | animation: loading-spinner 0.8s linear infinite; 49 | } 50 | 51 | .chat-switch-text { 52 | font-size: var(--cerebr-fs-13); 53 | } 54 | -------------------------------------------------------------------------------- /styles/components/sidebar.css: -------------------------------------------------------------------------------- 1 | /* 侧边栏基础样式 */ 2 | .cerebr-sidebar { 3 | position: fixed; 4 | top: 20px; 5 | right: -450px; 6 | width: 430px; 7 | height: calc(100vh - 40px); 8 | background: var(--cerebr-bg-color); 9 | color: var(--cerebr-text-color); 10 | box-shadow: none; 11 | z-index: 2147483647; 12 | border-radius: 12px; 13 | margin-right: 20px; 14 | overflow: hidden; 15 | visibility: hidden; 16 | transform: translateX(0); 17 | pointer-events: none; 18 | contain: style layout size; 19 | isolation: isolate; 20 | } 21 | 22 | .cerebr-sidebar.initialized { 23 | visibility: visible; 24 | transition: transform 0.3s ease, box-shadow 0.3s ease; 25 | pointer-events: auto; 26 | } 27 | 28 | .cerebr-sidebar.visible { 29 | transform: translateX(-450px); 30 | box-shadow: var(--cerebr-sidebar-box-shadow); 31 | } 32 | 33 | /* 侧边栏组件样式 */ 34 | .cerebr-sidebar__header { 35 | height: 40px; 36 | background: #f5f5f5; 37 | border-bottom: 1px solid #ddd; 38 | display: flex; 39 | align-items: center; 40 | padding: 0 15px; 41 | } 42 | 43 | .cerebr-sidebar__resizer { 44 | position: absolute; 45 | left: 0; 46 | top: 0; 47 | width: 5px; 48 | height: 100%; 49 | cursor: ew-resize; 50 | } 51 | 52 | .cerebr-sidebar__content { 53 | height: 100%; 54 | overflow: hidden; 55 | border-radius: 12px; 56 | contain: style layout size; 57 | } 58 | 59 | .cerebr-sidebar__iframe { 60 | width: 100%; 61 | height: 100%; 62 | border: none; 63 | background: var(--cerebr-bg-color); 64 | contain: strict; 65 | } 66 | -------------------------------------------------------------------------------- /styles/components/image-preview.css: -------------------------------------------------------------------------------- 1 | /* 图片预览模态框样式 */ 2 | .image-preview-modal { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background: var(--cerebr-modal-overlay-bg); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | z-index: 10000; 13 | opacity: 0; 14 | visibility: hidden; 15 | transition: opacity 0.3s ease; 16 | backdrop-filter: blur(20px); 17 | -webkit-backdrop-filter: blur(20px); 18 | } 19 | 20 | .image-preview-modal.visible { 21 | opacity: 1; 22 | visibility: visible; 23 | } 24 | 25 | .image-preview-content { 26 | max-width: 90%; 27 | max-height: 90%; 28 | position: relative; 29 | border-radius: 8px; 30 | overflow: hidden; 31 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 32 | } 33 | 34 | .image-preview-content img { 35 | max-width: 100%; 36 | max-height: 90vh; 37 | display: block; 38 | } 39 | 40 | .image-preview-close { 41 | position: absolute; 42 | top: 16px; 43 | right: 16px; 44 | width: 32px; 45 | height: 32px; 46 | background: var(--cerebr-close-button-bg); 47 | border: none; 48 | border-radius: 50%; 49 | color: white; 50 | cursor: pointer; 51 | display: flex; 52 | align-items: center; 53 | justify-content: center; 54 | transition: background-color 0.2s ease; 55 | } 56 | 57 | .image-preview-close:hover { 58 | background: var(--cerebr-modal-overlay-bg); 59 | } 60 | 61 | .image-preview-close svg { 62 | width: 20px; 63 | height: 20px; 64 | stroke: currentColor; 65 | stroke-width: 2; 66 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Extension 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' # 当推送新的版本标签时触发 8 | 9 | jobs: 10 | build-and-release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | submodules: 'recursive' 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '16' 21 | 22 | - name: Create Chrome ZIP file 23 | run: | 24 | zip -r cerebr.zip . \ 25 | -x "*.git*" \ 26 | -x "*.github*" \ 27 | -x "*.DS_Store" \ 28 | -x "README*" \ 29 | -x "manifest.firefox.json" 30 | 31 | - name: Create Firefox ZIP file 32 | run: | 33 | mv manifest.json manifest.json.bak 34 | cp manifest.firefox.json manifest.json 35 | zip -r cerebr-firefox.zip . \ 36 | -x "*.git*" \ 37 | -x "*.github*" \ 38 | -x "*.DS_Store" \ 39 | -x "README*" \ 40 | -x "manifest.firefox.json" \ 41 | -x "manifest.json.bak" \ 42 | -x "cerebr.zip" 43 | # Restore original manifest 44 | mv manifest.json.bak manifest.json 45 | 46 | - name: Create Release 47 | id: create_release 48 | uses: softprops/action-gh-release@v1 49 | with: 50 | files: | 51 | cerebr.zip 52 | cerebr-firefox.zip 53 | draft: false 54 | prerelease: false 55 | generate_release_notes: true 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /styles/components/webpage-menu.css: -------------------------------------------------------------------------------- 1 | #webpage-content-menu { 2 | position: fixed; 3 | min-width: 180px; 4 | max-width: 200px; 5 | background-color: var(--settings-bg-color); 6 | border: 1px solid var(--settings-border-color); 7 | border-radius: 8px; 8 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 9 | padding: 8px; 10 | z-index: 1001; 11 | display: none; 12 | backdrop-filter: var(--cerebr-surface-backdrop-filter); 13 | -webkit-backdrop-filter: var(--cerebr-surface-backdrop-filter); 14 | max-height: 300px; 15 | overflow-y: auto; 16 | } 17 | 18 | #webpage-content-menu.visible { 19 | display: block; 20 | } 21 | 22 | .webpage-menu-loading, 23 | .webpage-menu-empty { 24 | padding: 10px 8px; 25 | color: var(--cerebr-text-color-secondary); 26 | font-size: var(--cerebr-fs-13); 27 | } 28 | 29 | .webpage-menu-item { 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | padding: 8px; 34 | border-radius: 6px; 35 | cursor: pointer; 36 | transition: background-color 0.2s; 37 | -webkit-user-select: none; 38 | user-select: none; 39 | } 40 | 41 | .webpage-menu-item:hover { 42 | background-color: var(--settings-hover-bg-color); 43 | } 44 | 45 | .webpage-menu-item .title { 46 | flex: 1; 47 | white-space: nowrap; 48 | overflow: hidden; 49 | text-overflow: ellipsis; 50 | margin-right: 16px; /* 增加标题和开关之间的间距 */ 51 | min-width: 0; /* 确保在 flex 布局中 ellipsis 生效 */ 52 | } 53 | 54 | .webpage-menu-item .switch { 55 | flex-shrink: 0; /* 防止开关被压缩 */ 56 | } 57 | 58 | .webpage-menu-item .favicon { 59 | width: 16px; 60 | height: 16px; 61 | margin-right: 8px; 62 | vertical-align: middle; 63 | } 64 | -------------------------------------------------------------------------------- /styles/components/image-tag.css: -------------------------------------------------------------------------------- 1 | /* 图片组件样式 */ 2 | .image-tag { 3 | display: inline-flex; 4 | align-items: center; 5 | background: var(--cerebr-image-tag-bg); 6 | border: 1px solid var(--cerebr-image-tag-border-color); 7 | border-radius: 6px; 8 | padding: 4px 6px; 9 | margin: 0 4px; 10 | height: 24px; 11 | cursor: pointer; 12 | user-select: none; 13 | vertical-align: middle; 14 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 15 | gap: 6px; 16 | line-height: 1; 17 | box-sizing: border-box; 18 | box-shadow: 0 1px 2px var(--cerebr-image-tag-shadow); 19 | } 20 | 21 | .image-tag:hover { 22 | background: var(--cerebr-image-tag-hover-bg); 23 | transform: translateY(-1px); 24 | box-shadow: 0 2px 4px var(--cerebr-button-hover-bg); 25 | } 26 | 27 | .image-tag:active { 28 | transform: translateY(0); 29 | box-shadow: 0 1px 2px var(--cerebr-image-tag-shadow); 30 | } 31 | 32 | .image-tag img { 33 | width: 16px; 34 | height: 16px; 35 | object-fit: cover; 36 | border-radius: 4px; 37 | margin: 0; 38 | } 39 | 40 | .image-tag .delete-btn { 41 | width: 16px; 42 | height: 16px; 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | border: none; 47 | background: none; 48 | padding: 0; 49 | margin: 0; 50 | cursor: pointer; 51 | color: var(--cerebr-text-color); 52 | opacity: 0.6; 53 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 54 | } 55 | 56 | .image-tag .delete-btn:hover { 57 | opacity: 1; 58 | transform: scale(1.1); 59 | } 60 | 61 | .image-tag .delete-btn:active { 62 | transform: scale(0.95); 63 | } 64 | 65 | .image-tag .delete-btn svg { 66 | width: 12px; 67 | height: 12px; 68 | stroke: currentColor; 69 | stroke-width: 2; 70 | } -------------------------------------------------------------------------------- /styles/components/toast.css: -------------------------------------------------------------------------------- 1 | .toast-container { 2 | position: fixed; 3 | left: 50%; 4 | bottom: calc(64px + env(safe-area-inset-bottom)); 5 | transform: translateX(-50%); 6 | z-index: 2147483647; 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | gap: 8px; 11 | pointer-events: none; 12 | width: min(520px, calc(100vw - 24px)); 13 | } 14 | 15 | .toast { 16 | width: fit-content; 17 | max-width: 100%; 18 | padding: 10px 12px; 19 | border-radius: 12px; 20 | background: var(--cerebr-toast-bg); 21 | color: var(--cerebr-toast-text); 22 | font-size: var(--cerebr-fs-13); 23 | line-height: 1.35; 24 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.22); 25 | opacity: 0; 26 | transform: translateY(8px) scale(0.98); 27 | animation: toastIn 0.16s ease-out forwards; 28 | text-align: center; 29 | backdrop-filter: blur(var(--cerebr-blur-radius)); 30 | -webkit-backdrop-filter: blur(var(--cerebr-blur-radius)); 31 | } 32 | 33 | .toast--success { 34 | background: var(--cerebr-toast-success-bg); 35 | } 36 | 37 | .toast--error { 38 | background: var(--cerebr-toast-error-bg); 39 | } 40 | 41 | .toast--hide { 42 | animation: toastOut 0.16s ease-in forwards; 43 | } 44 | 45 | @keyframes toastIn { 46 | from { 47 | opacity: 0; 48 | transform: translateY(10px) scale(0.98); 49 | } 50 | to { 51 | opacity: 1; 52 | transform: translateY(0) scale(1); 53 | } 54 | } 55 | 56 | @keyframes toastOut { 57 | from { 58 | opacity: 1; 59 | transform: translateY(0) scale(1); 60 | } 61 | to { 62 | opacity: 0; 63 | transform: translateY(6px) scale(0.98); 64 | } 65 | } 66 | 67 | @media (prefers-reduced-motion: reduce) { 68 | .toast, 69 | .toast--hide { 70 | animation: none; 71 | opacity: 1; 72 | transform: none; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /styles/utils/animations.css: -------------------------------------------------------------------------------- 1 | /* 动画 */ 2 | @keyframes messageAppear { 3 | 0% { 4 | opacity: 0; 5 | transform: translateY(16px) scale(0.98); 6 | } 7 | 40% { 8 | opacity: 1; 9 | } 10 | 100% { 11 | opacity: 1; 12 | transform: translateY(0) scale(1); 13 | } 14 | } 15 | 16 | @keyframes menuAppear { 17 | from { 18 | opacity: 0; 19 | transform: translateY(4px); 20 | } 21 | to { 22 | opacity: 1; 23 | transform: translateY(0); 24 | } 25 | } 26 | 27 | /* 加载状态样式 */ 28 | .loading-content #webpage-switch:checked { 29 | opacity: 0; 30 | } 31 | 32 | .loading-content #webpage-switch:checked + .slider { 33 | background-color: var(--cerebr-toggle-bg-off); 34 | } 35 | 36 | .loading-content #webpage-switch:checked + .slider:before { 37 | display: none; 38 | } 39 | 40 | .loading-content #webpage-switch:checked + .slider:after { 41 | content: ''; 42 | position: absolute; 43 | width: 16px; 44 | height: 16px; 45 | border: 2px solid transparent; 46 | border-top-color: var(--cerebr-text-color); 47 | border-radius: 50%; 48 | animation: loading-spinner 0.8s linear infinite; 49 | left: 50%; 50 | top: 50%; 51 | transform: translate(-50%, -50%); 52 | } 53 | 54 | @keyframes loading-spinner { 55 | 0% { transform: translate(-50%, -50%) rotate(0deg); } 56 | 100% { transform: translate(-50%, -50%) rotate(360deg); } 57 | } 58 | 59 | /* 确保加载状态下的UI响应性 */ 60 | .loading-content { 61 | pointer-events: auto !important; 62 | } 63 | 64 | .loading-content #webpage-switch { 65 | pointer-events: none; 66 | } 67 | 68 | .loading-content #chat-container, 69 | .loading-content #input-container, 70 | .loading-content #settings-button, 71 | .loading-content #settings-menu, 72 | .loading-content .menu-item:not(#webpage-qa) { 73 | pointer-events: auto !important; 74 | } 75 | 76 | /* 加载状态下的菜单项样式 */ 77 | .loading-content #webpage-qa { 78 | opacity: 0.7; 79 | cursor: wait; 80 | } 81 | 82 | /* 深色模式适配加载动画 */ 83 | @media (prefers-color-scheme: dark) { 84 | .loading-content #webpage-switch:checked + .slider:after { 85 | border-top-color: var(--cerebr-text-color); 86 | } 87 | } -------------------------------------------------------------------------------- /styles/components/context-menu.css: -------------------------------------------------------------------------------- 1 | /* 右键菜单样式 */ 2 | #context-menu { 3 | position: fixed; 4 | background: var(--cerebr-surface-bg); 5 | border-radius: 8px; 6 | padding: 6px; 7 | min-width: 140px; 8 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 9 | z-index: 2147483647; 10 | display: none; 11 | backdrop-filter: var(--cerebr-surface-backdrop-filter); 12 | -webkit-backdrop-filter: var(--cerebr-surface-backdrop-filter); 13 | border: 1px solid var(--cerebr-surface-border); 14 | touch-action: none; 15 | -webkit-user-select: none; 16 | user-select: none; 17 | } 18 | 19 | #context-menu.visible { 20 | display: block; 21 | animation: menuAppear 0.14s ease-out; 22 | transform-origin: top left; 23 | } 24 | 25 | .context-menu-item { 26 | padding: 8px 12px; 27 | cursor: pointer; 28 | display: flex; 29 | align-items: center; 30 | gap: 8px; 31 | color: var(--cerebr-text-color); 32 | font-size: var(--cerebr-fs-13); 33 | border-radius: 6px; 34 | margin: 2px 0; 35 | transition: background-color 0.2s ease; 36 | white-space: nowrap; 37 | -webkit-user-select: none; 38 | user-select: none; 39 | } 40 | 41 | .context-menu-item:hover { 42 | background-color: var(--cerebr-message-user-bg); 43 | } 44 | 45 | .context-menu-item:focus-visible { 46 | outline: none; 47 | background-color: var(--cerebr-message-user-bg); 48 | box-shadow: 0 0 0 2px var(--cerebr-focus-border-color); 49 | } 50 | 51 | .context-menu-item svg { 52 | width: 14px; 53 | height: 14px; 54 | fill: none; 55 | stroke: currentColor; 56 | stroke-width: 2; 57 | flex-shrink: 0; 58 | } 59 | 60 | #stop-update svg { 61 | fill: currentColor; 62 | stroke: none; 63 | } 64 | 65 | #stop-update:hover { 66 | background-color: var(--cerebr-message-user-bg); 67 | color: #ff4d4d; 68 | } 69 | 70 | #delete-message:hover { 71 | background-color: var(--cerebr-message-user-bg); 72 | color: #ff4d4d; 73 | } 74 | 75 | #regenerate-message:hover { 76 | background-color: var(--cerebr-message-user-bg); 77 | } 78 | 79 | #regenerate-message svg { 80 | fill: currentColor; 81 | stroke: none; 82 | } 83 | 84 | @media (prefers-reduced-motion: reduce) { 85 | #context-menu.visible { 86 | animation: none; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/context-menu.js: -------------------------------------------------------------------------------- 1 | // 显示上下文菜单 2 | export function showContextMenu({ 3 | event, // 事件对象 4 | messageElement, // 消息元素 5 | contextMenu, // 右键菜单元素 6 | stopUpdateButton, // 停止更新按钮元素 7 | onMessageElementSelect, // 消息元素选择回调 8 | windowDimensions = { // 窗口尺寸(可选) 9 | width: window.innerWidth, 10 | height: window.innerHeight 11 | } 12 | }) { 13 | event.preventDefault(); 14 | 15 | // 记录打开菜单前的焦点,便于关闭时恢复 16 | try { 17 | contextMenu.__cerebrReturnFocusEl = document.activeElement; 18 | } catch { 19 | // ignore 20 | } 21 | 22 | // 调用消息元素选择回调 23 | if (onMessageElementSelect) { 24 | onMessageElementSelect(messageElement); 25 | } 26 | 27 | // 清理旧的内联 display(兼容旧版本) 28 | contextMenu.style.display = ''; 29 | // 设置菜单可见 30 | contextMenu.classList.add('visible'); 31 | 32 | // 根据消息状态显示或隐藏停止更新按钮 33 | if (messageElement.classList.contains('updating')) { 34 | stopUpdateButton.style.display = 'flex'; 35 | } else { 36 | stopUpdateButton.style.display = 'none'; 37 | } 38 | 39 | const menuWidth = contextMenu.offsetWidth; 40 | const menuHeight = contextMenu.offsetHeight; 41 | 42 | // 确保菜单不超出视口 43 | let x = event.clientX; 44 | let y = event.clientY; 45 | 46 | if (x + menuWidth > windowDimensions.width) { 47 | x = windowDimensions.width - menuWidth; 48 | } 49 | 50 | if (y + menuHeight > windowDimensions.height) { 51 | y = windowDimensions.height - menuHeight; 52 | } 53 | 54 | contextMenu.style.left = x + 'px'; 55 | contextMenu.style.top = y + 'px'; 56 | } 57 | 58 | // 隐藏上下文菜单 59 | export function hideContextMenu({ contextMenu, onMessageElementReset }) { 60 | contextMenu.classList.remove('visible'); 61 | if (onMessageElementReset) { 62 | onMessageElementReset(); 63 | } 64 | 65 | const returnFocusEl = contextMenu.__cerebrReturnFocusEl; 66 | contextMenu.__cerebrReturnFocusEl = null; 67 | if (returnFocusEl?.isConnected) { 68 | returnFocusEl.focus?.({ preventScroll: true }); 69 | } 70 | } 71 | 72 | // 复制消息内容 73 | export function copyMessageContent({ messageElement, onSuccess, onError }) { 74 | if (messageElement) { 75 | // 获取存储的原始文本 76 | const originalText = messageElement.getAttribute('data-original-text'); 77 | navigator.clipboard.writeText(originalText) 78 | .then(onSuccess) 79 | .catch(onError); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/scroll.js: -------------------------------------------------------------------------------- 1 | function getInputOverlapPx(chatContainer) { 2 | const inputContainer = document.getElementById('input-container'); 3 | if (!inputContainer) return 0; 4 | 5 | const containerRect = chatContainer.getBoundingClientRect(); 6 | const inputRect = inputContainer.getBoundingClientRect(); 7 | return Math.max(0, containerRect.bottom - inputRect.top); 8 | } 9 | 10 | /** 11 | * 确保某个聊天消息在可视区域内(考虑底部固定输入栏遮挡)。 12 | * @param {Object} params 13 | * @param {HTMLElement} params.chatContainer 14 | * @param {HTMLElement} params.element 15 | * @param {ScrollBehavior} [params.behavior='auto'] 16 | * @param {number} [params.marginPx=12] 17 | */ 18 | export function ensureChatElementVisible({ 19 | chatContainer, 20 | element, 21 | behavior = 'auto', 22 | marginPx = 12 23 | }) { 24 | if (!chatContainer || !element) return; 25 | 26 | requestAnimationFrame(() => { 27 | if (!element.isConnected || !chatContainer.isConnected || !chatContainer.contains(element)) return; 28 | const containerRect = chatContainer.getBoundingClientRect(); 29 | const elementRect = element.getBoundingClientRect(); 30 | const bottomOffsetPx = getInputOverlapPx(chatContainer); 31 | 32 | const visibleTop = containerRect.top + marginPx; 33 | const visibleBottom = containerRect.bottom - bottomOffsetPx - marginPx; 34 | 35 | if (elementRect.bottom > visibleBottom + 1) { 36 | const delta = elementRect.bottom - visibleBottom; 37 | chatContainer.scrollTo({ 38 | top: chatContainer.scrollTop + delta, 39 | behavior 40 | }); 41 | return; 42 | } 43 | 44 | if (elementRect.top < visibleTop - 1) { 45 | const delta = visibleTop - elementRect.top; 46 | chatContainer.scrollTo({ 47 | top: chatContainer.scrollTop - delta, 48 | behavior 49 | }); 50 | } 51 | }); 52 | } 53 | 54 | /** 55 | * 让聊天容器的底部 padding 能随输入栏高度变化而补足,避免消息被遮挡。 56 | * @param {Object} [params] 57 | * @param {number} [params.basePaddingPx=60] - 与 `#chat-container` 的默认 padding-bottom 对齐 58 | */ 59 | export function syncChatBottomExtraPadding({ basePaddingPx = 60 } = {}) { 60 | const inputContainer = document.getElementById('input-container'); 61 | if (!inputContainer) return; 62 | 63 | const inputHeight = inputContainer.getBoundingClientRect().height; 64 | const extraPadding = Math.max(0, Math.ceil(inputHeight - basePaddingPx)); 65 | document.documentElement.style.setProperty('--chat-bottom-extra-padding', `${extraPadding}px`); 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/viewport.js: -------------------------------------------------------------------------------- 1 | // 用于存储原始视口高度 2 | let originalViewportHeight = window.innerHeight; 3 | 4 | // 设置视口高度变量 5 | function setViewportHeight() { 6 | // 获取实际视口高度 7 | const vh = window.innerHeight * 0.01; 8 | // 设置CSS变量 9 | document.documentElement.style.setProperty('--vh', `${vh}px`); 10 | 11 | // 计算输入法是否弹出 12 | const isKeyboardVisible = window.innerHeight < originalViewportHeight * 0.8; 13 | 14 | if (isKeyboardVisible) { 15 | // 输入法弹出时,调整聊天容器的高度和上边距 16 | const keyboardHeight = originalViewportHeight - window.innerHeight; 17 | document.documentElement.style.setProperty('--keyboard-height', `${keyboardHeight}px`); 18 | document.documentElement.style.setProperty('--chat-top-margin', `${keyboardHeight}px`); 19 | document.body.classList.add('keyboard-visible'); 20 | } else { 21 | document.documentElement.style.setProperty('--keyboard-height', '0px'); 22 | document.documentElement.style.setProperty('--chat-top-margin', '0px'); 23 | document.body.classList.remove('keyboard-visible'); 24 | // 更新原始视口高度 25 | originalViewportHeight = window.innerHeight; 26 | } 27 | } 28 | 29 | // 初始设置 30 | setViewportHeight(); 31 | 32 | // 监听视口大小变化(包括输入法弹出) 33 | let resizeTimeout; 34 | window.addEventListener('resize', () => { 35 | // 使用防抖来优化性能 36 | clearTimeout(resizeTimeout); 37 | resizeTimeout = setTimeout(() => { 38 | setViewportHeight(); 39 | 40 | // // 获取聊天容器 41 | // const chatContainer = document.getElementById('chat-container'); 42 | // if (chatContainer) { 43 | // // 重新计算滚动位置 44 | // const scrollPosition = chatContainer.scrollTop; 45 | // const scrollHeight = chatContainer.scrollHeight; 46 | // const clientHeight = chatContainer.clientHeight; 47 | 48 | // // 如果之前滚动到底部,保持在底部 49 | // if (scrollHeight - scrollPosition <= clientHeight + 50) { 50 | // chatContainer.scrollTop = chatContainer.scrollHeight; 51 | // } 52 | // } 53 | }, 100); 54 | }); 55 | 56 | // 监听输入框焦点事件 57 | document.addEventListener('DOMContentLoaded', () => { 58 | const input = document.getElementById('message-input'); 59 | if (input) { 60 | input.addEventListener('focus', () => { 61 | // 给一点延迟,等待输入法完全展开 62 | setTimeout(() => { 63 | setViewportHeight(); 64 | // 滚动到底部 65 | // const chatContainer = document.getElementById('chat-container'); 66 | // if (chatContainer) { 67 | // chatContainer.scrollTop = chatContainer.scrollHeight; 68 | // } 69 | }, 300); 70 | }); 71 | 72 | input.addEventListener('blur', () => { 73 | // 输入框失去焦点时,重置视口高度 74 | setTimeout(() => { 75 | setViewportHeight(); 76 | }, 100); 77 | }); 78 | } 79 | }); -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "default_locale": "en", 4 | "name": "__MSG_extension_name__", 5 | "version": "2.3.88", 6 | "description": "__MSG_extension_description__", 7 | "content_security_policy": { 8 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" 9 | }, 10 | "icons": { 11 | "16": "icons/icon16.png", 12 | "48": "icons/icon48.png", 13 | "128": "icons/icon128.png" 14 | }, 15 | "permissions": [ 16 | "storage", 17 | "commands", 18 | "scripting", 19 | "webRequest", 20 | "activeTab", 21 | "tabs", 22 | "unlimitedStorage" 23 | ], 24 | "host_permissions": ["", "file:///*"], 25 | "action": { 26 | "default_title": "__MSG_action_open_sidebar__", 27 | "default_icon": { 28 | "16": "icons/icon16.png", 29 | "48": "icons/icon48.png", 30 | "128": "icons/icon128.png" 31 | } 32 | }, 33 | "commands": { 34 | "toggle_sidebar": { 35 | "suggested_key": { 36 | "default": "Alt+Z", 37 | "windows": "Alt+Z", 38 | "mac": "MacCtrl+Z" 39 | }, 40 | "description": "__MSG_command_toggle_sidebar_description__" 41 | }, 42 | "new_chat": { 43 | "suggested_key": { 44 | "default": "Alt+X", 45 | "windows": "Alt+X", 46 | "mac": "MacCtrl+X" 47 | }, 48 | "description": "__MSG_command_new_chat_description__" 49 | } 50 | }, 51 | "background": { 52 | "service_worker": "background.js", 53 | "type": "module" 54 | }, 55 | "content_scripts": [ 56 | { 57 | "matches": [""], 58 | "js": ["content.js"], 59 | "run_at": "document_start" 60 | } 61 | ], 62 | "web_accessible_resources": [ 63 | { 64 | "resources": [ 65 | "index.html", 66 | "src/main.js", 67 | "styles/main.css", 68 | "htmd/marked.min.js", 69 | "htmd/highlight.min.js", 70 | "htmd/mathjax-config.js", 71 | "htmd/tex-chtml-full.js", 72 | "lib/pdf.js", 73 | "lib/pdf.worker.js", 74 | "htmd/mermaid.min.js", 75 | "htmd/mermaid-init.js", 76 | "htmd/fonts/woff-v2/MathJax_AMS-Regular.woff", 77 | "htmd/fonts/woff-v2/MathJax_Calligraphic-Regular.woff", 78 | "htmd/fonts/woff-v2/MathJax_Fraktur-Bold.woff", 79 | "htmd/fonts/woff-v2/MathJax_Fraktur-Regular.woff", 80 | "htmd/fonts/woff-v2/MathJax_Main-Bold.woff", 81 | "htmd/fonts/woff-v2/MathJax_Main-Regular.woff", 82 | "htmd/fonts/woff-v2/MathJax_Math-BoldItalic.woff", 83 | "htmd/fonts/woff-v2/MathJax_Math-Italic.woff", 84 | "htmd/fonts/woff-v2/MathJax_Size1-Regular.woff", 85 | "htmd/fonts/woff-v2/MathJax_Size2-Regular.woff", 86 | "htmd/fonts/woff-v2/MathJax_Size3-Regular.woff", 87 | "htmd/fonts/woff-v2/MathJax_Size4-Regular.woff", 88 | "htmd/fonts/woff-v2/MathJax_Typewriter-Regular.woff", 89 | "htmd/fonts/woff-v2/MathJax_Vector-Bold.woff", 90 | "htmd/fonts/woff-v2/MathJax_Vector-Regular.woff", 91 | "htmd/fonts/woff-v2/MathJax_Zero.woff", 92 | "statics/image.png" 93 | ], 94 | "matches": [""] 95 | } 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /manifest.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "default_locale": "en", 4 | "name": "__MSG_extension_name__", 5 | "version": "2.3.88", 6 | "description": "__MSG_extension_description__", 7 | "content_security_policy": { 8 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" 9 | }, 10 | "icons": { 11 | "16": "icons/icon16.png", 12 | "48": "icons/icon48.png", 13 | "128": "icons/icon128.png" 14 | }, 15 | "permissions": [ 16 | "storage", 17 | "commands", 18 | "scripting", 19 | "webRequest", 20 | "activeTab", 21 | "tabs", 22 | "unlimitedStorage" 23 | ], 24 | "host_permissions": ["", "file:///*"], 25 | "action": { 26 | "default_title": "__MSG_action_open_sidebar__", 27 | "default_icon": { 28 | "16": "icons/icon16.png", 29 | "48": "icons/icon48.png", 30 | "128": "icons/icon128.png" 31 | } 32 | }, 33 | "commands": { 34 | "toggle_sidebar": { 35 | "suggested_key": { 36 | "default": "Alt+Z", 37 | "windows": "Alt+Z", 38 | "mac": "MacCtrl+Z" 39 | }, 40 | "description": "__MSG_command_toggle_sidebar_description__" 41 | }, 42 | "new_chat": { 43 | "suggested_key": { 44 | "default": "Alt+X", 45 | "windows": "Alt+X", 46 | "mac": "MacCtrl+X" 47 | }, 48 | "description": "__MSG_command_new_chat_description__" 49 | } 50 | }, 51 | "background": { 52 | "scripts": ["background.js"], 53 | "type": "module" 54 | }, 55 | "content_scripts": [ 56 | { 57 | "matches": [""], 58 | "js": ["content.js"], 59 | "run_at": "document_start" 60 | } 61 | ], 62 | "web_accessible_resources": [ 63 | { 64 | "resources": [ 65 | "index.html", 66 | "src/main.js", 67 | "styles/main.css", 68 | "htmd/marked.min.js", 69 | "htmd/highlight.min.js", 70 | "htmd/mathjax-config.js", 71 | "htmd/tex-chtml-full.js", 72 | "lib/pdf.js", 73 | "lib/pdf.worker.js", 74 | "htmd/mermaid.min.js", 75 | "htmd/mermaid-init.js", 76 | "htmd/fonts/woff-v2/MathJax_AMS-Regular.woff", 77 | "htmd/fonts/woff-v2/MathJax_Calligraphic-Regular.woff", 78 | "htmd/fonts/woff-v2/MathJax_Fraktur-Bold.woff", 79 | "htmd/fonts/woff-v2/MathJax_Fraktur-Regular.woff", 80 | "htmd/fonts/woff-v2/MathJax_Main-Bold.woff", 81 | "htmd/fonts/woff-v2/MathJax_Main-Regular.woff", 82 | "htmd/fonts/woff-v2/MathJax_Math-BoldItalic.woff", 83 | "htmd/fonts/woff-v2/MathJax_Math-Italic.woff", 84 | "htmd/fonts/woff-v2/MathJax_Size1-Regular.woff", 85 | "htmd/fonts/woff-v2/MathJax_Size2-Regular.woff", 86 | "htmd/fonts/woff-v2/MathJax_Size3-Regular.woff", 87 | "htmd/fonts/woff-v2/MathJax_Size4-Regular.woff", 88 | "htmd/fonts/woff-v2/MathJax_Typewriter-Regular.woff", 89 | "htmd/fonts/woff-v2/MathJax_Vector-Bold.woff", 90 | "htmd/fonts/woff-v2/MathJax_Vector-Regular.woff", 91 | "htmd/fonts/woff-v2/MathJax_Zero.woff", 92 | "statics/image.png" 93 | ], 94 | "matches": [""] 95 | } 96 | ], 97 | "browser_specific_settings": { 98 | "gecko": { 99 | "id": "addon@cerebr.app", 100 | "strict_min_version": "109.0" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /styles/components/preferences.css: -------------------------------------------------------------------------------- 1 | /* 偏好设置页面样式 */ 2 | #preferences-settings { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 1000; 9 | display: none; 10 | overflow-y: auto; 11 | flex-direction: column; 12 | background: var(--cerebr-bg-color); 13 | } 14 | 15 | #preferences-settings.visible { 16 | display: flex; 17 | } 18 | 19 | .preferences-content { 20 | padding: 20px; 21 | flex: 1; 22 | overflow-y: auto; 23 | display: flex; 24 | flex-direction: column; 25 | gap: 16px; 26 | max-width: 600px; 27 | margin: 0 auto; 28 | width: 100%; 29 | box-sizing: border-box; 30 | } 31 | 32 | .preferences-card { 33 | background: var(--cerebr-message-ai-bg); 34 | border: 1px solid var(--cerebr-card-border-color); 35 | border-radius: 12px; 36 | overflow: hidden; 37 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); 38 | } 39 | 40 | .preference-row { 41 | width: 100%; 42 | padding: 16px 20px; 43 | display: flex; 44 | align-items: center; 45 | justify-content: space-between; 46 | gap: 16px; 47 | color: var(--cerebr-text-color); 48 | background: transparent; 49 | border: none; 50 | text-align: left; 51 | box-sizing: border-box; 52 | transition: background-color 0.2s ease; 53 | } 54 | 55 | .preference-row + .preference-row { 56 | border-top: 1px solid var(--cerebr-card-border-color); 57 | } 58 | 59 | .preference-action, 60 | .preference-link { 61 | cursor: pointer; 62 | text-decoration: none; 63 | position: relative; 64 | } 65 | 66 | .preference-action:hover, 67 | .preference-link:hover { 68 | background-color: var(--cerebr-message-user-bg); 69 | } 70 | 71 | .preference-action:active, 72 | .preference-link:active { 73 | background-color: var(--cerebr-hover-bg-color); 74 | } 75 | 76 | .preference-action:focus-visible, 77 | .preference-link:focus-visible, 78 | .preference-select:focus-visible { 79 | outline: none; 80 | box-shadow: inset 0 0 0 2px var(--cerebr-focus-border-color); 81 | z-index: 1; 82 | } 83 | 84 | .preference-label { 85 | font-weight: 500; 86 | font-size: var(--cerebr-fs-14); 87 | color: var(--cerebr-text-color); 88 | } 89 | 90 | .preference-value { 91 | display: inline-flex; 92 | align-items: center; 93 | gap: 6px; 94 | font-size: var(--cerebr-fs-14); 95 | color: var(--cerebr-text-secondary-color); 96 | opacity: 0.8; 97 | } 98 | 99 | .preference-value svg { 100 | width: 16px; 101 | height: 16px; 102 | opacity: 0.6; 103 | } 104 | 105 | .preference-select { 106 | /* Keep selects visually aligned across rows */ 107 | width: 120px; 108 | min-width: 120px; 109 | flex: 0 0 120px; 110 | background: transparent; 111 | border: none; 112 | color: var(--cerebr-text-secondary-color); 113 | border-radius: 6px; 114 | padding: 4px 8px; 115 | font-size: var(--cerebr-fs-14); 116 | cursor: pointer; 117 | text-align: center; 118 | text-align-last: center; 119 | appearance: none; 120 | -webkit-appearance: none; 121 | font-family: inherit; 122 | } 123 | 124 | .preference-select:hover { 125 | color: var(--cerebr-text-color); 126 | background-color: var(--cerebr-hover-bg-color); 127 | } 128 | 129 | .preference-select option { 130 | background-color: var(--cerebr-bg-color); 131 | color: var(--cerebr-text-color); 132 | text-align: center; 133 | } 134 | -------------------------------------------------------------------------------- /styles/components/message.css: -------------------------------------------------------------------------------- 1 | /* 消息基础样式 */ 2 | .message { 3 | margin: 8px 0; 4 | padding: 12px 16px; 5 | border-radius: 8px; 6 | width: fit-content; 7 | max-width: calc(100% - 32px); 8 | word-wrap: break-word; 9 | font-size: var(--cerebr-fs-14); 10 | line-height: 1.5; 11 | position: relative; 12 | opacity: 0; 13 | transform: translateY(8px) scale(0.98) translateZ(0); 14 | animation: messageAppear 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; 15 | will-change: transform, opacity; 16 | box-shadow: 0 2px 6px var(--cerebr-message-shadow); 17 | transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), 18 | box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1); 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | backface-visibility: hidden; 22 | perspective: 1000px; 23 | } 24 | 25 | /* 消息中的图片样式 */ 26 | .message img { 27 | display: block; 28 | max-width: 100%; 29 | height: auto; 30 | margin: 8px 0; 31 | border-radius: 6px; 32 | } 33 | 34 | /* 添加更新中的消息样式 */ 35 | .message.updating { 36 | backface-visibility: hidden; 37 | -webkit-backface-visibility: hidden; 38 | -webkit-transform-style: preserve-3d; 39 | transform-style: preserve-3d; 40 | } 41 | 42 | .user-message { 43 | background-color: var(--cerebr-message-user-bg); 44 | margin-left: auto; 45 | margin-right: 0; 46 | border-bottom-right-radius: 4px; 47 | } 48 | 49 | .ai-message { 50 | background-color: var(--cerebr-message-ai-bg); 51 | margin-right: auto; 52 | margin-left: 0; 53 | border-bottom-left-radius: 4px; 54 | -webkit-touch-callout: none; /* iOS Safari */ 55 | } 56 | 57 | .message p { 58 | margin: 0; 59 | line-height: 1.5; 60 | } 61 | 62 | .message p + p { 63 | margin-top: 0.5em; 64 | } 65 | 66 | .message ul, .message ol { 67 | margin: 0.5em 0; 68 | padding-left: 24px; 69 | } 70 | 71 | .typing-indicator { 72 | display: inline-flex; 73 | align-items: center; 74 | gap: 6px; 75 | padding: 2px 0; 76 | } 77 | 78 | .typing-dot { 79 | width: 6px; 80 | height: 6px; 81 | border-radius: 999px; 82 | background: currentColor; 83 | opacity: 0.45; 84 | animation: cerebrTyping 1.1s ease-in-out infinite; 85 | } 86 | 87 | .typing-dot:nth-child(2) { 88 | animation-delay: 0.12s; 89 | } 90 | 91 | .typing-dot:nth-child(3) { 92 | animation-delay: 0.24s; 93 | } 94 | 95 | @keyframes cerebrTyping { 96 | 0%, 100% { transform: translateY(0); opacity: 0.35; } 97 | 50% { transform: translateY(-2px); opacity: 0.8; } 98 | } 99 | 100 | /* 链接样式 */ 101 | .message a { 102 | color: var(--cerebr-link-color); 103 | text-decoration: none; 104 | } 105 | 106 | .message a:hover { 107 | text-decoration: underline; 108 | } 109 | 110 | .message blockquote { 111 | margin: 0.5em 0; 112 | padding-left: 12px; 113 | border-left: 4px solid var(--cerebr-blockquote-border-color); 114 | color: var(--cerebr-blockquote-text-color); 115 | } 116 | 117 | .message:hover { 118 | transform: translateY(-1px) translateZ(0); 119 | box-shadow: 0 4px 12px var(--cerebr-message-hover-shadow); 120 | } 121 | 122 | /* 批量加载时的消息样式 */ 123 | .message.batch-load { 124 | animation: none; 125 | opacity: 0; 126 | transform: translateY(16px) scale(0.98); 127 | } 128 | 129 | .message.batch-load.show { 130 | opacity: 1; 131 | transform: translateY(0) scale(1); 132 | transition: opacity 0.3s ease, transform 0.3s ease; 133 | } 134 | 135 | @media (prefers-reduced-motion: reduce) { 136 | .message { 137 | animation: none; 138 | opacity: 1; 139 | transform: none; 140 | transition: none; 141 | } 142 | .message.batch-load { 143 | opacity: 1; 144 | transform: none; 145 | } 146 | .typing-dot { 147 | animation: none; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /styles/components/api-settings.css: -------------------------------------------------------------------------------- 1 | /* API设置页面样式 */ 2 | #api-settings { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 1000; 9 | display: none; 10 | overflow-y: auto; 11 | flex-direction: column; 12 | background: var(--cerebr-bg-color); 13 | } 14 | 15 | #api-settings.visible { 16 | display: flex; 17 | } 18 | 19 | .api-cards { 20 | padding: 16px; 21 | flex: 1; 22 | overflow-y: auto; 23 | } 24 | 25 | /* API卡片基础样式 */ 26 | .api-card { 27 | outline: none; 28 | cursor: pointer; 29 | border-radius: 8px; 30 | position: relative; 31 | margin-bottom: 12px; 32 | background: var(--cerebr-message-ai-bg); 33 | border: 1px solid var(--cerebr-card-border-color); 34 | transition: transform 0.2s ease, box-shadow 0.2s ease; 35 | padding: 15px; 36 | } 37 | 38 | .settings-header { 39 | padding: 16px; 40 | display: flex; 41 | align-items: center; 42 | border-bottom: 1px solid var(--border-color); 43 | background-color: var(--cerebr-bg-color); 44 | position: sticky; 45 | top: 0; 46 | z-index: 1; 47 | } 48 | 49 | .back-button { 50 | background: none; 51 | border: none; 52 | padding: 8px; 53 | margin-right: 12px; 54 | cursor: pointer; 55 | color: var(--cerebr-text-color); 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | border-radius: 6px; 60 | transition: background-color 0.2s ease; 61 | } 62 | 63 | .back-button:hover { 64 | background-color: var(--cerebr-message-user-bg); 65 | } 66 | 67 | .settings-title { 68 | font-size: var(--cerebr-fs-16); 69 | font-weight: 500; 70 | color: var(--cerebr-text-color); 71 | } 72 | 73 | .api-card:hover, 74 | .api-card:focus { 75 | transform: translateY(-1px); 76 | box-shadow: 0 4px 12px var(--cerebr-card-border-color); 77 | } 78 | 79 | .api-card.selected { 80 | border-color: var(--cerebr-highlight-border-color); 81 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 82 | } 83 | 84 | .api-card:focus:not(.selected) { 85 | border-color: var(--cerebr-focus-border-color); 86 | box-shadow: 0 0 0 1px var(--cerebr-focus-border-color); 87 | } 88 | 89 | .card-actions { 90 | display: flex; 91 | gap: 8px; 92 | z-index: 3; 93 | } 94 | 95 | .card-button { 96 | background: none; 97 | border: none; 98 | padding: 8px; 99 | cursor: pointer; 100 | color: var(--cerebr-text-color); 101 | opacity: 0.6; 102 | transition: opacity 0.2s, background-color 0.2s; 103 | display: flex; 104 | align-items: center; 105 | justify-content: center; 106 | width: 32px; 107 | height: 32px; 108 | border-radius: 4px; 109 | position: relative; 110 | } 111 | 112 | .card-button:hover { 113 | opacity: 1; 114 | background-color: var(--cerebr-button-hover-bg); 115 | } 116 | 117 | .card-button svg { 118 | width: 16px; 119 | height: 16px; 120 | pointer-events: none; 121 | stroke: currentColor; 122 | fill: none; 123 | stroke-width: 1.5; 124 | } 125 | 126 | .api-form { 127 | display: flex; 128 | flex-direction: column; 129 | gap: 12px; 130 | width: 100%; 131 | } 132 | 133 | .form-group { 134 | display: flex; 135 | flex-direction: column; 136 | gap: 4px; 137 | width: 100%; 138 | } 139 | 140 | .form-group input, 141 | .system-prompt { 142 | width: 100%; 143 | box-sizing: border-box; 144 | } 145 | 146 | .form-group-header { 147 | display: flex; 148 | justify-content: space-between; 149 | align-items: center; 150 | } 151 | 152 | .form-group label { 153 | font-size: var(--cerebr-fs-12); 154 | opacity: 0.8; 155 | } 156 | 157 | .form-group input { 158 | background: var(--cerebr-message-ai-bg); 159 | border: 1px solid var(--cerebr-card-border-color); 160 | padding: 8px; 161 | border-radius: 4px; 162 | color: var(--cerebr-text-color); 163 | font-size: var(--cerebr-fs-14); 164 | transition: border-color 0.2s ease; 165 | } 166 | 167 | .form-group input:focus { 168 | outline: none; 169 | border-color: var(--cerebr-highlight-border-color); 170 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 171 | } 172 | -------------------------------------------------------------------------------- /styles/components/chat-list.css: -------------------------------------------------------------------------------- 1 | /* 聊天列表页面样式 */ 2 | #chat-list-page { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 1000; 9 | display: none; 10 | flex-direction: column; 11 | background: var(--cerebr-bg-color); 12 | } 13 | 14 | #chat-list-page.show { 15 | display: flex; 16 | } 17 | 18 | #chat-list-page .settings-header { 19 | justify-content: space-between; 20 | } 21 | 22 | #chat-list-page .settings-title { 23 | flex-grow: 0; 24 | margin: 0 auto; 25 | } 26 | 27 | .chat-cards { 28 | padding: 16px; 29 | flex: 1; 30 | overflow-y: auto; 31 | } 32 | 33 | .chat-list-empty-state { 34 | margin-top: 20vh; 35 | padding: 16px; 36 | text-align: center; 37 | color: var(--cerebr-text-color-secondary); 38 | font-size: var(--cerebr-fs-14); 39 | } 40 | 41 | .chat-card { 42 | outline: none; 43 | cursor: pointer; 44 | border-radius: 8px; 45 | position: relative; 46 | margin-bottom: 12px; 47 | background: var(--cerebr-message-ai-bg); 48 | border: 1px solid var(--cerebr-card-border-color); 49 | transition: transform 0.2s ease, box-shadow 0.2s ease; 50 | } 51 | 52 | .chat-card:hover, 53 | .chat-card:focus { 54 | transform: translateY(-1px); 55 | box-shadow: 0 4px 12px var(--cerebr-card-border-color); 56 | } 57 | 58 | .chat-card.selected { 59 | border-color: var(--cerebr-highlight-border-color); 60 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 61 | } 62 | 63 | .chat-card .card-content { 64 | display: flex; 65 | justify-content: space-between; 66 | align-items: center; 67 | padding: 15px; 68 | } 69 | 70 | .chat-card .chat-title { 71 | font-size: var(--cerebr-fs-14); 72 | color: var(--cerebr-text-color); 73 | flex-grow: 1; 74 | margin-right: 12px; 75 | white-space: nowrap; 76 | overflow: hidden; 77 | text-overflow: ellipsis; 78 | } 79 | 80 | .chat-card .card-actions { 81 | display: flex; 82 | gap: 8px; 83 | z-index: 3; 84 | } 85 | 86 | .chat-card .card-button { 87 | background: none; 88 | border: none; 89 | padding: 8px; 90 | cursor: pointer; 91 | color: var(--cerebr-text-color); 92 | opacity: 0.6; 93 | transition: opacity 0.2s, background-color 0.2s; 94 | display: flex; 95 | align-items: center; 96 | justify-content: center; 97 | width: 32px; 98 | height: 32px; 99 | border-radius: 4px; 100 | position: relative; 101 | } 102 | 103 | .chat-card .card-button:hover { 104 | opacity: 1; 105 | background-color: var(--cerebr-button-hover-bg); 106 | } 107 | 108 | .chat-card .card-button svg { 109 | width: 16px; 110 | height: 16px; 111 | pointer-events: none; 112 | stroke: currentColor; 113 | fill: none; 114 | stroke-width: 1.5; 115 | } 116 | /* 搜索容器样式 */ 117 | .search-container { 118 | position: relative; 119 | margin-left: 16px; 120 | flex-grow: 1; 121 | } 122 | 123 | #chat-search-input { 124 | padding-right: 32px; /* 为清除按钮留出空间 */ 125 | } 126 | 127 | /* 隐藏浏览器原生的清除按钮 */ 128 | #chat-search-input::-webkit-search-cancel-button { 129 | -webkit-appearance: none; 130 | display: none; 131 | } 132 | 133 | /* 隐藏浏览器原生的清除按钮 */ 134 | #chat-search-input::-webkit-search-cancel-button { 135 | -webkit-appearance: none; 136 | display: none; 137 | } 138 | 139 | .clear-search-btn { 140 | position: absolute; 141 | right: 4px; 142 | top: 50%; 143 | transform: translateY(-50%); 144 | background: none; 145 | border: none; 146 | padding: 4px; 147 | cursor: pointer; 148 | color: var(--cerebr-text-color); 149 | opacity: 0.6; 150 | display: none; /* JS will control visibility */ 151 | align-items: center; 152 | justify-content: center; 153 | width: 28px; 154 | height: 28px; 155 | border-radius: 4px; 156 | } 157 | 158 | .clear-search-btn:hover { 159 | opacity: 1; 160 | background-color: var(--cerebr-button-hover-bg); 161 | } 162 | 163 | .clear-search-btn svg { 164 | width: 16px; 165 | height: 16px; 166 | pointer-events: none; 167 | } 168 | 169 | #chat-search-input { 170 | width: 100%; 171 | padding: 10px 15px; 172 | font-size: var(--cerebr-fs-14); 173 | border-radius: 8px; 174 | border: 1px solid var(--cerebr-card-border-color); 175 | background: var(--cerebr-message-ai-bg); 176 | color: var(--cerebr-text-color); 177 | outline: none; 178 | box-sizing: border-box; 179 | transition: border-color 0.2s, box-shadow 0.2s; 180 | } 181 | 182 | #chat-search-input:focus { 183 | border-color: var(--cerebr-card-border-color); 184 | box-shadow: none; 185 | } 186 | 187 | #chat-search-input::placeholder { 188 | color: var(--cerebr-text-color-secondary); 189 | } 190 | -------------------------------------------------------------------------------- /styles/components/settings.css: -------------------------------------------------------------------------------- 1 | /* 设置按钮和菜单样式 */ 2 | #settings-button { 3 | padding: 12px; 4 | background: none; 5 | border: none; 6 | cursor: pointer; 7 | color: var(--cerebr-icon-color); 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | #settings-button svg { 14 | width: 18px; 15 | height: 18px; 16 | fill: currentColor; 17 | opacity: 0.6; 18 | transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55), 19 | opacity 0.2s ease; 20 | } 21 | 22 | #settings-button:hover svg { 23 | transform: scale(1.25); 24 | opacity: 1; 25 | } 26 | 27 | #settings-button:active svg { 28 | transform: scale(0.95); 29 | opacity: 0.8; 30 | } 31 | 32 | #settings-menu { 33 | position: absolute; 34 | bottom: 100%; 35 | left: 8px; 36 | background-color: var(--cerebr-surface-bg); 37 | border: 1px solid var(--cerebr-surface-border); 38 | border-radius: 8px; 39 | box-shadow: 0 4px 20px var(--cerebr-popup-shadow); 40 | padding: 8px; 41 | display: none; 42 | min-width: 180px; 43 | margin-bottom: 8px; 44 | transform-origin: bottom left; 45 | backdrop-filter: var(--cerebr-surface-backdrop-filter); 46 | -webkit-backdrop-filter: var(--cerebr-surface-backdrop-filter); 47 | } 48 | 49 | #settings-menu.visible { 50 | display: block; 51 | animation: menuAppear 0.2s ease; 52 | } 53 | 54 | .menu-item { 55 | padding: 8px 12px; 56 | cursor: pointer; 57 | display: flex; 58 | align-items: center; 59 | justify-content: space-between; 60 | color: var(--cerebr-text-color); 61 | border-radius: 6px; 62 | margin: 2px 0; 63 | -webkit-user-select: none; 64 | user-select: none; 65 | } 66 | 67 | .menu-item:hover { 68 | background-color: var(--cerebr-message-user-bg); 69 | } 70 | 71 | .menu-item:focus-visible { 72 | outline: none; 73 | background-color: var(--cerebr-message-user-bg); 74 | box-shadow: 0 0 0 2px var(--cerebr-focus-border-color); 75 | } 76 | 77 | /* 开关样式 */ 78 | .switch { 79 | position: relative; 80 | display: inline-block; 81 | width: 36px; 82 | height: 20px; 83 | } 84 | 85 | .switch input { 86 | opacity: 0; 87 | width: 0; 88 | height: 0; 89 | } 90 | 91 | .slider { 92 | position: absolute; 93 | cursor: pointer; 94 | top: 0; 95 | left: 0; 96 | right: 0; 97 | bottom: 0; 98 | background-color: var(--cerebr-toggle-bg-off); 99 | transition: .3s; 100 | border-radius: 20px; 101 | } 102 | 103 | .slider:before { 104 | position: absolute; 105 | content: ""; 106 | height: 16px; 107 | width: 16px; 108 | left: 2px; 109 | bottom: 2px; 110 | background-color: #fff; 111 | transition: .3s; 112 | border-radius: 50%; 113 | box-shadow: 0 1px 3px var(--cerebr-popup-shadow); 114 | } 115 | 116 | input:checked + .slider { 117 | background-color: var(--cerebr-toggle-bg-on); 118 | } 119 | 120 | input:checked + .slider:before { 121 | transform: translateX(16px); 122 | } 123 | 124 | /* 高级设置区域样式 */ 125 | .advanced-settings { 126 | margin-top: 0; 127 | padding-top: 4px; 128 | } 129 | 130 | .advanced-settings-header { 131 | display: flex; 132 | justify-content: space-between; 133 | align-items: center; 134 | cursor: pointer; 135 | padding: 8px 12px; 136 | user-select: none; 137 | background: var(--cerebr-message-ai-bg); 138 | border-radius: 6px; 139 | transition: background-color 0.2s ease; 140 | } 141 | 142 | .advanced-settings-header:hover { 143 | opacity: 1; 144 | background: var(--cerebr-message-user-bg); 145 | } 146 | 147 | .toggle-icon { 148 | transition: transform 0.3s ease; 149 | font-size: var(--cerebr-fs-12); 150 | color: var(--cerebr-text-color); 151 | opacity: 0.6; 152 | } 153 | 154 | .advanced-settings-content { 155 | overflow: hidden; 156 | transition: height 0.3s ease; 157 | width: 100%; 158 | box-sizing: border-box; 159 | } 160 | 161 | .setting-item { 162 | margin-top: 12px; 163 | } 164 | 165 | .setting-item label { 166 | display: block; 167 | font-size: var(--cerebr-fs-12); 168 | margin-bottom: 4px; 169 | color: var(--cerebr-text-color); 170 | opacity: 0.8; 171 | } 172 | 173 | .system-prompt { 174 | width: 100%; 175 | min-height: 60px; 176 | padding: 8px; 177 | border: 1px solid var(--cerebr-input-border-color); 178 | border-radius: 4px; 179 | background: var(--cerebr-message-ai-bg); 180 | color: var(--cerebr-text-color); 181 | font-size: var(--cerebr-fs-14); 182 | line-height: 1.5; 183 | resize: vertical; 184 | font-family: inherit; 185 | transition: border-color 0.2s ease, box-shadow 0.2s ease; 186 | } 187 | 188 | .system-prompt:focus { 189 | outline: none; 190 | border-color: var(--cerebr-highlight-border-color); 191 | box-shadow: 0 0 0 1px var(--cerebr-highlight-border-color); 192 | } 193 | -------------------------------------------------------------------------------- /_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { "message": "Cerebr" }, 3 | "extension_description": { "message": "Cerebr - 智能AI聊天助手" }, 4 | "action_open_sidebar": { "message": "打开 Cerebr 侧边栏" }, 5 | "command_toggle_sidebar_description": { "message": "打开/关闭 Cerebr 侧边栏" }, 6 | "command_new_chat_description": { "message": "创建新的对话" }, 7 | 8 | "context_menu_label": { "message": "消息操作菜单" }, 9 | "context_copy_message": { "message": "复制消息" }, 10 | "context_copy_code": { "message": "复制代码" }, 11 | "context_copy_image": { "message": "复制图片" }, 12 | "context_copy_math": { "message": "复制公式" }, 13 | "context_regenerate": { "message": "重新生成" }, 14 | "context_stop_update": { "message": "停止更新" }, 15 | "context_delete_message": { "message": "删除消息" }, 16 | 17 | "aria_open_settings": { "message": "打开设置" }, 18 | "settings_menu_label": { "message": "设置菜单" }, 19 | "settings_dark_mode": { "message": "深色模式" }, 20 | "settings_webpage_content": { "message": "网页内容" }, 21 | "settings_new_chat": { "message": "新的对话" }, 22 | "settings_chat_list": { "message": "对话列表" }, 23 | "settings_api_settings": { "message": "API 设置" }, 24 | "settings_preferences": { "message": "偏好设置" }, 25 | 26 | "message_input_placeholder": { "message": "输入消息..." }, 27 | "message_input_aria": { "message": "消息输入框" }, 28 | 29 | "aria_back": { "message": "返回" }, 30 | "title_api_settings": { "message": "API 设置" }, 31 | "api_key_label": { "message": "API Key" }, 32 | "api_key_placeholder": { "message": "输入 API Key" }, 33 | "base_url_label": { "message": "Base URL" }, 34 | "base_url_placeholder": { "message": "输入 Base URL" }, 35 | "model_name_label": { "message": "Model Name" }, 36 | "model_name_placeholder": { "message": "输入模型名称" }, 37 | "api_duplicate_config_aria": { "message": "复制该配置" }, 38 | "api_delete_config_aria": { "message": "删除该配置" }, 39 | "api_advanced_settings": { "message": "高级设置" }, 40 | "api_system_prompt_label": { "message": "系统提示" }, 41 | 42 | "title_preferences": { "message": "偏好设置" }, 43 | "preferences_version": { "message": "版本号" }, 44 | "preferences_open_source": { "message": "开源地址" }, 45 | "preferences_group": { "message": "交流群" }, 46 | "preferences_group_aria": { "message": "Telegram 交流群:t.me/cerebr_chat" }, 47 | "preferences_font_size": { "message": "字体大小" }, 48 | "preferences_font_size_aria": { "message": "字体大小设置" }, 49 | "font_size_small": { "message": "小" }, 50 | "font_size_standard": { "message": "标准" }, 51 | "font_size_large": { "message": "大" }, 52 | "font_size_xlarge": { "message": "特大" }, 53 | "preferences_language_label": { "message": "语言" }, 54 | "preferences_language_aria": { "message": "语言设置" }, 55 | "language_auto": { "message": "自动" }, 56 | "language_en": { "message": "English" }, 57 | "language_zh_cn": { "message": "简体中文" }, 58 | "language_zh_tw": { "message": "繁體中文" }, 59 | 60 | "preferences_feedback": { "message": "用户反馈" }, 61 | "preferences_feedback_action": { "message": "去 GitHub" }, 62 | 63 | "title_chat_list": { "message": "对话列表" }, 64 | "chat_search_placeholder": { "message": "搜索对话..." }, 65 | "chat_clear_search_aria": { "message": "清除搜索" }, 66 | "chat_delete_chat_aria": { "message": "删除该对话" }, 67 | 68 | "image_preview_label": { "message": "图片预览" }, 69 | "image_preview_alt": { "message": "预览图片" }, 70 | "image_preview_close_aria": { "message": "关闭图片预览" }, 71 | 72 | "toast_copied_message": { "message": "已复制消息" }, 73 | "toast_copied_code": { "message": "已复制代码" }, 74 | "toast_copied_image": { "message": "已复制图片" }, 75 | "toast_copied_math": { "message": "已复制公式" }, 76 | "toast_copy_failed": { "message": "复制失败" }, 77 | "toast_copy_code_failed": { "message": "复制代码失败" }, 78 | "toast_copy_image_failed": { "message": "复制图片失败" }, 79 | "toast_copy_math_failed": { "message": "复制公式失败" }, 80 | "toast_no_math_found": { "message": "没有找到可复制的公式" }, 81 | "toast_handle_image_failed": { "message": "处理图片失败" }, 82 | 83 | "label_thinking": { "message": "正在思考" }, 84 | "label_copy": { "message": "复制" }, 85 | "label_copied": { "message": "已复制" }, 86 | "label_copy_failed": { "message": "复制失败" }, 87 | "label_deep_thinking": { "message": "深度思考" }, 88 | 89 | "chat_switch_loading": { "message": "正在加载对话…" }, 90 | "chat_list_empty": { "message": "暂无对话" }, 91 | "chat_list_no_match": { "message": "没有匹配的对话" }, 92 | "chat_new_title": { "message": "新对话" }, 93 | "chat_default_title": { "message": "默认对话" }, 94 | 95 | "webpage_tabs_loading": { "message": "加载标签页…" }, 96 | "webpage_tabs_empty": { "message": "没有可用的标签页" }, 97 | "youtube_transcript_prefix": { "message": "YouTube 字幕:" }, 98 | "webpage_prefix_current": { "message": "当前网页内容" }, 99 | "webpage_prefix_other": { "message": "其他打开的网页" }, 100 | "webpage_title_label": { "message": "标题" }, 101 | "webpage_url_label": { "message": "URL" }, 102 | "webpage_content_label": { "message": "内容" }, 103 | 104 | "label_image": { "message": "图片" }, 105 | "label_delete_image": { "message": "删除图片" }, 106 | 107 | "error_api_config_incomplete": { "message": "API 配置不完整" }, 108 | "error_response_unreadable": { "message": "响应体不可读" }, 109 | "error_send_failed": { "message": "发送失败: $1" }, 110 | "error_regenerate_failed": { "message": "重新生成失败: $1" }, 111 | "error_unable_get_image_data": { "message": "无法获取图片数据。" }, 112 | "error_fetch_image_proxy_failed": { "message": "通过代理获取图片失败: $1" }, 113 | "error_read_image_failed": { "message": "读取图片失败" }, 114 | "error_parse_image_failed": { "message": "解析图片失败" }, 115 | "error_not_image_file": { "message": "不是图片文件" }, 116 | "error_image_too_large": { "message": "图片过大,建议压缩或裁剪后再发送" } 117 | } 118 | -------------------------------------------------------------------------------- /_locales/zh_TW/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { "message": "Cerebr" }, 3 | "extension_description": { "message": "Cerebr - 智慧AI聊天助理" }, 4 | "action_open_sidebar": { "message": "開啟 Cerebr 側邊欄" }, 5 | "command_toggle_sidebar_description": { "message": "開啟/關閉 Cerebr 側邊欄" }, 6 | "command_new_chat_description": { "message": "建立新的對話" }, 7 | 8 | "context_menu_label": { "message": "訊息操作選單" }, 9 | "context_copy_message": { "message": "複製訊息" }, 10 | "context_copy_code": { "message": "複製程式碼" }, 11 | "context_copy_image": { "message": "複製圖片" }, 12 | "context_copy_math": { "message": "複製公式" }, 13 | "context_regenerate": { "message": "重新產生" }, 14 | "context_stop_update": { "message": "停止更新" }, 15 | "context_delete_message": { "message": "刪除訊息" }, 16 | 17 | "aria_open_settings": { "message": "開啟設定" }, 18 | "settings_menu_label": { "message": "設定選單" }, 19 | "settings_dark_mode": { "message": "深色模式" }, 20 | "settings_webpage_content": { "message": "網頁內容" }, 21 | "settings_new_chat": { "message": "新的對話" }, 22 | "settings_chat_list": { "message": "對話列表" }, 23 | "settings_api_settings": { "message": "API 設定" }, 24 | "settings_preferences": { "message": "偏好設定" }, 25 | 26 | "message_input_placeholder": { "message": "輸入訊息..." }, 27 | "message_input_aria": { "message": "訊息輸入框" }, 28 | 29 | "aria_back": { "message": "返回" }, 30 | "title_api_settings": { "message": "API 設定" }, 31 | "api_key_label": { "message": "API Key" }, 32 | "api_key_placeholder": { "message": "輸入 API Key" }, 33 | "base_url_label": { "message": "Base URL" }, 34 | "base_url_placeholder": { "message": "輸入 Base URL" }, 35 | "model_name_label": { "message": "Model Name" }, 36 | "model_name_placeholder": { "message": "輸入模型名稱" }, 37 | "api_duplicate_config_aria": { "message": "複製此設定" }, 38 | "api_delete_config_aria": { "message": "刪除此設定" }, 39 | "api_advanced_settings": { "message": "進階設定" }, 40 | "api_system_prompt_label": { "message": "系統提示" }, 41 | 42 | "title_preferences": { "message": "偏好設定" }, 43 | "preferences_version": { "message": "程式版本" }, 44 | "preferences_open_source": { "message": "專案首頁" }, 45 | "preferences_group": { "message": "交流群組" }, 46 | "preferences_group_aria": { "message": "Telegram 交流群:t.me/cerebr_chat" }, 47 | "preferences_font_size": { "message": "字體大小" }, 48 | "preferences_font_size_aria": { "message": "字體大小設定" }, 49 | "font_size_small": { "message": "小" }, 50 | "font_size_standard": { "message": "標準" }, 51 | "font_size_large": { "message": "大" }, 52 | "font_size_xlarge": { "message": "特大" }, 53 | "preferences_language_label": { "message": "語言" }, 54 | "preferences_language_aria": { "message": "語言設定" }, 55 | "language_auto": { "message": "自動" }, 56 | "language_en": { "message": "English" }, 57 | "language_zh_cn": { "message": "简体中文" }, 58 | "language_zh_tw": { "message": "繁體中文" }, 59 | 60 | "preferences_feedback": { "message": "意見回饋" }, 61 | "preferences_feedback_action": { "message": "前往 GitHub" }, 62 | 63 | "title_chat_list": { "message": "對話列表" }, 64 | "chat_search_placeholder": { "message": "搜尋對話..." }, 65 | "chat_clear_search_aria": { "message": "清除搜尋" }, 66 | "chat_delete_chat_aria": { "message": "刪除該對話" }, 67 | 68 | "image_preview_label": { "message": "圖片預覽" }, 69 | "image_preview_alt": { "message": "預覽圖片" }, 70 | "image_preview_close_aria": { "message": "關閉圖片預覽" }, 71 | 72 | "toast_copied_message": { "message": "已複製訊息" }, 73 | "toast_copied_code": { "message": "已複製程式碼" }, 74 | "toast_copied_image": { "message": "已複製圖片" }, 75 | "toast_copied_math": { "message": "已複製公式" }, 76 | "toast_copy_failed": { "message": "複製失敗" }, 77 | "toast_copy_code_failed": { "message": "複製程式碼失敗" }, 78 | "toast_copy_image_failed": { "message": "複製圖片失敗" }, 79 | "toast_copy_math_failed": { "message": "複製公式失敗" }, 80 | "toast_no_math_found": { "message": "沒有找到可複製的公式" }, 81 | "toast_handle_image_failed": { "message": "處理圖片失敗" }, 82 | 83 | "label_thinking": { "message": "正在思考" }, 84 | "label_copy": { "message": "複製" }, 85 | "label_copied": { "message": "已複製" }, 86 | "label_copy_failed": { "message": "複製失敗" }, 87 | "label_deep_thinking": { "message": "深度思考" }, 88 | 89 | "chat_switch_loading": { "message": "正在載入對話…" }, 90 | "chat_list_empty": { "message": "暫無對話" }, 91 | "chat_list_no_match": { "message": "沒有符合的對話" }, 92 | "chat_new_title": { "message": "新對話" }, 93 | "chat_default_title": { "message": "預設對話" }, 94 | 95 | "webpage_tabs_loading": { "message": "載入分頁…" }, 96 | "webpage_tabs_empty": { "message": "沒有可用的分頁" }, 97 | "youtube_transcript_prefix": { "message": "YouTube 字幕:" }, 98 | "webpage_prefix_current": { "message": "當前網頁內容" }, 99 | "webpage_prefix_other": { "message": "其他開啟的網頁" }, 100 | "webpage_title_label": { "message": "標題" }, 101 | "webpage_url_label": { "message": "URL" }, 102 | "webpage_content_label": { "message": "內容" }, 103 | 104 | "label_image": { "message": "圖片" }, 105 | "label_delete_image": { "message": "刪除圖片" }, 106 | 107 | "error_api_config_incomplete": { "message": "API 設定不完整" }, 108 | "error_response_unreadable": { "message": "回應內容不可讀" }, 109 | "error_send_failed": { "message": "傳送失敗: $1" }, 110 | "error_regenerate_failed": { "message": "重新產生失敗: $1" }, 111 | "error_unable_get_image_data": { "message": "無法取得圖片資料。" }, 112 | "error_fetch_image_proxy_failed": { "message": "透過代理伺服器取得圖片失敗:$1" }, 113 | "error_read_image_failed": { "message": "讀取圖片失敗" }, 114 | "error_parse_image_failed": { "message": "解析圖片失敗" }, 115 | "error_not_image_file": { "message": "不是圖片檔案" }, 116 | "error_image_too_large": { "message": "圖片過大,建議壓縮或裁剪後再傳送" } 117 | } 118 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Chrome Web Store 8 | 9 | 10 | Edge Add-on 11 | 12 | 13 | Firefox Add-on 14 | 15 | 16 | 17 | 18 |

19 | 20 | [English](./README.md) | [简体中文](./README_CN.md) 21 | 22 | # 🧠 Cerebr - 智能 AI 助手 23 | 24 | ![screenshot](./statics/image.png) 25 | 26 | Cerebr 是一款强大的浏览器 AI 助手扩展,现已支持 Chrome、Firefox 和 Edge,专注于提升您的工作效率和学习体验。"Cerebr"源自拉丁语词根,与"大脑"或"脑"相关。这个命名体现了我们的愿景:整合 Claude、OpenAI 等 AI 的强大能力,使 Cerebr 成为您的第二大脑,为您提供深度阅读和理解支持。 27 | 28 | 在尝试了市面上现有的浏览器 AI 助手后,我们发现它们要么有使用次数限制,要么界面过于花哨。Cerebr 应运而生,专注于提供一个简洁、高效、无干扰的 AI 助手体验。 29 | 30 | ## ✨ 核心特性 31 | 32 | - 🎯 **智能侧边栏** - 通过快捷键(Windows: `Alt+Z` / Mac: `Ctrl+Z`)快速唤出,随时随地与 AI 对话 33 | - 🔄 **多 API 支持** - 支持配置多个 API,灵活切换不同的 AI 助手 34 | - 🔁 **配置同步** - 支持跨浏览器的 API 配置同步,轻松在不同设备间共享设置 35 | - 💻 **多平台支持** - 已上架 Chrome、Firefox 和 Edge 商店,在不同浏览器中提供一致的体验。 36 | - 📝 **全能问答** - 支持网页内容问答、PDF 文档问答、图片问答等多种场景 37 | - 🎨 **优雅渲染** - 完美支持 Markdown 文本渲染、LaTeX 数学公式显示 38 | - ⚡ **实时响应** - 采用流式输出,即时获取 AI 回复 39 | - ⏹️ **灵活控制** - 支持在生成过程中随时停止,发送新消息自动停止当前生成 40 | - 🌓 **主题切换** - 支持浅色/深色主题,呵护您的眼睛 41 | - 🌐 **网页版** - 支持网页版,无需安装,通过任何浏览器访问,支持 vercel、GitHub Pages 和 cloudflare pages 部署 42 | 43 | ## 🛠️ 技术特性 44 | 45 | - 💾 **状态持久化** - 自动保存对话历史、侧边栏状态等 46 | - 🔄 **配置同步** - 支持通过浏览器原生同步API实现跨设备配置共享 47 | - 🔍 **智能提取** - 自动识别并提取网页/PDF 内容 48 | - ⌨️ **快捷操作** - 支持快捷键清空聊天(Windows: `Alt+X` / Mac: `Ctrl+X`)、上下键快速调用历史问题 49 | - 🔒 **安全可靠** - 支持多 API Key 管理,数据本地存储 50 | - 🎭 **兼容性强** - 官方支持 Chrome、Firefox、Edge 等主流浏览器,适配各类网页环境。 51 | 52 | ## 🎮 使用指南 53 | 54 | 1. 🔑 **配置 API** 55 | - 点击设置按钮 56 | - 填写 API Key、Base URL 和模型名称 57 | - 支持添加多个 API 配置 58 | 59 | 2. 💬 **开始对话** 60 | - 使用快捷键 Windows: `Alt+Z` / Mac: `Ctrl+Z` 唤出侧边栏 61 | - 输入问题并发送 62 | - 支持图片上传进行图像问答 63 | 64 | 3. 📚 **网页/PDF 问答** 65 | - 开启网页问答开关 66 | - 自动识别并提取当前页面内容 67 | - 支持 PDF 文件智能问答 68 | 69 | ## 💡 使用技巧 70 | 71 | - ↔️ **调整侧边栏宽度** - 拖动侧边栏左侧边界可调整宽度;双击边界可重置为默认宽度 72 | - ⌨️ **发送消息** - `Enter` 发送,`Shift+Enter` 换行,`Esc` 取消输入框焦点 73 | - ⬆️⬇️ **历史问题回溯** - 输入框为空时,按 `↑`/`↓` 可循环切换最近的问题;在最新一条时再按 `↓` 可回到空输入框 74 | - 📋 **消息菜单** - 对消息右键(触屏设备长按)可复制/重新生成/删除;`Esc` 关闭菜单 75 | - 🖼️ **图片预览** - 点击图片预览大图;按 `Esc` 或点击遮罩关闭 76 | 77 | ## 🔧 高级功能 78 | 79 | - 📋 **右键复制** - 支持右键直接复制消息文本 80 | - 🔄 **历史记录** - 使用上下方向键快速调用历史问题 81 | - ⏹️ **停止生成** - 在生成消息时右键显示停止按钮,可随时中断生成 82 | - 🖼️ **图片预览** - 点击图片可查看大图 83 | - ⚙️ **自定义配置** - 支持自定义快捷键、主题等设置 84 | 85 | ## 🚀 网页版部署 86 | 87 | 1. 你可以一键将 Cerebr 的 Web 版本部署到 Vercel: 88 | 89 | [![使用 Vercel 部署](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyym68686%2Fcerebr) 90 | 91 | 2. 你可以部署到 Cloudflare Pages: 92 | 93 | 2.1 注册好 CF 账号后,申请 Workers API TOKEN。 94 | 95 | 进入 CF 首页后,右上角选择配置文件 -> 我的个人资料 -> API 令牌 -> 创建令牌 -> 编辑 Cloudflare Workers -> `账户资源`和`区域资源`可以自己选择授予权限 -> 继续以显示摘要 -> 创建令牌 -> 保存令牌(**注意:** 保存好自己的令牌,因为只显示一次) 96 | 97 | 2.2 回到首页,左侧找到 Workers -> 打开 `Worker 和 Pages` -> 点击`创建` -> `Pages` -> 导入现有 Git 存储库 -> 找到 Fork 的存储库 -> 开始部署 98 | 99 | 2.3 项目名称写上自己喜欢的名字,在`构建命令`项输入: 100 | 101 | `npm install -g wrangler && wrangler pages deploy . --project-name cerebr --branch main` 102 | 103 | 2.4 下方`环境变量(高级)` -> 添加变量: 104 | 105 | `CLOUDFLARE_API_TOKEN`:填上刚申请到的API 106 | `CLOUDFLARE_ACCOUNT_ID`:Cloudflare 控制台首页的 URL 中获取,格式如 https://dash.cloudflare.com/ 107 | 108 | 2.5 保存并部署。 109 | 110 | (由于直接构建部署会导致 API 和 accountID 会以明文形式保存,若想更改成密文,可以选择部署完成后点击`继续处理项目` -> 设置 -> 变量和机密 -> 编辑 -> 把`文本`形式更改成`密文` -> 保存) 111 | 112 | 3. 你也可以部署到 GitHub Pages: 113 | 114 | ```bash 115 | # Fork 这个仓库 116 | # 然后进入你的仓库的 Settings -> Pages 117 | # 在"构建和部署"部分: 118 | # - 将"Source"选择为"Deploy from a branch" 119 | # - 选择你的分支(main/master)和根目录(/) 120 | # - 点击保存 121 | ``` 122 | 123 | 部署将由 GitHub Actions 自动处理。你可以通过 `https://<你的用户名>.github.io/cerebr` 访问你的站点 124 | 125 | ### Web 版本特点 126 | - 🌐 无需安装,通过任何浏览器访问 127 | - 💻 与 Chrome 扩展版本具有相同的强大功能 128 | - ☁️ 部署自己的实例以获得更好的控制 129 | - 🔒 安全私密的部署方案 130 | 131 | ## mac 桌面应用 132 | 133 | 安装 dmg 后,需要执行以下命令: 134 | 135 | ```bash 136 | sudo xattr -r -d com.apple.quarantine /Applications/Cerebr.app 137 | ``` 138 | 139 | 本项目使用 Pake 打包,打包命令如下: 140 | 141 | ```bash 142 | iconutil -c icns icon.iconset 143 | pake https://xxx/ --name Cerebr --hide-title-bar --icon ./icon.icns 144 | ``` 145 | 146 | https://github.com/tw93/Pake 147 | 148 | ## 🚀 最新更新 149 | 150 | - 🆕 支持图片问答功能 151 | - 🔄 优化网页内容提取算法 152 | - 🐛 修复数学公式渲染问题 153 | - ⚡ 提升整体性能和稳定性 154 | 155 | ## 📝 开发说明 156 | 157 | 本项目采用 Chrome Extension Manifest V3 开发,主要技术栈: 158 | 159 | - 🎨 原生 JavaScript + CSS 160 | - 📦 Chrome Extension API 161 | - 🔧 PDF.js + KaTeX + Marked.js 162 | 163 | ## 🤝 贡献指南 164 | 165 | 欢迎提交 Issue 和 Pull Request 来帮助改进项目。在提交之前,请确保: 166 | 167 | - 🔍 已经搜索过相关的 Issue 168 | - ✅ 遵循现有的代码风格 169 | - 📝 提供清晰的描述和复现步骤 170 | 171 | ## 📄 许可证 172 | 173 | 本项目采用 GPLv3 许可证 174 | -------------------------------------------------------------------------------- /src/utils/ui.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 输入框配置接口 3 | * @typedef {Object} TextareaConfig 4 | * @property {number} maxHeight - 输入框最大高度 5 | */ 6 | 7 | import { t } from './i18n.js'; 8 | 9 | /** 10 | * 图片预览配置接口 11 | * @property {HTMLElement} previewModal - 预览模态框元素 12 | * @property {HTMLElement} previewImage - 预览图片元素 13 | */ 14 | 15 | /** 16 | * 图片标签配置接口 17 | * @typedef {Object} ImageTagConfig 18 | * @property {function} onImageClick - 图片点击回调 19 | * @property {function} onDeleteClick - 删除按钮点击回调 20 | */ 21 | 22 | /** 23 | * 调整输入框高度 24 | * @param {Object} params - 参数对象 25 | * @param {HTMLElement} params.textarea - 输入框元素 26 | * @param {TextareaConfig} params.config - 输入框配置 27 | */ 28 | export function adjustTextareaHeight({ 29 | textarea, 30 | config = { maxHeight: 200 } 31 | }) { 32 | textarea.style.height = 'auto'; 33 | textarea.style.height = Math.min(textarea.scrollHeight, config.maxHeight) + 'px'; 34 | if (textarea.scrollHeight > config.maxHeight) { 35 | textarea.style.overflowY = 'auto'; 36 | } else { 37 | textarea.style.overflowY = 'hidden'; 38 | } 39 | } 40 | 41 | /** 42 | * 显示图片预览 43 | * @param {Object} params - 参数对象 44 | * @param {string} params.base64Data - 图片base64数据 45 | */ 46 | export function showImagePreview({ 47 | base64Data, 48 | config 49 | }) { 50 | try { 51 | config.previewModal.__cerebrReturnFocusEl = document.activeElement; 52 | } catch { 53 | // ignore 54 | } 55 | config.previewImage.src = base64Data; 56 | config.previewModal.classList.add('visible'); 57 | const closeButton = config.previewModal.querySelector?.('.image-preview-close'); 58 | closeButton?.focus?.({ preventScroll: true }); 59 | } 60 | 61 | /** 62 | * 隐藏图片预览 63 | * @param {Object} params - 参数对象 64 | */ 65 | export function hideImagePreview({ 66 | config 67 | }) { 68 | config.previewModal.classList.remove('visible'); 69 | config.previewImage.src = ''; 70 | 71 | const returnFocusEl = config.previewModal.__cerebrReturnFocusEl; 72 | config.previewModal.__cerebrReturnFocusEl = null; 73 | if (returnFocusEl?.isConnected) { 74 | returnFocusEl.focus?.({ preventScroll: true }); 75 | } 76 | } 77 | 78 | /** 79 | * 创建图片标签 80 | * @param {Object} params - 参数对象 81 | * @param {string} params.base64Data - 图片base64数据 82 | * @param {string} [params.fileName] - 文件名(可选) 83 | * @param {ImageTagConfig} params.config - 图片标签配置 84 | * @returns {HTMLElement} 创建的图片标签元素 85 | */ 86 | export function createImageTag({ 87 | base64Data, 88 | fileName = null, 89 | config = {} 90 | }) { 91 | const resolvedFileName = fileName || t('label_image'); 92 | const safeConfig = config || {}; 93 | if (!safeConfig.onDeleteClick) { 94 | safeConfig.onDeleteClick = (container) => { 95 | try { 96 | container.remove(); 97 | const input = container.closest?.('#message-input'); 98 | input?.dispatchEvent?.(new Event('input', { bubbles: true })); 99 | } catch { 100 | // ignore 101 | } 102 | }; 103 | } 104 | const container = document.createElement('span'); 105 | container.className = 'image-tag'; 106 | container.contentEditable = false; 107 | container.setAttribute('data-image', base64Data); 108 | container.title = resolvedFileName; 109 | 110 | const thumbnail = document.createElement('img'); 111 | thumbnail.src = base64Data.startsWith('data:') ? base64Data : `data:image/png;base64,${base64Data}`; 112 | thumbnail.alt = resolvedFileName; 113 | 114 | const deleteBtn = document.createElement('button'); 115 | deleteBtn.className = 'delete-btn'; 116 | deleteBtn.innerHTML = ''; 117 | deleteBtn.title = t('label_delete_image'); 118 | 119 | // 点击删除按钮时删除整个标签 120 | deleteBtn.addEventListener('click', (e) => { 121 | e.preventDefault(); 122 | e.stopPropagation(); 123 | if (safeConfig.onDeleteClick) { 124 | safeConfig.onDeleteClick(container); 125 | } 126 | }); 127 | 128 | container.appendChild(thumbnail); 129 | container.appendChild(deleteBtn); 130 | 131 | // 点击图片区域预览图片 132 | thumbnail.addEventListener('click', (e) => { 133 | e.preventDefault(); 134 | e.stopPropagation(); 135 | if (safeConfig.onImageClick) { 136 | safeConfig.onImageClick(base64Data); 137 | } 138 | }); 139 | 140 | return container; 141 | } 142 | 143 | function ensureToastContainer() { 144 | let container = document.getElementById('toast-container'); 145 | if (container) return container; 146 | container = document.createElement('div'); 147 | container.id = 'toast-container'; 148 | container.className = 'toast-container'; 149 | container.setAttribute('role', 'status'); 150 | container.setAttribute('aria-live', 'polite'); 151 | container.setAttribute('aria-atomic', 'true'); 152 | document.body.appendChild(container); 153 | return container; 154 | } 155 | 156 | export function showToast(message, { type = 'info', durationMs = 1600 } = {}) { 157 | if (!message) return; 158 | const container = ensureToastContainer(); 159 | 160 | const toast = document.createElement('div'); 161 | const typeClass = type ? ` toast--${type}` : ''; 162 | toast.className = `toast${typeClass}`; 163 | toast.textContent = message; 164 | container.appendChild(toast); 165 | 166 | const hide = () => { 167 | toast.classList.add('toast--hide'); 168 | setTimeout(() => toast.remove(), 180); 169 | }; 170 | 171 | setTimeout(hide, durationMs); 172 | } 173 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension_name": { "message": "Cerebr" }, 3 | "extension_description": { "message": "Cerebr - AI chat assistant" }, 4 | "action_open_sidebar": { "message": "Open Cerebr sidebar" }, 5 | "command_toggle_sidebar_description": { "message": "Toggle Cerebr sidebar" }, 6 | "command_new_chat_description": { "message": "Start a new chat" }, 7 | 8 | "context_menu_label": { "message": "Message actions" }, 9 | "context_copy_message": { "message": "Copy message" }, 10 | "context_copy_code": { "message": "Copy code" }, 11 | "context_copy_image": { "message": "Copy image" }, 12 | "context_copy_math": { "message": "Copy formula" }, 13 | "context_regenerate": { "message": "Regenerate" }, 14 | "context_stop_update": { "message": "Stop updating" }, 15 | "context_delete_message": { "message": "Delete message" }, 16 | 17 | "aria_open_settings": { "message": "Open settings" }, 18 | "settings_menu_label": { "message": "Settings menu" }, 19 | "settings_dark_mode": { "message": "Dark mode" }, 20 | "settings_webpage_content": { "message": "Webpage content" }, 21 | "settings_new_chat": { "message": "New chat" }, 22 | "settings_chat_list": { "message": "Chats" }, 23 | "settings_api_settings": { "message": "API settings" }, 24 | "settings_preferences": { "message": "Preferences" }, 25 | 26 | "message_input_placeholder": { "message": "Type a message..." }, 27 | "message_input_aria": { "message": "Message input" }, 28 | 29 | "aria_back": { "message": "Back" }, 30 | "title_api_settings": { "message": "API settings" }, 31 | "api_key_label": { "message": "API key" }, 32 | "api_key_placeholder": { "message": "Enter API key" }, 33 | "base_url_label": { "message": "Base URL" }, 34 | "base_url_placeholder": { "message": "Enter Base URL" }, 35 | "model_name_label": { "message": "Model name" }, 36 | "model_name_placeholder": { "message": "Enter model name" }, 37 | "api_duplicate_config_aria": { "message": "Duplicate this config" }, 38 | "api_delete_config_aria": { "message": "Delete this config" }, 39 | "api_advanced_settings": { "message": "Advanced settings" }, 40 | "api_system_prompt_label": { "message": "System prompt" }, 41 | 42 | "title_preferences": { "message": "Preferences" }, 43 | "preferences_version": { "message": "Version" }, 44 | "preferences_open_source": { "message": "Source code" }, 45 | "preferences_group": { "message": "Community" }, 46 | "preferences_group_aria": { "message": "Telegram group: t.me/cerebr_chat" }, 47 | "preferences_font_size": { "message": "Font size" }, 48 | "preferences_font_size_aria": { "message": "Font size setting" }, 49 | "font_size_small": { "message": "Small" }, 50 | "font_size_standard": { "message": "Standard" }, 51 | "font_size_large": { "message": "Large" }, 52 | "font_size_xlarge": { "message": "Extra large" }, 53 | "preferences_language_label": { "message": "Language" }, 54 | "preferences_language_aria": { "message": "Language setting" }, 55 | "language_auto": { "message": "Auto" }, 56 | "language_en": { "message": "English" }, 57 | "language_zh_cn": { "message": "简体中文" }, 58 | "language_zh_tw": { "message": "繁體中文" }, 59 | 60 | "preferences_feedback": { "message": "Feedback" }, 61 | "preferences_feedback_action": { "message": "Open GitHub" }, 62 | 63 | "title_chat_list": { "message": "Chats" }, 64 | "chat_search_placeholder": { "message": "Search chats..." }, 65 | "chat_clear_search_aria": { "message": "Clear search" }, 66 | "chat_delete_chat_aria": { "message": "Delete chat" }, 67 | 68 | "image_preview_label": { "message": "Image preview" }, 69 | "image_preview_alt": { "message": "Preview image" }, 70 | "image_preview_close_aria": { "message": "Close image preview" }, 71 | 72 | "toast_copied_message": { "message": "Message copied" }, 73 | "toast_copied_code": { "message": "Code copied" }, 74 | "toast_copied_image": { "message": "Image copied" }, 75 | "toast_copied_math": { "message": "Formula copied" }, 76 | "toast_copy_failed": { "message": "Copy failed" }, 77 | "toast_copy_code_failed": { "message": "Copy code failed" }, 78 | "toast_copy_image_failed": { "message": "Copy image failed" }, 79 | "toast_copy_math_failed": { "message": "Copy formula failed" }, 80 | "toast_no_math_found": { "message": "No formula found to copy" }, 81 | "toast_handle_image_failed": { "message": "Failed to process image" }, 82 | 83 | "label_thinking": { "message": "Thinking" }, 84 | "label_copy": { "message": "Copy" }, 85 | "label_copied": { "message": "Copied" }, 86 | "label_copy_failed": { "message": "Copy failed" }, 87 | "label_deep_thinking": { "message": "Deep thinking" }, 88 | 89 | "chat_switch_loading": { "message": "Loading chat…" }, 90 | "chat_list_empty": { "message": "No chats yet" }, 91 | "chat_list_no_match": { "message": "No matching chats" }, 92 | "chat_new_title": { "message": "New chat" }, 93 | "chat_default_title": { "message": "Default chat" }, 94 | 95 | "webpage_tabs_loading": { "message": "Loading tabs…" }, 96 | "webpage_tabs_empty": { "message": "No available tabs" }, 97 | "youtube_transcript_prefix": { "message": "YouTube transcript:" }, 98 | "webpage_prefix_current": { "message": "Current webpage content" }, 99 | "webpage_prefix_other": { "message": "Other open webpages" }, 100 | "webpage_title_label": { "message": "Title" }, 101 | "webpage_url_label": { "message": "URL" }, 102 | "webpage_content_label": { "message": "Content" }, 103 | 104 | "label_image": { "message": "Image" }, 105 | "label_delete_image": { "message": "Delete image" }, 106 | 107 | "error_api_config_incomplete": { "message": "API config is incomplete" }, 108 | "error_response_unreadable": { "message": "Response body is unreadable" }, 109 | "error_send_failed": { "message": "Send failed: $1" }, 110 | "error_regenerate_failed": { "message": "Regenerate failed: $1" }, 111 | "error_unable_get_image_data": { "message": "Unable to get image data." }, 112 | "error_fetch_image_proxy_failed": { "message": "Failed to fetch image via proxy: $1" }, 113 | "error_read_image_failed": { "message": "Failed to read image" }, 114 | "error_parse_image_failed": { "message": "Failed to parse image" }, 115 | "error_not_image_file": { "message": "Not an image file" }, 116 | "error_image_too_large": { "message": "Image is too large. Please compress or crop it before sending." } 117 | } 118 | -------------------------------------------------------------------------------- /src/utils/i18n.js: -------------------------------------------------------------------------------- 1 | import { syncStorageAdapter } from './storage-adapter.js'; 2 | 3 | const SUPPORTED_LOCALES = ['en', 'zh_CN', 'zh_TW']; 4 | export const LANGUAGE_PREFERENCE_KEY = 'uiLanguage'; 5 | export const LANGUAGE_AUTO = 'auto'; 6 | 7 | let activeLocale = 'en'; 8 | let activeMessages = Object.create(null); 9 | let fallbackMessages = Object.create(null); 10 | let initialized = false; 11 | 12 | function normalizeLocaleTag(tag) { 13 | if (!tag) return ''; 14 | return String(tag).replace(/_/g, '-').trim(); 15 | } 16 | 17 | function mapToSupportedLocale(rawTag) { 18 | const tag = normalizeLocaleTag(rawTag).toLowerCase(); 19 | if (!tag) return 'en'; 20 | 21 | if (tag.startsWith('zh')) { 22 | // Traditional: explicit script or region hints 23 | if (tag.includes('hant') || tag.endsWith('-tw') || tag.endsWith('-hk') || tag.endsWith('-mo')) { 24 | return 'zh_TW'; 25 | } 26 | return 'zh_CN'; 27 | } 28 | return 'en'; 29 | } 30 | 31 | export function detectSystemLocale() { 32 | try { 33 | if (typeof chrome !== 'undefined' && chrome.i18n?.getUILanguage) { 34 | return mapToSupportedLocale(chrome.i18n.getUILanguage()); 35 | } 36 | } catch { 37 | // ignore 38 | } 39 | return mapToSupportedLocale(typeof navigator !== 'undefined' ? navigator.language : 'en'); 40 | } 41 | 42 | function getResourceUrl(path) { 43 | try { 44 | if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) { 45 | return chrome.runtime.getURL(path); 46 | } 47 | } catch { 48 | // ignore 49 | } 50 | return new URL(path, window.location.href).toString(); 51 | } 52 | 53 | function coerceMessagesJsonToMap(json) { 54 | const map = Object.create(null); 55 | if (!json || typeof json !== 'object') return map; 56 | for (const [key, value] of Object.entries(json)) { 57 | if (value && typeof value === 'object' && typeof value.message === 'string') { 58 | map[key] = value.message; 59 | } 60 | } 61 | return map; 62 | } 63 | 64 | async function loadLocaleMessages(locale) { 65 | const url = getResourceUrl(`_locales/${locale}/messages.json`); 66 | const response = await fetch(url, { cache: 'no-cache' }); 67 | if (!response.ok) throw new Error(`Failed to load locale ${locale}: ${response.status}`); 68 | const json = await response.json(); 69 | return coerceMessagesJsonToMap(json); 70 | } 71 | 72 | function applySubstitutions(message, substitutions) { 73 | if (!substitutions || substitutions.length === 0) return message; 74 | let out = message; 75 | substitutions.forEach((value, index) => { 76 | const token = `$${index + 1}`; 77 | out = out.split(token).join(String(value)); 78 | }); 79 | return out; 80 | } 81 | 82 | export function t(key, substitutions = []) { 83 | const raw = activeMessages[key] ?? fallbackMessages[key]; 84 | if (typeof raw !== 'string') return key; 85 | return applySubstitutions(raw, Array.isArray(substitutions) ? substitutions : [substitutions]); 86 | } 87 | 88 | export function getActiveLocale() { 89 | return activeLocale; 90 | } 91 | 92 | export function getLanguagePreferenceLabel(value) { 93 | if (value === LANGUAGE_AUTO) return t('language_auto'); 94 | if (value === 'en') return t('language_en'); 95 | if (value === 'zh_CN') return t('language_zh_cn'); 96 | if (value === 'zh_TW') return t('language_zh_tw'); 97 | return value; 98 | } 99 | 100 | export async function getLanguagePreference() { 101 | try { 102 | const result = await syncStorageAdapter.get(LANGUAGE_PREFERENCE_KEY); 103 | const value = result?.[LANGUAGE_PREFERENCE_KEY]; 104 | if (value === LANGUAGE_AUTO) return LANGUAGE_AUTO; 105 | if (SUPPORTED_LOCALES.includes(value)) return value; 106 | } catch { 107 | // ignore 108 | } 109 | return LANGUAGE_AUTO; 110 | } 111 | 112 | export async function setLanguagePreference(value) { 113 | const normalized = value === LANGUAGE_AUTO ? LANGUAGE_AUTO : (SUPPORTED_LOCALES.includes(value) ? value : LANGUAGE_AUTO); 114 | await syncStorageAdapter.set({ [LANGUAGE_PREFERENCE_KEY]: normalized }); 115 | return normalized; 116 | } 117 | 118 | export async function initI18n() { 119 | const preference = await getLanguagePreference(); 120 | const resolved = preference === LANGUAGE_AUTO ? detectSystemLocale() : preference; 121 | 122 | // Always load fallback first 123 | try { 124 | fallbackMessages = await loadLocaleMessages('en'); 125 | } catch { 126 | fallbackMessages = Object.create(null); 127 | } 128 | 129 | activeLocale = resolved; 130 | try { 131 | activeMessages = await loadLocaleMessages(resolved); 132 | } catch { 133 | activeMessages = Object.create(null); 134 | activeLocale = 'en'; 135 | } 136 | 137 | initialized = true; 138 | 139 | try { 140 | const langAttr = activeLocale === 'zh_CN' ? 'zh-CN' : (activeLocale === 'zh_TW' ? 'zh-TW' : 'en'); 141 | document.documentElement.setAttribute('lang', langAttr); 142 | } catch { 143 | // ignore 144 | } 145 | 146 | window.dispatchEvent(new CustomEvent('cerebr:localeChanged', { detail: { locale: activeLocale, preference } })); 147 | } 148 | 149 | export async function reloadI18n() { 150 | return initI18n(); 151 | } 152 | 153 | function parseAttrBindings(value) { 154 | const raw = String(value || ''); 155 | return raw 156 | .split(';') 157 | .map(s => s.trim()) 158 | .filter(Boolean) 159 | .map(pair => { 160 | const idx = pair.indexOf(':'); 161 | if (idx === -1) return null; 162 | const attr = pair.slice(0, idx).trim(); 163 | const key = pair.slice(idx + 1).trim(); 164 | if (!attr || !key) return null; 165 | return { attr, key }; 166 | }) 167 | .filter(Boolean); 168 | } 169 | 170 | export function applyI18n(root = document) { 171 | if (!root) return; 172 | 173 | const scope = root.querySelectorAll ? root : document; 174 | 175 | // Text content 176 | scope.querySelectorAll?.('[data-i18n]')?.forEach((el) => { 177 | const key = el.getAttribute('data-i18n'); 178 | if (!key) return; 179 | el.textContent = t(key); 180 | }); 181 | 182 | // Attributes 183 | scope.querySelectorAll?.('[data-i18n-attr]')?.forEach((el) => { 184 | const bindings = parseAttrBindings(el.getAttribute('data-i18n-attr')); 185 | bindings.forEach(({ attr, key }) => { 186 | const value = t(key); 187 | if (value) el.setAttribute(attr, value); 188 | }); 189 | }); 190 | 191 | // Contenteditable placeholder polyfill: keep attribute updated, and toggle a data flag for CSS hooks. 192 | scope.querySelectorAll?.('[contenteditable][placeholder]')?.forEach((el) => { 193 | const hasContent = (el.textContent || '').trim().length > 0 || !!el.querySelector?.('.image-tag'); 194 | if (!hasContent) el.classList.add('is-empty'); 195 | else el.classList.remove('is-empty'); 196 | }); 197 | } 198 | 199 | export function onLocaleChanged(handler) { 200 | if (typeof handler !== 'function') return () => {}; 201 | const listener = (e) => handler(e?.detail); 202 | window.addEventListener('cerebr:localeChanged', listener); 203 | if (initialized) handler({ locale: activeLocale, preference: null }); 204 | return () => window.removeEventListener('cerebr:localeChanged', listener); 205 | } 206 | 207 | -------------------------------------------------------------------------------- /src/utils/image.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 处理图片拖放的通用函数 3 | * @param {DragEvent} e - 拖放事件对象 4 | * @param {Object} config - 配置对象 5 | * @param {HTMLElement} config.messageInput - 消息输入框元素 6 | * @param {Function} config.createImageTag - 创建图片标签的函数 7 | * @param {Function} config.onSuccess - 成功处理后的回调函数 8 | * @param {Function} config.onError - 错误处理的回调函数 9 | */ 10 | import { t } from './i18n.js'; 11 | 12 | export async function handleImageDrop(e, config) { 13 | const { 14 | messageInput, 15 | createImageTag, 16 | onSuccess = () => {}, 17 | onError = (error) => console.error('处理拖放事件失败:', error) 18 | } = config; 19 | 20 | e.preventDefault(); 21 | e.stopPropagation(); 22 | 23 | try { 24 | // 处理文件拖放 25 | if (e.dataTransfer.files.length > 0) { 26 | const file = e.dataTransfer.files[0]; 27 | if (file.type.startsWith('image/')) { 28 | try { 29 | const base64Data = await readImageFileAsDataUrl(file); 30 | insertImageToInput({ 31 | messageInput, 32 | createImageTag, 33 | imageData: { 34 | base64Data, 35 | fileName: file.name 36 | } 37 | }); 38 | onSuccess(); 39 | } catch (error) { 40 | onError(error); 41 | } 42 | return; 43 | } 44 | } 45 | 46 | // 处理网页图片拖放 47 | const data = e.dataTransfer.getData('text/plain'); 48 | if (data) { 49 | try { 50 | const imageData = JSON.parse(data); 51 | if (imageData.type === 'image') { 52 | insertImageToInput({ 53 | messageInput, 54 | createImageTag, 55 | imageData: { 56 | base64Data: imageData.data, 57 | fileName: imageData.name 58 | } 59 | }); 60 | onSuccess(); 61 | } 62 | } catch (error) { 63 | onError(error); 64 | } 65 | } 66 | } catch (error) { 67 | onError(error); 68 | } 69 | } 70 | 71 | function estimateDataUrlBytes(dataUrl) { 72 | if (typeof dataUrl !== 'string') return 0; 73 | const commaIndex = dataUrl.indexOf(','); 74 | if (commaIndex === -1) return 0; 75 | const base64 = dataUrl.slice(commaIndex + 1); 76 | // base64 length -> bytes (rough) 77 | return Math.floor((base64.length * 3) / 4); 78 | } 79 | 80 | function readFileAsDataUrl(file) { 81 | return new Promise((resolve, reject) => { 82 | const reader = new FileReader(); 83 | reader.onload = () => resolve(reader.result); 84 | reader.onerror = () => reject(reader.error || new Error(t('error_read_image_failed'))); 85 | reader.readAsDataURL(file); 86 | }); 87 | } 88 | 89 | function loadImage(dataUrl) { 90 | return new Promise((resolve, reject) => { 91 | const img = new Image(); 92 | img.onload = () => resolve(img); 93 | img.onerror = () => reject(new Error(t('error_parse_image_failed'))); 94 | img.src = dataUrl; 95 | }); 96 | } 97 | 98 | /** 99 | * 将图片文件读取为 dataURL,并在较大时做降采样压缩,避免传输/渲染卡顿。 100 | * @param {File} file 101 | * @param {Object} [options] 102 | * @param {number} [options.maxBytes=3500000] - 目标最大字节数(近似) 103 | * @param {number} [options.maxDimension=1600] - 最大边长 104 | * @param {number} [options.maxPixels=2600000] - 最大像素数(约 2.6MP) 105 | * @returns {Promise} dataURL 106 | */ 107 | export async function readImageFileAsDataUrl( 108 | file, 109 | { maxBytes = 3_500_000, maxDimension = 1600, maxPixels = 2_600_000 } = {} 110 | ) { 111 | if (!file || !file.type?.startsWith('image/')) { 112 | throw new Error(t('error_not_image_file')); 113 | } 114 | 115 | const originalDataUrl = await readFileAsDataUrl(file); 116 | if (file.size <= maxBytes) return originalDataUrl; 117 | 118 | const img = await loadImage(originalDataUrl); 119 | const srcWidth = img.naturalWidth || img.width; 120 | const srcHeight = img.naturalHeight || img.height; 121 | if (!srcWidth || !srcHeight) return originalDataUrl; 122 | 123 | let scale = 1; 124 | const maxSide = Math.max(srcWidth, srcHeight); 125 | if (maxSide > maxDimension) { 126 | scale = Math.min(scale, maxDimension / maxSide); 127 | } 128 | const pixels = srcWidth * srcHeight; 129 | if (pixels > maxPixels) { 130 | scale = Math.min(scale, Math.sqrt(maxPixels / pixels)); 131 | } 132 | 133 | if (scale >= 0.999) { 134 | // 仅文件较大但像素不大:尝试压 jpeg 135 | scale = 1; 136 | } 137 | 138 | const targetWidth = Math.max(1, Math.round(srcWidth * scale)); 139 | const targetHeight = Math.max(1, Math.round(srcHeight * scale)); 140 | 141 | const canvas = document.createElement('canvas'); 142 | canvas.width = targetWidth; 143 | canvas.height = targetHeight; 144 | const ctx = canvas.getContext('2d', { alpha: false }); 145 | if (!ctx) return originalDataUrl; 146 | 147 | ctx.drawImage(img, 0, 0, targetWidth, targetHeight); 148 | 149 | // 逐步降低质量,直到接近目标大小(或达到下限) 150 | const mimeType = 'image/jpeg'; 151 | let quality = 0.86; 152 | let optimized = canvas.toDataURL(mimeType, quality); 153 | for (let i = 0; i < 3 && estimateDataUrlBytes(optimized) > maxBytes && quality > 0.62; i++) { 154 | quality -= 0.1; 155 | optimized = canvas.toDataURL(mimeType, quality); 156 | } 157 | 158 | // 如果仍然过大,拒绝插入(避免后续发送必然失败) 159 | const optimizedBytes = estimateDataUrlBytes(optimized); 160 | if (optimizedBytes > Math.max(maxBytes * 1.8, 8_000_000)) { 161 | throw new Error(t('error_image_too_large')); 162 | } 163 | 164 | return optimizedBytes <= estimateDataUrlBytes(originalDataUrl) ? optimized : originalDataUrl; 165 | } 166 | 167 | /** 168 | * 在输入框中插入图片 169 | * @param {Object} params - 参数对象 170 | * @param {HTMLElement} params.messageInput - 消息输入框元素 171 | * @param {Function} params.createImageTag - 创建图片标签的函数 172 | * @param {Object} params.imageData - 图片数据 173 | * @param {string} params.imageData.base64Data - 图片的base64数据 174 | * @param {string} params.imageData.fileName - 图片文件名 175 | */ 176 | function insertImageToInput({ messageInput, createImageTag, imageData }) { 177 | const imageTag = createImageTag({ 178 | base64Data: imageData.base64Data, 179 | fileName: imageData.fileName 180 | }); 181 | 182 | // 确保输入框有焦点 183 | messageInput.focus(); 184 | 185 | // 获取或创建选区 186 | const selection = window.getSelection(); 187 | let range; 188 | 189 | // 检查是否有现有选区 190 | if (selection.rangeCount > 0) { 191 | range = selection.getRangeAt(0); 192 | } else { 193 | // 创建新的选区 194 | range = document.createRange(); 195 | // 将选区设置到输入框的末尾 196 | range.selectNodeContents(messageInput); 197 | range.collapse(false); 198 | selection.removeAllRanges(); 199 | selection.addRange(range); 200 | } 201 | 202 | // 插入图片标签 203 | range.deleteContents(); 204 | range.insertNode(imageTag); 205 | 206 | // 移动光标到图片标签后面 207 | const newRange = document.createRange(); 208 | newRange.setStartAfter(imageTag); 209 | newRange.collapse(true); 210 | selection.removeAllRanges(); 211 | selection.addRange(newRange); 212 | 213 | // 触发输入事件以调整高度 214 | messageInput.dispatchEvent(new Event('input')); 215 | } 216 | -------------------------------------------------------------------------------- /src/utils/reading-progress.js: -------------------------------------------------------------------------------- 1 | const READING_PROGRESS_KEY_PREFIX = 'cerebr_reading_progress_v1_'; 2 | 3 | function keyForChatId(chatId) { 4 | return `${READING_PROGRESS_KEY_PREFIX}${chatId}`; 5 | } 6 | 7 | function clamp(value, min, max) { 8 | if (Number.isNaN(value)) return min; 9 | if (value < min) return min; 10 | if (value > max) return max; 11 | return value; 12 | } 13 | 14 | function getFirstVisibleMessageState(chatContainer, marginPx = 12) { 15 | const messages = chatContainer.querySelectorAll('.message'); 16 | if (!messages.length) return null; 17 | 18 | const visibleTop = chatContainer.scrollTop + marginPx; 19 | 20 | // Binary search for the first message whose bottom >= visibleTop. 21 | let lo = 0; 22 | let hi = messages.length - 1; 23 | let ans = 0; 24 | while (lo <= hi) { 25 | const mid = (lo + hi) >> 1; 26 | const el = messages[mid]; 27 | const bottom = el.offsetTop + el.offsetHeight; 28 | if (bottom >= visibleTop) { 29 | ans = mid; 30 | hi = mid - 1; 31 | } else { 32 | lo = mid + 1; 33 | } 34 | } 35 | 36 | const anchor = messages[ans]; 37 | if (!anchor) return null; 38 | 39 | const anchorOffsetPx = Math.round(chatContainer.scrollTop - anchor.offsetTop); 40 | return { 41 | anchorIndex: ans, 42 | anchorOffsetPx 43 | }; 44 | } 45 | 46 | export function createReadingProgressManager({ 47 | chatContainer, 48 | getActiveChatId, 49 | storage 50 | }) { 51 | let isStarted = false; 52 | let isRestoring = false; 53 | let saveTimer = null; 54 | let pendingChatId = null; 55 | let pendingPayload = null; 56 | let lastSavedChatId = null; 57 | let lastSavedPayload = null; 58 | let pendingRestoreChatId = null; 59 | 60 | const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); 61 | 62 | const saveNow = async (chatId = getActiveChatId?.()) => { 63 | if (!chatId) return; 64 | if (!chatContainer?.isConnected) return; 65 | if (isRestoring) return; 66 | 67 | const anchorState = getFirstVisibleMessageState(chatContainer); 68 | if (!anchorState) return; 69 | 70 | const payload = { 71 | v: 1, 72 | ...anchorState, 73 | updatedAt: Date.now() 74 | }; 75 | 76 | // Avoid hammering storage when nothing changed. 77 | if (lastSavedChatId === chatId && lastSavedPayload) { 78 | if (lastSavedPayload.anchorIndex === payload.anchorIndex && 79 | lastSavedPayload.anchorOffsetPx === payload.anchorOffsetPx) { 80 | return; 81 | } 82 | } 83 | 84 | lastSavedChatId = chatId; 85 | lastSavedPayload = payload; 86 | await storage.set({ [keyForChatId(chatId)]: payload }); 87 | }; 88 | 89 | const flushPending = async () => { 90 | if (!pendingChatId || !pendingPayload) return; 91 | const chatId = pendingChatId; 92 | const payload = pendingPayload; 93 | pendingChatId = null; 94 | pendingPayload = null; 95 | 96 | if (lastSavedChatId === chatId && lastSavedPayload) { 97 | if (lastSavedPayload.anchorIndex === payload.anchorIndex && 98 | lastSavedPayload.anchorOffsetPx === payload.anchorOffsetPx) { 99 | return; 100 | } 101 | } 102 | 103 | lastSavedChatId = chatId; 104 | lastSavedPayload = payload; 105 | await storage.set({ [keyForChatId(chatId)]: payload }); 106 | }; 107 | 108 | const queueSave = () => { 109 | clearTimeout(saveTimer); 110 | saveTimer = setTimeout(() => void flushPending(), 120); 111 | }; 112 | 113 | const onScroll = () => { 114 | if (!isStarted) return; 115 | if (isRestoring) return; 116 | // While we have a pending restore, don't overwrite stored progress with the initial top position. 117 | if (pendingRestoreChatId) return; 118 | 119 | const chatId = getActiveChatId?.(); 120 | if (!chatId) return; 121 | if (!chatContainer?.isConnected) return; 122 | 123 | const anchorState = getFirstVisibleMessageState(chatContainer); 124 | if (!anchorState) return; 125 | 126 | const payload = { 127 | v: 1, 128 | ...anchorState, 129 | updatedAt: Date.now() 130 | }; 131 | 132 | pendingChatId = chatId; 133 | pendingPayload = payload; 134 | queueSave(); 135 | }; 136 | 137 | const onVisibilityChange = () => { 138 | if (document.visibilityState === 'hidden') { 139 | clearTimeout(saveTimer); 140 | void saveNow(); 141 | } 142 | }; 143 | 144 | const onBeforeUnload = () => { 145 | clearTimeout(saveTimer); 146 | void saveNow(); 147 | }; 148 | 149 | const restore = async (chatId) => { 150 | if (!chatId) return false; 151 | if (!chatContainer?.isConnected) return false; 152 | 153 | pendingRestoreChatId = chatId; 154 | isRestoring = true; 155 | try { 156 | const key = keyForChatId(chatId); 157 | const result = await storage.get(key); 158 | const state = result?.[key]; 159 | 160 | // Allow one layout tick after messages are rendered. 161 | await nextFrame(); 162 | await nextFrame(); 163 | 164 | const messages = chatContainer.querySelectorAll('.message'); 165 | if (!messages.length) return false; 166 | 167 | let targetScrollTop; 168 | if (!state || typeof state !== 'object') { 169 | targetScrollTop = chatContainer.scrollHeight; 170 | } else { 171 | const anchorIndex = typeof state.anchorIndex === 'number' ? state.anchorIndex : 0; 172 | const anchorOffsetPx = typeof state.anchorOffsetPx === 'number' ? state.anchorOffsetPx : 0; 173 | if (anchorIndex >= messages.length) { 174 | return false; 175 | } 176 | const clampedIndex = clamp(anchorIndex, 0, messages.length - 1); 177 | const anchor = messages[clampedIndex]; 178 | targetScrollTop = (anchor?.offsetTop || 0) + anchorOffsetPx; 179 | } 180 | 181 | const maxScrollTop = Math.max(0, chatContainer.scrollHeight - chatContainer.clientHeight); 182 | chatContainer.scrollTop = clamp(targetScrollTop, 0, maxScrollTop); 183 | return true; 184 | } catch { 185 | return false; 186 | } finally { 187 | pendingRestoreChatId = null; 188 | isRestoring = false; 189 | } 190 | }; 191 | 192 | const clear = async (chatId) => { 193 | if (!chatId) return; 194 | await storage.remove(keyForChatId(chatId)); 195 | }; 196 | 197 | const start = () => { 198 | if (isStarted) return; 199 | isStarted = true; 200 | chatContainer.addEventListener('scroll', onScroll, { passive: true }); 201 | document.addEventListener('visibilitychange', onVisibilityChange); 202 | window.addEventListener('beforeunload', onBeforeUnload); 203 | }; 204 | 205 | const stop = () => { 206 | if (!isStarted) return; 207 | isStarted = false; 208 | clearTimeout(saveTimer); 209 | saveTimer = null; 210 | pendingChatId = null; 211 | pendingPayload = null; 212 | pendingRestoreChatId = null; 213 | chatContainer.removeEventListener('scroll', onScroll); 214 | document.removeEventListener('visibilitychange', onVisibilityChange); 215 | window.removeEventListener('beforeunload', onBeforeUnload); 216 | }; 217 | 218 | return { 219 | start, 220 | stop, 221 | restore, 222 | clear, 223 | saveNow 224 | }; 225 | } 226 | -------------------------------------------------------------------------------- /styles/base/variables.css: -------------------------------------------------------------------------------- 1 | /* 根变量(默认浅色主题) */ 2 | :root { 3 | color-scheme: light dark; 4 | --cerebr-fs-12: 12px; 5 | --cerebr-fs-13: 13px; 6 | --cerebr-fs-14: 14px; 7 | --cerebr-fs-16: 16px; 8 | --cerebr-bg-color: #f5f5f7; 9 | --cerebr-text-color: #1d1d1f; 10 | --cerebr-text-color-secondary: rgba(0, 0, 0, 0.55); 11 | --cerebr-hover-bg-color: rgba(0, 0, 0, 0.05); 12 | --cerebr-surface-bg: rgba(255, 255, 255, 0.72); 13 | --cerebr-surface-border: rgba(0, 0, 0, 0.14); 14 | --cerebr-surface-backdrop-filter: blur(var(--cerebr-blur-radius)) saturate(1.2); 15 | --settings-bg-color: var(--cerebr-surface-bg); 16 | --settings-border-color: var(--cerebr-surface-border); 17 | --settings-hover-bg-color: var(--cerebr-message-user-bg); 18 | --cerebr-message-user-bg: #e6eaf0; 19 | --cerebr-message-ai-bg: #ffffff; 20 | --cerebr-input-bg: #ffffff; 21 | --cerebr-icon-color: #666666; 22 | /* --cerebr-bg-color: #f2f2f7; 23 | --cerebr-text-color: #1d1d1f; 24 | --cerebr-message-user-bg: #e5e5ea; 25 | --cerebr-message-ai-bg: #ffffff; 26 | --cerebr-input-bg: #ffffff; 27 | --cerebr-icon-color: #8e8e93; */ 28 | --cerebr-blur-radius: 12px; 29 | --cerebr-card-border-color: rgba(0, 0, 0, 0.15); 30 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 31 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.1); 32 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 33 | --cerebr-inline-code-bg: rgba(175, 184, 193, 0.2); 34 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.2); 35 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 36 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 37 | --cerebr-image-tag-bg: rgba(0, 122, 255, 0.08); 38 | --cerebr-image-tag-border-color: rgba(0, 122, 255, 0.15); 39 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.05); 40 | --cerebr-image-tag-hover-bg: rgba(0, 122, 255, 0.12); 41 | --cerebr-blockquote-text-color: rgba(0, 0, 0, 0.7); 42 | --cerebr-blockquote-border-color: rgba(0, 0, 0, 0.2); 43 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.15); 44 | --cerebr-message-shadow: rgba(0, 0, 0, 0.1); 45 | --cerebr-link-color: #0366d6; 46 | --cerebr-toggle-hover-bg: rgba(0, 0, 0, 0.06); 47 | --cerebr-reasoning-bg: rgba(0, 0, 0, 0.03); 48 | --cerebr-reasoning-text-color: #666; 49 | --cerebr-reasoning-hover-bg: rgba(0, 0, 0, 0.05); 50 | --cerebr-toggle-bg-off: rgba(128, 128, 128, 0.3); 51 | --cerebr-toggle-bg-on: #34c759; 52 | --cerebr-input-border-color: rgba(0, 0, 0, 0.15); 53 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.1); 54 | --cerebr-toast-bg: rgba(0, 0, 0, 0.78); 55 | --cerebr-toast-text: #ffffff; 56 | --cerebr-toast-error-bg: rgba(255, 59, 48, 0.92); 57 | --cerebr-toast-success-bg: rgba(52, 199, 89, 0.92); 58 | } 59 | 60 | /* 深色主题变量 */ 61 | :root.dark-theme { 62 | color-scheme: dark; 63 | --cerebr-bg-color: #262B33; 64 | --cerebr-text-color: #d8dde6; 65 | --cerebr-text-color-secondary: rgba(255, 255, 255, 0.62); 66 | --cerebr-hover-bg-color: rgba(255, 255, 255, 0.08); 67 | --cerebr-surface-bg: rgba(38, 43, 51, 0.78); 68 | --cerebr-surface-border: rgba(255, 255, 255, 0.14); 69 | --cerebr-surface-backdrop-filter: blur(var(--cerebr-blur-radius)) saturate(1.15); 70 | --settings-bg-color: var(--cerebr-surface-bg); 71 | --settings-border-color: var(--cerebr-surface-border); 72 | --settings-hover-bg-color: var(--cerebr-message-user-bg); 73 | --cerebr-message-user-bg: #3E4451; 74 | --cerebr-message-ai-bg: #2c313c; 75 | --cerebr-input-bg: #21252b; 76 | --cerebr-icon-color: #abb2bf; 77 | --cerebr-card-border-color: rgba(255, 255, 255, 0.1); 78 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 79 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.25); 80 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 81 | --cerebr-inline-code-bg: rgba(99, 110, 123, 0.4); 82 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.3); 83 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 84 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 85 | --cerebr-image-tag-bg: rgba(10, 132, 255, 0.12); 86 | --cerebr-image-tag-border-color: rgba(10, 132, 255, 0.2); 87 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.2); 88 | --cerebr-image-tag-hover-bg: rgba(10, 132, 255, 0.18); 89 | --cerebr-blockquote-text-color: rgba(255, 255, 255, 0.7); 90 | --cerebr-blockquote-border-color: rgba(255, 255, 255, 0.2); 91 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.2); 92 | --cerebr-message-shadow: rgba(0, 0, 0, 0.15); 93 | --cerebr-link-color: #58a6ff; 94 | --cerebr-toggle-hover-bg: rgba(255, 255, 255, 0.08); 95 | --cerebr-reasoning-bg: rgba(255, 255, 255, 0.03); 96 | --cerebr-reasoning-text-color: #b3b3b3; 97 | --cerebr-reasoning-hover-bg: rgba(255, 255, 255, 0.05); 98 | --cerebr-toggle-bg-off: rgba(255, 255, 255, 0.15); 99 | --cerebr-toggle-bg-on: #32d74b; 100 | --cerebr-input-border-color: rgba(255, 255, 255, 0.15); 101 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.2); 102 | --cerebr-toast-bg: rgba(0, 0, 0, 0.80); 103 | --cerebr-toast-text: #ffffff; 104 | --cerebr-toast-error-bg: rgba(255, 69, 58, 0.92); 105 | --cerebr-toast-success-bg: rgba(48, 209, 88, 0.92); 106 | } 107 | 108 | /* 强制浅色主题时的原生控件配色 */ 109 | :root.light-theme { 110 | color-scheme: light; 111 | } 112 | 113 | /* 系统深色主题 */ 114 | @media (prefers-color-scheme: dark) { 115 | :root:not(.light-theme) { 116 | color-scheme: dark; 117 | --cerebr-bg-color: #262B33; 118 | --cerebr-text-color: #d8dde6; 119 | --cerebr-text-color-secondary: rgba(255, 255, 255, 0.62); 120 | --cerebr-hover-bg-color: rgba(255, 255, 255, 0.08); 121 | --cerebr-surface-bg: rgba(38, 43, 51, 0.78); 122 | --cerebr-surface-border: rgba(255, 255, 255, 0.14); 123 | --cerebr-surface-backdrop-filter: blur(var(--cerebr-blur-radius)) saturate(1.15); 124 | --settings-bg-color: var(--cerebr-surface-bg); 125 | --settings-border-color: var(--cerebr-surface-border); 126 | --settings-hover-bg-color: var(--cerebr-message-user-bg); 127 | --cerebr-message-user-bg: #3E4451; 128 | --cerebr-message-ai-bg: #2c313c; 129 | --cerebr-input-bg: #21252b; 130 | --cerebr-icon-color: #abb2bf; 131 | --cerebr-card-border-color: rgba(255, 255, 255, 0.1); 132 | --cerebr-highlight-border-color: rgba(0, 122, 255, 0.5); 133 | --cerebr-button-hover-bg: rgba(0, 0, 0, 0.25); 134 | --cerebr-focus-border-color: rgba(0, 122, 255, 0.3); 135 | --cerebr-inline-code-bg: rgba(99, 110, 123, 0.4); 136 | --cerebr-popup-shadow: rgba(0, 0, 0, 0.3); 137 | --cerebr-modal-overlay-bg: rgba(0, 0, 0, 0.8); 138 | --cerebr-close-button-bg: rgba(0, 0, 0, 0.6); 139 | --cerebr-image-tag-bg: rgba(10, 132, 255, 0.12); 140 | --cerebr-image-tag-border-color: rgba(10, 132, 255, 0.2); 141 | --cerebr-image-tag-shadow: rgba(0, 0, 0, 0.2); 142 | --cerebr-image-tag-hover-bg: rgba(10, 132, 255, 0.18); 143 | --cerebr-blockquote-text-color: rgba(255, 255, 255, 0.7); 144 | --cerebr-blockquote-border-color: rgba(255, 255, 255, 0.2); 145 | --cerebr-message-hover-shadow: rgba(0, 0, 0, 0.2); 146 | --cerebr-message-shadow: rgba(0, 0, 0, 0.15); 147 | --cerebr-link-color: #58a6ff; 148 | --cerebr-toggle-hover-bg: rgba(255, 255, 255, 0.08); 149 | --cerebr-reasoning-bg: rgba(255, 255, 255, 0.03); 150 | --cerebr-reasoning-text-color: #b3b3b3; 151 | --cerebr-reasoning-hover-bg: rgba(255, 255, 255, 0.05); 152 | --cerebr-toggle-bg-off: rgba(255, 255, 255, 0.15); 153 | --cerebr-toggle-bg-on: #32d74b; 154 | --cerebr-input-border-color: rgba(255, 255, 255, 0.15); 155 | --cerebr-sidebar-box-shadow: -2px 0 15px rgba(0, 0, 0, 0.2); 156 | --cerebr-toast-bg: rgba(0, 0, 0, 0.80); 157 | --cerebr-toast-text: #ffffff; 158 | --cerebr-toast-error-bg: rgba(255, 69, 58, 0.92); 159 | --cerebr-toast-success-bg: rgba(48, 209, 88, 0.92); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/services/chat.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API配置接口 3 | * @typedef {Object} APIConfig 4 | * @property {string} baseUrl - API的基础URL 5 | * @property {string} apiKey - API密钥 6 | * @property {string} [modelName] - 模型名称,默认为 "gpt-4o" 7 | */ 8 | 9 | import { normalizeChatCompletionsUrl } from '../utils/api-url.js'; 10 | import { t } from '../utils/i18n.js'; 11 | 12 | /** 13 | * 网页信息接口 14 | * @typedef {Object} WebpageInfo 15 | * @property {string} title - 网页标题 16 | * @property {string} url - 网页URL 17 | * @property {string} content - 网页内容 18 | */ 19 | 20 | /** 21 | * 消息接口 22 | * @typedef {Object} Message 23 | * @property {string} role - 消息角色 ("system" | "user" | "assistant") 24 | * @property {string | Array<{type: string, text?: string, image_url?: {url: string}}>} content - 消息内容 25 | */ 26 | 27 | /** 28 | * API调用参数接口 29 | * @typedef {Object} APIParams 30 | * @property {Array} messages - 消息历史 31 | * @property {APIConfig} apiConfig - API配置 32 | * @property {string} userLanguage - 用户语言 33 | * @property {WebpageInfo} [webpageInfo] - 网页信息(可选) 34 | */ 35 | 36 | /** 37 | * 调用API发送消息并处理响应 38 | * @param {APIParams} params - API调用参数 39 | * @param {Object} chatManager - 聊天管理器实例 40 | * @param {string} chatId - 当前聊天ID 41 | * @param {Function} onMessageUpdate - 消息更新回调函数 42 | * @returns {Promise<{processStream: () => Promise<{content: string, reasoning_content: string}>, controller: AbortController}>} 43 | */ 44 | export async function callAPI({ 45 | messages, 46 | apiConfig, 47 | userLanguage, 48 | webpageInfo = null, 49 | }, chatManager, chatId, onMessageUpdate) { 50 | const baseUrl = normalizeChatCompletionsUrl(apiConfig?.baseUrl); 51 | if (!baseUrl || !apiConfig?.apiKey) { 52 | throw new Error(t('error_api_config_incomplete')); 53 | } 54 | 55 | // 构建系统消息 56 | let systemPrompt = apiConfig.advancedSettings?.systemPrompt || ''; 57 | systemPrompt = systemPrompt.replace(/\{\{userLanguage\}\}/gm, userLanguage) 58 | 59 | const systemMessage = { 60 | role: "system", 61 | content: `${systemPrompt}${ 62 | (webpageInfo && webpageInfo.pages) ? 63 | webpageInfo.pages.map(page => { 64 | const prefix = page.isCurrent ? t('webpage_prefix_current') : t('webpage_prefix_other'); 65 | const titleLabel = t('webpage_title_label'); 66 | const urlLabel = t('webpage_url_label'); 67 | const contentLabel = t('webpage_content_label'); 68 | return `\n${prefix}:\n${titleLabel}: ${page.title}\n${urlLabel}: ${page.url}\n${contentLabel}: ${page.content}`; 69 | }).join('\n\n---\n') : 70 | '' 71 | }` 72 | }; 73 | 74 | // 确保消息数组中有系统消息 75 | // 删除消息列表中的reasoning_content字段 76 | const processedMessages = messages.map(msg => { 77 | const { reasoning_content, updating, ...rest } = msg; 78 | return rest; 79 | }); 80 | 81 | if (systemMessage.content.trim() && (processedMessages.length === 0 || processedMessages[0].role !== "system")) { 82 | processedMessages.unshift(systemMessage); 83 | } 84 | 85 | // 注意:为了支持“首 token 前”也能立即停止更新,我们需要尽早把 controller 暴露出去。 86 | // 因此 fetch 的执行被延后到 processStream 内部。 87 | const controller = new AbortController(); 88 | const signal = controller.signal; 89 | 90 | const requestInit = { 91 | method: 'POST', 92 | headers: { 93 | 'Content-Type': 'application/json', 94 | 'Authorization': `Bearer ${apiConfig.apiKey}` 95 | }, 96 | body: JSON.stringify({ 97 | model: apiConfig.modelName || "gpt-4o", 98 | messages: processedMessages, 99 | stream: true, 100 | }), 101 | signal 102 | }; 103 | 104 | const processStream = async () => { 105 | let reader; 106 | try { 107 | const response = await fetch(baseUrl, requestInit); 108 | 109 | if (!response.ok) { 110 | const errorText = await response.text().catch(() => ''); 111 | throw new Error(errorText || response.statusText || `HTTP ${response.status}`); 112 | } 113 | 114 | // 处理流式响应 115 | reader = response.body?.getReader?.(); 116 | if (!reader) { 117 | throw new Error(t('error_response_unreadable')); 118 | } 119 | 120 | let buffer = ''; 121 | let currentMessage = { 122 | content: '', 123 | reasoning_content: '' 124 | }; 125 | let lastUpdateTime = 0; 126 | let updateTimeout = null; 127 | const UPDATE_INTERVAL = 100; // 每100ms更新一次 128 | 129 | const dispatchUpdate = () => { 130 | if (chatManager && chatId) { 131 | // 创建一个副本以避免回调函数意外修改 132 | const messageCopy = { ...currentMessage }; 133 | chatManager.updateLastMessage(chatId, messageCopy); 134 | onMessageUpdate(chatId, messageCopy); 135 | lastUpdateTime = Date.now(); 136 | } 137 | if (updateTimeout) { 138 | clearTimeout(updateTimeout); 139 | updateTimeout = null; 140 | } 141 | }; 142 | 143 | while (true) { 144 | const { done, value } = await reader.read(); 145 | if (done) { 146 | // 确保最后的数据被发送 147 | if (Date.now() - lastUpdateTime > 0) { 148 | dispatchUpdate(); 149 | } 150 | // console.log('[chat.js] processStream: 响应流结束'); 151 | break; 152 | } 153 | 154 | const chunk = new TextDecoder().decode(value); 155 | buffer += chunk; 156 | 157 | let newlineIndex; 158 | while ((newlineIndex = buffer.indexOf('\n')) !== -1) { 159 | const line = buffer.slice(0, newlineIndex); 160 | buffer = buffer.slice(newlineIndex + 1); 161 | 162 | if (line.startsWith('data: ')) { 163 | const data = line.slice(6); 164 | if (data === '[DONE]') { 165 | continue; 166 | } 167 | 168 | try { 169 | const delta = JSON.parse(data).choices[0]?.delta; 170 | let hasUpdate = false; 171 | if (delta?.content) { 172 | currentMessage.content += delta.content; 173 | hasUpdate = true; 174 | } 175 | if (delta?.reasoning_content) { 176 | currentMessage.reasoning_content += delta.reasoning_content; 177 | hasUpdate = true; 178 | } 179 | 180 | if (hasUpdate) { 181 | if (!updateTimeout) { 182 | // 如果距离上次更新超过了间隔,则立即更新 183 | if (Date.now() - lastUpdateTime > UPDATE_INTERVAL) { 184 | dispatchUpdate(); 185 | } else { 186 | // 否则,设置一个定时器,在间隔的剩余时间后更新 187 | updateTimeout = setTimeout(dispatchUpdate, UPDATE_INTERVAL - (Date.now() - lastUpdateTime)); 188 | } 189 | } 190 | } 191 | } catch (e) { 192 | console.error('解析数据时出错:', e); 193 | } 194 | } 195 | } 196 | } 197 | 198 | return currentMessage; 199 | } catch (error) { 200 | if (error.name === 'AbortError') { 201 | return; 202 | } 203 | throw error; 204 | } finally { 205 | try { 206 | await reader?.cancel?.(); 207 | } catch { 208 | // ignore 209 | } 210 | } 211 | }; 212 | 213 | return { 214 | processStream, 215 | controller 216 | }; 217 | } 218 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Chrome Web Store 8 | 9 | 10 | Edge Add-on 11 | 12 | 13 | Firefox Add-on 14 | 15 | 16 | 17 | 18 |

19 | 20 | [English](./README.md) | [Simplified Chinese](./README_CN.md) 21 | 22 | # 🧠 Cerebr - Intelligent AI Assistant 23 | 24 | ![screenshot](./statics/image.png) 25 | 26 | The name "Cerebr" comes from a Latin root related to "brain" or "cerebrum". This etymology reflects our vision: to integrate powerful AI capabilities from Claude, OpenAI, and others, making Cerebr your second brain for deep reading and understanding. Cerebr is a powerful browser AI assistant extension, available for Chrome, Firefox, and Edge, focused on enhancing your work efficiency and learning experience. 27 | 28 | Born from a need for a clean, efficient browser AI assistant, Cerebr stands out with its minimalist design and powerful features. While other solutions often come with limitations or cluttered interfaces, Cerebr focuses on delivering a seamless, distraction-free experience for your web browsing needs. 29 | 30 | ## ✨ Core Features 31 | 32 | - 🎯 **Smart Sidebar** - Quick access via hotkey (Windows: `Alt+Z` / Mac: `Ctrl+Z`) to chat with AI anytime, anywhere 33 | - 🔄 **Multiple API Support** - Configure multiple APIs to flexibly switch between different AI assistants 34 | - 🔁 **Config Sync** - Cross-browser API configuration synchronization for seamless device switching 35 | - 💻 **Multi-Platform Support** - Available for Chrome, Firefox, and Edge, bringing a consistent experience across browsers. 36 | - 📝 **Comprehensive Q&A** - Support webpage content Q&A, PDF document Q&A, image Q&A and more 37 | - 🎨 **Elegant Rendering** - Perfect support for Markdown text rendering and LaTeX math formula display 38 | - ⚡ **Real-time Response** - Stream output for instant AI replies 39 | - ⏹️ **Flexible Control** - Support stopping generation at any time, sending new messages will stop the current generation 40 | - 🌓 **Theme Switching** - Support light/dark themes to protect your eyes 41 | - 🌐 **Web Version** - Support web version, no installation required, accessable from any browser, support vercel, GitHub Pages and cloudflare pages deployment 42 | 43 | ## 🛠️ Technical Features 44 | 45 | - 💾 **State Persistence** - Automatically save chat history, sidebar status, etc. 46 | - 🔄 **Config Sync** - Cross-device configuration sharing through browser's native sync API 47 | - 🔍 **Smart Extraction** - Automatically identify and extract webpage/PDF content 48 | - ⌨️ **Shortcut Operations** - Support hotkey to clear chat (Windows: `Alt+X` / Mac: `Ctrl+X`), up/down keys for quick history recall 49 | - 🔒 **Secure & Reliable** - Support multiple API key management with local data storage 50 | - 🎭 **High Compatibility** - Officially supports Chrome, Firefox, and Edge, adapting to various webpage environments. 51 | 52 | ## 🎮 User Guide 53 | 54 | 1. 🔑 **Configure API** 55 | - Click the settings button 56 | - Fill in API Key, Base URL and model name 57 | - Support adding multiple API configurations 58 | 59 | 2. 💬 **Start Chatting** 60 | - Use hotkey Windows: `Alt+Z` / Mac: `Ctrl+Z` to summon sidebar 61 | - Input questions and send 62 | - Support image upload for visual Q&A 63 | 64 | 3. 📚 **Webpage/PDF Q&A** 65 | - Enable webpage Q&A switch 66 | - Automatically identify and extract current page content 67 | - Support intelligent PDF file Q&A 68 | 69 | ## 💡 Tips & Shortcuts 70 | 71 | - ↔️ **Resize Sidebar** - Drag the sidebar’s left edge to resize; double-click the edge to reset to default width 72 | - ⌨️ **Send Message** - `Enter` to send, `Shift+Enter` for a new line, `Esc` to blur the input 73 | - ⬆️⬇️ **Recall Previous Questions** - When the input is empty, press `↑`/`↓` to cycle through your recent questions; press `↓` at the newest item to return to an empty input 74 | - 📋 **Context Menu** - Right-click a message (or long-press on touch devices) for copy/regenerate/delete; `Esc` to close 75 | - 🖼️ **Image Preview** - Click an image to preview; press `Esc` or click outside to close 76 | 77 | ## 🔧 Advanced Features 78 | 79 | - 📋 **Right-click Copy** - Support right-click to directly copy message text 80 | - 🔄 **History Records** - Use up/down arrow keys to quickly recall historical questions 81 | - ⏹️ **Stop Generation** - Show stop button when generating messages, can stop generation at any time 82 | - 🖼️ **Image Preview** - Click images to view full size 83 | - ⚙️ **Custom Settings** - Support customizing hotkeys, themes and more 84 | 85 | ## 🚀 Web Version Deploy 86 | 87 | 1. You can quickly deploy the web version of Cerebr to Vercel with one click: 88 | 89 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyym68686%2Fcerebr) 90 | 91 | 2. You can deploy to Cloudflare Pages: 92 | 93 | 2.1 After registering a Cloudflare account, apply for a Workers API TOKEN. 94 | 95 | After entering the Cloudflare homepage, select "Profile" in the upper right corner -> "My Profile" -> "API Tokens" -> "Create Token" -> "Edit Cloudflare Workers" -> You can choose the permissions for "Account Resources" and "Zone Resources" by yourself -> Continue to summary -> Create Token -> Save the token (**Note:** Save your token properly as it will only be displayed once). 96 | 97 | 2.2 Return to the homepage, find "Workers" on the left -> Open "Workers & Pages" -> Click "Create" -> "Pages" -> "Import an existing Git repository" -> Find the forked repository -> Begin setup. 98 | 99 | 2.3 Enter a name you like for the project, and in the "Build command" field, input: 100 | 101 | `npm install -g wrangler && wrangler pages deploy . --project-name cerebr --branch main` 102 | 103 | 2.4 In the "Environment variables (advanced)" section below -> Add variable: 104 | 105 | `CLOUDFLARE_API_TOKEN`: Fill in the API just applied for 106 | `CLOUDFLARE_ACCOUNT_ID`: Obtained from the URL of the Cloudflare dashboard homepage, in the format like https://dash.cloudflare.com/ 107 | 108 | 2.5 Save and deploy. 109 | 110 | (Since direct build and deployment will cause the API and accountID to be saved in plain text, if you want to change them to ciphertext, you can choose to click "Continue to project" after deployment is completed -> "Settings" -> "Variables and Secrets" -> "Edit" -> Change the "Text" form to "Secret" -> Save) 111 | 112 | 3. You can also deploy to GitHub Pages: 113 | 114 | ```bash 115 | # Fork this repository 116 | # Then go to your repository's Settings -> Pages 117 | # In the "Build and deployment" section: 118 | # - Select "Deploy from a branch" as Source 119 | # - Choose your branch (main/master) and root (/) folder 120 | # - Click Save 121 | ``` 122 | 123 | The deployment will be automatically handled by GitHub Actions. You can access your site at `https://.github.io/cerebr` 124 | 125 | ### Web Version Features 126 | - 🌐 Access Cerebr from any browser without installation 127 | - 💻 Same powerful features as the Chrome extension 128 | - ☁️ Deploy your own instance for better control 129 | - 🔒 Secure and private deployment 130 | 131 | ## 📦 Desktop Application 132 | 133 | After installing the dmg file, you need to execute the following command: 134 | 135 | ```bash 136 | sudo xattr -r -d com.apple.quarantine /Applications/Cerebr.app 137 | ``` 138 | 139 | This project uses Pake to pack the dmg file, the command is as follows: 140 | 141 | ```bash 142 | iconutil -c icns icon.iconset 143 | pake https://xxx/ --name Cerebr --hide-title-bar --icon ./icon.icns 144 | ``` 145 | 146 | https://github.com/tw93/Pake 147 | 148 | ## 🚀 Latest Updates 149 | 150 | - 🆕 Added image Q&A functionality 151 | - 🔄 Optimized webpage content extraction algorithm 152 | - 🐛 Fixed math formula rendering issues 153 | - ⚡ Improved overall performance and stability 154 | 155 | ## 📝 Development Notes 156 | 157 | This project is developed using Chrome Extension Manifest V3, with main tech stack: 158 | 159 | - 🎨 Native JavaScript + CSS 160 | - 📦 Chrome Extension API 161 | - 🔧 PDF.js + KaTeX + Marked.js 162 | 163 | ## 🤝 Contribution Guide 164 | 165 | Welcome to submit Issues and Pull Requests to help improve the project. Before submitting, please ensure: 166 | 167 | - 🔍 You have searched related issues 168 | - ✅ Follow existing code style 169 | - 📝 Provide clear description and reproduction steps 170 | 171 | ## 📄 License 172 | 173 | This project is licensed under the GPLv3 License 174 | -------------------------------------------------------------------------------- /src/components/webpage-menu.js: -------------------------------------------------------------------------------- 1 | import { storageAdapter, browserAdapter } from '../utils/storage-adapter.js'; 2 | import { chatManager } from '../utils/chat-manager.js'; 3 | import { t } from '../utils/i18n.js'; 4 | 5 | const YT_TRANSCRIPT_KEY_PREFIX = 'cerebr_youtube_transcript_v1_'; 6 | 7 | function isYouTubeHost(hostname) { 8 | if (!hostname) return false; 9 | const host = String(hostname).toLowerCase(); 10 | return host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be'; 11 | } 12 | 13 | function getYouTubeVideoIdFromUrl(urlString) { 14 | try { 15 | const url = new URL(urlString); 16 | if (!isYouTubeHost(url.hostname)) return null; 17 | 18 | if (url.pathname === '/watch') return url.searchParams.get('v'); 19 | if (url.hostname === 'youtu.be') { 20 | const id = url.pathname.replace(/^\/+/, '').split('/')[0]; 21 | return id || null; 22 | } 23 | const shortsMatch = url.pathname.match(/^\/shorts\/([^/?#]+)/); 24 | if (shortsMatch) return shortsMatch[1]; 25 | const embedMatch = url.pathname.match(/^\/embed\/([^/?#]+)/); 26 | if (embedMatch) return embedMatch[1]; 27 | return null; 28 | } catch { 29 | return null; 30 | } 31 | } 32 | 33 | function sanitizeKeyPart(value) { 34 | return String(value || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80); 35 | } 36 | 37 | function makeYouTubeTranscriptKey({ videoId, lang }) { 38 | const vid = sanitizeKeyPart(videoId); 39 | const language = sanitizeKeyPart(lang || 'und'); 40 | return `${YT_TRANSCRIPT_KEY_PREFIX}${vid}_${language}`; 41 | } 42 | 43 | async function loadYouTubeTranscriptText(key) { 44 | if (!key) return null; 45 | const result = await storageAdapter.get(key); 46 | const payload = result?.[key]; 47 | if (!payload) return null; 48 | if (typeof payload === 'string') return payload; 49 | return payload?.text || null; 50 | } 51 | 52 | async function saveYouTubeTranscript({ key, videoId, lang, text }) { 53 | if (!key || !text) return; 54 | await storageAdapter.set({ 55 | [key]: { 56 | v: 1, 57 | videoId, 58 | lang: lang || null, 59 | text, 60 | updatedAt: Date.now() 61 | } 62 | }); 63 | } 64 | 65 | // 过滤重复的标签页,只保留每个 URL 最新访问的标签页 66 | function getUniqueTabsByUrl(tabs) { 67 | const seenUrls = new Set(); 68 | return tabs.filter(tab => { 69 | if (!tab.url || seenUrls.has(tab.url)) { 70 | return false; 71 | } 72 | seenUrls.add(tab.url); 73 | return true; 74 | }); 75 | } 76 | 77 | async function populateWebpageContentMenu(webpageContentMenu) { 78 | webpageContentMenu.innerHTML = `
${t('webpage_tabs_loading')}
`; 79 | let allTabs = await browserAdapter.getAllTabs(); 80 | 81 | // 1. 过滤掉浏览器自身的特殊页面 82 | allTabs = allTabs.filter(tab => tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://') && !tab.url.startsWith('about:')); 83 | 84 | // 2. 按照 lastAccessed 时间降序排序 85 | allTabs.sort((a, b) => b.lastAccessed - a.lastAccessed); 86 | 87 | // 2. 过滤掉重复的 URL 88 | const finalTabs = getUniqueTabsByUrl(allTabs); 89 | 90 | const { webpageSwitches: switches } = await storageAdapter.get('webpageSwitches'); 91 | 92 | webpageContentMenu.innerHTML = ''; 93 | 94 | if (finalTabs.length === 0) { 95 | webpageContentMenu.innerHTML = `
${t('webpage_tabs_empty')}
`; 96 | return; 97 | } 98 | 99 | for (const tab of finalTabs) { 100 | if (!tab.title || !tab.url) continue; 101 | 102 | const item = document.createElement('div'); 103 | item.className = 'webpage-menu-item'; 104 | 105 | // 添加 Favicon 106 | if (tab.favIconUrl) { 107 | const favicon = document.createElement('img'); 108 | favicon.src = tab.favIconUrl; 109 | favicon.className = 'favicon'; 110 | item.appendChild(favicon); 111 | } 112 | 113 | const title = document.createElement('span'); 114 | title.className = 'title'; 115 | title.textContent = tab.title; 116 | title.title = tab.title; // for tooltip on long titles 117 | 118 | const switchId = `webpage-switch-${tab.id}`; 119 | const switchLabel = document.createElement('label'); 120 | switchLabel.className = 'switch'; 121 | switchLabel.setAttribute('for', switchId); 122 | 123 | // Stop the click event from bubbling up, which would close the main menu. 124 | switchLabel.addEventListener('click', (e) => { 125 | e.stopPropagation(); 126 | }); 127 | 128 | const switchInput = document.createElement('input'); 129 | switchInput.type = 'checkbox'; 130 | switchInput.id = switchId; 131 | 132 | // 确定开关状态 133 | const isEnabled = switches && switches[tab.id] !== undefined ? switches[tab.id] : false; 134 | switchInput.checked = isEnabled; 135 | 136 | switchInput.addEventListener('change', async (e) => { 137 | const isChecked = e.target.checked; 138 | const { webpageSwitches: currentSwitches } = await storageAdapter.get('webpageSwitches'); 139 | const newSwitches = { ...currentSwitches, [tab.id]: isChecked }; 140 | await storageAdapter.set({ webpageSwitches: newSwitches }); 141 | 142 | // 如果是开启,且标签页未连接,则刷新它 143 | if (isChecked) { 144 | const isConnected = await browserAdapter.isTabConnected(tab.id); 145 | if (!isConnected) { 146 | await browserAdapter.reloadTab(tab.id); 147 | console.log(`Webpage-menu: populateWebpageContentMenu Reloaded tab ${tab.id} ${tab.title} (${tab.url}).`); 148 | // 可选:刷新后可以给个提示或自动重新打开菜单 149 | } 150 | } 151 | }); 152 | 153 | // 允许点击整行(除开关本身)来切换,避免误关一级菜单且提升可用性 154 | item.addEventListener('click', (e) => { 155 | e.stopPropagation(); 156 | // 点击开关区域时交给默认行为 157 | if (e.target.closest('.switch')) return; 158 | switchInput.checked = !switchInput.checked; 159 | switchInput.dispatchEvent(new Event('change', { bubbles: true })); 160 | }); 161 | 162 | const slider = document.createElement('span'); 163 | slider.className = 'slider'; 164 | 165 | switchLabel.appendChild(switchInput); 166 | switchLabel.appendChild(slider); 167 | item.appendChild(title); 168 | item.appendChild(switchLabel); 169 | webpageContentMenu.appendChild(item); 170 | } 171 | } 172 | 173 | export async function getEnabledTabsContent() { 174 | const { webpageSwitches: switches } = await storageAdapter.get('webpageSwitches'); 175 | let allTabs = await browserAdapter.getAllTabs(); 176 | const currentTab = await browserAdapter.getCurrentTab(); 177 | const activeChatId = chatManager.getCurrentChat()?.id || null; 178 | let combinedContent = null; 179 | 180 | // 1. 过滤掉浏览器自身的特殊页面 181 | allTabs = allTabs.filter(tab => tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('edge://') && !tab.url.startsWith('about:')); 182 | 183 | // 2. 按照 lastAccessed 时间降序排序 184 | allTabs.sort((a, b) => b.lastAccessed - a.lastAccessed); 185 | 186 | // 2. 过滤掉重复的 URL 187 | const finalTabs = getUniqueTabsByUrl(allTabs); 188 | 189 | for (const tab of finalTabs) { 190 | const isEnabled = switches && switches[tab.id] !== undefined ? switches[tab.id] : (tab.id === currentTab.id); 191 | if (isEnabled) { 192 | let isConnected = await browserAdapter.isTabConnected(tab.id); 193 | 194 | // 如果未连接,尝试重新加载并再次检查 195 | if (!isConnected) { 196 | await browserAdapter.reloadTab(tab.id); 197 | // 等待一段时间让标签页加载 198 | await new Promise(resolve => setTimeout(resolve, 1000)); 199 | isConnected = await browserAdapter.isTabConnected(tab.id); 200 | console.log(`Webpage-menu: getEnabledTabsContent Reloaded tab ${tab.id} ${tab.title} (${tab.url}) isConnected: ${isConnected}.`); 201 | } 202 | 203 | if (isConnected) { 204 | try { 205 | let pageData = null; 206 | console.log(`Webpage-menu: getting content ${tab.id} ${tab.title} (${tab.url}).`); 207 | pageData = await browserAdapter.sendMessage({ 208 | type: 'GET_PAGE_CONTENT_FROM_SIDEBAR', 209 | tabId: tab.id, 210 | skipWaitContent: true // 明确要求立即提取 211 | }); 212 | 213 | if (pageData && (pageData.content || pageData.youtubeTranscript?.transcript)) { 214 | let content = pageData.content || ''; 215 | 216 | // YouTube 字幕:优先使用本次提取结果;失败时回退到“当前对话已缓存的字幕” 217 | const videoId = tab?.url ? getYouTubeVideoIdFromUrl(tab.url) : null; 218 | const isYouTubeTab = (() => { 219 | if (!tab?.url) return false; 220 | try { 221 | return isYouTubeHost(new URL(tab.url).hostname); 222 | } catch { 223 | return false; 224 | } 225 | })(); 226 | 227 | if (videoId && isYouTubeTab) { 228 | const youtubeTranscript = pageData.youtubeTranscript; 229 | let transcriptText = youtubeTranscript?.transcript || null; 230 | const lang = youtubeTranscript?.lang || null; 231 | const key = transcriptText 232 | ? makeYouTubeTranscriptKey({ videoId, lang }) 233 | : (chatManager.getYouTubeTranscriptRef(activeChatId, videoId)?.key || null); 234 | 235 | if (transcriptText && key) { 236 | await saveYouTubeTranscript({ key, videoId, lang, text: transcriptText }); 237 | if (activeChatId) { 238 | chatManager.addYouTubeTranscriptRef(activeChatId, { key, videoId, lang }); 239 | } 240 | } 241 | 242 | if (!transcriptText && key) { 243 | transcriptText = await loadYouTubeTranscriptText(key); 244 | } 245 | 246 | if (transcriptText) { 247 | content = `${content}\n\n${t('youtube_transcript_prefix')}\n${transcriptText}`.trim(); 248 | } 249 | } 250 | 251 | if (!combinedContent) { 252 | combinedContent = { pages: [] }; 253 | } 254 | combinedContent.pages.push({ 255 | title: pageData.title, 256 | url: tab.url, 257 | content, 258 | isCurrent: tab.id === currentTab.id 259 | }); 260 | } 261 | } catch (e) { 262 | console.warn(`Could not get content from tab ${tab.id} (${tab.url}): ${e}`); 263 | } 264 | } 265 | } 266 | } 267 | return combinedContent; 268 | } 269 | 270 | export function initWebpageMenu({ webpageQAContainer, webpageContentMenu }) { 271 | webpageQAContainer.addEventListener('click', async (e) => { 272 | e.stopPropagation(); 273 | 274 | if (webpageContentMenu.classList.contains('visible')) { 275 | webpageContentMenu.classList.remove('visible'); 276 | webpageContentMenu.style.visibility = 'hidden'; // 确保隐藏 277 | return; 278 | } 279 | 280 | // 核心修复:先隐藏,计算完位置再显示,防止闪烁 281 | webpageContentMenu.style.visibility = 'hidden'; 282 | webpageContentMenu.classList.add('visible'); 283 | 284 | await populateWebpageContentMenu(webpageContentMenu); 285 | const rect = webpageQAContainer.getBoundingClientRect(); 286 | const menuHeight = webpageContentMenu.offsetHeight; 287 | const windowHeight = window.innerHeight; 288 | 289 | let top = rect.top; 290 | if (top + menuHeight > windowHeight) { 291 | top = windowHeight - menuHeight - 150; // 向上调整 292 | } 293 | 294 | webpageContentMenu.style.top = `${Math.max(8, top)}px`; 295 | webpageContentMenu.style.left = `${rect.right + 8}px`; 296 | 297 | // 在正确的位置上使其可见 298 | webpageContentMenu.style.visibility = 'visible'; 299 | }); 300 | } 301 | -------------------------------------------------------------------------------- /src/components/api-card.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API卡片配置接口 3 | * @typedef {Object} APIConfig 4 | * @property {string} apiKey - API密钥 5 | * @property {string} baseUrl - API的基础URL 6 | * @property {string} modelName - 模型名称 7 | * @property {Object} advancedSettings - 高级设置 8 | * @property {string} advancedSettings.systemPrompt - 系统提示 9 | * @property {boolean} advancedSettings.isExpanded - 高级设置是否展开 10 | */ 11 | 12 | /** 13 | * 渲染 API 卡片 14 | * @param {Object} params - 渲染参数 15 | * @param {Array} params.apiConfigs - API配置列表 16 | * @param {HTMLElement} params.apiCardsContainer - 卡片容器元素 17 | * @param {HTMLElement} params.templateCard - 模板卡片元素 18 | * @param {function} params.onCardCreate - 卡片创建回调函数 19 | * @param {function} params.onCardSelect - 卡片选择回调函数 20 | * @param {function} params.onCardDuplicate - 卡片复制回调函数 21 | * @param {function} params.onCardDelete - 卡片删除回调函数 22 | * @param {function} params.onCardChange - 卡片内容变更回调函数 23 | * @param {number} params.selectedIndex - 当前选中的卡片索引 24 | */ 25 | export function renderAPICards({ 26 | apiConfigs, 27 | apiCardsContainer, 28 | templateCard, 29 | onCardCreate, 30 | onCardSelect, 31 | onCardDuplicate, 32 | onCardDelete, 33 | onCardChange, 34 | selectedIndex 35 | }) { 36 | if (!templateCard) { 37 | console.error('找不到模板卡片元素'); 38 | return; 39 | } 40 | 41 | // 保存模板的副本 42 | const templateClone = templateCard.cloneNode(true); 43 | 44 | // 清空现有卡片 45 | apiCardsContainer.innerHTML = ''; 46 | 47 | // 先重新添加模板(保持隐藏状态) 48 | apiCardsContainer.appendChild(templateClone); 49 | 50 | // 移除所有卡片的选中状态 51 | document.querySelectorAll('.api-card').forEach(card => { 52 | card.classList.remove('selected'); 53 | }); 54 | 55 | // 渲染实际的卡片 56 | apiConfigs.forEach((config, index) => { 57 | const card = createAPICard({ 58 | config, 59 | index, 60 | templateCard: templateClone, 61 | onSelect: onCardSelect, 62 | onDuplicate: onCardDuplicate, 63 | onDelete: onCardDelete, 64 | onChange: onCardChange, 65 | isSelected: index === selectedIndex 66 | }); 67 | apiCardsContainer.appendChild(card); 68 | if (onCardCreate) { 69 | onCardCreate(card, index); 70 | } 71 | }); 72 | } 73 | 74 | /** 75 | * 创建单个 API 卡片 76 | * @param {Object} params - 创建参数 77 | * @param {APIConfig} params.config - API配置 78 | * @param {number} params.index - 卡片索引 79 | * @param {HTMLElement} params.templateCard - 模板卡片元素 80 | * @param {function} params.onSelect - 选择回调 81 | * @param {function} params.onDuplicate - 复制回调 82 | * @param {function} params.onDelete - 删除回调 83 | * @param {function} params.onChange - 变更回调 84 | * @param {boolean} params.isSelected - 是否选中 85 | * @returns {HTMLElement} 创建的卡片元素 86 | */ 87 | 88 | import { normalizeChatCompletionsUrl } from '../utils/api-url.js'; 89 | 90 | function createAPICard({ 91 | config, 92 | index, 93 | templateCard, 94 | onSelect, 95 | onDuplicate, 96 | onDelete, 97 | onChange, 98 | isSelected 99 | }) { 100 | // 克隆模板 101 | const template = templateCard.cloneNode(true); 102 | template.classList.remove('template'); 103 | template.style.display = ''; 104 | template.setAttribute('tabindex', '0'); 105 | 106 | // 设置选中状态 107 | if (isSelected) { 108 | template.classList.add('selected'); 109 | } else { 110 | template.classList.remove('selected'); 111 | } 112 | 113 | const apiKeyInput = template.querySelector('.api-key'); 114 | const baseUrlInput = template.querySelector('.base-url'); 115 | const modelNameInput = template.querySelector('.model-name'); 116 | const systemPromptInput = template.querySelector('.system-prompt'); 117 | const advancedSettingsHeader = template.querySelector('.advanced-settings-header'); 118 | const advancedSettingsContent = template.querySelector('.advanced-settings-content'); 119 | const toggleIcon = template.querySelector('.toggle-icon'); 120 | 121 | // 设置初始值 122 | apiKeyInput.value = config.apiKey || ''; 123 | baseUrlInput.value = config.baseUrl || 'https://api.openai.com/v1/chat/completions'; 124 | modelNameInput.value = config.modelName || 'gpt-4o'; 125 | 126 | // 设置系统提示的默认值 127 | systemPromptInput.value = config.advancedSettings?.systemPrompt || ''; 128 | 129 | // 设置高级设置的展开/折叠状态 130 | const isExpanded = config.advancedSettings?.isExpanded || false; 131 | advancedSettingsContent.style.display = isExpanded ? 'block' : 'none'; 132 | toggleIcon.style.transform = isExpanded ? 'rotate(180deg)' : ''; 133 | 134 | const buildNextConfig = ({ advancedSettingsOverride } = {}) => { 135 | const advancedSettings = { 136 | ...(config.advancedSettings || {}), 137 | isExpanded: advancedSettingsContent.style.display === 'block', 138 | systemPrompt: systemPromptInput.value, 139 | ...(advancedSettingsOverride || {}), 140 | }; 141 | 142 | return { 143 | ...config, 144 | apiKey: apiKeyInput.value, 145 | baseUrl: baseUrlInput.value, 146 | modelName: modelNameInput.value, 147 | advancedSettings, 148 | }; 149 | }; 150 | 151 | // 添加高级设置的展开/折叠功能 152 | advancedSettingsHeader.addEventListener('click', (e) => { 153 | e.stopPropagation(); 154 | const isCurrentlyExpanded = advancedSettingsContent.style.display === 'block'; 155 | advancedSettingsContent.style.display = isCurrentlyExpanded ? 'none' : 'block'; 156 | toggleIcon.style.transform = isCurrentlyExpanded ? '' : 'rotate(180deg)'; 157 | 158 | // 更新配置 159 | onChange(index, buildNextConfig({ 160 | advancedSettingsOverride: { 161 | isExpanded: !isCurrentlyExpanded, 162 | } 163 | })); 164 | }); 165 | 166 | // 系统提示:实时更新并自动保存(由外层实现节流/同步策略) 167 | systemPromptInput.addEventListener('input', () => { 168 | onChange(index, buildNextConfig(), { kind: 'systemPrompt' }); 169 | }); 170 | 171 | // 在失焦时强制落盘一次,避免 debounce 尚未触发导致丢失 172 | systemPromptInput.addEventListener('change', () => { 173 | onChange(index, buildNextConfig(), { kind: 'systemPrompt', flush: true }); 174 | }); 175 | 176 | // 其他字段:实时更新并自动保存(由外层实现节流/同步策略) 177 | [apiKeyInput, baseUrlInput, modelNameInput].forEach((input) => { 178 | input.addEventListener('input', () => { 179 | onChange(index, buildNextConfig(), { kind: 'apiFields' }); 180 | }); 181 | }); 182 | 183 | // 阻止输入框和按钮点击事件冒泡 184 | const stopPropagation = (e) => { 185 | e.stopPropagation(); 186 | e.preventDefault(); 187 | }; 188 | 189 | // 为输入框添加点击事件阻止冒泡 190 | [apiKeyInput, baseUrlInput, modelNameInput, systemPromptInput].forEach(input => { 191 | input.addEventListener('click', stopPropagation); 192 | input.addEventListener('focus', stopPropagation); 193 | }); 194 | 195 | // 添加输入法状态跟踪 196 | let isComposing = false; 197 | 198 | // 监听输入法开始 199 | [apiKeyInput, baseUrlInput, modelNameInput, systemPromptInput].forEach(input => { 200 | input.addEventListener('compositionstart', () => { 201 | isComposing = true; 202 | }); 203 | 204 | // 监听输入法结束 205 | input.addEventListener('compositionend', () => { 206 | isComposing = false; 207 | }); 208 | }); 209 | 210 | // 修改键盘事件处理(普通输入框) 211 | [apiKeyInput, baseUrlInput, modelNameInput].forEach(input => { 212 | input.addEventListener('keydown', async (e) => { 213 | if (e.key === 'Enter' && !e.shiftKey) { 214 | if (isComposing) { 215 | // 如果正在使用输入法,不触发选择 216 | return; 217 | } 218 | e.preventDefault(); 219 | e.stopPropagation(); 220 | 221 | if (input === baseUrlInput) { 222 | baseUrlInput.value = normalizeChatCompletionsUrl(baseUrlInput.value) || baseUrlInput.value.trim(); 223 | } 224 | 225 | const maybePromise = onChange(index, buildNextConfig(), { kind: 'apiFields', flush: true }); 226 | if (maybePromise && typeof maybePromise.then === 'function') { 227 | try { 228 | await maybePromise; 229 | } catch { 230 | // ignore 231 | } 232 | } 233 | onSelect(template, index); 234 | } 235 | }); 236 | }); 237 | 238 | // 修改键盘事件处理(系统提示 textarea:回车先 flush 再返回) 239 | systemPromptInput.addEventListener('keydown', async (e) => { 240 | if (e.key === 'Enter' && !e.shiftKey) { 241 | if (isComposing) return; 242 | e.preventDefault(); 243 | 244 | const maybePromise = onChange(index, buildNextConfig(), { kind: 'systemPrompt', flush: true }); 245 | if (maybePromise && typeof maybePromise.then === 'function') { 246 | try { 247 | await maybePromise; 248 | } catch { 249 | // ignore 250 | } 251 | } 252 | 253 | onSelect(template, index); 254 | } 255 | }); 256 | 257 | // 为按钮添加点击事件阻止冒泡 258 | template.querySelectorAll('.card-button').forEach(button => { 259 | button.addEventListener('click', stopPropagation); 260 | }); 261 | 262 | // 添加回车键选择功能 263 | template.addEventListener('keydown', (e) => { 264 | if (e.key === 'Enter' && !isComposing) { 265 | e.preventDefault(); 266 | onSelect(template, index); 267 | } 268 | }); 269 | 270 | // 监听输入框变化 271 | [apiKeyInput, baseUrlInput, modelNameInput].forEach(input => { 272 | input.addEventListener('change', () => { 273 | if (input === baseUrlInput) { 274 | baseUrlInput.value = normalizeChatCompletionsUrl(baseUrlInput.value) || baseUrlInput.value.trim(); 275 | } 276 | onChange(index, buildNextConfig(), { kind: 'apiFields', flush: true }); 277 | }); 278 | }); 279 | 280 | // 复制配置 281 | template.querySelector('.duplicate-btn').addEventListener('click', (e) => { 282 | e.stopPropagation(); 283 | e.preventDefault(); 284 | onDuplicate(config, index); 285 | }); 286 | 287 | // 删除配置 288 | template.querySelector('.delete-btn').addEventListener('click', (e) => { 289 | e.stopPropagation(); 290 | e.preventDefault(); 291 | onDelete(index); 292 | }); 293 | 294 | // 选择配置 295 | template.addEventListener('click', (e) => { 296 | // 如果点击的是输入框或按钮,不触发选择 297 | if (e.target.matches('input') || e.target.matches('.card-button') || e.target.closest('.card-button')) { 298 | return; 299 | } 300 | onSelect(template, index); 301 | }); 302 | 303 | return template; 304 | } 305 | 306 | /** 307 | * 创建API卡片回调处理函数 308 | * @param {Object} params - 参数对象 309 | * @param {function} params.selectCard - 选择卡片的函数 310 | * @param {Array} params.apiConfigs - API配置列表 311 | * @param {number} params.selectedConfigIndex - 当前选中的配置索引 312 | * @param {function} params.saveAPIConfigs - 保存API配置的函数 313 | * @param {function} params.renderAPICardsWithCallbacks - 重新渲染卡片的函数 314 | * @returns {Object} 回调函数对象 315 | */ 316 | export function createCardCallbacks({ 317 | selectCard, 318 | apiConfigs, 319 | selectedConfigIndex, 320 | saveAPIConfigs, 321 | queueApiConfigsPersist, 322 | flushApiConfigsPersist, 323 | queueSystemPromptPersist, 324 | flushSystemPromptPersist, 325 | renderAPICardsWithCallbacks, 326 | onBeforeCardDelete, 327 | }) { 328 | return { 329 | onCardSelect: selectCard, 330 | onCardDuplicate: (config, index) => { 331 | const cloned = (typeof structuredClone === 'function') 332 | ? structuredClone(config) 333 | : JSON.parse(JSON.stringify(config)); 334 | delete cloned.id; 335 | // 在当前选中卡片后面插入新卡片 336 | apiConfigs.splice(index + 1, 0, cloned); 337 | // 保存配置但不改变选中状态 338 | saveAPIConfigs(); 339 | // 重新渲染所有卡片,保持原来的选中状态 340 | renderAPICardsWithCallbacks(); 341 | }, 342 | onCardDelete: (index) => { 343 | if (apiConfigs.length > 1) { 344 | if (typeof onBeforeCardDelete === 'function') { 345 | onBeforeCardDelete(apiConfigs[index], index); 346 | } 347 | apiConfigs.splice(index, 1); 348 | if (selectedConfigIndex >= apiConfigs.length) { 349 | selectedConfigIndex = apiConfigs.length - 1; 350 | } 351 | saveAPIConfigs(); 352 | renderAPICardsWithCallbacks(); 353 | } 354 | }, 355 | onCardChange: (index, newConfig, options = {}) => { 356 | apiConfigs[index] = newConfig; 357 | 358 | if (options.kind === 'systemPrompt') { 359 | if (options.flush && typeof flushSystemPromptPersist === 'function') { 360 | return flushSystemPromptPersist(newConfig); 361 | } 362 | if (typeof queueSystemPromptPersist === 'function') { 363 | queueSystemPromptPersist(newConfig); 364 | return; 365 | } 366 | // 回退:如果未注入专用保存逻辑,就沿用全量保存 367 | } 368 | 369 | if (options.kind === 'apiFields') { 370 | if (options.flush && typeof flushApiConfigsPersist === 'function') { 371 | return flushApiConfigsPersist(); 372 | } 373 | if (typeof queueApiConfigsPersist === 'function') { 374 | queueApiConfigsPersist(); 375 | return; 376 | } 377 | // 回退:如果未注入专用保存逻辑,就沿用全量保存 378 | } 379 | 380 | saveAPIConfigs(); 381 | } 382 | }; 383 | } 384 | 385 | /** 386 | * 选择API卡片的函数 387 | * @param {Object} params - 参数对象 388 | * @param {Object} params.template - 模板对象 389 | * @param {number} params.index - 选中的索引 390 | * @param {function} params.onIndexChange - 索引变更回调函数 391 | * @param {function} params.onSave - 保存配置的回调函数 392 | * @param {string} params.cardSelector - 卡片元素的CSS选择器 393 | * @param {function} params.onSelect - 选中后的回调函数 394 | * @returns {void} 395 | */ 396 | export function selectCard({ 397 | template, 398 | index, 399 | onIndexChange, 400 | onSave, 401 | cardSelector = '.api-card', 402 | onSelect 403 | }) { 404 | // 更新选中索引 405 | onIndexChange(index); 406 | 407 | // 保存配置 408 | onSave(); 409 | 410 | // 更新UI状态 411 | document.querySelectorAll(cardSelector).forEach(card => { 412 | card.classList.remove('selected'); 413 | }); 414 | 415 | // 选中当前卡片 416 | const selectedCard = document.querySelectorAll(cardSelector)[index]; 417 | if (selectedCard) { 418 | selectedCard.classList.add('selected'); 419 | } 420 | 421 | // 执行选中后的回调 422 | if (onSelect) { 423 | onSelect(selectedCard, index); 424 | } 425 | 426 | return selectedCard; 427 | } 428 | -------------------------------------------------------------------------------- /src/components/chat-list.js: -------------------------------------------------------------------------------- 1 | import { appendMessage } from '../handlers/message-handler.js'; 2 | import { storageAdapter, browserAdapter, isExtensionEnvironment } from '../utils/storage-adapter.js'; 3 | import { t } from '../utils/i18n.js'; 4 | 5 | let renderToken = 0; 6 | let chatContentToken = 0; 7 | 8 | function scheduleWork(callback) { 9 | if (typeof requestIdleCallback === 'function') { 10 | requestIdleCallback(callback, { timeout: 1000 }); 11 | } else { 12 | requestAnimationFrame(() => callback({ timeRemaining: () => 0, didTimeout: true })); 13 | } 14 | } 15 | 16 | function createChatSwitchPlaceholder() { 17 | const wrapper = document.createElement('div'); 18 | wrapper.className = 'chat-switch-placeholder'; 19 | wrapper.innerHTML = ` 20 | 21 |
${t('chat_switch_loading')}
22 | `; 23 | return wrapper; 24 | } 25 | 26 | export function renderChatListIncremental(chatManager, chatCards, searchTerm = '') { 27 | const template = chatCards.querySelector('.chat-card.template'); 28 | if (!template) return; 29 | 30 | const lowerCaseSearchTerm = searchTerm.toLowerCase(); 31 | const currentChatId = chatManager.getCurrentChat()?.id; 32 | const allChats = chatManager.getAllChats(); 33 | 34 | const filteredChats = allChats.filter(chat => { 35 | if (!searchTerm) return true; 36 | const titleMatch = chat.title.toLowerCase().includes(lowerCaseSearchTerm); 37 | const contentMatch = chat.messages.some(message => 38 | message.content && 39 | ( 40 | (typeof message.content === 'string' && message.content.toLowerCase().includes(lowerCaseSearchTerm)) || 41 | (Array.isArray(message.content) && message.content.some(part => part.type === 'text' && part.text.toLowerCase().includes(lowerCaseSearchTerm))) 42 | ) 43 | ); 44 | return titleMatch || contentMatch; 45 | }); 46 | 47 | const myToken = ++renderToken; 48 | let index = 0; 49 | 50 | // 先清空(只保留模板),避免一次性 replaceChildren 大量节点导致长任务 51 | chatCards.replaceChildren(template); 52 | 53 | if (filteredChats.length === 0) { 54 | const empty = document.createElement('div'); 55 | empty.className = 'chat-list-empty-state'; 56 | empty.textContent = searchTerm ? t('chat_list_no_match') : t('chat_list_empty'); 57 | chatCards.appendChild(empty); 58 | return; 59 | } 60 | 61 | const renderChunk = (deadline) => { 62 | if (myToken !== renderToken) return; 63 | 64 | const fragment = document.createDocumentFragment(); 65 | const shouldContinue = () => { 66 | if (!deadline || typeof deadline.timeRemaining !== 'function') return false; 67 | return deadline.didTimeout || deadline.timeRemaining() > 8; 68 | }; 69 | 70 | // 至少渲染少量条目,避免空白;同时严格限制每次渲染数量,防止单次长任务 71 | const minPerChunk = 12; 72 | const maxPerChunk = 25; 73 | let rendered = 0; 74 | while (index < filteredChats.length && rendered < maxPerChunk && (rendered < minPerChunk || shouldContinue())) { 75 | const chat = filteredChats[index++]; 76 | const card = template.cloneNode(true); 77 | card.classList.remove('template'); 78 | card.style.display = ''; 79 | card.dataset.chatId = chat.id; 80 | 81 | const titleElement = card.querySelector('.chat-title'); 82 | titleElement.textContent = chat.title; 83 | 84 | if (chat.id === currentChatId) { 85 | card.classList.add('selected'); 86 | } else { 87 | card.classList.remove('selected'); 88 | } 89 | 90 | fragment.appendChild(card); 91 | rendered++; 92 | } 93 | 94 | chatCards.appendChild(fragment); 95 | 96 | if (index < filteredChats.length) { 97 | scheduleWork(renderChunk); 98 | } 99 | }; 100 | 101 | scheduleWork(renderChunk); 102 | } 103 | 104 | // 加载对话内容 105 | export async function loadChatContent(chat, chatContainer) { 106 | chatContainer.innerHTML = ''; 107 | // 确定要遍历的消息范围 108 | const messages = chat.messages; 109 | 110 | for (let i = 0; i < messages.length; i++) { 111 | const message = messages[i]; 112 | if (message.content) { 113 | await appendMessage({ 114 | text: message, 115 | sender: message.role === 'user' ? 'user' : 'ai', 116 | chatContainer, 117 | skipHistory: true, 118 | }); 119 | } 120 | } 121 | } 122 | 123 | async function loadChatContentIncremental(chat, chatContainer, token) { 124 | chatContainer.replaceChildren(createChatSwitchPlaceholder()); 125 | const messages = chat.messages; 126 | if (!Array.isArray(messages) || messages.length === 0) { 127 | chatContainer.innerHTML = ''; 128 | document.dispatchEvent(new CustomEvent('cerebr:chatContentChunk', { detail: { chatId: chat.id, done: true, rendered: 0 } })); 129 | document.dispatchEvent(new CustomEvent('cerebr:chatContentLoaded', { detail: { chatId: chat.id } })); 130 | return; 131 | } 132 | 133 | let index = 0; 134 | const myToken = token; 135 | let hasRenderedAny = false; 136 | 137 | const renderChunk = async (deadline) => { 138 | if (myToken !== chatContentToken) return; 139 | 140 | const fragment = document.createDocumentFragment(); 141 | const nodes = []; 142 | 143 | const shouldContinue = () => { 144 | if (!deadline || typeof deadline.timeRemaining !== 'function') return false; 145 | return deadline.didTimeout || deadline.timeRemaining() > 8; 146 | }; 147 | 148 | const minPerChunk = 6; 149 | const maxPerChunk = 14; 150 | let rendered = 0; 151 | 152 | while (index < messages.length && 153 | rendered < maxPerChunk && 154 | (rendered < minPerChunk || shouldContinue())) { 155 | if (myToken !== chatContentToken) return; 156 | 157 | const message = messages[index++]; 158 | if (!message?.content) continue; 159 | 160 | const element = await appendMessage({ 161 | text: message, 162 | sender: message.role === 'user' ? 'user' : 'ai', 163 | chatContainer, 164 | skipHistory: true, 165 | fragment 166 | }); 167 | nodes.push(element); 168 | rendered++; 169 | } 170 | 171 | if (myToken !== chatContentToken) return; 172 | if (!hasRenderedAny) { 173 | chatContainer.replaceChildren(fragment); 174 | hasRenderedAny = true; 175 | } else { 176 | chatContainer.appendChild(fragment); 177 | } 178 | requestAnimationFrame(() => { 179 | nodes.forEach((el) => el?.classList?.add('show')); 180 | }); 181 | 182 | document.dispatchEvent(new CustomEvent('cerebr:chatContentChunk', { detail: { chatId: chat.id, done: index >= messages.length, rendered: index } })); 183 | 184 | if (index < messages.length) { 185 | scheduleWork(renderChunk); 186 | return; 187 | } 188 | 189 | document.dispatchEvent(new CustomEvent('cerebr:chatContentLoaded', { detail: { chatId: chat.id } })); 190 | }; 191 | 192 | scheduleWork(renderChunk); 193 | } 194 | 195 | // 切换到指定对话 196 | export function switchToChat(chatId, chatManager) { 197 | // Optimistically switch current chat immediately; persist to storage in background. 198 | void chatManager.switchChat(chatId).catch((err) => console.error('切换对话失败:', err)); 199 | const chat = chatManager.getCurrentChat(); 200 | if (chat) { 201 | const token = ++chatContentToken; 202 | const chatContainer = document.getElementById('chat-container'); 203 | chatContainer.replaceChildren(createChatSwitchPlaceholder()); 204 | void loadChatContentIncremental(chat, chatContainer, token); 205 | 206 | // 更新对话列表中的选中状态 207 | document.querySelectorAll('.chat-card').forEach(card => { 208 | if (card.dataset.chatId === chatId) { 209 | card.classList.add('selected'); 210 | } else { 211 | card.classList.remove('selected'); 212 | } 213 | }); 214 | 215 | // 通知其他模块(例如:草稿/滚动按钮)当前对话已切换 216 | document.dispatchEvent(new CustomEvent('cerebr:chatSwitched', { detail: { chatId } })); 217 | } 218 | } 219 | 220 | // 显示对话列表 221 | export function showChatList(chatListPage, apiSettings) { 222 | chatListPage.classList.add('show'); 223 | apiSettings.classList.remove('visible'); // 确保API设置页面被隐藏 224 | } 225 | 226 | // 隐藏对话列表 227 | export function hideChatList(chatListPage) { 228 | chatListPage.classList.remove('show'); 229 | 230 | // Cancel any in-progress incremental render and clear heavy DOM off the critical path. 231 | renderToken++; 232 | const chatCards = chatListPage.querySelector('.chat-cards'); 233 | const template = chatCards?.querySelector('.chat-card.template'); 234 | if (chatCards && template) { 235 | const myToken = renderToken; 236 | const clearChunk = (deadline) => { 237 | if (myToken !== renderToken) return; 238 | 239 | const shouldContinue = () => { 240 | if (!deadline || typeof deadline.timeRemaining !== 'function') return false; 241 | return deadline.didTimeout || deadline.timeRemaining() > 8; 242 | }; 243 | 244 | let removed = 0; 245 | while (chatCards.lastElementChild && 246 | !chatCards.lastElementChild.classList.contains('template') && 247 | removed < 60 && 248 | (removed < 20 || shouldContinue())) { 249 | chatCards.lastElementChild.remove(); 250 | removed++; 251 | } 252 | 253 | if (chatCards.children.length > 1) { 254 | scheduleWork(clearChunk); 255 | } 256 | }; 257 | scheduleWork(clearChunk); 258 | } 259 | } 260 | 261 | // 初始化对话列表事件监听 262 | export function initChatListEvents({ 263 | chatListPage, 264 | chatCards, 265 | chatManager, 266 | onHide 267 | }) { 268 | // 为每个卡片添加点击事件 269 | chatCards.addEventListener('click', async (e) => { 270 | const card = e.target.closest('.chat-card'); 271 | if (!card || card.classList.contains('template')) return; 272 | 273 | if (!e.target.closest('.delete-btn')) { 274 | // 先准备聊天界面,再关闭列表页,避免“旧对话被清空”的闪动露出。 275 | switchToChat(card.dataset.chatId, chatManager); 276 | requestAnimationFrame(() => { 277 | if (onHide) onHide(); 278 | }); 279 | } 280 | }); 281 | 282 | // 为删除按钮添加点击事件 283 | chatCards.addEventListener('click', async (e) => { 284 | const deleteBtn = e.target.closest('.delete-btn'); 285 | if (!deleteBtn) return; 286 | 287 | const card = deleteBtn.closest('.chat-card'); 288 | if (!card || card.classList.contains('template')) return; 289 | 290 | e.stopPropagation(); 291 | const prevChatId = chatManager.getCurrentChat()?.id || null; 292 | // 清理该对话的阅读进度(避免无效残留) 293 | await storageAdapter.remove(`cerebr_reading_progress_v1_${card.dataset.chatId}`); 294 | await chatManager.deleteChat(card.dataset.chatId); 295 | scheduleWork(() => renderChatListIncremental(chatManager, chatCards)); 296 | 297 | // 如果删除的是当前对话,重新加载聊天内容 298 | const currentChat = chatManager.getCurrentChat(); 299 | if (currentChat) { 300 | const token = ++chatContentToken; 301 | const chatContainer = document.getElementById('chat-container'); 302 | chatContainer.replaceChildren(createChatSwitchPlaceholder()); 303 | void loadChatContentIncremental(currentChat, chatContainer, token); 304 | if (prevChatId !== currentChat.id) { 305 | document.dispatchEvent(new CustomEvent('cerebr:chatSwitched', { detail: { chatId: currentChat.id } })); 306 | } 307 | } 308 | }); 309 | 310 | // 返回按钮点击事件 311 | const backButton = chatListPage.querySelector('.back-button'); 312 | if (backButton) { 313 | backButton.addEventListener('click', () => { 314 | if (onHide) onHide(); 315 | }); 316 | } 317 | } 318 | 319 | // 初始化聊天列表功能 320 | export function initializeChatList({ 321 | chatListPage, 322 | chatManager, 323 | newChatButton, 324 | chatListButton, 325 | settingsMenu, 326 | apiSettings 327 | }) { 328 | const messageInput = document.getElementById('message-input'); 329 | // 新建对话按钮点击事件 330 | newChatButton.addEventListener('click', async () => { 331 | const currentChat = chatManager.getCurrentChat(); 332 | // 如果当前对话没有消息,则不创建新对话 333 | if (currentChat && currentChat.messages.length === 0) { 334 | return; 335 | } 336 | 337 | if (isExtensionEnvironment) { 338 | const currentTab = await browserAdapter.getCurrentTab(); 339 | if (currentTab) { 340 | await storageAdapter.set({ webpageSwitches: { [currentTab.id]: true } }); 341 | } 342 | } 343 | 344 | const newChat = chatManager.createNewChat(t('chat_new_title')); 345 | switchToChat(newChat.id, chatManager); 346 | settingsMenu.classList.remove('visible'); 347 | messageInput.focus(); 348 | }); 349 | 350 | // 对话列表按钮点击事件 351 | chatListButton.addEventListener('click', () => { 352 | const searchInput = document.getElementById('chat-search-input'); 353 | const chatCards = chatListPage.querySelector('.chat-cards'); 354 | if (searchInput) searchInput.value = ''; // 清空搜索框 355 | 356 | // Show UI first, then render incrementally off the click task. 357 | showChatList(chatListPage, apiSettings); 358 | 359 | settingsMenu.classList.remove('visible'); 360 | 361 | scheduleWork(() => renderChatListIncremental(chatManager, chatCards)); 362 | }); 363 | 364 | // 搜索框事件 365 | const searchInput = document.getElementById('chat-search-input'); 366 | const clearSearchBtn = chatListPage.querySelector('.clear-search-btn'); 367 | const chatCards = chatListPage.querySelector('.chat-cards'); 368 | 369 | let searchTimer = null; 370 | let lastSearchTerm = ''; 371 | 372 | searchInput.addEventListener('input', () => { 373 | const searchTerm = searchInput.value; 374 | clearSearchBtn.style.display = searchTerm ? 'flex' : 'none'; 375 | 376 | clearTimeout(searchTimer); 377 | searchTimer = setTimeout(() => { 378 | if (searchTerm === lastSearchTerm) return; 379 | lastSearchTerm = searchTerm; 380 | renderChatListIncremental(chatManager, chatCards, searchTerm); 381 | }, 140); 382 | }); 383 | 384 | clearSearchBtn.addEventListener('click', () => { 385 | searchInput.value = ''; 386 | searchInput.dispatchEvent(new Event('input')); 387 | searchInput.focus(); 388 | }); 389 | 390 | // 对话列表返回按钮点击事件 391 | const chatListBackButton = chatListPage.querySelector('.back-button'); 392 | if (chatListBackButton) { 393 | chatListBackButton.addEventListener('click', () => hideChatList(chatListPage)); 394 | } 395 | } 396 | -------------------------------------------------------------------------------- /src/utils/chat-manager.js: -------------------------------------------------------------------------------- 1 | import { storageAdapter } from './storage-adapter.js'; 2 | 3 | const LEGACY_CHATS_KEY = 'cerebr_chats'; 4 | const CHATS_INDEX_V2_KEY = 'cerebr_chats_index_v2'; 5 | const CHAT_V2_PREFIX = 'cerebr_chat_v2_'; 6 | const CURRENT_CHAT_ID_KEY = 'cerebr_current_chat_id'; 7 | 8 | const YT_TRANSCRIPT_REF_FIELD = 'youtubeTranscriptRefs'; 9 | 10 | function chatKeyV2(chatId) { 11 | return `${CHAT_V2_PREFIX}${chatId}`; 12 | } 13 | 14 | export class ChatManager { 15 | constructor() { 16 | this.storage = storageAdapter; 17 | this.currentChatId = null; 18 | this.chats = new Map(); 19 | this._saveDirty = false; 20 | this._indexDirty = false; 21 | this._dirtyChatIds = new Set(); 22 | this._pendingRemovals = new Set(); 23 | this._migrationQueue = []; 24 | this._legacyCleanupDone = false; 25 | this._saveScheduled = false; 26 | this._saveInProgress = false; 27 | this._savePromise = null; 28 | this._savePromiseResolve = null; 29 | this._savePromiseReject = null; 30 | this.initialize(); 31 | } 32 | 33 | async initialize() { 34 | // 优先加载 v2 索引(按 chatId 分片存储,避免每次写入整个 77MB) 35 | const indexResult = await this.storage.get(CHATS_INDEX_V2_KEY); 36 | const chatIds = indexResult[CHATS_INDEX_V2_KEY]; 37 | 38 | if (Array.isArray(chatIds) && chatIds.length > 0) { 39 | const keys = chatIds.map(chatKeyV2); 40 | const chatsByKey = await this.storage.get(keys); 41 | 42 | const missingIds = []; 43 | chatIds.forEach((id) => { 44 | const chat = chatsByKey[chatKeyV2(id)]; 45 | if (chat) { 46 | this.chats.set(chat.id, chat); 47 | } else { 48 | missingIds.push(id); 49 | } 50 | }); 51 | 52 | // 兼容:如果部分 v2 分片缺失,回退从旧的整体存储中补齐,并排队迁移 53 | if (missingIds.length > 0) { 54 | const legacyResult = await this.storage.get(LEGACY_CHATS_KEY); 55 | const legacyChats = legacyResult[LEGACY_CHATS_KEY] || []; 56 | if (Array.isArray(legacyChats) && legacyChats.length > 0) { 57 | const legacyMap = new Map(legacyChats.map(c => [c.id, c])); 58 | missingIds.forEach((id) => { 59 | const chat = legacyMap.get(id); 60 | if (chat) { 61 | this.chats.set(chat.id, chat); 62 | this._migrationQueue.push(chat.id); 63 | } 64 | }); 65 | if (this._migrationQueue.length > 0) { 66 | this._saveDirty = true; 67 | this._scheduleSave(); 68 | } 69 | } 70 | } 71 | } else { 72 | // v2 不存在:读取旧存储,并在空闲时迁移到 v2 73 | const legacyResult = await this.storage.get(LEGACY_CHATS_KEY); 74 | const savedChats = legacyResult[LEGACY_CHATS_KEY] || []; 75 | if (Array.isArray(savedChats)) { 76 | savedChats.forEach(chat => { 77 | this.chats.set(chat.id, chat); 78 | this._migrationQueue.push(chat.id); 79 | }); 80 | if (this._migrationQueue.length > 0) { 81 | this._indexDirty = true; 82 | this._saveDirty = true; 83 | this._scheduleSave(); 84 | } 85 | } 86 | } 87 | 88 | // 获取当前对话ID 89 | const currentChatResult = await this.storage.get(CURRENT_CHAT_ID_KEY); 90 | this.currentChatId = currentChatResult[CURRENT_CHAT_ID_KEY]; 91 | 92 | // 如果没有当前对话,创建一个默认对话 93 | if (!this.currentChatId || !this.chats.has(this.currentChatId)) { 94 | const defaultChat = this.createNewChat('默认对话'); 95 | this.currentChatId = defaultChat.id; 96 | await this.storage.set({ [CURRENT_CHAT_ID_KEY]: this.currentChatId }); 97 | } 98 | } 99 | 100 | createNewChat(title = '新对话') { 101 | const chatId = Date.now().toString(); 102 | const chat = { 103 | id: chatId, 104 | title: title, 105 | messages: [], 106 | createdAt: new Date().toISOString() 107 | }; 108 | this.chats.set(chatId, chat); 109 | this._dirtyChatIds.add(chatId); 110 | this._indexDirty = true; 111 | this.saveChats(); 112 | return chat; 113 | } 114 | 115 | async switchChat(chatId) { 116 | if (!this.chats.has(chatId)) { 117 | throw new Error('对话不存在'); 118 | } 119 | this.currentChatId = chatId; 120 | await this.storage.set({ [CURRENT_CHAT_ID_KEY]: chatId }); 121 | return this.chats.get(chatId); 122 | } 123 | 124 | async deleteChat(chatId) { 125 | if (!this.chats.has(chatId)) { 126 | throw new Error('对话不存在'); 127 | } 128 | 129 | const deletedChat = this.chats.get(chatId); 130 | const deletedRefs = Array.isArray(deletedChat?.[YT_TRANSCRIPT_REF_FIELD]) 131 | ? deletedChat[YT_TRANSCRIPT_REF_FIELD] 132 | : []; 133 | const deletedKeys = Array.from(new Set(deletedRefs.map(r => r?.key).filter(Boolean))); 134 | 135 | this.chats.delete(chatId); 136 | this._pendingRemovals.add(chatKeyV2(chatId)); 137 | this._indexDirty = true; 138 | this.saveChats(); 139 | 140 | // 清理不再被任何对话引用的 YouTube 字幕缓存 141 | if (deletedKeys.length > 0) { 142 | const stillReferenced = new Set(); 143 | for (const chat of this.chats.values()) { 144 | const refs = Array.isArray(chat?.[YT_TRANSCRIPT_REF_FIELD]) ? chat[YT_TRANSCRIPT_REF_FIELD] : []; 145 | refs.forEach((ref) => { 146 | if (ref?.key) stillReferenced.add(ref.key); 147 | }); 148 | } 149 | 150 | const keysToRemove = deletedKeys.filter((k) => !stillReferenced.has(k)); 151 | if (keysToRemove.length > 0) { 152 | await this.storage.remove(keysToRemove).catch(() => {}); 153 | } 154 | } 155 | 156 | // 如果删除的是当前对话,切换到其他对话 157 | if (chatId === this.currentChatId) { 158 | const nextChat = Array.from(this.chats.values()).pop(); 159 | if (nextChat) { 160 | await this.switchChat(nextChat.id); 161 | this.currentChatId = nextChat.id; 162 | } else { 163 | const newChat = this.createNewChat('默认对话'); 164 | await this.switchChat(newChat.id); 165 | this.currentChatId = newChat.id; 166 | } 167 | } 168 | } 169 | 170 | /** 171 | * 记录当前对话引用的 YouTube 字幕缓存 key(用于跨消息复用 & 删除对话时 GC)。 172 | * @param {string} chatId 173 | * @param {{key: string, videoId?: string, lang?: string, updatedAt?: number}} ref 174 | */ 175 | addYouTubeTranscriptRef(chatId, ref) { 176 | if (!chatId || !ref?.key) return; 177 | const chat = this.chats.get(chatId); 178 | if (!chat) return; 179 | 180 | if (!Array.isArray(chat[YT_TRANSCRIPT_REF_FIELD])) { 181 | chat[YT_TRANSCRIPT_REF_FIELD] = []; 182 | } 183 | 184 | const exists = chat[YT_TRANSCRIPT_REF_FIELD].some((r) => r?.key === ref.key); 185 | if (!exists) { 186 | chat[YT_TRANSCRIPT_REF_FIELD].push({ 187 | key: ref.key, 188 | videoId: ref.videoId || null, 189 | lang: ref.lang || null, 190 | updatedAt: ref.updatedAt || Date.now() 191 | }); 192 | } else { 193 | // Update metadata if present 194 | const idx = chat[YT_TRANSCRIPT_REF_FIELD].findIndex((r) => r?.key === ref.key); 195 | if (idx >= 0) { 196 | const current = chat[YT_TRANSCRIPT_REF_FIELD][idx] || {}; 197 | chat[YT_TRANSCRIPT_REF_FIELD][idx] = { 198 | ...current, 199 | videoId: ref.videoId || current.videoId || null, 200 | lang: ref.lang || current.lang || null, 201 | updatedAt: ref.updatedAt || Date.now() 202 | }; 203 | } 204 | } 205 | 206 | this._dirtyChatIds.add(chatId); 207 | this.saveChats(); 208 | } 209 | 210 | /** 211 | * 找到某个对话里对某个视频的字幕引用(优先最新)。 212 | * @param {string} chatId 213 | * @param {string} videoId 214 | */ 215 | getYouTubeTranscriptRef(chatId, videoId) { 216 | if (!chatId || !videoId) return null; 217 | const chat = this.chats.get(chatId); 218 | if (!chat) return null; 219 | const refs = Array.isArray(chat?.[YT_TRANSCRIPT_REF_FIELD]) ? chat[YT_TRANSCRIPT_REF_FIELD] : []; 220 | const matches = refs.filter((r) => r?.videoId === videoId && r?.key); 221 | if (matches.length === 0) return null; 222 | matches.sort((a, b) => (b?.updatedAt || 0) - (a?.updatedAt || 0)); 223 | return matches[0]; 224 | } 225 | 226 | getCurrentChat() { 227 | return this.chats.get(this.currentChatId); 228 | } 229 | 230 | getAllChats() { 231 | return Array.from(this.chats.values()).sort((a, b) => 232 | new Date(b.createdAt) - new Date(a.createdAt) 233 | ); 234 | } 235 | 236 | async addMessageToCurrentChat(message) { 237 | const currentChat = this.getCurrentChat(); 238 | if (!currentChat) { 239 | throw new Error('当前没有活动的对话'); 240 | } 241 | currentChat.messages.push(message); 242 | this._dirtyChatIds.add(currentChat.id); 243 | this.saveChats(); 244 | } 245 | 246 | async updateLastMessage(chatId, message) { 247 | const currentChat = this.chats.get(chatId); 248 | if (!currentChat || currentChat.messages.length === 0) { 249 | // throw new Error('当前没有消息可以更新'); 250 | return; 251 | } 252 | if (currentChat.messages[currentChat.messages.length - 1].role === 'user') { 253 | currentChat.messages.push({ 254 | role: 'assistant', 255 | updating: true 256 | }); 257 | } 258 | if (message.content) { 259 | currentChat.messages[currentChat.messages.length - 1].content = message.content; 260 | } 261 | if (message.reasoning_content) { 262 | currentChat.messages[currentChat.messages.length - 1].reasoning_content = message.reasoning_content; 263 | } 264 | this._dirtyChatIds.add(chatId); 265 | this.saveChats(); 266 | } 267 | 268 | async popMessage() { 269 | const currentChat = this.getCurrentChat(); 270 | if (!currentChat) { 271 | throw new Error('对话不存在'); 272 | } 273 | currentChat.messages.pop(); 274 | this._dirtyChatIds.add(currentChat.id); 275 | this.saveChats(); 276 | } 277 | 278 | async saveChats() { 279 | this._saveDirty = true; 280 | 281 | if (!this._savePromise) { 282 | this._savePromise = new Promise((resolve, reject) => { 283 | this._savePromiseResolve = resolve; 284 | this._savePromiseReject = reject; 285 | }); 286 | // 防止未 await 的调用产生 Unhandled Promise Rejection 287 | this._savePromise.catch(() => {}); 288 | } 289 | 290 | // 兼容:部分调用方会直接 mutate currentChat.messages 后再调用 saveChats() 291 | if (this.currentChatId && !this._dirtyChatIds.has(this.currentChatId)) { 292 | this._dirtyChatIds.add(this.currentChatId); 293 | } 294 | 295 | this._scheduleSave(); 296 | return this._savePromise; 297 | } 298 | 299 | async clearCurrentChat() { 300 | const currentChat = this.getCurrentChat(); 301 | if (currentChat) { 302 | currentChat.messages = []; 303 | this._dirtyChatIds.add(currentChat.id); 304 | this.saveChats(); 305 | } 306 | } 307 | 308 | _scheduleSave() { 309 | if (this._saveScheduled) return; 310 | this._saveScheduled = true; 311 | 312 | const run = (deadline) => { 313 | this._saveScheduled = false; 314 | void this._flushSave(deadline); 315 | }; 316 | 317 | if (typeof requestIdleCallback === 'function') { 318 | requestIdleCallback(run, { timeout: 1000 }); 319 | } else { 320 | setTimeout(run, 0); 321 | } 322 | } 323 | 324 | async _flushSave(deadline) { 325 | if (this._saveInProgress) return; 326 | if (!this._saveDirty) return; 327 | 328 | this._saveInProgress = true; 329 | try { 330 | const shouldYield = () => { 331 | if (!deadline || typeof deadline.timeRemaining !== 'function') return false; 332 | return deadline.timeRemaining() < 10; 333 | }; 334 | 335 | // 1) 处理删除 336 | if (this._pendingRemovals.size > 0 && !shouldYield()) { 337 | const keysToRemove = Array.from(this._pendingRemovals); 338 | this._pendingRemovals.clear(); 339 | await this.storage.remove(keysToRemove); 340 | } 341 | 342 | // 2) 确保 v2 索引存在/更新(只存 chatId 列表,避免重复大数据) 343 | if (this._indexDirty && !shouldYield()) { 344 | this._indexDirty = false; 345 | await this.storage.set({ [CHATS_INDEX_V2_KEY]: Array.from(this.chats.keys()) }); 346 | } 347 | 348 | // 3) 迁移:把 legacy 的 chat 逐步写入 v2(每次写一个,避免长任务) 349 | if (this._migrationQueue.length > 0 && !shouldYield()) { 350 | const migrateId = this._migrationQueue.pop(); 351 | const chat = this.chats.get(migrateId); 352 | if (chat) { 353 | await this.storage.set({ [chatKeyV2(migrateId)]: chat }); 354 | } 355 | } 356 | 357 | // 4) 正常保存:只写入被修改的 chat(避免每次写整份 77MB) 358 | if (this._dirtyChatIds.size > 0 && !shouldYield()) { 359 | const [chatId] = this._dirtyChatIds; 360 | this._dirtyChatIds.delete(chatId); 361 | const chat = this.chats.get(chatId); 362 | if (chat) { 363 | await this.storage.set({ [chatKeyV2(chatId)]: chat }); 364 | } 365 | } 366 | 367 | // 迁移完成后,尝试清理 legacy 大对象(如果存在) 368 | if (!this._legacyCleanupDone && this._migrationQueue.length === 0) { 369 | // 注意:remove 是幂等的;如果 legacy 已不存在也没关系 370 | this._legacyCleanupDone = true; 371 | await this.storage.remove(LEGACY_CHATS_KEY).catch(() => {}); 372 | } 373 | 374 | // 如果还有待处理工作,继续调度;否则完成本次保存 375 | this._saveDirty = this._pendingRemovals.size > 0 || 376 | this._indexDirty || 377 | this._migrationQueue.length > 0 || 378 | this._dirtyChatIds.size > 0; 379 | 380 | if (!this._saveDirty) { 381 | this._savePromiseResolve?.(); 382 | this._savePromise = null; 383 | this._savePromiseResolve = null; 384 | this._savePromiseReject = null; 385 | } else { 386 | this._scheduleSave(); 387 | } 388 | } catch (err) { 389 | console.error('保存对话失败:', err); 390 | this._savePromiseReject?.(err); 391 | this._savePromise = null; 392 | this._savePromiseResolve = null; 393 | this._savePromiseReject = null; 394 | } finally { 395 | this._saveInProgress = false; 396 | } 397 | } 398 | } 399 | 400 | // 创建并导出单例实例 401 | export const chatManager = new ChatManager(); 402 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cerebr 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 76 |
77 | 84 | 128 |
129 |
130 |
131 |
132 | 137 | API 设置 138 |
139 |
140 | 141 | 187 |
188 |
189 |
190 |
191 | 196 | 偏好设置 197 |
198 |
199 |
200 |
201 | 202 | 208 |
209 |
210 | 211 | 217 |
218 |
219 |
220 |
221 | 版本号 222 | - 223 |
224 | 225 | 开源地址 226 | 227 | GitHub 228 | 231 | 232 | 233 | 234 | 交流群 235 | 236 | Telegram 237 | 240 | 241 | 242 | 251 |
252 |
253 |
254 |
255 |
256 | 261 | 对话列表 262 |
263 | 264 | 269 |
270 |
271 |
272 | 273 | 286 |
287 |
288 | 298 |
299 | 300 | 301 | 302 | --------------------------------------------------------------------------------