diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 6141c061fa..2ca56c0837 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -54,7 +54,7 @@ jobs: yarn install - name: 🏃‍♀️ Translate - run: yarn sync:i18n && yarn auto:i18n + run: yarn i18n:sync && yarn i18n:translate - name: 🔍 Format run: yarn format diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 1258449007..1f7bf7d784 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -58,7 +58,7 @@ jobs: run: yarn typecheck - name: i18n Check - run: yarn check:i18n + run: yarn i18n:check - name: Test run: yarn test diff --git a/CLAUDE.md b/CLAUDE.md index c96fc0e403..c68187db93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ When creating a Pull Request, you MUST: - **Development**: `yarn dev` - Runs Electron app in development mode with hot reload - **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger - **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck) - - If having i18n sort issues, run `yarn sync:i18n` first to sync template + - If having i18n sort issues, run `yarn i18n:sync` first to sync template - If having formatting issues, run `yarn format` first - **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes - **Single Test**: @@ -40,20 +40,23 @@ When creating a Pull Request, you MUST: ## Project Architecture ### Electron Structure + - **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.) - **Renderer Process** (`src/renderer/`): React UI with Redux state management - **Preload Scripts** (`src/preload/`): Secure IPC bridge ### Key Components + - **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers. - **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc. - **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. - **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. ### Logging + ```typescript -import { loggerService } from '@logger' -const logger = loggerService.withContext('moduleName') +import { loggerService } from "@logger"; +const logger = loggerService.withContext("moduleName"); // Renderer: loggerService.initWindowSource('windowName') first -logger.info('message', CONTEXT) +logger.info("message", CONTEXT); ``` diff --git a/biome.jsonc b/biome.jsonc index 705b1e01f3..6f925f5af2 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -23,7 +23,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/.claude/**", "!**/.vscode/**"], + "includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"], "maxSize": 2097152 }, "formatter": { diff --git a/docs/en/guides/i18n.md b/docs/en/guides/i18n.md index a3284e3ab9..7fccfde695 100644 --- a/docs/en/guides/i18n.md +++ b/docs/en/guides/i18n.md @@ -71,7 +71,7 @@ Tools like i18n Ally cannot parse dynamic content within template strings, resul ```javascript // Not recommended - Plugin cannot resolve -const message = t(`fruits.${fruit}`) +const message = t(`fruits.${fruit}`); ``` #### 2. **No Real-time Rendering in Editor** @@ -91,14 +91,14 @@ For example: ```ts // src/renderer/src/i18n/label.ts const themeModeKeyMap = { - dark: 'settings.theme.dark', - light: 'settings.theme.light', - system: 'settings.theme.system' -} as const + dark: "settings.theme.dark", + light: "settings.theme.light", + system: "settings.theme.system", +} as const; export const getThemeModeLabel = (key: string): string => { - return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key -} + return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key; +}; ``` By avoiding template strings, you gain better developer experience, more reliable translation checks, and a more maintainable codebase. @@ -107,7 +107,7 @@ By avoiding template strings, you gain better developer experience, more reliabl The project includes several scripts to automate i18n-related tasks: -### `check:i18n` - Validate i18n Structure +### `i18n:check` - Validate i18n Structure This script checks: @@ -116,10 +116,10 @@ This script checks: - Whether keys are properly sorted ```bash -yarn check:i18n +yarn i18n:check ``` -### `sync:i18n` - Synchronize JSON Structure and Sort Order +### `i18n:sync` - Synchronize JSON Structure and Sort Order This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including: @@ -128,14 +128,14 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al 3. Sorting keys automatically ```bash -yarn sync:i18n +yarn i18n:sync ``` -### `auto:i18n` - Automatically Translate Pending Texts +### `i18n:translate` - Automatically Translate Pending Texts This script fills in texts marked as `[to be translated]` using machine translation. -Typically, after adding new texts in `zh-cn.json`, run `sync:i18n`, then `auto:i18n` to complete translations. +Typically, after adding new texts in `zh-cn.json`, run `i18n:sync`, then `i18n:translate` to complete translations. Before using this script, set the required environment variables: @@ -148,30 +148,20 @@ MODEL="qwen-plus-latest" Alternatively, add these variables directly to your `.env` file. ```bash -yarn auto:i18n -``` - -### `update:i18n` - Object-level Translation Update - -Updates translations in language files under `src/renderer/src/i18n/translate` at the object level, preserving existing translations and only updating new content. - -**Not recommended** — prefer `auto:i18n` for translation tasks. - -```bash -yarn update:i18n +yarn i18n:translate ``` ### Workflow 1. During development, first add the required text in `zh-cn.json` 2. Confirm it displays correctly in the Chinese environment -3. Run `yarn sync:i18n` to propagate the keys to other language files -4. Run `yarn auto:i18n` to perform machine translation +3. Run `yarn i18n:sync` to propagate the keys to other language files +4. Run `yarn i18n:translate` to perform machine translation 5. Grab a coffee and let the magic happen! ## Best Practices 1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages. -2. **Run Check Script Before Commit**: Use `yarn check:i18n` to catch i18n issues early. +2. **Run Check Script Before Commit**: Use `yarn i18n:check` to catch i18n issues early. 3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content. 4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error` diff --git a/docs/zh/guides/i18n.md b/docs/zh/guides/i18n.md index 82624d35c8..c8a8ccc66b 100644 --- a/docs/zh/guides/i18n.md +++ b/docs/zh/guides/i18n.md @@ -1,17 +1,17 @@ # 如何优雅地做好 i18n -## 使用i18n ally插件提升开发体验 +## 使用 i18n ally 插件提升开发体验 -i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反馈,帮助开发者更早发现文案缺失和错译问题。 +i18n ally 是一个强大的 VSCode 插件,它能在开发阶段提供实时反馈,帮助开发者更早发现文案缺失和错译问题。 项目中已经配置好了插件设置,直接安装即可。 ### 开发时优势 - **实时预览**:翻译文案会直接显示在编辑器中 -- **错误检测**:自动追踪标记出缺失的翻译或未使用的key -- **快速跳转**:可通过key直接跳转到定义处(Ctrl/Cmd + click) -- **自动补全**:输入i18n key时提供自动补全建议 +- **错误检测**:自动追踪标记出缺失的翻译或未使用的 key +- **快速跳转**:可通过 key 直接跳转到定义处(Ctrl/Cmd + click) +- **自动补全**:输入 i18n key 时提供自动补全建议 ### 效果展示 @@ -23,9 +23,9 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ## i18n 约定 -### **绝对避免使用flat格式** +### **绝对避免使用 flat 格式** -绝对避免使用flat格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构: +绝对避免使用 flat 格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构: ```json // 错误示例 - flat结构 @@ -52,14 +52,14 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 #### 为什么要使用嵌套结构 1. **自然分组**:通过对象结构天然能将相关上下文的文案分到一个组别中 -2. **插件要求**:i18n ally 插件需要嵌套或flat格式其一的文件才能正常分析 +2. **插件要求**:i18n ally 插件需要嵌套或 flat 格式其一的文件才能正常分析 ### **避免在`t()`中使用模板字符串** -**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在JavaScript开发中非常方便,但在国际化场景下会带来一系列问题。 +**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在 JavaScript 开发中非常方便,但在国际化场景下会带来一系列问题。 1. **插件无法跟踪** - i18n ally等工具无法解析模板字符串中的动态内容,导致: + i18n ally 等工具无法解析模板字符串中的动态内容,导致: - 无法正确显示实时预览 - 无法检测翻译缺失 @@ -67,11 +67,11 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ```javascript // 不推荐 - 插件无法解析 - const message = t(`fruits.${fruit}`) + const message = t(`fruits.${fruit}`); ``` 2. **编辑器无法实时渲染** - 在IDE中,模板字符串会显示为原始代码而非最终翻译结果,降低了开发体验。 + 在 IDE 中,模板字符串会显示为原始代码而非最终翻译结果,降低了开发体验。 3. **更难以维护** 由于插件无法跟踪这样的文案,编辑器中也无法渲染,开发者必须人工确认语言文件中是否存在相应的文案。 @@ -85,36 +85,36 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ```ts // src/renderer/src/i18n/label.ts const themeModeKeyMap = { - dark: 'settings.theme.dark', - light: 'settings.theme.light', - system: 'settings.theme.system' -} as const + dark: "settings.theme.dark", + light: "settings.theme.light", + system: "settings.theme.system", +} as const; export const getThemeModeLabel = (key: string): string => { - return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key -} + return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key; +}; ``` 通过避免模板字符串,可以获得更好的开发体验、更可靠的翻译检查以及更易维护的代码库。 ## 自动化脚本 -项目中有一系列脚本来自动化i18n相关任务: +项目中有一系列脚本来自动化 i18n 相关任务: -### `check:i18n` - 检查i18n结构 +### `i18n:check` - 检查 i18n 结构 此脚本会检查: - 所有语言文件是否为嵌套结构 -- 是否存在缺失的key -- 是否存在多余的key +- 是否存在缺失的 key +- 是否存在多余的 key - 是否已经有序 ```bash -yarn check:i18n +yarn i18n:check ``` -### `sync:i18n` - 同步json结构与排序 +### `i18n:sync` - 同步 json 结构与排序 此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括: @@ -123,14 +123,14 @@ yarn check:i18n 3. 自动排序 ```bash -yarn sync:i18n +yarn i18n:sync ``` -### `auto:i18n` - 自动翻译待翻译文本 +### `i18n:translate` - 自动翻译待翻译文本 次脚本自动将标记为待翻译的文本通过机器翻译填充。 -通常,在`zh-cn.json`中添加所需文案后,执行`sync:i18n`即可自动完成翻译。 +通常,在`zh-cn.json`中添加所需文案后,执行`i18n:sync`即可自动完成翻译。 使用该脚本前,需要配置环境变量,例如: @@ -143,29 +143,19 @@ MODEL="qwen-plus-latest" 你也可以通过直接编辑`.env`文件来添加环境变量。 ```bash -yarn auto:i18n -``` - -### `update:i18n` - 对象级别翻译更新 - -对`src/renderer/src/i18n/translate`中的语言文件进行对象级别的翻译更新,保留已有翻译,只更新新增内容。 - -**不建议**使用该脚本,更推荐使用`auto:i18n`进行翻译。 - -```bash -yarn update:i18n +yarn i18n:translate ``` ### 工作流 1. 开发阶段,先在`zh-cn.json`中添加所需文案 -2. 确认在中文环境下显示无误后,使用`yarn sync:i18n`将文案同步到其他语言文件 -3. 使用`yarn auto:i18n`进行自动翻译 +2. 确认在中文环境下显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件 +3. 使用`yarn i18n:translate`进行自动翻译 4. 喝杯咖啡,等翻译完成吧! ## 最佳实践 1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言 -2. **提交前运行检查脚本**:使用`yarn check:i18n`检查i18n是否有问题 +2. **提交前运行检查脚本**:使用`yarn i18n:check`检查 i18n 是否有问题 3. **小步提交翻译**:避免积累大量未翻译文本 -4. **保持key语义明确**:key应能清晰表达其用途,如`user.profile.avatar.upload.error` +4. **保持 key 语义明确**:key 应能清晰表达其用途,如`user.profile.avatar.upload.error` diff --git a/electron-builder.yml b/electron-builder.yml index 20c183a58b..db1184be87 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,60 +134,54 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.3 - Feature & Stability Update + Cherry Studio 1.7.4 - New Browser MCP & Model Updates - This release brings new features, UI improvements, and important bug fixes. + This release adds a powerful browser automation MCP server, new web search provider, and model support updates. ✨ New Features - - Add MCP server log viewer for better debugging - - Support custom Git Bash path configuration - - Add print to PDF and save as HTML for mini program webviews - - Add CherryIN API host selection settings - - Enhance assistant presets with sort and batch delete modes - - Open URL directly for SelectionAssistant search action - - Enhance web search tool switching with provider-specific context + - [MCP] Add @cherry/browser CDP MCP server with session management for browser automation + - [Web Search] Add ExaMCP free web search provider (no API key required) + - [Model] Support GPT 5.2 series models + - [Model] Add capabilities support for Doubao Seed Code models (tool calling, reasoning, vision) 🔧 Improvements - - Remove Intel Ultra limit for OVMS - - Improve settings tab and assistant item UI + - [Translate] Add reasoning effort option to translate service + - [i18n] Improve zh-TW Traditional Chinese locale + - [Settings] Update MCP Settings layout and styling 🐛 Bug Fixes - - Fix stack overflow with base64 images - - Fix infinite loop in knowledge queue processing - - Fix quick panel closing in multiple selection mode - - Fix thinking timer not stopping when reply is aborted - - Fix ThinkingButton icon display for fixed reasoning mode - - Fix knowledge query prioritization and intent prompt - - Fix OpenRouter embeddings support - - Fix SelectionAction window resize on Windows - - Add gpustack provider support for qwen3 thinking mode + - [Chat] Fix line numbers being wrongly copied from code blocks + - [Translate] Fix default to first supported reasoning effort when translating + - [Chat] Fix preserve thinking block in assistant messages + - [Web Search] Fix max search result limit + - [Embedding] Fix embedding dimensions retrieval for ModernAiProvider + - [Chat] Fix token calculation in prompt tool use plugin + - [Model] Fix Ollama provider options for Qwen model support + - [UI] Fix Chat component marginRight calculation for improved layout - Cherry Studio 1.7.3 - 功能与稳定性更新 + Cherry Studio 1.7.4 - 新增浏览器 MCP 与模型更新 - 本次更新带来新功能、界面改进和重要的问题修复。 + 本次更新新增强大的浏览器自动化 MCP 服务器、新的网页搜索提供商以及模型支持更新。 ✨ 新功能 - - 新增 MCP 服务器日志查看器,便于调试 - - 支持自定义 Git Bash 路径配置 - - 小程序 webview 支持打印 PDF 和保存为 HTML - - 新增 CherryIN API 主机选择设置 - - 助手预设增强:支持排序和批量删除模式 - - 划词助手搜索操作直接打开 URL - - 增强网页搜索工具切换逻辑,支持服务商特定上下文 + - [MCP] 新增 @cherry/browser CDP MCP 服务器,支持会话管理的浏览器自动化 + - [网页搜索] 新增 ExaMCP 免费网页搜索提供商(无需 API 密钥) + - [模型] 支持 GPT 5.2 系列模型 + - [模型] 为豆包 Seed Code 模型添加能力支持(工具调用、推理、视觉) 🔧 功能改进 - - 移除 OVMS 的 Intel Ultra 限制 - - 优化设置标签页和助手项目 UI + - [翻译] 为翻译服务添加推理强度选项 + - [国际化] 改进繁体中文(zh-TW)本地化 + - [设置] 优化 MCP 设置布局和样式 🐛 问题修复 - - 修复 base64 图片导致的栈溢出问题 - - 修复知识库队列处理的无限循环问题 - - 修复多选模式下快捷面板意外关闭的问题 - - 修复回复中止时思考计时器未停止的问题 - - 修复固定推理模式下思考按钮图标显示问题 - - 修复知识库查询优先级和意图提示 - - 修复 OpenRouter 嵌入模型支持 - - 修复 Windows 上划词助手窗口大小调整问题 - - 为 gpustack 服务商添加 qwen3 思考模式支持 + - [聊天] 修复代码块中行号被错误复制的问题 + - [翻译] 修复翻译时默认使用第一个支持的推理强度 + - [聊天] 修复助手消息中思考块的保留问题 + - [网页搜索] 修复最大搜索结果数限制 + - [嵌入] 修复 ModernAiProvider 嵌入维度获取问题 + - [聊天] 修复提示词工具使用插件的 token 计算问题 + - [模型] 修复 Ollama 提供商对 Qwen 模型的支持选项 + - [界面] 修复聊天组件右边距计算以改善布局 diff --git a/eslint.config.mjs b/eslint.config.mjs index 64fdefa1dc..9eb20d1238 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -61,6 +61,7 @@ export default defineConfig([ 'tests/**', '.yarn/**', '.gitignore', + '.conductor/**', 'scripts/cloudflare-worker.js', 'src/main/integration/nutstore/sso/lib/**', 'src/main/integration/cherryai/index.js', diff --git a/package.json b/package.json index 58bdaf128a..3fd6d1741f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.3", + "version": "1.7.4", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", @@ -53,10 +53,10 @@ "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", - "check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts", - "sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts", - "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", - "auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", + "i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts", + "i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts", + "i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", + "i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate", "update:languages": "tsx scripts/update-languages.ts", "update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts", "test": "vitest run --silent", @@ -70,7 +70,7 @@ "test:e2e": "yarn playwright test", "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:scripts": "vitest scripts", - "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn i18n:check && yarn format:check", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", diff --git a/scripts/auto-translate-i18n.ts b/scripts/auto-translate-i18n.ts index 7a1bea6f35..f57913b014 100644 --- a/scripts/auto-translate-i18n.ts +++ b/scripts/auto-translate-i18n.ts @@ -50,7 +50,7 @@ Usage Instructions: - pt-pt (Portuguese) Run Command: -yarn auto:i18n +yarn i18n:translate Performance Optimization Recommendations: - For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50 diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts index 5735474106..ac1adc3de8 100644 --- a/scripts/check-i18n.ts +++ b/scripts/check-i18n.ts @@ -145,7 +145,7 @@ export function main() { console.log('i18n 检查已通过') } catch (e) { console.error(e) - throw new Error(`检查未通过。尝试运行 yarn sync:i18n 以解决问题。`) + throw new Error(`检查未通过。尝试运行 yarn i18n:sync 以解决问题。`) } } diff --git a/src/main/mcpServers/__tests__/browser.test.ts b/src/main/mcpServers/__tests__/browser.test.ts new file mode 100644 index 0000000000..712eaf94ea --- /dev/null +++ b/src/main/mcpServers/__tests__/browser.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => { + const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => { + if (command === 'Runtime.evaluate') { + if (params?.expression === 'document.documentElement.outerHTML') { + return { result: { value: '

