From 313dac0f64c051b463f53c60700b6294c6111f07 Mon Sep 17 00:00:00 2001 From: yudong Date: Tue, 6 Jan 2026 15:17:22 +0800 Subject: [PATCH 01/20] fix: Changed the ID of the doubao-seed-1-8 from '251215' to '251228' (#12307) Co-authored-by: wangyudong --- src/renderer/src/config/models/__tests__/reasoning.test.ts | 4 ++-- src/renderer/src/config/models/default.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 56f9cd0b60..0f58be4ef0 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -745,7 +745,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => { }) it('should return doubao_after_251015 for Doubao-Seed-1.8 models', () => { - expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015') + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015') expect(getThinkModelType(createModel({ id: 'doubao-seed-1.8' }))).toBe('doubao_after_251015') }) @@ -879,7 +879,7 @@ describe('getThinkModelType - Comprehensive Coverage', () => { // auto > after_251015 > no_auto expect(getThinkModelType(createModel({ id: 'doubao-seed-1.6' }))).toBe('doubao') expect(getThinkModelType(createModel({ id: 'doubao-seed-1-6-251015' }))).toBe('doubao_after_251015') - expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251215' }))).toBe('doubao_after_251015') + expect(getThinkModelType(createModel({ id: 'doubao-seed-1-8-251228' }))).toBe('doubao_after_251015') expect(getThinkModelType(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toBe('doubao_no_auto') }) diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 1223d0c92c..408c047639 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -771,7 +771,7 @@ export const SYSTEM_MODELS: Record = ], doubao: [ { - id: 'doubao-seed-1-8-251215', + id: 'doubao-seed-1-8-251228', provider: 'doubao', name: 'Doubao-Seed-1.8', group: 'Doubao-Seed-1.8' From 9e45f801a801930ccfe62e3d779dfc223bd1ef83 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 6 Jan 2026 15:30:22 +0800 Subject: [PATCH 02/20] chore: optimize build excludes to reduce package size (#12311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exclude config, patches directories - Exclude app-upgrade-config.json - Exclude unnecessary node_modules files (*.cpp, node-addon-api, prebuild-install) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 --- electron-builder.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index bf7b7b4e91..af1774c941 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -28,6 +28,12 @@ files: - "!**/{tsconfig.json,tsconfig.tsbuildinfo,tsconfig.node.json,tsconfig.web.json}" - "!**/{.editorconfig,.jekyll-metadata}" - "!src" + - "!config" + - "!patches" + - "!app-upgrade-config.json" + - "!**/node_modules/**/*.cpp" + - "!**/node_modules/node-addon-api/**" + - "!**/node_modules/prebuild-install/**" - "!scripts" - "!local" - "!docs" From a5038ac84488ba023957eea1e7289ce498ab14e8 Mon Sep 17 00:00:00 2001 From: Phantom Date: Tue, 6 Jan 2026 17:28:34 +0800 Subject: [PATCH 03/20] fix: Add reasoning control for Deepseek hybrid inference models when reasoning effort is 'none' (#12314) fix: Add reasoning control for Deepseek hybrid inference models when reasoning effort is 'none' It prevents warning --- src/renderer/src/aiCore/utils/reasoning.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index ab8a0b7983..c57dc31d17 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -118,6 +118,11 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { thinking: { type: 'disabled' } } } + // Deepseek, default behavior is non-thinking + if (isDeepSeekHybridInferenceModel(model)) { + return {} + } + // GPT 5.1, GPT 5.2, or newer if (isSupportNoneReasoningEffortModel(model)) { return { From bb9b73557bd98d8a3a1f7ab63451c86b804fa6ec Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 6 Jan 2026 17:33:19 +0800 Subject: [PATCH 04/20] fix: use ipinfo lite API with token for IP country detection (#12312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use ipinfo lite API with token for IP country detection Switch from ipinfo.io/json to api.ipinfo.io/lite/me endpoint with authentication token to improve reliability and avoid rate limiting. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * fix: use country_code field from ipinfo lite API response The lite API returns country_code instead of country field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: Claude Sonnet 4.5 --- src/main/utils/ipService.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/utils/ipService.ts b/src/main/utils/ipService.ts index 3180f9457c..708af4c40e 100644 --- a/src/main/utils/ipService.ts +++ b/src/main/utils/ipService.ts @@ -13,18 +13,13 @@ export async function getIpCountry(): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - const ipinfo = await net.fetch('https://ipinfo.io/json', { - signal: controller.signal, - headers: { - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept-Language': 'en-US,en;q=0.9' - } + const ipinfo = await net.fetch(`https://api.ipinfo.io/lite/me?token=2a42580355dae4`, { + signal: controller.signal }) clearTimeout(timeoutId) const data = await ipinfo.json() - const country = data.country || 'CN' + const country = data.country_code || 'CN' logger.info(`Detected user IP address country: ${country}`) return country } catch (error) { From af7896b90048225c416f4933a54b80aedb35f943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:45:27 +0800 Subject: [PATCH 05/20] fix(prompts): standardize tool use example format to use 'A:' label consistently (#12313) - Changed all 'Assistant:' labels to 'A:' in tool use examples for consistency - Added missing blank line before final response in both files - Affects promptToolUsePlugin.ts and prompt.ts - Resolves #12310 --- .../toolUsePlugin/promptToolUsePlugin.ts | 3 ++- src/renderer/src/utils/prompt.ts | 17 +++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index 224cee05ae..67dd33e9e0 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -154,7 +154,8 @@ User: search 26 million (2019) -Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.` + +A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population.` /** * 构建可用工具部分(提取自 Cherry Studio) diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 4e799800a7..326392947a 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -14,7 +14,7 @@ Here are a few examples using notional tools: --- User: Generate an image of the oldest person in this document. -Assistant: I can use the document_qa tool to find out who the oldest person is in the document. +A: I can use the document_qa tool to find out who the oldest person is in the document. document_qa {"document": "document.pdf", "question": "Who is the oldest person mentioned?"} @@ -25,7 +25,7 @@ User: John Doe, a 55 year old lumberjack living in Newfoundland. -Assistant: I can use the image_generator tool to create a portrait of John Doe. +A: I can use the image_generator tool to create a portrait of John Doe. image_generator {"prompt": "A portrait of John Doe, a 55-year-old man living in Canada."} @@ -36,12 +36,12 @@ User: image.png -Assistant: the image is generated as image.png +A: the image is generated as image.png --- User: "What is the result of the following operation: 5 + 3 + 1294.678?" -Assistant: I can use the python_interpreter tool to calculate the result of the operation. +A: I can use the python_interpreter tool to calculate the result of the operation. python_interpreter {"code": "5 + 3 + 1294.678"} @@ -52,12 +52,12 @@ User: 1302.678 -Assistant: The result of the operation is 1302.678. +A: The result of the operation is 1302.678. --- User: "Which city has the highest population , Guangzhou or Shanghai?" -Assistant: I can use the search tool to find the population of Guangzhou. +A: I can use the search tool to find the population of Guangzhou. search {"query": "Population Guangzhou"} @@ -68,7 +68,7 @@ User: Guangzhou has a population of 15 million inhabitants as of 2021. -Assistant: I can use the search tool to find the population of Shanghai. +A: I can use the search tool to find the population of Shanghai. search {"query": "Population Shanghai"} @@ -78,7 +78,8 @@ User: search 26 million (2019) -Assistant: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population. + +A: The population of Shanghai is 26 million, while Guangzhou has a population of 15 million. Therefore, Shanghai has the highest population. ` export const AvailableTools = (tools: MCPTool[]) => { From 116ee6f94bb099f951b7c96ad63f2b90d8b65102 Mon Sep 17 00:00:00 2001 From: Shemol Date: Tue, 6 Jan 2026 22:19:03 +0800 Subject: [PATCH 06/20] fix: TokenFlux models list empty in drawing panel (#12326) Use fixed base URL for TokenFlux image API instead of provider.apiHost. After migration 191, apiHost was changed to include /openai/v1 suffix for chat API compatibility, but image API needs the base URL without this suffix, causing /openai/v1/v1/images/models (wrong path). Fixes #12284 Signed-off-by: SherlockShemol --- .../src/pages/paintings/utils/TokenFluxService.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/paintings/utils/TokenFluxService.ts b/src/renderer/src/pages/paintings/utils/TokenFluxService.ts index 4b1e224a8a..ddd362045b 100644 --- a/src/renderer/src/pages/paintings/utils/TokenFluxService.ts +++ b/src/renderer/src/pages/paintings/utils/TokenFluxService.ts @@ -6,6 +6,9 @@ import type { TokenFluxModel } from '../config/tokenFluxConfig' const logger = loggerService.withContext('TokenFluxService') +// 图片 API 使用固定的基础地址,独立于 provider.apiHost(后者是 OpenAI 兼容的聊天 API 地址) +const TOKENFLUX_IMAGE_API_HOST = 'https://api.tokenflux.ai' + export interface TokenFluxGenerationRequest { model: string input: { @@ -66,7 +69,7 @@ export class TokenFluxService { return cachedModels } - const response = await fetch(`${this.apiHost}/v1/images/models`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/models`, { headers: { Authorization: `Bearer ${this.apiKey}` } @@ -88,7 +91,7 @@ export class TokenFluxService { * Create a new image generation request */ async createGeneration(request: TokenFluxGenerationRequest, signal?: AbortSignal): Promise { - const response = await fetch(`${this.apiHost}/v1/images/generations`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/generations`, { method: 'POST', headers: this.getHeaders(), body: JSON.stringify(request), @@ -108,7 +111,7 @@ export class TokenFluxService { * Get the status and result of a generation */ async getGenerationResult(generationId: string): Promise { - const response = await fetch(`${this.apiHost}/v1/images/generations/${generationId}`, { + const response = await fetch(`${TOKENFLUX_IMAGE_API_HOST}/v1/images/generations/${generationId}`, { headers: { Authorization: `Bearer ${this.apiKey}` } From 6b0bb64795beb99b5e7a63261132695462d69da4 Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 7 Jan 2026 01:03:37 +0800 Subject: [PATCH 07/20] fix: convert 'developer' role to 'system' for unsupported providers (#12325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI SDK v5 uses 'developer' role for reasoning models, but some providers like Azure DeepSeek R1 only support 'system', 'user', 'assistant', 'tool' roles, causing HTTP 422 errors. This fix adds a custom fetch wrapper that converts 'developer' role back to 'system' for providers that don't support it. Fixes #12321 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .../src/aiCore/provider/providerConfig.ts | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 0ad15ea895..54915795a8 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -1,5 +1,5 @@ import { formatPrivateKey, hasProviderConfig, ProviderConfigFactory } from '@cherrystudio/ai-core/provider' -import { isOpenAIChatCompletionOnlyModel } from '@renderer/config/models' +import { isOpenAIChatCompletionOnlyModel, isOpenAIReasoningModel } from '@renderer/config/models' import { getAwsBedrockAccessKeyId, getAwsBedrockApiKey, @@ -29,6 +29,7 @@ import { isNewApiProvider, isOllamaProvider, isPerplexityProvider, + isSupportDeveloperRoleProvider, isSupportStreamOptionsProvider, isVertexProvider } from '@renderer/utils/provider' @@ -264,6 +265,14 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A } } + // Apply developer-to-system role conversion for providers that don't support developer role + // bug: https://github.com/vercel/ai/issues/10982 + // fixPR: https://github.com/vercel/ai/pull/11127 + // TODO: but the PR don't backport to v5, the code will be removed when upgrading to v6 + if (!isSupportDeveloperRoleProvider(actualProvider) || !isOpenAIReasoningModel(model)) { + extraOptions.fetch = createDeveloperToSystemFetch(extraOptions.fetch) + } + if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') { const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions) return { @@ -302,6 +311,44 @@ export function isModernSdkSupported(provider: Provider): boolean { return hasProviderConfig(aiSdkProviderId) } +/** + * Creates a custom fetch wrapper that converts 'developer' role to 'system' role in request body. + * This is needed for providers that don't support the 'developer' role (e.g., Azure DeepSeek R1). + * + * @param originalFetch - Optional original fetch function to wrap + * @returns A fetch function that transforms the request body + */ +function createDeveloperToSystemFetch(originalFetch?: typeof fetch): typeof fetch { + const baseFetch = originalFetch ?? fetch + return async (input: RequestInfo | URL, init?: RequestInit) => { + let options = init + if (options?.body && typeof options.body === 'string') { + try { + const body = JSON.parse(options.body) + if (body.messages && Array.isArray(body.messages)) { + let hasChanges = false + body.messages = body.messages.map((msg: { role: string }) => { + if (msg.role === 'developer') { + hasChanges = true + return { ...msg, role: 'system' } + } + return msg + }) + if (hasChanges) { + options = { + ...options, + body: JSON.stringify(body) + } + } + } + } catch { + // If parsing fails, just use original body + } + } + return baseFetch(input, options) + } +} + /** * 准备特殊provider的配置,主要用于异步处理的配置 */ @@ -360,5 +407,6 @@ export async function prepareSpecialProviderConfig( } } } + return config } From c940b5613f42df7b67d2d01771518cb3572aeb7f Mon Sep 17 00:00:00 2001 From: Little White Dog <43770746+xiaobaigou1000@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:53:10 +0800 Subject: [PATCH 08/20] fix: resolve ActionTranslate stalling after initialization (#12329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve ActionTranslate stalling after initialization Issue: When invoking translate from the selection assistant, the fetchResult function does not react to the completion of initialize, causing the ActionTranslate component to enter an infinite loading state. Cause: In commit 680bda3993ced26b028cdafbb61fadc5a4a70822, the initialize effect hook was refactored into a callback function. This refactor omitted the notification that fetchResult should run after initialization, so fetchResult never executes post‑initialization. Fix: Change the initialized flag from a ref to a state variable and have fetchResult listen to this state. This modification ensures the effect hook triggers fetchResult exactly once after initialization is complete. * fix(ActionTranslate): fix missing dependency in useEffect hook --------- Co-authored-by: icarus --- .../action/components/ActionTranslate.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index b5e0fea689..3ac80a014c 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -51,9 +51,9 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const [isContented, setIsContented] = useState(false) const [isLoading, setIsLoading] = useState(true) const [contentToCopy, setContentToCopy] = useState('') + const [initialized, setInitialized] = useState(false) // Use useRef for values that shouldn't trigger re-renders - const initialized = useRef(false) const assistantRef = useRef(null) const topicRef = useRef(null) const askId = useRef('') @@ -85,7 +85,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { // Initialize values only once const initialize = useCallback(async () => { - if (initialized.current) { + if (initialized) { logger.silly('[initialize] Already initialized.') return } @@ -114,8 +114,8 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { // Initialize topic topicRef.current = getDefaultTopic(currentAssistant.id) - initialized.current = true - }, [action.selectedText, isLanguagesLoaded, updateLanguagePair]) + setInitialized(true) + }, [action.selectedText, initialized, isLanguagesLoaded, updateLanguagePair]) // Try to initialize when: // 1. action.selectedText change (generally will not) @@ -126,7 +126,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { }, [initialize]) const fetchResult = useCallback(async () => { - if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized.current) return + if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized) return const setAskId = (id: string) => { askId.current = id @@ -174,7 +174,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { assistantRef.current = assistant logger.debug('process once') processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError) - }, [action, targetLanguage, alterLanguage, scrollToBottom]) + }, [action, targetLanguage, alterLanguage, scrollToBottom, initialized]) useEffect(() => { fetchResult() @@ -189,7 +189,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { }, [allMessages]) const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { - if (!initialized.current) { + if (!initialized) { return } setTargetLanguage(targetLanguage) From 91b6ed81cc07983f2848e9e5703806d7f59e0b1b Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 7 Jan 2026 14:13:05 +0800 Subject: [PATCH 09/20] fix(ProviderSettings): allow embedding model API check and optimize hooks (#12334) --- .../ProviderSettings/ProviderSetting.tsx | 27 ++++++++++--------- src/renderer/src/services/ApiService.ts | 8 +++++- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 777bc61984..91c65a7e3e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -5,7 +5,7 @@ import { HStack } from '@renderer/components/Layout' import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' import Selector from '@renderer/components/Selector' import { HelpTooltip } from '@renderer/components/TooltipIcons' -import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' +import { isRerankModel } from '@renderer/config/models' import { PROVIDER_URLS } from '@renderer/config/providers' import { useTheme } from '@renderer/context/ThemeProvider' import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useProvider' @@ -129,17 +129,20 @@ const ProviderSetting: FC = ({ providerId }) => { checking: false }) - const updateWebSearchProviderKey = ({ apiKey }: { apiKey: string }) => { - provider.id === 'zhipu' && dispatch(updateWebSearchProvider({ id: 'zhipu', apiKey: apiKey.split(',')[0] })) - } + const updateWebSearchProviderKey = useCallback( + ({ apiKey }: { apiKey: string }) => { + provider.id === 'zhipu' && dispatch(updateWebSearchProvider({ id: 'zhipu', apiKey: apiKey.split(',')[0] })) + }, + [dispatch, provider.id] + ) - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedUpdateApiKey = useCallback( - debounce((value) => { - updateProvider({ apiKey: formatApiKeys(value) }) - updateWebSearchProviderKey({ apiKey: formatApiKeys(value) }) - }, 150), - [] + const debouncedUpdateApiKey = useMemo( + () => + debounce((value: string) => { + updateProvider({ apiKey: formatApiKeys(value) }) + updateWebSearchProviderKey({ apiKey: formatApiKeys(value) }) + }, 150), + [updateProvider, updateWebSearchProviderKey] ) // 同步 provider.apiKey 到 localApiKey @@ -225,7 +228,7 @@ const ProviderSetting: FC = ({ providerId }) => { return } - const modelsToCheck = models.filter((model) => !isEmbeddingModel(model) && !isRerankModel(model)) + const modelsToCheck = models.filter((model) => !isRerankModel(model)) if (isEmpty(modelsToCheck)) { window.toast.error({ diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 0cd57a353a..89ebe6ecd3 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -601,6 +601,13 @@ export function checkApiProvider(provider: Provider): void { } } +/** + * Validates that a provider/model pair is working by sending a minimal request. + * @param provider - The provider configuration to test. + * @param model - The model to use for the validation request (chat or embeddings). + * @param timeout - Maximum time (ms) to wait for the request to complete. Defaults to 15000 ms. + * @throws {Error} If the request fails or times out, indicating the API is not usable. + */ export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise { checkApiProvider(provider) @@ -611,7 +618,6 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000 assistant.prompt = 'test' // 避免部分 provider 空系统提示词会报错 if (isEmbeddingModel(model)) { - // race 超时 15s logger.silly("it's a embedding model") const timerPromise = new Promise((_, reject) => setTimeout(() => reject('Timeout'), timeout)) await Promise.race([ai.getEmbeddingDimensions(model), timerPromise]) From ed3401a0163dac1f0876f0c3a172a7412d4779e2 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:48:27 +0800 Subject: [PATCH 10/20] =?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.76=20(#12317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade from 0.1.62 to 0.1.76 (latest stable) - Remove version-specific patch (no longer needed) --- package.json | 3 +- ...aude-agent-sdk-npm-0.1.62-23ae56f8c8.patch | 35 ------------------- pnpm-lock.yaml | 15 ++++---- 3 files changed, 7 insertions(+), 46 deletions(-) delete mode 100644 patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch diff --git a/package.json b/package.json index ece4d89802..91e7d08de9 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "release:ai-sdk-provider": "pnpm --filter @cherrystudio/ai-sdk-provider version patch && pnpm --filter @cherrystudio/ai-sdk-provider build && pnpm --filter @cherrystudio/ai-sdk-provider publish --access public" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.1.62", + "@anthropic-ai/claude-agent-sdk": "0.1.76", "@libsql/client": "0.14.0", "@napi-rs/system-ocr": "1.0.2", "@paymoapp/electron-shutdown-handler": "1.1.2", @@ -435,7 +435,6 @@ "@ai-sdk/openai-compatible@1.0.27": "1.0.28" }, "patchedDependencies": { - "@anthropic-ai/claude-agent-sdk@0.1.62": "patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch", "@napi-rs/system-ocr@1.0.2": "patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "tesseract.js@6.0.1": "patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "@ai-sdk/google@2.0.49": "patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch", diff --git a/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch b/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch deleted file mode 100644 index 62ab767576..0000000000 --- a/patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch +++ /dev/null @@ -1,35 +0,0 @@ -diff --git a/sdk.mjs b/sdk.mjs -index dea7766a3432a1e809f12d6daba4f2834a219689..e0b02ef73da177ba32b903887d7bbbeaa08cc6d3 100755 ---- a/sdk.mjs -+++ b/sdk.mjs -@@ -6250,7 +6250,7 @@ function createAbortController(maxListeners = DEFAULT_MAX_LISTENERS) { - } - - // ../src/transport/ProcessTransport.ts --import { spawn } from "child_process"; -+import { fork } from "child_process"; - import { createInterface } from "readline"; - - // ../src/utils/fsOperations.ts -@@ -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); - } -- const isNative = isNativeBinary(pathToClaudeCodeExecutable); -- const spawnCommand = isNative ? pathToClaudeCodeExecutable : executable; -- const spawnArgs = isNative ? [...executableArgs, ...args] : [...executableArgs, pathToClaudeCodeExecutable, ...args]; -- const spawnMessage = isNative ? `Spawning Claude Code native binary: ${spawnCommand} ${spawnArgs.join(" ")}` : `Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(" ")}`; -- logForSdkDebugging(spawnMessage); -- if (stderr) { -- stderr(spawnMessage); -- } -+ logForSdkDebugging(`Forking Claude Code Node.js process: ${pathToClaudeCodeExecutable} ${args.join(" ")}`); - const stderrMode = env.DEBUG_CLAUDE_AGENT_SDK || stderr ? "pipe" : "ignore"; -- this.child = spawn(spawnCommand, spawnArgs, { -+ this.child = fork(pathToClaudeCodeExecutable, args, { - cwd, -- stdio: ["pipe", "pipe", stderrMode], -+ stdio: stderrMode === "pipe" ? ["pipe", "pipe", "pipe", "ipc"] : ["pipe", "pipe", "ignore", "ipc"], - signal: this.abortController.signal, - env - }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e93839f943..3064005f8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,9 +34,6 @@ patchedDependencies: '@ai-sdk/openai@2.0.85': hash: f2077f4759520d1de69b164dfd8adca1a9ace9de667e35cb0e55e812ce2ac13b path: patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch - '@anthropic-ai/claude-agent-sdk@0.1.62': - hash: 61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8 - path: patches/@anthropic-ai-claude-agent-sdk-npm-0.1.62-23ae56f8c8.patch '@anthropic-ai/vertex-sdk@0.11.4': hash: 12e3275df5632dfe717d4db64df70e9b0128dfac86195da27722effe4749662f path: patches/@anthropic-ai-vertex-sdk-npm-0.11.4-c19cb41edb.patch @@ -88,8 +85,8 @@ importers: .: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: 0.1.62 - version: 0.1.62(patch_hash=61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8)(zod@4.3.4) + specifier: 0.1.76 + version: 0.1.76(zod@4.3.4) '@libsql/client': specifier: 0.14.0 version: 0.14.0 @@ -1459,11 +1456,11 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/claude-agent-sdk@0.1.62': - resolution: {integrity: sha512-KoJAQ0kdrbOukh4r0CFvFZgSKlAGAVJf8baeK2jpFCxbUhqr99Ier88v1L2iehWSWkXR6oVaThCYozN74Q3jUw==} + '@anthropic-ai/claude-agent-sdk@0.1.76': + resolution: {integrity: sha512-s7RvpXoFaLXLG7A1cJBAPD8ilwOhhc/12fb5mJXRuD561o4FmPtQ+WRfuy9akMmrFRfLsKv8Ornw3ClGAPL2fw==} engines: {node: '>=18.0.0'} peerDependencies: - zod: ^3.24.1 + zod: ^3.24.1 || ^4.0.0 '@anthropic-ai/sdk@0.27.3': resolution: {integrity: sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==} @@ -12415,7 +12412,7 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@anthropic-ai/claude-agent-sdk@0.1.62(patch_hash=61ed4549b423c717cbfef526e3ed5b0329c2de2de88c9ef772188668f7dc26e8)(zod@4.3.4)': + '@anthropic-ai/claude-agent-sdk@0.1.76(zod@4.3.4)': dependencies: zod: 4.3.4 optionalDependencies: From 334b9bbe04ccbd8fccccdc2e0afac552dd9be1c7 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Wed, 7 Jan 2026 16:22:40 +0800 Subject: [PATCH 11/20] fix: disable differential package for nsis and dmg (#12335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- electron-builder.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index af1774c941..3171ddf584 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -96,6 +96,7 @@ nsis: oneClick: false include: build/nsis-installer.nsh buildUniversalInstaller: false + differentialPackage: false portable: artifactName: ${productName}-${version}-${arch}-portable.${ext} buildUniversalInstaller: false @@ -111,6 +112,8 @@ mac: target: - target: dmg - target: zip +dmg: + writeUpdateInfo: false linux: artifactName: ${productName}-${version}-${arch}.${ext} target: From 6d15b0dfd1a6b4346175dd4cf88a210f81777e45 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:35:51 +0800 Subject: [PATCH 12/20] feat(mcp): add MCP Hub server for multi-server tool orchestration (#12192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mcp): add hub server type definitions - Add 'hub' to BuiltinMCPServerNames enum as '@cherry/hub' - Create GeneratedTool, SearchQuery, ExecInput, ExecOutput types - Add ExecutionContext and ConsoleMethods interfaces Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e Co-authored-by: Amp * feat(mcp): implement hub server core components - generator.ts: Convert MCP tools to JS functions with JSDoc - tool-registry.ts: In-memory cache with 10-min TTL - search.ts: Comma-separated keyword search with ranking - runtime.ts: Code execution with parallel/settle/console helpers Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e Co-authored-by: Amp * feat(mcp): integrate hub server with MCP infrastructure - Create HubServer class with search/exec tools - Implement mcp-bridge for calling tools via MCPService - Register hub server in factory with dependency injection - Initialize hub dependencies in MCPService constructor - Add hub server description label for i18n Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e Co-authored-by: Amp * test(mcp): add unit tests for hub server - generator.test.ts: Test schema conversion and JSDoc generation - search.test.ts: Test keyword matching, ranking, and limits - runtime.test.ts: Test code execution, helpers, and error handling Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e Co-authored-by: Amp * docs(mcp): add hub server documentation - Document search/exec tool usage and parameters - Explain configuration and caching behavior - Include architecture diagram and file structure Amp-Thread-ID: https://ampcode.com/threads/T-019b4e7d-86a3-770d-82f8-9e646e7e597e Co-authored-by: Amp * ♻️ refactor(hub): simplify dependency injection for HubServer - Remove HubServerDependencies interface and setHubServerDependencies from factory - Add initHubBridge() to mcp-bridge for direct initialization - Make HubServer constructor parameterless (uses pre-initialized bridge) - MCPService now calls initHubBridge() directly instead of factory setter - Add integration tests for full search → exec flow * 📝 docs(hub): add comments explaining why hub is not in builtin list - Add JSDoc to HubServer class explaining its purpose and design - Add comment to builtinMCPServers explaining hub exclusion - Hub is a meta-server for LLM code mode, auto-enabled internally * ✨ feat: add available tools section to HUB_MODE_SYSTEM_PROMPT - Add shared utility for generating MCP tool function names (serverName_toolName format) - Update hub server to use consistent function naming across search, exec and prompt - Add fetchAllActiveServerTools to ApiService for renderer process - Update parameterBuilder to include available tools in auto/hub mode prompt - Use CacheService for 1-minute tools caching in hub server - Remove ToolRegistry in favor of direct fetching with caching - Update search ranking to include server name matching - Fix tests to use new naming format Amp-Thread-ID: https://ampcode.com/threads/T-019b6971-d5c9-7719-9245-a89390078647 Co-authored-by: Amp * ♻️ refactor: consolidate MCP tool name utilities into shared module - Merge buildFunctionCallToolName from src/main/utils/mcp.ts into packages/shared/mcp.ts - Create unified buildMcpToolName base function with options for prefix, delimiter, maxLength, existingNames - Fix toCamelCase to normalize uppercase snake case (MY_SERVER → myServer) - Fix maxLength + existingNames interaction to respect length limit when adding collision suffix - Add comprehensive JSDoc documentation - Update tests and hub.test.ts for new lowercase normalization behavior * ✨ feat: isolate hub exec worker and filter disabled tools * 🐛 fix: inline hub worker source * 🐛 fix: sync hub tool cache and map * Update import path for buildFunctionCallToolName in BaseService * ✨ feat: refine hub mode system prompt * 🐛 fix: propagate hub tool errors * 📝 docs: clarify hub exec return * ✨ feat(hub): improve prompts and tool descriptions for better LLM success rate - Rewrite HUB_MODE_SYSTEM_PROMPT_BASE with Critical Rules section - Add Common Mistakes to Avoid section with examples - Update exec tool description with IMPORTANT return requirement - Improve search tool description clarity - Simplify generator output with return reminder in header - Add per-field @param JSDoc with required/optional markers Fixes issue where LLMs forgot to return values from exec code * ♻️ refactor(hub): return empty string when no tools available * ✨ feat(hub): add dedicated AUTO_MODE_SYSTEM_PROMPT for auto mode - Create self-contained prompt teaching XML tool_use format - Only shows search/exec tools (no generic examples) - Add complete workflow example with common mistakes - Update parameterBuilder to use getAutoModeSystemPrompt() - User prompt comes first, then auto mode instructions - Skip hub prompt when no tools available * ♻️ refactor: move hub prompts to dedicated prompts-code-mode.ts - Create src/renderer/src/config/prompts-code-mode.ts - Move HUB_MODE_SYSTEM_PROMPT_BASE and AUTO_MODE_SYSTEM_PROMPT_BASE - Move getHubModeSystemPrompt() and getAutoModeSystemPrompt() - Extract shared buildToolsSection() helper - Update parameterBuilder.ts import * ♻️ refactor: add mcpMode support to promptToolUsePlugin - Add mcpMode parameter to PromptToolUseConfig and defaultBuildSystemPrompt - Pass mcpMode through middleware config to plugin builder - Consolidate getAutoModeSystemPrompt into getHubModeSystemPrompt - Update parameterBuilder to use getHubModeSystemPrompt * ♻️ refactor: move getHubModeSystemPrompt to shared package - Create @cherrystudio/shared workspace package with exports - Move getHubModeSystemPrompt and ToolInfo to packages/shared/prompts - Add @cherrystudio/shared dependency to @cherrystudio/ai-core - Update promptToolUsePlugin to import from shared package - Update renderer prompts-code-mode.ts to re-export from shared - Add toolSetToToolInfoArray converter for type compatibility * Revert "♻️ refactor: move getHubModeSystemPrompt to shared package" This reverts commit 894b2fd487363ae516a0f0d2bbd4d857a0e612ef. * Remove duplicate Tool Use Examples header from system prompt * fix: add handleModeChange call in MCPToolsButton for manual mode activation * style: update AssistantMCPSettings to use min-height instead of overflow for better layout control * feat(i18n): add MCP server modes and truncate messages in multiple languages - Introduced new "mode" options for MCP servers: auto, disabled, and manual with corresponding descriptions and labels. - Added translations for "base64DataTruncated" and "truncated" messages across various language files. - Enhanced user experience by providing clearer feedback on data truncation. * Normalize tool names for search and exec in parser * Clarify tool usage rules in code mode prompts and examples * Clarify code execution instructions and update example usage * refactor: simplify JSDoc description handling by removing unnecessary truncation * refactor: optimize listAllActiveServerTools method to use Promise.allSettled for improved error handling and performance --------- Co-authored-by: Amp Co-authored-by: kangfenmao --- .../toolUsePlugin/promptToolUsePlugin.ts | 66 +++-- .../plugins/built-in/toolUsePlugin/type.ts | 1 + packages/shared/mcp.ts | 116 +++++++++ src/main/__tests__/mcp.test.ts | 240 ++++++++++++++++++ src/main/mcpServers/factory.ts | 4 + src/main/mcpServers/hub/README.md | 213 ++++++++++++++++ .../hub/__tests__/generator.test.ts | 119 +++++++++ src/main/mcpServers/hub/__tests__/hub.test.ts | 229 +++++++++++++++++ .../mcpServers/hub/__tests__/runtime.test.ts | 159 ++++++++++++ .../mcpServers/hub/__tests__/search.test.ts | 118 +++++++++ src/main/mcpServers/hub/generator.ts | 152 +++++++++++ src/main/mcpServers/hub/index.ts | 184 ++++++++++++++ src/main/mcpServers/hub/mcp-bridge.ts | 96 +++++++ src/main/mcpServers/hub/runtime.ts | 170 +++++++++++++ src/main/mcpServers/hub/search.ts | 109 ++++++++ src/main/mcpServers/hub/types.ts | 113 +++++++++ src/main/mcpServers/hub/worker.ts | 133 ++++++++++ src/main/services/MCPService.ts | 64 ++++- .../services/__tests__/MCPService.test.ts | 75 ++++++ src/main/services/agents/BaseService.ts | 2 +- src/main/utils/__tests__/mcp.test.ts | 225 ---------------- src/main/utils/mcp.ts | 29 --- .../middleware/AiSdkMiddlewareBuilder.ts | 3 +- .../src/aiCore/plugins/PluginBuilder.ts | 1 + .../aiCore/prepareParams/parameterBuilder.ts | 18 +- src/renderer/src/config/prompts-code-mode.ts | 174 +++++++++++++ src/renderer/src/i18n/label.ts | 3 +- 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 | 17 ++ src/renderer/src/i18n/translate/el-gr.json | 17 ++ src/renderer/src/i18n/translate/es-es.json | 17 ++ src/renderer/src/i18n/translate/fr-fr.json | 17 ++ src/renderer/src/i18n/translate/ja-jp.json | 17 ++ src/renderer/src/i18n/translate/pt-pt.json | 17 ++ src/renderer/src/i18n/translate/ro-ro.json | 17 ++ src/renderer/src/i18n/translate/ru-ru.json | 17 ++ .../tools/components/MCPToolsButton.tsx | 153 ++++++----- .../tools/components/UrlContextbutton.tsx | 4 +- .../components/WebSearchQuickPanelManager.tsx | 5 +- .../AssistantMCPSettings.tsx | 170 +++++++++---- src/renderer/src/services/ApiService.ts | 61 ++++- .../src/services/__tests__/mcpMode.test.ts | 54 ++++ src/renderer/src/store/mcp.ts | 22 ++ src/renderer/src/types/index.ts | 16 +- src/renderer/src/utils/mcp-tools.ts | 13 +- tests/main.setup.ts | 180 +++++++------ 48 files changed, 3193 insertions(+), 479 deletions(-) create mode 100644 packages/shared/mcp.ts create mode 100644 src/main/__tests__/mcp.test.ts create mode 100644 src/main/mcpServers/hub/README.md create mode 100644 src/main/mcpServers/hub/__tests__/generator.test.ts create mode 100644 src/main/mcpServers/hub/__tests__/hub.test.ts create mode 100644 src/main/mcpServers/hub/__tests__/runtime.test.ts create mode 100644 src/main/mcpServers/hub/__tests__/search.test.ts create mode 100644 src/main/mcpServers/hub/generator.ts create mode 100644 src/main/mcpServers/hub/index.ts create mode 100644 src/main/mcpServers/hub/mcp-bridge.ts create mode 100644 src/main/mcpServers/hub/runtime.ts create mode 100644 src/main/mcpServers/hub/search.ts create mode 100644 src/main/mcpServers/hub/types.ts create mode 100644 src/main/mcpServers/hub/worker.ts create mode 100644 src/main/services/__tests__/MCPService.test.ts delete mode 100644 src/main/utils/__tests__/mcp.test.ts delete mode 100644 src/main/utils/mcp.ts create mode 100644 src/renderer/src/config/prompts-code-mode.ts create mode 100644 src/renderer/src/services/__tests__/mcpMode.test.ts diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index 67dd33e9e0..3cf20f2a54 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -21,9 +21,6 @@ const TOOL_USE_TAG_CONFIG: TagConfig = { separator: '\n' } -/** - * 默认系统提示符模板 - */ export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. @@ -38,10 +35,16 @@ Tool use is formatted using XML-style tags. The tool name is enclosed in opening The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example: - python_interpreter - {"code": "5 + 3 + 1294.678"} + search + { "query": "browser,fetch" } + + exec + { "code": "const page = await CherryBrowser_fetch({ url: "https://example.com" })\nreturn page" } + + + The user will respond with the result of the tool use, which should be formatted as follows: @@ -59,13 +62,6 @@ For example, if the result of the tool use is an image file, you can use it in t Always adhere to this format for the tool use to ensure proper parsing and execution. -## Tool Use Examples -{{ TOOL_USE_EXAMPLES }} - -## Tool Use Available Tools -Above example were using notional tools that might not exist for you. You only have access to these tools: -{{ AVAILABLE_TOOLS }} - ## Tool Use Rules Here are the rules you should always follow to solve your task: 1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead. @@ -74,6 +70,8 @@ Here are the rules you should always follow to solve your task: 4. Never re-do a tool call that you previously did with the exact same parameters. 5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format. +{{ TOOLS_INFO }} + ## Response rules Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used. @@ -185,13 +183,30 @@ ${result} /** * 默认的系统提示符构建函数(提取自 Cherry Studio) */ -function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet): string { +function defaultBuildSystemPrompt(userSystemPrompt: string, tools: ToolSet, mcpMode?: string): string { const availableTools = buildAvailableTools(tools) if (availableTools === null) return userSystemPrompt - const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES) + if (mcpMode == 'auto') { + return DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', '').replace( + '{{ USER_SYSTEM_PROMPT }}', + userSystemPrompt || '' + ) + } + const toolsInfo = ` +## Tool Use Examples +{{ TOOL_USE_EXAMPLES }} + +## Tool Use Available Tools +Above example were using notional tools that might not exist for you. You only have access to these tools: +{{ AVAILABLE_TOOLS }}` + .replace('{{ TOOL_USE_EXAMPLES }}', DEFAULT_TOOL_USE_EXAMPLES) .replace('{{ AVAILABLE_TOOLS }}', availableTools) - .replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '') + + const fullPrompt = DEFAULT_SYSTEM_PROMPT.replace('{{ TOOLS_INFO }}', toolsInfo).replace( + '{{ USER_SYSTEM_PROMPT }}', + userSystemPrompt || '' + ) return fullPrompt } @@ -224,7 +239,17 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs // Find all tool use blocks while ((match = toolUsePattern.exec(contentToProcess)) !== null) { const fullMatch = match[0] - const toolName = match[2].trim() + let toolName = match[2].trim() + switch (toolName.toLowerCase()) { + case 'search': + toolName = 'mcp__CherryHub__search' + break + case 'exec': + toolName = 'mcp__CherryHub__exec' + break + default: + break + } const toolArgs = match[4].trim() // Try to parse the arguments as JSON @@ -256,7 +281,12 @@ function defaultParseToolUse(content: string, tools: ToolSet): { results: ToolUs } export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { - const { enabled = true, buildSystemPrompt = defaultBuildSystemPrompt, parseToolUse = defaultParseToolUse } = config + const { + enabled = true, + buildSystemPrompt = defaultBuildSystemPrompt, + parseToolUse = defaultParseToolUse, + mcpMode + } = config return definePlugin({ name: 'built-in:prompt-tool-use', @@ -286,7 +316,7 @@ export const createPromptToolUsePlugin = (config: PromptToolUseConfig = {}) => { // 构建系统提示符(只包含非 provider-defined 工具) const userSystemPrompt = typeof params.system === 'string' ? params.system : '' - const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools) + const systemPrompt = buildSystemPrompt(userSystemPrompt, promptTools, mcpMode) let systemMessage: string | null = systemPrompt if (config.createSystemMessage) { // 🎯 如果用户提供了自定义处理函数,使用它 diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts index 4937b25601..c92dfe4bde 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/type.ts @@ -23,6 +23,7 @@ export interface PromptToolUseConfig extends BaseToolUsePluginConfig { // 自定义工具解析函数(可选,有默认实现) parseToolUse?: (content: string, tools: ToolSet) => { results: ToolUseResult[]; content: string } createSystemMessage?: (systemPrompt: string, originalParams: any, context: AiRequestContext) => string | null + mcpMode?: string } /** diff --git a/packages/shared/mcp.ts b/packages/shared/mcp.ts new file mode 100644 index 0000000000..b8e5494f17 --- /dev/null +++ b/packages/shared/mcp.ts @@ -0,0 +1,116 @@ +/** + * Convert a string to camelCase, ensuring it's a valid JavaScript identifier. + * + * - Normalizes to lowercase first, then capitalizes word boundaries + * - Non-alphanumeric characters are treated as word separators + * - Non-ASCII characters are dropped (ASCII-only output) + * - If result starts with a digit, prefixes with underscore + * + * @example + * toCamelCase('my-server') // 'myServer' + * toCamelCase('MY_SERVER') // 'myServer' + * toCamelCase('123tool') // '_123tool' + */ +export function toCamelCase(str: string): string { + let result = str + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+(.)/g, (_, char) => char.toUpperCase()) + .replace(/[^a-zA-Z0-9]/g, '') + + if (result && !/^[a-zA-Z_]/.test(result)) { + result = '_' + result + } + + return result +} + +export type McpToolNameOptions = { + /** Prefix added before the name (e.g., 'mcp__'). Must be JS-identifier-safe. */ + prefix?: string + /** Delimiter between server and tool parts (e.g., '_' or '__'). Must be JS-identifier-safe. */ + delimiter?: string + /** Maximum length of the final name. Suffix numbers for uniqueness are included in this limit. */ + maxLength?: number + /** Mutable Set for collision detection. The final name will be added to this Set. */ + existingNames?: Set +} + +/** + * Build a valid JavaScript function name from server and tool names. + * Uses camelCase for both parts. + * + * @param serverName - The MCP server name (optional) + * @param toolName - The tool name + * @param options - Configuration options + * @returns A valid JS identifier + */ +export function buildMcpToolName( + serverName: string | undefined, + toolName: string, + options: McpToolNameOptions = {} +): string { + const { prefix = '', delimiter = '_', maxLength, existingNames } = options + + const serverPart = serverName ? toCamelCase(serverName) : '' + const toolPart = toCamelCase(toolName) + const baseName = serverPart ? `${prefix}${serverPart}${delimiter}${toolPart}` : `${prefix}${toolPart}` + + if (!existingNames) { + return maxLength ? truncateToLength(baseName, maxLength) : baseName + } + + let name = maxLength ? truncateToLength(baseName, maxLength) : baseName + let counter = 1 + + while (existingNames.has(name)) { + const suffix = String(counter) + const truncatedBase = maxLength ? truncateToLength(baseName, maxLength - suffix.length) : baseName + name = `${truncatedBase}${suffix}` + counter++ + } + + existingNames.add(name) + return name +} + +function truncateToLength(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str + } + return str.slice(0, maxLength).replace(/_+$/, '') +} + +/** + * Generate a unique function name from server name and tool name. + * Format: serverName_toolName (camelCase) + * + * @example + * generateMcpToolFunctionName('github', 'search_issues') // 'github_searchIssues' + */ +export function generateMcpToolFunctionName( + serverName: string | undefined, + toolName: string, + existingNames?: Set +): string { + return buildMcpToolName(serverName, toolName, { existingNames }) +} + +/** + * Builds a valid JavaScript function name for MCP tool calls. + * Format: mcp__{serverName}__{toolName} + * + * @param serverName - The MCP server name + * @param toolName - The tool name from the server + * @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars + * + * @example + * buildFunctionCallToolName('github', 'search_issues') // 'mcp__github__searchIssues' + */ +export function buildFunctionCallToolName(serverName: string, toolName: string): string { + return buildMcpToolName(serverName, toolName, { + prefix: 'mcp__', + delimiter: '__', + maxLength: 63 + }) +} diff --git a/src/main/__tests__/mcp.test.ts b/src/main/__tests__/mcp.test.ts new file mode 100644 index 0000000000..809db3d665 --- /dev/null +++ b/src/main/__tests__/mcp.test.ts @@ -0,0 +1,240 @@ +import { buildFunctionCallToolName, buildMcpToolName, generateMcpToolFunctionName, toCamelCase } from '@shared/mcp' +import { describe, expect, it } from 'vitest' + +describe('toCamelCase', () => { + it('should convert hyphenated strings', () => { + expect(toCamelCase('my-server')).toBe('myServer') + expect(toCamelCase('my-tool-name')).toBe('myToolName') + }) + + it('should convert underscored strings', () => { + expect(toCamelCase('my_server')).toBe('myServer') + expect(toCamelCase('search_issues')).toBe('searchIssues') + }) + + it('should handle mixed delimiters', () => { + expect(toCamelCase('my-server_name')).toBe('myServerName') + }) + + it('should handle leading numbers by prefixing underscore', () => { + expect(toCamelCase('123server')).toBe('_123server') + }) + + it('should handle special characters', () => { + expect(toCamelCase('test@server!')).toBe('testServer') + expect(toCamelCase('tool#name$')).toBe('toolName') + }) + + it('should trim whitespace', () => { + expect(toCamelCase(' server ')).toBe('server') + }) + + it('should handle empty string', () => { + expect(toCamelCase('')).toBe('') + }) + + it('should handle uppercase snake case', () => { + expect(toCamelCase('MY_SERVER')).toBe('myServer') + expect(toCamelCase('SEARCH_ISSUES')).toBe('searchIssues') + }) + + it('should handle mixed case', () => { + expect(toCamelCase('MyServer')).toBe('myserver') + expect(toCamelCase('myTOOL')).toBe('mytool') + }) +}) + +describe('buildMcpToolName', () => { + it('should build basic name with defaults', () => { + expect(buildMcpToolName('github', 'search_issues')).toBe('github_searchIssues') + }) + + it('should handle undefined server name', () => { + expect(buildMcpToolName(undefined, 'search_issues')).toBe('searchIssues') + }) + + it('should apply custom prefix and delimiter', () => { + expect(buildMcpToolName('github', 'search', { prefix: 'mcp__', delimiter: '__' })).toBe('mcp__github__search') + }) + + it('should respect maxLength', () => { + const result = buildMcpToolName('veryLongServerName', 'veryLongToolName', { maxLength: 20 }) + expect(result.length).toBeLessThanOrEqual(20) + }) + + it('should handle collision with existingNames', () => { + const existingNames = new Set(['github_search']) + const result = buildMcpToolName('github', 'search', { existingNames }) + expect(result).toBe('github_search1') + expect(existingNames.has('github_search1')).toBe(true) + }) + + it('should respect maxLength when adding collision suffix', () => { + const existingNames = new Set(['a'.repeat(20)]) + const result = buildMcpToolName('a'.repeat(20), '', { maxLength: 20, existingNames }) + expect(result.length).toBeLessThanOrEqual(20) + expect(existingNames.has(result)).toBe(true) + }) + + it('should handle multiple collisions with maxLength', () => { + const existingNames = new Set(['abcd', 'abc1', 'abc2']) + const result = buildMcpToolName('abcd', '', { maxLength: 4, existingNames }) + expect(result).toBe('abc3') + expect(result.length).toBeLessThanOrEqual(4) + }) +}) + +describe('generateMcpToolFunctionName', () => { + it('should return format serverName_toolName in camelCase', () => { + expect(generateMcpToolFunctionName('github', 'search_issues')).toBe('github_searchIssues') + }) + + it('should handle hyphenated names', () => { + expect(generateMcpToolFunctionName('my-server', 'my-tool')).toBe('myServer_myTool') + }) + + it('should handle undefined server name', () => { + expect(generateMcpToolFunctionName(undefined, 'search_issues')).toBe('searchIssues') + }) + + it('should handle collision detection', () => { + const existingNames = new Set() + const first = generateMcpToolFunctionName('github', 'search', existingNames) + const second = generateMcpToolFunctionName('github', 'search', existingNames) + expect(first).toBe('github_search') + expect(second).toBe('github_search1') + }) +}) + +describe('buildFunctionCallToolName', () => { + describe('basic format', () => { + it('should return format mcp__{server}__{tool} in camelCase', () => { + const result = buildFunctionCallToolName('github', 'search_issues') + expect(result).toBe('mcp__github__searchIssues') + }) + + it('should handle simple server and tool names', () => { + expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__getPage') + expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query') + }) + }) + + describe('valid JavaScript identifier', () => { + it('should always start with mcp__ prefix (valid JS identifier start)', () => { + const result = buildFunctionCallToolName('123server', '456tool') + expect(result).toMatch(/^mcp__/) + }) + + it('should handle hyphenated names with camelCase', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool') + expect(result).toBe('mcp__myServer__myTool') + }) + + it('should be a valid JavaScript identifier', () => { + const testCases = [ + ['github', 'create_issue'], + ['my-server', 'fetch-data'], + ['test@server', 'tool#name'], + ['server.name', 'tool.action'] + ] + + for (const [server, tool] of testCases) { + const result = buildFunctionCallToolName(server, tool) + expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/) + } + }) + }) + + describe('character sanitization', () => { + it('should convert special characters to camelCase boundaries', () => { + expect(buildFunctionCallToolName('my-server', 'my-tool-name')).toBe('mcp__myServer__myToolName') + expect(buildFunctionCallToolName('test@server!', 'tool#name$')).toBe('mcp__testServer__toolName') + expect(buildFunctionCallToolName('server.name', 'tool.action')).toBe('mcp__serverName__toolAction') + }) + + it('should handle spaces', () => { + const result = buildFunctionCallToolName('my server', 'my tool') + expect(result).toBe('mcp__myServer__myTool') + }) + }) + + describe('length constraints', () => { + it('should not exceed 63 characters', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName) + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should not end with underscores after truncation', () => { + const longServerName = 'a'.repeat(30) + const longToolName = 'b'.repeat(30) + const result = buildFunctionCallToolName(longServerName, longToolName) + expect(result).not.toMatch(/_+$/) + expect(result.length).toBeLessThanOrEqual(63) + }) + }) + + describe('edge cases', () => { + it('should handle empty server name', () => { + const result = buildFunctionCallToolName('', 'tool') + expect(result).toBe('mcp__tool') + }) + + it('should handle empty tool name', () => { + const result = buildFunctionCallToolName('server', '') + expect(result).toBe('mcp__server__') + }) + + it('should trim whitespace from names', () => { + const result = buildFunctionCallToolName(' server ', ' tool ') + expect(result).toBe('mcp__server__tool') + }) + + it('should handle mixed case by normalizing to lowercase first', () => { + const result = buildFunctionCallToolName('MyServer', 'MyTool') + expect(result).toBe('mcp__myserver__mytool') + }) + + it('should handle uppercase snake case', () => { + const result = buildFunctionCallToolName('MY_SERVER', 'SEARCH_ISSUES') + expect(result).toBe('mcp__myServer__searchIssues') + }) + }) + + describe('deterministic output', () => { + it('should produce consistent results for same input', () => { + const result1 = buildFunctionCallToolName('github', 'search_repos') + const result2 = buildFunctionCallToolName('github', 'search_repos') + expect(result1).toBe(result2) + }) + + it('should produce different results for different inputs', () => { + const result1 = buildFunctionCallToolName('server1', 'tool') + const result2 = buildFunctionCallToolName('server2', 'tool') + expect(result1).not.toBe(result2) + }) + }) + + describe('real-world scenarios', () => { + it('should handle GitHub MCP server', () => { + expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__createIssue') + expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__searchRepositories') + }) + + it('should handle filesystem MCP server', () => { + expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__readFile') + expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__writeFile') + }) + + it('should handle hyphenated server names (common in npm packages)', () => { + expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherryFetch__getPage') + expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcpServerGithub__search') + }) + + it('should handle scoped npm package style names', () => { + const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat') + expect(result).toBe('mcp__AnthropicMcpServer__chat') + }) + }) +}) diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 909901c1c8..1e9e0bab20 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -9,6 +9,7 @@ import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' import FileSystemServer from './filesystem' +import HubServer from './hub' import MemoryServer from './memory' import PythonServer from './python' import ThinkingServer from './sequentialthinking' @@ -52,6 +53,9 @@ export function createInMemoryMCPServer( case BuiltinMCPServerNames.browser: { return new BrowserServer().server } + case BuiltinMCPServerNames.hub: { + return new HubServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/main/mcpServers/hub/README.md b/src/main/mcpServers/hub/README.md new file mode 100644 index 0000000000..e15141b9c5 --- /dev/null +++ b/src/main/mcpServers/hub/README.md @@ -0,0 +1,213 @@ +# Hub MCP Server + +A built-in MCP server that aggregates all active MCP servers in Cherry Studio and exposes them through `search` and `exec` tools. + +## Overview + +The Hub server enables LLMs to discover and call tools from all active MCP servers without needing to know the specific server names or tool signatures upfront. + +## Auto Mode Integration + +The Hub server is the core component of Cherry Studio's **Auto MCP Mode**. When an assistant is set to Auto mode: + +1. **Automatic Injection**: The Hub server is automatically injected as the only MCP server for the assistant +2. **System Prompt**: A specialized system prompt (`HUB_MODE_SYSTEM_PROMPT`) is appended to guide the LLM on how to use the `search` and `exec` tools +3. **Dynamic Discovery**: The LLM can discover and use any tools from all active MCP servers without manual configuration + +### MCP Modes + +Cherry Studio supports three MCP modes per assistant: + +| Mode | Description | Tools Available | +|------|-------------|-----------------| +| **Disabled** | No MCP tools | None | +| **Auto** | Hub server only | `search`, `exec` | +| **Manual** | User selects servers | Selected server tools | + +### How Auto Mode Works + +``` +User Message + │ + ▼ +┌─────────────────────────────────────────┐ +│ Assistant (mcpMode: 'auto') │ +│ │ +│ System Prompt + HUB_MODE_SYSTEM_PROMPT │ +│ Tools: [hub.search, hub.exec] │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ LLM decides to use MCP tools │ +│ │ +│ 1. search({ query: "github,repo" }) │ +│ 2. exec({ code: "await searchRepos()" })│ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Hub Server │ +│ │ +│ Aggregates all active MCP servers │ +│ Routes tool calls to appropriate server │ +└─────────────────────────────────────────┘ +``` + +### Relevant Code + +- **Type Definition**: `src/renderer/src/types/index.ts` - `McpMode` type and `getEffectiveMcpMode()` +- **Hub Server Constant**: `src/renderer/src/store/mcp.ts` - `hubMCPServer` +- **Server Selection**: `src/renderer/src/services/ApiService.ts` - `getMcpServersForAssistant()` +- **System Prompt**: `src/renderer/src/config/prompts.ts` - `HUB_MODE_SYSTEM_PROMPT` +- **Prompt Injection**: `src/renderer/src/aiCore/prepareParams/parameterBuilder.ts` + +## Tools + +### `search` + +Search for available MCP tools by keywords. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `query` | string | Yes | Search keywords, comma-separated for OR matching | +| `limit` | number | No | Maximum results to return (default: 10, max: 50) | + +**Example:** +```json +{ + "query": "browser,chrome", + "limit": 5 +} +``` + +**Returns:** JavaScript function declarations with JSDoc comments that can be used in the `exec` tool. + +```javascript +// Found 2 tool(s): + +/** + * Launch a browser instance + * + * @param {{ browser?: "chromium" | "firefox" | "webkit", headless?: boolean }} params + * @returns {Promise} + */ +async function launchBrowser(params) { + return await __callTool("browser__launch_browser", params); +} +``` + +### `exec` + +Execute JavaScript code that calls MCP tools. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `code` | string | Yes | JavaScript code to execute | + +**Built-in Helpers:** +- `parallel(...promises)` - Run multiple tool calls concurrently (Promise.all) +- `settle(...promises)` - Run multiple tool calls and get all results (Promise.allSettled) +- `console.log/warn/error/info/debug` - Captured in output logs + +**Example:** +```javascript +// Call a single tool +const result = await searchRepos({ query: "react" }); +return result; + +// Call multiple tools in parallel +const [users, repos] = await parallel( + getUsers({ limit: 10 }), + searchRepos({ query: "typescript" }) +); +return { users, repos }; +``` + +**Returns:** +```json +{ + "result": { "users": [...], "repos": [...] }, + "logs": ["[log] Processing..."], + "error": null +} +``` + +## Usage Flow + +1. **Search** for tools using keywords: + ``` + search({ query: "github,repository" }) + ``` + +2. **Review** the returned function signatures and JSDoc + +3. **Execute** code using the discovered tools: + ``` + exec({ code: 'return await searchRepos({ query: "react" })' }) + ``` + +## Configuration + +The Hub server is a built-in server identified as `@cherry/hub`. + +### Using Auto Mode (Recommended) + +The easiest way to use the Hub server is through Auto mode: + +1. Click the **MCP Tools** button (hammer icon) in the input bar +2. Select **Auto** mode +3. The Hub server is automatically enabled for the assistant + +### Manual Configuration + +Alternatively, you can enable the Hub server manually: + +1. Go to **Settings** → **MCP Servers** +2. Find **Hub** in the built-in servers list +3. Toggle it on +4. In the assistant's MCP settings, select the Hub server + +## Caching + +- Tool definitions are cached for **10 minutes** +- Cache is automatically refreshed when expired +- Cache is invalidated when MCP servers connect/disconnect + +## Limitations + +- Code execution has a **60-second timeout** +- Console logs are limited to **1000 entries** +- Search results are limited to **50 tools** maximum +- The Hub server excludes itself from the aggregated server list + +## Architecture + +``` +LLM + │ + ▼ +HubServer + ├── search → ToolRegistry → SearchIndex + └── exec → Runtime → callMcpTool() + │ + ▼ + MCPService.callTool() + │ + ▼ + External MCP Servers +``` + +## Files + +| File | Description | +|------|-------------| +| `index.ts` | Main HubServer class | +| `types.ts` | TypeScript type definitions | +| `generator.ts` | Converts MCP tools to JS functions with JSDoc | +| `tool-registry.ts` | In-memory tool cache with TTL | +| `search.ts` | Keyword-based tool search | +| `runtime.ts` | JavaScript code execution engine | +| `mcp-bridge.ts` | Bridge to Cherry Studio's MCPService | diff --git a/src/main/mcpServers/hub/__tests__/generator.test.ts b/src/main/mcpServers/hub/__tests__/generator.test.ts new file mode 100644 index 0000000000..772a5e3c58 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/generator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { generateToolFunction, generateToolsCode } from '../generator' +import type { GeneratedTool } from '../types' + +describe('generator', () => { + describe('generateToolFunction', () => { + it('generates a simple tool function', () => { + const tool = { + id: 'test-id', + name: 'search_repos', + description: 'Search for GitHub repositories', + serverId: 'github', + serverName: 'github-server', + inputSchema: { + type: 'object' as const, + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results' } + }, + required: ['query'] + }, + type: 'mcp' as const + } + + const existingNames = new Set() + const callTool = async () => ({ success: true }) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.functionName).toBe('githubServer_searchRepos') + expect(result.jsCode).toContain('async function githubServer_searchRepos') + expect(result.jsCode).toContain('Search for GitHub repositories') + expect(result.jsCode).toContain('__callTool') + }) + + it('handles unique function names', () => { + const tool = { + id: 'test-id', + name: 'search', + serverId: 'server1', + serverName: 'server1', + inputSchema: { type: 'object' as const, properties: {} }, + type: 'mcp' as const + } + + const existingNames = new Set(['server1_search']) + const callTool = async () => ({}) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.functionName).toBe('server1_search1') + }) + + it('handles enum types in schema', () => { + const tool = { + id: 'test-id', + name: 'launch_browser', + serverId: 'browser', + serverName: 'browser', + inputSchema: { + type: 'object' as const, + properties: { + browser: { + type: 'string', + enum: ['chromium', 'firefox', 'webkit'] + } + } + }, + type: 'mcp' as const + } + + const existingNames = new Set() + const callTool = async () => ({}) + + const result = generateToolFunction(tool, existingNames, callTool) + + expect(result.jsCode).toContain('"chromium" | "firefox" | "webkit"') + }) + }) + + describe('generateToolsCode', () => { + it('generates code for multiple tools', () => { + const tools: GeneratedTool[] = [ + { + serverId: 's1', + serverName: 'server1', + toolName: 'tool1', + functionName: 'server1_tool1', + jsCode: 'async function server1_tool1() {}', + fn: async () => ({}), + signature: '{}', + returns: 'unknown' + }, + { + serverId: 's2', + serverName: 'server2', + toolName: 'tool2', + functionName: 'server2_tool2', + jsCode: 'async function server2_tool2() {}', + fn: async () => ({}), + signature: '{}', + returns: 'unknown' + } + ] + + const result = generateToolsCode(tools) + + expect(result).toContain('2 tool(s)') + expect(result).toContain('async function server1_tool1') + expect(result).toContain('async function server2_tool2') + }) + + it('returns message for empty tools', () => { + const result = generateToolsCode([]) + expect(result).toBe('// No tools available') + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/hub.test.ts b/src/main/mcpServers/hub/__tests__/hub.test.ts new file mode 100644 index 0000000000..51ec727ee9 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/hub.test.ts @@ -0,0 +1,229 @@ +import type { MCPTool } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { HubServer } from '../index' + +const mockTools: MCPTool[] = [ + { + id: 'github__search_repos', + name: 'search_repos', + description: 'Search for GitHub repositories', + serverId: 'github', + serverName: 'GitHub', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results' } + }, + required: ['query'] + }, + type: 'mcp' + }, + { + id: 'github__get_user', + name: 'get_user', + description: 'Get GitHub user profile', + serverId: 'github', + serverName: 'GitHub', + inputSchema: { + type: 'object', + properties: { + username: { type: 'string', description: 'GitHub username' } + }, + required: ['username'] + }, + type: 'mcp' + }, + { + id: 'database__query', + name: 'query', + description: 'Execute a database query', + serverId: 'database', + serverName: 'Database', + inputSchema: { + type: 'object', + properties: { + sql: { type: 'string', description: 'SQL query to execute' } + }, + required: ['sql'] + }, + type: 'mcp' + } +] + +vi.mock('@main/services/MCPService', () => ({ + default: { + listAllActiveServerTools: vi.fn(async () => mockTools), + callToolById: vi.fn(async (toolId: string, args: unknown) => { + if (toolId === 'github__search_repos') { + return { + content: [{ type: 'text', text: JSON.stringify({ repos: ['repo1', 'repo2'], query: args }) }] + } + } + if (toolId === 'github__get_user') { + return { + content: [{ type: 'text', text: JSON.stringify({ username: (args as any).username, id: 123 }) }] + } + } + if (toolId === 'database__query') { + return { + content: [{ type: 'text', text: JSON.stringify({ rows: [{ id: 1 }, { id: 2 }] }) }] + } + } + return { content: [{ type: 'text', text: '{}' }] } + }), + abortTool: vi.fn(async () => true) + } +})) + +import mcpService from '@main/services/MCPService' + +describe('HubServer Integration', () => { + let hubServer: HubServer + + beforeEach(() => { + vi.clearAllMocks() + hubServer = new HubServer() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('full search → exec flow', () => { + it('searches for tools and executes them', async () => { + const searchResult = await (hubServer as any).handleSearch({ query: 'github,repos' }) + + expect(searchResult.content).toBeDefined() + const searchText = JSON.parse(searchResult.content[0].text) + expect(searchText.total).toBeGreaterThan(0) + expect(searchText.tools).toContain('github_searchRepos') + + const execResult = await (hubServer as any).handleExec({ + code: 'return await github_searchRepos({ query: "test" })' + }) + + expect(execResult.content).toBeDefined() + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.result).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'test' } }) + }) + + it('handles multiple tool calls in parallel', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + + const execResult = await (hubServer as any).handleExec({ + code: ` + const results = await parallel( + github_searchRepos({ query: "react" }), + github_getUser({ username: "octocat" }) + ); + return results + ` + }) + + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.result).toHaveLength(2) + expect(execOutput.result[0]).toEqual({ repos: ['repo1', 'repo2'], query: { query: 'react' } }) + expect(execOutput.result[1]).toEqual({ username: 'octocat', id: 123 }) + }) + + it('searches across multiple servers', async () => { + const searchResult = await (hubServer as any).handleSearch({ query: 'query' }) + + const searchText = JSON.parse(searchResult.content[0].text) + expect(searchText.tools).toContain('database_query') + }) + }) + + describe('tools caching', () => { + it('uses cached tools within TTL', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + await (hubServer as any).handleSearch({ query: 'github' }) + const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + expect(secondCallCount).toBe(firstCallCount) // Should use cache + }) + + it('refreshes tools after cache invalidation', async () => { + await (hubServer as any).handleSearch({ query: 'github' }) + const firstCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + hubServer.invalidateCache() + + await (hubServer as any).handleSearch({ query: 'github' }) + const secondCallCount = vi.mocked(mcpService.listAllActiveServerTools).mock.calls.length + + expect(secondCallCount).toBe(firstCallCount + 1) + }) + }) + + describe('error handling', () => { + it('throws error for invalid search query', async () => { + await expect((hubServer as any).handleSearch({})).rejects.toThrow('query parameter is required') + }) + + it('throws error for invalid exec code', async () => { + await expect((hubServer as any).handleExec({})).rejects.toThrow('code parameter is required') + }) + + it('handles runtime errors in exec', async () => { + const execResult = await (hubServer as any).handleExec({ + code: 'throw new Error("test error")' + }) + + const execOutput = JSON.parse(execResult.content[0].text) + expect(execOutput.error).toBe('test error') + expect(execOutput.isError).toBe(true) + }) + }) + + describe('exec timeouts', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('aborts in-flight tool calls and returns logs on timeout', async () => { + vi.useFakeTimers() + + let toolCallStarted: (() => void) | null = null + const toolCallStartedPromise = new Promise((resolve) => { + toolCallStarted = resolve + }) + + vi.mocked(mcpService.callToolById).mockImplementationOnce(async () => { + toolCallStarted?.() + return await new Promise(() => {}) + }) + + const execPromise = (hubServer as any).handleExec({ + code: ` + console.log("starting"); + return await github_searchRepos({ query: "hang" }); + ` + }) + + await toolCallStartedPromise + await vi.advanceTimersByTimeAsync(60000) + await vi.runAllTimersAsync() + + const execResult = await execPromise + const execOutput = JSON.parse(execResult.content[0].text) + + expect(execOutput.error).toBe('Execution timed out after 60000ms') + expect(execOutput.result).toBeUndefined() + expect(execOutput.isError).toBe(true) + expect(execOutput.logs).toContain('[log] starting') + expect(vi.mocked(mcpService.abortTool)).toHaveBeenCalled() + }) + }) + + describe('server instance', () => { + it('creates a valid MCP server instance', () => { + expect(hubServer.server).toBeDefined() + expect(hubServer.server.setRequestHandler).toBeDefined() + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/runtime.test.ts b/src/main/mcpServers/hub/__tests__/runtime.test.ts new file mode 100644 index 0000000000..5268aa6dec --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/runtime.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' + +import { Runtime } from '../runtime' +import type { GeneratedTool } from '../types' + +vi.mock('../mcp-bridge', () => ({ + callMcpTool: vi.fn(async (toolId: string, params: unknown) => { + if (toolId === 'server__failing_tool') { + throw new Error('Tool failed') + } + return { toolId, params, success: true } + }) +})) + +const createMockTool = (partial: Partial): GeneratedTool => ({ + serverId: 'server1', + serverName: 'server1', + toolName: 'tool', + functionName: 'server1_mockTool', + jsCode: 'async function server1_mockTool() {}', + fn: async (params) => ({ result: params }), + signature: '{}', + returns: 'unknown', + ...partial +}) + +describe('Runtime', () => { + describe('execute', () => { + it('executes simple code and returns result', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('return 1 + 1', tools) + + expect(result.result).toBe(2) + expect(result.error).toBeUndefined() + }) + + it('executes async code', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('return await Promise.resolve(42)', tools) + + expect(result.result).toBe(42) + }) + + it('calls tool functions', async () => { + const runtime = new Runtime() + const tools = [ + createMockTool({ + functionName: 'searchRepos', + fn: async (params) => ({ repos: ['repo1', 'repo2'], query: params }) + }) + ] + + const result = await runtime.execute('return await searchRepos({ query: "test" })', tools) + + expect(result.result).toEqual({ toolId: 'searchRepos', params: { query: 'test' }, success: true }) + }) + + it('captures console logs', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + console.log("hello"); + console.warn("warning"); + return "done" + `, + tools + ) + + expect(result.result).toBe('done') + expect(result.logs).toContain('[log] hello') + expect(result.logs).toContain('[warn] warning') + }) + + it('handles errors gracefully', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute('throw new Error("test error")', tools) + + expect(result.result).toBeUndefined() + expect(result.error).toBe('test error') + expect(result.isError).toBe(true) + }) + + it('supports parallel helper', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const results = await parallel( + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ); + return results + `, + tools + ) + + expect(result.result).toEqual([1, 2, 3]) + }) + + it('supports settle helper', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const results = await settle( + Promise.resolve(1), + Promise.reject(new Error("fail")) + ); + return results.map(r => r.status) + `, + tools + ) + + expect(result.result).toEqual(['fulfilled', 'rejected']) + }) + + it('returns last expression when no explicit return', async () => { + const runtime = new Runtime() + const tools: GeneratedTool[] = [] + + const result = await runtime.execute( + ` + const x = 10; + const y = 20; + return x + y + `, + tools + ) + + expect(result.result).toBe(30) + }) + + it('stops execution when a tool throws', async () => { + const runtime = new Runtime() + const tools = [ + createMockTool({ + functionName: 'server__failing_tool' + }) + ] + + const result = await runtime.execute('return await server__failing_tool({})', tools) + + expect(result.result).toBeUndefined() + expect(result.error).toBe('Tool failed') + expect(result.isError).toBe(true) + }) + }) +}) diff --git a/src/main/mcpServers/hub/__tests__/search.test.ts b/src/main/mcpServers/hub/__tests__/search.test.ts new file mode 100644 index 0000000000..4e483003f2 --- /dev/null +++ b/src/main/mcpServers/hub/__tests__/search.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' + +import { searchTools } from '../search' +import type { GeneratedTool } from '../types' + +const createMockTool = (partial: Partial): GeneratedTool => { + const functionName = partial.functionName || 'server1_tool' + return { + serverId: 'server1', + serverName: 'server1', + toolName: partial.toolName || 'tool', + functionName, + jsCode: `async function ${functionName}() {}`, + fn: async () => ({}), + signature: '{}', + returns: 'unknown', + ...partial + } +} + +describe('search', () => { + describe('searchTools', () => { + it('returns all tools when query is empty', () => { + const tools = [ + createMockTool({ toolName: 'tool1', functionName: 'tool1' }), + createMockTool({ toolName: 'tool2', functionName: 'tool2' }) + ] + + const result = searchTools(tools, { query: '' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('tool1') + expect(result.tools).toContain('tool2') + }) + + it('filters tools by single keyword', () => { + const tools = [ + createMockTool({ toolName: 'search_repos', functionName: 'searchRepos' }), + createMockTool({ toolName: 'get_user', functionName: 'getUser' }), + createMockTool({ toolName: 'search_users', functionName: 'searchUsers' }) + ] + + const result = searchTools(tools, { query: 'search' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('searchRepos') + expect(result.tools).toContain('searchUsers') + expect(result.tools).not.toContain('getUser') + }) + + it('supports OR matching with comma-separated keywords', () => { + const tools = [ + createMockTool({ toolName: 'browser_open', functionName: 'browserOpen' }), + createMockTool({ toolName: 'chrome_launch', functionName: 'chromeLaunch' }), + createMockTool({ toolName: 'file_read', functionName: 'fileRead' }) + ] + + const result = searchTools(tools, { query: 'browser,chrome' }) + + expect(result.total).toBe(2) + expect(result.tools).toContain('browserOpen') + expect(result.tools).toContain('chromeLaunch') + expect(result.tools).not.toContain('fileRead') + }) + + it('matches against description', () => { + const tools = [ + createMockTool({ + toolName: 'launch', + functionName: 'launch', + description: 'Launch a browser instance' + }), + createMockTool({ + toolName: 'close', + functionName: 'close', + description: 'Close a window' + }) + ] + + const result = searchTools(tools, { query: 'browser' }) + + expect(result.total).toBe(1) + expect(result.tools).toContain('launch') + }) + + it('respects limit parameter', () => { + const tools = Array.from({ length: 20 }, (_, i) => + createMockTool({ toolName: `tool${i}`, functionName: `server1_tool${i}` }) + ) + + const result = searchTools(tools, { query: 'tool', limit: 5 }) + + expect(result.total).toBe(20) + const matches = (result.tools.match(/async function server1_tool\d+/g) || []).length + expect(matches).toBe(5) + }) + + it('is case insensitive', () => { + const tools = [createMockTool({ toolName: 'SearchRepos', functionName: 'searchRepos' })] + + const result = searchTools(tools, { query: 'SEARCH' }) + + expect(result.total).toBe(1) + }) + + it('ranks exact matches higher', () => { + const tools = [ + createMockTool({ toolName: 'searching', functionName: 'searching' }), + createMockTool({ toolName: 'search', functionName: 'search' }), + createMockTool({ toolName: 'search_more', functionName: 'searchMore' }) + ] + + const result = searchTools(tools, { query: 'search', limit: 1 }) + + expect(result.tools).toContain('function search(') + }) + }) +}) diff --git a/src/main/mcpServers/hub/generator.ts b/src/main/mcpServers/hub/generator.ts new file mode 100644 index 0000000000..523e6c864f --- /dev/null +++ b/src/main/mcpServers/hub/generator.ts @@ -0,0 +1,152 @@ +import { generateMcpToolFunctionName } from '@shared/mcp' +import type { MCPTool } from '@types' + +import type { GeneratedTool } from './types' + +type PropertySchema = Record +type InputSchema = { + type?: string + properties?: Record + required?: string[] +} + +function schemaTypeToTS(prop: Record): string { + const type = prop.type as string | string[] | undefined + const enumValues = prop.enum as unknown[] | undefined + + if (enumValues && Array.isArray(enumValues)) { + return enumValues.map((v) => (typeof v === 'string' ? `"${v}"` : String(v))).join(' | ') + } + + if (Array.isArray(type)) { + return type.map((t) => primitiveTypeToTS(t)).join(' | ') + } + + if (type === 'array') { + const items = prop.items as Record | undefined + if (items) { + return `${schemaTypeToTS(items)}[]` + } + return 'unknown[]' + } + + if (type === 'object') { + return 'object' + } + + return primitiveTypeToTS(type) +} + +function primitiveTypeToTS(type: string | undefined): string { + switch (type) { + case 'string': + return 'string' + case 'number': + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'null': + return 'null' + default: + return 'unknown' + } +} + +function jsonSchemaToSignature(schema: Record | undefined): string { + if (!schema || typeof schema !== 'object') { + return '{}' + } + + const properties = schema.properties as Record> | undefined + if (!properties) { + return '{}' + } + + const required = (schema.required as string[]) || [] + const parts: string[] = [] + + for (const [key, prop] of Object.entries(properties)) { + const isRequired = required.includes(key) + const typeStr = schemaTypeToTS(prop) + parts.push(`${key}${isRequired ? '' : '?'}: ${typeStr}`) + } + + return `{ ${parts.join(', ')} }` +} + +function generateJSDoc(tool: MCPTool, inputSchema: InputSchema | undefined, returns: string): string { + const lines: string[] = ['/**'] + + if (tool.description) { + const desc = tool.description.split('\n')[0] + lines.push(` * ${desc}`) + } + + const properties = inputSchema?.properties || {} + const required = inputSchema?.required || [] + + if (Object.keys(properties).length > 0) { + lines.push(` * @param {Object} params`) + for (const [name, prop] of Object.entries(properties)) { + const isReq = required.includes(name) + const type = schemaTypeToTS(prop) + const paramName = isReq ? `params.${name}` : `[params.${name}]` + const desc = (prop.description as string)?.split('\n')[0] || '' + lines.push(` * @param {${type}} ${paramName} ${desc}`) + } + } + + lines.push(` * @returns {Promise<${returns}>}`) + lines.push(` */`) + + return lines.join('\n') +} + +export function generateToolFunction( + tool: MCPTool, + existingNames: Set, + callToolFn: (functionName: string, params: unknown) => Promise +): GeneratedTool { + const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames) + + const inputSchema = tool.inputSchema as InputSchema | undefined + const outputSchema = tool.outputSchema as Record | undefined + + const signature = jsonSchemaToSignature(inputSchema) + const returns = outputSchema ? jsonSchemaToSignature(outputSchema) : 'unknown' + + const jsDoc = generateJSDoc(tool, inputSchema, returns) + + const jsCode = `${jsDoc} +async function ${functionName}(params) { + return await __callTool("${functionName}", params); +}` + + const fn = async (params: unknown): Promise => { + return await callToolFn(functionName, params) + } + + return { + serverId: tool.serverId, + serverName: tool.serverName, + toolName: tool.name, + functionName, + jsCode, + fn, + signature, + returns, + description: tool.description + } +} + +export function generateToolsCode(tools: GeneratedTool[]): string { + if (tools.length === 0) { + return '// No tools available' + } + + const header = `// ${tools.length} tool(s). ALWAYS use: const r = await ToolName({...}); return r;` + const code = tools.map((t) => t.jsCode).join('\n\n') + + return header + '\n\n' + code +} diff --git a/src/main/mcpServers/hub/index.ts b/src/main/mcpServers/hub/index.ts new file mode 100644 index 0000000000..2c55075a0d --- /dev/null +++ b/src/main/mcpServers/hub/index.ts @@ -0,0 +1,184 @@ +import { loggerService } from '@logger' +import { CacheService } from '@main/services/CacheService' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js' + +import { generateToolFunction } from './generator' +import { callMcpTool, clearToolMap, listAllTools, syncToolMapFromGeneratedTools } from './mcp-bridge' +import { Runtime } from './runtime' +import { searchTools } from './search' +import type { ExecInput, GeneratedTool, SearchQuery } from './types' + +const logger = loggerService.withContext('MCPServer:Hub') +const TOOLS_CACHE_KEY = 'hub:tools' +const TOOLS_CACHE_TTL = 60 * 1000 // 1 minute + +/** + * Hub MCP Server - A meta-server that aggregates all active MCP servers. + * + * This server is NOT included in builtinMCPServers because: + * 1. It aggregates tools from all other MCP servers, not a standalone tool provider + * 2. It's designed for LLM "code mode" - enabling AI to discover and call tools programmatically + * 3. It should be auto-enabled when code mode features are used, not manually installed by users + * + * The server exposes two tools: + * - `search`: Find available tools by keywords, returns JS function signatures + * - `exec`: Execute JavaScript code that calls discovered tools + */ +export class HubServer { + public server: Server + private runtime: Runtime + + constructor() { + this.runtime = new Runtime() + + this.server = new Server( + { + name: 'hub-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + this.setupRequestHandlers() + } + + private setupRequestHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + { + name: 'search', + description: + 'Search for available MCP tools by keywords. Use this FIRST to discover tools. Returns JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types for use in `exec`.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Comma-separated search keywords. A tool matches if ANY keyword appears in its name, description, or server name. Example: "chrome,browser,tab" matches tools related to Chrome OR browser OR tabs.' + }, + limit: { + type: 'number', + description: 'Maximum number of tools to return (default: 10, max: 50)' + } + }, + required: ['query'] + } + }, + { + name: 'exec', + description: + 'Execute JavaScript that calls MCP tools discovered via `search`. IMPORTANT: You MUST explicitly `return` the final value, or the result will be `undefined`.', + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: + 'JavaScript code to execute. The code runs inside an async context, so use `await` directly. Do NOT wrap your code in `(async () => { ... })()` - this causes double-wrapping and returns undefined. All discovered tools are async functions (call as `await ToolName(params)`). Helpers: `parallel(...promises)`, `settle(...promises)`, `console.*`. You MUST `return` the final value. Examples: `const r = await Tool({ id: "1" }); return r` or `return await Tool({ x: 1 })`' + } + }, + required: ['code'] + } + } + ] + } + }) + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + if (!args) { + throw new McpError(ErrorCode.InvalidParams, 'No arguments provided') + } + + try { + switch (name) { + case 'search': + return await this.handleSearch(args as unknown as SearchQuery) + case 'exec': + return await this.handleExec(args as unknown as ExecInput) + default: + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`) + } + } catch (error) { + if (error instanceof McpError) { + throw error + } + logger.error(`Error executing tool ${name}:`, error as Error) + throw new McpError( + ErrorCode.InternalError, + `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}` + ) + } + }) + } + + private async fetchTools(): Promise { + const cached = CacheService.get(TOOLS_CACHE_KEY) + if (cached) { + logger.debug('Returning cached tools') + syncToolMapFromGeneratedTools(cached) + return cached + } + + logger.debug('Fetching fresh tools') + const allTools = await listAllTools() + const existingNames = new Set() + const tools = allTools.map((tool) => generateToolFunction(tool, existingNames, callMcpTool)) + CacheService.set(TOOLS_CACHE_KEY, tools, TOOLS_CACHE_TTL) + syncToolMapFromGeneratedTools(tools) + return tools + } + + invalidateCache(): void { + CacheService.remove(TOOLS_CACHE_KEY) + clearToolMap() + logger.debug('Tools cache invalidated') + } + + private async handleSearch(query: SearchQuery) { + if (!query.query || typeof query.query !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'query parameter is required and must be a string') + } + + const tools = await this.fetchTools() + const result = searchTools(tools, query) + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + } + } + + private async handleExec(input: ExecInput) { + if (!input.code || typeof input.code !== 'string') { + throw new McpError(ErrorCode.InvalidParams, 'code parameter is required and must be a string') + } + + const tools = await this.fetchTools() + const result = await this.runtime.execute(input.code, tools) + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ], + isError: result.isError + } + } +} + +export default HubServer diff --git a/src/main/mcpServers/hub/mcp-bridge.ts b/src/main/mcpServers/hub/mcp-bridge.ts new file mode 100644 index 0000000000..9e45eeba0d --- /dev/null +++ b/src/main/mcpServers/hub/mcp-bridge.ts @@ -0,0 +1,96 @@ +/** + * Bridge module for Hub server to access MCPService. + * Re-exports the methods needed by tool-registry and runtime. + */ +import mcpService from '@main/services/MCPService' +import { generateMcpToolFunctionName } from '@shared/mcp' +import type { MCPCallToolResponse, MCPTool, MCPToolResultContent } from '@types' + +import type { GeneratedTool } from './types' + +export const listAllTools = () => mcpService.listAllActiveServerTools() + +const toolFunctionNameToIdMap = new Map() + +export async function refreshToolMap(): Promise { + const tools = await listAllTools() + syncToolMapFromTools(tools) +} + +export function syncToolMapFromTools(tools: MCPTool[]): void { + toolFunctionNameToIdMap.clear() + const existingNames = new Set() + for (const tool of tools) { + const functionName = generateMcpToolFunctionName(tool.serverName, tool.name, existingNames) + toolFunctionNameToIdMap.set(functionName, { serverId: tool.serverId, toolName: tool.name }) + } +} + +export function syncToolMapFromGeneratedTools(tools: GeneratedTool[]): void { + toolFunctionNameToIdMap.clear() + for (const tool of tools) { + toolFunctionNameToIdMap.set(tool.functionName, { serverId: tool.serverId, toolName: tool.toolName }) + } +} + +export function clearToolMap(): void { + toolFunctionNameToIdMap.clear() +} + +export const callMcpTool = async (functionName: string, params: unknown, callId?: string): Promise => { + const toolInfo = toolFunctionNameToIdMap.get(functionName) + if (!toolInfo) { + await refreshToolMap() + const retryToolInfo = toolFunctionNameToIdMap.get(functionName) + if (!retryToolInfo) { + throw new Error(`Tool not found: ${functionName}`) + } + const toolId = `${retryToolInfo.serverId}__${retryToolInfo.toolName}` + const result = await mcpService.callToolById(toolId, params, callId) + throwIfToolError(result) + return extractToolResult(result) + } + const toolId = `${toolInfo.serverId}__${toolInfo.toolName}` + const result = await mcpService.callToolById(toolId, params, callId) + throwIfToolError(result) + return extractToolResult(result) +} + +export const abortMcpTool = async (callId: string): Promise => { + return mcpService.abortTool(null as unknown as Electron.IpcMainInvokeEvent, callId) +} + +function extractToolResult(result: MCPCallToolResponse): unknown { + if (!result.content || result.content.length === 0) { + return null + } + + const textContent = result.content.find((c) => c.type === 'text') + if (textContent?.text) { + try { + return JSON.parse(textContent.text) + } catch { + return textContent.text + } + } + + return result.content +} + +function throwIfToolError(result: MCPCallToolResponse): void { + if (!result.isError) { + return + } + + const textContent = extractTextContent(result.content) + throw new Error(textContent ?? 'Tool execution failed') +} + +function extractTextContent(content: MCPToolResultContent[] | undefined): string | undefined { + if (!content || content.length === 0) { + return undefined + } + + const textBlock = content.find((item) => item.type === 'text' && item.text) + return textBlock?.text +} diff --git a/src/main/mcpServers/hub/runtime.ts b/src/main/mcpServers/hub/runtime.ts new file mode 100644 index 0000000000..dd33fc9ff0 --- /dev/null +++ b/src/main/mcpServers/hub/runtime.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto' +import { Worker } from 'node:worker_threads' + +import { loggerService } from '@logger' + +import { abortMcpTool, callMcpTool } from './mcp-bridge' +import type { + ExecOutput, + GeneratedTool, + HubWorkerCallToolMessage, + HubWorkerExecMessage, + HubWorkerMessage, + HubWorkerResultMessage +} from './types' +import { hubWorkerSource } from './worker' + +const logger = loggerService.withContext('MCPServer:Hub:Runtime') + +const MAX_LOGS = 1000 +const EXECUTION_TIMEOUT = 60000 + +export class Runtime { + async execute(code: string, tools: GeneratedTool[]): Promise { + return await new Promise((resolve) => { + const logs: string[] = [] + const activeCallIds = new Map() + let finished = false + let timedOut = false + let timeoutId: NodeJS.Timeout | null = null + + const worker = new Worker(hubWorkerSource, { eval: true }) + + const addLog = (entry: string) => { + if (logs.length >= MAX_LOGS) { + return + } + logs.push(entry) + } + + const finalize = async (output: ExecOutput, terminateWorker = true) => { + if (finished) { + return + } + finished = true + if (timeoutId) { + clearTimeout(timeoutId) + } + worker.removeAllListeners() + if (terminateWorker) { + try { + await worker.terminate() + } catch (error) { + logger.warn('Failed to terminate exec worker', error as Error) + } + } + resolve(output) + } + + const abortActiveTools = async () => { + const callIds = Array.from(activeCallIds.values()) + activeCallIds.clear() + if (callIds.length === 0) { + return + } + await Promise.allSettled(callIds.map((callId) => abortMcpTool(callId))) + } + + const handleToolCall = async (message: HubWorkerCallToolMessage) => { + if (finished || timedOut) { + return + } + const callId = crypto.randomUUID() + activeCallIds.set(message.requestId, callId) + + try { + const result = await callMcpTool(message.functionName, message.params, callId) + if (finished || timedOut) { + return + } + worker.postMessage({ type: 'toolResult', requestId: message.requestId, result }) + } catch (error) { + if (finished || timedOut) { + return + } + const errorMessage = error instanceof Error ? error.message : String(error) + worker.postMessage({ type: 'toolError', requestId: message.requestId, error: errorMessage }) + } finally { + activeCallIds.delete(message.requestId) + } + } + + const handleResult = (message: HubWorkerResultMessage) => { + const resolvedLogs = message.logs && message.logs.length > 0 ? message.logs : logs + void finalize({ + result: message.result, + logs: resolvedLogs.length > 0 ? resolvedLogs : undefined + }) + } + + const handleError = (errorMessage: string, messageLogs?: string[], terminateWorker = true) => { + const resolvedLogs = messageLogs && messageLogs.length > 0 ? messageLogs : logs + void finalize( + { + result: undefined, + logs: resolvedLogs.length > 0 ? resolvedLogs : undefined, + error: errorMessage, + isError: true + }, + terminateWorker + ) + } + + const handleMessage = (message: HubWorkerMessage) => { + if (!message || typeof message !== 'object') { + return + } + switch (message.type) { + case 'log': + addLog(message.entry) + break + case 'callTool': + void handleToolCall(message) + break + case 'result': + handleResult(message) + break + case 'error': + handleError(message.error, message.logs) + break + default: + break + } + } + + timeoutId = setTimeout(() => { + timedOut = true + void (async () => { + await abortActiveTools() + try { + await worker.terminate() + } catch (error) { + logger.warn('Failed to terminate exec worker after timeout', error as Error) + } + handleError(`Execution timed out after ${EXECUTION_TIMEOUT}ms`, undefined, false) + })() + }, EXECUTION_TIMEOUT) + + worker.on('message', handleMessage) + worker.on('error', (error) => { + logger.error('Worker execution error', error) + handleError(error instanceof Error ? error.message : String(error)) + }) + worker.on('exit', (code) => { + if (finished || timedOut) { + return + } + const message = code === 0 ? 'Exec worker exited unexpectedly' : `Exec worker exited with code ${code}` + logger.error(message) + handleError(message, undefined, false) + }) + + const execMessage: HubWorkerExecMessage = { + type: 'exec', + code, + tools: tools.map((tool) => ({ functionName: tool.functionName })) + } + worker.postMessage(execMessage) + }) + } +} diff --git a/src/main/mcpServers/hub/search.ts b/src/main/mcpServers/hub/search.ts new file mode 100644 index 0000000000..7bed36a285 --- /dev/null +++ b/src/main/mcpServers/hub/search.ts @@ -0,0 +1,109 @@ +import { generateToolsCode } from './generator' +import type { GeneratedTool, SearchQuery, SearchResult } from './types' + +const DEFAULT_LIMIT = 10 +const MAX_LIMIT = 50 + +export function searchTools(tools: GeneratedTool[], query: SearchQuery): SearchResult { + const { query: queryStr, limit = DEFAULT_LIMIT } = query + const effectiveLimit = Math.min(Math.max(1, limit), MAX_LIMIT) + + const keywords = queryStr + .toLowerCase() + .split(',') + .map((k) => k.trim()) + .filter((k) => k.length > 0) + + if (keywords.length === 0) { + const sliced = tools.slice(0, effectiveLimit) + return { + tools: generateToolsCode(sliced), + total: tools.length + } + } + + const matchedTools = tools.filter((tool) => { + const searchText = buildSearchText(tool).toLowerCase() + return keywords.some((keyword) => searchText.includes(keyword)) + }) + + const rankedTools = rankTools(matchedTools, keywords) + const sliced = rankedTools.slice(0, effectiveLimit) + + return { + tools: generateToolsCode(sliced), + total: matchedTools.length + } +} + +function buildSearchText(tool: GeneratedTool): string { + const combinedName = tool.serverName ? `${tool.serverName}_${tool.toolName}` : tool.toolName + const parts = [ + tool.toolName, + tool.functionName, + tool.serverName, + combinedName, + tool.description || '', + tool.signature + ] + return parts.join(' ') +} + +function rankTools(tools: GeneratedTool[], keywords: string[]): GeneratedTool[] { + const scored = tools.map((tool) => ({ + tool, + score: calculateScore(tool, keywords) + })) + + scored.sort((a, b) => b.score - a.score) + + return scored.map((s) => s.tool) +} + +function calculateScore(tool: GeneratedTool, keywords: string[]): number { + let score = 0 + const toolName = tool.toolName.toLowerCase() + const serverName = (tool.serverName || '').toLowerCase() + const functionName = tool.functionName.toLowerCase() + const description = (tool.description || '').toLowerCase() + + for (const keyword of keywords) { + // Match tool name + if (toolName === keyword) { + score += 10 + } else if (toolName.startsWith(keyword)) { + score += 5 + } else if (toolName.includes(keyword)) { + score += 3 + } + + // Match server name + if (serverName === keyword) { + score += 8 + } else if (serverName.startsWith(keyword)) { + score += 4 + } else if (serverName.includes(keyword)) { + score += 2 + } + + // Match function name (serverName_toolName format) + if (functionName === keyword) { + score += 10 + } else if (functionName.startsWith(keyword)) { + score += 5 + } else if (functionName.includes(keyword)) { + score += 3 + } + + if (description.includes(keyword)) { + const count = (description.match(new RegExp(escapeRegex(keyword), 'g')) || []).length + score += Math.min(count, 3) + } + } + + return score +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/src/main/mcpServers/hub/types.ts b/src/main/mcpServers/hub/types.ts new file mode 100644 index 0000000000..1c24ac7e77 --- /dev/null +++ b/src/main/mcpServers/hub/types.ts @@ -0,0 +1,113 @@ +import type { MCPServer, MCPTool } from '@types' + +export interface GeneratedTool { + serverId: string + serverName: string + toolName: string + functionName: string + jsCode: string + fn: (params: unknown) => Promise + signature: string + returns: string + description?: string +} + +export interface SearchQuery { + query: string + limit?: number +} + +export interface SearchResult { + tools: string + total: number +} + +export interface ExecInput { + code: string +} + +export type ExecOutput = { + result: unknown + logs?: string[] + error?: string + isError?: boolean +} + +export interface ToolRegistryOptions { + ttl?: number +} + +export interface MCPToolWithServer extends MCPTool { + server: MCPServer +} + +export interface ExecutionContext { + __callTool: (functionName: string, params: unknown) => Promise + parallel: (...promises: Promise[]) => Promise + settle: (...promises: Promise[]) => Promise[]> + console: ConsoleMethods + [functionName: string]: unknown +} + +export interface ConsoleMethods { + log: (...args: unknown[]) => void + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void + info: (...args: unknown[]) => void + debug: (...args: unknown[]) => void +} + +export type HubWorkerTool = { + functionName: string +} + +export type HubWorkerExecMessage = { + type: 'exec' + code: string + tools: HubWorkerTool[] +} + +export type HubWorkerCallToolMessage = { + type: 'callTool' + requestId: string + functionName: string + params: unknown +} + +export type HubWorkerToolResultMessage = { + type: 'toolResult' + requestId: string + result: unknown +} + +export type HubWorkerToolErrorMessage = { + type: 'toolError' + requestId: string + error: string +} + +export type HubWorkerResultMessage = { + type: 'result' + result: unknown + logs?: string[] +} + +export type HubWorkerErrorMessage = { + type: 'error' + error: string + logs?: string[] +} + +export type HubWorkerLogMessage = { + type: 'log' + entry: string +} + +export type HubWorkerMessage = + | HubWorkerExecMessage + | HubWorkerCallToolMessage + | HubWorkerToolResultMessage + | HubWorkerToolErrorMessage + | HubWorkerResultMessage + | HubWorkerErrorMessage + | HubWorkerLogMessage diff --git a/src/main/mcpServers/hub/worker.ts b/src/main/mcpServers/hub/worker.ts new file mode 100644 index 0000000000..88dcbc6858 --- /dev/null +++ b/src/main/mcpServers/hub/worker.ts @@ -0,0 +1,133 @@ +export const hubWorkerSource = ` +const crypto = require('node:crypto') +const { parentPort } = require('node:worker_threads') + +const MAX_LOGS = 1000 + +const logs = [] +const pendingCalls = new Map() +let isExecuting = false + +const stringify = (value) => { + if (value === undefined) return 'undefined' + if (value === null) return 'null' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + if (value instanceof Error) return value.message + + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +const pushLog = (level, args) => { + if (logs.length >= MAX_LOGS) { + return + } + const message = args.map((arg) => stringify(arg)).join(' ') + const entry = \`[\${level}] \${message}\` + logs.push(entry) + parentPort?.postMessage({ type: 'log', entry }) +} + +const capturedConsole = { + log: (...args) => pushLog('log', args), + warn: (...args) => pushLog('warn', args), + error: (...args) => pushLog('error', args), + info: (...args) => pushLog('info', args), + debug: (...args) => pushLog('debug', args) +} + +const callTool = (functionName, params) => + new Promise((resolve, reject) => { + const requestId = crypto.randomUUID() + pendingCalls.set(requestId, { resolve, reject }) + parentPort?.postMessage({ type: 'callTool', requestId, functionName, params }) + }) + +const buildContext = (tools) => { + const context = { + __callTool: callTool, + parallel: (...promises) => Promise.all(promises), + settle: (...promises) => Promise.allSettled(promises), + console: capturedConsole + } + + for (const tool of tools) { + context[tool.functionName] = (params) => callTool(tool.functionName, params) + } + + return context +} + +const runCode = async (code, context) => { + const contextKeys = Object.keys(context) + const contextValues = contextKeys.map((key) => context[key]) + + const wrappedCode = \` + return (async () => { + \${code} + })() + \` + + const fn = new Function(...contextKeys, wrappedCode) + return await fn(...contextValues) +} + +const handleExec = async (code, tools) => { + if (isExecuting) { + return + } + isExecuting = true + + try { + const context = buildContext(tools) + const result = await runCode(code, context) + parentPort?.postMessage({ type: 'result', result, logs: logs.length > 0 ? logs : undefined }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + parentPort?.postMessage({ type: 'error', error: errorMessage, logs: logs.length > 0 ? logs : undefined }) + } finally { + pendingCalls.clear() + } +} + +const handleToolResult = (message) => { + const pending = pendingCalls.get(message.requestId) + if (!pending) { + return + } + pendingCalls.delete(message.requestId) + pending.resolve(message.result) +} + +const handleToolError = (message) => { + const pending = pendingCalls.get(message.requestId) + if (!pending) { + return + } + pendingCalls.delete(message.requestId) + pending.reject(new Error(message.error)) +} + +parentPort?.on('message', (message) => { + if (!message || typeof message !== 'object') { + return + } + switch (message.type) { + case 'exec': + handleExec(message.code, message.tools ?? []) + break + case 'toolResult': + handleToolResult(message) + break + case 'toolError': + handleToolError(message) + break + default: + break + } +}) +` diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 7d36e6d7e3..7c0d31384d 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -3,9 +3,9 @@ import os from 'node:os' import path from 'node:path' import { loggerService } from '@logger' +import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp' import { createInMemoryMCPServer } from '@main/mcpServers/factory' import { makeSureDirExists, removeEnvProxy } from '@main/utils' -import { buildFunctionCallToolName } from '@main/utils/mcp' import { findCommandInShellEnv, getBinaryName, getBinaryPath, isBinaryExists } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { TraceMethod, withSpanFunc } from '@mcp-trace/trace-core' @@ -35,6 +35,7 @@ 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 { buildFunctionCallToolName } from '@shared/mcp' import { defaultAppHeaders } from '@shared/utils' import { BuiltinMCPServerNames, @@ -165,6 +166,67 @@ class McpService { this.getServerLogs = this.getServerLogs.bind(this) } + /** + * List all tools from all active MCP servers (excluding hub). + * Used by Hub server's tool registry. + */ + public async listAllActiveServerTools(): Promise { + const servers = await getMCPServersFromRedux() + const activeServers = servers.filter((server) => server.isActive) + + const results = await Promise.allSettled( + activeServers.map(async (server) => { + const tools = await this.listToolsImpl(server) + const disabledTools = new Set(server.disabledTools ?? []) + return disabledTools.size > 0 ? tools.filter((tool) => !disabledTools.has(tool.name)) : tools + }) + ) + + const allTools: MCPTool[] = [] + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + allTools.push(...result.value) + } else { + logger.error( + `[listAllActiveServerTools] Failed to list tools from ${activeServers[index].name}:`, + result.reason as Error + ) + } + }) + + return allTools + } + + /** + * Call a tool by its full ID (serverId__toolName format). + * Used by Hub server's runtime. + */ + public async callToolById(toolId: string, params: unknown, callId?: string): Promise { + const parts = toolId.split('__') + if (parts.length < 2) { + throw new Error(`Invalid tool ID format: ${toolId}`) + } + + const serverId = parts[0] + const toolName = parts.slice(1).join('__') + + const servers = await getMCPServersFromRedux() + const server = servers.find((s) => s.id === serverId) + + if (!server) { + throw new Error(`Server not found: ${serverId}`) + } + + logger.debug(`[callToolById] Calling tool ${toolName} on server ${server.name}`) + + return this.callTool(null as unknown as Electron.IpcMainInvokeEvent, { + server, + name: toolName, + args: params, + callId + }) + } + private getServerKey(server: MCPServer): string { return JSON.stringify({ baseUrl: server.baseUrl, diff --git a/src/main/services/__tests__/MCPService.test.ts b/src/main/services/__tests__/MCPService.test.ts new file mode 100644 index 0000000000..4757d20cff --- /dev/null +++ b/src/main/services/__tests__/MCPService.test.ts @@ -0,0 +1,75 @@ +import type { MCPServer, MCPTool } from '@types' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@main/apiServer/utils/mcp', () => ({ + getMCPServersFromRedux: vi.fn() +})) + +vi.mock('@main/services/WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => null) + } +})) + +import { getMCPServersFromRedux } from '@main/apiServer/utils/mcp' +import mcpService from '@main/services/MCPService' + +const baseInputSchema: { type: 'object'; properties: Record; required: string[] } = { + type: 'object', + properties: {}, + required: [] +} + +const createTool = (overrides: Partial): MCPTool => ({ + id: `${overrides.serverId}__${overrides.name}`, + name: overrides.name ?? 'tool', + description: overrides.description, + serverId: overrides.serverId ?? 'server', + serverName: overrides.serverName ?? 'server', + inputSchema: baseInputSchema, + type: 'mcp', + ...overrides +}) + +describe('MCPService.listAllActiveServerTools', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('filters disabled tools per server', async () => { + const servers: MCPServer[] = [ + { + id: 'alpha', + name: 'Alpha', + isActive: true, + disabledTools: ['disabled_tool'] + }, + { + id: 'beta', + name: 'Beta', + isActive: true + } + ] + + vi.mocked(getMCPServersFromRedux).mockResolvedValue(servers) + + const listToolsSpy = vi.spyOn(mcpService as any, 'listToolsImpl').mockImplementation(async (server: any) => { + if (server.id === 'alpha') { + return [ + createTool({ name: 'enabled_tool', serverId: server.id, serverName: server.name }), + createTool({ name: 'disabled_tool', serverId: server.id, serverName: server.name }) + ] + } + return [createTool({ name: 'beta_tool', serverId: server.id, serverName: server.name })] + }) + + const tools = await mcpService.listAllActiveServerTools() + + expect(listToolsSpy).toHaveBeenCalledTimes(2) + expect(tools.map((tool) => tool.name)).toEqual(['enabled_tool', 'beta_tool']) + }) +}) diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index e30814bb6f..bf1fb6ddbe 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import type { ModelValidationError } from '@main/apiServer/utils' import { validateModelId } from '@main/apiServer/utils' -import { buildFunctionCallToolName } from '@main/utils/mcp' +import { buildFunctionCallToolName } from '@shared/mcp' import type { AgentType, MCPTool, SlashCommand, Tool } from '@types' import { objectKeys } from '@types' import fs from 'fs' diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts deleted file mode 100644 index 706a44bc84..0000000000 --- a/src/main/utils/__tests__/mcp.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { buildFunctionCallToolName } from '../mcp' - -describe('buildFunctionCallToolName', () => { - describe('basic format', () => { - it('should return format mcp__{server}__{tool}', () => { - const result = buildFunctionCallToolName('github', 'search_issues') - expect(result).toBe('mcp__github__search_issues') - }) - - it('should handle simple server and tool names', () => { - expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page') - expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query') - expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search') - }) - }) - - describe('valid JavaScript identifier', () => { - it('should always start with mcp__ prefix (valid JS identifier start)', () => { - const result = buildFunctionCallToolName('123server', '456tool') - expect(result).toMatch(/^mcp__/) - expect(result).toBe('mcp__123server__456tool') - }) - - it('should only contain alphanumeric chars and underscores', () => { - const result = buildFunctionCallToolName('my-server', 'my-tool') - expect(result).toBe('mcp__my_server__my_tool') - expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/) - }) - - it('should be a valid JavaScript identifier', () => { - const testCases = [ - ['github', 'create_issue'], - ['my-server', 'fetch-data'], - ['test@server', 'tool#name'], - ['server.name', 'tool.action'], - ['123abc', 'def456'] - ] - - for (const [server, tool] of testCases) { - const result = buildFunctionCallToolName(server, tool) - // Valid JS identifiers match this pattern - expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/) - } - }) - }) - - describe('character sanitization', () => { - it('should replace dashes with underscores', () => { - const result = buildFunctionCallToolName('my-server', 'my-tool-name') - expect(result).toBe('mcp__my_server__my_tool_name') - }) - - it('should replace special characters with underscores', () => { - const result = buildFunctionCallToolName('test@server!', 'tool#name$') - expect(result).toBe('mcp__test_server__tool_name') - }) - - it('should replace dots with underscores', () => { - const result = buildFunctionCallToolName('server.name', 'tool.action') - expect(result).toBe('mcp__server_name__tool_action') - }) - - it('should replace spaces with underscores', () => { - const result = buildFunctionCallToolName('my server', 'my tool') - expect(result).toBe('mcp__my_server__my_tool') - }) - - it('should collapse consecutive underscores', () => { - const result = buildFunctionCallToolName('my--server', 'my___tool') - expect(result).toBe('mcp__my_server__my_tool') - expect(result).not.toMatch(/_{3,}/) - }) - - it('should trim leading and trailing underscores from parts', () => { - const result = buildFunctionCallToolName('_server_', '_tool_') - expect(result).toBe('mcp__server__tool') - }) - - it('should handle names with only special characters', () => { - const result = buildFunctionCallToolName('---', '###') - expect(result).toBe('mcp____') - }) - }) - - describe('length constraints', () => { - it('should not exceed 63 characters', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const result = buildFunctionCallToolName(longServerName, longToolName) - - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should truncate server name to max 20 chars', () => { - const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars - const result = buildFunctionCallToolName(longServerName, 'tool') - - expect(result).toBe('mcp__abcdefghijklmnopqrst__tool') - expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars - expect(result).not.toContain('uvwxyz') // Truncated - }) - - it('should truncate tool name to max 35 chars', () => { - const longToolName = 'a'.repeat(40) - const result = buildFunctionCallToolName('server', longToolName) - - const expectedTool = 'a'.repeat(35) - expect(result).toBe(`mcp__server__${expectedTool}`) - }) - - it('should not end with underscores after truncation', () => { - // Create a name that would end with underscores after truncation - const longServerName = 'a'.repeat(20) - const longToolName = 'b'.repeat(35) + '___extra' - const result = buildFunctionCallToolName(longServerName, longToolName) - - expect(result).not.toMatch(/_+$/) - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should handle max length edge case exactly', () => { - // mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars - const server = 'a'.repeat(20) - const tool = 'b'.repeat(35) - const result = buildFunctionCallToolName(server, tool) - - expect(result.length).toBe(62) - expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`) - }) - }) - - describe('edge cases', () => { - it('should handle empty server name', () => { - const result = buildFunctionCallToolName('', 'tool') - expect(result).toBe('mcp____tool') - }) - - it('should handle empty tool name', () => { - const result = buildFunctionCallToolName('server', '') - expect(result).toBe('mcp__server__') - }) - - it('should handle both empty names', () => { - const result = buildFunctionCallToolName('', '') - expect(result).toBe('mcp____') - }) - - it('should handle whitespace-only names', () => { - const result = buildFunctionCallToolName(' ', ' ') - expect(result).toBe('mcp____') - }) - - it('should trim whitespace from names', () => { - const result = buildFunctionCallToolName(' server ', ' tool ') - expect(result).toBe('mcp__server__tool') - }) - - it('should handle unicode characters', () => { - const result = buildFunctionCallToolName('服务器', '工具') - // Unicode chars are replaced with underscores, then collapsed - expect(result).toMatch(/^mcp__/) - }) - - it('should handle mixed case', () => { - const result = buildFunctionCallToolName('MyServer', 'MyTool') - expect(result).toBe('mcp__MyServer__MyTool') - }) - }) - - describe('deterministic output', () => { - it('should produce consistent results for same input', () => { - const serverName = 'github' - const toolName = 'search_repos' - - const result1 = buildFunctionCallToolName(serverName, toolName) - const result2 = buildFunctionCallToolName(serverName, toolName) - const result3 = buildFunctionCallToolName(serverName, toolName) - - expect(result1).toBe(result2) - expect(result2).toBe(result3) - }) - - it('should produce different results for different inputs', () => { - const result1 = buildFunctionCallToolName('server1', 'tool') - const result2 = buildFunctionCallToolName('server2', 'tool') - const result3 = buildFunctionCallToolName('server', 'tool1') - const result4 = buildFunctionCallToolName('server', 'tool2') - - expect(result1).not.toBe(result2) - expect(result3).not.toBe(result4) - }) - }) - - describe('real-world scenarios', () => { - it('should handle GitHub MCP server', () => { - expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue') - expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories') - expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request') - }) - - it('should handle filesystem MCP server', () => { - expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file') - expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file') - expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory') - }) - - it('should handle hyphenated server names (common in npm packages)', () => { - expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page') - expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search') - }) - - it('should handle scoped npm package style names', () => { - const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat') - expect(result).toBe('mcp__anthropic_mcp_server__chat') - }) - - it('should handle tools with long descriptive names', () => { - const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars') - expect(result.length).toBeLessThanOrEqual(63) - expect(result).toMatch(/^mcp__github__search_repositories_by_lan/) - }) - }) -}) diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts deleted file mode 100644 index 34eb0e63e7..0000000000 --- a/src/main/utils/mcp.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Builds a valid JavaScript function name for MCP tool calls. - * Format: mcp__{server_name}__{tool_name} - * - * @param serverName - The MCP server name - * @param toolName - The tool name from the server - * @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars - */ -export function buildFunctionCallToolName(serverName: string, toolName: string): string { - // Sanitize to valid JS identifier chars (alphanumeric + underscore only) - const sanitize = (str: string): string => - str - .trim() - .replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore - .replace(/_{2,}/g, '_') // Collapse multiple underscores - .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores - - const server = sanitize(serverName).slice(0, 20) // Keep server name short - const tool = sanitize(toolName).slice(0, 35) // More room for tool name - - let name = `mcp__${server}__${tool}` - - // Ensure max 63 chars and clean trailing underscores - if (name.length > 63) { - name = name.slice(0, 63).replace(/_+$/, '') - } - - return name -} diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index b2a796bd33..0d27390370 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -1,7 +1,7 @@ import type { WebSearchPluginConfig } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import { isGemini3Model, isSupportedThinkingTokenQwenModel } from '@renderer/config/models' -import type { MCPTool } from '@renderer/types' +import type { McpMode, MCPTool } from '@renderer/types' import { type Assistant, type Message, type Model, type Provider, SystemProviderIds } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { isOllamaProvider, isSupportEnableThinkingProvider } from '@renderer/utils/provider' @@ -38,6 +38,7 @@ export interface AiSdkMiddlewareConfig { enableWebSearch: boolean enableGenerateImage: boolean enableUrlContext: boolean + mcpMode?: McpMode mcpTools?: MCPTool[] uiMessages?: Message[] // 内置搜索配置 diff --git a/src/renderer/src/aiCore/plugins/PluginBuilder.ts b/src/renderer/src/aiCore/plugins/PluginBuilder.ts index eb46eb7524..d2104550a0 100644 --- a/src/renderer/src/aiCore/plugins/PluginBuilder.ts +++ b/src/renderer/src/aiCore/plugins/PluginBuilder.ts @@ -47,6 +47,7 @@ export function buildPlugins( plugins.push( createPromptToolUsePlugin({ enabled: true, + mcpMode: middlewareConfig.mcpMode, createSystemMessage: (systemPrompt, params, context) => { const modelId = typeof context.model === 'string' ? context.model : context.model.modelId if (modelId.includes('o1-mini') || modelId.includes('o1-preview')) { diff --git a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts index 52234c5f1f..8c8187e5b2 100644 --- a/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts +++ b/src/renderer/src/aiCore/prepareParams/parameterBuilder.ts @@ -26,11 +26,13 @@ import { isSupportedThinkingTokenModel, isWebSearchModel } from '@renderer/config/models' +import { getHubModeSystemPrompt } from '@renderer/config/prompts-code-mode' +import { fetchAllActiveServerTools } from '@renderer/services/ApiService' import { getDefaultModel } from '@renderer/services/AssistantService' import store from '@renderer/store' import type { CherryWebSearchConfig } from '@renderer/store/websearch' import type { Model } from '@renderer/types' -import { type Assistant, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types' +import { type Assistant, getEffectiveMcpMode, type MCPTool, type Provider, SystemProviderIds } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' import { replacePromptVariables } from '@renderer/utils/prompt' @@ -243,8 +245,18 @@ export async function buildStreamTextParams( params.tools = tools } - if (assistant.prompt) { - params.system = await replacePromptVariables(assistant.prompt, model.name) + let systemPrompt = assistant.prompt ? await replacePromptVariables(assistant.prompt, model.name) : '' + + if (getEffectiveMcpMode(assistant) === 'auto') { + const allActiveTools = await fetchAllActiveServerTools() + const autoModePrompt = getHubModeSystemPrompt(allActiveTools) + if (autoModePrompt) { + systemPrompt = systemPrompt ? `${systemPrompt}\n\n${autoModePrompt}` : autoModePrompt + } + } + + if (systemPrompt) { + params.system = systemPrompt } logger.debug('params', params) diff --git a/src/renderer/src/config/prompts-code-mode.ts b/src/renderer/src/config/prompts-code-mode.ts new file mode 100644 index 0000000000..9cd9b55721 --- /dev/null +++ b/src/renderer/src/config/prompts-code-mode.ts @@ -0,0 +1,174 @@ +import { generateMcpToolFunctionName } from '@shared/mcp' + +export interface ToolInfo { + name: string + serverName?: string + description?: string +} + +/** + * Hub Mode System Prompt - For native MCP tool calling + * Used when model supports native function calling via MCP protocol + */ +const HUB_MODE_SYSTEM_PROMPT_BASE = ` +## Hub MCP Tools – Code Execution Mode + +You can discover and call MCP tools through the hub server using **ONLY two meta-tools**: **search** and **exec**. + +### ⚠️ IMPORTANT: You can ONLY call these two tools directly + +| Tool | Purpose | +|------|---------| +| \`search\` | Discover available tools and their signatures | +| \`exec\` | Execute JavaScript code that calls the discovered tools | + +**All other tools (listed in "Discoverable Tools" below) can ONLY be called from INSIDE \`exec\` code.** +You CANNOT call them directly as tool calls. They are async functions available within the \`exec\` runtime. + +### Critical Rules (Read First) + +1. **ONLY \`search\` and \`exec\` are callable as tools.** All other tools must be used inside \`exec\` code. +2. You MUST explicitly \`return\` the final value from your \`exec\` code. If you do not return a value, the result will be \`undefined\`. +3. All MCP tools inside \`exec\` are async functions. Always call them as \`await ToolName(params)\`. +4. Use the exact function names and parameter shapes returned by \`search\`. +5. You CANNOT call \`search\` or \`exec\` from inside \`exec\` code—use them only as direct tool calls. +6. \`console.log\` output is NOT the result. Logs are separate; the final answer must come from \`return\`. + +### Workflow + +1. Call \`search\` with relevant keywords to discover tools. +2. Read the returned JavaScript function declarations and JSDoc to understand names and parameters. +3. Call \`exec\` with JavaScript code that uses the discovered tools and ends with an explicit \`return\`. +4. Use the \`exec\` result as your answer. + +### What \`search\` Does + +- Input: keyword string (comma-separated for OR-matching), plus optional \`limit\`. +- Output: JavaScript async function declarations with JSDoc showing exact function names, parameters, and return types. + +### What \`exec\` Does + +- Runs JavaScript code in an isolated async context (wrapped as \`(async () => { your code })())\`. +- All discovered tools are exposed as async functions: \`await ToolName(params)\`. +- Available helpers: + - \`parallel(...promises)\` → \`Promise.all(promises)\` + - \`settle(...promises)\` → \`Promise.allSettled(promises)\` + - \`console.log/info/warn/error/debug\` +- Returns JSON with: \`result\` (your returned value), \`logs\` (optional), \`error\` (optional), \`isError\` (optional). + +### Example: Single Tool Call + +\`\`\`javascript +// Step 1: search({ query: "browser,fetch" }) +// Step 2: exec with: +const page = await CherryBrowser_fetch({ url: "https://example.com" }) +return page +\`\`\` + +### Example: Multiple Tools with Parallel + +\`\`\`javascript +const [forecast, time] = await parallel( + Weather_getForecast({ city: "Paris" }), + Time_getLocalTime({ city: "Paris" }) +) +return { city: "Paris", forecast, time } +\`\`\` + +### Example: Handle Partial Failures with Settle + +\`\`\`javascript +const results = await settle( + Weather_getForecast({ city: "Paris" }), + Weather_getForecast({ city: "Tokyo" }) +) +const successful = results.filter(r => r.status === "fulfilled").map(r => r.value) +return { results, successful } +\`\`\` + +### Example: Error Handling + +\`\`\`javascript +try { + const user = await User_lookup({ email: "user@example.com" }) + return { found: true, user } +} catch (error) { + return { found: false, error: String(error) } +} +\`\`\` + +### Common Mistakes to Avoid + +❌ **Forgetting to return** (result will be \`undefined\`): +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +// Missing return! +\`\`\` + +✅ **Always return**: +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +return data +\`\`\` + +❌ **Only logging, not returning**: +\`\`\`javascript +const data = await SomeTool({ id: "123" }) +console.log(data) // Logs are NOT the result! +\`\`\` + +❌ **Missing await**: +\`\`\`javascript +const data = SomeTool({ id: "123" }) // Returns Promise, not value! +return data +\`\`\` + +❌ **Awaiting before parallel**: +\`\`\`javascript +await parallel(await ToolA(), await ToolB()) // Wrong: runs sequentially +\`\`\` + +✅ **Pass promises directly to parallel**: +\`\`\`javascript +await parallel(ToolA(), ToolB()) // Correct: runs in parallel +\`\`\` + +### Best Practices + +- Always call \`search\` first to discover tools and confirm signatures. +- Always use an explicit \`return\` at the end of \`exec\` code. +- Use \`parallel\` for independent operations that can run at the same time. +- Use \`settle\` when some calls may fail but you still want partial results. +- Prefer a single \`exec\` call for multi-step flows. +- Treat \`console.*\` as debugging only, never as the primary result. +` + +function buildToolsSection(tools: ToolInfo[]): string { + const existingNames = new Set() + return tools + .map((t) => { + const functionName = generateMcpToolFunctionName(t.serverName, t.name, existingNames) + const desc = t.description || '' + const normalizedDesc = desc.replace(/\s+/g, ' ').trim() + const truncatedDesc = normalizedDesc.length > 50 ? `${normalizedDesc.slice(0, 50)}...` : normalizedDesc + return `- ${functionName}: ${truncatedDesc}` + }) + .join('\n') +} + +export function getHubModeSystemPrompt(tools: ToolInfo[] = []): string { + if (tools.length === 0) { + return '' + } + + const toolsSection = buildToolsSection(tools) + + return `${HUB_MODE_SYSTEM_PROMPT_BASE} +## Discoverable Tools (ONLY usable inside \`exec\` code, NOT as direct tool calls) + +The following tools are available inside \`exec\`. Use \`search\` to get their full signatures. +Do NOT call these directly—wrap them in \`exec\` code. + +${toolsSection} +` +} diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 2e6f84026e..ebe243f6d2 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -332,7 +332,8 @@ const builtInMcpDescriptionKeyMap: Record = { [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp', [BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser', - [BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem' + [BuiltinMCPServerNames.nowledgeMem]: 'settings.mcp.builtinServersDescriptions.nowledge_mem', + [BuiltinMCPServerNames.hub]: 'settings.mcp.builtinServersDescriptions.hub' } as const export const getBuiltInMcpServerDescriptionLabel = (key: string): string => { diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 7ac425c54a..200899f7fd 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -544,6 +544,20 @@ "description": "Default enabled MCP servers", "enableFirst": "Enable this server in MCP settings first", "label": "MCP Servers", + "mode": { + "auto": { + "description": "AI discovers and uses tools automatically", + "label": "Auto" + }, + "disabled": { + "description": "No MCP tools", + "label": "Disabled" + }, + "manual": { + "description": "Select specific MCP servers", + "label": "Manual" + } + }, "noServersAvailable": "No MCP servers available. Add servers in settings", "title": "MCP Settings" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e598016fe2..533819faef 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -544,6 +544,20 @@ "description": "默认启用的 MCP 服务器", "enableFirst": "请先在 MCP 设置中启用此服务器", "label": "MCP 服务器", + "mode": { + "auto": { + "description": "AI 自动发现和使用工具", + "label": "自动" + }, + "disabled": { + "description": "不使用 MCP 工具", + "label": "禁用" + }, + "manual": { + "description": "选择特定的 MCP 服务器", + "label": "手动" + } + }, "noServersAvailable": "无可用 MCP 服务器。请在设置中添加服务器", "title": "MCP 服务器" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ec39a08058..b828ca4812 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -544,6 +544,20 @@ "description": "預設啟用的 MCP 伺服器", "enableFirst": "請先在 MCP 設定中啟用此伺服器", "label": "MCP 伺服器", + "mode": { + "auto": { + "description": "AI 自動發現和使用工具", + "label": "自動" + }, + "disabled": { + "description": "不使用 MCP 工具", + "label": "停用" + }, + "manual": { + "description": "選擇特定的 MCP 伺服器", + "label": "手動" + } + }, "noServersAvailable": "無可用 MCP 伺服器。請在設定中新增伺服器", "title": "MCP 設定" }, diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index d5445961e5..41bc6a2611 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -544,6 +544,20 @@ "description": "Standardmäßig aktivierte MCP-Server", "enableFirst": "Bitte aktivieren Sie diesen Server zuerst in den MCP-Einstellungen", "label": "MCP-Server", + "mode": { + "auto": { + "description": "KI entdeckt und nutzt Werkzeuge automatisch", + "label": "Auto" + }, + "disabled": { + "description": "Keine MCP-Tools", + "label": "Deaktiviert" + }, + "manual": { + "description": "Wählen Sie spezifische MCP-Server", + "label": "Handbuch" + } + }, "noServersAvailable": "Keine MCP-Server verfügbar. Bitte fügen Sie Server in den Einstellungen hinzu", "title": "MCP-Server" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Backup-Dateiformat fehlerhaft" }, + "base64DataTruncated": "Base64-Bilddaten abgeschnitten, Größe", "boundary": { "default": { "devtools": "Debug-Panel öffnen", @@ -1377,6 +1392,8 @@ "text": "Text", "toolInput": "Tool-Eingabe", "toolName": "Tool-Name", + "truncated": "Daten wurden gekürzt, Originalgröße", + "truncatedBadge": "Abgeschnitten", "unknown": "Unbekannter Fehler", "usage": "Nutzung", "user_message_not_found": "Ursprüngliche Benutzernachricht nicht gefunden", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index d0cfe0579c..79b4517656 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -544,6 +544,20 @@ "description": "Διακομιστής MCP που είναι ενεργοποιημένος εξ ορισμού", "enableFirst": "Πρώτα ενεργοποιήστε αυτόν τον διακομιστή στις ρυθμίσεις MCP", "label": "Διακομιστής MCP", + "mode": { + "auto": { + "description": "Η τεχνητή νοημοσύνη ανακαλύπτει και χρησιμοποιεί εργαλεία αυτόματα", + "label": "Αυτόματο" + }, + "disabled": { + "description": "Χωρίς εργαλεία MCP", + "label": "Ανάπηρος" + }, + "manual": { + "description": "Επιλέξτε συγκεκριμένους διακομιστές MCP", + "label": "Εγχειρίδιο" + } + }, "noServersAvailable": "Δεν υπάρχουν διαθέσιμοι διακομιστές MCP. Προσθέστε ένα διακομιστή στις ρυθμίσεις", "title": "Ρυθμίσεις MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Λάθος μορφή αρχείου που επιστρέφεται" }, + "base64DataTruncated": "Τα δεδομένα εικόνας Base64 έχουν περικοπεί, μέγεθος", "boundary": { "default": { "devtools": "Άνοιγμα πίνακα αποσφαλμάτωσης", @@ -1377,6 +1392,8 @@ "text": "κείμενο", "toolInput": "εισαγωγή εργαλείου", "toolName": "Όνομα εργαλείου", + "truncated": "Δεδομένα περικόπηκαν, αρχικό μέγεθος", + "truncatedBadge": "Αποκομμένο", "unknown": "Άγνωστο σφάλμα", "usage": "δοσολογία", "user_message_not_found": "Αδυναμία εύρεσης της αρχικής μηνύματος χρήστη", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 039a289e7a..9c90ac3996 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -544,6 +544,20 @@ "description": "Servidor MCP habilitado por defecto", "enableFirst": "Habilite este servidor en la configuración de MCP primero", "label": "Servidor MCP", + "mode": { + "auto": { + "description": "La IA descubre y utiliza herramientas automáticamente", + "label": "Auto" + }, + "disabled": { + "description": "Sin herramientas MCP", + "label": "Discapacitado" + }, + "manual": { + "description": "Seleccionar servidores MCP específicos", + "label": "Manual" + } + }, "noServersAvailable": "No hay servidores MCP disponibles. Agregue un servidor en la configuración", "title": "Configuración MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Formato de archivo de copia de seguridad incorrecto" }, + "base64DataTruncated": "Datos de imagen Base64 truncados, tamaño", "boundary": { "default": { "devtools": "Abrir el panel de depuración", @@ -1377,6 +1392,8 @@ "text": "Texto", "toolInput": "Herramienta de entrada", "toolName": "Nombre de la herramienta", + "truncated": "Datos truncados, tamaño original", + "truncatedBadge": "Truncado", "unknown": "Error desconocido", "usage": "Cantidad de uso", "user_message_not_found": "No se pudo encontrar el mensaje original del usuario", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 352678c4ad..af743c5784 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -544,6 +544,20 @@ "description": "Serveur MCP activé par défaut", "enableFirst": "Veuillez d'abord activer ce serveur dans les paramètres MCP", "label": "Serveur MCP", + "mode": { + "auto": { + "description": "L'IA découvre et utilise des outils automatiquement", + "label": "Auto" + }, + "disabled": { + "description": "Aucun outil MCP", + "label": "Désactivé" + }, + "manual": { + "description": "Sélectionner des serveurs MCP spécifiques", + "label": "Manuel" + } + }, "noServersAvailable": "Aucun serveur MCP disponible. Veuillez ajouter un serveur dans les paramètres", "title": "Paramètres MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Le format du fichier de sauvegarde est incorrect" }, + "base64DataTruncated": "Données d'image Base64 tronquées, taille", "boundary": { "default": { "devtools": "Ouvrir le panneau de débogage", @@ -1377,6 +1392,8 @@ "text": "texte", "toolInput": "entrée de l'outil", "toolName": "Nom de l'outil", + "truncated": "Données tronquées, taille d'origine", + "truncatedBadge": "Tronqué", "unknown": "Неизвестная ошибка", "usage": "Quantité", "user_message_not_found": "Impossible de trouver le message d'utilisateur original", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index b58fe588f6..8972411ea5 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -544,6 +544,20 @@ "description": "デフォルトで有効な MCP サーバー", "enableFirst": "まず MCP 設定でこのサーバーを有効にしてください", "label": "MCP サーバー", + "mode": { + "auto": { + "description": "AIはツールを自動的に発見し、使用する", + "label": "オート" + }, + "disabled": { + "description": "MCPツールなし", + "label": "無効" + }, + "manual": { + "description": "特定のMCPサーバーを選択", + "label": "マニュアル" + } + }, "noServersAvailable": "利用可能な MCP サーバーがありません。設定でサーバーを追加してください", "title": "MCP 設定" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "バックアップファイルの形式エラー" }, + "base64DataTruncated": "Base64画像データが切り捨てられています、サイズ", "boundary": { "default": { "devtools": "デバッグパネルを開く", @@ -1377,6 +1392,8 @@ "text": "テキスト", "toolInput": "ツール入力", "toolName": "ツール名", + "truncated": "データが切り捨てられました、元のサイズ", + "truncatedBadge": "切り捨て", "unknown": "不明なエラー", "usage": "用量", "user_message_not_found": "元のユーザーメッセージを見つけることができませんでした", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 24a38261ca..8e9c0a59e4 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -544,6 +544,20 @@ "description": "Servidor MCP ativado por padrão", "enableFirst": "Por favor, ative este servidor nas configurações do MCP primeiro", "label": "Servidor MCP", + "mode": { + "auto": { + "description": "IA descobre e usa ferramentas automaticamente", + "label": "Auto" + }, + "disabled": { + "description": "Sem ferramentas MCP", + "label": "Desativado" + }, + "manual": { + "description": "Selecione servidores MCP específicos", + "label": "Manual" + } + }, "noServersAvailable": "Nenhum servidor MCP disponível. Adicione um servidor nas configurações", "title": "Configurações do MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Formato do arquivo de backup está incorreto" }, + "base64DataTruncated": "Dados da imagem em Base64 truncados, tamanho", "boundary": { "default": { "devtools": "Abrir o painel de depuração", @@ -1377,6 +1392,8 @@ "text": "texto", "toolInput": "ferramenta de entrada", "toolName": "Nome da ferramenta", + "truncated": "Dados truncados, tamanho original", + "truncatedBadge": "Truncado", "unknown": "Erro desconhecido", "usage": "dosagem", "user_message_not_found": "Não foi possível encontrar a mensagem original do usuário", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index f95cb7bdeb..9c0ba398c9 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -544,6 +544,20 @@ "description": "Servere MCP activate implicit", "enableFirst": "Activează mai întâi acest server în setările MCP", "label": "Servere MCP", + "mode": { + "auto": { + "description": "AI descoperă și folosește instrumente automat", + "label": "Auto" + }, + "disabled": { + "description": "Niciun instrument MCP", + "label": "Dezactivat" + }, + "manual": { + "description": "Selectați servere MCP specifice", + "label": "Manual" + } + }, "noServersAvailable": "Nu există servere MCP disponibile. Adaugă servere în setări", "title": "Setări MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Eroare format fișier backup" }, + "base64DataTruncated": "Datele imagine Base64 sunt trunchiate, dimensiunea", "boundary": { "default": { "devtools": "Deschide panoul de depanare", @@ -1377,6 +1392,8 @@ "text": "Text", "toolInput": "Intrare instrument", "toolName": "Nume instrument", + "truncated": "Date trunchiate, dimensiunea originală", + "truncatedBadge": "Trunchiat", "unknown": "Eroare necunoscută", "usage": "Utilizare", "user_message_not_found": "Nu se poate găsi mesajul original al utilizatorului pentru a retrimite", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 74ce3df5fb..cde0f0e6ca 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -544,6 +544,20 @@ "description": "Серверы MCP, включенные по умолчанию", "enableFirst": "Сначала включите этот сервер в настройках MCP", "label": "Серверы MCP", + "mode": { + "auto": { + "description": "ИИ самостоятельно обнаруживает и использует инструменты", + "label": "Авто" + }, + "disabled": { + "description": "Нет инструментов MCP", + "label": "Отключено" + }, + "manual": { + "description": "Выберите конкретные MCP-серверы", + "label": "Руководство" + } + }, "noServersAvailable": "Нет доступных серверов MCP. Добавьте серверы в настройках", "title": "Настройки MCP" }, @@ -1297,6 +1311,7 @@ "backup": { "file_format": "Ошибка формата файла резервной копии" }, + "base64DataTruncated": "Данные изображения в формате Base64 усечены, размер", "boundary": { "default": { "devtools": "Открыть панель отладки", @@ -1377,6 +1392,8 @@ "text": "текст", "toolInput": "ввод инструмента", "toolName": "имя инструмента", + "truncated": "Данные усечены, исходный размер", + "truncatedBadge": "Усечённый", "unknown": "Неизвестная ошибка", "usage": "Дозировка", "user_message_not_found": "Не удалось найти исходное сообщение пользователя", diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx index 0b0610e1d6..a54af49cb4 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/MCPToolsButton.tsx @@ -8,11 +8,12 @@ import { useTimer } from '@renderer/hooks/useTimer' import type { ToolQuickPanelApi } from '@renderer/pages/home/Inputbar/types' import { getProviderByModel } from '@renderer/services/AssistantService' import { EventEmitter } from '@renderer/services/EventService' -import type { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' +import type { McpMode, MCPPrompt, MCPResource, MCPServer } from '@renderer/types' +import { getEffectiveMcpMode } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { isGeminiWebSearchProvider, isSupportUrlContextProvider } from '@renderer/utils/provider' import { Form, Input, Tooltip } from 'antd' -import { CircleX, Hammer, Plus } from 'lucide-react' +import { CircleX, Hammer, Plus, Sparkles } from 'lucide-react' import type { FC } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -25,7 +26,6 @@ interface Props { resizeTextArea: () => void } -// 添加类型定义 interface PromptArgument { name: string description?: string @@ -44,24 +44,19 @@ interface ResourceData { uri?: string } -// 提取到组件外的工具函数 const extractPromptContent = (response: any): string | null => { - // Handle string response (backward compatibility) if (typeof response === 'string') { return response } - // Handle GetMCPPromptResponse format if (response && Array.isArray(response.messages)) { let formattedContent = '' for (const message of response.messages) { if (!message.content) continue - // Add role prefix if available const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' - // Process different content types switch (message.content.type) { case 'text': formattedContent += `${rolePrefix}${message.content.text}\n\n` @@ -98,7 +93,6 @@ const extractPromptContent = (response: any): string | null => { return formattedContent.trim() } - // Fallback handling for single message format if (response && response.messages && response.messages.length > 0) { const message = response.messages[0] if (message.content && message.content.text) { @@ -121,7 +115,6 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, const model = assistant.model const { setTimeoutTimer } = useTimer() - // 使用 useRef 存储不需要触发重渲染的值 const isMountedRef = useRef(true) useEffect(() => { @@ -130,11 +123,30 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, } }, []) + const currentMode = useMemo(() => getEffectiveMcpMode(assistant), [assistant]) + const mcpServers = useMemo(() => assistant.mcpServers || [], [assistant.mcpServers]) const assistantMcpServers = useMemo( () => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)), [activedMcpServers, mcpServers] ) + + const handleModeChange = useCallback( + (mode: McpMode) => { + setTimeoutTimer( + 'updateMcpMode', + () => { + updateAssistant({ + ...assistant, + mcpMode: mode + }) + }, + 200 + ) + }, + [assistant, setTimeoutTimer, updateAssistant] + ) + const handleMcpServerSelect = useCallback( (server: MCPServer) => { const update = { ...assistant } @@ -144,29 +156,24 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, update.mcpServers = [...mcpServers, server] } - // only for gemini if (update.mcpServers.length > 0 && isGeminiModel(model) && isToolUseModeFunction(assistant)) { const provider = getProviderByModel(model) if (isSupportUrlContextProvider(provider) && assistant.enableUrlContext) { window.toast.warning(t('chat.mcp.warning.url_context')) update.enableUrlContext = false } - if ( - // 非官方 API (openrouter etc.) 可能支持同时启用内置搜索和函数调用 - // 这里先假设 gemini type 和 vertexai type 不支持 - isGeminiWebSearchProvider(provider) && - assistant.enableWebSearch - ) { + if (isGeminiWebSearchProvider(provider) && assistant.enableWebSearch) { window.toast.warning(t('chat.mcp.warning.gemini_web_search')) update.enableWebSearch = false } } + + update.mcpMode = 'manual' updateAssistant(update) }, [assistant, assistantMcpServers, mcpServers, model, t, updateAssistant] ) - // 使用 useRef 缓存事件处理函数 const handleMcpServerSelectRef = useRef(handleMcpServerSelect) handleMcpServerSelectRef.current = handleMcpServerSelect @@ -176,23 +183,7 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, return () => EventEmitter.off('mcp-server-select', handler) }, []) - const updateMcpEnabled = useCallback( - (enabled: boolean) => { - setTimeoutTimer( - 'updateMcpEnabled', - () => { - updateAssistant({ - ...assistant, - mcpServers: enabled ? assistant.mcpServers || [] : [] - }) - }, - 200 - ) - }, - [assistant, setTimeoutTimer, updateAssistant] - ) - - const menuItems = useMemo(() => { + const manualModeMenuItems = useMemo(() => { const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({ label: server.name, description: server.description || server.baseUrl, @@ -207,33 +198,70 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, action: () => navigate('/settings/mcp') }) - newList.unshift({ - label: t('settings.input.clear.all'), - description: t('settings.mcp.disable.description'), - icon: , - isSelected: false, - action: () => { - updateMcpEnabled(false) - quickPanelHook.close() - } - }) - return newList - }, [activedMcpServers, t, assistantMcpServers, navigate, updateMcpEnabled, quickPanelHook]) + }, [activedMcpServers, t, assistantMcpServers, navigate]) - const openQuickPanel = useCallback(() => { + const openManualModePanel = useCallback(() => { quickPanelHook.open({ - title: t('settings.mcp.title'), - list: menuItems, + title: t('assistants.settings.mcp.mode.manual.label'), + list: manualModeMenuItems, symbol: QuickPanelReservedSymbol.Mcp, multiple: true, afterAction({ item }) { item.isSelected = !item.isSelected } }) + }, [manualModeMenuItems, quickPanelHook, t]) + + const menuItems = useMemo(() => { + const newList: QuickPanelListItem[] = [] + + newList.push({ + label: t('assistants.settings.mcp.mode.disabled.label'), + description: t('assistants.settings.mcp.mode.disabled.description'), + icon: , + isSelected: currentMode === 'disabled', + action: () => { + handleModeChange('disabled') + quickPanelHook.close() + } + }) + + newList.push({ + label: t('assistants.settings.mcp.mode.auto.label'), + description: t('assistants.settings.mcp.mode.auto.description'), + icon: , + isSelected: currentMode === 'auto', + action: () => { + handleModeChange('auto') + quickPanelHook.close() + } + }) + + newList.push({ + label: t('assistants.settings.mcp.mode.manual.label'), + description: t('assistants.settings.mcp.mode.manual.description'), + icon: , + isSelected: currentMode === 'manual', + isMenu: true, + action: () => { + handleModeChange('manual') + openManualModePanel() + } + }) + + return newList + }, [t, currentMode, handleModeChange, quickPanelHook, openManualModePanel]) + + const openQuickPanel = useCallback(() => { + quickPanelHook.open({ + title: t('settings.mcp.title'), + list: menuItems, + symbol: QuickPanelReservedSymbol.Mcp, + multiple: false + }) }, [menuItems, quickPanelHook, t]) - // 使用 useCallback 优化 insertPromptIntoTextArea const insertPromptIntoTextArea = useCallback( (promptText: string) => { setInputValue((prev) => { @@ -245,7 +273,6 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, const selectionEndPosition = cursorPosition + promptText.length const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) - // 使用 requestAnimationFrame 优化 DOM 操作 requestAnimationFrame(() => { textArea.focus() textArea.setSelectionRange(selectionStart, selectionEndPosition) @@ -424,7 +451,6 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, [activedMcpServers, t, insertPromptIntoTextArea] ) - // 优化 resourcesList 的状态更新 const [resourcesList, setResourcesList] = useState([]) useEffect(() => { @@ -514,17 +540,26 @@ const MCPToolsButton: FC = ({ quickPanel, setInputValue, resizeTextArea, } }, [openPromptList, openQuickPanel, openResourcesList, quickPanel, t]) + const isActive = currentMode !== 'disabled' + + const getButtonIcon = () => { + switch (currentMode) { + case 'auto': + return + case 'disabled': + case 'manual': + default: + return + } + } + return ( - 0} - aria-label={t('settings.mcp.title')}> - + + {getButtonIcon()} ) } -// 使用 React.memo 包装组件 export default React.memo(MCPToolsButton) diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx index 2c2b5077a7..aa8aac3bf7 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/UrlContextbutton.tsx @@ -1,6 +1,7 @@ import { ActionIconButton } from '@renderer/components/Buttons' import { useAssistant } from '@renderer/hooks/useAssistant' import { useTimer } from '@renderer/hooks/useTimer' +import { getEffectiveMcpMode } from '@renderer/types' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { Tooltip } from 'antd' import { Link } from 'lucide-react' @@ -30,8 +31,7 @@ const UrlContextButton: FC = ({ assistantId }) => { () => { const update = { ...assistant } if ( - assistant.mcpServers && - assistant.mcpServers.length > 0 && + getEffectiveMcpMode(assistant) !== 'disabled' && urlContentNewState === true && isToolUseModeFunction(assistant) ) { diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx index 5728887af8..262b1f0492 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/WebSearchQuickPanelManager.tsx @@ -16,7 +16,7 @@ import { useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' import type { ToolQuickPanelController, ToolRenderContext } from '@renderer/pages/home/Inputbar/types' import { getProviderByModel } from '@renderer/services/AssistantService' import WebSearchService from '@renderer/services/WebSearchService' -import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types' +import { getEffectiveMcpMode, type WebSearchProvider, type WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { isPromptToolUse } from '@renderer/utils/mcp-tools' @@ -108,8 +108,7 @@ export const useWebSearchPanelController = (assistantId: string, quickPanelContr isGeminiModel(model) && isToolUseModeFunction(assistant) && update.enableWebSearch && - assistant.mcpServers && - assistant.mcpServers.length > 0 + getEffectiveMcpMode(assistant) !== 'disabled' ) { update.enableWebSearch = false window.toast.warning(t('chat.mcp.warning.gemini_web_search')) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx index ac89141092..1c243130dc 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantMCPSettings.tsx @@ -1,8 +1,9 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { Box } from '@renderer/components/Layout' import { useMCPServers } from '@renderer/hooks/useMCPServers' -import type { Assistant, AssistantSettings } from '@renderer/types' -import { Empty, Switch, Tooltip } from 'antd' +import type { Assistant, AssistantSettings, McpMode } from '@renderer/types' +import { getEffectiveMcpMode } from '@renderer/types' +import { Empty, Radio, Switch, Tooltip } from 'antd' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -27,22 +28,26 @@ const AssistantMCPSettings: React.FC = ({ assistant, updateAssistant }) = const { t } = useTranslation() const { mcpServers: allMcpServers } = useMCPServers() + const currentMode = getEffectiveMcpMode(assistant) + + const handleModeChange = (mode: McpMode) => { + updateAssistant({ ...assistant, mcpMode: mode }) + } + const onUpdate = (ids: string[]) => { const mcpServers = ids .map((id) => allMcpServers.find((server) => server.id === id)) .filter((server): server is MCPServer => server !== undefined && server.isActive) - updateAssistant({ ...assistant, mcpServers }) + updateAssistant({ ...assistant, mcpServers, mcpMode: 'manual' }) } const handleServerToggle = (serverId: string) => { const currentServerIds = assistant.mcpServers?.map((server) => server.id) || [] if (currentServerIds.includes(serverId)) { - // Remove server if it's already enabled onUpdate(currentServerIds.filter((id) => id !== serverId)) } else { - // Add server if it's not enabled onUpdate([...currentServerIds, serverId]) } } @@ -58,49 +63,77 @@ const AssistantMCPSettings: React.FC = ({ assistant, updateAssistant }) = - {allMcpServers.length > 0 && ( - - {enabledCount} / {allMcpServers.length} {t('settings.mcp.active')} - - )} - {allMcpServers.length > 0 ? ( - - {allMcpServers.map((server) => { - const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false + + handleModeChange(e.target.value)}> + + + {t('assistants.settings.mcp.mode.disabled.label')} + {t('assistants.settings.mcp.mode.disabled.description')} + + + + + {t('assistants.settings.mcp.mode.auto.label')} + {t('assistants.settings.mcp.mode.auto.description')} + + + + + {t('assistants.settings.mcp.mode.manual.label')} + {t('assistants.settings.mcp.mode.manual.description')} + + + + - return ( - - - {server.name} - {server.description && {server.description}} - {server.baseUrl && {server.baseUrl}} - - - handleServerToggle(server.id)} - size="small" - /> - - - ) - })} - - ) : ( - - - + {currentMode === 'manual' && ( + <> + {allMcpServers.length > 0 && ( + + {enabledCount} / {allMcpServers.length} {t('settings.mcp.active')} + + )} + + {allMcpServers.length > 0 ? ( + + {allMcpServers.map((server) => { + const isEnabled = assistant.mcpServers?.some((s) => s.id === server.id) || false + + return ( + + + {server.name} + {server.description && {server.description}} + {server.baseUrl && {server.baseUrl}} + + + handleServerToggle(server.id)} + size="small" + /> + + + ) + })} + + ) : ( + + + + )} + )} ) @@ -110,7 +143,7 @@ const Container = styled.div` display: flex; flex: 1; flex-direction: column; - overflow: hidden; + min-height: 0; ` const HeaderContainer = styled.div` @@ -127,9 +160,54 @@ const InfoIcon = styled(InfoCircleOutlined)` cursor: help; ` +const ModeSelector = styled.div` + margin-bottom: 16px; + + .ant-radio-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .ant-radio-button-wrapper { + height: auto; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid var(--color-border); + + &:not(:first-child)::before { + display: none; + } + + &:first-child { + border-radius: 8px; + } + + &:last-child { + border-radius: 8px; + } + } +` + +const ModeOption = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +` + +const ModeLabel = styled.span` + font-weight: 600; +` + +const ModeDescription = styled.span` + font-size: 12px; + color: var(--color-text-2); +` + const EnabledCount = styled.span` font-size: 12px; color: var(--color-text-2); + margin-bottom: 8px; ` const EmptyContainer = styled.div` diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 89ebe6ecd3..0975fc5f29 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -8,8 +8,9 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod import { getStoreSetting } from '@renderer/hooks/useSettings' import i18n from '@renderer/i18n' import store from '@renderer/store' +import { hubMCPServer } from '@renderer/store/mcp' import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types' -import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types' +import { type FetchChatCompletionParams, getEffectiveMcpMode, isSystemProvider } from '@renderer/types' import type { StreamTextParams } from '@renderer/types/aiCoreTypes' import { type Chunk, ChunkType } from '@renderer/types/chunk' import type { Message, ResponseError } from '@renderer/types/newMessage' @@ -51,14 +52,60 @@ import type { StreamProcessorCallbacks } from './StreamProcessingService' const logger = loggerService.withContext('ApiService') -export async function fetchMcpTools(assistant: Assistant) { - // Get MCP tools (Fix duplicate declaration) - let mcpTools: MCPTool[] = [] // Initialize as empty array +/** + * Get the MCP servers to use based on the assistant's MCP mode. + */ +export function getMcpServersForAssistant(assistant: Assistant): MCPServer[] { + const mode = getEffectiveMcpMode(assistant) const allMcpServers = store.getState().mcp.servers || [] const activedMcpServers = allMcpServers.filter((s) => s.isActive) - const assistantMcpServers = assistant.mcpServers || [] - const enabledMCPs = activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id)) + switch (mode) { + case 'disabled': + return [] + case 'auto': + return [hubMCPServer] + case 'manual': { + const assistantMcpServers = assistant.mcpServers || [] + return activedMcpServers.filter((server) => assistantMcpServers.some((s) => s.id === server.id)) + } + default: + return [] + } +} + +export async function fetchAllActiveServerTools(): Promise { + const allMcpServers = store.getState().mcp.servers || [] + const activedMcpServers = allMcpServers.filter((s) => s.isActive) + + if (activedMcpServers.length === 0) { + return [] + } + + try { + const toolPromises = activedMcpServers.map(async (mcpServer: MCPServer) => { + try { + const tools = await window.api.mcp.listTools(mcpServer) + return tools.filter((tool: any) => !mcpServer.disabledTools?.includes(tool.name)) + } catch (error) { + logger.error(`Error fetching tools from MCP server ${mcpServer.name}:`, error as Error) + return [] + } + }) + const results = await Promise.allSettled(toolPromises) + return results + .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') + .map((result) => result.value) + .flat() + } catch (toolError) { + logger.error('Error fetching all active server tools:', toolError as Error) + return [] + } +} + +export async function fetchMcpTools(assistant: Assistant) { + let mcpTools: MCPTool[] = [] + const enabledMCPs = getMcpServersForAssistant(assistant) if (enabledMCPs && enabledMCPs.length > 0) { try { @@ -198,6 +245,7 @@ export async function fetchChatCompletion({ const usePromptToolUse = isPromptToolUse(assistant) || (isToolUseModeFunction(assistant) && !isFunctionCallingModel(assistant.model)) + const mcpMode = getEffectiveMcpMode(assistant) const middlewareConfig: AiSdkMiddlewareConfig = { streamOutput: assistant.settings?.streamOutput ?? true, onChunk: onChunkReceived, @@ -210,6 +258,7 @@ export async function fetchChatCompletion({ enableWebSearch: capabilities.enableWebSearch, enableGenerateImage: capabilities.enableGenerateImage, enableUrlContext: capabilities.enableUrlContext, + mcpMode, mcpTools, uiMessages, knowledgeRecognition: assistant.knowledgeRecognition diff --git a/src/renderer/src/services/__tests__/mcpMode.test.ts b/src/renderer/src/services/__tests__/mcpMode.test.ts new file mode 100644 index 0000000000..9117caa666 --- /dev/null +++ b/src/renderer/src/services/__tests__/mcpMode.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import type { Assistant, MCPServer } from '../../types' +import { getEffectiveMcpMode } from '../../types' + +describe('getEffectiveMcpMode', () => { + it('should return mcpMode when explicitly set to auto', () => { + const assistant: Partial = { mcpMode: 'auto' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto') + }) + + it('should return disabled when mcpMode is explicitly disabled', () => { + const assistant: Partial = { mcpMode: 'disabled' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return manual when mcpMode is explicitly manual', () => { + const assistant: Partial = { mcpMode: 'manual' } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual') + }) + + it('should return manual when no mcpMode but mcpServers has items (backward compatibility)', () => { + const assistant: Partial = { + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('manual') + }) + + it('should return disabled when no mcpMode and no mcpServers (backward compatibility)', () => { + const assistant: Partial = {} + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return disabled when no mcpMode and empty mcpServers (backward compatibility)', () => { + const assistant: Partial = { mcpServers: [] } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should prioritize explicit mcpMode over mcpServers presence', () => { + const assistant: Partial = { + mcpMode: 'disabled', + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('disabled') + }) + + it('should return auto when mcpMode is auto regardless of mcpServers', () => { + const assistant: Partial = { + mcpMode: 'auto', + mcpServers: [{ id: 'test', name: 'Test Server', isActive: true }] as MCPServer[] + } + expect(getEffectiveMcpMode(assistant as Assistant)).toBe('auto') + }) +}) diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 3b94248401..0e2028a2b8 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -86,6 +86,28 @@ export { mcpSlice } // Export the reducer as default export export default mcpSlice.reducer +/** + * Hub MCP server for auto mode - aggregates all MCP servers for LLM code mode. + * This server is injected automatically when mcpMode === 'auto'. + */ +export const hubMCPServer: BuiltinMCPServer = { + id: 'hub', + name: BuiltinMCPServerNames.hub, + type: 'inMemory', + isActive: true, + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true +} + +/** + * User-installable built-in MCP servers shown in the UI. + * + * Note: The `hub` server (@cherry/hub) is intentionally excluded because: + * - It's a meta-server that aggregates all other MCP servers + * - It's designed for LLM code mode, not direct user interaction + * - It should be auto-enabled internally when needed, not manually installed + */ export const builtinMCPServers: BuiltinMCPServer[] = [ { id: nanoid(), diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 388b61d248..9719897525 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -27,6 +27,8 @@ export * from './ocr' export * from './plugin' export * from './provider' +export type McpMode = 'disabled' | 'auto' | 'manual' + export type Assistant = { id: string name: string @@ -47,6 +49,8 @@ export type Assistant = { // enableUrlContext 是 Gemini/Anthropic 的特有功能 enableUrlContext?: boolean enableGenerateImage?: boolean + /** MCP mode: 'disabled' (no MCP), 'auto' (hub server only), 'manual' (user selects servers) */ + mcpMode?: McpMode mcpServers?: MCPServer[] knowledgeRecognition?: 'off' | 'on' regularPhrases?: QuickPhrase[] // Added for regular phrase @@ -57,6 +61,15 @@ export type Assistant = { targetLanguage?: TranslateLanguage } +/** + * Get the effective MCP mode for an assistant with backward compatibility. + * Legacy assistants without mcpMode default based on mcpServers presence. + */ +export function getEffectiveMcpMode(assistant: Assistant): McpMode { + if (assistant.mcpMode) return assistant.mcpMode + return (assistant.mcpServers?.length ?? 0) > 0 ? 'manual' : 'disabled' +} + export type TranslateAssistant = Assistant & { model: Model content: string @@ -757,7 +770,8 @@ export const BuiltinMCPServerNames = { python: '@cherry/python', didiMCP: '@cherry/didi-mcp', browser: '@cherry/browser', - nowledgeMem: '@cherry/nowledge-mem' + nowledgeMem: '@cherry/nowledge-mem', + hub: '@cherry/hub' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames] diff --git a/src/renderer/src/utils/mcp-tools.ts b/src/renderer/src/utils/mcp-tools.ts index 691689dcc4..3bc4a273cb 100644 --- a/src/renderer/src/utils/mcp-tools.ts +++ b/src/renderer/src/utils/mcp-tools.ts @@ -13,7 +13,7 @@ import { isFunctionCallingModel, isVisionModel } from '@renderer/config/models' import i18n from '@renderer/i18n' import { currentSpan } from '@renderer/services/SpanManagerService' import store from '@renderer/store' -import { addMCPServer } from '@renderer/store/mcp' +import { addMCPServer, hubMCPServer } from '@renderer/store/mcp' import type { Assistant, MCPCallToolResponse, @@ -325,7 +325,16 @@ export function filterMCPTools( export function getMcpServerByTool(tool: MCPTool) { const servers = store.getState().mcp.servers - return servers.find((s) => s.id === tool.serverId) + const server = servers.find((s) => s.id === tool.serverId) + if (server) { + return server + } + // For hub server (auto mode), the server isn't in the store + // Return the hub server constant if the tool's serverId matches + if (tool.serverId === 'hub') { + return hubMCPServer + } + return undefined } export function isToolAutoApproved(tool: MCPTool, server?: MCPServer): boolean { diff --git a/tests/main.setup.ts b/tests/main.setup.ts index 5cadb89d02..9d6731e4a7 100644 --- a/tests/main.setup.ts +++ b/tests/main.setup.ts @@ -10,59 +10,69 @@ vi.mock('@logger', async () => { }) // Mock electron modules that are commonly used in main process -vi.mock('electron', () => ({ - app: { - getPath: vi.fn((key: string) => { - switch (key) { - case 'userData': - return '/mock/userData' - case 'temp': - return '/mock/temp' - case 'logs': - return '/mock/logs' - default: - return '/mock/unknown' +vi.mock('electron', () => { + const mock = { + app: { + getPath: vi.fn((key: string) => { + switch (key) { + case 'userData': + return '/mock/userData' + case 'temp': + return '/mock/temp' + case 'logs': + return '/mock/logs' + default: + return '/mock/unknown' + } + }), + getVersion: vi.fn(() => '1.0.0') + }, + ipcMain: { + handle: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeHandler: vi.fn(), + removeAllListeners: vi.fn() + }, + BrowserWindow: vi.fn(), + dialog: { + showErrorBox: vi.fn(), + showMessageBox: vi.fn(), + showOpenDialog: vi.fn(), + showSaveDialog: vi.fn() + }, + shell: { + openExternal: vi.fn(), + showItemInFolder: vi.fn() + }, + session: { + defaultSession: { + clearCache: vi.fn(), + clearStorageData: vi.fn() } - }), - getVersion: vi.fn(() => '1.0.0') - }, - ipcMain: { - handle: vi.fn(), - on: vi.fn(), - once: vi.fn(), - removeHandler: vi.fn(), - removeAllListeners: vi.fn() - }, - BrowserWindow: vi.fn(), - dialog: { - showErrorBox: vi.fn(), - showMessageBox: vi.fn(), - showOpenDialog: vi.fn(), - showSaveDialog: vi.fn() - }, - shell: { - openExternal: vi.fn(), - showItemInFolder: vi.fn() - }, - session: { - defaultSession: { - clearCache: vi.fn(), - clearStorageData: vi.fn() - } - }, - webContents: { - getAllWebContents: vi.fn(() => []) - }, - systemPreferences: { - getMediaAccessStatus: vi.fn(), - askForMediaAccess: vi.fn() - }, - screen: { - getPrimaryDisplay: vi.fn(), - getAllDisplays: vi.fn() - }, - Notification: vi.fn() -})) + }, + webContents: { + getAllWebContents: vi.fn(() => []) + }, + systemPreferences: { + getMediaAccessStatus: vi.fn(), + askForMediaAccess: vi.fn() + }, + nativeTheme: { + themeSource: 'system', + shouldUseDarkColors: false, + on: vi.fn(), + removeListener: vi.fn() + }, + screen: { + getPrimaryDisplay: vi.fn(), + getAllDisplays: vi.fn() + }, + Notification: vi.fn() + } + + return { __esModule: true, ...mock, default: mock } +}) // Mock Winston for LoggerService dependencies vi.mock('winston', () => ({ @@ -98,13 +108,17 @@ vi.mock('winston-daily-rotate-file', () => { }) // Mock Node.js modules -vi.mock('node:os', () => ({ - platform: vi.fn(() => 'darwin'), - arch: vi.fn(() => 'x64'), - version: vi.fn(() => '20.0.0'), - cpus: vi.fn(() => [{ model: 'Mock CPU' }]), - totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB -})) +vi.mock('node:os', () => { + const mock = { + platform: vi.fn(() => 'darwin'), + arch: vi.fn(() => 'x64'), + version: vi.fn(() => '20.0.0'), + cpus: vi.fn(() => [{ model: 'Mock CPU' }]), + homedir: vi.fn(() => '/mock/home'), + totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB + } + return { ...mock, default: mock } +}) vi.mock('node:path', async () => { const actual = await vi.importActual('node:path') @@ -115,25 +129,29 @@ vi.mock('node:path', async () => { } }) -vi.mock('node:fs', () => ({ - promises: { - access: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - mkdir: vi.fn(), - readdir: vi.fn(), - stat: vi.fn(), - unlink: vi.fn(), - rmdir: vi.fn() - }, - existsSync: vi.fn(), - readFileSync: vi.fn(), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - readdirSync: vi.fn(), - statSync: vi.fn(), - unlinkSync: vi.fn(), - rmdirSync: vi.fn(), - createReadStream: vi.fn(), - createWriteStream: vi.fn() -})) +vi.mock('node:fs', () => { + const mock = { + promises: { + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + unlink: vi.fn(), + rmdir: vi.fn() + }, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + statSync: vi.fn(), + unlinkSync: vi.fn(), + rmdirSync: vi.fn(), + createReadStream: vi.fn(), + createWriteStream: vi.fn() + } + + return { ...mock, default: mock } +}) From 2777af77d859b12653b60a0375e7a4cfd0a7b1b3 Mon Sep 17 00:00:00 2001 From: Zhaolin Liang Date: Wed, 7 Jan 2026 16:45:15 +0800 Subject: [PATCH 13/20] fix: paragraph handle and plus button not selectable (#12320) --- src/renderer/src/components/RichEditor/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/RichEditor/index.tsx b/src/renderer/src/components/RichEditor/index.tsx index 793ccda1ae..30d12f2ad0 100644 --- a/src/renderer/src/components/RichEditor/index.tsx +++ b/src/renderer/src/components/RichEditor/index.tsx @@ -580,7 +580,7 @@ const RichEditor = ({ - + {enableContentSearch && ( From d0a1512f2342524b16326e14b92f2dcf7fe38234 Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 7 Jan 2026 16:51:25 +0800 Subject: [PATCH 14/20] fix: optimize action component state management to prevent duplicate loading spinners (#12318) * refactor: separate message extraction from rendering Extract `lastAssistantMessage` memoization separately from rendering `MessageContent` component, improving code clarity and separation of concerns. * feat: Replace manual loading state with AssistantMessageStatus tracking * refactor: Replace loading state with status enum in translation action - Add LoadingOutlined icon for preparing state - Remove AssistantMessageStatus dependency - Simplify streaming detection using local status state * feat: Add logging and status sync for translation action * feat: Refactor action component state management to be consistent with translate action Replace separate `isContented` and `isLoading` states with a single `status` state that tracks 'preparing', 'streaming', and 'finished' phases. Sync status with assistant message status and update footer loading prop accordingly. * fix: Add missing pauseTrace import to ActionTranslate component * fix: Add missing break statements in assistant message status handling * fix: Move pauseTrace call inside abort completion condition --- src/renderer/src/store/thunk/messageThunk.ts | 1 + .../action/components/ActionGeneral.tsx | 62 +++++++++++++---- .../action/components/ActionTranslate.tsx | 69 ++++++++++++++----- 3 files changed, 101 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index aaa2ffc2c4..39e44c2efb 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -823,6 +823,7 @@ const fetchAndProcessAssistantResponseImpl = async ( const streamProcessorCallbacks = createStreamProcessor(callbacks) const abortController = new AbortController() + logger.silly('Add Abort Controller', { id: userMessageId }) addAbortController(userMessageId!, () => abortController.abort()) await transformMessagesAndFetch( diff --git a/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx b/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx index 9dd2fbc4f5..4d8f09b346 100644 --- a/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionGeneral.tsx @@ -12,6 +12,7 @@ import { } from '@renderer/services/AssistantService' import { pauseTrace } from '@renderer/services/SpanManagerService' import type { Assistant, Topic } from '@renderer/types' +import { AssistantMessageStatus } from '@renderer/types/newMessage' import type { ActionItem } from '@renderer/types/selectionTypes' import { abortCompletion } from '@renderer/utils/abortController' import { ChevronDown } from 'lucide-react' @@ -34,8 +35,7 @@ const ActionGeneral: FC = React.memo(({ action, scrollToBottom }) => { const { language } = useSettings() const [error, setError] = useState(null) const [showOriginal, setShowOriginal] = useState(false) - const [isContented, setIsContented] = useState(false) - const [isLoading, setIsLoading] = useState(true) + const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing') const [contentToCopy, setContentToCopy] = useState('') const initialized = useRef(false) @@ -96,19 +96,24 @@ const ActionGeneral: FC = React.memo(({ action, scrollToBottom }) => { }, [action, language]) const fetchResult = useCallback(() => { + if (!initialized.current) { + return + } + setStatus('preparing') + const setAskId = (id: string) => { askId.current = id } const onStream = () => { - setIsContented(true) + setStatus('streaming') scrollToBottom?.() } const onFinish = (content: string) => { + setStatus('finished') setContentToCopy(content) - setIsLoading(false) } const onError = (error: Error) => { - setIsLoading(false) + setStatus('finished') setError(error.message) } @@ -131,17 +136,40 @@ const ActionGeneral: FC = React.memo(({ action, scrollToBottom }) => { const allMessages = useTopicMessages(topicRef.current?.id || '') - // Memoize the messages to prevent unnecessary re-renders - const messageContent = useMemo(() => { + const currentAssistantMessage = useMemo(() => { const assistantMessages = allMessages.filter((message) => message.role === 'assistant') - const lastAssistantMessage = assistantMessages[assistantMessages.length - 1] - return lastAssistantMessage ? : null + if (assistantMessages.length === 0) { + return null + } + return assistantMessages[assistantMessages.length - 1] }, [allMessages]) + useEffect(() => { + // Sync message status + switch (currentAssistantMessage?.status) { + case AssistantMessageStatus.PROCESSING: + case AssistantMessageStatus.PENDING: + case AssistantMessageStatus.SEARCHING: + setStatus('streaming') + break + case AssistantMessageStatus.PAUSED: + case AssistantMessageStatus.ERROR: + case AssistantMessageStatus.SUCCESS: + setStatus('finished') + break + case undefined: + break + default: + logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status }) + } + }, [currentAssistantMessage?.status]) + + const isPreparing = status === 'preparing' + const isStreaming = status === 'streaming' + const handlePause = () => { if (askId.current) { abortCompletion(askId.current) - setIsLoading(false) } if (topicRef.current?.id) { pauseTrace(topicRef.current.id) @@ -150,7 +178,6 @@ const ActionGeneral: FC = React.memo(({ action, scrollToBottom }) => { const handleRegenerate = () => { setContentToCopy('') - setIsLoading(true) fetchResult() } @@ -178,13 +205,20 @@ const ActionGeneral: FC = React.memo(({ action, scrollToBottom }) => { )} - {!isContented && isLoading && } - {messageContent} + {isPreparing && } + {!isPreparing && currentAssistantMessage && ( + + )} {error && {error}} - + ) }) diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index 3ac80a014c..a5ce31bab7 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -9,7 +9,9 @@ import { useSettings } from '@renderer/hooks/useSettings' import useTranslate from '@renderer/hooks/useTranslate' import MessageContent from '@renderer/pages/home/Messages/MessageContent' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { pauseTrace } from '@renderer/services/SpanManagerService' import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types' +import { AssistantMessageStatus } from '@renderer/types/newMessage' import type { ActionItem } from '@renderer/types/selectionTypes' import { abortCompletion } from '@renderer/utils/abortController' import { detectLanguage } from '@renderer/utils/translate' @@ -48,8 +50,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const [error, setError] = useState('') const [showOriginal, setShowOriginal] = useState(false) - const [isContented, setIsContented] = useState(false) - const [isLoading, setIsLoading] = useState(true) + const [status, setStatus] = useState<'preparing' | 'streaming' | 'finished'>('preparing') const [contentToCopy, setContentToCopy] = useState('') const [initialized, setInitialized] = useState(false) @@ -106,6 +107,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { // Initialize language pair. // It will update targetLangRef, so we could get latest target language in the following code await updateLanguagePair() + logger.silly('[initialize] UpdateLanguagePair completed.') // Initialize assistant const currentAssistant = getDefaultTranslateAssistant(targetLangRef.current, action.selectedText) @@ -132,20 +134,18 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { askId.current = id } const onStream = () => { - setIsContented(true) + setStatus('streaming') scrollToBottom?.() } const onFinish = (content: string) => { + setStatus('finished') setContentToCopy(content) - setIsLoading(false) } const onError = (error: Error) => { - setIsLoading(false) + setStatus('finished') setError(error.message) } - setIsLoading(true) - let sourceLanguageCode: TranslateLanguageCode try { @@ -182,12 +182,37 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const allMessages = useTopicMessages(topicRef.current?.id || '') - const messageContent = useMemo(() => { + const currentAssistantMessage = useMemo(() => { const assistantMessages = allMessages.filter((message) => message.role === 'assistant') - const lastAssistantMessage = assistantMessages[assistantMessages.length - 1] - return lastAssistantMessage ? : null + if (assistantMessages.length === 0) { + return null + } + return assistantMessages[assistantMessages.length - 1] }, [allMessages]) + useEffect(() => { + // Sync message status + switch (currentAssistantMessage?.status) { + case AssistantMessageStatus.PROCESSING: + case AssistantMessageStatus.PENDING: + case AssistantMessageStatus.SEARCHING: + setStatus('streaming') + break + case AssistantMessageStatus.PAUSED: + case AssistantMessageStatus.ERROR: + case AssistantMessageStatus.SUCCESS: + setStatus('finished') + break + case undefined: + break + default: + logger.warn('Unexpected assistant message status:', { status: currentAssistantMessage?.status }) + } + }, [currentAssistantMessage?.status]) + + const isPreparing = status === 'preparing' + const isStreaming = status === 'streaming' + const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { if (!initialized) { return @@ -200,15 +225,18 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { } const handlePause = () => { + // FIXME: It doesn't work because abort signal is not set. + logger.silly('Try to pause: ', { id: askId.current }) if (askId.current) { abortCompletion(askId.current) - setIsLoading(false) + } + if (topicRef.current?.id) { + pauseTrace(topicRef.current.id) } } const handleRegenerate = () => { setContentToCopy('') - setIsLoading(true) fetchResult() } @@ -228,7 +256,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { title={t('translate.target_language')} optionFilterProp="label" onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} - disabled={isLoading} + disabled={isStreaming} /> @@ -240,7 +268,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { title={t('translate.alter_language')} optionFilterProp="label" onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))} - disabled={isLoading} + disabled={isStreaming} /> @@ -267,13 +295,20 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { )} - {!isContented && isLoading && } - {messageContent} + {isPreparing && } + {!isPreparing && currentAssistantMessage && ( + + )} {error && {error}} - + ) } From 040f4daa98b1577312e84cc27b484621b91690af Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 7 Jan 2026 17:11:41 +0800 Subject: [PATCH 15/20] fix: enable reasoning cot bug (#12342) --- .../src/aiCore/middleware/AiSdkMiddlewareBuilder.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 0d27390370..247dc8e5c8 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -183,13 +183,12 @@ function addProviderSpecificMiddlewares(builder: AiSdkMiddlewareBuilder, config: break case 'openai': case 'azure-openai': { - if (config.enableReasoning) { - const tagName = getReasoningTagName(config.model?.id.toLowerCase()) - builder.add({ - name: 'thinking-tag-extraction', - middleware: extractReasoningMiddleware({ tagName }) - }) - } + // 就算这里不传参数也有可能调用推理 + const tagName = getReasoningTagName(config.model?.id.toLowerCase()) + builder.add({ + name: 'thinking-tag-extraction', + middleware: extractReasoningMiddleware({ tagName }) + }) break } case 'gemini': From b83fbc0acec44da14ab87385aa1779840470a17f Mon Sep 17 00:00:00 2001 From: Le Bao <77217928+TacKana@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:23:17 +0800 Subject: [PATCH 16/20] fix(SearchService): Fix inability to retrieve search results from Bing, Baidu, and Google This commit fixes a bug where search results could not be retrieved from Bing, Baidu, and Google. The root cause of this issue was a discrepancy in page content when the Electron window was hidden versus when it was visible. Additionally, the previous use of `did-finish-load` caused page jitter within the window, leading to sporadic failures in fetching search content. To resolve this, I've enabled offscreen rendering, ensuring consistent page content regardless of window visibility. Furthermore, I've switched to using the `ready-to-show` event to ensure the complete page DOM is available before attempting to retrieve content, thereby eliminating the search bug. * feat(fetch): add request throttling (already present in the original, keeping it) Co-authored-by: suyao --- src/main/services/SearchService.ts | 10 +++-- .../src/utils/__tests__/fetch.test.ts | 41 +++++++++++++++++++ src/renderer/src/utils/fetch.ts | 39 ++++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 6c69f80889..03a154dcd4 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -22,7 +22,8 @@ export class SearchService { webPreferences: { nodeIntegration: true, contextIsolation: false, - devTools: is.dev + devTools: is.dev, + offscreen: true // 启用离屏渲染 } }) @@ -68,7 +69,8 @@ export class SearchService { // Wait for the page to fully load before getting the content await new Promise((resolve) => { const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout - window.webContents.once('did-finish-load', () => { + window.once('ready-to-show', () => { + //让网页加载完成后执行,原来的.webContents.once('did-finish-load'会导致网页抖动 clearTimeout(loadTimeout) // Small delay to ensure JavaScript has executed setTimeout(resolve, 500) @@ -76,7 +78,9 @@ export class SearchService { }) // Get the page content after ensuring it's fully loaded - return await window.webContents.executeJavaScript('document.documentElement.outerHTML') + const executeJavaScript = await window.webContents.executeJavaScript('document.documentElement.outerHTML') + // logger.info(executeJavaScript) + return executeJavaScript } } diff --git a/src/renderer/src/utils/__tests__/fetch.test.ts b/src/renderer/src/utils/__tests__/fetch.test.ts index 6b36cb41f8..38f51fcb42 100644 --- a/src/renderer/src/utils/__tests__/fetch.test.ts +++ b/src/renderer/src/utils/__tests__/fetch.test.ts @@ -181,6 +181,47 @@ describe('fetch', () => { consoleSpy.mockRestore() }) + + it('should throttle requests to the same domain', async () => { + const fetchCallTimes: number[] = [] + vi.mocked(global.fetch).mockImplementation(async () => { + fetchCallTimes.push(Date.now()) + return createMockResponse() + }) + + // 3 URLs from the same domain + const urls = ['https://zhihu.com/a', 'https://zhihu.com/b', 'https://zhihu.com/c'] + await fetchWebContents(urls) + + expect(fetchCallTimes).toHaveLength(3) + // Verify that requests are spaced out (at least 400ms apart due to 500ms interval) + if (fetchCallTimes.length >= 2) { + const timeDiff1 = fetchCallTimes[1] - fetchCallTimes[0] + expect(timeDiff1).toBeGreaterThanOrEqual(400) + } + if (fetchCallTimes.length >= 3) { + const timeDiff2 = fetchCallTimes[2] - fetchCallTimes[1] + expect(timeDiff2).toBeGreaterThanOrEqual(400) + } + }) + + it('should allow parallel requests to different domains', async () => { + const fetchCallTimes: Map = new Map() + vi.mocked(global.fetch).mockImplementation(async (url) => { + fetchCallTimes.set(url as string, Date.now()) + return createMockResponse() + }) + + // URLs from different domains + const urls = ['https://zhihu.com/a', 'https://douban.com/b', 'https://github.com/c'] + await fetchWebContents(urls) + + expect(fetchCallTimes.size).toBe(3) + // Different domains should start nearly simultaneously (within 100ms) + const times = Array.from(fetchCallTimes.values()) + const maxDiff = Math.max(...times) - Math.min(...times) + expect(maxDiff).toBeLessThan(100) + }) }) describe('fetchRedirectUrl', () => { diff --git a/src/renderer/src/utils/fetch.ts b/src/renderer/src/utils/fetch.ts index 52c91c0896..c9da595cd9 100644 --- a/src/renderer/src/utils/fetch.ts +++ b/src/renderer/src/utils/fetch.ts @@ -4,6 +4,7 @@ import { nanoid } from '@reduxjs/toolkit' import type { WebSearchProviderResult } from '@renderer/types' import { createAbortPromise } from '@renderer/utils/abortController' import { isAbortError } from '@renderer/utils/error' +import PQueue from 'p-queue' import TurndownService from 'turndown' const logger = loggerService.withContext('Utils:fetch') @@ -13,6 +14,33 @@ export const noContent = 'No content found' type ResponseFormat = 'markdown' | 'html' | 'text' +// Domain queue management for throttling requests to the same domain +const domainQueues = new Map() +const DOMAIN_CONCURRENCY = 1 +const DOMAIN_INTERVAL = 500 // ms between requests to the same domain + +function getDomainQueue(domain: string): PQueue { + if (!domainQueues.has(domain)) { + domainQueues.set( + domain, + new PQueue({ + concurrency: DOMAIN_CONCURRENCY, + interval: DOMAIN_INTERVAL, + intervalCap: 1 + }) + ) + } + return domainQueues.get(domain)! +} + +function getDomain(url: string): string { + try { + return new URL(url).hostname + } catch { + return 'unknown' + } +} + /** * Validates if the string is a properly formatted URL */ @@ -31,10 +59,15 @@ export async function fetchWebContents( usingBrowser: boolean = false, httpOptions: RequestInit = {} ): Promise { - // parallel using fetchWebContent - const results = await Promise.allSettled(urls.map((url) => fetchWebContent(url, format, usingBrowser, httpOptions))) + const results = await Promise.allSettled( + urls.map((url) => { + const domain = getDomain(url) + const queue = getDomainQueue(domain) + return queue.add(() => fetchWebContent(url, format, usingBrowser, httpOptions), { throwOnTimeout: true }) + }) + ) return results.map((result, index) => { - if (result.status === 'fulfilled') { + if (result.status === 'fulfilled' && result.value) { return result.value } else { return { From 3ec6e1167f49254b5a017cd0513405b18a0b3c2c Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 7 Jan 2026 17:31:35 +0800 Subject: [PATCH 17/20] chore: release v1.7.10 --- electron-builder.yml | 52 +++++++++++++++++++------------------------- package.json | 2 +- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 3171ddf584..20ab8fec6a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -143,44 +143,36 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.9 - New Features & Bug Fixes + Cherry Studio 1.7.10 - New Features & Bug Fixes ✨ New Features - - [Agent] Add 302.AI provider support - - [Browser] Browser data now persists and supports multiple tabs - - [Language] Add Romanian language support - - [Search] Add fuzzy search for file list - - [Models] Add latest Zhipu models - - [Image] Improve text-to-image functionality + - [MCP] Add MCP Hub with Auto mode for intelligent multi-server tool orchestration 🐛 Bug Fixes - - [Mac] Fix mini window unexpected closing issue - - [Preview] Fix HTML preview controls not working in fullscreen - - [Translate] Fix translation duplicate execution issue - - [Zoom] Fix page zoom reset issue during navigation - - [Agent] Fix crash when switching between agent and assistant - - [Agent] Fix navigation in agent mode - - [Copy] Fix markdown copy button issue - - [Windows] Fix compatibility issues on non-Windows systems + - [Search] Fix Bing, Baidu, and Google search results not retrieving + - [Chat] Fix reasoning process not displaying correctly for some proxy models + - [Chat] Fix duplicate loading spinners on action buttons + - [Editor] Fix paragraph handle and plus button not clickable + - [Drawing] Fix TokenFlux models not showing in drawing panel + - [Translate] Fix translation stalling after initialization + - [Error] Fix app freeze when viewing error details with large images + - [Notes] Fix folder overlay blocking webview preview + - [Chat] Fix thinking time display when stopping generation - Cherry Studio 1.7.9 - 新功能与问题修复 + Cherry Studio 1.7.10 - 新功能与问题修复 ✨ 新功能 - - [Agent] 新增 302.AI 服务商支持 - - [浏览器] 浏览器数据现在可以保存,支持多标签页 - - [语言] 新增罗马尼亚语支持 - - [搜索] 文件列表新增模糊搜索功能 - - [模型] 新增最新智谱模型 - - [图片] 优化文生图功能 + - [MCP] 新增 MCP Hub 智能模式,可自动管理和调用多个 MCP 服务器工具 🐛 问题修复 - - [Mac] 修复迷你窗口意外关闭的问题 - - [预览] 修复全屏模式下 HTML 预览控件无法使用的问题 - - [翻译] 修复翻译重复执行的问题 - - [缩放] 修复页面导航时缩放被重置的问题 - - [智能体] 修复在智能体和助手间切换时崩溃的问题 - - [智能体] 修复智能体模式下的导航问题 - - [复制] 修复 Markdown 复制按钮问题 - - [兼容性] 修复非 Windows 系统的兼容性问题 + - [搜索] 修复必应、百度、谷歌搜索无法获取结果的问题 + - [对话] 修复部分代理模型的推理过程无法正确显示的问题 + - [对话] 修复操作按钮重复显示加载状态的问题 + - [编辑器] 修复段落手柄和加号按钮无法点击的问题 + - [绘图] 修复 TokenFlux 模型在绘图面板不显示的问题 + - [翻译] 修复翻译功能初始化后卡住的问题 + - [错误] 修复查看包含大图片的错误详情时应用卡死的问题 + - [笔记] 修复文件夹遮挡网页预览的问题 + - [对话] 修复停止生成时思考时间显示问题 diff --git a/package.json b/package.json index 91e7d08de9..f43c2e1208 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.9", + "version": "1.7.10", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 77664388539ae6c80620d5c61d3d11efe3f16a46 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 7 Jan 2026 18:40:39 +0800 Subject: [PATCH 18/20] Revert "fix(SearchService): Fix inability to retrieve search results from Bing, Baidu, and Google" This reverts commit b83fbc0acec44da14ab87385aa1779840470a17f. --- src/main/services/SearchService.ts | 10 ++--- .../src/utils/__tests__/fetch.test.ts | 41 ------------------- src/renderer/src/utils/fetch.ts | 39 ++---------------- 3 files changed, 6 insertions(+), 84 deletions(-) diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 03a154dcd4..6c69f80889 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -22,8 +22,7 @@ export class SearchService { webPreferences: { nodeIntegration: true, contextIsolation: false, - devTools: is.dev, - offscreen: true // 启用离屏渲染 + devTools: is.dev } }) @@ -69,8 +68,7 @@ export class SearchService { // Wait for the page to fully load before getting the content await new Promise((resolve) => { const loadTimeout = setTimeout(() => resolve(), 10000) // 10 second timeout - window.once('ready-to-show', () => { - //让网页加载完成后执行,原来的.webContents.once('did-finish-load'会导致网页抖动 + window.webContents.once('did-finish-load', () => { clearTimeout(loadTimeout) // Small delay to ensure JavaScript has executed setTimeout(resolve, 500) @@ -78,9 +76,7 @@ export class SearchService { }) // Get the page content after ensuring it's fully loaded - const executeJavaScript = await window.webContents.executeJavaScript('document.documentElement.outerHTML') - // logger.info(executeJavaScript) - return executeJavaScript + return await window.webContents.executeJavaScript('document.documentElement.outerHTML') } } diff --git a/src/renderer/src/utils/__tests__/fetch.test.ts b/src/renderer/src/utils/__tests__/fetch.test.ts index 38f51fcb42..6b36cb41f8 100644 --- a/src/renderer/src/utils/__tests__/fetch.test.ts +++ b/src/renderer/src/utils/__tests__/fetch.test.ts @@ -181,47 +181,6 @@ describe('fetch', () => { consoleSpy.mockRestore() }) - - it('should throttle requests to the same domain', async () => { - const fetchCallTimes: number[] = [] - vi.mocked(global.fetch).mockImplementation(async () => { - fetchCallTimes.push(Date.now()) - return createMockResponse() - }) - - // 3 URLs from the same domain - const urls = ['https://zhihu.com/a', 'https://zhihu.com/b', 'https://zhihu.com/c'] - await fetchWebContents(urls) - - expect(fetchCallTimes).toHaveLength(3) - // Verify that requests are spaced out (at least 400ms apart due to 500ms interval) - if (fetchCallTimes.length >= 2) { - const timeDiff1 = fetchCallTimes[1] - fetchCallTimes[0] - expect(timeDiff1).toBeGreaterThanOrEqual(400) - } - if (fetchCallTimes.length >= 3) { - const timeDiff2 = fetchCallTimes[2] - fetchCallTimes[1] - expect(timeDiff2).toBeGreaterThanOrEqual(400) - } - }) - - it('should allow parallel requests to different domains', async () => { - const fetchCallTimes: Map = new Map() - vi.mocked(global.fetch).mockImplementation(async (url) => { - fetchCallTimes.set(url as string, Date.now()) - return createMockResponse() - }) - - // URLs from different domains - const urls = ['https://zhihu.com/a', 'https://douban.com/b', 'https://github.com/c'] - await fetchWebContents(urls) - - expect(fetchCallTimes.size).toBe(3) - // Different domains should start nearly simultaneously (within 100ms) - const times = Array.from(fetchCallTimes.values()) - const maxDiff = Math.max(...times) - Math.min(...times) - expect(maxDiff).toBeLessThan(100) - }) }) describe('fetchRedirectUrl', () => { diff --git a/src/renderer/src/utils/fetch.ts b/src/renderer/src/utils/fetch.ts index c9da595cd9..52c91c0896 100644 --- a/src/renderer/src/utils/fetch.ts +++ b/src/renderer/src/utils/fetch.ts @@ -4,7 +4,6 @@ import { nanoid } from '@reduxjs/toolkit' import type { WebSearchProviderResult } from '@renderer/types' import { createAbortPromise } from '@renderer/utils/abortController' import { isAbortError } from '@renderer/utils/error' -import PQueue from 'p-queue' import TurndownService from 'turndown' const logger = loggerService.withContext('Utils:fetch') @@ -14,33 +13,6 @@ export const noContent = 'No content found' type ResponseFormat = 'markdown' | 'html' | 'text' -// Domain queue management for throttling requests to the same domain -const domainQueues = new Map() -const DOMAIN_CONCURRENCY = 1 -const DOMAIN_INTERVAL = 500 // ms between requests to the same domain - -function getDomainQueue(domain: string): PQueue { - if (!domainQueues.has(domain)) { - domainQueues.set( - domain, - new PQueue({ - concurrency: DOMAIN_CONCURRENCY, - interval: DOMAIN_INTERVAL, - intervalCap: 1 - }) - ) - } - return domainQueues.get(domain)! -} - -function getDomain(url: string): string { - try { - return new URL(url).hostname - } catch { - return 'unknown' - } -} - /** * Validates if the string is a properly formatted URL */ @@ -59,15 +31,10 @@ export async function fetchWebContents( usingBrowser: boolean = false, httpOptions: RequestInit = {} ): Promise { - const results = await Promise.allSettled( - urls.map((url) => { - const domain = getDomain(url) - const queue = getDomainQueue(domain) - return queue.add(() => fetchWebContent(url, format, usingBrowser, httpOptions), { throwOnTimeout: true }) - }) - ) + // parallel using fetchWebContent + const results = await Promise.allSettled(urls.map((url) => fetchWebContent(url, format, usingBrowser, httpOptions))) return results.map((result, index) => { - if (result.status === 'fulfilled' && result.value) { + if (result.status === 'fulfilled') { return result.value } else { return { From 8d56bf80dd83d35a5c5951fa13b43101dd6fe29f Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 7 Jan 2026 18:46:07 +0800 Subject: [PATCH 19/20] chore: update GitHub Actions workflow to enable corepack for pnpm installation Replaced the pnpm action setup with a corepack enable command to streamline dependency management in the workflow. --- .github/workflows/update-app-upgrade-config.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-app-upgrade-config.yml b/.github/workflows/update-app-upgrade-config.yml index 088fa4260a..49f3e64bb4 100644 --- a/.github/workflows/update-app-upgrade-config.yml +++ b/.github/workflows/update-app-upgrade-config.yml @@ -154,9 +154,10 @@ jobs: with: node-version: 22 - - name: Install pnpm + - name: Enable corepack if: steps.check.outputs.should_run == 'true' - uses: pnpm/action-setup@v4 + working-directory: main + run: corepack enable pnpm - name: Install dependencies if: steps.check.outputs.should_run == 'true' From 90cd06d23d282bd92e5e746c286027d45273fe41 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 7 Jan 2026 18:50:15 +0800 Subject: [PATCH 20/20] chore: release v1.7.11 Updated version number to 1.7.11 in package.json and electron-builder.yml. Added release notes highlighting the introduction of the MCP Hub with Auto mode and various bug fixes, including improvements to the Chat and Editor components. --- electron-builder.yml | 6 ++---- package.json | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 20ab8fec6a..d812c21e14 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -143,13 +143,12 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.10 - New Features & Bug Fixes + Cherry Studio 1.7.11 - New Features & Bug Fixes ✨ New Features - [MCP] Add MCP Hub with Auto mode for intelligent multi-server tool orchestration 🐛 Bug Fixes - - [Search] Fix Bing, Baidu, and Google search results not retrieving - [Chat] Fix reasoning process not displaying correctly for some proxy models - [Chat] Fix duplicate loading spinners on action buttons - [Editor] Fix paragraph handle and plus button not clickable @@ -160,13 +159,12 @@ releaseInfo: - [Chat] Fix thinking time display when stopping generation - Cherry Studio 1.7.10 - 新功能与问题修复 + Cherry Studio 1.7.11 - 新功能与问题修复 ✨ 新功能 - [MCP] 新增 MCP Hub 智能模式,可自动管理和调用多个 MCP 服务器工具 🐛 问题修复 - - [搜索] 修复必应、百度、谷歌搜索无法获取结果的问题 - [对话] 修复部分代理模型的推理过程无法正确显示的问题 - [对话] 修复操作按钮重复显示加载状态的问题 - [编辑器] 修复段落手柄和加号按钮无法点击的问题 diff --git a/package.json b/package.json index f43c2e1208..2610e81ef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.10", + "version": "1.7.11", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js",