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 @@ -

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

+

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

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

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

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