Test

Content

' } } + } + if (params?.expression === 'document.body.innerText') { + return { result: { value: 'Test\nContent' } } + } + return { result: { value: 'ok' } } + } + return {} + }) + + const debuggerObj = { + isAttached: vi.fn(() => true), + attach: vi.fn(), + detach: vi.fn(), + sendCommand + } + + const webContents = { + debugger: debuggerObj, + setUserAgent: vi.fn(), + getURL: vi.fn(() => 'https://example.com/'), + getTitle: vi.fn(async () => 'Example Title'), + once: vi.fn(), + removeListener: vi.fn(), + on: vi.fn() + } + + const loadURL = vi.fn(async () => {}) + + const windows: any[] = [] + + class MockBrowserWindow { + private destroyed = false + public webContents = webContents + public loadURL = loadURL + public isDestroyed = vi.fn(() => this.destroyed) + public close = vi.fn(() => { + this.destroyed = true + }) + public destroy = vi.fn(() => { + this.destroyed = true + }) + public on = vi.fn() + + constructor() { + windows.push(this) + } + } + + const app = { + isReady: vi.fn(() => true), + whenReady: vi.fn(async () => {}), + on: vi.fn() + } + + return { + BrowserWindow: MockBrowserWindow as any, + app, + __mockDebugger: debuggerObj, + __mockSendCommand: sendCommand, + __mockLoadURL: loadURL, + __mockWindows: windows + } +}) + +import * as electron from 'electron' +const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] } + +import { CdpBrowserController } from '../browser' + +describe('CdpBrowserController', () => { + it('executes single-line code via Runtime.evaluate', async () => { + const controller = new CdpBrowserController() + const result = await controller.execute('1+1') + expect(result).toBe('ok') + }) + + it('opens a URL (hidden) and returns current page info', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, false) + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('opens a URL (visible) when show=true', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, true, 'session-a') + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('reuses session for execute and supports multiline', async () => { + const controller = new CdpBrowserController() + await controller.open('https://foo.bar/', 5000, false, 'session-b') + const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b') + expect(result).toBe('ok') + }) + + it('evicts least recently used session when exceeding maxSessions', async () => { + const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 }) + await controller.open('https://foo.bar/', 5000, false, 's1') + await controller.open('https://foo.bar/', 5000, false, 's2') + await controller.open('https://foo.bar/', 5000, false, 's3') + const destroyedCount = __mockWindows.filter( + (w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0 + ).length + expect(destroyedCount).toBeGreaterThanOrEqual(1) + }) + + it('fetches URL and returns html format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'html') + expect(result).toBe('

