From 5bd550bfb41195b3c19070218767794bbfff167f Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 13 Dec 2025 21:09:38 +0800 Subject: [PATCH 01/41] Fix/cannot get dimension (#11879) * fix: use ModernAiProvider for embedding dimensions * fix(ollama) * Update src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: defi-failure <159208748+defi-failure@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index c51f8aac8a..937827db01 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -88,7 +88,11 @@ export abstract class OpenAIBaseClient< } override async getEmbeddingDimensions(model: Model): Promise { - const sdk = await this.getSdkInstance() + let sdk: OpenAI = await this.getSdkInstance() + if (isOllamaProvider(this.provider)) { + const embedBaseUrl = `${this.provider.apiHost.replace(/(\/(api|v1))\/?$/, '')}/v1` + sdk = sdk.withOptions({ baseURL: embedBaseUrl }) + } const data = await sdk.embeddings.create({ model: model.id, From f0ec2354dc3e87cf918d4de40f0c975664267b75 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Sat, 13 Dec 2025 22:51:11 +0800 Subject: [PATCH 02/41] chore: fix sync to gitcode action retry logic (#11881) --- .github/workflows/sync-to-gitcode.yml | 48 +++++++++++++++++---------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/workflows/sync-to-gitcode.yml b/.github/workflows/sync-to-gitcode.yml index 4462ff6375..53ecae445b 100644 --- a/.github/workflows/sync-to-gitcode.yml +++ b/.github/workflows/sync-to-gitcode.yml @@ -216,6 +216,7 @@ jobs: local filename=$(basename "$file") local max_retries=3 local retry=0 + local curl_status=0 echo "Uploading: $filename" @@ -224,34 +225,45 @@ jobs: while [ $retry -lt $max_retries ]; do # Get upload URL + curl_status=0 UPLOAD_INFO=$(curl -s --connect-timeout 30 --max-time 60 \ -H "Authorization: Bearer ${GITCODE_TOKEN}" \ - "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") + "${API_URL}/repos/${GITCODE_OWNER}/${GITCODE_REPO}/releases/${TAG_NAME}/upload_url?file_name=${encoded_filename}") || curl_status=$? - UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty') + if [ $curl_status -eq 0 ]; then + UPLOAD_URL=$(echo "$UPLOAD_INFO" | jq -r '.url // empty') - if [ -n "$UPLOAD_URL" ]; then - # Write headers to temp file to avoid shell escaping issues - echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt + if [ -n "$UPLOAD_URL" ]; then + # Write headers to temp file to avoid shell escaping issues + echo "$UPLOAD_INFO" | jq -r '.headers | to_entries[] | "header = \"" + .key + ": " + .value + "\""' > /tmp/upload_headers.txt - # Upload file using PUT with headers from file - UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ - -K /tmp/upload_headers.txt \ - --data-binary "@${file}" \ - "$UPLOAD_URL") + # Upload file using PUT with headers from file + curl_status=0 + UPLOAD_RESPONSE=$(curl -s -w "\n%{http_code}" -X PUT \ + -K /tmp/upload_headers.txt \ + --data-binary "@${file}" \ + "$UPLOAD_URL") || curl_status=$? - HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1) - RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d') + if [ $curl_status -eq 0 ]; then + HTTP_CODE=$(echo "$UPLOAD_RESPONSE" | tail -n1) + RESPONSE_BODY=$(echo "$UPLOAD_RESPONSE" | sed '$d') - if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then - echo " Uploaded: $filename" - return 0 + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo " Uploaded: $filename" + return 0 + else + echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries" + echo " Response: $RESPONSE_BODY" + fi + else + echo " Upload request failed (curl exit $curl_status), retry $((retry + 1))/$max_retries" + fi else - echo " Failed (HTTP $HTTP_CODE), retry $((retry + 1))/$max_retries" - echo " Response: $RESPONSE_BODY" + echo " Failed to get upload URL, retry $((retry + 1))/$max_retries" + echo " Response: $UPLOAD_INFO" fi else - echo " Failed to get upload URL, retry $((retry + 1))/$max_retries" + echo " Failed to get upload URL (curl exit $curl_status), retry $((retry + 1))/$max_retries" echo " Response: $UPLOAD_INFO" fi From ee7eee24da007f1026e0dc198c63b1ec5a66414a Mon Sep 17 00:00:00 2001 From: SuYao Date: Sat, 13 Dec 2025 22:58:59 +0800 Subject: [PATCH 03/41] fix: max search result (#11883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add search result limit * fix: typo * Update src/renderer/src/aiCore/utils/websearch.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: test * Apply suggestion from @GeorgeDong32 Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: George·Dong <98630204+GeorgeDong32@users.noreply.github.com> --- src/renderer/src/aiCore/utils/__tests__/websearch.test.ts | 2 +- src/renderer/src/aiCore/utils/websearch.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts b/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts index fa5e3c3b36..5c95a664aa 100644 --- a/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/websearch.test.ts @@ -259,7 +259,7 @@ describe('websearch utils', () => { expect(result).toEqual({ xai: { - maxSearchResults: 50, + maxSearchResults: 30, returnCitations: true, sources: [{ type: 'web', excludedWebsites: [] }, { type: 'news' }, { type: 'x' }], mode: 'on' diff --git a/src/renderer/src/aiCore/utils/websearch.ts b/src/renderer/src/aiCore/utils/websearch.ts index 127636a50b..14a99139be 100644 --- a/src/renderer/src/aiCore/utils/websearch.ts +++ b/src/renderer/src/aiCore/utils/websearch.ts @@ -9,6 +9,8 @@ import type { CherryWebSearchConfig } from '@renderer/store/websearch' import type { Model } from '@renderer/types' import { mapRegexToPatterns } from '@renderer/utils/blacklistMatchPattern' +const X_AI_MAX_SEARCH_RESULT = 30 + export function getWebSearchParams(model: Model): Record { if (model.provider === 'hunyuan') { return { enable_enhancement: true, citation: true, search_info: true } @@ -82,7 +84,7 @@ export function buildProviderBuiltinWebSearchConfig( const excludeDomains = mapRegexToPatterns(webSearchConfig.excludeDomains) return { xai: { - maxSearchResults: webSearchConfig.maxResults, + maxSearchResults: Math.min(webSearchConfig.maxResults, X_AI_MAX_SEARCH_RESULT), returnCitations: true, sources: [ { From a1e44a6827b6d5bd8433fdcacd2da249119a5bcf Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sat, 13 Dec 2025 23:16:46 +0800 Subject: [PATCH 04/41] fix: adjust marginRight calculation in Chat component for improved layout Updated the marginRight property in the Chat component to include the border width when the topic position is 'right' and topics are shown. This change enhances the layout by ensuring proper spacing in the UI. --- src/renderer/src/pages/home/Chat.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 45cbc45f4d..01bd12377c 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -180,7 +180,8 @@ const Chat: FC = (props) => { From fd921103ddbae1ca57382f87a382ad7b12d270b3 Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 14 Dec 2025 20:05:45 +0800 Subject: [PATCH 05/41] fix: preserve thinking block (#11901) * fix: preserve thinking block * test: add coverage for reasoning parts in assistant messages (#11902) * Initial plan * test: add test coverage for reasoning parts in assistant messages Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: DeJeune <67425183+DeJeune@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- .../__tests__/message-converter.test.ts | 31 +++++++++++++++++++ .../aiCore/prepareParams/messageConverter.ts | 5 +-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts index cb0c5cf9a6..2a69f3bcef 100644 --- a/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts +++ b/src/renderer/src/aiCore/prepareParams/__tests__/message-converter.test.ts @@ -109,6 +109,20 @@ const createImageBlock = ( ...overrides }) +const createThinkingBlock = ( + messageId: string, + overrides: Partial> = {} +): ThinkingMessageBlock => ({ + id: overrides.id ?? `thinking-block-${++blockCounter}`, + messageId, + type: MessageBlockType.THINKING, + createdAt: overrides.createdAt ?? new Date(2024, 0, 1, 0, 0, blockCounter).toISOString(), + status: overrides.status ?? MessageBlockStatus.SUCCESS, + content: overrides.content ?? 'Let me think...', + thinking_millsec: overrides.thinking_millsec ?? 1000, + ...overrides +}) + describe('messageConverter', () => { beforeEach(() => { convertFileBlockToFilePartMock.mockReset() @@ -229,6 +243,23 @@ describe('messageConverter', () => { } ]) }) + + it('includes reasoning parts for assistant messages with thinking blocks', async () => { + const model = createModel() + const message = createMessage('assistant') + message.__mockContent = 'Here is my answer' + message.__mockThinkingBlocks = [createThinkingBlock(message.id, { content: 'Let me think...' })] + + const result = await convertMessageToSdkParam(message, false, model) + + expect(result).toEqual({ + role: 'assistant', + content: [ + { type: 'text', text: 'Here is my answer' }, + { type: 'reasoning', text: 'Let me think...' } + ] + }) + }) }) describe('convertMessagesToSdkMessages', () => { diff --git a/src/renderer/src/aiCore/prepareParams/messageConverter.ts b/src/renderer/src/aiCore/prepareParams/messageConverter.ts index 328a10b941..c3798c1f43 100644 --- a/src/renderer/src/aiCore/prepareParams/messageConverter.ts +++ b/src/renderer/src/aiCore/prepareParams/messageConverter.ts @@ -3,6 +3,7 @@ * 将 Cherry Studio 消息格式转换为 AI SDK 消息格式 */ +import type { ReasoningPart } from '@ai-sdk/provider-utils' import { loggerService } from '@logger' import { isImageEnhancementModel, isVisionModel } from '@renderer/config/models' import type { Message, Model } from '@renderer/types' @@ -163,13 +164,13 @@ async function convertMessageToAssistantModelMessage( thinkingBlocks: ThinkingMessageBlock[], model?: Model ): Promise { - const parts: Array = [] + const parts: Array = [] if (content) { parts.push({ type: 'text', text: content }) } for (const thinkingBlock of thinkingBlocks) { - parts.push({ type: 'text', text: thinkingBlock.content }) + parts.push({ type: 'reasoning', text: thinkingBlock.content }) } for (const fileBlock of fileBlocks) { From 68f70e3b1669b1bb0f73f594a4a578a1e0f97873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Mon, 15 Dec 2025 03:12:01 +0800 Subject: [PATCH 06/41] fix: add capabilities support for Doubao Seed Code models (#11910) - Add tool calling support in tooluse.ts - Add reasoning support in reasoning.ts - Add vision support in vision.ts Doubao Seed Code models (doubao-seed-code-preview-251028 and future models) now support function calling, deep thinking (enabled/disabled), and image understanding. --- src/renderer/src/config/models/reasoning.ts | 2 +- src/renderer/src/config/models/tooluse.ts | 1 + src/renderer/src/config/models/vision.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index bd525a43aa..d06d58a082 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -388,7 +388,7 @@ export function isQwenAlwaysThinkModel(model?: Model): boolean { // Doubao 支持思考模式的模型正则 export const DOUBAO_THINKING_MODEL_REGEX = - /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i + /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$))|seed-code(?:-preview)?(?:-\d+)?)(?:-[\w-]+)*/i // 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx // Auto thinking is no longer supported after version 251015, see https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-6 diff --git a/src/renderer/src/config/models/tooluse.ts b/src/renderer/src/config/models/tooluse.ts index c30a8eb4ad..50890aaf8d 100644 --- a/src/renderer/src/config/models/tooluse.ts +++ b/src/renderer/src/config/models/tooluse.ts @@ -26,6 +26,7 @@ export const FUNCTION_CALLING_MODELS = [ 'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型 'grok-3(?:-[\\w-]+)?', 'doubao-seed-1[.-]6(?:-[\\w-]+)?', + 'doubao-seed-code(?:-[\\w-]+)?', 'kimi-k2(?:-[\\w-]+)?', 'ling-\\w+(?:-[\\w-]+)?', 'ring-\\w+(?:-[\\w-]+)?', diff --git a/src/renderer/src/config/models/vision.ts b/src/renderer/src/config/models/vision.ts index 81c6a77c80..183ec99433 100644 --- a/src/renderer/src/config/models/vision.ts +++ b/src/renderer/src/config/models/vision.ts @@ -46,6 +46,7 @@ const visionAllowedModels = [ 'kimi-latest', 'gemma-3(?:-[\\w-]+)', 'doubao-seed-1[.-]6(?:-[\\w-]+)?', + 'doubao-seed-code(?:-[\\w-]+)?', 'kimi-thinking-preview', `gemma3(?:[-:\\w]+)?`, 'kimi-vl-a3b-thinking(?:-[\\w-]+)?', From e78f25ff916ab368e539d7022ac89425eb3b96c5 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:04:06 +0800 Subject: [PATCH 07/41] i18n: Improve zh-tw Traditional Chinese locale (#11915) --- src/renderer/src/i18n/locales/zh-tw.json | 1604 +++++++++++----------- 1 file changed, 802 insertions(+), 802 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 146c9faae6..2896077fa4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1,23 +1,23 @@ { "agent": { "add": { - "description": "調用各種工具處理複雜任務", + "description": "呼叫各種工具處理複雜任務", "error": { - "failed": "無法新增代理人", + "failed": "無法新增 Agent", "invalid_agent": "無效的 Agent" }, "model": { "tooltip": "目前,僅支援 Anthropic 端點的模型可供代理功能使用。" }, - "title": "新增代理", + "title": "新增 Agent", "type": { "placeholder": "選擇 Agent 類型" } }, "delete": { - "content": "刪除該 Agent 將強制終止並刪除該 Agent 下的所有會話。您確定嗎?", + "content": "刪除該 Agent 會強制停止並刪除該 Agent 的所有工作階段。確定要刪除嗎?", "error": { - "failed": "刪除代理程式失敗" + "failed": "刪除 Agent 失敗" }, "title": "刪除 Agent" }, @@ -26,8 +26,8 @@ }, "get": { "error": { - "failed": "無法取得代理程式。", - "null_id": "代理程式 ID 為空。" + "failed": "無法取得 Agent。", + "null_id": "Agent ID 為空。" } }, "gitBash": { @@ -37,12 +37,12 @@ }, "customPath": "使用自訂路徑:{{path}}", "error": { - "description": "在 Windows 上執行代理程式需要 Git Bash。沒有它代理程式無法運作。請從以下地址安裝 Git for Windows", - "recheck": "重新檢測 Git Bash 安裝", + "description": "在 Windows 上執行 Agent 需要 Git Bash。沒有它 Agent 無法運作。請從以下網址安裝 Git for Windows", + "recheck": "重新偵測 Git Bash 安裝", "title": "需要 Git Bash" }, "found": { - "title": "已配置 Git Bash" + "title": "已設定 Git Bash" }, "notFound": "找不到 Git Bash。請先安裝。", "pick": { @@ -58,12 +58,12 @@ }, "list": { "error": { - "failed": "無法列出代理程式。" + "failed": "無法列出 Agent。" } }, "server": { "error": { - "not_running": "API 伺服器已啟用,但運行不正常。" + "not_running": "API 伺服器已啟用,但運作不正常。" } }, "session": { @@ -78,11 +78,11 @@ "select_failed": "無法選擇目錄。" }, "add": { - "title": "新增會議" + "title": "新增工作階段" }, "allowed_tools": { "empty": "目前此代理沒有可用的工具。", - "helper": "選擇預先授權的工具,未選取的工具在使用時需要手動審批。", + "helper": "預先核准的工具可直接執行;未選取的工具在使用前需要手動核准。", "label": "預先授權工具", "placeholder": "選擇預先授權的工具" }, @@ -108,8 +108,8 @@ "null_id": "工作階段 ID 為空" } }, - "label_one": "會議", - "label_other": "Sessions", + "label_one": "工作階段", + "label_other": "工作階段", "update": { "error": { "failed": "無法更新工作階段" @@ -119,8 +119,8 @@ "settings": { "advance": { "maxTurns": { - "description": "設定代理自動執行的請求/回覆輪次数。", - "helper": "數值越高可自動運行越久;數值越低更容易掌控。", + "description": "設定代理自動執行的請求/回覆輪次數。", + "helper": "數值越高可自動運作越久;數值越低更容易掌控。", "label": "會話輪次上限" }, "permissionMode": { @@ -192,7 +192,7 @@ "permissionMode": { "acceptEdits": { "behavior": "預先授權受信任的檔案系統工具,允許即時執行。", - "description": "檔案編輯與檔案系統操作會自動通過核准。", + "description": "檔案編輯與檔案系統操作會自動核准。", "title": "自動接受檔案編輯" }, "bypassPermissions": { @@ -220,7 +220,7 @@ "title": "權限模式" }, "preapproved": { - "autoBadge": "模式自動添加", + "autoBadge": "模式自動新增", "autoDescription": "此工具由目前的權限模式自動預先授權。", "empty": "沒有符合篩選條件的工具。", "mcpBadge": "MCP 工具", @@ -236,7 +236,7 @@ "autoTools": "自動:{{count}}", "customTools": "自訂:{{count}}", "helper": "設定會自動儲存,可隨時回到上方步驟調整。", - "mcp": "MCP:{{count}}", + "mcp": "MCP:{{count}}", "mode": "模式:{{mode}}" }, "steps": { @@ -269,9 +269,9 @@ "aria": { "allowRequest": "允許工具請求", "denyRequest": "拒絕工具請求", - "hideDetails": "隱藏工具詳情", + "hideDetails": "隱藏工具詳細資訊", "runWithOptions": "帶選項執行", - "showDetails": "顯示工具詳情" + "showDetails": "顯示工具詳細資訊" }, "button": { "cancel": "取消", @@ -301,22 +301,22 @@ "waiting": "等待工具權限決定..." }, "type": { - "label": "代理類型", + "label": "Agent 類型", "unknown": "未知類型" }, "update": { "error": { - "failed": "無法更新代理程式" + "failed": "無法更新 Agent" } }, "warning": { - "enable_server": "啟用 API 伺服器以使用代理程式。" + "enable_server": "啟用 API 伺服器以使用 Agent。" } }, "apiServer": { "actions": { "copy": "複製", - "regenerate": "重新生成", + "regenerate": "重新產生", "restart": { "button": "重新啟動", "tooltip": "重新啟動伺服器" @@ -328,7 +328,7 @@ "title": "授權標頭" }, "authHeaderText": "在授權標頭中使用:", - "configuration": "配置", + "configuration": "設定", "description": "透過 OpenAI 相容的 HTTP API 公開 Cherry Studio 的 AI 功能", "documentation": { "title": "API 文件" @@ -336,9 +336,9 @@ "fields": { "apiKey": { "copyTooltip": "複製 API 金鑰", - "description": "用於 API 訪問的安全認證令牌", + "description": "用於 API 存取的安全認證權杖", "label": "API 金鑰", - "placeholder": "API 金鑰將自動生成" + "placeholder": "API 金鑰會自動產生" }, "port": { "description": "HTTP 伺服器的 TCP 連接埠 (1000-65535)", @@ -352,7 +352,7 @@ }, "messages": { "apiKeyCopied": "API 金鑰已複製到剪貼簿", - "apiKeyRegenerated": "API 金鑰已重新生成", + "apiKeyRegenerated": "API 金鑰已重新產生", "notEnabled": "API 伺服器未啟用。", "operationFailed": "API 伺服器操作失敗:", "restartError": "重新啟動 API 伺服器失敗:", @@ -425,8 +425,8 @@ "type": "助手圖示" }, "list": { - "showByList": "列表展示", - "showByTags": "標籤展示" + "showByList": "列表顯示", + "showByTags": "標籤顯示" }, "presets": { "add": { @@ -450,7 +450,7 @@ } }, "title": "建立助手", - "unsaved_changes_warning": "有未保存的變更,確定要關閉嗎?" + "unsaved_changes_warning": "有未儲存的變更,確定要關閉嗎?" }, "delete": { "popup": { @@ -469,15 +469,15 @@ "agent": "匯出助手" }, "import": { - "button": "導入", + "button": "匯入", "error": { - "fetch_failed": "從 URL 獲取資料失敗", + "fetch_failed": "從 URL 取得資料失敗", "invalid_format": "無效的助手格式:缺少必填欄位", "url_required": "請輸入 URL" }, "file_filter": "JSON 檔案", "select_file": "選擇檔案", - "title": "從外部導入", + "title": "從外部匯入", "type": { "file": "檔案", "url": "URL" @@ -487,7 +487,7 @@ "manage": { "batch_delete": { "button": "批次刪除", - "confirm": "您確定要刪除所選的 {{count}} 個助理嗎?" + "confirm": "確定要刪除所選的 {{count}} 個助手嗎?" }, "mode": { "delete": "刪除", @@ -500,7 +500,7 @@ "no_results": "沒有找到相關助手" }, "settings": { - "title": "助手配置" + "title": "助手設定" }, "sorting": { "title": "排序" @@ -523,10 +523,10 @@ "knowledge_base": { "label": "知識庫設定", "recognition": { - "label": "調用知識庫", - "off": "強制檢索", + "label": "呼叫知識庫", + "off": "強制查詢", "on": "意圖識別", - "tip": "助手將調用大語言模型的意圖識別能力,判斷是否需要調用知識庫進行回答,該功能將依賴模型的能力" + "tip": "助手會使用大型語言模型的意圖識別能力,判斷是否需要查詢知識庫;此功能仰賴模型能力" } }, "mcp": { @@ -550,25 +550,25 @@ "xhigh": "極力思考" }, "regular_phrases": { - "add": "添加短语", + "add": "新增短語", "contentLabel": "內容", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}", - "delete": "刪除短语", - "deleteConfirm": "確定要刪除這個短语嗎?", - "edit": "編輯短语", - "title": "常用短语", + "contentPlaceholder": "請輸入短語內容,支援使用變數,然後按 Tab 鍵可以快速定位到變數進行修改。例如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後傳送到 ${email}", + "delete": "刪除短語", + "deleteConfirm": "確定要刪除這個短語嗎?", + "edit": "編輯短語", + "title": "常用短語", "titleLabel": "標題", "titlePlaceholder": "輸入標題" }, "title": "助手設定", "tool_use_mode": { - "function": "函數", - "label": "工具調用方式", + "function": "函式", + "label": "工具呼叫方式", "prompt": "提示詞" } }, "tags": { - "add": "添加標籤", + "add": "新增標籤", "delete": "刪除標籤", "deleteConfirm": "確定要刪除這個標籤嗎?", "manage": "標籤管理", @@ -618,7 +618,7 @@ "all": "顯示全部" }, "update_available": "有可用更新", - "whole_word": "全字匹配" + "whole_word": "全字比對" }, "chat": { "add": { @@ -662,13 +662,13 @@ }, "history": { "assistant_node": "助手", - "click_to_navigate": "點擊跳轉到對應訊息", + "click_to_navigate": "點選以前往對應訊息", "coming_soon": "聊天工作流圖表即將上線", "no_messages": "沒有找到訊息", - "start_conversation": "開始對話以查看聊天流程圖", + "start_conversation": "開始對話以檢視聊天流程圖", "title": "聊天歷史", - "user_node": "用戶", - "view_full_content": "查看完整內容" + "user_node": "使用者", + "view_full_content": "檢視完整內容" }, "input": { "activity_directory": { @@ -697,8 +697,8 @@ "file_error": "檔案處理錯誤", "file_not_supported": "模型不支援此檔案類型", "file_not_supported_count": "{{count}} 個檔案不被支援", - "generate_image": "生成圖片", - "generate_image_not_supported": "模型不支援生成圖片", + "generate_image": "產生圖片", + "generate_image_not_supported": "模型不支援產生圖片", "knowledge_base": "知識庫", "new": { "context": "清除上下文 {{Command}}" @@ -712,7 +712,7 @@ "send": "傳送", "settings": "設定", "slash_commands": { - "description": "代理會話斜線命令", + "description": "Agent 工作階段斜線指令", "title": "斜線指令" }, "thinking": { @@ -720,15 +720,15 @@ "label": "思考", "mode": { "custom": { - "label": "自定義", - "tip": "模型最多可以思考的 Token 數。需要考慮模型的上下文限制,否則會報錯" + "label": "自訂", + "tip": "模型最多可用來思考的 Token 數。請依模型的上下文上限調整,否則可能發生錯誤" }, "default": { "label": "預設", - "tip": "模型會自動確定思考的 Token 數" + "tip": "模型會自動決定思考的 Token 數" }, "tokens": { - "tip": "設置思考的 Token 數" + "tip": "設定思考的 Token 數" } } }, @@ -743,19 +743,19 @@ "translating": "翻譯中...", "upload": { "attachment": "上傳附件", - "document": "上傳文件(模型不支援圖片)", - "image_or_document": "上傳圖片或文件", - "upload_from_local": "上傳本地文件..." + "document": "上傳檔案(模型不支援圖片)", + "image_or_document": "上傳圖片或檔案", + "upload_from_local": "上傳本機檔案..." }, "url_context": "網頁上下文", "web_search": { "builtin": { - "disabled_content": "當前模型不支持網路搜尋功能", - "enabled_content": "使用模型內置的網路搜尋功能", - "label": "模型內置" + "disabled_content": "目前模型不支援網路搜尋功能", + "enabled_content": "使用模型內建的網路搜尋功能", + "label": "模型內建" }, "button": { - "ok": "去設定" + "ok": "前往設定" }, "enable": "開啟網路搜尋", "enable_content": "需要先在設定中開啟網路搜尋", @@ -772,10 +772,10 @@ "parse_tool_call": "無法轉換為有效的工具呼叫格式:{{toolCall}}" }, "warning": { - "gemini_web_search": "Gemini 不支援同時使用原生網路搜尋工具與函數呼叫", - "multiple_tools": "存在多個匹配的MCP工具,已選擇 {{tool}}", - "no_tool": "未匹配到所需的MCP工具 {{tool}}", - "url_context": "Gemini 不支援同時使用網頁內容與函數呼叫" + "gemini_web_search": "Gemini 不支援同時使用原生網路搜尋工具與函式呼叫", + "multiple_tools": "找到多個相符的 MCP 工具,已選擇 {{tool}}", + "no_tool": "找不到符合需求的 MCP 工具:{{tool}}", + "url_context": "Gemini 不支援同時使用網頁內容與函式呼叫" } }, "message": { @@ -791,25 +791,25 @@ "model": "切換模型" }, "useful": { - "label": "設置為上下文", + "label": "設為上下文", "tip": "在這組訊息中,該訊息將被選擇加入上下文" } }, "multiple": { "select": { - "empty": "未選中任何訊息", + "empty": "未選取任何訊息", "label": "多選" } }, "navigation": { - "bottom": "回到底部", + "bottom": "回到底端", "close": "關閉", "first": "已經是第一條訊息", "history": "聊天歷史", "last": "已經是最後一條訊息", "next": "下一條訊息", "prev": "上一條訊息", - "top": "回到頂部" + "top": "回到頂端" }, "resend": "重新傳送", "save": { @@ -835,8 +835,8 @@ "title": "檔案" }, "maintext": { - "description": "包括主要的文本內容", - "title": "主文本" + "description": "包括主要的文字內容", + "title": "主文字" }, "thinking": { "description": "包括模型思考內容", @@ -866,13 +866,13 @@ "title": "選擇知識庫" }, "content": { - "tip": "已選擇 {{count}} 項內容,文本類型將合併儲存為一個筆記", + "tip": "已選擇 {{count}} 項內容,文字類型將合併儲存為一個筆記", "title": "選擇要儲存的內容類型" } }, "title": "儲存到知識庫" }, - "label": "保存", + "label": "儲存", "topic": { "knowledge": { "content": { @@ -881,21 +881,21 @@ } }, "empty": { - "no_content": "此話題沒有可保存的內容" + "no_content": "此話題沒有可儲存的內容" }, "error": { - "save_failed": "保存話題失敗,請檢查知識庫設定" + "save_failed": "儲存話題失敗,請檢查知識庫設定" }, "loading": "正在分析話題內容...", "select": { "content": { - "label": "選擇要保存的內容類型", + "label": "選擇要儲存的內容類型", "selected_tip": "已選擇 {{count}} 項內容,來自 {{messages}} 條訊息", - "tip": "話題將以包含完整對話上下文的形式保存到知識庫" + "tip": "話題將以包含完整對話上下文的形式儲存到知識庫" } }, - "success": "話題已成功保存到知識庫({{count}} 項內容)", - "title": "保存話題到知識庫" + "success": "話題已成功儲存到知識庫({{count}} 項內容)", + "title": "儲存話題到知識庫" } } }, @@ -905,18 +905,18 @@ }, "code_collapsible": "程式碼區塊可折疊", "code_editor": { - "autocompletion": "自動補全", - "fold_gutter": "折疊控件", - "highlight_active_line": "高亮當前行", + "autocompletion": "自動完成", + "fold_gutter": "折疊控制項", + "highlight_active_line": "醒目標示目前行", "keymap": "快捷鍵", "title": "程式碼編輯器" }, "code_execution": { "timeout_minutes": { - "label": "超時時間", - "tip": "程式碼執行超時時間(分鐘)" + "label": "逾時時間", + "tip": "程式碼執行逾時時間(分鐘)" }, - "tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!", + "tip": "可執行的程式碼區塊工具列中會顯示 [執行] 按鈕,請注意不要執行危險程式碼!", "title": "程式碼執行" }, "code_fancy_block": { @@ -925,7 +925,7 @@ }, "code_image_tools": { "label": "啟用預覽工具", - "tip": "為 mermaid 等程式碼區塊渲染後的圖像啟用預覽工具" + "tip": "為 mermaid 等程式碼區塊渲染後的影像啟用預覽工具" }, "code_wrappable": "程式碼區塊可自動換行", "context_count": { @@ -934,10 +934,10 @@ }, "max": "不限", "max_tokens": { - "confirm": "設置最大 Token 數", - "confirm_content": "設置單次交互所用的最大 Token 數,會影響返回結果的長度。要根據模型上下文限制來設定,否則會發生錯誤", + "confirm": "設定最大 Token 數", + "confirm_content": "設定單次互動的最大 Token 數,會影響回應內容的長度。請依模型的上下文上限調整,否則可能發生錯誤", "label": "最大 Token 數", - "tip": "模型可以生成的最大 Token 數。要根據模型上下文限制來設定,否則會發生錯誤" + "tip": "模型可產生的最大 Token 數。請依模型的上下文上限調整,否則可能發生錯誤" }, "reset": "重設", "set_as_default": "設為預設助手", @@ -952,7 +952,7 @@ }, "top_p": { "label": "Top-P", - "tip": "模型生成文字的隨機程度。值越小,AI 生成的內容越單調,也越容易理解;值越大,AI 回覆的詞彙範圍越大,越多樣化" + "tip": "模型產生文字的隨機程度。值越小,AI 產生的內容越單調,也越容易理解;值越大,AI 回應的詞彙範圍越大,越多樣化" } }, "suggestions": { @@ -985,23 +985,23 @@ "label": "匯出為 Markdown", "reason": "匯出為 Markdown (包含思考)" }, - "notes": "導出到筆記", + "notes": "匯出到筆記", "notion": "匯出到 Notion", "obsidian": "匯出到 Obsidian", - "obsidian_atributes": "配置筆記屬性", + "obsidian_atributes": "設定筆記屬性", "obsidian_btn": "確定", "obsidian_created": "建立時間", "obsidian_created_placeholder": "請選擇建立時間", "obsidian_export_failed": "匯出失敗", "obsidian_export_success": "匯出成功", - "obsidian_fetch_error": "獲取 Obsidian 保管庫失敗", - "obsidian_fetch_folders_error": "獲取文件夾結構失敗", - "obsidian_loading": "加載中...", + "obsidian_fetch_error": "取得 Obsidian 保管庫失敗", + "obsidian_fetch_folders_error": "取得資料夾結構失敗", + "obsidian_loading": "載入中...", "obsidian_no_vault_selected": "請先選擇一個保管庫", "obsidian_no_vaults": "未找到 Obsidian 保管庫", "obsidian_operate": "處理方式", "obsidian_operate_append": "追加", - "obsidian_operate_new_or_overwrite": "新建(如果存在就覆蓋)", + "obsidian_operate_new_or_overwrite": "新增(若已存在則覆蓋)", "obsidian_operate_placeholder": "請選擇處理方式", "obsidian_operate_prepend": "前置", "obsidian_path": "路徑", @@ -1020,9 +1020,9 @@ "obsidian_vault_placeholder": "請選擇保管庫名稱", "siyuan": "匯出到思源筆記", "title": "匯出", - "title_naming_failed": "標題生成失敗,使用預設標題", - "title_naming_success": "標題生成成功", - "wait_for_title_naming": "正在生成標題...", + "title_naming_failed": "產生標題失敗,改用預設標題", + "title_naming_success": "已成功產生標題", + "wait_for_title_naming": "正在產生標題...", "word": "匯出為 Word", "yuque": "匯出到語雀" }, @@ -1049,7 +1049,7 @@ }, "code": { "auto_update_to_latest": "檢查更新並安裝最新版本", - "bun_required_message": "運行 CLI 工具需要安裝 Bun 環境", + "bun_required_message": "運作 CLI 工具需要安裝 Bun 環境", "cli_tool": "CLI 工具", "cli_tool_placeholder": "選擇要使用的 CLI 工具", "custom_path": "自訂路徑", @@ -1057,7 +1057,7 @@ "custom_path_required": "此終端機需要設定自訂路徑", "custom_path_set": "自訂終端機路徑設定成功", "description": "快速啟動多個程式碼 CLI 工具,提高開發效率", - "env_vars_help": "輸入自定義環境變數(每行一個,格式:KEY=value)", + "env_vars_help": "輸入自訂環境變數(每行一個,格式:KEY=value)", "environment_variables": "環境變數", "folder_placeholder": "選擇工作目錄", "install_bun": "安裝 Bun", @@ -1067,7 +1067,7 @@ "error": "啟動失敗,請重試", "label": "啟動", "success": "啟動成功", - "validation_error": "請完成所有必填項目:CLI 工具、模型和工作目錄" + "validation_error": "請完成所有必填欄位:CLI 工具、模型和工作目錄" }, "launching": "啟動中...", "model": "模型", @@ -1083,11 +1083,11 @@ "working_directory": "工作目錄" }, "code_block": { - "collapse": "折疊", + "collapse": "收合", "copy": { "failed": "複製失敗", "label": "複製", - "source": "複製源碼", + "source": "複製原始碼", "success": "已複製" }, "download": { @@ -1096,26 +1096,26 @@ }, "label": "下載", "png": "下載 PNG", - "source": "下載源碼", + "source": "下載原始碼", "svg": "下載 SVG" }, "edit": { "label": "編輯", "save": { "failed": { - "label": "保存失敗", - "message_not_found": "保存失敗,沒有找到對應的消息" + "label": "儲存失敗", + "message_not_found": "儲存失敗,找不到對應的訊息" }, - "label": "保存修改", - "success": "已保存" + "label": "儲存變更", + "success": "已儲存" } }, "expand": "展開", "more": "更多", - "run": "運行代碼", + "run": "執行", "split": { - "label": "分割視圖", - "restore": "取消分割視圖" + "label": "分割檢視", + "restore": "還原分割檢視" }, "wrap": { "off": "停用自動換行", @@ -1134,19 +1134,19 @@ "assistant_one": "助手", "assistant_other": "助手", "avatar": "頭像", - "back": "返回", + "back": "回上一頁", "browse": "瀏覽", "cancel": "取消", "chat": "聊天", "clear": "清除", "close": "關閉", - "collapse": "折疊", + "collapse": "收合", "completed": "已完成", "confirm": "確認", "copied": "已複製", "copy": "複製", "copy_failed": "複製失敗", - "current": "当前", + "current": "目前", "cut": "剪下", "default": "預設", "delete": "刪除", @@ -1154,9 +1154,9 @@ "delete_failed": "刪除失敗", "delete_success": "刪除成功", "description": "描述", - "detail": "詳情", + "detail": "詳細資訊", "disabled": "已停用", - "docs": "文件", + "docs": "說明文件", "download": "下載", "duplicate": "複製", "edit": "編輯", @@ -1168,7 +1168,7 @@ }, "expand": "展開", "file": { - "not_supported": "不支持的文件類型 {{type}}" + "not_supported": "不支援的檔案類型 {{type}}" }, "footnote": "引用內容", "footnotes": "引用", @@ -1180,7 +1180,7 @@ "invalid_value": "無效值", "knowledge_base": "知識庫", "language": "語言", - "loading": "加載中...", + "loading": "載入中...", "model": "模型", "models": "模型", "more": "更多", @@ -1201,7 +1201,7 @@ "provider": "供應商", "reasoning_content": "已深度思考", "refresh": "重新整理", - "regenerate": "重新生成", + "regenerate": "重新產生", "rename": "重新命名", "reset": "重設", "save": "儲存", @@ -1211,20 +1211,20 @@ "select_all": "全選", "selected": "已選擇", "selectedItems": "已選擇 {{count}} 項", - "selectedMessages": "選中 {{count}} 條訊息", + "selectedMessages": "已選取 {{count}} 則訊息", "settings": "設定", "sort": { "pinyin": { - "asc": "按拼音升序", - "desc": "按拼音降序", - "label": "按拼音排序" + "asc": "依拼音遞增", + "desc": "依拼音遞減", + "label": "依拼音排序" } }, "stop": "停止", "success": "成功", "swap": "交換", "topics": "話題", - "unknown": "Unknown", + "unknown": "未知", "unnamed": "未命名", "update_success": "更新成功", "upload_files": "上傳檔案", @@ -1237,60 +1237,60 @@ "endpoint_type": { "anthropic": "Anthropic", "gemini": "Gemini", - "image-generation": "圖像生成 (OpenAI)", + "image-generation": "影像產生(OpenAI)", "jina-rerank": "Jina Rerank", "openai": "OpenAI", "openai-response": "OpenAI-Response" }, "error": { - "availableProviders": "可用提供商", + "availableProviders": "可用供應商", "availableTools": "可用工具", "backup": { "file_format": "備份檔案格式錯誤" }, "boundary": { "default": { - "devtools": "打開除錯面板", + "devtools": "開啟除錯面板", "message": "似乎出現了一些問題...", "reload": "重新載入" }, - "details": "詳細信息", + "details": "詳細資訊", "mcp": { - "invalid": "無效的MCP伺服器" + "invalid": "無效的 MCP 伺服器" } }, "cause": "錯誤原因", "chat": { "chunk": { - "non_json": "返回了無效的資料格式" + "non_json": "回傳了無效的資料格式" }, - "insufficient_balance": "請前往 {{provider}} 充值", - "no_api_key": "您未配置 API 密钥,请前往 {{provider}} 获取API密钥", - "quota_exceeded": "您今日{{quota}}免费配额已用尽,请前往 {{provider}} 获取API密钥,配置API密钥后继续使用", - "response": "出現錯誤。如果尚未設定 API 金鑰,請前往設定 > 模型提供者中設定金鑰" + "insufficient_balance": "請前往 {{provider}} 儲值。", + "no_api_key": "尚未設定 API 金鑰,請前往 {{provider}} 取得 API 金鑰。", + "quota_exceeded": "今日免費配額({{quota}})已用盡,請前往 {{provider}} 取得 API 金鑰並完成設定後再繼續使用。", + "response": "發生錯誤。請確認是否已在「設定 > 供應商」設定 API 金鑰。" }, "content": "內容", - "data": "数据", - "detail": "錯誤詳情", - "details": "詳細信息", + "data": "資料", + "detail": "錯誤詳細資訊", + "details": "詳細資訊", "errors": "錯誤", "finishReason": "結束原因", "functionality": "功能", "http": { "400": "請求錯誤,請檢查請求參數是否正確。如果修改了模型設定,請重設到預設設定", "401": "身份驗證失敗,請檢查 API 金鑰是否正確", - "403": "禁止存取,請檢查是否實名認證,或聯絡供應商商問被禁止原因", - "404": "模型不存在或者請求路徑錯誤", + "403": "禁止存取,請確認帳號是否完成實名認證,或聯絡供應商詢問原因", + "404": "模型不存在或請求路徑錯誤", "429": "請求過多,請稍後再試", "500": "伺服器錯誤,請稍後再試", "502": "閘道器錯誤,請稍後再試", "503": "服務無法使用,請稍後再試", - "504": "閘道器超時,請稍後再試" + "504": "閘道器逾時,請稍後再試" }, "lastError": "最後錯誤", - "maxEmbeddingsPerCall": "每次調用的最大嵌入", + "maxEmbeddingsPerCall": "每次呼叫的最大嵌入", "message": "錯誤訊息", - "missing_user_message": "無法切換模型回應:原始用戶訊息已被刪除。請發送新訊息以獲得此模型回應。", + "missing_user_message": "無法切換模型回應:原始使用者訊息已被刪除。請傳送新訊息以獲得此模型回應。", "model": { "exists": "模型已存在", "not_exists": "模型不存在" @@ -1301,44 +1301,44 @@ "no_api_key": "API 金鑰未設定", "no_response": "無回應", "originalError": "原錯誤", - "originalMessage": "原消息", + "originalMessage": "原始訊息", "parameter": "參數", "pause_placeholder": "回應已暫停", "prompt": "提示詞", - "provider": "提供商", + "provider": "供應商", "providerId": "提供者 ID", "provider_disabled": "模型供應商未啟用", "reason": "原因", "render": { - "description": "消息內容渲染失敗,請檢查消息內容格式是否正確", + "description": "訊息內容渲染失敗,請檢查訊息內容格式是否正確", "title": "渲染錯誤" }, "requestBody": "請求內容", - "requestBodyValues": "请求体", + "requestBodyValues": "請求本文參數", "requestUrl": "請求路徑", - "response": "響應", - "responseBody": "响应内容", - "responseHeaders": "响应首部", - "responses": "響應", + "response": "回應", + "responseBody": "回應本文", + "responseHeaders": "回應標頭", + "responses": "回應", "role": "角色", - "stack": "堆棧信息", + "stack": "堆疊追蹤", "status": "狀態碼", "statusCode": "狀態碼", - "statusText": "狀態文本", - "text": "文本", + "statusText": "狀態文字", + "text": "文字", "toolInput": "工具輸入", - "toolName": "工具名", + "toolName": "工具名稱", "unknown": "未知錯誤", "usage": "用量", - "user_message_not_found": "無法找到原始用戶訊息", + "user_message_not_found": "無法找到原始使用者訊息", "value": "值", "values": "值" }, "export": { "assistant": "助手", "attached_files": "附件", - "conversation_details": "會話詳細資訊", - "conversation_history": "會話歷史", + "conversation_details": "對話詳細資訊", + "conversation_history": "對話紀錄", "created": "建立時間", "last_updated": "最後更新", "messages": "訊息數", @@ -1366,7 +1366,7 @@ "document": "文件", "edit": "編輯", "error": { - "open_path": "無法開啟路徑: {{path}}" + "open_path": "無法開啟路徑:{{path}}" }, "file": "檔案", "image": "圖片", @@ -1407,15 +1407,15 @@ }, "html_artifacts": { "capture": { - "label": "捕獲頁面", + "label": "擷取頁面", "to_clipboard": "複製到剪貼簿", - "to_file": "保存為圖片" + "to_file": "儲存為圖片" }, "code": "程式碼", - "empty_preview": "無內容可展示", - "generating": "生成中", + "empty_preview": "沒有內容可顯示", + "generating": "產生中", "preview": "預覽", - "split": "分屏" + "split": "分割畫面" }, "import": { "chatgpt": { @@ -1466,7 +1466,7 @@ "chunk_size_change_warning": "分段大小和重疊大小修改只針對新新增的內容有效", "chunk_size_placeholder": "預設值(不建議修改)", "chunk_size_too_large": "分段大小不能超過模型上下文限制({{max_context}})", - "chunk_size_tooltip": "將文件切割分段,每段的大小,不能超過模型上下文限制", + "chunk_size_tooltip": "將檔案切割分段,每段大小不得超過模型的上下文上限", "clear_selection": "清除選擇", "delete": "刪除", "delete_confirm": "確定要刪除此知識庫嗎?", @@ -1474,16 +1474,16 @@ "dimensions_auto_set": "自動設定嵌入維度", "dimensions_default": "模型將使用預設嵌入維度", "dimensions_error_invalid": "無效的嵌入維度", - "dimensions_set_right": "⚠️ 請確保模型支援所設置的嵌入維度大小", - "dimensions_size_placeholder": "留空表示不設置", + "dimensions_set_right": "⚠️ 請確認模型支援所設定的嵌入維度大小", + "dimensions_size_placeholder": "留空表示不設定", "dimensions_size_too_large": "嵌入維度不能超過模型上下文限制({{max_context}})", "dimensions_size_tooltip": "嵌入維度大小,數值越大消耗的 Token 也越多。留空則不傳遞 dimensions 參數。", "directories": "目錄", "directory_placeholder": "請輸入目錄路徑", - "document_count": "請求文件片段數量", + "document_count": "請求檔案片段數量", "document_count_default": "預設", - "document_count_help": "請求文件片段數量越多,附帶的資訊越多,但需要消耗的 Token 也越多", - "drag_file": "拖拽檔案到這裡", + "document_count_help": "請求檔案片段數量越多,附帶的資訊越多,但需要消耗的 Token 也越多", + "drag_file": "拖曳檔案到這裡", "drag_image": "拖曳圖片到這裡", "edit_remark": "修改備註", "edit_remark_placeholder": "請輸入備註內容", @@ -1491,7 +1491,7 @@ "embedding_model_required": "知識庫嵌入模型是必需的", "empty": "暫無知識庫", "error": { - "failed_to_create": "知識庫創建失敗", + "failed_to_create": "知識庫建立失敗", "failed_to_edit": "知識庫編輯失敗", "model_invalid": "未選擇模型", "video": { @@ -1511,7 +1511,7 @@ "text": "遷移" }, "confirm": { - "content": "檢測到嵌入模型或維度有變更,無法直接保存配置,可以執行遷移。知識庫遷移不會刪除舊知識庫,而是建立一個副本之後重新處理所有知識庫條目,可能消耗大量 tokens,請謹慎操作。", + "content": "偵測到嵌入模型或維度有變更,無法直接儲存設定,可以執行遷移。知識庫遷移不會刪除舊知識庫,而是建立一個副本後重新處理所有知識庫條目,可能消耗大量 Token,請謹慎操作。", "ok": "開始遷移", "title": "知識庫遷移" }, @@ -1524,7 +1524,7 @@ "target_model": "目標模型" }, "model_info": "模型資訊", - "name_required": "知識庫名稱為必填項目", + "name_required": "知識庫名稱為必填欄位", "no_bases": "暫無知識庫", "no_match": "不符合知識庫內容", "no_provider": "知識庫模型供應商遺失,該知識庫將不再支援,請重新建立知識庫", @@ -1532,7 +1532,7 @@ "not_support": "知識庫資料庫引擎已更新,該知識庫將不再支援,請重新建立知識庫", "notes": "筆記", "notes_placeholder": "輸入此知識庫的附加資訊或上下文...", - "provider_not_found": "未找到服務商", + "provider_not_found": "未找到供應商", "quota": "{{name}} 剩餘配額:{{quota}}", "quota_empty": "今日{{name}}額度不足,請前往官網申請", "quota_infinity": "{{name}} 配額:無限制", @@ -1541,10 +1541,10 @@ "search_placeholder": "輸入查詢內容", "settings": { "preprocessing": "預處理", - "preprocessing_tooltip": "預處理上傳的文件", + "preprocessing_tooltip": "預處理上傳的檔案", "title": "知識庫設定" }, - "sitemap_added": "添加成功", + "sitemap_added": "新增成功", "sitemap_placeholder": "請輸入網站地圖 URL", "sitemaps": "網站", "source": "來源", @@ -1559,15 +1559,15 @@ "status_preprocess_failed": "預處理失敗", "status_processing": "處理中", "subtitle_file": "字幕檔案", - "threshold": "匹配度閾值", + "threshold": "相符度門檻", "threshold_placeholder": "未設定", - "threshold_too_large_or_small": "閾值不能大於 1 或小於 0", + "threshold_too_large_or_small": "門檻值不能大於 1 或小於 0", "threshold_tooltip": "用於衡量使用者問題與知識庫內容之間的相關性(0-1)", "title": "知識庫", - "topN": "返回結果數量", + "topN": "回傳結果數量", "topN_placeholder": "未設定", - "topN_too_large_or_small": "返回結果數量不能大於 30 或小於 1", - "topN_tooltip": "返回的匹配結果數量,數值越大,匹配結果越多,但消耗的 Token 也越多", + "topN_too_large_or_small": "回傳結果數量不能大於 30 或小於 1", + "topN_tooltip": "回傳的相符結果數量,數值越大,相符結果越多,但消耗的 Token 也越多", "url_added": "網址已新增", "url_placeholder": "請輸入網址,多個網址用換行符號分隔", "urls": "網址", @@ -1599,7 +1599,7 @@ }, "launchpad": { "apps": "應用", - "minapps": "小程序" + "minapps": "小程式" }, "lmstudio": { "keep_alive_time": { @@ -1620,10 +1620,10 @@ "add_user_failed": "新增使用者失敗", "all_users": "所有使用者", "cannot_delete_default_user": "不能刪除預設使用者", - "configure_memory_first": "請先配置記憶設定", + "configure_memory_first": "請先設定記憶設定", "content": "內容", "current_user": "目前使用者", - "custom": "自定義", + "custom": "自訂", "default": "預設", "default_user": "預設使用者", "delete_confirm": "確定要刪除這條記憶嗎?", @@ -1655,35 +1655,35 @@ "loading": "載入記憶中...", "loading_memories": "正在載入記憶...", "memories_description": "顯示 {{count}} / {{total}} 條記憶", - "memories_reset_success": "{{user}} 的所有記憶已成功重置", + "memories_reset_success": "{{user}} 的所有記憶已成功重設", "memory": "個記憶", "memory_content": "記憶內容", "memory_placeholder": "輸入記憶內容...", - "new_user_id": "新使用者ID", - "new_user_id_placeholder": "輸入唯一的使用者ID", + "new_user_id": "新使用者 ID", + "new_user_id_placeholder": "輸入唯一的使用者 ID", "no_matching_memories": "未找到符合的記憶", "no_memories": "暫無記憶", "no_memories_description": "開始新增您的第一個記憶吧", - "not_configured_desc": "請在記憶設定中配置嵌入和LLM模型以啟用記憶功能。", - "not_configured_title": "記憶未配置", + "not_configured_desc": "請在記憶設定中設定嵌入和 LLM 模型以啟用記憶功能。", + "not_configured_title": "記憶未設定", "pagination_total": "第 {{start}}-{{end}} 項,共 {{total}} 項", "please_enter_memory": "請輸入記憶內容", "please_select_embedding_model": "請選擇一個嵌入模型", - "please_select_llm_model": "請選擇一個LLM模型", + "please_select_llm_model": "請選擇一個 LLM 模型", "reset_filters": "重設篩選", - "reset_memories": "重置記憶", + "reset_memories": "重設記憶", "reset_memories_confirm_content": "確定要永久刪除 {{user}} 的所有記憶嗎?此操作無法復原。", - "reset_memories_confirm_title": "重置所有記憶", - "reset_memories_failed": "重置記憶失敗", - "reset_user_memories": "重置使用者記憶", - "reset_user_memories_confirm_content": "確定要重置 {{user}} 的所有記憶嗎?", - "reset_user_memories_confirm_title": "重置使用者記憶", - "reset_user_memories_failed": "重置使用者記憶失敗", + "reset_memories_confirm_title": "重設所有記憶", + "reset_memories_failed": "重設記憶失敗", + "reset_user_memories": "重設使用者記憶", + "reset_user_memories_confirm_content": "確定要重設 {{user}} 的所有記憶嗎?", + "reset_user_memories_confirm_title": "重設使用者記憶", + "reset_user_memories_failed": "重設使用者記憶失敗", "score": "分數", "search": "搜尋", "search_placeholder": "搜尋記憶...", "select_embedding_model_placeholder": "選擇嵌入模型", - "select_llm_model_placeholder": "選擇LLM模型", + "select_llm_model_placeholder": "選擇 LLM 模型", "select_user": "選擇使用者", "settings": "設定", "settings_title": "記憶體設定", @@ -1701,16 +1701,16 @@ "user": "使用者", "user_created": "使用者 {{user}} 建立並切換成功", "user_deleted": "使用者 {{user}} 刪除成功", - "user_id": "使用者ID", - "user_id_exists": "此使用者ID已存在", - "user_id_invalid_chars": "使用者ID只能包含字母、數字、連字符和底線", - "user_id_placeholder": "輸入使用者ID(可選)", - "user_id_required": "使用者ID為必填欄位", - "user_id_reserved": "'default-user' 為保留字,請使用其他ID", - "user_id_rules": "使用者ID必须唯一,只能包含字母、數字、連字符(-)和底線(_)", - "user_id_too_long": "使用者ID不能超過50個字元", + "user_id": "使用者 ID", + "user_id_exists": "此使用者 ID 已存在", + "user_id_invalid_chars": "使用者 ID 只能包含英文字母、數字、連字號與底線", + "user_id_placeholder": "輸入使用者 ID(可選)", + "user_id_required": "使用者 ID 為必填欄位", + "user_id_reserved": "'default-user' 為保留字,請使用其他 ID", + "user_id_rules": "使用者 ID 必須唯一,只能包含英文字母、數字、連字號(-)與底線(_)", + "user_id_too_long": "使用者 ID 不能超過 50 個字元", "user_management": "使用者管理", - "user_memories_reset": "{{user}} 的所有記憶已重置", + "user_memories_reset": "{{user}} 的所有記憶已重設", "user_switch_failed": "切換使用者失敗", "user_switched": "使用者內容已切換至 {{user}}", "users": "使用者" @@ -1729,8 +1729,8 @@ } }, "connection": { - "failed": "連接失敗", - "success": "連接成功" + "failed": "連線失敗", + "success": "連線成功" } }, "assistant": { @@ -1739,8 +1739,8 @@ } }, "attachments": { - "pasted_image": "剪切板圖片", - "pasted_text": "剪切板文件" + "pasted_image": "剪貼簿圖片", + "pasted_text": "剪貼簿檔案" }, "backup": { "failed": "備份失敗", @@ -1750,7 +1750,7 @@ "success": "備份成功" }, "branch": { - "error": "分支创建失败" + "error": "分支建立失敗" }, "chat": { "completion": { @@ -1766,7 +1766,7 @@ }, "delete": { "confirm": { - "content": "確認刪除選中的 {{count}} 條訊息嗎?", + "content": "確定要刪除選取的 {{count}} 則訊息嗎?", "title": "刪除確認" }, "failed": "刪除失敗", @@ -1782,11 +1782,11 @@ "empty_url": "無法下載圖片,可能是提示詞包含敏感內容或違禁詞彙", "error": { "chunk_overlap_too_large": "分段重疊不能大於分段大小", - "copy": "复制失败", + "copy": "複製失敗", "dimension_too_large": "內容尺寸過大", "enter": { "api": { - "host": "請先輸入您的 API 主機地址", + "host": "請先輸入您的 API 主機位址", "label": "請先輸入您的 API 金鑰" }, "model": "請先選擇一個模型", @@ -1802,39 +1802,39 @@ "enter": { "model": "請選擇一個模型" }, - "nutstore": "無效的坚果云設定", - "nutstore_token": "無效的坚果云 Token", + "nutstore": "無效的堅果雲設定", + "nutstore_token": "無效的堅果雲 Token", "proxy": { "url": "無效的代理伺服器 URL" }, "webdav": "無效的 WebDAV 設定" }, "joplin": { - "export": "匯出 Joplin 失敗,請保持 Joplin 已運行並檢查連接狀態或檢查設定", + "export": "匯出 Joplin 失敗,請確認 Joplin 正在運作,並檢查連線狀態與設定。", "no_config": "未設定 Joplin 授權 Token 或 URL" }, "markdown": { "export": { - "preconf": "導出 Markdown 文件到預先設定的路徑失敗", - "specified": "導出 Markdown 文件失敗" + "preconf": "匯出 Markdown 檔案到預先設定的路徑失敗", + "specified": "匯出 Markdown 檔案失敗" } }, "notes": { - "export": "導出筆記失敗" + "export": "匯出筆記失敗" }, "notion": { - "export": "匯出 Notion 錯誤,請檢查連接狀態並對照文件檢查設定", + "export": "匯出 Notion 失敗,請檢查連線狀態並參考說明文件確認設定", "no_api_key": "未設定 Notion API Key 或 Notion Database ID", "no_content": "沒有可匯出至 Notion 的內容" }, "siyuan": { - "export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置", - "no_config": "未配置思源筆記 API 地址或令牌" + "export": "匯出思源筆記失敗,請檢查連線狀態並參考說明文件確認設定", + "no_config": "未設定思源筆記 API 位址或權杖" }, "unknown": "未知錯誤", "yuque": { - "export": "匯出語雀錯誤,請檢查連接狀態並對照文件檢查設定", - "no_config": "未設定語雀 Token 或知識庫 Url" + "export": "匯出語雀失敗,請檢查連線狀態並參考說明文件確認設定", + "no_config": "未設定語雀 Token 或知識庫 URL" } }, "group": { @@ -1885,15 +1885,15 @@ }, "video": { "error": { - "local_file_missing": "本地視頻檔案路徑不存在", - "unsupported_type": "不支援的視頻類型", - "youtube_url_missing": "YouTube 視頻連結不存在" + "local_file_missing": "本機影片檔案路徑不存在", + "unsupported_type": "不支援的影片類型", + "youtube_url_missing": "YouTube 影片連結不存在" } } }, "processing": "正在處理...", "regenerate": { - "confirm": "重新生成會覆蓋目前訊息" + "confirm": "重新產生會覆寫目前訊息" }, "reset": { "confirm": { @@ -1907,8 +1907,8 @@ } }, "restore": { - "failed": "恢復失敗", - "success": "恢復成功" + "failed": "還原失敗", + "success": "還原成功" }, "save": { "success": { @@ -1922,34 +1922,34 @@ }, "markdown": { "export": { - "preconf": "成功導出 Markdown 文件到預先設定的路徑", - "specified": "成功導出 Markdown 文件" + "preconf": "成功匯出 Markdown 檔案到預先設定的路徑", + "specified": "成功匯出 Markdown 檔案" } }, "notes": { - "export": "成功導出到筆記" + "export": "成功匯出到筆記" }, "notion": { "export": "成功匯出到 Notion" }, "siyuan": { - "export": "導出到思源筆記成功" + "export": "成功匯出到思源筆記" }, "yuque": { "export": "成功匯出到語雀" } }, "switch": { - "disabled": "請等待當前回覆完成" + "disabled": "請等待目前回覆完成" }, "tools": { - "abort_failed": "工具調用中斷失敗", - "aborted": "工具調用已中斷", + "abort_failed": "工具呼叫中斷失敗", + "aborted": "工具呼叫已中斷", "autoApproveEnabled": "此工具已啟用自動批准", "cancelled": "已取消", "completed": "已完成", "error": "發生錯誤", - "invoking": "調用中", + "invoking": "呼叫中", "pending": "等待中", "preview": "預覽", "raw": "原始碼" @@ -1971,7 +1971,7 @@ }, "warning": { "rate": { - "limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試" + "limit": "傳送過於頻繁,請在 {{seconds}} 秒後再嘗試" } }, "websearch": { @@ -1979,12 +1979,12 @@ "fetch_complete": "{{count}} 個搜尋結果", "rag": "正在執行 RAG...", "rag_complete": "保留 {{countBefore}} 個結果中的 {{countAfter}} 個...", - "rag_failed": "RAG 失敗,返回空結果..." + "rag_failed": "RAG 失敗,回傳空結果..." } }, "minapp": { - "add_to_launchpad": "添加到启动台", - "add_to_sidebar": "添加到侧边栏", + "add_to_launchpad": "新增到啟動台", + "add_to_sidebar": "新增到側邊欄", "popup": { "close": "關閉小工具", "devtools": "開發者工具", @@ -1992,13 +1992,13 @@ "goForward": "下一頁", "minimize": "最小化小工具", "openExternal": "在瀏覽器中開啟", - "open_link_external_off": "当前:使用預設視窗開啟連結", - "open_link_external_on": "当前:在瀏覽器中開啟連結", + "open_link_external_off": "目前:使用預設視窗開啟連結", + "open_link_external_on": "目前:在瀏覽器中開啟連結", "refresh": "重新整理", "rightclick_copyurl": "右鍵複製 URL" }, - "remove_from_launchpad": "从启动台移除", - "remove_from_sidebar": "从侧边栏移除", + "remove_from_launchpad": "從啟動台移除", + "remove_from_sidebar": "從側邊欄移除", "sidebar": { "close": { "title": "關閉" @@ -2010,7 +2010,7 @@ "title": "隱藏" }, "remove_custom": { - "title": "刪除自定義應用" + "title": "刪除自訂小工具" } }, "title": "小工具" @@ -2018,28 +2018,28 @@ "minapps": { "ant-ling": "Ant Ling", "baichuan": "百小應", - "baidu-ai-search": "百度AI搜索", + "baidu-ai-search": "百度 AI 搜尋", "chatglm": "智譜清言", - "dangbei": "當貝AI", + "dangbei": "當貝 AI", "doubao": "豆包", "hailuo": "海螺", - "metaso": "秘塔AI搜索", - "nami-ai": "納米AI", - "nami-ai-search": "納米AI搜索", + "metaso": "秘塔 AI 搜尋", + "nami-ai": "奈米 AI", + "nami-ai-search": "奈米 AI 搜尋", "qwen": "通義千問", "sensechat": "商量", - "stepfun": "階躍AI", + "stepfun": "階躍 AI", "tencent-yuanbao": "騰訊元寶", - "tiangong-ai": "天工AI", + "tiangong-ai": "天工 AI", "wanzhi": "萬知", "wenxin": "文心一言", - "wps-copilot": "WPS靈犀", + "wps-copilot": "WPS 靈犀", "xiaoyi": "小藝", "zhihu": "知乎直答" }, "miniwindow": { "alert": { - "google_login": "提示:如遇到Google登入提示\"不受信任的瀏覽器\",請先在小程序列表中的Google小程序中完成帳號登入,再在其它小程序使用Google登入" + "google_login": "提示:若 Google 登入時顯示「不受信任的瀏覽器」,請先在小程式清單中的 Google 小程式完成帳號登入,再到其他小程式使用 Google 登入。" }, "clipboard": { "empty": "剪貼簿為空" @@ -2054,7 +2054,7 @@ "backspace_clear": "按 Backspace 清空", "copy_last_message": "按 C 鍵複製", "esc": "按 ESC {{action}}", - "esc_back": "返回", + "esc_back": "回傳", "esc_close": "關閉視窗", "esc_pause": "暫停" }, @@ -2065,7 +2065,7 @@ } }, "tooltip": { - "pin": "窗口置頂" + "pin": "視窗置頂" } }, "models": { @@ -2078,12 +2078,12 @@ "embedding_dimensions": "嵌入維度", "embedding_model": "嵌入模型", "embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增", - "enable_tool_use": "工具調用", + "enable_tool_use": "工具呼叫", "filter": { "by_tag": "按標籤篩選", "selected": "已選標籤" }, - "function_calling": "函數調用", + "function_calling": "函式呼叫", "invalid_model": "無效模型", "no_matches": "無可用模型", "parameter_name": "參數名稱", @@ -2108,8 +2108,8 @@ "reasoning": "推理", "rerank_model": "重排模型", "rerank_model_not_support_provider": "目前,重新排序模型不支援此提供者({{provider}})", - "rerank_model_support_provider": "目前重排序模型僅支持部分服務商 ({{provider}})", - "rerank_model_tooltip": "在設定 -> 模型服務中點擊管理按鈕添加", + "rerank_model_support_provider": "目前重排序模型僅支援部分供應商 ({{provider}})", + "rerank_model_tooltip": "在「設定 → 模型服務」點選 [管理] 按鈕新增", "search": { "placeholder": "搜尋模型...", "tooltip": "搜尋模型" @@ -2139,110 +2139,110 @@ } }, "navigate": { - "provider_settings": "跳轉到服務商設置界面" + "provider_settings": "前往供應商設定頁面" }, "notes": { "auto_rename": { - "empty_note": "筆記為空,無法生成名稱", - "failed": "生成筆記名稱失敗", - "label": "生成筆記名稱", - "success": "筆記名稱生成成功" + "empty_note": "筆記為空,無法產生名稱", + "failed": "產生筆記名稱失敗", + "label": "產生筆記名稱", + "success": "已成功產生筆記名稱" }, - "characters": "字符", - "collapse": "收起", + "characters": "字元", + "collapse": "收合", "content_placeholder": "請輸入筆記內容...", "copyContent": "複製內容", - "delete": "删除", + "delete": "刪除", "delete_confirm": "確定要刪除此 {{type}} 嗎?", "delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?", "delete_note_confirm": "確定要刪除筆記 \"{{name}}\" 嗎?", - "drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入", + "drop_markdown_hint": "拖曳 .md 檔案或資料夾到此處匯入", "empty": "暫無筆記", "expand": "展開", "export_failed": "匯出至知識庫失敗", "export_knowledge": "匯出筆記至知識庫", "export_success": "成功匯出至知識庫", - "folder": "文件夹", - "new_folder": "新建文件夾", - "new_note": "新建筆記", - "no_content_to_copy": "沒有內容可複制", - "no_file_selected": "請選擇要上傳的文件", + "folder": "資料夾", + "new_folder": "新增資料夾", + "new_note": "新增筆記", + "no_content_to_copy": "沒有內容可複製", + "no_file_selected": "請選擇要上傳的檔案", "no_valid_files": "沒有上傳有效的檔案", - "open_folder": "打開外部文件夾", - "open_outside": "從外部打開", - "rename": "重命名", - "rename_changed": "由於安全策略,文件名已從 {{original}} 更改為 {{final}}", + "open_folder": "開啟外部資料夾", + "open_outside": "從外部開啟", + "rename": "重新命名", + "rename_changed": "基於安全性考量,檔名已從 {{original}} 變更為 {{final}}", "save": "儲存到筆記", "search": { - "both": "名稱+內容", + "both": "名稱 + 內容", "content": "內容", - "found_results": "找到 {{count}} 個結果 (名稱: {{nameCount}}, 內容: {{contentCount}})", - "more_matches": "個匹配", - "searching": "搜索中...", - "show_less": "收起" + "found_results": "找到 {{count}} 個結果(名稱:{{nameCount}},內容:{{contentCount}})", + "more_matches": "更多符合結果", + "searching": "搜尋中...", + "show_less": "收合" }, "settings": { "data": { - "apply": "應用", - "apply_path_failed": "應用路徑失敗", - "current_work_directory": "當前工作目錄", + "apply": "套用", + "apply_path_failed": "套用路徑失敗", + "current_work_directory": "目前工作目錄", "invalid_directory": "選擇的目錄無效或無權限", "path_required": "請選擇工作目錄", "path_updated": "工作目錄更新成功", - "reset_failed": "重置失敗", - "reset_to_default": "重置為默認", + "reset_failed": "重設失敗", + "reset_to_default": "重設為預設值", "select": "選擇", "select_directory_failed": "選擇目錄失敗", - "title": "數據設置", - "work_directory_description": "工作目錄是存儲所有筆記文件的位置。\n更改工作目錄不會移動現有文件,請手動遷移文件。", + "title": "資料設定", + "work_directory_description": "工作目錄是儲存所有筆記檔案的位置。\n變更工作目錄不會移動現有檔案,請手動移轉檔案。", "work_directory_placeholder": "選擇筆記工作目錄" }, "display": { - "compress_content": "縮減欄寬", - "compress_content_description": "開啟後將限制每行字數,使屏幕顯示的內容減少", - "default_font": "默認字體", - "font_size": "字體大小", - "font_size_description": "調整字體大小以獲得更好的閱讀體驗 (10-30px)", + "compress_content": "內容壓縮", + "compress_content_description": "啟用後會限制每行字數,減少畫面顯示的內容,但讓長段落更好閱讀。", + "default_font": "預設字型", + "font_size": "字型大小", + "font_size_description": "調整字型大小以獲得更好的閱讀體驗 (10-30px)", "font_size_large": "大", "font_size_medium": "中", "font_size_small": "小", - "font_title": "字體設置", - "serif_font": "襯線字體", + "font_title": "字型設定", + "serif_font": "襯線字型", "show_table_of_contents": "顯示目錄大綱", - "show_table_of_contents_description": "顯示目錄大綱側邊欄,方便文檔內導航", - "title": "顯示" + "show_table_of_contents_description": "顯示目錄大綱側邊欄,方便在檔案內導覽", + "title": "顯示設定" }, "editor": { "edit_mode": { - "description": "在編輯視圖下,新筆記默認採用的編輯模式", - "preview_mode": "實時預覽", - "source_mode": "源碼模式", - "title": "默認編輯視圖" + "description": "在編輯檢視中,新筆記的預設編輯模式", + "preview_mode": "即時預覽", + "source_mode": "原始碼模式", + "title": "預設編輯檢視" }, - "title": "編輯器設置", + "title": "編輯器設定", "view_mode": { - "description": "新筆記默認的視圖模式", + "description": "新筆記的預設檢視模式", "edit_mode": "編輯模式", "read_mode": "閱讀模式", - "title": "默認視圖" + "title": "預設檢視" }, - "view_mode_description": "設置新標籤頁的默認視圖模式。" + "view_mode_description": "設定新標籤頁的預設檢視模式。" }, "title": "更多選項" }, "show_starred": "顯示收藏的筆記", - "sort_a2z": "文件名(A-Z)", + "sort_a2z": "檔名(A-Z)", "sort_created_asc": "建立時間(從舊到新)", "sort_created_desc": "建立時間(從新到舊)", "sort_updated_asc": "更新時間(從舊到新)", "sort_updated_desc": "更新時間(從新到舊)", - "sort_z2a": "文件名(Z-A)", - "spell_check": "拼寫檢查", - "spell_check_tooltip": "啟用/禁用拼寫檢查", + "sort_z2a": "檔名(Z-A)", + "spell_check": "拼字檢查", + "spell_check_tooltip": "啟用/停用拼字檢查", "star": "收藏筆記", "starred_notes": "收藏的筆記", "title": "筆記", - "unsaved_changes": "你有未儲存的內容,確定要離開嗎?", + "unsaved_changes": "尚有未儲存的內容,確定要離開嗎?", "unstar": "取消收藏", "untitled_folder": "新資料夾", "untitled_note": "無標題筆記", @@ -2255,14 +2255,14 @@ "notification": { "assistant": "助手回應", "knowledge": { - "error": "無法將 {{type}} 加入知識庫: {{error}}", + "error": "無法將 {{type}} 加入知識庫:{{error}}", "success": "成功將 {{type}} 新增至知識庫" }, - "tip": "如果回應成功,則只針對超過30秒的訊息發出提醒" + "tip": "如果回應成功,則只針對超過 30 秒的訊息發出提醒" }, "ocr": { "builtin": { - "system": "系统 OCR" + "system": "系統 OCR" }, "error": { "provider": { @@ -2270,17 +2270,17 @@ "existing": "提供者已存在", "get_providers": "取得可用提供者失敗", "not_found": "OCR 提供者不存在", - "update_failed": "更新配置失敗" + "update_failed": "更新設定失敗" }, - "unknown": "OCR過程發生錯誤" + "unknown": "OCR 過程發生錯誤" }, "file": { - "not_supported": "不支持的文件類型 {{type}}" + "not_supported": "不支援的檔案類型 {{type}}" }, "processing": "OCR 處理中...", "warning": { "provider": { - "fallback": "已回退到 {{name}},這可能導致問題" + "fallback": "已改用 {{name}},這可能導致問題" } } }, @@ -2302,7 +2302,7 @@ "stop": "停止 OVMS", "stopping": "停止中" }, - "description": "

1. 下載 OV 模型。

2. 在 'Manager' 中新增模型。

僅支援 Windows!

OVMS 安裝路徑: '%USERPROFILE%\\.cherrystudio\\ovms' 。

請參考 Intel OVMS 指南

", + "description": "

1. 下載 OV 模型。

2. 在 'Manager' 中新增模型。

僅支援 Windows!

OVMS 安裝路徑:'%USERPROFILE%\\.cherrystudio\\ovms' 。

請參考 Intel OVMS 指南

", "download": { "button": "下載", "error": "下載失敗", @@ -2317,25 +2317,25 @@ "placeholder": "必填,例如 Qwen3-8B-int4-ov", "required": "請輸入模型名稱" }, - "model_source": "模型來源:", - "model_task": "模型任務:", + "model_source": "模型來源:", + "model_task": "模型任務:", "success": "下載成功", - "success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下載成功,請前往 OVMS 管理界面添加模型", + "success_desc": "模型\"{{modelName}}\"-\"{{modelId}}\"下載成功,請前往 OVMS 管理介面新增模型", "tip": "模型正在下載,有時需要幾個小時。請耐心等候...", "title": "下載 Intel OpenVINO 模型" }, "failed": { - "install": "安裝 OVMS 失敗:", + "install": "安裝 OVMS 失敗:", "install_code_100": "未知錯誤", "install_code_101": "僅支援 Intel(R) CPU", "install_code_102": "僅支援 Windows", "install_code_103": "下載 OVMS runtime 失敗", "install_code_104": "安裝 OVMS runtime 失敗", - "install_code_105": "創建 ovdnd.exe 失敗", - "install_code_106": "創建 run.bat 失敗", + "install_code_105": "建立 ovdnd.exe 失敗", + "install_code_106": "建立 run.bat 失敗", "install_code_110": "清理舊 OVMS runtime 失敗", - "run": "執行 OVMS 失敗:", - "stop": "停止 OVMS 失敗:" + "run": "執行 OVMS 失敗:", + "stop": "停止 OVMS 失敗:" }, "status": { "not_installed": "OVMS 未安裝", @@ -2353,7 +2353,7 @@ "square": "方形" }, "auto_create_paint": "自動新增圖片", - "auto_create_paint_tip": "圖片生成後,會自動新增圖片", + "auto_create_paint_tip": "圖片產生後會自動新增圖片", "background": "背景", "background_options": { "auto": "自動", @@ -2373,31 +2373,31 @@ }, "custom_size": "自訂尺寸", "edit": { - "image_file": "編輯圖像", - "magic_prompt_option_tip": "智能優化編輯提示詞", - "model_tip": "部分編輯僅支持 V_2 和 V_2_TURBO 版本", - "number_images_tip": "生成的編輯結果數量", + "image_file": "編輯影像", + "magic_prompt_option_tip": "開啟後會自動調整編輯提示詞,以提升效果", + "model_tip": "部分編輯僅支援 V_2 和 V_2_TURBO 版本", + "number_images_tip": "要產生的編輯結果數量", "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", "seed_tip": "控制編輯結果的隨機性", - "style_type_tip": "編輯後的圖像風格,僅適用於 V_2 及以上版本" + "style_type_tip": "編輯後的影像風格,僅適用於 V_2 及以上版本" }, "generate": { "height": "高度", - "magic_prompt_option_tip": "智能優化生成效果的提示詞", + "magic_prompt_option_tip": "開啟後會自動調整提示詞,以提升效果", "model_tip": "模型版本:V2 是最新 API 模型,V2A 是高速模型,V_1 是初代模型,_TURBO 是高速處理版", - "negative_prompt_tip": "描述不想在圖像中出現的內容", - "number_images_tip": "一次生成的圖片數量", - "person_generation": "人物生成", - "person_generation_tip": "允許模型生成人物圖像", + "negative_prompt_tip": "描述不想在影像中出現的內容", + "number_images_tip": "一次產生的圖片數量", + "person_generation": "產生人物", + "person_generation_tip": "允許模型產生人物影像", "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", "safety_tolerance": "安全耐性", - "safety_tolerance_tip": "控制圖像生成的安全耐性,僅適用於 FLUX.1-Kontext-pro 版本", - "seed_tip": "控制圖像生成的隨機性,以重現相同的生成結果", - "style_type_tip": "圖像生成風格,僅適用於 V_2 及以上版本", + "safety_tolerance_tip": "控制影像產生的安全耐性,僅適用於 FLUX.1-Kontext-pro 版本", + "seed_tip": "控制影像產生的隨機性,以重現相同結果", + "style_type_tip": "產生圖片的風格,僅適用於 V_2 及以上版本", "width": "寬度" }, - "generated_image": "生成圖片", - "go_to_settings": "去設置", + "generated_image": "產生的圖片", + "go_to_settings": "前往設定", "guidance_scale": "引導比例", "guidance_scale_tip": "無分類器指導。控制模型在尋找相關影像時對提示詞的遵循程度", "image": { @@ -2433,10 +2433,10 @@ }, "negative_prompt": "反向提示詞", "negative_prompt_tip": "描述你不想在圖片中出現的內容", - "no_image_generation_model": "暫無可用的圖片生成模型,請先新增模型並設置端點類型為 {{endpoint_type}}", - "number_images": "生成數量", - "number_images_tip": "一次生成的圖片數量 (1-4)", - "paint_course": "教程", + "no_image_generation_model": "暫無可用的圖片產生模型,請先新增模型並設定端點類型為 {{endpoint_type}}", + "number_images": "張數", + "number_images_tip": "一次產生的圖片數量 (1-4)", + "paint_course": "教學", "per_image": "每張圖片", "per_images": "每張圖片", "person_generation_options": { @@ -2448,9 +2448,9 @@ "prompt_enhancement": "提示詞增強", "prompt_enhancement_tip": "開啟後將提示重寫為詳細的、適合模型的版本", "prompt_placeholder": "描述你想建立的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山", - "prompt_placeholder_edit": "輸入你的圖片描述,文本繪製用 ' 雙引號 ' 包裹", - "prompt_placeholder_en": "輸入英文圖片描述,目前僅支持英文提示詞", - "proxy_required": "打開代理並開啟”TUN 模式 “查看生成圖片或複製到瀏覽器開啟,後續會支持國內直連", + "prompt_placeholder_edit": "輸入你的圖片描述,文字繪製用 ' 雙引號 ' 包裹", + "prompt_placeholder_en": "輸入英文圖片描述,目前僅支援英文提示詞", + "proxy_required": "請開啟代理並啟用「TUN 模式」,即可查看產生的圖片;也可以複製到瀏覽器開啟。日後將支援免代理直接連線。", "quality": "品質", "quality_options": { "auto": "自動", @@ -2459,19 +2459,19 @@ "medium": "中" }, "regenerate": { - "confirm": "這將覆蓋已生成的圖片,是否繼續?" + "confirm": "這將覆蓋已產生的圖片,是否繼續?" }, "remix": { "image_file": "參考圖", "image_weight": "參考圖權重", - "image_weight_tip": "調整參考圖像的影響程度", - "magic_prompt_option_tip": "智能優化重混提示詞", + "image_weight_tip": "調整參考影像的影響程度", + "magic_prompt_option_tip": "開啟後會自動調整重混提示詞,以提升效果", "model_tip": "選擇重混使用的 AI 模型版本", "negative_prompt_tip": "描述不想在重混結果中出現的元素", - "number_images_tip": "生成的重混結果數量", + "number_images_tip": "要產生的重混結果數量", "rendering_speed_tip": "控制渲染速度與品質之間的平衡,僅適用於 V_3 版本", "seed_tip": "控制重混結果的隨機性", - "style_type_tip": "重混後的圖像風格,僅適用於 V_2 及以上版本" + "style_type_tip": "重混後的影像風格,僅適用於 V_2 及以上版本" }, "rendering_speed": "渲染速度", "rendering_speeds": { @@ -2479,14 +2479,14 @@ "quality": "高品質", "turbo": "快速" }, - "req_error_model": "獲取模型失敗", - "req_error_no_balance": "請檢查令牌的有效性", + "req_error_model": "取得模型失敗", + "req_error_no_balance": "請檢查權杖的有效性", "req_error_text": "伺服器繁忙或提示詞中出現「版權詞」或「敏感詞」,請重試。", - "req_error_token": "請檢查令牌的有效性", + "req_error_token": "請檢查權杖的有效性", "required_field": "必填欄位", "seed": "隨機種子", - "seed_desc_tip": "相同的種子和提示詞可以生成相似的圖片,設置 -1 每次生成都不一樣", - "seed_tip": "相同的種子和提示詞可以生成相似的圖片", + "seed_desc_tip": "相同的種子和提示詞可以產生相似的圖片,設定為 -1 時,每次結果都會不同", + "seed_tip": "相同的種子和提示詞可以產生相似的圖片", "select_model": "選擇模型", "style_type": "風格", "style_types": { @@ -2504,10 +2504,10 @@ "uploaded_input": "已上傳輸入", "upscale": { "detail": "細節", - "detail_tip": "控制放大圖像的細節增強程度", + "detail_tip": "控制放大影像的細節增強程度", "image_file": "需要放大的圖片", - "magic_prompt_option_tip": "智能優化放大提示詞", - "number_images_tip": "生成的放大結果數量", + "magic_prompt_option_tip": "開啟後會自動調整放大提示詞,以提升效果", + "number_images_tip": "要產生的放大結果數量", "resemblance": "相似度", "resemblance_tip": "控制放大結果與原圖的相似程度", "seed_tip": "控制放大結果的隨機性" @@ -2544,15 +2544,15 @@ "image": "複製為圖片", "src": "複製圖片來源" }, - "dialog": "開啟預覽窗口", + "dialog": "開啟預覽視窗", "label": "預覽", "pan": "移動", "pan_down": "下移", "pan_left": "左移", "pan_right": "右移", "pan_up": "上移", - "reset": "重置", - "source": "查看源碼", + "reset": "重設", + "source": "檢視原始碼", "zoom_in": "放大", "zoom_out": "縮小" }, @@ -2565,7 +2565,7 @@ "302ai": "302.AI", "ai-gateway": "AI 閘道器", "aihubmix": "AiHubMix", - "aionly": "唯一AI (AiOnly)", + "aionly": "唯一 AI (AiOnly)", "alayanew": "Alaya NeW", "anthropic": "Anthropic", "aws-bedrock": "AWS Bedrock", @@ -2669,20 +2669,20 @@ "title": "粗體" }, "bulletList": { - "description": "建立簡單的項目符號清單", + "description": "建立簡單的列點清單", "title": "無序清單" }, "calloutInfo": { - "description": "添加資訊提示框", + "description": "新增資訊提示框", "title": "資訊提示框" }, "calloutWarning": { - "description": "添加警告提示框", + "description": "新增警告提示框", "title": "警告提示框" }, "code": { - "description": "插入代碼片段", - "title": "代碼" + "description": "插入程式碼片段", + "title": "程式碼" }, "codeBlock": { "description": "插入程式碼片段", @@ -2693,11 +2693,11 @@ "title": "分欄" }, "date": { - "description": "插入當前日期", + "description": "插入目前日期", "title": "日期" }, "divider": { - "description": "添加水平分隔線", + "description": "新增水平分隔線", "title": "分隔線" }, "hardBreak": { @@ -2733,7 +2733,7 @@ "title": "圖片" }, "inlineCode": { - "description": "添加行內程式碼", + "description": "新增行內程式碼", "title": "行內程式碼" }, "inlineMath": { @@ -2745,7 +2745,7 @@ "title": "斜體" }, "link": { - "description": "添加連結", + "description": "新增連結", "title": "連結" }, "noCommandsFound": "未找到命令", @@ -2778,8 +2778,8 @@ "title": "下劃線" }, "undo": { - "description": "撤銷上一步操作", - "title": "撤銷" + "description": "復原上一步操作", + "title": "復原" } }, "dragHandle": "拖拽塊", @@ -2799,7 +2799,7 @@ "propertyName": "屬性名稱" }, "image": { - "placeholder": "添加圖片" + "placeholder": "新增圖片" }, "imageUploader": { "embedImage": "嵌入圖片", @@ -2808,29 +2808,29 @@ "invalidType": "請選擇圖片檔案", "invalidUrl": "無效的圖片連結", "processing": "正在處理圖片...", - "title": "添加圖片", + "title": "新增圖片", "tooLarge": "圖片大小不能超過 10MB", "upload": "上傳", "uploadError": "圖片上傳失敗", "uploadFile": "上傳檔案", "uploadHint": "支援 JPG、PNG、GIF 等格式,最大 10MB", "uploadSuccess": "圖片上傳成功", - "uploadText": "點擊或拖拽圖片到此處上傳", + "uploadText": "點選或拖曳圖片到此處上傳", "uploading": "正在上傳圖片", - "urlPlaceholder": "貼上圖片連結地址", - "urlRequired": "請輸入圖片連結地址" + "urlPlaceholder": "貼上圖片連結網址", + "urlRequired": "請輸入圖片連結網址" }, "link": { - "remove": "移除鏈接", - "text": "鏈接標題", - "textPlaceholder": "請輸入鏈接標題", - "url": "鏈接地址" + "remove": "移除連結", + "text": "連結標題", + "textPlaceholder": "請輸入連結標題", + "url": "連結網址" }, "math": { "placeholder": "輸入 LaTeX 公式" }, - "placeholder": "輸入'/'調用命令", - "plusButton": "點擊在下方添加", + "placeholder": "輸入'/'呼叫命令", + "plusButton": "點選以在下方新增", "toolbar": { "blockMath": "數學公式塊", "blockquote": "引用", @@ -2865,13 +2865,13 @@ "copy": "複製", "explain": "解釋", "quote": "引用", - "refine": "優化", + "refine": "潤飾", "search": "搜尋", "summary": "總結", "translate": "翻譯" }, "translate": { - "smart_translate_tips": "智能翻譯:內容將優先翻譯為目標語言;內容已是目標語言的,將翻譯為備用語言" + "smart_translate_tips": "智慧翻譯:內容將優先翻譯為目標語言;內容已是目標語言時,將翻譯為備用語言" }, "window": { "c_copy": "C 複製", @@ -2883,7 +2883,7 @@ "original_show": "顯示原文", "pin": "置頂", "pinned": "已置頂", - "r_regenerate": "R 重新生成" + "r_regenerate": "R 重新產生" } }, "name": "劃詞助手", @@ -2905,7 +2905,7 @@ }, "advanced": { "filter_list": { - "description": "進階功能,建議有經驗的用戶在了解情況下再進行設置", + "description": "進階功能,建議有經驗的使用者在了解情況下再進行設定", "title": "篩選名單" }, "filter_mode": { @@ -2921,12 +2921,12 @@ "description": "目前僅支援 Windows & macOS", "mac_process_trust_hint": { "button": { - "go_to_settings": "去設定", - "open_accessibility_settings": "打開輔助使用設定" + "go_to_settings": "前往設定", + "open_accessibility_settings": "開啟輔助使用設定" }, "description": { "0": "劃詞助手需「輔助使用權限」才能正常工作。", - "1": "請點擊「去設定」,並在稍後彈出的權限請求彈窗中點擊 「打開系統設定」 按鈕,然後在之後的應用程式列表中找到 「Cherry Studio」,並開啟權限開關。", + "1": "請點選「前往設定」,並在稍後出現的權限請求對話框中點選「開啟系統設定」按鈕,接著在應用程式清單中找到「Cherry Studio」,並開啟權限開關。", "2": "完成設定後,請再次開啟劃詞助手。" }, "title": "輔助使用權限" @@ -2937,8 +2937,8 @@ "filter_modal": { "title": "應用篩選名單", "user_tips": { - "mac": "請輸入應用的 Bundle ID,每行一個,不區分大小寫,可以模糊匹配。例如:com.google.Chrome、com.apple.mail等", - "windows": "請輸入應用的執行檔名稱,每行一個,不區分大小寫,可以模糊匹配。例如:chrome.exe、weixin.exe、Cherry Studio.exe等" + "mac": "請輸入應用程式的 Bundle ID(每行一個、不區分大小寫,可模糊比對)。例如:com.google.Chrome、com.apple.mail 等", + "windows": "請輸入應用程式的執行檔名稱(每行一個、不區分大小寫,可模糊比對)。例如:chrome.exe、weixin.exe、Cherry Studio.exe 等" } }, "search_modal": { @@ -2974,14 +2974,14 @@ "ctrlkey_note": "劃詞後,再 按住 Ctrl 鍵,才顯示工具列", "description": "劃詞後,觸發取詞並顯示工具列的方式", "description_note": { - "mac": "若使用了快捷鍵或鍵盤映射工具對 ⌘ 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", + "mac": "若使用了快捷鍵或鍵盤對映工具對 ⌘ 鍵進行了重新對應,可能導致部分應用程式無法劃詞。", "windows": "在某些應用中可能無法透過 Ctrl 鍵劃詞。若使用了 AHK 等工具對 Ctrl 鍵進行了重新對應,可能導致部分應用程式無法劃詞。" }, "selected": "劃詞", "selected_note": "劃詞後,立即顯示工具列", "shortcut": "快捷鍵", "shortcut_link": "前往快捷鍵設定", - "shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設置取詞快捷鍵並啟用。", + "shortcut_note": "劃詞後,使用快捷鍵顯示工具列。請在快捷鍵設定頁面中設定取詞快捷鍵並啟用。", "title": "取詞方式" } }, @@ -3011,7 +3011,7 @@ "prompt": { "copy_placeholder": "複製佔位符", "label": "使用者提示詞 (Prompt)", - "placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的末尾", + "placeholder": "使用佔位符 {{text}} 代表選取的文字,不填寫時,選取的文字將加到本提示詞的結尾", "placeholder_text": "佔位符", "tooltip": "使用者提示詞,作為使用者輸入的補充,不會覆蓋助手的系統提示詞" }, @@ -3026,7 +3026,7 @@ "title": "自動關閉" }, "auto_pin": { - "description": "預設將視窗置於頂部", + "description": "預設將視窗置於頂端", "title": "自動置頂" }, "follow_toolbar": { @@ -3034,11 +3034,11 @@ "title": "跟隨工具列" }, "opacity": { - "description": "設置視窗的預設透明度,100% 為完全不透明", + "description": "設定視窗的預設透明度,100% 為完全不透明", "title": "透明度" }, "remember_size": { - "description": "應用運行期間,視窗會按上次調整的大小顯示", + "description": "應用運作期間,視窗會按上次調整的大小顯示", "title": "記住大小" }, "title": "功能視窗" @@ -3058,7 +3058,7 @@ }, "debug": { "open": "開啟", - "title": "調試面板" + "title": "除錯面板" }, "description": "一款為創作者而生的強大 AI 助手", "downloading": "正在下載...", @@ -3105,30 +3105,30 @@ }, "data": { "app_data": { - "copy_data_option": "複製數據,會自動重啟後將原始目錄數據複製到新目錄", - "copy_failed": "複製數據失敗", - "copy_success": "成功複製數據到新位置", - "copy_time_notice": "複製數據將需要一些時間,複製期間不要關閉應用", - "copying": "正在複製數據到新位置...", - "copying_warning": "數據複製中,不要強制退出應用,複製完成後會自動重啟應用", - "label": "應用數據", - "migration_title": "數據遷移", + "copy_data_option": "複製資料,會在重新啟動後將原始目錄的資料複製到新目錄", + "copy_failed": "複製資料失敗", + "copy_success": "成功複製資料到新位置", + "copy_time_notice": "複製資料需要一些時間,期間請勿關閉應用程式", + "copying": "正在複製資料到新位置...", + "copying_warning": "資料複製中,請勿強制關閉應用程式;複製完成後會自動重新啟動應用程式", + "label": "應用程式資料", + "migration_title": "資料移轉", "new_path": "新路徑", "original_path": "原始路徑", - "path_change_failed": "數據目錄更改失敗", + "path_change_failed": "資料目錄變更失敗", "path_changed_without_copy": "路徑已變更成功", - "restart_notice": "變更數據目錄後可能需要重啟應用才能生效", - "select": "修改目錄", - "select_error": "變更數據目錄失敗", + "restart_notice": "變更資料目錄後可能需要重新啟動應用程式才能生效", + "select": "變更目錄", + "select_error": "變更資料目錄失敗", "select_error_in_app_path": "新路徑與應用安裝路徑相同,請選擇其他路徑", "select_error_root_path": "新路徑不能是根路徑", "select_error_same_path": "新路徑與舊路徑相同,請選擇其他路徑", "select_error_write_permission": "新路徑沒有寫入權限", "select_not_empty_dir": "新路徑不為空", - "select_not_empty_dir_content": "新路徑不為空,選擇複製將覆蓋新路徑中的數據,有數據丟失和複製失敗的風險,是否繼續?", - "select_success": "數據目錄已變更,應用將重啟以應用變更", - "select_title": "變更應用數據目錄", - "stop_quit_app_reason": "應用目前正在遷移數據,不能退出" + "select_not_empty_dir_content": "新路徑不為空,若選擇複製將覆寫新路徑中的資料,可能導致資料遺失或複製失敗。是否繼續?", + "select_success": "資料目錄已變更,應用程式將重新啟動以套用變更", + "select_title": "變更應用程式資料目錄", + "stop_quit_app_reason": "應用程式目前正在移轉資料,無法關閉" }, "app_knowledge": { "button": { @@ -3144,12 +3144,12 @@ "label": "應用程式日誌" }, "backup": { - "skip_file_data_help": "備份時跳過備份圖片、知識庫等數據文件,僅備份聊天記錄和設置。減少空間佔用,加快備份速度", + "skip_file_data_help": "備份時跳過圖片、知識庫等資料檔案,只備份聊天記錄與設定。可減少空間佔用並加快備份速度", "skip_file_data_title": "精簡備份" }, "clear_cache": { "button": "清除快取", - "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作不可恢復,是否繼續?", + "confirm": "清除快取將刪除應用快取資料,包括小工具資料。此操作無法恢復,是否繼續?", "error": "清除快取失敗", "success": "快取清除成功", "title": "清除快取" @@ -3158,11 +3158,11 @@ "title": "資料目錄" }, "divider": { - "basic": "基礎數據設定", + "basic": "基本資料設定", "cloud_storage": "雲備份設定", "export_settings": "匯出設定", "import_settings": "匯入設定", - "third_party": "第三方連接" + "third_party": "第三方連結" }, "export_menu": { "docx": "匯出為 Word", @@ -3170,10 +3170,10 @@ "joplin": "匯出到 Joplin", "markdown": "匯出為 Markdown", "markdown_reason": "匯出為 Markdown(包含思考)", - "notes": "導出到筆記", + "notes": "匯出到筆記", "notion": "匯出到 Notion", "obsidian": "匯出到 Obsidian", - "plain_text": "複製為純文本", + "plain_text": "複製為純文字", "siyuan": "匯出到思源筆記", "title": "匯出選單設定", "yuque": "匯出到語雀" @@ -3182,14 +3182,14 @@ "confirm": { "button": "選擇備份檔案" }, - "content": "匯出部分數據,包括聊天記錄、設定。請注意,備份過程可能需要一些時間,感謝您的耐心等候。", + "content": "匯出部分資料,包括聊天記錄與設定。請注意,備份過程可能需要一些時間,感謝耐心等候。", "lan": { "auto_close_tip": "將於 {{seconds}} 秒後自動關閉...", "confirm_close_message": "檔案傳輸正在進行中。關閉將會中斷傳輸。您確定要強制關閉嗎?", "confirm_close_title": "確認關閉", "connected": "已連線", "connection_failed": "連線失敗", - "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請打開 Cherry Studio App 掃描此 QR 碼。", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請開啟 Cherry Studio App 掃描此 QR 碼。", "error": { "init_failed": "初始化失敗", "no_file": "未選擇檔案", @@ -3197,13 +3197,13 @@ "send_failed": "無法傳送檔案" }, "force_close": "強制關閉", - "generating_qr": "正在生成 QR 碼...", + "generating_qr": "正在產生 QR 碼...", "noZipSelected": "未選取壓縮檔案", - "scan_qr": "請使用手機掃描QR碼", + "scan_qr": "請使用手機掃描 QR 碼", "selectZip": "選擇壓縮檔案", - "sendZip": "開始恢復資料", + "sendZip": "開始還原資料", "status": { - "completed": "轉帳完成", + "completed": "傳輸完成", "connected": "已連線", "connecting": "連線中...", "disconnected": "已斷線", @@ -3211,7 +3211,7 @@ "initializing": "正在初始化連線...", "preparing": "正在準備傳輸...", "sending": "傳輸中 {{progress}}%", - "waiting_qr_scan": "請掃描QR碼以連接" + "waiting_qr_scan": "請掃描 QR 碼以連線" }, "title": "區域網路傳輸", "transfer_progress": "傳輸進度" @@ -3221,20 +3221,20 @@ "hour_interval_one": "{{count}} 小時", "hour_interval_other": "{{count}} 小時", "import_settings": { - "button": "匯入 Json 檔案", - "chatgpt": "匯入 ChatGPT 數據", - "title": "匯入外部應用程式數據" + "button": "匯入 JSON 檔案", + "chatgpt": "匯入 ChatGPT 資料", + "title": "匯入外部應用程式資料" }, "joplin": { "check": { "button": "檢查", "empty_token": "請先輸入 Joplin 授權 Token", "empty_url": "請先輸入 Joplin 剪輯服務 URL", - "fail": "Joplin 連接驗證失敗", - "success": "Joplin 連接驗證成功" + "fail": "Joplin 連結驗證失敗", + "success": "Joplin 連結驗證成功" }, "export_reasoning": { - "help": "啟用後,匯出內容將包含助手生成的思維鏈(思考過程)。", + "help": "啟用後,匯出內容將包含助手產生的思維鏈(思考過程)。", "title": "匯出時包含思維鏈" }, "help": "在 Joplin 選項中,啟用剪輯服務(無需安裝瀏覽器外掛),確認埠編號,並複製授權 Token", @@ -3246,7 +3246,7 @@ }, "limit": { "appDataDiskQuota": "磁碟空間警告", - "appDataDiskQuotaDescription": "資料目錄空間即將用盡, 請清理磁碟空間, 否則會丟失數據" + "appDataDiskQuotaDescription": "資料目錄空間即將用盡,請清理磁碟空間,否則可能導致資料遺失" }, "local": { "autoSync": { @@ -3254,55 +3254,55 @@ "off": "關閉" }, "backup": { - "button": "本地備份", + "button": "本機備份", "manager": { "columns": { "actions": "操作", - "fileName": "文件名", + "fileName": "檔名", "modifiedTime": "修改時間", "size": "大小" }, "delete": { "confirm": { - "multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作無法撤銷。", - "single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作無法撤銷。", + "multiple": "確定要刪除選取的 {{count}} 個備份檔案嗎?此操作無法復原。", + "single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作無法復原。", "title": "確認刪除" }, "error": "刪除失敗", - "selected": "刪除選中", + "selected": "刪除選取內容", "success": { - "multiple": "已刪除 {{count}} 個備份文件", + "multiple": "已刪除 {{count}} 個備份檔案", "single": "刪除成功" }, "text": "刪除" }, "fetch": { - "error": "獲取備份文件失敗" + "error": "無法取得備份檔案" }, - "refresh": "刷新", + "refresh": "重新整理", "restore": { - "error": "恢復失敗", - "success": "恢復成功,應用將很快刷新", - "text": "恢復" + "error": "還原失敗", + "success": "還原成功,應用將很快重新整理", + "text": "還原" }, "select": { "files": { - "delete": "請選擇要刪除的備份文件" + "delete": "請選擇要刪除的備份檔案" } }, - "title": "備份文件管理" + "title": "備份檔案管理" }, "modal": { "filename": { - "placeholder": "請輸入備份文件名" + "placeholder": "請輸入備份檔名" }, - "title": "本地備份" + "title": "本機備份" } }, "directory": { "label": "備份目錄", "placeholder": "請選擇備份目錄", - "select_error_app_data_path": "新路徑不能與應用數據路徑相同", + "select_error_app_data_path": "新路徑不能與應用程式資料路徑相同", "select_error_in_app_install_path": "新路徑不能與應用安裝路徑相同", "select_error_write_permission": "新路徑沒有寫入權限", "select_title": "選擇備份目錄" @@ -3318,15 +3318,15 @@ "minute_interval_other": "{{count}} 分鐘", "noSync": "等待下次備份", "restore": { - "button": "備份文件管理", + "button": "備份檔案管理", "confirm": { - "content": "從本地備份恢復將覆蓋當前數據,是否繼續?", - "title": "確認恢復" + "content": "從本機備份還原會覆寫目前資料,是否繼續?", + "title": "確認還原" } }, "syncError": "備份錯誤", "syncStatus": "備份狀態", - "title": "本地備份" + "title": "本機備份" }, "markdown_export": { "exclude_citations": { @@ -3357,8 +3357,8 @@ }, "message_title": { "use_topic_naming": { - "help": "開啟後,使用快速模型為導出的消息命名標題。該項也會影響所有透過 Markdown 導出的方式", - "title": "使用快速模型為導出的消息命名標題" + "help": "開啟後,使用快速模型為匯出的訊息命名標題。此設定也會影響所有透過 Markdown 匯出的方式", + "title": "使用快速模型為匯出的訊息命名標題" } }, "minute_interval_one": "{{count}} 分鐘", @@ -3370,8 +3370,8 @@ "button": "檢查", "empty_api_key": "未設定 API key", "empty_database_id": "未設定 Database ID", - "error": "連接異常,請檢查網路及 API key 和 Database ID 是否正確", - "fail": "連接失敗,請檢查網路及 API key 和 Database ID 是否正確", + "error": "連線異常,請檢查網路及 API key 和 Database ID 是否正確", + "fail": "連線失敗,請檢查網路及 API key 和 Database ID 是否正確", "success": "連線成功" }, "database_id": "Notion 資料庫 ID", @@ -3380,7 +3380,7 @@ "help": "啟用後,匯出到 Notion 時會包含思維鏈內容。", "title": "匯出時包含思維鏈" }, - "help": "Notion 設定文件", + "help": "Notion 說明文件", "page_name_key": "頁面標題欄位名稱", "page_name_key_placeholder": "請輸入頁面標題欄位名稱,預設為 Name", "title": "Notion 設定" @@ -3390,57 +3390,57 @@ "button": "備份到堅果雲", "modal": { "filename": { - "placeholder": "請輸入備份檔案名" + "placeholder": "請輸入備份檔名" }, "title": "備份到堅果雲" } }, "checkConnection": { - "fail": "堅果雲連接失敗", - "name": "檢查連接", - "success": "已連接堅果雲" + "fail": "堅果雲連線失敗", + "name": "檢查連線", + "success": "已連線至堅果雲" }, "isLogin": "已登入", "login": { "button": "登入" }, "logout": { - "button": "退出登入", - "content": "退出後將無法備份至堅果雲和從堅果雲恢復", - "title": "確定要退出堅果雲登入?" + "button": "登出", + "content": "登出後將無法備份到堅果雲或從堅果雲還原。", + "title": "確定要登出堅果雲嗎?" }, "new_folder": { "button": { "cancel": "取消", "confirm": "確定", - "label": "新建文件夾" + "label": "新增資料夾" } }, "notLogin": "未登入", "path": { - "label": "堅果雲存儲路徑", - "placeholder": "請輸入堅果雲的存儲路徑" + "label": "堅果雲儲存路徑", + "placeholder": "請輸入堅果雲儲存路徑" }, "pathSelector": { - "currentPath": "當前路徑", - "return": "返回", - "title": "堅果雲存儲路徑" + "currentPath": "目前路徑", + "return": "上一層", + "title": "堅果雲儲存路徑" }, "restore": { - "button": "從堅果雲恢復", + "button": "從堅果雲還原", "confirm": { - "content": "從堅果雲恢復將覆蓋目前資料,是否繼續?", - "title": "從堅果雲恢復" + "content": "從堅果雲還原會覆寫目前資料,是否繼續?", + "title": "從堅果雲還原" } }, "title": "堅果雲設定", - "username": "堅果雲用戶名" + "username": "堅果雲使用者名稱" }, "obsidian": { "default_vault": "預設 Obsidian 倉庫", "default_vault_export_failed": "匯出失敗", - "default_vault_fetch_error": "獲取 Obsidian 倉庫失敗", - "default_vault_loading": "正在獲取 Obsidian 倉庫...", + "default_vault_fetch_error": "取得 Obsidian 倉庫失敗", + "default_vault_loading": "正在取得 Obsidian 倉庫...", "default_vault_no_vaults": "未找到 Obsidian 倉庫", "default_vault_placeholder": "請選擇預設 Obsidian 倉庫", "title": "Obsidian 設定" @@ -3458,7 +3458,7 @@ }, "backup": { "button": "立即備份", - "error": "S3 備份失敗: {{message}}", + "error": "S3 備份失敗:{{message}}", "manager": { "button": "管理備份" }, @@ -3473,7 +3473,7 @@ }, "bucket": { "label": "儲存桶", - "placeholder": "Bucket,例如: example" + "placeholder": "Bucket,例如:example" }, "endpoint": { "label": "API 位址", @@ -3492,11 +3492,11 @@ }, "delete": { "confirm": { - "multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。", - "single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。", + "multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作無法復原。", + "single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作無法復原。", "title": "確認刪除" }, - "error": "刪除備份檔案失敗: {{message}}", + "error": "刪除備份檔案失敗:{{message}}", "label": "刪除", "selected": "刪除選中 ({{count}})", "success": { @@ -3506,7 +3506,7 @@ }, "files": { "fetch": { - "error": "取得備份檔案清單失敗: {{message}}" + "error": "取得備份檔案清單失敗:{{message}}" } }, "refresh": "重新整理", @@ -3522,7 +3522,7 @@ }, "region": { "label": "區域", - "placeholder": "Region,例如: us-east-1" + "placeholder": "Region,例如:us-east-1" }, "restore": { "config": { @@ -3530,11 +3530,11 @@ }, "confirm": { "cancel": "取消", - "content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?", + "content": "恢復資料將覆寫目前所有資料,此操作無法復原。確定要繼續嗎?", "ok": "確認恢復", "title": "確認恢復資料" }, - "error": "資料恢復失敗: {{message}}", + "error": "資料恢復失敗:{{message}}", "file": { "required": "請選擇要恢復的備份檔案" }, @@ -3559,38 +3559,38 @@ "label": "精簡備份" }, "syncStatus": { - "error": "同步錯誤: {{message}}", + "error": "同步錯誤:{{message}}", "label": "同步狀態", - "lastSync": "上次同步: {{time}}", + "lastSync": "上次同步:{{time}}", "noSync": "未同步" }, "title": { - "help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等", + "help": "與 AWS S3 API 相容的物件儲存服務,例如 AWS S3、Cloudflare R2、阿里雲 OSS、騰訊雲 COS 等", "label": "S3 相容儲存", "tooltip": "S3 相容儲存設定指南" } }, "siyuan": { - "api_url": "API 地址", + "api_url": "思源筆記 API URL", "api_url_placeholder": "例如:http://127.0.0.1:6806", "box_id": "筆記本 ID", "box_id_placeholder": "請輸入筆記本 ID", "check": { "button": "檢查", - "empty_config": "請填寫 API 地址和令牌", - "error": "連接異常,請檢查網絡連接", - "fail": "連接失敗,請檢查 API 地址和令牌", - "success": "連接成功", - "title": "連接檢查" + "empty_config": "請填寫 API 位址和權杖", + "error": "連線異常,請檢查網路連線", + "fail": "連線失敗,請檢查 API 位址和權杖", + "success": "連線成功", + "title": "連線檢查" }, - "root_path": "文檔根路徑", + "root_path": "思源筆記根路徑", "root_path_placeholder": "例如:/CherryStudio", - "title": "思源筆記配置", + "title": "思源筆記設定", "token": { - "help": "在思源筆記 -> 設置 -> 關於中獲取", - "label": "API 令牌" + "help": "在思源筆記 → 設定 → 關於中取得", + "label": "思源筆記權杖" }, - "token_placeholder": "請輸入思源筆記令牌" + "token_placeholder": "請輸入思源筆記權杖" }, "title": "資料設定", "webdav": { @@ -3603,50 +3603,50 @@ "manager": { "columns": { "actions": "操作", - "fileName": "文件名", + "fileName": "檔名", "modifiedTime": "修改時間", "size": "大小" }, "delete": { "confirm": { - "multiple": "確定要刪除選中的 {{count}} 個備份文件嗎?此操作不可恢復", - "single": "確定要刪除備份文件 \"{{fileName}}\" 嗎?此操作不可恢復", + "multiple": "確定要刪除選取的 {{count}} 個備份檔案嗎?此操作無法復原", + "single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作無法復原", "title": "確認刪除" }, "error": "刪除失敗", - "selected": "刪除選中", + "selected": "刪除選取內容", "success": { - "multiple": "成功刪除 {{count}} 個備份文件", + "multiple": "已刪除 {{count}} 個備份檔案", "single": "刪除成功" }, "text": "刪除" }, "fetch": { - "error": "獲取備份文件失敗" + "error": "無法取得備份檔案" }, - "refresh": "刷新", + "refresh": "重新整理", "restore": { - "error": "恢復失敗", - "success": "恢復成功,應用將在幾秒後刷新", - "text": "恢復" + "error": "還原失敗", + "success": "還原成功,應用程式將在幾秒後重新整理", + "text": "還原" }, "select": { "files": { - "delete": "請選擇要刪除的備份文件" + "delete": "請選擇要刪除的備份檔案" } }, - "title": "備份數據管理" + "title": "備份檔案管理" }, "modal": { "filename": { - "placeholder": "請輸入備份文件名" + "placeholder": "請輸入備份檔名" }, "title": "備份到 WebDAV" } }, "disableStream": { "help": "開啟後,將檔案載入到記憶體中再上傳,可解決部分 WebDAV 服務不相容 chunked 上傳的問題,但會增加記憶體佔用。", - "title": "禁用串流上傳" + "title": "停用串流上傳" }, "host": { "label": "WebDAV 主機位址", @@ -3683,8 +3683,8 @@ "button": "檢查", "empty_repo_url": "請先輸入知識庫 URL", "empty_token": "請先輸入語雀 Token", - "fail": "語雀連接驗證失敗", - "success": "語雀連接驗證成功" + "fail": "語雀連結驗證失敗", + "success": "語雀連結驗證成功" }, "help": "取得語雀 Token", "repo_url": "知識庫 URL", @@ -3696,7 +3696,7 @@ }, "developer": { "enable_developer_mode": "啟用開發者模式", - "help": "啟用開發者模式後,將可以使用調用鏈功能查看模型調用過程的數據流。", + "help": "啟用開發者模式後,可使用呼叫鏈功能檢視模型呼叫過程的資料流。", "title": "開發者模式" }, "display": { @@ -3714,23 +3714,23 @@ "code": "程式碼字型", "default": "預設", "global": "全域字型", - "select": "選擇字體", + "select": "選擇字型", "title": "字型設定" }, "navbar": { "position": { - "label": "導航欄位置", + "label": "導覽列位置", "left": "左側", - "top": "頂部" + "top": "頂端" }, - "title": "導航欄設定" + "title": "導覽列設定" }, "sidebar": { "chat": { "hiddenMessage": "助手是基礎功能,不支援隱藏" }, "disabled": "隱藏的圖示", - "empty": "把要隱藏的功能從左側拖拽到這裡", + "empty": "把要隱藏的功能從左側拖曳到這裡", "files": { "icon": "顯示檔案圖示" }, @@ -3765,7 +3765,7 @@ "title": "自動更新" }, "avatar": { - "builtin": "內置頭像", + "builtin": "內建頭像", "reset": "重設頭像" }, "backup": { @@ -3793,9 +3793,9 @@ "beta_version": "測試版本 (Beta)", "beta_version_tooltip": "功能可能會隨時變化,錯誤較多,升級較快", "rc_version": "預覽版本 (RC)", - "rc_version_tooltip": "相對穩定,請務必提前備份數據", + "rc_version_tooltip": "相對穩定,請務必提前備份資料", "title": "測試計畫", - "tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份數據", + "tooltip": "參與測試計畫,體驗最新功能,但同時也帶來更多風險,請務必提前備份資料", "version_channel_not_match": "預覽版和測試版的切換將在下一個正式版發布時生效", "version_options": "版本選項" }, @@ -3811,10 +3811,10 @@ }, "hardware_acceleration": { "confirm": { - "content": "禁用硬件加速需要重新啟動應用程序才能生效。是否立即重新啟動?", + "content": "停用硬體加速需要重新啟動應用程式才能生效。要立即重新啟動嗎?", "title": "需要重新啟動" }, - "title": "禁用硬件加速" + "title": "停用硬體加速" }, "input": { "auto_translate_with_space": "快速敲擊 3 次空格翻譯", @@ -3836,7 +3836,7 @@ "launch": { "onboot": "開機自動啟動", "title": "啟動", - "totray": "啟動時最小化到系统匣" + "totray": "啟動時最小化到系統匣" }, "math": { "engine": { @@ -3845,58 +3845,58 @@ }, "single_dollar": { "label": "啟用 $...$", - "tip": "渲染單個美元符號 $...$ 包裹的數學公式,默認啟用。" + "tip": "渲染單個美元符號 $...$ 包裹的數學公式,預設啟用。" }, "title": "數學公式設定" }, "mcp": { "actions": "操作", "active": "啟用", - "addError": "添加伺服器失敗", + "addError": "新增伺服器失敗", "addServer": { - "create": "快速創建", + "create": "快速建立", "importFrom": { "connectionFailed": "連線失敗", - "dxt": "導入 DXT 包", - "dxtFile": "DXT 包文件", - "dxtHelp": "選擇包含 MCP 服務器的 .dxt 文件", - "dxtProcessFailed": "處理 DXT 文件失敗", + "dxt": "匯入 DXT 套件", + "dxtFile": "DXT 套件檔案", + "dxtHelp": "選擇包含 MCP 伺服器的 .dxt 檔案", + "dxtProcessFailed": "處理 DXT 檔案失敗", "error": { "multipleServers": "不能從多個伺服器匯入" }, "invalid": "無效的輸入,請檢查 JSON 格式", "json": "從 JSON 匯入", - "method": "導入方式", + "method": "匯入方式", "nameExists": "伺服器已存在:{{name}}", - "noDxtFile": "請選擇一個 DXT 文件", - "oneServer": "每次只能保存一個 MCP 伺服器配置", + "noDxtFile": "請選擇一個 DXT 檔案", + "oneServer": "每次只能儲存一個 MCP 伺服器設定", "placeholder": "貼上 MCP 伺服器 JSON 設定", "selectDxtFile": "選擇 DXT 檔案", - "tooltip": "請從 MCP Servers 的介紹頁面複製配置 JSON(優先使用\n NPX 或 UVX 配置),並粘貼到輸入框中" + "tooltip": "請從 MCP Servers 的介紹頁面複製設定 JSON(優先使用 NPX 或 UVX 設定),並貼上到輸入框中" }, "label": "新增伺服器" }, "addSuccess": "伺服器新增成功", - "advancedSettings": "高級設定", + "advancedSettings": "進階設定", "args": "參數", "argsTooltip": "每個參數佔一行", - "baseUrlTooltip": "遠端 URL 地址", - "builtinServers": "內置伺服器", + "baseUrlTooltip": "遠端 URL 位址", + "builtinServers": "內建伺服器", "builtinServersDescriptions": { - "brave_search": "一個集成了Brave 搜索 API 的 MCP 伺服器實現,提供網頁與本地搜尋雙重功能。需要配置 BRAVE_API_KEY 環境變數", - "didi_mcp": "一個集成了滴滴 MCP 伺服器實現,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要配置 DIDI_API_KEY 環境變數", - "dify_knowledge": "Dify 的 MCP 伺服器實現,提供了一個簡單的 API 來與 Dify 進行互動。需要配置 Dify Key", - "fetch": "用於獲取 URL 網頁內容的 MCP 伺服器", - "filesystem": "實現文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要配置允許訪問的目錄", + "brave_search": "一個整合了 Brave 搜尋 API 的 MCP 伺服器實做,提供網頁與本機搜尋雙重功能。需要設定 BRAVE_API_KEY 環境變數", + "didi_mcp": "一個整合了滴滴 MCP 伺服器實做,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要設定 DIDI_API_KEY 環境變數", + "dify_knowledge": "Dify 的 MCP 伺服器實做,提供了一個簡單的 API 來與 Dify 進行互動。需要設定 Dify Key", + "fetch": "用於取得 URL 網頁內容的 MCP 伺服器", + "filesystem": "實做文件系統操作的模型上下文協議(MCP)的 Node.js 伺服器。需要設定允許存取的目錄", "mcp_auto_install": "自動安裝 MCP 服務(測試版)", - "memory": "基於本地知識圖譜的持久性記憶基礎實現。這使得模型能夠在不同對話間記住使用者的相關資訊。需要配置 MEMORY_FILE_PATH 環境變數。", + "memory": "基於本機知識圖譜的持久性記憶基礎實做。這使得模型能夠在不同對話間記住使用者的相關資訊。需要設定 MEMORY_FILE_PATH 環境變數。", "no": "無描述", - "python": "在安全的沙盒環境中執行 Python 代碼。使用 Pyodide 運行 Python,支援大多數標準庫和科學計算套件", - "sequentialthinking": "一個 MCP 伺服器實現,提供了透過結構化思維過程進行動態和反思性問題解決的工具" + "python": "在安全的沙盒環境中執行 Python 程式碼。使用 Pyodide 執行 Python,支援大多數標準函式庫和科學計算套件", + "sequentialthinking": "一個 MCP 伺服器實做,提供了透過結構化思維過程進行動態和反思性問題解決的工具" }, "command": "指令", "config_description": "設定模型上下文協議伺服器", - "customRegistryPlaceholder": "請輸入私有倉庫位址,如: https://npm.company.com", + "customRegistryPlaceholder": "請輸入私有倉庫位址,如:https://npm.company.com", "deleteError": "刪除伺服器失敗", "deleteServer": "刪除伺服器", "deleteServerConfirm": "確定要刪除此伺服器嗎?", @@ -3911,17 +3911,17 @@ "discover": "發現", "duplicateName": "已存在相同名稱的伺服器", "editJson": "編輯 JSON", - "editMcpJson": "編輯 MCP 配置", + "editMcpJson": "編輯 MCP 設定", "editServer": "編輯伺服器", "env": "環境變數", "envTooltip": "格式:KEY=value,每行一個", "errors": { - "32000": "MCP 伺服器啟動失敗,請根據教程檢查參數是否填寫完整", + "32000": "MCP 伺服器啟動失敗,請根據教學檢查參數是否填寫完整", "toolNotFound": "未找到工具 {{name}}" }, "fetch": { - "button": "獲取伺服器", - "success": "伺服器獲取成功" + "button": "取得伺服器", + "success": "伺服器取得成功" }, "findMore": "更多 MCP", "headers": "請求標頭", @@ -3929,29 +3929,29 @@ "inMemory": "記憶體", "install": "安裝", "installError": "安裝相依套件失敗", - "installHelp": "獲取安裝幫助", + "installHelp": "取得安裝幫助", "installSuccess": "相依套件安裝成功", "jsonFormatError": "JSON 格式錯誤", - "jsonModeHint": "編輯 MCP 伺服器配置的 JSON 表示。保存前請確保格式正確", - "jsonSaveError": "保存 JSON 配置失敗", - "jsonSaveSuccess": "JSON 配置已儲存", - "logoUrl": "標誌網址", + "jsonModeHint": "編輯 MCP 伺服器設定的 JSON 表示。儲存前請確認格式正確", + "jsonSaveError": "儲存 JSON 設定失敗", + "jsonSaveSuccess": "JSON 設定已儲存", + "logoUrl": "Logo URL", "logs": "日誌", - "longRunning": "長時間運行模式", - "longRunningTooltip": "啟用後,伺服器支援長時間任務,接收到進度通知時會重置超時計時器,並延長最大超時時間至10分鐘", + "longRunning": "長時間運作模式", + "longRunningTooltip": "啟用後,伺服器支援長時間任務;收到進度通知時會重設逾時計時器,並將最大逾時時間延長至 10 分鐘", "marketplaces": "市場", - "missingDependencies": "缺失,請安裝它以繼續", + "missingDependencies": "遺失,請安裝它以繼續", "more": { "awesome": "精選的 MCP 伺服器清單", "composio": "Composio MCP 開發工具", "glama": "Glama MCP 伺服器目錄", "higress": "Higress MCP 伺服器", "mcpso": "MCP 伺服器發現平台", - "modelscope": "魔搭社區 MCP 伺服器", + "modelscope": "魔搭社群 MCP 伺服器", "official": "官方 MCP 伺服器集合", "pulsemcp": "Pulse MCP 伺服器", "smithery": "Smithery MCP 工具", - "zhipu": "精選MCP,極速接入" + "zhipu": "精選 MCP,極速接入" }, "name": "名稱", "newServer": "MCP 伺服器", @@ -3962,27 +3962,27 @@ "npx_list": { "actions": "操作", "description": "描述", - "no_packages": "未找到包", + "no_packages": "找不到套件", "npm": "NPM", - "package_name": "包名稱", - "scope_placeholder": "輸入 npm 作用域 (例如 @your-org)", - "scope_required": "請輸入 npm 作用域", - "search": "搜索", - "search_error": "搜索失敗", + "package_name": "套件名稱", + "scope_placeholder": "輸入 npm 作用區域 (例如 @your-org)", + "scope_required": "請輸入 npm 作用區域", + "search": "搜尋", + "search_error": "搜尋失敗", "usage": "用法", "version": "版本" }, "oauth": { "callback": { - "message": "您可以關閉此頁面並返回 Cherry Studio", + "message": "關閉此頁面後即可回到 Cherry Studio", "title": "認證成功" } }, "prompts": { "arguments": "參數", "availablePrompts": "可用提示", - "genericError": "獲取提示錯誤", - "loadError": "獲取提示失敗", + "genericError": "取得提示錯誤", + "loadError": "取得提示失敗", "noPromptsAvailable": "無可用提示", "requiredField": "必填欄位" }, @@ -3995,16 +3995,16 @@ "provider": "提供者", "providerPlaceholder": "提供者名稱", "providerUrl": "提供者網址", - "providers": "提供商", + "providers": "供應商", "registry": "套件管理源", "registryDefault": "預設", "registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題", - "requiresConfig": "需要配置", + "requiresConfig": "需要設定", "resources": { "availableResources": "可用資源", - "blob": "二進位數據", - "blobInvisible": "隱藏二進位數據", - "genericError": "获取资源错误", + "blob": "二進位資料", + "blobInvisible": "隱藏二進位資料", + "genericError": "取得資源錯誤", "mimeType": "MIME 類型", "noResourcesAvailable": "無可用資源", "size": "大小", @@ -4012,10 +4012,10 @@ "uri": "URI" }, "search": { - "placeholder": "搜索 MCP 伺服器...", - "tooltip": "搜索 MCP 伺服器" + "placeholder": "搜尋 MCP 伺服器...", + "tooltip": "搜尋 MCP 伺服器" }, - "searchNpx": "搜索 MCP", + "searchNpx": "搜尋 MCP", "serverPlural": "伺服器", "serverSingular": "伺服器", "servers": "MCP 伺服器", @@ -4026,17 +4026,17 @@ "sync": { "button": "同步", "discoverMcpServers": "發現 MCP 伺服器", - "discoverMcpServersDescription": "訪問平台以發現可用的 MCP 伺服器", + "discoverMcpServersDescription": "存取平台以發現可用的 MCP 伺服器", "error": "同步 MCP 伺服器出錯", - "getToken": "獲取 API 令牌", - "getTokenDescription": "從您的帳戶獲取個人 API 令牌", + "getToken": "取得 API 權杖", + "getTokenDescription": "從帳戶取得個人 API 權杖", "noServersAvailable": "無可用的 MCP 伺服器", "selectProvider": "選擇提供者:", - "setToken": "輸入您的令牌", + "setToken": "輸入權杖", "success": "同步 MCP 伺服器成功", "title": "同步伺服器", - "tokenPlaceholder": "在此輸入 API 令牌", - "tokenRequired": "需要 API 令牌", + "tokenPlaceholder": "在此輸入 API 權杖", + "tokenRequired": "需要 API 權杖", "unauthorized": "同步未授權" }, "system": "系統", @@ -4049,17 +4049,17 @@ }, "tags": "標籤", "tagsPlaceholder": "輸入標籤", - "timeout": "超時", - "timeoutTooltip": "對該伺服器請求的超時時間(秒),預設為 60 秒", + "timeout": "逾時", + "timeoutTooltip": "對該伺服器請求的逾時時間(秒),預設為 60 秒", "title": "MCP", "tools": { "autoApprove": { - "label": "自動批准", + "label": "自動核准", "tooltip": { - "confirm": "是否運行該MCP工具?", - "disabled": "工具運行前需要手動批准", - "enabled": "工具將自動運行而無需批准", - "howToEnable": "啟用工具後才能使用自動批准" + "confirm": "是否運作該 MCP 工具?", + "disabled": "工具運作前需要手動核准", + "enabled": "工具將自動運作而無需手動核准", + "howToEnable": "啟用工具後才能使用自動核准" } }, "availableTools": "可用工具", @@ -4070,26 +4070,26 @@ }, "label": "輸入模式" }, - "loadError": "獲取工具失敗", + "loadError": "取得工具失敗", "noToolsAvailable": "無可用工具", - "run": "運行" + "run": "運作" }, "type": "類型", "types": { - "inMemory": "內置", + "inMemory": "記憶體內", "sse": "SSE", "stdio": "STDIO", - "streamableHttp": "流式" + "streamableHttp": "串流 HTTP" }, "updateError": "更新伺服器失敗", "updateSuccess": "伺服器更新成功", "url": "URL", - "user": "用戶" + "user": "使用者" }, "messages": { "divider": { "label": "訊息間顯示分隔線", - "tooltip": "不適用於氣泡樣式消息" + "tooltip": "不適用於氣泡樣式訊息" }, "grid_columns": "訊息網格展示列數", "grid_popover_trigger": { @@ -4099,7 +4099,7 @@ }, "input": { "confirm_delete_message": "刪除訊息前確認", - "confirm_regenerate_message": "重新生成訊息前確認", + "confirm_regenerate_message": "重新產生訊息前確認", "enable_quick_triggers": "啟用 / 和 @ 觸發快捷選單", "paste_long_text_as_file": "將長文字貼上為檔案", "paste_long_text_threshold": "長文字長度", @@ -4115,11 +4115,11 @@ "navigation": { "anchor": "對話錨點", "buttons": "上下按鈕", - "label": "訊息導航", + "label": "訊息導覽", "none": "不顯示" }, "prompt": "提示詞顯示", - "show_message_outline": "顯示消息大綱", + "show_message_outline": "顯示訊息大綱", "title": "訊息設定", "use_serif_font": "使用襯線字型" }, @@ -4127,20 +4127,20 @@ "api_key": "Mineru 現在每天提供 500 頁的免費配額,且無需輸入金鑰。" }, "miniapps": { - "cache_change_notice": "更改將在打開的小程式增減至設定值後生效", - "cache_description": "設置同時保持活躍狀態的小程式最大數量", - "cache_settings": "緩存設置", - "cache_title": "小程式緩存數量", + "cache_change_notice": "變更會在開啟的小程式數量調整至設定值後生效", + "cache_description": "設定同時保持活躍狀態的小程式最大數量", + "cache_settings": "快取設定", + "cache_title": "小程式快取數量", "custom": { - "conflicting_ids": "與預設應用 ID 衝突: {{ids}}", + "conflicting_ids": "與預設應用 ID 衝突:{{ids}}", "duplicate_ids": "發現重複的 ID: {{ids}}", - "edit_description": "編輯自定義小程序配置", - "edit_title": "編輯自定義小程序", + "edit_description": "編輯自訂小程式設定", + "edit_title": "編輯自訂小程式", "id": "ID", "id_error": "ID 是必填項", "id_placeholder": "請輸入 ID", "logo": "Logo", - "logo_file": "上傳 Logo 文件", + "logo_file": "上傳 Logo 檔案", "logo_upload_button": "上傳", "logo_upload_error": "Logo 上傳失敗", "logo_upload_label": "上傳 Logo", @@ -4149,36 +4149,36 @@ "logo_url_label": "Logo URL", "logo_url_placeholder": "請輸入 Logo URL", "name": "名稱", - "name_error": "名稱是必填項", + "name_error": "名稱為必填欄位", "name_placeholder": "請輸入名稱", - "placeholder": "請輸入自定義小程序配置(JSON 格式)", - "remove_error": "自定義小程序刪除失敗", - "remove_success": "自定義小程序刪除成功", - "save": "保存", - "save_error": "自定義小程序保存失敗", - "save_success": "自定義小程序保存成功", - "title": "自定義", + "placeholder": "請輸入自訂小程式設定(JSON 格式)", + "remove_error": "自訂小程式刪除失敗", + "remove_success": "自訂小程式刪除成功", + "save": "儲存", + "save_error": "自訂小程式儲存失敗", + "save_success": "自訂小程式儲存成功", + "title": "自訂", "url": "URL", "url_error": "URL 是必填項", "url_placeholder": "請輸入 URL" }, "disabled": "隱藏的小程式", - "display_title": "小程式顯示設置", - "empty": "把要隱藏的小程式從左側拖拽到這裡", + "display_title": "小程式顯示設定", + "empty": "把要隱藏的小程式從左側拖曳到這裡", "open_link_external": { - "title": "在瀏覽器中打開新視窗連結" + "title": "在瀏覽器中開啟新視窗連結" }, - "reset_tooltip": "重置為預設值", - "sidebar_description": "設置側邊欄是否顯示活躍的小程式", - "sidebar_title": "側邊欄活躍小程式顯示設置", - "title": "小程式設置", + "reset_tooltip": "重設為預設值", + "sidebar_description": "設定側邊欄是否顯示活躍的小程式", + "sidebar_title": "側邊欄活躍小程式顯示設定", + "title": "小程式設定", "visible": "顯示的小程式" }, "model": "預設模型", "models": { "add": { "add_model": "新增模型", - "batch_add_models": "批量新增模型", + "batch_add_models": "批次新增模型", "endpoint_type": { "label": "端點類型", "placeholder": "選擇端點類型", @@ -4204,35 +4204,35 @@ "tooltip": "例如 GPT-4" }, "supported_text_delta": { - "label": "支持增量文本輸出", - "tooltip": "模型每次返回文本增量,而不是一次性返回所有文本,預設開啟,如果模型不支持,請關閉" + "label": "支援增量文字輸出", + "tooltip": "模型每次回傳文字增量,而不是一次性回傳所有文字,預設開啟,如果模型不支援,請關閉" } }, - "api_key": "API 密鑰", + "api_key": "API 金鑰", "base_url": "基礎 URL", "check": { "all": "所有", - "all_models_passed": "所有模型檢查通過", + "all_models_passed": "所有模型檢查皆成功", "button_caption": "健康檢查", "disabled": "關閉", - "disclaimer": "健康檢查需要發送請求,請謹慎使用。按次收費的模型可能產生更多費用,請自行承擔。", + "disclaimer": "健康檢查需要傳送請求,請謹慎使用。按次收費的模型可能產生更多費用,請自行承擔。", "enable_concurrent": "並行檢查", "enabled": "開啟", "failed": "失敗", - "keys_status_count": "通過:{{count_passed}} 個密鑰,失敗:{{count_failed}} 個密鑰", - "model_status_failed": "{{count}} 個模型完全無法訪問", - "model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問", - "model_status_passed": "{{count}} 個模型通過健康檢查", + "keys_status_count": "成功:{{count_passed}} 個金鑰,失敗:{{count_failed}} 個金鑰", + "model_status_failed": "{{count}} 個模型完全無法存取", + "model_status_partial": "其中 {{count}} 個模型用某些金鑰無法存取", + "model_status_passed": "{{count}} 個模型健康檢查成功", "model_status_summary": "{{provider}}: {{summary}}", - "no_api_keys": "未找到 API 密鑰,請先添加 API 密鑰", + "no_api_keys": "未找到 API 金鑰,請先新增 API 金鑰", "no_results": "無結果", - "passed": "通過", - "select_api_key": "選擇要使用的 API 密鑰:", + "passed": "成功", + "select_api_key": "選擇要使用的 API 金鑰:", "single": "單個", "start": "開始", - "timeout": "超時", + "timeout": "逾時", "title": "模型健康檢查", - "use_all_keys": "使用密鑰" + "use_all_keys": "使用金鑰" }, "default_assistant_model": "預設助手模型", "default_assistant_model_description": "建立新助手時使用的模型,如果助手未設定模型,則使用此模型", @@ -4243,27 +4243,27 @@ "label": "新增列表中的模型" }, "add_whole_group": "新增整個分組", - "refetch_list": "重新獲取模型列表", + "refetch_list": "重新取得模型列表", "remove_listed": "移除列表中的模型", "remove_model": "移除模型", "remove_whole_group": "移除整個分組" }, "provider_id": "提供者 ID", - "provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?", - "provider_key_add_failed_by_empty_data": "添加提供者 API 密鑰失敗,數據為空", - "provider_key_add_failed_by_invalid_data": "添加提供者 API 密鑰失敗,數據格式錯誤", - "provider_key_added": "成功為 {{provider}} 添加 API 密鑰", - "provider_key_already_exists": "{{provider}} 已存在相同API 密鑰,不會重複添加", - "provider_key_confirm_title": "為{{provider}}添加 API 密鑰", - "provider_key_no_change": "{{provider}} 的 API 密鑰沒有變化", - "provider_key_overridden": "成功更新 {{provider}} 的 API 密鑰", + "provider_key_add_confirm": "是否要為 {{provider}} 新增 API 金鑰?", + "provider_key_add_failed_by_empty_data": "新增提供者 API 金鑰失敗,資料為空", + "provider_key_add_failed_by_invalid_data": "新增提供者 API 金鑰失敗,資料格式錯誤", + "provider_key_added": "成功為 {{provider}} 新增 API 金鑰", + "provider_key_already_exists": "{{provider}} 已存在相同 API 金鑰,不會重複新增", + "provider_key_confirm_title": "為{{provider}}新增 API 金鑰", + "provider_key_no_change": "{{provider}} 的 API 金鑰沒有變化", + "provider_key_overridden": "成功更新 {{provider}} 的 API 金鑰", "provider_key_override_confirm": "{{provider}} 已存在相同 API 金鑰,是否覆蓋?", "provider_name": "提供者名稱", "quick_assistant_default_tag": "預設", "quick_assistant_model": "快捷助手模型", "quick_assistant_selection": "選擇助手", "quick_model": { - "description": "用於執行話題命名、搜尋關鍵字提煉等簡單任務的模型", + "description": "用於話題命名、搜尋關鍵字提煉等簡單任務的模型", "label": "快速模型", "setting_title": "快速模型設定", "tooltip": "建議選擇輕量模型,不建議選擇思考模型" @@ -4288,7 +4288,7 @@ "label": "更多設定", "warn": "風險警告" }, - "no_provider_selected": "未選擇提供商", + "no_provider_selected": "未選擇供應商", "notification": { "assistant": "助手訊息", "backup": "備份訊息", @@ -4329,7 +4329,7 @@ } }, "privacy": { - "enable_privacy_mode": "匿名發送錯誤報告和資料統計", + "enable_privacy_mode": "匿名傳送錯誤報告和資料統計", "title": "隱私設定" }, "provider": { @@ -4342,9 +4342,9 @@ "type": "供應商類型" }, "anthropic": { - "apikey": "API 密鑰", + "apikey": "API 金鑰", "auth_failed": "Anthropic 身份驗證失敗", - "auth_method": "认证方式", + "auth_method": "認證方式", "auth_success": "Anthropic OAuth 認證成功", "authenticated": "已認證", "authenticating": "正在認證", @@ -4360,48 +4360,48 @@ "logout_success": "成功登出 Anthropic", "oauth": "網頁 OAuth", "start_auth": "開始授權", - "submit_code": "完成登錄" + "submit_code": "完成登入" }, - "anthropic_api_host": "Anthropic API 主機地址", + "anthropic_api_host": "Anthropic API 主機位址", "anthropic_api_host_preview": "Anthropic 預覽:{{url}}", - "anthropic_api_host_tooltip": "僅在服務商提供 Claude 相容的基礎網址時設定。", + "anthropic_api_host_tooltip": "僅在供應商提供 Claude 相容的基礎網址時設定。", "api": { "key": { "check": { "latency": "耗時" }, "error": { - "duplicate": "API 密鑰已存在", - "empty": "API 密鑰不能為空" + "duplicate": "API 金鑰已存在", + "empty": "API 金鑰不能為空" }, "list": { - "open": "打開管理界面", - "title": "API 密鑰管理" + "open": "開啟管理介面", + "title": "API 金鑰管理" }, "new_key": { - "placeholder": "輸入一個或多個密鑰" + "placeholder": "輸入一個或多個金鑰" } }, "options": { "array_content": { - "help": "該提供商是否支援 message 的 content 欄位為 array 類型", + "help": "該供應商是否支援 message 的 content 欄位為 array 類型", "label": "支援陣列格式的 message content" }, "developer_role": { - "help": "該提供商是否支援 role: \"developer\" 的訊息", + "help": "該供應商是否支援 role: \"developer\" 的訊息", "label": "支援開發人員訊息" }, "enable_thinking": { - "help": "該提供商是否支援透過 enable_thinking 參數控制 Qwen3 等模型的思考", + "help": "該供應商是否支援透過 enable_thinking 參數控制 Qwen3 等模型的思考", "label": "支援 enable_thinking" }, "label": "API 設定", "service_tier": { - "help": "該提供商是否支援設定 service_tier 參數。啟用後,可在對話頁面的服務層級設定中調整此參數。(僅限 OpenAI 模型)", + "help": "該供應商是否支援設定 service_tier 參數。啟用後,可在對話頁面的服務層級設定中調整此參數。(僅限 OpenAI 模型)", "label": "支援 service_tier" }, "stream_options": { - "help": "該提供商是否支援 stream_options 參數", + "help": "該供應商是否支援 stream_options 參數", "label": "支援 stream_options" }, "verbosity": { @@ -4412,32 +4412,32 @@ "url": { "preview": "預覽:{{url}}", "reset": "重設", - "tip": "在末尾添加 # 以停用自動附加的 API 版本。" + "tip": "在結尾新增 # 以停用自動附加的 API 版本。" } }, - "api_host": "API 主機地址", + "api_host": "API 主機位址", "api_host_no_valid": "API 位址不合法", "api_host_preview": "預覽:{{url}}", - "api_host_tooltip": "僅在服務商需要自訂的 OpenAI 相容端點時才覆蓋。", + "api_host_tooltip": "僅在供應商需要自訂的 OpenAI 相容端點時才覆蓋。", "api_key": { "label": "API 金鑰", "tip": "多個金鑰使用逗號分隔" }, "api_version": "API 版本", "aws-bedrock": { - "access_key_id": "AWS 存取密鑰 ID", - "access_key_id_help": "您的 AWS 存取密鑰 ID,用於存取 AWS Bedrock 服務", + "access_key_id": "AWS 存取金鑰 ID", + "access_key_id_help": "您的 AWS 存取金鑰 ID,用於存取 AWS Bedrock 服務", "api_key": "Bedrock API 金鑰", "api_key_help": "您的 AWS Bedrock API 金鑰,用於身份驗證", "auth_type": "認證方式", "auth_type_api_key": "Bedrock API 金鑰", "auth_type_help": "選擇使用 IAM 憑證或 Bedrock API 金鑰進行身份驗證", "auth_type_iam": "IAM 憑證", - "description": "AWS Bedrock 是亞馬遜提供的全托管基础模型服務,支持多種先進的大語言模型", + "description": "AWS Bedrock 是亞馬遜提供的全託管基礎模型服務,支援多種先進的大型語言模型", "region": "AWS 區域", "region_help": "您的 AWS 服務區域,例如 us-east-1", - "secret_access_key": "AWS 存取密鑰", - "secret_access_key_help": "您的 AWS 存取密鑰,請妥善保管", + "secret_access_key": "AWS 存取金鑰", + "secret_access_key_help": "您的 AWS 存取金鑰,請妥善保管", "title": "AWS Bedrock 設定" }, "azure": { @@ -4451,59 +4451,59 @@ "label": "密碼", "tip": "輸入密碼" }, - "tip": "適用於透過伺服器部署的實例(請參閱文檔)。目前僅支援 Basic 方案(RFC7617)", + "tip": "適用於透過伺服器部署的執行個體(請參閱文件)。目前僅支援 Basic 方案(RFC7617)", "user_name": { - "label": "用戶", + "label": "使用者", "tip": "留空以停用" } }, "bills": "費用帳單", - "charge": "餘額充值", + "charge": "餘額儲值", "check": "檢查", "check_all_keys": "檢查所有金鑰", "check_multiple_keys": "檢查多個 API 金鑰", "copilot": { - "auth_failed": "Github Copilot 認證失敗", - "auth_success": "Github Copilot 認證成功", + "auth_failed": "GitHub Copilot 認證失敗", + "auth_success": "GitHub Copilot 認證成功", "auth_success_title": "認證成功", "code_copied": "授權碼已自動複製到剪貼簿", - "code_failed": "獲取 Device Code 失敗,請重試", - "code_generated_desc": "請將設備代碼複製到下面的瀏覽器連結中", - "code_generated_title": "獲取設備代碼", - "connect": "連接 Github", + "code_failed": "取得裝置碼失敗,請重試", + "code_generated_desc": "請將裝置碼複製到下方的瀏覽器連結中", + "code_generated_title": "取得裝置碼", + "connect": "連線 GitHub", "custom_headers": "自訂請求標頭", - "description": "您的 Github 帳號需要訂閱 Copilot", + "description": "GitHub 帳號需要訂閱 Copilot", "description_detail": "GitHub Copilot 是一個基於 AI 的程式碼助手,需要有效的 GitHub Copilot 訂閱才能使用", "expand": "展開", "headers_description": "自訂請求標頭 (json 格式)", "invalid_json": "JSON 格式錯誤", - "login": "登入 Github", - "logout": "退出 Github", - "logout_failed": "退出失敗,請重試", + "login": "登入 GitHub", + "logout": "登出 GitHub", + "logout_failed": "登出失敗,請重試", "logout_success": "已成功登出", "model_setting": "模型設定", - "open_verification_first": "請先點擊上方連結訪問驗證頁面", + "open_verification_first": "請先點選上方連結前往驗證頁面", "open_verification_page": "開啟授權頁面", "rate_limit": "速率限制", "start_auth": "開始授權", "step_authorize": "開啟授權頁面", "step_authorize_desc": "在 GitHub 上完成授權", - "step_authorize_detail": "點擊下方按鈕開啟 GitHub 授權頁面,然後輸入複製的授權碼", + "step_authorize_detail": "點選下方按鈕開啟 GitHub 授權頁面,然後輸入複製的授權碼", "step_connect": "完成連線", - "step_connect_desc": "確認連接到 GitHub", - "step_connect_detail": "在 GitHub 頁面完成授權後,點擊此按鈕完成連線", + "step_connect_desc": "確認已連線至 GitHub", + "step_connect_detail": "在 GitHub 頁面完成授權後,點選此按鈕完成連線", "step_copy_code": "複製授權碼", - "step_copy_code_desc": "複製設備授權碼", + "step_copy_code_desc": "複製裝置授權碼", "step_copy_code_detail": "授權碼已自動複製,您也可以手動複製", - "step_get_code": "獲取授權碼", - "step_get_code_desc": "生成設備授權碼" + "step_get_code": "取得授權碼", + "step_get_code_desc": "產生裝置授權碼" }, "delete": { "content": "確定要刪除此提供者嗎?", "title": "刪除提供者" }, "dmxapi": { - "select_platform": "選擇平臺" + "select_platform": "選擇平台" }, "docs_check": "檢查", "docs_more_details": "檢視更多細節", @@ -4519,7 +4519,7 @@ "oauth": { "button": "使用 {{provider}} 帳號登入", "description": "本服務由 {{provider}} 提供", - "error": "认证失败", + "error": "認證失敗", "official_website": "官方網站" }, "openai": { @@ -4531,7 +4531,7 @@ "search_placeholder": "搜尋模型 ID 或名稱", "title": "模型提供者", "vertex_ai": { - "api_host_help": "Vertex AI 的 API 地址,不建議填寫,通常適用於反向代理", + "api_host_help": "Vertex AI 的 API 位址,不建議填寫,通常適用於反向代理", "documentation": "檢視官方文件以取得更多設定詳細資訊:", "learn_more": "瞭解更多", "location": "地區", @@ -4544,7 +4544,7 @@ "client_email": "Client Email", "client_email_help": "從 Google Cloud Console 下載的 JSON 金鑰檔案中的 client_email 欄位", "client_email_placeholder": "輸入服務帳戶 client email", - "description": "使用服務帳戶進行身份驗證,適用於 ADC 不可用的環境", + "description": "使用服務帳戶進行身份驗證,適用於 ADC 無法使用的環境", "incomplete_config": "請先完成服務帳戶設定", "private_key": "私密金鑰", "private_key_help": "從 Google Cloud Console 下載的 JSON 金鑰檔案中的 private_key 欄位", @@ -4562,7 +4562,7 @@ "system": "系統代理伺服器", "title": "代理伺服器模式" }, - "tip": "支援模糊匹配(*.test.com,192.168.0.0/16)" + "tip": "支援模糊比對(*.test.com,192.168.0.0/16)" }, "quickAssistant": { "click_tray_to_show": "點選工具列圖示啟動", @@ -4586,12 +4586,12 @@ "add": "新增短語", "assistant": "助手提示詞", "contentLabel": "內容", - "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按 Tab 鍵可以快速定位到變量進行修改。比如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後發送到 ${email}", + "contentPlaceholder": "請輸入短語內容,支援使用變數,然後按 Tab 鍵可以快速定位到變數進行修改。例如:\n幫我規劃從 ${from} 到 ${to} 的行程,然後傳送到 ${email}", "delete": "刪除短語", "deleteConfirm": "刪除後無法復原,是否繼續?", "edit": "編輯短語", - "global": "全局快速短語", - "locationLabel": "添加位置", + "global": "全域快速短語", + "locationLabel": "新增位置", "title": "快捷短語", "titleLabel": "標題", "titlePlaceholder": "請輸入短語標題" @@ -4604,7 +4604,7 @@ "copy_last_message": "複製上一則訊息", "edit_last_user_message": "編輯最後一則使用者訊息", "enabled": "啟用", - "exit_fullscreen": "退出螢幕", + "exit_fullscreen": "離開全螢幕", "label": "按鍵", "mini_window": "快捷助手", "new_topic": "新增話題", @@ -4614,8 +4614,8 @@ "reset_defaults_confirm": "確定要重設所有快捷鍵嗎?", "reset_to_default": "重設為預設", "search_message": "搜尋訊息", - "search_message_in_chat": "在當前對話中搜尋訊息", - "selection_assistant_select_text": "劃詞助手:取词", + "search_message_in_chat": "在目前對話中搜尋訊息", + "selection_assistant_select_text": "劃詞助手:取詞", "selection_assistant_toggle": "開關劃詞助手", "show_app": "顯示 / 隱藏應用程式", "show_settings": "開啟設定", @@ -4652,14 +4652,14 @@ }, "image": { "error": { - "provider_not_found": "該提供商不存在" + "provider_not_found": "該供應商不存在" }, "system": { - "no_need_configure": "MacOS 無需配置" + "no_need_configure": "MacOS 無需設定" }, "title": "圖片" }, - "image_provider": "OCR 服務提供商", + "image_provider": "OCR 服務供應商", "paddleocr": { "aistudio_access_token": "星河社群存取權杖", "aistudio_url_label": "星河社群", @@ -4669,7 +4669,7 @@ }, "system": { "win": { - "langs_tooltip": "依賴 Windows 提供服務,您需要在系統中下載語言包來支援相關語言。" + "langs_tooltip": "仰賴 Windows 提供的服務,需要在系統中下載語言套件,才能支援相關語言。" } }, "tesseract": { @@ -4697,10 +4697,10 @@ "limit": { "label": "截斷長度", "placeholder": "輸入長度", - "tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字符)" + "tooltip": "限制搜尋結果的內容長度,超過限制的內容將被截斷(例如 2000 字元)" }, "unit": { - "char": "字符", + "char": "字元", "token": "Token" } }, @@ -4708,7 +4708,7 @@ "rag_failed": "RAG 失敗" }, "info": { - "dimensions_auto_success": "維度自動獲取成功,維度為 {{dimensions}}" + "dimensions_auto_success": "維度自動取得成功,維度為 {{dimensions}}" }, "method": { "cutoff": "截斷", @@ -4718,8 +4718,8 @@ }, "rag": { "document_count": { - "label": "文檔片段數量", - "tooltip": "預期從單個搜尋結果中提取的文檔片段數量,實際提取的總數量是這個值乘以搜尋結果數量。" + "label": "文件片段數量", + "tooltip": "預期從單個搜尋結果中提取的文件片段數量,實際提取的總數量是這個值乘以搜尋結果數量。" } }, "title": "搜尋結果壓縮" @@ -4727,28 +4727,28 @@ "content_limit": "內容長度限制", "content_limit_tooltip": "限制搜尋結果的內容長度;超過限制的內容將被截斷。", "free": "免費", - "no_provider_selected": "請選擇搜尋服務商後再檢查", + "no_provider_selected": "請選擇搜尋供應商後再檢查", "overwrite": "覆蓋搜尋服務", "overwrite_tooltip": "強制使用搜尋服務而不是 LLM", "search_max_result": { "label": "搜尋結果個數", "tooltip": "未開啟搜尋結果壓縮的情況下,數量過大可能會消耗過多 tokens" }, - "search_provider": "搜尋服務商", - "search_provider_placeholder": "選擇一個搜尋服務商", + "search_provider": "搜尋供應商", + "search_provider_placeholder": "選擇一個搜尋供應商", "search_with_time": "搜尋包含日期", "subscribe": "黑名單訂閱", "subscribe_add": "新增訂閱", - "subscribe_add_failed": "订阅源添加失败", - "subscribe_add_success": "訂閱源新增成功!", + "subscribe_add_failed": "訂閱來源新增失敗", + "subscribe_add_success": "訂閱來源新增成功!", "subscribe_delete": "刪除", "subscribe_name": { "label": "替代名稱", - "placeholder": "下載的訂閱源沒有名稱時使用的替代名稱。" + "placeholder": "下載的訂閱來源沒有名稱時使用的替代名稱。" }, "subscribe_update": "更新", - "subscribe_update_failed": "订阅源更新失败", - "subscribe_update_success": "订阅源更新成功", + "subscribe_update_failed": "訂閱來源更新失敗", + "subscribe_update_success": "訂閱來源更新成功", "subscribe_url": "訂閱網址", "tavily": { "api_key": { @@ -4759,8 +4759,8 @@ "title": "Tavily" }, "title": "網路搜尋", - "url_invalid": "輸入了無效的URL", - "url_required": "需要輸入URL" + "url_invalid": "輸入了無效的 URL", + "url_required": "需要輸入 URL" } }, "topic": { @@ -4781,27 +4781,27 @@ "title": "刪除自訂語言" }, "error": { - "add": "添加失敗", - "delete": "删除失败", + "add": "新增失敗", + "delete": "刪除失敗", "langCode": { "builtin": "該語言已內建支援", - "empty": "語言代碼為空", + "empty": "語言代號為空", "exists": "該語言已存在", - "invalid": "無效的語言代碼" + "invalid": "無效的語言代號" }, "update": "更新失敗", "value": { - "empty": "語言名不能為空", - "too_long": "語言名過長" + "empty": "語言名稱不能留空", + "too_long": "語言名稱過長" } }, "langCode": { - "help": "[語言+區域]的格式,[2~3位小寫字母]-[2~3位小寫字母]", - "label": "語言代碼", + "help": "[語言 + 區域] 的格式,[2~3 位小寫字母]-[2~3 位小寫字母]", + "label": "語言代號", "placeholder": "zh-tw" }, "success": { - "add": "添加成功", + "add": "新增成功", "delete": "刪除成功", "update": "更新成功" }, @@ -4811,29 +4811,29 @@ } }, "value": { - "help": "1~32個字元", - "label": "语言名称", + "help": "1~32 個字元", + "label": "語言名稱", "placeholder": "繁體中文" } }, - "prompt": "翻译提示词", - "title": "翻译设置" + "prompt": "翻譯提示詞", + "title": "翻譯設定" }, "tray": { - "onclose": "關閉時最小化到系统匣", - "show": "顯示系统匣圖示", - "title": "系统匣" + "onclose": "關閉時最小化到系統匣", + "show": "顯示系統匣圖示", + "title": "系統匣" }, "zoom": { - "reset": "重置", + "reset": "重設", "title": "縮放" } }, "title": { - "apps": "小程序", - "code": "Code", - "files": "文件", - "home": "主頁", + "apps": "小程式", + "code": "程式碼", + "files": "檔案", + "home": "首頁", "knowledge": "知識庫", "launchpad": "啟動台", "mcp-servers": "MCP 伺服器", @@ -4845,20 +4845,20 @@ "translate": "翻譯" }, "trace": { - "backList": "返回清單", + "backList": "回到清單", "edasSupport": "Powered by Alibaba Cloud EDAS", "endTime": "結束時間", "inputs": "輸入", "label": "呼叫鏈", "name": "節點名稱", - "noTraceList": "沒有找到Trace資訊", + "noTraceList": "沒有找到 Trace 資訊", "outputs": "輸出", - "parentId": "上級Id", - "spanDetail": "Span詳情", + "parentId": "上級 Id", + "spanDetail": "Span 詳細資訊", "spendTime": "消耗時間", "startTime": "開始時間", "tag": "標籤", - "tokenUsage": "Token使用量", + "tokenUsage": "Token 使用量", "traceWindow": "呼叫鏈視窗" }, "translate": { @@ -4884,48 +4884,48 @@ "method": { "algo": { "label": "演算法", - "tip": "使用franc進行語言檢測" + "tip": "使用 franc 進行語言偵測" }, "auto": { "label": "自動", - "tip": "自動選擇合適的檢測方法" + "tip": "自動選擇合適的偵測方法" }, - "label": "自動檢測方法", + "label": "自動偵測方法", "llm": { - "tip": "使用快速模型進行語言檢測,消耗少量token。" + "tip": "使用快速模型進行語言偵測,消耗少量 token。" }, "placeholder": "選擇自動偵測方法", - "tip": "自動檢測輸入語言時使用的方法" + "tip": "自動偵測輸入語言時使用的方法" } }, "detected": { - "language": "自動檢測" + "language": "自動偵測" }, "empty": "翻譯內容為空", "error": { - "chat_qwen_mt": "Qwen MT 模型不可在对话中使用,請轉至翻譯頁面", + "chat_qwen_mt": "Qwen MT 模型無法在對話中使用,請前往翻譯頁面", "detect": { - "qwen_mt": "QwenMT模型不能用於語言檢測", - "unknown": "檢測到未知語言", + "qwen_mt": "QwenMT 模型不能用於語言偵測", + "unknown": "偵測到未知語言", "update_setting": "設定失敗" }, - "empty": "翻译结果为空内容", + "empty": "翻譯結果為空", "failed": "翻譯失敗", - "invalid_source": "無效的源語言", + "invalid_source": "無效的來源語言", "not_configured": "翻譯模型未設定", "not_supported": "不支援的語言 {{language}}", "unknown": "翻譯過程中遇到未知錯誤" }, "exchange": { - "label": "交換源語言與目標語言" + "label": "交換來源語言與目標語言" }, "files": { - "drag_text": "拖放到此处", + "drag_text": "拖曳到此處", "error": { "check_type": "檢查檔案類型時發生錯誤", - "multiple": "不允许上传多个文件", - "too_large": "文件過大", - "unknown": "读取文件内容失败" + "multiple": "不允許上傳多個檔案", + "too_large": "檔案過大", + "unknown": "讀取檔案內容失敗" }, "reading": "讀取檔案內容中..." }, @@ -4935,11 +4935,11 @@ "delete": "刪除翻譯歷史", "empty": "翻譯歷史為空", "error": { - "delete": "删除失败", - "save": "保存翻譯歷史失敗" + "delete": "刪除失敗", + "save": "儲存翻譯歷史失敗" }, "search": { - "placeholder": "搜索翻譯歷史" + "placeholder": "搜尋翻譯歷史" }, "title": "翻譯歷史" }, @@ -4947,14 +4947,14 @@ "aborted": "翻譯中止" }, "input": { - "placeholder": "可粘貼或拖入文字、文字檔案、圖片(支援OCR)" + "placeholder": "可貼上或拖曳文字、文字檔案、圖片(支援 OCR)" }, "language": { - "not_pair": "源語言與設定的語言不同", - "same": "源語言和目標語言相同" + "not_pair": "來源語言與設定的語言不同", + "same": "來源語言和目標語言相同" }, "menu": { - "description": "對當前輸入框內容進行翻譯" + "description": "對目前輸入框內容進行翻譯" }, "not": { "found": "未找到翻譯內容" @@ -4966,10 +4966,10 @@ "settings": { "autoCopy": "翻譯完成後自動複製", "bidirectional": "雙向翻譯設定", - "bidirectional_tip": "開啟後,僅支援在源語言和目標語言之間進行雙向翻譯", + "bidirectional_tip": "開啟後,僅支援在來源語言和目標語言之間進行雙向翻譯", "model": "模型設定", "model_desc": "翻譯服務使用的模型", - "model_placeholder": "选择翻译模型", + "model_placeholder": "選擇翻譯模型", "no_model_warning": "未選擇翻譯模型", "preview": "Markdown 預覽", "scroll_sync": "滾動同步設定", @@ -4997,11 +4997,11 @@ "later": "稍後", "message": "新版本 {{version}} 已準備就緒,是否立即安裝?", "noReleaseNotes": "暫無更新日誌", - "saveDataError": "保存數據失敗,請重試", + "saveDataError": "儲存資料失敗,請重試", "title": "更新提示" }, "warning": { - "missing_provider": "供應商不存在,已回退到預設供應商 {{provider}}。這可能導致問題。" + "missing_provider": "供應商不存在,已改用預設供應商 {{provider}}。這可能導致問題。" }, "words": { "knowledgeGraph": "知識圖譜", From a1f0addafb4ab953b39726abe0b7c04ab22df060 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 15 Dec 2025 10:09:51 +0800 Subject: [PATCH 08/41] fix: update MCPSettings layout and styling - Increased maxHeight of the modal body to 70vh for improved visibility. - Changed LogList to extend Scrollbar for better scrolling experience. - Updated LogItem background color to use a more appropriate variable for consistency. --- .../src/pages/settings/MCPSettings/McpSettings.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 30b7b45f30..d4fc9aeb36 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -817,7 +817,7 @@ const McpSettings: React.FC = () => { width={720} centered transitionName="animation-move-down" - bodyStyle={{ maxHeight: '60vh', minHeight: '40vh', overflowY: 'auto' }} + bodyStyle={{ maxHeight: '70vh', minHeight: '40vh', overflowY: 'auto' }} afterOpenChange={(open) => { if (open) { fetchServerLogs() @@ -861,14 +861,16 @@ const AdvancedSettingsButton = styled.div` align-items: center; ` -const LogList = styled.div` +const LogList = styled(Scrollbar)` display: flex; flex-direction: column; gap: 12px; + padding-bottom: 15px; + padding-top: 5px; ` const LogItem = styled.div` - background: var(--color-bg-2, #1f1f1f); + background: var(--color-background-mute, #1f1f1f); color: var(--color-text-1, #e6e6e6); border-radius: 8px; padding: 10px 12px; From 4d3d5ae4cee601a44326c0a94ee62f78b481a77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:41:54 +0800 Subject: [PATCH 09/41] fix/line-number-wrongly-copied (#11857) * fix(code-viewer): copy selected code without line numbers * fix(context-menu): strip line numbers from code selection * style(codeviewer): fix format * fix: preserve indentation and format when copying mixed content (text + code blocks) - Replace regex-based extraction with DOM structure-based approach - Remove line number elements while preserving all other content - Use TreeWalker to handle mixed content (text paragraphs + code blocks) - Preserve indentation and newlines in code blocks - Simplify CodeViewer.tsx by removing duplicate context menu logic Fixes #11790 * style: remove unused comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: optimize TreeWalker performance --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/ContextMenu/index.tsx | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/ContextMenu/index.tsx b/src/renderer/src/components/ContextMenu/index.tsx index 719cd14133..8db88955bd 100644 --- a/src/renderer/src/components/ContextMenu/index.tsx +++ b/src/renderer/src/components/ContextMenu/index.tsx @@ -6,6 +6,61 @@ interface ContextMenuProps { children: React.ReactNode } +/** + * Extract text content from selection, filtering out line numbers in code viewers. + * Preserves all content including plain text and code blocks, only removing line numbers. + * This ensures right-click copy in code blocks doesn't include line numbers while preserving indentation. + */ +function extractSelectedText(selection: Selection): string { + // Validate selection + if (selection.rangeCount === 0 || selection.isCollapsed) { + return '' + } + + const range = selection.getRangeAt(0) + const fragment = range.cloneContents() + + // Check if the selection contains code viewer elements + const hasLineNumbers = fragment.querySelectorAll('.line-number').length > 0 + + // If no line numbers, return the original text (preserves formatting) + if (!hasLineNumbers) { + return selection.toString() + } + + // Remove all line number elements + fragment.querySelectorAll('.line-number').forEach((el) => el.remove()) + + // Handle all content using optimized TreeWalker with precise node filtering + // This approach handles mixed content correctly while improving performance + const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null) + + let result = '' + let node = walker.nextNode() + + while (node) { + if (node.nodeType === Node.TEXT_NODE) { + // Preserve text content including whitespace + result += node.textContent + } else if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element + + // Add newline after block elements and code lines to preserve structure + if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(element.tagName)) { + result += '\n' + } else if (element.classList.contains('line')) { + // Add newline after code lines to preserve code structure + result += '\n' + } + } + + node = walker.nextNode() + } + + // Clean up excessive newlines but preserve code structure + return result.trim() +} + // FIXME: Why does this component name look like a generic component but is not customizable at all? const ContextMenu: React.FC = ({ children }) => { const { t } = useTranslation() @@ -45,8 +100,12 @@ const ContextMenu: React.FC = ({ children }) => { const onOpenChange = (open: boolean) => { if (open) { - const selectedText = window.getSelection()?.toString() - setSelectedText(selectedText) + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + setSelectedText(undefined) + return + } + setSelectedText(extractSelectedText(selection) || undefined) } } From 71df9d61fd908abf08889bed824c4cce7dfd5c6f Mon Sep 17 00:00:00 2001 From: Phantom Date: Mon, 15 Dec 2025 15:43:00 +0800 Subject: [PATCH 10/41] fix(translate): default to first supported reasoning effort when translating (#11869) * feat(translate): add reasoning effort option to translate service Add support for configuring reasoning effort level in translation requests. This allows better control over the translation quality and processing time based on model capabilities. * test: add comprehensive tests for getModelSupportedReasoningEffort * test(reasoning): update model test cases and comments - Remove test case for 'gpt-4o-deep-research' as it needs to be an actual OpenAI model - Add provider requirement comment for Grok 4 Fast recognition - Simplify array assertions in test cases - Add comment about Qwen models working well for name-based fallback * docs(reasoning): add detailed jsdoc for getModelSupportedReasoningEffort * refactor(openai): replace getThinkModelType with getModelSupportedReasoningEffort Simplify reasoning effort validation by using getModelSupportedReasoningEffort * refactor(models): rename getModelSupportedReasoningEffort to getModelSupportedReasoningEffortOptions Update function name and all related references to better reflect its purpose of returning reasoning effort options --- .../legacy/clients/openai/OpenAIApiClient.ts | 10 +- src/renderer/src/aiCore/utils/reasoning.ts | 12 +- .../config/models/__tests__/reasoning.test.ts | 353 ++++++++++++++++++ src/renderer/src/config/models/reasoning.ts | 65 +++- src/renderer/src/services/AssistantService.ts | 14 +- src/renderer/src/services/TranslateService.ts | 14 +- 6 files changed, 448 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts index cfc9087545..d839da8964 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIApiClient.ts @@ -10,7 +10,7 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - getThinkModelType, + getModelSupportedReasoningEffortOptions, isDeepSeekHybridInferenceModel, isDoubaoThinkingAutoModel, isGPT5SeriesModel, @@ -33,7 +33,6 @@ import { isSupportedThinkingTokenQwenModel, isSupportedThinkingTokenZhipuModel, isVisionModel, - MODEL_SUPPORTED_REASONING_EFFORT, ZHIPU_RESULT_TOKENS } from '@renderer/config/models' import { mapLanguageToQwenMTModel } from '@renderer/config/translate' @@ -304,16 +303,15 @@ export class OpenAIAPIClient extends OpenAIBaseClient< // Grok models/Perplexity models/OpenAI models if (isSupportedReasoningEffortModel(model)) { // 检查模型是否支持所选选项 - const modelType = getThinkModelType(model) - const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] - if (supportedOptions.includes(reasoningEffort)) { + const supportedOptions = getModelSupportedReasoningEffortOptions(model) + if (supportedOptions?.includes(reasoningEffort)) { return { reasoning_effort: reasoningEffort } } else { // 如果不支持,fallback到第一个支持的值 return { - reasoning_effort: supportedOptions[0] + reasoning_effort: supportedOptions?.[0] } } } diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index 996d676761..f182405714 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -8,7 +8,7 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant' import { findTokenLimit, GEMINI_FLASH_MODEL_REGEX, - getThinkModelType, + getModelSupportedReasoningEffortOptions, isDeepSeekHybridInferenceModel, isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, @@ -30,8 +30,7 @@ import { isSupportedThinkingTokenHunyuanModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, - isSupportedThinkingTokenZhipuModel, - MODEL_SUPPORTED_REASONING_EFFORT + isSupportedThinkingTokenZhipuModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' @@ -330,16 +329,15 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin // Grok models/Perplexity models/OpenAI models, use reasoning_effort if (isSupportedReasoningEffortModel(model)) { // 检查模型是否支持所选选项 - const modelType = getThinkModelType(model) - const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] - if (supportedOptions.includes(reasoningEffort)) { + const supportedOptions = getModelSupportedReasoningEffortOptions(model) + if (supportedOptions?.includes(reasoningEffort)) { return { reasoningEffort } } else { // 如果不支持,fallback到第一个支持的值 return { - reasoningEffort: supportedOptions[0] + reasoningEffort: supportedOptions?.[0] } } } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 350758c3e2..5a60676b64 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -5,6 +5,7 @@ import { isEmbeddingModel, isRerankModel } from '../embedding' import { isOpenAIReasoningModel, isSupportedReasoningEffortOpenAIModel } from '../openai' import { findTokenLimit, + getModelSupportedReasoningEffortOptions, getThinkModelType, isClaude4SeriesModel, isClaude45ReasoningModel, @@ -1651,3 +1652,355 @@ describe('isGemini3ThinkingTokenModel', () => { ).toBe(false) }) }) + +describe('getModelSupportedReasoningEffortOptions', () => { + describe('Edge cases', () => { + it('should return undefined for undefined model', () => { + expect(getModelSupportedReasoningEffortOptions(undefined)).toBeUndefined() + }) + + it('should return undefined for null model', () => { + expect(getModelSupportedReasoningEffortOptions(null)).toBeUndefined() + }) + + it('should return undefined for non-reasoning models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-4o' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'claude-3-opus' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'random-model' }))).toBeUndefined() + }) + }) + + describe('OpenAI models', () => { + it('should return correct options for o-series models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o3' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o3-mini' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'o4' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-oss-reasoning' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for deep research models', () => { + // Note: Deep research models need to be actual OpenAI reasoning models to be detected + // 'sonar-deep-research' from Perplexity is the primary deep research model + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'sonar-deep-research' }))).toEqual(['medium']) + }) + + it('should return correct options for GPT-5 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-preview' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5 Pro models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-pro' }))).toEqual(['high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-pro-preview' }))).toEqual(['high']) + }) + + it('should return correct options for GPT-5 Codex models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-codex' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5-codex-mini' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5.1 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-preview' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-mini' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for GPT-5.1 Codex models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-codex' }))).toEqual([ + 'none', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-5.1-codex-mini' }))).toEqual([ + 'none', + 'medium', + 'high' + ]) + }) + }) + + describe('Grok models', () => { + it('should return correct options for Grok 3 mini', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'grok-3-mini' }))).toEqual(['low', 'high']) + }) + + it('should return correct options for Grok 4 Fast', () => { + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'grok-4-fast', provider: 'openrouter' })) + ).toEqual(['none', 'auto']) + }) + }) + + describe('Gemini models', () => { + it('should return correct options for Gemini Flash models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-flash-latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-flash-latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + + it('should return correct options for Gemini Pro models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-2.5-pro-latest' }))).toEqual([ + 'low', + 'medium', + 'high', + 'auto' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-pro-latest' }))).toEqual([ + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + + it('should return correct options for Gemini 3 models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-flash' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gemini-3-pro-preview' }))).toEqual([ + 'low', + 'medium', + 'high' + ]) + }) + }) + + describe('Qwen models', () => { + it('should return correct options for controllable Qwen models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-plus' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-turbo' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen-flash' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-8b' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return undefined for always-thinking Qwen models', () => { + // These models always think and don't support thinking token control + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-thinking' }))).toBeUndefined() + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'qwen3-vl-235b-thinking' }))).toBeUndefined() + }) + }) + + describe('Doubao models', () => { + it('should return correct options for auto-thinking Doubao models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1.6' }))).toEqual([ + 'none', + 'auto', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-1-5-thinking-pro-m' }))).toEqual([ + 'none', + 'auto', + 'high' + ]) + }) + + it('should return correct options for Doubao models after 251015', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1-6-251015' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-seed-1-6-lite-251015' }))).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + }) + + it('should return correct options for other Doubao thinking models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'doubao-1.5-thinking-vision-pro' }))).toEqual([ + 'none', + 'high' + ]) + }) + }) + + describe('Other providers', () => { + it('should return correct options for Hunyuan models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'hunyuan-a13b' }))).toEqual(['none', 'auto']) + }) + + it('should return correct options for Zhipu models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'glm-4.5' }))).toEqual(['none', 'auto']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'glm-4.6' }))).toEqual(['none', 'auto']) + }) + + it('should return correct options for Perplexity models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'sonar-deep-research' }))).toEqual(['medium']) + }) + + it('should return correct options for DeepSeek hybrid models', () => { + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-v3.1', provider: 'deepseek' })) + ).toEqual(['none', 'auto']) + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-v3.2', provider: 'openrouter' })) + ).toEqual(['none', 'auto']) + expect( + getModelSupportedReasoningEffortOptions(createModel({ id: 'deepseek-chat', provider: 'deepseek' })) + ).toEqual(['none', 'auto']) + }) + }) + + describe('Name-based fallback', () => { + it('should fall back to name when id does not match', () => { + // Grok 4 Fast requires openrouter provider to be recognized + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'grok-4-fast', + provider: 'openrouter' + }) + ) + ).toEqual(['none', 'auto']) + + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'gpt-5.1' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + + // Qwen models work well for name-based fallback + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'custom-id', + name: 'qwen-plus' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + }) + + it('should use id result when id matches', () => { + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'gpt-5.1', + name: 'Different Name' + }) + ) + ).toEqual(['none', 'low', 'medium', 'high']) + + expect( + getModelSupportedReasoningEffortOptions( + createModel({ + id: 'o3-mini', + name: 'Some other name' + }) + ) + ).toEqual(['low', 'medium', 'high']) + }) + }) + + describe('Case sensitivity', () => { + it('should handle case insensitive model IDs', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'GPT-5.1' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'O3-MINI' }))).toEqual(['low', 'medium', 'high']) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'Gemini-2.5-Flash-Latest' }))).toEqual([ + 'none', + 'low', + 'medium', + 'high', + 'auto' + ]) + }) + }) + + describe('Integration with MODEL_SUPPORTED_OPTIONS', () => { + it('should return values that match MODEL_SUPPORTED_OPTIONS configuration', () => { + // Verify that returned values match the configuration + const model = createModel({ id: 'o3' }) + const result = getModelSupportedReasoningEffortOptions(model) + expect(result).toEqual(MODEL_SUPPORTED_OPTIONS.o) + + const gpt5Model = createModel({ id: 'gpt-5' }) + const gpt5Result = getModelSupportedReasoningEffortOptions(gpt5Model) + expect(gpt5Result).toEqual(MODEL_SUPPORTED_OPTIONS.gpt5) + + const geminiModel = createModel({ id: 'gemini-2.5-flash-latest' }) + const geminiResult = getModelSupportedReasoningEffortOptions(geminiModel) + expect(geminiResult).toEqual(MODEL_SUPPORTED_OPTIONS.gemini) + }) + }) +}) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index d06d58a082..a5e47ef3b1 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -1,6 +1,7 @@ import type { Model, ReasoningEffortConfig, + ReasoningEffortOption, SystemProviderId, ThinkingModelType, ThinkingOptionConfig @@ -28,7 +29,7 @@ export const REASONING_REGEX = // 模型类型到支持的reasoning_effort的映射表 // TODO: refactor this. too many identical options -export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { +export const MODEL_SUPPORTED_REASONING_EFFORT = { default: ['low', 'medium', 'high'] as const, o: ['low', 'medium', 'high'] as const, openai_deep_research: ['medium'] as const, @@ -54,7 +55,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = { zhipu: ['auto'] as const, perplexity: ['low', 'medium', 'high'] as const, deepseek_hybrid: ['auto'] as const -} as const +} as const satisfies ReasoningEffortConfig // 模型类型到支持选项的映射表 export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { @@ -166,6 +167,64 @@ export const getThinkModelType = (model: Model): ThinkingModelType => { } } +const _getModelSupportedReasoningEffortOptions = (model: Model): ReasoningEffortOption[] | undefined => { + if (!isSupportedReasoningEffortModel(model) && !isSupportedThinkingTokenModel(model)) { + return undefined + } + // use private function to avoid redundant function calling + const thinkingType = _getThinkModelType(model) + return MODEL_SUPPORTED_OPTIONS[thinkingType] +} + +/** + * Gets the supported reasoning effort options for a given model. + * + * This function determines which reasoning effort levels a model supports based on its type. + * It works with models that support either `reasoning_effort` parameter (like OpenAI o-series) + * or thinking token control (like Claude, Gemini, Qwen, etc.). + * + * The function implements a fallback mechanism: it first checks the model's `id`, and if that + * doesn't match any known patterns, it falls back to checking the model's `name`. + * + * @param model - The model to check for reasoning effort support. Can be undefined or null. + * @returns An array of supported reasoning effort options, or undefined if: + * - The model is null/undefined + * - The model doesn't support reasoning effort or thinking tokens + * + * @example + * // OpenAI o-series models support low, medium, high + * getModelSupportedReasoningEffortOptions({ id: 'o3-mini', ... }) + * // Returns: ['low', 'medium', 'high'] + * + * @example + * // GPT-5.1 models support none, low, medium, high + * getModelSupportedReasoningEffortOptions({ id: 'gpt-5.1', ... }) + * // Returns: ['none', 'low', 'medium', 'high'] + * + * @example + * // Gemini Flash models support none, low, medium, high, auto + * getModelSupportedReasoningEffortOptions({ id: 'gemini-2.5-flash-latest', ... }) + * // Returns: ['none', 'low', 'medium', 'high', 'auto'] + * + * @example + * // Non-reasoning models return undefined + * getModelSupportedReasoningEffortOptions({ id: 'gpt-4o', ... }) + * // Returns: undefined + * + * @example + * // Name fallback when id doesn't match + * getModelSupportedReasoningEffortOptions({ id: 'custom-id', name: 'gpt-5.1', ... }) + * // Returns: ['none', 'low', 'medium', 'high'] + */ +export const getModelSupportedReasoningEffortOptions = ( + model: Model | undefined | null +): ReasoningEffortOption[] | undefined => { + if (!model) return undefined + + const { idResult, nameResult } = withModelIdAndNameAsId(model, _getModelSupportedReasoningEffortOptions) + return idResult ?? nameResult +} + function _isSupportedThinkingTokenModel(model: Model): boolean { // Specifically for DeepSeek V3.1. White list for now if (isDeepSeekHybridInferenceModel(model)) { @@ -201,12 +260,14 @@ function _isSupportedThinkingTokenModel(model: Model): boolean { } /** 用于判断是否支持控制思考,但不一定以reasoning_effort的方式 */ +// TODO: rename it export function isSupportedThinkingTokenModel(model?: Model): boolean { if (!model) return false const { idResult, nameResult } = withModelIdAndNameAsId(model, _isSupportedThinkingTokenModel) return idResult || nameResult } +// TODO: it should be merged in isSupportedThinkingTokenModel export function isSupportedReasoningEffortModel(model?: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 5ae3710e87..233b3c19ca 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -6,6 +6,7 @@ import { MAX_CONTEXT_COUNT, UNLIMITED_CONTEXT_COUNT } from '@renderer/config/constant' +import { getModelSupportedReasoningEffortOptions } from '@renderer/config/models' import { isQwenMTModel } from '@renderer/config/models/qwen' import { UNKNOWN } from '@renderer/config/translate' import { getStoreProviders } from '@renderer/hooks/useStore' @@ -54,7 +55,11 @@ export function getDefaultAssistant(): Assistant { } } -export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, text: string): TranslateAssistant { +export function getDefaultTranslateAssistant( + targetLanguage: TranslateLanguage, + text: string, + _settings?: Partial +): TranslateAssistant { const model = getTranslateModel() const assistant: Assistant = getDefaultAssistant() @@ -68,9 +73,12 @@ export function getDefaultTranslateAssistant(targetLanguage: TranslateLanguage, throw new Error('Unknown target language') } + const reasoningEffort = getModelSupportedReasoningEffortOptions(model)?.[0] const settings = { - temperature: 0.7 - } + temperature: 0.7, + reasoning_effort: reasoningEffort, + ..._settings + } satisfies Partial const getTranslateContent = (model: Model, text: string, targetLanguage: TranslateLanguage): string => { if (isQwenMTModel(model)) { diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index a5abb2baee..328f1a8edf 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -1,8 +1,10 @@ import { loggerService } from '@logger' import { db } from '@renderer/databases' import type { + AssistantSettings, CustomTranslateLanguage, FetchChatCompletionRequestOptions, + ReasoningEffortOption, TranslateHistory, TranslateLanguage, TranslateLanguageCode @@ -20,6 +22,10 @@ import { getDefaultTranslateAssistant } from './AssistantService' const logger = loggerService.withContext('TranslateService') +type TranslateOptions = { + reasoningEffort: ReasoningEffortOption +} + /** * 翻译文本到目标语言 * @param text - 需要翻译的文本内容 @@ -33,10 +39,14 @@ export const translateText = async ( text: string, targetLanguage: TranslateLanguage, onResponse?: (text: string, isComplete: boolean) => void, - abortKey?: string + abortKey?: string, + options?: TranslateOptions ) => { let abortError - const assistant = getDefaultTranslateAssistant(targetLanguage, text) + const assistantSettings: Partial | undefined = options + ? { reasoning_effort: options?.reasoningEffort } + : undefined + const assistant = getDefaultTranslateAssistant(targetLanguage, text, assistantSettings) const signal = abortKey ? readyToAbort(abortKey) : undefined From aeebd343d709aa95d1833f01dd93b0d877a86c09 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:28:42 +0800 Subject: [PATCH 11/41] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20ExaMCP=20free?= =?UTF-8?q?=20web=20search=20provider=20(#11874)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add ExaMCP free web search provider Add a new web search provider that uses Exa's free MCP API endpoint, requiring no API key. This provides users with a free alternative to the existing Exa provider. - Add 'exa-mcp' to WebSearchProviderIds - Create ExaMcpProvider using JSON-RPC/SSE protocol - Add provider config and migration for existing users - Use same Exa logo in settings UI * Add robust text chunk parser for ExaMcpProvider results --- src/renderer/src/config/webSearchProviders.ts | 10 + .../WebSearchProviderSetting.tsx | 1 + .../WebSearchProvider/ExaMcpProvider.ts | 209 ++++++++++++++++++ .../WebSearchProviderFactory.ts | 3 + src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 25 +++ src/renderer/src/types/index.ts | 1 + 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts diff --git a/src/renderer/src/config/webSearchProviders.ts b/src/renderer/src/config/webSearchProviders.ts index 1ce3297afb..169ae35930 100644 --- a/src/renderer/src/config/webSearchProviders.ts +++ b/src/renderer/src/config/webSearchProviders.ts @@ -31,6 +31,11 @@ export const WEB_SEARCH_PROVIDER_CONFIG: Record = ({ providerId }) => { case 'searxng': return SearxngLogo case 'exa': + case 'exa-mcp': return ExaLogo case 'bocha': return BochaLogo diff --git a/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts new file mode 100644 index 0000000000..8e04ba0a68 --- /dev/null +++ b/src/renderer/src/providers/WebSearchProvider/ExaMcpProvider.ts @@ -0,0 +1,209 @@ +import { loggerService } from '@logger' +import type { WebSearchState } from '@renderer/store/websearch' +import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types' + +import BaseWebSearchProvider from './BaseWebSearchProvider' + +const logger = loggerService.withContext('ExaMcpProvider') + +interface McpSearchRequest { + jsonrpc: string + id: number + method: string + params: { + name: string + arguments: { + query: string + numResults?: number + livecrawl?: 'fallback' | 'preferred' + type?: 'auto' | 'fast' | 'deep' + } + } +} + +interface McpSearchResponse { + jsonrpc: string + result: { + content: Array<{ type: string; text: string }> + } +} + +interface ExaSearchResult { + title?: string + url?: string + text?: string + publishedDate?: string + author?: string +} + +interface ExaSearchResults { + results?: ExaSearchResult[] + autopromptString?: string +} + +const DEFAULT_API_HOST = 'https://mcp.exa.ai/mcp' +const DEFAULT_NUM_RESULTS = 8 +const REQUEST_TIMEOUT_MS = 25000 + +export default class ExaMcpProvider extends BaseWebSearchProvider { + constructor(provider: WebSearchProvider) { + super(provider) + if (!this.apiHost) { + this.apiHost = DEFAULT_API_HOST + } + } + + public async search( + query: string, + websearch: WebSearchState, + httpOptions?: RequestInit + ): Promise { + try { + if (!query.trim()) { + throw new Error('Search query cannot be empty') + } + + const searchRequest: McpSearchRequest = { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'web_search_exa', + arguments: { + query, + type: 'auto', + numResults: websearch.maxResults || DEFAULT_NUM_RESULTS, + livecrawl: 'fallback' + } + } + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + try { + const response = await fetch(this.apiHost!, { + method: 'POST', + headers: { + ...this.defaultHeaders(), + accept: 'application/json, text/event-stream', + 'content-type': 'application/json' + }, + body: JSON.stringify(searchRequest), + signal: httpOptions?.signal ? AbortSignal.any([controller.signal, httpOptions.signal]) : controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Search error (${response.status}): ${errorText}`) + } + + const responseText = await response.text() + const searchResults = this.parseResponse(responseText) + + return { + query: searchResults.autopromptString || query, + results: (searchResults.results || []).slice(0, websearch.maxResults).map((result) => ({ + title: result.title || 'No title', + content: result.text || '', + url: result.url || '' + })) + } + } catch (error) { + clearTimeout(timeoutId) + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('Search request timed out') + } + + throw error + } + } catch (error) { + logger.error('Exa MCP search failed:', error as Error) + throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + private parsetextChunk(raw: string): ExaSearchResult[] { + const items: ExaSearchResult[] = [] + for (const chunk of raw.split('\n\n')) { + // logger.debug('Parsing chunk:', {"chunks": chunk}) + // 3. Parse the labeled lines inside the text block + const lines = chunk.split('\n') + // logger.debug('Lines:', lines); + let title = '' + let publishedDate = '' + let url = '' + let fullText = '' + + // We’ll capture everything after the first "Text:" as the article text + let textStartIndex = -1 + + lines.forEach((line, idx) => { + if (line.startsWith('Title:')) { + title = line.replace(/^Title:\s*/, '') + } else if (line.startsWith('Published Date:')) { + publishedDate = line.replace(/^Published Date:\s*/, '') + } else if (line.startsWith('URL:')) { + url = line.replace(/^URL:\s*/, '') + } else if (line.startsWith('Text:') && textStartIndex === -1) { + // mark where "Text:" starts + textStartIndex = idx + // text on the same line after "Text: " + fullText = line.replace(/^Text:\s*/, '') + } + }) + if (textStartIndex !== -1) { + const rest = lines.slice(textStartIndex + 1).join('\n') + if (rest.trim().length > 0) { + fullText = (fullText ? fullText + '\n' : '') + rest + } + } + + // If we at least got a title or URL, treat it as a valid article + if (title || url || fullText) { + items.push({ + title, + publishedDate, + url, + text: fullText + }) + } + } + return items + } + + private parseResponse(responseText: string): ExaSearchResults { + // Parse SSE response format + const lines = responseText.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data: McpSearchResponse = JSON.parse(line.substring(6)) + if (data.result?.content?.[0]?.text) { + // The text content contains stringified JSON with the actual results + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Continue to next line if parsing fails + logger.warn('Failed to parse SSE line:', { line }) + } + } + } + + // Try parsing as direct JSON response (non-SSE) + try { + const data: McpSearchResponse = JSON.parse(responseText) + if (data.result?.content?.[0]?.text) { + return { results: this.parsetextChunk(data.result.content[0].text) } + } + } catch { + // Ignore parsing errors + logger.warn('Failed to parse direct JSON response:', { responseText }) + } + + return { results: [] } + } +} diff --git a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts index 0b961c23d8..4adface5af 100644 --- a/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts +++ b/src/renderer/src/providers/WebSearchProvider/WebSearchProviderFactory.ts @@ -3,6 +3,7 @@ import type { WebSearchProvider } from '@renderer/types' import type BaseWebSearchProvider from './BaseWebSearchProvider' import BochaProvider from './BochaProvider' import DefaultProvider from './DefaultProvider' +import ExaMcpProvider from './ExaMcpProvider' import ExaProvider from './ExaProvider' import LocalBaiduProvider from './LocalBaiduProvider' import LocalBingProvider from './LocalBingProvider' @@ -24,6 +25,8 @@ export default class WebSearchProviderFactory { return new SearxngProvider(provider) case 'exa': return new ExaProvider(provider) + case 'exa-mcp': + return new ExaMcpProvider(provider) case 'local-google': return new LocalGoogleProvider(provider) case 'local-baidu': diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 30b6b72129..7eb762afd8 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -67,7 +67,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 183, + version: 184, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 8559a39e27..0a1f8ea70d 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -2992,6 +2992,31 @@ const migrateConfig = { logger.error('migrate 183 error', error as Error) return state } + }, + '184': (state: RootState) => { + try { + // Add exa-mcp (free) web search provider if not exists + const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp') + if (!exaMcpExists) { + // Find the index of 'exa' provider to insert after it + const exaIndex = state.websearch.providers.findIndex((p) => p.id === 'exa') + const newProvider = { + id: 'exa-mcp' as const, + name: 'ExaMCP', + apiHost: 'https://mcp.exa.ai/mcp' + } + if (exaIndex !== -1) { + state.websearch.providers.splice(exaIndex + 1, 0, newProvider) + } else { + state.websearch.providers.push(newProvider) + } + } + logger.info('migrate 184 success') + return state + } catch (error) { + logger.error('migrate 184 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6e7e4e41e0..0ff8131627 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -600,6 +600,7 @@ export const WebSearchProviderIds = { tavily: 'tavily', searxng: 'searxng', exa: 'exa', + 'exa-mcp': 'exa-mcp', bocha: 'bocha', 'local-google': 'local-google', 'local-bing': 'local-bing', From d41229c69b623c2862f5a13283115e258ccc29f9 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Tue, 16 Dec 2025 09:29:30 +0800 Subject: [PATCH 12/41] Add browser CDP MCP server with session management (#11844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: add CDP browser MCP server * ♻️ refactor: add navigation timeout for browser cdp * 🐛 fix: reuse window for execute and add debugger logging * ✨ feat: add show option and multiline execute for browser cdp * ✨ feat: support multiple sessions for browser cdp * ♻️ refactor: add LRU and idle cleanup for browser cdp sessions * Refactor browser-cdp for readability and set Firefox UA * 🐛 fix: type electron mock for cdp tests * ♻️ refactor: rename browser_cdp MCP server to browser Simplify the MCP server name from @cherry/browser-cdp to just browser for cleaner tool naming in the MCP protocol. * ✨ feat: add fetch tool to browser MCP server Add a new `fetch` tool that uses the CDP-controlled browser to fetch URLs and return content in various formats (html, txt, markdown, json). Also ignore .conductor folder in biome and eslint configs. * ♻️ refactor: split browser MCP server into modular folder structure Reorganize browser.ts (525 lines) into browser/ folder with separate files for better maintainability. Each tool now has its own file with schema, definition, and handler. * ♻️ refactor: use switch statement in browser server request handler * ♻️ refactor: extract helpers and use handler registry pattern - Add successResponse/errorResponse helpers in tools/utils.ts - Add closeWindow helper to consolidate window cleanup logic - Add ensureDebuggerAttached helper to consolidate debugger setup - Add toolHandlers map for registry-based handler lookup - Simplify server.ts to use dynamic handler dispatch * 🐛 fix: improve browser MCP server robustness - Add try-catch for JSON.parse in fetch() to handle invalid JSON gracefully - Add Zod schema validation to reset tool for consistency with other tools - Fix memory leak in open() by ensuring event listeners cleanup on timeout - Add JSDoc comments for key methods and classes * ♻️ refactor: rename browser MCP to @cherry/browser Follow naming convention of other builtin MCP servers. * 🌐 i18n: translate pending strings across 8 locales Translate all "[to be translated]" markers including: - CDP browser MCP server description (all 8 locales) - "Extra High" reasoning chain length option (6 locales) - Git Bash configuration strings (el-gr, ja-jp) --- biome.jsonc | 2 +- eslint.config.mjs | 1 + src/main/mcpServers/__tests__/browser.test.ts | 134 ++++++++ src/main/mcpServers/browser/controller.ts | 307 ++++++++++++++++++ src/main/mcpServers/browser/index.ts | 3 + src/main/mcpServers/browser/server.ts | 50 +++ src/main/mcpServers/browser/tools/execute.ts | 48 +++ src/main/mcpServers/browser/tools/fetch.ts | 49 +++ src/main/mcpServers/browser/tools/index.ts | 25 ++ src/main/mcpServers/browser/tools/open.ts | 47 +++ src/main/mcpServers/browser/tools/reset.ts | 34 ++ src/main/mcpServers/browser/tools/utils.ts | 13 + src/main/mcpServers/browser/types.ts | 4 + src/main/mcpServers/factory.ts | 4 + src/renderer/src/i18n/label.ts | 3 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 19 +- src/renderer/src/i18n/translate/es-es.json | 3 +- src/renderer/src/i18n/translate/fr-fr.json | 3 +- src/renderer/src/i18n/translate/ja-jp.json | 19 +- src/renderer/src/i18n/translate/pt-pt.json | 3 +- src/renderer/src/i18n/translate/ru-ru.json | 3 +- src/renderer/src/store/mcp.ts | 9 + src/renderer/src/types/index.ts | 3 +- 27 files changed, 765 insertions(+), 25 deletions(-) create mode 100644 src/main/mcpServers/__tests__/browser.test.ts create mode 100644 src/main/mcpServers/browser/controller.ts create mode 100644 src/main/mcpServers/browser/index.ts create mode 100644 src/main/mcpServers/browser/server.ts create mode 100644 src/main/mcpServers/browser/tools/execute.ts create mode 100644 src/main/mcpServers/browser/tools/fetch.ts create mode 100644 src/main/mcpServers/browser/tools/index.ts create mode 100644 src/main/mcpServers/browser/tools/open.ts create mode 100644 src/main/mcpServers/browser/tools/reset.ts create mode 100644 src/main/mcpServers/browser/tools/utils.ts create mode 100644 src/main/mcpServers/browser/types.ts diff --git a/biome.jsonc b/biome.jsonc index 705b1e01f3..6f925f5af2 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -23,7 +23,7 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!**/.claude/**", "!**/.vscode/**"], + "includes": ["**", "!**/.claude/**", "!**/.vscode/**", "!**/.conductor/**"], "maxSize": 2097152 }, "formatter": { diff --git a/eslint.config.mjs b/eslint.config.mjs index 64fdefa1dc..9eb20d1238 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -61,6 +61,7 @@ export default defineConfig([ 'tests/**', '.yarn/**', '.gitignore', + '.conductor/**', 'scripts/cloudflare-worker.js', 'src/main/integration/nutstore/sso/lib/**', 'src/main/integration/cherryai/index.js', diff --git a/src/main/mcpServers/__tests__/browser.test.ts b/src/main/mcpServers/__tests__/browser.test.ts new file mode 100644 index 0000000000..712eaf94ea --- /dev/null +++ b/src/main/mcpServers/__tests__/browser.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => { + const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => { + if (command === 'Runtime.evaluate') { + if (params?.expression === 'document.documentElement.outerHTML') { + return { result: { value: '

Test

Content

' } } + } + if (params?.expression === 'document.body.innerText') { + return { result: { value: 'Test\nContent' } } + } + return { result: { value: 'ok' } } + } + return {} + }) + + const debuggerObj = { + isAttached: vi.fn(() => true), + attach: vi.fn(), + detach: vi.fn(), + sendCommand + } + + const webContents = { + debugger: debuggerObj, + setUserAgent: vi.fn(), + getURL: vi.fn(() => 'https://example.com/'), + getTitle: vi.fn(async () => 'Example Title'), + once: vi.fn(), + removeListener: vi.fn(), + on: vi.fn() + } + + const loadURL = vi.fn(async () => {}) + + const windows: any[] = [] + + class MockBrowserWindow { + private destroyed = false + public webContents = webContents + public loadURL = loadURL + public isDestroyed = vi.fn(() => this.destroyed) + public close = vi.fn(() => { + this.destroyed = true + }) + public destroy = vi.fn(() => { + this.destroyed = true + }) + public on = vi.fn() + + constructor() { + windows.push(this) + } + } + + const app = { + isReady: vi.fn(() => true), + whenReady: vi.fn(async () => {}), + on: vi.fn() + } + + return { + BrowserWindow: MockBrowserWindow as any, + app, + __mockDebugger: debuggerObj, + __mockSendCommand: sendCommand, + __mockLoadURL: loadURL, + __mockWindows: windows + } +}) + +import * as electron from 'electron' +const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] } + +import { CdpBrowserController } from '../browser' + +describe('CdpBrowserController', () => { + it('executes single-line code via Runtime.evaluate', async () => { + const controller = new CdpBrowserController() + const result = await controller.execute('1+1') + expect(result).toBe('ok') + }) + + it('opens a URL (hidden) and returns current page info', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, false) + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('opens a URL (visible) when show=true', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://foo.bar/', 5000, true, 'session-a') + expect(result.currentUrl).toBe('https://example.com/') + expect(result.title).toBe('Example Title') + }) + + it('reuses session for execute and supports multiline', async () => { + const controller = new CdpBrowserController() + await controller.open('https://foo.bar/', 5000, false, 'session-b') + const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b') + expect(result).toBe('ok') + }) + + it('evicts least recently used session when exceeding maxSessions', async () => { + const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 }) + await controller.open('https://foo.bar/', 5000, false, 's1') + await controller.open('https://foo.bar/', 5000, false, 's2') + await controller.open('https://foo.bar/', 5000, false, 's3') + const destroyedCount = __mockWindows.filter( + (w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0 + ).length + expect(destroyedCount).toBeGreaterThanOrEqual(1) + }) + + it('fetches URL and returns html format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'html') + expect(result).toBe('

Test

Content

') + }) + + it('fetches URL and returns txt format', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'txt') + expect(result).toBe('Test\nContent') + }) + + it('fetches URL and returns markdown format (default)', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/') + expect(typeof result).toBe('string') + expect(result).toContain('Test') + }) +}) diff --git a/src/main/mcpServers/browser/controller.ts b/src/main/mcpServers/browser/controller.ts new file mode 100644 index 0000000000..6246da45d2 --- /dev/null +++ b/src/main/mcpServers/browser/controller.ts @@ -0,0 +1,307 @@ +import { app, BrowserWindow } from 'electron' +import TurndownService from 'turndown' + +import { logger, userAgent } from './types' + +/** + * Controller for managing browser windows via Chrome DevTools Protocol (CDP). + * Supports multiple sessions with LRU eviction and idle timeout cleanup. + */ +export class CdpBrowserController { + private windows: Map = new Map() + private readonly maxSessions: number + private readonly idleTimeoutMs: number + + constructor(options?: { maxSessions?: number; idleTimeoutMs?: number }) { + this.maxSessions = options?.maxSessions ?? 5 + this.idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000 + } + + private async ensureAppReady() { + if (!app.isReady()) { + await app.whenReady() + } + } + + private touch(sessionId: string) { + const entry = this.windows.get(sessionId) + if (entry) entry.lastActive = Date.now() + } + + private closeWindow(win: BrowserWindow, sessionId: string) { + try { + if (!win.isDestroyed()) { + if (win.webContents.debugger.isAttached()) { + win.webContents.debugger.detach() + } + win.close() + } + } catch (error) { + logger.warn('Error closing window', { error, sessionId }) + } + } + + private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionId: string) { + if (!dbg.isAttached()) { + try { + logger.info('Attaching debugger', { sessionId }) + dbg.attach('1.3') + await dbg.sendCommand('Page.enable') + await dbg.sendCommand('Runtime.enable') + logger.info('Debugger attached and domains enabled') + } catch (error) { + logger.error('Failed to attach debugger', { error }) + throw error + } + } + } + + private sweepIdle() { + const now = Date.now() + for (const [id, entry] of this.windows.entries()) { + if (now - entry.lastActive > this.idleTimeoutMs) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + } + } + + private evictIfNeeded(newSessionId: string) { + if (this.windows.size < this.maxSessions) return + let lruId: string | null = null + let lruTime = Number.POSITIVE_INFINITY + for (const [id, entry] of this.windows.entries()) { + if (id === newSessionId) continue + if (entry.lastActive < lruTime) { + lruTime = entry.lastActive + lruId = id + } + } + if (lruId) { + const entry = this.windows.get(lruId) + if (entry) { + this.closeWindow(entry.win, lruId) + } + this.windows.delete(lruId) + logger.info('Evicted session to respect maxSessions', { evicted: lruId }) + } + } + + private async getWindow(sessionId = 'default', forceNew = false, show = false): Promise { + await this.ensureAppReady() + + this.sweepIdle() + + const existing = this.windows.get(sessionId) + if (existing && !existing.win.isDestroyed() && !forceNew) { + this.touch(sessionId) + return existing.win + } + + if (existing && !existing.win.isDestroyed() && forceNew) { + try { + if (existing.win.webContents.debugger.isAttached()) { + existing.win.webContents.debugger.detach() + } + } catch (error) { + logger.warn('Error detaching debugger before recreate', { error, sessionId }) + } + existing.win.destroy() + this.windows.delete(sessionId) + } + + this.evictIfNeeded(sessionId) + + const win = new BrowserWindow({ + show, + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true + } + }) + + // Use a standard Chrome UA to avoid some anti-bot blocks + win.webContents.setUserAgent(userAgent) + + // Log navigation lifecycle to help diagnose slow loads + win.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { sessionId })) + win.webContents.on('dom-ready', () => logger.info(`dom-ready`, { sessionId })) + win.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { sessionId })) + win.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc })) + + win.on('closed', () => { + this.windows.delete(sessionId) + }) + + this.windows.set(sessionId, { win, lastActive: Date.now() }) + return win + } + + /** + * Opens a URL in a browser window and waits for navigation to complete. + * @param url - The URL to navigate to + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param show - Whether to show the browser window (default: false) + * @param sessionId - Session identifier for window reuse (default: 'default') + * @returns Object containing the current URL and page title after navigation + */ + public async open(url: string, timeout = 10000, show = false, sessionId = 'default') { + const win = await this.getWindow(sessionId, true, show) + logger.info('Loading URL', { url, sessionId }) + const { webContents } = win + this.touch(sessionId) + + // Track resolution state to prevent multiple handlers from firing + let resolved = false + let onFinish: () => void + let onDomReady: () => void + let onFail: (_event: Electron.Event, code: number, desc: string) => void + + // Define cleanup outside Promise to ensure it's callable in finally block, + // preventing memory leaks when timeout occurs before navigation completes + const cleanup = () => { + webContents.removeListener('did-finish-load', onFinish) + webContents.removeListener('did-fail-load', onFail) + webContents.removeListener('dom-ready', onDomReady) + } + + const loadPromise = new Promise((resolve, reject) => { + onFinish = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onDomReady = () => { + if (resolved) return + resolved = true + cleanup() + resolve() + } + onFail = (_event: Electron.Event, code: number, desc: string) => { + if (resolved) return + resolved = true + cleanup() + reject(new Error(`Navigation failed (${code}): ${desc}`)) + } + webContents.once('did-finish-load', onFinish) + webContents.once('dom-ready', onDomReady) + webContents.once('did-fail-load', onFail) + }) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Navigation timed out')), timeout) + }) + + try { + await Promise.race([win.loadURL(url), loadPromise, timeoutPromise]) + } finally { + // Always cleanup listeners to prevent memory leaks on timeout + cleanup() + } + + const currentUrl = webContents.getURL() + const title = await webContents.getTitle() + return { currentUrl, title } + } + + public async execute(code: string, timeout = 5000, sessionId = 'default') { + const win = await this.getWindow(sessionId) + this.touch(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + const evalPromise = dbg.sendCommand('Runtime.evaluate', { + expression: code, + awaitPromise: true, + returnByValue: true + }) + + const result = await Promise.race([ + evalPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out')), timeout)) + ]) + + const evalResult = result as any + + if (evalResult?.exceptionDetails) { + const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error' + logger.warn('Runtime.evaluate raised exception', { message }) + throw new Error(message) + } + + const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null + return value + } + + public async reset(sessionId?: string) { + if (sessionId) { + const entry = this.windows.get(sessionId) + if (entry) { + this.closeWindow(entry.win, sessionId) + } + this.windows.delete(sessionId) + logger.info('Browser CDP context reset', { sessionId }) + return + } + + for (const [id, entry] of this.windows.entries()) { + this.closeWindow(entry.win, id) + this.windows.delete(id) + } + logger.info('Browser CDP context reset (all sessions)') + } + + /** + * Fetches a URL and returns content in the specified format. + * @param url - The URL to fetch + * @param format - Output format: 'html', 'txt', 'markdown', or 'json' (default: 'markdown') + * @param timeout - Navigation timeout in milliseconds (default: 10000) + * @param sessionId - Session identifier (default: 'default') + * @returns Content in the requested format. For 'json', returns parsed object or { data: rawContent } if parsing fails + */ + public async fetch( + url: string, + format: 'html' | 'txt' | 'markdown' | 'json' = 'markdown', + timeout = 10000, + sessionId = 'default' + ) { + await this.open(url, timeout, false, sessionId) + + const win = await this.getWindow(sessionId) + const dbg = win.webContents.debugger + + await this.ensureDebuggerAttached(dbg, sessionId) + + let expression: string + if (format === 'json' || format === 'txt') { + expression = 'document.body.innerText' + } else { + expression = 'document.documentElement.outerHTML' + } + + const result = (await dbg.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true + })) as { result?: { value?: string } } + + const content = result?.result?.value ?? '' + + if (format === 'markdown') { + const turndownService = new TurndownService() + return turndownService.turndown(content) + } + if (format === 'json') { + // Attempt to parse as JSON; if content is not valid JSON, wrap it in a data object + try { + return JSON.parse(content) + } catch { + return { data: content } + } + } + return content + } +} diff --git a/src/main/mcpServers/browser/index.ts b/src/main/mcpServers/browser/index.ts new file mode 100644 index 0000000000..fbdb0a0f6e --- /dev/null +++ b/src/main/mcpServers/browser/index.ts @@ -0,0 +1,3 @@ +export { CdpBrowserController } from './controller' +export { BrowserServer } from './server' +export { BrowserServer as default } from './server' diff --git a/src/main/mcpServers/browser/server.ts b/src/main/mcpServers/browser/server.ts new file mode 100644 index 0000000000..3e889a7b66 --- /dev/null +++ b/src/main/mcpServers/browser/server.ts @@ -0,0 +1,50 @@ +import type { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { Server as MCServer } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { app } from 'electron' + +import { CdpBrowserController } from './controller' +import { toolDefinitions, toolHandlers } from './tools' + +export class BrowserServer { + public server: Server + private controller = new CdpBrowserController() + + constructor() { + const server = new MCServer( + { + name: '@cherry/browser', + version: '0.1.0' + }, + { + capabilities: { + resources: {}, + tools: {} + } + } + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: toolDefinitions + } + }) + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + const handler = toolHandlers[name] + if (!handler) { + throw new Error('Tool not found') + } + return handler(this.controller, args) + }) + + app.on('before-quit', () => { + void this.controller.reset() + }) + + this.server = server + } +} + +export default BrowserServer diff --git a/src/main/mcpServers/browser/tools/execute.ts b/src/main/mcpServers/browser/tools/execute.ts new file mode 100644 index 0000000000..1585a467a8 --- /dev/null +++ b/src/main/mcpServers/browser/tools/execute.ts @@ -0,0 +1,48 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const ExecuteSchema = z.object({ + code: z + .string() + .describe( + 'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.' + ), + timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), + sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)') +}) + +export const executeToolDefinition = { + name: 'execute', + description: + 'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.', + inputSchema: { + type: 'object', + properties: { + code: { + type: 'string', + description: 'One-line JS to evaluate in page context' + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds (default 5000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; targets a specific page (default: default)' + } + }, + required: ['code'] + } +} + +export async function handleExecute(controller: CdpBrowserController, args: unknown) { + const { code, timeout, sessionId } = ExecuteSchema.parse(args) + try { + const value = await controller.execute(code, timeout, sessionId ?? 'default') + return successResponse(typeof value === 'string' ? value : JSON.stringify(value)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/fetch.ts b/src/main/mcpServers/browser/tools/fetch.ts new file mode 100644 index 0000000000..b749aaff93 --- /dev/null +++ b/src/main/mcpServers/browser/tools/fetch.ts @@ -0,0 +1,49 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { errorResponse, successResponse } from './utils' + +export const FetchSchema = z.object({ + url: z.url().describe('URL to fetch'), + format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + sessionId: z.string().optional().describe('Session identifier (default: default)') +}) + +export const fetchToolDefinition = { + name: 'fetch', + description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to fetch' + }, + format: { + type: 'string', + enum: ['html', 'txt', 'markdown', 'json'], + description: 'Output format (default: markdown)' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default: 10000)' + }, + sessionId: { + type: 'string', + description: 'Session identifier (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleFetch(controller: CdpBrowserController, args: unknown) { + const { url, format, timeout, sessionId } = FetchSchema.parse(args) + try { + const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default') + return successResponse(typeof content === 'string' ? content : JSON.stringify(content)) + } catch (error) { + return errorResponse(error as Error) + } +} diff --git a/src/main/mcpServers/browser/tools/index.ts b/src/main/mcpServers/browser/tools/index.ts new file mode 100644 index 0000000000..19f1ee4163 --- /dev/null +++ b/src/main/mcpServers/browser/tools/index.ts @@ -0,0 +1,25 @@ +export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute' +export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch' +export { handleOpen, OpenSchema, openToolDefinition } from './open' +export { handleReset, resetToolDefinition } from './reset' + +import type { CdpBrowserController } from '../controller' +import { executeToolDefinition, handleExecute } from './execute' +import { fetchToolDefinition, handleFetch } from './fetch' +import { handleOpen, openToolDefinition } from './open' +import { handleReset, resetToolDefinition } from './reset' + +export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition] + +export const toolHandlers: Record< + string, + ( + controller: CdpBrowserController, + args: unknown + ) => Promise<{ content: { type: string; text: string }[]; isError: boolean }> +> = { + open: handleOpen, + execute: handleExecute, + reset: handleReset, + fetch: handleFetch +} diff --git a/src/main/mcpServers/browser/tools/open.ts b/src/main/mcpServers/browser/tools/open.ts new file mode 100644 index 0000000000..9739b3bcae --- /dev/null +++ b/src/main/mcpServers/browser/tools/open.ts @@ -0,0 +1,47 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +export const OpenSchema = z.object({ + url: z.url().describe('URL to open in the controlled Electron window'), + timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), + show: z.boolean().optional().describe('Whether to show the browser window (default: false)'), + sessionId: z + .string() + .optional() + .describe('Session identifier; separate sessions keep separate pages (default: default)') +}) + +export const openToolDefinition = { + name: 'open', + description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol', + inputSchema: { + type: 'object', + properties: { + url: { + type: 'string', + description: 'URL to load' + }, + timeout: { + type: 'number', + description: 'Navigation timeout in milliseconds (default 10000)' + }, + show: { + type: 'boolean', + description: 'Whether to show the browser window (default false)' + }, + sessionId: { + type: 'string', + description: 'Session identifier; separate sessions keep separate pages (default: default)' + } + }, + required: ['url'] + } +} + +export async function handleOpen(controller: CdpBrowserController, args: unknown) { + const { url, timeout, show, sessionId } = OpenSchema.parse(args) + const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default') + return successResponse(JSON.stringify(res)) +} diff --git a/src/main/mcpServers/browser/tools/reset.ts b/src/main/mcpServers/browser/tools/reset.ts new file mode 100644 index 0000000000..d09d251119 --- /dev/null +++ b/src/main/mcpServers/browser/tools/reset.ts @@ -0,0 +1,34 @@ +import * as z from 'zod' + +import type { CdpBrowserController } from '../controller' +import { successResponse } from './utils' + +/** Zod schema for validating reset tool arguments */ +export const ResetSchema = z.object({ + sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions') +}) + +/** MCP tool definition for the reset tool */ +export const resetToolDefinition = { + name: 'reset', + description: 'Reset the controlled window and detach debugger', + inputSchema: { + type: 'object', + properties: { + sessionId: { + type: 'string', + description: 'Session identifier to reset; omit to reset all sessions' + } + } + } +} + +/** + * Handler for the reset MCP tool. + * Closes browser window(s) and detaches debugger for the specified session or all sessions. + */ +export async function handleReset(controller: CdpBrowserController, args: unknown) { + const { sessionId } = ResetSchema.parse(args) + await controller.reset(sessionId) + return successResponse('reset') +} diff --git a/src/main/mcpServers/browser/tools/utils.ts b/src/main/mcpServers/browser/tools/utils.ts new file mode 100644 index 0000000000..2c5ecc0f1d --- /dev/null +++ b/src/main/mcpServers/browser/tools/utils.ts @@ -0,0 +1,13 @@ +export function successResponse(text: string) { + return { + content: [{ type: 'text', text }], + isError: false + } +} + +export function errorResponse(error: Error) { + return { + content: [{ type: 'text', text: error.message }], + isError: true + } +} diff --git a/src/main/mcpServers/browser/types.ts b/src/main/mcpServers/browser/types.ts new file mode 100644 index 0000000000..2cc934e6ce --- /dev/null +++ b/src/main/mcpServers/browser/types.ts @@ -0,0 +1,4 @@ +import { loggerService } from '@logger' + +export const logger = loggerService.withContext('MCPBrowserCDP') +export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0' diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index 2323701e49..ce736f6843 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -4,6 +4,7 @@ import type { BuiltinMCPServerName } from '@types' import { BuiltinMCPServerNames } from '@types' import BraveSearchServer from './brave-search' +import BrowserServer from './browser' import DiDiMcpServer from './didi-mcp' import DifyKnowledgeServer from './dify-knowledge' import FetchServer from './fetch' @@ -48,6 +49,9 @@ export function createInMemoryMCPServer( const apiKey = envs.DIDI_API_KEY return new DiDiMcpServer(apiKey).server } + case BuiltinMCPServerNames.browser: { + return new BrowserServer().server + } default: throw new Error(`Unknown in-memory MCP server: ${name}`) } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 434fb415f9..2830267088 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -343,7 +343,8 @@ const builtInMcpDescriptionKeyMap: Record = { [BuiltinMCPServerNames.filesystem]: 'settings.mcp.builtinServersDescriptions.filesystem', [BuiltinMCPServerNames.difyKnowledge]: 'settings.mcp.builtinServersDescriptions.dify_knowledge', [BuiltinMCPServerNames.python]: 'settings.mcp.builtinServersDescriptions.python', - [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp' + [BuiltinMCPServerNames.didiMCP]: 'settings.mcp.builtinServersDescriptions.didi_mcp', + [BuiltinMCPServerNames.browser]: 'settings.mcp.builtinServersDescriptions.browser' } 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 86f3900329..298ffeb86c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3884,6 +3884,7 @@ "builtinServers": "Builtin Servers", "builtinServersDescriptions": { "brave_search": "An MCP server implementation integrating the Brave Search API, providing both web and local search functionalities. Requires configuring the BRAVE_API_KEY environment variable", + "browser": "Control a headless Electron window via Chrome DevTools Protocol. Tools: open URL, execute single-line JS, reset session.", "didi_mcp": "DiDi MCP server providing ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in Mainland China. Requires configuring the DIDI_API_KEY environment variable", "dify_knowledge": "Dify's MCP server implementation provides a simple API to interact with Dify. Requires configuring the Dify Key", "fetch": "MCP server for retrieving URL web content", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a205408c47..8c10f3687b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3884,6 +3884,7 @@ "builtinServers": "内置服务器", "builtinServersDescriptions": { "brave_search": "一个集成了Brave 搜索 API 的 MCP 服务器实现,提供网页与本地搜索双重功能。需要配置 BRAVE_API_KEY 环境变量", + "browser": "通过 Chrome DevTools 协议控制隐藏的 Electron 窗口,支持打开 URL、执行单行 JS、重置会话", "didi_mcp": "一个集成了滴滴 MCP 服务器实现,提供网约车服务包括地图搜索、价格预估、订单管理和司机跟踪。仅支持中国大陆地区。需要配置 DIDI_API_KEY 环境变量", "dify_knowledge": "Dify 的 MCP 服务器实现,提供了一个简单的 API 来与 Dify 进行交互。需要配置 Dify Key", "fetch": "用于获取 URL 网页内容的 MCP 服务器", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2896077fa4..162373b557 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3884,6 +3884,7 @@ "builtinServers": "內建伺服器", "builtinServersDescriptions": { "brave_search": "一個整合了 Brave 搜尋 API 的 MCP 伺服器實做,提供網頁與本機搜尋雙重功能。需要設定 BRAVE_API_KEY 環境變數", + "browser": "透過 Chrome DevTools Protocol 控制 headless Electron 視窗。工具:開啟 URL、執行單行 JS、重設工作階段。", "didi_mcp": "一個整合了滴滴 MCP 伺服器實做,提供網約車服務包括地圖搜尋、價格預估、訂單管理和司機追蹤。僅支援中國大陸地區。需要設定 DIDI_API_KEY 環境變數", "dify_knowledge": "Dify 的 MCP 伺服器實做,提供了一個簡單的 API 來與 Dify 進行互動。需要設定 Dify Key", "fetch": "用於取得 URL 網頁內容的 MCP 伺服器", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index cbb5bc637a..4bc992759c 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3884,6 +3884,7 @@ "builtinServers": "Integrierter Server", "builtinServersDescriptions": { "brave_search": "MCP-Server-Implementierung mit Brave-Search-API, die sowohl Web- als auch lokale Suchfunktionen bietet. BRAVE_API_KEY-Umgebungsvariable muss konfiguriert werden", + "browser": "Steuert ein headless Electron-Fenster über das Chrome DevTools Protocol. Tools: URL öffnen, einzeiligen JS ausführen, Sitzung zurücksetzen.", "didi_mcp": "An integrated Didi MCP server implementation that provides ride-hailing services including map search, price estimation, order management, and driver tracking. Only available in mainland China. Requires the DIDI_API_KEY environment variable to be configured.", "dify_knowledge": "MCP-Server-Implementierung von Dify, die einen einfachen API-Zugriff auf Dify bietet. Dify Key muss konfiguriert werden", "fetch": "MCP-Server zum Abrufen von Webseiteninhalten", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index e26abd58fd..44fba429f7 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "Χρησιμοποιείται αυτόματα εντοπισμένο Git Bash", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "Διαγραφή προσαρμοσμένης διαδρομής" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "Χρησιμοποιείται προσαρμοσμένη διαδρομή: {{path}}", "error": { "description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από", "recheck": "Επανέλεγχος Εγκατάστασης του Git Bash", "title": "Απαιτείται Git Bash" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Το Git Bash διαμορφώθηκε" }, "notFound": "Το Git Bash δεν βρέθηκε. Παρακαλώ εγκαταστήστε το πρώτα.", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Επιλογή διαδρομής Git Bash", + "failed": "Αποτυχία ορισμού διαδρομής Git Bash", + "invalidPath": "Το επιλεγμένο αρχείο δεν είναι έγκυρο εκτελέσιμο Git Bash (bash.exe).", + "title": "Επιλογή εκτελέσιμου Git Bash" }, "success": "Το Git Bash εντοπίστηκε με επιτυχία!" }, @@ -547,7 +547,7 @@ "medium": "Μεσαίο", "minimal": "ελάχιστος", "off": "Απενεργοποίηση", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Εξαιρετικά Υψηλή" }, "regular_phrases": { "add": "Προσθήκη φράσης", @@ -3884,6 +3884,7 @@ "builtinServers": "Ενσωματωμένοι Διακομιστές", "builtinServersDescriptions": { "brave_search": "μια εφαρμογή διακομιστή MCP που ενσωματώνει το Brave Search API, παρέχοντας δυνατότητες αναζήτησης στον ιστό και τοπικής αναζήτησης. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος BRAVE_API_KEY", + "browser": "Ελέγχει ένα headless παράθυρο Electron μέσω του Chrome DevTools Protocol. Εργαλεία: άνοιγμα URL, εκτέλεση JS μίας γραμμής, επαναφορά συνεδρίας.", "didi_mcp": "Διακομιστής DiDi MCP που παρέχει υπηρεσίες μεταφοράς συμπεριλαμβανομένης της αναζήτησης χαρτών, εκτίμησης τιμών, διαχείρισης παραγγελιών και παρακολούθησης οδηγών. Διαθέσιμο μόνο στην ηπειρωτική Κίνα. Απαιτεί διαμόρφωση της μεταβλητής περιβάλλοντος DIDI_API_KEY", "dify_knowledge": "Η υλοποίηση του Dify για τον διακομιστή MCP, παρέχει μια απλή API για να αλληλεπιδρά με το Dify. Απαιτείται η ρύθμιση του κλειδιού Dify", "fetch": "Εξυπηρετητής MCP για λήψη περιεχομένου ιστοσελίδας URL", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 4316c80616..5cf620ed45 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -547,7 +547,7 @@ "medium": "Medio", "minimal": "minimal", "off": "Apagado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Agregar frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "Una implementación de servidor MCP que integra la API de búsqueda de Brave, proporcionando funciones de búsqueda web y búsqueda local. Requiere configurar la variable de entorno BRAVE_API_KEY", + "browser": "Controla una ventana Electron headless mediante Chrome DevTools Protocol. Herramientas: abrir URL, ejecutar JS de una línea, reiniciar sesión.", "didi_mcp": "Servidor DiDi MCP que proporciona servicios de transporte incluyendo búsqueda de mapas, estimación de precios, gestión de pedidos y seguimiento de conductores. Disponible solo en China Continental. Requiere configurar la variable de entorno DIDI_API_KEY", "dify_knowledge": "Implementación del servidor MCP de Dify, que proporciona una API sencilla para interactuar con Dify. Se requiere configurar la clave de Dify.", "fetch": "Servidor MCP para obtener el contenido de la página web de una URL", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 66b9fef86d..fdb72727b8 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -547,7 +547,7 @@ "medium": "Moyen", "minimal": "minimal", "off": "Off", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Très élevée" }, "regular_phrases": { "add": "Добавить фразу", @@ -3884,6 +3884,7 @@ "builtinServers": "Serveurs intégrés", "builtinServersDescriptions": { "brave_search": "Une implémentation de serveur MCP intégrant l'API de recherche Brave, offrant des fonctionnalités de recherche web et locale. Nécessite la configuration de la variable d'environnement BRAVE_API_KEY", + "browser": "Contrôle une fenêtre Electron headless via Chrome DevTools Protocol. Outils : ouvrir une URL, exécuter du JS en une ligne, réinitialiser la session.", "didi_mcp": "Serveur DiDi MCP fournissant des services de transport incluant la recherche de cartes, l'estimation des prix, la gestion des commandes et le suivi des conducteurs. Disponible uniquement en Chine continentale. Nécessite la configuration de la variable d'environnement DIDI_API_KEY", "dify_knowledge": "Implémentation du serveur MCP de Dify, fournissant une API simple pour interagir avec Dify. Nécessite la configuration de la clé Dify", "fetch": "serveur MCP utilisé pour récupérer le contenu des pages web URL", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 493d693580..d004d539e5 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -31,25 +31,25 @@ } }, "gitBash": { - "autoDetected": "[to be translated]:Using auto-detected Git Bash", + "autoDetected": "自動検出されたGit Bashを使用中", "clear": { - "button": "[to be translated]:Clear custom path" + "button": "カスタムパスをクリア" }, - "customPath": "[to be translated]:Using custom path: {{path}}", + "customPath": "カスタムパスを使用中: {{path}}", "error": { "description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。", "recheck": "Git Bashのインストールを再確認してください", "title": "Git Bashが必要です" }, "found": { - "title": "[to be translated]:Git Bash configured" + "title": "Git Bashが設定されました" }, "notFound": "Git Bash が見つかりません。先にインストールしてください。", "pick": { - "button": "[to be translated]:Select Git Bash Path", - "failed": "[to be translated]:Failed to set Git Bash path", - "invalidPath": "[to be translated]:Selected file is not a valid Git Bash executable (bash.exe).", - "title": "[to be translated]:Select Git Bash executable" + "button": "Git Bashパスを選択", + "failed": "Git Bashパスの設定に失敗しました", + "invalidPath": "選択されたファイルは有効なGit Bash実行ファイル(bash.exe)ではありません。", + "title": "Git Bash実行ファイルを選択" }, "success": "Git Bashが正常に検出されました!" }, @@ -547,7 +547,7 @@ "medium": "普通の思考", "minimal": "最小限の思考", "off": "オフ", - "xhigh": "[to be translated]:Extra High" + "xhigh": "超高" }, "regular_phrases": { "add": "プロンプトを追加", @@ -3884,6 +3884,7 @@ "builtinServers": "組み込みサーバー", "builtinServersDescriptions": { "brave_search": "Brave検索APIを統合したMCPサーバーの実装で、ウェブ検索とローカル検索の両機能を提供します。BRAVE_API_KEY環境変数の設定が必要です", + "browser": "Chrome DevTools Protocolを介してheadless Electronウィンドウを制御します。ツール:URLを開く、単一行JSを実行、セッションをリセット。", "didi_mcp": "DiDi MCPサーバーは、地図検索、料金見積もり、注文管理、ドライバー追跡を含むライドシェアサービスを提供します。中国本土でのみ利用可能です。DIDI_API_KEY環境変数の設定が必要です", "dify_knowledge": "DifyのMCPサーバー実装は、Difyと対話するためのシンプルなAPIを提供します。Dify Keyの設定が必要です。", "fetch": "URLのウェブページコンテンツを取得するためのMCPサーバー", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index fba1a8e706..32c1965f54 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -547,7 +547,7 @@ "medium": "Médio", "minimal": "mínimo", "off": "Desligado", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Extra Alta" }, "regular_phrases": { "add": "Adicionar Frase", @@ -3884,6 +3884,7 @@ "builtinServers": "Servidores integrados", "builtinServersDescriptions": { "brave_search": "uma implementação de servidor MCP integrada com a API de pesquisa Brave, fornecendo funcionalidades de pesquisa web e local. Requer a configuração da variável de ambiente BRAVE_API_KEY", + "browser": "Controla uma janela Electron headless via Chrome DevTools Protocol. Ferramentas: abrir URL, executar JS de linha única, reiniciar sessão.", "didi_mcp": "Servidor DiDi MCP que fornece serviços de transporte incluindo pesquisa de mapas, estimativa de preços, gestão de pedidos e rastreamento de motoristas. Disponível apenas na China Continental. Requer configuração da variável de ambiente DIDI_API_KEY", "dify_knowledge": "Implementação do servidor MCP do Dify, que fornece uma API simples para interagir com o Dify. Requer a configuração da chave Dify", "fetch": "servidor MCP para obter o conteúdo da página web do URL", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 2972336406..3d022ae24b 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -547,7 +547,7 @@ "medium": "Среднее", "minimal": "минимальный", "off": "Выключить", - "xhigh": "[to be translated]:Extra High" + "xhigh": "Сверхвысокое" }, "regular_phrases": { "add": "Добавить подсказку", @@ -3884,6 +3884,7 @@ "builtinServers": "Встроенные серверы", "builtinServersDescriptions": { "brave_search": "реализация сервера MCP с интеграцией API поиска Brave, обеспечивающая функции веб-поиска и локального поиска. Требуется настройка переменной среды BRAVE_API_KEY", + "browser": "Управление headless-окном Electron через Chrome DevTools Protocol. Инструменты: открытие URL, выполнение однострочного JS, сброс сессии.", "didi_mcp": "Сервер DiDi MCP, предоставляющий услуги такси, включая поиск на карте, оценку стоимости, управление заказами и отслеживание водителей. Доступен только в материковом Китае. Требует настройки переменной окружения DIDI_API_KEY", "dify_knowledge": "Реализация сервера MCP Dify, предоставляющая простой API для взаимодействия с Dify. Требуется настройка ключа Dify", "fetch": "MCP-сервер для получения содержимого веб-страниц по URL", diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index aef38bcbe8..ed7076bc1c 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -174,6 +174,15 @@ export const builtinMCPServers: BuiltinMCPServer[] = [ provider: 'CherryAI', installSource: 'builtin', isTrusted: true + }, + { + id: nanoid(), + name: BuiltinMCPServerNames.browser, + type: 'inMemory', + isActive: false, + provider: 'CherryAI', + installSource: 'builtin', + isTrusted: true } ] as const diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 0ff8131627..197d217793 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -749,7 +749,8 @@ export const BuiltinMCPServerNames = { filesystem: '@cherry/filesystem', difyKnowledge: '@cherry/dify-knowledge', python: '@cherry/python', - didiMCP: '@cherry/didi-mcp' + didiMCP: '@cherry/didi-mcp', + browser: '@cherry/browser' } as const export type BuiltinMCPServerName = (typeof BuiltinMCPServerNames)[keyof typeof BuiltinMCPServerNames] From b66787280a62f615fa651459d4fd1d96effb46b6 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 16 Dec 2025 18:57:58 +0800 Subject: [PATCH 13/41] chore: release v1.7.4 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 | 74 ++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 35 insertions(+), 41 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 20c183a58b..db1184be87 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,60 +134,54 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.3 - Feature & Stability Update + Cherry Studio 1.7.4 - New Browser MCP & Model Updates - This release brings new features, UI improvements, and important bug fixes. + This release adds a powerful browser automation MCP server, new web search provider, and model support updates. ✨ New Features - - Add MCP server log viewer for better debugging - - Support custom Git Bash path configuration - - Add print to PDF and save as HTML for mini program webviews - - Add CherryIN API host selection settings - - Enhance assistant presets with sort and batch delete modes - - Open URL directly for SelectionAssistant search action - - Enhance web search tool switching with provider-specific context + - [MCP] Add @cherry/browser CDP MCP server with session management for browser automation + - [Web Search] Add ExaMCP free web search provider (no API key required) + - [Model] Support GPT 5.2 series models + - [Model] Add capabilities support for Doubao Seed Code models (tool calling, reasoning, vision) 🔧 Improvements - - Remove Intel Ultra limit for OVMS - - Improve settings tab and assistant item UI + - [Translate] Add reasoning effort option to translate service + - [i18n] Improve zh-TW Traditional Chinese locale + - [Settings] Update MCP Settings layout and styling 🐛 Bug Fixes - - Fix stack overflow with base64 images - - Fix infinite loop in knowledge queue processing - - Fix quick panel closing in multiple selection mode - - Fix thinking timer not stopping when reply is aborted - - Fix ThinkingButton icon display for fixed reasoning mode - - Fix knowledge query prioritization and intent prompt - - Fix OpenRouter embeddings support - - Fix SelectionAction window resize on Windows - - Add gpustack provider support for qwen3 thinking mode + - [Chat] Fix line numbers being wrongly copied from code blocks + - [Translate] Fix default to first supported reasoning effort when translating + - [Chat] Fix preserve thinking block in assistant messages + - [Web Search] Fix max search result limit + - [Embedding] Fix embedding dimensions retrieval for ModernAiProvider + - [Chat] Fix token calculation in prompt tool use plugin + - [Model] Fix Ollama provider options for Qwen model support + - [UI] Fix Chat component marginRight calculation for improved layout - Cherry Studio 1.7.3 - 功能与稳定性更新 + Cherry Studio 1.7.4 - 新增浏览器 MCP 与模型更新 - 本次更新带来新功能、界面改进和重要的问题修复。 + 本次更新新增强大的浏览器自动化 MCP 服务器、新的网页搜索提供商以及模型支持更新。 ✨ 新功能 - - 新增 MCP 服务器日志查看器,便于调试 - - 支持自定义 Git Bash 路径配置 - - 小程序 webview 支持打印 PDF 和保存为 HTML - - 新增 CherryIN API 主机选择设置 - - 助手预设增强:支持排序和批量删除模式 - - 划词助手搜索操作直接打开 URL - - 增强网页搜索工具切换逻辑,支持服务商特定上下文 + - [MCP] 新增 @cherry/browser CDP MCP 服务器,支持会话管理的浏览器自动化 + - [网页搜索] 新增 ExaMCP 免费网页搜索提供商(无需 API 密钥) + - [模型] 支持 GPT 5.2 系列模型 + - [模型] 为豆包 Seed Code 模型添加能力支持(工具调用、推理、视觉) 🔧 功能改进 - - 移除 OVMS 的 Intel Ultra 限制 - - 优化设置标签页和助手项目 UI + - [翻译] 为翻译服务添加推理强度选项 + - [国际化] 改进繁体中文(zh-TW)本地化 + - [设置] 优化 MCP 设置布局和样式 🐛 问题修复 - - 修复 base64 图片导致的栈溢出问题 - - 修复知识库队列处理的无限循环问题 - - 修复多选模式下快捷面板意外关闭的问题 - - 修复回复中止时思考计时器未停止的问题 - - 修复固定推理模式下思考按钮图标显示问题 - - 修复知识库查询优先级和意图提示 - - 修复 OpenRouter 嵌入模型支持 - - 修复 Windows 上划词助手窗口大小调整问题 - - 为 gpustack 服务商添加 qwen3 思考模式支持 + - [聊天] 修复代码块中行号被错误复制的问题 + - [翻译] 修复翻译时默认使用第一个支持的推理强度 + - [聊天] 修复助手消息中思考块的保留问题 + - [网页搜索] 修复最大搜索结果数限制 + - [嵌入] 修复 ModernAiProvider 嵌入维度获取问题 + - [聊天] 修复提示词工具使用插件的 token 计算问题 + - [模型] 修复 Ollama 提供商对 Qwen 模型的支持选项 + - [界面] 修复聊天组件右边距计算以改善布局 diff --git a/package.json b/package.json index 58bdaf128a..fa63de9dec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.3", + "version": "1.7.4", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From f2b4a2382b60ab4c62e39a388ef18b5482748785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Tue, 16 Dec 2025 22:08:05 +0800 Subject: [PATCH 14/41] refactor: rename i18n commands for better consistency (#11938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: rename i18n commands for better consistency - Rename `check:i18n` to `i18n:check` - Rename `sync:i18n` to `i18n:sync` - Rename `update:i18n` to `i18n:translate` (clearer purpose) - Rename `auto:i18n` to `i18n:all` (runs check, sync, and translate) - Update lint script to use new `i18n:check` command name This follows the common naming convention of grouping related commands under a namespace prefix (e.g., `test:main`, `test:renderer`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor: update i18n command names and improve documentation - Renamed i18n commands for consistency: `sync:i18n` to `i18n:sync`, `check:i18n` to `i18n:check`, and `auto:i18n` to `i18n:translate`. - Updated relevant documentation and scripts to reflect new command names. - Improved formatting and clarity in i18n-related guides and scripts. This change enhances the clarity and usability of i18n commands across the project. --------- Co-authored-by: Claude Opus 4.5 --- .github/workflows/auto-i18n.yml | 2 +- .github/workflows/pr-ci.yml | 2 +- CLAUDE.md | 11 +++-- docs/en/guides/i18n.md | 44 ++++++++------------ docs/zh/guides/i18n.md | 74 ++++++++++++++------------------- package.json | 10 ++--- scripts/auto-translate-i18n.ts | 2 +- scripts/check-i18n.ts | 2 +- 8 files changed, 65 insertions(+), 82 deletions(-) diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 6141c061fa..2ca56c0837 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -54,7 +54,7 @@ jobs: yarn install - name: 🏃‍♀️ Translate - run: yarn sync:i18n && yarn auto:i18n + run: yarn i18n:sync && yarn i18n:translate - name: 🔍 Format run: yarn format diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 1258449007..1f7bf7d784 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -58,7 +58,7 @@ jobs: run: yarn typecheck - name: i18n Check - run: yarn check:i18n + run: yarn i18n:check - name: Test run: yarn test diff --git a/CLAUDE.md b/CLAUDE.md index c96fc0e403..c68187db93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ When creating a Pull Request, you MUST: - **Development**: `yarn dev` - Runs Electron app in development mode with hot reload - **Debug**: `yarn debug` - Starts with debugging enabled, use `chrome://inspect` to attach debugger - **Build Check**: `yarn build:check` - **REQUIRED** before commits (lint + test + typecheck) - - If having i18n sort issues, run `yarn sync:i18n` first to sync template + - If having i18n sort issues, run `yarn i18n:sync` first to sync template - If having formatting issues, run `yarn format` first - **Test**: `yarn test` - Run all tests (Vitest) across main and renderer processes - **Single Test**: @@ -40,20 +40,23 @@ When creating a Pull Request, you MUST: ## Project Architecture ### Electron Structure + - **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.) - **Renderer Process** (`src/renderer/`): React UI with Redux state management - **Preload Scripts** (`src/preload/`): Secure IPC bridge ### Key Components + - **AI Core** (`src/renderer/src/aiCore/`): Middleware pipeline for multiple AI providers. - **Services** (`src/main/services/`): MCPService, KnowledgeService, WindowService, etc. - **Build System**: Electron-Vite with experimental rolldown-vite, yarn workspaces. - **State Management**: Redux Toolkit (`src/renderer/src/store/`) for predictable state. ### Logging + ```typescript -import { loggerService } from '@logger' -const logger = loggerService.withContext('moduleName') +import { loggerService } from "@logger"; +const logger = loggerService.withContext("moduleName"); // Renderer: loggerService.initWindowSource('windowName') first -logger.info('message', CONTEXT) +logger.info("message", CONTEXT); ``` diff --git a/docs/en/guides/i18n.md b/docs/en/guides/i18n.md index a3284e3ab9..7fccfde695 100644 --- a/docs/en/guides/i18n.md +++ b/docs/en/guides/i18n.md @@ -71,7 +71,7 @@ Tools like i18n Ally cannot parse dynamic content within template strings, resul ```javascript // Not recommended - Plugin cannot resolve -const message = t(`fruits.${fruit}`) +const message = t(`fruits.${fruit}`); ``` #### 2. **No Real-time Rendering in Editor** @@ -91,14 +91,14 @@ For example: ```ts // src/renderer/src/i18n/label.ts const themeModeKeyMap = { - dark: 'settings.theme.dark', - light: 'settings.theme.light', - system: 'settings.theme.system' -} as const + dark: "settings.theme.dark", + light: "settings.theme.light", + system: "settings.theme.system", +} as const; export const getThemeModeLabel = (key: string): string => { - return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key -} + return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key; +}; ``` By avoiding template strings, you gain better developer experience, more reliable translation checks, and a more maintainable codebase. @@ -107,7 +107,7 @@ By avoiding template strings, you gain better developer experience, more reliabl The project includes several scripts to automate i18n-related tasks: -### `check:i18n` - Validate i18n Structure +### `i18n:check` - Validate i18n Structure This script checks: @@ -116,10 +116,10 @@ This script checks: - Whether keys are properly sorted ```bash -yarn check:i18n +yarn i18n:check ``` -### `sync:i18n` - Synchronize JSON Structure and Sort Order +### `i18n:sync` - Synchronize JSON Structure and Sort Order This script uses `zh-cn.json` as the source of truth to sync structure across all language files, including: @@ -128,14 +128,14 @@ This script uses `zh-cn.json` as the source of truth to sync structure across al 3. Sorting keys automatically ```bash -yarn sync:i18n +yarn i18n:sync ``` -### `auto:i18n` - Automatically Translate Pending Texts +### `i18n:translate` - Automatically Translate Pending Texts This script fills in texts marked as `[to be translated]` using machine translation. -Typically, after adding new texts in `zh-cn.json`, run `sync:i18n`, then `auto:i18n` to complete translations. +Typically, after adding new texts in `zh-cn.json`, run `i18n:sync`, then `i18n:translate` to complete translations. Before using this script, set the required environment variables: @@ -148,30 +148,20 @@ MODEL="qwen-plus-latest" Alternatively, add these variables directly to your `.env` file. ```bash -yarn auto:i18n -``` - -### `update:i18n` - Object-level Translation Update - -Updates translations in language files under `src/renderer/src/i18n/translate` at the object level, preserving existing translations and only updating new content. - -**Not recommended** — prefer `auto:i18n` for translation tasks. - -```bash -yarn update:i18n +yarn i18n:translate ``` ### Workflow 1. During development, first add the required text in `zh-cn.json` 2. Confirm it displays correctly in the Chinese environment -3. Run `yarn sync:i18n` to propagate the keys to other language files -4. Run `yarn auto:i18n` to perform machine translation +3. Run `yarn i18n:sync` to propagate the keys to other language files +4. Run `yarn i18n:translate` to perform machine translation 5. Grab a coffee and let the magic happen! ## Best Practices 1. **Use Chinese as Source Language**: All development starts in Chinese, then translates to other languages. -2. **Run Check Script Before Commit**: Use `yarn check:i18n` to catch i18n issues early. +2. **Run Check Script Before Commit**: Use `yarn i18n:check` to catch i18n issues early. 3. **Translate in Small Increments**: Avoid accumulating a large backlog of untranslated content. 4. **Keep Keys Semantically Clear**: Keys should clearly express their purpose, e.g., `user.profile.avatar.upload.error` diff --git a/docs/zh/guides/i18n.md b/docs/zh/guides/i18n.md index 82624d35c8..c8a8ccc66b 100644 --- a/docs/zh/guides/i18n.md +++ b/docs/zh/guides/i18n.md @@ -1,17 +1,17 @@ # 如何优雅地做好 i18n -## 使用i18n ally插件提升开发体验 +## 使用 i18n ally 插件提升开发体验 -i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反馈,帮助开发者更早发现文案缺失和错译问题。 +i18n ally 是一个强大的 VSCode 插件,它能在开发阶段提供实时反馈,帮助开发者更早发现文案缺失和错译问题。 项目中已经配置好了插件设置,直接安装即可。 ### 开发时优势 - **实时预览**:翻译文案会直接显示在编辑器中 -- **错误检测**:自动追踪标记出缺失的翻译或未使用的key -- **快速跳转**:可通过key直接跳转到定义处(Ctrl/Cmd + click) -- **自动补全**:输入i18n key时提供自动补全建议 +- **错误检测**:自动追踪标记出缺失的翻译或未使用的 key +- **快速跳转**:可通过 key 直接跳转到定义处(Ctrl/Cmd + click) +- **自动补全**:输入 i18n key 时提供自动补全建议 ### 效果展示 @@ -23,9 +23,9 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ## i18n 约定 -### **绝对避免使用flat格式** +### **绝对避免使用 flat 格式** -绝对避免使用flat格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构: +绝对避免使用 flat 格式,如`"add.button.tip": "添加"`。应采用清晰的嵌套结构: ```json // 错误示例 - flat结构 @@ -52,14 +52,14 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 #### 为什么要使用嵌套结构 1. **自然分组**:通过对象结构天然能将相关上下文的文案分到一个组别中 -2. **插件要求**:i18n ally 插件需要嵌套或flat格式其一的文件才能正常分析 +2. **插件要求**:i18n ally 插件需要嵌套或 flat 格式其一的文件才能正常分析 ### **避免在`t()`中使用模板字符串** -**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在JavaScript开发中非常方便,但在国际化场景下会带来一系列问题。 +**强烈建议避免使用模板字符串**进行动态插值。虽然模板字符串在 JavaScript 开发中非常方便,但在国际化场景下会带来一系列问题。 1. **插件无法跟踪** - i18n ally等工具无法解析模板字符串中的动态内容,导致: + i18n ally 等工具无法解析模板字符串中的动态内容,导致: - 无法正确显示实时预览 - 无法检测翻译缺失 @@ -67,11 +67,11 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ```javascript // 不推荐 - 插件无法解析 - const message = t(`fruits.${fruit}`) + const message = t(`fruits.${fruit}`); ``` 2. **编辑器无法实时渲染** - 在IDE中,模板字符串会显示为原始代码而非最终翻译结果,降低了开发体验。 + 在 IDE 中,模板字符串会显示为原始代码而非最终翻译结果,降低了开发体验。 3. **更难以维护** 由于插件无法跟踪这样的文案,编辑器中也无法渲染,开发者必须人工确认语言文件中是否存在相应的文案。 @@ -85,36 +85,36 @@ i18n ally是一个强大的VSCode插件,它能在开发阶段提供实时反 ```ts // src/renderer/src/i18n/label.ts const themeModeKeyMap = { - dark: 'settings.theme.dark', - light: 'settings.theme.light', - system: 'settings.theme.system' -} as const + dark: "settings.theme.dark", + light: "settings.theme.light", + system: "settings.theme.system", +} as const; export const getThemeModeLabel = (key: string): string => { - return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key -} + return themeModeKeyMap[key] ? t(themeModeKeyMap[key]) : key; +}; ``` 通过避免模板字符串,可以获得更好的开发体验、更可靠的翻译检查以及更易维护的代码库。 ## 自动化脚本 -项目中有一系列脚本来自动化i18n相关任务: +项目中有一系列脚本来自动化 i18n 相关任务: -### `check:i18n` - 检查i18n结构 +### `i18n:check` - 检查 i18n 结构 此脚本会检查: - 所有语言文件是否为嵌套结构 -- 是否存在缺失的key -- 是否存在多余的key +- 是否存在缺失的 key +- 是否存在多余的 key - 是否已经有序 ```bash -yarn check:i18n +yarn i18n:check ``` -### `sync:i18n` - 同步json结构与排序 +### `i18n:sync` - 同步 json 结构与排序 此脚本以`zh-cn.json`文件为基准,将结构同步到其他语言文件,包括: @@ -123,14 +123,14 @@ yarn check:i18n 3. 自动排序 ```bash -yarn sync:i18n +yarn i18n:sync ``` -### `auto:i18n` - 自动翻译待翻译文本 +### `i18n:translate` - 自动翻译待翻译文本 次脚本自动将标记为待翻译的文本通过机器翻译填充。 -通常,在`zh-cn.json`中添加所需文案后,执行`sync:i18n`即可自动完成翻译。 +通常,在`zh-cn.json`中添加所需文案后,执行`i18n:sync`即可自动完成翻译。 使用该脚本前,需要配置环境变量,例如: @@ -143,29 +143,19 @@ MODEL="qwen-plus-latest" 你也可以通过直接编辑`.env`文件来添加环境变量。 ```bash -yarn auto:i18n -``` - -### `update:i18n` - 对象级别翻译更新 - -对`src/renderer/src/i18n/translate`中的语言文件进行对象级别的翻译更新,保留已有翻译,只更新新增内容。 - -**不建议**使用该脚本,更推荐使用`auto:i18n`进行翻译。 - -```bash -yarn update:i18n +yarn i18n:translate ``` ### 工作流 1. 开发阶段,先在`zh-cn.json`中添加所需文案 -2. 确认在中文环境下显示无误后,使用`yarn sync:i18n`将文案同步到其他语言文件 -3. 使用`yarn auto:i18n`进行自动翻译 +2. 确认在中文环境下显示无误后,使用`yarn i18n:sync`将文案同步到其他语言文件 +3. 使用`yarn i18n:translate`进行自动翻译 4. 喝杯咖啡,等翻译完成吧! ## 最佳实践 1. **以中文为源语言**:所有开发首先使用中文,再翻译为其他语言 -2. **提交前运行检查脚本**:使用`yarn check:i18n`检查i18n是否有问题 +2. **提交前运行检查脚本**:使用`yarn i18n:check`检查 i18n 是否有问题 3. **小步提交翻译**:避免积累大量未翻译文本 -4. **保持key语义明确**:key应能清晰表达其用途,如`user.profile.avatar.upload.error` +4. **保持 key 语义明确**:key 应能清晰表达其用途,如`user.profile.avatar.upload.error` diff --git a/package.json b/package.json index fa63de9dec..3fd6d1741f 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,10 @@ "typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"", "typecheck:node": "tsgo --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsgo --noEmit -p tsconfig.web.json --composite false", - "check:i18n": "dotenv -e .env -- tsx scripts/check-i18n.ts", - "sync:i18n": "dotenv -e .env -- tsx scripts/sync-i18n.ts", - "update:i18n": "dotenv -e .env -- tsx scripts/update-i18n.ts", - "auto:i18n": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", + "i18n:check": "dotenv -e .env -- tsx scripts/check-i18n.ts", + "i18n:sync": "dotenv -e .env -- tsx scripts/sync-i18n.ts", + "i18n:translate": "dotenv -e .env -- tsx scripts/auto-translate-i18n.ts", + "i18n:all": "yarn i18n:check && yarn i18n:sync && yarn i18n:translate", "update:languages": "tsx scripts/update-languages.ts", "update:upgrade-config": "tsx scripts/update-app-upgrade-config.ts", "test": "vitest run --silent", @@ -70,7 +70,7 @@ "test:e2e": "yarn playwright test", "test:lint": "oxlint --deny-warnings && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --cache", "test:scripts": "vitest scripts", - "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn check:i18n && yarn format:check", + "lint": "oxlint --fix && eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --cache && yarn typecheck && yarn i18n:check && yarn format:check", "format": "biome format --write && biome lint --write", "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", diff --git a/scripts/auto-translate-i18n.ts b/scripts/auto-translate-i18n.ts index 7a1bea6f35..f57913b014 100644 --- a/scripts/auto-translate-i18n.ts +++ b/scripts/auto-translate-i18n.ts @@ -50,7 +50,7 @@ Usage Instructions: - pt-pt (Portuguese) Run Command: -yarn auto:i18n +yarn i18n:translate Performance Optimization Recommendations: - For stable API services: MAX_CONCURRENT_TRANSLATIONS=8, TRANSLATION_DELAY_MS=50 diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts index 5735474106..ac1adc3de8 100644 --- a/scripts/check-i18n.ts +++ b/scripts/check-i18n.ts @@ -145,7 +145,7 @@ export function main() { console.log('i18n 检查已通过') } catch (e) { console.error(e) - throw new Error(`检查未通过。尝试运行 yarn sync:i18n 以解决问题。`) + throw new Error(`检查未通过。尝试运行 yarn i18n:sync 以解决问题。`) } } From 432b31c7b1f364dd911895320e74651000ea3b4e Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Wed, 17 Dec 2025 02:11:11 +0000 Subject: [PATCH 15/41] fix: Bind OAuth callback server to localhost (#11956) Updated the server to listen explicitly on 127.0.0.1 instead of all interfaces. The log message was also updated to reflect the new binding address. --- src/main/services/mcp/oauth/callback.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/mcp/oauth/callback.ts b/src/main/services/mcp/oauth/callback.ts index c13ecd5c07..7da7544585 100644 --- a/src/main/services/mcp/oauth/callback.ts +++ b/src/main/services/mcp/oauth/callback.ts @@ -128,8 +128,8 @@ export class CallBackServer { }) return new Promise((resolve, reject) => { - server.listen(port, () => { - logger.info(`OAuth callback server listening on port ${port}`) + server.listen(port, '127.0.0.1', () => { + logger.info(`OAuth callback server listening on 127.0.0.1:${port}`) resolve(server) }) From 784fdd4fed6eb7ebcf547d43b815b76b2b38c50f Mon Sep 17 00:00:00 2001 From: SuYao Date: Wed, 17 Dec 2025 13:36:13 +0800 Subject: [PATCH 16/41] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E6=81=A2=E5=A4=8D=E5=9C=BA=E6=99=AF=E4=B8=8B?= =?UTF-8?q?=E7=9A=84=E7=AC=94=E8=AE=B0=E7=9B=AE=E5=BD=95=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=92=8C=E9=BB=98=E8=AE=A4=E8=B7=AF=E5=BE=84=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20(#11950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复跨平台恢复场景下的笔记目录验证和默认路径重置逻辑 * fix: 优化跨平台恢复场景下的笔记目录验证逻辑,跳过默认路径的验证 --- src/main/services/FileStorage.ts | 2 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + src/renderer/src/pages/notes/NotesPage.tsx | 38 ++++++++++++++++++++++ 12 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 81f5c15bd9..78bffa6692 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -163,7 +163,7 @@ class FileStorage { fs.mkdirSync(this.storageDir, { recursive: true }) } if (!fs.existsSync(this.notesDir)) { - fs.mkdirSync(this.storageDir, { recursive: true }) + fs.mkdirSync(this.notesDir, { recursive: true }) } if (!fs.existsSync(this.tempDir)) { fs.mkdirSync(this.tempDir, { recursive: true }) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 298ffeb86c..3d12402e61 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -2152,6 +2152,7 @@ "collapse": "Collapse", "content_placeholder": "Please enter the note content...", "copyContent": "Copy Content", + "crossPlatformRestoreWarning": "Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "delete", "delete_confirm": "Are you sure you want to delete this {{type}}?", "delete_folder_confirm": "Are you sure you want to delete the folder \"{{name}}\" and all of its contents?", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 8c10f3687b..f8222c4123 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -2152,6 +2152,7 @@ "collapse": "收起", "content_placeholder": "请输入笔记内容...", "copyContent": "复制内容", + "crossPlatformRestoreWarning": "检测到从其他设备恢复配置,但笔记目录为空。请将笔记文件复制到: {{path}}", "delete": "删除", "delete_confirm": "确定要删除这个{{type}}吗?", "delete_folder_confirm": "确定要删除文件夹 \"{{name}}\" 及其所有内容吗?", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 162373b557..27eac8065d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2152,6 +2152,7 @@ "collapse": "收合", "content_placeholder": "請輸入筆記內容...", "copyContent": "複製內容", + "crossPlatformRestoreWarning": "偵測到從其他裝置恢復設定,但筆記目錄為空。請將筆記檔案複製到:{{path}}", "delete": "刪除", "delete_confirm": "確定要刪除此 {{type}} 嗎?", "delete_folder_confirm": "確定要刪除資料夾 \"{{name}}\" 及其所有內容嗎?", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 4bc992759c..6fd98193ac 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -2152,6 +2152,7 @@ "collapse": "Einklappen", "content_placeholder": "Bitte Notizinhalt eingeben...", "copyContent": "Inhalt kopieren", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "Löschen", "delete_confirm": "Möchten Sie diesen {{type}} wirklich löschen?", "delete_folder_confirm": "Möchten Sie Ordner \"{{name}}\" und alle seine Inhalte wirklich löschen?", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 44fba429f7..0f002836ff 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -2152,6 +2152,7 @@ "collapse": "σύμπτυξη", "content_placeholder": "Παρακαλώ εισαγάγετε το περιεχόμενο των σημειώσεων...", "copyContent": "αντιγραφή περιεχομένου", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "διαγραφή", "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};", "delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 5cf620ed45..18832aeca5 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -2152,6 +2152,7 @@ "collapse": "ocultar", "content_placeholder": "Introduzca el contenido de la nota...", "copyContent": "copiar contenido", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "eliminar", "delete_confirm": "¿Estás seguro de que deseas eliminar este {{type}}?", "delete_folder_confirm": "¿Está seguro de que desea eliminar la carpeta \"{{name}}\" y todo su contenido?", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index fdb72727b8..e31cba94dd 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -2152,6 +2152,7 @@ "collapse": "réduire", "content_placeholder": "Veuillez saisir le contenu de la note...", "copyContent": "contenu copié", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "supprimer", "delete_confirm": "Êtes-vous sûr de vouloir supprimer ce {{type}} ?", "delete_folder_confirm": "Êtes-vous sûr de vouloir supprimer le dossier \"{{name}}\" et tout son contenu ?", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d004d539e5..93d4219e22 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -2152,6 +2152,7 @@ "collapse": "閉じる", "content_placeholder": "メモの内容を入力してください...", "copyContent": "コンテンツをコピーします", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "削除", "delete_confirm": "この{{type}}を本当に削除しますか?", "delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 32c1965f54..11564b14bc 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -2152,6 +2152,7 @@ "collapse": "[minimizar]", "content_placeholder": "Introduza o conteúdo da nota...", "copyContent": "copiar conteúdo", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "eliminar", "delete_confirm": "Tem a certeza de que deseja eliminar este {{type}}?", "delete_folder_confirm": "Tem a certeza de que deseja eliminar a pasta \"{{name}}\" e todos os seus conteúdos?", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 3d022ae24b..f0d9937dba 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -2152,6 +2152,7 @@ "collapse": "Свернуть", "content_placeholder": "Введите содержимое заметки...", "copyContent": "Копировать контент", + "crossPlatformRestoreWarning": "[to be translated]:Cross-platform configuration restored, but notes directory is empty. Please copy your note files to: {{path}}", "delete": "удалить", "delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?", "delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?", diff --git a/src/renderer/src/pages/notes/NotesPage.tsx b/src/renderer/src/pages/notes/NotesPage.tsx index 7692aa9975..22d4ba4459 100644 --- a/src/renderer/src/pages/notes/NotesPage.tsx +++ b/src/renderer/src/pages/notes/NotesPage.tsx @@ -39,6 +39,7 @@ import { } from '@renderer/store/note' import type { NotesSortType, NotesTreeNode } from '@renderer/types/note' import type { FileChangeEvent } from '@shared/config/types' +import { message } from 'antd' import { debounce } from 'lodash' import { AnimatePresence, motion } from 'motion/react' import type { FC } from 'react' @@ -246,6 +247,43 @@ const NotesPage: FC = () => { updateNotesPath(defaultPath) return } + + // 验证路径是否有效(处理跨平台恢复场景) + try { + // 获取当前平台的默认路径 + const info = await window.api.getAppInfo() + const defaultPath = info.notesPath + + // 如果当前路径就是默认路径,跳过验证(默认路径始终有效) + if (notesPath === defaultPath) { + return + } + + const isValid = await window.api.file.validateNotesDirectory(notesPath) + if (!isValid) { + logger.warn('Invalid notes path detected, resetting to default', { path: notesPath }) + + // 重置为默认路径 + updateNotesPath(defaultPath) + + // 检查默认路径下是否有笔记文件 + try { + const tree = await window.api.file.getDirectoryStructure(defaultPath) + if (!tree || tree.length === 0) { + // 默认目录为空,提示用户需要迁移文件 + message.warning({ + content: t('notes.crossPlatformRestoreWarning', { path: defaultPath }), + duration: 10 + }) + } + } catch (error) { + // 目录不存在或读取失败,会由 FileStorage 自动创建 + logger.debug('Default notes directory will be created', { error }) + } + } + } catch (error) { + logger.error('Failed to validate notes path:', error as Error) + } } initialize() From bfeef7ef911698af77f7f853bec03c239233e7e8 Mon Sep 17 00:00:00 2001 From: Pleasure1234 <3196812536@qq.com> Date: Wed, 17 Dec 2025 07:21:06 +0000 Subject: [PATCH 17/41] fix: refactor provider headers logic in providerConfig (#11849) Simplifies and centralizes header construction by merging defaultAppHeaders and extra_headers, and sets X-Api-Key for OpenAI providers. Removes redundant header assignment logic for improved maintainability. --- .../src/aiCore/provider/providerConfig.ts | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 1c410bf124..556b870e59 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -32,6 +32,7 @@ import { isSupportStreamOptionsProvider, isVertexProvider } from '@renderer/utils/provider' +import { defaultAppHeaders } from '@shared/utils' import { cloneDeep, isEmpty } from 'lodash' import type { AiSdkConfig } from '../types' @@ -197,18 +198,13 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A extraOptions.mode = 'chat' } - // 添加额外headers - if (actualProvider.extra_headers) { - extraOptions.headers = actualProvider.extra_headers - // copy from openaiBaseClient/openaiResponseApiClient - if (aiSdkProviderId === 'openai') { - extraOptions.headers = { - ...extraOptions.headers, - 'HTTP-Referer': 'https://cherry-ai.com', - 'X-Title': 'Cherry Studio', - 'X-Api-Key': baseConfig.apiKey - } - } + extraOptions.headers = { + ...defaultAppHeaders(), + ...actualProvider.extra_headers + } + + if (aiSdkProviderId === 'openai') { + extraOptions.headers['X-Api-Key'] = baseConfig.apiKey } // azure // https://learn.microsoft.com/en-us/azure/ai-foundry/openai/latest From 782f8496e0c2568da69a18b4dade35fd9cbfaf09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Wed, 17 Dec 2025 15:37:11 +0800 Subject: [PATCH 18/41] feat: add tool use mode setting to default assistant settings (#11943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add tool use mode setting to default assistant settings - Add toolUseMode selector (prompt/function) to DefaultAssistantSettings - Add dividers between model parameter sections for better UI - Reduce slider margins for compact layout - Add migration (v185) to reset toolUseMode to 'function' for existing users 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: reset toolUseMode for all assistants during migration - Update migration logic to reset toolUseMode to 'function' for all assistants with a 'prompt' setting. - Ensure compatibility with function calling models by checking model type before resetting. --------- Co-authored-by: Claude Opus 4.5 --- .../DefaultAssistantSettings.tsx | 44 +++++++++++++++---- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 21 +++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index 2b1bed5ebe..65be642adc 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -2,13 +2,14 @@ import { CloseCircleFilled, QuestionCircleOutlined } from '@ant-design/icons' import EmojiPicker from '@renderer/components/EmojiPicker' import { ResetIcon } from '@renderer/components/Icons' import { HStack } from '@renderer/components/Layout' +import Selector from '@renderer/components/Selector' import { TopView } from '@renderer/components/TopView' import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultAssistant } from '@renderer/hooks/useAssistant' import type { AssistantSettings as AssistantSettingsType } from '@renderer/types' import { getLeadingEmoji, modalConfirm } from '@renderer/utils' -import { Button, Col, Flex, Input, InputNumber, Modal, Popover, Row, Slider, Switch, Tooltip } from 'antd' +import { Button, Col, Divider, Flex, Input, InputNumber, Modal, Popover, Row, Slider, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' import type { Dispatch, FC, SetStateAction } from 'react' import { useState } from 'react' @@ -26,6 +27,9 @@ const AssistantSettings: FC = () => { const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0) const [topP, setTopP] = useState(defaultAssistant.settings?.topP ?? 1) const [enableTopP, setEnableTopP] = useState(defaultAssistant.settings?.enableTopP ?? false) + const [toolUseMode, setToolUseMode] = useState( + defaultAssistant.settings?.toolUseMode ?? 'function' + ) const [emoji, setEmoji] = useState(defaultAssistant.emoji || getLeadingEmoji(defaultAssistant.name) || '') const [name, setName] = useState( defaultAssistant.name.replace(getLeadingEmoji(defaultAssistant.name) || '', '').trim() @@ -46,7 +50,8 @@ const AssistantSettings: FC = () => { maxTokens: settings.maxTokens ?? maxTokens, streamOutput: settings.streamOutput ?? true, topP: settings.topP ?? topP, - enableTopP: settings.enableTopP ?? enableTopP + enableTopP: settings.enableTopP ?? enableTopP, + toolUseMode: settings.toolUseMode ?? toolUseMode } }) } @@ -73,6 +78,7 @@ const AssistantSettings: FC = () => { setMaxTokens(0) setTopP(1) setEnableTopP(false) + setToolUseMode('function') updateDefaultAssistant({ ...defaultAssistant, settings: { @@ -84,7 +90,8 @@ const AssistantSettings: FC = () => { maxTokens: DEFAULT_MAX_TOKENS, streamOutput: true, topP: 1, - enableTopP: false + enableTopP: false, + toolUseMode: 'function' } }) } @@ -107,10 +114,9 @@ const AssistantSettings: FC = () => { return ( - {t('common.name')} - + } arrow trigger="click"> @@ -161,6 +167,7 @@ const AssistantSettings: FC = () => { +) + +const ManageDivider: FC = () =>
+ +const LeftGroup: FC = ({ children }) =>
{children}
+ +const RightGroup: FC = ({ children }) => ( +
{children}
+) + +const SelectedBadge: FC>> = ({ + children, + className, + ...props +}) => ( + + {children} + +) + +const SearchInputWrapper: FC = ({ children }) => ( +
{children}
+) + +interface SearchInputProps extends React.InputHTMLAttributes { + ref?: Ref +} + +const SearchInput: FC = ({ className, ref, ...props }) => ( + +) + +export default TopicManagePanel diff --git a/src/renderer/src/pages/home/Tabs/components/Topics.tsx b/src/renderer/src/pages/home/Tabs/components/Topics.tsx index 7284f9167c..29232e65d9 100644 --- a/src/renderer/src/pages/home/Tabs/components/Topics.tsx +++ b/src/renderer/src/pages/home/Tabs/components/Topics.tsx @@ -1,3 +1,4 @@ +import AssistantAvatar from '@renderer/components/Avatar/AssistantAvatar' import { DraggableVirtualList } from '@renderer/components/DraggableList' import { CopyIcon, DeleteIcon, EditIcon } from '@renderer/components/Icons' import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' @@ -37,8 +38,10 @@ import dayjs from 'dayjs' import { findIndex } from 'lodash' import { BrushCleaning, + CheckSquare, FolderOpen, HelpCircle, + ListChecks, MenuIcon, NotebookPen, PackagePlus, @@ -46,6 +49,7 @@ import { PinOffIcon, Save, Sparkles, + Square, UploadIcon, XIcon } from 'lucide-react' @@ -55,6 +59,7 @@ import { useDispatch, useSelector } from 'react-redux' import styled from 'styled-components' import AddButton from './AddButton' +import { TopicManagePanel, useTopicManageMode } from './TopicManageMode' interface Props { assistant: Assistant @@ -81,6 +86,10 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se const deleteTimerRef = useRef(null) const [editingTopicId, setEditingTopicId] = useState(null) + // 管理模式状态 + const manageState = useTopicManageMode() + const { isManageMode, selectedIds, searchText, enterManageMode, exitManageMode, toggleSelectTopic } = manageState + const { startEdit, isEditing, inputProps } = useInPlaceEdit({ onSave: (name: string) => { const topic = assistant.topics.find((t) => t.id === editingTopicId) @@ -437,6 +446,7 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se .map((a) => ({ label: a.name, key: a.id, + icon: , onClick: () => onMoveTopic(topic, a) })) }) @@ -492,107 +502,187 @@ export const Topics: React.FC = ({ assistant: _assistant, activeTopic, se return assistant.topics }, [assistant.topics, pinTopicsToTop]) + // Filter topics based on search text (only in manage mode) + // Supports: case-insensitive, space-separated keywords (all must match) + const deferredSearchText = useDeferredValue(searchText) + const filteredTopics = useMemo(() => { + if (!isManageMode || !deferredSearchText.trim()) { + return sortedTopics + } + // Split by spaces and filter out empty strings + const keywords = deferredSearchText + .toLowerCase() + .split(/\s+/) + .filter((k) => k.length > 0) + if (keywords.length === 0) { + return sortedTopics + } + // All keywords must match (AND logic) + return sortedTopics.filter((topic) => { + const lowerName = topic.name.toLowerCase() + return keywords.every((keyword) => lowerName.includes(keyword)) + }) + }, [sortedTopics, deferredSearchText, isManageMode]) + const singlealone = topicPosition === 'right' && position === 'right' return ( - - EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> - {t('chat.add.topic.title')} - -
- - }> - {(topic) => { - const isActive = topic.id === activeTopic?.id - const topicName = topic.name.replace('`', '') - const topicPrompt = topic.prompt - const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt - - const getTopicNameClassName = () => { - if (isRenaming(topic.id)) return 'shimmer' - if (isNewlyRenamed(topic.id)) return 'typing' - return '' + <> + + EventEmitter.emit(EVENT_NAMES.ADD_NEW_TOPIC)}> + {t('chat.add.topic.title')} + + + + + + + } + disabled={isManageMode}> + {(topic) => { + const isActive = topic.id === activeTopic?.id + const topicName = topic.name.replace('`', '') + const topicPrompt = topic.prompt + const fullTopicPrompt = t('common.prompt') + ': ' + topicPrompt + const isSelected = selectedIds.has(topic.id) + const canSelect = !topic.pinned - return ( - - setTargetTopic(topic)} - className={classNames(isActive ? 'active' : '', singlealone ? 'singlealone' : '')} - onClick={editingTopicId === topic.id && isEditing ? undefined : () => onSwitchTopic(topic)} - style={{ - borderRadius, - cursor: editingTopicId === topic.id && isEditing ? 'default' : 'pointer' - }}> - {isPending(topic.id) && !isActive && } - {isFulfilled(topic.id) && !isActive && } - - {editingTopicId === topic.id && isEditing ? ( - e.stopPropagation()} /> - ) : ( - { - setEditingTopicId(topic.id) - startEdit(topic.name) - }}> - {topicName} - + const getTopicNameClassName = () => { + if (isRenaming(topic.id)) return 'shimmer' + if (isNewlyRenamed(topic.id)) return 'typing' + return '' + } + + const handleItemClick = () => { + if (isManageMode) { + if (canSelect) { + toggleSelectTopic(topic.id) + } + } else { + onSwitchTopic(topic) + } + } + + return ( + + setTargetTopic(topic)} + className={classNames( + isActive && !isManageMode ? 'active' : '', + singlealone ? 'singlealone' : '', + isManageMode && isSelected ? 'selected' : '', + isManageMode && !canSelect ? 'disabled' : '' )} - {!topic.pinned && ( - - {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} -
- }> - { - if (e.ctrlKey || e.metaKey) { - handleConfirmDelete(topic, e) - } else if (deletingTopicId === topic.id) { - handleConfirmDelete(topic, e) - } else { - handleDeleteClick(topic.id, e) - } - }}> - {deletingTopicId === topic.id ? ( - + onClick={editingTopicId === topic.id && isEditing ? undefined : handleItemClick} + style={{ + borderRadius, + cursor: + editingTopicId === topic.id && isEditing + ? 'default' + : isManageMode && !canSelect + ? 'not-allowed' + : 'pointer' + }}> + {isPending(topic.id) && !isActive && } + {isFulfilled(topic.id) && !isActive && } + + {isManageMode && ( + + {isSelected ? ( + ) : ( - + )} + + )} + {editingTopicId === topic.id && isEditing ? ( + e.stopPropagation()} /> + ) : ( + { + setEditingTopicId(topic.id) + startEdit(topic.name) + } + }> + {topicName} + + )} + {!topic.pinned && ( + + {t('chat.topics.delete.shortcut', { key: isMac ? '⌘' : 'Ctrl' })} +
+ }> + { + if (e.ctrlKey || e.metaKey) { + handleConfirmDelete(topic, e) + } else if (deletingTopicId === topic.id) { + handleConfirmDelete(topic, e) + } else { + handleDeleteClick(topic.id, e) + } + }}> + {deletingTopicId === topic.id ? ( + + ) : ( + + )} + + + )} + {topic.pinned && ( + + - + )} + + {topicPrompt && ( + + {fullTopicPrompt} + )} - {topic.pinned && ( - - - + {showTopicTime && ( + {dayjs(topic.createdAt).format('MM/DD HH:mm')} )} - - {topicPrompt && ( - - {fullTopicPrompt} - - )} - {showTopicTime && {dayjs(topic.createdAt).format('MM/DD HH:mm')}} - - - ) - }} - + + + ) + }} + + + {/* 管理模式底部面板 */} + + ) } @@ -640,6 +730,15 @@ const TopicListItem = styled.div` box-shadow: none; } } + + &.selected { + background-color: var(--color-primary-bg); + box-shadow: inset 0 0 0 1px var(--color-primary); + } + + &.disabled { + opacity: 0.5; + } ` const TopicNameContainer = styled.div` @@ -648,7 +747,6 @@ const TopicNameContainer = styled.div` align-items: center; gap: 4px; height: 20px; - justify-content: space-between; ` const TopicName = styled.div` @@ -659,6 +757,8 @@ const TopicName = styled.div` font-size: 13px; position: relative; will-change: background-position, width; + flex: 1; + text-align: left; --color-shimmer-mid: var(--color-text-1); --color-shimmer-end: color-mix(in srgb, var(--color-text-1) 25%, transparent); @@ -765,3 +865,49 @@ const MenuButton = styled.div` font-size: 12px; } ` + +const HeaderRow = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding-right: 10px; + margin-bottom: 5px; +` + +const HeaderIconButton = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + border-radius: var(--list-item-border-radius); + cursor: pointer; + color: var(--color-text-2); + transition: all 0.2s; + + &:hover { + background-color: var(--color-background-mute); + color: var(--color-text-1); + } + + &.active { + color: var(--color-primary); + + &:hover { + background-color: var(--color-background-mute); + } + } +` + +const SelectIcon = styled.div` + display: flex; + align-items: center; + margin-right: 4px; + + &.disabled { + opacity: 0.5; + } +` diff --git a/src/renderer/src/pages/home/Tabs/index.tsx b/src/renderer/src/pages/home/Tabs/index.tsx index 6317ff3bca..4504550a16 100644 --- a/src/renderer/src/pages/home/Tabs/index.tsx +++ b/src/renderer/src/pages/home/Tabs/index.tsx @@ -145,6 +145,7 @@ const Container = styled.div` width: var(--assistants-width); transition: width 0.3s; height: calc(100vh - var(--navbar-height)); + position: relative; &.right { height: calc(100vh - var(--navbar-height)); From e85009fcd6cc625069b977834985a252a9781b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Wed, 17 Dec 2025 17:54:44 +0800 Subject: [PATCH 21/41] feat(assistants): merge import/subscribe popups and add export to manage (#11946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(assistants): merge import and subscribe popups, add export to manage - Merge import and subscribe buttons into single unified popup - Add export functionality to manage assistant presets - Change delete mode to manage mode with both export and delete options - Show import count in success message - Default to manage mode when opening manage popup - Fix unsubscribe button to clear URL properly - Fix file import not working issue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 --- src/renderer/src/i18n/locales/en-us.json | 12 +- src/renderer/src/i18n/locales/zh-cn.json | 12 +- src/renderer/src/i18n/locales/zh-tw.json | 8 +- src/renderer/src/i18n/translate/de-de.json | 8 +- src/renderer/src/i18n/translate/el-gr.json | 8 +- src/renderer/src/i18n/translate/es-es.json | 8 +- src/renderer/src/i18n/translate/fr-fr.json | 8 +- src/renderer/src/i18n/translate/ja-jp.json | 8 +- src/renderer/src/i18n/translate/pt-pt.json | 8 +- src/renderer/src/i18n/translate/ru-ru.json | 8 +- .../presets/AssistantPresetsPage.tsx | 16 +- .../AssistantsSubscribeUrlSettings.tsx | 58 ------ .../components/ImportAssistantPresetPopup.tsx | 191 +++++++++++++----- .../ManageAssistantPresetsPopup.tsx | 55 +++-- 14 files changed, 260 insertions(+), 148 deletions(-) delete mode 100755 src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index b6f4b50cd6..f38cdc1def 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -472,6 +472,7 @@ "button": "Import", "error": { "fetch_failed": "Failed to fetch from URL", + "file_required": "Please select a file first", "invalid_format": "Invalid assistant format: missing required fields", "url_required": "Please enter a URL" }, @@ -486,11 +487,14 @@ }, "manage": { "batch_delete": { - "button": "Batch Delete", + "button": "Delete", "confirm": "Are you sure you want to delete the selected {{count}} assistants?" }, + "batch_export": { + "button": "Export" + }, "mode": { - "delete": "Delete", + "manage": "Manage", "sort": "Sort" }, "title": "Manage Assistants" @@ -1248,11 +1252,13 @@ } }, "stop": "Stop", + "subscribe": "Subscribe", "success": "Success", "swap": "Swap", "topics": "Topics", "unknown": "Unknown", "unnamed": "Unnamed", + "unsubscribe": "Unsubscribe", "update_success": "Update successfully", "upload_files": "Upload file", "warning": "Warning", @@ -1747,7 +1753,7 @@ "import": { "error": "Import failed" }, - "imported": "Imported successfully" + "imported": "Successfully imported {{count}} assistant(s)" }, "api": { "check": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ceb8cad739..882b897ef5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -472,6 +472,7 @@ "button": "导入", "error": { "fetch_failed": "从 URL 获取数据失败", + "file_required": "请先选择文件", "invalid_format": "无效的助手格式:缺少必填字段", "url_required": "请输入 URL" }, @@ -486,11 +487,14 @@ }, "manage": { "batch_delete": { - "button": "批量删除", + "button": "删除", "confirm": "确定要删除选中的 {{count}} 个助手吗?" }, + "batch_export": { + "button": "导出" + }, "mode": { - "delete": "删除", + "manage": "管理", "sort": "排序" }, "title": "管理助手" @@ -1248,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "订阅", "success": "成功", "swap": "交换", "topics": "话题", "unknown": "未知", "unnamed": "未命名", + "unsubscribe": "退订", "update_success": "更新成功", "upload_files": "上传文件", "warning": "警告", @@ -1747,7 +1753,7 @@ "import": { "error": "导入失败" }, - "imported": "导入成功" + "imported": "成功导入 {{count}} 个助手" }, "api": { "check": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index f150f5aef3..3feb287c1d 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -472,6 +472,7 @@ "button": "匯入", "error": { "fetch_failed": "從 URL 取得資料失敗", + "file_required": "請先選擇一個檔案", "invalid_format": "無效的助手格式:缺少必填欄位", "url_required": "請輸入 URL" }, @@ -489,8 +490,11 @@ "button": "批次刪除", "confirm": "確定要刪除所選的 {{count}} 個助手嗎?" }, + "batch_export": { + "button": "匯出" + }, "mode": { - "delete": "刪除", + "manage": "管理", "sort": "排序" }, "title": "管理助手" @@ -1248,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "訂閱", "success": "成功", "swap": "交換", "topics": "話題", "unknown": "未知", "unnamed": "未命名", + "unsubscribe": "取消訂閱", "update_success": "更新成功", "upload_files": "上傳檔案", "warning": "警告", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 074b53c4da..f535978606 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -472,6 +472,7 @@ "button": "Importieren", "error": { "fetch_failed": "Daten von URL abrufen fehlgeschlagen", + "file_required": "Bitte wählen Sie zuerst eine Datei aus", "invalid_format": "Ungültiges Assistentenformat: Pflichtfelder fehlen", "url_required": "Bitte geben Sie eine URL ein" }, @@ -489,8 +490,11 @@ "button": "Stapel löschen", "confirm": "Sind Sie sicher, dass Sie die ausgewählten {{count}} Assistenten löschen möchten?" }, + "batch_export": { + "button": "Exportieren" + }, "mode": { - "delete": "Löschen", + "manage": "Verwalten", "sort": "Sortieren" }, "title": "Assistenten verwalten" @@ -1248,11 +1252,13 @@ } }, "stop": "Stoppen", + "subscribe": "Abonnieren", "success": "Erfolgreich", "swap": "Tauschen", "topics": "Themen", "unknown": "Unbekannt", "unnamed": "Unbenannt", + "unsubscribe": "Abmelden", "update_success": "Erfolgreich aktualisiert", "upload_files": "Dateien hochladen", "warning": "Warnung", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 5f7b00f3be..99592e9adc 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -472,6 +472,7 @@ "button": "Εισαγωγή", "error": { "fetch_failed": "Αποτυχία λήψης δεδομένων από το URL", + "file_required": "Παρακαλώ επιλέξτε πρώτα ένα αρχείο", "invalid_format": "Μη έγκυρη μορφή βοηθού: λείπουν υποχρεωτικά πεδία", "url_required": "Παρακαλώ εισάγετε ένα URL" }, @@ -489,8 +490,11 @@ "button": "Μαζική Διαγραφή", "confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τους επιλεγμένους {{count}} βοηθούς;" }, + "batch_export": { + "button": "Εξαγωγή" + }, "mode": { - "delete": "Διαγραφή", + "manage": "Διαχειριστείτε", "sort": "Ταξινόμηση" }, "title": "Διαχείριση βοηθών" @@ -1248,11 +1252,13 @@ } }, "stop": "σταματήστε", + "subscribe": "Εγγραφείτε", "success": "Επιτυχία", "swap": "Εναλλαγή", "topics": "Θέματα", "unknown": "Άγνωστο", "unnamed": "Χωρίς όνομα", + "unsubscribe": "Απεγγραφή", "update_success": "Επιτυχής ενημέρωση", "upload_files": "Ανέβασμα αρχείου", "warning": "Προσοχή", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 66746875d9..31c7158587 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -472,6 +472,7 @@ "button": "Importar", "error": { "fetch_failed": "Error al obtener datos desde la URL", + "file_required": "Por favor, selecciona primero un archivo", "invalid_format": "Formato de asistente inválido: faltan campos obligatorios", "url_required": "Por favor introduce una URL" }, @@ -489,8 +490,11 @@ "button": "Eliminación por lotes", "confirm": "¿Estás seguro de que quieres eliminar los {{count}} asistentes seleccionados?" }, + "batch_export": { + "button": "Exportar" + }, "mode": { - "delete": "Eliminar", + "manage": "Gestionar", "sort": "Ordenar" }, "title": "Gestionar asistentes" @@ -1248,11 +1252,13 @@ } }, "stop": "Detener", + "subscribe": "Suscribirse", "success": "Éxito", "swap": "Intercambiar", "topics": "Temas", "unknown": "Desconocido", "unnamed": "Sin nombre", + "unsubscribe": "Cancelar suscripción", "update_success": "Actualización exitosa", "upload_files": "Subir archivo", "warning": "Advertencia", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 76efea8e3d..da1d297a7f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -472,6 +472,7 @@ "button": "Importer", "error": { "fetch_failed": "Échec de la récupération des données depuis l'URL", + "file_required": "Veuillez d'abord sélectionner un fichier", "invalid_format": "Format d'assistant invalide : champs obligatoires manquants", "url_required": "Veuillez saisir une URL" }, @@ -489,8 +490,11 @@ "button": "Suppression par lot", "confirm": "Êtes-vous sûr de vouloir supprimer les {{count}} assistants sélectionnés ?" }, + "batch_export": { + "button": "Exporter" + }, "mode": { - "delete": "Supprimer", + "manage": "Gérer", "sort": "Trier" }, "title": "Gérer les assistants" @@ -1248,11 +1252,13 @@ } }, "stop": "Arrêter", + "subscribe": "S'abonner", "success": "Succès", "swap": "Échanger", "topics": "Sujets", "unknown": "Inconnu", "unnamed": "Sans nom", + "unsubscribe": "Se désabonner", "update_success": "Mise à jour réussie", "upload_files": "Uploader des fichiers", "warning": "Avertissement", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d9e62b6229..93bacf506c 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -472,6 +472,7 @@ "button": "インポート", "error": { "fetch_failed": "URLからのデータ取得に失敗しました", + "file_required": "まずファイルを選択してください", "invalid_format": "無効なアシスタント形式:必須フィールドが不足しています", "url_required": "URLを入力してください" }, @@ -489,8 +490,11 @@ "button": "バッチ削除", "confirm": "選択した{{count}}件のアシスタントを削除してもよろしいですか?" }, + "batch_export": { + "button": "エクスポート" + }, "mode": { - "delete": "削除", + "manage": "管理", "sort": "並べ替え" }, "title": "アシスタントを管理" @@ -1248,11 +1252,13 @@ } }, "stop": "停止", + "subscribe": "購読", "success": "成功", "swap": "交換", "topics": "トピック", "unknown": "Unknown", "unnamed": "無題", + "unsubscribe": "配信停止", "update_success": "更新成功", "upload_files": "ファイルをアップロードする", "warning": "警告", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 69c2ae2609..9bd6881673 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -472,6 +472,7 @@ "button": "Importar", "error": { "fetch_failed": "Falha ao obter dados do URL", + "file_required": "Por favor, selecione um arquivo primeiro", "invalid_format": "Formato de assistente inválido: campos obrigatórios em falta", "url_required": "Por favor insere um URL" }, @@ -489,8 +490,11 @@ "button": "Exclusão em Lote", "confirm": "Tem certeza de que deseja excluir os {{count}} assistentes selecionados?" }, + "batch_export": { + "button": "Exportar" + }, "mode": { - "delete": "Excluir", + "manage": "Gerenciar", "sort": "Ordenar" }, "title": "Gerir assistentes" @@ -1248,11 +1252,13 @@ } }, "stop": "Parar", + "subscribe": "Subscrever", "success": "Sucesso", "swap": "Trocar", "topics": "Tópicos", "unknown": "Desconhecido", "unnamed": "Sem nome", + "unsubscribe": "Cancelar inscrição", "update_success": "Atualização bem-sucedida", "upload_files": "Carregar arquivo", "warning": "Aviso", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 8ef955addd..7665115d5c 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -472,6 +472,7 @@ "button": "Импортировать", "error": { "fetch_failed": "Ошибка получения данных с URL", + "file_required": "Сначала выберите файл", "invalid_format": "Неверный формат помощника: отсутствуют обязательные поля", "url_required": "Пожалуйста, введите URL" }, @@ -489,8 +490,11 @@ "button": "Массовое удаление", "confirm": "Вы уверены, что хотите удалить выбранных {{count}} ассистентов?" }, + "batch_export": { + "button": "Экспорт" + }, "mode": { - "delete": "Удалить", + "manage": "Управлять", "sort": "Сортировать" }, "title": "Управление помощниками" @@ -1248,11 +1252,13 @@ } }, "stop": "остановить", + "subscribe": "Подписаться", "success": "Успешно", "swap": "Поменять местами", "topics": "Топики", "unknown": "Неизвестно", "unnamed": "Без имени", + "unsubscribe": "Отписаться", "update_success": "Обновление выполнено успешно", "upload_files": "Загрузить файл", "warning": "Предупреждение", diff --git a/src/renderer/src/pages/store/assistants/presets/AssistantPresetsPage.tsx b/src/renderer/src/pages/store/assistants/presets/AssistantPresetsPage.tsx index 79d1275b10..a56c04b152 100644 --- a/src/renderer/src/pages/store/assistants/presets/AssistantPresetsPage.tsx +++ b/src/renderer/src/pages/store/assistants/presets/AssistantPresetsPage.tsx @@ -1,7 +1,6 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' import ListItem from '@renderer/components/ListItem' -import GeneralPopup from '@renderer/components/Popups/GeneralPopup' import Scrollbar from '@renderer/components/Scrollbar' import CustomTag from '@renderer/components/Tags/CustomTag' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' @@ -11,7 +10,7 @@ import type { AssistantPreset } from '@renderer/types' import { uuid } from '@renderer/utils' import { Button, Empty, Flex, Input } from 'antd' import { omit } from 'lodash' -import { Import, Plus, Rss, Search, Settings2 } from 'lucide-react' +import { Import, Plus, Search, Settings2 } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,7 +22,6 @@ import { groupTranslations } from './assistantPresetGroupTranslations' import AddAssistantPresetPopup from './components/AddAssistantPresetPopup' import AssistantPresetCard from './components/AssistantPresetCard' import { AssistantPresetGroupIcon } from './components/AssistantPresetGroupIcon' -import AssistantsSubscribeUrlSettings from './components/AssistantsSubscribeUrlSettings' import ImportAssistantPresetPopup from './components/ImportAssistantPresetPopup' import ManageAssistantPresetsPopup from './components/ManageAssistantPresetsPopup' @@ -177,15 +175,6 @@ const AssistantPresetsPage: FC = () => { } } - const handleSubscribeSettings = () => { - GeneralPopup.show({ - title: t('assistants.presets.settings.title'), - content: , - footer: null, - width: 600 - }) - } - const handleManageAgents = () => { ManageAssistantPresetsPopup.show() } @@ -292,9 +281,6 @@ const AssistantPresetsPage: FC = () => { - diff --git a/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx b/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx deleted file mode 100755 index 8ea3b92fde..0000000000 --- a/src/renderer/src/pages/store/assistants/presets/components/AssistantsSubscribeUrlSettings.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { HStack } from '@renderer/components/Layout' -import { useTheme } from '@renderer/context/ThemeProvider' -import { useSettings } from '@renderer/hooks/useSettings' -import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '@renderer/pages/settings' -import { useAppDispatch } from '@renderer/store' -import { setAgentssubscribeUrl } from '@renderer/store/settings' -import Input from 'antd/es/input/Input' -import { HelpCircle } from 'lucide-react' -import type { FC } from 'react' -import { useTranslation } from 'react-i18next' - -const AssistantsSubscribeUrlSettings: FC = () => { - const { t } = useTranslation() - const { theme } = useTheme() - const dispatch = useAppDispatch() - - const { agentssubscribeUrl } = useSettings() - - const handleAgentChange = (e: React.ChangeEvent) => { - dispatch(setAgentssubscribeUrl(e.target.value)) - } - - const handleHelpClick = () => { - window.open('https://docs.cherry-ai.com/data-settings/assistants-subscribe', '_blank') - } - - return ( - - - - {t('assistants.presets.tag.agent')} - {t('settings.tool.websearch.subscribe_add')} - - - - - - {t('settings.tool.websearch.subscribe_url')} - - - - - - ) -} - -export default AssistantsSubscribeUrlSettings diff --git a/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx b/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx index 9a76d1d02e..37b7938248 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/ImportAssistantPresetPopup.tsx @@ -1,11 +1,15 @@ import { TopView } from '@renderer/components/TopView' import { useAssistantPresets } from '@renderer/hooks/useAssistantPresets' +import { useSettings } from '@renderer/hooks/useSettings' import { useTimer } from '@renderer/hooks/useTimer' import { getDefaultModel } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' +import { useAppDispatch } from '@renderer/store' +import { setAgentssubscribeUrl } from '@renderer/store/settings' import type { AssistantPreset } from '@renderer/types' import { uuid } from '@renderer/utils' -import { Button, Flex, Form, Input, Modal, Radio } from 'antd' +import { Button, Divider, Flex, Form, Input, Modal, Radio, Typography } from 'antd' +import { HelpCircle } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,35 +24,53 @@ const PopupContainer: React.FC = ({ resolve }) => { const { addAssistantPreset } = useAssistantPresets() const [importType, setImportType] = useState<'url' | 'file'>('url') const [loading, setLoading] = useState(false) + const [subscribeLoading, setSubscribeLoading] = useState(false) const { setTimeoutTimer } = useTimer() + const dispatch = useAppDispatch() + const { agentssubscribeUrl } = useSettings() + const [subscribeUrl, setSubscribeUrl] = useState(agentssubscribeUrl || '') + const [selectedFile, setSelectedFile] = useState<{ name: string; content: Uint8Array } | null>(null) + const [urlValue, setUrlValue] = useState('') + + const isImportDisabled = importType === 'url' ? !urlValue.trim() : !selectedFile + const isSubscribed = !!agentssubscribeUrl + + const handleSelectFile = async () => { + const result = await window.api.file.open({ + filters: [{ name: t('assistants.presets.import.file_filter'), extensions: ['json'] }] + }) + + if (result) { + setSelectedFile({ name: result.fileName, content: result.content }) + } + } + + const onFinish = async () => { + // Validate before setting loading + if (importType === 'url' && !urlValue.trim()) { + window.toast.error(t('assistants.presets.import.error.url_required')) + return + } + if (importType === 'file' && !selectedFile) { + window.toast.error(t('assistants.presets.import.error.file_required')) + return + } - const onFinish = async (values: { url?: string }) => { setLoading(true) try { let presets: AssistantPreset[] = [] if (importType === 'url') { - if (!values.url) { - throw new Error(t('assistants.presets.import.error.url_required')) - } - const response = await fetch(values.url) + const response = await fetch(urlValue.trim()) if (!response.ok) { throw new Error(t('assistants.presets.import.error.fetch_failed')) } const data = await response.json() presets = Array.isArray(data) ? data : [data] } else { - const result = await window.api.file.open({ - filters: [{ name: t('assistants.presets.import.file_filter'), extensions: ['json'] }] - }) - - if (result) { - presets = JSON.parse(new TextDecoder('utf-8').decode(result.content)) - if (!Array.isArray(presets)) { - presets = [presets] - } - } else { - return + presets = JSON.parse(new TextDecoder('utf-8').decode(selectedFile!.content)) + if (!Array.isArray(presets)) { + presets = [presets] } } @@ -74,7 +96,7 @@ const PopupContainer: React.FC = ({ resolve }) => { addAssistantPreset(newPreset) } - window.toast.success(t('message.agents.imported')) + window.toast.success(t('message.agents.imported', { count: presets.length })) setTimeoutTimer('onFinish', () => EventEmitter.emit(EVENT_NAMES.SHOW_ASSISTANTS), 0) setOpen(false) @@ -88,7 +110,42 @@ const PopupContainer: React.FC = ({ resolve }) => { const onCancel = () => { setOpen(false) - resolve(null) + } + + const handleSubscribeUrlChange = (e: React.ChangeEvent) => { + setSubscribeUrl(e.target.value) + } + + const handleSubscribe = async () => { + // If already subscribed, unsubscribe + if (isSubscribed) { + dispatch(setAgentssubscribeUrl('')) + setSubscribeUrl('') + window.location.reload() + return + } + + if (!subscribeUrl.trim()) { + return + } + + setSubscribeLoading(true) + try { + const response = await fetch(subscribeUrl) + if (!response.ok) { + throw new Error(t('assistants.presets.import.error.fetch_failed')) + } + dispatch(setAgentssubscribeUrl(subscribeUrl)) + window.location.reload() + } catch (error) { + window.toast.error(error instanceof Error ? error.message : t('message.agents.import.error')) + } finally { + setSubscribeLoading(false) + } + } + + const handleHelpClick = () => { + window.open('https://docs.cherry-ai.com/data-settings/assistants-subscribe', '_blank') } return ( @@ -96,39 +153,79 @@ const PopupContainer: React.FC = ({ resolve }) => { title={t('assistants.presets.import.title')} open={open} onCancel={onCancel} - maskClosable={false} - footer={ - - - - - } + afterClose={() => resolve(null)} + footer={null} transitionName="animation-move-down" + styles={{ body: { padding: '16px' } }} centered>
- - setImportType(e.target.value)}> - {t('assistants.presets.import.type.url')} - {t('assistants.presets.import.type.file')} - + + + setImportType(e.target.value)}> + {t('assistants.presets.import.type.url')} + {t('assistants.presets.import.type.file')} + + + {importType === 'url' && ( + + setUrlValue(e.target.value)} + /> + + )} + + {importType === 'file' && ( + <> + + {selectedFile && ( + + {selectedFile.name} + + )} +
+ + )} + + + - - {importType === 'url' && ( - - - - )} - - {importType === 'file' && ( - - - - )} + + + + + + {t('assistants.presets.tag.agent')} + {t('settings.tool.websearch.subscribe_add')} + + + + + + + + ) } diff --git a/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx b/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx index b75a569c5f..961ec24abe 100644 --- a/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx +++ b/src/renderer/src/pages/store/assistants/presets/components/ManageAssistantPresetsPopup.tsx @@ -1,4 +1,4 @@ -import { MenuOutlined } from '@ant-design/icons' +import { ExportOutlined, MenuOutlined } from '@ant-design/icons' import { DraggableList } from '@renderer/components/DraggableList' import { DeleteIcon } from '@renderer/components/Icons' import { Box, HStack } from '@renderer/components/Layout' @@ -10,13 +10,13 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -type Mode = 'sort' | 'delete' +type Mode = 'sort' | 'manage' const PopupContainer: React.FC = () => { const [open, setOpen] = useState(true) const { t } = useTranslation() const { presets, setAssistantPresets } = useAssistantPresets() - const [mode, setMode] = useState(() => (presets.length > 50 ? 'delete' : 'sort')) + const [mode, setMode] = useState('manage') const [selectedIds, setSelectedIds] = useState>(new Set()) const onCancel = () => { @@ -88,6 +88,23 @@ const PopupContainer: React.FC = () => { }) } + const handleBatchExport = async () => { + if (selectedIds.size === 0) return + + const selectedPresets = presets.filter((p) => selectedIds.has(p.id)) + const exportData = selectedPresets.map((p) => ({ + name: p.name, + emoji: p.emoji, + prompt: p.prompt, + description: p.description, + group: p.group + })) + + const fileName = selectedIds.size === 1 ? `${selectedPresets[0].name}.json` : `assistants_${selectedIds.size}.json` + + await window.api.file.save(fileName, JSON.stringify(exportData, null, 2)) + } + const isAllSelected = presets.length > 0 && selectedIds.size === presets.length const isIndeterminate = selectedIds.size > 0 && selectedIds.size < presets.length @@ -98,13 +115,14 @@ const PopupContainer: React.FC = () => { onCancel={onCancel} afterClose={onClose} footer={null} + width={600} transitionName="animation-move-down" centered> {presets.length > 0 && ( <> - {mode === 'delete' ? ( + {mode === 'manage' ? ( {t('common.select_all')} @@ -119,15 +137,24 @@ const PopupContainer: React.FC = () => {
)} - {mode === 'delete' && ( - + {mode === 'manage' && ( + <> + + + )} { onChange={(value) => handleModeChange(value as Mode)} options={[ { label: t('assistants.presets.manage.mode.sort'), value: 'sort' }, - { label: t('assistants.presets.manage.mode.delete'), value: 'delete' } + { label: t('assistants.presets.manage.mode.manage'), value: 'manage' } ]} /> From c676a93595f8383018d68595cfe2dbe04aba09ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Wed, 17 Dec 2025 19:23:56 +0800 Subject: [PATCH 22/41] fix(installer): auto-install VC++ Redistributable without user prompt (#11927) --- build/nsis-installer.nsh | 65 +++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/build/nsis-installer.nsh b/build/nsis-installer.nsh index 769ccaaa19..e644e18f3d 100644 --- a/build/nsis-installer.nsh +++ b/build/nsis-installer.nsh @@ -12,8 +12,13 @@ ; https://github.com/electron-userland/electron-builder/issues/1122 !ifndef BUILD_UNINSTALLER + ; Check VC++ Redistributable based on architecture stored in $1 Function checkVCRedist - ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" + ${If} $1 == "arm64" + ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\ARM64" "Installed" + ${Else} + ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" + ${EndIf} FunctionEnd Function checkArchitectureCompatibility @@ -97,29 +102,47 @@ Call checkVCRedist ${If} $0 != "1" - MessageBox MB_YESNO "\ - NOTE: ${PRODUCT_NAME} requires $\r$\n\ - 'Microsoft Visual C++ Redistributable'$\r$\n\ - to function properly.$\r$\n$\r$\n\ - Download and install now?" /SD IDYES IDYES InstallVCRedist IDNO DontInstall - InstallVCRedist: - inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" - ExecWait "$TEMP\vc_redist.x64.exe /install /norestart" - ;IfErrors InstallError ContinueInstall ; vc_redist exit code is unreliable :( - Call checkVCRedist - ${If} $0 == "1" - Goto ContinueInstall - ${EndIf} + ; VC++ is required - install automatically since declining would abort anyway + ; Select download URL based on system architecture (stored in $1) + ${If} $1 == "arm64" + StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.arm64.exe" + StrCpy $3 "$TEMP\vc_redist.arm64.exe" + ${Else} + StrCpy $2 "https://aka.ms/vs/17/release/vc_redist.x64.exe" + StrCpy $3 "$TEMP\vc_redist.x64.exe" + ${EndIf} - ;InstallError: - MessageBox MB_ICONSTOP "\ - There was an unexpected error installing$\r$\n\ - Microsoft Visual C++ Redistributable.$\r$\n\ - The installation of ${PRODUCT_NAME} cannot continue." - DontInstall: + inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable..." \ + $2 $3 /END + Pop $0 ; Get download status from inetc::get + ${If} $0 != "OK" + MessageBox MB_ICONSTOP|MB_YESNO "\ + Failed to download Microsoft Visual C++ Redistributable.$\r$\n$\r$\n\ + Error: $0$\r$\n$\r$\n\ + Would you like to open the download page in your browser?$\r$\n\ + $2" IDYES openDownloadUrl IDNO skipDownloadUrl + openDownloadUrl: + ExecShell "open" $2 + skipDownloadUrl: Abort + ${EndIf} + + ExecWait "$3 /install /quiet /norestart" + ; Note: vc_redist exit code is unreliable, verify via registry check instead + + Call checkVCRedist + ${If} $0 != "1" + MessageBox MB_ICONSTOP|MB_YESNO "\ + Microsoft Visual C++ Redistributable installation failed.$\r$\n$\r$\n\ + Would you like to open the download page in your browser?$\r$\n\ + $2$\r$\n$\r$\n\ + The installation of ${PRODUCT_NAME} cannot continue." IDYES openInstallUrl IDNO skipInstallUrl + openInstallUrl: + ExecShell "open" $2 + skipInstallUrl: + Abort + ${EndIf} ${EndIf} - ContinueInstall: Pop $4 Pop $3 Pop $2 From ef25eef0ebc6d3dbb754780669593a7a9f3c1d7d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 16 Dec 2025 12:18:11 +0800 Subject: [PATCH 23/41] feat(knowledge): use prompt injection for forced knowledge base search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the default knowledge base retrieval behavior from tool call to prompt injection mode. This provides faster response times when knowledge base search is forced. Intent recognition mode (tool call) is still available as an opt-in option. - Remove toolChoiceMiddleware for forced knowledge base search - Add prompt injection for knowledge base references in KnowledgeService - Move transformMessagesAndFetch to ApiService, delete OrchestrateService - Export getMessageContent from searchOrchestrationPlugin - Add setCitationBlockId callback to citationCallbacks - Default knowledgeRecognition to 'off' (prompt mode) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../middleware/AiSdkMiddlewareBuilder.ts | 11 -- .../plugins/searchOrchestrationPlugin.ts | 46 ++---- src/renderer/src/services/ApiService.ts | 57 ++++++++ src/renderer/src/services/KnowledgeService.ts | 132 ++++++++++++++++++ .../src/services/OrchestrateService.ts | 91 ------------ .../src/services/StreamProcessingService.ts | 4 + .../callbacks/citationCallbacks.ts | 7 +- src/renderer/src/store/thunk/messageThunk.ts | 6 +- 8 files changed, 218 insertions(+), 136 deletions(-) delete mode 100644 src/renderer/src/services/OrchestrateService.ts diff --git a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts index 10a4d59384..b2a796bd33 100644 --- a/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts +++ b/src/renderer/src/aiCore/middleware/AiSdkMiddlewareBuilder.ts @@ -7,7 +7,6 @@ import type { Chunk } from '@renderer/types/chunk' import { isOllamaProvider, isSupportEnableThinkingProvider } from '@renderer/utils/provider' import type { LanguageModelMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' -import { isEmpty } from 'lodash' import { getAiSdkProviderId } from '../provider/factory' import { isOpenRouterGeminiGenerateImageModel } from '../utils/image' @@ -16,7 +15,6 @@ import { openrouterGenerateImageMiddleware } from './openrouterGenerateImageMidd import { openrouterReasoningMiddleware } from './openrouterReasoningMiddleware' import { qwenThinkingMiddleware } from './qwenThinkingMiddleware' import { skipGeminiThoughtSignatureMiddleware } from './skipGeminiThoughtSignatureMiddleware' -import { toolChoiceMiddleware } from './toolChoiceMiddleware' const logger = loggerService.withContext('AiSdkMiddlewareBuilder') @@ -136,15 +134,6 @@ export class AiSdkMiddlewareBuilder { export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageModelMiddleware[] { const builder = new AiSdkMiddlewareBuilder() - // 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库) - if (!isEmpty(config.assistant?.knowledge_bases?.map((base) => base.id)) && config.knowledgeRecognition !== 'on') { - builder.add({ - name: 'force-knowledge-first', - middleware: toolChoiceMiddleware('builtin_knowledge_search') - }) - logger.debug('Added toolChoice middleware to force knowledge base search on first round') - } - // 1. 根据provider添加特定中间件 if (config.provider) { addProviderSpecificMiddlewares(builder, config) diff --git a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts index 6be577f194..5b095a4461 100644 --- a/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts +++ b/src/renderer/src/aiCore/plugins/searchOrchestrationPlugin.ts @@ -31,7 +31,7 @@ import { webSearchToolWithPreExtractedKeywords } from '../tools/WebSearchTool' const logger = loggerService.withContext('SearchOrchestrationPlugin') -const getMessageContent = (message: ModelMessage) => { +export const getMessageContent = (message: ModelMessage) => { if (typeof message.content === 'string') return message.content return message.content.reduce((acc, part) => { if (part.type === 'text') { @@ -266,14 +266,14 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string) // 判断是否需要各种搜索 const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id) const hasKnowledgeBase = !isEmpty(knowledgeBaseIds) - const knowledgeRecognition = assistant.knowledgeRecognition || 'on' + const knowledgeRecognition = assistant.knowledgeRecognition || 'off' const globalMemoryEnabled = selectGlobalMemoryEnabled(store.getState()) const shouldWebSearch = !!assistant.webSearchProviderId const shouldKnowledgeSearch = hasKnowledgeBase && knowledgeRecognition === 'on' const shouldMemorySearch = globalMemoryEnabled && assistant.enableMemory // 执行意图分析 - if (shouldWebSearch || hasKnowledgeBase) { + if (shouldWebSearch || shouldKnowledgeSearch) { const analysisResult = await analyzeSearchIntent(lastUserMessage, assistant, { shouldWebSearch, shouldKnowledgeSearch, @@ -330,41 +330,25 @@ export const searchOrchestrationPlugin = (assistant: Assistant, topicId: string) // 📚 知识库搜索工具配置 const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id) const hasKnowledgeBase = !isEmpty(knowledgeBaseIds) - const knowledgeRecognition = assistant.knowledgeRecognition || 'on' + const knowledgeRecognition = assistant.knowledgeRecognition || 'off' + const shouldKnowledgeSearch = hasKnowledgeBase && knowledgeRecognition === 'on' - if (hasKnowledgeBase) { - if (knowledgeRecognition === 'off') { - // off 模式:直接添加知识库搜索工具,使用用户消息作为搜索关键词 + if (shouldKnowledgeSearch) { + // on 模式:根据意图识别结果决定是否添加工具 + const needsKnowledgeSearch = + analysisResult?.knowledge && + analysisResult.knowledge.question && + analysisResult.knowledge.question[0] !== 'not_needed' + + if (needsKnowledgeSearch && analysisResult.knowledge) { + // logger.info('📚 Adding knowledge search tool (intent-based)') const userMessage = userMessages[context.requestId] - const fallbackKeywords = { - question: [getMessageContent(userMessage) || 'search'], - rewrite: getMessageContent(userMessage) || 'search' - } - // logger.info('📚 Adding knowledge search tool (force mode)') params.tools['builtin_knowledge_search'] = knowledgeSearchTool( assistant, - fallbackKeywords, + analysisResult.knowledge, getMessageContent(userMessage), topicId ) - // params.toolChoice = { type: 'tool', toolName: 'builtin_knowledge_search' } - } else { - // on 模式:根据意图识别结果决定是否添加工具 - const needsKnowledgeSearch = - analysisResult?.knowledge && - analysisResult.knowledge.question && - analysisResult.knowledge.question[0] !== 'not_needed' - - if (needsKnowledgeSearch && analysisResult.knowledge) { - // logger.info('📚 Adding knowledge search tool (intent-based)') - const userMessage = userMessages[context.requestId] - params.tools['builtin_knowledge_search'] = knowledgeSearchTool( - assistant, - analysisResult.knowledge, - getMessageContent(userMessage), - topicId - ) - } } } diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 3c081c3da1..0cd57a353a 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -34,6 +34,10 @@ import { getProviderByModel, getQuickModel } from './AssistantService' +import { ConversationService } from './ConversationService' +import { injectUserMessageWithKnowledgeSearchPrompt } from './KnowledgeService' +import type { BlockManager } from './messageStreaming' +import type { StreamProcessorCallbacks } from './StreamProcessingService' // import { processKnowledgeSearch } from './KnowledgeService' // import { // filterContextMessages, @@ -79,6 +83,59 @@ export async function fetchMcpTools(assistant: Assistant) { return mcpTools } +/** + * 将用户消息转换为LLM可以理解的格式并发送请求 + * @param request - 包含消息内容和助手信息的请求对象 + * @param onChunkReceived - 接收流式响应数据的回调函数 + */ +// 目前先按照函数来写,后续如果有需要到class的地方就改回来 +export async function transformMessagesAndFetch( + request: { + messages: Message[] + assistant: Assistant + blockManager: BlockManager + assistantMsgId: string + callbacks: StreamProcessorCallbacks + topicId?: string // 添加 topicId 用于 trace + options: { + signal?: AbortSignal + timeout?: number + headers?: Record + } + }, + onChunkReceived: (chunk: Chunk) => void +) { + const { messages, assistant } = request + + try { + const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(messages, assistant) + + // replace prompt variables + assistant.prompt = await replacePromptVariables(assistant.prompt, assistant.model?.name) + + // inject knowledge search prompt into model messages + await injectUserMessageWithKnowledgeSearchPrompt({ + modelMessages, + assistant, + assistantMsgId: request.assistantMsgId, + topicId: request.topicId, + blockManager: request.blockManager, + setCitationBlockId: request.callbacks.setCitationBlockId! + }) + + await fetchChatCompletion({ + messages: modelMessages, + assistant: assistant, + topicId: request.topicId, + requestOptions: request.options, + uiMessages, + onChunkReceived + }) + } catch (error: any) { + onChunkReceived({ type: ChunkType.ERROR, error }) + } +} + export async function fetchChatCompletion({ messages, prompt, diff --git a/src/renderer/src/services/KnowledgeService.ts b/src/renderer/src/services/KnowledgeService.ts index e78cfa62e5..ce9577c68d 100644 --- a/src/renderer/src/services/KnowledgeService.ts +++ b/src/renderer/src/services/KnowledgeService.ts @@ -2,10 +2,13 @@ import { loggerService } from '@logger' import type { Span } from '@opentelemetry/api' import { ModernAiProvider } from '@renderer/aiCore' import AiProvider from '@renderer/aiCore/legacy' +import { getMessageContent } from '@renderer/aiCore/plugins/searchOrchestrationPlugin' import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, DEFAULT_KNOWLEDGE_THRESHOLD } from '@renderer/config/constant' import { getEmbeddingMaxContext } from '@renderer/config/embedings' +import { REFERENCE_PROMPT } from '@renderer/config/prompts' import { addSpan, endSpan } from '@renderer/services/SpanManagerService' import store from '@renderer/store' +import type { Assistant } from '@renderer/types' import { type FileMetadata, type KnowledgeBase, @@ -16,13 +19,17 @@ import { } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' +import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { routeToEndpoint } from '@renderer/utils' import type { ExtractResults } from '@renderer/utils/extract' +import { createCitationBlock } from '@renderer/utils/messageUtils/create' import { isAzureOpenAIProvider, isGeminiProvider } from '@renderer/utils/provider' +import type { ModelMessage, UserModelMessage } from 'ai' import { isEmpty } from 'lodash' import { getProviderByModel } from './AssistantService' import FileManager from './FileManager' +import type { BlockManager } from './messageStreaming' const logger = loggerService.withContext('RendererKnowledgeService') @@ -338,3 +345,128 @@ export function processKnowledgeReferences( } } } + +export const injectUserMessageWithKnowledgeSearchPrompt = async ({ + modelMessages, + assistant, + assistantMsgId, + topicId, + blockManager, + setCitationBlockId +}: { + modelMessages: ModelMessage[] + assistant: Assistant + assistantMsgId: string + topicId?: string + blockManager: BlockManager + setCitationBlockId: (blockId: string) => void +}) => { + if (assistant.knowledge_bases?.length && modelMessages.length > 0) { + const lastUserMessage = modelMessages[modelMessages.length - 1] + const isUserMessage = lastUserMessage.role === 'user' + + if (!isUserMessage) { + return + } + + const knowledgeReferences = await getKnowledgeReferences({ + assistant, + lastUserMessage, + topicId: topicId + }) + + if (knowledgeReferences.length === 0) { + return + } + + await createKnowledgeReferencesBlock({ + assistantMsgId, + knowledgeReferences, + blockManager, + setCitationBlockId + }) + + const question = getMessageContent(lastUserMessage) || '' + const references = JSON.stringify(knowledgeReferences, null, 2) + + const knowledgeSearchPrompt = REFERENCE_PROMPT.replace('{question}', question).replace('{references}', references) + + if (typeof lastUserMessage.content === 'string') { + lastUserMessage.content = knowledgeSearchPrompt + } else if (Array.isArray(lastUserMessage.content)) { + const textPart = lastUserMessage.content.find((part) => part.type === 'text') + if (textPart) { + textPart.text = knowledgeSearchPrompt + } else { + lastUserMessage.content.push({ + type: 'text', + text: knowledgeSearchPrompt + }) + } + } + } +} + +export const getKnowledgeReferences = async ({ + assistant, + lastUserMessage, + topicId +}: { + assistant: Assistant + lastUserMessage: UserModelMessage + topicId?: string +}) => { + // 如果助手没有知识库,返回空字符串 + if (!assistant || isEmpty(assistant.knowledge_bases)) { + return [] + } + + // 获取知识库ID + const knowledgeBaseIds = assistant.knowledge_bases?.map((base) => base.id) + + // 获取用户消息内容 + const question = getMessageContent(lastUserMessage) || '' + + // 获取知识库引用 + const knowledgeReferences = await processKnowledgeSearch( + { + knowledge: { + question: [question], + rewrite: '' + } + }, + knowledgeBaseIds, + topicId! + ) + + // 返回提示词 + return knowledgeReferences +} + +export const createKnowledgeReferencesBlock = async ({ + assistantMsgId, + knowledgeReferences, + blockManager, + setCitationBlockId +}: { + assistantMsgId: string + knowledgeReferences: KnowledgeReference[] + blockManager: BlockManager + setCitationBlockId: (blockId: string) => void +}) => { + // 创建引用块 + const citationBlock = createCitationBlock( + assistantMsgId, + { knowledge: knowledgeReferences }, + { status: MessageBlockStatus.SUCCESS } + ) + + // 处理引用块 + blockManager.handleBlockTransition(citationBlock, MessageBlockType.CITATION) + + // 设置引用块ID + setCitationBlockId(citationBlock.id) + + // 返回引用块 + return citationBlock +} diff --git a/src/renderer/src/services/OrchestrateService.ts b/src/renderer/src/services/OrchestrateService.ts deleted file mode 100644 index 71f17d6804..0000000000 --- a/src/renderer/src/services/OrchestrateService.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { Assistant, Message } from '@renderer/types' -import type { Chunk } from '@renderer/types/chunk' -import { ChunkType } from '@renderer/types/chunk' -import { replacePromptVariables } from '@renderer/utils/prompt' - -import { fetchChatCompletion } from './ApiService' -import { ConversationService } from './ConversationService' - -/** - * The request object for handling a user message. - */ -export interface OrchestrationRequest { - messages: Message[] - assistant: Assistant - options: { - signal?: AbortSignal - timeout?: number - headers?: Record - } - topicId?: string // 添加 topicId 用于 trace -} - -/** - * The OrchestrationService is responsible for orchestrating the different services - * to handle a user's message. It contains the core logic of the application. - */ -// NOTE:暂时没有用到这个类 -export class OrchestrationService { - constructor() { - // In the future, this could be a singleton, but for now, a new instance is fine. - // this.conversationService = new ConversationService() - } - - /** - * This is the core method to handle user messages. - * It takes the message context and an events object for callbacks, - * and orchestrates the call to the LLM. - * The logic is moved from `messageThunk.ts`. - * @param request The orchestration request containing messages and assistant info. - * @param events A set of callbacks to report progress and results to the UI layer. - */ - async transformMessagesAndFetch(request: OrchestrationRequest, onChunkReceived: (chunk: Chunk) => void) { - const { messages, assistant } = request - - try { - const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(messages, assistant) - - await fetchChatCompletion({ - messages: modelMessages, - assistant: assistant, - requestOptions: request.options, - onChunkReceived, - topicId: request.topicId, - uiMessages: uiMessages - }) - } catch (error: any) { - onChunkReceived({ type: ChunkType.ERROR, error }) - } - } -} - -/** - * 将用户消息转换为LLM可以理解的格式并发送请求 - * @param request - 包含消息内容和助手信息的请求对象 - * @param onChunkReceived - 接收流式响应数据的回调函数 - */ -// 目前先按照函数来写,后续如果有需要到class的地方就改回来 -export async function transformMessagesAndFetch( - request: OrchestrationRequest, - onChunkReceived: (chunk: Chunk) => void -) { - const { messages, assistant } = request - - try { - const { modelMessages, uiMessages } = await ConversationService.prepareMessagesForModel(messages, assistant) - - // replace prompt variables - assistant.prompt = await replacePromptVariables(assistant.prompt, assistant.model?.name) - - await fetchChatCompletion({ - messages: modelMessages, - assistant: assistant, - requestOptions: request.options, - onChunkReceived, - topicId: request.topicId, - uiMessages - }) - } catch (error: any) { - onChunkReceived({ type: ChunkType.ERROR, error }) - } -} diff --git a/src/renderer/src/services/StreamProcessingService.ts b/src/renderer/src/services/StreamProcessingService.ts index 26f52b8037..7e80672d5d 100644 --- a/src/renderer/src/services/StreamProcessingService.ts +++ b/src/renderer/src/services/StreamProcessingService.ts @@ -34,6 +34,10 @@ export interface StreamProcessorCallbacks { onLLMWebSearchInProgress?: () => void // LLM Web search complete onLLMWebSearchComplete?: (llmWebSearchResult: WebSearchResponse) => void + // Get citation block ID + getCitationBlockId?: () => string | null + // Set citation block ID + setCitationBlockId?: (blockId: string) => void // Image generation chunk received onImageCreated?: () => void onImageDelta?: (imageData: GenerateImageResponse) => void diff --git a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts index 9e99fe7520..3245493636 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts @@ -121,6 +121,11 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => }, // 暴露给外部的方法,用于textCallbacks中获取citationBlockId - getCitationBlockId: () => citationBlockId + getCitationBlockId: () => citationBlockId, + + // 暴露给外部的方法,用于 KnowledgeService 中设置 citationBlockId + setCitationBlockId: (blockId: string) => { + citationBlockId = blockId + } } } diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index a70fdf572d..8219fa0cce 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -2,12 +2,11 @@ import { loggerService } from '@logger' import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter' import { AgentApiClient } from '@renderer/api/agent' import db from '@renderer/databases' -import { fetchMessagesSummary } from '@renderer/services/ApiService' +import { fetchMessagesSummary, transformMessagesAndFetch } from '@renderer/services/ApiService' import { DbService } from '@renderer/services/db/DbService' import FileManager from '@renderer/services/FileManager' import { BlockManager } from '@renderer/services/messageStreaming/BlockManager' import { createCallbacks } from '@renderer/services/messageStreaming/callbacks' -import { transformMessagesAndFetch } from '@renderer/services/OrchestrateService' import { endSpan } from '@renderer/services/SpanManagerService' import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService' import store from '@renderer/store' @@ -814,6 +813,9 @@ const fetchAndProcessAssistantResponseImpl = async ( messages: messagesForContext, assistant, topicId, + blockManager, + assistantMsgId, + callbacks, options: { signal: abortController.signal, timeout: 30000, From bdfda7afb1fe890758c7b78897d9c8c784b09cce Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 17 Dec 2025 22:27:17 +0800 Subject: [PATCH 24/41] fix: correct typo in Gemini 3 Pro Image Preview model name (#11969) --- src/renderer/src/config/models/default.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 45fa7f79c4..d7b0e885d0 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -362,7 +362,7 @@ export const SYSTEM_MODELS: Record = { id: 'gemini-3-pro-image-preview', provider: 'gemini', - name: 'Gemini 3 Pro Image Privew', + name: 'Gemini 3 Pro Image Preview', group: 'Gemini 3' }, { From 1d5dafa32548e87eea143bc7be0ffe3a71ffe88d Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Wed, 17 Dec 2025 23:08:42 +0800 Subject: [PATCH 25/41] refactor: rewrite filesystem MCP server with improved tool set (#11937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: rewrite filesystem MCP server with new tool set - Replace existing filesystem MCP with modular architecture - Implement 6 new tools: glob, ls, grep, read, write, delete - Add comprehensive TypeScript types and Zod schemas - Maintain security with path validation and allowed directories - Improve error handling and user feedback - Add result limits for performance (100 files/matches max) - Format output with clear, helpful messages - Keep backward compatibility with existing import patterns BREAKING CHANGE: Tools renamed from snake_case to lowercase - read_file → read - write_file → write - list_directory → ls - search_files → glob - New tools: grep, delete - Removed: edit_file, create_directory, directory_tree, move_file, get_file_info * 🐛 fix: remove filesystem allowed directories restriction * 🐛 fix: relax binary detection for text files * ✨ feat: add edit tool with fuzzy matching to filesystem MCP server - Add edit tool with 9 fallback replacers from opencode for robust string replacement (SimpleReplacer, LineTrimmedReplacer, BlockAnchorReplacer, WhitespaceNormalizedReplacer, etc.) - Add Levenshtein distance algorithm for similarity matching - Improve descriptions for all tools (read, write, glob, grep, ls, delete) following opencode patterns for better LLM guidance - Register edit tool in server and export from tools index * ♻️ refactor: replace allowedDirectories with baseDir in filesystem MCP server - Change server to use single baseDir (from WORKSPACE_ROOT env or userData/workspace default) - Remove list_allowed_directories tool as restriction mechanism is removed - Add ripgrep integration for faster grep searches with JS fallback - Simplify validatePath() by removing allowlist checks - Display paths relative to baseDir in tool outputs * 📝 docs: standardize filesystem MCP server tool descriptions - Unify description format to bullet-point style across all tools - Add absolute path requirement to ls, glob, grep schemas and descriptions - Update glob and grep to output absolute paths instead of relative paths - Add missing error case documentation for edit tool (old_string === new_string) - Standardize optional path parameter descriptions * ♻️ refactor: use ripgrep for glob tool and extract shared utilities - Extract shared ripgrep utilities (runRipgrep, getRipgrepAddonPath) to types.ts - Rewrite glob tool to use `rg --files --glob` for reliable file matching - Update grep tool to import shared ripgrep utilities * 🐛 fix: handle ripgrep exit code 2 with valid results in glob tool - Process ripgrep stdout when content exists, regardless of exit code - Exit code 2 can indicate partial errors while still returning valid results - Remove fallback directory listing (had buggy regex for root-level files) - Update tool description to clarify patterns without "/" match at any depth * 🔥 chore: remove filesystem.ts.backup file Remove unnecessary backup file from mcpServers directory * 🐛 fix: use correct default workspace path in filesystem MCP server Change default baseDir from userData/workspace to userData/Data/Workspace to match the app's data storage convention (Data/Files, Data/Notes, etc.) Addresses PR #11937 review feedback. * 🐛 fix: pass WORKSPACE_ROOT to FileSystemServer constructor The envs object passed to createInMemoryMCPServer was not being used for the filesystem server. Now WORKSPACE_ROOT is passed as a constructor parameter, following the same pattern as other MCP servers. * \feat: add link to documentation for MCP server configuration requirement Wrap the configuration requirement tag in a link to the documentation for better user guidance on MCP server settings. --------- Co-authored-by: kangfenmao --- src/main/mcpServers/factory.ts | 2 +- src/main/mcpServers/filesystem.ts | 652 ------------------ src/main/mcpServers/filesystem/index.ts | 2 + src/main/mcpServers/filesystem/server.ts | 118 ++++ .../mcpServers/filesystem/tools/delete.ts | 93 +++ src/main/mcpServers/filesystem/tools/edit.ts | 130 ++++ src/main/mcpServers/filesystem/tools/glob.ts | 149 ++++ src/main/mcpServers/filesystem/tools/grep.ts | 266 +++++++ src/main/mcpServers/filesystem/tools/index.ts | 8 + src/main/mcpServers/filesystem/tools/ls.ts | 150 ++++ src/main/mcpServers/filesystem/tools/read.ts | 101 +++ src/main/mcpServers/filesystem/tools/write.ts | 83 +++ src/main/mcpServers/filesystem/types.ts | 627 +++++++++++++++++ .../MCPSettings/BuiltinMCPServerList.tsx | 11 +- 14 files changed, 1736 insertions(+), 656 deletions(-) delete mode 100644 src/main/mcpServers/filesystem.ts create mode 100644 src/main/mcpServers/filesystem/index.ts create mode 100644 src/main/mcpServers/filesystem/server.ts create mode 100644 src/main/mcpServers/filesystem/tools/delete.ts create mode 100644 src/main/mcpServers/filesystem/tools/edit.ts create mode 100644 src/main/mcpServers/filesystem/tools/glob.ts create mode 100644 src/main/mcpServers/filesystem/tools/grep.ts create mode 100644 src/main/mcpServers/filesystem/tools/index.ts create mode 100644 src/main/mcpServers/filesystem/tools/ls.ts create mode 100644 src/main/mcpServers/filesystem/tools/read.ts create mode 100644 src/main/mcpServers/filesystem/tools/write.ts create mode 100644 src/main/mcpServers/filesystem/types.ts diff --git a/src/main/mcpServers/factory.ts b/src/main/mcpServers/factory.ts index ce736f6843..909901c1c8 100644 --- a/src/main/mcpServers/factory.ts +++ b/src/main/mcpServers/factory.ts @@ -36,7 +36,7 @@ export function createInMemoryMCPServer( return new FetchServer().server } case BuiltinMCPServerNames.filesystem: { - return new FileSystemServer(args).server + return new FileSystemServer(envs.WORKSPACE_ROOT).server } case BuiltinMCPServerNames.difyKnowledge: { const difyKey = envs.DIFY_KEY diff --git a/src/main/mcpServers/filesystem.ts b/src/main/mcpServers/filesystem.ts deleted file mode 100644 index ba10783881..0000000000 --- a/src/main/mcpServers/filesystem.ts +++ /dev/null @@ -1,652 +0,0 @@ -// port https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/index.ts - -import { loggerService } from '@logger' -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import { createTwoFilesPatch } from 'diff' -import fs from 'fs/promises' -import { minimatch } from 'minimatch' -import os from 'os' -import path from 'path' -import * as z from 'zod' - -const logger = loggerService.withContext('MCP:FileSystemServer') - -// Normalize all paths consistently -function normalizePath(p: string): string { - return path.normalize(p) -} - -function expandHome(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return path.join(os.homedir(), filepath.slice(1)) - } - return filepath -} - -// Security utilities -async function validatePath(allowedDirectories: string[], requestedPath: string): Promise { - const expandedPath = expandHome(requestedPath) - const absolute = path.isAbsolute(expandedPath) - ? path.resolve(expandedPath) - : path.resolve(process.cwd(), expandedPath) - - const normalizedRequested = normalizePath(absolute) - - // Check if path is within allowed directories - const isAllowed = allowedDirectories.some((dir) => normalizedRequested.startsWith(dir)) - if (!isAllowed) { - throw new Error( - `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(', ')}` - ) - } - - // Handle symlinks by checking their real path - try { - const realPath = await fs.realpath(absolute) - const normalizedReal = normalizePath(realPath) - const isRealPathAllowed = allowedDirectories.some((dir) => normalizedReal.startsWith(dir)) - if (!isRealPathAllowed) { - throw new Error('Access denied - symlink target outside allowed directories') - } - return realPath - } catch (error) { - // For new files that don't exist yet, verify parent directory - const parentDir = path.dirname(absolute) - try { - const realParentPath = await fs.realpath(parentDir) - const normalizedParent = normalizePath(realParentPath) - const isParentAllowed = allowedDirectories.some((dir) => normalizedParent.startsWith(dir)) - if (!isParentAllowed) { - throw new Error('Access denied - parent directory outside allowed directories') - } - return absolute - } catch { - throw new Error(`Parent directory does not exist: ${parentDir}`) - } - } -} - -// Schema definitions -const ReadFileArgsSchema = z.object({ - path: z.string() -}) - -const ReadMultipleFilesArgsSchema = z.object({ - paths: z.array(z.string()) -}) - -const WriteFileArgsSchema = z.object({ - path: z.string(), - content: z.string() -}) - -const EditOperation = z.object({ - oldText: z.string().describe('Text to search for - must match exactly'), - newText: z.string().describe('Text to replace with') -}) - -const EditFileArgsSchema = z.object({ - path: z.string(), - edits: z.array(EditOperation), - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') -}) - -const CreateDirectoryArgsSchema = z.object({ - path: z.string() -}) - -const ListDirectoryArgsSchema = z.object({ - path: z.string() -}) - -const DirectoryTreeArgsSchema = z.object({ - path: z.string() -}) - -const MoveFileArgsSchema = z.object({ - source: z.string(), - destination: z.string() -}) - -const SearchFilesArgsSchema = z.object({ - path: z.string(), - pattern: z.string(), - excludePatterns: z.array(z.string()).optional().default([]) -}) - -const GetFileInfoArgsSchema = z.object({ - path: z.string() -}) - -interface FileInfo { - size: number - created: Date - modified: Date - accessed: Date - isDirectory: boolean - isFile: boolean - permissions: string -} - -// Tool implementations -async function getFileStats(filePath: string): Promise { - const stats = await fs.stat(filePath) - return { - size: stats.size, - created: stats.birthtime, - modified: stats.mtime, - accessed: stats.atime, - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - permissions: stats.mode.toString(8).slice(-3) - } -} - -async function searchFiles( - allowedDirectories: string[], - rootPath: string, - pattern: string, - excludePatterns: string[] = [] -): Promise { - const results: string[] = [] - - async function search(currentPath: string) { - const entries = await fs.readdir(currentPath, { withFileTypes: true }) - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name) - - try { - // Validate each path before processing - await validatePath(allowedDirectories, fullPath) - - // Check if path matches any exclude pattern - const relativePath = path.relative(rootPath, fullPath) - const shouldExclude = excludePatterns.some((pattern) => { - const globPattern = pattern.includes('*') ? pattern : `**/${pattern}/**` - return minimatch(relativePath, globPattern, { dot: true }) - }) - - if (shouldExclude) { - continue - } - - if (entry.name.toLowerCase().includes(pattern.toLowerCase())) { - results.push(fullPath) - } - - if (entry.isDirectory()) { - await search(fullPath) - } - } catch (error) { - // Skip invalid paths during search - } - } - } - - await search(rootPath) - return results -} - -// file editing and diffing utilities -function normalizeLineEndings(text: string): string { - return text.replace(/\r\n/g, '\n') -} - -function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { - // Ensure consistent line endings for diff - const normalizedOriginal = normalizeLineEndings(originalContent) - const normalizedNew = normalizeLineEndings(newContent) - - return createTwoFilesPatch(filepath, filepath, normalizedOriginal, normalizedNew, 'original', 'modified') -} - -async function applyFileEdits( - filePath: string, - edits: Array<{ oldText: string; newText: string }>, - dryRun = false -): Promise { - // Read file content and normalize line endings - const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')) - - // Apply edits sequentially - let modifiedContent = content - for (const edit of edits) { - const normalizedOld = normalizeLineEndings(edit.oldText) - const normalizedNew = normalizeLineEndings(edit.newText) - - // If exact match exists, use it - if (modifiedContent.includes(normalizedOld)) { - modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew) - continue - } - - // Otherwise, try line-by-line matching with flexibility for whitespace - const oldLines = normalizedOld.split('\n') - const contentLines = modifiedContent.split('\n') - let matchFound = false - - for (let i = 0; i <= contentLines.length - oldLines.length; i++) { - const potentialMatch = contentLines.slice(i, i + oldLines.length) - - // Compare lines with normalized whitespace - const isMatch = oldLines.every((oldLine, j) => { - const contentLine = potentialMatch[j] - return oldLine.trim() === contentLine.trim() - }) - - if (isMatch) { - // Preserve original indentation of first line - const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '' - const newLines = normalizedNew.split('\n').map((line, j) => { - if (j === 0) return originalIndent + line.trimStart() - // For subsequent lines, try to preserve relative indentation - const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '' - const newIndent = line.match(/^\s*/)?.[0] || '' - if (oldIndent && newIndent) { - const relativeIndent = newIndent.length - oldIndent.length - return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart() - } - return line - }) - - contentLines.splice(i, oldLines.length, ...newLines) - modifiedContent = contentLines.join('\n') - matchFound = true - break - } - } - - if (!matchFound) { - throw new Error(`Could not find exact match for edit:\n${edit.oldText}`) - } - } - - // Create unified diff - const diff = createUnifiedDiff(content, modifiedContent, filePath) - - // Format diff with appropriate number of backticks - let numBackticks = 3 - while (diff.includes('`'.repeat(numBackticks))) { - numBackticks++ - } - const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n` - - if (!dryRun) { - await fs.writeFile(filePath, modifiedContent, 'utf-8') - } - - return formattedDiff -} - -class FileSystemServer { - public server: Server - private allowedDirectories: string[] - constructor(allowedDirs: string[]) { - if (!Array.isArray(allowedDirs) || allowedDirs.length === 0) { - throw new Error('No allowed directories provided, please specify at least one directory in args') - } - - this.allowedDirectories = allowedDirs.map((dir) => normalizePath(path.resolve(expandHome(dir)))) - - // Validate that all directories exist and are accessible - this.validateDirs().catch((error) => { - logger.error('Error validating allowed directories:', error) - throw new Error(`Error validating allowed directories: ${error}`) - }) - - this.server = new Server( - { - name: 'secure-filesystem-server', - version: '0.2.0' - }, - { - capabilities: { - tools: {} - } - } - ) - this.initialize() - } - - async validateDirs() { - // Validate that all directories exist and are accessible - await Promise.all( - this.allowedDirectories.map(async (dir) => { - try { - const stats = await fs.stat(expandHome(dir)) - if (!stats.isDirectory()) { - logger.error(`Error: ${dir} is not a directory`) - throw new Error(`Error: ${dir} is not a directory`) - } - } catch (error: any) { - logger.error(`Error accessing directory ${dir}:`, error) - throw new Error(`Error accessing directory ${dir}:`, error) - } - }) - ) - } - - initialize() { - // Tool handlers - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'read_file', - description: - 'Read the complete contents of a file from the file system. ' + - 'Handles various text encodings and provides detailed error messages ' + - 'if the file cannot be read. Use this tool when you need to examine ' + - 'the contents of a single file. Only works within allowed directories.', - inputSchema: z.toJSONSchema(ReadFileArgsSchema) - }, - { - name: 'read_multiple_files', - description: - 'Read the contents of multiple files simultaneously. This is more ' + - 'efficient than reading files one by one when you need to analyze ' + - "or compare multiple files. Each file's content is returned with its " + - "path as a reference. Failed reads for individual files won't stop " + - 'the entire operation. Only works within allowed directories.', - inputSchema: z.toJSONSchema(ReadMultipleFilesArgsSchema) - }, - { - name: 'write_file', - description: - 'Create a new file or completely overwrite an existing file with new content. ' + - 'Use with caution as it will overwrite existing files without warning. ' + - 'Handles text content with proper encoding. Only works within allowed directories.', - inputSchema: z.toJSONSchema(WriteFileArgsSchema) - }, - { - name: 'edit_file', - description: - 'Make line-based edits to a text file. Each edit replaces exact line sequences ' + - 'with new content. Returns a git-style diff showing the changes made. ' + - 'Only works within allowed directories.', - inputSchema: z.toJSONSchema(EditFileArgsSchema) - }, - { - name: 'create_directory', - description: - 'Create a new directory or ensure a directory exists. Can create multiple ' + - 'nested directories in one operation. If the directory already exists, ' + - 'this operation will succeed silently. Perfect for setting up directory ' + - 'structures for projects or ensuring required paths exist. Only works within allowed directories.', - inputSchema: z.toJSONSchema(CreateDirectoryArgsSchema) - }, - { - name: 'list_directory', - description: - 'Get a detailed listing of all files and directories in a specified path. ' + - 'Results clearly distinguish between files and directories with [FILE] and [DIR] ' + - 'prefixes. This tool is essential for understanding directory structure and ' + - 'finding specific files within a directory. Only works within allowed directories.', - inputSchema: z.toJSONSchema(ListDirectoryArgsSchema) - }, - { - name: 'directory_tree', - description: - 'Get a recursive tree view of files and directories as a JSON structure. ' + - "Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " + - 'Files have no children array, while directories always have a children array (which may be empty). ' + - 'The output is formatted with 2-space indentation for readability. Only works within allowed directories.', - inputSchema: z.toJSONSchema(DirectoryTreeArgsSchema) - }, - { - name: 'move_file', - description: - 'Move or rename files and directories. Can move files between directories ' + - 'and rename them in a single operation. If the destination exists, the ' + - 'operation will fail. Works across different directories and can be used ' + - 'for simple renaming within the same directory. Both source and destination must be within allowed directories.', - inputSchema: z.toJSONSchema(MoveFileArgsSchema) - }, - { - name: 'search_files', - description: - 'Recursively search for files and directories matching a pattern. ' + - 'Searches through all subdirectories from the starting path. The search ' + - 'is case-insensitive and matches partial names. Returns full paths to all ' + - "matching items. Great for finding files when you don't know their exact location. " + - 'Only searches within allowed directories.', - inputSchema: z.toJSONSchema(SearchFilesArgsSchema) - }, - { - name: 'get_file_info', - description: - 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' + - 'information including size, creation time, last modified time, permissions, ' + - 'and type. This tool is perfect for understanding file characteristics ' + - 'without reading the actual content. Only works within allowed directories.', - inputSchema: z.toJSONSchema(GetFileInfoArgsSchema) - }, - { - name: 'list_allowed_directories', - description: - 'Returns the list of directories that this server is allowed to access. ' + - 'Use this to understand which directories are available before trying to access files.', - inputSchema: { - type: 'object', - properties: {}, - required: [] - } - } - ] - } - }) - - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - const { name, arguments: args } = request.params - - switch (name) { - case 'read_file': { - const parsed = ReadFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for read_file: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - const content = await fs.readFile(validPath, 'utf-8') - return { - content: [{ type: 'text', text: content }] - } - } - - case 'read_multiple_files': { - const parsed = ReadMultipleFilesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`) - } - const results = await Promise.all( - parsed.data.paths.map(async (filePath: string) => { - try { - const validPath = await validatePath(this.allowedDirectories, filePath) - const content = await fs.readFile(validPath, 'utf-8') - return `${filePath}:\n${content}\n` - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return `${filePath}: Error - ${errorMessage}` - } - }) - ) - return { - content: [{ type: 'text', text: results.join('\n---\n') }] - } - } - - case 'write_file': { - const parsed = WriteFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for write_file: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - await fs.writeFile(validPath, parsed.data.content, 'utf-8') - return { - content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }] - } - } - - case 'edit_file': { - const parsed = EditFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for edit_file: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun) - return { - content: [{ type: 'text', text: result }] - } - } - - case 'create_directory': { - const parsed = CreateDirectoryArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for create_directory: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - await fs.mkdir(validPath, { recursive: true }) - return { - content: [{ type: 'text', text: `Successfully created directory ${parsed.data.path}` }] - } - } - - case 'list_directory': { - const parsed = ListDirectoryArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for list_directory: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - const entries = await fs.readdir(validPath, { withFileTypes: true }) - const formatted = entries - .map((entry) => `${entry.isDirectory() ? '[DIR]' : '[FILE]'} ${entry.name}`) - .join('\n') - return { - content: [{ type: 'text', text: formatted }] - } - } - - case 'directory_tree': { - const parsed = DirectoryTreeArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`) - } - - interface TreeEntry { - name: string - type: 'file' | 'directory' - children?: TreeEntry[] - } - - async function buildTree(allowedDirectories: string[], currentPath: string): Promise { - const validPath = await validatePath(allowedDirectories, currentPath) - const entries = await fs.readdir(validPath, { withFileTypes: true }) - const result: TreeEntry[] = [] - - for (const entry of entries) { - const entryData: TreeEntry = { - name: entry.name, - type: entry.isDirectory() ? 'directory' : 'file' - } - - if (entry.isDirectory()) { - const subPath = path.join(currentPath, entry.name) - entryData.children = await buildTree(allowedDirectories, subPath) - } - - result.push(entryData) - } - - return result - } - - const treeData = await buildTree(this.allowedDirectories, parsed.data.path) - return { - content: [ - { - type: 'text', - text: JSON.stringify(treeData, null, 2) - } - ] - } - } - - case 'move_file': { - const parsed = MoveFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for move_file: ${parsed.error}`) - } - const validSourcePath = await validatePath(this.allowedDirectories, parsed.data.source) - const validDestPath = await validatePath(this.allowedDirectories, parsed.data.destination) - await fs.rename(validSourcePath, validDestPath) - return { - content: [ - { type: 'text', text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` } - ] - } - } - - case 'search_files': { - const parsed = SearchFilesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for search_files: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - const results = await searchFiles( - this.allowedDirectories, - validPath, - parsed.data.pattern, - parsed.data.excludePatterns - ) - return { - content: [{ type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' }] - } - } - - case 'get_file_info': { - const parsed = GetFileInfoArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`) - } - const validPath = await validatePath(this.allowedDirectories, parsed.data.path) - const info = await getFileStats(validPath) - return { - content: [ - { - type: 'text', - text: Object.entries(info) - .map(([key, value]) => `${key}: ${value}`) - .join('\n') - } - ] - } - } - - case 'list_allowed_directories': { - return { - content: [ - { - type: 'text', - text: `Allowed directories:\n${this.allowedDirectories.join('\n')}` - } - ] - } - } - - default: - throw new Error(`Unknown tool: ${name}`) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { - content: [{ type: 'text', text: `Error: ${errorMessage}` }], - isError: true - } - } - }) - } -} - -export default FileSystemServer diff --git a/src/main/mcpServers/filesystem/index.ts b/src/main/mcpServers/filesystem/index.ts new file mode 100644 index 0000000000..cec4c31cdf --- /dev/null +++ b/src/main/mcpServers/filesystem/index.ts @@ -0,0 +1,2 @@ +// Re-export FileSystemServer to maintain existing import pattern +export { default, FileSystemServer } from './server' diff --git a/src/main/mcpServers/filesystem/server.ts b/src/main/mcpServers/filesystem/server.ts new file mode 100644 index 0000000000..164ba0c9c4 --- /dev/null +++ b/src/main/mcpServers/filesystem/server.ts @@ -0,0 +1,118 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' +import { app } from 'electron' +import fs from 'fs/promises' +import path from 'path' + +import { + deleteToolDefinition, + editToolDefinition, + globToolDefinition, + grepToolDefinition, + handleDeleteTool, + handleEditTool, + handleGlobTool, + handleGrepTool, + handleLsTool, + handleReadTool, + handleWriteTool, + lsToolDefinition, + readToolDefinition, + writeToolDefinition +} from './tools' +import { logger } from './types' + +export class FileSystemServer { + public server: Server + private baseDir: string + + constructor(baseDir?: string) { + if (baseDir && path.isAbsolute(baseDir)) { + this.baseDir = baseDir + logger.info(`Using provided baseDir for filesystem MCP: ${baseDir}`) + } else { + const userData = app.getPath('userData') + this.baseDir = path.join(userData, 'Data', 'Workspace') + logger.info(`Using default workspace for filesystem MCP baseDir: ${this.baseDir}`) + } + + this.server = new Server( + { + name: 'filesystem-server', + version: '2.0.0' + }, + { + capabilities: { + tools: {} + } + } + ) + + this.initialize() + } + + async initialize() { + try { + await fs.mkdir(this.baseDir, { recursive: true }) + } catch (error) { + logger.error('Failed to create filesystem MCP baseDir', { error, baseDir: this.baseDir }) + } + + // Register tool list handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + globToolDefinition, + lsToolDefinition, + grepToolDefinition, + readToolDefinition, + editToolDefinition, + writeToolDefinition, + deleteToolDefinition + ] + } + }) + + // Register tool call handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + const { name, arguments: args } = request.params + + switch (name) { + case 'glob': + return await handleGlobTool(args, this.baseDir) + + case 'ls': + return await handleLsTool(args, this.baseDir) + + case 'grep': + return await handleGrepTool(args, this.baseDir) + + case 'read': + return await handleReadTool(args, this.baseDir) + + case 'edit': + return await handleEditTool(args, this.baseDir) + + case 'write': + return await handleWriteTool(args, this.baseDir) + + case 'delete': + return await handleDeleteTool(args, this.baseDir) + + default: + throw new Error(`Unknown tool: ${name}`) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error(`Tool execution error for ${request.params.name}:`, { error }) + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true + } + } + }) + } +} + +export default FileSystemServer diff --git a/src/main/mcpServers/filesystem/tools/delete.ts b/src/main/mcpServers/filesystem/tools/delete.ts new file mode 100644 index 0000000000..83becc4f17 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/delete.ts @@ -0,0 +1,93 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import { logger, validatePath } from '../types' + +// Schema definition +export const DeleteToolSchema = z.object({ + path: z.string().describe('The path to the file or directory to delete'), + recursive: z.boolean().optional().describe('For directories, whether to delete recursively (default: false)') +}) + +// Tool definition with detailed description +export const deleteToolDefinition = { + name: 'delete', + description: `Deletes a file or directory from the filesystem. + +CAUTION: This operation cannot be undone! + +- For files: simply provide the path +- For empty directories: provide the path +- For non-empty directories: set recursive=true +- The path must be an absolute path, not a relative path +- Always verify the path before deleting to avoid data loss`, + inputSchema: z.toJSONSchema(DeleteToolSchema) +} + +// Handler implementation +export async function handleDeleteTool(args: unknown, baseDir: string) { + const parsed = DeleteToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for delete: ${parsed.error}`) + } + + const targetPath = parsed.data.path + const validPath = await validatePath(targetPath, baseDir) + const recursive = parsed.data.recursive || false + + // Check if path exists and get stats + let stats + try { + stats = await fs.stat(validPath) + } catch (error: any) { + if (error.code === 'ENOENT') { + throw new Error(`Path not found: ${targetPath}`) + } + throw error + } + + const isDirectory = stats.isDirectory() + const relativePath = path.relative(baseDir, validPath) + + // Perform deletion + try { + if (isDirectory) { + if (recursive) { + // Delete directory recursively + await fs.rm(validPath, { recursive: true, force: true }) + } else { + // Try to delete empty directory + await fs.rmdir(validPath) + } + } else { + // Delete file + await fs.unlink(validPath) + } + } catch (error: any) { + if (error.code === 'ENOTEMPTY') { + throw new Error(`Directory not empty: ${targetPath}. Use recursive=true to delete non-empty directories.`) + } + throw new Error(`Failed to delete: ${error.message}`) + } + + // Log the operation + logger.info('Path deleted', { + path: validPath, + type: isDirectory ? 'directory' : 'file', + recursive: isDirectory ? recursive : undefined + }) + + // Format output + const itemType = isDirectory ? 'Directory' : 'File' + const recursiveNote = isDirectory && recursive ? ' (recursive)' : '' + + return { + content: [ + { + type: 'text', + text: `${itemType} deleted${recursiveNote}: ${relativePath}` + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/edit.ts b/src/main/mcpServers/filesystem/tools/edit.ts new file mode 100644 index 0000000000..c1a0e637ce --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/edit.ts @@ -0,0 +1,130 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import { logger, replaceWithFuzzyMatch, validatePath } from '../types' + +// Schema definition +export const EditToolSchema = z.object({ + file_path: z.string().describe('The path to the file to modify'), + old_string: z.string().describe('The text to replace'), + new_string: z.string().describe('The text to replace it with'), + replace_all: z.boolean().optional().default(false).describe('Replace all occurrences of old_string (default false)') +}) + +// Tool definition with detailed description +export const editToolDefinition = { + name: 'edit', + description: `Performs exact string replacements in files. + +- You must use the 'read' tool at least once before editing +- The file_path must be an absolute path, not a relative path +- Preserve exact indentation from read output (after the line number prefix) +- Never include line number prefixes in old_string or new_string +- ALWAYS prefer editing existing files over creating new ones +- The edit will FAIL if old_string is not found in the file +- The edit will FAIL if old_string appears multiple times (provide more context or use replace_all) +- The edit will FAIL if old_string equals new_string +- Use replace_all to rename variables or replace all occurrences`, + inputSchema: z.toJSONSchema(EditToolSchema) +} + +// Handler implementation +export async function handleEditTool(args: unknown, baseDir: string) { + const parsed = EditToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for edit: ${parsed.error}`) + } + + const { file_path: filePath, old_string: oldString, new_string: newString, replace_all: replaceAll } = parsed.data + + // Validate path + const validPath = await validatePath(filePath, baseDir) + + // Check if file exists + try { + const stats = await fs.stat(validPath) + if (!stats.isFile()) { + throw new Error(`Path is not a file: ${filePath}`) + } + } catch (error: any) { + if (error.code === 'ENOENT') { + // If old_string is empty, this is a create new file operation + if (oldString === '') { + // Create parent directory if needed + const parentDir = path.dirname(validPath) + await fs.mkdir(parentDir, { recursive: true }) + + // Write the new content + await fs.writeFile(validPath, newString, 'utf-8') + + logger.info('File created', { path: validPath }) + + const relativePath = path.relative(baseDir, validPath) + return { + content: [ + { + type: 'text', + text: `Created new file: ${relativePath}\nLines: ${newString.split('\n').length}` + } + ] + } + } + throw new Error(`File not found: ${filePath}`) + } + throw error + } + + // Read current content + const content = await fs.readFile(validPath, 'utf-8') + + // Handle special case: old_string is empty (create file with content) + if (oldString === '') { + await fs.writeFile(validPath, newString, 'utf-8') + + logger.info('File overwritten', { path: validPath }) + + const relativePath = path.relative(baseDir, validPath) + return { + content: [ + { + type: 'text', + text: `Overwrote file: ${relativePath}\nLines: ${newString.split('\n').length}` + } + ] + } + } + + // Perform the replacement with fuzzy matching + const newContent = replaceWithFuzzyMatch(content, oldString, newString, replaceAll) + + // Write the modified content + await fs.writeFile(validPath, newContent, 'utf-8') + + logger.info('File edited', { + path: validPath, + replaceAll + }) + + // Generate a simple diff summary + const oldLines = content.split('\n').length + const newLines = newContent.split('\n').length + const lineDiff = newLines - oldLines + + const relativePath = path.relative(baseDir, validPath) + let diffSummary = `Edited: ${relativePath}` + if (lineDiff > 0) { + diffSummary += `\n+${lineDiff} lines` + } else if (lineDiff < 0) { + diffSummary += `\n${lineDiff} lines` + } + + return { + content: [ + { + type: 'text', + text: diffSummary + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/glob.ts b/src/main/mcpServers/filesystem/tools/glob.ts new file mode 100644 index 0000000000..d6a6b4a757 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/glob.ts @@ -0,0 +1,149 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import type { FileInfo } from '../types' +import { logger, MAX_FILES_LIMIT, runRipgrep, validatePath } from '../types' + +// Schema definition +export const GlobToolSchema = z.object({ + pattern: z.string().describe('The glob pattern to match files against'), + path: z + .string() + .optional() + .describe('The directory to search in (must be absolute path). Defaults to the base directory') +}) + +// Tool definition with detailed description +export const globToolDefinition = { + name: 'glob', + description: `Fast file pattern matching tool that works with any codebase size. + +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching absolute file paths sorted by modification time (newest first) +- Use this when you need to find files by name patterns +- Patterns without "/" (e.g., "*.txt") match files at ANY depth in the directory tree +- Patterns with "/" (e.g., "src/*.ts") match relative to the search path +- Pattern syntax: * (any chars), ** (any path), {a,b} (alternatives), ? (single char) +- Results are limited to 100 files +- The path parameter must be an absolute path if specified +- If path is not specified, defaults to the base directory +- IMPORTANT: Omit the path field for the default directory (don't use "undefined" or "null")`, + inputSchema: z.toJSONSchema(GlobToolSchema) +} + +// Handler implementation +export async function handleGlobTool(args: unknown, baseDir: string) { + const parsed = GlobToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for glob: ${parsed.error}`) + } + + const searchPath = parsed.data.path || baseDir + const validPath = await validatePath(searchPath, baseDir) + + // Verify the search directory exists + try { + const stats = await fs.stat(validPath) + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${validPath}`) + } + } catch (error: unknown) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + throw new Error(`Directory not found: ${validPath}`) + } + throw error + } + + // Validate pattern + const pattern = parsed.data.pattern.trim() + if (!pattern) { + throw new Error('Pattern cannot be empty') + } + + const files: FileInfo[] = [] + let truncated = false + + // Build ripgrep arguments for file listing using --glob=pattern format + const rgArgs: string[] = [ + '--files', + '--follow', + '--hidden', + `--glob=${pattern}`, + '--glob=!.git/*', + '--glob=!node_modules/*', + '--glob=!dist/*', + '--glob=!build/*', + '--glob=!__pycache__/*', + validPath + ] + + // Use ripgrep for file listing + logger.debug('Running ripgrep with args', { rgArgs }) + const rgResult = await runRipgrep(rgArgs) + logger.debug('Ripgrep result', { + ok: rgResult.ok, + exitCode: rgResult.exitCode, + stdoutLength: rgResult.stdout.length, + stdoutPreview: rgResult.stdout.slice(0, 500) + }) + + // Process results if we have stdout content + // Exit code 2 can indicate partial errors (e.g., permission denied on some dirs) but still have valid results + if (rgResult.ok && rgResult.stdout.length > 0) { + const lines = rgResult.stdout.split('\n').filter(Boolean) + logger.debug('Parsed lines from ripgrep', { lineCount: lines.length, lines }) + + for (const line of lines) { + if (files.length >= MAX_FILES_LIMIT) { + truncated = true + break + } + + const filePath = line.trim() + if (!filePath) continue + + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(validPath, filePath) + + try { + const stats = await fs.stat(absolutePath) + files.push({ + path: absolutePath, + type: 'file', // ripgrep --files only returns files + size: stats.size, + modified: stats.mtime + }) + } catch (error) { + logger.debug('Failed to stat file from ripgrep output, skipping', { file: absolutePath, error }) + } + } + } + + // Sort by modification time (newest first) + files.sort((a, b) => { + const aTime = a.modified ? a.modified.getTime() : 0 + const bTime = b.modified ? b.modified.getTime() : 0 + return bTime - aTime + }) + + // Format output - always use absolute paths + const output: string[] = [] + if (files.length === 0) { + output.push(`No files found matching pattern "${parsed.data.pattern}" in ${validPath}`) + } else { + output.push(...files.map((f) => f.path)) + if (truncated) { + output.push('') + output.push(`(Results truncated to ${MAX_FILES_LIMIT} files. Consider using a more specific pattern.)`) + } + } + + return { + content: [ + { + type: 'text', + text: output.join('\n') + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/grep.ts b/src/main/mcpServers/filesystem/tools/grep.ts new file mode 100644 index 0000000000..d822db9d88 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/grep.ts @@ -0,0 +1,266 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import type { GrepMatch } from '../types' +import { isBinaryFile, MAX_GREP_MATCHES, MAX_LINE_LENGTH, runRipgrep, validatePath } from '../types' + +// Schema definition +export const GrepToolSchema = z.object({ + pattern: z.string().describe('The regex pattern to search for in file contents'), + path: z + .string() + .optional() + .describe('The directory to search in (must be absolute path). Defaults to the base directory'), + include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")') +}) + +// Tool definition with detailed description +export const grepToolDefinition = { + name: 'grep', + description: `Fast content search tool that works with any codebase size. + +- Searches file contents using regular expressions +- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+") +- Filter files by pattern with include (e.g., "*.js", "*.{ts,tsx}") +- Returns absolute file paths and line numbers with matching content +- Results are limited to 100 matches +- Binary files are automatically skipped +- Common directories (node_modules, .git, dist) are excluded +- The path parameter must be an absolute path if specified +- If path is not specified, defaults to the base directory`, + inputSchema: z.toJSONSchema(GrepToolSchema) +} + +// Handler implementation +export async function handleGrepTool(args: unknown, baseDir: string) { + const parsed = GrepToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for grep: ${parsed.error}`) + } + + const data = parsed.data + + if (!data.pattern) { + throw new Error('Pattern is required for grep') + } + + const searchPath = data.path || baseDir + const validPath = await validatePath(searchPath, baseDir) + + const matches: GrepMatch[] = [] + let truncated = false + let regex: RegExp + + // Build ripgrep arguments + const rgArgs: string[] = [ + '--no-heading', + '--line-number', + '--color', + 'never', + '--ignore-case', + '--glob', + '!.git/**', + '--glob', + '!node_modules/**', + '--glob', + '!dist/**', + '--glob', + '!build/**', + '--glob', + '!__pycache__/**' + ] + + if (data.include) { + for (const pat of data.include + .split(',') + .map((p) => p.trim()) + .filter(Boolean)) { + rgArgs.push('--glob', pat) + } + } + + rgArgs.push(data.pattern) + rgArgs.push(validPath) + + try { + regex = new RegExp(data.pattern, 'gi') + } catch (error) { + throw new Error(`Invalid regex pattern: ${data.pattern}`) + } + + async function searchFile(filePath: string): Promise { + if (matches.length >= MAX_GREP_MATCHES) { + truncated = true + return + } + + try { + // Skip binary files + if (await isBinaryFile(filePath)) { + return + } + + const content = await fs.readFile(filePath, 'utf-8') + const lines = content.split('\n') + + lines.forEach((line, index) => { + if (matches.length >= MAX_GREP_MATCHES) { + truncated = true + return + } + + if (regex.test(line)) { + // Truncate long lines + const truncatedLine = line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + '...' : line + + matches.push({ + file: filePath, + line: index + 1, + content: truncatedLine.trim() + }) + } + }) + } catch (error) { + // Skip files we can't read + } + } + + async function searchDirectory(dir: string): Promise { + if (matches.length >= MAX_GREP_MATCHES) { + truncated = true + return + } + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + if (matches.length >= MAX_GREP_MATCHES) { + truncated = true + break + } + + const fullPath = path.join(dir, entry.name) + + // Skip common ignore patterns + if (entry.name.startsWith('.') && entry.name !== '.env.example') { + continue + } + if (['node_modules', 'dist', 'build', '__pycache__', '.git'].includes(entry.name)) { + continue + } + + if (entry.isFile()) { + // Check if file matches include pattern + if (data.include) { + const includePatterns = data.include.split(',').map((p) => p.trim()) + const fileName = path.basename(fullPath) + const matchesInclude = includePatterns.some((pattern) => { + // Simple glob pattern matching + const regexPattern = pattern + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + .replace(/\{([^}]+)\}/g, (_, group) => `(${group.split(',').join('|')})`) + return new RegExp(`^${regexPattern}$`).test(fileName) + }) + if (!matchesInclude) { + continue + } + } + + await searchFile(fullPath) + } else if (entry.isDirectory()) { + await searchDirectory(fullPath) + } + } + } catch (error) { + // Skip directories we can't read + } + } + + // Perform the search + let usedRipgrep = false + try { + const rgResult = await runRipgrep(rgArgs) + if (rgResult.ok && rgResult.exitCode !== null && rgResult.exitCode !== 2) { + usedRipgrep = true + const lines = rgResult.stdout.split('\n').filter(Boolean) + for (const line of lines) { + if (matches.length >= MAX_GREP_MATCHES) { + truncated = true + break + } + + const firstColon = line.indexOf(':') + const secondColon = line.indexOf(':', firstColon + 1) + if (firstColon === -1 || secondColon === -1) continue + + const filePart = line.slice(0, firstColon) + const linePart = line.slice(firstColon + 1, secondColon) + const contentPart = line.slice(secondColon + 1) + const lineNum = Number.parseInt(linePart, 10) + if (!Number.isFinite(lineNum)) continue + + const absoluteFilePath = path.isAbsolute(filePart) ? filePart : path.resolve(baseDir, filePart) + const truncatedLine = + contentPart.length > MAX_LINE_LENGTH ? contentPart.substring(0, MAX_LINE_LENGTH) + '...' : contentPart + + matches.push({ + file: absoluteFilePath, + line: lineNum, + content: truncatedLine.trim() + }) + } + } + } catch { + usedRipgrep = false + } + + if (!usedRipgrep) { + const stats = await fs.stat(validPath) + if (stats.isFile()) { + await searchFile(validPath) + } else { + await searchDirectory(validPath) + } + } + + // Format output + const output: string[] = [] + + if (matches.length === 0) { + output.push('No matches found') + } else { + // Group matches by file + const fileGroups = new Map() + matches.forEach((match) => { + if (!fileGroups.has(match.file)) { + fileGroups.set(match.file, []) + } + fileGroups.get(match.file)!.push(match) + }) + + // Format grouped matches - always use absolute paths + fileGroups.forEach((fileMatches, filePath) => { + output.push(`\n${filePath}:`) + fileMatches.forEach((match) => { + output.push(` ${match.line}: ${match.content}`) + }) + }) + + if (truncated) { + output.push('') + output.push(`(Results truncated to ${MAX_GREP_MATCHES} matches. Consider using a more specific pattern or path.)`) + } + } + + return { + content: [ + { + type: 'text', + text: output.join('\n') + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/index.ts b/src/main/mcpServers/filesystem/tools/index.ts new file mode 100644 index 0000000000..2e02d613c4 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/index.ts @@ -0,0 +1,8 @@ +// Export all tool definitions and handlers +export { deleteToolDefinition, handleDeleteTool } from './delete' +export { editToolDefinition, handleEditTool } from './edit' +export { globToolDefinition, handleGlobTool } from './glob' +export { grepToolDefinition, handleGrepTool } from './grep' +export { handleLsTool, lsToolDefinition } from './ls' +export { handleReadTool, readToolDefinition } from './read' +export { handleWriteTool, writeToolDefinition } from './write' diff --git a/src/main/mcpServers/filesystem/tools/ls.ts b/src/main/mcpServers/filesystem/tools/ls.ts new file mode 100644 index 0000000000..22672c9fb9 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/ls.ts @@ -0,0 +1,150 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import { MAX_FILES_LIMIT, validatePath } from '../types' + +// Schema definition +export const LsToolSchema = z.object({ + path: z.string().optional().describe('The directory to list (must be absolute path). Defaults to the base directory'), + recursive: z.boolean().optional().describe('Whether to list directories recursively (default: false)') +}) + +// Tool definition with detailed description +export const lsToolDefinition = { + name: 'ls', + description: `Lists files and directories in a specified path. + +- Returns a tree-like structure with icons (📁 directories, 📄 files) +- Shows the absolute directory path in the header +- Entries are sorted alphabetically with directories first +- Can list recursively with recursive=true (up to 5 levels deep) +- Common directories (node_modules, dist, .git) are excluded +- Hidden files (starting with .) are excluded except .env.example +- Results are limited to 100 entries +- The path parameter must be an absolute path if specified +- If path is not specified, defaults to the base directory`, + inputSchema: z.toJSONSchema(LsToolSchema) +} + +// Handler implementation +export async function handleLsTool(args: unknown, baseDir: string) { + const parsed = LsToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for ls: ${parsed.error}`) + } + + const targetPath = parsed.data.path || baseDir + const validPath = await validatePath(targetPath, baseDir) + const recursive = parsed.data.recursive || false + + interface TreeNode { + name: string + type: 'file' | 'directory' + children?: TreeNode[] + } + + let fileCount = 0 + let truncated = false + + async function buildTree(dirPath: string, depth: number = 0): Promise { + if (fileCount >= MAX_FILES_LIMIT) { + truncated = true + return [] + } + + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const nodes: TreeNode[] = [] + + // Sort entries: directories first, then files, alphabetically + entries.sort((a, b) => { + if (a.isDirectory() && !b.isDirectory()) return -1 + if (!a.isDirectory() && b.isDirectory()) return 1 + return a.name.localeCompare(b.name) + }) + + for (const entry of entries) { + if (fileCount >= MAX_FILES_LIMIT) { + truncated = true + break + } + + // Skip hidden files and common ignore patterns + if (entry.name.startsWith('.') && entry.name !== '.env.example') { + continue + } + if (['node_modules', 'dist', 'build', '__pycache__'].includes(entry.name)) { + continue + } + + fileCount++ + const node: TreeNode = { + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file' + } + + if (entry.isDirectory() && recursive && depth < 5) { + // Limit depth to prevent infinite recursion + const childPath = path.join(dirPath, entry.name) + node.children = await buildTree(childPath, depth + 1) + } + + nodes.push(node) + } + + return nodes + } catch (error) { + return [] + } + } + + // Build the tree + const tree = await buildTree(validPath) + + // Format as text output + function formatTree(nodes: TreeNode[], prefix: string = ''): string[] { + const lines: string[] = [] + + nodes.forEach((node, index) => { + const isLastNode = index === nodes.length - 1 + const connector = isLastNode ? '└── ' : '├── ' + const icon = node.type === 'directory' ? '📁 ' : '📄 ' + + lines.push(prefix + connector + icon + node.name) + + if (node.children && node.children.length > 0) { + const childPrefix = prefix + (isLastNode ? ' ' : '│ ') + lines.push(...formatTree(node.children, childPrefix)) + } + }) + + return lines + } + + // Generate output + const output: string[] = [] + output.push(`Directory: ${validPath}`) + output.push('') + + if (tree.length === 0) { + output.push('(empty directory)') + } else { + const treeLines = formatTree(tree, '') + output.push(...treeLines) + + if (truncated) { + output.push('') + output.push(`(Results truncated to ${MAX_FILES_LIMIT} files. Consider listing a more specific directory.)`) + } + } + + return { + content: [ + { + type: 'text', + text: output.join('\n') + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/read.ts b/src/main/mcpServers/filesystem/tools/read.ts new file mode 100644 index 0000000000..460c88dda4 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/read.ts @@ -0,0 +1,101 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import { DEFAULT_READ_LIMIT, isBinaryFile, MAX_LINE_LENGTH, validatePath } from '../types' + +// Schema definition +export const ReadToolSchema = z.object({ + file_path: z.string().describe('The path to the file to read'), + offset: z.number().optional().describe('The line number to start reading from (1-based)'), + limit: z.number().optional().describe('The number of lines to read (defaults to 2000)') +}) + +// Tool definition with detailed description +export const readToolDefinition = { + name: 'read', + description: `Reads a file from the local filesystem. + +- Assumes this tool can read all files on the machine +- The file_path parameter must be an absolute path, not a relative path +- By default, reads up to 2000 lines starting from the beginning +- You can optionally specify a line offset and limit for long files +- Any lines longer than 2000 characters will be truncated +- Results are returned with line numbers starting at 1 +- Binary files are detected and rejected with an error +- Empty files return a warning`, + inputSchema: z.toJSONSchema(ReadToolSchema) +} + +// Handler implementation +export async function handleReadTool(args: unknown, baseDir: string) { + const parsed = ReadToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for read: ${parsed.error}`) + } + + const filePath = parsed.data.file_path + const validPath = await validatePath(filePath, baseDir) + + // Check if file exists + try { + const stats = await fs.stat(validPath) + if (!stats.isFile()) { + throw new Error(`Path is not a file: ${filePath}`) + } + } catch (error: any) { + if (error.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`) + } + throw error + } + + // Check if file is binary + if (await isBinaryFile(validPath)) { + throw new Error(`Cannot read binary file: ${filePath}`) + } + + // Read file content + const content = await fs.readFile(validPath, 'utf-8') + const lines = content.split('\n') + + // Apply offset and limit + const offset = (parsed.data.offset || 1) - 1 // Convert to 0-based + const limit = parsed.data.limit || DEFAULT_READ_LIMIT + + if (offset < 0 || offset >= lines.length) { + throw new Error(`Invalid offset: ${offset + 1}. File has ${lines.length} lines.`) + } + + const selectedLines = lines.slice(offset, offset + limit) + + // Format output with line numbers and truncate long lines + const output: string[] = [] + const relativePath = path.relative(baseDir, validPath) + + output.push(`File: ${relativePath}`) + if (offset > 0 || limit < lines.length) { + output.push(`Lines ${offset + 1} to ${Math.min(offset + limit, lines.length)} of ${lines.length}`) + } + output.push('') + + selectedLines.forEach((line, index) => { + const lineNumber = offset + index + 1 + const truncatedLine = line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + '...' : line + output.push(`${lineNumber.toString().padStart(6)}\t${truncatedLine}`) + }) + + if (offset + limit < lines.length) { + output.push('') + output.push(`(${lines.length - (offset + limit)} more lines not shown)`) + } + + return { + content: [ + { + type: 'text', + text: output.join('\n') + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/tools/write.ts b/src/main/mcpServers/filesystem/tools/write.ts new file mode 100644 index 0000000000..2898f2f874 --- /dev/null +++ b/src/main/mcpServers/filesystem/tools/write.ts @@ -0,0 +1,83 @@ +import fs from 'fs/promises' +import path from 'path' +import * as z from 'zod' + +import { logger, validatePath } from '../types' + +// Schema definition +export const WriteToolSchema = z.object({ + file_path: z.string().describe('The path to the file to write'), + content: z.string().describe('The content to write to the file') +}) + +// Tool definition with detailed description +export const writeToolDefinition = { + name: 'write', + description: `Writes a file to the local filesystem. + +- This tool will overwrite the existing file if one exists at the path +- You MUST use the read tool first to understand what you're overwriting +- ALWAYS prefer using the 'edit' tool for existing files +- NEVER proactively create documentation files unless explicitly requested +- Parent directories will be created automatically if they don't exist +- The file_path must be an absolute path, not a relative path`, + inputSchema: z.toJSONSchema(WriteToolSchema) +} + +// Handler implementation +export async function handleWriteTool(args: unknown, baseDir: string) { + const parsed = WriteToolSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments for write: ${parsed.error}`) + } + + const filePath = parsed.data.file_path + const validPath = await validatePath(filePath, baseDir) + + // Create parent directory if it doesn't exist + const parentDir = path.dirname(validPath) + try { + await fs.mkdir(parentDir, { recursive: true }) + } catch (error: any) { + if (error.code !== 'EEXIST') { + throw new Error(`Failed to create parent directory: ${error.message}`) + } + } + + // Check if file exists (for logging) + let isOverwrite = false + try { + await fs.stat(validPath) + isOverwrite = true + } catch { + // File doesn't exist, that's fine + } + + // Write the file + try { + await fs.writeFile(validPath, parsed.data.content, 'utf-8') + } catch (error: any) { + throw new Error(`Failed to write file: ${error.message}`) + } + + // Log the operation + logger.info('File written', { + path: validPath, + overwrite: isOverwrite, + size: parsed.data.content.length + }) + + // Format output + const relativePath = path.relative(baseDir, validPath) + const action = isOverwrite ? 'Updated' : 'Created' + const lines = parsed.data.content.split('\n').length + + return { + content: [ + { + type: 'text', + text: `${action} file: ${relativePath}\n` + `Size: ${parsed.data.content.length} bytes\n` + `Lines: ${lines}` + } + ] + } +} diff --git a/src/main/mcpServers/filesystem/types.ts b/src/main/mcpServers/filesystem/types.ts new file mode 100644 index 0000000000..922fe0b23a --- /dev/null +++ b/src/main/mcpServers/filesystem/types.ts @@ -0,0 +1,627 @@ +import { loggerService } from '@logger' +import { isMac, isWin } from '@main/constant' +import { spawn } from 'child_process' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' + +export const logger = loggerService.withContext('MCP:FileSystemServer') + +// Constants +export const MAX_LINE_LENGTH = 2000 +export const DEFAULT_READ_LIMIT = 2000 +export const MAX_FILES_LIMIT = 100 +export const MAX_GREP_MATCHES = 100 + +// Common types +export interface FileInfo { + path: string + type: 'file' | 'directory' + size?: number + modified?: Date +} + +export interface GrepMatch { + file: string + line: number + content: string +} + +// Utility functions for path handling +export function normalizePath(p: string): string { + return path.normalize(p) +} + +export function expandHome(filepath: string): string { + if (filepath.startsWith('~/') || filepath === '~') { + return path.join(os.homedir(), filepath.slice(1)) + } + return filepath +} + +// Security validation +export async function validatePath(requestedPath: string, baseDir?: string): Promise { + const expandedPath = expandHome(requestedPath) + const root = baseDir ?? process.cwd() + const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(root, expandedPath) + + // Handle symlinks by checking their real path + try { + const realPath = await fs.realpath(absolute) + return normalizePath(realPath) + } catch (error) { + // For new files that don't exist yet, verify parent directory + const parentDir = path.dirname(absolute) + try { + const realParentPath = await fs.realpath(parentDir) + normalizePath(realParentPath) + return normalizePath(absolute) + } catch { + return normalizePath(absolute) + } + } +} + +// ============================================================================ +// Edit Tool Utilities - Fuzzy matching replacers from opencode +// ============================================================================ + +export type Replacer = (content: string, find: string) => Generator + +// Similarity thresholds for block anchor fallback matching +const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0 +const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3 + +/** + * Levenshtein distance algorithm implementation + */ +function levenshtein(a: string, b: string): number { + if (a === '' || b === '') { + return Math.max(a.length, b.length) + } + const matrix = Array.from({ length: a.length + 1 }, (_, i) => + Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)) + ) + + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost) + } + } + return matrix[a.length][b.length] +} + +export const SimpleReplacer: Replacer = function* (_content, find) { + yield find +} + +export const LineTrimmedReplacer: Replacer = function* (content, find) { + const originalLines = content.split('\n') + const searchLines = find.split('\n') + + if (searchLines[searchLines.length - 1] === '') { + searchLines.pop() + } + + for (let i = 0; i <= originalLines.length - searchLines.length; i++) { + let matches = true + + for (let j = 0; j < searchLines.length; j++) { + const originalTrimmed = originalLines[i + j].trim() + const searchTrimmed = searchLines[j].trim() + + if (originalTrimmed !== searchTrimmed) { + matches = false + break + } + } + + if (matches) { + let matchStartIndex = 0 + for (let k = 0; k < i; k++) { + matchStartIndex += originalLines[k].length + 1 + } + + let matchEndIndex = matchStartIndex + for (let k = 0; k < searchLines.length; k++) { + matchEndIndex += originalLines[i + k].length + if (k < searchLines.length - 1) { + matchEndIndex += 1 + } + } + + yield content.substring(matchStartIndex, matchEndIndex) + } + } +} + +export const BlockAnchorReplacer: Replacer = function* (content, find) { + const originalLines = content.split('\n') + const searchLines = find.split('\n') + + if (searchLines.length < 3) { + return + } + + if (searchLines[searchLines.length - 1] === '') { + searchLines.pop() + } + + const firstLineSearch = searchLines[0].trim() + const lastLineSearch = searchLines[searchLines.length - 1].trim() + const searchBlockSize = searchLines.length + + const candidates: Array<{ startLine: number; endLine: number }> = [] + for (let i = 0; i < originalLines.length; i++) { + if (originalLines[i].trim() !== firstLineSearch) { + continue + } + + for (let j = i + 2; j < originalLines.length; j++) { + if (originalLines[j].trim() === lastLineSearch) { + candidates.push({ startLine: i, endLine: j }) + break + } + } + } + + if (candidates.length === 0) { + return + } + + if (candidates.length === 1) { + const { startLine, endLine } = candidates[0] + const actualBlockSize = endLine - startLine + 1 + + let similarity = 0 + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim() + const searchLine = searchLines[j].trim() + const maxLen = Math.max(originalLine.length, searchLine.length) + if (maxLen === 0) { + continue + } + const distance = levenshtein(originalLine, searchLine) + similarity += (1 - distance / maxLen) / linesToCheck + + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + break + } + } + } else { + similarity = 1.0 + } + + if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) { + let matchStartIndex = 0 + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1 + } + let matchEndIndex = matchStartIndex + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length + if (k < endLine) { + matchEndIndex += 1 + } + } + yield content.substring(matchStartIndex, matchEndIndex) + } + return + } + + let bestMatch: { startLine: number; endLine: number } | null = null + let maxSimilarity = -1 + + for (const candidate of candidates) { + const { startLine, endLine } = candidate + const actualBlockSize = endLine - startLine + 1 + + let similarity = 0 + const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) + + if (linesToCheck > 0) { + for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) { + const originalLine = originalLines[startLine + j].trim() + const searchLine = searchLines[j].trim() + const maxLen = Math.max(originalLine.length, searchLine.length) + if (maxLen === 0) { + continue + } + const distance = levenshtein(originalLine, searchLine) + similarity += 1 - distance / maxLen + } + similarity /= linesToCheck + } else { + similarity = 1.0 + } + + if (similarity > maxSimilarity) { + maxSimilarity = similarity + bestMatch = candidate + } + } + + if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) { + const { startLine, endLine } = bestMatch + let matchStartIndex = 0 + for (let k = 0; k < startLine; k++) { + matchStartIndex += originalLines[k].length + 1 + } + let matchEndIndex = matchStartIndex + for (let k = startLine; k <= endLine; k++) { + matchEndIndex += originalLines[k].length + if (k < endLine) { + matchEndIndex += 1 + } + } + yield content.substring(matchStartIndex, matchEndIndex) + } +} + +export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) { + const normalizeWhitespace = (text: string) => text.replace(/\s+/g, ' ').trim() + const normalizedFind = normalizeWhitespace(find) + + const lines = content.split('\n') + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (normalizeWhitespace(line) === normalizedFind) { + yield line + } else { + const normalizedLine = normalizeWhitespace(line) + if (normalizedLine.includes(normalizedFind)) { + const words = find.trim().split(/\s+/) + if (words.length > 0) { + const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+') + try { + const regex = new RegExp(pattern) + const match = line.match(regex) + if (match) { + yield match[0] + } + } catch { + // Invalid regex pattern, skip + } + } + } + } + } + + const findLines = find.split('\n') + if (findLines.length > 1) { + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length) + if (normalizeWhitespace(block.join('\n')) === normalizedFind) { + yield block.join('\n') + } + } + } +} + +export const IndentationFlexibleReplacer: Replacer = function* (content, find) { + const removeIndentation = (text: string) => { + const lines = text.split('\n') + const nonEmptyLines = lines.filter((line) => line.trim().length > 0) + if (nonEmptyLines.length === 0) return text + + const minIndent = Math.min( + ...nonEmptyLines.map((line) => { + const match = line.match(/^(\s*)/) + return match ? match[1].length : 0 + }) + ) + + return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join('\n') + } + + const normalizedFind = removeIndentation(find) + const contentLines = content.split('\n') + const findLines = find.split('\n') + + for (let i = 0; i <= contentLines.length - findLines.length; i++) { + const block = contentLines.slice(i, i + findLines.length).join('\n') + if (removeIndentation(block) === normalizedFind) { + yield block + } + } +} + +export const EscapeNormalizedReplacer: Replacer = function* (content, find) { + const unescapeString = (str: string): string => { + return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => { + switch (capturedChar) { + case 'n': + return '\n' + case 't': + return '\t' + case 'r': + return '\r' + case "'": + return "'" + case '"': + return '"' + case '`': + return '`' + case '\\': + return '\\' + case '\n': + return '\n' + case '$': + return '$' + default: + return match + } + }) + } + + const unescapedFind = unescapeString(find) + + if (content.includes(unescapedFind)) { + yield unescapedFind + } + + const lines = content.split('\n') + const findLines = unescapedFind.split('\n') + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join('\n') + const unescapedBlock = unescapeString(block) + + if (unescapedBlock === unescapedFind) { + yield block + } + } +} + +export const TrimmedBoundaryReplacer: Replacer = function* (content, find) { + const trimmedFind = find.trim() + + if (trimmedFind === find) { + return + } + + if (content.includes(trimmedFind)) { + yield trimmedFind + } + + const lines = content.split('\n') + const findLines = find.split('\n') + + for (let i = 0; i <= lines.length - findLines.length; i++) { + const block = lines.slice(i, i + findLines.length).join('\n') + + if (block.trim() === trimmedFind) { + yield block + } + } +} + +export const ContextAwareReplacer: Replacer = function* (content, find) { + const findLines = find.split('\n') + if (findLines.length < 3) { + return + } + + if (findLines[findLines.length - 1] === '') { + findLines.pop() + } + + const contentLines = content.split('\n') + + const firstLine = findLines[0].trim() + const lastLine = findLines[findLines.length - 1].trim() + + for (let i = 0; i < contentLines.length; i++) { + if (contentLines[i].trim() !== firstLine) continue + + for (let j = i + 2; j < contentLines.length; j++) { + if (contentLines[j].trim() === lastLine) { + const blockLines = contentLines.slice(i, j + 1) + const block = blockLines.join('\n') + + if (blockLines.length === findLines.length) { + let matchingLines = 0 + let totalNonEmptyLines = 0 + + for (let k = 1; k < blockLines.length - 1; k++) { + const blockLine = blockLines[k].trim() + const findLine = findLines[k].trim() + + if (blockLine.length > 0 || findLine.length > 0) { + totalNonEmptyLines++ + if (blockLine === findLine) { + matchingLines++ + } + } + } + + if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) { + yield block + break + } + } + break + } + } + } +} + +export const MultiOccurrenceReplacer: Replacer = function* (content, find) { + let startIndex = 0 + + while (true) { + const index = content.indexOf(find, startIndex) + if (index === -1) break + + yield find + startIndex = index + find.length + } +} + +/** + * All replacers in order of specificity + */ +export const ALL_REPLACERS: Replacer[] = [ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer +] + +/** + * Replace oldString with newString in content using fuzzy matching + */ +export function replaceWithFuzzyMatch( + content: string, + oldString: string, + newString: string, + replaceAll = false +): string { + if (oldString === newString) { + throw new Error('old_string and new_string must be different') + } + + let notFound = true + + for (const replacer of ALL_REPLACERS) { + for (const search of replacer(content, oldString)) { + const index = content.indexOf(search) + if (index === -1) continue + notFound = false + if (replaceAll) { + return content.replaceAll(search, newString) + } + const lastIndex = content.lastIndexOf(search) + if (index !== lastIndex) continue + return content.substring(0, index) + newString + content.substring(index + search.length) + } + } + + if (notFound) { + throw new Error('old_string not found in content') + } + throw new Error( + 'Found multiple matches for old_string. Provide more surrounding lines in old_string to identify the correct match.' + ) +} + +// ============================================================================ +// Binary File Detection +// ============================================================================ + +// Check if a file is likely binary +export async function isBinaryFile(filePath: string): Promise { + try { + const buffer = Buffer.alloc(4096) + const fd = await fs.open(filePath, 'r') + const { bytesRead } = await fd.read(buffer, 0, buffer.length, 0) + await fd.close() + + if (bytesRead === 0) return false + + const view = buffer.subarray(0, bytesRead) + + let zeroBytes = 0 + let evenZeros = 0 + let oddZeros = 0 + let nonPrintable = 0 + + for (let i = 0; i < view.length; i++) { + const b = view[i] + + if (b === 0) { + zeroBytes++ + if (i % 2 === 0) evenZeros++ + else oddZeros++ + continue + } + + // treat common whitespace as printable + if (b === 9 || b === 10 || b === 13) continue + + // basic ASCII printable range + if (b >= 32 && b <= 126) continue + + // bytes >= 128 are likely part of UTF-8 sequences; count as printable + if (b >= 128) continue + + nonPrintable++ + } + + // If there are lots of null bytes, it's probably binary unless it looks like UTF-16 text. + if (zeroBytes > 0) { + const evenSlots = Math.ceil(view.length / 2) + const oddSlots = Math.floor(view.length / 2) + const evenZeroRatio = evenSlots > 0 ? evenZeros / evenSlots : 0 + const oddZeroRatio = oddSlots > 0 ? oddZeros / oddSlots : 0 + + // UTF-16LE/BE tends to have zeros on every other byte. + if (evenZeroRatio > 0.7 || oddZeroRatio > 0.7) return false + + if (zeroBytes / view.length > 0.05) return true + } + + // Heuristic: too many non-printable bytes => binary. + return nonPrintable / view.length > 0.3 + } catch { + return false + } +} + +// ============================================================================ +// Ripgrep Utilities +// ============================================================================ + +export interface RipgrepResult { + ok: boolean + stdout: string + exitCode: number | null +} + +export function getRipgrepAddonPath(): string { + const pkgJsonPath = require.resolve('@anthropic-ai/claude-agent-sdk/package.json') + const pkgRoot = path.dirname(pkgJsonPath) + const platform = isMac ? 'darwin' : isWin ? 'win32' : 'linux' + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + return path.join(pkgRoot, 'vendor', 'ripgrep', `${arch}-${platform}`, 'ripgrep.node') +} + +export async function runRipgrep(args: string[]): Promise { + const addonPath = getRipgrepAddonPath() + const childScript = `const { ripgrepMain } = require(process.env.RIPGREP_ADDON_PATH); process.exit(ripgrepMain(process.argv.slice(1)));` + + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--eval', childScript, 'rg', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + RIPGREP_ADDON_PATH: addonPath + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + let stdout = '' + + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString('utf-8') + }) + + child.on('error', () => { + resolve({ ok: false, stdout: '', exitCode: null }) + }) + + child.on('close', (code) => { + resolve({ ok: true, stdout, exitCode: code }) + }) + }) +} diff --git a/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx b/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx index 6ee9f3efca..1fc067b422 100644 --- a/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/BuiltinMCPServerList.tsx @@ -61,9 +61,14 @@ const BuiltinMCPServerList: FC = () => { {getMcpTypeLabel(server.type ?? 'stdio')} {server?.shouldConfig && ( - - {t('settings.mcp.requiresConfig')} - + + + {t('settings.mcp.requiresConfig')} + + )} From 739096decab998e0d27aae6e33b7791c05d2fa4d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Wed, 17 Dec 2025 23:13:51 +0800 Subject: [PATCH 26/41] chore(release): v1.7.5 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 | 60 ++++++++++++++++---------------------------- package.json | 2 +- 2 files changed, 23 insertions(+), 39 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index db1184be87..e3ab493666 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,54 +134,38 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.4 - New Browser MCP & Model Updates + Cherry Studio 1.7.5 - Filesystem MCP Overhaul & Topic Management - This release adds a powerful browser automation MCP server, new web search provider, and model support updates. + This release features a completely rewritten filesystem MCP server, new batch topic management, and improved assistant management. ✨ New Features - - [MCP] Add @cherry/browser CDP MCP server with session management for browser automation - - [Web Search] Add ExaMCP free web search provider (no API key required) - - [Model] Support GPT 5.2 series models - - [Model] Add capabilities support for Doubao Seed Code models (tool calling, reasoning, vision) - - 🔧 Improvements - - [Translate] Add reasoning effort option to translate service - - [i18n] Improve zh-TW Traditional Chinese locale - - [Settings] Update MCP Settings layout and styling + - [MCP] Rewrite filesystem MCP server with improved tool set (glob, ls, grep, read, write, edit, delete) + - [Topics] Add topic manage mode for batch delete and move operations with search functionality + - [Assistants] Merge import/subscribe popups and add export to assistant management + - [Knowledge] Use prompt injection for forced knowledge base search (faster response times) + - [Settings] Add tool use mode setting (prompt/function) to default assistant settings 🐛 Bug Fixes - - [Chat] Fix line numbers being wrongly copied from code blocks - - [Translate] Fix default to first supported reasoning effort when translating - - [Chat] Fix preserve thinking block in assistant messages - - [Web Search] Fix max search result limit - - [Embedding] Fix embedding dimensions retrieval for ModernAiProvider - - [Chat] Fix token calculation in prompt tool use plugin - - [Model] Fix Ollama provider options for Qwen model support - - [UI] Fix Chat component marginRight calculation for improved layout + - [Model] Correct typo in Gemini 3 Pro Image Preview model name + - [Installer] Auto-install VC++ Redistributable without user prompt + - [Notes] Fix notes directory validation and default path reset for cross-platform restore + - [OAuth] Bind OAuth callback server to localhost (127.0.0.1) for security - Cherry Studio 1.7.4 - 新增浏览器 MCP 与模型更新 + Cherry Studio 1.7.5 - 文件系统 MCP 重构与话题管理 - 本次更新新增强大的浏览器自动化 MCP 服务器、新的网页搜索提供商以及模型支持更新。 + 本次更新完全重写了文件系统 MCP 服务器,新增批量话题管理功能,并改进了助手管理。 ✨ 新功能 - - [MCP] 新增 @cherry/browser CDP MCP 服务器,支持会话管理的浏览器自动化 - - [网页搜索] 新增 ExaMCP 免费网页搜索提供商(无需 API 密钥) - - [模型] 支持 GPT 5.2 系列模型 - - [模型] 为豆包 Seed Code 模型添加能力支持(工具调用、推理、视觉) - - 🔧 功能改进 - - [翻译] 为翻译服务添加推理强度选项 - - [国际化] 改进繁体中文(zh-TW)本地化 - - [设置] 优化 MCP 设置布局和样式 + - [MCP] 重写文件系统 MCP 服务器,提供改进的工具集(glob、ls、grep、read、write、edit、delete) + - [话题] 新增话题管理模式,支持批量删除和移动操作,带搜索功能 + - [助手] 合并导入/订阅弹窗,并在助手管理中添加导出功能 + - [知识库] 使用提示词注入进行强制知识库搜索(响应更快) + - [设置] 在默认助手设置中添加工具使用模式设置(prompt/function) 🐛 问题修复 - - [聊天] 修复代码块中行号被错误复制的问题 - - [翻译] 修复翻译时默认使用第一个支持的推理强度 - - [聊天] 修复助手消息中思考块的保留问题 - - [网页搜索] 修复最大搜索结果数限制 - - [嵌入] 修复 ModernAiProvider 嵌入维度获取问题 - - [聊天] 修复提示词工具使用插件的 token 计算问题 - - [模型] 修复 Ollama 提供商对 Qwen 模型的支持选项 - - [界面] 修复聊天组件右边距计算以改善布局 + - [模型] 修正 Gemini 3 Pro Image Preview 模型名称的拼写错误 + - [安装程序] 自动安装 VC++ 运行库,无需用户确认 + - [笔记] 修复跨平台恢复场景下的笔记目录验证和默认路径重置逻辑 + - [OAuth] 将 OAuth 回调服务器绑定到 localhost (127.0.0.1) 以提高安全性 diff --git a/package.json b/package.json index 3fd6d1741f..b894030b58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.4", + "version": "1.7.5", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 150bb3e3a091618036ed8fbb5a2b295271ca8259 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 18 Dec 2025 09:57:23 +0800 Subject: [PATCH 27/41] fix: auto-discover and persist Git Bash path on Windows for scoop (#11921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-discover and persist Git Bash path on Windows - Add autoDiscoverGitBash function to find and cache Git Bash path when needed - Modify System_CheckGitBash IPC handler to auto-discover and persist path - Update Claude Code service with fallback auto-discovery mechanism - Git Bash path is now cached after first discovery, improving UX for Windows users * udpate * fix: remove redundant validation of auto-discovered Git Bash path The autoDiscoverGitBash function already returns a validated path, so calling validateGitBashPath again is unnecessary. Co-Authored-By: Claude * udpate * test: add unit tests for autoDiscoverGitBash function Add comprehensive test coverage for autoDiscoverGitBash including: - Discovery with no existing config path - Validation of existing config paths - Handling of invalid existing paths - Config persistence verification - Real-world scenarios (standard Git, portable Git, user-configured paths) Co-Authored-By: Claude * fix: remove unnecessary async keyword from System_CheckGitBash handler The handler doesn't use await since autoDiscoverGitBash is synchronous. Removes async for consistency with other IPC handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: rename misleading test to match actual behavior Renamed "should not call configManager.set multiple times on single discovery" to "should persist on each discovery when config remains undefined" to accurately describe that each call to autoDiscoverGitBash persists when the config mock returns undefined. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor: use generic type parameter instead of type assertion Replace `as string | undefined` with `get()` for better type safety when retrieving GitBashPath from config. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor: simplify Git Bash path resolution in Claude Code service Remove redundant validateGitBashPath call since autoDiscoverGitBash already handles validation of configured paths before attempting discovery. Also remove unused ConfigKeys and configManager imports. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: attempt auto-discovery when configured Git Bash path is invalid Previously, if a user had an invalid configured path (e.g., Git was moved or uninstalled), autoDiscoverGitBash would return null without attempting to find a valid installation. Now it logs a warning and attempts auto-discovery, providing a better user experience by automatically fixing invalid configurations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: ensure CLAUDE_CODE_GIT_BASH_PATH env var takes precedence over config Previously, if a valid config path existed, the environment variable CLAUDE_CODE_GIT_BASH_PATH was never checked. Now the precedence order is: 1. CLAUDE_CODE_GIT_BASH_PATH env var (highest - runtime override) 2. Configured path from settings 3. Auto-discovery via findGitBash This allows users to temporarily override the configured path without modifying their persistent settings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor: improve code quality and test robustness - Remove duplicate logging in Claude Code service (autoDiscoverGitBash logs internally) - Simplify Git Bash path initialization with ternary expression - Add afterEach cleanup to restore original env vars in tests - Extract mockExistingPaths helper to reduce test code duplication 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat: track Git Bash path source to distinguish manual vs auto-discovered - Add GitBashPathSource type and GitBashPathInfo interface to shared constants - Add GitBashPathSource config key to persist path origin ('manual' | 'auto') - Update autoDiscoverGitBash to mark discovered paths as 'auto' - Update setGitBashPath IPC to mark user-set paths as 'manual' - Add getGitBashPathInfo API to retrieve path with source info - Update AgentModal UI to show different text based on source: - Manual: "Using custom path" with clear button - Auto: "Auto-discovered" without clear button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor: simplify Git Bash config UI as form field - Replace large Alert components with compact form field - Use static isWin constant instead of async platform detection - Show Git Bash field only on Windows with auto-fill support - Disable save button when Git Bash path is missing on Windows - Add "Auto-discovered" hint for auto-detected paths - Remove hasGitBash state, simplify checkGitBash logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * ui: add explicit select button for Git Bash path Replace click-on-input interaction with a dedicated "Select" button for clearer UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor: simplify Git Bash UI by removing clear button - Remove handleClearGitBash function (no longer needed) - Remove clear button from UI (auto-discover fills value, user can re-select) - Remove auto-discovered hint (SourceHint) - Remove unused SourceHint styled component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat: add reset button to restore auto-discovered Git Bash path - Add handleResetGitBash to clear manual setting and re-run auto-discovery - Show "Reset" button only when source is 'manual' - Show "Auto-discovered" hint when path was found automatically - User can re-select if auto-discovered path is not suitable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * fix: re-run auto-discovery when resetting Git Bash path When setGitBashPath(null) is called (reset), now automatically re-runs autoDiscoverGitBash() to restore the auto-discovered path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * feat(i18n): add Git Bash config translations Add translations for: - autoDiscoveredHint: hint text for auto-discovered paths - placeholder: input placeholder for bash.exe selection - tooltip: help tooltip text - error.required: validation error message Supported languages: en-US, zh-CN, zh-TW 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * update i18n * fix: auto-discover Git Bash when getting path info When getGitBashPathInfo() is called and no path is configured, automatically trigger autoDiscoverGitBash() first. This handles the upgrade scenario from old versions that don't have Git Bash path configured. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 1 + packages/shared/config/constant.ts | 8 + src/main/ipc.ts | 25 +- src/main/services/ConfigManager.ts | 3 +- .../agents/services/claudecode/index.ts | 7 +- src/main/utils/__tests__/process.test.ts | 294 +++++++++++++++++- src/main/utils/process.ts | 78 ++++- src/preload/index.ts | 3 +- .../components/Popups/agent/AgentModal.tsx | 177 +++++------ src/renderer/src/i18n/locales/en-us.json | 6 +- src/renderer/src/i18n/locales/zh-cn.json | 6 +- src/renderer/src/i18n/locales/zh-tw.json | 6 +- src/renderer/src/i18n/translate/de-de.json | 6 +- src/renderer/src/i18n/translate/el-gr.json | 6 +- src/renderer/src/i18n/translate/es-es.json | 6 +- src/renderer/src/i18n/translate/fr-fr.json | 6 +- src/renderer/src/i18n/translate/ja-jp.json | 6 +- src/renderer/src/i18n/translate/pt-pt.json | 6 +- src/renderer/src/i18n/translate/ru-ru.json | 6 +- 19 files changed, 539 insertions(+), 117 deletions(-) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 0ebe48266d..aec1d57b43 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -244,6 +244,7 @@ export enum IpcChannel { System_GetCpuName = 'system:getCpuName', System_CheckGitBash = 'system:checkGitBash', System_GetGitBashPath = 'system:getGitBashPath', + System_GetGitBashPathInfo = 'system:getGitBashPathInfo', System_SetGitBashPath = 'system:setGitBashPath', // DevTools diff --git a/packages/shared/config/constant.ts b/packages/shared/config/constant.ts index 1e02ce7706..af0191f4fa 100644 --- a/packages/shared/config/constant.ts +++ b/packages/shared/config/constant.ts @@ -488,3 +488,11 @@ export const MACOS_TERMINALS_WITH_COMMANDS: TerminalConfigWithCommand[] = [ // resources/scripts should be maintained manually export const HOME_CHERRY_DIR = '.cherrystudio' + +// Git Bash path configuration types +export type GitBashPathSource = 'manual' | 'auto' + +export interface GitBashPathInfo { + path: string | null + source: GitBashPathSource | null +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d7e82ff875..4cb3402414 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -6,7 +6,14 @@ import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { generateSignature } from '@main/integration/cherryai' import anthropicService from '@main/services/AnthropicService' -import { findGitBash, getBinaryPath, isBinaryExists, runInstallScript, validateGitBashPath } from '@main/utils/process' +import { + autoDiscoverGitBash, + getBinaryPath, + getGitBashPathInfo, + isBinaryExists, + runInstallScript, + validateGitBashPath +} from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' @@ -499,9 +506,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } try { - const customPath = configManager.get(ConfigKeys.GitBashPath) as string | undefined - const bashPath = findGitBash(customPath) - + // Use autoDiscoverGitBash to handle auto-discovery and persistence + const bashPath = autoDiscoverGitBash() if (bashPath) { logger.info('Git Bash is available', { path: bashPath }) return true @@ -524,13 +530,22 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return customPath ?? null }) + // Returns { path, source } where source is 'manual' | 'auto' | null + ipcMain.handle(IpcChannel.System_GetGitBashPathInfo, () => { + return getGitBashPathInfo() + }) + ipcMain.handle(IpcChannel.System_SetGitBashPath, (_, newPath: string | null) => { if (!isWin) { return false } if (!newPath) { + // Clear manual setting and re-run auto-discovery configManager.set(ConfigKeys.GitBashPath, null) + configManager.set(ConfigKeys.GitBashPathSource, null) + // Re-run auto-discovery to restore auto-discovered path if available + autoDiscoverGitBash() return true } @@ -539,7 +554,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { return false } + // Set path with 'manual' source configManager.set(ConfigKeys.GitBashPath, validated) + configManager.set(ConfigKeys.GitBashPathSource, 'manual') return true }) diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index c693d4b05a..6f2bbd44a4 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -32,7 +32,8 @@ export enum ConfigKeys { Proxy = 'proxy', EnableDeveloperMode = 'enableDeveloperMode', ClientId = 'clientId', - GitBashPath = 'gitBashPath' + GitBashPath = 'gitBashPath', + GitBashPathSource = 'gitBashPathSource' // 'manual' | 'auto' | null } export class ConfigManager { diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index ba863f7c50..45cecb049f 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -15,8 +15,8 @@ import { query } from '@anthropic-ai/claude-agent-sdk' import { loggerService } from '@logger' import { config as apiConfigService } from '@main/apiServer/config' import { validateModelId } from '@main/apiServer/utils' -import { ConfigKeys, configManager } from '@main/services/ConfigManager' -import { validateGitBashPath } from '@main/utils/process' +import { isWin } from '@main/constant' +import { autoDiscoverGitBash } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' import { app } from 'electron' @@ -109,7 +109,8 @@ class ClaudeCodeService implements AgentServiceInterface { Object.entries(loginShellEnv).filter(([key]) => !key.toLowerCase().endsWith('_proxy')) ) as Record - const customGitBashPath = validateGitBashPath(configManager.get(ConfigKeys.GitBashPath) as string | undefined) + // Auto-discover Git Bash path on Windows (already logs internally) + const customGitBashPath = isWin ? autoDiscoverGitBash() : null const env = { ...loginShellEnvWithoutProxies, diff --git a/src/main/utils/__tests__/process.test.ts b/src/main/utils/__tests__/process.test.ts index 0485ec5fad..a1ac2fd9a5 100644 --- a/src/main/utils/__tests__/process.test.ts +++ b/src/main/utils/__tests__/process.test.ts @@ -1,9 +1,21 @@ +import { configManager } from '@main/services/ConfigManager' import { execFileSync } from 'child_process' import fs from 'fs' import path from 'path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { findExecutable, findGitBash, validateGitBashPath } from '../process' +import { autoDiscoverGitBash, findExecutable, findGitBash, validateGitBashPath } from '../process' + +// Mock configManager +vi.mock('@main/services/ConfigManager', () => ({ + ConfigKeys: { + GitBashPath: 'gitBashPath' + }, + configManager: { + get: vi.fn(), + set: vi.fn() + } +})) // Mock dependencies vi.mock('child_process') @@ -695,4 +707,284 @@ describe.skipIf(process.platform !== 'win32')('process utilities', () => { }) }) }) + + describe('autoDiscoverGitBash', () => { + const originalEnvVar = process.env.CLAUDE_CODE_GIT_BASH_PATH + + beforeEach(() => { + vi.mocked(configManager.get).mockReset() + vi.mocked(configManager.set).mockReset() + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + }) + + afterEach(() => { + // Restore original environment variable + if (originalEnvVar !== undefined) { + process.env.CLAUDE_CODE_GIT_BASH_PATH = originalEnvVar + } else { + delete process.env.CLAUDE_CODE_GIT_BASH_PATH + } + }) + + /** + * Helper to mock fs.existsSync with a set of valid paths + */ + const mockExistingPaths = (...validPaths: string[]) => { + vi.mocked(fs.existsSync).mockImplementation((p) => validPaths.includes(p as string)) + } + + describe('with no existing config path', () => { + it('should discover and persist Git Bash path when not configured', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should return null and not persist when Git Bash is not found', () => { + vi.mocked(configManager.get).mockReturnValue(undefined) + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = autoDiscoverGitBash() + + expect(result).toBeNull() + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('environment variable precedence', () => { + it('should use env var over valid config path', () => { + const envPath = 'C:\\EnvGit\\bin\\bash.exe' + const configPath = 'C:\\ConfigGit\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + vi.mocked(configManager.get).mockReturnValue(configPath) + mockExistingPaths(envPath, configPath) + + const result = autoDiscoverGitBash() + + // Env var should take precedence + expect(result).toBe(envPath) + // Should not persist env var path (it's a runtime override) + expect(configManager.set).not.toHaveBeenCalled() + }) + + it('should fall back to config path when env var is invalid', () => { + const envPath = 'C:\\Invalid\\bash.exe' + const configPath = 'C:\\ConfigGit\\bin\\bash.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + vi.mocked(configManager.get).mockReturnValue(configPath) + // Env path is invalid (doesn't exist), only config path exists + mockExistingPaths(configPath) + + const result = autoDiscoverGitBash() + + // Should fall back to config path + expect(result).toBe(configPath) + expect(configManager.set).not.toHaveBeenCalled() + }) + + it('should fall back to auto-discovery when both env var and config are invalid', () => { + const envPath = 'C:\\InvalidEnv\\bash.exe' + const configPath = 'C:\\InvalidConfig\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + process.env.CLAUDE_CODE_GIT_BASH_PATH = envPath + process.env.ProgramFiles = 'C:\\Program Files' + vi.mocked(configManager.get).mockReturnValue(configPath) + // Both env and config paths are invalid, only standard Git exists + mockExistingPaths(gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(discoveredPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + }) + + describe('with valid existing config path', () => { + it('should validate and return existing path without re-discovering', () => { + const existingPath = 'C:\\CustomGit\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + mockExistingPaths(existingPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(existingPath) + // Should not call findGitBash or persist again + expect(configManager.set).not.toHaveBeenCalled() + // Should not call execFileSync (which findGitBash would use for discovery) + expect(execFileSync).not.toHaveBeenCalled() + }) + + it('should not override existing valid config with auto-discovery', () => { + const existingPath = 'C:\\CustomGit\\bin\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + mockExistingPaths(existingPath, discoveredPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(existingPath) + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('with invalid existing config path', () => { + it('should attempt auto-discovery when existing path does not exist', () => { + const existingPath = 'C:\\NonExistent\\bin\\bash.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + process.env.ProgramFiles = 'C:\\Program Files' + // Invalid path doesn't exist, but Git is installed at standard location + mockExistingPaths(gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + // Should discover and return the new path + expect(result).toBe(discoveredPath) + // Should persist the discovered path (overwrites invalid) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + + it('should attempt auto-discovery when existing path is not bash.exe', () => { + const existingPath = 'C:\\CustomGit\\bin\\git.exe' + const discoveredPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + process.env.ProgramFiles = 'C:\\Program Files' + // Invalid path exists but is not bash.exe (validation will fail) + // Git is installed at standard location + mockExistingPaths(existingPath, gitPath, discoveredPath) + + const result = autoDiscoverGitBash() + + // Should discover and return the new path + expect(result).toBe(discoveredPath) + // Should persist the discovered path (overwrites invalid) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', discoveredPath) + }) + + it('should return null when existing path is invalid and discovery fails', () => { + const existingPath = 'C:\\NonExistent\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(existingPath) + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.mocked(execFileSync).mockImplementation(() => { + throw new Error('Not found') + }) + + const result = autoDiscoverGitBash() + + // Both validation and discovery failed + expect(result).toBeNull() + // Should not persist when discovery fails + expect(configManager.set).not.toHaveBeenCalled() + }) + }) + + describe('config persistence verification', () => { + it('should persist discovered path with correct config key', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + autoDiscoverGitBash() + + // Verify the exact call to configManager.set + expect(configManager.set).toHaveBeenCalledTimes(1) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should persist on each discovery when config remains undefined', () => { + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + autoDiscoverGitBash() + autoDiscoverGitBash() + + // Each call discovers and persists since config remains undefined (mocked) + expect(configManager.set).toHaveBeenCalledTimes(2) + }) + }) + + describe('real-world scenarios', () => { + it('should discover and persist standard Git for Windows installation', () => { + const gitPath = 'C:\\Program Files\\Git\\cmd\\git.exe' + const bashPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + process.env.ProgramFiles = 'C:\\Program Files' + mockExistingPaths(gitPath, bashPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should discover portable Git via where.exe and persist', () => { + const gitPath = 'D:\\PortableApps\\Git\\bin\\git.exe' + const bashPath = 'D:\\PortableApps\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(undefined) + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = p?.toString() || '' + // Common git paths don't exist + if (pathStr.includes('Program Files\\Git\\cmd\\git.exe')) return false + if (pathStr.includes('Program Files (x86)\\Git\\cmd\\git.exe')) return false + // Portable bash path exists + if (pathStr === bashPath) return true + return false + }) + + vi.mocked(execFileSync).mockReturnValue(gitPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(bashPath) + expect(configManager.set).toHaveBeenCalledWith('gitBashPath', bashPath) + }) + + it('should respect user-configured path over auto-discovery', () => { + const userConfiguredPath = 'D:\\MyGit\\bin\\bash.exe' + const systemPath = 'C:\\Program Files\\Git\\bin\\bash.exe' + + vi.mocked(configManager.get).mockReturnValue(userConfiguredPath) + mockExistingPaths(userConfiguredPath, systemPath) + + const result = autoDiscoverGitBash() + + expect(result).toBe(userConfiguredPath) + expect(configManager.set).not.toHaveBeenCalled() + // Verify findGitBash was not called for discovery + expect(execFileSync).not.toHaveBeenCalled() + }) + }) + }) }) diff --git a/src/main/utils/process.ts b/src/main/utils/process.ts index 7175af7e75..ccc0f66535 100644 --- a/src/main/utils/process.ts +++ b/src/main/utils/process.ts @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import type { GitBashPathInfo, GitBashPathSource } from '@shared/config/constant' import { HOME_CHERRY_DIR } from '@shared/config/constant' import { execFileSync, spawn } from 'child_process' import fs from 'fs' @@ -6,6 +7,7 @@ import os from 'os' import path from 'path' import { isWin } from '../constant' +import { ConfigKeys, configManager } from '../services/ConfigManager' import { getResourcePath } from '.' const logger = loggerService.withContext('Utils:Process') @@ -59,7 +61,7 @@ export async function getBinaryPath(name?: string): Promise { export async function isBinaryExists(name: string): Promise { const cmd = await getBinaryPath(name) - return await fs.existsSync(cmd) + return fs.existsSync(cmd) } /** @@ -225,3 +227,77 @@ export function validateGitBashPath(customPath?: string | null): string | null { logger.debug('Validated custom Git Bash path', { path: resolved }) return resolved } + +/** + * Auto-discover and persist Git Bash path if not already configured + * Only called when Git Bash is actually needed + * + * Precedence order: + * 1. CLAUDE_CODE_GIT_BASH_PATH environment variable (highest - runtime override) + * 2. Configured path from settings (manual or auto) + * 3. Auto-discovery via findGitBash (only if no valid config exists) + */ +export function autoDiscoverGitBash(): string | null { + if (!isWin) { + return null + } + + // 1. Check environment variable override first (highest priority) + const envOverride = process.env.CLAUDE_CODE_GIT_BASH_PATH + if (envOverride) { + const validated = validateGitBashPath(envOverride) + if (validated) { + logger.debug('Using CLAUDE_CODE_GIT_BASH_PATH override', { path: validated }) + return validated + } + logger.warn('CLAUDE_CODE_GIT_BASH_PATH provided but path is invalid', { path: envOverride }) + } + + // 2. Check if a path is already configured + const existingPath = configManager.get(ConfigKeys.GitBashPath) + const existingSource = configManager.get(ConfigKeys.GitBashPathSource) + + if (existingPath) { + const validated = validateGitBashPath(existingPath) + if (validated) { + return validated + } + // Existing path is invalid, try to auto-discover + logger.warn('Existing Git Bash path is invalid, attempting auto-discovery', { + path: existingPath, + source: existingSource + }) + } + + // 3. Try to find Git Bash via auto-discovery + const discoveredPath = findGitBash() + if (discoveredPath) { + // Persist the discovered path with 'auto' source + configManager.set(ConfigKeys.GitBashPath, discoveredPath) + configManager.set(ConfigKeys.GitBashPathSource, 'auto') + logger.info('Auto-discovered Git Bash path', { path: discoveredPath }) + } + + return discoveredPath +} + +/** + * Get Git Bash path info including source + * If no path is configured, triggers auto-discovery first + */ +export function getGitBashPathInfo(): GitBashPathInfo { + if (!isWin) { + return { path: null, source: null } + } + + let path = configManager.get(ConfigKeys.GitBashPath) ?? null + let source = configManager.get(ConfigKeys.GitBashPathSource) ?? null + + // If no path configured, trigger auto-discovery (handles upgrade from old versions) + if (!path) { + path = autoDiscoverGitBash() + source = path ? 'auto' : null + } + + return { path, source } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 117bec3b91..dc08e9a2df 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,7 +2,7 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import { electronAPI } from '@electron-toolkit/preload' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' -import type { TerminalConfig, UpgradeChannel } from '@shared/config/constant' +import type { GitBashPathInfo, TerminalConfig, UpgradeChannel } from '@shared/config/constant' import type { LogLevel, LogSourceWithContext } from '@shared/config/logger' import type { FileChangeEvent, WebviewKeyEvent } from '@shared/config/types' import type { MCPServerLogEntry } from '@shared/config/types' @@ -126,6 +126,7 @@ const api = { getCpuName: () => ipcRenderer.invoke(IpcChannel.System_GetCpuName), checkGitBash: (): Promise => ipcRenderer.invoke(IpcChannel.System_CheckGitBash), getGitBashPath: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPath), + getGitBashPathInfo: (): Promise => ipcRenderer.invoke(IpcChannel.System_GetGitBashPathInfo), setGitBashPath: (newPath: string | null): Promise => ipcRenderer.invoke(IpcChannel.System_SetGitBashPath, newPath) }, diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 8a8b4fe61b..25e4b81f18 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -3,6 +3,7 @@ import { ErrorBoundary } from '@renderer/components/ErrorBoundary' import { HelpTooltip } from '@renderer/components/TooltipIcons' import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' +import { isWin } from '@renderer/config/constant' import { useAgents } from '@renderer/hooks/agents/useAgents' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton' @@ -16,7 +17,8 @@ import type { UpdateAgentForm } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types' -import { Alert, Button, Input, Modal, Select } from 'antd' +import type { GitBashPathInfo } from '@shared/config/constant' +import { Button, Input, Modal, Select } from 'antd' import { AlertTriangleIcon } from 'lucide-react' import type { ChangeEvent, FormEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -59,8 +61,7 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) - const [hasGitBash, setHasGitBash] = useState(true) - const [customGitBashPath, setCustomGitBashPath] = useState('') + const [gitBashPathInfo, setGitBashPathInfo] = useState({ path: null, source: null }) useEffect(() => { if (open) { @@ -68,29 +69,15 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { } }, [agent, open]) - const checkGitBash = useCallback( - async (showToast = false) => { - try { - const [gitBashInstalled, savedPath] = await Promise.all([ - window.api.system.checkGitBash(), - window.api.system.getGitBashPath().catch(() => null) - ]) - setCustomGitBashPath(savedPath ?? '') - setHasGitBash(gitBashInstalled) - if (showToast) { - if (gitBashInstalled) { - window.toast.success(t('agent.gitBash.success', 'Git Bash detected successfully!')) - } else { - window.toast.error(t('agent.gitBash.notFound', 'Git Bash not found. Please install it first.')) - } - } - } catch (error) { - logger.error('Failed to check Git Bash:', error as Error) - setHasGitBash(true) // Default to true on error to avoid false warnings - } - }, - [t] - ) + const checkGitBash = useCallback(async () => { + if (!isWin) return + try { + const pathInfo = await window.api.system.getGitBashPathInfo() + setGitBashPathInfo(pathInfo) + } catch (error) { + logger.error('Failed to check Git Bash:', error as Error) + } + }, []) useEffect(() => { checkGitBash() @@ -119,24 +106,22 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { return } - setCustomGitBashPath(pickedPath) - await checkGitBash(true) + await checkGitBash() } catch (error) { logger.error('Failed to pick Git Bash path', error as Error) window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) } }, [checkGitBash, t]) - const handleClearGitBash = useCallback(async () => { + const handleResetGitBash = useCallback(async () => { try { + // Clear manual setting and re-run auto-discovery await window.api.system.setGitBashPath(null) - setCustomGitBashPath('') - await checkGitBash(true) + await checkGitBash() } catch (error) { - logger.error('Failed to clear Git Bash path', error as Error) - window.toast.error(t('agent.gitBash.pick.failed', 'Failed to set Git Bash path')) + logger.error('Failed to reset Git Bash path', error as Error) } - }, [checkGitBash, t]) + }, [checkGitBash]) const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { @@ -268,6 +253,12 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { return } + if (isWin && !gitBashPathInfo.path) { + window.toast.error(t('agent.gitBash.error.required', 'Git Bash path is required on Windows')) + loadingRef.current = false + return + } + if (isEditing(agent)) { if (!agent) { loadingRef.current = false @@ -327,7 +318,8 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { t, updateAgent, afterSubmit, - addAgent + addAgent, + gitBashPathInfo.path ] ) @@ -346,66 +338,6 @@ const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { footer={null}> - {!hasGitBash && ( - -
- {t( - 'agent.gitBash.error.description', - 'Git Bash is required to run agents on Windows. The agent cannot function without it. Please install Git for Windows from' - )}{' '} - { - e.preventDefault() - window.api.openWebsite('https://git-scm.com/download/win') - }} - style={{ textDecoration: 'underline' }}> - git-scm.com - -
- - -
- } - type="error" - showIcon - style={{ marginBottom: 16 }} - /> - )} - - {hasGitBash && customGitBashPath && ( - -
- {t('agent.gitBash.customPath', { - defaultValue: 'Using custom path: {{path}}', - path: customGitBashPath - })} -
-
- - -
-
- } - type="success" - showIcon - style={{ marginBottom: 16 }} - /> - )} + {isWin && ( + +
+ + +
+ + + + {gitBashPathInfo.source === 'manual' && ( + + )} + + {gitBashPathInfo.path && gitBashPathInfo.source === 'auto' && ( + {t('agent.gitBash.autoDiscoveredHint', 'Auto-discovered')} + )} +
+ )} +