From c0beab0f8a3160f03956b9f5af9442065ddfa9a9 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Wed, 26 Nov 2025 21:09:27 +0800 Subject: [PATCH 01/17] chore: update release notes for v1.7.0-rc.3 - Updated version to 1.7.0-rc.3 in package.json - Added new features including support for Silicon provider and AIHubMix - Consolidated bug fixes related to providers, models, UI, and settings - Improved SDK integration with upgraded dependencies --- electron-builder.yml | 90 ++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index b76c5c9049..823c147a05 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,66 +134,56 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-rc.2 + What's New in v1.7.0-rc.3 ✨ New Features: - - AI Models: Added support for Gemini 3, Gemini 3 Pro with image preview, and GPT-5.1 - - Import: ChatGPT conversation import feature - - Agent: Git Bash detection and requirement check for Windows agents - - Search: Native language emoji search with CLDR data format - - Provider: Endpoint type support for cherryin provider - - Debug: Local crash mini dump file for better diagnostics + - Provider: Added Silicon provider support for Anthropic API compatibility + - Provider: AIHubMix support for nano banana - 🐛 Important Bug Fixes: - - Error Handling: Improved error display in AiSdkToChunkAdapter - - Database: Optimized DatabaseManager and fixed libsql crash issues - - Memory: Fixed EventEmitter memory leak in useApiServer hook - - Messages: Fixed adjacent user messages appearing when assistant message contains error only - - Tools: Fixed missing execution state for approved tool permissions - - File Processing: Fixed "no such file" error for non-English filenames in open-mineru - - PDF: Fixed mineru PDF validation and 403 errors - - Images: Fixed base64 image save issues - - Search: Fixed URL context and web search capability - - Models: Added verbosity parameter support for GPT-5 models - - UI: Improved todo tool status icon visibility and colors - - Providers: Fixed api-host for vercel ai-gateway and gitcode update config + 🐛 Bug Fixes: + - i18n: Clean up translation tags and untranslated strings + - Provider: Fixed Silicon provider code list + - Provider: Fixed Poe API reasoning parameters for GPT-5 and reasoning models + - Provider: Fixed duplicate /v1 in Anthropic API endpoints + - Provider: Fixed Azure provider handling in AI SDK integration + - Models: Added Claude Opus 4.5 pattern to THINKING_TOKEN_MAP + - Models: Improved Gemini reasoning and message handling + - Models: Fixed custom parameters for Gemini models + - Models: Fixed qwen-mt-flash text delta support + - Models: Fixed Groq verbosity setting + - UI: Fixed quota display and quota tips + - UI: Fixed web search button condition + - Settings: Fixed updateAssistantPreset reducer to properly update preset + - Settings: Respect enableMaxTokens setting when maxTokens is not configured + - SDK: Fixed header merging logic in AI SDK ⚡ Improvements: - - SDK: Updated Google and OpenAI SDKs with new features - - UI: Simplified knowledge base creation modal and agent creation form - - Tools: Replaced renderToolContent function with ToolContent component - - Architecture: Namespace tool call IDs with session ID to prevent conflicts - - Config: AI SDK configuration refactoring + - SDK: Upgraded @anthropic-ai/claude-agent-sdk to 0.1.53 - v1.7.0-rc.2 新特性 + v1.7.0-rc.3 更新内容 ✨ 新功能: - - AI 模型:新增 Gemini 3、Gemini 3 Pro 图像预览支持,以及 GPT-5.1 - - 导入:ChatGPT 对话导入功能 - - Agent:Windows Agent 的 Git Bash 检测和要求检查 - - 搜索:支持本地语言 emoji 搜索(CLDR 数据格式) - - 提供商:cherryin provider 的端点类型支持 - - 调试:启用本地崩溃 mini dump 文件,方便诊断 + - 提供商:新增 Silicon 提供商对 Anthropic API 的兼容性支持 + - 提供商:AIHubMix 支持 nano banana - 🐛 重要修复: - - 错误处理:改进 AiSdkToChunkAdapter 的错误显示 - - 数据库:优化 DatabaseManager 并修复 libsql 崩溃问题 - - 内存:修复 useApiServer hook 中的 EventEmitter 内存泄漏 - - 消息:修复当助手消息仅包含错误时相邻用户消息出现的问题 - - 工具:修复批准工具权限缺少执行状态的问题 - - 文件处理:修复 open-mineru 处理非英文文件名时的"无此文件"错误 - - PDF:修复 mineru PDF 验证和 403 错误 - - 图片:修复 base64 图片保存问题 - - 搜索:修复 URL 上下文和网络搜索功能 - - 模型:为 GPT-5 模型添加 verbosity 参数支持 - - UI:改进 todo 工具状态图标可见性和颜色 - - 提供商:修复 vercel ai-gateway 和 gitcode 更新配置的 api-host + 🐛 问题修复: + - 国际化:清理翻译标签和未翻译字符串 + - 提供商:修复 Silicon 提供商代码列表 + - 提供商:修复 Poe API 对 GPT-5 和推理模型的推理参数 + - 提供商:修复 Anthropic API 端点重复 /v1 问题 + - 提供商:修复 Azure 提供商在 AI SDK 集成中的处理 + - 模型:Claude Opus 4.5 添加到 THINKING_TOKEN_MAP + - 模型:改进 Gemini 推理和消息处理 + - 模型:修复 Gemini 模型自定义参数 + - 模型:修复 qwen-mt-flash text delta 支持 + - 模型:修复 Groq verbosity 设置 + - 界面:修复配额显示和配额提示 + - 界面:修复 Web 搜索按钮条件 + - 设置:修复 updateAssistantPreset reducer 正确更新 preset + - 设置:尊重 enableMaxTokens 设置 + - SDK:修复 AI SDK 中 header 合并逻辑 ⚡ 改进: - - SDK:更新 Google 和 OpenAI SDK,新增功能和修复 - - UI:简化知识库创建模态框和 agent 创建表单 - - 工具:用 ToolContent 组件替换 renderToolContent 函数,提升可读性 - - 架构:用会话 ID 命名工具调用 ID 以防止冲突 - - 配置:AI SDK 配置重构 + - SDK:升级 @anthropic-ai/claude-agent-sdk 到 0.1.53 diff --git a/package.json b/package.json index de10bb4f0d..5550405df3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-rc.2", + "version": "1.7.0-rc.3", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From eb4670c22c0e3c8468ea1cb2ab8d2f27e524419d Mon Sep 17 00:00:00 2001 From: Apine <53335668+JavanShen@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:17:25 +0800 Subject: [PATCH 02/17] docs: correct the links on the readme (#11477) --- README.md | 2 +- docs/zh/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 09a4e14bbd..f790c10cbd 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ -

English | 中文 | Official Site | Documents | Development | Feedback

+

English | 中文 | Official Site | Documents | Development | Feedback

diff --git a/docs/zh/README.md b/docs/zh/README.md index 1398495177..f8a1f1ab8c 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -34,7 +34,7 @@

- English | 中文 | 官方网站 | 文档 | 开发 | 反馈
+ English | 中文 | 官方网站 | 文档 | 开发 | 反馈

