From 058a2c763b41ff08720e077d24089c6e97ef268a Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 10 Dec 2025 13:42:15 +0800 Subject: [PATCH 01/38] fix: restore API version control with trailing # delimiter (addresses #11750) (#11773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(utils): add isWithTrailingSharp URL helper function Add new utility function to check if URLs end with trailing '#' character Includes comprehensive test cases covering various URL patterns and edge cases * fix(api): check whether to auto append api version or not when formatting api host - extract api version to variable in GeminiAPIClient for consistency - simplify getBaseURL in OpenAIBaseClient by removing formatApiHost - modify provider api host formatting to respect trailing # - add tests for url parsing with trailing # characters * fix: update provider config tests for new isWithTrailingSharp function - Add isWithTrailingSharp to vi.mock in providerConfig tests - Update test expectations to match new formatApiHost calling behavior - All tests now pass with the new trailing # delimiter functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * fix(anthropic): prevent duplicate api version in base url The Anthropic SDK automatically appends /v1 to endpoints, so we need to avoid duplication by removing the version from baseURL and explicitly setting the path in listModels --------- Co-authored-by: Claude Sonnet 4.5 --- packages/shared/anthropic/index.ts | 7 +- .../clients/anthropic/AnthropicAPIClient.ts | 3 +- .../legacy/clients/gemini/GeminiAPIClient.ts | 8 +- .../legacy/clients/openai/OpenAIBaseClient.ts | 12 ++- .../clients/openai/OpenAIResponseAPIClient.ts | 3 +- .../provider/__tests__/providerConfig.test.ts | 25 ++++- .../src/aiCore/provider/providerConfig.ts | 10 +- src/renderer/src/utils/__tests__/api.test.ts | 102 ++++++++++++++++++ src/renderer/src/utils/api.ts | 17 +++ 9 files changed, 162 insertions(+), 25 deletions(-) diff --git a/packages/shared/anthropic/index.ts b/packages/shared/anthropic/index.ts index bff143d118..b9e9cb8846 100644 --- a/packages/shared/anthropic/index.ts +++ b/packages/shared/anthropic/index.ts @@ -88,16 +88,11 @@ export function getSdkClient( } }) } - let baseURL = + const baseURL = provider.type === 'anthropic' ? provider.apiHost : (provider.anthropicApiHost && provider.anthropicApiHost.trim()) || provider.apiHost - // Anthropic SDK automatically appends /v1 to all endpoints (like /v1/messages, /v1/models) - // We need to strip api version from baseURL to avoid duplication (e.g., /v3/v1/models) - // formatProviderApiHost adds /v1 for AI SDK compatibility, but Anthropic SDK needs it removed - baseURL = baseURL.replace(/\/v\d+(?:alpha|beta)?(?=\/|$)/i, '') - logger.debug('Anthropic API baseURL', { baseURL, providerId: provider.id }) if (provider.id === 'aihubmix') { diff --git a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts index 15f3cf1007..9b63b77ddf 100644 --- a/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/anthropic/AnthropicAPIClient.ts @@ -124,7 +124,8 @@ export class AnthropicAPIClient extends BaseApiClient< override async listModels(): Promise { const sdk = (await this.getSdkInstance()) as Anthropic - const response = await sdk.models.list() + // prevent auto appended /v1. It's included in baseUrl. + const response = await sdk.models.list({ path: '/models' }) return response.data } diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index 9c930a33ec..ac10106f37 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -173,13 +173,15 @@ export class GeminiAPIClient extends BaseApiClient< return this.sdkInstance } + const apiVersion = this.getApiVersion() + this.sdkInstance = new GoogleGenAI({ vertexai: false, apiKey: this.apiKey, - apiVersion: this.getApiVersion(), + apiVersion, httpOptions: { baseUrl: this.getBaseURL(), - apiVersion: this.getApiVersion(), + apiVersion, headers: { ...this.provider.extra_headers } @@ -200,7 +202,7 @@ export class GeminiAPIClient extends BaseApiClient< return trailingVersion } - return 'v1beta' + return '' } /** diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 910590aeab..c51f8aac8a 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -25,7 +25,7 @@ import type { OpenAISdkRawOutput, ReasoningEffortOptionalParams } from '@renderer/types/sdk' -import { formatApiHost, withoutTrailingSlash } from '@renderer/utils/api' +import { withoutTrailingSlash } from '@renderer/utils/api' import { isOllamaProvider } from '@renderer/utils/provider' import { BaseApiClient } from '../BaseApiClient' @@ -49,8 +49,9 @@ export abstract class OpenAIBaseClient< } // 仅适用于openai - override getBaseURL(isSupportedAPIVerion: boolean = true): string { - return formatApiHost(this.provider.apiHost, isSupportedAPIVerion) + override getBaseURL(): string { + // apiHost is formatted when called by AiProvider + return this.provider.apiHost } override async generateImage({ @@ -129,7 +130,7 @@ export abstract class OpenAIBaseClient< } if (isOllamaProvider(this.provider)) { - const baseUrl = withoutTrailingSlash(this.getBaseURL(false)) + const baseUrl = withoutTrailingSlash(this.getBaseURL()) .replace(/\/v1$/, '') .replace(/\/api$/, '') const response = await fetch(`${baseUrl}/api/tags`, { @@ -184,6 +185,7 @@ export abstract class OpenAIBaseClient< let apiKeyForSdkInstance = this.apiKey let baseURLForSdkInstance = this.getBaseURL() + logger.debug('baseURLForSdkInstance', { baseURLForSdkInstance }) let headersForSdkInstance = { ...this.defaultHeaders(), ...this.provider.extra_headers @@ -195,7 +197,7 @@ export abstract class OpenAIBaseClient< // this.provider.apiKey不允许修改 // this.provider.apiKey = token apiKeyForSdkInstance = token - baseURLForSdkInstance = this.getBaseURL(false) + baseURLForSdkInstance = this.getBaseURL() headersForSdkInstance = { ...headersForSdkInstance, ...COPILOT_DEFAULT_HEADERS diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts index 8356826e26..b4f63e2bce 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIResponseAPIClient.ts @@ -122,6 +122,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< if (this.sdkInstance) { return this.sdkInstance } + const baseUrl = this.getBaseURL() if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') { return new AzureOpenAI({ @@ -134,7 +135,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< return new OpenAI({ dangerouslyAllowBrowser: true, apiKey: this.apiKey, - baseURL: this.getBaseURL(), + baseURL: baseUrl, defaultHeaders: { ...this.defaultHeaders(), ...this.provider.extra_headers diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index 43d3cc52b8..20aa78dcbd 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -42,7 +42,8 @@ vi.mock('@renderer/utils/api', () => ({ routeToEndpoint: vi.fn((host) => ({ baseURL: host, endpoint: '/chat/completions' - })) + })), + isWithTrailingSharp: vi.fn((host) => host?.endsWith('#') || false) })) vi.mock('@renderer/utils/provider', async (importOriginal) => { @@ -227,12 +228,19 @@ describe('CherryAI provider configuration', () => { // Mock the functions to simulate non-CherryAI provider vi.mocked(isCherryAIProvider).mockReturnValue(false) vi.mocked(getProviderByModel).mockReturnValue(provider) + // Mock isWithTrailingSharp to return false for this test + vi.mocked(formatApiHost as any).mockImplementation((host, isSupportedAPIVersion = true) => { + if (isSupportedAPIVersion === false) { + return host + } + return `${host}/v1` + }) // Call getActualProvider const actualProvider = getActualProvider(model) - // Verify that formatApiHost was called with default parameters (true) - expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + // Verify that formatApiHost was called with appendApiVersion parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com', true) expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') }) @@ -303,12 +311,19 @@ describe('Perplexity provider configuration', () => { vi.mocked(isCherryAIProvider).mockReturnValue(false) vi.mocked(isPerplexityProvider).mockReturnValue(false) vi.mocked(getProviderByModel).mockReturnValue(provider) + // Mock isWithTrailingSharp to return false for this test + vi.mocked(formatApiHost as any).mockImplementation((host, isSupportedAPIVersion = true) => { + if (isSupportedAPIVersion === false) { + return host + } + return `${host}/v1` + }) // Call getActualProvider const actualProvider = getActualProvider(model) - // Verify that formatApiHost was called with default parameters (true) - expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + // Verify that formatApiHost was called with appendApiVersion parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com', true) expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') }) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 99e4fbd1c9..33b03a997d 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -17,6 +17,7 @@ import { formatAzureOpenAIApiHost, formatOllamaApiHost, formatVertexApiHost, + isWithTrailingSharp, routeToEndpoint } from '@renderer/utils/api' import { @@ -69,14 +70,15 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider { */ export function formatProviderApiHost(provider: Provider): Provider { const formatted = { ...provider } + const appendApiVersion = !isWithTrailingSharp(provider.apiHost) if (formatted.anthropicApiHost) { - formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost) + formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost, appendApiVersion) } if (isAnthropicProvider(provider)) { const baseHost = formatted.anthropicApiHost || formatted.apiHost // AI SDK needs /v1 in baseURL, Anthropic SDK will strip it in getSdkClient - formatted.apiHost = formatApiHost(baseHost) + formatted.apiHost = formatApiHost(baseHost, appendApiVersion) if (!formatted.anthropicApiHost) { formatted.anthropicApiHost = formatted.apiHost } @@ -85,7 +87,7 @@ export function formatProviderApiHost(provider: Provider): Provider { } else if (isOllamaProvider(formatted)) { formatted.apiHost = formatOllamaApiHost(formatted.apiHost) } else if (isGeminiProvider(formatted)) { - formatted.apiHost = formatApiHost(formatted.apiHost, true, 'v1beta') + formatted.apiHost = formatApiHost(formatted.apiHost, appendApiVersion, 'v1beta') } else if (isAzureOpenAIProvider(formatted)) { formatted.apiHost = formatAzureOpenAIApiHost(formatted.apiHost) } else if (isVertexProvider(formatted)) { @@ -95,7 +97,7 @@ export function formatProviderApiHost(provider: Provider): Provider { } else if (isPerplexityProvider(formatted)) { formatted.apiHost = formatApiHost(formatted.apiHost, false) } else { - formatted.apiHost = formatApiHost(formatted.apiHost) + formatted.apiHost = formatApiHost(formatted.apiHost, appendApiVersion) } return formatted } diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index fe34dcf26e..f5251b8393 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -10,6 +10,7 @@ import { formatVertexApiHost, getTrailingApiVersion, hasAPIVersion, + isWithTrailingSharp, maskApiKey, routeToEndpoint, splitApiKeyString, @@ -450,6 +451,43 @@ describe('api', () => { it('returns undefined for empty string', () => { expect(getTrailingApiVersion('')).toBeUndefined() }) + + it('returns undefined when URL ends with # regardless of version', () => { + expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v2beta#')).toBeUndefined() + expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1#')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/service/v1#')).toBeUndefined() + }) + + it('handles URLs with # and trailing slash correctly', () => { + expect(getTrailingApiVersion('https://api.example.com/v1/#')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v2beta/#')).toBeUndefined() + }) + + it('handles URLs with version followed by # and additional path', () => { + expect(getTrailingApiVersion('https://api.example.com/v1#endpoint')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v2beta#chat/completions')).toBeUndefined() + }) + + it('handles complex URLs with multiple # characters', () => { + expect(getTrailingApiVersion('https://api.example.com/v1#path#')).toBeUndefined() + expect(getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v2beta#')).toBeUndefined() + }) + + it('handles URLs ending with # when version is not at the end', () => { + expect(getTrailingApiVersion('https://api.example.com/v1/service#')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v1/api/chat#')).toBeUndefined() + }) + + it('distinguishes between URLs with and without trailing #', () => { + // Without # - should extract version + expect(getTrailingApiVersion('https://api.example.com/v1')).toBe('v1') + expect(getTrailingApiVersion('https://api.example.com/v2beta')).toBe('v2beta') + + // With # - should return undefined + expect(getTrailingApiVersion('https://api.example.com/v1#')).toBeUndefined() + expect(getTrailingApiVersion('https://api.example.com/v2beta#')).toBeUndefined() + }) }) describe('withoutTrailingApiVersion', () => { @@ -495,6 +533,70 @@ describe('api', () => { }) }) + describe('isWithTrailingSharp', () => { + it('returns true when URL ends with #', () => { + expect(isWithTrailingSharp('https://api.example.com#')).toBe(true) + expect(isWithTrailingSharp('http://localhost:3000#')).toBe(true) + expect(isWithTrailingSharp('#')).toBe(true) + }) + + it('returns false when URL does not end with #', () => { + expect(isWithTrailingSharp('https://api.example.com')).toBe(false) + expect(isWithTrailingSharp('http://localhost:3000')).toBe(false) + expect(isWithTrailingSharp('')).toBe(false) + }) + + it('returns false when URL has # in the middle but not at the end', () => { + expect(isWithTrailingSharp('https://api.example.com#path')).toBe(false) + expect(isWithTrailingSharp('https://api.example.com#section/path')).toBe(false) + expect(isWithTrailingSharp('https://api.example.com#path#other')).toBe(false) + }) + + it('handles URLs with multiple # characters', () => { + expect(isWithTrailingSharp('https://api.example.com##')).toBe(true) + expect(isWithTrailingSharp('https://api.example.com#path#')).toBe(true) + expect(isWithTrailingSharp('https://api.example.com###')).toBe(true) + }) + + it('handles URLs with trailing whitespace after #', () => { + expect(isWithTrailingSharp('https://api.example.com# ')).toBe(false) + expect(isWithTrailingSharp('https://api.example.com#\t')).toBe(false) + expect(isWithTrailingSharp('https://api.example.com#\n')).toBe(false) + }) + + it('handles URLs with whitespace before trailing #', () => { + expect(isWithTrailingSharp(' https://api.example.com#')).toBe(true) + expect(isWithTrailingSharp('\thttps://localhost:3000#')).toBe(true) + }) + + it('preserves type safety with generic parameter', () => { + const url1: string = 'https://api.example.com#' + const url2 = 'https://example.com' as const + + expect(isWithTrailingSharp(url1)).toBe(true) + expect(isWithTrailingSharp(url2)).toBe(false) + }) + + it('handles complex real-world URLs', () => { + expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions#')).toBe(true) + expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4#')).toBe(true) + expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta#')).toBe(true) + + expect(isWithTrailingSharp('https://open.cherryin.net/v1/chat/completions')).toBe(false) + expect(isWithTrailingSharp('https://api.openai.com/v1/engines/gpt-4')).toBe(false) + expect(isWithTrailingSharp('https://gateway.ai.cloudflare.com/v1/xxx/v1beta')).toBe(false) + }) + + it('handles edge cases', () => { + expect(isWithTrailingSharp('#')).toBe(true) + expect(isWithTrailingSharp(' #')).toBe(true) + expect(isWithTrailingSharp('# ')).toBe(false) + expect(isWithTrailingSharp('path#')).toBe(true) + expect(isWithTrailingSharp('/path/with/trailing/#')).toBe(true) + expect(isWithTrailingSharp('/path/without/trailing/')).toBe(false) + }) + }) + describe('withoutTrailingSharp', () => { it('removes trailing # from URL', () => { expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com') diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 10f31ae5c0..25a73dcb16 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -62,6 +62,23 @@ export function withoutTrailingSlash(url: T): T { return url.replace(/\/$/, '') as T } +/** + * Checks if a URL string ends with a trailing '#' character. + * + * @template T - The string type to preserve type safety + * @param {T} url - The URL string to check + * @returns {boolean} True if the URL ends with '#', false otherwise + * + * @example + * ```ts + * isWithTrailingSharp('https://example.com#') // true + * isWithTrailingSharp('https://example.com') // false + * ``` + */ +export function isWithTrailingSharp(url: T): boolean { + return url.endsWith('#') +} + /** * Removes the trailing '#' from a URL string if it exists. * From 6df60a69c3a98158b112fb87d4de92d3e5fcd46e Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:42:04 +0800 Subject: [PATCH 02/38] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20chore(deps):=20upgra?= =?UTF-8?q?de=20@anthropic-ai/claude-agent-sdk=20to=200.1.62=20(#11824)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade from 0.1.53 to 0.1.62 and recreate the spawn->fork patch for proper IPC communication in Electron. --- ...aude-agent-sdk-npm-0.1.62-23ae56f8c8.patch} | 4 ++-- package.json | 2 +- yarn.lock | 18 +++++++++--------- 3 files changed, 12 insertions(+), 12 deletions(-) rename .yarn/patches/{@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch => @anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch} (92%) diff --git a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch similarity index 92% rename from .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch rename to .yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch index 4481b58f32..62ab767576 100644 --- a/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch +++ b/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch @@ -1,5 +1,5 @@ diff --git a/sdk.mjs b/sdk.mjs -index bf429a344b7d59f70aead16b639f949b07688a81..f77d50cc5d3fb04292cb3ac7fa7085d02dcc628f 100755 +index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755 --- a/sdk.mjs +++ b/sdk.mjs @@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { @@ -11,7 +11,7 @@ index bf429a344b7d59f70aead16b639f949b07688a81..f77d50cc5d3fb04292cb3ac7fa7085d0 import { createInterface } from "readline"; // ../src/utils/fsOperations.ts -@@ -6619,18 +6619,11 @@ class ProcessTransport { +@@ -6644,18 +6644,11 @@ class ProcessTransport { const errorMessage = isNativeBinary(pathToClaudeCodeExecutable) ? `Claude Code native binary not found at ${pathToClaudeCodeExecutable}. Please ensure Claude Code is installed via native installer or specify a valid path with options.pathToClaudeCodeExecutable.` : `Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`; throw new ReferenceError(errorMessage); } diff --git a/package.json b/package.json index 7ad1385fcb..b658251bc3 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "release:ai-sdk-provider": "yarn workspace @cherrystudio/ai-sdk-provider version patch --immediate && yarn workspace @cherrystudio/ai-sdk-provider build && yarn workspace @cherrystudio/ai-sdk-provider npm publish --access public" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.53#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch", + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", "@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", diff --git a/yarn.lock b/yarn.lock index a9bccb17e1..e93fa9cd6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -503,9 +503,9 @@ __metadata: languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk@npm:0.1.53": - version: 0.1.53 - resolution: "@anthropic-ai/claude-agent-sdk@npm:0.1.53" +"@anthropic-ai/claude-agent-sdk@npm:0.1.62": + version: 0.1.62 + resolution: "@anthropic-ai/claude-agent-sdk@npm:0.1.62" dependencies: "@img/sharp-darwin-arm64": "npm:^0.33.5" "@img/sharp-darwin-x64": "npm:^0.33.5" @@ -534,13 +534,13 @@ __metadata: optional: true "@img/sharp-win32-x64": optional: true - checksum: 10c0/9b8e444f113e1f6a425d87287c653a5a441836c6100e954fdc33ce9149c8d87ca1a7d495563f4fac583cbaf14946fe18c321eb555b3f0e44a5de8433ba06bdaf + checksum: 10c0/bca0978651cd28798cd71a0071618eca37253905841fa0e20ec59f69ac4865e2c6c4e5fec034bc7b85a5748df5c3c37e3193d6adbd1cad73668f112d049390a3 languageName: node linkType: hard -"@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.53#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch": - version: 0.1.53 - resolution: "@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.53#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch::version=0.1.53&hash=b05505" +"@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch": + version: 0.1.62 + resolution: "@anthropic-ai/claude-agent-sdk@patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch::version=0.1.62&hash=b8fdbe" dependencies: "@img/sharp-darwin-arm64": "npm:^0.33.5" "@img/sharp-darwin-x64": "npm:^0.33.5" @@ -569,7 +569,7 @@ __metadata: optional: true "@img/sharp-win32-x64": optional: true - checksum: 10c0/54abfc37ca1e1617503b1a70d31a165b95cb898e6192637d3ab450be081bc8c89933714d1b150f5c3ef3948b3c481f81b9dfaf45fa1edff745477edf3e3c58e5 + checksum: 10c0/6c59cfc3d3b7d903d946c5da6e0c2ad6798ae837b67c2a9e679df2803d7577823f8feec26e48fa9f815b9ff19612c66e2682fdd182be0344b60febb6e64ac85e languageName: node linkType: hard @@ -10046,7 +10046,7 @@ __metadata: "@ai-sdk/perplexity": "npm:^2.0.20" "@ai-sdk/test-server": "npm:^0.0.1" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" - "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.53#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.53-4b77f4cf29.patch" + "@anthropic-ai/claude-agent-sdk": "patch:@anthropic-ai/claude-agent-sdk@npm%3A0.1.62#~/.yarn/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch" "@anthropic-ai/sdk": "npm:^0.41.0" "@anthropic-ai/vertex-sdk": "patch:@anthropic-ai/vertex-sdk@npm%3A0.11.4#~/.yarn/patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch" "@aws-sdk/client-bedrock": "npm:^3.910.0" From 711f805a5b3c360a034445a0682054117a1ce875 Mon Sep 17 00:00:00 2001 From: zane Date: Thu, 11 Dec 2025 10:46:13 +0800 Subject: [PATCH 03/38] fix(aiCore): omit empty content in assistant messages with tool_calls (#11818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(aiCore): omit empty content in assistant messages with tool_calls When an assistant message contains tool_calls but no text content, the content field was being set to undefined or empty string. This caused API errors on strict OpenAI-compatible endpoints like CherryIn: "messages: text content blocks must be non-empty" The fix conditionally includes the content field only when there is actual text content, which conforms to the OpenAI API specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(aiCore): omit empty assistant message in new aiCore StreamEventManager When building recursive params after tool execution, only add the assistant message when textBuffer has content. This avoids sending empty/invalid assistant messages to strict OpenAI-compatible APIs like CherryIn, which causes "text content blocks must be non-empty" errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * revert: remove legacy OpenAIApiClient fix (legacy is deprecated) The legacy aiCore code is no longer used. Only the fix in the new aiCore architecture (StreamEventManager.ts) is needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../plugins/built-in/toolUsePlugin/StreamEventManager.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts index 59a425712c..555d4929d9 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/StreamEventManager.ts @@ -135,10 +135,8 @@ export class StreamEventManager { // 构建新的对话消息 const newMessages: ModelMessage[] = [ ...(context.originalParams.messages || []), - { - role: 'assistant', - content: textBuffer - }, + // 只有当 textBuffer 有内容时才添加 assistant 消息,避免空消息导致 API 错误 + ...(textBuffer ? [{ role: 'assistant' as const, content: textBuffer }] : []), { role: 'user', content: toolResultsText From 96085707cea18cd6c9b234dc827b332f70a58a26 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:18:56 +0800 Subject: [PATCH 04/38] feat: add MCP server log viewer (#11826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add MCP server log viewer * 🧹 chore: format files * 🐛 fix: resolve MCP log viewer type errors * 🧹 chore: sync i18n for MCP log viewer * 💄 fix: improve MCP log modal contrast in dark mode * 🌐 fix: translate MCP log viewer strings Add translations for noLogs and viewLogs keys in: - German (de-de) - Greek (el-gr) - Spanish (es-es) - French (fr-fr) - Japanese (ja-jp) - Portuguese (pt-pt) - Russian (ru-ru) * 🌐 fix: update MCP log viewer translations and key references Added "logs" key to various language files and updated references in the MCP settings component to improve consistency across translations. This includes updates for English, Chinese (Simplified and Traditional), German, Greek, Spanish, French, Japanese, Portuguese, and Russian. --------- Co-authored-by: kangfenmao --- packages/shared/IpcChannel.ts | 2 + packages/shared/config/types.ts | 8 ++ src/main/index.ts | 2 +- src/main/ipc.ts | 2 + src/main/services/MCPService.ts | 94 +++++++++++++- .../__tests__/ServerLogBuffer.test.ts | 29 +++++ src/main/services/mcp/ServerLogBuffer.ts | 36 ++++++ src/preload/index.ts | 12 +- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + src/renderer/src/i18n/translate/de-de.json | 2 + src/renderer/src/i18n/translate/el-gr.json | 2 + src/renderer/src/i18n/translate/es-es.json | 2 + src/renderer/src/i18n/translate/fr-fr.json | 2 + src/renderer/src/i18n/translate/ja-jp.json | 2 + src/renderer/src/i18n/translate/pt-pt.json | 2 + src/renderer/src/i18n/translate/ru-ru.json | 2 + .../settings/MCPSettings/McpSettings.tsx | 117 +++++++++++++++++- 19 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 src/main/services/__tests__/ServerLogBuffer.test.ts create mode 100644 src/main/services/mcp/ServerLogBuffer.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 1c61745a60..88e7ae85d5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -90,6 +90,8 @@ export enum IpcChannel { Mcp_AbortTool = 'mcp:abort-tool', Mcp_GetServerVersion = 'mcp:get-server-version', Mcp_Progress = 'mcp:progress', + Mcp_GetServerLogs = 'mcp:get-server-logs', + Mcp_ServerLog = 'mcp:server-log', // Python Python_Execute = 'python:execute', diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 8fba6399f8..7dff53c753 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -23,6 +23,14 @@ export type MCPProgressEvent = { progress: number // 0-1 range } +export type MCPServerLogEntry = { + timestamp: number + level: 'debug' | 'info' | 'warn' | 'error' | 'stderr' | 'stdout' + message: string + data?: any + source?: string +} + export type WebviewKeyEvent = { webviewId: number key: string diff --git a/src/main/index.ts b/src/main/index.ts index 56750e6b61..3588a370ff 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,8 +19,8 @@ import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' import { appMenuService } from './services/AppMenuService' import { configManager } from './services/ConfigManager' -import mcpService from './services/MCPService' import { nodeTraceService } from './services/NodeTraceService' +import mcpService from './services/MCPService' import powerMonitorService from './services/PowerMonitorService' import { CHERRY_STUDIO_PROTOCOL, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 444ca5fb8e..714292c67e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -765,6 +765,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity) ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool) ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion) + ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) + ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) // DXT upload handler ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 3925376226..f9b43f039d 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -33,6 +33,7 @@ import { import { nanoid } from '@reduxjs/toolkit' import { HOME_CHERRY_DIR } from '@shared/config/constant' import type { MCPProgressEvent } from '@shared/config/types' +import type { MCPServerLogEntry } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import { defaultAppHeaders } from '@shared/utils' import { @@ -56,6 +57,7 @@ import { CacheService } from './CacheService' import DxtService from './DxtService' import { CallBackServer } from './mcp/oauth/callback' import { McpOAuthClientProvider } from './mcp/oauth/provider' +import { ServerLogBuffer } from './mcp/ServerLogBuffer' import { windowService } from './WindowService' // Generic type for caching wrapped functions @@ -142,6 +144,7 @@ class McpService { private pendingClients: Map> = new Map() private dxtService = new DxtService() private activeToolCalls: Map = new Map() + private serverLogs = new ServerLogBuffer(200) constructor() { this.initClient = this.initClient.bind(this) @@ -172,6 +175,19 @@ class McpService { }) } + private emitServerLog(server: MCPServer, entry: MCPServerLogEntry) { + const serverKey = this.getServerKey(server) + this.serverLogs.append(serverKey, entry) + const mainWindow = windowService.getMainWindow() + if (mainWindow) { + mainWindow.webContents.send(IpcChannel.Mcp_ServerLog, { ...entry, serverId: server.id }) + } + } + + public getServerLogs(_: Electron.IpcMainInvokeEvent, server: MCPServer): MCPServerLogEntry[] { + return this.serverLogs.get(this.getServerKey(server)) + } + async initClient(server: MCPServer): Promise { const serverKey = this.getServerKey(server) @@ -366,9 +382,25 @@ class McpService { } const stdioTransport = new StdioClientTransport(transportOptions) - stdioTransport.stderr?.on('data', (data) => - getServerLogger(server).debug(`Stdio stderr`, { data: data.toString() }) - ) + stdioTransport.stderr?.on('data', (data) => { + const msg = data.toString() + getServerLogger(server).debug(`Stdio stderr`, { data: msg }) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'stderr', + message: msg.trim(), + source: 'stdio' + }) + }) + ;(stdioTransport as any).stdout?.on('data', (data: any) => { + const msg = data.toString() + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'stdout', + message: msg.trim(), + source: 'stdio' + }) + }) return stdioTransport } else { throw new Error('Either baseUrl or command must be provided') @@ -436,6 +468,13 @@ class McpService { } } + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'info', + message: 'Server connected', + source: 'client' + }) + // Store the new client in the cache this.clients.set(serverKey, client) @@ -446,9 +485,22 @@ class McpService { this.clearServerCache(serverKey) logger.debug(`Activated server: ${server.name}`) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'info', + message: 'Server activated', + source: 'client' + }) return client } catch (error) { getServerLogger(server).error(`Error activating server ${server.name}`, error as Error) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'error', + message: `Error activating server: ${(error as Error)?.message}`, + data: redactSensitive(error), + source: 'client' + }) throw error } } finally { @@ -506,6 +558,16 @@ class McpService { // Set up logging message notification handler client.setNotificationHandler(LoggingMessageNotificationSchema, async (notification) => { logger.debug(`Message from server ${server.name}:`, notification.params) + const msg = notification.params?.message + if (msg) { + this.emitServerLog(server, { + timestamp: Date.now(), + level: (notification.params?.level as MCPServerLogEntry['level']) || 'info', + message: typeof msg === 'string' ? msg : JSON.stringify(msg), + data: redactSensitive(notification.params?.data), + source: notification.params?.logger || 'server' + }) + } }) getServerLogger(server).debug(`Set up notification handlers`) @@ -540,6 +602,7 @@ class McpService { this.clients.delete(serverKey) // Clear all caches for this server this.clearServerCache(serverKey) + this.serverLogs.remove(serverKey) } else { logger.warn(`No client found for server`, { serverKey }) } @@ -548,6 +611,12 @@ class McpService { async stopServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { const serverKey = this.getServerKey(server) getServerLogger(server).debug(`Stopping server`) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'info', + message: 'Stopping server', + source: 'client' + }) await this.closeClient(serverKey) } @@ -574,6 +643,12 @@ class McpService { async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) { getServerLogger(server).debug(`Restarting server`) const serverKey = this.getServerKey(server) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'info', + message: 'Restarting server', + source: 'client' + }) await this.closeClient(serverKey) // Clear cache before restarting to ensure fresh data this.clearServerCache(serverKey) @@ -606,9 +681,22 @@ class McpService { // Attempt to list tools as a way to check connectivity await client.listTools() getServerLogger(server).debug(`Connectivity check successful`) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'info', + message: 'Connectivity check successful', + source: 'connectivity' + }) return true } catch (error) { getServerLogger(server).error(`Connectivity check failed`, error as Error) + this.emitServerLog(server, { + timestamp: Date.now(), + level: 'error', + message: `Connectivity check failed: ${(error as Error).message}`, + data: redactSensitive(error), + source: 'connectivity' + }) // Close the client if connectivity check fails to ensure a clean state for the next attempt const serverKey = this.getServerKey(server) await this.closeClient(serverKey) diff --git a/src/main/services/__tests__/ServerLogBuffer.test.ts b/src/main/services/__tests__/ServerLogBuffer.test.ts new file mode 100644 index 0000000000..0b7abe91e8 --- /dev/null +++ b/src/main/services/__tests__/ServerLogBuffer.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { ServerLogBuffer } from '../mcp/ServerLogBuffer' + +describe('ServerLogBuffer', () => { + it('keeps a bounded number of entries per server', () => { + const buffer = new ServerLogBuffer(3) + const key = 'srv' + + buffer.append(key, { timestamp: 1, level: 'info', message: 'a' }) + buffer.append(key, { timestamp: 2, level: 'info', message: 'b' }) + buffer.append(key, { timestamp: 3, level: 'info', message: 'c' }) + buffer.append(key, { timestamp: 4, level: 'info', message: 'd' }) + + const logs = buffer.get(key) + expect(logs).toHaveLength(3) + expect(logs[0].message).toBe('b') + expect(logs[2].message).toBe('d') + }) + + it('isolates entries by server key', () => { + const buffer = new ServerLogBuffer(5) + buffer.append('one', { timestamp: 1, level: 'info', message: 'a' }) + buffer.append('two', { timestamp: 2, level: 'info', message: 'b' }) + + expect(buffer.get('one')).toHaveLength(1) + expect(buffer.get('two')).toHaveLength(1) + }) +}) diff --git a/src/main/services/mcp/ServerLogBuffer.ts b/src/main/services/mcp/ServerLogBuffer.ts new file mode 100644 index 0000000000..01c45f373f --- /dev/null +++ b/src/main/services/mcp/ServerLogBuffer.ts @@ -0,0 +1,36 @@ +export type MCPServerLogEntry = { + timestamp: number + level: 'debug' | 'info' | 'warn' | 'error' | 'stderr' | 'stdout' + message: string + data?: any + source?: string +} + +/** + * Lightweight ring buffer for per-server MCP logs. + */ +export class ServerLogBuffer { + private maxEntries: number + private logs: Map = new Map() + + constructor(maxEntries = 200) { + this.maxEntries = maxEntries + } + + append(serverKey: string, entry: MCPServerLogEntry) { + const list = this.logs.get(serverKey) ?? [] + list.push(entry) + if (list.length > this.maxEntries) { + list.splice(0, list.length - this.maxEntries) + } + this.logs.set(serverKey, list) + } + + get(serverKey: string): MCPServerLogEntry[] { + return [...(this.logs.get(serverKey) ?? [])] + } + + remove(serverKey: string) { + this.logs.delete(serverKey) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index a357f59f00..654e727cc6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -5,6 +5,7 @@ import type { SpanContext } from '@opentelemetry/api' import type { TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' +import type { MCPServerLogEntry } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' import type { @@ -372,7 +373,16 @@ const api = { }, abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId), getServerVersion: (server: MCPServer): Promise => - ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server) + ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server), + getServerLogs: (server: MCPServer): Promise => + ipcRenderer.invoke(IpcChannel.Mcp_GetServerLogs, server), + onServerLog: (callback: (log: MCPServerLogEntry & { serverId?: string }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, log: MCPServerLogEntry & { serverId?: string }) => { + callback(log) + } + ipcRenderer.on(IpcChannel.Mcp_ServerLog, listener) + return () => ipcRenderer.off(IpcChannel.Mcp_ServerLog, listener) + } }, python: { execute: (script: string, context?: Record, timeout?: number) => diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 4ebc57cb9b..1e8c854a12 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Failed to save JSON configuration.", "jsonSaveSuccess": "JSON configuration has been saved.", "logoUrl": "Logo URL", + "logs": "Logs", "longRunning": "Long Running Mode", "longRunningTooltip": "When enabled, the server supports long-running tasks. When receiving progress notifications, the timeout will be reset and the maximum execution time will be extended to 10 minutes.", "marketplaces": "Marketplaces", @@ -3931,6 +3932,7 @@ "name": "Name", "newServer": "MCP Server", "noDescriptionAvailable": "No description available", + "noLogs": "No logs yet", "noServers": "No servers configured", "not_support": "Model not supported", "npx_list": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8829bfe08e..9c2cea8d68 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "保存 JSON 配置失败", "jsonSaveSuccess": "JSON 配置已保存", "logoUrl": "标志网址", + "logs": "日志", "longRunning": "长时间运行模式", "longRunningTooltip": "启用后,服务器支持长时间任务,接收到进度通知时会重置超时计时器,并延长最大超时时间至10分钟", "marketplaces": "市场", @@ -3931,6 +3932,7 @@ "name": "名称", "newServer": "MCP 服务器", "noDescriptionAvailable": "暂无描述", + "noLogs": "暂无日志", "noServers": "未配置服务器", "not_support": "模型不支持", "npx_list": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 1a036a29e1..d497ba82ac 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "保存 JSON 配置失敗", "jsonSaveSuccess": "JSON 配置已儲存", "logoUrl": "標誌網址", + "logs": "日誌", "longRunning": "長時間運行模式", "longRunningTooltip": "啟用後,伺服器支援長時間任務,接收到進度通知時會重置超時計時器,並延長最大超時時間至10分鐘", "marketplaces": "市場", @@ -3931,6 +3932,7 @@ "name": "名稱", "newServer": "MCP 伺服器", "noDescriptionAvailable": "描述不存在", + "noLogs": "暫無日誌", "noServers": "未設定伺服器", "not_support": "不支援此模型", "npx_list": { diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 1300fbf6c7..e11976cd8b 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "JSON-Konfiguration speichern fehlgeschlagen", "jsonSaveSuccess": "JSON-Konfiguration erfolgreich gespeichert", "logoUrl": "Logo-URL", + "logs": "Protokolle", "longRunning": "Lang laufender Modus", "longRunningTooltip": "Nach Aktivierung unterstützt der Server lange Aufgaben. Wenn ein Fortschrittsbenachrichtigung empfangen wird, wird der Timeout-Timer zurückgesetzt und die maximale Timeout-Zeit auf 10 Minuten verlängert", "marketplaces": "Marktplätze", @@ -3931,6 +3932,7 @@ "name": "Name", "newServer": "MCP-Server", "noDescriptionAvailable": "Keine Beschreibung", + "noLogs": "Noch keine Protokolle", "noServers": "Server nicht konfiguriert", "not_support": "Modell nicht unterstützt", "npx_list": { diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 535a36489e..3bcd43fc6e 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Αποτυχία αποθήκευσης της διαμορφωτικής ρύθμισης JSON", "jsonSaveSuccess": "Η διαμορφωτική ρύθμιση JSON αποθηκεύτηκε επιτυχώς", "logoUrl": "URL Λογότυπου", + "logs": "Αρχεία καταγραφής", "longRunning": "Μακροχρόνια λειτουργία", "longRunningTooltip": "Όταν ενεργοποιηθεί, ο διακομιστής υποστηρίζει μακροχρόνιες εργασίες, επαναφέρει το χρονικό όριο μετά από λήψη ειδοποίησης προόδου και επεκτείνει το μέγιστο χρονικό όριο σε 10 λεπτά.", "marketplaces": "Αγορές", @@ -3931,6 +3932,7 @@ "name": "Όνομα", "newServer": "Διακομιστής MCP", "noDescriptionAvailable": "Δεν υπάρχει διαθέσιμη περιγραφή", + "noLogs": "Δεν υπάρχουν αρχεία καταγραφής ακόμα", "noServers": "Δεν έχουν ρυθμιστεί διακομιστές", "not_support": "Το μοντέλο δεν υποστηρίζεται", "npx_list": { diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 43d5919f00..5ccfe83b51 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Fallo al guardar la configuración JSON", "jsonSaveSuccess": "Configuración JSON guardada exitosamente", "logoUrl": "URL del logotipo", + "logs": "Registros", "longRunning": "Modo de ejecución prolongada", "longRunningTooltip": "Una vez habilitado, el servidor admite tareas de larga duración, reinicia el temporizador de tiempo de espera al recibir notificaciones de progreso y amplía el tiempo máximo de espera hasta 10 minutos.", "marketplaces": "Mercados", @@ -3931,6 +3932,7 @@ "name": "Nombre", "newServer": "Servidor MCP", "noDescriptionAvailable": "Sin descripción disponible por ahora", + "noLogs": "Aún no hay registros", "noServers": "No se han configurado servidores", "not_support": "El modelo no es compatible", "npx_list": { diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index ef7db8b3b3..f84fe2b775 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Échec de la sauvegarde de la configuration JSON", "jsonSaveSuccess": "Configuration JSON sauvegardée", "logoUrl": "Адрес логотипа", + "logs": "Journaux", "longRunning": "Mode d'exécution prolongée", "longRunningTooltip": "Une fois activé, le serveur prend en charge les tâches de longue durée, réinitialise le minuteur de temporisation à la réception des notifications de progression, et prolonge le délai d'expiration maximal à 10 minutes.", "marketplaces": "Places de marché", @@ -3931,6 +3932,7 @@ "name": "Nom", "newServer": "Сервер MCP", "noDescriptionAvailable": "Aucune description disponible pour le moment", + "noLogs": "Aucun journal pour le moment", "noServers": "Aucun serveur configuré", "not_support": "Модель не поддерживается", "npx_list": { diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 42c50c8827..0aea0f7dc6 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "JSON設定の保存に失敗しました", "jsonSaveSuccess": "JSON設定が保存されました。", "logoUrl": "ロゴURL", + "logs": "ログ", "longRunning": "長時間運行モード", "longRunningTooltip": "このオプションを有効にすると、サーバーは長時間のタスクをサポートします。進行状況通知を受信すると、タイムアウトがリセットされ、最大実行時間が10分に延長されます。", "marketplaces": "マーケットプレイス", @@ -3931,6 +3932,7 @@ "name": "名前", "newServer": "MCP サーバー", "noDescriptionAvailable": "説明がありません", + "noLogs": "ログはまだありません", "noServers": "サーバーが設定されていません", "not_support": "モデルはサポートされていません", "npx_list": { diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index bc84fc99b1..a28cf2a820 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Falha ao salvar configuração JSON", "jsonSaveSuccess": "Configuração JSON salva com sucesso", "logoUrl": "URL do Logotipo", + "logs": "Registros", "longRunning": "Modo de execução prolongada", "longRunningTooltip": "Quando ativado, o servidor suporta tarefas de longa duração, redefinindo o temporizador de tempo limite ao receber notificações de progresso e estendendo o tempo máximo de tempo limite para 10 minutos.", "marketplaces": "Mercados", @@ -3931,6 +3932,7 @@ "name": "Nome", "newServer": "Servidor MCP", "noDescriptionAvailable": "Nenhuma descrição disponível no momento", + "noLogs": "Ainda sem registos", "noServers": "Nenhum servidor configurado", "not_support": "Modelo Não Suportado", "npx_list": { diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 109bffb9b2..3597d963e0 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3912,6 +3912,7 @@ "jsonSaveError": "Не удалось сохранить конфигурацию JSON", "jsonSaveSuccess": "JSON конфигурация сохранена", "logoUrl": "URL логотипа", + "logs": "Журналы", "longRunning": "Длительный режим работы", "longRunningTooltip": "Включив эту опцию, сервер будет поддерживать длительные задачи. При получении уведомлений о ходе выполнения будет сброшен тайм-аут и максимальное время выполнения будет увеличено до 10 минут.", "marketplaces": "Торговые площадки", @@ -3931,6 +3932,7 @@ "name": "Имя", "newServer": "MCP сервер", "noDescriptionAvailable": "Описание отсутствует", + "noLogs": "Логов пока нет", "noServers": "Серверы не настроены", "not_support": "Модель не поддерживается", "npx_list": { diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index c48ae8f794..30b7b45f30 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -9,8 +9,9 @@ import MCPDescription from '@renderer/pages/settings/MCPSettings/McpDescription' import type { MCPPrompt, MCPResource, MCPServer, MCPTool } from '@renderer/types' import { parseKeyValueString } from '@renderer/utils/env' import { formatMcpError } from '@renderer/utils/error' +import type { MCPServerLogEntry } from '@shared/config/types' import type { TabsProps } from 'antd' -import { Badge, Button, Flex, Form, Input, Radio, Select, Switch, Tabs } from 'antd' +import { Badge, Button, Flex, Form, Input, Modal, Radio, Select, Switch, Tabs, Tag, Typography } from 'antd' import TextArea from 'antd/es/input/TextArea' import { ChevronDown, SaveIcon } from 'lucide-react' import React, { useCallback, useEffect, useState } from 'react' @@ -88,8 +89,11 @@ const McpSettings: React.FC = () => { const [showAdvanced, setShowAdvanced] = useState(false) const [serverVersion, setServerVersion] = useState(null) + const [logModalOpen, setLogModalOpen] = useState(false) + const [logs, setLogs] = useState<(MCPServerLogEntry & { serverId?: string })[]>([]) const { theme } = useTheme() + const { Text } = Typography const navigate = useNavigate() @@ -234,12 +238,43 @@ const McpSettings: React.FC = () => { } } + const fetchServerLogs = async () => { + try { + const history = await window.api.mcp.getServerLogs(server) + setLogs(history) + } catch (error) { + logger.warn('Failed to load server logs', error as Error) + } + } + + useEffect(() => { + const unsubscribe = window.api.mcp.onServerLog((log) => { + if (log.serverId && log.serverId !== server.id) return + setLogs((prev) => { + const merged = [...prev, log] + if (merged.length > 200) { + return merged.slice(merged.length - 200) + } + return merged + }) + }) + + return () => { + unsubscribe?.() + } + }, [server.id]) + + useEffect(() => { + setLogs([]) + }, [server.id]) + useEffect(() => { if (server.isActive) { fetchTools() fetchPrompts() fetchResources() fetchServerVersion() + fetchServerLogs() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [server.id, server.isActive]) @@ -736,6 +771,9 @@ const McpSettings: React.FC = () => { {server?.name} {serverVersion && } + - )} - + {isCherryIN && isChineseUser ? ( + + ) : ( + + setApiHost(e.target.value)} + onBlur={onUpdateApiHost} + /> + {isApiHostResettable && ( + + )} + + )} {isVertexProvider(provider) && ( {t('settings.provider.vertex_ai.api_host_help')} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8d9176be15..30b6b72129 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 182, + version: 183, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index a80336e697..8559a39e27 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2976,6 +2976,22 @@ const migrateConfig = { logger.error('migrate 182 error', error as Error) return state } + }, + '183': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.cherryin) { + provider.apiHost = 'https://open.cherryin.cc' + provider.anthropicApiHost = 'https://open.cherryin.cc' + } + }) + state.llm.providers = moveProvider(state.llm.providers, SystemProviderIds.poe, 10) + logger.info('migrate 183 success') + return state + } catch (error) { + logger.error('migrate 183 error', error as Error) + return state + } } } From fda22874753fc11fd86707ab920baecb8623fe6b Mon Sep 17 00:00:00 2001 From: Ying-xi <62348590+Ying-xi@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:21:10 +0800 Subject: [PATCH 06/38] fix(knowledge): prioritize query & refine intent prompt (#11828) Fixes logic issues in knowledge base search: 1. Inverted search priority in KnowledgeService to use specific sub-queries over generic rewrites. 2. Updated SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY to explicitly allow decomposed questions, improving intent recognition for complex queries. --- src/renderer/src/config/prompts.ts | 2 +- src/renderer/src/services/KnowledgeService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/config/prompts.ts b/src/renderer/src/config/prompts.ts index 926a138f14..815eb7d113 100644 --- a/src/renderer/src/config/prompts.ts +++ b/src/renderer/src/config/prompts.ts @@ -306,7 +306,7 @@ export const SEARCH_SUMMARY_PROMPT_KNOWLEDGE_ONLY = ` **Use user's language to rephrase the question.** Follow these guidelines: 1. If the question is a simple writing task, greeting (e.g., Hi, Hello, How are you), or does not require searching for information (unless the greeting contains a follow-up question), return 'not_needed' in the 'question' XML block. This indicates that no search is required. - 2. For knowledge, You need rewrite user query into 'rewrite' XML block with one alternative version while preserving the original intent and meaning. Also include the original question in the 'question' block. + 2. For knowledge, You need rewrite user query into 'rewrite' XML block with one alternative version while preserving the original intent and meaning. Also include the rephrased or decomposed question(s) in the 'question' block. 3. Always return the rephrased question inside the 'question' XML block. 4. Always wrap the rephrased question in the appropriate XML blocks: use for queries that can be answered from a pre-existing knowledge base. Ensure that the rephrased question is always contained within a block inside the wrapper. 5. *use knowledge to rephrase the question* diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index e2f2e6fc15..e78cfa62e5 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -162,7 +162,7 @@ export const searchKnowledgeBase = async ( const searchResults: KnowledgeSearchResult[] = await window.api.knowledgeBase.search( { - search: rewrite || query, + search: query || rewrite || '', base: baseParams }, currentSpan?.spanContext() From 03db02d5f751e06577e017573165fa7efeb97183 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 11 Dec 2025 11:29:18 +0800 Subject: [PATCH 07/38] fix(ThinkingButton): show correct icon when isFixedReasoning (#11825) --- .../tools/components/ThinkingButton.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx index 5909189516..96e7adca93 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/ThinkingButton.tsx @@ -93,7 +93,7 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle level: option, label: getReasoningEffortOptionsLabel(option), description: '', - icon: ThinkingIcon(option), + icon: ThinkingIcon({ option }), isSelected: currentReasoningEffort === option, action: () => onThinkingChange(option) })) @@ -135,7 +135,7 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle { label: t('assistants.settings.reasoning_effort.label'), description: '', - icon: ThinkingIcon(currentReasoningEffort), + icon: ThinkingIcon({ option: currentReasoningEffort }), isMenu: true, action: () => openQuickPanel() } @@ -163,37 +163,40 @@ const ThinkingButton: FC = ({ quickPanel, model, assistantId }): ReactEle aria-label={ariaLabel} aria-pressed={currentReasoningEffort !== 'none'} style={isFixedReasoning ? { cursor: 'default' } : undefined}> - {ThinkingIcon(currentReasoningEffort)} + {ThinkingIcon({ option: currentReasoningEffort, isFixedReasoning })} ) } -const ThinkingIcon = (option?: ThinkingOption) => { +const ThinkingIcon = (props: { option?: ThinkingOption; isFixedReasoning?: boolean }) => { let IconComponent: React.FC> | null = null - - switch (option) { - case 'minimal': - IconComponent = MdiLightbulbOn30 - break - case 'low': - IconComponent = MdiLightbulbOn50 - break - case 'medium': - IconComponent = MdiLightbulbOn80 - break - case 'high': - IconComponent = MdiLightbulbOn - break - case 'auto': - IconComponent = MdiLightbulbAutoOutline - break - case 'none': - IconComponent = MdiLightbulbOffOutline - break - default: - IconComponent = MdiLightbulbOffOutline - break + if (props.isFixedReasoning) { + IconComponent = MdiLightbulbAutoOutline + } else { + switch (props.option) { + case 'minimal': + IconComponent = MdiLightbulbOn30 + break + case 'low': + IconComponent = MdiLightbulbOn50 + break + case 'medium': + IconComponent = MdiLightbulbOn80 + break + case 'high': + IconComponent = MdiLightbulbOn + break + case 'auto': + IconComponent = MdiLightbulbAutoOutline + break + case 'none': + IconComponent = MdiLightbulbOffOutline + break + default: + IconComponent = MdiLightbulbOffOutline + break + } } return From 880673c4eba641a60b4ae6d78512c240b766c52b Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 11 Dec 2025 11:57:16 +0800 Subject: [PATCH 08/38] fix(AssistantPresetCard): update group handling to use isArray for better type safety --- .../assistants/presets/components/AssistantPresetCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx b/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx index 25b81cf38a..4c9ce082d7 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/AssistantPresetCard.tsx @@ -7,6 +7,7 @@ import type { AssistantPreset } from '@renderer/types' import { getLeadingEmoji } from '@renderer/utils' import { Button, Dropdown } from 'antd' import { t } from 'i18next' +import { isArray } from 'lodash' import { ArrowDownAZ, Ellipsis, PlusIcon, SquareArrowOutUpRight } from 'lucide-react' import { type FC, memo, useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' @@ -142,7 +143,7 @@ const AssistantPresetCard: FC = ({ preset, onClick, activegroup, getLocal {getLocalizedGroupName('我的')} )} - {!!preset.group?.length && + {isArray(preset.group) && preset.group.map((group) => ( {getLocalizedGroupName(group)} From 600a045ff710f36d7951d1e94866935f0c95158e Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:05:41 +0800 Subject: [PATCH 09/38] chore: add gitcode release sync workflow (#11807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add gitcode release sync workflow * fix(ci): address review feedback for gitcode sync workflow - Use Authorization header instead of token in URL query parameter - Add file existence check before copying signed Windows artifacts - Remove inappropriate `|| true` from artifact listing - Use heredoc for safe GITHUB_OUTPUT writing - Add error context logging in upload_file function - Add curl timeout for API requests (connect: 30s, max: 60s) - Add cleanup step for temp files with `if: always()` - Add env var validation for GitCode credentials 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/sync-to-gitcode.yml | 293 ++++++++++++++++++++++++++ scripts/win-sign.js | 10 +- 2 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sync-to-gitcode.yml diff --git a/.github/workflows/sync-to-gitcode.yml b/.github/workflows/sync-to-gitcode.yml new file mode 100644 index 0000000000..4462ff6375 --- /dev/null +++ b/.github/workflows/sync-to-gitcode.yml @@ -0,0 +1,293 @@ +name: Sync Release to GitCode + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. v1.0.0)' + required: true + clean: + description: 'Clean node_modules before build' + type: boolean + default: false + +permissions: + contents: read + +jobs: + build-and-sync-to-gitcode: + runs-on: [self-hosted, windows-signing] + steps: + - name: Get tag name + id: get-tag + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + fi + + - name: Check out Git repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ steps.get-tag.outputs.tag }} + + - name: Set package.json version + shell: bash + run: | + TAG="${{ steps.get-tag.outputs.tag }}" + VERSION="${TAG#v}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Install corepack + shell: bash + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: Clean node_modules + if: ${{ github.event.inputs.clean == 'true' }} + shell: bash + run: rm -rf node_modules + + - name: Install Dependencies + shell: bash + run: yarn install + + - name: Build Windows with code signing + shell: bash + run: yarn build:win + env: + WIN_SIGN: true + CHERRY_CERT_PATH: ${{ secrets.CHERRY_CERT_PATH }} + CHERRY_CERT_KEY: ${{ secrets.CHERRY_CERT_KEY }} + CHERRY_CERT_CSP: ${{ secrets.CHERRY_CERT_CSP }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_OPTIONS: --max-old-space-size=8192 + MAIN_VITE_CHERRYAI_CLIENT_SECRET: ${{ secrets.MAIN_VITE_CHERRYAI_CLIENT_SECRET }} + MAIN_VITE_MINERU_API_KEY: ${{ secrets.MAIN_VITE_MINERU_API_KEY }} + RENDERER_VITE_AIHUBMIX_SECRET: ${{ secrets.RENDERER_VITE_AIHUBMIX_SECRET }} + RENDERER_VITE_PPIO_APP_SECRET: ${{ secrets.RENDERER_VITE_PPIO_APP_SECRET }} + + - name: List built Windows artifacts + shell: bash + run: | + echo "Built Windows artifacts:" + ls -la dist/*.exe dist/*.blockmap dist/latest*.yml + + - name: Download GitHub release assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + run: | + echo "Downloading release assets for $TAG_NAME..." + mkdir -p release-assets + cd release-assets + + # Download all assets from the release + gh release download "$TAG_NAME" \ + --repo "${{ github.repository }}" \ + --pattern "*" \ + --skip-existing + + echo "Downloaded GitHub release assets:" + ls -la + + - name: Replace Windows files with signed versions + shell: bash + run: | + echo "Replacing Windows files with signed versions..." + + # Verify signed files exist first + if ! ls dist/*.exe 1>/dev/null 2>&1; then + echo "ERROR: No signed .exe files found in dist/" + exit 1 + fi + + # Remove unsigned Windows files from downloaded assets + # *.exe, *.exe.blockmap, latest.yml (Windows only) + rm -f release-assets/*.exe release-assets/*.exe.blockmap release-assets/latest.yml 2>/dev/null || true + + # Copy signed Windows files with error checking + cp dist/*.exe release-assets/ || { echo "ERROR: Failed to copy .exe files"; exit 1; } + cp dist/*.exe.blockmap release-assets/ || { echo "ERROR: Failed to copy .blockmap files"; exit 1; } + cp dist/latest.yml release-assets/ || { echo "ERROR: Failed to copy latest.yml"; exit 1; } + + echo "Final release assets:" + ls -la release-assets/ + + - name: Get release info + id: release-info + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + run: | + # Always use gh cli to avoid special character issues + RELEASE_NAME=$(gh release view "$TAG_NAME" --repo "${{ github.repository }}" --json name -q '.name') + # Use delimiter to safely handle special characters in release name + { + echo 'name<> $GITHUB_OUTPUT + # Extract releaseNotes from electron-builder.yml (from releaseNotes: | to end of file, remove 4-space indent) + sed -n '/releaseNotes: |/,$ { /releaseNotes: |/d; s/^ //; p }' electron-builder.yml > release_body.txt + + - name: Create GitCode release and upload files + shell: bash + env: + GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }} + GITCODE_OWNER: ${{ vars.GITCODE_OWNER }} + GITCODE_REPO: ${{ vars.GITCODE_REPO }} + GITCODE_API_URL: ${{ vars.GITCODE_API_URL }} + TAG_NAME: ${{ steps.get-tag.outputs.tag }} + RELEASE_NAME: ${{ steps.release-info.outputs.name }} + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + run: | + # Validate required environment variables + if [ -z "$GITCODE_TOKEN" ]; then + echo "ERROR: GITCODE_TOKEN is not set" + exit 1 + fi + if [ -z "$GITCODE_OWNER" ]; then + echo "ERROR: GITCODE_OWNER is not set" + exit 1 + fi + if [ -z "$GITCODE_REPO" ]; then + echo "ERROR: GITCODE_REPO is not set" + exit 1 + fi + + API_URL="${GITCODE_API_URL:-https://api.gitcode.com/api/v5}" + + echo "Creating GitCode release..." + echo "Tag: $TAG_NAME" + echo "Repo: $GITCODE_OWNER/$GITCODE_REPO" + + # Step 1: Create release + # Use --rawfile to read body directly from file, avoiding shell variable encoding issues + jq -n \ + --arg tag "$TAG_NAME" \ + --arg name "$RELEASE_NAME" \ + --rawfile body release_body.txt \ + '{ + tag_name: $tag, + name: $name, + body: $body, + target_commitish: "main" + }' > /tmp/release_payload.json + + RELEASE_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \ + --connect-timeout 30 --max-time 60 \ + "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases" \ + -H "Content-Type: application/json; charset=utf-8" \ + -H "Authorization: Bearer ${GITCODE_TOKEN}" \ + --data-binary "@/tmp/release_payload.json") + + HTTP_CODE=$(echo "$RELEASE_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$RELEASE_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "Release created successfully" + else + echo "Warning: Release creation returned HTTP $HTTP_CODE" + echo "$RESPONSE_BODY" + exit 1 + fi + + # Step 2: Upload files to release + echo "Uploading files to GitCode release..." + + # Function to upload a single file with retry + upload_file() { + local file="$1" + local filename=$(basename "$file") + local max_retries=3 + local retry=0 + + echo "Uploading: $filename" + + # URL encode the filename + encoded_filename=$(printf '%s' "$filename" | jq -sRr @uri) + + while [ $retry -lt $max_retries ]; do + # Get upload URL + UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \ + -H "Authorization: Bearer ${GITCODE_TOKEN}" \ + "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") + + UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty') + + if [ -n "$UPLOAD_URL" ]; then + # Write headers to temp file to avoid shell escaping issues + echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt + + # Upload file using PUT with headers from file + UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ + -K /tmp/upload_headers.txt \ + --data-binary "@${file}" \ + "$UPLOAD_URL") + + HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo " Uploaded: $filename" + return 0 + else + echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries" + echo " Response: $RESPONSE_BODY" + fi + else + echo " Failed to get upload URL, retry $((retry + 1))/$max_retries" + echo " Response: $UPLOAD_INFO" + fi + + retry=$((retry + 1)) + [ $retry -lt $max_retries ] && sleep 3 + done + + echo " Failed: $filename after $max_retries retries" + exit 1 + } + + # Upload non-yml/json files first + for file in release-assets/*; do + if [ -f "$file" ]; then + filename=$(basename "$file") + if [[ ! "$filename" =~ \.(yml|yaml|json)$ ]]; then + upload_file "$file" + fi + fi + done + + # Upload yml/json files last + for file in release-assets/*; do + if [ -f "$file" ]; then + filename=$(basename "$file") + if [[ "$filename" =~ \.(yml|yaml|json)$ ]]; then + upload_file "$file" + fi + fi + done + + echo "GitCode release sync completed!" + + - name: Cleanup temp files + if: always() + shell: bash + run: | + rm -f /tmp/release_payload.json /tmp/upload_headers.txt release_body.txt + rm -rf release-assets/ diff --git a/scripts/win-sign.js b/scripts/win-sign.js index f9b37c3aed..cdbfe11e17 100644 --- a/scripts/win-sign.js +++ b/scripts/win-sign.js @@ -5,9 +5,17 @@ exports.default = async function (configuration) { const { path } = configuration if (configuration.path) { try { + const certPath = process.env.CHERRY_CERT_PATH + const keyContainer = process.env.CHERRY_CERT_KEY + const csp = process.env.CHERRY_CERT_CSP + + if (!certPath || !keyContainer || !csp) { + throw new Error('CHERRY_CERT_PATH, CHERRY_CERT_KEY or CHERRY_CERT_CSP is not set') + } + console.log('Start code signing...') console.log('Signing file:', path) - const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /a /v "${path}"` + const signCommand = `signtool sign /tr http://timestamp.comodoca.com /td sha256 /fd sha256 /v /f "${certPath}" /csp "${csp}" /k "${keyContainer}" "${path}"` execSync(signCommand, { stdio: 'inherit' }) console.log('Code signing completed') } catch (error) { From c4fd48376dcffc6f43a88dd21491164be1c81ae3 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 11 Dec 2025 14:51:32 +0800 Subject: [PATCH 10/38] feat(SelectionAssistant): open URL for search action (#11770) * feat(SelectionAssistant): open URL for search action When selected text is a valid URI or file path, directly open it instead of searching. This enhances the search action to be smarter about handling URLs and file paths. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: format * feat: increase maximum custom and enabled items in settings actions list Updated the maximum number of custom items from 8 to 10 and enabled items from 6 to 8 in the settings actions list to enhance user customization options. --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../hooks/useSettingsActionsList.ts | 4 +- .../selection/toolbar/SelectionToolbar.tsx | 90 +++++++++++++------ 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts index 341ac8f9c6..843fee150b 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/hooks/useSettingsActionsList.ts @@ -9,8 +9,8 @@ import { DEFAULT_SEARCH_ENGINES } from '../components/SelectionActionSearchModal const logger = loggerService.withContext('useSettingsActionsList') -const MAX_CUSTOM_ITEMS = 8 -const MAX_ENABLED_ITEMS = 6 +const MAX_CUSTOM_ITEMS = 10 +const MAX_ENABLED_ITEMS = 8 export const useActionItems = ( initialItems: ActionItem[] | undefined, diff --git a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx index 505a3b8fda..37a56acba5 100644 --- a/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx +++ b/src/renderer/src/windows/selection/toolbar/SelectionToolbar.tsx @@ -202,6 +202,30 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [customCss, demo]) + /** + * Check if text is a valid URI or file path + */ + const isUriOrFilePath = (text: string): boolean => { + const trimmed = text.trim() + // Must not contain newlines or whitespace + if (/\s/.test(trimmed)) { + return false + } + // URI patterns: http://, https://, ftp://, file://, etc. + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) { + return true + } + // Windows absolute path: C:\, D:\, etc. + if (/^[a-zA-Z]:[/\\]/.test(trimmed)) { + return true + } + // Unix absolute path: /path/to/file + if (/^\/[^/]/.test(trimmed)) { + return true + } + return false + } + // copy selected text to clipboard const handleCopy = useCallback(async () => { if (selectedText.current) { @@ -219,6 +243,43 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { } }, [setTimeoutTimer]) + const handleSearch = useCallback((action: ActionItem) => { + if (!action.selectedText) return + + const selectedText = action.selectedText.trim() + + let actionString = '' + if (isUriOrFilePath(selectedText)) { + actionString = selectedText + } else { + if (!action.searchEngine) return + + const customUrl = action.searchEngine.split('|')[1] + if (!customUrl) return + + actionString = customUrl.replace('{{queryString}}', encodeURIComponent(selectedText)) + } + + window.api?.openWebsite(actionString) + window.api?.selection.hideToolbar() + }, []) + + /** + * Quote the selected text to the inputbar of the main window + */ + const handleQuote = (action: ActionItem) => { + if (action.selectedText) { + window.api?.quoteToMainWindow(action.selectedText) + window.api?.selection.hideToolbar() + } + } + + const handleDefaultAction = (action: ActionItem) => { + // [macOS] only macOS has the available isFullscreen mode + window.api?.selection.processAction(action, isFullScreen.current) + window.api?.selection.hideToolbar() + } + const handleAction = useCallback( (action: ActionItem) => { if (demo) return @@ -241,36 +302,9 @@ const SelectionToolbar: FC<{ demo?: boolean }> = ({ demo = false }) => { break } }, - [demo, handleCopy] + [demo, handleCopy, handleSearch] ) - const handleSearch = (action: ActionItem) => { - if (!action.searchEngine) return - - const customUrl = action.searchEngine.split('|')[1] - if (!customUrl) return - - const searchUrl = customUrl.replace('{{queryString}}', encodeURIComponent(action.selectedText || '')) - window.api?.openWebsite(searchUrl) - window.api?.selection.hideToolbar() - } - - /** - * Quote the selected text to the inputbar of the main window - */ - const handleQuote = (action: ActionItem) => { - if (action.selectedText) { - window.api?.quoteToMainWindow(action.selectedText) - window.api?.selection.hideToolbar() - } - } - - const handleDefaultAction = (action: ActionItem) => { - // [macOS] only macOS has the available isFullscreen mode - window.api?.selection.processAction(action, isFullScreen.current) - window.api?.selection.hideToolbar() - } - return ( From 9ac7e2c78d96b3d4933d6c14095ae77b83ce8aef Mon Sep 17 00:00:00 2001 From: SuYao Date: Thu, 11 Dec 2025 15:01:01 +0800 Subject: [PATCH 11/38] feat: enhance web search tool switching logic to support provider-specific context (#11769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance web search tool switching logic to support provider-specific context * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: consolidate control flow in switchWebSearchTool (#11771) * Initial plan * refactor: make control flow consistent in switchWebSearchTool Replace early returns with break statements in all switch cases to ensure consistent control flow. Move fallback logic into default case for clarity. Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: format * fix: ensure switchWebSearchTool is always called for cherryin providers - Add missing else branch to prevent silent failure when provider extraction fails - Add empty string check for extracted providerId from split operation - Ensures web search functionality is preserved in all edge cases Addresses PR review feedback from #11769 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * refactor: simplify repetitive switchWebSearchTool calls - Extract providerId determination logic before calling switchWebSearchTool - Call switchWebSearchTool only once at the end with updated providerId - Reduce code duplication while maintaining all edge case handling Addresses review feedback from @kangfenmao 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * refactor: eliminate code duplication in switchWebSearchTool - Extract helper functions: ensureToolsObject, applyToolBasedSearch, applyProviderOptionsSearch - Replace switch statement and fallback if-else chain with providerHandlers map - Use array-based priority order for fallback logic - Reduce code from 73 lines to 80 lines but with much better maintainability - Eliminates 12 instances of "if (!params.tools) params.tools = {}" - Single source of truth for each provider's configuration logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- .../built-in/webSearchPlugin/helper.ts | 101 ++++++++++++++---- .../plugins/built-in/webSearchPlugin/index.ts | 18 +++- 2 files changed, 95 insertions(+), 24 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index 61e6f49b81..6e313bdd27 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -6,6 +6,7 @@ import { type Tool } from 'ai' import { createOpenRouterOptions, createXaiOptions, mergeProviderOptions } from '../../../options' import type { ProviderOptionsMap } from '../../../options/types' +import type { AiRequestContext } from '../../' import type { OpenRouterSearchConfig } from './openrouter' /** @@ -95,28 +96,84 @@ export type WebSearchToolInputSchema = { 'openai-chat': InferToolInput } -export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any) => { - if (config.openai) { - if (!params.tools) params.tools = {} - params.tools.web_search = openai.tools.webSearch(config.openai) - } else if (config['openai-chat']) { - if (!params.tools) params.tools = {} - params.tools.web_search_preview = openai.tools.webSearchPreview(config['openai-chat']) - } else if (config.anthropic) { - if (!params.tools) params.tools = {} - params.tools.web_search = anthropic.tools.webSearch_20250305(config.anthropic) - } else if (config.google) { - // case 'google-vertex': - if (!params.tools) params.tools = {} - params.tools.web_search = google.tools.googleSearch(config.google || {}) - } else if (config.xai) { - const searchOptions = createXaiOptions({ - searchParameters: { ...config.xai, mode: 'on' } - }) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) - } else if (config.openrouter) { - const searchOptions = createOpenRouterOptions(config.openrouter) - params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) +/** + * Helper function to ensure params.tools object exists + */ +const ensureToolsObject = (params: any) => { + if (!params.tools) params.tools = {} +} + +/** + * Helper function to apply tool-based web search configuration + */ +const applyToolBasedSearch = (params: any, toolName: string, toolInstance: any) => { + ensureToolsObject(params) + params.tools[toolName] = toolInstance +} + +/** + * Helper function to apply provider options-based web search configuration + */ +const applyProviderOptionsSearch = (params: any, searchOptions: any) => { + params.providerOptions = mergeProviderOptions(params.providerOptions, searchOptions) +} + +export const switchWebSearchTool = (config: WebSearchPluginConfig, params: any, context?: AiRequestContext) => { + const providerId = context?.providerId + + // Provider-specific configuration map + const providerHandlers: Record void> = { + openai: () => { + const cfg = config.openai ?? DEFAULT_WEB_SEARCH_CONFIG.openai + applyToolBasedSearch(params, 'web_search', openai.tools.webSearch(cfg)) + }, + 'openai-chat': () => { + const cfg = (config['openai-chat'] ?? DEFAULT_WEB_SEARCH_CONFIG['openai-chat']) as OpenAISearchPreviewConfig + applyToolBasedSearch(params, 'web_search_preview', openai.tools.webSearchPreview(cfg)) + }, + anthropic: () => { + const cfg = config.anthropic ?? DEFAULT_WEB_SEARCH_CONFIG.anthropic + applyToolBasedSearch(params, 'web_search', anthropic.tools.webSearch_20250305(cfg)) + }, + google: () => { + const cfg = (config.google ?? DEFAULT_WEB_SEARCH_CONFIG.google) as GoogleSearchConfig + applyToolBasedSearch(params, 'web_search', google.tools.googleSearch(cfg)) + }, + xai: () => { + const cfg = config.xai ?? DEFAULT_WEB_SEARCH_CONFIG.xai + const searchOptions = createXaiOptions({ searchParameters: { ...cfg, mode: 'on' } }) + applyProviderOptionsSearch(params, searchOptions) + }, + openrouter: () => { + const cfg = (config.openrouter ?? DEFAULT_WEB_SEARCH_CONFIG.openrouter) as OpenRouterSearchConfig + const searchOptions = createOpenRouterOptions(cfg) + applyProviderOptionsSearch(params, searchOptions) + } } + + // Try provider-specific handler first + const handler = providerId && providerHandlers[providerId] + if (handler) { + handler() + return params + } + + // Fallback: apply based on available config keys (prioritized order) + const fallbackOrder: Array = [ + 'openai', + 'openai-chat', + 'anthropic', + 'google', + 'xai', + 'openrouter' + ] + + for (const key of fallbackOrder) { + if (config[key]) { + providerHandlers[key]() + break + } + } + return params } diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts index a46df7dd4c..e02fd179fe 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/index.ts @@ -17,8 +17,22 @@ export const webSearchPlugin = (config: WebSearchPluginConfig = DEFAULT_WEB_SEAR name: 'webSearch', enforce: 'pre', - transformParams: async (params: any) => { - switchWebSearchTool(config, params) + transformParams: async (params: any, context) => { + let { providerId } = context + + // For cherryin providers, extract the actual provider from the model's provider string + // Expected format: "cherryin.{actualProvider}" (e.g., "cherryin.gemini") + if (providerId === 'cherryin' || providerId === 'cherryin-chat') { + const provider = params.model?.provider + if (provider && typeof provider === 'string' && provider.includes('.')) { + const extractedProviderId = provider.split('.')[1] + if (extractedProviderId) { + providerId = extractedProviderId + } + } + } + + switchWebSearchTool(config, params, { ...context, providerId }) return params } }) From 8cd4b1b747c3895621991108ada665208472f8b4 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:02:26 +0800 Subject: [PATCH 12/38] =?UTF-8?q?=F0=9F=90=9B=20fix:=20stabilize=20MCP=20l?= =?UTF-8?q?og=20IPC=20registration=20(#11830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc.ts | 1 - src/main/services/MCPService.ts | 12 +++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 714292c67e..478564eb47 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -766,7 +766,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool) ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion) ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) - ipcMain.handle(IpcChannel.Mcp_GetServerLogs, mcpService.getServerLogs) // DXT upload handler ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => { diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index f9b43f039d..cc6bbaa366 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -162,6 +162,7 @@ class McpService { this.cleanup = this.cleanup.bind(this) this.checkMcpConnectivity = this.checkMcpConnectivity.bind(this) this.getServerVersion = this.getServerVersion.bind(this) + this.getServerLogs = this.getServerLogs.bind(this) } private getServerKey(server: MCPServer): string { @@ -392,15 +393,8 @@ class McpService { source: 'stdio' }) }) - ;(stdioTransport as any).stdout?.on('data', (data: any) => { - const msg = data.toString() - this.emitServerLog(server, { - timestamp: Date.now(), - level: 'stdout', - message: msg.trim(), - source: 'stdio' - }) - }) + // StdioClientTransport does not expose stdout as a readable stream for raw logging + // (stdout is reserved for JSON-RPC). Avoid attaching a listener that would never fire. return stdioTransport } else { throw new Error('Either baseUrl or command must be provided') From ed695a8620919333b6363e9c1921ab5f364e7b05 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 11 Dec 2025 15:04:04 +0800 Subject: [PATCH 13/38] feat: Support custom git bash path (#11813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: allow custom Git Bash path for Claude Code Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * format code * format code * update i18n * fix: correct Git Bash invalid path translation key Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * test: cover null inputs for validateGitBashPath Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * refactor: rely on findGitBash for env override check Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * fix: validate env override for Git Bash path Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * chore: align Git Bash path getter with platform guard Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * test: cover env override behavior in findGitBash Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> * refactor: unify Git Bash path detection logic - Add customPath parameter to findGitBash() for config-based paths - Simplify checkGitBash IPC handler by delegating to findGitBash - Change validateGitBashPath success log level from info to debug - Only show success Alert when custom path is configured - Add tests for customPath parameter priority handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 2 + src/main/ipc.ts | 36 ++++- src/main/services/ConfigManager.ts | 3 +- .../agents/services/claudecode/index.ts | 7 +- src/main/utils/__tests__/process.test.ts | 128 +++++++++++++++++- src/main/utils/process.ts | 50 ++++++- src/preload/index.ts | 5 +- .../components/Popups/agent/AgentModal.tsx | 77 ++++++++++- src/renderer/src/i18n/locales/en-us.json | 14 ++ src/renderer/src/i18n/locales/zh-cn.json | 14 ++ src/renderer/src/i18n/locales/zh-tw.json | 14 ++ src/renderer/src/i18n/translate/de-de.json | 14 ++ src/renderer/src/i18n/translate/el-gr.json | 14 ++ src/renderer/src/i18n/translate/es-es.json | 14 ++ src/renderer/src/i18n/translate/fr-fr.json | 16 ++- src/renderer/src/i18n/translate/ja-jp.json | 14 ++ src/renderer/src/i18n/translate/pt-pt.json | 14 ++ src/renderer/src/i18n/translate/ru-ru.json | 14 ++ 18 files changed, 438 insertions(+), 12 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 88e7ae85d5..f3cf112fe0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -241,6 +241,8 @@ export enum IpcChannel { System_GetHostname = 'system:getHostname', System_GetCpuName = 'system:getCpuName', System_CheckGitBash = 'system:checkGitBash', + System_GetGitBashPath = 'system:getGitBashPath', + System_SetGitBashPath = 'system:setGitBashPath', // DevTools System_ToggleDevTools = 'system:toggleDevTools', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 478564eb47..a960eb7dc0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,7 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' +import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript, validateGitBashPath } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -35,7 +35,7 @@ import appService from './services/AppService' import AppUpdater from './services/AppUpdater' import BackupManager from './services/BackupManager' import { codeToolsService } from './services/CodeToolsService' -import { configManager } from './services/ConfigManager' +import { ConfigKeys, configManager } from './services/ConfigManager' import CopilotService from './services/CopilotService' import DxtService from './services/DxtService' import { ExportService } from './services/ExportService' @@ -499,7 +499,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - const bashPath = findGitBash() + const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined + const bashPath = findGitBash(customPath) if (bashPath) { logger.info('Git Bash is available', { path: bashPath }) @@ -513,6 +514,35 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return false } }) + + ipcMain.handle(IpcChannel.System_GetGitBashPath, () => { + if (!isWin) { + return null + } + + const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined + return customPath ?? null + }) + + ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => { + if (!isWin) { + return false + } + + if (!newPath) { + configManager.set(ConfigKeys.GitBashPath, null) + return true + } + + const validated = validateGitBashPath(newPath) + if (!validated) { + return false + } + + configManager.set(ConfigKeys.GitBashPath, validated) + return true + }) + ipcMain.handle(IpcChannel.System_ToggleDevTools, (e) => { const win = BrowserWindow.fromWebContents(e.sender) win && win.webContents.toggleDevTools() diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 61e285ac1b..c693d4b05a 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -31,7 +31,8 @@ export enum ConfigKeys { DisableHardwareAcceleration = 'disableHardwareAcceleration', Proxy = 'proxy', EnableDeveloperMode = 'enableDeveloperMode', - ClientId = 'clientId' + ClientId = 'clientId', + GitBashPath = 'gitBashPath' } export class ConfigManager { diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index e5cefadd68..ba863f7c50 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -15,6 +15,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' +import { ConfigKeys, configManager } from '@main/services/ConfigManager' +import { validateGitBashPath } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' @@ -107,6 +109,8 @@ class ClaudeCodeService implements AgentServiceInterface { Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy')) ) as Record + const customGitBashPath = validateGitBashPath(configManager.get(ConfigKeys.GitBashPath) as string | undefined) + const env = { ...loginShellEnvWithoutProxies, // TODO: fix the proxy api server @@ -126,7 +130,8 @@ class ClaudeCodeService implements AgentServiceInterface { // Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues // on Windows when the username contains non-ASCII characters (e.g., Chinese characters) // This prevents the SDK from using the user's home directory which may have encoding problems - CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude') + CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude'), + ...(customGitBashPath ? { CLAUDE_CODE_GIT_BASH_PATH: customGitBashPath } : {}) } const errorChunks: string[] = [] diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts index 45c0f8b42b..0485ec5fad 100644 --- a/src/main/utils/__tests__/process.test.ts +++ b/src/main/utils/__tests__/process.test.ts @@ -3,7 +3,7 @@ import fs from 'fs' import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { findExecutable, findGitBash } from '../process' +import { findExecutable, findGitBash, validateGitBashPath } from '../process' // Mock dependencies vi.mock('child_process') @@ -289,7 +289,133 @@ describe.skipIf(process.platform !== 'win32')('process utilities', () => { }) }) + describe('validateGitBashPath', () => { + it('returns null when path is null', () => { + const result = validateGitBashPath(null) + + expect(result).toBeNull() + }) + + it('returns null when path is undefined', () => { + const result = validateGitBashPath(undefined) + + expect(result).toBeNull() + }) + + it('returns normalized path when valid bash.exe exists', () => { + const customPath = 'C:\\PortableGit\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === 'C:\\PortableGit\\bin\\bash.exe') + + const result = validateGitBashPath(customPath) + + expect(result).toBe('C:\\PortableGit\\bin\\bash.exe') + }) + + it('returns null when file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = validateGitBashPath('C:\\missing\\bash.exe') + + expect(result).toBeNull() + }) + + it('returns null when path is not bash.exe', () => { + const customPath = 'C:\\PortableGit\\bin\\git.exe' + vi.mocked(fs.existsSync).mockReturnValue(true) + + const result = validateGitBashPath(customPath) + + expect(result).toBeNull() + }) + }) + describe('findGitBash', () => { + describe('customPath parameter', () => { + beforeEach(() => { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + it('uses customPath when valid', () => { + const customPath = 'C:\\CustomGit\\bin\\bash.exe' + vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath) + + const result = findGitBash(customPath) + + expect(result).toBe(customPath) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('falls back when customPath is invalid', () => { + const customPath = 'C:\\Invalid\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === customPath) return false + if (p === gitPath) return true + if (p === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash(customPath) + + expect(result).toBe(bashPath) + }) + + it('prioritizes customPath over env override', () => { + const customPath = 'C:\\CustomGit\\bin\\bash.exe' + const envPath = 'C:\\EnvGit\\bin\\bash.exe' + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => p === customPath || p === envPath) + + const result = findGitBash(customPath) + + expect(result).toBe(customPath) + }) + }) + + describe('env override', () => { + beforeEach(() => { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + it('uses CLAUDE_CODE_GIT_BASH_PATH when valid', () => { + const envPath = 'C:\\OverrideGit\\bin\\bash.exe' + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => p === envPath) + + const result = findGitBash() + + expect(result).toBe(envPath) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('falls back when CLAUDE_CODE_GIT_BASH_PATH is invalid', () => { + const envPath = 'C:\\Invalid\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + + vi.mocked(fs.existsSync).mockImplementation((p) => { + if (p === envPath) return false + if (p === gitPath) return true + if (p === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = findGitBash() + + expect(result).toBe(bashPath) + }) + }) + describe('git.exe path derivation', () => { it('should derive bash.exe from standard Git installation (Git/cmd/git.exe)', () => { const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index b59a37a048..7175af7e75 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -131,15 +131,37 @@ export function findExecutable(name: string): string | null { /** * Find Git Bash executable on Windows + * @param customPath - Optional custom path from config * @returns Full path to bash.exe or null if not found */ -export function findGitBash(): string | null { +export function findGitBash(customPath?: string | null): string | null { // Git Bash is Windows-only if (!isWin) { return null } - // 1. Find git.exe and derive bash.exe path + // 1. Check custom path from config first + if (customPath) { + const validated = validateGitBashPath(customPath) + if (validated) { + logger.debug('Using custom Git Bash path from config', { path: validated }) + return validated + } + logger.warn('Custom Git Bash path provided but invalid', { path: customPath }) + } + + // 2. Check environment variable override + const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH + if (envOverride) { + const validated = validateGitBashPath(envOverride) + if (validated) { + logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override for bash.exe', { path: validated }) + return validated + } + logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride }) + } + + // 3. Find git.exe and derive bash.exe path const gitPath = findExecutable('git') if (gitPath) { // Try multiple possible locations for bash.exe relative to git.exe @@ -164,7 +186,7 @@ export function findGitBash(): string | null { }) } - // 2. Fallback: check common Git Bash paths directly + // 4. Fallback: check common Git Bash paths directly const commonBashPaths = [ path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'), path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'), @@ -181,3 +203,25 @@ export function findGitBash(): string | null { logger.debug('Git Bash not found - checked git derivation and common paths') return null } + +export function validateGitBashPath(customPath?: string | null): string | null { + if (!customPath) { + return null + } + + const resolved = path.resolve(customPath) + + if (!fs.existsSync(resolved)) { + logger.warn('Custom Git Bash path does not exist', { path: resolved }) + return null + } + + const isExe = resolved.toLowerCase().endsWith('bash.exe') + if (!isExe) { + logger.warn('Custom Git Bash path is not bash.exe', { path: resolved }) + return null + } + + logger.debug('Validated custom Git Bash path', { path: resolved }) + return resolved +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 654e727cc6..fda288f68e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -124,7 +124,10 @@ const api = { getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType), getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname), getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName), - checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash) + checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash), + getGitBashPath: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath), + setGitBashPath: (newPath: string | null): Promise => + ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath) }, devTools: { toggle: () => ipcRenderer.invoke(IpcChannel.System_ToggleDevTools) diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 0d3ce94731..8a8b4fe61b 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -60,6 +60,7 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const [form, setForm] = useState(() => buildAgentForm(agent)) const [hasGitBash, setHasGitBash] = useState(true) + const [customGitBashPath, setCustomGitBashPath] = useState('') useEffect(() => { if (open) { @@ -70,7 +71,11 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const checkGitBash = useCallback( async (showToast = false) => { try { - const gitBashInstalled = await window.api.system.checkGitBash() + const [gitBashInstalled, savedPath] = await Promise.all([ + window.api.system.checkGitBash(), + window.api.system.getGitBashPath().catch(() => null) + ]) + setCustomGitBashPath(savedPath ?? '') setHasGitBash(gitBashInstalled) if (showToast) { if (gitBashInstalled) { @@ -93,6 +98,46 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' + const handlePickGitBash = useCallback(async () => { + try { + const selected = await window.api.file.select({ + title: t('agent.gitBash.pick.title', 'Select Git Bash executable'), + filters: [{ name: 'Executable', extensions: ['exe'] }], + properties: ['openFile'] + }) + + if (!selected || selected.length === 0) { + return + } + + const pickedPath = selected[0].path + const ok = await window.api.system.setGitBashPath(pickedPath) + if (!ok) { + window.toast.error( + t('agent.gitBash.pick.invalidPath', 'Selected file is not a valid Git Bash executable (bash.exe).') + ) + return + } + + setCustomGitBashPath(pickedPath) + await checkGitBash(true) + } catch (error) { + logger.error('Failed to pick Git Bash path', error as Error) + window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + } + }, [checkGitBash, t]) + + const handleClearGitBash = useCallback(async () => { + try { + await window.api.system.setGitBashPath(null) + setCustomGitBashPath('') + await checkGitBash(true) + } catch (error) { + logger.error('Failed to clear Git Bash path', error as Error) + window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + } + }, [checkGitBash, t]) + const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {}) @@ -324,6 +369,9 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { + } type="error" @@ -331,6 +379,33 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { style={{ marginBottom: 16 }} /> )} + + {hasGitBash && customGitBashPath && ( + +
+ {t('agent.gitBash.customPath', { + defaultValue: 'Using custom path: {{path}}', + path: customGitBashPath + })} +
+
+ + +
+ + } + type="success" + showIcon + style={{ marginBottom: 16 }} + /> + )}