├── .gitignore ├── README.md ├── images ├── api-setting.png ├── custom-tool-usage1.png ├── custom-tool-usage2.png ├── installation1.png ├── installation2.png ├── installation3.png ├── installation4.png ├── installation5.png ├── model-setting.png └── quote-usage.png ├── package-lock.json ├── package.json ├── public ├── background.js ├── contentScript.js ├── floatButton.css ├── floatButton.js ├── icons │ ├── icon128.png │ ├── icon16.png │ └── icon48.png ├── index.html ├── manifest.json └── styles.css ├── src ├── api │ └── chatApi.js ├── assets │ └── styles.css ├── components │ ├── App.js │ ├── ChatPanel.js │ ├── HistoryPanel.js │ ├── MessageList.js │ ├── ModelSelector.js │ ├── SettingsPanel.js │ ├── Tabs.js │ └── ToolsPanel.js ├── index.js └── utils │ └── storage.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # 测试 7 | /coverage 8 | 9 | # 构建输出 10 | /build 11 | /dist 12 | 13 | # 环境变量 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | # 日志 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | *.log 25 | 26 | # 编辑器配置 27 | .idea/ 28 | .vscode/ 29 | *.swp 30 | *.swo 31 | .DS_Store 32 | 33 | # 缓存 34 | .cache/ 35 | .eslintcache 36 | .npm 37 | 38 | # 临时文件 39 | *.tmp 40 | *.temp -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebChat 浏览器侧边栏 AI 问答插件 2 | 3 | WebChat 是一个功能强大的 Chrome 扩展,可以帮助您在浏览网页时快速引用内容并与 AI 进行交互。 4 | 5 | 现有的网页侧边栏 AI 插件存在``几个不好用的硬伤``: 6 | 1. 不能自定义 api 设置自己想用的模型; 7 | 2. 不能对多段不连续的内容同时引用提问; 8 | 3. 不能自定义提示词。希望使用此功能:选中网页文本之后弹出自定义命令按钮,点击弹出的自定义的命令按钮就会``选中内容+自定义提示词``快速同时发送(比如:自定义“翻译”按钮,提示词是:“将上述内容翻译为英文”)。 9 | 10 | **项目地址:** [https://github.com/tangpan360/webchat](https://github.com/tangpan360/webchat) 11 | 12 | ## 使用方法 13 | 14 | ### 划线引用 15 | 1. 选中网页上的任意文本 16 | 2. 点击出现的"引用"按钮 17 | 3. 引用内容会自动添加到侧边栏中 18 | 4. 可以继续选择其他文本进行引用 19 | 5. 在输入框中添加问题或直接发送引用内容 20 | 21 | ![划线引用使用示意图](images/quote-usage.png) 22 | 23 | ### 自定义工具 24 | 1. 点击"划线工具栏"选项卡 25 | 2. 添加新工具,设置名称和提示词 26 | 3. 选中网页文本时,新工具按钮会出现在引用按钮旁边 27 | 4. 点击自定义工具按钮,自动引用内容并添加提示词发送 28 | 29 | ![自定义工具使用示意图1](images/custom-tool-usage1.png) 30 | ![自定义工具使用示意图2](images/custom-tool-usage2.png) 31 | 32 | ### 自定义模型设置 33 | 1. 点击侧边栏右上角的设置图标 34 | 2. 在设置面板中选择"设置"选项卡 35 | 3. 选择您希望使用的模型 36 | 4. 可以添加自定义API密钥和自定义参数 37 | 38 | ![自定义api设置示意图](images/api-setting.png) 39 | ![自定义模型设置示意图](images/model-setting.png) 40 | 41 | ## 主要功能 42 | 43 | ### 1. 划线引用功能 44 | - 选中网页文本后,会出现引用按钮工具栏 45 | - 支持多段不连续内容的引用 46 | - 所有引用内容显示在输入框上方,可随时编辑或删除 47 | 48 | ### 2. 自定义划线工具 49 | - 通过"划线工具栏"选项卡添加自定义工具 50 | - 每个工具可以配置名称和提示词 51 | - 选中文本后一键执行"引用+提示词+发送"的操作组合 52 | 53 | ### 3. 聊天功能 54 | - 支持 GPT-3.5、GPT-4 等多种模型 55 | - 流式响应,实时显示 AI 回复 56 | - 支持停止生成、重新生成等操作 57 | 58 | ### 4. 历史记录管理 59 | - 自动保存所有对话 60 | - 历史记录可随时查看和恢复 61 | - 支持编辑和删除历史消息 62 | 63 | ### 5. 界面特性 64 | - 悬浮按钮可拖动且紧贴屏幕边缘 65 | - 支持消息复制、删除等快捷操作 66 | 67 | ## 安装方法 68 | 69 | ### 方法一:直接安装发布版本(推荐) 70 | 71 | 1. 前往本项目的 [Releases](https://github.com/tangpan360/webchat/releases) 页面 72 | 2. 下载最新版本的 `webchat.zip` 文件 73 | 3. 解压下载的文件到本地文件夹 74 | 4. 在 Chrome 浏览器中打开 `chrome://extensions/` 75 | 5. 开启右上角的"开发者模式" 76 | 6. 点击"加载已解压的扩展程序" 77 | 7. 选择解压后的文件夹 78 | 8. 扩展安装完成后,图标将显示在浏览器工具栏 79 | 80 | ![安装步骤示意图1](images/installation1.png) 81 | ![安装步骤示意图2](images/installation2.png) 82 | ![安装步骤示意图3](images/installation3.png) 83 | ![安装步骤示意图4](images/installation4.png) 84 | ![安装步骤示意图5](images/installation5.png) 85 | 86 | ### 方法二:从源码构建安装(开发者) 87 | 88 | 1. 首先,克隆或下载本仓库到本地: 89 | ```bash 90 | git clone https://github.com/tangpan360/webchat.git 91 | cd webchat 92 | ``` 93 | 94 | 2. 安装项目依赖: 95 | ```bash 96 | npm install 97 | ``` 98 | 99 | 3. 构建项目: 100 | ```bash 101 | npm run build 102 | ``` 103 | 这将在项目根目录下生成一个 `dist` 文件夹,其中包含扩展所需的所有文件。 104 | 105 | 4. 在 Chrome 浏览器中加载扩展: 106 | - 打开 Chrome 浏览器,在地址栏输入 `chrome://extensions/` 107 | - 在右上角开启"开发者模式" 108 | - 点击"加载已解压的扩展程序"按钮 109 | - 选择项目中的 `dist` 目录 110 | - 成功后,扩展图标将出现在浏览器工具栏中 111 | 112 | ## 开发指南 113 | 114 | ### 开发模式 115 | 116 | 如果您想在开发模式下运行: 117 | 118 | ```bash 119 | npm start 120 | ``` 121 | 122 | 这将启动开发服务器,当您修改代码时,扩展会自动重新构建。重新构建后,您需要在 `chrome://extensions/` 页面点击扩展卡片上的"刷新"按钮或重新加载扩展。 123 | 124 | ### 项目结构 125 | 126 | 本项目使用 React 构建,主要文件结构: 127 | 128 | - `/src` - 源代码目录 129 | - `/components` - React 组件 130 | - `/assets` - 样式和资源文件 131 | - `/utils` - 工具函数 132 | - `/api` - API 调用 133 | - `/public` - 静态资源和扩展配置 134 | - `/dist` - 构建输出目录(不包含在源码仓库中) 135 | 136 | ### 发布流程 137 | 138 | 如果您是项目维护者并想发布新版本: 139 | 140 | 1. 更新 `manifest.json` 中的版本号 141 | 2. 构建项目:`npm run build` 142 | 3. 将 `dist` 目录中的所有文件压缩为 zip 文件: 143 | ```bash 144 | cd dist 145 | zip -r ../webchat.zip * 146 | ``` 147 | 4. 在 GitHub 创建新的 Release,上传 zip 文件作为附件 148 | 149 | ## 技术特性 150 | 151 | - 使用 React 构建用户界面 152 | - 使用 Chrome 扩展 API 实现跨页面通信 153 | - 支持 Markdown 渲染,包括代码高亮和公式 154 | - 本地存储聊天历史和用户配置 155 | 156 | ## 贡献指南 157 | 158 | 欢迎提交 Pull Request 或 Issue 来改进这个项目! 159 | 160 | 1. Fork 本仓库 161 | 2. 创建您的功能分支: `git checkout -b feature/amazing-feature` 162 | 3. 提交您的更改: `git commit -m '添加了一些很棒的功能'` 163 | 4. 推送到分支: `git push origin feature/amazing-feature` 164 | 5. 提交 Pull Request 165 | -------------------------------------------------------------------------------- /images/api-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/api-setting.png -------------------------------------------------------------------------------- /images/custom-tool-usage1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/custom-tool-usage1.png -------------------------------------------------------------------------------- /images/custom-tool-usage2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/custom-tool-usage2.png -------------------------------------------------------------------------------- /images/installation1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/installation1.png -------------------------------------------------------------------------------- /images/installation2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/installation2.png -------------------------------------------------------------------------------- /images/installation3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/installation3.png -------------------------------------------------------------------------------- /images/installation4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/installation4.png -------------------------------------------------------------------------------- /images/installation5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/installation5.png -------------------------------------------------------------------------------- /images/model-setting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/model-setting.png -------------------------------------------------------------------------------- /images/quote-usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/images/quote-usage.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webchat", 3 | "version": "1.0.5", 4 | "description": "浏览器侧边栏AI问答插件", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack --watch --mode=development", 8 | "build": "webpack --mode=production", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "katex": "^0.16.21", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-markdown": "^8.0.7", 18 | "react-syntax-highlighter": "^15.5.0", 19 | "rehype-katex": "^6.0.3", 20 | "remark-gfm": "^3.0.1", 21 | "remark-math": "^5.1.1" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.22.5", 25 | "@babel/preset-env": "^7.22.5", 26 | "@babel/preset-react": "^7.22.5", 27 | "babel-loader": "^9.1.2", 28 | "copy-webpack-plugin": "^11.0.0", 29 | "css-loader": "^6.8.1", 30 | "html-webpack-plugin": "^5.5.3", 31 | "style-loader": "^3.3.3", 32 | "webpack": "^5.88.0", 33 | "webpack-cli": "^5.1.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/background.js: -------------------------------------------------------------------------------- 1 | // 激活侧边栏功能 2 | chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); 3 | 4 | // 监听扩展安装或更新 5 | chrome.runtime.onInstalled.addListener((details) => { 6 | if (details.reason === "install") { 7 | console.log("WebChat扩展已安装"); 8 | // 初始化工具栏设置 9 | chrome.storage.local.set({ 'toolbarSettings': { enabled: true } }); 10 | } else if (details.reason === "update") { 11 | console.log(`WebChat扩展已更新到版本 ${chrome.runtime.getManifest().version}`); 12 | // 确保工具栏设置存在 13 | chrome.storage.local.get(['toolbarSettings'], (result) => { 14 | if (!result.toolbarSettings) { 15 | chrome.storage.local.set({ 'toolbarSettings': { enabled: true } }); 16 | } 17 | }); 18 | } 19 | }); 20 | 21 | // 存储引用内容的数组 22 | let quotedTexts = []; 23 | 24 | // 存储自定义工具 25 | let customTools = []; 26 | 27 | // 工具栏设置 28 | let toolbarSettings = { enabled: true }; 29 | 30 | // 状态变量 31 | let pendingActions = []; // 存储等待执行的操作 32 | let sidePanelReady = false; // 侧边栏是否已准备好 33 | let sidePanelOpening = false; // 侧边栏是否正在打开中 34 | 35 | // 初始化存储 36 | chrome.storage.local.get(['customTools', 'toolbarSettings'], (result) => { 37 | if (result.customTools) { 38 | customTools = result.customTools; 39 | } 40 | if (result.toolbarSettings) { 41 | toolbarSettings = result.toolbarSettings; 42 | } 43 | }); 44 | 45 | // 跟踪侧边栏状态 46 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 47 | if (message.type === "sidePanelReady") { 48 | console.log("侧边栏已准备好接收消息"); 49 | sidePanelReady = true; 50 | sidePanelOpening = false; 51 | 52 | // 处理所有待处理的操作 53 | if (pendingActions.length > 0) { 54 | console.log(`执行${pendingActions.length}个待处理操作`); 55 | pendingActions.forEach(action => { 56 | chrome.runtime.sendMessage(action); 57 | }); 58 | pendingActions = []; // 清空待处理队列 59 | } 60 | 61 | if (sendResponse) { 62 | sendResponse({ status: "acknowledged" }); 63 | } 64 | } 65 | 66 | // 当侧边栏关闭时重置状态 67 | if (message.type === "sidePanelClosed") { 68 | sidePanelReady = false; 69 | console.log("侧边栏已关闭"); 70 | if (sendResponse) { 71 | sendResponse({ status: "acknowledged" }); 72 | } 73 | } 74 | }); 75 | 76 | // 监听来自content script的消息 77 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 78 | // 处理打开侧边栏的请求 79 | if (message.action === "openSidePanel") { 80 | // 标记侧边栏正在打开 81 | sidePanelOpening = true; 82 | sidePanelReady = false; 83 | chrome.sidePanel.open({ windowId: sender.tab?.windowId }); 84 | } 85 | 86 | // 处理添加引用内容的请求 87 | if (message.type === "addQuote") { 88 | // 添加新的引用内容 89 | quotedTexts.push(message.quote); 90 | 91 | // 将引用内容发送给侧边栏 92 | chrome.runtime.sendMessage({ 93 | type: "updateQuotes", 94 | quotes: quotedTexts 95 | }); 96 | 97 | // 尝试打开侧边栏 98 | if (sender.tab) { 99 | chrome.sidePanel.open({ windowId: sender.tab.windowId }); 100 | } 101 | } 102 | 103 | // 处理获取引用内容的请求 104 | if (message.type === "getQuotes") { 105 | // 直接回复当前引用内容 106 | chrome.runtime.sendMessage({ 107 | type: "updateQuotes", 108 | quotes: quotedTexts 109 | }); 110 | 111 | // 也返回响应(如果请求方使用了回调) 112 | if (sendResponse) { 113 | sendResponse({ quotes: quotedTexts }); 114 | } 115 | 116 | return true; // 指示我们可能会异步回复 117 | } 118 | 119 | // 处理删除单个引用的请求 120 | if (message.type === "deleteQuote") { 121 | quotedTexts = quotedTexts.filter(quote => quote.id !== message.quoteId); 122 | 123 | // 将更新后的引用内容发送给侧边栏 124 | chrome.runtime.sendMessage({ 125 | type: "updateQuotes", 126 | quotes: quotedTexts 127 | }); 128 | } 129 | 130 | // 处理清空所有引用的请求 131 | if (message.type === "clearAllQuotes") { 132 | quotedTexts = []; 133 | 134 | // 通知侧边栏清空引用 135 | chrome.runtime.sendMessage({ 136 | type: "updateQuotes", 137 | quotes: [] 138 | }); 139 | } 140 | 141 | // 获取自定义工具 142 | if (message.type === "getCustomTools") { 143 | chrome.storage.local.get(['customTools'], (result) => { 144 | if (result.customTools) { 145 | customTools = result.customTools; 146 | } 147 | sendResponse({ tools: customTools }); 148 | }); 149 | return true; // 指示我们会异步回复 150 | } 151 | 152 | // 更新自定义工具列表 153 | if (message.type === "updateCustomTools") { 154 | chrome.storage.local.get(['customTools'], (result) => { 155 | if (result.customTools) { 156 | customTools = result.customTools; 157 | 158 | // 通知所有内容脚本更新工具按钮 159 | chrome.tabs.query({}, (tabs) => { 160 | tabs.forEach(tab => { 161 | chrome.tabs.sendMessage(tab.id, { type: "updateCustomTools" }) 162 | .catch(() => {}); // 忽略不支持的标签页错误 163 | }); 164 | }); 165 | } 166 | }); 167 | } 168 | 169 | // 更新工具栏设置 170 | if (message.type === "updateToolbarSettings") { 171 | if (message.settings && message.settings.hasOwnProperty('enabled')) { 172 | toolbarSettings = message.settings; 173 | 174 | // 保存设置到存储 175 | chrome.storage.local.set({ 'toolbarSettings': toolbarSettings }); 176 | 177 | // 通知所有内容脚本更新工具栏状态 178 | chrome.tabs.query({}, (tabs) => { 179 | tabs.forEach(tab => { 180 | chrome.tabs.sendMessage(tab.id, { 181 | type: "updateToolbarSettings", 182 | settings: toolbarSettings 183 | }).catch(() => {}); // 忽略不支持的标签页错误 184 | }); 185 | }); 186 | 187 | console.log('工具栏设置已更新:', toolbarSettings); 188 | } 189 | } 190 | 191 | // 执行工具操作(自动发送引用+提示词) 192 | if (message.type === "executeToolAction") { 193 | try { 194 | // 确保数据完整 195 | if (!message.data || !message.data.text || !message.data.prompt) { 196 | console.error('executeToolAction 数据不完整:', message.data); 197 | return; 198 | } 199 | 200 | // 生成唯一操作ID(如果没有) 201 | if (!message.data.actionId) { 202 | message.data.actionId = Date.now().toString() + Math.random().toString(36).substring(2, 8); 203 | console.log('为工具操作生成ID:', message.data.actionId); 204 | } 205 | 206 | console.log('background收到工具操作请求:', message.data.prompt, 'ID:', message.data.actionId); 207 | 208 | const actionMessage = { 209 | type: "executeToolAction", 210 | data: message.data 211 | }; 212 | 213 | // 如果侧边栏已准备好,直接发送 214 | if (sidePanelReady) { 215 | console.log('侧边栏已准备好,直接发送工具操作', message.data.actionId); 216 | chrome.runtime.sendMessage(actionMessage); 217 | } 218 | // 侧边栏未打开或正在打开中,添加到待处理队列 219 | else { 220 | // 如果侧边栏未打开,尝试打开 221 | if (!sidePanelOpening) { 222 | console.log('侧边栏未打开,正在打开侧边栏'); 223 | sidePanelOpening = true; 224 | if (sender.tab) { 225 | chrome.sidePanel.open({ windowId: sender.tab.windowId }); 226 | } 227 | } else { 228 | console.log('侧边栏正在打开中'); 229 | } 230 | 231 | // 将消息添加到队列(确保队列中没有重复的相同消息) 232 | // 先检查队列中是否已经有相同ID的消息 233 | const existingIndex = pendingActions.findIndex( 234 | act => act.data && act.data.actionId === message.data.actionId 235 | ); 236 | 237 | if (existingIndex >= 0) { 238 | console.log('队列中已存在相同ID的消息,跳过添加'); 239 | } else { 240 | console.log('将工具操作添加到待处理队列', message.data.actionId); 241 | pendingActions.push(actionMessage); 242 | } 243 | } 244 | } catch (error) { 245 | console.error('处理工具操作请求时出错:', error); 246 | } 247 | 248 | return true; 249 | } 250 | 251 | return true; // 允许异步响应 252 | }); -------------------------------------------------------------------------------- /public/contentScript.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 内容脚本,用于捕获网页中的选中文本 3 | * 这个脚本会被注入到浏览器扩展所访问的网页中 4 | */ 5 | 6 | // 工具栏及按钮元素 7 | let toolsContainer = null; 8 | let customTools = []; // 自定义工具列表 9 | let toolbarEnabled = true; // 工具栏启用状态 10 | 11 | // 创建工具栏 12 | function createToolsContainer() { 13 | if (toolsContainer) return; 14 | 15 | // 创建工具栏容器 16 | toolsContainer = document.createElement('div'); 17 | toolsContainer.className = 'webchat-tools-container webchat-extension-styles'; 18 | // 添加不可选中属性 19 | toolsContainer.setAttribute('unselectable', 'on'); 20 | toolsContainer.setAttribute('onselectstart', 'return false;'); 21 | 22 | // 添加阻止事件冒泡 23 | toolsContainer.addEventListener('mousedown', function(e) { 24 | e.stopPropagation(); 25 | }); 26 | 27 | toolsContainer.addEventListener('click', function(e) { 28 | e.stopPropagation(); 29 | }); 30 | 31 | // 添加到文档中 32 | document.body.appendChild(toolsContainer); 33 | 34 | // 加载自定义工具 35 | loadCustomTools(); 36 | 37 | // 加载工具栏设置 38 | loadToolbarSettings(); 39 | 40 | // 初始隐藏工具栏 41 | hideToolsContainer(); 42 | } 43 | 44 | // 加载自定义工具 45 | function loadCustomTools() { 46 | if (typeof chrome !== 'undefined' && chrome.runtime) { 47 | chrome.runtime.sendMessage({ type: 'getCustomTools' }, (response) => { 48 | if (response && response.tools) { 49 | customTools = response.tools; 50 | updateToolsButtons(); 51 | } 52 | }); 53 | } 54 | } 55 | 56 | // 加载工具栏设置 57 | function loadToolbarSettings() { 58 | if (typeof chrome !== 'undefined' && chrome.runtime && chrome.storage) { 59 | chrome.storage.local.get(['toolbarSettings'], (result) => { 60 | if (result && result.toolbarSettings) { 61 | toolbarEnabled = result.toolbarSettings.enabled; 62 | console.log('工具栏启用状态:', toolbarEnabled); 63 | } 64 | }); 65 | } 66 | } 67 | 68 | // 更新工具按钮 69 | function updateToolsButtons() { 70 | // 清空现有按钮 71 | toolsContainer.innerHTML = ''; 72 | 73 | // 添加默认的复制按钮 74 | const copyButton = createToolButton('复制', handleCopyButtonClick); 75 | toolsContainer.appendChild(copyButton); 76 | 77 | // 添加默认的引用按钮 78 | const quoteButton = createToolButton('引用', handleQuoteButtonClick); 79 | toolsContainer.appendChild(quoteButton); 80 | 81 | // 添加自定义工具按钮 82 | customTools.forEach(tool => { 83 | const button = createToolButton(tool.name, (event) => handleCustomToolClick(tool, event)); 84 | toolsContainer.appendChild(button); 85 | }); 86 | } 87 | 88 | // 创建工具按钮 89 | function createToolButton(text, clickHandler) { 90 | const button = document.createElement('button'); 91 | button.className = 'webchat-tool-button'; 92 | button.textContent = text; 93 | button.addEventListener('click', (event) => { 94 | // 阻止事件冒泡,防止选中内容被取消 95 | event.stopPropagation(); 96 | event.preventDefault(); 97 | clickHandler(event); 98 | }); 99 | return button; 100 | } 101 | 102 | // 复制按钮点击处理函数 103 | function handleCopyButtonClick(event) { 104 | // 阻止事件冒泡和默认行为 105 | event.stopPropagation(); 106 | event.preventDefault(); 107 | 108 | const selection = window.getSelection(); 109 | if (!selection || !selection.toString().trim()) return; 110 | 111 | const selectedText = selection.toString().trim(); 112 | const button = event.target; 113 | const originalText = button.textContent; 114 | const originalBackground = button.style.background || '#1a73e8'; 115 | const buttonWidth = button.offsetWidth; 116 | const buttonHeight = button.offsetHeight; 117 | 118 | // 尝试复制文本到剪贴板 119 | navigator.clipboard.writeText(selectedText) 120 | .then(() => { 121 | // 复制成功,显示对号并变绿 122 | button.style.width = `${buttonWidth}px`; 123 | button.style.height = `${buttonHeight}px`; 124 | button.innerHTML = ''; 125 | button.style.background = '#52c41a'; 126 | 127 | // 1秒后恢复原样 128 | setTimeout(() => { 129 | button.textContent = originalText; 130 | button.style.background = originalBackground; 131 | button.style.width = ''; 132 | button.style.height = ''; 133 | }, 1000); 134 | }) 135 | .catch(error => { 136 | // 复制失败,显示错号并变红 137 | button.style.width = `${buttonWidth}px`; 138 | button.style.height = `${buttonHeight}px`; 139 | button.innerHTML = ''; 140 | button.style.background = '#f44336'; 141 | 142 | // 1秒后恢复原样 143 | setTimeout(() => { 144 | button.textContent = originalText; 145 | button.style.background = originalBackground; 146 | button.style.width = ''; 147 | button.style.height = ''; 148 | }, 1000); 149 | 150 | console.error('复制到剪贴板失败:', error); 151 | }); 152 | } 153 | 154 | // 引用按钮点击处理函数 155 | function handleQuoteButtonClick(event) { 156 | // 阻止事件冒泡和默认行为 157 | event.stopPropagation(); 158 | event.preventDefault(); 159 | 160 | const selection = window.getSelection(); 161 | if (!selection || !selection.toString().trim()) return; 162 | 163 | const selectedText = selection.toString().trim(); 164 | 165 | // 获取当前时间作为引用ID 166 | const quoteId = Date.now().toString(); 167 | 168 | // 向侧边栏应用发送消息 169 | window.postMessage({ 170 | type: 'addQuote', 171 | quote: { 172 | id: quoteId, 173 | text: selectedText 174 | } 175 | }, '*'); 176 | 177 | // 向浏览器扩展发送消息 178 | if (typeof chrome !== 'undefined' && chrome.runtime) { 179 | chrome.runtime.sendMessage({ 180 | type: 'addQuote', 181 | quote: { 182 | id: quoteId, 183 | text: selectedText 184 | } 185 | }); 186 | } 187 | 188 | // 自动打开侧边栏 189 | if (typeof chrome !== 'undefined' && chrome.runtime) { 190 | chrome.runtime.sendMessage({ 191 | action: "openSidePanel" 192 | }); 193 | } 194 | 195 | // 清除选区并隐藏工具栏 196 | selection.removeAllRanges(); 197 | hideToolsContainer(); 198 | } 199 | 200 | // 自定义工具按钮点击处理函数 201 | function handleCustomToolClick(tool, event) { 202 | // 阻止事件冒泡和默认行为 203 | event.stopPropagation(); 204 | event.preventDefault(); 205 | 206 | try { 207 | const selection = window.getSelection(); 208 | if (!selection || !selection.toString().trim()) return; 209 | 210 | const selectedText = selection.toString().trim(); 211 | 212 | // 获取当前时间作为引用ID 213 | const quoteId = Date.now().toString(); 214 | 215 | // 为操作生成唯一ID 216 | const actionId = quoteId + Math.random().toString(36).substring(2, 8); 217 | 218 | // 引用选中内容并立即发送工具操作请求 219 | if (typeof chrome !== 'undefined' && chrome.runtime) { 220 | console.log('处理工具点击:', tool.name, 'ID:', actionId); 221 | 222 | // 首先添加引用 223 | chrome.runtime.sendMessage({ 224 | type: 'addQuote', 225 | quote: { 226 | id: quoteId, 227 | text: selectedText 228 | } 229 | }); 230 | 231 | // 然后打开侧边栏 232 | chrome.runtime.sendMessage({ 233 | action: "openSidePanel" 234 | }); 235 | 236 | // 最后发送工具操作 237 | console.log('发送工具操作请求:', tool.name); 238 | chrome.runtime.sendMessage({ 239 | type: 'executeToolAction', 240 | data: { 241 | text: selectedText, 242 | prompt: tool.prompt, 243 | actionId: actionId // 添加唯一ID 244 | } 245 | }); 246 | } 247 | 248 | // 清除选区并隐藏工具栏 249 | selection.removeAllRanges(); 250 | hideToolsContainer(); 251 | } catch (error) { 252 | console.error('工具操作执行失败:', error); 253 | // 仍然隐藏工具栏 254 | hideToolsContainer(); 255 | } 256 | } 257 | 258 | // 显示工具栏 259 | function showToolsContainer(x, y) { 260 | // 如果工具栏被禁用,则不显示 261 | if (!toolbarEnabled) return; 262 | 263 | if (!toolsContainer) createToolsContainer(); 264 | 265 | // 设置工具栏位置 266 | toolsContainer.style.left = `${x}px`; 267 | toolsContainer.style.top = `${y}px`; 268 | toolsContainer.style.display = 'flex'; 269 | } 270 | 271 | // 隐藏工具栏 272 | function hideToolsContainer() { 273 | if (toolsContainer) { 274 | toolsContainer.style.display = 'none'; 275 | } 276 | } 277 | 278 | // 处理文本选择 279 | function handleTextSelection(e) { 280 | setTimeout(() => { 281 | // 如果工具栏被禁用,则不显示 282 | if (!toolbarEnabled) return; 283 | 284 | const selection = window.getSelection(); 285 | if (!selection || !selection.toString().trim()) { 286 | hideToolsContainer(); 287 | return; 288 | } 289 | 290 | // 获取选区的位置 291 | const range = selection.getRangeAt(0); 292 | const rect = range.getBoundingClientRect(); 293 | 294 | // 获取视口尺寸和滚动位置 295 | const viewportHeight = window.innerHeight; 296 | const viewportWidth = window.innerWidth; 297 | const scrollX = window.scrollX; 298 | const scrollY = window.scrollY; 299 | 300 | // 判断选中区域相对于视口的位置 301 | const rectTop = rect.top; // 选区顶部相对于视口顶部的位置 302 | const rectBottom = rect.bottom; // 选区底部相对于视口顶部的位置 303 | const isTopVisible = rectTop >= 0 && rectTop < viewportHeight; 304 | const isBottomVisible = rectBottom >= 0 && rectBottom < viewportHeight; 305 | 306 | // 计算工具栏的基础水平位置(居中或靠左) 307 | let x = scrollX + rect.left; 308 | if (rect.width > 100) { 309 | x += (rect.width / 2) - 50; // 大致居中 310 | } 311 | 312 | // 确保工具栏不会超出视口左右边界 313 | const maxX = viewportWidth - 100; // 假设最小宽度100px 314 | if (x > maxX) x = maxX - 5; 315 | if (x < 0) x = 5; 316 | 317 | let y; 318 | 319 | // 根据不同情况计算工具栏的垂直位置 320 | if (isBottomVisible) { 321 | // 情况1和3:下部可见,显示在下部 322 | y = scrollY + rectBottom + 10; 323 | } else if (isTopVisible) { 324 | // 情况2:上部可见,下部不可见,显示在上部 325 | y = scrollY + rectTop - 40; // 减去工具栏的估计高度和一些间距 326 | } else { 327 | // 情况4:上部和下部都不可见(选中内容太长,中间可见) 328 | // 在视口中间显示工具栏 329 | y = scrollY + (viewportHeight / 2); 330 | } 331 | 332 | // 确保工具栏在视口内可见 333 | if (y < scrollY) { 334 | y = scrollY + 10; // 如果太靠上,则放在页面顶部附近 335 | } else if (y > scrollY + viewportHeight - 50) { 336 | y = scrollY + viewportHeight - 50; // 如果太靠下,则放在页面底部附近 337 | } 338 | 339 | showToolsContainer(x, y); 340 | }, 10); 341 | } 342 | 343 | // 检查是否点击了工具栏之外的区域 344 | function handleDocumentClick(e) { 345 | // 如果工具栏存在,并且点击的不是工具栏或其子元素 346 | if (toolsContainer && e.target !== toolsContainer && !toolsContainer.contains(e.target)) { 347 | hideToolsContainer(); 348 | } 349 | // 注意:这里不添加阻止冒泡,因为这是文档级别的点击,我们需要让普通点击正常工作 350 | } 351 | 352 | // 监听来自扩展后台的消息 353 | function handleExtensionMessages() { 354 | if (typeof chrome !== 'undefined' && chrome.runtime) { 355 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 356 | if (message.type === 'updateCustomTools') { 357 | loadCustomTools(); 358 | } else if (message.type === 'updateToolbarSettings') { 359 | // 更新工具栏启用状态 360 | if (message.settings && message.settings.hasOwnProperty('enabled')) { 361 | toolbarEnabled = message.settings.enabled; 362 | console.log('工具栏启用状态已更新:', toolbarEnabled); 363 | 364 | // 如果禁用了工具栏,则隐藏 365 | if (!toolbarEnabled) { 366 | hideToolsContainer(); 367 | } 368 | } 369 | } 370 | return true; 371 | }); 372 | } 373 | } 374 | 375 | // 处理键盘选择文本 376 | function handleKeyboardSelection(e) { 377 | // 如果工具栏被禁用,则不显示 378 | if (!toolbarEnabled) return; 379 | 380 | // 检测常见的文本选择组合键 381 | const isTextSelectionKey = ( 382 | // Ctrl+A (全选) 383 | (e.ctrlKey && e.key === 'a') || 384 | // Shift+方向键 385 | (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight')) || 386 | // Shift+Home/End 387 | (e.shiftKey && (e.key === 'Home' || e.key === 'End')) 388 | ); 389 | 390 | if (isTextSelectionKey) { 391 | setTimeout(() => { 392 | const selection = window.getSelection(); 393 | if (!selection || !selection.toString().trim()) { 394 | return; 395 | } 396 | 397 | // 特殊处理Ctrl+A全选的情况 398 | if (e.ctrlKey && e.key === 'a' && toolsContainer && toolsContainer.style.display === 'flex') { 399 | // 移除工具栏中的文本从选择中 400 | try { 401 | const selRange = selection.getRangeAt(0); 402 | 403 | // 如果工具栏可见,创建一个不包含工具栏的选择范围 404 | if (document.body.contains(toolsContainer)) { 405 | const toolsRange = document.createRange(); 406 | toolsRange.selectNode(toolsContainer); 407 | 408 | // 检查是否有重叠 409 | if (selRange.intersectsNode(toolsContainer)) { 410 | // 工具栏在选择范围内,我们需要排除它 411 | selection.removeAllRanges(); // 清除当前选择 412 | 413 | // 重新创建选择,但排除工具栏 414 | // 注意:这是一个简化的处理方式,实际上可能需要更复杂的范围操作 415 | const newRange = document.createRange(); 416 | newRange.selectNodeContents(document.body); 417 | selection.addRange(newRange); 418 | 419 | // 重新获取选区范围 420 | if (selection.rangeCount === 0) return; 421 | } 422 | } 423 | } catch (err) { 424 | console.error('调整选择范围时出错:', err); 425 | } 426 | } 427 | 428 | // 获取选区范围 429 | if (selection.rangeCount === 0) return; 430 | const range = selection.getRangeAt(0); 431 | const rect = range.getBoundingClientRect(); 432 | 433 | // 获取视口尺寸和滚动位置 434 | const viewportHeight = window.innerHeight; 435 | const viewportWidth = window.innerWidth; 436 | const scrollX = window.scrollX; 437 | const scrollY = window.scrollY; 438 | 439 | // 判断选中区域相对于视口的位置 440 | const rectTop = rect.top; 441 | const rectBottom = rect.bottom; 442 | const isTopVisible = rectTop >= 0 && rectTop < viewportHeight; 443 | const isBottomVisible = rectBottom >= 0 && rectBottom < viewportHeight; 444 | 445 | // 计算工具栏的水平位置 446 | let x = scrollX + rect.left; 447 | if (rect.width > 100) { 448 | x += (rect.width / 2) - 50; 449 | } 450 | 451 | // 确保不超出视口边界 452 | const maxX = viewportWidth - 100; 453 | if (x > maxX) x = maxX - 5; 454 | if (x < 0) x = 5; 455 | 456 | let y; 457 | 458 | // 根据不同情况计算垂直位置 459 | if (isBottomVisible) { 460 | // 下部可见,显示在下部 461 | y = scrollY + rectBottom + 10; 462 | } else if (isTopVisible) { 463 | // 上部可见,显示在上部 464 | y = scrollY + rectTop - 40; 465 | } else { 466 | // 中间可见或全部不可见,显示在视口中间 467 | y = scrollY + (viewportHeight / 2); 468 | } 469 | 470 | // 确保在视口内可见 471 | if (y < scrollY) { 472 | y = scrollY + 10; 473 | } else if (y > scrollY + viewportHeight - 50) { 474 | y = scrollY + viewportHeight - 50; 475 | } 476 | 477 | showToolsContainer(x, y); 478 | }, 10); 479 | } 480 | } 481 | 482 | // 初始化 483 | function init() { 484 | createToolsContainer(); 485 | 486 | // 添加事件监听器 487 | document.addEventListener('mouseup', handleTextSelection); 488 | document.addEventListener('mousedown', handleDocumentClick); 489 | document.addEventListener('keyup', handleKeyboardSelection); 490 | document.addEventListener('selectionchange', () => { 491 | const selection = window.getSelection(); 492 | if (!selection || !selection.toString().trim()) { 493 | hideToolsContainer(); 494 | } 495 | }); 496 | 497 | // 监听扩展消息 498 | handleExtensionMessages(); 499 | 500 | // 添加样式 501 | const style = document.createElement('style'); 502 | style.textContent = ` 503 | .webchat-tools-container { 504 | position: absolute; 505 | display: flex; 506 | align-items: center; 507 | background-color: #fff; 508 | border-radius: 4px; 509 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 510 | z-index: 10000; 511 | padding: 4px; 512 | gap: 4px; 513 | user-select: none; 514 | -webkit-user-select: none; 515 | -moz-user-select: none; 516 | -ms-user-select: none; 517 | pointer-events: auto; 518 | } 519 | 520 | .webchat-tool-button { 521 | padding: 4px 8px; 522 | background: #1a73e8; 523 | color: white; 524 | border: none; 525 | border-radius: 3px; 526 | font-size: 12px; 527 | cursor: pointer; 528 | font-family: system-ui, -apple-system, sans-serif; 529 | min-width: auto !important; 530 | max-width: none !important; 531 | width: auto !important; 532 | height: auto !important; 533 | line-height: normal !important; 534 | margin: 0 !important; 535 | transition: background-color 0.3s; 536 | display: inline-flex; 537 | justify-content: center; 538 | align-items: center; 539 | min-width: 40px !important; 540 | box-sizing: border-box !important; 541 | line-height: 1 !important; 542 | user-select: none; 543 | -webkit-user-select: none; 544 | -moz-user-select: none; 545 | -ms-user-select: none; 546 | } 547 | 548 | .webchat-tool-button:hover { 549 | background: #0d66d0; 550 | } 551 | `; 552 | document.head.appendChild(style); 553 | 554 | console.log('WebChat 划线工具栏已初始化'); 555 | } 556 | 557 | // 当文档加载完成后初始化 558 | if (document.readyState === 'loading') { 559 | document.addEventListener('DOMContentLoaded', init); 560 | } else { 561 | init(); 562 | } -------------------------------------------------------------------------------- /public/floatButton.css: -------------------------------------------------------------------------------- 1 | /* 悬浮按钮样式 */ 2 | .webchat-float-button { 3 | position: fixed; 4 | right: 0px; 5 | top: 50%; 6 | transform: translateY(-50%); 7 | width: 48px; 8 | height: 48px; 9 | border-radius: 50%; 10 | background-color: #1a73e8; 11 | color: white; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | cursor: pointer; 16 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 17 | z-index: 10000; 18 | transition: all 0.3s ease; 19 | border: none; 20 | outline: none; 21 | min-width: 48px !important; 22 | max-width: 48px !important; 23 | padding: 0 !important; 24 | margin: 0 !important; 25 | box-sizing: border-box !important; 26 | text-align: center !important; 27 | font-size: 16px !important; 28 | line-height: 48px !important; 29 | } 30 | 31 | .webchat-float-button:hover { 32 | background-color: #0d66d0; 33 | transform: translateY(-50%) scale(1.05); 34 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 35 | } 36 | 37 | .webchat-float-button:active { 38 | transform: translateY(-50%) scale(0.95); 39 | } 40 | 41 | /* 悬浮按钮图标 */ 42 | .webchat-float-button svg { 43 | width: 24px; 44 | height: 24px; 45 | stroke: currentColor; 46 | display: block !important; 47 | margin: 0 auto !important; 48 | } 49 | 50 | /* 拖动时的样式 */ 51 | .webchat-float-button.dragging { 52 | opacity: 0.8; 53 | background-color: #0d66d0; 54 | } -------------------------------------------------------------------------------- /public/floatButton.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 浮动按钮脚本 3 | * 在页面右侧显示一个可拖动的悬浮按钮,点击后打开AI对话侧边栏 4 | */ 5 | 6 | (function() { 7 | // 避免在扩展页面中运行 8 | if (window.location.href.includes('chrome-extension://')) { 9 | return; 10 | } 11 | 12 | // 存储按钮状态和位置的键 13 | const BUTTON_POSITION_KEY = 'webchat_float_button_position'; 14 | 15 | // 创建浮动按钮 16 | function createFloatButton() { 17 | // 检查按钮是否已存在 18 | if (document.querySelector('.webchat-float-button')) { 19 | return; 20 | } 21 | 22 | // 创建按钮元素 23 | const floatButton = document.createElement('button'); 24 | floatButton.className = 'webchat-float-button webchat-extension-styles'; 25 | floatButton.setAttribute('title', '打开AI对话'); 26 | floatButton.innerHTML = ` 27 | 28 | 29 | 30 | `; 31 | 32 | // 添加到文档中 33 | document.body.appendChild(floatButton); 34 | 35 | // 载入保存的位置 36 | loadButtonPosition(floatButton); 37 | 38 | // 添加事件监听器 39 | setupDragEvents(floatButton); 40 | setupClickEvent(floatButton); 41 | 42 | console.log('WebChat 悬浮按钮已初始化'); 43 | } 44 | 45 | // 设置拖动事件 46 | function setupDragEvents(button) { 47 | let isDragging = false; 48 | let initialY, initialTop; 49 | 50 | button.addEventListener('mousedown', function(e) { 51 | // 只处理左键点击 52 | if (e.button !== 0) return; 53 | 54 | // 阻止默认行为和冒泡 55 | e.preventDefault(); 56 | e.stopPropagation(); 57 | 58 | // 开始拖动 59 | isDragging = true; 60 | initialY = e.clientY; 61 | initialTop = parseInt(window.getComputedStyle(button).top, 10); 62 | 63 | // 添加拖动样式 64 | button.classList.add('dragging'); 65 | }); 66 | 67 | document.addEventListener('mousemove', function(e) { 68 | if (!isDragging) return; 69 | 70 | // 计算新位置 71 | const newTop = initialTop + (e.clientY - initialY); 72 | 73 | // 限制在窗口内 74 | const maxTop = window.innerHeight - button.offsetHeight; 75 | const limitedTop = Math.max(0, Math.min(newTop, maxTop)); 76 | 77 | // 应用新位置 78 | button.style.top = `${limitedTop}px`; 79 | button.style.transform = 'none'; // 移除默认的居中垂直变换 80 | }); 81 | 82 | document.addEventListener('mouseup', function() { 83 | if (!isDragging) return; 84 | 85 | // 结束拖动 86 | isDragging = false; 87 | button.classList.remove('dragging'); 88 | 89 | // 存储按钮位置 90 | saveButtonPosition(button); 91 | }); 92 | } 93 | 94 | // 设置点击事件 95 | function setupClickEvent(button) { 96 | button.addEventListener('click', function(e) { 97 | // 防止拖动后触发点击 98 | if (button.classList.contains('dragging')) { 99 | return; 100 | } 101 | 102 | // 阻止默认行为和冒泡 103 | e.preventDefault(); 104 | e.stopPropagation(); 105 | 106 | // 打开侧边栏 107 | if (chrome && chrome.runtime) { 108 | chrome.runtime.sendMessage({ action: 'openSidePanel' }); 109 | } 110 | }); 111 | } 112 | 113 | // 保存按钮位置到本地存储 114 | function saveButtonPosition(button) { 115 | const position = { 116 | top: button.style.top 117 | }; 118 | 119 | if (chrome && chrome.storage) { 120 | chrome.storage.local.set({ [BUTTON_POSITION_KEY]: position }); 121 | } else { 122 | localStorage.setItem(BUTTON_POSITION_KEY, JSON.stringify(position)); 123 | } 124 | } 125 | 126 | // 从本地存储加载按钮位置 127 | function loadButtonPosition(button) { 128 | if (chrome && chrome.storage) { 129 | chrome.storage.local.get([BUTTON_POSITION_KEY], function(result) { 130 | if (result && result[BUTTON_POSITION_KEY]) { 131 | applyPosition(button, result[BUTTON_POSITION_KEY]); 132 | } 133 | }); 134 | } else { 135 | const savedPosition = localStorage.getItem(BUTTON_POSITION_KEY); 136 | if (savedPosition) { 137 | applyPosition(button, JSON.parse(savedPosition)); 138 | } 139 | } 140 | } 141 | 142 | // 应用保存的位置 143 | function applyPosition(button, position) { 144 | if (position.top) { 145 | button.style.top = position.top; 146 | button.style.transform = 'none'; // 移除默认的居中垂直变换 147 | } 148 | } 149 | 150 | // 等待页面加载完成后初始化 151 | if (document.readyState === 'loading') { 152 | document.addEventListener('DOMContentLoaded', createFloatButton); 153 | } else { 154 | createFloatButton(); 155 | } 156 | })(); -------------------------------------------------------------------------------- /public/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/public/icons/icon128.png -------------------------------------------------------------------------------- /public/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/public/icons/icon16.png -------------------------------------------------------------------------------- /public/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tangpan360/webchat/6c01dfe6005798c2c275b03c3ce31cb1f33e1c40/public/icons/icon48.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebChat 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "WebChat", 4 | "version": "1.0.5", 5 | "description": "浏览器侧边栏AI问答插件", 6 | "action": { 7 | "default_title": "WebChat", 8 | "default_icon": { 9 | "16": "icons/icon16.png", 10 | "48": "icons/icon48.png", 11 | "128": "icons/icon128.png" 12 | } 13 | }, 14 | "icons": { 15 | "16": "icons/icon16.png", 16 | "48": "icons/icon48.png", 17 | "128": "icons/icon128.png" 18 | }, 19 | "permissions": [ 20 | "storage", 21 | "sidePanel", 22 | "scripting" 23 | ], 24 | "host_permissions": [ 25 | "" 26 | ], 27 | "side_panel": { 28 | "default_path": "index.html" 29 | }, 30 | "background": { 31 | "service_worker": "background.js" 32 | }, 33 | "content_scripts": [ 34 | { 35 | "matches": [""], 36 | "js": ["contentScript.js", "floatButton.js"], 37 | "css": ["styles.css", "floatButton.css"] 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | /* 这些样式仅应用于扩展的自身元素,不影响网页内容 */ 2 | .webchat-extension-styles { 3 | --webchat-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 4 | --webchat-bg-color: #f5f5f5; 5 | --webchat-text-color: #333; 6 | --webchat-border-color: #e0e0e0; 7 | --webchat-active-color: #1a73e8; 8 | } 9 | 10 | /* 只应用于侧边栏面板 */ 11 | body.webchat-panel { 12 | font-family: var(--webchat-font-family); 13 | background-color: var(--webchat-bg-color); 14 | color: var(--webchat-text-color); 15 | margin: 0; 16 | padding: 0; 17 | box-sizing: border-box; 18 | height: 100vh; 19 | overflow: hidden; 20 | } 21 | 22 | body.webchat-panel * { 23 | box-sizing: border-box; 24 | } 25 | 26 | body.webchat-panel #app { 27 | height: 100vh; 28 | display: flex; 29 | flex-direction: column; 30 | } 31 | 32 | body.webchat-panel .tab-container { 33 | display: flex; 34 | border-bottom: 1px solid var(--webchat-border-color); 35 | background-color: #fff; 36 | } 37 | 38 | body.webchat-panel .tab { 39 | padding: 12px 16px; 40 | cursor: pointer; 41 | font-weight: 500; 42 | font-size: 14px; 43 | color: #666; 44 | border-bottom: 2px solid transparent; 45 | } 46 | 47 | body.webchat-panel .tab.active { 48 | color: var(--webchat-active-color); 49 | border-bottom: 2px solid var(--webchat-active-color); 50 | } 51 | 52 | body.webchat-panel .content { 53 | flex: 1; 54 | overflow: auto; 55 | padding: 16px; 56 | background-color: #fff; 57 | } 58 | 59 | body.webchat-panel .hidden { 60 | display: none; 61 | } -------------------------------------------------------------------------------- /src/api/chatApi.js: -------------------------------------------------------------------------------- 1 | import { getStorage } from '../utils/storage'; 2 | 3 | /** 4 | * 压缩消息内容以减少token使用 5 | * @param {string} content - 要压缩的内容 6 | * @param {number} maxLength - 最大长度 7 | * @returns {string} - 压缩后的内容 8 | */ 9 | const compressContent = (content, maxLength) => { 10 | if (!content || content.length <= maxLength) return content; 11 | 12 | // 简单的压缩策略:保留前后部分,中间用...替代 13 | const firstPart = Math.floor(maxLength / 2); 14 | const secondPart = maxLength - firstPart - 3; // 减去"..."的长度 15 | 16 | return content.substring(0, firstPart) + '...' + content.substring(content.length - secondPart); 17 | }; 18 | 19 | /** 20 | * 处理历史消息,控制数量和长度 21 | * @param {Array} messages - 历史消息数组 22 | * @param {Object} settings - 设置对象 23 | * @returns {Array} - 处理后的历史消息 24 | */ 25 | const processMessages = (messages, settings) => { 26 | const maxMessages = settings.maxHistoryMessages || 10; // 默认保留10条 27 | const compressionThreshold = settings.compressionThreshold || 1000; // 默认1000字符压缩 28 | 29 | // 限制消息数量,保留最近的消息 30 | let processedMessages = [...messages]; 31 | 32 | if (processedMessages.length > maxMessages) { 33 | // 始终保留系统消息 34 | const systemMessages = processedMessages.filter(msg => msg.role === 'system'); 35 | 36 | // 取最近的用户和助手消息 37 | const recentMessages = processedMessages 38 | .filter(msg => msg.role !== 'system') 39 | .slice(-maxMessages); 40 | 41 | processedMessages = [...systemMessages, ...recentMessages]; 42 | } 43 | 44 | // 压缩过长的消息 45 | if (compressionThreshold > 0) { 46 | processedMessages = processedMessages.map(msg => { 47 | if (msg.content && msg.content.length > compressionThreshold) { 48 | return { 49 | ...msg, 50 | content: compressContent(msg.content, compressionThreshold) 51 | }; 52 | } 53 | return msg; 54 | }); 55 | } 56 | 57 | return processedMessages; 58 | }; 59 | 60 | /** 61 | * 发送消息到API并获取响应 62 | * @param {string} content - 用户消息内容 63 | * @param {string} modelId - 使用的模型ID 64 | * @param {Array} previousMessages - 之前的消息历史 65 | * @param {Function} onChunkReceived - 接收到内容块时的回调函数 66 | * @param {AbortSignal} signal - 用于中断请求的信号 67 | * @returns {Promise} - 助手的响应消息 68 | */ 69 | export const sendMessage = async (content, modelId, previousMessages, onChunkReceived, signal) => { 70 | // 获取设置 71 | const settings = await getStorage('settings'); 72 | const defaultApiKey = 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; 73 | 74 | // 增强验证:检查密钥是否为空或默认值 75 | if (!settings || !settings.apiKey || settings.apiKey === defaultApiKey || settings.apiKey.trim() === '') { 76 | throw new Error('缺少API密钥,请在设置中配置有效的API密钥'); 77 | } 78 | 79 | // 检查API URL是否有效 80 | if (!settings.apiUrl || settings.apiUrl.trim() === '') { 81 | throw new Error('缺少API URL,请在设置中配置有效的API URL'); 82 | } 83 | 84 | // 处理消息历史,控制数量和长度 85 | const processedHistory = processMessages(previousMessages, settings); 86 | 87 | // 映射为API格式 88 | const history = processedHistory.map(msg => ({ 89 | role: msg.role, 90 | content: msg.content 91 | })); 92 | 93 | // 创建请求体 94 | const messages = [ 95 | ...history, 96 | { role: 'user', content } 97 | ]; 98 | 99 | // 发送请求 100 | try { 101 | const response = await fetch(settings.apiUrl, { 102 | method: 'POST', 103 | headers: { 104 | 'Content-Type': 'application/json', 105 | 'Authorization': `Bearer ${settings.apiKey}` 106 | }, 107 | body: JSON.stringify({ 108 | model: modelId, 109 | messages, 110 | stream: true 111 | }), 112 | signal // 传递AbortSignal以支持中断请求 113 | }); 114 | 115 | if (!response.ok) { 116 | const error = await response.json(); 117 | throw new Error(error.error?.message || '请求失败'); 118 | } 119 | 120 | // 处理流式响应 121 | const reader = response.body.getReader(); 122 | const decoder = new TextDecoder(); 123 | let content = ''; 124 | 125 | // 这里实现流式解析响应 126 | // 注意:不同的API提供商可能有不同的响应格式 127 | // 以下是针对OpenAI API的示例实现 128 | while (true) { 129 | const { done, value } = await reader.read(); 130 | if (done) break; 131 | 132 | const chunk = decoder.decode(value); 133 | const lines = chunk.split('\n'); 134 | 135 | for (const line of lines) { 136 | if (line.startsWith('data: ') && line !== 'data: [DONE]') { 137 | try { 138 | const data = JSON.parse(line.substring(6)); 139 | const contentDelta = data.choices[0]?.delta?.content || ''; 140 | content += contentDelta; 141 | 142 | // 使用回调函数更新UI,实现流式显示 143 | if (contentDelta && typeof onChunkReceived === 'function') { 144 | onChunkReceived(contentDelta); 145 | } 146 | } catch (e) { 147 | console.error('解析响应数据失败:', e); 148 | } 149 | } 150 | } 151 | } 152 | 153 | return { 154 | role: 'assistant', 155 | content, 156 | model: modelId 157 | }; 158 | } catch (error) { 159 | console.error('API请求失败:', error); 160 | throw error; 161 | } 162 | }; -------------------------------------------------------------------------------- /src/assets/styles.css: -------------------------------------------------------------------------------- 1 | /* 全局样式 */ 2 | .app-container { 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | background-color: #f9f9f9; 7 | } 8 | 9 | /* 标签样式 */ 10 | .tabs { 11 | display: flex; 12 | background-color: #fff; 13 | border-bottom: 1px solid #e0e0e0; 14 | } 15 | 16 | .tab { 17 | padding: 12px 20px; 18 | cursor: pointer; 19 | color: #666; 20 | font-weight: 500; 21 | border-bottom: 2px solid transparent; 22 | transition: all 0.2s ease; 23 | } 24 | 25 | .tab:hover { 26 | color: #1a73e8; 27 | } 28 | 29 | .tab.active { 30 | color: #1a73e8; 31 | border-bottom-color: #1a73e8; 32 | } 33 | 34 | /* 面板容器 */ 35 | .panel-container { 36 | flex: 1; 37 | overflow: hidden; 38 | position: relative; 39 | } 40 | 41 | .panel { 42 | display: none; 43 | height: 100%; 44 | overflow: auto; 45 | } 46 | 47 | .panel.active { 48 | display: flex; 49 | flex-direction: column; 50 | } 51 | 52 | /* 聊天面板 */ 53 | .chat-panel { 54 | display: flex; 55 | flex-direction: column; 56 | height: 100%; 57 | } 58 | 59 | .chat-header { 60 | display: flex; 61 | justify-content: space-between; 62 | align-items: center; 63 | padding: 12px 16px; 64 | border-bottom: 1px solid #e0e0e0; 65 | background-color: #fff; 66 | } 67 | 68 | .new-chat-btn { 69 | padding: 8px 16px; 70 | background-color: #1a73e8; 71 | color: white; 72 | border: none; 73 | border-radius: 4px; 74 | cursor: pointer; 75 | font-weight: 500; 76 | } 77 | 78 | .new-chat-btn:hover { 79 | background-color: #0d66d0; 80 | } 81 | 82 | .current-chat-title { 83 | flex: 1; 84 | margin: 0 16px; 85 | font-weight: 500; 86 | color: #333; 87 | white-space: nowrap; 88 | overflow: hidden; 89 | text-overflow: ellipsis; 90 | text-align: center; 91 | } 92 | 93 | /* 模型选择器 */ 94 | .model-selector { 95 | position: relative; 96 | min-width: 140px; 97 | } 98 | 99 | .selected-model { 100 | display: flex; 101 | justify-content: space-between; 102 | align-items: center; 103 | padding: 8px 12px; 104 | border: 1px solid #e0e0e0; 105 | border-radius: 4px; 106 | background-color: #f5f5f5; 107 | cursor: pointer; 108 | } 109 | 110 | .dropdown-arrow { 111 | margin-left: 8px; 112 | font-size: 10px; 113 | } 114 | 115 | .model-dropdown { 116 | position: absolute; 117 | top: 100%; 118 | left: 0; 119 | right: 0; 120 | background-color: white; 121 | border: 1px solid #e0e0e0; 122 | border-radius: 4px; 123 | margin-top: 4px; 124 | z-index: 10; 125 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 126 | } 127 | 128 | .model-option { 129 | padding: 8px 12px; 130 | cursor: pointer; 131 | } 132 | 133 | .model-option:hover { 134 | background-color: #f5f5f5; 135 | } 136 | 137 | .model-option.selected { 138 | background-color: #e8f0fe; 139 | color: #1a73e8; 140 | } 141 | 142 | /* 消息列表 */ 143 | .message-list { 144 | flex: 1; 145 | overflow-y: auto; 146 | padding: 16px; 147 | background-color: #fff; 148 | } 149 | 150 | .message { 151 | margin-bottom: 12px; 152 | display: flex; 153 | flex-direction: column; 154 | position: relative; 155 | } 156 | 157 | .message-header { 158 | display: flex; 159 | justify-content: space-between; 160 | align-items: center; 161 | margin-bottom: 4px; 162 | height: 24px; 163 | } 164 | 165 | .message-role { 166 | font-weight: 600; 167 | color: #444; 168 | } 169 | 170 | .message-actions { 171 | display: flex; 172 | gap: 5px; 173 | opacity: 0.4; 174 | transition: opacity 0.2s ease; 175 | } 176 | 177 | .message:hover .message-actions { 178 | opacity: 1; 179 | } 180 | 181 | .message-action-btn { 182 | background: none; 183 | border: none; 184 | cursor: pointer; 185 | color: #666; 186 | padding: 4px; 187 | border-radius: 4px; 188 | display: flex; 189 | align-items: center; 190 | justify-content: center; 191 | transition: all 0.2s ease; 192 | } 193 | 194 | .message-action-btn:hover { 195 | background-color: #f0f0f0; 196 | color: #1a73e8; 197 | } 198 | 199 | .message-action-btn.delete:hover { 200 | background-color: #ffebee; 201 | color: #d32f2f; 202 | } 203 | 204 | .message.user .message-role { 205 | color: #1a73e8; 206 | } 207 | 208 | .message.assistant .message-role { 209 | color: #34a853; 210 | } 211 | 212 | .message-content { 213 | background-color: #f5f5f5; 214 | padding: 12px 16px 12px 24px; 215 | border-radius: 8px; 216 | line-height: 1.5; 217 | position: relative; 218 | } 219 | 220 | .message-text { 221 | display: flex; 222 | flex-direction: column; 223 | position: relative; 224 | } 225 | 226 | /* 消息加载指示器 */ 227 | .message-loading { 228 | align-self: flex-start; 229 | margin-top: 12px; 230 | padding-left: 2px; 231 | } 232 | 233 | .message-loading .dot { 234 | display: inline-block; 235 | width: 7px; 236 | height: 7px; 237 | margin: 0 2px; 238 | border-radius: 50%; 239 | background-color: rgba(26, 115, 232, 0.7); 240 | opacity: 0.9; 241 | } 242 | 243 | .message.assistant .message-loading .dot { 244 | background-color: rgba(52, 168, 83, 0.7); 245 | } 246 | 247 | .message-loading .dot:nth-child(1) { 248 | animation: dot-jump 1.4s 0s infinite ease-in-out; 249 | } 250 | 251 | .message-loading .dot:nth-child(2) { 252 | animation: dot-jump 1.4s 0.2s infinite ease-in-out; 253 | } 254 | 255 | .message-loading .dot:nth-child(3) { 256 | animation: dot-jump 1.4s 0.4s infinite ease-in-out; 257 | } 258 | 259 | @keyframes dot-jump { 260 | 0%, 80%, 100% { 261 | transform: translateY(0); 262 | opacity: 0.6; 263 | } 264 | 40% { 265 | transform: translateY(-6px); 266 | opacity: 1; 267 | } 268 | } 269 | 270 | .message-content ul, 271 | .message-content ol { 272 | margin-left: 16px; 273 | padding-left: 16px; 274 | } 275 | 276 | .message-content li { 277 | margin-bottom: 6px; 278 | } 279 | 280 | .message.user .message-content { 281 | background-color: #e8f0fe; 282 | margin-left: 20px; 283 | margin-right: 0; 284 | } 285 | 286 | .message.assistant .message-content { 287 | margin-left: 0; 288 | margin-right: 20px; 289 | } 290 | 291 | /* 代码块样式 */ 292 | .code-block { 293 | margin: 12px 0; 294 | border-radius: 6px; 295 | overflow: hidden; 296 | border: 1px solid #e0e0e0; 297 | } 298 | 299 | .code-header { 300 | display: flex; 301 | justify-content: space-between; 302 | align-items: center; 303 | padding: 8px 12px; 304 | background-color: #2d2d2d; 305 | color: #e6e6e6; 306 | } 307 | 308 | .code-language { 309 | font-family: monospace; 310 | font-size: 12px; 311 | } 312 | 313 | .copy-button { 314 | background-color: transparent; 315 | border: 1px solid #e6e6e6; 316 | color: #e6e6e6; 317 | padding: 2px 8px; 318 | border-radius: 4px; 319 | cursor: pointer; 320 | font-size: 12px; 321 | } 322 | 323 | .copy-button:hover { 324 | background-color: rgba(255, 255, 255, 0.1); 325 | } 326 | 327 | /* 输入区域 */ 328 | .chat-input-container { 329 | display: flex; 330 | flex-direction: column; 331 | padding: 10px; 332 | background-color: #fff; 333 | border-top: 1px solid #e0e0e0; 334 | } 335 | 336 | /* 添加输入框行容器 */ 337 | .input-row { 338 | display: flex; 339 | align-items: flex-end; 340 | } 341 | 342 | .chat-input { 343 | flex: 1; 344 | padding: 10px; 345 | border: 1px solid #ddd; 346 | border-radius: 5px; 347 | resize: none; 348 | min-height: 40px; 349 | max-height: 200px; 350 | overflow-y: auto; 351 | font-family: inherit; 352 | font-size: 14px; 353 | line-height: 1.5; 354 | transition: border-color 0.3s; 355 | } 356 | 357 | .chat-input:focus { 358 | border-color: #1890ff; 359 | outline: none; 360 | } 361 | 362 | .send-btn { 363 | margin-left: 8px; 364 | padding: 0 16px; 365 | background-color: #1a73e8; 366 | color: white; 367 | border: none; 368 | border-radius: 4px; 369 | cursor: pointer; 370 | font-weight: 500; 371 | align-self: flex-end; 372 | height: 40px; 373 | } 374 | 375 | .send-btn:hover { 376 | background-color: #0d66d0; 377 | } 378 | 379 | .send-btn:disabled { 380 | background-color: #ccc; 381 | cursor: not-allowed; 382 | } 383 | 384 | /* 历史面板 */ 385 | .history-panel { 386 | padding: 0; 387 | height: 100%; 388 | overflow-y: auto; 389 | display: flex; 390 | flex-direction: column; 391 | } 392 | 393 | .panel-header { 394 | position: sticky; 395 | top: 0; 396 | z-index: 100; 397 | background-color: #fff; 398 | padding: 16px; 399 | border-bottom: 1px solid #e0e0e0; 400 | display: flex; 401 | justify-content: space-between; 402 | align-items: center; 403 | } 404 | 405 | .panel-header-actions { 406 | display: flex; 407 | align-items: center; 408 | gap: 10px; 409 | } 410 | 411 | .save-message-header { 412 | padding: 6px 10px; 413 | border-radius: 4px; 414 | font-size: 14px; 415 | animation: fadeInOut 3s ease; 416 | } 417 | 418 | .save-message-header.success { 419 | background-color: #d1e7dd; 420 | color: #0f5132; 421 | } 422 | 423 | .save-message-header.error { 424 | background-color: #f8d7da; 425 | color: #721c24; 426 | } 427 | 428 | @keyframes fadeInOut { 429 | 0% { opacity: 0; } 430 | 10% { opacity: 1; } 431 | 90% { opacity: 1; } 432 | 100% { opacity: 0; } 433 | } 434 | 435 | .panel-header h2 { 436 | margin-bottom: 0; 437 | color: #333; 438 | } 439 | 440 | .panel-content { 441 | flex: 1; 442 | overflow-y: auto; 443 | padding: 16px; 444 | } 445 | 446 | .history-panel h2 { 447 | margin-bottom: 16px; 448 | color: #333; 449 | } 450 | 451 | .history-list { 452 | display: flex; 453 | flex-direction: column; 454 | gap: 12px; 455 | } 456 | 457 | .history-item { 458 | display: flex; 459 | justify-content: space-between; 460 | padding: 12px; 461 | border: 1px solid #e0e0e0; 462 | border-radius: 6px; 463 | background-color: white; 464 | cursor: pointer; 465 | transition: all 0.2s ease; 466 | } 467 | 468 | .history-item:hover { 469 | background-color: #f5f5f5; 470 | transform: translateY(-2px); 471 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 472 | } 473 | 474 | .history-item-info { 475 | flex: 1; 476 | } 477 | 478 | .history-item-title { 479 | font-weight: 500; 480 | margin-bottom: 4px; 481 | } 482 | 483 | .history-item-meta { 484 | font-size: 12px; 485 | color: #666; 486 | display: flex; 487 | gap: 12px; 488 | } 489 | 490 | .history-item-actions { 491 | display: flex; 492 | gap: 8px; 493 | align-items: center; 494 | } 495 | 496 | .rename-btn, .delete-btn { 497 | padding: 6px 12px; 498 | border-radius: 4px; 499 | border: none; 500 | cursor: pointer; 501 | font-size: 12px; 502 | } 503 | 504 | .rename-btn { 505 | background-color: #f5f5f5; 506 | color: #333; 507 | } 508 | 509 | .rename-btn:hover { 510 | background-color: #e5e5e5; 511 | } 512 | 513 | .delete-btn { 514 | background-color: #f8d7da; 515 | color: #721c24; 516 | } 517 | 518 | .delete-btn:hover { 519 | background-color: #f5c6cb; 520 | } 521 | 522 | .history-panel.empty { 523 | display: flex; 524 | flex-direction: column; 525 | justify-content: center; 526 | align-items: center; 527 | color: #666; 528 | gap: 16px; 529 | height: 100%; 530 | } 531 | 532 | .history-panel.empty .new-chat-btn { 533 | margin-top: 10px; 534 | } 535 | 536 | /* 设置面板 */ 537 | .settings-panel { 538 | padding: 0; 539 | height: 100%; 540 | overflow-y: auto; 541 | display: flex; 542 | flex-direction: column; 543 | } 544 | 545 | .settings-content { 546 | flex: 1; 547 | overflow-y: auto; 548 | padding: 16px; 549 | } 550 | 551 | .settings-panel h2 { 552 | margin-bottom: 0; 553 | color: #333; 554 | } 555 | 556 | .settings-section { 557 | margin-bottom: 24px; 558 | background-color: white; 559 | padding: 16px; 560 | border-radius: 8px; 561 | border: 1px solid #e0e0e0; 562 | } 563 | 564 | .settings-section h3 { 565 | margin-bottom: 16px; 566 | padding-bottom: 8px; 567 | border-bottom: 1px solid #eee; 568 | color: #555; 569 | } 570 | 571 | .form-group { 572 | margin-bottom: 16px; 573 | } 574 | 575 | .form-group label { 576 | display: block; 577 | margin-bottom: 8px; 578 | font-weight: 500; 579 | color: #555; 580 | } 581 | 582 | .form-group input { 583 | width: 100%; 584 | padding: 10px; 585 | border: 1px solid #e0e0e0; 586 | border-radius: 4px; 587 | font-size: 14px; 588 | } 589 | 590 | /* 占位符输入框样式 */ 591 | .placeholder-input { 592 | position: relative; 593 | transition: all 0.2s ease; 594 | } 595 | 596 | .placeholder-input.showing-placeholder { 597 | color: transparent; 598 | cursor: text; 599 | } 600 | 601 | .placeholder-input.showing-placeholder::placeholder { 602 | color: #999; 603 | opacity: 1; 604 | font-style: italic; 605 | user-select: none; 606 | font-weight: 400; 607 | } 608 | 609 | .placeholder-input:focus::placeholder { 610 | color: #bbbbbb; 611 | } 612 | 613 | /* 设置密码输入框占位符样式,避免显示密码点 */ 614 | input[type="password"].placeholder-input.showing-placeholder::placeholder { 615 | font-family: Arial, sans-serif; 616 | letter-spacing: 0; 617 | } 618 | 619 | /* API密钥输入框容器 */ 620 | .api-key-container { 621 | position: relative; 622 | display: flex; 623 | align-items: center; 624 | } 625 | 626 | .api-key-container input { 627 | flex: 1; 628 | padding-right: 40px; /* 为按钮留出空间 */ 629 | } 630 | 631 | .toggle-password-btn { 632 | position: absolute; 633 | right: 8px; 634 | background: none; 635 | border: none; 636 | cursor: pointer; 637 | color: #666; 638 | padding: 5px; 639 | display: flex; 640 | align-items: center; 641 | justify-content: center; 642 | border-radius: 50%; 643 | transition: all 0.2s ease; 644 | } 645 | 646 | .toggle-password-btn:hover { 647 | background-color: #f0f0f0; 648 | color: #1a73e8; 649 | } 650 | 651 | .form-group input[type="number"] { 652 | width: 100%; 653 | padding: 10px; 654 | border: 1px solid #e0e0e0; 655 | border-radius: 4px; 656 | font-size: 14px; 657 | } 658 | 659 | .form-hint { 660 | display: block; 661 | font-size: 12px; 662 | color: #666; 663 | margin-top: 4px; 664 | font-style: italic; 665 | } 666 | 667 | .models-list { 668 | margin-bottom: 20px; 669 | display: flex; 670 | flex-direction: column; 671 | gap: 8px; 672 | } 673 | 674 | .model-item { 675 | display: flex; 676 | justify-content: space-between; 677 | align-items: center; 678 | padding: 10px; 679 | border: 1px solid #e0e0e0; 680 | border-radius: 4px; 681 | background-color: #f9f9f9; 682 | } 683 | 684 | .model-info { 685 | flex: 1; 686 | } 687 | 688 | .model-name { 689 | font-weight: 500; 690 | } 691 | 692 | .model-id { 693 | font-size: 12px; 694 | color: #666; 695 | font-family: monospace; 696 | } 697 | 698 | .remove-model-btn { 699 | background-color: #f8d7da; 700 | color: #721c24; 701 | border: none; 702 | padding: 4px 8px; 703 | border-radius: 4px; 704 | cursor: pointer; 705 | font-size: 12px; 706 | } 707 | 708 | .add-model-section { 709 | background-color: #f9f9f9; 710 | padding: 16px; 711 | border-radius: 4px; 712 | margin-top: 16px; 713 | } 714 | 715 | .add-model-section h4 { 716 | margin-bottom: 12px; 717 | color: #555; 718 | } 719 | 720 | .add-model-btn { 721 | background-color: #d1e7dd; 722 | color: #0f5132; 723 | border: none; 724 | padding: 8px 16px; 725 | border-radius: 4px; 726 | cursor: pointer; 727 | font-weight: 500; 728 | } 729 | 730 | .settings-actions { 731 | margin-top: 24px; 732 | display: flex; 733 | justify-content: flex-end; 734 | } 735 | 736 | .save-settings-btn { 737 | background-color: #1a73e8; 738 | color: white; 739 | border: none; 740 | padding: 10px 20px; 741 | border-radius: 4px; 742 | cursor: pointer; 743 | font-weight: 500; 744 | white-space: nowrap; 745 | } 746 | 747 | .save-settings-btn:hover { 748 | background-color: #0d66d0; 749 | } 750 | 751 | .save-settings-btn:disabled { 752 | background-color: #ccc; 753 | cursor: not-allowed; 754 | } 755 | 756 | .save-message { 757 | margin-top: 16px; 758 | padding: 10px; 759 | border-radius: 4px; 760 | text-align: center; 761 | } 762 | 763 | .save-message.success { 764 | background-color: #d1e7dd; 765 | color: #0f5132; 766 | } 767 | 768 | .save-message.error { 769 | background-color: #f8d7da; 770 | color: #721c24; 771 | } 772 | 773 | /* 消息编辑样式 */ 774 | .message-edit-container { 775 | flex: 1; 776 | display: flex; 777 | flex-direction: column; 778 | padding: 16px; 779 | background-color: #fff; 780 | overflow: auto; 781 | } 782 | 783 | .message-edit-textarea { 784 | flex: 1; 785 | min-height: 150px; 786 | padding: 12px; 787 | border: 1px solid #1a73e8; 788 | border-radius: 4px; 789 | font-family: inherit; 790 | font-size: 14px; 791 | line-height: 1.5; 792 | resize: none; 793 | margin-bottom: 12px; 794 | } 795 | 796 | .message-edit-actions { 797 | display: flex; 798 | justify-content: flex-end; 799 | gap: 8px; 800 | } 801 | 802 | .cancel-edit-btn, .save-edit-btn { 803 | padding: 8px 16px; 804 | border-radius: 4px; 805 | cursor: pointer; 806 | font-weight: 500; 807 | font-size: 14px; 808 | border: none; 809 | } 810 | 811 | .cancel-edit-btn { 812 | background-color: #f5f5f5; 813 | color: #333; 814 | } 815 | 816 | .cancel-edit-btn:hover { 817 | background-color: #e5e5e5; 818 | } 819 | 820 | .save-edit-btn { 821 | background-color: #1a73e8; 822 | color: white; 823 | } 824 | 825 | .save-edit-btn:hover { 826 | background-color: #0d66d0; 827 | } 828 | 829 | /* 停止按钮样式 */ 830 | .stop-btn { 831 | margin-left: 8px; 832 | padding: 0 16px; 833 | background-color: #f44336; 834 | color: white; 835 | border: none; 836 | border-radius: 4px; 837 | cursor: pointer; 838 | font-weight: 500; 839 | align-self: flex-end; 840 | height: 40px; 841 | } 842 | 843 | .stop-btn:hover { 844 | background-color: #d32f2f; 845 | } 846 | 847 | /* 删除确认样式 */ 848 | .delete-confirm-actions { 849 | display: flex; 850 | align-items: center; 851 | gap: 5px; 852 | } 853 | 854 | .delete-confirm-text { 855 | font-size: 12px; 856 | color: #ff4d4f; 857 | } 858 | 859 | .confirm-btn { 860 | padding: 1px 5px; 861 | font-size: 12px; 862 | border: none; 863 | border-radius: 3px; 864 | cursor: pointer; 865 | } 866 | 867 | .confirm-btn.yes { 868 | background-color: #ff4d4f; 869 | color: white; 870 | } 871 | 872 | .confirm-btn.yes:hover { 873 | background-color: #ff7875; 874 | } 875 | 876 | .confirm-btn.no { 877 | background-color: #f0f0f0; 878 | color: #666; 879 | } 880 | 881 | .confirm-btn.no:hover { 882 | background-color: #d9d9d9; 883 | } 884 | 885 | /* 复制成功状态样式 */ 886 | .message-action-btn.copied { 887 | color: #52c41a; 888 | } 889 | 890 | /* 引用内容样式 */ 891 | .quoted-content-container { 892 | margin-bottom: 10px; 893 | background-color: #f5f7fa; 894 | border-radius: 8px; 895 | border: 1px solid #e0e6ed; 896 | overflow: hidden; 897 | } 898 | 899 | .quoted-content-header { 900 | display: flex; 901 | justify-content: space-between; 902 | align-items: center; 903 | padding: 8px 12px; 904 | background-color: #eaeff5; 905 | border-bottom: 1px solid #e0e6ed; 906 | } 907 | 908 | .quoted-content-header span { 909 | font-size: 14px; 910 | font-weight: 500; 911 | color: #4a5568; 912 | } 913 | 914 | .clear-all-quotes-btn { 915 | background: none; 916 | border: none; 917 | font-size: 13px; 918 | color: #e53e3e; 919 | cursor: pointer; 920 | padding: 2px 6px; 921 | border-radius: 4px; 922 | } 923 | 924 | .clear-all-quotes-btn:hover { 925 | background-color: rgba(229, 62, 62, 0.1); 926 | } 927 | 928 | .quoted-content-list { 929 | max-height: 200px; 930 | overflow-y: auto; 931 | padding: 8px; 932 | } 933 | 934 | .quoted-item { 935 | display: flex; 936 | align-items: flex-start; 937 | margin-bottom: 8px; 938 | padding: 8px; 939 | background-color: white; 940 | border-radius: 6px; 941 | border: 1px solid #e0e6ed; 942 | position: relative; 943 | } 944 | 945 | .quoted-text { 946 | flex: 1; 947 | font-size: 14px; 948 | line-height: 1.5; 949 | color: #4a5568; 950 | margin-right: 24px; 951 | max-height: 100px; 952 | overflow-y: auto; 953 | white-space: pre-wrap; 954 | word-break: break-word; 955 | } 956 | 957 | .delete-quote-btn { 958 | position: absolute; 959 | right: 8px; 960 | top: 8px; 961 | background: none; 962 | border: none; 963 | width: 20px; 964 | height: 20px; 965 | display: flex; 966 | align-items: center; 967 | justify-content: center; 968 | color: #a0aec0; 969 | font-size: 16px; 970 | cursor: pointer; 971 | border-radius: 50%; 972 | } 973 | 974 | .delete-quote-btn:hover { 975 | background-color: #f1f5f9; 976 | color: #e53e3e; 977 | } 978 | 979 | /* Markdown 引用样式 */ 980 | .markdown-quote { 981 | border-left: 3px solid #1a73e8; 982 | padding-left: 12px; 983 | margin: 8px 0; 984 | color: #4a5568; 985 | background-color: #f8f9fa; 986 | padding: 10px 10px 10px 12px; 987 | border-radius: 0 4px 4px 0; 988 | } 989 | 990 | /* 确保原始引用内容在消息中可见 */ 991 | .message-text pre { 992 | white-space: pre-wrap; 993 | word-break: break-word; 994 | } 995 | 996 | .message-text blockquote p { 997 | margin: 0; 998 | } 999 | 1000 | /* 确保引用内容前的 > 符号显示 */ 1001 | .message-text blockquote::before { 1002 | content: ''; 1003 | display: block; 1004 | } 1005 | 1006 | /* 划线工具栏面板样式 */ 1007 | .tools-panel { 1008 | padding: 0; 1009 | height: 100%; 1010 | overflow-y: auto; 1011 | display: flex; 1012 | flex-direction: column; 1013 | } 1014 | 1015 | .tools-content { 1016 | flex: 1; 1017 | overflow-y: auto; 1018 | padding: 16px; 1019 | } 1020 | 1021 | .tools-panel h2 { 1022 | margin-bottom: 0; 1023 | color: #333; 1024 | } 1025 | 1026 | .tools-panel h3 { 1027 | margin: 16px 0; 1028 | color: #444; 1029 | } 1030 | 1031 | .tools-description { 1032 | background-color: #f5f7fa; 1033 | padding: 12px 16px; 1034 | border-radius: 6px; 1035 | margin-bottom: 20px; 1036 | border-left: 3px solid #1a73e8; 1037 | } 1038 | 1039 | .tools-description p { 1040 | margin: 8px 0; 1041 | color: #555; 1042 | } 1043 | 1044 | .tools-list { 1045 | margin-bottom: 24px; 1046 | } 1047 | 1048 | .empty-tools-message { 1049 | padding: 16px; 1050 | background-color: #f9f9f9; 1051 | border-radius: 6px; 1052 | color: #666; 1053 | text-align: center; 1054 | border: 1px dashed #ccc; 1055 | } 1056 | 1057 | .tool-item { 1058 | background-color: #fff; 1059 | border: 1px solid #e0e6ed; 1060 | border-radius: 8px; 1061 | margin-bottom: 12px; 1062 | overflow: hidden; 1063 | } 1064 | 1065 | .tool-details { 1066 | padding: 16px; 1067 | } 1068 | 1069 | .tool-header { 1070 | display: flex; 1071 | justify-content: space-between; 1072 | align-items: center; 1073 | margin-bottom: 12px; 1074 | } 1075 | 1076 | .tool-name { 1077 | font-size: 16px; 1078 | font-weight: 500; 1079 | color: #333; 1080 | } 1081 | 1082 | .tool-actions { 1083 | display: flex; 1084 | gap: 8px; 1085 | } 1086 | 1087 | .edit-tool-btn, .delete-tool-btn { 1088 | padding: 4px 10px; 1089 | border-radius: 4px; 1090 | font-size: 12px; 1091 | border: none; 1092 | cursor: pointer; 1093 | } 1094 | 1095 | .edit-tool-btn { 1096 | background-color: #eaeff5; 1097 | color: #1a73e8; 1098 | } 1099 | 1100 | .edit-tool-btn:hover { 1101 | background-color: #d7e3f7; 1102 | } 1103 | 1104 | .delete-tool-btn { 1105 | background-color: #fff1f0; 1106 | color: #d32f2f; 1107 | } 1108 | 1109 | .delete-tool-btn:hover { 1110 | background-color: #ffcdd2; 1111 | } 1112 | 1113 | .tool-prompt { 1114 | display: flex; 1115 | background-color: #f9f9f9; 1116 | padding: 10px; 1117 | border-radius: 6px; 1118 | } 1119 | 1120 | .tool-prompt-label { 1121 | color: #666; 1122 | margin-right: 8px; 1123 | white-space: nowrap; 1124 | } 1125 | 1126 | .tool-prompt-text { 1127 | color: #333; 1128 | white-space: pre-wrap; 1129 | word-break: break-word; 1130 | } 1131 | 1132 | .tool-editing { 1133 | padding: 16px; 1134 | } 1135 | 1136 | .tool-edit-actions { 1137 | display: flex; 1138 | justify-content: flex-end; 1139 | gap: 8px; 1140 | margin-top: 16px; 1141 | } 1142 | 1143 | .add-tool-form { 1144 | background-color: #fff; 1145 | border: 1px solid #e0e6ed; 1146 | border-radius: 8px; 1147 | padding: 16px; 1148 | } 1149 | 1150 | .form-actions { 1151 | margin-top: 16px; 1152 | display: flex; 1153 | justify-content: flex-end; 1154 | } 1155 | 1156 | .add-tool-btn { 1157 | padding: 8px 16px; 1158 | background-color: #1a73e8; 1159 | color: white; 1160 | border: none; 1161 | border-radius: 4px; 1162 | cursor: pointer; 1163 | font-weight: 500; 1164 | } 1165 | 1166 | .add-tool-btn:hover { 1167 | background-color: #0d66d0; 1168 | } 1169 | 1170 | /* 多功能按钮工具栏样式 */ 1171 | .webchat-tools-container { 1172 | position: absolute; 1173 | display: flex; 1174 | align-items: center; 1175 | background-color: #fff; 1176 | border-radius: 4px; 1177 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 1178 | z-index: 10000; 1179 | padding: 4px; 1180 | gap: 4px; 1181 | } 1182 | 1183 | .webchat-tool-button { 1184 | padding: 4px 8px; 1185 | background: #1a73e8; 1186 | color: white; 1187 | border: none; 1188 | border-radius: 3px; 1189 | font-size: 12px; 1190 | cursor: pointer; 1191 | display: flex; 1192 | align-items: center; 1193 | gap: 4px; 1194 | font-family: system-ui, -apple-system, sans-serif; 1195 | } 1196 | 1197 | .webchat-tool-button:hover { 1198 | background: #0d66d0; 1199 | } 1200 | 1201 | .tool-editing textarea, 1202 | .add-tool-form textarea { 1203 | width: 100%; 1204 | padding: 10px; 1205 | border: 1px solid #e0e0e0; 1206 | border-radius: 4px; 1207 | font-size: 14px; 1208 | min-height: 80px; 1209 | resize: vertical; 1210 | font-family: inherit; 1211 | line-height: 1.5; 1212 | } 1213 | 1214 | .form-group { 1215 | margin-bottom: 16px; 1216 | } 1217 | 1218 | .form-group label { 1219 | display: block; 1220 | margin-bottom: 8px; 1221 | font-weight: 500; 1222 | color: #555; 1223 | } 1224 | 1225 | .form-group input { 1226 | width: 100%; 1227 | padding: 10px; 1228 | border: 1px solid #e0e0e0; 1229 | border-radius: 4px; 1230 | font-size: 14px; 1231 | } 1232 | 1233 | .form-hint { 1234 | display: block; 1235 | font-size: 12px; 1236 | color: #666; 1237 | margin-top: 4px; 1238 | font-style: italic; 1239 | } 1240 | 1241 | /* API错误消息 */ 1242 | .api-error-message { 1243 | margin-bottom: 10px; 1244 | padding: 10px; 1245 | background-color: #fff3cd; 1246 | border: 1px solid #ffecb5; 1247 | border-radius: 4px; 1248 | color: #856404; 1249 | display: flex; 1250 | justify-content: space-between; 1251 | align-items: center; 1252 | } 1253 | 1254 | .api-error-message a { 1255 | color: #1a73e8; 1256 | text-decoration: none; 1257 | font-weight: 500; 1258 | margin-left: 8px; 1259 | } 1260 | 1261 | .api-error-message a:hover { 1262 | text-decoration: underline; 1263 | } 1264 | 1265 | /* API 配置部分 */ 1266 | .current-api-section { 1267 | margin-bottom: 24px; 1268 | padding-bottom: 16px; 1269 | border-bottom: 1px solid #eee; 1270 | } 1271 | 1272 | .api-configs-section { 1273 | margin-bottom: 24px; 1274 | } 1275 | 1276 | .api-configs-section h4, 1277 | .add-api-config-section h4, 1278 | .edit-api-config-section h4 { 1279 | margin-bottom: 16px; 1280 | color: #555; 1281 | font-size: 16px; 1282 | } 1283 | 1284 | .no-configs-message { 1285 | padding: 12px; 1286 | background-color: #f9f9f9; 1287 | border: 1px dashed #ddd; 1288 | border-radius: 4px; 1289 | color: #666; 1290 | text-align: center; 1291 | font-style: italic; 1292 | } 1293 | 1294 | .api-configs-list { 1295 | display: flex; 1296 | flex-direction: column; 1297 | gap: 8px; 1298 | margin-bottom: 20px; 1299 | } 1300 | 1301 | .api-config-item { 1302 | display: flex; 1303 | justify-content: space-between; 1304 | align-items: center; 1305 | padding: 12px; 1306 | border: 1px solid #e0e0e0; 1307 | border-radius: 4px; 1308 | background-color: #f9f9f9; 1309 | transition: all 0.2s ease; 1310 | } 1311 | 1312 | .api-config-item.selected { 1313 | border-color: #1a73e8; 1314 | background-color: #e8f0fe; 1315 | } 1316 | 1317 | .api-config-info { 1318 | flex: 1; 1319 | cursor: pointer; 1320 | } 1321 | 1322 | .api-config-name { 1323 | font-weight: 500; 1324 | margin-bottom: 4px; 1325 | } 1326 | 1327 | .api-config-url { 1328 | font-size: 12px; 1329 | color: #666; 1330 | font-family: monospace; 1331 | word-break: break-all; 1332 | } 1333 | 1334 | .api-config-actions { 1335 | display: flex; 1336 | gap: 8px; 1337 | } 1338 | 1339 | .edit-api-config-btn { 1340 | background-color: #e8f0fe; 1341 | color: #1a73e8; 1342 | border: none; 1343 | padding: 5px 10px; 1344 | border-radius: 4px; 1345 | font-size: 12px; 1346 | cursor: pointer; 1347 | } 1348 | 1349 | .edit-api-config-btn:hover { 1350 | background-color: #d7e3f7; 1351 | } 1352 | 1353 | .remove-api-config-btn { 1354 | background-color: #f8d7da; 1355 | color: #721c24; 1356 | border: none; 1357 | padding: 5px 10px; 1358 | border-radius: 4px; 1359 | font-size: 12px; 1360 | cursor: pointer; 1361 | } 1362 | 1363 | .remove-api-config-btn:hover { 1364 | background-color: #f5c6cb; 1365 | } 1366 | 1367 | .add-api-config-section, 1368 | .edit-api-config-section { 1369 | padding: 16px; 1370 | background-color: #f9f9f9; 1371 | border: 1px solid #e0e0e0; 1372 | border-radius: 4px; 1373 | margin-bottom: 16px; 1374 | } 1375 | 1376 | .edit-api-config-section { 1377 | border-color: #1a73e8; 1378 | background-color: #f5f9ff; 1379 | } 1380 | 1381 | .add-api-config-btn, 1382 | .save-api-config-btn { 1383 | background-color: #1a73e8; 1384 | color: white; 1385 | border: none; 1386 | padding: 10px 16px; 1387 | border-radius: 4px; 1388 | font-weight: 500; 1389 | cursor: pointer; 1390 | transition: background-color 0.2s ease; 1391 | margin-top: 8px; 1392 | } 1393 | 1394 | .add-api-config-btn:hover, 1395 | .save-api-config-btn:hover { 1396 | background-color: #0d66d0; 1397 | } 1398 | 1399 | .edit-api-config-actions { 1400 | display: flex; 1401 | justify-content: flex-end; 1402 | gap: 8px; 1403 | margin-top: 16px; 1404 | } 1405 | 1406 | .cancel-edit-btn { 1407 | background-color: #f5f5f5; 1408 | color: #666; 1409 | border: none; 1410 | padding: 10px 16px; 1411 | border-radius: 4px; 1412 | font-weight: 500; 1413 | cursor: pointer; 1414 | transition: background-color 0.2s ease; 1415 | } 1416 | 1417 | .cancel-edit-btn:hover { 1418 | background-color: #e0e0e0; 1419 | } 1420 | 1421 | /* 工具栏开关样式 */ 1422 | .toolbar-toggle-container { 1423 | margin: 15px 0; 1424 | padding: 15px; 1425 | background-color: #f5f5f5; 1426 | border-radius: 8px; 1427 | border: 1px solid #e0e0e0; 1428 | } 1429 | 1430 | .toolbar-toggle-label { 1431 | display: flex; 1432 | align-items: center; 1433 | font-weight: 500; 1434 | cursor: pointer; 1435 | } 1436 | 1437 | .toolbar-toggle-label span { 1438 | margin-right: 10px; 1439 | } 1440 | 1441 | .toggle-switch { 1442 | position: relative; 1443 | display: inline-block; 1444 | width: 46px; 1445 | height: 24px; 1446 | } 1447 | 1448 | .toggle-switch input { 1449 | opacity: 0; 1450 | width: 0; 1451 | height: 0; 1452 | } 1453 | 1454 | .toggle-slider { 1455 | position: absolute; 1456 | cursor: pointer; 1457 | top: 0; 1458 | left: 0; 1459 | right: 0; 1460 | bottom: 0; 1461 | background-color: #ccc; 1462 | transition: .4s; 1463 | border-radius: 24px; 1464 | } 1465 | 1466 | .toggle-slider:before { 1467 | position: absolute; 1468 | content: ""; 1469 | height: 18px; 1470 | width: 18px; 1471 | left: 3px; 1472 | bottom: 3px; 1473 | background-color: white; 1474 | transition: .4s; 1475 | border-radius: 50%; 1476 | } 1477 | 1478 | input:checked + .toggle-slider { 1479 | background-color: #1a73e8; 1480 | } 1481 | 1482 | input:focus + .toggle-slider { 1483 | box-shadow: 0 0 1px #1a73e8; 1484 | } 1485 | 1486 | input:checked + .toggle-slider:before { 1487 | transform: translateX(22px); 1488 | } 1489 | 1490 | .toggle-status { 1491 | margin-left: 10px; 1492 | font-weight: normal; 1493 | font-size: 13px; 1494 | color: #666; 1495 | } 1496 | 1497 | .toggle-description { 1498 | margin-top: 10px; 1499 | font-size: 13px; 1500 | color: #666; 1501 | padding-left: 5px; 1502 | } -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import Tabs from './Tabs'; 3 | import ChatPanel from './ChatPanel'; 4 | import HistoryPanel from './HistoryPanel'; 5 | import SettingsPanel from './SettingsPanel'; 6 | import ToolsPanel from './ToolsPanel'; 7 | 8 | const App = () => { 9 | const [activeTab, setActiveTab] = useState('chat'); 10 | 11 | // 监听历史对话加载事件,自动切换到"对话"标签 12 | useEffect(() => { 13 | const handleLoadChat = () => { 14 | setActiveTab('chat'); 15 | }; 16 | 17 | // 监听标签切换事件 18 | const handleSwitchTab = (event) => { 19 | if (event.detail && event.detail.tab) { 20 | setActiveTab(event.detail.tab); 21 | } 22 | }; 23 | 24 | window.addEventListener('loadChat', handleLoadChat); 25 | window.addEventListener('switchTab', handleSwitchTab); 26 | 27 | return () => { 28 | window.removeEventListener('loadChat', handleLoadChat); 29 | window.removeEventListener('switchTab', handleSwitchTab); 30 | }; 31 | }, []); 32 | 33 | return ( 34 |
35 | 36 | 37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 | 46 |
47 | 48 |
49 | 50 |
51 | 52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default App; -------------------------------------------------------------------------------- /src/components/ChatPanel.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { getStorage, saveChat } from '../utils/storage'; 3 | import { sendMessage } from '../api/chatApi'; 4 | import MessageList from './MessageList'; 5 | import ModelSelector from './ModelSelector'; 6 | 7 | const CURRENT_CHAT_KEY = 'webchat_current_chat_id'; 8 | 9 | const ChatPanel = () => { 10 | const [messages, setMessages] = useState([]); 11 | const [input, setInput] = useState(''); 12 | const [isLoading, setIsLoading] = useState(false); 13 | const [selectedModel, setSelectedModel] = useState('gpt-3.5-turbo'); 14 | const [currentChatId, setCurrentChatId] = useState(null); 15 | const [streamingMessage, setStreamingMessage] = useState(null); 16 | const [editingMessageIndex, setEditingMessageIndex] = useState(null); 17 | const [editingMessageContent, setEditingMessageContent] = useState(''); 18 | const [isGenerating, setIsGenerating] = useState(false); 19 | const [quotes, setQuotes] = useState([]); // 存储引用内容 20 | const [currentChatTitle, setCurrentChatTitle] = useState(''); 21 | const [apiError, setApiError] = useState(''); 22 | const abortControllerRef = useRef(null); 23 | const lastChatIdRef = useRef(null); // 用于跟踪上一次的聊天ID 24 | const messageListRef = useRef(null); // 添加对MessageList组件的引用 25 | const lastEditedIndexRef = useRef(null); // 存储最后编辑的消息索引 26 | // 添加新的引用,用于实时获取当前选中的模型 27 | const currentModelRef = useRef(selectedModel); 28 | const restoreScrollPositionRef = useRef(null); // 添加新的引用,用于记录滚动位置 29 | 30 | // 跟踪selectedModel的变化,更新引用值 31 | useEffect(() => { 32 | currentModelRef.current = selectedModel; 33 | }, [selectedModel]); 34 | 35 | // 自定义setCurrentChatId函数,在设置时同时保存到localStorage 36 | const setChatId = (chatId) => { 37 | if (chatId) { 38 | // 保存到localStorage以便在组件重新加载时恢复 39 | localStorage.setItem(CURRENT_CHAT_KEY, chatId); 40 | lastChatIdRef.current = chatId; // 更新引用值 41 | } 42 | setCurrentChatId(chatId); 43 | }; 44 | 45 | // 尝试从localStorage恢复currentChatId 46 | useEffect(() => { 47 | const loadCurrentChat = async () => { 48 | const savedChatId = localStorage.getItem(CURRENT_CHAT_KEY); 49 | if (savedChatId) { 50 | console.log('从localStorage恢复对话ID:', savedChatId); 51 | setChatId(savedChatId); 52 | lastChatIdRef.current = savedChatId; 53 | 54 | // 立即加载对话内容,防止历史丢失 55 | try { 56 | const chats = await getStorage('chats') || {}; 57 | if (chats[savedChatId]) { 58 | console.log('立即加载找到的对话:', chats[savedChatId].title); 59 | setMessages(chats[savedChatId].messages || []); 60 | setCurrentChatTitle(chats[savedChatId].title || '新对话'); 61 | setSelectedModel(chats[savedChatId].model || 'gpt-3.5-turbo'); 62 | 63 | // 确保所有状态被重置 - 特别是在侧边栏重新打开的情况下 64 | setIsLoading(false); 65 | setIsGenerating(false); 66 | setStreamingMessage(null); 67 | if (abortControllerRef.current) { 68 | abortControllerRef.current = null; 69 | } 70 | } else { 71 | console.log('存储中未找到对话:', savedChatId); 72 | } 73 | } catch (error) { 74 | console.error('加载对话数据失败:', error); 75 | } 76 | } 77 | 78 | // 通知background.js侧边栏已准备好 79 | if (typeof chrome !== 'undefined' && chrome.runtime) { 80 | chrome.runtime.sendMessage({ 81 | type: 'sidePanelReady' 82 | }); 83 | console.log('已通知背景脚本侧边栏已准备好接收消息'); 84 | } 85 | }; 86 | 87 | loadCurrentChat(); 88 | 89 | // 侧边栏关闭时通知 90 | return () => { 91 | if (typeof chrome !== 'undefined' && chrome.runtime) { 92 | chrome.runtime.sendMessage({ 93 | type: 'sidePanelClosed' 94 | }); 95 | console.log('已通知背景脚本侧边栏已关闭'); 96 | } 97 | }; 98 | }, []); 99 | 100 | // 监听引用内容更新 101 | useEffect(() => { 102 | const handleQuotesUpdate = (event) => { 103 | if (event.type === 'updateQuotes') { 104 | setQuotes(event.quotes || []); 105 | } 106 | }; 107 | 108 | // 监听工具执行操作 109 | const handleToolAction = async (message) => { 110 | try { 111 | if (message.type === 'executeToolAction' && message.data) { 112 | // 获取操作ID 113 | const operationId = message.data.actionId || 'unknown'; 114 | console.log('ChatPanel收到工具操作请求', operationId); 115 | 116 | // 如果当前已经在加载或者生成中,不处理新的工具操作 117 | if (isLoading || isGenerating) { 118 | console.log('正在处理其他请求,忽略当前工具操作'); 119 | return; 120 | } 121 | 122 | // 使用操作ID进行去重 123 | const processedActions = JSON.parse(sessionStorage.getItem('processedActions') || '[]'); 124 | if (operationId !== 'unknown' && processedActions.includes(operationId)) { 125 | console.log('该操作ID已处理过,跳过:', operationId); 126 | return; 127 | } 128 | 129 | // 将当前操作ID添加到已处理列表 130 | if (operationId !== 'unknown') { 131 | processedActions.push(operationId); 132 | // 只保留最近的20个ID 133 | if (processedActions.length > 20) { 134 | processedActions.shift(); 135 | } 136 | sessionStorage.setItem('processedActions', JSON.stringify(processedActions)); 137 | } 138 | 139 | // 确保有效的对话ID并尝试加载完整的对话历史 140 | if (!currentChatId) { 141 | const savedChatId = localStorage.getItem(CURRENT_CHAT_KEY) || lastChatIdRef.current; 142 | if (savedChatId) { 143 | console.log('工具操作前恢复对话ID:', savedChatId); 144 | setChatId(savedChatId); 145 | 146 | // 尝试加载对话历史 147 | const chats = await getStorage('chats') || {}; 148 | if (chats[savedChatId]) { 149 | console.log('从存储恢复对话历史:', chats[savedChatId].messages?.length || 0, '条消息'); 150 | setMessages(chats[savedChatId].messages || []); 151 | setCurrentChatTitle(chats[savedChatId].title || '新对话'); 152 | setSelectedModel(chats[savedChatId].model || 'gpt-3.5-turbo'); 153 | } 154 | } else { 155 | console.log('没有找到有效的对话ID,可能需要创建新对话'); 156 | // 不立即创建,让handleSend函数处理 157 | } 158 | } else { 159 | // 即使有currentChatId,也确保消息历史是最新的 160 | if (messages.length === 0) { 161 | console.log('当前有对话ID但消息为空,尝试恢复历史消息'); 162 | const chats = await getStorage('chats') || {}; 163 | if (chats[currentChatId]) { 164 | console.log('从存储恢复对话历史:', chats[currentChatId].messages?.length || 0, '条消息'); 165 | setMessages(chats[currentChatId].messages || []); 166 | } 167 | } 168 | } 169 | 170 | const { text, prompt } = message.data; 171 | 172 | // 构建完整消息:引用内容 + 提示词 173 | let fullContent = `> ${text}\n\n${prompt}`; 174 | console.log('准备执行工具操作:', fullContent.substring(0, 50) + (fullContent.length > 50 ? '...' : '')); 175 | 176 | // 重置任何可能的错误状态 177 | setStreamingMessage(null); 178 | 179 | // 直接发送消息,不再使用延迟或轮询 180 | console.log('立即执行发送操作'); 181 | handleSend(fullContent); 182 | 183 | // 添加安全检查,确保动画最终会消失 184 | setTimeout(() => { 185 | // 如果30秒后仍然在加载状态,强制重置 186 | if (isLoading || isGenerating) { 187 | console.log('检测到长时间加载,强制重置状态'); 188 | setIsLoading(false); 189 | setIsGenerating(false); 190 | setStreamingMessage(null); 191 | if (abortControllerRef.current) { 192 | abortControllerRef.current.abort(); 193 | abortControllerRef.current = null; 194 | } 195 | } 196 | }, 30000); 197 | } 198 | } catch (error) { 199 | console.error('执行工具操作失败:', error); 200 | // 确保错误情况下也重置状态 201 | setIsLoading(false); 202 | setIsGenerating(false); 203 | setStreamingMessage(null); 204 | } finally { 205 | console.log('消息处理完成,状态已重置'); 206 | } 207 | }; 208 | 209 | // 监听来自扩展后台的消息 210 | const messageListener = (message, sender, sendResponse) => { 211 | if (message.type === 'updateQuotes') { 212 | setQuotes(message.quotes || []); 213 | } else if (message.type === 'executeToolAction') { 214 | handleToolAction(message); 215 | // 发送接收确认 216 | if (sendResponse) { 217 | sendResponse({ received: true }); 218 | } 219 | } 220 | 221 | return true; // 表示将异步回复 222 | }; 223 | 224 | // 添加消息监听器 225 | if (typeof chrome !== 'undefined' && chrome.runtime) { 226 | chrome.runtime.onMessage.addListener(messageListener); 227 | 228 | // 首次加载时主动获取已存在的引用内容 229 | chrome.runtime.sendMessage({ 230 | type: 'getQuotes' 231 | }); 232 | } 233 | 234 | // 添加窗口消息事件监听器(用于开发环境或直接通信) 235 | const windowMessageHandler = (event) => { 236 | const data = event.data; 237 | if (data && data.type === 'addQuote') { 238 | setQuotes(prev => [...prev, data.quote]); 239 | } 240 | if (data && data.type === 'updateQuotes') { 241 | setQuotes(data.quotes || []); 242 | } 243 | if (data && data.type === 'executeToolAction') { 244 | handleToolAction(data); 245 | } 246 | }; 247 | 248 | window.addEventListener('message', windowMessageHandler); 249 | 250 | return () => { 251 | // 移除监听器 252 | if (typeof chrome !== 'undefined' && chrome.runtime) { 253 | chrome.runtime.onMessage.removeListener(messageListener); 254 | } 255 | window.removeEventListener('message', windowMessageHandler); 256 | 257 | // 组件卸载时清理状态,防止侧边栏关闭再打开时状态不一致 258 | if (isLoading || isGenerating) { 259 | console.log('组件卸载时重置状态'); 260 | // 如果有进行中的请求,取消它 261 | if (abortControllerRef.current) { 262 | abortControllerRef.current.abort(); 263 | abortControllerRef.current = null; 264 | } 265 | } 266 | }; 267 | }, [isLoading, isGenerating, currentChatId]); 268 | 269 | // 删除单个引用 270 | const handleDeleteQuote = (quoteId) => { 271 | // 更新本地状态 272 | setQuotes(prev => prev.filter(quote => quote.id !== quoteId)); 273 | 274 | // 向扩展后台发送删除请求 275 | if (typeof chrome !== 'undefined' && chrome.runtime) { 276 | chrome.runtime.sendMessage({ 277 | type: 'deleteQuote', 278 | quoteId 279 | }); 280 | } 281 | }; 282 | 283 | // 清空所有引用 284 | const handleClearAllQuotes = () => { 285 | // 更新本地状态 286 | setQuotes([]); 287 | 288 | // 向扩展后台发送清空请求 289 | if (typeof chrome !== 'undefined' && chrome.runtime) { 290 | chrome.runtime.sendMessage({ 291 | type: 'clearAllQuotes' 292 | }); 293 | } 294 | }; 295 | 296 | // 初始化对话或加载现有对话 297 | useEffect(() => { 298 | // 监听加载历史对话事件 299 | const handleLoadChat = async (event) => { 300 | const { chatId } = event.detail; 301 | console.log('ChatPanel接收到加载对话事件,chatId:', chatId); 302 | const chats = await getStorage('chats') || {}; 303 | 304 | if (chats[chatId]) { 305 | console.log('找到对话数据,加载对话:', chats[chatId]); 306 | setChatId(chatId); 307 | setSelectedModel(chats[chatId].model || 'gpt-3.5-turbo'); 308 | setMessages(chats[chatId].messages || []); 309 | setCurrentChatTitle(chats[chatId].title || '新对话'); 310 | setStreamingMessage(null); // 确保清除任何流式消息 311 | } else { 312 | console.error('未找到对话数据:', chatId); 313 | } 314 | }; 315 | 316 | // 创建新对话或恢复现有对话 317 | const initializeChat = async () => { 318 | // 检查是否有已保存的对话ID 319 | const savedChatId = localStorage.getItem(CURRENT_CHAT_KEY); 320 | if (savedChatId) { 321 | console.log('尝试从localStorage加载对话:', savedChatId); 322 | const chats = await getStorage('chats') || {}; 323 | if (chats[savedChatId]) { 324 | console.log('成功加载保存的对话:', chats[savedChatId].title); 325 | setChatId(savedChatId); 326 | setSelectedModel(chats[savedChatId].model || 'gpt-3.5-turbo'); 327 | setMessages(chats[savedChatId].messages || []); 328 | setCurrentChatTitle(chats[savedChatId].title || '新对话'); 329 | return; // 已加载保存的对话,无需创建新对话 330 | } else { 331 | console.warn('保存的对话ID存在,但对话数据未找到:', savedChatId); 332 | } 333 | } 334 | 335 | // 如果没有有效的保存对话,则创建新对话 336 | const newChatId = `chat_${Date.now()}`; 337 | console.log('创建新对话:', newChatId); 338 | setChatId(newChatId); 339 | 340 | // 初始化新对话 341 | const newChat = { 342 | id: newChatId, 343 | title: '新对话', 344 | model: selectedModel, 345 | createdAt: new Date().toISOString(), 346 | messages: [] 347 | }; 348 | 349 | // 保存新对话 350 | const chats = await getStorage('chats') || {}; 351 | chats[newChatId] = newChat; 352 | await saveChat(chats); 353 | }; 354 | 355 | // 注册事件监听器 356 | window.addEventListener('loadChat', handleLoadChat); 357 | 358 | // 监听来自历史面板的新建对话事件 359 | const handleNewChatEvent = () => { 360 | handleNewChat(); 361 | }; 362 | 363 | window.addEventListener('newChat', handleNewChatEvent); 364 | 365 | // 初始化对话 366 | initializeChat(); 367 | 368 | return () => { 369 | window.removeEventListener('loadChat', handleLoadChat); 370 | window.removeEventListener('newChat', handleNewChatEvent); 371 | }; 372 | }, []); 373 | 374 | // 自动生成对话标题 375 | const generateChatTitle = (content) => { 376 | // 简单方法:取用户输入的前15个字符作为标题 377 | if (!content || content.length === 0) return '新对话'; 378 | 379 | // 去除空白字符 380 | const trimmedContent = content.replace(/\s+/g, ' ').trim(); 381 | 382 | // 如果内容太短,直接返回 383 | if (trimmedContent.length <= 20) return trimmedContent; 384 | 385 | // 否则截取前20个字符,并添加省略号 386 | return trimmedContent.substring(0, 20) + '...'; 387 | }; 388 | 389 | // 自定义设置模型的函数,同时更新引用和状态 390 | const handleModelChange = (modelId) => { 391 | setSelectedModel(modelId); 392 | currentModelRef.current = modelId; 393 | 394 | // 如果有当前对话,更新对话的模型信息 395 | if (currentChatId) { 396 | updateChatModel(currentChatId, modelId); 397 | } 398 | }; 399 | 400 | // 更新对话使用的模型 401 | const updateChatModel = async (chatId, modelId) => { 402 | if (!chatId) return; 403 | 404 | try { 405 | const chats = await getStorage('chats') || {}; 406 | if (chats[chatId]) { 407 | chats[chatId].model = modelId; 408 | await saveChat(chats); 409 | } 410 | } catch (error) { 411 | console.error('更新对话模型失败:', error); 412 | } 413 | }; 414 | 415 | // 处理发送消息逻辑 416 | const handleSend = async (overrideInput = null) => { 417 | try { 418 | const messageContent = overrideInput !== null ? overrideInput : input; 419 | 420 | // 如果消息为空则不处理 421 | if (!messageContent.trim()) { 422 | return; 423 | } 424 | 425 | // 立即重置滚动位置函数,确保用户流畅的体验 426 | if (restoreScrollPositionRef.current) { 427 | restoreScrollPositionRef.current(); 428 | restoreScrollPositionRef.current = null; 429 | } 430 | 431 | // 清空输入框并重置高度 432 | if (!overrideInput) { 433 | setInput(''); 434 | // 重置输入框高度 435 | resetTextareaHeight(); 436 | } 437 | 438 | // 检查API设置 439 | const settings = await getStorage('settings'); 440 | const defaultApiUrl = 'https://api.openai.com/v1/chat/completions'; 441 | const defaultApiKey = 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; 442 | 443 | // 检查API Key是否未设置或者是默认值 444 | if (!settings || !settings.apiKey || settings.apiKey === defaultApiKey || settings.apiKey.trim() === '') { 445 | setApiError('未设置API密钥,请在设置中配置API密钥'); 446 | return; 447 | } 448 | 449 | // 检查API URL是否未设置或者为空 450 | if (!settings.apiUrl || settings.apiUrl.trim() === '') { 451 | setApiError('未设置API URL,请在设置中配置API URL'); 452 | return; 453 | } 454 | 455 | // 清除之前的错误 456 | setApiError(''); 457 | 458 | if ((!messageContent.trim() && quotes.length === 0) || isLoading || isGenerating) { 459 | console.log('发送条件不满足或当前正在处理其他请求'); 460 | return; 461 | } 462 | 463 | // 使用当前最新的模型设置,确保模型选择后立即使用 464 | const currentModel = currentModelRef.current; 465 | 466 | // 组合引用内容和用户输入 467 | let fullContent = ''; 468 | 469 | // 先添加引用内容 470 | if (quotes.length > 0) { 471 | quotes.forEach((quote) => { 472 | // 每段引用内容前只添加 > 符号 473 | fullContent += `> ${quote.text}\n\n`; 474 | }); 475 | 476 | if (messageContent.trim()) { 477 | // 用户输入直接跟在引用内容后面,不需要添加标识 478 | fullContent += messageContent; 479 | } 480 | } else { 481 | fullContent = messageContent; 482 | } 483 | 484 | console.log('准备发送消息:', fullContent.substring(0, 50) + (fullContent.length > 50 ? '...' : '')); 485 | 486 | // 确保我们有一个有效的对话ID 487 | if (!currentChatId) { 488 | console.log('发送前恢复或创建新对话'); 489 | const savedChatId = localStorage.getItem(CURRENT_CHAT_KEY); 490 | if (savedChatId) { 491 | console.log('从localStorage恢复对话ID:', savedChatId); 492 | setChatId(savedChatId); 493 | 494 | // 尝试加载对话历史 495 | const chats = await getStorage('chats') || {}; 496 | if (chats[savedChatId]) { 497 | console.log('加载历史消息:', chats[savedChatId].messages?.length || 0, '条'); 498 | setMessages(chats[savedChatId].messages || []); 499 | setCurrentChatTitle(chats[savedChatId].title || '新对话'); 500 | setSelectedModel(chats[savedChatId].model || 'gpt-3.5-turbo'); 501 | } 502 | } else { 503 | // 创建新对话 504 | const newChatId = `chat_${Date.now()}`; 505 | setChatId(newChatId); 506 | 507 | // 初始化新对话 508 | const newChat = { 509 | id: newChatId, 510 | title: '新对话', 511 | model: selectedModel, 512 | createdAt: new Date().toISOString(), 513 | messages: [] 514 | }; 515 | 516 | // 保存新对话 517 | const chats = await getStorage('chats') || {}; 518 | chats[newChatId] = newChat; 519 | await saveChat(chats); 520 | } 521 | } else if (messages.length === 0) { 522 | // 有对话ID但没有消息,可能是侧边栏刚刚打开 523 | console.log('有对话ID但没有消息,尝试从存储加载'); 524 | try { 525 | const chats = await getStorage('chats') || {}; 526 | if (chats[currentChatId]) { 527 | console.log('从存储加载对话消息:', chats[currentChatId].messages?.length || 0, '条'); 528 | setMessages(chats[currentChatId].messages || []); 529 | // 确保更新后再添加新消息 530 | const currentMessages = chats[currentChatId].messages || []; 531 | 532 | const userMessage = { 533 | role: 'user', 534 | content: fullContent 535 | }; 536 | 537 | // 清空输入框和引用内容 538 | setQuotes([]); 539 | 540 | // 如果使用的是Chrome扩展环境,通知后台清空引用内容 541 | if (typeof chrome !== 'undefined' && chrome.runtime) { 542 | chrome.runtime.sendMessage({ 543 | type: 'clearAllQuotes' 544 | }); 545 | } 546 | 547 | // 将新消息添加到当前消息列表 548 | setMessages([...currentMessages, userMessage]); 549 | // 后续代码继续处理发送... 550 | setIsLoading(true); 551 | setIsGenerating(true); 552 | 553 | // 创建流式响应的初始消息 554 | setStreamingMessage({ 555 | role: 'assistant', 556 | content: '', 557 | model: currentModel // 使用当前引用的模型值 558 | }); 559 | 560 | try { 561 | // 创建AbortController用于中断请求 562 | abortControllerRef.current = new AbortController(); 563 | 564 | // 使用回调函数实现流式输出 565 | const onChunkReceived = (chunk) => { 566 | setStreamingMessage(prev => { 567 | if (!prev) return { 568 | role: 'assistant', 569 | content: chunk, 570 | model: currentModel 571 | }; 572 | return { 573 | ...prev, 574 | content: prev.content + chunk 575 | }; 576 | }); 577 | }; 578 | 579 | const assistantMessage = await sendMessage( 580 | fullContent, 581 | currentModel, // 使用当前引用的模型值 582 | currentMessages, // 使用加载的历史消息 583 | onChunkReceived, 584 | abortControllerRef.current.signal 585 | ); 586 | 587 | // 为AI消息添加模型信息 588 | assistantMessage.model = currentModel; 589 | 590 | // 完成后清除流式消息,并添加完整回复 591 | setStreamingMessage(null); 592 | 593 | const updatedMessages = [...currentMessages, userMessage, assistantMessage]; 594 | setMessages(updatedMessages); 595 | 596 | // 更新存储 597 | if (currentChatId) { 598 | const updatedChats = await getStorage('chats') || {}; 599 | if (updatedChats[currentChatId]) { 600 | // 如果是第一次发送消息,自动更新对话标题 601 | if (updatedChats[currentChatId].messages.length === 0 && updatedChats[currentChatId].title === '新对话') { 602 | const newTitle = generateChatTitle(messageContent); 603 | updatedChats[currentChatId].title = newTitle; 604 | setCurrentChatTitle(newTitle); 605 | } 606 | 607 | updatedChats[currentChatId].messages = updatedMessages; 608 | updatedChats[currentChatId].model = currentModel; 609 | await saveChat(updatedChats); 610 | } 611 | } 612 | 613 | // 确保重置状态 614 | setIsLoading(false); 615 | setIsGenerating(false); 616 | setStreamingMessage(null); 617 | abortControllerRef.current = null; 618 | 619 | // 强制滚动到底部,不受用户滚动状态影响 620 | if (messageListRef.current) { 621 | messageListRef.current.resetUserScrollState(); 622 | } 623 | 624 | return; // 结束执行,跳过下面的普通处理流程 625 | } catch (error) { 626 | // 处理错误... 627 | console.error('发送消息失败:', error); 628 | setIsLoading(false); 629 | setIsGenerating(false); 630 | setStreamingMessage(null); 631 | abortControllerRef.current = null; 632 | return; // 结束执行 633 | } 634 | } 635 | } catch (error) { 636 | console.error('加载对话历史失败:', error); 637 | } 638 | } 639 | 640 | const userMessage = { 641 | role: 'user', 642 | content: fullContent 643 | }; 644 | 645 | // 清空输入框和引用内容 646 | setQuotes([]); 647 | 648 | // 如果使用的是Chrome扩展环境,通知后台清空引用内容 649 | if (typeof chrome !== 'undefined' && chrome.runtime) { 650 | chrome.runtime.sendMessage({ 651 | type: 'clearAllQuotes' 652 | }); 653 | } 654 | 655 | // 将新消息添加到当前消息列表 656 | const updatedMessages = [...messages, userMessage]; 657 | setMessages(updatedMessages); 658 | 659 | // 强制滚动到底部,不受用户滚动状态影响 660 | if (messageListRef.current) { 661 | messageListRef.current.resetUserScrollState(); 662 | } 663 | 664 | // 如果是第一条消息,自动更新对话标题 665 | if (messages.length === 0 && (!currentChatTitle || currentChatTitle === '新对话')) { 666 | const newTitle = generateChatTitle(messageContent); 667 | setCurrentChatTitle(newTitle); 668 | 669 | // 更新存储中的标题 670 | if (currentChatId) { 671 | const chats = await getStorage('chats') || {}; 672 | if (chats[currentChatId]) { 673 | chats[currentChatId].title = newTitle; 674 | await saveChat(chats); 675 | } 676 | } 677 | } 678 | 679 | setIsLoading(true); 680 | setIsGenerating(true); 681 | 682 | // 创建流式响应的初始消息 683 | setStreamingMessage({ 684 | role: 'assistant', 685 | content: '', 686 | model: currentModel // 使用当前引用的模型值 687 | }); 688 | 689 | try { 690 | // 创建AbortController用于中断请求 691 | abortControllerRef.current = new AbortController(); 692 | 693 | // 使用回调函数实现流式输出 694 | const onChunkReceived = (chunk) => { 695 | setStreamingMessage(prev => { 696 | if (!prev) return { 697 | role: 'assistant', 698 | content: chunk, 699 | model: currentModel 700 | }; 701 | return { 702 | ...prev, 703 | content: prev.content + chunk 704 | }; 705 | }); 706 | }; 707 | 708 | const assistantMessage = await sendMessage( 709 | fullContent, 710 | currentModel, // 使用当前引用的模型值 711 | messages, 712 | onChunkReceived, 713 | abortControllerRef.current.signal 714 | ); 715 | 716 | // 为AI消息添加模型信息 717 | assistantMessage.model = currentModel; 718 | 719 | // 完成后清除流式消息,并添加完整回复 720 | setStreamingMessage(null); 721 | 722 | const updatedMessages = [...messages, userMessage, assistantMessage]; 723 | setMessages(updatedMessages); 724 | 725 | // 更新存储 726 | if (currentChatId) { 727 | const chats = await getStorage('chats') || {}; 728 | if (chats[currentChatId]) { 729 | chats[currentChatId].messages = updatedMessages; 730 | chats[currentChatId].model = currentModel; 731 | await saveChat(chats); 732 | } else { 733 | // 处理对话不存在的情况 734 | chats[currentChatId] = { 735 | id: currentChatId, 736 | title: generateChatTitle(messageContent), 737 | model: currentModel, 738 | createdAt: new Date().toISOString(), 739 | messages: updatedMessages 740 | }; 741 | await saveChat(chats); 742 | } 743 | } else { 744 | console.error('发送消息完成但没有有效的对话ID'); 745 | } 746 | } catch (error) { 747 | // 如果是主动中断请求,不显示错误消息 748 | if (error.name === 'AbortError') { 749 | console.log('请求被用户取消'); 750 | 751 | // 如果有部分生成的内容,将其保存为完整回复 752 | if (streamingMessage && streamingMessage.content) { 753 | const assistantMessage = { 754 | role: 'assistant', 755 | content: streamingMessage.content + '\n\n**[用户已中止生成]**', 756 | model: selectedModel // 添加模型信息 757 | }; 758 | 759 | const updatedMessages = [...messages, userMessage, assistantMessage]; 760 | setMessages(updatedMessages); 761 | 762 | // 更新存储 763 | if (currentChatId) { 764 | const chats = await getStorage('chats') || {}; 765 | if (chats[currentChatId]) { 766 | chats[currentChatId].messages = updatedMessages; 767 | await saveChat(chats); 768 | } 769 | } 770 | } 771 | } else { 772 | console.error('发送消息失败:', error); 773 | // 显示错误消息 774 | setStreamingMessage(null); 775 | setMessages(prev => [ 776 | ...prev, 777 | { role: 'assistant', content: `发送消息失败: ${error.message}` } 778 | ]); 779 | } 780 | } finally { 781 | setIsLoading(false); 782 | setIsGenerating(false); 783 | setStreamingMessage(null); 784 | abortControllerRef.current = null; 785 | console.log('消息处理完成,状态已重置'); 786 | } 787 | } catch (outerError) { 788 | console.error('handleSend外层错误:', outerError); 789 | setIsLoading(false); 790 | setIsGenerating(false); 791 | setStreamingMessage(null); 792 | if (abortControllerRef.current) { 793 | abortControllerRef.current.abort(); 794 | abortControllerRef.current = null; 795 | } 796 | } 797 | }; 798 | 799 | // 停止生成回复 800 | const handleStopGeneration = () => { 801 | if (abortControllerRef.current) { 802 | // 保存当前已生成的内容,确保不会丢失 803 | if (streamingMessage && streamingMessage.content) { 804 | const partialMessage = { 805 | role: 'assistant', 806 | content: streamingMessage.content + '\n\n**[用户已中止生成]**', 807 | model: currentModelRef.current // 确保保存模型信息 808 | }; 809 | 810 | // 更新消息列表 811 | setMessages(prev => { 812 | const newMessages = [...prev, partialMessage]; 813 | // 异步更新存储 814 | (async () => { 815 | if (currentChatId) { 816 | const chats = await getStorage('chats') || {}; 817 | if (chats[currentChatId]) { 818 | chats[currentChatId].messages = newMessages; 819 | await saveChat(chats); 820 | } 821 | } 822 | })(); 823 | return newMessages; 824 | }); 825 | } 826 | 827 | // 中止请求 828 | abortControllerRef.current.abort(); 829 | } 830 | }; 831 | 832 | const handleNewChat = async () => { 833 | setMessages([]); 834 | setStreamingMessage(null); 835 | setApiError(''); 836 | const chatId = `chat_${Date.now()}`; 837 | const newChat = { 838 | id: chatId, 839 | title: '新对话', 840 | model: selectedModel, 841 | createdAt: new Date().toISOString(), 842 | messages: [] 843 | }; 844 | 845 | // 保存到存储 846 | const chats = await getStorage('chats') || {}; 847 | chats[chatId] = newChat; 848 | await saveChat(chats); 849 | 850 | setChatId(chatId); 851 | setCurrentChatTitle('新对话'); 852 | }; 853 | 854 | // 消息操作处理函数 855 | const handleCopyMessage = (content) => { 856 | navigator.clipboard.writeText(content) 857 | .then(() => alert('消息内容已复制到剪贴板')) 858 | .catch(err => console.error('复制失败:', err)); 859 | }; 860 | 861 | const handleEditMessage = (index, message) => { 862 | // 记录当前编辑的消息索引,用于编辑完成后恢复位置 863 | lastEditedIndexRef.current = index; 864 | // 记录当前滚动位置以便在退出编辑模式时恢复 865 | if (messageListRef.current) { 866 | restoreScrollPositionRef.current = messageListRef.current.preserveScrollPosition(); 867 | } 868 | setEditingMessageIndex(index); 869 | setEditingMessageContent(message.content); 870 | }; 871 | 872 | const handleSaveEdit = async () => { 873 | // 记录当前编辑的消息索引 874 | const targetScrollIndex = editingMessageIndex; 875 | 876 | if (editingMessageIndex !== null) { 877 | // 使用 MessageList 的 preserveScrollPosition 来保存当前滚动位置 878 | const restoreScroll = messageListRef.current?.preserveScrollPosition(); 879 | 880 | // 检查消息内容是否真的有变化 881 | const originalMessage = messages[editingMessageIndex]; 882 | if (originalMessage.content !== editingMessageContent) { 883 | // 构建更新后的消息数组 884 | const updatedMessages = [...messages]; 885 | updatedMessages[editingMessageIndex] = { 886 | ...updatedMessages[editingMessageIndex], 887 | content: editingMessageContent 888 | }; 889 | 890 | // 更新本地消息 891 | setMessages(updatedMessages); 892 | 893 | // 保存到存储 894 | if (currentChatId) { 895 | const chats = await getStorage('chats') || {}; 896 | if (chats[currentChatId]) { 897 | chats[currentChatId].messages = updatedMessages; 898 | await saveChat(chats); 899 | } 900 | } 901 | } 902 | 903 | // 重置编辑状态 904 | setEditingMessageIndex(null); 905 | setEditingMessageContent(''); 906 | 907 | // 等到下一帧再尝试恢复滚动位置 908 | requestAnimationFrame(() => { 909 | // 先尝试使用通用的恢复方法 910 | if (restoreScroll) restoreScroll(); 911 | 912 | // 再尝试使用具体消息索引滚动 (作为备份) 913 | setTimeout(() => { 914 | // 如果第一种方法不成功,尝试第二种方法 915 | if (messageListRef.current && targetScrollIndex !== null) { 916 | messageListRef.current.scrollToMessage(targetScrollIndex); 917 | } 918 | }, 50); 919 | }); 920 | } 921 | }; 922 | 923 | const handleCancelEdit = () => { 924 | // 记录当前编辑的消息索引 925 | const targetScrollIndex = editingMessageIndex; 926 | 927 | // 使用 MessageList 的 preserveScrollPosition 来保存当前滚动位置 928 | const restoreScroll = messageListRef.current?.preserveScrollPosition(); 929 | 930 | // 重置编辑状态 931 | setEditingMessageIndex(null); 932 | setEditingMessageContent(''); 933 | 934 | // 使用多层保险机制恢复滚动位置 935 | requestAnimationFrame(() => { 936 | // 先尝试使用通用的恢复方法 937 | if (restoreScroll) restoreScroll(); 938 | 939 | // 再尝试使用具体消息索引滚动 (作为备份) 940 | setTimeout(() => { 941 | if (messageListRef.current && targetScrollIndex !== null) { 942 | messageListRef.current.scrollToMessage(targetScrollIndex); 943 | } 944 | }, 50); 945 | }); 946 | }; 947 | 948 | const handleDeleteMessage = async (index) => { 949 | // 删除消息 950 | let updatedMessages = [...messages]; 951 | 952 | // 如果删除的是用户消息,则同时删除其后的AI回复 953 | if ( 954 | updatedMessages[index].role === 'user' && 955 | index + 1 < updatedMessages.length && 956 | updatedMessages[index + 1].role === 'assistant' 957 | ) { 958 | updatedMessages.splice(index, 2); 959 | } else { 960 | updatedMessages.splice(index, 1); 961 | } 962 | 963 | setMessages(updatedMessages); 964 | 965 | // 更新存储 966 | if (currentChatId) { 967 | const chats = await getStorage('chats') || {}; 968 | if (chats[currentChatId]) { 969 | chats[currentChatId].messages = updatedMessages; 970 | await saveChat(chats); 971 | } 972 | } 973 | }; 974 | 975 | const handleRegenerateMessage = async (index) => { 976 | let messageToRegenerate; 977 | 978 | // 强制滚动到底部,重置用户滚动状态 979 | if (messageListRef.current) { 980 | messageListRef.current.resetUserScrollState(); 981 | } 982 | 983 | if (messages[index].role === 'assistant') { 984 | // 找到这条AI消息之前的用户消息 985 | let userMessageIndex = index - 1; 986 | while (userMessageIndex >= 0 && messages[userMessageIndex].role !== 'user') { 987 | userMessageIndex--; 988 | } 989 | 990 | if (userMessageIndex < 0) return; 991 | 992 | // 重置内容:保留到用户消息,删除当前AI回复 993 | const updatedMessages = messages.slice(0, index); 994 | setMessages(updatedMessages); 995 | 996 | // 使用用户消息内容重新生成 997 | messageToRegenerate = messages[userMessageIndex].content; 998 | 999 | // 直接发送消息内容,不通过handleSend添加用户消息 1000 | const onChunkReceived = (chunk) => { 1001 | setStreamingMessage(prev => ({ 1002 | ...prev, 1003 | content: prev.content + chunk 1004 | })); 1005 | }; 1006 | 1007 | try { 1008 | setIsLoading(true); 1009 | setIsGenerating(true); 1010 | setStreamingMessage({ 1011 | role: 'assistant', 1012 | content: '', 1013 | model: selectedModel // 添加模型信息 1014 | }); 1015 | 1016 | // 创建AbortController用于中断请求 1017 | abortControllerRef.current = new AbortController(); 1018 | 1019 | const assistantMessage = await sendMessage( 1020 | messageToRegenerate, 1021 | selectedModel, 1022 | updatedMessages, 1023 | onChunkReceived, 1024 | abortControllerRef.current.signal 1025 | ); 1026 | 1027 | // 完成后清除流式消息,并添加完整回复 1028 | setStreamingMessage(null); 1029 | 1030 | // 直接添加AI回复,不添加用户消息 1031 | setMessages([...updatedMessages, assistantMessage]); 1032 | 1033 | // 更新存储 1034 | if (currentChatId) { 1035 | const chats = await getStorage('chats') || {}; 1036 | if (chats[currentChatId]) { 1037 | chats[currentChatId].messages = [...updatedMessages, assistantMessage]; 1038 | await saveChat(chats); 1039 | } 1040 | } 1041 | } catch (error) { 1042 | // 错误处理 1043 | if (error.name === 'AbortError') { 1044 | console.log('请求被用户取消'); 1045 | if (streamingMessage && streamingMessage.content) { 1046 | const partialMessage = { 1047 | role: 'assistant', 1048 | content: streamingMessage.content + '\n\n**[用户已中止生成]**', 1049 | model: selectedModel // 添加模型信息 1050 | }; 1051 | setMessages([...updatedMessages, partialMessage]); 1052 | 1053 | // 更新存储 1054 | if (currentChatId) { 1055 | const chats = await getStorage('chats') || {}; 1056 | if (chats[currentChatId]) { 1057 | chats[currentChatId].messages = [...updatedMessages, partialMessage]; 1058 | await saveChat(chats); 1059 | } 1060 | } 1061 | } 1062 | } else { 1063 | console.error('重新生成失败:', error); 1064 | setMessages([...updatedMessages, { 1065 | role: 'assistant', 1066 | content: `重新生成失败: ${error.message}` 1067 | }]); 1068 | } 1069 | } finally { 1070 | setIsLoading(false); 1071 | setIsGenerating(false); 1072 | setStreamingMessage(null); 1073 | abortControllerRef.current = null; 1074 | } 1075 | } else if (messages[index].role === 'user') { 1076 | // 如果是用户消息,查找之后的AI回复 1077 | const hasNextAI = index + 1 < messages.length && messages[index + 1].role === 'assistant'; 1078 | 1079 | // 创建一个新的消息数组,保留当前用户消息但删除后面的AI回复 1080 | const updatedMessages = hasNextAI ? 1081 | messages.slice(0, index + 1) : 1082 | [...messages.slice(0, index), messages[index]]; 1083 | 1084 | setMessages(updatedMessages); 1085 | 1086 | // 使用用户消息内容重新生成 1087 | messageToRegenerate = messages[index].content; 1088 | 1089 | // 直接发送消息内容,不通过handleSend添加用户消息 1090 | const onChunkReceived = (chunk) => { 1091 | setStreamingMessage(prev => ({ 1092 | ...prev, 1093 | content: prev.content + chunk 1094 | })); 1095 | }; 1096 | 1097 | try { 1098 | setIsLoading(true); 1099 | setIsGenerating(true); 1100 | setStreamingMessage({ 1101 | role: 'assistant', 1102 | content: '', 1103 | model: selectedModel // 添加模型信息 1104 | }); 1105 | 1106 | // 创建AbortController用于中断请求 1107 | abortControllerRef.current = new AbortController(); 1108 | 1109 | const assistantMessage = await sendMessage( 1110 | messageToRegenerate, 1111 | selectedModel, 1112 | updatedMessages, 1113 | onChunkReceived, 1114 | abortControllerRef.current.signal 1115 | ); 1116 | 1117 | // 完成后清除流式消息,并添加完整回复 1118 | setStreamingMessage(null); 1119 | 1120 | // 直接添加AI回复,不添加用户消息 1121 | setMessages([...updatedMessages, assistantMessage]); 1122 | 1123 | // 更新存储 1124 | if (currentChatId) { 1125 | const chats = await getStorage('chats') || {}; 1126 | if (chats[currentChatId]) { 1127 | chats[currentChatId].messages = [...updatedMessages, assistantMessage]; 1128 | await saveChat(chats); 1129 | } 1130 | } 1131 | } catch (error) { 1132 | // 错误处理 1133 | if (error.name === 'AbortError') { 1134 | console.log('请求被用户取消'); 1135 | if (streamingMessage && streamingMessage.content) { 1136 | const partialMessage = { 1137 | role: 'assistant', 1138 | content: streamingMessage.content + '\n\n**[用户已中止生成]**', 1139 | model: selectedModel // 添加模型信息 1140 | }; 1141 | setMessages([...updatedMessages, partialMessage]); 1142 | 1143 | // 更新存储 1144 | if (currentChatId) { 1145 | const chats = await getStorage('chats') || {}; 1146 | if (chats[currentChatId]) { 1147 | chats[currentChatId].messages = [...updatedMessages, partialMessage]; 1148 | await saveChat(chats); 1149 | } 1150 | } 1151 | } 1152 | } else { 1153 | console.error('重新生成失败:', error); 1154 | setMessages([...updatedMessages, { 1155 | role: 'assistant', 1156 | content: `重新生成失败: ${error.message}` 1157 | }]); 1158 | } 1159 | } finally { 1160 | setIsLoading(false); 1161 | setIsGenerating(false); 1162 | setStreamingMessage(null); 1163 | abortControllerRef.current = null; 1164 | } 1165 | } else { 1166 | return; // 不支持的消息类型 1167 | } 1168 | }; 1169 | 1170 | // 组合消息列表(包括流式响应) 1171 | const allMessages = streamingMessage 1172 | ? [...messages, streamingMessage] 1173 | : messages; 1174 | 1175 | // 在组件中添加以下函数,用于自动调整textarea的高度 1176 | const handleTextareaInput = (e) => { 1177 | const textarea = e.target; 1178 | // 重置高度以便正确计算新高度 1179 | textarea.style.height = 'auto'; 1180 | // 设置新高度,但不超过CSS中设置的max-height 1181 | textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px'; 1182 | }; 1183 | 1184 | // 添加新函数用于重置输入框高度 1185 | const resetTextareaHeight = () => { 1186 | const textarea = document.querySelector('.chat-input'); 1187 | if (textarea) { 1188 | textarea.style.height = 'auto'; 1189 | } 1190 | }; 1191 | 1192 | return ( 1193 |
1194 |
1195 | 1201 | 1202 |
1203 | {currentChatTitle || '新对话'} 1204 |
1205 | 1206 | {/* 模型选择器组件,传递更新函数 */} 1207 | 1211 |
1212 | 1213 | {/* 使用固定高度的容器并设置overflow属性,防止DOM变化导致滚动重置 */} 1214 |
1219 | {editingMessageIndex !== null ? ( 1220 |
1226 |