From 8f00321a60138af43637171941afebc3e6f9e469 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 27 Nov 2025 10:31:34 +0800 Subject: [PATCH 03/17] fix: inconsistent text color in release notes last line (#11480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move color and font-size styles from p selector to container level in UpdateNotesWrapper. This ensures all content (including li elements not wrapped in p tags) uses consistent color. The issue occurred because .replace(/\n/g, '\n\n') creates a "loose list" in Markdown where most list items get wrapped in

tags, but the last item (without trailing newline) may not, causing it to inherit a different color from the parent .markdown class. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/renderer/src/pages/settings/AboutSettings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 5feb2fa5bb..3bf02a518d 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -404,11 +404,11 @@ const UpdateNotesWrapper = styled.div` margin: 8px 0; background-color: var(--color-bg-2); border-radius: 6px; + color: var(--color-text-2); + font-size: 14px; p { margin: 0; - color: var(--color-text-2); - font-size: 14px; } ` From a2f67dddb6ed64646101fd7e10abbfdb9edb8ce0 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Thu, 27 Nov 2025 13:41:33 +0800 Subject: [PATCH 04/17] fix: resolve readonly property error in assistant preset settings (#11491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When updating assistant preset settings, if agent.settings was undefined, it was assigned the DEFAULT_ASSISTANT_SETTINGS object directly. Since this object is defined with `as const`, it is readonly and subsequent property assignments would fail with "Cannot assign to read only property". Fixed by creating a shallow copy of DEFAULT_ASSISTANT_SETTINGS instead of referencing it directly. Closes #11490 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/renderer/src/store/assistants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 4db73a9547..51638be9f6 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -216,7 +216,7 @@ const assistantsSlice = createSlice({ if (agent.id === action.payload.assistantId) { for (const key in settings) { if (!agent.settings) { - agent.settings = DEFAULT_ASSISTANT_SETTINGS + agent.settings = { ...DEFAULT_ASSISTANT_SETTINGS } } agent.settings[key] = settings[key] } From d15571c727deac1bea7771a6806935913ee0ca93 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:05:14 +0800 Subject: [PATCH 05/17] fix(code-tools): support Chinese paths and validate directory existence (#11489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `chcp 65001` to Windows batch file to switch CMD.exe to UTF-8 code page, fixing CLI tool launch failure when working directory contains Chinese or other non-ASCII characters - Add directory existence validation before launching terminal to provide immediate error feedback instead of delayed failure Closes #11483 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/main/services/CodeToolsService.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/services/CodeToolsService.ts b/src/main/services/CodeToolsService.ts index 82c9c64f87..35655a88e7 100644 --- a/src/main/services/CodeToolsService.ts +++ b/src/main/services/CodeToolsService.ts @@ -548,6 +548,17 @@ class CodeToolsService { logger.debug(`Environment variables:`, Object.keys(env)) logger.debug(`Options:`, options) + // Validate directory exists before proceeding + if (!directory || !fs.existsSync(directory)) { + const errorMessage = `Directory does not exist: ${directory}` + logger.error(errorMessage) + return { + success: false, + message: errorMessage, + command: '' + } + } + const packageName = await this.getPackageName(cliTool) const bunPath = await this.getBunPath() const executableName = await this.getCliExecutableName(cliTool) @@ -709,6 +720,7 @@ class CodeToolsService { // Build bat file content, including debug information const batContent = [ '@echo off', + 'chcp 65001 >nul 2>&1', // Switch to UTF-8 code page for international path support `title ${cliTool} - Cherry Studio`, // Set window title in bat file 'echo ================================================', 'echo Cherry Studio CLI Tool Launcher', From d8191bd4fb9a0ff4ea951aa857a6f6c0b196b35f Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 27 Nov 2025 17:22:33 +0800 Subject: [PATCH 06/17] refactor: improve verbosity configuration with type-safe validators (#11463) * refactor(models): improve verbosity level handling for GPT-5 models Replace hardcoded verbosity configuration with validator functions Add support for GPT-5.1 series models * test(models): restructure model utility tests into logical groups Improve test organization by grouping related test cases under descriptive describe blocks for better maintainability and readability. Each model utility function now has its own dedicated test section with clear subcategories for different behaviors. * fix: add null check for model in getModelSupportedVerbosity Handle null model case defensively by returning default verbosity * refactor(config): remove redundant as const from MODEL_SUPPORTED_VERBOSITY array * refactor(models): simplify validator function in MODEL_SUPPORTED_VERBOSITY * test(model utils): add tests for undefined/null input handling * fix(models): handle undefined/null input in getModelSupportedVerbosity Remove ts-expect-error comments and update type signature to explicitly handle undefined/null inputs. Also add support for GPT-5.1 series models. * test(models): add test case for gpt-5-pro variant model --- .../src/config/models/__tests__/utils.test.ts | 508 ++++++++++++------ src/renderer/src/config/models/utils.ts | 58 +- 2 files changed, 387 insertions(+), 179 deletions(-) diff --git a/src/renderer/src/config/models/__tests__/utils.test.ts b/src/renderer/src/config/models/__tests__/utils.test.ts index f3f4d402af..a163061ea1 100644 --- a/src/renderer/src/config/models/__tests__/utils.test.ts +++ b/src/renderer/src/config/models/__tests__/utils.test.ts @@ -125,195 +125,371 @@ describe('model utils', () => { openAIWebSearchOnlyMock.mockReturnValue(false) }) - it('detects OpenAI LLM models through reasoning and GPT prefix', () => { - expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false) - expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false) + describe('OpenAI model detection', () => { + describe('isOpenAILLMModel', () => { + it('returns false for undefined model', () => { + expect(isOpenAILLMModel(undefined as unknown as Model)).toBe(false) + }) - reasoningMock.mockReturnValueOnce(true) - expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true) + it('returns false for image generation models', () => { + expect(isOpenAILLMModel(createModel({ id: 'gpt-4o-image' }))).toBe(false) + }) - expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true) - }) + it('returns true for reasoning models', () => { + reasoningMock.mockReturnValueOnce(true) + expect(isOpenAILLMModel(createModel({ id: 'o1-preview' }))).toBe(true) + }) - it('detects OpenAI models via GPT prefix or reasoning support', () => { - expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true) - reasoningMock.mockReturnValueOnce(true) - expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true) - }) - - it('evaluates support for flex service tier and alias helper', () => { - expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true) - expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false) - expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true) - expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) - expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false) - }) - - it('detects verbosity support for GPT-5+ families', () => { - expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true) - expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) - expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) - }) - - it('limits verbosity controls for GPT-5 Pro models', () => { - const proModel = createModel({ id: 'gpt-5-pro' }) - const previewModel = createModel({ id: 'gpt-5-preview' }) - expect(getModelSupportedVerbosity(proModel)).toEqual([undefined, 'high']) - expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high']) - expect(isGPT5ProModel(proModel)).toBe(true) - expect(isGPT5ProModel(previewModel)).toBe(false) - }) - - it('identifies OpenAI chat-completion-only models', () => { - expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) - expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true) - expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false) - }) - - it('filters unsupported OpenAI catalog entries', () => { - expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true) - expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false) - }) - - it('calculates temperature/top-p support correctly', () => { - const model = createModel({ id: 'o1' }) - reasoningMock.mockReturnValue(true) - expect(isNotSupportTemperatureAndTopP(model)).toBe(true) - - const openWeight = createModel({ id: 'gpt-oss-debug' }) - expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false) - - const chatOnly = createModel({ id: 'o1-preview' }) - reasoningMock.mockReturnValue(false) - expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true) - - const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' }) - expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true) - }) - - it('handles gemma and gemini detections plus zhipu tagging', () => { - expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true) - expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true) - expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false) - - expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true) - - expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true) - expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false) - }) - - it('groups qwen models by prefix', () => { - const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' }) - const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' }) - const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' }) - - const grouped = groupQwenModels([qwen, qwenOmni, other]) - expect(Object.keys(grouped)).toContain('qwen-7b') - expect(Object.keys(grouped)).toContain('qwen2.5') - expect(grouped.DeepSeek).toContain(other) - }) - - it('aggregates boolean helpers based on regex rules', () => { - expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true) - expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true) - expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true) - expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true) - }) - - describe('isNotSupportedTextDelta', () => { - it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => { - // qwen-mt series that don't support text delta - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true) - expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true) - expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true) + it('returns true for GPT-prefixed models', () => { + expect(isOpenAILLMModel(createModel({ id: 'GPT-5-turbo' }))).toBe(true) + }) }) - it('returns false for qwen-mt-flash and other models', () => { - // qwen-mt-flash supports text delta - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false) + describe('isOpenAIModel', () => { + it('detects models via GPT prefix', () => { + expect(isOpenAIModel(createModel({ id: 'gpt-4.1' }))).toBe(true) + }) - // Legacy qwen models without mt prefix (support text delta) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false) - - // Other qwen models - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false) - - // Non-qwen models - expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false) + it('detects models via reasoning support', () => { + reasoningMock.mockReturnValueOnce(true) + expect(isOpenAIModel(createModel({ id: 'o3' }))).toBe(true) + }) }) - it('handles models with version suffixes', () => { - // qwen-mt models with version suffixes - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true) + describe('isOpenAIChatCompletionOnlyModel', () => { + it('identifies chat-completion-only models', () => { + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o-search-preview' }))).toBe(true) + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'o1-mini' }))).toBe(true) + }) - // Legacy qwen models with version suffixes (support text delta) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false) - expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false) + it('returns false for general models', () => { + expect(isOpenAIChatCompletionOnlyModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) }) }) - it('evaluates GPT-5 family helpers', () => { - expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) - expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false) - expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true) - expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-prompt' }))).toBe(true) - expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + describe('GPT-5 family detection', () => { + describe('isGPT5SeriesModel', () => { + it('returns true for GPT-5 models', () => { + expect(isGPT5SeriesModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) + }) + + it('returns false for GPT-5.1 models', () => { + expect(isGPT5SeriesModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(false) + }) + }) + + describe('isGPT51SeriesModel', () => { + it('returns true for GPT-5.1 models', () => { + expect(isGPT51SeriesModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true) + }) + }) + + describe('isGPT5SeriesReasoningModel', () => { + it('returns true for GPT-5 reasoning models', () => { + expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5' }))).toBe(true) + }) + it('returns false for gpt-5-chat', () => { + expect(isGPT5SeriesReasoningModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + }) + }) + + describe('isGPT5ProModel', () => { + it('returns true for GPT-5 Pro models', () => { + expect(isGPT5ProModel(createModel({ id: 'gpt-5-pro' }))).toBe(true) + }) + + it('returns false for non-Pro GPT-5 models', () => { + expect(isGPT5ProModel(createModel({ id: 'gpt-5-preview' }))).toBe(false) + }) + }) }) - it('wraps generate/vision helpers that operate on arrays', () => { - const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] - expect(isVisionModels(models)).toBe(true) - visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false) - expect(isVisionModels(models)).toBe(false) + describe('Verbosity support', () => { + describe('isSupportVerbosityModel', () => { + it('returns true for GPT-5 models', () => { + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5' }))).toBe(true) + }) - expect(isGenerateImageModels(models)).toBe(true) - generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false) - expect(isGenerateImageModels(models)).toBe(false) + it('returns false for GPT-5 chat models', () => { + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5-chat' }))).toBe(false) + }) + + it('returns true for GPT-5.1 models', () => { + expect(isSupportVerbosityModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + }) + }) + + describe('getModelSupportedVerbosity', () => { + it('returns only "high" for GPT-5 Pro models', () => { + expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro' }))).toEqual([undefined, 'high']) + expect(getModelSupportedVerbosity(createModel({ id: 'gpt-5-pro-2025-10-06' }))).toEqual([undefined, 'high']) + }) + + it('returns all levels for non-Pro GPT-5 models', () => { + const previewModel = createModel({ id: 'gpt-5-preview' }) + expect(getModelSupportedVerbosity(previewModel)).toEqual([undefined, 'low', 'medium', 'high']) + }) + + it('returns all levels for GPT-5.1 models', () => { + const gpt51Model = createModel({ id: 'gpt-5.1-preview' }) + expect(getModelSupportedVerbosity(gpt51Model)).toEqual([undefined, 'low', 'medium', 'high']) + }) + + it('returns only undefined for non-GPT-5 models', () => { + expect(getModelSupportedVerbosity(createModel({ id: 'gpt-4o' }))).toEqual([undefined]) + expect(getModelSupportedVerbosity(createModel({ id: 'claude-3.5' }))).toEqual([undefined]) + }) + + it('returns only undefined for undefiend/null input', () => { + expect(getModelSupportedVerbosity(undefined)).toEqual([undefined]) + expect(getModelSupportedVerbosity(null)).toEqual([undefined]) + }) + }) }) - it('filters models for agent usage', () => { - expect(agentModelFilter(createModel())).toBe(true) + describe('Flex service tier support', () => { + describe('isSupportFlexServiceTierModel', () => { + it('returns true for supported models', () => { + expect(isSupportFlexServiceTierModel(createModel({ id: 'o3' }))).toBe(true) + expect(isSupportFlexServiceTierModel(createModel({ id: 'o4-mini' }))).toBe(true) + expect(isSupportFlexServiceTierModel(createModel({ id: 'gpt-5-preview' }))).toBe(true) + }) - embeddingMock.mockReturnValueOnce(true) - expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false) + it('returns false for unsupported models', () => { + expect(isSupportFlexServiceTierModel(createModel({ id: 'o3-mini' }))).toBe(false) + }) + }) - embeddingMock.mockReturnValue(false) - rerankMock.mockReturnValueOnce(true) - expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false) - - rerankMock.mockReturnValue(false) - textToImageMock.mockReturnValueOnce(true) - expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false) + describe('isSupportedFlexServiceTier', () => { + it('returns false for non-flex models', () => { + expect(isSupportedFlexServiceTier(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + }) }) - it('identifies models with maximum temperature of 1.0', () => { - // Zhipu models should have max temperature of 1.0 - expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true) + describe('Temperature and top-p support', () => { + describe('isNotSupportTemperatureAndTopP', () => { + it('returns true for reasoning models', () => { + const model = createModel({ id: 'o1' }) + reasoningMock.mockReturnValue(true) + expect(isNotSupportTemperatureAndTopP(model)).toBe(true) + }) - // Anthropic models should have max temperature of 1.0 - expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true) + it('returns false for open weight models', () => { + const openWeight = createModel({ id: 'gpt-oss-debug' }) + expect(isNotSupportTemperatureAndTopP(openWeight)).toBe(false) + }) - // Moonshot models should have max temperature of 1.0 - expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true) - expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true) + it('returns true for chat-only models without reasoning', () => { + const chatOnly = createModel({ id: 'o1-preview' }) + reasoningMock.mockReturnValue(false) + expect(isNotSupportTemperatureAndTopP(chatOnly)).toBe(true) + }) - // Other models should return false - expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false) - expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false) - expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false) - expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false) + it('returns true for Qwen MT models', () => { + const qwenMt = createModel({ id: 'qwen-mt-large', provider: 'aliyun' }) + expect(isNotSupportTemperatureAndTopP(qwenMt)).toBe(true) + }) + }) + }) + + describe('Text delta support', () => { + describe('isNotSupportTextDeltaModel', () => { + it('returns true for qwen-mt-turbo and qwen-mt-plus models', () => { + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo' }))).toBe(true) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true) + expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Turbo' }))).toBe(true) + expect(isNotSupportTextDeltaModel(createModel({ id: 'QWEN-MT-PLUS' }))).toBe(true) + }) + + it('returns false for qwen-mt-flash and other models', () => { + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-flash' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'Qwen-MT-Flash' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-max' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen2.5-72b' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-vl-plus' }))).toBe(false) + }) + + it('returns false for non-qwen models', () => { + expect(isNotSupportTextDeltaModel(createModel({ id: 'gpt-4o' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'claude-3.5' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'glm-4-plus' }))).toBe(false) + }) + + it('handles models with version suffixes', () => { + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-turbo-1201' }))).toBe(true) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-mt-plus-0828' }))).toBe(true) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-turbo-0828' }))).toBe(false) + expect(isNotSupportTextDeltaModel(createModel({ id: 'qwen-plus-latest' }))).toBe(false) + }) + }) + }) + + describe('Model provider detection', () => { + describe('isGemmaModel', () => { + it('detects Gemma models by ID', () => { + expect(isGemmaModel(createModel({ id: 'Gemma-3-27B' }))).toBe(true) + }) + + it('detects Gemma models by group', () => { + expect(isGemmaModel(createModel({ group: 'Gemma' }))).toBe(true) + }) + + it('returns false for non-Gemma models', () => { + expect(isGemmaModel(createModel({ id: 'gpt-4o' }))).toBe(false) + }) + }) + + describe('isGeminiModel', () => { + it('detects Gemini models', () => { + expect(isGeminiModel(createModel({ id: 'Gemini-2.0' }))).toBe(true) + }) + }) + + describe('isZhipuModel', () => { + it('detects Zhipu models by provider', () => { + expect(isZhipuModel(createModel({ provider: 'zhipu' }))).toBe(true) + }) + + it('returns false for non-Zhipu models', () => { + expect(isZhipuModel(createModel({ provider: 'openai' }))).toBe(false) + }) + }) + + describe('isAnthropicModel', () => { + it('detects Anthropic models', () => { + expect(isAnthropicModel(createModel({ id: 'claude-3.5' }))).toBe(true) + }) + }) + + describe('isQwenMTModel', () => { + it('detects Qwen MT models', () => { + expect(isQwenMTModel(createModel({ id: 'qwen-mt-plus' }))).toBe(true) + }) + }) + + describe('isOpenAIOpenWeightModel', () => { + it('detects OpenAI open weight models', () => { + expect(isOpenAIOpenWeightModel(createModel({ id: 'gpt-oss-free' }))).toBe(true) + }) + }) + }) + + describe('System message support', () => { + describe('isNotSupportSystemMessageModel', () => { + it('returns true for models that do not support system messages', () => { + expect(isNotSupportSystemMessageModel(createModel({ id: 'gemma-moe' }))).toBe(true) + }) + }) + }) + + describe('Model grouping', () => { + describe('groupQwenModels', () => { + it('groups qwen models by prefix', () => { + const qwen = createModel({ id: 'Qwen-7B', provider: 'qwen', name: 'Qwen-7B' }) + const qwenOmni = createModel({ id: 'qwen2.5-omni', name: 'qwen2.5-omni' }) + const other = createModel({ id: 'deepseek-v3', group: 'DeepSeek' }) + + const grouped = groupQwenModels([qwen, qwenOmni, other]) + expect(Object.keys(grouped)).toContain('qwen-7b') + expect(Object.keys(grouped)).toContain('qwen2.5') + expect(grouped.DeepSeek).toContain(other) + }) + }) + }) + + describe('Vision and image generation', () => { + describe('isVisionModels', () => { + it('returns true when all models support vision', () => { + const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] + expect(isVisionModels(models)).toBe(true) + }) + + it('returns false when some models do not support vision', () => { + const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] + visionMock.mockReturnValueOnce(true).mockReturnValueOnce(false) + expect(isVisionModels(models)).toBe(false) + }) + }) + + describe('isGenerateImageModels', () => { + it('returns true when all models support image generation', () => { + const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] + expect(isGenerateImageModels(models)).toBe(true) + }) + + it('returns false when some models do not support image generation', () => { + const models = [createModel({ id: 'gpt-4o' }), createModel({ id: 'gpt-4o-mini' })] + generateImageMock.mockReturnValueOnce(true).mockReturnValueOnce(false) + expect(isGenerateImageModels(models)).toBe(false) + }) + }) + }) + + describe('Model filtering', () => { + describe('isSupportedModel', () => { + it('filters supported OpenAI catalog entries', () => { + expect(isSupportedModel({ id: 'gpt-4', object: 'model' } as any)).toBe(true) + }) + + it('filters unsupported OpenAI catalog entries', () => { + expect(isSupportedModel({ id: 'tts-1', object: 'model' } as any)).toBe(false) + }) + }) + + describe('agentModelFilter', () => { + it('returns true for regular models', () => { + expect(agentModelFilter(createModel())).toBe(true) + }) + + it('filters out embedding models', () => { + embeddingMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'text-embedding' }))).toBe(false) + }) + + it('filters out rerank models', () => { + embeddingMock.mockReturnValue(false) + rerankMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'rerank' }))).toBe(false) + }) + + it('filters out text-to-image models', () => { + rerankMock.mockReturnValue(false) + textToImageMock.mockReturnValueOnce(true) + expect(agentModelFilter(createModel({ id: 'gpt-image-1' }))).toBe(false) + }) + }) + }) + + describe('Temperature limits', () => { + describe('isMaxTemperatureOneModel', () => { + it('returns true for Zhipu models', () => { + expect(isMaxTemperatureOneModel(createModel({ id: 'glm-4' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'GLM-4-Plus' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'glm-3-turbo' }))).toBe(true) + }) + + it('returns true for Anthropic models', () => { + expect(isMaxTemperatureOneModel(createModel({ id: 'claude-3.5-sonnet' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'Claude-3-opus' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'claude-2.1' }))).toBe(true) + }) + + it('returns true for Moonshot models', () => { + expect(isMaxTemperatureOneModel(createModel({ id: 'moonshot-1.0' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'kimi-k2-thinking' }))).toBe(true) + expect(isMaxTemperatureOneModel(createModel({ id: 'Moonshot-Pro' }))).toBe(true) + }) + + it('returns false for other models', () => { + expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4o' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'qwen-max' }))).toBe(false) + expect(isMaxTemperatureOneModel(createModel({ id: 'gemini-pro' }))).toBe(false) + }) + }) }) }) diff --git a/src/renderer/src/config/models/utils.ts b/src/renderer/src/config/models/utils.ts index 1d5c9a6443..25e802b257 100644 --- a/src/renderer/src/config/models/utils.ts +++ b/src/renderer/src/config/models/utils.ts @@ -4,7 +4,14 @@ import { type Model, SystemProviderIds } from '@renderer/types' import type { OpenAIVerbosity, ValidOpenAIVerbosity } from '@renderer/types/aiCoreTypes' import { getLowerBaseModelName } from '@renderer/utils' -import { isOpenAIChatCompletionOnlyModel, isOpenAIOpenWeightModel, isOpenAIReasoningModel } from './openai' +import { + isGPT5ProModel, + isGPT5SeriesModel, + isGPT51SeriesModel, + isOpenAIChatCompletionOnlyModel, + isOpenAIOpenWeightModel, + isOpenAIReasoningModel +} from './openai' import { isQwenMTModel } from './qwen' import { isGenerateImageModel, isTextToImageModel, isVisionModel } from './vision' export const NOT_SUPPORTED_REGEX = /(?:^tts|whisper|speech)/i @@ -123,21 +130,46 @@ export const isNotSupportSystemMessageModel = (model: Model): boolean => { return isQwenMTModel(model) || isGemmaModel(model) } -// GPT-5 verbosity configuration +// Verbosity settings is only supported by GPT-5 and newer models +// Specifically, GPT-5 and GPT-5.1 for now // gpt-5-pro only supports 'high', other GPT-5 models support all levels -export const MODEL_SUPPORTED_VERBOSITY: Record = { - 'gpt-5-pro': ['high'], - default: ['low', 'medium', 'high'] -} as const +const MODEL_SUPPORTED_VERBOSITY: readonly { + readonly validator: (model: Model) => boolean + readonly values: readonly ValidOpenAIVerbosity[] +}[] = [ + // gpt-5-pro + { validator: isGPT5ProModel, values: ['high'] }, + // gpt-5 except gpt-5-pro + { + validator: (model: Model) => isGPT5SeriesModel(model) && !isGPT5ProModel(model), + values: ['low', 'medium', 'high'] + }, + // gpt-5.1 + { validator: isGPT51SeriesModel, values: ['low', 'medium', 'high'] } +] -export const getModelSupportedVerbosity = (model: Model): OpenAIVerbosity[] => { - const modelId = getLowerBaseModelName(model.id) - let supportedValues: ValidOpenAIVerbosity[] - if (modelId.includes('gpt-5-pro')) { - supportedValues = MODEL_SUPPORTED_VERBOSITY['gpt-5-pro'] - } else { - supportedValues = MODEL_SUPPORTED_VERBOSITY.default +/** + * Returns the list of supported verbosity levels for the given model. + * If the model is not recognized as a GPT-5 series model, only `undefined` is returned. + * For GPT-5-pro, only 'high' is supported; for other GPT-5 models, 'low', 'medium', and 'high' are supported. + * For GPT-5.1 series models, 'low', 'medium', and 'high' are supported. + * @param model - The model to check + * @returns An array of supported verbosity levels, always including `undefined` as the first element + */ +export const getModelSupportedVerbosity = (model: Model | undefined | null): OpenAIVerbosity[] => { + if (!model) { + return [undefined] } + + let supportedValues: ValidOpenAIVerbosity[] = [] + + for (const { validator, values } of MODEL_SUPPORTED_VERBOSITY) { + if (validator(model)) { + supportedValues = [...values] + break + } + } + return [undefined, ...supportedValues] } From d0bd10190d632268a47b4e6adee028c954ed74d5 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 27 Nov 2025 19:52:31 +0800 Subject: [PATCH 07/17] feat(test): e2e framework (#11494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(test): e2e framework Add Playwright-based e2e testing framework for Electron app with: - Custom fixtures for electronApp and mainWindow - Page Object Model (POM) pattern implementation - 15 example test cases covering app launch, navigation, settings, and chat - Comprehensive README for humans and AI assistants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(tests): update imports and improve code readability - Changed imports from 'import { Page, Locator }' to 'import type { Locator, Page }' for better type clarity across multiple page files. - Reformatted waitFor calls in ChatPage and HomePage for improved readability. - Updated index.ts to correct the export order of ChatPage and SidebarPage. - Minor adjustments in electron.fixture.ts and electron-app.ts for consistency in import statements. These changes enhance the maintainability and clarity of the test codebase. * chore: update linting configuration to include tests directory - Added 'tests/**' to the ignore patterns in .oxlintrc.json and eslint.config.mjs to ensure test files are not linted. - Minor adjustment in electron.fixture.ts to improve the fixture definition. These changes streamline the linting process and enhance code organization. * fix(test): select main window by title to fix flaky e2e tests on Mac On Mac, the app may create miniWindow for QuickAssistant alongside mainWindow. Using firstWindow() could randomly select the wrong window, causing test failures. Now we wait for the window with title "Cherry Studio" to ensure we get the main window. Also removed unused electron-app.ts utility file. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .oxlintrc.json | 1 + eslint.config.mjs | 1 + package.json | 3 +- playwright.config.ts | 82 +++-- tests/e2e/README.md | 310 ++++++++++++++++++ tests/e2e/fixtures/electron.fixture.ts | 53 +++ tests/e2e/global-setup.ts | 25 ++ tests/e2e/global-teardown.ts | 16 + tests/e2e/launch.test.tsx | 13 - tests/e2e/pages/base.page.ts | 110 +++++++ tests/e2e/pages/chat.page.ts | 140 ++++++++ tests/e2e/pages/home.page.ts | 110 +++++++ tests/e2e/pages/index.ts | 8 + tests/e2e/pages/settings.page.ts | 159 +++++++++ tests/e2e/pages/sidebar.page.ts | 122 +++++++ tests/e2e/specs/app-launch.spec.ts | 49 +++ .../e2e/specs/conversation/basic-chat.spec.ts | 35 ++ tests/e2e/specs/navigation.spec.ts | 46 +++ tests/e2e/specs/settings/general.spec.ts | 55 ++++ tests/e2e/utils/index.ts | 4 + tests/e2e/utils/wait-helpers.ts | 103 ++++++ yarn.lock | 55 +--- 22 files changed, 1415 insertions(+), 85 deletions(-) create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/fixtures/electron.fixture.ts create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts delete mode 100644 tests/e2e/launch.test.tsx create mode 100644 tests/e2e/pages/base.page.ts create mode 100644 tests/e2e/pages/chat.page.ts create mode 100644 tests/e2e/pages/home.page.ts create mode 100644 tests/e2e/pages/index.ts create mode 100644 tests/e2e/pages/settings.page.ts create mode 100644 tests/e2e/pages/sidebar.page.ts create mode 100644 tests/e2e/specs/app-launch.spec.ts create mode 100644 tests/e2e/specs/conversation/basic-chat.spec.ts create mode 100644 tests/e2e/specs/navigation.spec.ts create mode 100644 tests/e2e/specs/settings/general.spec.ts create mode 100644 tests/e2e/utils/index.ts create mode 100644 tests/e2e/utils/wait-helpers.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 7d18f83c7c..093ae25f18 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -11,6 +11,7 @@ "dist/**", "out/**", "local/**", + "tests/**", ".yarn/**", ".gitignore", "scripts/cloudflare-worker.js", diff --git a/eslint.config.mjs b/eslint.config.mjs index fcc952ed65..64fdefa1dc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,6 +58,7 @@ export default defineConfig([ 'dist/**', 'out/**', 'local/**', + 'tests/**', '.yarn/**', '.gitignore', 'scripts/cloudflare-worker.js', diff --git a/package.json b/package.json index 5550405df3..de89b4514c 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opeoginni/github-copilot-openai-compatible": "^0.1.21", - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.55.1", "@radix-ui/react-context-menu": "^2.2.16", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.12.0", @@ -321,7 +321,6 @@ "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "pdf-parse": "^1.1.1", - "playwright": "^1.55.1", "proxy-agent": "^6.5.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/playwright.config.ts b/playwright.config.ts index e12ce7ab6d..0b67f0e76f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,42 +1,64 @@ -import { defineConfig, devices } from '@playwright/test' +import { defineConfig } from '@playwright/test' /** - * See https://playwright.dev/docs/test-configuration. + * Playwright configuration for Electron e2e testing. + * See https://playwright.dev/docs/test-configuration */ export default defineConfig({ - // Look for test files, relative to this configuration file. - testDir: './tests/e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://localhost:3000', + // Look for test files in the specs directory + testDir: './tests/e2e/specs', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry' + // Global timeout for each test + timeout: 60000, + + // Assertion timeout + expect: { + timeout: 10000 }, - /* Configure projects for major browsers */ + // Electron apps should run tests sequentially to avoid conflicts + fullyParallel: false, + workers: 1, + + // Fail the build on CI if you accidentally left test.only in the source code + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Reporter configuration + reporter: [['html', { outputFolder: 'playwright-report' }], ['list']], + + // Global setup and teardown + globalSetup: './tests/e2e/global-setup.ts', + globalTeardown: './tests/e2e/global-teardown.ts', + + // Output directory for test artifacts + outputDir: './test-results', + + // Shared settings for all tests + use: { + // Collect trace when retrying the failed test + trace: 'retain-on-failure', + + // Take screenshot only on failure + screenshot: 'only-on-failure', + + // Record video only on failure + video: 'retain-on-failure', + + // Action timeout + actionTimeout: 15000, + + // Navigation timeout + navigationTimeout: 30000 + }, + + // Single project for Electron testing projects: [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'] } + name: 'electron', + testMatch: '**/*.spec.ts' } ] - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://localhost:3000', - // reuseExistingServer: !process.env.CI, - // }, }) diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000000..6da89ddd6e --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,310 @@ +# E2E Testing Guide + +本目录包含 Cherry Studio 的端到端 (E2E) 测试,使用 Playwright 测试 Electron 应用。 + +## 目录结构 + +``` +tests/e2e/ +├── README.md # 本文档 +├── global-setup.ts # 全局测试初始化 +├── global-teardown.ts # 全局测试清理 +├── fixtures/ +│ └── electron.fixture.ts # Electron 应用启动 fixture +├── utils/ +│ ├── wait-helpers.ts # 等待辅助函数 +│ └── index.ts # 工具导出 +├── pages/ # Page Object Model +│ ├── base.page.ts # 基础页面对象类 +│ ├── sidebar.page.ts # 侧边栏导航 +│ ├── home.page.ts # 首页/聊天页 +│ ├── settings.page.ts # 设置页 +│ ├── chat.page.ts # 聊天交互 +│ └── index.ts # 页面对象导出 +└── specs/ # 测试用例 + ├── app-launch.spec.ts # 应用启动测试 + ├── navigation.spec.ts # 页面导航测试 + ├── settings/ # 设置相关测试 + │ └── general.spec.ts + └── conversation/ # 对话相关测试 + └── basic-chat.spec.ts +``` + +--- + +## 运行测试 + +### 前置条件 + +1. 安装依赖:`yarn install` +2. 构建应用:`yarn build` + +### 运行命令 + +```bash +# 运行所有 e2e 测试 +yarn test:e2e + +# 带可视化窗口运行(可以看到测试过程) +yarn test:e2e --headed + +# 运行特定测试文件 +yarn playwright test tests/e2e/specs/app-launch.spec.ts + +# 运行匹配名称的测试 +yarn playwright test -g "should launch" + +# 调试模式(会暂停并打开调试器) +yarn playwright test --debug + +# 使用 Playwright UI 模式 +yarn playwright test --ui + +# 查看测试报告 +yarn playwright show-report +``` + +### 常见问题 + +**Q: 测试时看不到窗口?** +A: 默认是 headless 模式,使用 `--headed` 参数可看到窗口。 + +**Q: 测试失败,提示找不到元素?** +A: +1. 确保已运行 `yarn build` 构建最新代码 +2. 检查选择器是否正确,UI 可能已更新 + +**Q: 测试超时?** +A: Electron 应用启动较慢,可在测试中增加超时时间: +```typescript +test.setTimeout(60000) // 60秒 +``` + +--- + +## AI 助手指南:创建新测试用例 + +以下内容供 AI 助手(如 Claude、GPT)在创建新测试用例时参考。 + +### 基本原则 + +1. **使用 Page Object Model (POM)**:所有页面交互应通过 `pages/` 目录下的页面对象进行 +2. **使用自定义 fixture**:从 `../fixtures/electron.fixture` 导入 `test` 和 `expect` +3. **等待策略**:使用 `utils/wait-helpers.ts` 中的等待函数,避免硬编码 `waitForTimeout` +4. **测试独立性**:每个测试应该独立运行,不依赖其他测试的状态 + +### 创建新测试文件 + +```typescript +// tests/e2e/specs/[feature]/[feature].spec.ts + +import { test, expect } from '../../fixtures/electron.fixture' +import { SomePageObject } from '../../pages/some.page' +import { waitForAppReady } from '../../utils/wait-helpers' + +test.describe('Feature Name', () => { + let pageObject: SomePageObject + + test.beforeEach(async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + pageObject = new SomePageObject(mainWindow) + }) + + test('should do something', async ({ mainWindow }) => { + // 测试逻辑 + }) +}) +``` + +### 创建新页面对象 + +```typescript +// tests/e2e/pages/[feature].page.ts + +import { Page, Locator } from '@playwright/test' +import { BasePage } from './base.page' + +export class FeaturePage extends BasePage { + // 定义页面元素定位器 + readonly someButton: Locator + readonly someInput: Locator + + constructor(page: Page) { + super(page) + // 使用多种选择器策略,提高稳定性 + this.someButton = page.locator('[class*="SomeButton"], button:has-text("Some Text")') + this.someInput = page.locator('input[placeholder*="placeholder"]') + } + + // 页面操作方法 + async doSomething(): Promise { + await this.someButton.click() + } + + // 状态检查方法 + async isSomethingVisible(): Promise { + return this.someButton.isVisible() + } +} +``` + +### 选择器最佳实践 + +```typescript +// 优先级从高到低: + +// 1. data-testid(最稳定,但需要在源码中添加) +page.locator('[data-testid="submit-button"]') + +// 2. 语义化角色 +page.locator('button[role="submit"]') +page.locator('[aria-label="Send message"]') + +// 3. 类名模糊匹配(适应 CSS Modules / styled-components) +page.locator('[class*="SendButton"]') +page.locator('[class*="send-button"]') + +// 4. 文本内容 +page.locator('button:has-text("发送")') +page.locator('text=Submit') + +// 5. 组合选择器(提高稳定性) +page.locator('[class*="ChatInput"] textarea, [class*="InputBar"] textarea') + +// 避免使用: +// - 精确类名(容易因构建变化而失效) +// - 层级过深的选择器 +// - 索引选择器(如 nth-child)除非必要 +``` + +### 等待策略 + +```typescript +import { waitForAppReady, waitForNavigation, waitForModal } from '../../utils/wait-helpers' + +// 等待应用就绪 +await waitForAppReady(mainWindow) + +// 等待导航完成(HashRouter) +await waitForNavigation(mainWindow, '/settings') + +// 等待模态框出现 +await waitForModal(mainWindow) + +// 等待元素可见 +await page.locator('.some-element').waitFor({ state: 'visible', timeout: 10000 }) + +// 等待元素消失 +await page.locator('.loading').waitFor({ state: 'hidden' }) + +// 避免使用固定等待时间 +// BAD: await page.waitForTimeout(3000) +// GOOD: await page.waitForSelector('.element', { state: 'visible' }) +``` + +### 断言模式 + +```typescript +// 使用 Playwright 的自动重试断言 +await expect(page.locator('.element')).toBeVisible() +await expect(page.locator('.element')).toHaveText('expected text') +await expect(page.locator('.element')).toHaveCount(3) + +// 检查 URL(HashRouter) +await expect(page).toHaveURL(/.*#\/settings.*/) + +// 软断言(不会立即失败) +await expect.soft(page.locator('.element')).toBeVisible() + +// 自定义超时 +await expect(page.locator('.slow-element')).toBeVisible({ timeout: 30000 }) +``` + +### 处理 Electron 特性 + +```typescript +// 访问 Electron 主进程 +const bounds = await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + return win?.getBounds() +}) + +// 检查窗口状态 +const isMaximized = await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + return win?.isMaximized() +}) + +// 调用 IPC(通过 preload 暴露的 API) +const result = await mainWindow.evaluate(() => { + return (window as any).api.someMethod() +}) +``` + +### 测试文件命名规范 + +``` +specs/ +├── [feature].spec.ts # 单文件测试 +├── [feature]/ +│ ├── [sub-feature].spec.ts # 子功能测试 +│ └── [another].spec.ts +``` + +示例: +- `app-launch.spec.ts` - 应用启动 +- `navigation.spec.ts` - 页面导航 +- `settings/general.spec.ts` - 通用设置 +- `conversation/basic-chat.spec.ts` - 基础聊天 + +### 添加新页面对象后的清单 + +1. 在 `pages/` 目录创建 `[feature].page.ts` +2. 继承 `BasePage` 类 +3. 在 `pages/index.ts` 中导出 +4. 在对应的 spec 文件中导入使用 + +### 测试用例编写清单 + +- [ ] 使用自定义 fixture (`test`, `expect`) +- [ ] 在 `beforeEach` 中调用 `waitForAppReady` +- [ ] 使用 Page Object 进行页面交互 +- [ ] 使用描述性的测试名称 +- [ ] 添加适当的断言 +- [ ] 处理可能的异步操作 +- [ ] 考虑测试失败时的清理 + +### 调试技巧 + +```typescript +// 截图调试 +await mainWindow.screenshot({ path: 'debug.png' }) + +// 打印页面 HTML +console.log(await mainWindow.content()) + +// 暂停测试进行调试 +await mainWindow.pause() + +// 打印元素数量 +console.log(await page.locator('.element').count()) +``` + +--- + +## 配置文件 + +主要配置在项目根目录的 `playwright.config.ts`: + +- `testDir`: 测试目录 (`./tests/e2e/specs`) +- `timeout`: 测试超时 (60秒) +- `workers`: 并发数 (1,Electron 需要串行) +- `retries`: 重试次数 (CI 环境下为 2) + +--- + +## 相关文档 + +- [Playwright 官方文档](https://playwright.dev/docs/intro) +- [Playwright Electron 测试](https://playwright.dev/docs/api/class-electron) +- [Page Object Model](https://playwright.dev/docs/pom) diff --git a/tests/e2e/fixtures/electron.fixture.ts b/tests/e2e/fixtures/electron.fixture.ts new file mode 100644 index 0000000000..cf9def26e0 --- /dev/null +++ b/tests/e2e/fixtures/electron.fixture.ts @@ -0,0 +1,53 @@ +import type { ElectronApplication, Page } from '@playwright/test' +import { _electron as electron, test as base } from '@playwright/test' + +/** + * Custom fixtures for Electron e2e testing. + * Provides electronApp and mainWindow to all tests. + */ +export type ElectronFixtures = { + electronApp: ElectronApplication + mainWindow: Page +} + +export const test = base.extend({ + electronApp: async ({}, use) => { + // Launch Electron app from project root + // The args ['.'] tells Electron to load the app from current directory + const electronApp = await electron.launch({ + args: ['.'], + env: { + ...process.env, + NODE_ENV: 'development' + }, + timeout: 60000 + }) + + await use(electronApp) + + // Cleanup: close the app after test + await electronApp.close() + }, + + mainWindow: async ({ electronApp }, use) => { + // Wait for the main window (title: "Cherry Studio", not "Quick Assistant") + // On Mac, the app may create miniWindow for QuickAssistant with different title + const mainWindow = await electronApp.waitForEvent('window', { + predicate: async (window) => { + const title = await window.title() + return title === 'Cherry Studio' + }, + timeout: 60000 + }) + + // Wait for React app to mount + await mainWindow.waitForSelector('#root', { state: 'attached', timeout: 60000 }) + + // Wait for initial content to load + await mainWindow.waitForLoadState('domcontentloaded') + + await use(mainWindow) + } +}) + +export { expect } from '@playwright/test' diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000000..edda731d5d --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,25 @@ +import * as fs from 'fs' +import * as path from 'path' + +/** + * Global setup for Playwright e2e tests. + * This runs once before all tests. + */ +async function globalSetup() { + console.log('Running global setup...') + + // Create test results directories + const resultsDir = path.join(process.cwd(), 'test-results') + const screenshotsDir = path.join(resultsDir, 'screenshots') + + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }) + } + + // Set environment variables for testing + process.env.NODE_ENV = 'test' + + console.log('Global setup complete') +} + +export default globalSetup diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000000..6336248e14 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,16 @@ +/** + * Global teardown for Playwright e2e tests. + * This runs once after all tests complete. + */ +async function globalTeardown() { + console.log('Running global teardown...') + + // Cleanup tasks can be added here: + // - Kill orphaned Electron processes + // - Clean up temporary test data + // - Reset test databases + + console.log('Global teardown complete') +} + +export default globalTeardown diff --git a/tests/e2e/launch.test.tsx b/tests/e2e/launch.test.tsx deleted file mode 100644 index 8636c01695..0000000000 --- a/tests/e2e/launch.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { _electron as electron, expect, test } from '@playwright/test' - -let electronApp: any -let window: any - -test.describe('App Launch', () => { - test('should launch and close the main application', async () => { - electronApp = await electron.launch({ args: ['.'] }) - window = await electronApp.firstWindow() - expect(window).toBeDefined() - await electronApp.close() - }) -}) diff --git a/tests/e2e/pages/base.page.ts b/tests/e2e/pages/base.page.ts new file mode 100644 index 0000000000..fe8065a650 --- /dev/null +++ b/tests/e2e/pages/base.page.ts @@ -0,0 +1,110 @@ +import type { Locator, Page } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' + +/** + * Base Page Object class. + * All page objects should extend this class. + */ +export abstract class BasePage { + constructor(protected page: Page) {} + + /** + * Navigate to a path using HashRouter. + * The app uses HashRouter, so we need to change window.location.hash. + */ + async navigateTo(routePath: string): Promise { + await this.page.evaluate((p) => { + window.location.hash = p + }, routePath) + await this.page.waitForLoadState('domcontentloaded') + } + + /** + * Wait for an element to be visible. + */ + async waitForElement(selector: string, timeout: number = 10000): Promise { + const locator = this.page.locator(selector) + await locator.waitFor({ state: 'visible', timeout }) + return locator + } + + /** + * Wait for an element to be hidden. + */ + async waitForElementHidden(selector: string, timeout: number = 10000): Promise { + const locator = this.page.locator(selector) + await locator.waitFor({ state: 'hidden', timeout }) + } + + /** + * Take a screenshot for debugging. + */ + async takeScreenshot(name: string): Promise { + const screenshotsDir = path.join(process.cwd(), 'test-results', 'screenshots') + if (!fs.existsSync(screenshotsDir)) { + fs.mkdirSync(screenshotsDir, { recursive: true }) + } + + await this.page.screenshot({ + path: path.join(screenshotsDir, `${name}.png`), + fullPage: true + }) + } + + /** + * Get the current route from the hash. + */ + async getCurrentRoute(): Promise { + const url = this.page.url() + const hash = new URL(url).hash + return hash.replace('#', '') || '/' + } + + /** + * Click an element with retry. + */ + async clickWithRetry(selector: string, maxRetries: number = 3): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + await this.page.click(selector, { timeout: 5000 }) + return + } catch (error) { + if (i === maxRetries - 1) throw error + await this.page.waitForTimeout(500) + } + } + } + + /** + * Fill an input field. + */ + async fillInput(selector: string, value: string): Promise { + const input = this.page.locator(selector) + await input.fill(value) + } + + /** + * Get text content of an element. + */ + async getTextContent(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.textContent() + } + + /** + * Check if an element is visible. + */ + async isElementVisible(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.isVisible() + } + + /** + * Count elements matching a selector. + */ + async countElements(selector: string): Promise { + const locator = this.page.locator(selector) + return locator.count() + } +} diff --git a/tests/e2e/pages/chat.page.ts b/tests/e2e/pages/chat.page.ts new file mode 100644 index 0000000000..c0b6b91814 --- /dev/null +++ b/tests/e2e/pages/chat.page.ts @@ -0,0 +1,140 @@ +import type { Locator, Page } from '@playwright/test' + +import { BasePage } from './base.page' + +/** + * Page Object for the Chat/Conversation interface. + * Handles message input, sending, and conversation management. + */ +export class ChatPage extends BasePage { + readonly chatContainer: Locator + readonly inputArea: Locator + readonly sendButton: Locator + readonly messageList: Locator + readonly userMessages: Locator + readonly assistantMessages: Locator + readonly newTopicButton: Locator + readonly topicList: Locator + readonly stopButton: Locator + + constructor(page: Page) { + super(page) + this.chatContainer = page.locator('#chat, [class*="Chat"]') + this.inputArea = page.locator( + '[class*="Inputbar"] textarea, [class*="InputBar"] textarea, [contenteditable="true"]' + ) + this.sendButton = page.locator( + '[class*="SendMessageButton"], [class*="send-button"], button[aria-label*="send"], button[title*="send"]' + ) + this.messageList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]') + this.userMessages = page.locator('[class*="UserMessage"], [class*="user-message"]') + this.assistantMessages = page.locator('[class*="AssistantMessage"], [class*="assistant-message"]') + this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]') + this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]') + this.stopButton = page.locator('[class*="StopButton"], [class*="stop-button"]') + } + + /** + * Navigate to chat/home page. + */ + async goto(): Promise { + await this.navigateTo('/') + await this.chatContainer + .first() + .waitFor({ state: 'visible', timeout: 15000 }) + .catch(() => {}) + } + + /** + * Check if chat is visible. + */ + async isChatVisible(): Promise { + return this.chatContainer.first().isVisible() + } + + /** + * Type a message in the input area. + */ + async typeMessage(message: string): Promise { + await this.inputArea.first().fill(message) + } + + /** + * Clear the input area. + */ + async clearInput(): Promise { + await this.inputArea.first().clear() + } + + /** + * Click the send button. + */ + async clickSend(): Promise { + await this.sendButton.first().click() + } + + /** + * Type and send a message. + */ + async sendMessage(message: string): Promise { + await this.typeMessage(message) + await this.clickSend() + } + + /** + * Get the current input value. + */ + async getInputValue(): Promise { + return (await this.inputArea.first().inputValue()) || (await this.inputArea.first().textContent()) || '' + } + + /** + * Get the count of user messages. + */ + async getUserMessageCount(): Promise { + return this.userMessages.count() + } + + /** + * Get the count of assistant messages. + */ + async getAssistantMessageCount(): Promise { + return this.assistantMessages.count() + } + + /** + * Check if send button is enabled. + */ + async isSendButtonEnabled(): Promise { + const isDisabled = await this.sendButton.first().isDisabled() + return !isDisabled + } + + /** + * Create a new topic/conversation. + */ + async createNewTopic(): Promise { + await this.newTopicButton.first().click() + } + + /** + * Check if stop button is visible (indicates ongoing generation). + */ + async isGenerating(): Promise { + return this.stopButton.first().isVisible() + } + + /** + * Click stop button to stop generation. + */ + async stopGeneration(): Promise { + await this.stopButton.first().click() + } + + /** + * Wait for generation to complete. + */ + async waitForGenerationComplete(timeout: number = 60000): Promise { + await this.stopButton.first().waitFor({ state: 'hidden', timeout }) + } +} diff --git a/tests/e2e/pages/home.page.ts b/tests/e2e/pages/home.page.ts new file mode 100644 index 0000000000..4d3efb88aa --- /dev/null +++ b/tests/e2e/pages/home.page.ts @@ -0,0 +1,110 @@ +import type { Locator, Page } from '@playwright/test' + +import { BasePage } from './base.page' + +/** + * Page Object for the Home/Chat page. + * This is the main page where users interact with AI assistants. + */ +export class HomePage extends BasePage { + readonly homePage: Locator + readonly chatContainer: Locator + readonly inputBar: Locator + readonly messagesList: Locator + readonly sendButton: Locator + readonly newTopicButton: Locator + readonly assistantTabs: Locator + readonly topicList: Locator + + constructor(page: Page) { + super(page) + this.homePage = page.locator('#home-page, [class*="HomePage"], [class*="Home"]') + this.chatContainer = page.locator('#chat, [class*="Chat"]') + this.inputBar = page.locator('[class*="Inputbar"], [class*="InputBar"], [class*="input-bar"]') + this.messagesList = page.locator('#messages, [class*="Messages"], [class*="MessageList"]') + this.sendButton = page.locator('[class*="SendMessageButton"], [class*="send-button"], button[type="submit"]') + this.newTopicButton = page.locator('[class*="NewTopicButton"], [class*="new-topic"]') + this.assistantTabs = page.locator('[class*="HomeTabs"], [class*="AssistantTabs"]') + this.topicList = page.locator('[class*="TopicList"], [class*="topic-list"]') + } + + /** + * Navigate to the home page. + */ + async goto(): Promise { + await this.navigateTo('/') + await this.homePage + .first() + .waitFor({ state: 'visible', timeout: 15000 }) + .catch(() => {}) + } + + /** + * Check if the home page is loaded. + */ + async isLoaded(): Promise { + return this.homePage.first().isVisible() + } + + /** + * Type a message in the input area. + */ + async typeMessage(message: string): Promise { + const input = this.page.locator( + '[class*="Inputbar"] textarea, [class*="Inputbar"] [contenteditable], [class*="InputBar"] textarea' + ) + await input.first().fill(message) + } + + /** + * Click the send button to send a message. + */ + async sendMessage(): Promise { + await this.sendButton.first().click() + } + + /** + * Type and send a message. + */ + async sendChatMessage(message: string): Promise { + await this.typeMessage(message) + await this.sendMessage() + } + + /** + * Get the count of messages in the chat. + */ + async getMessageCount(): Promise { + const messages = this.page.locator('[class*="Message"]:not([class*="Messages"]):not([class*="MessageList"])') + return messages.count() + } + + /** + * Create a new topic/conversation. + */ + async createNewTopic(): Promise { + await this.newTopicButton.first().click() + } + + /** + * Check if the chat interface is visible. + */ + async isChatVisible(): Promise { + return this.chatContainer.first().isVisible() + } + + /** + * Check if the input bar is visible. + */ + async isInputBarVisible(): Promise { + return this.inputBar.first().isVisible() + } + + /** + * Get the placeholder text of the input field. + */ + async getInputPlaceholder(): Promise { + const input = this.page.locator('[class*="Inputbar"] textarea, [class*="InputBar"] textarea') + return input.first().getAttribute('placeholder') + } +} diff --git a/tests/e2e/pages/index.ts b/tests/e2e/pages/index.ts new file mode 100644 index 0000000000..453b8fa532 --- /dev/null +++ b/tests/e2e/pages/index.ts @@ -0,0 +1,8 @@ +/** + * Export all page objects for easy importing. + */ +export { BasePage } from './base.page' +export { ChatPage } from './chat.page' +export { HomePage } from './home.page' +export { SettingsPage } from './settings.page' +export { SidebarPage } from './sidebar.page' diff --git a/tests/e2e/pages/settings.page.ts b/tests/e2e/pages/settings.page.ts new file mode 100644 index 0000000000..44fd2b683b --- /dev/null +++ b/tests/e2e/pages/settings.page.ts @@ -0,0 +1,159 @@ +import type { Locator, Page } from '@playwright/test' + +import { BasePage } from './base.page' + +/** + * Page Object for the Settings page. + * Handles navigation and interaction with various settings sections. + */ +export class SettingsPage extends BasePage { + readonly settingsContainer: Locator + readonly providerMenuItem: Locator + readonly modelMenuItem: Locator + readonly generalMenuItem: Locator + readonly displayMenuItem: Locator + readonly dataMenuItem: Locator + readonly mcpMenuItem: Locator + readonly memoryMenuItem: Locator + readonly aboutMenuItem: Locator + + constructor(page: Page) { + super(page) + this.settingsContainer = page.locator('[id="content-container"], [class*="Settings"]') + this.providerMenuItem = page.locator('a[href*="/settings/provider"]') + this.modelMenuItem = page.locator('a[href*="/settings/model"]') + this.generalMenuItem = page.locator('a[href*="/settings/general"]') + this.displayMenuItem = page.locator('a[href*="/settings/display"]') + this.dataMenuItem = page.locator('a[href*="/settings/data"]') + this.mcpMenuItem = page.locator('a[href*="/settings/mcp"]') + this.memoryMenuItem = page.locator('a[href*="/settings/memory"]') + this.aboutMenuItem = page.locator('a[href*="/settings/about"]') + } + + /** + * Navigate to settings page (provider by default). + */ + async goto(): Promise { + await this.navigateTo('/settings/provider') + await this.waitForElement('[id="content-container"], [class*="Settings"]') + } + + /** + * Check if settings page is loaded. + */ + async isLoaded(): Promise { + return this.settingsContainer.first().isVisible() + } + + /** + * Navigate to Provider settings. + */ + async goToProvider(): Promise { + try { + await this.providerMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/provider') + } + await this.page.waitForURL('**/#/settings/provider**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Model settings. + */ + async goToModel(): Promise { + try { + await this.modelMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/model') + } + await this.page.waitForURL('**/#/settings/model**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to General settings. + */ + async goToGeneral(): Promise { + try { + await this.generalMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/general') + } + await this.page.waitForURL('**/#/settings/general**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Display settings. + */ + async goToDisplay(): Promise { + try { + await this.displayMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/display') + } + await this.page.waitForURL('**/#/settings/display**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Data settings. + */ + async goToData(): Promise { + try { + await this.dataMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/data') + } + await this.page.waitForURL('**/#/settings/data**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to MCP settings. + */ + async goToMCP(): Promise { + try { + await this.mcpMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/mcp') + } + await this.page.waitForURL('**/#/settings/mcp**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Memory settings. + */ + async goToMemory(): Promise { + try { + await this.memoryMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/memory') + } + await this.page.waitForURL('**/#/settings/memory**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to About page. + */ + async goToAbout(): Promise { + try { + await this.aboutMenuItem.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/about') + } + await this.page.waitForURL('**/#/settings/about**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Toggle a switch setting by its label. + */ + async toggleSwitch(label: string): Promise { + const switchElement = this.page.locator(`text=${label}`).locator('..').locator('button[role="switch"], .ant-switch') + await switchElement.first().click() + } + + /** + * Check if a menu item is active/selected. + */ + async isMenuItemActive(menuItem: Locator): Promise { + const className = await menuItem.getAttribute('class') + return className?.includes('active') || className?.includes('selected') || false + } +} diff --git a/tests/e2e/pages/sidebar.page.ts b/tests/e2e/pages/sidebar.page.ts new file mode 100644 index 0000000000..a65c332165 --- /dev/null +++ b/tests/e2e/pages/sidebar.page.ts @@ -0,0 +1,122 @@ +import type { Locator, Page } from '@playwright/test' + +import { BasePage } from './base.page' + +/** + * Page Object for the Sidebar/Navigation component. + * Handles navigation between different sections of the app. + */ +export class SidebarPage extends BasePage { + readonly sidebar: Locator + readonly homeLink: Locator + readonly storeLink: Locator + readonly knowledgeLink: Locator + readonly filesLink: Locator + readonly settingsLink: Locator + readonly appsLink: Locator + readonly translateLink: Locator + + constructor(page: Page) { + super(page) + this.sidebar = page.locator('[class*="Sidebar"], nav, aside') + this.homeLink = page.locator('a[href="#/"], a[href="#!/"]').first() + this.storeLink = page.locator('a[href*="/store"]') + this.knowledgeLink = page.locator('a[href*="/knowledge"]') + this.filesLink = page.locator('a[href*="/files"]') + this.settingsLink = page.locator('a[href*="/settings"]') + this.appsLink = page.locator('a[href*="/apps"]') + this.translateLink = page.locator('a[href*="/translate"]') + } + + /** + * Navigate to Home page. + */ + async goToHome(): Promise { + // Try clicking the home link, or navigate directly + try { + await this.homeLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/') + } + await this.page.waitForURL(/.*#\/$|.*#$|.*#\/home.*/, { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Knowledge page. + */ + async goToKnowledge(): Promise { + try { + await this.knowledgeLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/knowledge') + } + await this.page.waitForURL('**/#/knowledge**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Settings page. + */ + async goToSettings(): Promise { + try { + await this.settingsLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/settings/provider') + } + await this.page.waitForURL('**/#/settings/**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Files page. + */ + async goToFiles(): Promise { + try { + await this.filesLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/files') + } + await this.page.waitForURL('**/#/files**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Apps page. + */ + async goToApps(): Promise { + try { + await this.appsLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/apps') + } + await this.page.waitForURL('**/#/apps**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Store page. + */ + async goToStore(): Promise { + try { + await this.storeLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/store') + } + await this.page.waitForURL('**/#/store**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Navigate to Translate page. + */ + async goToTranslate(): Promise { + try { + await this.translateLink.click({ timeout: 5000 }) + } catch { + await this.navigateTo('/translate') + } + await this.page.waitForURL('**/#/translate**', { timeout: 10000 }).catch(() => {}) + } + + /** + * Check if sidebar is visible. + */ + async isVisible(): Promise { + return this.sidebar.first().isVisible() + } +} diff --git a/tests/e2e/specs/app-launch.spec.ts b/tests/e2e/specs/app-launch.spec.ts new file mode 100644 index 0000000000..0a58c64fb9 --- /dev/null +++ b/tests/e2e/specs/app-launch.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '../fixtures/electron.fixture' +import { waitForAppReady } from '../utils/wait-helpers' + +test.describe('App Launch', () => { + test('should launch the application successfully', async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + expect(mainWindow).toBeDefined() + + const title = await mainWindow.title() + expect(title).toBeTruthy() + }) + + test('should display the main content', async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + + // Check for main app content + const hasContent = await mainWindow.evaluate(() => { + const root = document.querySelector('#root') + return root !== null && root.innerHTML.length > 100 + }) + + expect(hasContent).toBe(true) + }) + + test('should have React root mounted', async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + + const hasReactRoot = await mainWindow.evaluate(() => { + const root = document.querySelector('#root') + return root !== null && root.children.length > 0 + }) + + expect(hasReactRoot).toBe(true) + }) + + test('should have window with reasonable size', async ({ electronApp, mainWindow }) => { + await waitForAppReady(mainWindow) + + const bounds = await electronApp.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0] + return win?.getBounds() + }) + + expect(bounds).toBeDefined() + // Window should have some reasonable size (may vary based on saved state) + expect(bounds!.width).toBeGreaterThan(400) + expect(bounds!.height).toBeGreaterThan(300) + }) +}) diff --git a/tests/e2e/specs/conversation/basic-chat.spec.ts b/tests/e2e/specs/conversation/basic-chat.spec.ts new file mode 100644 index 0000000000..2e03ed4ede --- /dev/null +++ b/tests/e2e/specs/conversation/basic-chat.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '../../fixtures/electron.fixture' +import { waitForAppReady } from '../../utils/wait-helpers' + +test.describe('Basic Chat', () => { + test.beforeEach(async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + }) + + test('should display main content on home page', async ({ mainWindow }) => { + // Home page is the default, just verify content exists + const hasContent = await mainWindow.evaluate(() => { + const root = document.querySelector('#root') + return root !== null && root.innerHTML.length > 100 + }) + + expect(hasContent).toBe(true) + }) + + test('should have input area for chat', async ({ mainWindow }) => { + // Look for textarea or input elements that could be chat input + const inputElements = mainWindow.locator('textarea, [contenteditable="true"], input[type="text"]') + const count = await inputElements.count() + + // There should be at least one input element + expect(count).toBeGreaterThan(0) + }) + + test('should have interactive elements', async ({ mainWindow }) => { + // Check for buttons or clickable elements + const buttons = mainWindow.locator('button') + const count = await buttons.count() + + expect(count).toBeGreaterThan(0) + }) +}) diff --git a/tests/e2e/specs/navigation.spec.ts b/tests/e2e/specs/navigation.spec.ts new file mode 100644 index 0000000000..085bff3930 --- /dev/null +++ b/tests/e2e/specs/navigation.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '../fixtures/electron.fixture' +import { SidebarPage } from '../pages/sidebar.page' +import { waitForAppReady } from '../utils/wait-helpers' + +test.describe('Navigation', () => { + let sidebarPage: SidebarPage + + test.beforeEach(async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + sidebarPage = new SidebarPage(mainWindow) + }) + + test('should navigate to Settings page', async ({ mainWindow }) => { + await sidebarPage.goToSettings() + + // Wait a bit for navigation to complete + await mainWindow.waitForTimeout(1000) + + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/settings') + }) + + test('should navigate to Files page', async ({ mainWindow }) => { + await sidebarPage.goToFiles() + + await mainWindow.waitForTimeout(1000) + + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/files') + }) + + test('should navigate back to Home', async ({ mainWindow }) => { + // First go to settings + await sidebarPage.goToSettings() + await mainWindow.waitForTimeout(1000) + + // Then go back to home + await sidebarPage.goToHome() + await mainWindow.waitForTimeout(1000) + + // Verify we're on home page + const currentUrl = mainWindow.url() + // Home page URL should be either / or empty hash + expect(currentUrl).toMatch(/#\/?$|#$/) + }) +}) diff --git a/tests/e2e/specs/settings/general.spec.ts b/tests/e2e/specs/settings/general.spec.ts new file mode 100644 index 0000000000..6943cf3504 --- /dev/null +++ b/tests/e2e/specs/settings/general.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '../../fixtures/electron.fixture' +import { SettingsPage } from '../../pages/settings.page' +import { SidebarPage } from '../../pages/sidebar.page' +import { waitForAppReady } from '../../utils/wait-helpers' + +test.describe('Settings Page', () => { + let settingsPage: SettingsPage + let sidebarPage: SidebarPage + + test.beforeEach(async ({ mainWindow }) => { + await waitForAppReady(mainWindow) + sidebarPage = new SidebarPage(mainWindow) + settingsPage = new SettingsPage(mainWindow) + + // Navigate to settings + await sidebarPage.goToSettings() + await mainWindow.waitForTimeout(1000) + }) + + test('should display settings page', async ({ mainWindow }) => { + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/settings') + }) + + test('should have settings menu items', async ({ mainWindow }) => { + // Check for settings menu items by looking for links + const menuItems = mainWindow.locator('a[href*="/settings/"]') + const count = await menuItems.count() + expect(count).toBeGreaterThan(0) + }) + + test('should navigate to General settings', async ({ mainWindow }) => { + await settingsPage.goToGeneral() + await mainWindow.waitForTimeout(500) + + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/settings/general') + }) + + test('should navigate to Display settings', async ({ mainWindow }) => { + await settingsPage.goToDisplay() + await mainWindow.waitForTimeout(500) + + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/settings/display') + }) + + test('should navigate to About page', async ({ mainWindow }) => { + await settingsPage.goToAbout() + await mainWindow.waitForTimeout(500) + + const currentUrl = mainWindow.url() + expect(currentUrl).toContain('/settings/about') + }) +}) diff --git a/tests/e2e/utils/index.ts b/tests/e2e/utils/index.ts new file mode 100644 index 0000000000..908302f024 --- /dev/null +++ b/tests/e2e/utils/index.ts @@ -0,0 +1,4 @@ +/** + * Export all utilities for easy importing. + */ +export * from './wait-helpers' diff --git a/tests/e2e/utils/wait-helpers.ts b/tests/e2e/utils/wait-helpers.ts new file mode 100644 index 0000000000..f2ad09ccc1 --- /dev/null +++ b/tests/e2e/utils/wait-helpers.ts @@ -0,0 +1,103 @@ +import type { Page } from '@playwright/test' + +/** + * Wait for the application to be fully ready. + * The app uses PersistGate which may delay initial render. + * Layout can be either Sidebar-based or TabsContainer-based depending on settings. + */ +export async function waitForAppReady(page: Page, timeout: number = 60000): Promise { + // First, wait for React root to be attached + await page.waitForSelector('#root', { state: 'attached', timeout }) + + // Wait for main app content to render + // The app may show either: + // 1. Sidebar layout (navbarPosition === 'left') + // 2. TabsContainer layout (default) + // 3. Home page content + await page.waitForSelector( + [ + '#home-page', // Home page container + '[class*="Sidebar"]', // Sidebar component + '[class*="TabsContainer"]', // Tabs container + '[class*="home-navbar"]', // Home navbar + '[class*="Container"]' // Generic container from styled-components + ].join(', '), + { + state: 'visible', + timeout + } + ) + + // Additional wait for React to fully hydrate + await page.waitForLoadState('domcontentloaded') +} + +/** + * Wait for navigation to a specific path. + * The app uses HashRouter, so paths are prefixed with #. + */ +export async function waitForNavigation(page: Page, path: string, timeout: number = 15000): Promise { + await page.waitForURL(`**/#${path}**`, { timeout }) +} + +/** + * Wait for the chat interface to be ready. + */ +export async function waitForChatReady(page: Page, timeout: number = 30000): Promise { + await page.waitForSelector( + ['#home-page', '[class*="Chat"]', '[class*="Inputbar"]', '[class*="home-tabs"]'].join(', '), + { state: 'visible', timeout } + ) +} + +/** + * Wait for the settings page to load. + */ +export async function waitForSettingsLoad(page: Page, timeout: number = 30000): Promise { + await page.waitForSelector(['[class*="SettingsPage"]', '[class*="Settings"]', 'a[href*="/settings/"]'].join(', '), { + state: 'visible', + timeout + }) +} + +/** + * Wait for a modal/dialog to appear. + */ +export async function waitForModal(page: Page, timeout: number = 10000): Promise { + await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'visible', timeout }) +} + +/** + * Wait for a modal/dialog to close. + */ +export async function waitForModalClose(page: Page, timeout: number = 10000): Promise { + await page.waitForSelector('.ant-modal, [role="dialog"], .ant-drawer', { state: 'hidden', timeout }) +} + +/** + * Wait for loading state to complete. + */ +export async function waitForLoadingComplete(page: Page, timeout: number = 30000): Promise { + const spinner = page.locator('.ant-spin, [class*="Loading"], [class*="Spinner"]') + if ((await spinner.count()) > 0) { + await spinner.first().waitFor({ state: 'hidden', timeout }) + } +} + +/** + * Wait for a notification/toast to appear. + */ +export async function waitForNotification(page: Page, timeout: number = 10000): Promise { + await page.waitForSelector('.ant-notification, .ant-message, [class*="Notification"]', { + state: 'visible', + timeout + }) +} + +/** + * Sleep for a specified duration. + * Use sparingly - prefer explicit waits when possible. + */ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/yarn.lock b/yarn.lock index 02d11ef5d5..7f7ed62da7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5437,14 +5437,14 @@ __metadata: languageName: node linkType: hard -"@playwright/test@npm:^1.52.0": - version: 1.52.0 - resolution: "@playwright/test@npm:1.52.0" +"@playwright/test@npm:^1.55.1": + version: 1.57.0 + resolution: "@playwright/test@npm:1.57.0" dependencies: - playwright: "npm:1.52.0" + playwright: "npm:1.57.0" bin: playwright: cli.js - checksum: 10c0/1c428b421593eb4f79b7c99783a389c3ab3526c9051ec772749f4fca61414dfa9f2344eba846faac5f238084aa96c836364a91d81d3034ac54924f239a93e247 + checksum: 10c0/35ba4b28be72bf0a53e33dbb11c6cff848fb9a37f49e893ce63a90675b5291ec29a1ba82c8a3b043abaead129400f0589623e9ace2e6a1c8eaa409721ecc3774 languageName: node linkType: hard @@ -10059,7 +10059,7 @@ __metadata: "@opentelemetry/sdk-trace-web": "npm:^2.0.0" "@opeoginni/github-copilot-openai-compatible": "npm:^0.1.21" "@paymoapp/electron-shutdown-handler": "npm:^1.1.2" - "@playwright/test": "npm:^1.52.0" + "@playwright/test": "npm:^1.55.1" "@radix-ui/react-context-menu": "npm:^2.2.16" "@reduxjs/toolkit": "npm:^2.2.5" "@shikijs/markdown-it": "npm:^3.12.0" @@ -10219,7 +10219,6 @@ __metadata: p-queue: "npm:^8.1.0" pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" - playwright: "npm:^1.55.1" proxy-agent: "npm:^6.5.0" qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" @@ -20699,51 +20698,27 @@ __metadata: languageName: node linkType: hard -"playwright-core@npm:1.52.0": - version: 1.52.0 - resolution: "playwright-core@npm:1.52.0" +"playwright-core@npm:1.57.0": + version: 1.57.0 + resolution: "playwright-core@npm:1.57.0" bin: playwright-core: cli.js - checksum: 10c0/640945507e6ca2144e9f596b2a6ecac042c2fd3683ff99e6271e9a7b38f3602d415f282609d569456f66680aab8b3c5bb1b257d8fb63a7fc0ed648261110421f + checksum: 10c0/798e35d83bf48419a8c73de20bb94d68be5dde68de23f95d80a0ebe401e3b83e29e3e84aea7894d67fa6c79d2d3d40cc5bcde3e166f657ce50987aaa2421b6a9 languageName: node linkType: hard -"playwright-core@npm:1.56.1": - version: 1.56.1 - resolution: "playwright-core@npm:1.56.1" - bin: - playwright-core: cli.js - checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7 - languageName: node - linkType: hard - -"playwright@npm:1.52.0": - version: 1.52.0 - resolution: "playwright@npm:1.52.0" +"playwright@npm:1.57.0": + version: 1.57.0 + resolution: "playwright@npm:1.57.0" dependencies: fsevents: "npm:2.3.2" - playwright-core: "npm:1.52.0" + playwright-core: "npm:1.57.0" dependenciesMeta: fsevents: optional: true bin: playwright: cli.js - checksum: 10c0/2c6edf1e15e59bbaf77f3fa0fe0ac975793c17cff835d9c8b8bc6395a3b6f1c01898b3058ab37891b2e4d424bcc8f1b4844fe70d943e0143d239d7451408c579 - languageName: node - linkType: hard - -"playwright@npm:^1.55.1": - version: 1.56.1 - resolution: "playwright@npm:1.56.1" - dependencies: - fsevents: "npm:2.3.2" - playwright-core: "npm:1.56.1" - dependenciesMeta: - fsevents: - optional: true - bin: - playwright: cli.js - checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4 + checksum: 10c0/ab03c99a67b835bdea9059f516ad3b6e42c21025f9adaa161a4ef6bc7ca716dcba476d287140bb240d06126eb23f889a8933b8f5f1f1a56b80659d92d1358899 languageName: node linkType: hard From 0836eef1a6a5bf95782315de891e39fa4153c0b3 Mon Sep 17 00:00:00 2001 From: xerxesliu Date: Thu, 27 Nov 2025 20:22:27 +0800 Subject: [PATCH 08/17] fix: store JSON custom parameters as strings instead of objects (#11501) (#11503) Previously, JSON-type custom parameters were incorrectly parsed and stored as objects in the UI layer, causing API requests to fail when getCustomParameters() attempted to JSON.parse() an already-parsed object. Changes: - AssistantModelSettings.tsx: Remove JSON.parse() in onChange handler, store as string - reasoning.ts: Add comments explaining JSON parsing flow - BaseApiClient.ts: Add comments for legacy API clients --- .../src/aiCore/legacy/clients/BaseApiClient.ts | 3 +++ src/renderer/src/aiCore/utils/reasoning.ts | 4 ++++ .../AssistantModelSettings.tsx | 18 ++++++++++++------ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts index c1c06b359b..e755ce3f20 100644 --- a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts @@ -405,6 +405,9 @@ export abstract class BaseApiClient< if (!param.name?.trim()) { return acc } + // Parse JSON type parameters (Legacy API clients) + // Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148 + // The UI stores JSON type params as strings, this function parses them before sending to API if (param.type === 'json') { const value = param.value as string if (value === 'undefined') { diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index ba4ab35f8e..8f0df91e7b 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -684,6 +684,10 @@ export function getCustomParameters(assistant: Assistant): Record { if (!param.name?.trim()) { return acc } + // Parse JSON type parameters + // Related: src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx:133-148 + // The UI stores JSON type params as strings (e.g., '{"key":"value"}') + // This function parses them into objects before sending to the API if (param.type === 'json') { const value = param.value as string if (value === 'undefined') { diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index c452096d47..bc594235a7 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -135,12 +135,18 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA { - try { - const jsonValue = JSON.parse(e.target.value) - onUpdateCustomParameter(index, 'value', jsonValue) - } catch { - onUpdateCustomParameter(index, 'value', e.target.value) - } + // For JSON type parameters, always store the value as a STRING + // + // Data Flow: + // 1. UI stores: { name: "config", value: '{"key":"value"}', type: "json" } ← STRING format + // 2. API parses: getCustomParameters() in src/renderer/src/aiCore/utils/reasoning.ts:687-696 + // calls JSON.parse() to convert string to object + // 3. Request sends: The parsed object is sent to the AI provider + // + // Previously this code was parsing JSON here and storing + // the object directly, which caused getCustomParameters() to fail when trying + // to JSON.parse() an already-parsed object. + onUpdateCustomParameter(index, 'value', e.target.value) }} /> ) From 1746e8b21f14e84b7912da1be3bec945cedf98fd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 21:50:51 +0800 Subject: [PATCH 09/17] Fix MCP server confusion when multiple instances of the same server are configured (#10897) * Initial plan * Fix MCP server confusion by making tool IDs unique with serverId Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * Run yarn format to fix code formatting Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * Fix unit test: allow dash separator in tool names Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * Fix edge cases: preserve suffix on truncation, handle non-alphanumeric serverId Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --- src/main/services/MCPService.ts | 2 +- src/main/utils/__tests__/mcp.test.ts | 196 +++++++++++++++++++++++++++ src/main/utils/mcp.ts | 31 ++++- 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/main/utils/__tests__/mcp.test.ts diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 3831d0af1e..0b8db73930 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -620,7 +620,7 @@ class McpService { tools.map((tool: SDKTool) => { const serverTool: MCPTool = { ...tool, - id: buildFunctionCallToolName(server.name, tool.name), + id: buildFunctionCallToolName(server.name, tool.name, server.id), serverId: server.id, serverName: server.name, type: 'mcp' diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts new file mode 100644 index 0000000000..b1a35f925e --- /dev/null +++ b/src/main/utils/__tests__/mcp.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' + +import { buildFunctionCallToolName } from '../mcp' + +describe('buildFunctionCallToolName', () => { + describe('basic functionality', () => { + it('should combine server name and tool name', () => { + const result = buildFunctionCallToolName('github', 'search_issues') + expect(result).toContain('github') + expect(result).toContain('search') + }) + + it('should sanitize names by replacing dashes with underscores', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool') + // Input dashes are replaced, but the separator between server and tool is a dash + expect(result).toBe('my_serv-my_tool') + expect(result).toContain('_') + }) + + it('should handle empty server names gracefully', () => { + const result = buildFunctionCallToolName('', 'tool') + expect(result).toBeTruthy() + }) + }) + + describe('uniqueness with serverId', () => { + it('should generate different IDs for same server name but different serverIds', () => { + const serverId1 = 'server-id-123456' + const serverId2 = 'server-id-789012' + const serverName = 'github' + const toolName = 'search_repos' + + const result1 = buildFunctionCallToolName(serverName, toolName, serverId1) + const result2 = buildFunctionCallToolName(serverName, toolName, serverId2) + + expect(result1).not.toBe(result2) + expect(result1).toContain('123456') + expect(result2).toContain('789012') + }) + + it('should generate same ID when serverId is not provided', () => { + const serverName = 'github' + const toolName = 'search_repos' + + const result1 = buildFunctionCallToolName(serverName, toolName) + const result2 = buildFunctionCallToolName(serverName, toolName) + + expect(result1).toBe(result2) + }) + + it('should include serverId suffix when provided', () => { + const serverId = 'abc123def456' + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should include last 6 chars of serverId + expect(result).toContain('ef456') + }) + }) + + describe('character sanitization', () => { + it('should replace invalid characters with underscores', () => { + const result = buildFunctionCallToolName('test@server', 'tool#name') + expect(result).not.toMatch(/[@#]/) + expect(result).toMatch(/^[a-zA-Z0-9_-]+$/) + }) + + it('should ensure name starts with a letter', () => { + const result = buildFunctionCallToolName('123server', '456tool') + expect(result).toMatch(/^[a-zA-Z]/) + }) + + it('should handle consecutive underscores/dashes', () => { + const result = buildFunctionCallToolName('my--server', 'my__tool') + expect(result).not.toMatch(/[_-]{2,}/) + }) + }) + + describe('length constraints', () => { + it('should truncate names longer than 63 characters', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') + + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should not end with underscore or dash after truncation', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') + + expect(result).not.toMatch(/[_-]$/) + }) + + it('should preserve serverId suffix even with long server/tool names', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const serverId = 'server-id-xyz789' + + const result = buildFunctionCallToolName(longServerName, longToolName, serverId) + + // The suffix should be preserved and not truncated + expect(result).toContain('xyz789') + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should ensure two long-named servers with different IDs produce different results', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const serverId1 = 'server-id-abc123' + const serverId2 = 'server-id-def456' + + const result1 = buildFunctionCallToolName(longServerName, longToolName, serverId1) + const result2 = buildFunctionCallToolName(longServerName, longToolName, serverId2) + + // Both should be within limit + expect(result1.length).toBeLessThanOrEqual(63) + expect(result2.length).toBeLessThanOrEqual(63) + + // They should be different due to preserved suffix + expect(result1).not.toBe(result2) + }) + }) + + describe('edge cases with serverId', () => { + it('should handle serverId with only non-alphanumeric characters', () => { + const serverId = '------' // All dashes + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should still produce a valid unique suffix via fallback hash + expect(result).toBeTruthy() + expect(result.length).toBeLessThanOrEqual(63) + expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + // Should have a suffix (underscore followed by something) + expect(result).toMatch(/_[a-z0-9]+$/) + }) + + it('should produce different results for different non-alphanumeric serverIds', () => { + const serverId1 = '------' + const serverId2 = '!!!!!!' + + const result1 = buildFunctionCallToolName('server', 'tool', serverId1) + const result2 = buildFunctionCallToolName('server', 'tool', serverId2) + + // Should be different because the hash fallback produces different values + expect(result1).not.toBe(result2) + }) + + it('should handle empty string serverId differently from undefined', () => { + const resultWithEmpty = buildFunctionCallToolName('server', 'tool', '') + const resultWithUndefined = buildFunctionCallToolName('server', 'tool', undefined) + + // Empty string is falsy, so both should behave the same (no suffix) + expect(resultWithEmpty).toBe(resultWithUndefined) + }) + + it('should handle serverId with mixed alphanumeric and special chars', () => { + const serverId = 'ab@#cd' // Mixed chars, last 6 chars contain some alphanumeric + const result = buildFunctionCallToolName('server', 'tool', serverId) + + // Should extract alphanumeric chars: 'abcd' from 'ab@#cd' + expect(result).toContain('abcd') + }) + }) + + describe('real-world scenarios', () => { + it('should handle GitHub MCP server instances correctly', () => { + const serverName = 'github' + const toolName = 'search_repositories' + + const githubComId = 'server-github-com-abc123' + const gheId = 'server-ghe-internal-xyz789' + + const tool1 = buildFunctionCallToolName(serverName, toolName, githubComId) + const tool2 = buildFunctionCallToolName(serverName, toolName, gheId) + + // Should be different + expect(tool1).not.toBe(tool2) + + // Both should be valid identifiers + expect(tool1).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + expect(tool2).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) + + // Both should be <= 63 chars + expect(tool1.length).toBeLessThanOrEqual(63) + expect(tool2.length).toBeLessThanOrEqual(63) + }) + + it('should handle tool names that already include server name prefix', () => { + const result = buildFunctionCallToolName('github', 'github_search_repos') + expect(result).toBeTruthy() + // Should not double the server name + expect(result.split('github').length - 1).toBeLessThanOrEqual(2) + }) + }) +}) diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts index 23d19806d9..cfa700f2e6 100644 --- a/src/main/utils/mcp.ts +++ b/src/main/utils/mcp.ts @@ -1,7 +1,25 @@ -export function buildFunctionCallToolName(serverName: string, toolName: string) { +export function buildFunctionCallToolName(serverName: string, toolName: string, serverId?: string) { const sanitizedServer = serverName.trim().replace(/-/g, '_') const sanitizedTool = toolName.trim().replace(/-/g, '_') + // Calculate suffix first to reserve space for it + // Suffix format: "_" + 6 alphanumeric chars = 7 chars total + let serverIdSuffix = '' + if (serverId) { + // Take the last 6 characters of the serverId for brevity + serverIdSuffix = serverId.slice(-6).replace(/[^a-zA-Z0-9]/g, '') + + // Fallback: if suffix becomes empty (all non-alphanumeric chars), use a simple hash + if (!serverIdSuffix) { + const hash = serverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) + serverIdSuffix = hash.toString(36).slice(-6) || 'x' + } + } + + // Reserve space for suffix when calculating max base name length + const SUFFIX_LENGTH = serverIdSuffix ? serverIdSuffix.length + 1 : 0 // +1 for underscore + const MAX_BASE_LENGTH = 63 - SUFFIX_LENGTH + // Combine server name and tool name let name = sanitizedTool if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) { @@ -20,9 +38,9 @@ export function buildFunctionCallToolName(serverName: string, toolName: string) // Remove consecutive underscores/dashes (optional improvement) name = name.replace(/[_-]{2,}/g, '_') - // Truncate to 63 characters maximum - if (name.length > 63) { - name = name.slice(0, 63) + // Truncate base name BEFORE adding suffix to ensure suffix is never cut off + if (name.length > MAX_BASE_LENGTH) { + name = name.slice(0, MAX_BASE_LENGTH) } // Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges @@ -30,5 +48,10 @@ export function buildFunctionCallToolName(serverName: string, toolName: string) name = name.slice(0, -1) } + // Now append the suffix - it will always fit within 63 chars + if (serverIdSuffix) { + name = `${name}_${serverIdSuffix}` + } + return name } From 0d12b5fbc2257b8ef25f4513a8a7a4757f7d3e15 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 27 Nov 2025 22:22:04 +0800 Subject: [PATCH 10/17] fix(SelectModelPopup): memoize adapted models to avoid unnecessary updates (#11506) fix(SelectModelPopup): memoize adapted models to avoid unnecessary update --- .../src/components/Popups/SelectModelPopup/api-model-popup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx index df4dbb0485..3924d6b57f 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx @@ -57,7 +57,7 @@ const PopupContainer: React.FC = ({ model, apiFilter, modelFilter, showTa const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) const { models, isLoading } = useApiModels(apiFilter) - const adaptedModels = models.map((model) => apiModelAdapter(model)) + const adaptedModels = useMemo(() => models.map((model) => apiModelAdapter(model)), [models]) // 当前选中的模型ID const currentModelId = model ? model.id : '' From bf35902696ce9f374b9f0a3d299faee8d51efa21 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 27 Nov 2025 22:35:24 +0800 Subject: [PATCH 11/17] fix(mcp): ensure tool uniqueness by using tool IDs for multiple server instances (#11508) --- .../src/aiCore/utils/__tests__/mcp.test.ts | 47 ++++++++++--------- src/renderer/src/aiCore/utils/mcp.ts | 4 +- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts index a832e9f632..dc26a03c80 100644 --- a/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/mcp.test.ts @@ -71,10 +71,11 @@ describe('mcp utils', () => { const result = setupToolsConfig(mcpTools) expect(result).not.toBeUndefined() - expect(Object.keys(result!)).toEqual(['test-tool']) - expect(result!['test-tool']).toHaveProperty('description') - expect(result!['test-tool']).toHaveProperty('inputSchema') - expect(result!['test-tool']).toHaveProperty('execute') + // Tools are now keyed by id (which includes serverId suffix) for uniqueness + expect(Object.keys(result!)).toEqual(['test-tool-1']) + expect(result!['test-tool-1']).toHaveProperty('description') + expect(result!['test-tool-1']).toHaveProperty('inputSchema') + expect(result!['test-tool-1']).toHaveProperty('execute') }) it('should handle multiple MCP tools', () => { @@ -109,7 +110,8 @@ describe('mcp utils', () => { expect(result).not.toBeUndefined() expect(Object.keys(result!)).toHaveLength(2) - expect(Object.keys(result!)).toEqual(['tool1', 'tool2']) + // Tools are keyed by id for uniqueness + expect(Object.keys(result!)).toEqual(['tool1-id', 'tool2-id']) }) }) @@ -135,9 +137,10 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['get-weather']) + // Tools are keyed by id for uniqueness when multiple server instances exist + expect(Object.keys(result)).toEqual(['get-weather-id']) - const tool = result['get-weather'] as Tool + const tool = result['get-weather-id'] as Tool expect(tool.description).toBe('Get weather information') expect(tool.inputSchema).toBeDefined() expect(typeof tool.execute).toBe('function') @@ -160,8 +163,8 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['no-desc-tool']) - const tool = result['no-desc-tool'] as Tool + expect(Object.keys(result)).toEqual(['no-desc-tool-id']) + const tool = result['no-desc-tool-id'] as Tool expect(tool.description).toBe('Tool from test-server') }) @@ -202,13 +205,13 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['complex-tool']) - const tool = result['complex-tool'] as Tool + expect(Object.keys(result)).toEqual(['complex-tool-id']) + const tool = result['complex-tool-id'] as Tool expect(tool.inputSchema).toBeDefined() expect(typeof tool.execute).toBe('function') }) - it('should preserve tool names with special characters', () => { + it('should preserve tool id with special characters', () => { const mcpTools: MCPTool[] = [ { id: 'special-tool-id', @@ -225,7 +228,8 @@ describe('mcp utils', () => { ] const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result)).toEqual(['tool_with-special.chars']) + // Tools are keyed by id for uniqueness + expect(Object.keys(result)).toEqual(['special-tool-id']) }) it('should handle multiple tools with different schemas', () => { @@ -276,10 +280,11 @@ describe('mcp utils', () => { const result = convertMcpToolsToAiSdkTools(mcpTools) - expect(Object.keys(result).sort()).toEqual(['boolean-tool', 'number-tool', 'string-tool']) - expect(result['string-tool']).toBeDefined() - expect(result['number-tool']).toBeDefined() - expect(result['boolean-tool']).toBeDefined() + // Tools are keyed by id for uniqueness + expect(Object.keys(result).sort()).toEqual(['boolean-tool-id', 'number-tool-id', 'string-tool-id']) + expect(result['string-tool-id']).toBeDefined() + expect(result['number-tool-id']).toBeDefined() + expect(result['boolean-tool-id']).toBeDefined() }) }) @@ -310,7 +315,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['test-exec-tool'] as Tool + const tool = tools['test-exec-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'test-call-123' }) expect(requestToolConfirmation).toHaveBeenCalled() @@ -343,7 +348,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['cancelled-tool'] as Tool + const tool = tools['cancelled-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'cancel-call-123' }) expect(requestToolConfirmation).toHaveBeenCalled() @@ -385,7 +390,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['error-tool'] as Tool + const tool = tools['error-tool-id'] as Tool await expect( tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'error-call-123' }) @@ -421,7 +426,7 @@ describe('mcp utils', () => { ] const tools = convertMcpToolsToAiSdkTools(mcpTools) - const tool = tools['auto-approve-tool'] as Tool + const tool = tools['auto-approve-tool-id'] as Tool const result = await tool.execute!({}, { messages: [], abortSignal: undefined, toolCallId: 'auto-call-123' }) expect(requestToolConfirmation).not.toHaveBeenCalled() diff --git a/src/renderer/src/aiCore/utils/mcp.ts b/src/renderer/src/aiCore/utils/mcp.ts index 84bc661aa0..7d3be9ac96 100644 --- a/src/renderer/src/aiCore/utils/mcp.ts +++ b/src/renderer/src/aiCore/utils/mcp.ts @@ -28,7 +28,9 @@ export function convertMcpToolsToAiSdkTools(mcpTools: MCPTool[]): ToolSet { const tools: ToolSet = {} for (const mcpTool of mcpTools) { - tools[mcpTool.name] = tool({ + // Use mcpTool.id (which includes serverId suffix) to ensure uniqueness + // when multiple instances of the same MCP server type are configured + tools[mcpTool.id] = tool({ description: mcpTool.description || `Tool from ${mcpTool.serverName}`, inputSchema: jsonSchema(mcpTool.inputSchema as JSONSchema7), execute: async (params, { toolCallId }) => { From 77a9504f74578a1aa18e999a5e715533dff978e3 Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 27 Nov 2025 22:45:43 +0800 Subject: [PATCH 12/17] Fix/condition OpenAI settings (#11509) * fix(provider): update service tier support logic for OpenAI and Azure providers * fix(settings): enhance OpenAI settings visibility logic with verbosity support --- src/renderer/src/pages/home/Tabs/SettingsTab.tsx | 9 ++++++--- src/renderer/src/utils/provider.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 31dcfe437e..57dac8c78a 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -9,7 +9,7 @@ import { DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@renderer/config/constant' -import { isOpenAIModel } from '@renderer/config/models' +import { isOpenAIModel, isSupportVerbosityModel } from '@renderer/config/models' import { UNKNOWN } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTheme } from '@renderer/context/ThemeProvider' @@ -56,7 +56,7 @@ import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from import { isGroqSystemProvider, ThemeMode } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' -import { isSupportServiceTierProvider } from '@renderer/utils/provider' +import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@renderer/utils/provider' import { Button, Col, InputNumber, Row, Slider, Switch } from 'antd' import { Settings2 } from 'lucide-react' import type { FC } from 'react' @@ -183,7 +183,10 @@ const SettingsTab: FC = (props) => { const model = assistant.model || getDefaultModel() - const showOpenAiSettings = isOpenAIModel(model) || isSupportServiceTierProvider(provider) + const showOpenAiSettings = + isOpenAIModel(model) || + isSupportServiceTierProvider(provider) || + (isSupportVerbosityModel(model) && isSupportVerbosityProvider(provider)) return ( diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index e8fc1b5cc7..66b4d708d7 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -71,15 +71,21 @@ export const isSupportEnableThinkingProvider = (provider: Provider) => { ) } -const NOT_SUPPORT_SERVICE_TIER_PROVIDERS = ['github', 'copilot', 'cerebras'] as const satisfies SystemProviderId[] +const SUPPORT_SERVICE_TIER_PROVIDERS = [ + SystemProviderIds.openai, + SystemProviderIds['azure-openai'], + SystemProviderIds.groq + // TODO: 等待上游支持aws-bedrock +] /** - * 判断提供商是否支持 service_tier 设置。 Only for OpenAI API. + * 判断提供商是否支持 service_tier 设置 */ export const isSupportServiceTierProvider = (provider: Provider) => { return ( provider.apiOptions?.isSupportServiceTier === true || - (isSystemProvider(provider) && !NOT_SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) + provider.type === 'azure-openai' || + (isSystemProvider(provider) && SUPPORT_SERVICE_TIER_PROVIDERS.some((pid) => pid === provider.id)) ) } From 7ce1590eafc2fba66e346d9d48519afdb1dfd17e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Nov 2025 10:12:21 +0800 Subject: [PATCH 13/17] fix: add null checks and type guards to all MessageAgentTools to prevent rendering errors (#11512) * Initial plan * fix: add null checks to BashTool to prevent rendering errors Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: add null checks to all MessageAgentTools to prevent rendering errors Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: add Array.isArray checks to prevent map errors on non-array values Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * fix: add typeof checks for string operations to prevent type errors Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * refactor: remove redundant typeof string checks for typed outputs Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --- .../Tools/MessageAgentTools/BashOutputTool.tsx | 4 ++-- .../Messages/Tools/MessageAgentTools/BashTool.tsx | 8 ++++---- .../Messages/Tools/MessageAgentTools/EditTool.tsx | 8 ++++---- .../Tools/MessageAgentTools/ExitPlanModeTool.tsx | 7 ++++--- .../Messages/Tools/MessageAgentTools/GlobTool.tsx | 4 ++-- .../Messages/Tools/MessageAgentTools/GrepTool.tsx | 6 +++--- .../Tools/MessageAgentTools/MultiEditTool.tsx | 11 ++++++----- .../Tools/MessageAgentTools/NotebookEditTool.tsx | 6 +++--- .../Messages/Tools/MessageAgentTools/ReadTool.tsx | 4 ++-- .../Tools/MessageAgentTools/SearchTool.tsx | 6 +++--- .../Tools/MessageAgentTools/SkillTool.tsx | 4 ++-- .../Messages/Tools/MessageAgentTools/TaskTool.tsx | 15 ++++++++------- .../Tools/MessageAgentTools/TodoWriteTool.tsx | 9 +++++---- .../Tools/MessageAgentTools/WebFetchTool.tsx | 4 ++-- .../Tools/MessageAgentTools/WebSearchTool.tsx | 4 ++-- .../Tools/MessageAgentTools/WriteTool.tsx | 6 +++--- 16 files changed, 55 insertions(+), 51 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx index b47bb3f64a..39d72abcf8 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/BashOutputTool.tsx @@ -76,7 +76,7 @@ export function BashOutputTool({ input, output }: { - input: BashOutputToolInput + input?: BashOutputToolInput output?: BashOutputToolOutput }): NonNullable[number] { const parsedOutput = parseBashOutput(output) @@ -144,7 +144,7 @@ export function BashOutputTool({ label="Bash Output" params={

- {input.bash_id} + {input?.bash_id} {statusConfig && ( [number] { // 如果有输出,计算输出行数 const outputLines = output ? output.split('\n').length : 0 - // 处理命令字符串的截断 - const command = input.command + // 处理命令字符串的截断,添加空值检查 + const command = input?.command ?? '' const needsTruncate = command.length > MAX_TAG_LENGTH const displayCommand = needsTruncate ? `${command.slice(0, MAX_TAG_LENGTH)}...` : command @@ -31,7 +31,7 @@ export function BashTool({ } label="Bash" - params={input.description} + params={input?.description} stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined} />
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx index a49a89664d..3eff8118ef 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/EditTool.tsx @@ -32,19 +32,19 @@ export function EditTool({ input, output }: { - input: EditToolInput + input?: EditToolInput output?: EditToolOutput }): NonNullable[number] { return { key: AgentToolsType.Edit, - label: } label="Edit" params={input.file_path} />, + label: } label="Edit" params={input?.file_path} />, children: ( <> {/* Diff View */} {/* Old Content */} - {renderCodeBlock(input.old_string, 'old')} + {renderCodeBlock(input?.old_string ?? '', 'old')} {/* New Content */} - {renderCodeBlock(input.new_string, 'new')} + {renderCodeBlock(input?.new_string ?? '', 'new')} {/* Output */} {output} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx index 0c0a4ec4a7..f92116478d 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ExitPlanModeTool.tsx @@ -10,18 +10,19 @@ export function ExitPlanModeTool({ input, output }: { - input: ExitPlanModeToolInput + input?: ExitPlanModeToolInput output?: ExitPlanModeToolOutput }): NonNullable[number] { + const plan = input?.plan ?? '' return { key: AgentToolsType.ExitPlanMode, label: ( } label="ExitPlanMode" - stats={`${input.plan.split('\n\n').length} plans`} + stats={`${plan.split('\n\n').length} plans`} /> ), - children: {input.plan + '\n\n' + (output ?? '')} + children: {plan + '\n\n' + (output ?? '')} } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx index 97e816be1d..b70d6da40e 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GlobTool.tsx @@ -8,7 +8,7 @@ export function GlobTool({ input, output }: { - input: GlobToolInputType + input?: GlobToolInputType output?: GlobToolOutputType }): NonNullable[number] { // 如果有输出,计算文件数量 @@ -20,7 +20,7 @@ export function GlobTool({ } label="Glob" - params={input.pattern} + params={input?.pattern} stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx index dbf7e0bbf1..16149549df 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/GrepTool.tsx @@ -8,7 +8,7 @@ export function GrepTool({ input, output }: { - input: GrepToolInput + input?: GrepToolInput output?: GrepToolOutput }): NonNullable[number] { // 如果有输出,计算结果行数 @@ -22,8 +22,8 @@ export function GrepTool({ label="Grep" params={ <> - {input.pattern} - {input.output_mode && ({input.output_mode})} + {input?.pattern} + {input?.output_mode && ({input.output_mode})} } stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined} diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx index 546fd439dc..00922126e7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/MultiEditTool.tsx @@ -9,18 +9,19 @@ import { AgentToolsType } from './types' export function MultiEditTool({ input }: { - input: MultiEditToolInput + input?: MultiEditToolInput output?: MultiEditToolOutput }): NonNullable[number] { + const edits = Array.isArray(input?.edits) ? input.edits : [] return { key: AgentToolsType.MultiEdit, - label: } label="MultiEdit" params={input.file_path} />, + label: } label="MultiEdit" params={input?.file_path} />, children: (
- {input.edits.map((edit, index) => ( + {edits.map((edit, index) => (
- {renderCodeBlock(edit.old_string, 'old')} - {renderCodeBlock(edit.new_string, 'new')} + {renderCodeBlock(edit.old_string ?? '', 'old')} + {renderCodeBlock(edit.new_string ?? '', 'new')}
))}
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx index 8f9eb36a2e..fe0638f3c9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/NotebookEditTool.tsx @@ -11,7 +11,7 @@ export function NotebookEditTool({ input, output }: { - input: NotebookEditToolInput + input?: NotebookEditToolInput output?: NotebookEditToolOutput }): NonNullable[number] { return { @@ -20,10 +20,10 @@ export function NotebookEditTool({ <> } label="NotebookEdit" /> - {input.notebook_path}{' '} + {input?.notebook_path}{' '} ), - children: {output} + children: {output ?? ''} } } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx index 043d8a94c4..30ae162276 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/ReadTool.tsx @@ -46,7 +46,7 @@ export function ReadTool({ input, output }: { - input: ReadToolInputType + input?: ReadToolInputType output?: ReadToolOutputType }): NonNullable[number] { const outputString = normalizeOutputString(output) @@ -58,7 +58,7 @@ export function ReadTool({ } label="Read File" - params={input.file_path.split('/').pop()} + params={input?.file_path?.split('/').pop()} stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx index 8eda9dea5f..66bf28c671 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SearchTool.tsx @@ -8,7 +8,7 @@ export function SearchTool({ input, output }: { - input: SearchToolInputType + input?: SearchToolInputType output?: SearchToolOutputType }): NonNullable[number] { // 如果有输出,计算结果数量 @@ -20,13 +20,13 @@ export function SearchTool({ } label="Search" - params={`"${input}"`} + params={input ? `"${input}"` : undefined} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} /> ), children: (
- + {input && } {output && (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx index 1c0651a9e1..6127984676 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/SkillTool.tsx @@ -8,12 +8,12 @@ export function SkillTool({ input, output }: { - input: SkillToolInput + input?: SkillToolInput output?: SkillToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Skill" params={input.command} />, + label: } label="Skill" params={input?.command} />, children:
{output}
} } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx index 2c5a4a1c73..18117590c7 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TaskTool.tsx @@ -9,19 +9,20 @@ export function TaskTool({ input, output }: { - input: TaskToolInputType + input?: TaskToolInputType output?: TaskToolOutputType }): NonNullable[number] { return { key: 'tool', - label: } label="Task" params={input.description} />, + label: } label="Task" params={input?.description} />, children: (
- {output?.map((item) => ( -
-
{item.type === 'text' ? {item.text} : item.text}
-
- ))} + {Array.isArray(output) && + output.map((item) => ( +
+
{item.type === 'text' ? {item.text} : item.text}
+
+ ))}
) } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx index 2796e44fc9..a81de46dcd 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/TodoWriteTool.tsx @@ -38,9 +38,10 @@ const getStatusConfig = (status: TodoItem['status']) => { export function TodoWriteTool({ input }: { - input: TodoWriteToolInputType + input?: TodoWriteToolInputType }): NonNullable[number] { - const doneCount = input.todos.filter((todo) => todo.status === 'completed').length + const todos = Array.isArray(input?.todos) ? input.todos : [] + const doneCount = todos.filter((todo) => todo.status === 'completed').length return { key: AgentToolsType.TodoWrite, @@ -49,12 +50,12 @@ export function TodoWriteTool({ icon={} label="Todo Write" params={`${doneCount} Done`} - stats={`${input.todos.length} ${input.todos.length === 1 ? 'item' : 'items'}`} + stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`} /> ), children: (
- {input.todos.map((todo, index) => { + {todos.map((todo, index) => { const statusConfig = getStatusConfig(todo.status) return (
diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx index f54c541459..f8bd27df5f 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebFetchTool.tsx @@ -8,12 +8,12 @@ export function WebFetchTool({ input, output }: { - input: WebFetchToolInput + input?: WebFetchToolInput output?: WebFetchToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Web Fetch" params={input.url} />, + label: } label="Web Fetch" params={input?.url} />, children:
{output}
} } diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx index 7042c63afb..4f50839cc9 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WebSearchTool.tsx @@ -8,7 +8,7 @@ export function WebSearchTool({ input, output }: { - input: WebSearchToolInput + input?: WebSearchToolInput output?: WebSearchToolOutput }): NonNullable[number] { // 如果有输出,计算结果数量 @@ -20,7 +20,7 @@ export function WebSearchTool({ } label="Web Search" - params={input.query} + params={input?.query} stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined} /> ), diff --git a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx index d035163dcc..fd0d637f50 100644 --- a/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx +++ b/src/renderer/src/pages/home/Messages/Tools/MessageAgentTools/WriteTool.tsx @@ -7,12 +7,12 @@ import type { WriteToolInput, WriteToolOutput } from './types' export function WriteTool({ input }: { - input: WriteToolInput + input?: WriteToolInput output?: WriteToolOutput }): NonNullable[number] { return { key: 'tool', - label: } label="Write" params={input.file_path} />, - children:
{input.content}
+ label: } label="Write" params={input?.file_path} />, + children:
{input?.content}
} } From b18c64b7251c3a3e5e9395b25d6b71fc8567b809 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 28 Nov 2025 11:00:02 +0800 Subject: [PATCH 14/17] feat: enhance support for AWS Bedrock and Azure OpenAI providers (#11510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance support for AWS Bedrock and Azure OpenAI providers * fix: resolve PR review issues for AWS Bedrock support - Fix header.ts logic bug: change && to || for Vertex/Bedrock provider check - Fix regex in reasoning.ts to match AWS Bedrock model format (anthropic.claude-*) - Add test coverage for AWS Bedrock format in isClaude4SeriesModel - Add Bedrock provider tests including anthropicBeta parameter 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../src/aiCore/prepareParams/header.ts | 2 +- .../aiCore/prepareParams/parameterBuilder.ts | 3 +- .../__tests__/integratedRegistry.test.ts | 32 +++++++++- .../aiCore/utils/__tests__/options.test.ts | 63 +++++++++++++++++++ src/renderer/src/aiCore/utils/options.ts | 6 ++ .../config/models/__tests__/reasoning.test.ts | 3 + src/renderer/src/config/models/reasoning.ts | 6 +- src/renderer/src/utils/provider.ts | 1 + 8 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/aiCore/prepareParams/header.ts b/src/renderer/src/aiCore/prepareParams/header.ts index 19d4611377..615f07db35 100644 --- a/src/renderer/src/aiCore/prepareParams/header.ts +++ b/src/renderer/src/aiCore/prepareParams/header.ts @@ -17,7 +17,7 @@ export function addAnthropicHeaders(assistant: Assistant, model: Model): string[ if ( isClaude45ReasoningModel(model) && isToolUseModeFunction(assistant) && - !(isVertexProvider(provider) && isAwsBedrockProvider(provider)) + !(isVertexProvider(provider) || isAwsBedrockProvider(provider)) ) { anthropicHeaders.push(INTERLEAVED_THINKING_HEADER) } diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index dda3bd0b47..c977745a39 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -28,6 +28,7 @@ import { type Assistant, type MCPTool, type Provider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import { replacePromptVariables } from '@renderer/utils/prompt' +import { isAwsBedrockProvider } from '@renderer/utils/provider' import type { ModelMessage, Tool } from 'ai' import { stepCountIs } from 'ai' @@ -175,7 +176,7 @@ export async function buildStreamTextParams( let headers: Record = options.requestOptions?.headers ?? {} - if (isAnthropicModel(model)) { + if (isAnthropicModel(model) && !isAwsBedrockProvider(provider)) { const newBetaHeaders = { 'anthropic-beta': addAnthropicHeaders(assistant, model).join(',') } headers = combineHeaders(headers, newBetaHeaders) } diff --git a/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts b/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts index 1e8b1a9547..9b2c0639e2 100644 --- a/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/integratedRegistry.test.ts @@ -1,4 +1,4 @@ -import type { Provider } from '@renderer/types' +import type { Model, Provider } from '@renderer/types' import { describe, expect, it, vi } from 'vitest' import { getAiSdkProviderId } from '../factory' @@ -68,6 +68,18 @@ function createTestProvider(id: string, type: string): Provider { } as Provider } +function createAzureProvider(id: string, apiVersion?: string, model?: string): Provider { + return { + id, + type: 'azure-openai', + name: `Azure Test ${id}`, + apiKey: 'azure-test-key', + apiHost: 'azure-test-host', + apiVersion, + models: [{ id: model || 'gpt-4' } as Model] + } +} + describe('Integrated Provider Registry', () => { describe('Provider ID Resolution', () => { it('should resolve openrouter provider correctly', () => { @@ -111,6 +123,24 @@ describe('Integrated Provider Registry', () => { const result = getAiSdkProviderId(unknownProvider) expect(result).toBe('unknown-provider') }) + + it('should handle Azure OpenAI providers correctly', () => { + const azureProvider = createAzureProvider('azure-test', '2024-02-15', 'gpt-4o') + const result = getAiSdkProviderId(azureProvider) + expect(result).toBe('azure') + }) + + it('should handle Azure OpenAI providers response endpoint correctly', () => { + const azureProvider = createAzureProvider('azure-test', 'v1', 'gpt-4o') + const result = getAiSdkProviderId(azureProvider) + expect(result).toBe('azure-responses') + }) + + it('should handle Azure provider Claude Models', () => { + const provider = createTestProvider('azure-anthropic', 'anthropic') + const result = getAiSdkProviderId(provider) + expect(result).toBe('azure-anthropic') + }) }) describe('Backward Compatibility', () => { diff --git a/src/renderer/src/aiCore/utils/__tests__/options.test.ts b/src/renderer/src/aiCore/utils/__tests__/options.test.ts index 8f2629f4d8..ca6b883d74 100644 --- a/src/renderer/src/aiCore/utils/__tests__/options.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/options.test.ts @@ -154,6 +154,10 @@ vi.mock('../websearch', () => ({ getWebSearchParams: vi.fn(() => ({ enable_search: true })) })) +vi.mock('../../prepareParams/header', () => ({ + addAnthropicHeaders: vi.fn(() => ['context-1m-2025-08-07']) +})) + const ensureWindowApi = () => { const globalWindow = window as any globalWindow.api = globalWindow.api || {} @@ -633,5 +637,64 @@ describe('options utils', () => { expect(result.providerOptions).toHaveProperty('anthropic') }) }) + + describe('AWS Bedrock provider', () => { + const bedrockProvider = { + id: 'bedrock', + name: 'AWS Bedrock', + type: 'aws-bedrock', + apiKey: 'test-key', + apiHost: 'https://bedrock.us-east-1.amazonaws.com', + models: [] as Model[] + } as Provider + + const bedrockModel: Model = { + id: 'anthropic.claude-sonnet-4-20250514-v1:0', + name: 'Claude Sonnet 4', + provider: 'bedrock' + } as Model + + it('should build basic Bedrock options', () => { + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions).toHaveProperty('bedrock') + expect(result.providerOptions.bedrock).toBeDefined() + }) + + it('should include anthropicBeta when Anthropic headers are needed', async () => { + const { addAnthropicHeaders } = await import('../../prepareParams/header') + vi.mocked(addAnthropicHeaders).mockReturnValue(['interleaved-thinking-2025-05-14', 'context-1m-2025-08-07']) + + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: false, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions.bedrock).toHaveProperty('anthropicBeta') + expect(result.providerOptions.bedrock.anthropicBeta).toEqual([ + 'interleaved-thinking-2025-05-14', + 'context-1m-2025-08-07' + ]) + }) + + it('should include reasoning parameters when enabled', () => { + const result = buildProviderOptions(mockAssistant, bedrockModel, bedrockProvider, { + enableReasoning: true, + enableWebSearch: false, + enableGenerateImage: false + }) + + expect(result.providerOptions.bedrock).toHaveProperty('reasoningConfig') + expect(result.providerOptions.bedrock.reasoningConfig).toEqual({ + type: 'enabled', + budgetTokens: 5000 + }) + }) + }) }) }) diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 4fb6f07e1f..a1352a801a 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -36,6 +36,7 @@ import { isSupportServiceTierProvider, isSupportVerbosityProvider } from '@rende import type { JSONValue } from 'ai' import { t } from 'i18next' +import { addAnthropicHeaders } from '../prepareParams/header' import { getAiSdkProviderId } from '../provider/factory' import { buildGeminiGenerateImageParams } from './image' import { @@ -469,6 +470,11 @@ function buildBedrockProviderOptions( } } + const betaHeaders = addAnthropicHeaders(assistant, model) + if (betaHeaders.length > 0) { + providerOptions.anthropicBeta = betaHeaders + } + return providerOptions } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index d711659f98..3e8b268a64 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -309,11 +309,14 @@ describe('Ling Models', () => { describe('Claude & regional providers', () => { it('identifies claude 4.5 variants', () => { expect(isClaude45ReasoningModel(createModel({ id: 'claude-sonnet-4.5-preview' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'claude-sonnet-4-5@20250929' }))).toBe(true) expect(isClaude45ReasoningModel(createModel({ id: 'claude-3-sonnet' }))).toBe(false) }) it('identifies claude 4 variants', () => { expect(isClaude4SeriesModel(createModel({ id: 'claude-opus-4' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'claude-sonnet-4@20250514' }))).toBe(true) + expect(isClaude4SeriesModel(createModel({ id: 'anthropic.claude-sonnet-4-20250514-v1:0' }))).toBe(true) expect(isClaude4SeriesModel(createModel({ id: 'claude-4.2-sonnet-variant' }))).toBe(false) expect(isClaude4SeriesModel(createModel({ id: 'claude-3-haiku' }))).toBe(false) }) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 7572d56fde..dab918d5fd 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -396,7 +396,11 @@ export function isClaude45ReasoningModel(model: Model): boolean { export function isClaude4SeriesModel(model: Model): boolean { const modelId = getLowerBaseModelName(model.id, '/') - const regex = /claude-(sonnet|opus|haiku)-4(?:[.-]\d+)?(?:-[\w-]+)?$/i + // Supports various formats including: + // - Direct API: claude-sonnet-4, claude-opus-4-20250514 + // - GCP Vertex AI: claude-sonnet-4@20250514 + // - AWS Bedrock: anthropic.claude-sonnet-4-20250514-v1:0 + const regex = /claude-(sonnet|opus|haiku)-4(?:[.-]\d+)?(?:[@\-:][\w\-:]+)?$/i return regex.test(modelId) } diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts index 66b4d708d7..fae0aababa 100644 --- a/src/renderer/src/utils/provider.ts +++ b/src/renderer/src/utils/provider.ts @@ -108,6 +108,7 @@ const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [ 'gemini', 'vertexai', 'anthropic', + 'azure-openai', 'new-api' ] as const satisfies ProviderType[] From 5167c927bed4c13411b084990a74d2a2203d4854 Mon Sep 17 00:00:00 2001 From: Phantom Date: Fri, 28 Nov 2025 13:56:46 +0800 Subject: [PATCH 15/17] fix: preserve openrouter reasoning with web search (#11505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(options): implement deep merging for provider options Add deep merge functionality to preserve nested properties when combining provider options. The new implementation handles object merging recursively while maintaining type safety. * refactor(tsconfig): reorganize include paths in tsconfig files Clean up and reorder include paths for better maintainability and consistency between tsconfig.node.json and tsconfig.web.json * test: add aiCore test configuration and script Add new test configuration for aiCore package and corresponding test script in package.json to enable running tests specifically for the aiCore module. * fix: format * fix(aiCore): resolve test failures and update test infrastructure - Add vitest setup file with global mocks for @cherrystudio/ai-sdk-provider - Fix context assertions: use 'model' instead of 'modelId' in plugin tests - Fix error handling tests: update expected error messages to match actual behavior - Fix streamText tests: use 'maxOutputTokens' instead of 'maxTokens' - Fix schemas test: update expected provider list to match actual implementation - Fix mock-responses: use AI SDK v5 format (inputTokens/outputTokens) - Update vi.mock to use importOriginal for preserving jsonSchema export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): add alias mock for @cherrystudio/ai-sdk-provider in tests The vi.mock in setup file doesn't work for source code imports. Use vitest resolve.alias to mock the external package properly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): disable unused-vars warnings in mock file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): use import.meta.url for ESM compatibility in vitest config __dirname is not available in ESM modules, use fileURLToPath instead. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix(aiCore): use absolute paths in vitest config for workspace compatibility - Use path.resolve for setupFiles and all alias paths - Extend aiCore vitest.config.ts from root workspace config - Change aiCore test environment to 'node' instead of 'jsdom' 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * docs(factory): improve mergeProviderOptions documentation Add detailed explanation of merge behavior with examples * test(factory): add tests for mergeProviderOptions behavior Add test cases to verify mergeProviderOptions correctly handles primitive values, arrays, and nested objects during merging * refactor(tests): clean up mock responses test fixtures Remove unused mock streaming chunks and error responses to simplify test fixtures Update warning details structure in mock complete responses * docs(test): clarify comment in generateImage test Update comment to use consistent 'model id' terminology instead of 'modelId' * test(factory): verify array replacement in mergeProviderOptions --------- Co-authored-by: suyao Co-authored-by: Claude --- package.json | 1 + .../src/__tests__/fixtures/mock-responses.ts | 119 ++---------------- .../src/__tests__/mocks/ai-sdk-provider.ts | 35 ++++++ packages/aiCore/src/__tests__/setup.ts | 9 ++ .../core/options/__tests__/factory.test.ts | 109 ++++++++++++++++ packages/aiCore/src/core/options/factory.ts | 60 ++++++++- .../core/providers/__tests__/schemas.test.ts | 9 +- .../runtime/__tests__/generateImage.test.ts | 31 +++-- .../runtime/__tests__/generateText.test.ts | 15 ++- .../core/runtime/__tests__/streamText.test.ts | 22 ++-- packages/aiCore/vitest.config.ts | 12 +- tsconfig.node.json | 12 +- tsconfig.web.json | 14 +-- vitest.config.ts | 12 ++ 14 files changed, 308 insertions(+), 152 deletions(-) create mode 100644 packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts create mode 100644 packages/aiCore/src/__tests__/setup.ts create mode 100644 packages/aiCore/src/core/options/__tests__/factory.test.ts diff --git a/package.json b/package.json index de89b4514c..304785117e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test": "vitest run --silent", "test:main": "vitest run --project main", "test:renderer": "vitest run --project renderer", + "test:aicore": "vitest run --project aiCore", "test:update": "yarn test:renderer --update", "test:coverage": "vitest run --coverage --silent", "test:ui": "vitest --ui", diff --git a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts index 9855cfb36c..388a4f7fd5 100644 --- a/packages/aiCore/src/__tests__/fixtures/mock-responses.ts +++ b/packages/aiCore/src/__tests__/fixtures/mock-responses.ts @@ -3,12 +3,13 @@ * Provides realistic mock responses for all provider types */ -import { jsonSchema, type ModelMessage, type Tool } from 'ai' +import type { ModelMessage, Tool } from 'ai' +import { jsonSchema } from 'ai' /** * Standard test messages for all scenarios */ -export const testMessages = { +export const testMessages: Record = { simple: [{ role: 'user' as const, content: 'Hello, how are you?' }], conversation: [ @@ -45,7 +46,7 @@ export const testMessages = { { role: 'assistant' as const, content: '15 * 23 = 345' }, { role: 'user' as const, content: 'Now divide that by 5' } ] -} satisfies Record +} /** * Standard test tools for tool calling scenarios @@ -138,68 +139,17 @@ export const testTools: Record = { } } -/** - * Mock streaming chunks for different providers - */ -export const mockStreamingChunks = { - text: [ - { type: 'text-delta' as const, textDelta: 'Hello' }, - { type: 'text-delta' as const, textDelta: ', ' }, - { type: 'text-delta' as const, textDelta: 'this ' }, - { type: 'text-delta' as const, textDelta: 'is ' }, - { type: 'text-delta' as const, textDelta: 'a ' }, - { type: 'text-delta' as const, textDelta: 'test.' } - ], - - withToolCall: [ - { type: 'text-delta' as const, textDelta: 'Let me check the weather for you.' }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: '{"location":' - }, - { - type: 'tool-call-delta' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - argsTextDelta: ' "San Francisco, CA"}' - }, - { - type: 'tool-call' as const, - toolCallType: 'function' as const, - toolCallId: 'call_123', - toolName: 'getWeather', - args: { location: 'San Francisco, CA' } - } - ], - - withFinish: [ - { type: 'text-delta' as const, textDelta: 'Complete response.' }, - { - type: 'finish' as const, - finishReason: 'stop' as const, - usage: { - promptTokens: 10, - completionTokens: 5, - totalTokens: 15 - } - } - ] -} - /** * Mock complete responses for non-streaming scenarios + * Note: AI SDK v5 uses inputTokens/outputTokens instead of promptTokens/completionTokens */ export const mockCompleteResponses = { simple: { text: 'This is a simple response.', finishReason: 'stop' as const, usage: { - promptTokens: 15, - completionTokens: 8, + inputTokens: 15, + outputTokens: 8, totalTokens: 23 } }, @@ -215,8 +165,8 @@ export const mockCompleteResponses = { ], finishReason: 'tool-calls' as const, usage: { - promptTokens: 25, - completionTokens: 12, + inputTokens: 25, + outputTokens: 12, totalTokens: 37 } }, @@ -225,14 +175,15 @@ export const mockCompleteResponses = { text: 'Response with warnings.', finishReason: 'stop' as const, usage: { - promptTokens: 10, - completionTokens: 5, + inputTokens: 10, + outputTokens: 5, totalTokens: 15 }, warnings: [ { type: 'unsupported-setting' as const, - message: 'Temperature parameter not supported for this model' + setting: 'temperature', + details: 'Temperature parameter not supported for this model' } ] } @@ -285,47 +236,3 @@ export const mockImageResponses = { warnings: [] } } - -/** - * Mock error responses - */ -export const mockErrors = { - invalidApiKey: { - name: 'APIError', - message: 'Invalid API key provided', - statusCode: 401 - }, - - rateLimitExceeded: { - name: 'RateLimitError', - message: 'Rate limit exceeded. Please try again later.', - statusCode: 429, - headers: { - 'retry-after': '60' - } - }, - - modelNotFound: { - name: 'ModelNotFoundError', - message: 'The requested model was not found', - statusCode: 404 - }, - - contextLengthExceeded: { - name: 'ContextLengthError', - message: "This model's maximum context length is 4096 tokens", - statusCode: 400 - }, - - timeout: { - name: 'TimeoutError', - message: 'Request timed out after 30000ms', - code: 'ETIMEDOUT' - }, - - networkError: { - name: 'NetworkError', - message: 'Network connection failed', - code: 'ECONNREFUSED' - } -} diff --git a/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts new file mode 100644 index 0000000000..57dcdd0fd1 --- /dev/null +++ b/packages/aiCore/src/__tests__/mocks/ai-sdk-provider.ts @@ -0,0 +1,35 @@ +/** + * Mock for @cherrystudio/ai-sdk-provider + * This mock is used in tests to avoid importing the actual package + */ + +export type CherryInProviderSettings = { + apiKey?: string + baseURL?: string +} + +// oxlint-disable-next-line no-unused-vars +export const createCherryIn = (_options?: CherryInProviderSettings) => ({ + // oxlint-disable-next-line no-unused-vars + languageModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + chat: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin-chat', + modelId: 'mock-model', + doGenerate: async () => ({ text: 'mock response' }), + doStream: async () => ({ stream: (async function* () {})() }) + }), + // oxlint-disable-next-line no-unused-vars + textEmbeddingModel: (_modelId: string) => ({ + specificationVersion: 'v1', + provider: 'cherryin', + modelId: 'mock-embedding-model' + }) +}) diff --git a/packages/aiCore/src/__tests__/setup.ts b/packages/aiCore/src/__tests__/setup.ts new file mode 100644 index 0000000000..1e35458ad6 --- /dev/null +++ b/packages/aiCore/src/__tests__/setup.ts @@ -0,0 +1,9 @@ +/** + * Vitest Setup File + * Global test configuration and mocks for @cherrystudio/ai-core package + */ + +// Mock Vite SSR helper to avoid Node environment errors +;(globalThis as any).__vite_ssr_exportName__ = (_name: string, value: any) => value + +// Note: @cherrystudio/ai-sdk-provider is mocked via alias in vitest.config.ts diff --git a/packages/aiCore/src/core/options/__tests__/factory.test.ts b/packages/aiCore/src/core/options/__tests__/factory.test.ts new file mode 100644 index 0000000000..86f8017818 --- /dev/null +++ b/packages/aiCore/src/core/options/__tests__/factory.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' + +import { createOpenAIOptions, createOpenRouterOptions, mergeProviderOptions } from '../factory' + +describe('mergeProviderOptions', () => { + it('deep merges provider options for the same provider', () => { + const reasoningOptions = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'medium' + } + }) + const webSearchOptions = createOpenRouterOptions({ + plugins: [{ id: 'web', max_results: 5 }] + }) + + const merged = mergeProviderOptions(reasoningOptions, webSearchOptions) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, + effort: 'medium' + }, + plugins: [{ id: 'web', max_results: 5 }] + }) + }) + + it('preserves options from other providers while merging', () => { + const openRouter = createOpenRouterOptions({ + reasoning: { enabled: true } + }) + const openAI = createOpenAIOptions({ + reasoningEffort: 'low' + }) + const merged = mergeProviderOptions(openRouter, openAI) + + expect(merged.openrouter).toEqual({ reasoning: { enabled: true } }) + expect(merged.openai).toEqual({ reasoningEffort: 'low' }) + }) + + it('overwrites primitive values with later values', () => { + const first = createOpenAIOptions({ + reasoningEffort: 'low', + user: 'user-123' + }) + const second = createOpenAIOptions({ + reasoningEffort: 'high', + maxToolCalls: 5 + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openai).toEqual({ + reasoningEffort: 'high', // overwritten by second + user: 'user-123', // preserved from first + maxToolCalls: 5 // added from second + }) + }) + + it('overwrites arrays with later values instead of merging', () => { + const first = createOpenRouterOptions({ + models: ['gpt-4', 'gpt-3.5-turbo'] + }) + const second = createOpenRouterOptions({ + models: ['claude-3-opus', 'claude-3-sonnet'] + }) + + const merged = mergeProviderOptions(first, second) + + // Array is completely replaced, not merged + expect(merged.openrouter?.models).toEqual(['claude-3-opus', 'claude-3-sonnet']) + }) + + it('deeply merges nested objects while overwriting primitives', () => { + const first = createOpenRouterOptions({ + reasoning: { + enabled: true, + effort: 'low' + }, + user: 'user-123' + }) + const second = createOpenRouterOptions({ + reasoning: { + effort: 'high', + max_tokens: 500 + }, + user: 'user-456' + }) + + const merged = mergeProviderOptions(first, second) + + expect(merged.openrouter).toEqual({ + reasoning: { + enabled: true, // preserved from first + effort: 'high', // overwritten by second + max_tokens: 500 // added from second + }, + user: 'user-456' // overwritten by second + }) + }) + + it('replaces arrays instead of merging them', () => { + const first = createOpenRouterOptions({ plugins: [{ id: 'old' }] }) + const second = createOpenRouterOptions({ plugins: [{ id: 'new' }] }) + const merged = mergeProviderOptions(first, second) + // @ts-expect-error type-check for openrouter options is skipped. see function signature of createOpenRouterOptions + expect(merged.openrouter?.plugins).toEqual([{ id: 'new' }]) + }) +}) diff --git a/packages/aiCore/src/core/options/factory.ts b/packages/aiCore/src/core/options/factory.ts index ecd53e6330..1e493b2337 100644 --- a/packages/aiCore/src/core/options/factory.ts +++ b/packages/aiCore/src/core/options/factory.ts @@ -26,13 +26,65 @@ export function createGenericProviderOptions( return { [provider]: options } as Record> } +type PlainObject = Record + +const isPlainObject = (value: unknown): value is PlainObject => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function deepMergeObjects(target: T, source: PlainObject): T { + const result: PlainObject = { ...target } + Object.entries(source).forEach(([key, value]) => { + if (isPlainObject(value) && isPlainObject(result[key])) { + result[key] = deepMergeObjects(result[key], value) + } else { + result[key] = value + } + }) + return result as T +} + /** - * 合并多个供应商的options - * @param optionsMap 包含多个供应商选项的对象 - * @returns 合并后的TypedProviderOptions + * Deep-merge multiple provider-specific options. + * Nested objects are recursively merged; primitive values are overwritten. + * + * When the same key appears in multiple options: + * - If both values are plain objects: they are deeply merged (recursive merge) + * - If values are primitives/arrays: the later value overwrites the earlier one + * + * @example + * mergeProviderOptions( + * { openrouter: { reasoning: { enabled: true, effort: 'low' }, user: 'user-123' } }, + * { openrouter: { reasoning: { effort: 'high', max_tokens: 500 }, models: ['gpt-4'] } } + * ) + * // Result: { + * // openrouter: { + * // reasoning: { enabled: true, effort: 'high', max_tokens: 500 }, + * // user: 'user-123', + * // models: ['gpt-4'] + * // } + * // } + * + * @param optionsMap Objects containing options for multiple providers + * @returns Fully merged TypedProviderOptions */ export function mergeProviderOptions(...optionsMap: Partial[]): TypedProviderOptions { - return Object.assign({}, ...optionsMap) + return optionsMap.reduce((acc, options) => { + if (!options) { + return acc + } + Object.entries(options).forEach(([providerId, providerOptions]) => { + if (!providerOptions) { + return + } + if (acc[providerId]) { + acc[providerId] = deepMergeObjects(acc[providerId] as PlainObject, providerOptions as PlainObject) + } else { + acc[providerId] = providerOptions as any + } + }) + return acc + }, {} as TypedProviderOptions) } /** diff --git a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts index 82b390ba05..02fe21889a 100644 --- a/packages/aiCore/src/core/providers/__tests__/schemas.test.ts +++ b/packages/aiCore/src/core/providers/__tests__/schemas.test.ts @@ -19,15 +19,20 @@ describe('Provider Schemas', () => { expect(Array.isArray(baseProviders)).toBe(true) expect(baseProviders.length).toBeGreaterThan(0) + // These are the actual base providers defined in schemas.ts const expectedIds = [ 'openai', - 'openai-responses', + 'openai-chat', 'openai-compatible', 'anthropic', 'google', 'xai', 'azure', - 'deepseek' + 'azure-responses', + 'deepseek', + 'openrouter', + 'cherryin', + 'cherryin-chat' ] const actualIds = baseProviders.map((p) => p.id) expectedIds.forEach((id) => { diff --git a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts index 217319aacc..56ab87dbcc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateImage.test.ts @@ -232,11 +232,13 @@ describe('RuntimeExecutor.generateImage', () => { expect(pluginCallOrder).toEqual(['onRequestStart', 'transformParams', 'transformResult', 'onRequestEnd']) + // transformParams receives params without model (model is handled separately) + // and context with core fields + dynamic fields (requestId, startTime, etc.) expect(testPlugin.transformParams).toHaveBeenCalledWith( - { prompt: 'A test image' }, + expect.objectContaining({ prompt: 'A test image' }), expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -273,11 +275,12 @@ describe('RuntimeExecutor.generateImage', () => { await executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' }) + // resolveModel receives model id and context with core fields expect(modelResolutionPlugin.resolveModel).toHaveBeenCalledWith( 'dall-e-3', expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) @@ -339,12 +342,11 @@ describe('RuntimeExecutor.generateImage', () => { .generateImage({ model: 'invalid-model', prompt: 'A test image' }) .catch((error) => error) - expect(thrownError).toBeInstanceOf(ImageGenerationError) - expect(thrownError.message).toContain('Failed to generate image:') + // Error is thrown from pluginEngine directly as ImageModelResolutionError + expect(thrownError).toBeInstanceOf(ImageModelResolutionError) + expect(thrownError.message).toContain('Failed to resolve image model: invalid-model') expect(thrownError.providerId).toBe('openai') expect(thrownError.modelId).toBe('invalid-model') - expect(thrownError.cause).toBeInstanceOf(ImageModelResolutionError) - expect(thrownError.cause.message).toContain('Failed to resolve image model: invalid-model') }) it('should handle ImageModelResolutionError without provider', async () => { @@ -362,8 +364,9 @@ describe('RuntimeExecutor.generateImage', () => { const apiError = new Error('API request failed') vi.mocked(aiGenerateImage).mockRejectedValue(apiError) + // Error propagates directly from pluginEngine without wrapping await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'API request failed' ) }) @@ -376,8 +379,9 @@ describe('RuntimeExecutor.generateImage', () => { vi.mocked(aiGenerateImage).mockRejectedValue(noImageError) vi.mocked(NoImageGeneratedError.isInstance).mockReturnValue(true) + // Error propagates directly from pluginEngine await expect(executor.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'No image generated' ) }) @@ -398,15 +402,17 @@ describe('RuntimeExecutor.generateImage', () => { [errorPlugin] ) + // Error propagates directly from pluginEngine await expect(executorWithPlugin.generateImage({ model: 'dall-e-3', prompt: 'A test image' })).rejects.toThrow( - 'Failed to generate image:' + 'Generation failed' ) + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'dall-e-3' + model: 'dall-e-3' }) ) }) @@ -419,9 +425,10 @@ describe('RuntimeExecutor.generateImage', () => { const abortController = new AbortController() setTimeout(() => abortController.abort(), 10) + // Error propagates directly from pluginEngine await expect( executor.generateImage({ model: 'dall-e-3', prompt: 'A test image', abortSignal: abortController.signal }) - ).rejects.toThrow('Failed to generate image:') + ).rejects.toThrow('Operation was aborted') }) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts index 9a0f204159..cb1d1d671a 100644 --- a/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/generateText.test.ts @@ -17,10 +17,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - generateText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + generateText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -409,11 +413,12 @@ describe('RuntimeExecutor.generateText', () => { }) ).rejects.toThrow('Generation failed') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts index eae04783bb..49253594cc 100644 --- a/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts +++ b/packages/aiCore/src/core/runtime/__tests__/streamText.test.ts @@ -11,10 +11,14 @@ import type { AiPlugin } from '../../plugins' import { globalRegistryManagement } from '../../providers/RegistryManagement' import { RuntimeExecutor } from '../executor' -// Mock AI SDK -vi.mock('ai', () => ({ - streamText: vi.fn() -})) +// Mock AI SDK - use importOriginal to keep jsonSchema and other non-mocked exports +vi.mock('ai', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + streamText: vi.fn() + } +}) vi.mock('../../providers/RegistryManagement', () => ({ globalRegistryManagement: { @@ -153,7 +157,7 @@ describe('RuntimeExecutor.streamText', () => { describe('Max Tokens Parameter', () => { const maxTokensValues = [10, 50, 100, 500, 1000, 2000, 4000] - it.each(maxTokensValues)('should support maxTokens=%s', async (maxTokens) => { + it.each(maxTokensValues)('should support maxOutputTokens=%s', async (maxOutputTokens) => { const mockStream = { textStream: (async function* () { yield 'Response' @@ -168,12 +172,13 @@ describe('RuntimeExecutor.streamText', () => { await executor.streamText({ model: 'gpt-4', messages: testMessages.simple, - maxOutputTokens: maxTokens + maxOutputTokens }) + // Parameters are passed through without transformation expect(streamText).toHaveBeenCalledWith( expect.objectContaining({ - maxTokens + maxOutputTokens }) ) }) @@ -513,11 +518,12 @@ describe('RuntimeExecutor.streamText', () => { }) ).rejects.toThrow('Stream error') + // onError receives the original error and context with core fields expect(errorPlugin.onError).toHaveBeenCalledWith( error, expect.objectContaining({ providerId: 'openai', - modelId: 'gpt-4' + model: 'gpt-4' }) ) }) diff --git a/packages/aiCore/vitest.config.ts b/packages/aiCore/vitest.config.ts index 0cc6b51df4..2f520ea967 100644 --- a/packages/aiCore/vitest.config.ts +++ b/packages/aiCore/vitest.config.ts @@ -1,12 +1,20 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + import { defineConfig } from 'vitest/config' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ test: { - globals: true + globals: true, + setupFiles: [path.resolve(__dirname, './src/__tests__/setup.ts')] }, resolve: { alias: { - '@': './src' + '@': path.resolve(__dirname, './src'), + // Mock external packages that may not be available in test environment + '@cherrystudio/ai-sdk-provider': path.resolve(__dirname, './src/__tests__/mocks/ai-sdk-provider.ts') } }, esbuild: { diff --git a/tsconfig.node.json b/tsconfig.node.json index 83c3f2b461..6953fa7b37 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -2,14 +2,14 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "include": [ "electron.vite.config.*", - "src/main/**/*", - "src/preload/**/*", - "src/main/env.d.ts", - "src/renderer/src/types/*", - "packages/shared/**/*", "scripts", + "src/main/**/*", + "src/main/env.d.ts", + "src/preload/**/*", + "src/renderer/src/services/traceApi.ts", + "src/renderer/src/types/*", "packages/mcp-trace/**/*", - "src/renderer/src/services/traceApi.ts" + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 2d91fe0260..b09020a20d 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -1,16 +1,16 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": [ - "src/renderer/src/**/*", - "src/preload/*.d.ts", "local/src/renderer/**/*", - "packages/shared/**/*", - "tests/__mocks__/**/*", - "packages/mcp-trace/**/*", - "packages/aiCore/src/**/*", + "src/renderer/src/**/*", "src/main/integration/cherryai/index.js", + "src/preload/*.d.ts", + "tests/__mocks__/**/*", + "packages/aiCore/src/**/*", + "packages/ai-sdk-provider/**/*", "packages/extension-table-plus/**/*", - "packages/ai-sdk-provider/**/*" + "packages/mcp-trace/**/*", + "packages/shared/**/*", ], "compilerOptions": { "composite": true, diff --git a/vitest.config.ts b/vitest.config.ts index b4440a2461..a245f7a416 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,18 @@ export default defineConfig({ environment: 'node', include: ['scripts/**/*.{test,spec}.{ts,tsx}', 'scripts/**/__tests__/**/*.{test,spec}.{ts,tsx}'] } + }, + // aiCore 包单元测试配置 + { + extends: 'packages/aiCore/vitest.config.ts', + test: { + name: 'aiCore', + environment: 'node', + include: [ + 'packages/aiCore/**/*.{test,spec}.{ts,tsx}', + 'packages/aiCore/**/__tests__/**/*.{test,spec}.{ts,tsx}' + ] + } } ], // 全局共享配置 From 1b926178f1d192fe107867db9d60b17d4bb7fada Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 28 Nov 2025 14:44:45 +0800 Subject: [PATCH 16/17] chore: update @openrouter/ai-sdk-provider to version 1.2.8 in package.json and yarn.lock --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 304785117e..515217ad22 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "@modelcontextprotocol/sdk": "^1.17.5", "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", - "@openrouter/ai-sdk-provider": "^1.2.5", + "@openrouter/ai-sdk-provider": "^1.2.8", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "2.0.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", diff --git a/yarn.lock b/yarn.lock index 7f7ed62da7..3bd2fc9278 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5044,15 +5044,15 @@ __metadata: languageName: node linkType: hard -"@openrouter/ai-sdk-provider@npm:^1.2.5": - version: 1.2.5 - resolution: "@openrouter/ai-sdk-provider@npm:1.2.5" +"@openrouter/ai-sdk-provider@npm:^1.2.8": + version: 1.2.8 + resolution: "@openrouter/ai-sdk-provider@npm:1.2.8" dependencies: "@openrouter/sdk": "npm:^0.1.8" peerDependencies: ai: ^5.0.0 zod: ^3.24.1 || ^v4 - checksum: 10c0/f422f767ff8fcba2bb2fca32e5e2df163abae3c754f98416830654c5135db3aed5d4f941bfa0005109d202053a2e6a4a6b997940eb154ac964c87dd85dbe82e1 + checksum: 10c0/a1508d8d538f601f0b7f5f96da32ddbd3c156742a20b427742963d8ac2cee26ce857ad7c64df743efce632b1602b19c81dcd03ebc24ae5a371211a65ead1c181 languageName: node linkType: hard @@ -10050,7 +10050,7 @@ __metadata: "@mozilla/readability": "npm:^0.6.0" "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch" "@notionhq/client": "npm:^2.2.15" - "@openrouter/ai-sdk-provider": "npm:^1.2.5" + "@openrouter/ai-sdk-provider": "npm:^1.2.8" "@opentelemetry/api": "npm:^1.9.0" "@opentelemetry/core": "npm:2.0.0" "@opentelemetry/exporter-trace-otlp-http": "npm:^0.200.0" From 4620b71aee8031ea6a8813c4fcf5b26376289c70 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 28 Nov 2025 15:13:23 +0800 Subject: [PATCH 17/17] chore: update release notes for v1.7.0 --- electron-builder.yml | 140 +++++++++++++++++++++++++++++-------------- package.json | 2 +- 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 823c147a05..d75cd5855d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,56 +134,108 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - What's New in v1.7.0-rc.3 + A New Era of Intelligence with Cherry Studio 1.7.0 - ✨ New Features: - - Provider: Added Silicon provider support for Anthropic API compatibility - - Provider: AIHubMix support for nano banana + Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts. - 🐛 Bug Fixes: - - i18n: Clean up translation tags and untranslated strings - - Provider: Fixed Silicon provider code list - - Provider: Fixed Poe API reasoning parameters for GPT-5 and reasoning models - - Provider: Fixed duplicate /v1 in Anthropic API endpoints - - Provider: Fixed Azure provider handling in AI SDK integration - - Models: Added Claude Opus 4.5 pattern to THINKING_TOKEN_MAP - - Models: Improved Gemini reasoning and message handling - - Models: Fixed custom parameters for Gemini models - - Models: Fixed qwen-mt-flash text delta support - - Models: Fixed Groq verbosity setting - - UI: Fixed quota display and quota tips - - UI: Fixed web search button condition - - Settings: Fixed updateAssistantPreset reducer to properly update preset - - Settings: Respect enableMaxTokens setting when maxTokens is not configured - - SDK: Fixed header merging logic in AI SDK + For years, AI assistants have been reactive — waiting for your commands, responding to your questions. With Agent, we're changing that. Now, AI can truly work alongside you: understanding complex goals, breaking them into steps, and executing them independently. - ⚡ Improvements: - - SDK: Upgraded @anthropic-ai/claude-agent-sdk to 0.1.53 + This is what we've been building toward. And it's just the beginning. + + 🤖 Meet Agent + Imagine having a brilliant colleague who never sleeps. Give Agent a goal — write a report, analyze data, refactor code — and watch it work. It reasons through problems, breaks them into steps, calls the right tools, and adapts when things change. + + - **Think → Plan → Act**: From goal to execution, fully autonomous + - **Deep Reasoning**: Multi-turn thinking that solves real problems + - **Tool Mastery**: File operations, web search, code execution, and more + - **Skill Plugins**: Extend with custom commands and capabilities + - **You Stay in Control**: Real-time approval for sensitive actions + - **Full Visibility**: Every thought, every decision, fully transparent + + 🌐 Expanding Ecosystem + - **New Providers**: HuggingFace, Mistral, CherryIN, AI Gateway, Intel OVMS, Didi MCP + - **New Models**: Claude 4.5 Haiku, DeepSeek v3.2, GLM-4.6, Doubao, Ling series + - **MCP Integration**: Alibaba Cloud, ModelScope, Higress, MCP.so, TokenFlux and more + + 📚 Smarter Knowledge Base + - **OpenMinerU**: Self-hosted document processing + - **Full-Text Search**: Find anything instantly across your notes + - **Enhanced Tool Selection**: Smarter configuration for better AI assistance + + 📝 Notes, Reimagined + - Full-text search with highlighted results + - AI-powered smart rename + - Export as image + - Auto-wrap for tables + + 🖼️ Image & OCR + - Intel OVMS painting capabilities + - Intel OpenVINO NPU-accelerated OCR + + 🌍 Now in 10+ Languages + - Added German support + - Enhanced internationalization + + ⚡ Faster & More Polished + - Electron 38 upgrade + - New MCP management interface + - Dozens of UI refinements + + ❤️ Fully Open Source + Commercial restrictions removed. Cherry Studio now follows standard AGPL v3 — free for teams of any size. + + The Agent Era is here. We can't wait to see what you'll create. - v1.7.0-rc.3 更新内容 + Cherry Studio 1.7.0:开启智能新纪元 - ✨ 新功能: - - 提供商:新增 Silicon 提供商对 Anthropic API 的兼容性支持 - - 提供商:AIHubMix 支持 nano banana + 今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。 - 🐛 问题修复: - - 国际化:清理翻译标签和未翻译字符串 - - 提供商:修复 Silicon 提供商代码列表 - - 提供商:修复 Poe API 对 GPT-5 和推理模型的推理参数 - - 提供商:修复 Anthropic API 端点重复 /v1 问题 - - 提供商:修复 Azure 提供商在 AI SDK 集成中的处理 - - 模型:Claude Opus 4.5 添加到 THINKING_TOKEN_MAP - - 模型:改进 Gemini 推理和消息处理 - - 模型:修复 Gemini 模型自定义参数 - - 模型:修复 qwen-mt-flash text delta 支持 - - 模型:修复 Groq verbosity 设置 - - 界面:修复配额显示和配额提示 - - 界面:修复 Web 搜索按钮条件 - - 设置:修复 updateAssistantPreset reducer 正确更新 preset - - 设置:尊重 enableMaxTokens 设置 - - SDK:修复 AI SDK 中 header 合并逻辑 + 多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。 - ⚡ 改进: - - SDK:升级 @anthropic-ai/claude-agent-sdk 到 0.1.53 + 这是我们一直在构建的未来。而这,仅仅是开始。 + + 🤖 认识 Agent + 想象一位永不疲倦的得力伙伴。给 Agent 一个目标——撰写报告、分析数据、重构代码——然后看它工作。它会推理问题、拆解步骤、调用工具,并在情况变化时灵活应对。 + + - **思考 → 规划 → 行动**:从目标到执行,全程自主 + - **深度推理**:多轮思考,解决真实问题 + - **工具大师**:文件操作、网络搜索、代码执行,样样精通 + - **技能插件**:自定义命令,无限扩展 + - **你掌控全局**:敏感操作,实时审批 + - **完全透明**:每一步思考,每一个决策,清晰可见 + + 🌐 生态持续壮大 + - **新增服务商**:Hugging Face、Mistral、Perplexity、SophNet、AI Gateway、Cerebras AI + - **新增模型**:Gemini 3、Gemini 3 Pro(支持图像预览)、GPT-5.1、Claude Opus 4.5 + - **MCP 集成**:百炼、魔搭、Higress、MCP.so、TokenFlux 等平台 + + 📚 更智能的知识库 + - **OpenMinerU**:本地自部署文档处理 + - **全文搜索**:笔记内容一搜即达 + - **增强工具选择**:更智能的配置,更好的 AI 协助 + + 📝 笔记,焕然一新 + - 全文搜索,结果高亮 + - AI 智能重命名 + - 导出为图片 + - 表格自动换行 + + 🖼️ 图像与 OCR + - Intel OVMS 绘图能力 + - Intel OpenVINO NPU 加速 OCR + + 🌍 支持 10+ 种语言 + - 新增德语支持 + - 全面增强国际化 + + ⚡ 更快、更精致 + - 升级 Electron 38 + - 新的 MCP 管理界面 + - 数十处 UI 细节打磨 + + ❤️ 完全开源 + 商用限制已移除。Cherry Studio 现遵循标准 AGPL v3 协议——任意规模团队均可自由使用。 + + Agent 纪元已至。期待你的创造。 diff --git a/package.json b/package.json index 515217ad22..52c57b886f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.0-rc.3", + "version": "1.7.0", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js",