Test

Content

') + }) + + it('fetches URL and returns txt format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'txt') + expect(result).toBe('Test\nContent') + }) + + it('fetches URL and returns markdown format (default)', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/') + expect(typeof result).toBe('string') + expect(result).toContain('Test') + }) +}) diff --git a/src/main/mcpServers/browser/controller.ts b/src/main/mcpServers/browser/controller.ts new file mode 100644 index 0000000000..6246da45d2 --- /dev/null +++ b/src/main/mcpServers/browser/controller.ts @@ -0,0 +1,307 @@ +import { app, BrowserWindow } from 'electron' +import TurndownService from 'turndown' + +import { logger, userAgent } from './types' + +/** + * Controller for managing browser windows via Chrome DevTools Protocol (CDP). + * Supports multiple sessions with LRU eviction and idle timeout cleanup. + */ +export class CdpBrowserController { + private windows: Map = new Map() + private readonly maxSessions: number + private readonly idleTimeoutMs: number + + constructor(options?: { maxSessions?: number; idleTimeoutMs?: number }) { + this.maxSessions = options?.maxSessions ?? 5 + this.idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000 + } + + private async ensureAppReady() { + if (!app.isReady()) { + await app.whenReady() + } + } + + private touch(sessionId: string) { + const entry = this.windows.get(sessionId) + if (entry) entry.lastActive = Date.now() + } + + private closeWindow(win: BrowserWindow, sessionId: string) { + try { + if (!win.isDestroyed()) { + if (win.webContents.debugger.isAttached()) { + win.webContents.debugger.detach() + } + win.close() + } + } catch (error) { + logger.warn('Error closing window', { error, sessionId }) + } + } + + private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionId: string) { + if (!dbg.isAttached()) { + try { + logger.info('Attaching debugger', { sessionId }) + dbg.attach('1.3') + await dbg.sendCommand('Page.enable') + await dbg.sendCommand('Runtime.enable') + logger.info('Debugger attached and domains enabled') + } catch (error) { + logger.error('Failed to attach debugger', { error }) + throw error + } + } + } + + private sweepIdle() { + const now = Date.now() + for (const [id, entry] of this.windows.entries()) { + if (now - entry.lastActive > this.idleTimeoutMs) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + } + } + + private evictIfNeeded(newSessionId: string) { + if (this.windows.size < this.maxSessions) return + let lruId: string | null = null + let lruTime = Number.POSITIVE_INFINITY + for (const [id, entry] of this.windows.entries()) { + if (id === newSessionId) continue + if (entry.lastActive < lruTime) { + lruTime = entry.lastActive + lruId = id + } + } + if (lruId) { + const entry = this.windows.get(lruId) + if (entry) { + this.closeWindow(entry.win, lruId) + } + this.windows.delete(lruId) + logger.info('Evicted session to respect maxSessions', { evicted: lruId }) + } + } + + private async getWindow(sessionId = 'default', forceNew = false, show = false): Promise { + await this.ensureAppReady() + + this.sweepIdle() + + const existing = this.windows.get(sessionId) + if (existing && !existing.win.isDestroyed() && !forceNew) { + this.touch(sessionId) + return existing.win + } + + if (existing && !existing.win.isDestroyed() && forceNew) { + try { + if (existing.win.webContents.debugger.isAttached()) { + existing.win.webContents.debugger.detach() + } + } catch (error) { + logger.warn('Error detaching debugger before recreate', { error, sessionId }) + } + existing.win.destroy() + this.windows.delete(sessionId) + } + + this.evictIfNeeded(sessionId) + + const win = new BrowserWindow({ + show, + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + // Use a standard Chrome UA to avoid some anti-bot blocks + win.webContents.setUserAgent(userAgent) + + // Log navigation lifecycle to help diagnose slow loads + win.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { sessionId })) + win.webContents.on('dom-ready', () => logger.info(`dom-ready`, { sessionId })) + win.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { sessionId })) + win.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc })) + + win.on('closed', () => { + this.windows.delete(sessionId) + }) + + this.windows.set(sessionId, { win, lastActive: Date.now() }) + return win + } + + /** + * Opens a URL in a browser window and waits for navigation to complete. + * @param url - The URL to navigate to + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param show - Whether to show the browser window (default: false) + * @param sessionId - Session identifier for window reuse (default: 'default') + * @returns Object containing the current URL and page title after navigation + */ + public async open(url: string, timeout = 10000, show = false, sessionId = 'default') { + const win = await this.getWindow(sessionId, true, show) + logger.info('Loading URL', { url, sessionId }) + const { webContents } = win + this.touch(sessionId) + + // Track resolution state to prevent multiple handlers from firing + let resolved = false + let onFinish: () => void + let onDomReady: () => void + let onFail: (_event: Electron.Event, code: number, desc: string) => void + + // Define cleanup outside Promise to ensure it's callable in finally block, + // preventing memory leaks when timeout occurs before navigation completes + const cleanup = () => { + webContents.removeListener('did-finish-load', onFinish) + webContents.removeListener('did-fail-load', onFail) + webContents.removeListener('dom-ready', onDomReady) + } + + const loadPromise = new Promise((resolve, reject) => { + onFinish = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onDomReady = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onFail = (_event: Electron.Event, code: number, desc: string) => { + if (resolved) return + resolved = true + cleanup() + reject(new Error(`Navigation failed (${code}): ${desc}`)) + } + webContents.once('did-finish-load', onFinish) + webContents.once('dom-ready', onDomReady) + webContents.once('did-fail-load', onFail) + }) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Navigation timed out')), timeout) + }) + + try { + await Promise.race([win.loadURL(url), loadPromise, timeoutPromise]) + } finally { + // Always cleanup listeners to prevent memory leaks on timeout + cleanup() + } + + const currentUrl = webContents.getURL() + const title = await webContents.getTitle() + return { currentUrl, title } + } + + public async execute(code: string, timeout = 5000, sessionId = 'default') { + const win = await this.getWindow(sessionId) + this.touch(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + const evalPromise = dbg.sendCommand('Runtime.evaluate', { + expression: code, + awaitPromise: true, + returnByValue: true + }) + + const result = await Promise.race([ + evalPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out')), timeout)) + ]) + + const evalResult = result as any + + if (evalResult?.exceptionDetails) { + const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error' + logger.warn('Runtime.evaluate raised exception', { message }) + throw new Error(message) + } + + const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null + return value + } + + public async reset(sessionId?: string) { + if (sessionId) { + const entry = this.windows.get(sessionId) + if (entry) { + this.closeWindow(entry.win, sessionId) + } + this.windows.delete(sessionId) + logger.info('Browser CDP context reset', { sessionId }) + return + } + + for (const [id, entry] of this.windows.entries()) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + logger.info('Browser CDP context reset (all sessions)') + } + + /** + * Fetches a URL and returns content in the specified format. + * @param url - The URL to fetch + * @param format - Output format: 'html', 'txt', 'markdown', or 'json' (default: 'markdown') + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param sessionId - Session identifier (default: 'default') + * @returns Content in the requested format. For 'json', returns parsed object or { data: rawContent } if parsing fails + */ + public async fetch( + url: string, + format: 'html' | 'txt' | 'markdown' | 'json' = 'markdown', + timeout = 10000, + sessionId = 'default' + ) { + await this.open(url, timeout, false, sessionId) + + const win = await this.getWindow(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + let expression: string + if (format === 'json' || format === 'txt') { + expression = 'document.body.innerText' + } else { + expression = 'document.documentElement.outerHTML' + } + + const result = (await dbg.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true + })) as { result?: { value?: string } } + + const content = result?.result?.value ?? '' + + if (format === 'markdown') { + const turndownService = new TurndownService() + return turndownService.turndown(content) + } + if (format === 'json') { + // Attempt to parse as JSON; if content is not valid JSON, wrap it in a data object + try { + return JSON.parse(content) + } catch { + return { data: content } + } + } + return content + } +} diff --git a/src/main/mcpServers/browser/index.ts b/src/main/mcpServers/browser/index.ts new file mode 100644 index 0000000000..fbdb0a0f6e --- /dev/null +++ b/src/main/mcpServers/browser/index.ts @@ -0,0 +1,3 @@ +export { CdpBrowserController } from './controller' +export { BrowserServer } from './server' +export { BrowserServer as default } from './server' diff --git a/src/main/mcpServers/browser/server.ts b/src/main/mcpServers/browser/server.ts new file mode 100644 index 0000000000..3e889a7b66 --- /dev/null +++ b/src/main/mcpServers/browser/server.ts @@ -0,0 +1,50 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { Server as MCServer } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { app } from 'electron' + +import { CdpBrowserController } from './controller' +import { toolDefinitions, toolHandlers } from './tools' + +export class BrowserServer { + public server: Server + private controller = new CdpBrowserController() + + constructor() { + const server = new MCServer( + { + name: '@cherry/browser', + version: '0.1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: toolDefinitions + } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const handler = toolHandlers[name] + if (!handler) { + throw new Error('Tool not found') + } + return handler(this.controller, args) + }) + + app.on('before-quit', () => { + void this.controller.reset() + }) + + this.server = server + } +} + +export default BrowserServer diff --git a/src/main/mcpServers/browser/tools/execute.ts b/src/main/mcpServers/browser/tools/execute.ts new file mode 100644 index 0000000000..1585a467a8 --- /dev/null +++ b/src/main/mcpServers/browser/tools/execute.ts @@ -0,0 +1,48 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const ExecuteSchema = z.object({ + code: z + .string() + .describe( + 'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.' + ), + timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), + sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)') +}) + +export const executeToolDefinition = { + name: 'execute', + description: + 'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.', + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'One-line JS to evaluate in page context' + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default 5000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; targets a specific page (default: default)' + } + }, + required: ['code'] + } +} + +export async function handleExecute(controller: CdpBrowserController, args: unknown) { + const { code, timeout, sessionId } = ExecuteSchema.parse(args) + try { + const value = await controller.execute(code, timeout, sessionId ?? 'default') + return successResponse(typeof value === 'string' ? value : JSON.stringify(value)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/fetch.ts b/src/main/mcpServers/browser/tools/fetch.ts new file mode 100644 index 0000000000..b749aaff93 --- /dev/null +++ b/src/main/mcpServers/browser/tools/fetch.ts @@ -0,0 +1,49 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const FetchSchema = z.object({ + url: z.url().describe('URL to fetch'), + format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + sessionId: z.string().optional().describe('Session identifier (default: default)') +}) + +export const fetchToolDefinition = { + name: 'fetch', + description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to fetch' + }, + format: { + type: 'string', + enum: ['html', 'txt', 'markdown', 'json'], + description: 'Output format (default: markdown)' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default: 10000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleFetch(controller: CdpBrowserController, args: unknown) { + const { url, format, timeout, sessionId } = FetchSchema.parse(args) + try { + const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default') + return successResponse(typeof content === 'string' ? content : JSON.stringify(content)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/index.ts b/src/main/mcpServers/browser/tools/index.ts new file mode 100644 index 0000000000..19f1ee4163 --- /dev/null +++ b/src/main/mcpServers/browser/tools/index.ts @@ -0,0 +1,25 @@ +export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute' +export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch' +export { handleOpen, OpenSchema, openToolDefinition } from './open' +export { handleReset, resetToolDefinition } from './reset' + +import type { CdpBrowserController } from '../controller' +import { executeToolDefinition, handleExecute } from './execute' +import { fetchToolDefinition, handleFetch } from './fetch' +import { handleOpen, openToolDefinition } from './open' +import { handleReset, resetToolDefinition } from './reset' + +export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition] + +export const toolHandlers: Record< + string, + ( + controller: CdpBrowserController, + args: unknown + ) => Promise<{ content: { type: string; text: string }[]; isError: boolean }> +> = { + open: handleOpen, + execute: handleExecute, + reset: handleReset, + fetch: handleFetch +} diff --git a/src/main/mcpServers/browser/tools/open.ts b/src/main/mcpServers/browser/tools/open.ts new file mode 100644 index 0000000000..9739b3bcae --- /dev/null +++ b/src/main/mcpServers/browser/tools/open.ts @@ -0,0 +1,47 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +export const OpenSchema = z.object({ + url: z.url().describe('URL to open in the controlled Electron window'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + show: z.boolean().optional().describe('Whether to show the browser window (default: false)'), + sessionId: z + .string() + .optional() + .describe('Session identifier; separate sessions keep separate pages (default: default)') +}) + +export const openToolDefinition = { + name: 'open', + description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to load' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default 10000)' + }, + show: { + type: 'boolean', + description: 'Whether to show the browser window (default false)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; separate sessions keep separate pages (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleOpen(controller: CdpBrowserController, args: unknown) { + const { url, timeout, show, sessionId } = OpenSchema.parse(args) + const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default') + return successResponse(JSON.stringify(res)) +} diff --git a/src/main/mcpServers/browser/tools/reset.ts b/src/main/mcpServers/browser/tools/reset.ts new file mode 100644 index 0000000000..d09d251119 --- /dev/null +++ b/src/main/mcpServers/browser/tools/reset.ts @@ -0,0 +1,34 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +/** Zod schema for validating reset tool arguments */ +export const ResetSchema = z.object({ + sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions') +}) + +/** MCP tool definition for the reset tool */ +export const resetToolDefinition = { + name: 'reset', + description: 'Reset the controlled window and detach debugger', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Session identifier to reset; omit to reset all sessions' + } + } + } +} + +/** + * Handler for the reset MCP tool. + * Closes browser window(s) and detaches debugger for the specified session or all sessions. + */ +export async function handleReset(controller: CdpBrowserController, args: unknown) { + const { sessionId } = ResetSchema.parse(args) + await controller.reset(sessionId) + return successResponse('reset') +} diff --git a/src/main/mcpServers/browser/tools/utils.ts b/src/main/mcpServers/browser/tools/utils.ts new file mode 100644 index 0000000000..2c5ecc0f1d --- /dev/null +++ b/src/main/mcpServers/browser/tools/utils.ts @@ -0,0 +1,13 @@ +export function successResponse(text: string) { + return { + content: [{ type: 'text', text }], + isError: false + } +} + +export function errorResponse(error: Error) { + return { + content: [{ type: 'text', text: error.message }], + isError: true + } +} diff --git a/src/main/mcpServers/browser/types.ts b/src/main/mcpServers/browser/types.ts new file mode 100644 index 0000000000..2cc934e6ce --- /dev/null +++ b/src/main/mcpServers/browser/types.ts @@ -0,0 +1,4 @@ +import { loggerService } from '@logger' + +export const logger = loggerService.withContext('MCPBrowserCDP') +export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0' diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 2323701e49..ce736f6843 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -4,6 +4,7 @@ import type { BuiltinMCPServerName } from '@types' import { BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' +import BrowserServer from './browser' import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' @@ -48,6 +49,9 @@ export function createInMemoryMCPServer( const apiKey = envs.DIDI_API_KEY return new DiDiMcpServer(apiKey).server } + case BuiltinMCPServerNames.browser: { + return new BrowserServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 1ce3297afb..169ae35930 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -31,6 +31,11 @@ export const WEB_SEARCH_PROVIDER_CONFIG: Record = { [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', - [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' + [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp', + [BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index aa610bf4fb..d2ae573be1 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3910,6 +3910,7 @@ "builtinServers": "Builtin Servers", "builtinServersDescriptions": { "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", + "browser": "Control a headless Electron window via Chrome DevTools Protocol. Tools: open URL, execute single-line JS, reset session.", "didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "fetch": "MCP server for retrieving URL web content", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 78d6cd1304..901e1daa14 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3910,6 +3910,7 @@ "builtinServers": "内置服务器", "builtinServersDescriptions": { "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", + "browser": "通过 Chrome DevTools 协议控制隐藏的 Electron 窗口,支持打开 URL、执行单行 JS、重置会话", "didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "fetch": "用于获取 URL 网页内容的 MCP 服务器", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 19200a4336..a0f9966964 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3910,6 +3910,7 @@ "builtinServers": "內建伺服器", "builtinServersDescriptions": { "brave_search": "一個整合了 Brave 搜尋 API 的 MCP 伺服器實做,提供網頁與本機搜尋雙重功能。需要設定 BRAVE_API_KEY 環境變數", + "browser": "透過 Chrome DevTools Protocol 控制 headless Electron 視窗。工具:開啟 URL、執行單行 JS、重設工作階段。", "didi_mcp": "一個整合了滴滴 MCP 伺服器實做,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要設定 DIDI_API_KEY 環境變數", "dify_knowledge": "Dify 的 MCP 伺服器實做,提供了一個簡單的 API 來與 Dify 進行互動。需要設定 Dify Key", "fetch": "用於取得 URL 網頁內容的 MCP 伺服器", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index cbb5bc637a..4bc992759c 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3884,6 +3884,7 @@ "builtinServers": "Integrierter Server", "builtinServersDescriptions": { "brave_search": "MCP-Server-Implementierung mit Brave-Search-API, die sowohl Web- als auch lokale Suchfunktionen bietet. BRAVE_API_KEY-Umgebungsvariable muss konfiguriert werden", + "browser": "Steuert ein headless Electron-Fenster über das Chrome DevTools Protocol. Tools: URL öffnen, einzeiligen JS ausführen, Sitzung zurücksetzen.", "didi_mcp": "An integrated Didi MCP server implementation that provides ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in mainland China. Requires the DIDI_API_KEY environment variable to be configured.", "dify_knowledge": "MCP-Server-Implementierung von Dify, die einen einfachen API-Zugriff auf Dify bietet. Dify Key muss konfiguriert werden", "fetch": "MCP-Server zum Abrufen von Webseiteninhalten", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index e26abd58fd..44fba429f7 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "Χρησιμοποιείται αυτόματα εντοπισμένο Git Bash", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "Διαγραφή προσαρμοσμένης διαδρομής" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "Χρησιμοποιείται προσαρμοσμένη διαδρομή: {{path}}", "error": { "description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από", "recheck": "Επανέλεγχος Εγκατάστασης του Git Bash", "title": "Απαιτείται Git Bash" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Το Git Bash διαμορφώθηκε" }, "notFound": "Το Git Bash δεν βρέθηκε. Παρακαλώ εγκαταστήστε το πρώτα.", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Επιλογή διαδρομής Git Bash", + "failed": "Αποτυχία ορισμού διαδρομής Git Bash", + "invalidPath": "Το επιλεγμένο αρχείο δεν είναι έγκυρο εκτελέσιμο Git Bash (bash.exe).", + "title": "Επιλογή εκτελέσιμου Git Bash" }, "success": "Το Git Bash εντοπίστηκε με επιτυχία!" }, @@ -547,7 +547,7 @@ "medium": "Μεσαίο", "minimal": "ελάχιστος", "off": "Απενεργοποίηση", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Εξαιρετικά Υψηλή" }, "regular_phrases": { "add": "Προσθήκη φράσης", @@ -3884,6 +3884,7 @@ "builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServersDescriptions": { "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", + "browser": "Ελέγχει ένα headless παράθυρο Electron μέσω του Chrome DevTools Protocol. Εργαλεία: άνοιγμα URL, εκτέλεση JS μίας γραμμής, επαναφορά συνεδρίας.", "didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 4316c80616..5cf620ed45 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -547,7 +547,7 @@ "medium": "Medio", "minimal": "minimal", "off": "Apagado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Agregar frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", + "browser": "Controla una ventana Electron headless mediante Chrome DevTools Protocol. Herramientas: abrir URL, ejecutar JS de una línea, reiniciar sesión.", "didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 66b9fef86d..fdb72727b8 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -547,7 +547,7 @@ "medium": "Moyen", "minimal": "minimal", "off": "Off", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Très élevée" }, "regular_phrases": { "add": "Добавить фразу", @@ -3884,6 +3884,7 @@ "builtinServers": "Serveurs intégrés", "builtinServersDescriptions": { "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", + "browser": "Contrôle une fenêtre Electron headless via Chrome DevTools Protocol. Outils : ouvrir une URL, exécuter du JS en une ligne, réinitialiser la session.", "didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 493d693580..d004d539e5 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "自動検出されたGit Bashを使用中", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "カスタムパスをクリア" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "カスタムパスを使用中: {{path}}", "error": { "description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。", "recheck": "Git Bashのインストールを再確認してください", "title": "Git Bashが必要です" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Git Bashが設定されました" }, "notFound": "Git Bash が見つかりません。先にインストールしてください。", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Git Bashパスを選択", + "failed": "Git Bashパスの設定に失敗しました", + "invalidPath": "選択されたファイルは有効なGit Bash実行ファイル(bash.exe)ではありません。", + "title": "Git Bash実行ファイルを選択" }, "success": "Git Bashが正常に検出されました!" }, @@ -547,7 +547,7 @@ "medium": "普通の思考", "minimal": "最小限の思考", "off": "オフ", - "xhigh": "[to be translated]:Extra High" + "xhigh": "超高" }, "regular_phrases": { "add": "プロンプトを追加", @@ -3884,6 +3884,7 @@ "builtinServers": "組み込みサーバー", "builtinServersDescriptions": { "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", + "browser": "Chrome DevTools Protocolを介してheadless Electronウィンドウを制御します。ツール:URLを開く、単一行JSを実行、セッションをリセット。", "didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index fba1a8e706..32c1965f54 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -547,7 +547,7 @@ "medium": "Médio", "minimal": "mínimo", "off": "Desligado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Adicionar Frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", + "browser": "Controla uma janela Electron headless via Chrome DevTools Protocol. Ferramentas: abrir URL, executar JS de linha única, reiniciar sessão.", "didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "fetch": "servidor MCP para obter o conteúdo da página web do URL", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 2972336406..3d022ae24b 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -547,7 +547,7 @@ "medium": "Среднее", "minimal": "минимальный", "off": "Выключить", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Сверхвысокое" }, "regular_phrases": { "add": "Добавить подсказку", @@ -3884,6 +3884,7 @@ "builtinServers": "Встроенные серверы", "builtinServersDescriptions": { "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", + "browser": "Управление headless-окном Electron через Chrome DevTools Protocol. Инструменты: открытие URL, выполнение однострочного JS, сброс сессии.", "didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL", diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index 438fc053d1..a92b8646c1 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -145,6 +145,7 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { case 'searxng': return SearxngLogo case 'exa': + case 'exa-mcp': return ExaLogo case 'bocha': return BochaLogo diff --git a/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts new file mode 100644 index 0000000000..8e04ba0a68 --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts @@ -0,0 +1,209 @@ +import { loggerService } from '@logger' +import type { WebSearchState } from '@renderer/store/websearch' +import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' + +import BaseWebSearchProvider from './BaseWebSearchProvider' + +const logger = loggerService.withContext('ExaMcpProvider') + +interface McpSearchRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + numResults?: number + livecrawl?: 'fallback' | 'preferred' + type?: 'auto' | 'fast' | 'deep' + } + } +} + +interface McpSearchResponse { + jsonrpc: string + result: { + content: Array<{ type: string; text: string }> + } +} + +interface ExaSearchResult { + title?: string + url?: string + text?: string + publishedDate?: string + author?: string +} + +interface ExaSearchResults { + results?: ExaSearchResult[] + autopromptString?: string +} + +const DEFAULT_API_HOST = 'https://mcp.exa.ai/mcp' +const DEFAULT_NUM_RESULTS = 8 +const REQUEST_TIMEOUT_MS = 25000 + +export default class ExaMcpProvider extends BaseWebSearchProvider { + constructor(provider: WebSearchProvider) { + super(provider) + if (!this.apiHost) { + this.apiHost = DEFAULT_API_HOST + } + } + + public async search( + query: string, + websearch: WebSearchState, + httpOptions?: RequestInit + ): Promise { + try { + if (!query.trim()) { + throw new Error('Search query cannot be empty') + } + + const searchRequest: McpSearchRequest = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'web_search_exa', + arguments: { + query, + type: 'auto', + numResults: websearch.maxResults || DEFAULT_NUM_RESULTS, + livecrawl: 'fallback' + } + } + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + try { + const response = await fetch(this.apiHost!, { + method: 'POST', + headers: { + ...this.defaultHeaders(), + accept: 'application/json, text/event-stream', + 'content-type': 'application/json' + }, + body: JSON.stringify(searchRequest), + signal: httpOptions?.signal ? AbortSignal.any([controller.signal, httpOptions.signal]) : controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + const searchResults = this.parseResponse(responseText) + + return { + query: searchResults.autopromptString || query, + results: (searchResults.results || []).slice(0, websearch.maxResults).map((result) => ({ + title: result.title || 'No title', + content: result.text || '', + url: result.url || '' + })) + } + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Search request timed out') + } + + throw error + } + } catch (error) { + logger.error('Exa MCP search failed:', error as Error) + throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + private parsetextChunk(raw: string): ExaSearchResult[] { + const items: ExaSearchResult[] = [] + for (const chunk of raw.split('\n\n')) { + // logger.debug('Parsing chunk:', {"chunks": chunk}) + // 3. Parse the labeled lines inside the text block + const lines = chunk.split('\n') + // logger.debug('Lines:', lines); + let title = '' + let publishedDate = '' + let url = '' + let fullText = '' + + // We’ll capture everything after the first "Text:" as the article text + let textStartIndex = -1 + + lines.forEach((line, idx) => { + if (line.startsWith('Title:')) { + title = line.replace(/^Title:\s*/, '') + } else if (line.startsWith('Published Date:')) { + publishedDate = line.replace(/^Published Date:\s*/, '') + } else if (line.startsWith('URL:')) { + url = line.replace(/^URL:\s*/, '') + } else if (line.startsWith('Text:') && textStartIndex === -1) { + // mark where "Text:" starts + textStartIndex = idx + // text on the same line after "Text: " + fullText = line.replace(/^Text:\s*/, '') + } + }) + if (textStartIndex !== -1) { + const rest = lines.slice(textStartIndex + 1).join('\n') + if (rest.trim().length > 0) { + fullText = (fullText ? fullText + '\n' : '') + rest + } + } + + // If we at least got a title or URL, treat it as a valid article + if (title || url || fullText) { + items.push({ + title, + publishedDate, + url, + text: fullText + }) + } + } + return items + } + + private parseResponse(responseText: string): ExaSearchResults { + // Parse SSE response format + const lines = responseText.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data: McpSearchResponse = JSON.parse(line.substring(6)) + if (data.result?.content?.[0]?.text) { + // The text content contains stringified JSON with the actual results + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Continue to next line if parsing fails + logger.warn('Failed to parse SSE line:', { line }) + } + } + } + + // Try parsing as direct JSON response (non-SSE) + try { + const data: McpSearchResponse = JSON.parse(responseText) + if (data.result?.content?.[0]?.text) { + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Ignore parsing errors + logger.warn('Failed to parse direct JSON response:', { responseText }) + } + + return { results: [] } + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts index 0b961c23d8..4adface5af 100644 --- a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts +++ b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts @@ -3,6 +3,7 @@ import type { WebSearchProvider } from '@renderer/types' import type BaseWebSearchProvider from './BaseWebSearchProvider' import BochaProvider from './BochaProvider' import DefaultProvider from './DefaultProvider' +import ExaMcpProvider from './ExaMcpProvider' import ExaProvider from './ExaProvider' import LocalBaiduProvider from './LocalBaiduProvider' import LocalBingProvider from './LocalBingProvider' @@ -24,6 +25,8 @@ export default class WebSearchProviderFactory { return new SearxngProvider(provider) case 'exa': return new ExaProvider(provider) + case 'exa-mcp': + return new ExaMcpProvider(provider) case 'local-google': return new LocalGoogleProvider(provider) case 'local-baidu': diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 0a6ba1cc89..6dfb4623b9 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -89,7 +89,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 183, + version: 184, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], transforms: [securePersistTransform], migrate diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index aef38bcbe8..ed7076bc1c 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -174,6 +174,15 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ provider: 'CherryAI', installSource: 'builtin', isTrusted: true + }, + { + id: nanoid(), + name: BuiltinMCPServerNames.browser, + type: 'inMemory', + isActive: false, + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true } ] as const diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8559a39e27..0a1f8ea70d 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2992,6 +2992,31 @@ const migrateConfig = { logger.error('migrate 183 error', error as Error) return state } + }, + '184': (state: RootState) => { + try { + // Add exa-mcp (free) web search provider if not exists + const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp') + if (!exaMcpExists) { + // Find the index of 'exa' provider to insert after it + const exaIndex = state.websearch.providers.findIndex((p) => p.id === 'exa') + const newProvider = { + id: 'exa-mcp' as const, + name: 'ExaMCP', + apiHost: 'https://mcp.exa.ai/mcp' + } + if (exaIndex !== -1) { + state.websearch.providers.splice(exaIndex + 1, 0, newProvider) + } else { + state.websearch.providers.push(newProvider) + } + } + logger.info('migrate 184 success') + return state + } catch (error) { + logger.error('migrate 184 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b6f42059b4..311dbf58be 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -603,6 +603,7 @@ export const WebSearchProviderIds = { tavily: 'tavily', searxng: 'searxng', exa: 'exa', + 'exa-mcp': 'exa-mcp', bocha: 'bocha', 'local-google': 'local-google', 'local-bing': 'local-bing', @@ -751,7 +752,8 @@ export const BuiltinMCPServerNames = { filesystem: '@cherry/filesystem', difyKnowledge: '@cherry/dify-knowledge', python: '@cherry/python', - didiMCP: '@cherry/didi-mcp' + didiMCP: '@cherry/didi-mcp', + browser: '@cherry/browser' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames]