diff --git a/.oxlintrc.json b/.oxlintrc.json
index 54725dbca9..cca61bf841 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -11,6 +11,7 @@
"dist/**",
"out/**",
"local/**",
+ "tests/**",
".yarn/**",
".gitignore",
"scripts/cloudflare-worker.js",
diff --git a/README.md b/README.md
index 09a4e14bbd..f790c10cbd 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@
-
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 | 中文 | 官方网站 | 文档 | 开发 | 反馈
diff --git a/electron-builder.yml b/electron-builder.yml
index 00728bf9b9..65fd61fc53 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -135,66 +135,108 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
releaseInfo:
releaseNotes: |
- What's New in v1.7.0-rc.2
+ A New Era of Intelligence with Cherry Studio 1.7.0
- ✨ 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
+ Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts.
- 🐛 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
+ 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: 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
+ 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.2 新特性
+ Cherry Studio 1.7.0:开启智能新纪元
- ✨ 新功能:
- - AI 模型:新增 Gemini 3、Gemini 3 Pro 图像预览支持,以及 GPT-5.1
- - 导入:ChatGPT 对话导入功能
- - Agent:Windows Agent 的 Git Bash 检测和要求检查
- - 搜索:支持本地语言 emoji 搜索(CLDR 数据格式)
- - 提供商:cherryin provider 的端点类型支持
- - 调试:启用本地崩溃 mini dump 文件,方便诊断
+ 今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。
- 🐛 重要修复:
- - 错误处理:改进 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
+ 多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。
- ⚡ 改进:
- - SDK:更新 Google 和 OpenAI SDK,新增功能和修复
- - UI:简化知识库创建模态框和 agent 创建表单
- - 工具:用 ToolContent 组件替换 renderToolContent 函数,提升可读性
- - 架构:用会话 ID 命名工具调用 ID 以防止冲突
- - 配置:AI SDK 配置重构
+ 这是我们一直在构建的未来。而这,仅仅是开始。
+
+ 🤖 认识 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/eslint.config.mjs b/eslint.config.mjs
index 3655fbe7cc..ef610da5fb 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 575db098ba..794e0ab309 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,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",
@@ -168,7 +169,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",
@@ -176,7 +177,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",
@@ -326,7 +327,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/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/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/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',
diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts
index c403552fd2..ce49f093ce 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
}
diff --git a/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts b/src/renderer/src/aiCore/legacy/clients/BaseApiClient.ts
index b5bf8ab8b2..32d72b3eac 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/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__/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/__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/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 }) => {
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/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/components/Popups/SelectModelPopup/api-model-popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/api-model-popup.tsx
index 72c5554fbf..7b1fa929da 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 : ''
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/__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/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/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]
}
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}
}
}
diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
index bd6f5337ec..68a14cca21 100644
--- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
+++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
@@ -19,7 +19,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'
@@ -34,7 +34,7 @@ import type { Assistant, AssistantSettings, CodeStyleVarious, MathEngine } from
import { isGroqSystemProvider } 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 type { MultiModelMessageStyle, SendMessageShortcut } from '@shared/data/preference/preferenceTypes'
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
import { Col, InputNumber, Row, Slider } from 'antd'
@@ -242,7 +242,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/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx
index 68be4fab30..f55b56775d 100644
--- a/src/renderer/src/pages/settings/AboutSettings.tsx
+++ b/src/renderer/src/pages/settings/AboutSettings.tsx
@@ -401,11 +401,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;
}
`
diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
index 5b66268367..120b10103c 100644
--- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
+++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx
@@ -134,12 +134,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)
}}
/>
)
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]
}
diff --git a/src/renderer/src/utils/provider.ts b/src/renderer/src/utils/provider.ts
index e8fc1b5cc7..fae0aababa 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))
)
}
@@ -102,6 +108,7 @@ const SUPPORT_URL_CONTEXT_PROVIDER_TYPES = [
'gemini',
'vertexai',
'anthropic',
+ 'azure-openai',
'new-api'
] as const satisfies ProviderType[]
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/tsconfig.node.json b/tsconfig.node.json
index 9f3768e016..da589b1144 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",
- "packages/mcp-trace/**/*",
+ "src/main/**/*",
+ "src/main/env.d.ts",
+ "src/preload/**/*",
"src/renderer/src/services/traceApi.ts",
+ "src/renderer/src/types/*",
+ "packages/mcp-trace/**/*",
+ "packages/shared/**/*",,
"tests/__mocks__/**/*"
],
"compilerOptions": {
diff --git a/tsconfig.web.json b/tsconfig.web.json
index f254b778f0..9c5f48fb67 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}'
+ ]
+ }
}
],
// 全局共享配置
diff --git a/yarn.lock b/yarn.lock
index bc60f013b9..e563de008d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6702,15 +6702,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
@@ -7102,14 +7102,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
@@ -13811,7 +13811,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"
@@ -13820,7 +13820,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"
@@ -13981,7 +13981,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"
@@ -24733,51 +24732,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