From a35bf4afa1772ce6e95638c88d0f7cdd0f24bf9e Mon Sep 17 00:00:00 2001 From: GeekMr <140129782+WilliamWang1721@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:15:17 +0800 Subject: [PATCH 01/18] fix(azure-openai): normalize Azure endpoint (#12055) Co-authored-by: William Wang --- .../OpenAIBaseClient.azureEndpoint.test.ts | 38 +++++++++++++++++++ .../legacy/clients/openai/OpenAIBaseClient.ts | 3 +- .../clients/openai/azureOpenAIEndpoint.ts | 4 ++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/aiCore/legacy/clients/__tests__/OpenAIBaseClient.azureEndpoint.test.ts create mode 100644 src/renderer/src/aiCore/legacy/clients/openai/azureOpenAIEndpoint.ts diff --git a/src/renderer/src/aiCore/legacy/clients/__tests__/OpenAIBaseClient.azureEndpoint.test.ts b/src/renderer/src/aiCore/legacy/clients/__tests__/OpenAIBaseClient.azureEndpoint.test.ts new file mode 100644 index 0000000000..e3b2ef2676 --- /dev/null +++ b/src/renderer/src/aiCore/legacy/clients/__tests__/OpenAIBaseClient.azureEndpoint.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { normalizeAzureOpenAIEndpoint } from '../openai/azureOpenAIEndpoint' + +describe('normalizeAzureOpenAIEndpoint', () => { + it.each([ + { + apiHost: 'https://example.openai.azure.com/openai', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com/openai/', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com/openai/v1', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com/openai/v1/', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com/', + expectedEndpoint: 'https://example.openai.azure.com' + }, + { + apiHost: 'https://example.openai.azure.com/OPENAI/V1', + expectedEndpoint: 'https://example.openai.azure.com' + } + ])('strips trailing /openai from $apiHost', ({ apiHost, expectedEndpoint }) => { + expect(normalizeAzureOpenAIEndpoint(apiHost)).toBe(expectedEndpoint) + }) +}) diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 9d03552cb3..efc3f4f7ce 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -29,6 +29,7 @@ import { withoutTrailingSlash } from '@renderer/utils/api' import { isOllamaProvider } from '@renderer/utils/provider' import { BaseApiClient } from '../BaseApiClient' +import { normalizeAzureOpenAIEndpoint } from './azureOpenAIEndpoint' const logger = loggerService.withContext('OpenAIBaseClient') @@ -213,7 +214,7 @@ export abstract class OpenAIBaseClient< dangerouslyAllowBrowser: true, apiKey: apiKeyForSdkInstance, apiVersion: this.provider.apiVersion, - endpoint: this.provider.apiHost + endpoint: normalizeAzureOpenAIEndpoint(this.provider.apiHost) }) as TSdkInstance } else { this.sdkInstance = new OpenAI({ diff --git a/src/renderer/src/aiCore/legacy/clients/openai/azureOpenAIEndpoint.ts b/src/renderer/src/aiCore/legacy/clients/openai/azureOpenAIEndpoint.ts new file mode 100644 index 0000000000..777dbe74d7 --- /dev/null +++ b/src/renderer/src/aiCore/legacy/clients/openai/azureOpenAIEndpoint.ts @@ -0,0 +1,4 @@ +export function normalizeAzureOpenAIEndpoint(apiHost: string): string { + const normalizedHost = apiHost.replace(/\/+$/, '') + return normalizedHost.replace(/\/openai(?:\/v1)?$/i, '') +} From c747b8e2a47d02d60397105fbdb9787760dcf324 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 21 Dec 2025 17:20:16 +0800 Subject: [PATCH 02/18] fix(prompt): remove unprofessional reward text and improve language instruction clarity (#12054) * fix(toolUsePlugin): correct prompt formatting and instructions - Remove misleading reward statement from tool use prompt - Fix typo in XML tag format instruction ("MARK" to "MAKE") - Reorganize response rules section for better clarity * refactor(tool-use): consolidate default system prompt into shared module Move DEFAULT_SYSTEM_PROMPT to core plugin module and reuse it in renderer Update prompt to allow multiple tool uses per message and add response language rule --- .../toolUsePlugin/promptToolUsePlugin.ts | 13 ++-- src/renderer/src/utils/prompt.ts | 59 +------------------ 2 files changed, 11 insertions(+), 61 deletions(-) diff --git a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts index 22e8b5a605..224cee05ae 100644 --- a/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts +++ b/packages/aiCore/src/core/plugins/built-in/toolUsePlugin/promptToolUsePlugin.ts @@ -22,10 +22,10 @@ const TOOL_USE_TAG_CONFIG: TagConfig = { } /** - * 默认系统提示符模板(提取自 Cherry Studio) + * 默认系统提示符模板 */ -const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \\ -You can use one tool per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. +export const DEFAULT_SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ +You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. ## Tool Use Formatting @@ -74,10 +74,13 @@ Here are the rules you should always follow to solve your task: 4. Never re-do a tool call that you previously did with the exact same parameters. 5. For tool use, MAKE SURE use XML tag format as shown in the examples above. Do not use any other format. +## Response rules + +Respond in the language of the user's query, unless the user instructions specify additional requirements for the language to be used. + # User Instructions {{ USER_SYSTEM_PROMPT }} - -Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000.` +` /** * 默认工具使用示例(提取自 Cherry Studio) diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 5db92d06a5..4e799800a7 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -1,64 +1,11 @@ +import { DEFAULT_SYSTEM_PROMPT } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import store from '@renderer/store' import type { MCPTool } from '@renderer/types' const logger = loggerService.withContext('Utils:Prompt') -export const SYSTEM_PROMPT = `In this environment you have access to a set of tools you can use to answer the user's question. \ -You can use one or more tools per message, and will receive the result of that tool use in the user's response. You use tools step-by-step to accomplish a given task, with each tool use informed by the result of the previous tool use. - -## Tool Use Formatting - -Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. Here's the structure: - - - {tool_name} - {json_arguments} - - -The tool name should be the exact name of the tool you are using, and the arguments should be a JSON object containing the parameters required by that tool. For example: - - python_interpreter - {"code": "5 + 3 + 1294.678"} - - -The user will respond with the result of the tool use, which should be formatted as follows: - - - {tool_name} - {result} - - -The result should be a string, which can represent a file or any other output type. You can use this result as input for the next action. -For example, if the result of the tool use is an image file, you can use it in the next action like this: - - - image_transformer - {"image": "image_1.jpg"} - - -Always adhere to this format for the tool use to ensure proper parsing and execution. - -## Tool Use Examples -{{ TOOL_USE_EXAMPLES }} - -## Tool Use Available Tools -Above example were using notional tools that might not exist for you. You only have access to these tools: -{{ AVAILABLE_TOOLS }} - -## Tool Use Rules -Here are the rules you should always follow to solve your task: -1. Always use the right arguments for the tools. Never use variable names as the action arguments, use the value instead. -2. Call a tool only when needed: do not call the search agent if you do not need information, try to solve the task yourself. -3. If no tool call is needed, just answer the question directly. -4. Never re-do a tool call that you previously did with the exact same parameters. -5. For tool use, MARK SURE use XML tag format as shown in the examples above. Do not use any other format. - -# User Instructions -{{ USER_SYSTEM_PROMPT }} -Response in user query language. -Now Begin! If you solve the task correctly, you will receive a reward of $1,000,000. -` +export { DEFAULT_SYSTEM_PROMPT as SYSTEM_PROMPT } export const THINK_TOOL_PROMPT = `{{ USER_SYSTEM_PROMPT }}` @@ -258,7 +205,7 @@ export const replacePromptVariables = async (userSystemPrompt: string, modelName export const buildSystemPromptWithTools = (userSystemPrompt: string, tools?: MCPTool[]): string => { if (tools && tools.length > 0) { - return SYSTEM_PROMPT.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '') + return DEFAULT_SYSTEM_PROMPT.replace('{{ USER_SYSTEM_PROMPT }}', userSystemPrompt || '') .replace('{{ TOOL_USE_EXAMPLES }}', ToolUseExamples) .replace('{{ AVAILABLE_TOOLS }}', AvailableTools(tools)) } From c4f94848e816a2f04ca2496d61eb5badfa55dfbd Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Sun, 21 Dec 2025 17:22:59 +0800 Subject: [PATCH 03/18] feat:upgrade ovms to 2025.4, add preset-model Qwen3-4B-int4-ov (#12045) --- resources/scripts/install-ovms.js | 4 ++-- .../ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/resources/scripts/install-ovms.js b/resources/scripts/install-ovms.js index f2be80bffe..8ccd522b01 100644 --- a/resources/scripts/install-ovms.js +++ b/resources/scripts/install-ovms.js @@ -6,8 +6,8 @@ const { downloadWithPowerShell } = require('./download') // Base URL for downloading OVMS binaries const OVMS_RELEASE_BASE_URL = - 'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.3.0/ovms_windows_python_on.zip' -const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.3_ex.zip' + 'https://storage.openvinotoolkit.org/repositories/openvino_model_server/packages/2025.4.1/ovms_windows_python_on.zip' +const OVMS_EX_URL = 'https://gitcode.com/gcw_ggDjjkY3/kjfile/releases/download/download/ovms_25.4_ex.zip' /** * error code: diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx index 518017d5b3..bb39376d3b 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx @@ -35,6 +35,13 @@ interface PresetModel { } const PRESET_MODELS: PresetModel[] = [ + { + modelId: 'OpenVINO/Qwen3-4B-int4-ov', + modelName: 'Qwen3-4B-int4-ov', + modelSource: 'https://www.modelscope.cn/models', + task: 'text_generation', + label: 'Qwen3-4B-int4-ov (Text Generation)' + }, { modelId: 'OpenVINO/Qwen3-8B-int4-ov', modelName: 'Qwen3-8B-int4-ov', From 9a435b8abbe141c1eed36d396abc094a8e926203 Mon Sep 17 00:00:00 2001 From: atoz03 <31232741+atoz03@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:32:32 +0800 Subject: [PATCH 04/18] feat(history-search): show keyword-adjacent snippets and align matching text (#12034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(history-search): show keyword-adjacent snippets and align matching text - Limit search results to title plus nearby lines with ellipses - Merge multi-keyword hit ranges and truncate long lines - Match against sanitized visible text to avoid URL/image false hits * fix(history): 针对review 的改进:避免搜索高亮嵌套并优化命名与省略逻辑注释 --- .../history/components/SearchResults.tsx | 204 +++++++++++++++--- 1 file changed, 178 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/pages/history/components/SearchResults.tsx b/src/renderer/src/pages/history/components/SearchResults.tsx index e0ffba8b2a..7189e78e5a 100644 --- a/src/renderer/src/pages/history/components/SearchResults.tsx +++ b/src/renderer/src/pages/history/components/SearchResults.tsx @@ -17,6 +17,7 @@ type SearchResult = { message: Message topic: Topic content: string + snippet: string } interface Props extends React.HTMLAttributes { @@ -25,6 +26,158 @@ interface Props extends React.HTMLAttributes { onTopicClick: (topic: Topic) => void } +const SEARCH_SNIPPET_CONTEXT_LINES = 1 +const SEARCH_SNIPPET_MAX_LINES = 12 +const SEARCH_SNIPPET_MAX_LINE_LENGTH = 160 +const SEARCH_SNIPPET_LINE_FRAGMENT_RADIUS = 40 +const SEARCH_SNIPPET_MAX_LINE_FRAGMENTS = 3 + +const stripMarkdownFormatting = (text: string) => { + return text + .replace(/```(?:[^\n]*\n)?([\s\S]*?)```/g, '$1') + .replace(/!\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/#+\s/g, '') + .replace(/<[^>]*>/g, '') +} + +const normalizeText = (text: string) => text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + +const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const mergeRanges = (ranges: Array<[number, number]>) => { + const sorted = ranges.slice().sort((a, b) => a[0] - b[0]) + const merged: Array<[number, number]> = [] + for (const range of sorted) { + const last = merged[merged.length - 1] + if (!last || range[0] > last[1] + 1) { + merged.push([range[0], range[1]]) + continue + } + last[1] = Math.max(last[1], range[1]) + } + return merged +} + +const buildLineSnippet = (line: string, regexes: RegExp[]) => { + if (line.length <= SEARCH_SNIPPET_MAX_LINE_LENGTH) { + return line + } + + const matchRanges: Array<[number, number]> = [] + for (const regex of regexes) { + regex.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = regex.exec(line)) !== null) { + matchRanges.push([match.index, match.index + match[0].length]) + if (match[0].length === 0) { + regex.lastIndex += 1 + } + } + } + + if (matchRanges.length === 0) { + return `${line.slice(0, SEARCH_SNIPPET_MAX_LINE_LENGTH)}...` + } + + const expandedRanges: Array<[number, number]> = matchRanges.map(([start, end]) => [ + Math.max(0, start - SEARCH_SNIPPET_LINE_FRAGMENT_RADIUS), + Math.min(line.length, end + SEARCH_SNIPPET_LINE_FRAGMENT_RADIUS) + ]) + const mergedRanges = mergeRanges(expandedRanges) + const limitedRanges = mergedRanges.slice(0, SEARCH_SNIPPET_MAX_LINE_FRAGMENTS) + + let result = limitedRanges.map(([start, end]) => line.slice(start, end)).join(' ... ') + // 片段未从行首开始,补前置省略号。 + if (limitedRanges[0][0] > 0) { + result = `...${result}` + } + // 片段未覆盖到行尾,补后置省略号。 + if (limitedRanges[limitedRanges.length - 1][1] < line.length) { + result = `${result}...` + } + // 还有未展示的匹配片段,提示省略。 + if (mergedRanges.length > SEARCH_SNIPPET_MAX_LINE_FRAGMENTS) { + result = `${result}...` + } + // 最终长度超限,强制截断并补省略号。 + if (result.length > SEARCH_SNIPPET_MAX_LINE_LENGTH) { + result = `${result.slice(0, SEARCH_SNIPPET_MAX_LINE_LENGTH)}...` + } + return result +} + +const buildSearchSnippet = (text: string, terms: string[]) => { + const normalized = normalizeText(stripMarkdownFormatting(text)) + const lines = normalized.split('\n') + if (lines.length === 0) { + return '' + } + + const nonEmptyTerms = terms.filter((term) => term.length > 0) + const regexes = nonEmptyTerms.map((term) => new RegExp(escapeRegex(term), 'gi')) + const matchedLineIndexes: number[] = [] + + if (regexes.length > 0) { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] + const isMatch = regexes.some((regex) => { + regex.lastIndex = 0 + return regex.test(line) + }) + if (isMatch) { + matchedLineIndexes.push(i) + } + } + } + + const ranges: Array<[number, number]> = + matchedLineIndexes.length > 0 + ? mergeRanges( + matchedLineIndexes.map((index) => [ + Math.max(0, index - SEARCH_SNIPPET_CONTEXT_LINES), + Math.min(lines.length - 1, index + SEARCH_SNIPPET_CONTEXT_LINES) + ]) + ) + : [[0, Math.min(lines.length - 1, SEARCH_SNIPPET_MAX_LINES - 1)]] + + const outputLines: string[] = [] + let truncated = false + + if (ranges[0][0] > 0) { + outputLines.push('...') + } + + for (const [start, end] of ranges) { + if (outputLines.length >= SEARCH_SNIPPET_MAX_LINES) { + truncated = true + break + } + if (outputLines.length > 0 && outputLines[outputLines.length - 1] !== '...') { + outputLines.push('...') + } + for (let i = start; i <= end; i += 1) { + if (outputLines.length >= SEARCH_SNIPPET_MAX_LINES) { + truncated = true + break + } + outputLines.push(buildLineSnippet(lines[i], regexes)) + } + if (truncated) { + break + } + } + + if ((truncated || ranges[ranges.length - 1][1] < lines.length - 1) && outputLines.at(-1) !== '...') { + outputLines.push('...') + } + + return outputLines.join('\n') +} + const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...props }) => { const { handleScroll, containerRef } = useScrollPosition('SearchResults') const observerRef = useRef(null) @@ -44,17 +197,6 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const [searchStats, setSearchStats] = useState({ count: 0, time: 0 }) const [isLoading, setIsLoading] = useState(false) - const removeMarkdown = (text: string) => { - return text - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .replace(/\[(.*?)\]\((.*?)\)/g, '$1') - .replace(/```[\s\S]*?```/g, '') - .replace(/`(.*?)`/g, '$1') - .replace(/#+\s/g, '') - .replace(/<[^>]*>/g, '') - } - const onSearch = useCallback(async () => { setSearchResults([]) setIsLoading(true) @@ -69,13 +211,16 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p const startTime = performance.now() const newSearchTerms = keywords .toLowerCase() - .split(' ') + .split(/\s+/) .filter((term) => term.length > 0) - const searchRegexes = newSearchTerms.map((term) => new RegExp(term, 'i')) + const searchRegexes = newSearchTerms.map((term) => new RegExp(escapeRegex(term), 'i')) const blocks = (await db.message_blocks.toArray()) .filter((block) => block.type === MessageBlockType.MAIN_TEXT) - .filter((block) => searchRegexes.some((regex) => regex.test(block.content))) + .filter((block) => { + const searchableContent = stripMarkdownFormatting(block.content) + return searchRegexes.some((regex) => regex.test(searchableContent)) + }) const messages = topics?.flatMap((topic) => topic.messages) @@ -85,7 +230,12 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p if (message) { const topic = storeTopicsMap.get(message.topicId) if (topic) { - return { message, topic, content: block.content } + return { + message, + topic, + content: block.content, + snippet: buildSearchSnippet(block.content, newSearchTerms) + } } } return null @@ -103,15 +253,17 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p }, [keywords, storeTopicsMap, topics]) const highlightText = (text: string) => { - let highlightedText = removeMarkdown(text) - searchTerms.forEach((term) => { - try { - const regex = new RegExp(term, 'gi') - highlightedText = highlightedText.replace(regex, (match) => `${match}`) - } catch (error) { - // - } - }) + const uniqueTerms = Array.from(new Set(searchTerms.filter((term) => term.length > 0))) + if (uniqueTerms.length === 0) { + return + } + + const pattern = uniqueTerms + .sort((a, b) => b.length - a.length) + .map((term) => escapeRegex(term)) + .join('|') + const regex = new RegExp(pattern, 'gi') + const highlightedText = text.replace(regex, (match) => `${match}`) return } @@ -150,7 +302,7 @@ const SearchResults: FC = ({ keywords, onMessageClick, onTopicClick, ...p hideOnSinglePage: true }} style={{ opacity: isLoading ? 0 : 1 }} - renderItem={({ message, topic, content }) => ( + renderItem={({ message, topic, snippet }) => ( = ({ keywords, onMessageClick, onTopicClick, ...p {topic.name}
onMessageClick(message)}> - {highlightText(content)} + {highlightText(snippet)}
{new Date(message.createdAt).toLocaleString()} From fc3e92e2f702eb9898e6dd98ff00d0f22ccd9806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A7=91=E5=9B=BF=E8=84=91=E8=A2=8B?= <70054568+eeee0717@users.noreply.github.com> Date: Sun, 21 Dec 2025 17:39:23 +0800 Subject: [PATCH 05/18] refactor: change qrcode landrop to lantransfer (#11968) * refactor: change qrcode landrop to lantransfer * chore: update docs and tests * fix: pr review * fix: pr review * chore: remove qrcode dependency * fix: pr review * fix: format * fix: test --- docs/zh/references/lan-transfer-protocol.md | 850 ++++++++++++++++++ package.json | 3 +- packages/shared/IpcChannel.ts | 18 +- packages/shared/config/types.ts | 193 ++++ src/main/index.ts | 8 +- src/main/ipc.ts | 23 +- src/main/services/BackupManager.ts | 50 ++ src/main/services/LocalTransferService.ts | 207 +++++ src/main/services/WebSocketService.ts | 359 -------- .../BackupManager.deleteTempBackup.test.ts | 274 ++++++ .../__tests__/LocalTransferService.test.ts | 481 ++++++++++ .../lanTransfer/LanTransferClientService.ts | 525 +++++++++++ .../LanTransferClientService.test.ts | 133 +++ .../__tests__/binaryProtocol.test.ts | 103 +++ .../__tests__/handlers/connection.test.ts | 265 ++++++ .../__tests__/handlers/fileTransfer.test.ts | 216 +++++ .../__tests__/responseManager.test.ts | 177 ++++ .../services/lanTransfer/binaryProtocol.ts | 67 ++ .../lanTransfer/handlers/connection.ts | 162 ++++ .../lanTransfer/handlers/fileTransfer.ts | 267 ++++++ .../services/lanTransfer/handlers/index.ts | 22 + src/main/services/lanTransfer/index.ts | 21 + .../services/lanTransfer/responseManager.ts | 144 +++ src/main/services/lanTransfer/types.ts | 65 ++ src/preload/index.ts | 48 +- .../Popups/ExportToPhoneLanPopup.tsx | 553 ------------ .../Popups/LanTransferPopup/LanDeviceCard.tsx | 97 ++ .../LanTransferPopup/ProgressIndicator.tsx | 55 ++ .../Popups/LanTransferPopup/hook.ts | 397 ++++++++ .../Popups/LanTransferPopup/index.tsx | 37 + .../Popups/LanTransferPopup/popup.tsx | 88 ++ .../Popups/LanTransferPopup/types.ts | 84 ++ src/renderer/src/i18n/locales/en-us.json | 45 +- src/renderer/src/i18n/locales/zh-cn.json | 45 +- src/renderer/src/i18n/locales/zh-tw.json | 45 +- src/renderer/src/i18n/translate/de-de.json | 43 +- src/renderer/src/i18n/translate/el-gr.json | 43 +- src/renderer/src/i18n/translate/es-es.json | 43 +- src/renderer/src/i18n/translate/fr-fr.json | 43 +- src/renderer/src/i18n/translate/ja-jp.json | 43 +- src/renderer/src/i18n/translate/pt-pt.json | 43 +- src/renderer/src/i18n/translate/ru-ru.json | 43 +- .../settings/DataSettings/DataSettings.tsx | 5 +- yarn.lock | 198 ++-- 44 files changed, 5432 insertions(+), 1199 deletions(-) create mode 100644 docs/zh/references/lan-transfer-protocol.md create mode 100644 src/main/services/LocalTransferService.ts delete mode 100644 src/main/services/WebSocketService.ts create mode 100644 src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts create mode 100644 src/main/services/__tests__/LocalTransferService.test.ts create mode 100644 src/main/services/lanTransfer/LanTransferClientService.ts create mode 100644 src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/handlers/connection.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts create mode 100644 src/main/services/lanTransfer/__tests__/responseManager.test.ts create mode 100644 src/main/services/lanTransfer/binaryProtocol.ts create mode 100644 src/main/services/lanTransfer/handlers/connection.ts create mode 100644 src/main/services/lanTransfer/handlers/fileTransfer.ts create mode 100644 src/main/services/lanTransfer/handlers/index.ts create mode 100644 src/main/services/lanTransfer/index.ts create mode 100644 src/main/services/lanTransfer/responseManager.ts create mode 100644 src/main/services/lanTransfer/types.ts delete mode 100644 src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/hook.ts create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/index.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/popup.tsx create mode 100644 src/renderer/src/components/Popups/LanTransferPopup/types.ts diff --git a/docs/zh/references/lan-transfer-protocol.md b/docs/zh/references/lan-transfer-protocol.md new file mode 100644 index 0000000000..a4c01a23c5 --- /dev/null +++ b/docs/zh/references/lan-transfer-protocol.md @@ -0,0 +1,850 @@ +# Cherry Studio 局域网传输协议规范 + +> 版本: 1.0 +> 最后更新: 2025-12 + +本文档定义了 Cherry Studio 桌面客户端(Electron)与移动端(Expo)之间的局域网文件传输协议。 + +--- + +## 目录 + +1. [协议概述](#1-协议概述) +2. [服务发现(Bonjour/mDNS)](#2-服务发现bonjourmdns) +3. [TCP 连接与握手](#3-tcp-连接与握手) +4. [消息格式规范](#4-消息格式规范) +5. [文件传输协议](#5-文件传输协议) +6. [心跳与连接保活](#6-心跳与连接保活) +7. [错误处理](#7-错误处理) +8. [常量与配置](#8-常量与配置) +9. [完整时序图](#9-完整时序图) +10. [移动端实现指南](#10-移动端实现指南) + +--- + +## 1. 协议概述 + +### 1.1 架构角色 + +| 角色 | 平台 | 职责 | +| -------------------- | --------------- | ---------------------------- | +| **Client(客户端)** | Electron 桌面端 | 扫描服务、发起连接、发送文件 | +| **Server(服务端)** | Expo 移动端 | 发布服务、接受连接、接收文件 | + +### 1.2 协议栈(v1) + +``` +┌─────────────────────────────────────┐ +│ 应用层(文件传输) │ +├─────────────────────────────────────┤ +│ 消息层(控制: JSON \n) │ +│ (数据: 二进制帧) │ +├─────────────────────────────────────┤ +│ 传输层(TCP) │ +├─────────────────────────────────────┤ +│ 发现层(Bonjour/mDNS) │ +└─────────────────────────────────────┘ +``` + +### 1.3 通信流程概览 + +``` +1. 服务发现 → 移动端发布 mDNS 服务,桌面端扫描发现 +2. TCP 握手 → 建立连接,交换设备信息(`version=1`) +3. 文件传输 → 控制消息使用 JSON,`file_chunk` 使用二进制帧分块传输 +4. 连接保活 → ping/pong 心跳 +``` + +--- + +## 2. 服务发现(Bonjour/mDNS) + +### 2.1 服务类型 + +| 属性 | 值 | +| ------------ | -------------------- | +| 服务类型 | `cherrystudio` | +| 协议 | `tcp` | +| 完整服务标识 | `_cherrystudio._tcp` | + +### 2.2 服务发布(移动端) + +移动端需要通过 mDNS/Bonjour 发布服务: + +```typescript +// 服务发布参数 +{ + name: "Cherry Studio Mobile", // 设备名称 + type: "cherrystudio", // 服务类型 + protocol: "tcp", // 协议 + port: 53317, // TCP 监听端口 + txt: { // TXT 记录(可选) + version: "1", + platform: "ios" // 或 "android" + } +} +``` + +### 2.3 服务发现(桌面端) + +桌面端扫描并解析服务信息: + +```typescript +// 发现的服务信息结构 +type LocalTransferPeer = { + id: string; // 唯一标识符 + name: string; // 设备名称 + host?: string; // 主机名 + fqdn?: string; // 完全限定域名 + port?: number; // TCP 端口 + type?: string; // 服务类型 + protocol?: "tcp" | "udp"; // 协议 + addresses: string[]; // IP 地址列表 + txt?: Record; // TXT 记录 + updatedAt: number; // 发现时间戳 +}; +``` + +### 2.4 IP 地址选择策略 + +当服务有多个 IP 地址时,优先选择 IPv4: + +```typescript +// 优先选择 IPv4 地址 +const preferredAddress = addresses.find((addr) => isIPv4(addr)) || addresses[0]; +``` + +--- + +## 3. TCP 连接与握手 + +### 3.1 连接建立 + +1. 客户端使用发现的 `host:port` 建立 TCP 连接 +2. 连接成功后立即发送握手消息 +3. 等待服务端响应握手确认 + +### 3.2 握手消息(协议版本 v1) + +#### Client → Server: `handshake` + +```typescript +type LanTransferHandshakeMessage = { + type: "handshake"; + deviceName: string; // 设备名称 + version: string; // 协议版本,当前为 "1" + platform?: string; // 平台:'darwin' | 'win32' | 'linux' + appVersion?: string; // 应用版本 +}; +``` + +**示例:** + +```json +{ + "type": "handshake", + "deviceName": "Cherry Studio 1.7.2", + "version": "1", + "platform": "darwin", + "appVersion": "1.7.2" +} +``` + +### 4. 消息格式规范(混合协议) + +v1 使用"控制 JSON + 二进制数据帧"的混合协议(流式传输模式,无 per-chunk ACK): + +- **控制消息**(握手、心跳、file_start/ack、file_end、file_complete):UTF-8 JSON,`\n` 分隔 +- **数据消息**(`file_chunk`):二进制帧,使用 Magic + 总长度做分帧,不经 Base64 + +### 4.1 控制消息编码(JSON + `\n`) + +| 属性 | 规范 | +| ---------- | ------------ | +| 编码格式 | UTF-8 | +| 序列化格式 | JSON | +| 消息分隔符 | `\n`(0x0A) | + +```typescript +function sendControlMessage(socket: Socket, message: object): void { + socket.write(`${JSON.stringify(message)}\n`); +} +``` + +### 4.2 `file_chunk` 二进制帧格式 + +为解决 TCP 分包/粘包并消除 Base64 开销,`file_chunk` 采用带总长度的二进制帧: + +``` +┌──────────┬──────────┬────────┬───────────────┬──────────────┬────────────┬───────────┐ +│ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ +│ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (UTF-8) │ (4B BE) │ (raw) │ +└──────────┴──────────┴────────┴───────────────┴──────────────┴────────────┴───────────┘ +``` + +| 字段 | 大小 | 说明 | +| -------------- | ---- | ------------------------------------------- | +| Magic | 2B | 常量 `0x43 0x53` ("CS"), 用于区分 JSON 消息 | +| TotalLen | 4B | Big-endian,帧总长度(不含 Magic/TotalLen) | +| Type | 1B | `0x01` 代表 `file_chunk` | +| TransferId Len | 2B | Big-endian,transferId 字符串长度 | +| TransferId | nB | UTF-8 transferId(长度由上一字段给出) | +| ChunkIdx | 4B | Big-endian,块索引,从 0 开始 | +| Data | mB | 原始文件二进制数据(未编码) | + +> 计算帧总长度:`TotalLen = 1 + 2 + transferIdLen + 4 + dataLen`(即 Type~Data 的长度和)。 + +### 4.3 消息解析策略 + +1. 读取 socket 数据到缓冲区; +2. 若前两字节为 `0x43 0x53` → 按二进制帧解析: + - 至少需要 6 字节头(Magic + TotalLen),不足则等待更多数据 + - 读取 `TotalLen` 判断帧整体长度,缓冲区不足则继续等待 + - 解析 Type/TransferId/ChunkIdx/Data,并传入文件接收逻辑 +3. 否则若首字节为 `{` → 按 JSON + `\n` 解析控制消息 +4. 其它数据丢弃 1 字节并继续循环,避免阻塞。 + +### 4.4 消息类型汇总(v1) + +| 类型 | 方向 | 编码 | 用途 | +| ---------------- | --------------- | -------- | ----------------------- | +| `handshake` | Client → Server | JSON+\n | 握手请求(version=1) | +| `handshake_ack` | Server → Client | JSON+\n | 握手响应 | +| `ping` | Client → Server | JSON+\n | 心跳请求 | +| `pong` | Server → Client | JSON+\n | 心跳响应 | +| `file_start` | Client → Server | JSON+\n | 开始文件传输 | +| `file_start_ack` | Server → Client | JSON+\n | 文件传输确认 | +| `file_chunk` | Client → Server | 二进制帧 | 文件数据块(无 Base64,流式无 per-chunk ACK) | +| `file_end` | Client → Server | JSON+\n | 文件传输结束 | +| `file_complete` | Server → Client | JSON+\n | 传输完成结果 | + +``` +{"type":"message_type",...其他字段...}\n +``` + +--- + +## 5. 文件传输协议 + +### 5.1 传输流程 + +``` +Client (Sender) Server (Receiver) + | | + |──── 1. file_start ────────────────>| + | (文件元数据) | + | | + |<─── 2. file_start_ack ─────────────| + | (接受/拒绝) | + | | + |══════ 循环发送数据块(流式,无 ACK) ═════| + | | + |──── 3. file_chunk [0] ────────────>| + | | + |──── 3. file_chunk [1] ────────────>| + | | + | ... 重复直到所有块发送完成 ... | + | | + |══════════════════════════════════════ + | | + |──── 5. file_end ──────────────────>| + | (所有块已发送) | + | | + |<─── 6. file_complete ──────────────| + | (最终结果) | +``` + +### 5.2 消息定义 + +#### 5.2.1 `file_start` - 开始传输 + +**方向:** Client → Server + +```typescript +type LanTransferFileStartMessage = { + type: "file_start"; + transferId: string; // UUID,唯一传输标识 + fileName: string; // 文件名(含扩展名) + fileSize: number; // 文件总字节数 + mimeType: string; // MIME 类型 + checksum: string; // 整个文件的 SHA-256 哈希(hex) + totalChunks: number; // 总数据块数 + chunkSize: number; // 每块大小(字节) +}; +``` + +**示例:** + +```json +{ + "type": "file_start", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "fileName": "backup.zip", + "fileSize": 524288000, + "mimeType": "application/zip", + "checksum": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456", + "totalChunks": 8192, + "chunkSize": 65536 +} +``` + +#### 5.2.2 `file_start_ack` - 传输确认 + +**方向:** Server → Client + +```typescript +type LanTransferFileStartAckMessage = { + type: "file_start_ack"; + transferId: string; // 对应的传输 ID + accepted: boolean; // 是否接受传输 + message?: string; // 拒绝原因 +}; +``` + +**接受示例:** + +```json +{ + "type": "file_start_ack", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "accepted": true +} +``` + +**拒绝示例:** + +```json +{ + "type": "file_start_ack", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "accepted": false, + "message": "Insufficient storage space" +} +``` + +#### 5.2.3 `file_chunk` - 数据块 + +**方向:** Client → Server(**二进制帧**,见 4.2) + +- 不再使用 JSON/`\n`,也不再使用 Base64 +- 帧结构:`Magic` + `TotalLen` + `Type` + `TransferId` + `ChunkIdx` + `Data` +- `Type` 固定 `0x01`,`Data` 为原始文件二进制数据 +- 传输完整性依赖 `file_start.checksum`(全文件 SHA-256);分块校验和可选,不在帧中发送 + +#### 5.2.4 `file_chunk_ack` - 数据块确认(v1 流式不使用) + +v1 采用流式传输,不发送 per-chunk ACK。本节类型仅保留作为向后兼容参考,实际不会发送。 + +#### 5.2.5 `file_end` - 传输结束 + +**方向:** Client → Server + +```typescript +type LanTransferFileEndMessage = { + type: "file_end"; + transferId: string; // 传输 ID +}; +``` + +**示例:** + +```json +{ + "type": "file_end", + "transferId": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +#### 5.2.6 `file_complete` - 传输完成 + +**方向:** Server → Client + +```typescript +type LanTransferFileCompleteMessage = { + type: "file_complete"; + transferId: string; // 传输 ID + success: boolean; // 是否成功 + filePath?: string; // 保存路径(成功时) + error?: string; // 错误信息(失败时) +}; +``` + +**成功示例:** + +```json +{ + "type": "file_complete", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "success": true, + "filePath": "/storage/emulated/0/Documents/backup.zip" +} +``` + +**失败示例:** + +```json +{ + "type": "file_complete", + "transferId": "550e8400-e29b-41d4-a716-446655440000", + "success": false, + "error": "File checksum verification failed" +} +``` + +### 5.3 校验和算法 + +#### 整个文件校验和(保持不变) + +```typescript +async function calculateFileChecksum(filePath: string): Promise { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + + for await (const chunk of stream) { + hash.update(chunk); + } + + return hash.digest("hex"); +} +``` + +#### 数据块校验和 + +v1 默认 **不传输分块校验和**,依赖最终文件 checksum。若需要,可在应用层自定义(非协议字段)。 + +### 5.4 校验流程 + +**发送端(Client):** + +1. 发送前计算整个文件的 SHA-256 → `file_start.checksum` +2. 分块直接发送原始二进制(无 Base64) + +**接收端(Server):** + +1. 收到 `file_chunk` 后直接使用二进制数据 +2. 边收边落盘并增量计算 SHA-256(推荐) +3. 所有块接收完成后,计算/完成增量哈希,得到最终 SHA-256 +4. 与 `file_start.checksum` 比对,结果写入 `file_complete` + +### 5.5 数据块大小计算 + +```typescript +const CHUNK_SIZE = 512 * 1024; // 512KB + +const totalChunks = Math.ceil(fileSize / CHUNK_SIZE); + +// 最后一个块可能小于 CHUNK_SIZE +const lastChunkSize = fileSize % CHUNK_SIZE || CHUNK_SIZE; +``` + +--- + +## 6. 心跳与连接保活 + +### 6.1 心跳消息 + +#### `ping` + +**方向:** Client → Server + +```typescript +type LanTransferPingMessage = { + type: "ping"; + payload?: string; // 可选载荷 +}; +``` + +```json +{ + "type": "ping", + "payload": "heartbeat" +} +``` + +#### `pong` + +**方向:** Server → Client + +```typescript +type LanTransferPongMessage = { + type: "pong"; + received: boolean; // 确认收到 + payload?: string; // 回传 ping 的载荷 +}; +``` + +```json +{ + "type": "pong", + "received": true, + "payload": "heartbeat" +} +``` + +### 6.2 心跳策略 + +- 握手成功后立即发送一次 `ping` 验证连接 +- 可选:定期发送心跳保持连接活跃 +- `pong` 应返回 `ping` 中的 `payload`(可选) + +--- + +## 7. 错误处理 + +### 7.1 超时配置 + +| 操作 | 超时时间 | 说明 | +| ---------- | -------- | --------------------- | +| TCP 连接 | 10 秒 | 连接建立超时 | +| 握手等待 | 10 秒 | 等待 `handshake_ack` | +| 传输完成 | 60 秒 | 等待 `file_complete` | + +### 7.2 错误场景处理 + +| 场景 | Client 处理 | Server 处理 | +| --------------- | ------------------ | ---------------------- | +| TCP 连接失败 | 通知 UI,允许重试 | - | +| 握手超时 | 断开连接,通知 UI | 关闭 socket | +| 握手被拒绝 | 显示拒绝原因 | - | +| 数据块处理失败 | 中止传输,清理状态 | 清理临时文件 | +| 连接意外断开 | 清理状态,通知 UI | 清理临时文件 | +| 存储空间不足 | - | 发送 `accepted: false` | + +### 7.3 资源清理 + +**Client 端:** + +```typescript +function cleanup(): void { + // 1. 销毁文件读取流 + if (readStream) { + readStream.destroy(); + } + // 2. 清理传输状态 + activeTransfer = undefined; + // 3. 关闭 socket(如需要) + socket?.destroy(); +} +``` + +**Server 端:** + +```typescript +function cleanup(): void { + // 1. 关闭文件写入流 + if (writeStream) { + writeStream.end(); + } + // 2. 删除未完成的临时文件 + if (tempFilePath) { + fs.unlinkSync(tempFilePath); + } + // 3. 清理传输状态 + activeTransfer = undefined; +} +``` + +--- + +## 8. 常量与配置 + +### 8.1 协议常量 + +```typescript +// 协议版本(v1 = 控制 JSON + 二进制 chunk + 流式传输) +export const LAN_TRANSFER_PROTOCOL_VERSION = "1"; + +// 服务发现 +export const LAN_TRANSFER_SERVICE_TYPE = "cherrystudio"; +export const LAN_TRANSFER_SERVICE_FULL_NAME = "_cherrystudio._tcp"; + +// TCP 端口 +export const LAN_TRANSFER_TCP_PORT = 53317; + +// 文件传输(与二进制帧一致) +export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB +export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟 + +// 超时设置 +export const LAN_TRANSFER_HANDSHAKE_TIMEOUT_MS = 10_000; // 10秒 +export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; // 30秒 +export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000; // 60秒 +``` + +### 8.2 支持的文件类型 + +当前仅支持 ZIP 文件: + +```typescript +export const LAN_TRANSFER_ALLOWED_EXTENSIONS = [".zip"]; +export const LAN_TRANSFER_ALLOWED_MIME_TYPES = [ + "application/zip", + "application/x-zip-compressed", +]; +``` + +--- + +## 9. 完整时序图 + +### 9.1 完整传输流程(v1,流式传输) + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Renderer│ │ Main │ │ Mobile │ +│ (UI) │ │ Process │ │ Server │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ ════════════ 服务发现阶段 ════════════ │ + │ │ │ + │ startScan() │ │ + │────────────────────────────────────>│ │ + │ │ mDNS browse │ + │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─>│ + │ │ │ + │ │<─ ─ ─ service discovered ─ ─ ─ ─ ─ ─│ + │ │ │ + │<────── onServicesUpdated ───────────│ │ + │ │ │ + │ ════════════ 握手连接阶段 ════════════ │ + │ │ │ + │ connect(peer) │ │ + │────────────────────────────────────>│ │ + │ │──────── TCP Connect ───────────────>│ + │ │ │ + │ │──────── handshake ─────────────────>│ + │ │ │ + │ │<─────── handshake_ack ──────────────│ + │ │ │ + │ │──────── ping ──────────────────────>│ + │ │<─────── pong ───────────────────────│ + │ │ │ + │<────── connect result ──────────────│ │ + │ │ │ + │ ════════════ 文件传输阶段 ════════════ │ + │ │ │ + │ sendFile(path) │ │ + │────────────────────────────────────>│ │ + │ │──────── file_start ────────────────>│ + │ │ │ + │ │<─────── file_start_ack ─────────────│ + │ │ │ + │ │ │ + │ │══════ 循环发送数据块 ═══════════════│ + │ │ │ + │ │──────── file_chunk[0] (binary) ────>│ + │<────── progress event ──────────────│ │ + │ │ │ + │ │──────── file_chunk[1] (binary) ────>│ + │<────── progress event ──────────────│ │ + │ │ │ + │ │ ... 重复 ... │ + │ │ │ + │ │══════════════════════════════════════│ + │ │ │ + │ │──────── file_end ──────────────────>│ + │ │ │ + │ │<─────── file_complete ──────────────│ + │ │ │ + │<────── complete event ──────────────│ │ + │<────── sendFile result ─────────────│ │ + │ │ │ +``` + +--- + +## 10. 移动端实现指南(v1 要点) + +### 10.1 必须实现的功能 + +1. **mDNS 服务发布** + + - 发布 `_cherrystudio._tcp` 服务 + - 提供 TCP 端口号 `53317` + - 可选:TXT 记录(版本、平台信息) + +2. **TCP 服务端** + + - 监听指定端口 + - 支持单连接或多连接 + +3. **消息解析** + + - 控制消息:UTF-8 + `\n` JSON + - 数据消息:二进制帧(Magic+TotalLen 分帧) + +4. **握手处理** + + - 验证 `handshake` 消息 + - 发送 `handshake_ack` 响应 + - 响应 `ping` 消息 + +5. **文件接收(流式模式)** + - 解析 `file_start`,准备接收 + - 接收 `file_chunk` 二进制帧,直接写入文件/缓冲并增量哈希 + - v1 不发送 per-chunk ACK(流式传输) + - 处理 `file_end`,完成增量哈希并校验 checksum + - 发送 `file_complete` 结果 + +### 10.2 推荐的库 + +**React Native / Expo:** + +- mDNS: `react-native-zeroconf` 或 `@homielab/react-native-bonjour` +- TCP: `react-native-tcp-socket` +- Crypto: `expo-crypto` 或 `react-native-quick-crypto` + +### 10.3 接收端伪代码 + +```typescript +class FileReceiver { + private transfer?: { + id: string; + fileName: string; + fileSize: number; + checksum: string; + totalChunks: number; + receivedChunks: number; + tempPath: string; + // v1: 边收边写文件,避免大文件 OOM + // stream: FileSystem writable stream (平台相关封装) + }; + + handleMessage(message: any) { + switch (message.type) { + case "handshake": + this.handleHandshake(message); + break; + case "ping": + this.sendPong(message); + break; + case "file_start": + this.handleFileStart(message); + break; + // v1: file_chunk 为二进制帧,不再走 JSON 分支 + case "file_end": + this.handleFileEnd(message); + break; + } + } + + handleFileStart(msg: LanTransferFileStartMessage) { + // 1. 检查存储空间 + // 2. 创建临时文件 + // 3. 初始化传输状态 + // 4. 发送 file_start_ack + } + + // v1: 二进制帧处理在 socket data 流中解析,随后调用 handleBinaryFileChunk + handleBinaryFileChunk(transferId: string, chunkIndex: number, data: Buffer) { + // 直接使用二进制数据,按 chunkSize/lastChunk 计算长度 + // 写入文件流并更新增量 SHA-256 + this.transfer.receivedChunks++; + // v1: 流式传输,不发送 per-chunk ACK + } + + handleFileEnd(msg: LanTransferFileEndMessage) { + // 1. 合并所有数据块 + // 2. 验证完整文件 checksum + // 3. 写入最终位置 + // 4. 发送 file_complete + } +} +``` + +--- + +## 附录 A:TypeScript 类型定义 + +完整的类型定义位于 `packages/shared/config/types.ts`: + +```typescript +// 握手消息 +export interface LanTransferHandshakeMessage { + type: "handshake"; + deviceName: string; + version: string; + platform?: string; + appVersion?: string; +} + +export interface LanTransferHandshakeAckMessage { + type: "handshake_ack"; + accepted: boolean; + message?: string; +} + +// 心跳消息 +export interface LanTransferPingMessage { + type: "ping"; + payload?: string; +} + +export interface LanTransferPongMessage { + type: "pong"; + received: boolean; + payload?: string; +} + +// 文件传输消息 (Client -> Server) +export interface LanTransferFileStartMessage { + type: "file_start"; + transferId: string; + fileName: string; + fileSize: number; + mimeType: string; + checksum: string; + totalChunks: number; + chunkSize: number; +} + +export interface LanTransferFileChunkMessage { + type: "file_chunk"; + transferId: string; + chunkIndex: number; + data: string; // Base64 encoded (v1: 二进制帧模式下不使用) +} + +export interface LanTransferFileEndMessage { + type: "file_end"; + transferId: string; +} + +// 文件传输响应消息 (Server -> Client) +export interface LanTransferFileStartAckMessage { + type: "file_start_ack"; + transferId: string; + accepted: boolean; + message?: string; +} + +// v1 流式不发送 per-chunk ACK,以下类型仅用于向后兼容参考 +export interface LanTransferFileChunkAckMessage { + type: "file_chunk_ack"; + transferId: string; + chunkIndex: number; + received: boolean; + error?: string; +} + +export interface LanTransferFileCompleteMessage { + type: "file_complete"; + transferId: string; + success: boolean; + filePath?: string; + error?: string; +} + +// 常量 +export const LAN_TRANSFER_TCP_PORT = 53317; +export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024; +export const LAN_TRANSFER_CHUNK_TIMEOUT_MS = 30_000; +``` + +--- + +## 附录 B:版本历史 + +| 版本 | 日期 | 变更 | +| ---- | ------- | ---------------------------------------- | +| 1.0 | 2025-12 | 初始发布版本,支持二进制帧格式与流式传输 | diff --git a/package.json b/package.json index 1f56cc31fd..9560089333 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch", "@paymoapp/electron-shutdown-handler": "^1.1.2", "@strongtz/win32-arm64-msvc": "^0.4.7", + "bonjour-service": "^1.3.0", "emoji-picker-element-data": "^1", "express": "^5.1.0", "font-list": "^2.0.0", @@ -97,10 +98,8 @@ "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", "os-proxy-config": "^1.1.2", - "qrcode.react": "^4.2.0", "selection-hook": "^1.0.12", "sharp": "^0.34.3", - "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tesseract.js": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index aec1d57b43..c97b258676 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -233,6 +233,8 @@ export enum IpcChannel { Backup_ListS3Files = 'backup:listS3Files', Backup_DeleteS3File = 'backup:deleteS3File', Backup_CheckS3Connection = 'backup:checkS3Connection', + Backup_CreateLanTransferBackup = 'backup:createLanTransferBackup', + Backup_DeleteTempBackup = 'backup:deleteTempBackup', // zip Zip_Compress = 'zip:compress', @@ -381,10 +383,14 @@ export enum IpcChannel { ClaudeCodePlugin_ReadContent = 'claudeCodePlugin:read-content', ClaudeCodePlugin_WriteContent = 'claudeCodePlugin:write-content', - // WebSocket - WebSocket_Start = 'webSocket:start', - WebSocket_Stop = 'webSocket:stop', - WebSocket_Status = 'webSocket:status', - WebSocket_SendFile = 'webSocket:send-file', - WebSocket_GetAllCandidates = 'webSocket:get-all-candidates' + // Local Transfer + LocalTransfer_ListServices = 'local-transfer:list', + LocalTransfer_StartScan = 'local-transfer:start-scan', + LocalTransfer_StopScan = 'local-transfer:stop-scan', + LocalTransfer_ServicesUpdated = 'local-transfer:services-updated', + LocalTransfer_Connect = 'local-transfer:connect', + LocalTransfer_Disconnect = 'local-transfer:disconnect', + LocalTransfer_ClientEvent = 'local-transfer:client-event', + LocalTransfer_SendFile = 'local-transfer:send-file', + LocalTransfer_CancelTransfer = 'local-transfer:cancel-transfer' } diff --git a/packages/shared/config/types.ts b/packages/shared/config/types.ts index 7dff53c753..56f746b0d5 100644 --- a/packages/shared/config/types.ts +++ b/packages/shared/config/types.ts @@ -52,3 +52,196 @@ export interface WebSocketCandidatesResponse { interface: string priority: number } + +export type LocalTransferPeer = { + id: string + name: string + host?: string + fqdn?: string + port?: number + type?: string + protocol?: 'tcp' | 'udp' + addresses: string[] + txt?: Record + updatedAt: number +} + +export type LocalTransferState = { + services: LocalTransferPeer[] + isScanning: boolean + lastScanStartedAt?: number + lastUpdatedAt: number + lastError?: string +} + +export type LanHandshakeRequestMessage = { + type: 'handshake' + deviceName: string + version: string + platform?: string + appVersion?: string +} + +export type LanHandshakeAckMessage = { + type: 'handshake_ack' + accepted: boolean + message?: string +} + +export type LocalTransferConnectPayload = { + peerId: string + metadata?: Record + timeoutMs?: number +} + +export type LanClientEvent = + | { + type: 'ping_sent' + payload: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'pong' + payload?: string + received?: boolean + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'socket_closed' + reason?: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'error' + message: string + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'file_transfer_progress' + transferId: string + fileName: string + bytesSent: number + totalBytes: number + chunkIndex: number + totalChunks: number + progress: number // 0-100 + speed: number // bytes/sec + timestamp: number + peerId?: string + peerName?: string + } + | { + type: 'file_transfer_complete' + transferId: string + fileName: string + success: boolean + filePath?: string + error?: string + timestamp: number + peerId?: string + peerName?: string + } + +// ============================================================================= +// LAN File Transfer Protocol Types +// ============================================================================= + +// Constants for file transfer +export const LAN_TRANSFER_TCP_PORT = 53317 +export const LAN_TRANSFER_CHUNK_SIZE = 512 * 1024 // 512KB +export const LAN_TRANSFER_MAX_FILE_SIZE = 500 * 1024 * 1024 // 500MB +export const LAN_TRANSFER_COMPLETE_TIMEOUT_MS = 60_000 // 60s - wait for file_complete after file_end +export const LAN_TRANSFER_GLOBAL_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes - global transfer timeout + +// Binary protocol constants (v1) +export const LAN_TRANSFER_PROTOCOL_VERSION = '1' +export const LAN_BINARY_FRAME_MAGIC = 0x4353 // "CS" as uint16 +export const LAN_BINARY_TYPE_FILE_CHUNK = 0x01 + +// Messages from Electron (Client/Sender) to Mobile (Server/Receiver) + +/** Request to start file transfer */ +export type LanFileStartMessage = { + type: 'file_start' + transferId: string + fileName: string + fileSize: number + mimeType: string // 'application/zip' + checksum: string // SHA-256 of entire file + totalChunks: number + chunkSize: number +} + +/** + * File chunk data (JSON format) + * @deprecated Use binary frame format in protocol v1. This type is kept for reference only. + */ +export type LanFileChunkMessage = { + type: 'file_chunk' + transferId: string + chunkIndex: number + data: string // Base64 encoded + chunkChecksum: string // SHA-256 of this chunk +} + +/** Notification that all chunks have been sent */ +export type LanFileEndMessage = { + type: 'file_end' + transferId: string +} + +/** Request to cancel file transfer */ +export type LanFileCancelMessage = { + type: 'file_cancel' + transferId: string + reason?: string +} + +// Messages from Mobile (Server/Receiver) to Electron (Client/Sender) + +/** Acknowledgment of file transfer request */ +export type LanFileStartAckMessage = { + type: 'file_start_ack' + transferId: string + accepted: boolean + message?: string // Rejection reason +} + +/** + * Acknowledgment of file chunk received + * @deprecated Protocol v1 uses streaming mode without per-chunk acknowledgment. + * This type is kept for backward compatibility reference only. + */ +export type LanFileChunkAckMessage = { + type: 'file_chunk_ack' + transferId: string + chunkIndex: number + received: boolean + message?: string +} + +/** Final result of file transfer */ +export type LanFileCompleteMessage = { + type: 'file_complete' + transferId: string + success: boolean + filePath?: string // Path where file was saved on mobile + error?: string + // Enhanced error diagnostics + errorCode?: 'CHECKSUM_MISMATCH' | 'INCOMPLETE_TRANSFER' | 'DISK_ERROR' | 'CANCELLED' + receivedChunks?: number + receivedBytes?: number +} + +/** Payload for sending a file via IPC */ +export type LanFileSendPayload = { + filePath: string +} diff --git a/src/main/index.ts b/src/main/index.ts index 3588a370ff..657c31dfc4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,8 +19,10 @@ import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' import { appMenuService } from './services/AppMenuService' import { configManager } from './services/ConfigManager' -import { nodeTraceService } from './services/NodeTraceService' +import { lanTransferClientService } from './services/lanTransfer' import mcpService from './services/MCPService' +import { localTransferService } from './services/LocalTransferService' +import { nodeTraceService } from './services/NodeTraceService' import powerMonitorService from './services/PowerMonitorService' import { CHERRY_STUDIO_PROTOCOL, @@ -156,6 +158,7 @@ if (!app.requestSingleInstanceLock()) { registerShortcuts(mainWindow) registerIpc(mainWindow, app) + localTransferService.startDiscovery({ resetList: true }) replaceDevtoolsFont(mainWindow) @@ -237,6 +240,9 @@ if (!app.requestSingleInstanceLock()) { if (selectionService) { selectionService.quit() } + + lanTransferClientService.dispose() + localTransferService.dispose() }) app.on('will-quit', async () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4cb3402414..0bebb62fca 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -18,6 +18,7 @@ import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { UpgradeChannel } from '@shared/config/constant' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' +import type { LocalTransferConnectPayload } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { PluginError } from '@types' import type { @@ -49,6 +50,8 @@ import { ExportService } from './services/ExportService' import { fileStorage as fileManager } from './services/FileStorage' import FileService from './services/FileSystemService' import KnowledgeService from './services/KnowledgeService' +import { lanTransferClientService } from './services/lanTransfer' +import { localTransferService } from './services/LocalTransferService' import mcpService from './services/MCPService' import MemoryService from './services/memory/MemoryService' import { openTraceWindow, setTraceWindowTitle } from './services/NodeTraceService' @@ -80,7 +83,6 @@ import { import storeSyncService from './services/StoreSyncService' import { themeService } from './services/ThemeService' import VertexAIService from './services/VertexAIService' -import WebSocketService from './services/WebSocketService' import { setOpenLinkExternal } from './services/WebviewService' import { windowService } from './services/WindowService' import { calculateDirectorySize, getResourcePath } from './utils' @@ -583,6 +585,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File.bind(backupManager)) ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection.bind(backupManager)) + ipcMain.handle(IpcChannel.Backup_CreateLanTransferBackup, backupManager.createLanTransferBackup.bind(backupManager)) + ipcMain.handle(IpcChannel.Backup_DeleteTempBackup, backupManager.deleteTempBackup.bind(backupManager)) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open.bind(fileManager)) @@ -1114,12 +1118,17 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { } }) - // WebSocket - ipcMain.handle(IpcChannel.WebSocket_Start, WebSocketService.start) - ipcMain.handle(IpcChannel.WebSocket_Stop, WebSocketService.stop) - ipcMain.handle(IpcChannel.WebSocket_Status, WebSocketService.getStatus) - ipcMain.handle(IpcChannel.WebSocket_SendFile, WebSocketService.sendFile) - ipcMain.handle(IpcChannel.WebSocket_GetAllCandidates, WebSocketService.getAllCandidates) + ipcMain.handle(IpcChannel.LocalTransfer_ListServices, () => localTransferService.getState()) + ipcMain.handle(IpcChannel.LocalTransfer_StartScan, () => localTransferService.startDiscovery({ resetList: true })) + ipcMain.handle(IpcChannel.LocalTransfer_StopScan, () => localTransferService.stopDiscovery()) + ipcMain.handle(IpcChannel.LocalTransfer_Connect, (_, payload: LocalTransferConnectPayload) => + lanTransferClientService.connectAndHandshake(payload) + ) + ipcMain.handle(IpcChannel.LocalTransfer_Disconnect, () => lanTransferClientService.disconnect()) + ipcMain.handle(IpcChannel.LocalTransfer_SendFile, (_, payload: { filePath: string }) => + lanTransferClientService.sendFile(payload.filePath) + ) + ipcMain.handle(IpcChannel.LocalTransfer_CancelTransfer, () => lanTransferClientService.cancelTransfer()) ipcMain.handle(IpcChannel.APP_CrashRenderProcess, () => { mainWindow.webContents.forcefullyCrashRenderer() diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index f331254fdf..46b78ed5a9 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -767,6 +767,56 @@ class BackupManager { const s3Client = this.getS3Storage(s3Config) return await s3Client.checkConnection() } + + /** + * Create a temporary backup for LAN transfer + * Creates a lightweight backup (skipBackupFile=true) in the temp directory + * Returns the path to the created ZIP file + */ + async createLanTransferBackup(_: Electron.IpcMainInvokeEvent, data: string): Promise { + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 12) + const fileName = `cherry-studio.${timestamp}.zip` + const tempPath = path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer') + + // Ensure temp directory exists + await fs.ensureDir(tempPath) + + // Create backup with skipBackupFile=true (no Data folder) + const backupedFilePath = await this.backup(_, fileName, data, tempPath, true) + + logger.info(`[BackupManager] Created LAN transfer backup at: ${backupedFilePath}`) + return backupedFilePath + } + + /** + * Delete a temporary backup file after LAN transfer completes + */ + async deleteTempBackup(_: Electron.IpcMainInvokeEvent, filePath: string): Promise { + try { + // Security check: only allow deletion within temp directory + const tempBase = path.normalize(path.join(app.getPath('temp'), 'cherry-studio', 'lan-transfer')) + const resolvedPath = path.normalize(path.resolve(filePath)) + + // Use normalized paths with trailing separator to prevent prefix attacks (e.g., /temp-evil) + if (!resolvedPath.startsWith(tempBase + path.sep) && resolvedPath !== tempBase) { + logger.warn(`[BackupManager] Attempted to delete file outside temp directory: ${filePath}`) + return false + } + + if (await fs.pathExists(resolvedPath)) { + await fs.remove(resolvedPath) + logger.info(`[BackupManager] Deleted temp backup: ${resolvedPath}`) + return true + } + return false + } catch (error) { + logger.error('[BackupManager] Failed to delete temp backup:', error as Error) + return false + } + } } export default BackupManager diff --git a/src/main/services/LocalTransferService.ts b/src/main/services/LocalTransferService.ts new file mode 100644 index 0000000000..bc2743757c --- /dev/null +++ b/src/main/services/LocalTransferService.ts @@ -0,0 +1,207 @@ +import { loggerService } from '@logger' +import type { LocalTransferPeer, LocalTransferState } from '@shared/config/types' +import { IpcChannel } from '@shared/IpcChannel' +import type { Browser, Service } from 'bonjour-service' +import Bonjour from 'bonjour-service' + +import { windowService } from './WindowService' + +const SERVICE_TYPE = 'cherrystudio' +const SERVICE_PROTOCOL = 'tcp' as const + +const logger = loggerService.withContext('LocalTransferService') + +type StartDiscoveryOptions = { + resetList?: boolean +} + +class LocalTransferService { + private static instance: LocalTransferService + private bonjour: Bonjour | null = null + private browser: Browser | null = null + private services = new Map() + private isScanning = false + private lastScanStartedAt?: number + private lastUpdatedAt = Date.now() + private lastError?: string + + private constructor() {} + + public static getInstance(): LocalTransferService { + if (!LocalTransferService.instance) { + LocalTransferService.instance = new LocalTransferService() + } + return LocalTransferService.instance + } + + public startDiscovery(options?: StartDiscoveryOptions): LocalTransferState { + if (options?.resetList) { + this.services.clear() + } + + this.isScanning = true + this.lastScanStartedAt = Date.now() + this.lastUpdatedAt = Date.now() + this.lastError = undefined + this.restartBrowser() + this.broadcastState() + return this.getState() + } + + public stopDiscovery(): LocalTransferState { + if (this.browser) { + try { + this.browser.stop() + } catch (error) { + logger.warn('Failed to stop local transfer browser', error as Error) + } + } + this.isScanning = false + this.lastUpdatedAt = Date.now() + this.broadcastState() + return this.getState() + } + + public getState(): LocalTransferState { + const services = Array.from(this.services.values()).sort((a, b) => a.name.localeCompare(b.name)) + return { + services, + isScanning: this.isScanning, + lastScanStartedAt: this.lastScanStartedAt, + lastUpdatedAt: this.lastUpdatedAt, + lastError: this.lastError + } + } + + public getPeerById(id: string): LocalTransferPeer | undefined { + return this.services.get(id) + } + + public dispose(): void { + this.stopDiscovery() + this.services.clear() + this.browser?.removeAllListeners() + this.browser = null + if (this.bonjour) { + try { + this.bonjour.destroy() + } catch (error) { + logger.warn('Failed to destroy Bonjour instance', error as Error) + } + this.bonjour = null + } + } + + private getBonjour(): Bonjour { + if (!this.bonjour) { + this.bonjour = new Bonjour() + } + return this.bonjour + } + + private restartBrowser(): void { + // Clean up existing browser + if (this.browser) { + this.browser.removeAllListeners() + try { + this.browser.stop() + } catch (error) { + logger.warn('Error while stopping Bonjour browser', error as Error) + } + this.browser = null + } + + // Destroy and recreate Bonjour instance to prevent socket leaks + if (this.bonjour) { + try { + this.bonjour.destroy() + } catch (error) { + logger.warn('Error while destroying Bonjour instance', error as Error) + } + this.bonjour = null + } + + const browser = this.getBonjour().find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL }) + this.browser = browser + this.bindBrowserEvents(browser) + + try { + browser.start() + logger.info('Local transfer discovery started') + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + this.lastError = err.message + logger.error('Failed to start local transfer discovery', err) + } + } + + private bindBrowserEvents(browser: Browser) { + browser.on('up', (service) => { + const peer = this.normalizeService(service) + logger.info(`LAN peer detected: ${peer.name} (${peer.addresses.join(', ')})`) + this.services.set(peer.id, peer) + this.lastUpdatedAt = Date.now() + this.broadcastState() + }) + + browser.on('down', (service) => { + const key = this.buildServiceKey(service.fqdn || service.name, service.host, service.port) + if (this.services.delete(key)) { + logger.info(`LAN peer removed: ${service.name}`) + this.lastUpdatedAt = Date.now() + this.broadcastState() + } + }) + + browser.on('error', (error) => { + const err = error instanceof Error ? error : new Error(String(error)) + logger.error('Local transfer discovery error', err) + this.lastError = err.message + this.broadcastState() + }) + } + + private normalizeService(service: Service): LocalTransferPeer { + const addressCandidates = [...(service.addresses || []), service.referer?.address].filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + const addresses = Array.from(new Set(addressCandidates)) + const txtEntries = Object.entries(service.txt || {}) + const txt = + txtEntries.length > 0 + ? Object.fromEntries( + txtEntries.map(([key, value]) => [key, value === undefined || value === null ? '' : String(value)]) + ) + : undefined + + const peer: LocalTransferPeer = { + id: this.buildServiceKey(service.fqdn || service.name, service.host, service.port), + name: service.name, + host: service.host, + fqdn: service.fqdn, + port: service.port, + type: service.type, + protocol: service.protocol, + addresses, + txt, + updatedAt: Date.now() + } + + return peer + } + + private buildServiceKey(name?: string, host?: string, port?: number): string { + const raw = [name, host, port?.toString()].filter(Boolean).join('-') + return raw || `service-${Date.now()}` + } + + private broadcastState() { + const mainWindow = windowService.getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send(IpcChannel.LocalTransfer_ServicesUpdated, this.getState()) + } +} + +export const localTransferService = LocalTransferService.getInstance() diff --git a/src/main/services/WebSocketService.ts b/src/main/services/WebSocketService.ts deleted file mode 100644 index e52919e96a..0000000000 --- a/src/main/services/WebSocketService.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { loggerService } from '@logger' -import type { WebSocketCandidatesResponse, WebSocketStatusResponse } from '@shared/config/types' -import * as fs from 'fs' -import { networkInterfaces } from 'os' -import * as path from 'path' -import type { Socket } from 'socket.io' -import { Server } from 'socket.io' - -import { windowService } from './WindowService' - -const logger = loggerService.withContext('WebSocketService') - -class WebSocketService { - private io: Server | null = null - private isStarted = false - private port = 7017 - private connectedClients = new Set() - - private getLocalIpAddress(): string | undefined { - const interfaces = networkInterfaces() - - // 按优先级排序的网络接口名称模式 - const interfacePriority = [ - // macOS: 以太网/Wi-Fi 优先 - /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) - /^(en|eth)[0-9]+$/, // 以太网接口 - /^wlan[0-9]+$/, // 无线接口 - // Windows: 以太网/Wi-Fi 优先 - /^(Ethernet|Wi-Fi|Local Area Connection)/, - /^(Wi-Fi|无线网络连接)/, - // Linux: 以太网/Wi-Fi 优先 - /^(eth|enp|wlp|wlan)[0-9]+/, - // 虚拟化接口(低优先级) - /^bridge[0-9]+$/, // Docker bridge - /^veth[0-9]+$/, // Docker veth - /^docker[0-9]+/, // Docker interfaces - /^br-[0-9a-f]+/, // Docker bridge - /^vmnet[0-9]+$/, // VMware - /^vboxnet[0-9]+$/, // VirtualBox - // VPN 隧道接口(低优先级) - /^utun[0-9]+$/, // macOS VPN - /^tun[0-9]+$/, // Linux/Unix VPN - /^tap[0-9]+$/, // TAP interfaces - /^tailscale[0-9]*$/, // Tailscale VPN - /^wg[0-9]+$/ // WireGuard VPN - ] - - const candidates: Array<{ interface: string; address: string; priority: number }> = [] - - for (const [name, ifaces] of Object.entries(interfaces)) { - for (const iface of ifaces || []) { - if (iface.family === 'IPv4' && !iface.internal) { - // 计算接口优先级 - let priority = 999 // 默认最低优先级 - for (let i = 0; i < interfacePriority.length; i++) { - if (interfacePriority[i].test(name)) { - priority = i - break - } - } - - candidates.push({ - interface: name, - address: iface.address, - priority - }) - } - } - } - - if (candidates.length === 0) { - logger.warn('无法获取局域网 IP,使用默认 IP: 127.0.0.1') - return '127.0.0.1' - } - - // 按优先级排序,选择优先级最高的 - candidates.sort((a, b) => a.priority - b.priority) - const best = candidates[0] - - logger.info(`获取局域网 IP: ${best.address} (interface: ${best.interface})`) - return best.address - } - - public start = async (): Promise<{ success: boolean; port?: number; error?: string }> => { - if (this.isStarted && this.io) { - return { success: true, port: this.port } - } - - try { - this.io = new Server(this.port, { - cors: { - origin: '*', - methods: ['GET', 'POST'] - }, - transports: ['websocket', 'polling'], - allowEIO3: true, - pingTimeout: 60000, - pingInterval: 25000 - }) - - this.io.on('connection', (socket: Socket) => { - this.connectedClients.add(socket.id) - - const mainWindow = windowService.getMainWindow() - if (!mainWindow) { - logger.error('Main window is null, cannot send connection event') - } else { - mainWindow.webContents.send('websocket-client-connected', { - connected: true, - clientId: socket.id - }) - logger.info(`Connection event sent to renderer, total clients: ${this.connectedClients.size}`) - } - - socket.on('message', (data) => { - logger.info('Received message from mobile:', data) - mainWindow?.webContents.send('websocket-message-received', data) - socket.emit('message_received', { success: true }) - }) - - socket.on('disconnect', () => { - logger.info(`Client disconnected: ${socket.id}`) - this.connectedClients.delete(socket.id) - - if (this.connectedClients.size === 0) { - mainWindow?.webContents.send('websocket-client-connected', { - connected: false, - clientId: socket.id - }) - } - }) - }) - - // Engine 层面的事件监听 - this.io.engine.on('connection_error', (err) => { - logger.error('Engine connection error:', err) - }) - - this.io.engine.on('connection', (rawSocket) => { - const remoteAddr = rawSocket.request.connection.remoteAddress - logger.info(`[Engine] Raw connection from: ${remoteAddr}`) - logger.info(`[Engine] Transport: ${rawSocket.transport.name}`) - - rawSocket.on('packet', (packet: { type: string; data?: any }) => { - logger.info( - `[Engine] ← Packet from ${remoteAddr}: type="${packet.type}"`, - packet.data ? { data: packet.data } : {} - ) - }) - - rawSocket.on('packetCreate', (packet: { type: string; data?: any }) => { - logger.info(`[Engine] → Packet to ${remoteAddr}: type="${packet.type}"`) - }) - - rawSocket.on('close', (reason: string) => { - logger.warn(`[Engine] Connection closed from ${remoteAddr}, reason: ${reason}`) - }) - - rawSocket.on('error', (error: Error) => { - logger.error(`[Engine] Connection error from ${remoteAddr}:`, error) - }) - }) - - // Socket.IO 握手失败监听 - this.io.on('connection_error', (err) => { - logger.error('[Socket.IO] Connection error during handshake:', err) - }) - - this.isStarted = true - logger.info(`WebSocket server started on port ${this.port}`) - - return { success: true, port: this.port } - } catch (error) { - logger.error('Failed to start WebSocket server:', error as Error) - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - } - } - } - - public stop = async (): Promise<{ success: boolean }> => { - if (!this.isStarted || !this.io) { - return { success: true } - } - - try { - await new Promise((resolve) => { - this.io!.close(() => { - resolve() - }) - }) - - this.io = null - this.isStarted = false - this.connectedClients.clear() - logger.info('WebSocket server stopped') - - return { success: true } - } catch (error) { - logger.error('Failed to stop WebSocket server:', error as Error) - return { success: false } - } - } - - public getStatus = async (): Promise => { - return { - isRunning: this.isStarted, - port: this.isStarted ? this.port : undefined, - ip: this.isStarted ? this.getLocalIpAddress() : undefined, - clientConnected: this.connectedClients.size > 0 - } - } - - public getAllCandidates = async (): Promise => { - const interfaces = networkInterfaces() - - // 按优先级排序的网络接口名称模式 - const interfacePriority = [ - // macOS: 以太网/Wi-Fi 优先 - /^en[0-9]+$/, // en0, en1 (以太网/Wi-Fi) - /^(en|eth)[0-9]+$/, // 以太网接口 - /^wlan[0-9]+$/, // 无线接口 - // Windows: 以太网/Wi-Fi 优先 - /^(Ethernet|Wi-Fi|Local Area Connection)/, - /^(Wi-Fi|无线网络连接)/, - // Linux: 以太网/Wi-Fi 优先 - /^(eth|enp|wlp|wlan)[0-9]+/, - // 虚拟化接口(低优先级) - /^bridge[0-9]+$/, // Docker bridge - /^veth[0-9]+$/, // Docker veth - /^docker[0-9]+/, // Docker interfaces - /^br-[0-9a-f]+/, // Docker bridge - /^vmnet[0-9]+$/, // VMware - /^vboxnet[0-9]+$/, // VirtualBox - // VPN 隧道接口(低优先级) - /^utun[0-9]+$/, // macOS VPN - /^tun[0-9]+$/, // Linux/Unix VPN - /^tap[0-9]+$/, // TAP interfaces - /^tailscale[0-9]*$/, // Tailscale VPN - /^wg[0-9]+$/ // WireGuard VPN - ] - - const candidates: Array<{ host: string; interface: string; priority: number }> = [] - - for (const [name, ifaces] of Object.entries(interfaces)) { - for (const iface of ifaces || []) { - if (iface.family === 'IPv4' && !iface.internal) { - // 计算接口优先级 - let priority = 999 // 默认最低优先级 - for (let i = 0; i < interfacePriority.length; i++) { - if (interfacePriority[i].test(name)) { - priority = i - break - } - } - - candidates.push({ - host: iface.address, - interface: name, - priority - }) - - logger.debug(`Found interface: ${name} -> ${iface.address} (priority: ${priority})`) - } - } - } - - // 按优先级排序返回 - candidates.sort((a, b) => a.priority - b.priority) - logger.info( - `Found ${candidates.length} IP candidates: ${candidates.map((c) => `${c.host}(${c.interface})`).join(', ')}` - ) - return candidates - } - - public sendFile = async ( - _: Electron.IpcMainInvokeEvent, - filePath: string - ): Promise<{ success: boolean; error?: string }> => { - if (!this.isStarted || !this.io) { - const errorMsg = 'WebSocket server is not running.' - logger.error(errorMsg) - return { success: false, error: errorMsg } - } - - if (this.connectedClients.size === 0) { - const errorMsg = 'No client connected.' - logger.error(errorMsg) - return { success: false, error: errorMsg } - } - - const mainWindow = windowService.getMainWindow() - - return new Promise((resolve, reject) => { - const stats = fs.statSync(filePath) - const totalSize = stats.size - const filename = path.basename(filePath) - const stream = fs.createReadStream(filePath) - let bytesSent = 0 - const startTime = Date.now() - - logger.info(`Starting file transfer: ${filename} (${this.formatFileSize(totalSize)})`) - - // 向客户端发送文件开始的信号,包含文件名和总大小 - this.io!.emit('zip-file-start', { filename, totalSize }) - - stream.on('data', (chunk) => { - bytesSent += chunk.length - const progress = (bytesSent / totalSize) * 100 - - // 向客户端发送文件块 - this.io!.emit('zip-file-chunk', chunk) - - // 向渲染进程发送进度更新 - mainWindow?.webContents.send('file-send-progress', { progress }) - - // 每10%记录一次进度 - if (Math.floor(progress) % 10 === 0) { - const elapsed = (Date.now() - startTime) / 1000 - const speed = elapsed > 0 ? bytesSent / elapsed : 0 - logger.info(`Transfer progress: ${Math.floor(progress)}% (${this.formatFileSize(speed)}/s)`) - } - }) - - stream.on('end', () => { - const totalTime = (Date.now() - startTime) / 1000 - const avgSpeed = totalTime > 0 ? totalSize / totalTime : 0 - logger.info( - `File transfer completed: ${filename} in ${totalTime.toFixed(1)}s (${this.formatFileSize(avgSpeed)}/s)` - ) - - // 确保发送100%的进度 - mainWindow?.webContents.send('file-send-progress', { progress: 100 }) - // 向客户端发送文件结束的信号 - this.io!.emit('zip-file-end') - resolve({ success: true }) - }) - - stream.on('error', (error) => { - logger.error(`File transfer failed: ${filename}`, error) - reject({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }) - }) - }) - } - - private formatFileSize(bytes: number): string { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] - } -} - -export default new WebSocketService() diff --git a/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts b/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts new file mode 100644 index 0000000000..062d140eb5 --- /dev/null +++ b/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Use vi.hoisted to define mocks that are available during hoisting +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + } +})) + +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => mockLogger + } +})) + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((key: string) => { + if (key === 'temp') return '/tmp' + if (key === 'userData') return '/mock/userData' + return '/mock/unknown' + }) + } +})) + +vi.mock('fs-extra', () => ({ + default: { + pathExists: vi.fn(), + remove: vi.fn(), + ensureDir: vi.fn(), + copy: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + createWriteStream: vi.fn(), + createReadStream: vi.fn() + }, + pathExists: vi.fn(), + remove: vi.fn(), + ensureDir: vi.fn(), + copy: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + createWriteStream: vi.fn(), + createReadStream: vi.fn() +})) + +vi.mock('../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn() + } +})) + +vi.mock('../WebDav', () => ({ + default: vi.fn() +})) + +vi.mock('../S3Storage', () => ({ + default: vi.fn() +})) + +vi.mock('../../utils', () => ({ + getDataPath: vi.fn(() => '/mock/data') +})) + +vi.mock('archiver', () => ({ + default: vi.fn() +})) + +vi.mock('node-stream-zip', () => ({ + default: vi.fn() +})) + +// Import after mocks +import * as fs from 'fs-extra' + +import BackupManager from '../BackupManager' + +describe('BackupManager.deleteTempBackup - Security Tests', () => { + let backupManager: BackupManager + + beforeEach(() => { + vi.clearAllMocks() + backupManager = new BackupManager() + }) + + describe('Normal Operations', () => { + it('should delete valid file in allowed directory', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/backup.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(validPath) + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Deleted temp backup')) + }) + + it('should delete file in nested subdirectory', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const nestedPath = '/tmp/cherry-studio/lan-transfer/sub/dir/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, nestedPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(nestedPath) + }) + + it('should return false when file does not exist', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false as never) + + const missingPath = '/tmp/cherry-studio/lan-transfer/missing.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, missingPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Path Traversal Attacks', () => { + it('should block basic directory traversal attack (../../../../etc/passwd)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.pathExists).not.toHaveBeenCalled() + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('outside temp directory')) + }) + + it('should block absolute path escape (/etc/passwd)', async () => { + const attackPath = '/etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should block traversal with multiple slashes', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block relative path traversal from current directory', async () => { + const attackPath = '../../../etc/passwd' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block traversal to parent directory', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer/../backup/secret.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Prefix Attacks', () => { + it('should block similar prefix attack (lan-transfer-evil)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transfer-evil/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should block path without separator (lan-transferx)', async () => { + const attackPath = '/tmp/cherry-studio/lan-transferx' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + + it('should block different temp directory prefix', async () => { + const attackPath = '/tmp-evil/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, attackPath) + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Error Handling', () => { + it('should return false and log error on permission denied', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockRejectedValue(new Error('EACCES: permission denied') as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(false) + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to delete'), expect.any(Error)) + }) + + it('should return false on fs.pathExists error', async () => { + vi.mocked(fs.pathExists).mockRejectedValue(new Error('ENOENT') as never) + + const validPath = '/tmp/cherry-studio/lan-transfer/file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, validPath) + + expect(result).toBe(false) + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should handle empty path string', async () => { + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, '') + + expect(result).toBe(false) + expect(fs.remove).not.toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should allow deletion of the temp directory itself', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const tempDir = '/tmp/cherry-studio/lan-transfer' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, tempDir) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalledWith(tempDir) + }) + + it('should handle path with trailing slash', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const pathWithSlash = '/tmp/cherry-studio/lan-transfer/sub/' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, pathWithSlash) + + // path.normalize removes trailing slash + expect(result).toBe(true) + }) + + it('should handle file with special characters in name', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const specialPath = '/tmp/cherry-studio/lan-transfer/file with spaces & (special).zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, specialPath) + + expect(result).toBe(true) + expect(fs.remove).toHaveBeenCalled() + }) + + it('should handle path with double slashes', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true as never) + vi.mocked(fs.remove).mockResolvedValue(undefined as never) + + const doubleSlashPath = '/tmp/cherry-studio//lan-transfer//file.zip' + const result = await backupManager.deleteTempBackup({} as Electron.IpcMainInvokeEvent, doubleSlashPath) + + // path.normalize handles double slashes + expect(result).toBe(true) + }) + }) +}) diff --git a/src/main/services/__tests__/LocalTransferService.test.ts b/src/main/services/__tests__/LocalTransferService.test.ts new file mode 100644 index 0000000000..d00c7c269b --- /dev/null +++ b/src/main/services/__tests__/LocalTransferService.test.ts @@ -0,0 +1,481 @@ +import { EventEmitter } from 'events' +import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' + +// Create mock objects before vi.mock calls +const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() +} + +let mockMainWindow: { + isDestroyed: Mock + webContents: { send: Mock } +} | null = null + +let mockBrowser: EventEmitter & { + start: Mock + stop: Mock + removeAllListeners: Mock +} + +let mockBonjour: { + find: Mock + destroy: Mock +} + +// Mock dependencies before importing the service +vi.mock('@logger', () => ({ + loggerService: { + withContext: () => mockLogger + } +})) + +vi.mock('../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => mockMainWindow) + } +})) + +vi.mock('bonjour-service', () => ({ + default: vi.fn(() => mockBonjour) +})) + +describe('LocalTransferService', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + + // Reset mock objects + mockMainWindow = { + isDestroyed: vi.fn(() => false), + webContents: { send: vi.fn() } + } + + mockBrowser = Object.assign(new EventEmitter(), { + start: vi.fn(), + stop: vi.fn(), + removeAllListeners: vi.fn() + }) + + mockBonjour = { + find: vi.fn(() => mockBrowser), + destroy: vi.fn() + } + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('startDiscovery', () => { + it('should set isScanning to true and start browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + const state = localTransferService.startDiscovery() + + expect(state.isScanning).toBe(true) + expect(state.lastScanStartedAt).toBeDefined() + expect(mockBonjour.find).toHaveBeenCalledWith({ type: 'cherrystudio', protocol: 'tcp' }) + expect(mockBrowser.start).toHaveBeenCalled() + }) + + it('should clear services when resetList is true', async () => { + const { localTransferService } = await import('../LocalTransferService') + + // First, start discovery and add a service + localTransferService.startDiscovery() + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(1) + + // Now restart with resetList + const state = localTransferService.startDiscovery({ resetList: true }) + + expect(state.services).toHaveLength(0) + }) + + it('should broadcast state after starting discovery', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + expect(mockMainWindow?.webContents.send).toHaveBeenCalled() + }) + + it('should handle browser.start() error', async () => { + mockBrowser.start.mockImplementation(() => { + throw new Error('Failed to start mDNS') + }) + + const { localTransferService } = await import('../LocalTransferService') + + const state = localTransferService.startDiscovery() + + expect(state.lastError).toBe('Failed to start mDNS') + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('stopDiscovery', () => { + it('should set isScanning to false and stop browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + const state = localTransferService.stopDiscovery() + + expect(state.isScanning).toBe(false) + expect(mockBrowser.stop).toHaveBeenCalled() + }) + + it('should handle browser.stop() error gracefully', async () => { + mockBrowser.stop.mockImplementation(() => { + throw new Error('Stop failed') + }) + + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Should not throw + expect(() => localTransferService.stopDiscovery()).not.toThrow() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should broadcast state after stopping', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + vi.clearAllMocks() + + localTransferService.stopDiscovery() + + expect(mockMainWindow?.webContents.send).toHaveBeenCalled() + }) + }) + + describe('browser events', () => { + it('should add service on "up" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local', + type: 'cherrystudio', + protocol: 'tcp' + }) + + const state = localTransferService.getState() + expect(state.services).toHaveLength(1) + expect(state.services[0].name).toBe('Test Service') + expect(state.services[0].port).toBe(12345) + expect(state.services[0].addresses).toContain('192.168.1.100') + }) + + it('should remove service on "down" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Add service + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(1) + + // Remove service + mockBrowser.emit('down', { + name: 'Test Service', + host: 'localhost', + port: 12345, + fqdn: 'test.local' + }) + + expect(localTransferService.getState().services).toHaveLength(0) + expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('removed')) + }) + + it('should set lastError on "error" event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('error', new Error('Discovery failed')) + + const state = localTransferService.getState() + expect(state.lastError).toBe('Discovery failed') + expect(mockLogger.error).toHaveBeenCalled() + }) + + it('should handle non-Error objects in error event', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('error', 'String error message') + + const state = localTransferService.getState() + expect(state.lastError).toBe('String error message') + }) + }) + + describe('getState', () => { + it('should return sorted services by name', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Zebra Service', + host: 'host1', + port: 1001, + addresses: ['192.168.1.1'] + }) + + mockBrowser.emit('up', { + name: 'Alpha Service', + host: 'host2', + port: 1002, + addresses: ['192.168.1.2'] + }) + + const state = localTransferService.getState() + expect(state.services[0].name).toBe('Alpha Service') + expect(state.services[1].name).toBe('Zebra Service') + }) + + it('should include all state properties', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + const state = localTransferService.getState() + + expect(state).toHaveProperty('services') + expect(state).toHaveProperty('isScanning') + expect(state).toHaveProperty('lastScanStartedAt') + expect(state).toHaveProperty('lastUpdatedAt') + }) + }) + + describe('getPeerById', () => { + it('should return peer when exists', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + fqdn: 'test.local' + }) + + const services = localTransferService.getState().services + const peer = localTransferService.getPeerById(services[0].id) + + expect(peer).toBeDefined() + expect(peer?.name).toBe('Test Service') + }) + + it('should return undefined when peer does not exist', async () => { + const { localTransferService } = await import('../LocalTransferService') + + const peer = localTransferService.getPeerById('non-existent-id') + + expect(peer).toBeUndefined() + }) + }) + + describe('normalizeService', () => { + it('should deduplicate addresses', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100', '192.168.1.100', '10.0.0.1'], + referer: { address: '192.168.1.100' } + }) + + const services = localTransferService.getState().services + expect(services[0].addresses).toHaveLength(2) + expect(services[0].addresses).toContain('192.168.1.100') + expect(services[0].addresses).toContain('10.0.0.1') + }) + + it('should filter empty addresses', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100', '', null as any] + }) + + const services = localTransferService.getState().services + expect(services[0].addresses).toEqual(['192.168.1.100']) + }) + + it('should convert txt null/undefined values to empty strings', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + txt: { + version: '1.0', + nullValue: null, + undefinedValue: undefined, + numberValue: 42 + } + }) + + const services = localTransferService.getState().services + expect(services[0].txt).toEqual({ + version: '1.0', + nullValue: '', + undefinedValue: '', + numberValue: '42' + }) + }) + + it('should not include txt when empty', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'], + txt: {} + }) + + const services = localTransferService.getState().services + expect(services[0].txt).toBeUndefined() + }) + }) + + describe('dispose', () => { + it('should clean up all resources', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + mockBrowser.emit('up', { + name: 'Test Service', + host: 'localhost', + port: 12345, + addresses: ['192.168.1.100'] + }) + + localTransferService.dispose() + + expect(localTransferService.getState().services).toHaveLength(0) + expect(localTransferService.getState().isScanning).toBe(false) + expect(mockBrowser.removeAllListeners).toHaveBeenCalled() + expect(mockBonjour.destroy).toHaveBeenCalled() + }) + + it('should handle bonjour.destroy() error gracefully', async () => { + mockBonjour.destroy.mockImplementation(() => { + throw new Error('Destroy failed') + }) + + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + // Should not throw + expect(() => localTransferService.dispose()).not.toThrow() + expect(mockLogger.warn).toHaveBeenCalled() + }) + + it('should be safe to call multiple times', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + + expect(() => { + localTransferService.dispose() + localTransferService.dispose() + }).not.toThrow() + }) + }) + + describe('broadcastState', () => { + it('should not throw when main window is null', async () => { + mockMainWindow = null + + const { localTransferService } = await import('../LocalTransferService') + + // Should not throw + expect(() => localTransferService.startDiscovery()).not.toThrow() + }) + + it('should not throw when main window is destroyed', async () => { + mockMainWindow = { + isDestroyed: vi.fn(() => true), + webContents: { send: vi.fn() } + } + + const { localTransferService } = await import('../LocalTransferService') + + // Should not throw + expect(() => localTransferService.startDiscovery()).not.toThrow() + expect(mockMainWindow.webContents.send).not.toHaveBeenCalled() + }) + }) + + describe('restartBrowser', () => { + it('should destroy old bonjour instance to prevent socket leaks', async () => { + const { localTransferService } = await import('../LocalTransferService') + + // First start + localTransferService.startDiscovery() + expect(mockBonjour.destroy).not.toHaveBeenCalled() + + // Restart - should destroy old instance + localTransferService.startDiscovery() + expect(mockBonjour.destroy).toHaveBeenCalled() + }) + + it('should remove all listeners from old browser', async () => { + const { localTransferService } = await import('../LocalTransferService') + + localTransferService.startDiscovery() + localTransferService.startDiscovery() + + expect(mockBrowser.removeAllListeners).toHaveBeenCalled() + }) + }) +}) diff --git a/src/main/services/lanTransfer/LanTransferClientService.ts b/src/main/services/lanTransfer/LanTransferClientService.ts new file mode 100644 index 0000000000..a6da2f1a20 --- /dev/null +++ b/src/main/services/lanTransfer/LanTransferClientService.ts @@ -0,0 +1,525 @@ +import * as crypto from 'node:crypto' +import { createConnection, type Socket } from 'node:net' + +import { loggerService } from '@logger' +import type { + LanClientEvent, + LanFileCompleteMessage, + LanHandshakeAckMessage, + LocalTransferConnectPayload, + LocalTransferPeer +} from '@shared/config/types' +import { LAN_TRANSFER_GLOBAL_TIMEOUT_MS } from '@shared/config/types' +import { IpcChannel } from '@shared/IpcChannel' + +import { localTransferService } from '../LocalTransferService' +import { windowService } from '../WindowService' +import { + abortTransfer, + buildHandshakeMessage, + calculateFileChecksum, + cleanupTransfer, + createDataHandler, + createTransferState, + formatFileSize, + HANDSHAKE_PROTOCOL_VERSION, + pickHost, + sendFileEnd, + sendFileStart, + sendTestPing, + streamFileChunks, + validateFile, + waitForFileComplete, + waitForFileStartAck +} from './handlers' +import { ResponseManager } from './responseManager' +import type { ActiveFileTransfer, ConnectionContext, FileTransferContext } from './types' + +const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000 + +const logger = loggerService.withContext('LanTransferClientService') + +/** + * LAN Transfer Client Service + * + * Handles outgoing file transfers to LAN peers via TCP. + * Protocol v1 with streaming mode (no per-chunk acknowledgment). + */ +class LanTransferClientService { + private socket: Socket | null = null + private currentPeer?: LocalTransferPeer + private dataHandler?: ReturnType + private responseManager = new ResponseManager() + private isConnecting = false + private activeTransfer?: ActiveFileTransfer + private lastConnectOptions?: LocalTransferConnectPayload + private consecutiveJsonErrors = 0 + private static readonly MAX_CONSECUTIVE_JSON_ERRORS = 3 + private reconnectPromise: Promise | null = null + + constructor() { + this.responseManager.setTimeoutCallback(() => void this.disconnect()) + } + + /** + * Connect to a LAN peer and perform handshake. + */ + public async connectAndHandshake(options: LocalTransferConnectPayload): Promise { + if (this.isConnecting) { + throw new Error('LAN transfer client is busy') + } + + const peer = localTransferService.getPeerById(options.peerId) + if (!peer) { + throw new Error('Selected LAN peer is no longer available') + } + if (!peer.port) { + throw new Error('Selected peer does not expose a TCP port') + } + + const host = pickHost(peer) + if (!host) { + throw new Error('Unable to resolve a reachable host for the peer') + } + + await this.disconnect() + this.isConnecting = true + + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port: peer.port as number }, () => { + logger.info(`Connected to LAN peer ${peer.name} (${host}:${peer.port})`) + socket.setKeepAlive(true, 30_000) + this.socket = socket + this.currentPeer = peer + this.attachSocketListeners(socket) + + this.responseManager.waitForResponse( + 'handshake_ack', + options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, + (payload) => { + const ack = payload as LanHandshakeAckMessage + if (!ack.accepted) { + const message = ack.message || 'Handshake rejected by remote device' + logger.warn(`Handshake rejected by ${peer.name}: ${message}`) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + reject(new Error(message)) + void this.disconnect() + return + } + logger.info(`Handshake accepted by ${peer.name}`) + socket.setTimeout(0) + this.isConnecting = false + this.lastConnectOptions = options + sendTestPing(this.createConnectionContext()) + resolve(ack) + }, + (error) => { + this.isConnecting = false + reject(error) + } + ) + + const handshakeMessage = buildHandshakeMessage() + this.sendControlMessage(handshakeMessage) + }) + + socket.setTimeout(options.timeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS, () => { + const error = new Error('Handshake timed out') + logger.error('LAN transfer socket timeout', error) + this.broadcastClientEvent({ + type: 'error', + message: error.message, + timestamp: Date.now() + }) + reject(error) + socket.destroy(error) + void this.disconnect() + }) + + socket.once('error', (error) => { + logger.error('LAN transfer socket error', error as Error) + const message = error instanceof Error ? error.message : String(error) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + this.isConnecting = false + reject(error instanceof Error ? error : new Error(message)) + void this.disconnect() + }) + + socket.once('close', () => { + logger.info('LAN transfer socket closed') + if (this.socket === socket) { + this.socket = null + this.dataHandler?.resetBuffer() + this.responseManager.rejectAll(new Error('LAN transfer socket closed')) + this.currentPeer = undefined + abortTransfer(this.activeTransfer, new Error('LAN transfer socket closed')) + } + this.isConnecting = false + this.broadcastClientEvent({ + type: 'socket_closed', + reason: 'connection_closed', + timestamp: Date.now() + }) + }) + }) + } + + /** + * Disconnect from the current peer. + */ + public async disconnect(): Promise { + const socket = this.socket + if (!socket) { + return + } + + this.socket = null + this.dataHandler?.resetBuffer() + this.currentPeer = undefined + this.responseManager.rejectAll(new Error('LAN transfer socket disconnected')) + abortTransfer(this.activeTransfer, new Error('LAN transfer socket disconnected')) + + const DISCONNECT_TIMEOUT_MS = 3000 + await new Promise((resolve) => { + const timeout = setTimeout(() => { + logger.warn('Disconnect timeout, forcing cleanup') + socket.removeAllListeners() + resolve() + }, DISCONNECT_TIMEOUT_MS) + + socket.once('close', () => { + clearTimeout(timeout) + resolve() + }) + + socket.destroy() + }) + } + + /** + * Dispose the service and clean up all resources. + */ + public dispose(): void { + this.responseManager.rejectAll(new Error('LAN transfer client disposed')) + cleanupTransfer(this.activeTransfer) + this.activeTransfer = undefined + if (this.socket) { + this.socket.destroy() + this.socket = null + } + this.dataHandler?.resetBuffer() + this.isConnecting = false + } + + /** + * Send a ZIP file to the connected peer. + */ + public async sendFile(filePath: string): Promise { + await this.ensureConnection() + + if (this.activeTransfer) { + throw new Error('A file transfer is already in progress') + } + + // Validate file + const { stats, fileName } = await validateFile(filePath) + + // Calculate checksum + logger.info('Calculating file checksum...') + const checksum = await calculateFileChecksum(filePath) + logger.info(`File checksum: ${checksum.substring(0, 16)}...`) + + // Connection can drop while validating/checking file; ensure it is still ready before starting transfer. + await this.ensureConnection() + + // Initialize transfer state + const transferId = crypto.randomUUID() + this.activeTransfer = createTransferState(transferId, fileName, stats.size, checksum) + + logger.info( + `Starting file transfer: ${fileName} (${formatFileSize(stats.size)}, ${this.activeTransfer.totalChunks} chunks)` + ) + + // Global timeout + const globalTimeoutError = new Error('Transfer timed out (global timeout exceeded)') + const globalTimeoutHandle = setTimeout(() => { + logger.warn('Global transfer timeout exceeded, aborting transfer', { transferId, fileName }) + abortTransfer(this.activeTransfer, globalTimeoutError) + }, LAN_TRANSFER_GLOBAL_TIMEOUT_MS) + + try { + const result = await this.performFileTransfer(filePath, transferId, fileName) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error(`File transfer failed: ${message}`) + + this.broadcastClientEvent({ + type: 'file_transfer_complete', + transferId, + fileName, + success: false, + error: message, + timestamp: Date.now() + }) + + throw error + } finally { + clearTimeout(globalTimeoutHandle) + cleanupTransfer(this.activeTransfer) + this.activeTransfer = undefined + } + } + + /** + * Cancel the current file transfer. + */ + public cancelTransfer(): void { + if (!this.activeTransfer) { + logger.warn('No active transfer to cancel') + return + } + + const { transferId, fileName } = this.activeTransfer + logger.info(`Cancelling file transfer: ${fileName}`) + + this.activeTransfer.isCancelled = true + + try { + this.sendControlMessage({ + type: 'file_cancel', + transferId, + reason: 'Cancelled by user' + }) + } catch (error) { + // Expected when connection is already broken + logger.warn('Failed to send cancel message', error as Error) + } + + abortTransfer(this.activeTransfer, new Error('Transfer cancelled by user')) + } + + // ============================================================================= + // Private Methods + // ============================================================================= + + private async ensureConnection(): Promise { + // Check socket is valid and writable (not just undestroyed) + if (this.socket && !this.socket.destroyed && this.socket.writable && this.currentPeer) { + return + } + + if (!this.lastConnectOptions) { + throw new Error('No active connection. Please connect to a peer first.') + } + + // Prevent concurrent reconnection attempts + if (this.reconnectPromise) { + logger.debug('Waiting for existing reconnection attempt...') + await this.reconnectPromise + return + } + + logger.info('Connection lost, attempting to reconnect...') + this.reconnectPromise = this.connectAndHandshake(this.lastConnectOptions) + .then(() => { + // Handshake succeeded, connection restored + }) + .finally(() => { + this.reconnectPromise = null + }) + + await this.reconnectPromise + } + + private async performFileTransfer( + filePath: string, + transferId: string, + fileName: string + ): Promise { + const transfer = this.activeTransfer! + const ctx = this.createFileTransferContext() + + // Step 1: Send file_start + sendFileStart(ctx, transfer) + + // Step 2: Wait for file_start_ack + const startAck = await waitForFileStartAck(ctx, transferId, transfer.abortController.signal) + if (!startAck.accepted) { + throw new Error(startAck.message || 'Transfer rejected by receiver') + } + logger.info('Received file_start_ack: accepted') + + // Step 3: Stream file chunks + await streamFileChunks(this.socket!, filePath, transfer, transfer.abortController.signal, (bytesSent, chunkIndex) => + this.onTransferProgress(transfer, bytesSent, chunkIndex) + ) + + // Step 4: Send file_end + sendFileEnd(ctx, transferId) + + // Step 5: Wait for file_complete + const result = await waitForFileComplete(ctx, transferId, transfer.abortController.signal) + logger.info(`File transfer ${result.success ? 'completed' : 'failed'}`) + + // Broadcast completion + this.broadcastClientEvent({ + type: 'file_transfer_complete', + transferId, + fileName, + success: result.success, + filePath: result.filePath, + error: result.error, + timestamp: Date.now() + }) + + return result + } + + private onTransferProgress(transfer: ActiveFileTransfer, bytesSent: number, chunkIndex: number): void { + const progress = (bytesSent / transfer.fileSize) * 100 + const elapsed = (Date.now() - transfer.startedAt) / 1000 + const speed = elapsed > 0 ? bytesSent / elapsed : 0 + + this.broadcastClientEvent({ + type: 'file_transfer_progress', + transferId: transfer.transferId, + fileName: transfer.fileName, + bytesSent, + totalBytes: transfer.fileSize, + chunkIndex, + totalChunks: transfer.totalChunks, + progress: Math.round(progress * 100) / 100, + speed, + timestamp: Date.now() + }) + } + + private attachSocketListeners(socket: Socket): void { + this.dataHandler = createDataHandler((line) => this.handleControlLine(line)) + socket.on('data', (chunk: Buffer) => { + try { + this.dataHandler?.handleData(chunk) + } catch (error) { + logger.error('Data handler error', error as Error) + void this.disconnect() + } + }) + } + + private handleControlLine(line: string): void { + let payload: Record + try { + payload = JSON.parse(line) + this.consecutiveJsonErrors = 0 // Reset on successful parse + } catch { + this.consecutiveJsonErrors++ + logger.warn('Received invalid JSON control message', { line, consecutiveErrors: this.consecutiveJsonErrors }) + + if (this.consecutiveJsonErrors >= LanTransferClientService.MAX_CONSECUTIVE_JSON_ERRORS) { + const message = `Protocol error: ${this.consecutiveJsonErrors} consecutive invalid messages, disconnecting` + logger.error(message) + this.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + void this.disconnect() + } + return + } + + const type = payload?.type as string | undefined + if (!type) { + logger.warn('Received control message without type', payload) + return + } + + // Try to resolve a pending response + const transferId = payload?.transferId as string | undefined + const chunkIndex = payload?.chunkIndex as number | undefined + if (this.responseManager.tryResolve(type, payload, transferId, chunkIndex)) { + return + } + + logger.info('Received control message', payload) + + if (type === 'pong') { + this.broadcastClientEvent({ + type: 'pong', + payload: payload?.payload as string | undefined, + received: payload?.received as boolean | undefined, + timestamp: Date.now() + }) + return + } + + // Ignore late-arriving file transfer messages + const fileTransferMessageTypes = ['file_start_ack', 'file_complete'] + if (fileTransferMessageTypes.includes(type)) { + logger.debug('Ignoring late file transfer message', { type, payload }) + return + } + + this.broadcastClientEvent({ + type: 'error', + message: `Unexpected control message type: ${type}`, + timestamp: Date.now() + }) + } + + private sendControlMessage(message: Record): void { + if (!this.socket || this.socket.destroyed || !this.socket.writable) { + throw new Error('Socket is not connected') + } + const payload = JSON.stringify(message) + this.socket.write(`${payload}\n`) + } + + private createConnectionContext(): ConnectionContext { + return { + socket: this.socket, + currentPeer: this.currentPeer, + sendControlMessage: (msg) => this.sendControlMessage(msg), + broadcastClientEvent: (event) => this.broadcastClientEvent(event) + } + } + + private createFileTransferContext(): FileTransferContext { + return { + ...this.createConnectionContext(), + activeTransfer: this.activeTransfer, + setActiveTransfer: (transfer) => { + this.activeTransfer = transfer + }, + waitForResponse: (type, timeoutMs, resolve, reject, transferId, chunkIndex, abortSignal) => { + this.responseManager.waitForResponse(type, timeoutMs, resolve, reject, transferId, chunkIndex, abortSignal) + } + } + } + + private broadcastClientEvent(event: LanClientEvent): void { + const mainWindow = windowService.getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send(IpcChannel.LocalTransfer_ClientEvent, { + ...event, + peerId: event.peerId ?? this.currentPeer?.id, + peerName: event.peerName ?? this.currentPeer?.name + }) + } +} + +export const lanTransferClientService = new LanTransferClientService() + +// Re-export for backward compatibility +export { HANDSHAKE_PROTOCOL_VERSION } diff --git a/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts b/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts new file mode 100644 index 0000000000..16f188aa93 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/LanTransferClientService.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock dependencies before importing the service +vi.mock('node:net', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + createConnection: vi.fn() + } +}) + +vi.mock('electron', () => ({ + app: { + getName: vi.fn(() => 'Cherry Studio'), + getVersion: vi.fn(() => '1.0.0') + } +})) + +vi.mock('../../LocalTransferService', () => ({ + localTransferService: { + getPeerById: vi.fn() + } +})) + +vi.mock('../../WindowService', () => ({ + windowService: { + getMainWindow: vi.fn(() => ({ + isDestroyed: () => false, + webContents: { + send: vi.fn() + } + })) + } +})) + +// Import after mocks +import { localTransferService } from '../../LocalTransferService' + +describe('LanTransferClientService', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe('connectAndHandshake - validation', () => { + it('should throw error when peer is not found', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue(undefined) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'non-existent' + }) + ).rejects.toThrow('Selected LAN peer is no longer available') + }) + + it('should throw error when peer has no port', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue({ + id: 'test-peer', + name: 'Test Peer', + addresses: ['192.168.1.100'], + updatedAt: Date.now() + }) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'test-peer' + }) + ).rejects.toThrow('Selected peer does not expose a TCP port') + }) + + it('should throw error when no reachable host', async () => { + vi.mocked(localTransferService.getPeerById).mockReturnValue({ + id: 'test-peer', + name: 'Test Peer', + port: 12345, + addresses: [], + updatedAt: Date.now() + }) + + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect( + lanTransferClientService.connectAndHandshake({ + peerId: 'test-peer' + }) + ).rejects.toThrow('Unable to resolve a reachable host for the peer') + }) + }) + + describe('cancelTransfer', () => { + it('should not throw when no active transfer', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + // Should not throw, just log warning + expect(() => lanTransferClientService.cancelTransfer()).not.toThrow() + }) + }) + + describe('dispose', () => { + it('should clean up resources without throwing', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + // Should not throw + expect(() => lanTransferClientService.dispose()).not.toThrow() + }) + }) + + describe('sendFile', () => { + it('should throw error when not connected', async () => { + const { lanTransferClientService } = await import('../LanTransferClientService') + + await expect(lanTransferClientService.sendFile('/path/to/file.zip')).rejects.toThrow( + 'No active connection. Please connect to a peer first.' + ) + }) + }) + + describe('HANDSHAKE_PROTOCOL_VERSION', () => { + it('should export protocol version', async () => { + const { HANDSHAKE_PROTOCOL_VERSION } = await import('../LanTransferClientService') + + expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1') + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts new file mode 100644 index 0000000000..c485a33098 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/binaryProtocol.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'node:events' +import type { Socket } from 'node:net' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BINARY_TYPE_FILE_CHUNK, sendBinaryChunk } from '../binaryProtocol' + +describe('binaryProtocol', () => { + describe('sendBinaryChunk', () => { + let mockSocket: Socket + let writtenBuffers: Buffer[] + + beforeEach(() => { + writtenBuffers = [] + mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, + write: vi.fn((buffer: Buffer) => { + writtenBuffers.push(Buffer.from(buffer)) + return true + }), + cork: vi.fn(), + uncork: vi.fn() + }) as unknown as Socket + }) + + it('should send binary chunk with correct frame format', () => { + const transferId = 'test-uuid-1234' + const chunkIndex = 5 + const data = Buffer.from('test data chunk') + + const result = sendBinaryChunk(mockSocket, transferId, chunkIndex, data) + + expect(result).toBe(true) + expect(mockSocket.cork).toHaveBeenCalled() + expect(mockSocket.uncork).toHaveBeenCalled() + expect(mockSocket.write).toHaveBeenCalledTimes(2) + + // Verify header structure + const header = writtenBuffers[0] + + // Magic bytes "CS" + expect(header[0]).toBe(0x43) + expect(header[1]).toBe(0x53) + + // Type byte + const typeOffset = 2 + 4 // magic + totalLen + expect(header[typeOffset]).toBe(BINARY_TYPE_FILE_CHUNK) + + // TransferId length + const tidLenOffset = typeOffset + 1 + const tidLen = header.readUInt16BE(tidLenOffset) + expect(tidLen).toBe(Buffer.from(transferId).length) + + // ChunkIndex + const chunkIdxOffset = tidLenOffset + 2 + tidLen + expect(header.readUInt32BE(chunkIdxOffset)).toBe(chunkIndex) + + // Data buffer + expect(writtenBuffers[1].toString()).toBe('test data chunk') + }) + + it('should return false when socket write returns false (backpressure)', () => { + ;(mockSocket.write as ReturnType).mockReturnValueOnce(false) + + const result = sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data')) + + expect(result).toBe(false) + }) + + it('should correctly calculate totalLen in frame header', () => { + const transferId = 'uuid-1234' + const data = Buffer.from('chunk data here') + + sendBinaryChunk(mockSocket, transferId, 0, data) + + const header = writtenBuffers[0] + const totalLen = header.readUInt32BE(2) // After magic bytes + + // totalLen = type(1) + tidLen(2) + tid(n) + idx(4) + data(m) + const expectedTotalLen = 1 + 2 + Buffer.from(transferId).length + 4 + data.length + expect(totalLen).toBe(expectedTotalLen) + }) + + it('should throw error when socket is not writable', () => { + ;(mockSocket as any).writable = false + + expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable') + }) + + it('should throw error when socket is destroyed', () => { + ;(mockSocket as any).destroyed = true + + expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable') + }) + }) + + describe('BINARY_TYPE_FILE_CHUNK', () => { + it('should be 0x01', () => { + expect(BINARY_TYPE_FILE_CHUNK).toBe(0x01) + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts new file mode 100644 index 0000000000..3983e538d3 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/handlers/connection.test.ts @@ -0,0 +1,265 @@ +import { EventEmitter } from 'node:events' +import type { Socket } from 'node:net' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + buildHandshakeMessage, + createDataHandler, + getAbortError, + HANDSHAKE_PROTOCOL_VERSION, + pickHost, + waitForSocketDrain +} from '../../handlers/connection' + +// Mock electron app +vi.mock('electron', () => ({ + app: { + getName: vi.fn(() => 'Cherry Studio'), + getVersion: vi.fn(() => '1.0.0') + } +})) + +describe('connection handlers', () => { + describe('buildHandshakeMessage', () => { + it('should build handshake message with correct structure', () => { + const message = buildHandshakeMessage() + + expect(message.type).toBe('handshake') + expect(message.deviceName).toBe('Cherry Studio') + expect(message.version).toBe(HANDSHAKE_PROTOCOL_VERSION) + expect(message.appVersion).toBe('1.0.0') + expect(typeof message.platform).toBe('string') + }) + + it('should use protocol version 1', () => { + expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1') + }) + }) + + describe('pickHost', () => { + it('should prefer IPv4 addresses', () => { + const peer = { + id: '1', + name: 'Test', + addresses: ['fe80::1', '192.168.1.100', '::1'], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('192.168.1.100') + }) + + it('should fall back to first address if no IPv4', () => { + const peer = { + id: '1', + name: 'Test', + addresses: ['fe80::1', '::1'], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('fe80::1') + }) + + it('should fall back to host property if no addresses', () => { + const peer = { + id: '1', + name: 'Test', + host: 'example.local', + addresses: [], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBe('example.local') + }) + + it('should return undefined if no addresses or host', () => { + const peer = { + id: '1', + name: 'Test', + addresses: [], + updatedAt: Date.now() + } + + expect(pickHost(peer)).toBeUndefined() + }) + }) + + describe('createDataHandler', () => { + it('should parse complete lines from buffer', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"type":"test"}\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should handle partial lines across multiple chunks', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"type":')) + handler.handleData(Buffer.from('"test"}\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should handle multiple lines in single chunk', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('{"a":1}\n{"b":2}\n')) + + expect(lines).toEqual(['{"a":1}', '{"b":2}']) + }) + + it('should reset buffer', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('partial')) + handler.resetBuffer() + handler.handleData(Buffer.from('{"complete":true}\n')) + + expect(lines).toEqual(['{"complete":true}']) + }) + + it('should trim whitespace from lines', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from(' {"type":"test"} \n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should skip empty lines', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + handler.handleData(Buffer.from('\n\n{"type":"test"}\n\n')) + + expect(lines).toEqual(['{"type":"test"}']) + }) + + it('should throw error when buffer exceeds MAX_LINE_BUFFER_SIZE', () => { + const handler = createDataHandler(vi.fn()) + + // Create a buffer larger than 1MB (MAX_LINE_BUFFER_SIZE) + const largeData = 'x'.repeat(1024 * 1024 + 1) + + expect(() => handler.handleData(Buffer.from(largeData))).toThrow('Control message too large') + }) + + it('should reset buffer after exceeding MAX_LINE_BUFFER_SIZE', () => { + const lines: string[] = [] + const handler = createDataHandler((line) => lines.push(line)) + + // Create a buffer larger than 1MB + const largeData = 'x'.repeat(1024 * 1024 + 1) + + try { + handler.handleData(Buffer.from(largeData)) + } catch { + // Expected error + } + + // Buffer should be reset, so lineBuffer should be empty + expect(handler.lineBuffer).toBe('') + }) + }) + + describe('waitForSocketDrain', () => { + let mockSocket: Socket & EventEmitter + + beforeEach(() => { + mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, + write: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn() + }) as unknown as Socket & EventEmitter + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should throw error when abort signal is already aborted', async () => { + const abortController = new AbortController() + abortController.abort(new Error('Already aborted')) + + await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Already aborted') + }) + + it('should throw error when socket is destroyed', async () => { + ;(mockSocket as any).destroyed = true + const abortController = new AbortController() + + await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Socket is closed') + }) + + it('should resolve when drain event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + // Emit drain event after a short delay + setImmediate(() => mockSocket.emit('drain')) + + await expect(drainPromise).resolves.toBeUndefined() + }) + + it('should reject when close event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => mockSocket.emit('close')) + + await expect(drainPromise).rejects.toThrow('Socket closed while waiting for drain') + }) + + it('should reject when error event is emitted', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => mockSocket.emit('error', new Error('Network error'))) + + await expect(drainPromise).rejects.toThrow('Network error') + }) + + it('should reject when abort signal is triggered', async () => { + const abortController = new AbortController() + + const drainPromise = waitForSocketDrain(mockSocket, abortController.signal) + + setImmediate(() => abortController.abort(new Error('User cancelled'))) + + await expect(drainPromise).rejects.toThrow('User cancelled') + }) + }) + + describe('getAbortError', () => { + it('should return Error reason directly', () => { + const originalError = new Error('Original') + const signal = { aborted: true, reason: originalError } as AbortSignal + + expect(getAbortError(signal, 'Fallback')).toBe(originalError) + }) + + it('should create Error from string reason', () => { + const signal = { aborted: true, reason: 'String reason' } as AbortSignal + + expect(getAbortError(signal, 'Fallback').message).toBe('String reason') + }) + + it('should use fallback for empty reason', () => { + const signal = { aborted: true, reason: '' } as AbortSignal + + expect(getAbortError(signal, 'Fallback').message).toBe('Fallback') + }) + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts new file mode 100644 index 0000000000..814fd2f5c9 --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/handlers/fileTransfer.test.ts @@ -0,0 +1,216 @@ +import { EventEmitter } from 'node:events' +import type * as fs from 'node:fs' +import type { Socket } from 'node:net' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + abortTransfer, + cleanupTransfer, + createTransferState, + formatFileSize, + streamFileChunks +} from '../../handlers/fileTransfer' +import type { ActiveFileTransfer } from '../../types' + +// Mock binaryProtocol +vi.mock('../../binaryProtocol', () => ({ + sendBinaryChunk: vi.fn().mockReturnValue(true) +})) + +// Mock connection handlers +vi.mock('./connection', () => ({ + waitForSocketDrain: vi.fn().mockResolvedValue(undefined), + getAbortError: vi.fn((signal, fallback) => { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) return reason + if (typeof reason === 'string' && reason.length > 0) return new Error(reason) + return new Error(fallback) + }) +})) + +// Note: validateFile and calculateFileChecksum tests are skipped because +// the test environment has globally mocked node:fs and node:os modules. +// These functions are tested through integration tests instead. + +describe('fileTransfer handlers', () => { + describe('createTransferState', () => { + it('should create transfer state with correct defaults', () => { + const state = createTransferState('uuid-123', 'test.zip', 1024000, 'abc123') + + expect(state.transferId).toBe('uuid-123') + expect(state.fileName).toBe('test.zip') + expect(state.fileSize).toBe(1024000) + expect(state.checksum).toBe('abc123') + expect(state.bytesSent).toBe(0) + expect(state.currentChunk).toBe(0) + expect(state.isCancelled).toBe(false) + expect(state.abortController).toBeInstanceOf(AbortController) + }) + + it('should calculate totalChunks based on chunk size', () => { + // 512KB chunk size + const state = createTransferState('id', 'test.zip', 1024 * 1024, 'checksum') // 1MB + + expect(state.totalChunks).toBe(2) // 1MB / 512KB = 2 + }) + }) + + describe('abortTransfer', () => { + it('should abort transfer and destroy stream', () => { + const mockStream = { + destroyed: false, + destroy: vi.fn() + } as unknown as fs.ReadStream + + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + stream: mockStream, + isCancelled: false, + abortController: new AbortController() + } + + const error = new Error('Test abort') + abortTransfer(transfer, error) + + expect(transfer.isCancelled).toBe(true) + expect(transfer.abortController.signal.aborted).toBe(true) + expect(mockStream.destroy).toHaveBeenCalledWith(error) + }) + + it('should handle undefined transfer', () => { + expect(() => abortTransfer(undefined, new Error('test'))).not.toThrow() + }) + + it('should not abort already aborted controller', () => { + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + isCancelled: false, + abortController: new AbortController() + } + + transfer.abortController.abort() + + // Should not throw when aborting again + expect(() => abortTransfer(transfer, new Error('test'))).not.toThrow() + }) + }) + + describe('cleanupTransfer', () => { + it('should cleanup transfer resources', () => { + const mockStream = { + destroyed: false, + destroy: vi.fn() + } as unknown as fs.ReadStream + + const transfer: ActiveFileTransfer = { + transferId: 'test', + fileName: 'test.zip', + fileSize: 1000, + checksum: 'abc', + totalChunks: 1, + chunkSize: 512000, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + stream: mockStream, + isCancelled: false, + abortController: new AbortController() + } + + cleanupTransfer(transfer) + + expect(transfer.abortController.signal.aborted).toBe(true) + expect(mockStream.destroy).toHaveBeenCalled() + }) + + it('should handle undefined transfer', () => { + expect(() => cleanupTransfer(undefined)).not.toThrow() + }) + }) + + describe('formatFileSize', () => { + it('should format 0 bytes', () => { + expect(formatFileSize(0)).toBe('0 B') + }) + + it('should format bytes', () => { + expect(formatFileSize(500)).toBe('500 B') + }) + + it('should format kilobytes', () => { + expect(formatFileSize(1024)).toBe('1 KB') + expect(formatFileSize(2048)).toBe('2 KB') + }) + + it('should format megabytes', () => { + expect(formatFileSize(1024 * 1024)).toBe('1 MB') + expect(formatFileSize(5 * 1024 * 1024)).toBe('5 MB') + }) + + it('should format gigabytes', () => { + expect(formatFileSize(1024 * 1024 * 1024)).toBe('1 GB') + }) + + it('should format with decimal precision', () => { + expect(formatFileSize(1536)).toBe('1.5 KB') + expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB') + }) + }) + + // Note: streamFileChunks tests require careful mocking of fs.createReadStream + // which is globally mocked in the test environment. These tests verify the + // streaming logic works correctly with mock streams. + describe('streamFileChunks', () => { + let mockSocket: Socket & EventEmitter + let mockProgress: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockSocket = Object.assign(new EventEmitter(), { + destroyed: false, + writable: true, + write: vi.fn().mockReturnValue(true), + cork: vi.fn(), + uncork: vi.fn() + }) as unknown as Socket & EventEmitter + + mockProgress = vi.fn() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + it('should throw when abort signal is already aborted', async () => { + const transfer = createTransferState('test-id', 'test.zip', 1024, 'checksum') + transfer.abortController.abort(new Error('Already cancelled')) + + await expect( + streamFileChunks(mockSocket, '/fake/path.zip', transfer, transfer.abortController.signal, mockProgress) + ).rejects.toThrow() + }) + + // Note: Full integration testing of streamFileChunks with actual file streaming + // requires a real file system, which cannot be easily mocked in ESM. + // The abort signal test above verifies the early abort path. + // Additional streaming tests are covered through integration tests. + }) +}) diff --git a/src/main/services/lanTransfer/__tests__/responseManager.test.ts b/src/main/services/lanTransfer/__tests__/responseManager.test.ts new file mode 100644 index 0000000000..170ee2de8c --- /dev/null +++ b/src/main/services/lanTransfer/__tests__/responseManager.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ResponseManager } from '../responseManager' + +describe('ResponseManager', () => { + let manager: ResponseManager + + beforeEach(() => { + vi.useFakeTimers() + manager = new ResponseManager() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('buildResponseKey', () => { + it('should build key with type only', () => { + expect(manager.buildResponseKey('handshake_ack')).toBe('handshake_ack') + }) + + it('should build key with type and transferId', () => { + expect(manager.buildResponseKey('file_start_ack', 'uuid-123')).toBe('file_start_ack:uuid-123') + }) + + it('should build key with type, transferId, and chunkIndex', () => { + expect(manager.buildResponseKey('file_chunk_ack', 'uuid-123', 5)).toBe('file_chunk_ack:uuid-123:5') + }) + }) + + describe('waitForResponse', () => { + it('should resolve when tryResolve is called with matching key', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('handshake_ack', 5000, resolve, reject) + }) + + const payload = { type: 'handshake_ack', accepted: true } + const resolved = manager.tryResolve('handshake_ack', payload) + + expect(resolved).toBe(true) + await expect(resolvePromise).resolves.toEqual(payload) + }) + + it('should reject on timeout', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('handshake_ack', 1000, resolve, reject) + }) + + vi.advanceTimersByTime(1001) + + await expect(resolvePromise).rejects.toThrow('Timeout waiting for handshake_ack') + }) + + it('should call onTimeout callback when timeout occurs', async () => { + const onTimeout = vi.fn() + manager.setTimeoutCallback(onTimeout) + + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('test', 1000, resolve, reject) + }) + + vi.advanceTimersByTime(1001) + + await expect(resolvePromise).rejects.toThrow() + expect(onTimeout).toHaveBeenCalled() + }) + + it('should reject when abort signal is triggered', async () => { + const abortController = new AbortController() + + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('test', 10000, resolve, reject, undefined, undefined, abortController.signal) + }) + + abortController.abort(new Error('User cancelled')) + + await expect(resolvePromise).rejects.toThrow('User cancelled') + }) + + it('should replace existing response with same key', async () => { + const firstReject = vi.fn() + const secondResolve = vi.fn() + const secondReject = vi.fn() + + manager.waitForResponse('test', 5000, vi.fn(), firstReject) + manager.waitForResponse('test', 5000, secondResolve, secondReject) + + // First should be cleared (no rejection since it's replaced) + const payload = { type: 'test' } + manager.tryResolve('test', payload) + + expect(secondResolve).toHaveBeenCalledWith(payload) + }) + }) + + describe('tryResolve', () => { + it('should return false when no matching response', () => { + expect(manager.tryResolve('nonexistent', {})).toBe(false) + }) + + it('should match with transferId', async () => { + const resolvePromise = new Promise((resolve, reject) => { + manager.waitForResponse('file_start_ack', 5000, resolve, reject, 'uuid-123') + }) + + const payload = { type: 'file_start_ack', transferId: 'uuid-123' } + manager.tryResolve('file_start_ack', payload, 'uuid-123') + + await expect(resolvePromise).resolves.toEqual(payload) + }) + }) + + describe('rejectAll', () => { + it('should reject all pending responses', async () => { + const promises = [ + new Promise((resolve, reject) => { + manager.waitForResponse('test1', 5000, resolve, reject) + }), + new Promise((resolve, reject) => { + manager.waitForResponse('test2', 5000, resolve, reject, 'uuid') + }) + ] + + manager.rejectAll(new Error('Connection closed')) + + await expect(promises[0]).rejects.toThrow('Connection closed') + await expect(promises[1]).rejects.toThrow('Connection closed') + }) + }) + + describe('clearPendingResponse', () => { + it('should clear specific response by key', () => { + manager.waitForResponse('test', 5000, vi.fn(), vi.fn()) + + manager.clearPendingResponse('test') + + expect(manager.tryResolve('test', {})).toBe(false) + }) + + it('should clear all responses when no key provided', () => { + manager.waitForResponse('test1', 5000, vi.fn(), vi.fn()) + manager.waitForResponse('test2', 5000, vi.fn(), vi.fn()) + + manager.clearPendingResponse() + + expect(manager.tryResolve('test1', {})).toBe(false) + expect(manager.tryResolve('test2', {})).toBe(false) + }) + }) + + describe('getAbortError', () => { + it('should return Error reason directly', () => { + const originalError = new Error('Original error') + const signal = { aborted: true, reason: originalError } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback') + + expect(error).toBe(originalError) + }) + + it('should create Error from string reason', () => { + const signal = { aborted: true, reason: 'String reason' } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback') + + expect(error.message).toBe('String reason') + }) + + it('should use fallback message when no reason', () => { + const signal = { aborted: true } as AbortSignal + + const error = manager.getAbortError(signal, 'Fallback message') + + expect(error.message).toBe('Fallback message') + }) + }) +}) diff --git a/src/main/services/lanTransfer/binaryProtocol.ts b/src/main/services/lanTransfer/binaryProtocol.ts new file mode 100644 index 0000000000..864a8b95bd --- /dev/null +++ b/src/main/services/lanTransfer/binaryProtocol.ts @@ -0,0 +1,67 @@ +import type { Socket } from 'node:net' + +/** + * Binary protocol constants (v1) + */ +export const BINARY_TYPE_FILE_CHUNK = 0x01 + +/** + * Send file chunk as binary frame (protocol v1 - streaming mode) + * + * Frame format: + * ``` + * ┌──────────┬──────────┬──────────┬───────────────┬──────────────┬────────────┬───────────┐ + * │ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ + * │ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (variable) │ (4B BE) │ (raw) │ + * └──────────┴──────────┴──────────┴───────────────┴──────────────┴────────────┴───────────┘ + * ``` + * + * @param socket - TCP socket to write to + * @param transferId - UUID of the transfer + * @param chunkIndex - Index of the chunk (0-based) + * @param data - Raw chunk data buffer + * @returns true if data was buffered, false if backpressure should be applied + */ +export function sendBinaryChunk(socket: Socket, transferId: string, chunkIndex: number, data: Buffer): boolean { + if (!socket || socket.destroyed || !socket.writable) { + throw new Error('Socket is not writable') + } + + const tidBuffer = Buffer.from(transferId, 'utf8') + const tidLen = tidBuffer.length + + // totalLen = type(1) + tidLen(2) + tid(n) + idx(4) + data(m) + const totalLen = 1 + 2 + tidLen + 4 + data.length + + const header = Buffer.allocUnsafe(2 + 4 + 1 + 2 + tidLen + 4) + let offset = 0 + + // Magic (2 bytes): "CS" + header[offset++] = 0x43 + header[offset++] = 0x53 + + // TotalLen (4 bytes, Big-Endian) + header.writeUInt32BE(totalLen, offset) + offset += 4 + + // Type (1 byte) + header[offset++] = BINARY_TYPE_FILE_CHUNK + + // TransferId length (2 bytes, Big-Endian) + header.writeUInt16BE(tidLen, offset) + offset += 2 + + // TransferId (variable) + tidBuffer.copy(header, offset) + offset += tidLen + + // ChunkIndex (4 bytes, Big-Endian) + header.writeUInt32BE(chunkIndex, offset) + + socket.cork() + const wroteHeader = socket.write(header) + const wroteData = socket.write(data) + socket.uncork() + + return wroteHeader && wroteData +} diff --git a/src/main/services/lanTransfer/handlers/connection.ts b/src/main/services/lanTransfer/handlers/connection.ts new file mode 100644 index 0000000000..5a53eeb373 --- /dev/null +++ b/src/main/services/lanTransfer/handlers/connection.ts @@ -0,0 +1,162 @@ +import { isIP, type Socket } from 'node:net' +import { platform } from 'node:os' + +import { loggerService } from '@logger' +import type { LanHandshakeRequestMessage, LocalTransferPeer } from '@shared/config/types' +import { app } from 'electron' + +import type { ConnectionContext } from '../types' + +export const HANDSHAKE_PROTOCOL_VERSION = '1' + +/** Maximum size for line buffer to prevent memory exhaustion from malicious peers */ +const MAX_LINE_BUFFER_SIZE = 1024 * 1024 // 1MB limit for control messages + +const logger = loggerService.withContext('LanTransferConnection') + +/** + * Build a handshake request message with device info. + */ +export function buildHandshakeMessage(): LanHandshakeRequestMessage { + return { + type: 'handshake', + deviceName: app.getName(), + version: HANDSHAKE_PROTOCOL_VERSION, + platform: platform(), + appVersion: app.getVersion() + } +} + +/** + * Pick the best host address from a peer's available addresses. + * Prefers IPv4 addresses over IPv6. + */ +export function pickHost(peer: LocalTransferPeer): string | undefined { + const preferred = peer.addresses?.find((addr) => isIP(addr) === 4) || peer.addresses?.[0] + return preferred || peer.host +} + +/** + * Send a test ping message after successful handshake. + */ +export function sendTestPing(ctx: ConnectionContext): void { + const payload = 'hello world' + try { + ctx.sendControlMessage({ type: 'ping', payload }) + logger.info('Sent LAN ping test payload') + ctx.broadcastClientEvent({ + type: 'ping_sent', + payload, + timestamp: Date.now() + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error('Failed to send LAN test ping', error as Error) + ctx.broadcastClientEvent({ + type: 'error', + message, + timestamp: Date.now() + }) + } +} + +/** + * Attach data listener to socket for receiving control messages. + * Returns a function to parse the line buffer. + */ +export function createDataHandler(onControlLine: (line: string) => void): { + lineBuffer: string + handleData: (chunk: Buffer) => void + resetBuffer: () => void +} { + let lineBuffer = '' + + return { + get lineBuffer() { + return lineBuffer + }, + handleData(chunk: Buffer) { + lineBuffer += chunk.toString('utf8') + + // Prevent memory exhaustion from malicious peers sending data without newlines + if (lineBuffer.length > MAX_LINE_BUFFER_SIZE) { + logger.error('Line buffer exceeded maximum size, resetting') + lineBuffer = '' + throw new Error('Control message too large') + } + + let newlineIndex = lineBuffer.indexOf('\n') + while (newlineIndex !== -1) { + const line = lineBuffer.slice(0, newlineIndex).trim() + lineBuffer = lineBuffer.slice(newlineIndex + 1) + if (line.length > 0) { + onControlLine(line) + } + newlineIndex = lineBuffer.indexOf('\n') + } + }, + resetBuffer() { + lineBuffer = '' + } + } +} + +/** + * Wait for socket to drain (backpressure handling). + */ +export async function waitForSocketDrain(socket: Socket, abortSignal: AbortSignal): Promise { + if (abortSignal.aborted) { + throw getAbortError(abortSignal, 'Transfer aborted while waiting for socket drain') + } + if (socket.destroyed) { + throw new Error('Socket is closed') + } + + await new Promise((resolve, reject) => { + const cleanup = () => { + socket.off('drain', onDrain) + socket.off('close', onClose) + socket.off('error', onError) + abortSignal.removeEventListener('abort', onAbort) + } + + const onDrain = () => { + cleanup() + resolve() + } + + const onClose = () => { + cleanup() + reject(new Error('Socket closed while waiting for drain')) + } + + const onError = (error: Error) => { + cleanup() + reject(error) + } + + const onAbort = () => { + cleanup() + reject(getAbortError(abortSignal, 'Transfer aborted while waiting for socket drain')) + } + + socket.once('drain', onDrain) + socket.once('close', onClose) + socket.once('error', onError) + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** + * Get the error from an abort signal, or create a fallback error. + */ +export function getAbortError(signal: AbortSignal, fallbackMessage: string): Error { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) { + return reason + } + if (typeof reason === 'string' && reason.length > 0) { + return new Error(reason) + } + return new Error(fallbackMessage) +} diff --git a/src/main/services/lanTransfer/handlers/fileTransfer.ts b/src/main/services/lanTransfer/handlers/fileTransfer.ts new file mode 100644 index 0000000000..c469a58421 --- /dev/null +++ b/src/main/services/lanTransfer/handlers/fileTransfer.ts @@ -0,0 +1,267 @@ +import * as crypto from 'node:crypto' +import * as fs from 'node:fs' +import type { Socket } from 'node:net' +import * as path from 'node:path' + +import { loggerService } from '@logger' +import type { + LanFileCompleteMessage, + LanFileEndMessage, + LanFileStartAckMessage, + LanFileStartMessage +} from '@shared/config/types' +import { + LAN_TRANSFER_CHUNK_SIZE, + LAN_TRANSFER_COMPLETE_TIMEOUT_MS, + LAN_TRANSFER_MAX_FILE_SIZE +} from '@shared/config/types' + +import { sendBinaryChunk } from '../binaryProtocol' +import type { ActiveFileTransfer, FileTransferContext } from '../types' +import { getAbortError, waitForSocketDrain } from './connection' + +const DEFAULT_FILE_START_ACK_TIMEOUT_MS = 30_000 // 30s for file_start_ack + +const logger = loggerService.withContext('LanTransferFileHandler') + +/** + * Validate a file for transfer. + * Checks existence, type, extension, and size limits. + */ +export async function validateFile(filePath: string): Promise<{ stats: fs.Stats; fileName: string }> { + let stats: fs.Stats + try { + stats = await fs.promises.stat(filePath) + } catch (error) { + const nodeError = error as NodeJS.ErrnoException + if (nodeError.code === 'ENOENT') { + throw new Error(`File not found: ${filePath}`) + } else if (nodeError.code === 'EACCES') { + throw new Error(`Permission denied: ${filePath}`) + } else if (nodeError.code === 'ENOTDIR') { + throw new Error(`Invalid path: ${filePath}`) + } else { + throw new Error(`Cannot access file: ${filePath} (${nodeError.code || 'unknown error'})`) + } + } + + if (!stats.isFile()) { + throw new Error('Path is not a file') + } + + const fileName = path.basename(filePath) + const ext = path.extname(fileName).toLowerCase() + if (ext !== '.zip') { + throw new Error('Only ZIP files are supported') + } + + if (stats.size > LAN_TRANSFER_MAX_FILE_SIZE) { + throw new Error(`File too large. Maximum size is ${formatFileSize(LAN_TRANSFER_MAX_FILE_SIZE)}`) + } + + return { stats, fileName } +} + +/** + * Calculate SHA-256 checksum of a file. + */ +export async function calculateFileChecksum(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256') + const stream = fs.createReadStream(filePath) + stream.on('data', (data) => hash.update(data)) + stream.on('end', () => resolve(hash.digest('hex'))) + stream.on('error', reject) + }) +} + +/** + * Create initial transfer state for a new file transfer. + */ +export function createTransferState( + transferId: string, + fileName: string, + fileSize: number, + checksum: string +): ActiveFileTransfer { + const chunkSize = LAN_TRANSFER_CHUNK_SIZE + const totalChunks = Math.ceil(fileSize / chunkSize) + + return { + transferId, + fileName, + fileSize, + checksum, + totalChunks, + chunkSize, + bytesSent: 0, + currentChunk: 0, + startedAt: Date.now(), + isCancelled: false, + abortController: new AbortController() + } +} + +/** + * Send file_start message to receiver. + */ +export function sendFileStart(ctx: FileTransferContext, transfer: ActiveFileTransfer): void { + const startMessage: LanFileStartMessage = { + type: 'file_start', + transferId: transfer.transferId, + fileName: transfer.fileName, + fileSize: transfer.fileSize, + mimeType: 'application/zip', + checksum: transfer.checksum, + totalChunks: transfer.totalChunks, + chunkSize: transfer.chunkSize + } + ctx.sendControlMessage(startMessage) + logger.info('Sent file_start message') +} + +/** + * Wait for file_start_ack from receiver. + */ +export function waitForFileStartAck( + ctx: FileTransferContext, + transferId: string, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + ctx.waitForResponse( + 'file_start_ack', + DEFAULT_FILE_START_ACK_TIMEOUT_MS, + (payload) => resolve(payload as LanFileStartAckMessage), + reject, + transferId, + undefined, + abortSignal + ) + }) +} + +/** + * Wait for file_complete from receiver after all chunks sent. + */ +export function waitForFileComplete( + ctx: FileTransferContext, + transferId: string, + abortSignal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + ctx.waitForResponse( + 'file_complete', + LAN_TRANSFER_COMPLETE_TIMEOUT_MS, + (payload) => resolve(payload as LanFileCompleteMessage), + reject, + transferId, + undefined, + abortSignal + ) + }) +} + +/** + * Send file_end message to receiver. + */ +export function sendFileEnd(ctx: FileTransferContext, transferId: string): void { + const endMessage: LanFileEndMessage = { + type: 'file_end', + transferId + } + ctx.sendControlMessage(endMessage) + logger.info('Sent file_end message') +} + +/** + * Stream file chunks to the receiver (v1 streaming mode - no per-chunk acknowledgment). + */ +export async function streamFileChunks( + socket: Socket, + filePath: string, + transfer: ActiveFileTransfer, + abortSignal: AbortSignal, + onProgress: (bytesSent: number, chunkIndex: number) => void +): Promise { + const { chunkSize, transferId } = transfer + + const stream = fs.createReadStream(filePath, { highWaterMark: chunkSize }) + transfer.stream = stream + + let chunkIndex = 0 + let bytesSent = 0 + + try { + for await (const chunk of stream) { + if (abortSignal.aborted) { + throw getAbortError(abortSignal, 'Transfer aborted') + } + + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytesSent += buffer.length + + // Send chunk as binary frame (v1 streaming) with backpressure handling + const canContinue = sendBinaryChunk(socket, transferId, chunkIndex, buffer) + if (!canContinue) { + await waitForSocketDrain(socket, abortSignal) + } + + // Update progress + transfer.bytesSent = bytesSent + transfer.currentChunk = chunkIndex + + onProgress(bytesSent, chunkIndex) + chunkIndex++ + } + + logger.info(`File streaming completed: ${chunkIndex} chunks sent`) + } catch (error) { + logger.error('File streaming failed', error as Error) + throw error + } +} + +/** + * Abort an active transfer and clean up resources. + */ +export function abortTransfer(transfer: ActiveFileTransfer | undefined, error: Error): void { + if (!transfer) { + return + } + + transfer.isCancelled = true + if (!transfer.abortController.signal.aborted) { + transfer.abortController.abort(error) + } + if (transfer.stream && !transfer.stream.destroyed) { + transfer.stream.destroy(error) + } +} + +/** + * Clean up transfer resources without error. + */ +export function cleanupTransfer(transfer: ActiveFileTransfer | undefined): void { + if (!transfer) { + return + } + + if (!transfer.abortController.signal.aborted) { + transfer.abortController.abort() + } + if (transfer.stream && !transfer.stream.destroyed) { + transfer.stream.destroy() + } +} + +/** + * Format bytes into human-readable size string. + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} diff --git a/src/main/services/lanTransfer/handlers/index.ts b/src/main/services/lanTransfer/handlers/index.ts new file mode 100644 index 0000000000..33620d188c --- /dev/null +++ b/src/main/services/lanTransfer/handlers/index.ts @@ -0,0 +1,22 @@ +export { + buildHandshakeMessage, + createDataHandler, + getAbortError, + HANDSHAKE_PROTOCOL_VERSION, + pickHost, + sendTestPing, + waitForSocketDrain +} from './connection' +export { + abortTransfer, + calculateFileChecksum, + cleanupTransfer, + createTransferState, + formatFileSize, + sendFileEnd, + sendFileStart, + streamFileChunks, + validateFile, + waitForFileComplete, + waitForFileStartAck +} from './fileTransfer' diff --git a/src/main/services/lanTransfer/index.ts b/src/main/services/lanTransfer/index.ts new file mode 100644 index 0000000000..12f3c38afc --- /dev/null +++ b/src/main/services/lanTransfer/index.ts @@ -0,0 +1,21 @@ +/** + * LAN Transfer Client Module + * + * Protocol: v1.0 (streaming mode) + * + * Features: + * - Binary frame format for file chunks (no base64 overhead) + * - Streaming mode (no per-chunk acknowledgment) + * - JSON messages for control flow (handshake, file_start, file_end, etc.) + * - Global timeout protection + * - Backpressure handling + * + * Binary Frame Format: + * ┌──────────┬──────────┬──────────┬───────────────┬──────────────┬────────────┬───────────┐ + * │ Magic │ TotalLen │ Type │ TransferId Len│ TransferId │ ChunkIdx │ Data │ + * │ 0x43 0x53│ (4B BE) │ 0x01 │ (2B BE) │ (variable) │ (4B BE) │ (raw) │ + * └──────────┴──────────┴──────────┴───────────────┴──────────────┴────────────┴───────────┘ + */ + +export { HANDSHAKE_PROTOCOL_VERSION, lanTransferClientService } from './LanTransferClientService' +export type { ActiveFileTransfer, ConnectionContext, FileTransferContext, PendingResponse } from './types' diff --git a/src/main/services/lanTransfer/responseManager.ts b/src/main/services/lanTransfer/responseManager.ts new file mode 100644 index 0000000000..74d5196dba --- /dev/null +++ b/src/main/services/lanTransfer/responseManager.ts @@ -0,0 +1,144 @@ +import type { PendingResponse } from './types' + +/** + * Manages pending response handlers for awaiting control messages. + * Handles timeouts, abort signals, and cleanup. + */ +export class ResponseManager { + private pendingResponses = new Map() + private onTimeout?: () => void + + /** + * Set a callback to be called when a response times out. + * Typically used to trigger disconnect on timeout. + */ + setTimeoutCallback(callback: () => void): void { + this.onTimeout = callback + } + + /** + * Build a composite key for identifying pending responses. + */ + buildResponseKey(type: string, transferId?: string, chunkIndex?: number): string { + const parts = [type] + if (transferId !== undefined) parts.push(transferId) + if (chunkIndex !== undefined) parts.push(String(chunkIndex)) + return parts.join(':') + } + + /** + * Register a response listener with timeout and optional abort signal. + */ + waitForResponse( + type: string, + timeoutMs: number, + resolve: (payload: unknown) => void, + reject: (error: Error) => void, + transferId?: string, + chunkIndex?: number, + abortSignal?: AbortSignal + ): void { + const responseKey = this.buildResponseKey(type, transferId, chunkIndex) + + // Clear any existing response with the same key + this.clearPendingResponse(responseKey) + + const timeoutHandle = setTimeout(() => { + this.clearPendingResponse(responseKey) + const error = new Error(`Timeout waiting for ${type}`) + reject(error) + this.onTimeout?.() + }, timeoutMs) + + const pending: PendingResponse = { + type, + transferId, + chunkIndex, + resolve, + reject, + timeoutHandle, + abortSignal + } + + if (abortSignal) { + const abortListener = () => { + this.clearPendingResponse(responseKey) + reject(this.getAbortError(abortSignal, `Aborted while waiting for ${type}`)) + } + pending.abortListener = abortListener + abortSignal.addEventListener('abort', abortListener, { once: true }) + } + + this.pendingResponses.set(responseKey, pending) + } + + /** + * Try to resolve a pending response by type and optional identifiers. + * Returns true if a matching response was found and resolved. + */ + tryResolve(type: string, payload: unknown, transferId?: string, chunkIndex?: number): boolean { + const responseKey = this.buildResponseKey(type, transferId, chunkIndex) + const pendingResponse = this.pendingResponses.get(responseKey) + + if (pendingResponse) { + const resolver = pendingResponse.resolve + this.clearPendingResponse(responseKey) + resolver(payload) + return true + } + + return false + } + + /** + * Clear a single pending response by key, or all responses if no key provided. + */ + clearPendingResponse(key?: string): void { + if (key) { + const pending = this.pendingResponses.get(key) + if (pending?.timeoutHandle) { + clearTimeout(pending.timeoutHandle) + } + if (pending?.abortSignal && pending.abortListener) { + pending.abortSignal.removeEventListener('abort', pending.abortListener) + } + this.pendingResponses.delete(key) + } else { + // Clear all pending responses + for (const pending of this.pendingResponses.values()) { + if (pending.timeoutHandle) { + clearTimeout(pending.timeoutHandle) + } + if (pending.abortSignal && pending.abortListener) { + pending.abortSignal.removeEventListener('abort', pending.abortListener) + } + } + this.pendingResponses.clear() + } + } + + /** + * Reject all pending responses with the given error. + */ + rejectAll(error: Error): void { + for (const key of Array.from(this.pendingResponses.keys())) { + const pending = this.pendingResponses.get(key) + this.clearPendingResponse(key) + pending?.reject(error) + } + } + + /** + * Get the abort error from an abort signal, or create a fallback error. + */ + getAbortError(signal: AbortSignal, fallbackMessage: string): Error { + const reason = (signal as AbortSignal & { reason?: unknown }).reason + if (reason instanceof Error) { + return reason + } + if (typeof reason === 'string' && reason.length > 0) { + return new Error(reason) + } + return new Error(fallbackMessage) + } +} diff --git a/src/main/services/lanTransfer/types.ts b/src/main/services/lanTransfer/types.ts new file mode 100644 index 0000000000..52be660af3 --- /dev/null +++ b/src/main/services/lanTransfer/types.ts @@ -0,0 +1,65 @@ +import type * as fs from 'node:fs' +import type { Socket } from 'node:net' + +import type { LanClientEvent, LocalTransferPeer } from '@shared/config/types' + +/** + * Pending response handler for awaiting control messages + */ +export type PendingResponse = { + type: string + transferId?: string + chunkIndex?: number + resolve: (payload: unknown) => void + reject: (error: Error) => void + timeoutHandle?: NodeJS.Timeout + abortSignal?: AbortSignal + abortListener?: () => void +} + +/** + * Active file transfer state tracking + */ +export type ActiveFileTransfer = { + transferId: string + fileName: string + fileSize: number + checksum: string + totalChunks: number + chunkSize: number + bytesSent: number + currentChunk: number + startedAt: number + stream?: fs.ReadStream + isCancelled: boolean + abortController: AbortController +} + +/** + * Context interface for connection handlers + * Provides access to service methods without circular dependencies + */ +export type ConnectionContext = { + socket: Socket | null + currentPeer?: LocalTransferPeer + sendControlMessage: (message: Record) => void + broadcastClientEvent: (event: LanClientEvent) => void +} + +/** + * Context interface for file transfer handlers + * Extends connection context with transfer-specific methods + */ +export type FileTransferContext = ConnectionContext & { + activeTransfer?: ActiveFileTransfer + setActiveTransfer: (transfer: ActiveFileTransfer | undefined) => void + waitForResponse: ( + type: string, + timeoutMs: number, + resolve: (payload: unknown) => void, + reject: (error: Error) => void, + transferId?: string, + chunkIndex?: number, + abortSignal?: AbortSignal + ) => void +} diff --git a/src/preload/index.ts b/src/preload/index.ts index dc08e9a2df..46ce84903f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,7 +4,15 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' 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 { + FileChangeEvent, + LanClientEvent, + LanFileCompleteMessage, + LanHandshakeAckMessage, + LocalTransferConnectPayload, + LocalTransferState, + WebviewKeyEvent +} from '@shared/config/types' import type { MCPServerLogEntry } from '@shared/config/types' import { IpcChannel } from '@shared/IpcChannel' import type { Notification } from '@types' @@ -172,7 +180,11 @@ const api = { listS3Files: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_ListS3Files, s3Config), deleteS3File: (fileName: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_DeleteS3File, fileName, s3Config), - checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config) + checkS3Connection: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_CheckS3Connection, s3Config), + createLanTransferBackup: (data: string): Promise => + ipcRenderer.invoke(IpcChannel.Backup_CreateLanTransferBackup, data), + deleteTempBackup: (filePath: string): Promise => + ipcRenderer.invoke(IpcChannel.Backup_DeleteTempBackup, filePath) }, file: { select: (options?: OpenDialogOptions): Promise => @@ -589,12 +601,32 @@ const api = { writeContent: (options: WritePluginContentOptions): Promise> => ipcRenderer.invoke(IpcChannel.ClaudeCodePlugin_WriteContent, options) }, - webSocket: { - start: () => ipcRenderer.invoke(IpcChannel.WebSocket_Start), - stop: () => ipcRenderer.invoke(IpcChannel.WebSocket_Stop), - status: () => ipcRenderer.invoke(IpcChannel.WebSocket_Status), - sendFile: (filePath: string) => ipcRenderer.invoke(IpcChannel.WebSocket_SendFile, filePath), - getAllCandidates: () => ipcRenderer.invoke(IpcChannel.WebSocket_GetAllCandidates) + localTransfer: { + getState: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_ListServices), + startScan: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_StartScan), + stopScan: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_StopScan), + connect: (payload: LocalTransferConnectPayload): Promise => + ipcRenderer.invoke(IpcChannel.LocalTransfer_Connect, payload), + disconnect: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_Disconnect), + onServicesUpdated: (callback: (state: LocalTransferState) => void): (() => void) => { + const channel = IpcChannel.LocalTransfer_ServicesUpdated + const listener = (_: Electron.IpcRendererEvent, state: LocalTransferState) => callback(state) + ipcRenderer.on(channel, listener) + return () => { + ipcRenderer.removeListener(channel, listener) + } + }, + onClientEvent: (callback: (event: LanClientEvent) => void): (() => void) => { + const channel = IpcChannel.LocalTransfer_ClientEvent + const listener = (_: Electron.IpcRendererEvent, event: LanClientEvent) => callback(event) + ipcRenderer.on(channel, listener) + return () => { + ipcRenderer.removeListener(channel, listener) + } + }, + sendFile: (filePath: string): Promise => + ipcRenderer.invoke(IpcChannel.LocalTransfer_SendFile, { filePath }), + cancelTransfer: (): Promise => ipcRenderer.invoke(IpcChannel.LocalTransfer_CancelTransfer) } } diff --git a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx b/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx deleted file mode 100644 index cbe51ac614..0000000000 --- a/src/renderer/src/components/Popups/ExportToPhoneLanPopup.tsx +++ /dev/null @@ -1,553 +0,0 @@ -import { loggerService } from '@logger' -import { AppLogo } from '@renderer/config/env' -import { SettingHelpText, SettingRow } from '@renderer/pages/settings' -import type { WebSocketCandidatesResponse } from '@shared/config/types' -import { Alert, Button, Modal, Progress, Spin } from 'antd' -import { QRCodeSVG } from 'qrcode.react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' - -import { TopView } from '../TopView' - -const logger = loggerService.withContext('ExportToPhoneLanPopup') - -interface Props { - resolve: (data: any) => void -} - -type ConnectionPhase = 'initializing' | 'waiting_qr_scan' | 'connecting' | 'connected' | 'disconnected' | 'error' -type TransferPhase = 'idle' | 'preparing' | 'sending' | 'completed' | 'error' - -const LoadingQRCode: React.FC = () => { - const { t } = useTranslation() - return ( -
- - - {t('settings.data.export_to_phone.lan.generating_qr')} - -
- ) -} - -const ScanQRCode: React.FC<{ qrCodeValue: string }> = ({ qrCodeValue }) => { - const { t } = useTranslation() - return ( -
- - - {t('settings.data.export_to_phone.lan.scan_qr')} - -
- ) -} - -const ConnectingAnimation: React.FC = () => { - const { t } = useTranslation() - return ( -
-
- - - {t('settings.data.export_to_phone.lan.status.connecting')} - -
-
- ) -} - -const ConnectedDisplay: React.FC = () => { - const { t } = useTranslation() - return ( -
-
- 📱 - - {t('settings.data.export_to_phone.lan.connected')} - -
-
- ) -} - -const ErrorQRCode: React.FC<{ error: string | null }> = ({ error }) => { - const { t } = useTranslation() - return ( -
- ⚠️ - - {t('settings.data.export_to_phone.lan.connection_failed')} - - {error && {error}} -
- ) -} - -const PopupContainer: React.FC = ({ resolve }) => { - const [isOpen, setIsOpen] = useState(true) - const [connectionPhase, setConnectionPhase] = useState('initializing') - const [transferPhase, setTransferPhase] = useState('idle') - const [qrCodeValue, setQrCodeValue] = useState('') - const [selectedFolderPath, setSelectedFolderPath] = useState(null) - const [sendProgress, setSendProgress] = useState(0) - const [error, setError] = useState(null) - const [autoCloseCountdown, setAutoCloseCountdown] = useState(null) - - const { t } = useTranslation() - - // 派生状态 - const isConnected = connectionPhase === 'connected' - const canSend = isConnected && selectedFolderPath && transferPhase === 'idle' - const isSending = transferPhase === 'preparing' || transferPhase === 'sending' - - // 状态文本映射 - const connectionStatusText = useMemo(() => { - const statusMap = { - initializing: t('settings.data.export_to_phone.lan.status.initializing'), - waiting_qr_scan: t('settings.data.export_to_phone.lan.status.waiting_qr_scan'), - connecting: t('settings.data.export_to_phone.lan.status.connecting'), - connected: t('settings.data.export_to_phone.lan.status.connected'), - disconnected: t('settings.data.export_to_phone.lan.status.disconnected'), - error: t('settings.data.export_to_phone.lan.status.error') - } - return statusMap[connectionPhase] - }, [connectionPhase, t]) - - const transferStatusText = useMemo(() => { - const statusMap = { - idle: '', - preparing: t('settings.data.export_to_phone.lan.status.preparing'), - sending: t('settings.data.export_to_phone.lan.status.sending'), - completed: t('settings.data.export_to_phone.lan.status.completed'), - error: t('settings.data.export_to_phone.lan.status.error') - } - return statusMap[transferPhase] - }, [transferPhase, t]) - - // 状态样式映射 - const connectionStatusStyles = useMemo(() => { - const styleMap = { - initializing: { - bg: 'var(--color-background-mute)', - border: 'var(--color-border-mute)' - }, - waiting_qr_scan: { - bg: 'var(--color-primary-mute)', - border: 'var(--color-primary-soft)' - }, - connecting: { bg: 'var(--color-status-warning)', border: 'var(--color-status-warning)' }, - connected: { - bg: 'var(--color-status-success)', - border: 'var(--color-status-success)' - }, - disconnected: { bg: 'var(--color-error)', border: 'var(--color-error)' }, - error: { bg: 'var(--color-error)', border: 'var(--color-error)' } - } - return styleMap[connectionPhase] - }, [connectionPhase]) - - const initWebSocket = useCallback(async () => { - try { - setConnectionPhase('initializing') - await window.api.webSocket.start() - const { port, ip } = await window.api.webSocket.status() - - if (ip && port) { - const candidatesData = await window.api.webSocket.getAllCandidates() - - const optimizeConnectionInfo = () => { - const ipToNumber = (ip: string) => { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) - } - - const compressedData = [ - 'CSA', - ipToNumber(ip), - candidatesData.map((candidate: WebSocketCandidatesResponse) => ipToNumber(candidate.host)), - port, // 端口号 - Date.now() % 86400000 - ] - - return compressedData - } - - const compressedData = optimizeConnectionInfo() - const qrCodeValue = JSON.stringify(compressedData) - setQrCodeValue(qrCodeValue) - setConnectionPhase('waiting_qr_scan') - } else { - setError(t('settings.data.export_to_phone.lan.error.no_ip')) - setConnectionPhase('error') - } - } catch (error) { - setError( - `${t('settings.data.export_to_phone.lan.error.init_failed')}: ${error instanceof Error ? error.message : ''}` - ) - setConnectionPhase('error') - logger.error('Failed to initialize WebSocket:', error as Error) - } - }, [t]) - - const handleClientConnected = useCallback((_event: any, data: { connected: boolean }) => { - logger.info(`Client connection status: ${data.connected ? 'connected' : 'disconnected'}`) - if (data.connected) { - setConnectionPhase('connected') - setError(null) - } else { - setConnectionPhase('disconnected') - } - }, []) - - const handleMessageReceived = useCallback((_event: any, data: any) => { - logger.info(`Received message from mobile: ${JSON.stringify(data)}`) - }, []) - - const handleSendProgress = useCallback( - (_event: any, data: { progress: number }) => { - const progress = data.progress - setSendProgress(progress) - - if (transferPhase === 'preparing' && progress > 0) { - setTransferPhase('sending') - } - - if (progress >= 100) { - setTransferPhase('completed') - // 启动 3 秒倒计时自动关闭 - setAutoCloseCountdown(3) - } - }, - [transferPhase] - ) - - const handleSelectZip = useCallback(async () => { - const result = await window.api.file.select() - if (result) { - setSelectedFolderPath(result[0].path) - } - }, []) - - const handleSendZip = useCallback(async () => { - if (!selectedFolderPath) { - setError(t('settings.data.export_to_phone.lan.error.no_file')) - return - } - - setTransferPhase('preparing') - setError(null) - setSendProgress(0) - - try { - logger.info(`Starting file transfer: ${selectedFolderPath}`) - await window.api.webSocket.sendFile(selectedFolderPath) - } catch (error) { - setError( - `${t('settings.data.export_to_phone.lan.error.send_failed')}: ${error instanceof Error ? error.message : ''}` - ) - setTransferPhase('error') - logger.error('Failed to send file:', error as Error) - } - }, [selectedFolderPath, t]) - - // 尝试关闭弹窗 - 如果正在传输则显示确认 - const handleCancel = useCallback(() => { - if (isSending) { - window.modal.confirm({ - title: t('settings.data.export_to_phone.lan.confirm_close_title'), - content: t('settings.data.export_to_phone.lan.confirm_close_message'), - centered: true, - okButtonProps: { - danger: true - }, - okText: t('settings.data.export_to_phone.lan.force_close'), - onOk: () => setIsOpen(false) - }) - } else { - setIsOpen(false) - } - }, [isSending, t]) - - // 清理并关闭 - const handleClose = useCallback(async () => { - try { - // 主动断开 WebSocket 连接 - if (isConnected || connectionPhase !== 'disconnected') { - logger.info('Closing popup, stopping WebSocket') - await window.api.webSocket.stop() - } - } catch (error) { - logger.error('Failed to stop WebSocket on close:', error as Error) - } - resolve({}) - }, [resolve, isConnected, connectionPhase]) - - useEffect(() => { - initWebSocket() - - const removeClientConnectedListener = window.electron.ipcRenderer.on( - 'websocket-client-connected', - handleClientConnected - ) - const removeMessageReceivedListener = window.electron.ipcRenderer.on( - 'websocket-message-received', - handleMessageReceived - ) - const removeSendProgressListener = window.electron.ipcRenderer.on('file-send-progress', handleSendProgress) - - return () => { - removeClientConnectedListener() - removeMessageReceivedListener() - removeSendProgressListener() - window.api.webSocket.stop() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - // 自动关闭倒计时 - useEffect(() => { - if (autoCloseCountdown === null) return - - if (autoCloseCountdown <= 0) { - logger.debug('Auto-closing popup after transfer completion') - setIsOpen(false) - return - } - - const timer = setTimeout(() => { - setAutoCloseCountdown(autoCloseCountdown - 1) - }, 1000) - - return () => clearTimeout(timer) - }, [autoCloseCountdown]) - - // 状态指示器组件 - const StatusIndicator = useCallback( - () => ( -
- {connectionStatusText} -
- ), - [connectionStatusStyles, connectionStatusText] - ) - - // 二维码显示组件 - 使用显式条件渲染以避免类型不匹配 - const QRCodeDisplay = useCallback(() => { - switch (connectionPhase) { - case 'waiting_qr_scan': - case 'disconnected': - return - case 'initializing': - return - case 'connecting': - return - case 'connected': - return - case 'error': - return - default: - return null - } - }, [connectionPhase, qrCodeValue, error]) - - // 传输进度组件 - const TransferProgress = useCallback(() => { - if (!isSending && transferPhase !== 'completed') return null - - return ( -
-
-
- - {t('settings.data.export_to_phone.lan.transfer_progress')} - - - {transferPhase === 'completed' ? '✅ ' + t('common.completed') : `${Math.round(sendProgress)}%`} - -
- - -
-
- ) - }, [isSending, transferPhase, sendProgress, t]) - - const AutoCloseCountdown = useCallback(() => { - if (transferPhase !== 'completed' || autoCloseCountdown === null || autoCloseCountdown <= 0) return null - - return ( -
- {t('settings.data.export_to_phone.lan.auto_close_tip', { seconds: autoCloseCountdown })} -
- ) - }, [transferPhase, autoCloseCountdown, t]) - - // 错误显示组件 - const ErrorDisplay = useCallback(() => { - if (!error || transferPhase !== 'error') return null - - return ( -
- ❌ {error} -
- ) - }, [error, transferPhase]) - - return ( - - - - - - - - - - - - -
- - -
-
- - - {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} - - - - - -
- ) -} - -const TopViewKey = 'ExportToPhoneLanPopup' - -export default class ExportToPhoneLanPopup { - static topviewId = 0 - static hide() { - TopView.hide(TopViewKey) - } - static show() { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - TopView.hide(TopViewKey) - }} - />, - TopViewKey - ) - }) - } -} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx b/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx new file mode 100644 index 0000000000..db16112e04 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/LanDeviceCard.tsx @@ -0,0 +1,97 @@ +import { cn } from '@renderer/utils' +import type { FC, KeyboardEventHandler } from 'react' +import { useTranslation } from 'react-i18next' + +import { ProgressIndicator } from './ProgressIndicator' +import type { LanDeviceCardProps } from './types' + +export const LanDeviceCard: FC = ({ + service, + transferState, + isConnected, + handshakeInProgress, + isDisabled, + onSendFile +}) => { + const { t } = useTranslation() + + // Device info + const deviceName = service.txt?.modelName || t('common.unknown') + const platform = service.txt?.platform + const appVersion = service.txt?.appVersion + const platformInfo = [platform, appVersion].filter(Boolean).join(' ') + const displayTitle = platformInfo ? `${deviceName} (${platformInfo})` : deviceName + + // Address info + const primaryAddress = service.addresses?.[0] + const addressesWithPort = primaryAddress ? (service.port ? `${primaryAddress}:${service.port}` : primaryAddress) : '' + + // Progress visibility + const shouldShowProgress = + transferState && ['selecting', 'transferring', 'completed', 'failed'].includes(transferState.status) + + // Status text + const statusText = handshakeInProgress + ? t('settings.data.export_to_phone.lan.handshake.in_progress') + : isConnected + ? t('settings.data.export_to_phone.lan.connected') + : t('settings.data.export_to_phone.lan.send_file') + + // Event handlers + const handleClick = () => { + if (isDisabled) return + onSendFile(service.id) + } + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleClick() + } + } + + return ( +
+ {/* Header */} +
+
+
{displayTitle}
+ {statusText} +
+
+ + {/* Meta Row - IP Address */} +
+ + {t('settings.data.export_to_phone.lan.ip_addresses')} + + {addressesWithPort || t('common.unknown')} +
+ + {/* Footer with Progress */} +
+ {shouldShowProgress && transferState && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx b/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx new file mode 100644 index 0000000000..b9707b4485 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/ProgressIndicator.tsx @@ -0,0 +1,55 @@ +import { cn } from '@renderer/utils' +import type { FC } from 'react' +import { useTranslation } from 'react-i18next' + +import type { ProgressIndicatorProps } from './types' + +export const ProgressIndicator: FC = ({ transferState, handshakeInProgress }) => { + const { t } = useTranslation() + + const progressPercent = Math.min(100, Math.max(0, transferState.progress ?? 0)) + + const progressLabel = (() => { + if (transferState.status === 'failed') { + return transferState.error || t('common.unknown_error') + } + if (transferState.status === 'selecting') { + return handshakeInProgress + ? t('settings.data.export_to_phone.lan.handshake.in_progress') + : t('settings.data.export_to_phone.lan.status.preparing') + } + return `${Math.round(progressPercent)}%` + })() + + const isFailed = transferState.status === 'failed' + const isCompleted = transferState.status === 'completed' + + return ( +
+ {/* Label Row */} +
+ {transferState.fileName} + {progressLabel} +
+ + {/* Progress Track */} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/hook.ts b/src/renderer/src/components/Popups/LanTransferPopup/hook.ts new file mode 100644 index 0000000000..6d2ea77527 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/hook.ts @@ -0,0 +1,397 @@ +import { loggerService } from '@logger' +import { getBackupData } from '@renderer/services/BackupService' +import type { LocalTransferPeer } from '@shared/config/types' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' +import { useTranslation } from 'react-i18next' + +import type { LanPeerTransferState, LanTransferAction, LanTransferReducerState } from './types' + +const logger = loggerService.withContext('useLanTransfer') + +// ========================================== +// Initial State +// ========================================== + +export const initialState: LanTransferReducerState = { + open: true, + lanState: null, + lanHandshakePeerId: null, + lastHandshakeResult: null, + fileTransferState: {}, + tempBackupPath: null +} + +// ========================================== +// Reducer +// ========================================== + +export function lanTransferReducer(state: LanTransferReducerState, action: LanTransferAction): LanTransferReducerState { + switch (action.type) { + case 'SET_OPEN': + return { ...state, open: action.payload } + + case 'SET_LAN_STATE': + return { ...state, lanState: action.payload } + + case 'SET_HANDSHAKE_PEER_ID': + return { ...state, lanHandshakePeerId: action.payload } + + case 'SET_HANDSHAKE_RESULT': + return { ...state, lastHandshakeResult: action.payload } + + case 'SET_TEMP_BACKUP_PATH': + return { ...state, tempBackupPath: action.payload } + + case 'UPDATE_TRANSFER_STATE': { + const { peerId, state: transferState } = action.payload + return { + ...state, + fileTransferState: { + ...state.fileTransferState, + [peerId]: { + ...(state.fileTransferState[peerId] ?? { progress: 0, status: 'idle' as const }), + ...transferState + } + } + } + } + + case 'SET_TRANSFER_STATE': { + const { peerId, state: transferState } = action.payload + return { + ...state, + fileTransferState: { + ...state.fileTransferState, + [peerId]: transferState + } + } + } + + case 'CLEANUP_STALE_PEERS': { + const activeIds = action.payload + const newFileTransferState: Record = {} + for (const id of Object.keys(state.fileTransferState)) { + if (activeIds.has(id)) { + newFileTransferState[id] = state.fileTransferState[id] + } + } + return { + ...state, + fileTransferState: newFileTransferState, + lastHandshakeResult: + state.lastHandshakeResult && activeIds.has(state.lastHandshakeResult.peerId) + ? state.lastHandshakeResult + : null, + lanHandshakePeerId: + state.lanHandshakePeerId && activeIds.has(state.lanHandshakePeerId) ? state.lanHandshakePeerId : null + } + } + + case 'RESET_CONNECTION_STATE': + return { + ...state, + fileTransferState: {}, + lastHandshakeResult: null, + lanHandshakePeerId: null, + tempBackupPath: null + } + + default: + return state + } +} + +// ========================================== +// Hook Return Type +// ========================================== + +export interface UseLanTransferReturn { + // State + state: LanTransferReducerState + + // Derived values + lanDevices: LocalTransferPeer[] + isAnyTransferring: boolean + lastError: string | undefined + + // Actions + handleSendFile: (peerId: string) => Promise + handleModalCancel: () => void + getTransferState: (peerId: string) => LanPeerTransferState | undefined + isConnected: (peerId: string) => boolean + isHandshakeInProgress: (peerId: string) => boolean + + // Dispatch (for advanced use) + dispatch: React.Dispatch +} + +// ========================================== +// Hook +// ========================================== + +export function useLanTransfer(): UseLanTransferReturn { + const { t } = useTranslation() + const [state, dispatch] = useReducer(lanTransferReducer, initialState) + const isSendingRef = useRef(false) + + // ========================================== + // Derived Values + // ========================================== + + const lanDevices = useMemo(() => state.lanState?.services ?? [], [state.lanState]) + + const isAnyTransferring = useMemo( + () => Object.values(state.fileTransferState).some((s) => s.status === 'transferring' || s.status === 'selecting'), + [state.fileTransferState] + ) + + const lastError = state.lanState?.lastError + + // ========================================== + // LAN State Sync + // ========================================== + + const syncLanState = useCallback(async () => { + if (!window.api?.localTransfer) { + logger.warn('Local transfer bridge is unavailable') + return + } + try { + const nextState = await window.api.localTransfer.getState() + dispatch({ type: 'SET_LAN_STATE', payload: nextState }) + } catch (error) { + logger.error('Failed to sync LAN state', error as Error) + } + }, []) + + // ========================================== + // Send File Handler + // ========================================== + + const handleSendFile = useCallback( + async (peerId: string) => { + if (!window.api?.localTransfer || isSendingRef.current) { + return + } + isSendingRef.current = true + + dispatch({ + type: 'SET_TRANSFER_STATE', + payload: { peerId, state: { progress: 0, status: 'selecting' } } + }) + + let backupPath: string | null = null + + try { + // Step 0: Ensure handshake (connect if needed) + if (!state.lastHandshakeResult?.ack.accepted || state.lastHandshakeResult.peerId !== peerId) { + dispatch({ type: 'SET_HANDSHAKE_PEER_ID', payload: peerId }) + try { + const ack = await window.api.localTransfer.connect({ peerId }) + dispatch({ + type: 'SET_HANDSHAKE_RESULT', + payload: { peerId, ack, timestamp: Date.now() } + }) + if (!ack.accepted) { + throw new Error(ack.message || t('settings.data.export_to_phone.lan.connection_failed')) + } + } finally { + dispatch({ type: 'SET_HANDSHAKE_PEER_ID', payload: null }) + } + } + + // Step 1: Create temporary backup + logger.info('Creating temporary backup for LAN transfer...') + const backupData = await getBackupData() + backupPath = await window.api.backup.createLanTransferBackup(backupData) + dispatch({ type: 'SET_TEMP_BACKUP_PATH', payload: backupPath }) + + // Extract filename from path + const fileName = backupPath.split(/[/\\]/).pop() || 'backup.zip' + + // Step 2: Set transferring state + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { fileName, progress: 0, status: 'transferring' } } + }) + + // Step 3: Send file + logger.info(`Sending backup file: ${backupPath}`) + const result = await window.api.localTransfer.sendFile(backupPath) + + if (result.success) { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { progress: 100, status: 'completed' } } + }) + } else { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { status: 'failed', error: result.error } } + }) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { peerId, state: { status: 'failed', error: message } } + }) + logger.error('Failed to send file', error as Error) + } finally { + // Step 4: Clean up temp file + if (backupPath) { + try { + await window.api.backup.deleteTempBackup(backupPath) + logger.info('Cleaned up temporary backup file') + } catch (cleanupError) { + logger.warn('Failed to clean up temp backup', cleanupError as Error) + } + dispatch({ type: 'SET_TEMP_BACKUP_PATH', payload: null }) + } + isSendingRef.current = false + } + }, + [state.lastHandshakeResult, t] + ) + + // ========================================== + // Teardown + // ========================================== + + // Use ref to track temp backup path for cleanup without causing effect re-runs + const tempBackupPathRef = useRef(null) + tempBackupPathRef.current = state.tempBackupPath + + const teardownLan = useCallback(async () => { + if (!window.api?.localTransfer) { + return + } + try { + await window.api.localTransfer.cancelTransfer?.() + } catch (error) { + logger.warn('Failed to cancel LAN transfer on close', error as Error) + } + try { + await window.api.localTransfer.disconnect?.() + } catch (error) { + logger.warn('Failed to disconnect LAN on close', error as Error) + } + // Clean up temp backup if exists (use ref to get current value) + if (tempBackupPathRef.current) { + try { + await window.api.backup.deleteTempBackup(tempBackupPathRef.current) + } catch (error) { + logger.warn('Failed to cleanup temp backup on close', error as Error) + } + } + dispatch({ type: 'RESET_CONNECTION_STATE' }) + }, []) // No dependencies - uses ref for current value + + const handleModalCancel = useCallback(() => { + void teardownLan() + dispatch({ type: 'SET_OPEN', payload: false }) + }, [teardownLan]) + + // ========================================== + // Effects + // ========================================== + + // Initial sync and service listener + useEffect(() => { + if (!window.api?.localTransfer) { + return + } + syncLanState() + const removeListener = window.api.localTransfer.onServicesUpdated((lanState) => { + dispatch({ type: 'SET_LAN_STATE', payload: lanState }) + }) + return () => { + removeListener?.() + } + }, [syncLanState]) + + // Client events listener (progress, completion) + useEffect(() => { + if (!window.api?.localTransfer) { + return + } + const removeListener = window.api.localTransfer.onClientEvent((event) => { + const key = event.peerId ?? 'global' + + if (event.type === 'file_transfer_progress') { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { + peerId: key, + state: { + transferId: event.transferId, + fileName: event.fileName, + progress: event.progress, + speed: event.speed, + status: 'transferring' + } + } + }) + } else if (event.type === 'file_transfer_complete') { + dispatch({ + type: 'UPDATE_TRANSFER_STATE', + payload: { + peerId: key, + state: { + progress: event.success ? 100 : undefined, + status: event.success ? 'completed' : 'failed', + error: event.error + } + } + }) + } + }) + return () => { + removeListener?.() + } + }, []) + + // Cleanup stale peers when services change + useEffect(() => { + const activeIds = new Set(lanDevices.map((s) => s.id)) + dispatch({ type: 'CLEANUP_STALE_PEERS', payload: activeIds }) + }, [lanDevices]) + + // Cleanup on unmount only (teardownLan is stable with no deps) + useEffect(() => { + return () => { + void teardownLan() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ========================================== + // Helper Functions + // ========================================== + + const getTransferState = useCallback((peerId: string) => state.fileTransferState[peerId], [state.fileTransferState]) + + const isConnected = useCallback( + (peerId: string) => + state.lastHandshakeResult?.peerId === peerId && state.lastHandshakeResult?.ack.accepted === true, + [state.lastHandshakeResult] + ) + + const isHandshakeInProgress = useCallback( + (peerId: string) => state.lanHandshakePeerId === peerId, + [state.lanHandshakePeerId] + ) + + return { + state, + lanDevices, + isAnyTransferring, + lastError, + handleSendFile, + handleModalCancel, + getTransferState, + isConnected, + isHandshakeInProgress, + dispatch + } +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/index.tsx b/src/renderer/src/components/Popups/LanTransferPopup/index.tsx new file mode 100644 index 0000000000..66455f12a1 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/index.tsx @@ -0,0 +1,37 @@ +import { TopView } from '@renderer/components/TopView' + +import { getHideCallback, PopupContainer } from './popup' +import type { PopupResolveData } from './types' + +// Re-export types for external use +export type { LanPeerTransferState } from './types' + +const TopViewKey = 'LanTransferPopup' + +export default class LanTransferPopup { + static topviewId = 0 + + static hide() { + // Try to use the registered callback for proper cleanup, fallback to TopView.hide + const callback = getHideCallback() + if (callback) { + callback() + } else { + TopView.hide(TopViewKey) + } + } + + static show() { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx b/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx new file mode 100644 index 0000000000..34c53a6ad6 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/popup.tsx @@ -0,0 +1,88 @@ +import { Modal } from 'antd' +import { TriangleAlert } from 'lucide-react' +import type { FC } from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { useLanTransfer } from './hook' +import { LanDeviceCard } from './LanDeviceCard' +import type { PopupContainerProps } from './types' + +// Module-level callback for external hide access +let hideCallback: (() => void) | null = null +export const setHideCallback = (cb: () => void) => { + hideCallback = cb +} +export const getHideCallback = () => hideCallback + +export const PopupContainer: FC = ({ resolve }) => { + const { t } = useTranslation() + + const { + state, + lanDevices, + isAnyTransferring, + lastError, + handleSendFile, + handleModalCancel, + getTransferState, + isConnected, + isHandshakeInProgress + } = useLanTransfer() + + const contentTitle = useMemo(() => t('settings.data.export_to_phone.lan.title'), [t]) + + const onClose = () => resolve({}) + + // Register hide callback for external access + setHideCallback(handleModalCancel) + + return ( + +
+ {/* Error Display */} + {lastError &&
{lastError}
} + + {/* Device List */} +
+ {lanDevices.length === 0 ? ( + // Warning when no devices +
+ + + {t('settings.data.export_to_phone.lan.no_connection_warning')} + +
+ ) : ( + // Device cards + lanDevices.map((service) => { + const transferState = getTransferState(service.id) + const connected = isConnected(service.id) + const handshakeInProgress = isHandshakeInProgress(service.id) + const isCardDisabled = isAnyTransferring || handshakeInProgress + + return ( + + ) + }) + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/Popups/LanTransferPopup/types.ts b/src/renderer/src/components/Popups/LanTransferPopup/types.ts new file mode 100644 index 0000000000..644541bc27 --- /dev/null +++ b/src/renderer/src/components/Popups/LanTransferPopup/types.ts @@ -0,0 +1,84 @@ +import type { LanHandshakeAckMessage, LocalTransferPeer, LocalTransferState } from '@shared/config/types' + +// ========================================== +// Transfer Status +// ========================================== + +export type TransferStatus = 'idle' | 'selecting' | 'transferring' | 'completed' | 'failed' + +// ========================================== +// Per-Peer Transfer State +// ========================================== + +export interface LanPeerTransferState { + transferId?: string + fileName?: string + progress: number + speed?: number + status: TransferStatus + error?: string +} + +// ========================================== +// Handshake Result +// ========================================== + +export type HandshakeResult = { + peerId: string + ack: LanHandshakeAckMessage + timestamp: number +} | null + +// ========================================== +// Reducer State +// ========================================== + +export interface LanTransferReducerState { + open: boolean + lanState: LocalTransferState | null + lanHandshakePeerId: string | null + lastHandshakeResult: HandshakeResult + fileTransferState: Record + tempBackupPath: string | null +} + +// ========================================== +// Reducer Actions +// ========================================== + +export type LanTransferAction = + | { type: 'SET_OPEN'; payload: boolean } + | { type: 'SET_LAN_STATE'; payload: LocalTransferState | null } + | { type: 'SET_HANDSHAKE_PEER_ID'; payload: string | null } + | { type: 'SET_HANDSHAKE_RESULT'; payload: HandshakeResult } + | { type: 'SET_TEMP_BACKUP_PATH'; payload: string | null } + | { type: 'UPDATE_TRANSFER_STATE'; payload: { peerId: string; state: Partial } } + | { type: 'SET_TRANSFER_STATE'; payload: { peerId: string; state: LanPeerTransferState } } + | { type: 'CLEANUP_STALE_PEERS'; payload: Set } + | { type: 'RESET_CONNECTION_STATE' } + +// ========================================== +// Component Props +// ========================================== + +export interface LanDeviceCardProps { + service: LocalTransferPeer + transferState?: LanPeerTransferState + isConnected: boolean + handshakeInProgress: boolean + isDisabled: boolean + onSendFile: (peerId: string) => void +} + +export interface ProgressIndicatorProps { + transferState: LanPeerTransferState + handshakeInProgress: boolean +} + +export interface PopupResolveData { + // Empty for now, can be extended +} + +export interface PopupContainerProps { + resolve: (data: PopupResolveData) => void +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index f4012363e3..83443ab842 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3232,24 +3232,43 @@ }, "content": "Export some data, including chat logs and settings. Please note that the backup process may take some time. Thank you for your patience.", "lan": { - "auto_close_tip": "Auto-closing in {{seconds}} seconds...", - "confirm_close_message": "File transfer is in progress. Closing will interrupt the transfer. Are you sure you want to force close?", - "confirm_close_title": "Confirm Close", "connected": "Connected", "connection_failed": "Connection failed", - "content": "Please ensure your computer and phone are on the same network for LAN transfer. Open the Cherry Studio App to scan this QR code.", + "content": "Please ensure your computer and phone are on the same network for LAN transfer.", + "device_list_title": "Local network devices", + "discovered_devices": "Discovered devices", "error": { + "file_too_large": "File too large, maximum 500MB supported", "init_failed": "Initialization failed", + "invalid_file_type": "Only ZIP files are supported", "no_file": "No file selected", "no_ip": "Unable to get IP address", + "not_connected": "Please complete handshake first", "send_failed": "Failed to send file" }, - "force_close": "Force Close", - "generating_qr": "Generating QR code...", - "noZipSelected": "No compressed file selected", - "scan_qr": "Please scan QR code with your phone", - "selectZip": "Select a compressed file", - "sendZip": "Begin data recovery", + "file_transfer": { + "cancelled": "Transfer cancelled", + "failed": "File transfer failed: {{message}}", + "progress": "Sending... {{progress}}%", + "success": "File sent successfully" + }, + "handshake": { + "button": "Handshake", + "failed": "Handshake failed: {{message}}", + "in_progress": "Handshaking...", + "success": "Handshake completed with {{device}}", + "test_message_received": "Received pong from {{device}}", + "test_message_sent": "Sent hello world test payload" + }, + "idle_hint": "Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "IP addresses", + "last_seen": "Last seen at {{time}}", + "metadata": "Metadata", + "no_connection_warning": "Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "No LAN peers found yet", + "scan_devices": "Scan devices", + "scanning_hint": "Scanning your local network for Cherry Studio peers...", + "send_file": "Send File", "status": { "completed": "Transfer completed", "connected": "Connected", @@ -3258,9 +3277,11 @@ "error": "Connection error", "initializing": "Initializing connection...", "preparing": "Preparing transfer...", - "sending": "Transferring {{progress}}%", - "waiting_qr_scan": "Please scan QR code to connect" + "sending": "Transferring {{progress}}%" }, + "status_badge_idle": "Idle", + "status_badge_scanning": "Scanning", + "stop_scan": "Stop scan", "title": "LAN transmission", "transfer_progress": "Transfer progress" }, diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 0e5b2f60e7..dc6ea92588 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3232,24 +3232,43 @@ }, "content": "导出部分数据,包括聊天记录、设置。请注意,备份过程可能需要一些时间,感谢您的耐心等待。", "lan": { - "auto_close_tip": "{{seconds}} 秒后自动关闭...", - "confirm_close_message": "文件正在传输中,关闭将中断传输。确定要强制关闭吗?", - "confirm_close_title": "确认关闭", "connected": "连接成功", "connection_failed": "连接失败", - "content": "请确保电脑和手机处于同一网络以使用局域网传输。请打开 Cherry Studio App 扫描此二维码。", + "content": "请确保电脑和手机处于同一网络以使用局域网传输。", + "device_list_title": "局域网设备列表", + "discovered_devices": "已发现的设备", "error": { + "file_too_large": "文件过大,最大支持 500MB", "init_failed": "初始化失败", + "invalid_file_type": "仅支持 ZIP 文件", "no_file": "未选择文件", "no_ip": "无法获取 IP 地址", + "not_connected": "请先完成握手连接", "send_failed": "发送文件失败" }, - "force_close": "强制关闭", - "generating_qr": "正在生成二维码...", - "noZipSelected": "未选择压缩文件", - "scan_qr": "请使用手机扫码连接", - "selectZip": "选择压缩文件", - "sendZip": "开始恢复数据", + "file_transfer": { + "cancelled": "传输已取消", + "failed": "文件发送失败: {{message}}", + "progress": "发送中... {{progress}}%", + "success": "文件发送成功" + }, + "handshake": { + "button": "握手测试", + "failed": "握手失败:{{message}}", + "in_progress": "正在握手...", + "success": "已与 {{device}} 建立握手", + "test_message_received": "已收到 {{device}} 的 pong 响应", + "test_message_sent": "已发送 hello world 测试数据" + }, + "idle_hint": "扫描已暂停。开始扫描以发现局域网中的 Cherry Studio 设备。", + "ip_addresses": "IP 地址", + "last_seen": "最后活动:{{time}}", + "metadata": "元数据", + "no_connection_warning": "请在 Cherry Studio 移动端打开局域网传输", + "no_devices": "尚未发现局域网设备", + "scan_devices": "扫描设备", + "scanning_hint": "正在扫描局域网中的 Cherry Studio 设备...", + "send_file": "发送文件", "status": { "completed": "传输完成", "connected": "连接成功", @@ -3258,9 +3277,11 @@ "error": "连接出错", "initializing": "正在初始化连接...", "preparing": "准备传输中...", - "sending": "传输中 {{progress}}%", - "waiting_qr_scan": "请扫描二维码连接" + "sending": "传输中 {{progress}}%" }, + "status_badge_idle": "空闲", + "status_badge_scanning": "扫描中", + "stop_scan": "停止扫描", "title": "局域网传输", "transfer_progress": "传输进度" }, diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9625c68386..434e7e4a39 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3232,24 +3232,43 @@ }, "content": "匯出部分資料,包括聊天記錄與設定。請注意,備份過程可能需要一些時間,感謝耐心等候。", "lan": { - "auto_close_tip": "將於 {{seconds}} 秒後自動關閉...", - "confirm_close_message": "檔案傳輸正在進行中。關閉將會中斷傳輸。您確定要強制關閉嗎?", - "confirm_close_title": "確認關閉", "connected": "已連線", "connection_failed": "連線失敗", - "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。請開啟 Cherry Studio App 掃描此 QR 碼。", + "content": "請確保電腦和手機處於同一網路以使用區域網路傳輸。", + "device_list_title": "區域網路裝置", + "discovered_devices": "已發現的裝置", "error": { + "file_too_large": "檔案過大,僅支援最大 500MB", "init_failed": "初始化失敗", + "invalid_file_type": "僅支援 ZIP 檔案", "no_file": "未選擇檔案", "no_ip": "無法取得 IP 位址", + "not_connected": "請先完成握手", "send_failed": "無法傳送檔案" }, - "force_close": "強制關閉", - "generating_qr": "正在產生 QR 碼...", - "noZipSelected": "未選取壓縮檔案", - "scan_qr": "請使用手機掃描 QR 碼", - "selectZip": "選擇壓縮檔案", - "sendZip": "開始還原資料", + "file_transfer": { + "cancelled": "傳輸已取消", + "failed": "檔案傳輸失敗:{{message}}", + "progress": "傳送中... {{progress}}%", + "success": "檔案傳送成功" + }, + "handshake": { + "button": "握手", + "failed": "握手失敗:{{message}}", + "in_progress": "握手中...", + "success": "已與 {{device}} 完成握手", + "test_message_received": "收到來自 {{device}} 的 pong", + "test_message_sent": "已送出 hello world 測試封包" + }, + "idle_hint": "掃描已暫停。開始掃描以尋找區域網路中的 Cherry Studio 裝置。", + "ip_addresses": "IP 位址", + "last_seen": "上次看到:{{time}}", + "metadata": "中繼資料", + "no_connection_warning": "請在 Cherry Studio 行動裝置開啟區域網路傳輸", + "no_devices": "尚未找到區域網路節點", + "scan_devices": "掃描裝置", + "scanning_hint": "正在掃描區域網路中的 Cherry Studio 裝置...", + "send_file": "傳送檔案", "status": { "completed": "傳輸完成", "connected": "已連線", @@ -3258,9 +3277,11 @@ "error": "連線錯誤", "initializing": "正在初始化連線...", "preparing": "正在準備傳輸...", - "sending": "傳輸中 {{progress}}%", - "waiting_qr_scan": "請掃描 QR 碼以連線" + "sending": "傳輸中 {{progress}}%" }, + "status_badge_idle": "閒置", + "status_badge_scanning": "掃描中", + "stop_scan": "停止掃描", "title": "區域網路傳輸", "transfer_progress": "傳輸進度" }, diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index b3acb49950..85019563a6 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3232,24 +3232,43 @@ }, "content": "Exportieren Sie einige Daten, einschließlich Chat-Protokollen und Einstellungen. Bitte beachten Sie, dass der Sicherungsvorgang einige Zeit in Anspruch nehmen kann. Vielen Dank für Ihre Geduld.", "lan": { - "auto_close_tip": "Automatisches Schließen in {{seconds}} Sekunden...", - "confirm_close_message": "Dateiübertragung läuft. Beim Schließen wird die Übertragung unterbrochen. Möchten Sie wirklich das Schließen erzwingen?", - "confirm_close_title": "Schließen bestätigen", "connected": "Verbunden", "connection_failed": "Verbindung fehlgeschlagen", "content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Initialisierung fehlgeschlagen", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Keine Datei ausgewählt", "no_ip": "IP-Adresse kann nicht abgerufen werden", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Fehler beim Senden der Datei" }, - "force_close": "Erzwungenes Schließen", - "generating_qr": "QR-Code wird generiert...", - "noZipSelected": "Keine komprimierte Datei ausgewählt", - "scan_qr": "Bitte scannen Sie den QR-Code mit Ihrem Telefon.", - "selectZip": "Wählen Sie eine komprimierte Datei", - "sendZip": "Datenwiederherstellung beginnen", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Übertragung abgeschlossen", "connected": "Verbunden", @@ -3258,9 +3277,11 @@ "error": "Verbindungsfehler", "initializing": "Verbindung wird initialisiert...", "preparing": "Übertragung wird vorbereitet...", - "sending": "Übertrage {{progress}}%", - "waiting_qr_scan": "Bitte QR-Code scannen, um zu verbinden" + "sending": "Übertrage {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "LAN-Übertragung", "transfer_progress": "Übertragungsfortschritt" }, diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index ae7b855646..1bdf109fde 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3232,24 +3232,43 @@ }, "content": "Εξαγωγή μέρους των δεδομένων, συμπεριλαμβανομένων των ιστορικών συνομιλιών και των ρυθμίσεων. Σημειώστε ότι η διαδικασία δημιουργίας αντιγράφων ασφαλείας ενδέχεται να διαρκέσει κάποιο χρονικό διάστημα, ευχαριστούμε για την υπομονή σας.", "lan": { - "auto_close_tip": "Αυτόματο κλείσιμο σε {{seconds}} δευτερόλεπτα...", - "confirm_close_message": "Η μεταφορά αρχείων είναι σε εξέλιξη. Το κλείσιμο θα διακόψει τη μεταφορά. Είστε σίγουροι ότι θέλετε να κλείσετε βίαια;", - "confirm_close_title": "Επιβεβαίωση Κλεισίματος", "connected": "Συνδεδεμένος", "connection_failed": "Η σύνδεση απέτυχε", "content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Η αρχικοποίηση απέτυχε", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Κανένα αρχείο δεν επιλέχθηκε", "no_ip": "Αδυναμία λήψης διεύθυνσης IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Αποτυχία αποστολής αρχείου" }, - "force_close": "Κλείσιμο με βία", - "generating_qr": "Δημιουργία κώδικα QR...", - "noZipSelected": "Δεν επιλέχθηκε συμπιεσμένο αρχείο", - "scan_qr": "Παρακαλώ σαρώστε τον κωδικό QR με το τηλέφωνό σας", - "selectZip": "Επιλέξτε συμπιεσμένο αρχείο", - "sendZip": "Έναρξη ανάκτησης δεδομένων", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Η μεταφορά ολοκληρώθηκε", "connected": "Συνδεδεμένος", @@ -3258,9 +3277,11 @@ "error": "Σφάλμα σύνδεσης", "initializing": "Αρχικοποίηση σύνδεσης...", "preparing": "Προετοιμασία μεταφοράς...", - "sending": "Μεταφορά {{progress}}%", - "waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση" + "sending": "Μεταφορά {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Μεταφορά τοπικού δικτύου", "transfer_progress": "Πρόοδος μεταφοράς" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 26b499cba2..ffb1be85a5 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3232,24 +3232,43 @@ }, "content": "Exportar parte de los datos, incluidos los registros de chat y la configuración. Tenga en cuenta que el proceso de copia de seguridad puede tardar un tiempo; gracias por su paciencia.", "lan": { - "auto_close_tip": "Cierre automático en {{seconds}} segundos...", - "confirm_close_message": "La transferencia de archivos está en progreso. Cerrar interrumpirá la transferencia. ¿Estás seguro de que quieres forzar el cierre?", - "confirm_close_title": "Confirmar Cierre", "connected": "Conectado", "connection_failed": "Conexión fallida", "content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Falló la inicialización", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Ningún archivo seleccionado", "no_ip": "No se puede obtener la dirección IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Error al enviar el archivo" }, - "force_close": "Cerrar forzosamente", - "generating_qr": "Generando código QR...", - "noZipSelected": "No se ha seleccionado ningún archivo comprimido", - "scan_qr": "Por favor, escanea el código QR con tu teléfono", - "selectZip": "Seleccionar archivo comprimido", - "sendZip": "Comenzar la recuperación de datos", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transferencia completada", "connected": "Conectado", @@ -3258,9 +3277,11 @@ "error": "Error de conexión", "initializing": "Inicializando conexión...", "preparing": "Preparando transferencia...", - "sending": "Transfiriendo {{progress}}%", - "waiting_qr_scan": "Por favor, escanea el código QR para conectarte" + "sending": "Transfiriendo {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Transferencia de red local", "transfer_progress": "Progreso de transferencia" }, diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 4dff56d7e9..76736c73c1 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3232,24 +3232,43 @@ }, "content": "Exporter une partie des données, incluant les historiques de discussion et les paramètres. Veuillez noter que le processus de sauvegarde peut prendre un certain temps ; merci pour votre patience.", "lan": { - "auto_close_tip": "Fermeture automatique dans {{seconds}} secondes...", - "confirm_close_message": "Le transfert de fichier est en cours. Fermer interrompra le transfert. Êtes-vous sûr de vouloir forcer la fermeture ?", - "confirm_close_title": "Confirmer la fermeture", "connected": "Connecté", "connection_failed": "Échec de la connexion", "content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Échec de l'initialisation", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Aucun fichier sélectionné", "no_ip": "Impossible d'obtenir l'adresse IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Échec de l'envoi du fichier" }, - "force_close": "Fermer de force", - "generating_qr": "Génération du code QR...", - "noZipSelected": "Aucun fichier compressé sélectionné", - "scan_qr": "Veuillez scanner le code QR avec votre téléphone", - "selectZip": "Sélectionner le fichier compressé", - "sendZip": "Commencer la restauration des données", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transfert terminé", "connected": "Connecté", @@ -3258,9 +3277,11 @@ "error": "Erreur de connexion", "initializing": "Initialisation de la connexion...", "preparing": "Préparation du transfert...", - "sending": "Transfert {{progress}} %", - "waiting_qr_scan": "Veuillez scanner le code QR pour vous connecter" + "sending": "Transfert {{progress}} %" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Transmission en réseau local", "transfer_progress": "Progression du transfert" }, diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 090a1927cd..8b6c99648f 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3232,24 +3232,43 @@ }, "content": "一部のデータ、チャット履歴や設定をエクスポートします。バックアップには時間がかかる場合がありますので、しばらくお待ちください。", "lan": { - "auto_close_tip": "{{seconds}}秒後に自動的に閉じます...", - "confirm_close_message": "ファイル転送が進行中です。閉じると転送が中断されます。強制終了してもよろしいですか?", - "confirm_close_title": "閉じることを確認", "connected": "接続済み", "connection_failed": "接続に失敗しました", "content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "初期化に失敗しました", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "ファイルが選択されていません", "no_ip": "IPアドレスを取得できません", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "ファイルの送信に失敗しました" }, - "force_close": "強制終了", - "generating_qr": "QRコードを生成中...", - "noZipSelected": "圧縮ファイルが選択されていません", - "scan_qr": "携帯電話でQRコードをスキャンしてください", - "selectZip": "圧縮ファイルを選択", - "sendZip": "データの復元を開始します", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "転送完了", "connected": "接続済み", @@ -3258,9 +3277,11 @@ "error": "接続エラー", "initializing": "接続を初期化中...", "preparing": "転送準備中...", - "sending": "転送中 {{progress}}%", - "waiting_qr_scan": "QRコードをスキャンして接続してください" + "sending": "転送中 {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "LAN転送", "transfer_progress": "転送進行" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 50cc4fae03..8a97fd7c2d 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3232,24 +3232,43 @@ }, "content": "Exportar parte dos dados, incluindo registros de conversas e configurações. Observe que o processo de backup pode demorar um pouco; agradecemos sua paciência.", "lan": { - "auto_close_tip": "Fechando automaticamente em {{seconds}} segundos...", - "confirm_close_message": "Transferência de arquivo em andamento. Fechar irá interromper a transferência. Tem certeza de que deseja forçar o fechamento?", - "confirm_close_title": "Confirmar Fechamento", "connected": "Conectado", "connection_failed": "Falha na conexão", "content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Falha na inicialização", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Nenhum arquivo selecionado", "no_ip": "Incapaz de obter endereço IP", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Falha ao enviar arquivo" }, - "force_close": "Forçar Fechamento", - "generating_qr": "Gerando código QR...", - "noZipSelected": "Nenhum arquivo de compressão selecionado", - "scan_qr": "Por favor, escaneie o código QR com o seu telefone", - "selectZip": "Selecionar arquivo compactado", - "sendZip": "Iniciar recuperação de dados", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Transferência concluída", "connected": "Conectado", @@ -3258,9 +3277,11 @@ "error": "Erro de conexão", "initializing": "Inicializando conexão...", "preparing": "Preparando transferência...", - "sending": "Transferindo {{progress}}%", - "waiting_qr_scan": "Por favor, escaneie o código QR para conectar" + "sending": "Transferindo {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "transmissão de rede local", "transfer_progress": "Progresso da transferência" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 8a6a781451..e0ecc8d091 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3232,24 +3232,43 @@ }, "content": "Экспорт части данных, включая историю чатов и настройки. Обратите внимание, процесс резервного копирования может занять некоторое время, благодарим за ваше терпение.", "lan": { - "auto_close_tip": "Автоматическое закрытие через {{seconds}} секунд...", - "confirm_close_message": "Передача файла в процессе. Закрытие прервет передачу. Вы уверены, что хотите принудительно закрыть?", - "confirm_close_title": "Подтвердить закрытие", "connected": "Подключено", "connection_failed": "Соединение не удалось", "content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.", + "device_list_title": "[to be translated]:Local network devices", + "discovered_devices": "[to be translated]:Discovered devices", "error": { + "file_too_large": "[to be translated]:File too large, maximum 500MB supported", "init_failed": "Инициализация не удалась", + "invalid_file_type": "[to be translated]:Only ZIP files are supported", "no_file": "Файл не выбран", "no_ip": "Не удалось получить IP-адрес", + "not_connected": "[to be translated]:Please complete handshake first", "send_failed": "Не удалось отправить файл" }, - "force_close": "Принудительное закрытие", - "generating_qr": "Генерация QR-кода...", - "noZipSelected": "Архив не выбран", - "scan_qr": "Пожалуйста, отсканируйте QR-код с помощью вашего телефона", - "selectZip": "Выберите архив", - "sendZip": "Начать восстановление данных", + "file_transfer": { + "cancelled": "[to be translated]:Transfer cancelled", + "failed": "[to be translated]:File transfer failed: {{message}}", + "progress": "[to be translated]:Sending... {{progress}}%", + "success": "[to be translated]:File sent successfully" + }, + "handshake": { + "button": "[to be translated]:Handshake", + "failed": "[to be translated]:Handshake failed: {{message}}", + "in_progress": "[to be translated]:Handshaking...", + "success": "[to be translated]:Handshake completed with {{device}}", + "test_message_received": "[to be translated]:Received pong from {{device}}", + "test_message_sent": "[to be translated]:Sent hello world test payload" + }, + "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", + "ip_addresses": "[to be translated]:IP addresses", + "last_seen": "[to be translated]:Last seen at {{time}}", + "metadata": "[to be translated]:Metadata", + "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", + "no_devices": "[to be translated]:No LAN peers found yet", + "scan_devices": "[to be translated]:Scan devices", + "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", + "send_file": "[to be translated]:Send File", "status": { "completed": "Перевод завершён", "connected": "Подключено", @@ -3258,9 +3277,11 @@ "error": "Ошибка подключения", "initializing": "Инициализация соединения...", "preparing": "Подготовка передачи...", - "sending": "Передача {{progress}}%", - "waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения" + "sending": "Передача {{progress}}%" }, + "status_badge_idle": "[to be translated]:Idle", + "status_badge_scanning": "[to be translated]:Scanning", + "stop_scan": "[to be translated]:Stop scan", "title": "Передача по локальной сети", "transfer_progress": "Прогресс передачи" }, diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index b72db6fd61..6c111fcdaf 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -11,7 +11,7 @@ import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { HStack } from '@renderer/components/Layout' import ListItem from '@renderer/components/ListItem' import BackupPopup from '@renderer/components/Popups/BackupPopup' -import ExportToPhoneLanPopup from '@renderer/components/Popups/ExportToPhoneLanPopup' +import LanTransferPopup from '@renderer/components/Popups/LanTransferPopup' import RestorePopup from '@renderer/components/Popups/RestorePopup' import { useTheme } from '@renderer/context/ThemeProvider' import { useKnowledgeFiles } from '@renderer/hooks/useKnowledgeFiles' @@ -628,11 +628,12 @@ const DataSettings: FC = () => { {t('settings.data.export_to_phone.title')} - + {t('settings.data.data.title')} diff --git a/yarn.lock b/yarn.lock index d9d5ec1d6c..8c856e8cec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4463,6 +4463,13 @@ __metadata: languageName: node linkType: hard +"@leichtgewicht/ip-codec@npm:^2.0.1": + version: 2.0.5 + resolution: "@leichtgewicht/ip-codec@npm:2.0.5" + checksum: 10c0/14a0112bd59615eef9e3446fea018045720cd3da85a98f801a685a818b0d96ef2a1f7227e8d271def546b2e2a0fe91ef915ba9dc912ab7967d2317b1a051d66b + languageName: node + linkType: hard + "@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.0.3, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1": version: 1.2.3 resolution: "@lezer/common@npm:1.2.3" @@ -7255,13 +7262,6 @@ __metadata: languageName: node linkType: hard -"@socket.io/component-emitter@npm:~3.1.0": - version: 3.1.2 - resolution: "@socket.io/component-emitter@npm:3.1.2" - checksum: 10c0/c4242bad66f67e6f7b712733d25b43cbb9e19a595c8701c3ad99cbeb5901555f78b095e24852f862fffb43e96f1d8552e62def885ca82ae1bb05da3668fd87d7 - languageName: node - linkType: hard - "@standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" @@ -8279,7 +8279,7 @@ __metadata: languageName: node linkType: hard -"@types/cors@npm:^2.8.12, @types/cors@npm:^2.8.19": +"@types/cors@npm:^2.8.19": version: 2.8.19 resolution: "@types/cors@npm:2.8.19" dependencies: @@ -8836,15 +8836,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:>=10.0.0": - version: 24.3.1 - resolution: "@types/node@npm:24.3.1" - dependencies: - undici-types: "npm:~7.10.0" - checksum: 10c0/99b86fc32294fcd61136ca1f771026443a1e370e9f284f75e243b29299dd878e18c193deba1ce29a374932db4e30eb80826e1049b9aad02d36f5c30b94b6f928 - languageName: node - linkType: hard - "@types/node@npm:^18.11.18": version: 18.19.86 resolution: "@types/node@npm:18.19.86" @@ -10202,6 +10193,7 @@ __metadata: archiver: "npm:^7.0.1" async-mutex: "npm:^0.5.0" axios: "npm:^1.7.3" + bonjour-service: "npm:^1.3.0" browser-image-compression: "npm:^2.0.2" chardet: "npm:^2.1.0" check-disk-space: "npm:3.4.0" @@ -10290,7 +10282,6 @@ __metadata: pdf-lib: "npm:^1.17.1" pdf-parse: "npm:^1.1.1" proxy-agent: "npm:^6.5.0" - qrcode.react: "npm:^4.2.0" react: "npm:^19.2.0" react-dom: "npm:^19.2.0" react-error-boundary: "npm:^6.0.0" @@ -10322,7 +10313,6 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" - socket.io: "npm:^4.8.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" striptags: "npm:^3.2.0" @@ -10385,16 +10375,6 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.4": - version: 1.3.8 - resolution: "accepts@npm:1.3.8" - dependencies: - mime-types: "npm:~2.1.34" - negotiator: "npm:0.6.3" - checksum: 10c0/3a35c5f5586cfb9a21163ca47a5f77ac34fa8ceb5d17d2fa2c0d81f41cbd7f8c6fa52c77e2c039acc0f4d09e71abdc51144246900f6bef5e3c4b333f77d89362 - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -11033,13 +11013,6 @@ __metadata: languageName: node linkType: hard -"base64id@npm:2.0.0, base64id@npm:~2.0.0": - version: 2.0.0 - resolution: "base64id@npm:2.0.0" - checksum: 10c0/6919efd237ed44b9988cbfc33eca6f173a10e810ce50292b271a1a421aac7748ef232a64d1e6032b08f19aae48dce6ee8f66c5ae2c9e5066c82b884861d4d453 - languageName: node - linkType: hard - "basic-ftp@npm:^5.0.2": version: 5.0.5 resolution: "basic-ftp@npm:5.0.5" @@ -11155,6 +11128,16 @@ __metadata: languageName: node linkType: hard +"bonjour-service@npm:^1.3.0": + version: 1.3.0 + resolution: "bonjour-service@npm:1.3.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + multicast-dns: "npm:^7.2.5" + checksum: 10c0/5721fd9f9bb968e9cc16c1e8116d770863dd2329cb1f753231de1515870648c225142b7eefa71f14a5c22bc7b37ddd7fdeb018700f28a8c936d50d4162d433c7 + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -12266,7 +12249,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1, cookie@npm:~0.7.2": +"cookie@npm:^0.7.1": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 @@ -12303,7 +12286,7 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5, cors@npm:~2.8.5": +"cors@npm:^2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" dependencies: @@ -12973,18 +12956,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": - version: 4.3.7 - resolution: "debug@npm:4.3.7" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b - languageName: node - linkType: hard - "decamelize@npm:1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -13373,6 +13344,15 @@ __metadata: languageName: node linkType: hard +"dns-packet@npm:^5.2.2": + version: 5.6.1 + resolution: "dns-packet@npm:5.6.1" + dependencies: + "@leichtgewicht/ip-codec": "npm:^2.0.1" + checksum: 10c0/8948d3d03063fb68e04a1e386875f8c3bcc398fc375f535f2b438fad8f41bf1afa6f5e70893ba44f4ae884c089247e0a31045722fa6ff0f01d228da103f1811d + languageName: node + linkType: hard + "doctrine@npm:3.0.0": version: 3.0.0 resolution: "doctrine@npm:3.0.0" @@ -13941,30 +13921,6 @@ __metadata: languageName: node linkType: hard -"engine.io-parser@npm:~5.2.1": - version: 5.2.3 - resolution: "engine.io-parser@npm:5.2.3" - checksum: 10c0/ed4900d8dbef470ab3839ccf3bfa79ee518ea8277c7f1f2759e8c22a48f64e687ea5e474291394d0c94f84054749fd93f3ef0acb51fa2f5f234cc9d9d8e7c536 - languageName: node - linkType: hard - -"engine.io@npm:~6.6.0": - version: 6.6.4 - resolution: "engine.io@npm:6.6.4" - dependencies: - "@types/cors": "npm:^2.8.12" - "@types/node": "npm:>=10.0.0" - accepts: "npm:~1.3.4" - base64id: "npm:2.0.0" - cookie: "npm:~0.7.2" - cors: "npm:~2.8.5" - debug: "npm:~4.3.1" - engine.io-parser: "npm:~5.2.1" - ws: "npm:~8.17.1" - checksum: 10c0/845761163f8ea7962c049df653b75dafb6b3693ad6f59809d4474751d7b0392cbf3dc2730b8a902ff93677a91fd28711d34ab29efd348a8a4b49c6b0724021ab - languageName: node - linkType: hard - "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -19198,7 +19154,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -19602,6 +19558,18 @@ __metadata: languageName: node linkType: hard +"multicast-dns@npm:^7.2.5": + version: 7.2.5 + resolution: "multicast-dns@npm:7.2.5" + dependencies: + dns-packet: "npm:^5.2.2" + thunky: "npm:^1.0.2" + bin: + multicast-dns: cli.js + checksum: 10c0/5120171d4bdb1577764c5afa96e413353bff530d1b37081cb29cccc747f989eb1baf40574fe8e27060fc1aef72b59c042f72b9b208413de33bcf411343c69057 + languageName: node + linkType: hard + "mustache@npm:^4.2.0": version: 4.2.0 resolution: "mustache@npm:4.2.0" @@ -19685,13 +19653,6 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:0.6.3": - version: 0.6.3 - resolution: "negotiator@npm:0.6.3" - checksum: 10c0/3ec9fd413e7bf071c937ae60d572bc67155262068ed522cf4b3be5edbe6ddf67d095ec03a3a14ebf8fc8e95f8e1d61be4869db0dbb0de696f6b837358bd43fc2 - languageName: node - linkType: hard - "negotiator@npm:^1.0.0": version: 1.0.0 resolution: "negotiator@npm:1.0.0" @@ -21381,15 +21342,6 @@ __metadata: languageName: node linkType: hard -"qrcode.react@npm:^4.2.0": - version: 4.2.0 - resolution: "qrcode.react@npm:4.2.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 - languageName: node - linkType: hard - "qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" @@ -23666,41 +23618,6 @@ __metadata: languageName: node linkType: hard -"socket.io-adapter@npm:~2.5.2": - version: 2.5.5 - resolution: "socket.io-adapter@npm:2.5.5" - dependencies: - debug: "npm:~4.3.4" - ws: "npm:~8.17.1" - checksum: 10c0/04a5a2a9c4399d1b6597c2afc4492ab1e73430cc124ab02b09e948eabf341180b3866e2b61b5084cb899beb68a4db7c328c29bda5efb9207671b5cb0bc6de44e - languageName: node - linkType: hard - -"socket.io-parser@npm:~4.2.4": - version: 4.2.4 - resolution: "socket.io-parser@npm:4.2.4" - dependencies: - "@socket.io/component-emitter": "npm:~3.1.0" - debug: "npm:~4.3.1" - checksum: 10c0/9383b30358fde4a801ea4ec5e6860915c0389a091321f1c1f41506618b5cf7cd685d0a31c587467a0c4ee99ef98c2b99fb87911f9dfb329716c43b587f29ca48 - languageName: node - linkType: hard - -"socket.io@npm:^4.8.1": - version: 4.8.1 - resolution: "socket.io@npm:4.8.1" - dependencies: - accepts: "npm:~1.3.4" - base64id: "npm:~2.0.0" - cors: "npm:~2.8.5" - debug: "npm:~4.3.2" - engine.io: "npm:~6.6.0" - socket.io-adapter: "npm:~2.5.2" - socket.io-parser: "npm:~4.2.4" - checksum: 10c0/acf931a2bb235be96433b71da3d8addc63eeeaa8acabd33dc8d64e12287390a45f1e9f389a73cf7dc336961cd491679741b7a016048325c596835abbcc017ca9 - languageName: node - linkType: hard - "socks-proxy-agent@npm:^8.0.3, socks-proxy-agent@npm:^8.0.5": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" @@ -24488,6 +24405,13 @@ __metadata: languageName: node linkType: hard +"thunky@npm:^1.0.2": + version: 1.1.0 + resolution: "thunky@npm:1.1.0" + checksum: 10c0/369764f39de1ce1de2ba2fa922db4a3f92e9c7f33bcc9a713241bc1f4a5238b484c17e0d36d1d533c625efb00e9e82c3e45f80b47586945557b45abb890156d2 + languageName: node + linkType: hard + "tiktok-video-element@npm:^0.1.0": version: 0.1.1 resolution: "tiktok-video-element@npm:0.1.1" @@ -25190,13 +25114,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.10.0": - version: 7.10.0 - resolution: "undici-types@npm:7.10.0" - checksum: 10c0/8b00ce50e235fe3cc601307f148b5e8fb427092ee3b23e8118ec0a5d7f68eca8cee468c8fc9f15cbb2cf2a3797945ebceb1cbd9732306a1d00e0a9b6afa0f635 - languageName: node - linkType: hard - "undici@npm:6.21.2": version: 6.21.2 resolution: "undici@npm:6.21.2" @@ -26224,21 +26141,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:~8.17.1": - version: 8.17.1 - resolution: "ws@npm:8.17.1" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 10c0/f4a49064afae4500be772abdc2211c8518f39e1c959640457dcee15d4488628620625c783902a52af2dd02f68558da2868fd06e6fd0e67ebcd09e6881b1b5bfe - languageName: node - linkType: hard - "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz": version: 0.20.2 resolution: "xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz" From e16413de76e51c14f09d02ae9b9f1da988c9354d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Sun, 21 Dec 2025 20:01:41 +0800 Subject: [PATCH 06/18] feat(icons): add MCP logo and replace Hammer icon (#12061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic Hammer icon with the official MCP (Model Context Protocol) logo in settings sidebar, tab container, and MCP settings page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/renderer/src/components/Icons/SVGIcon.tsx | 17 +++++ .../src/components/Tab/TabContainer.tsx | 4 +- src/renderer/src/i18n/locales/en-us.json | 2 +- src/renderer/src/i18n/locales/zh-cn.json | 2 +- src/renderer/src/i18n/locales/zh-tw.json | 4 +- src/renderer/src/i18n/translate/de-de.json | 68 +++++++++--------- src/renderer/src/i18n/translate/el-gr.json | 68 +++++++++--------- src/renderer/src/i18n/translate/es-es.json | 70 +++++++++---------- src/renderer/src/i18n/translate/fr-fr.json | 70 +++++++++---------- src/renderer/src/i18n/translate/ja-jp.json | 66 ++++++++--------- src/renderer/src/i18n/translate/pt-pt.json | 60 ++++++++-------- src/renderer/src/i18n/translate/ru-ru.json | 66 ++++++++--------- .../pages/settings/MCPSettings/McpTool.tsx | 5 +- .../src/pages/settings/MCPSettings/index.tsx | 3 +- .../src/pages/settings/SettingsPage.tsx | 4 +- 15 files changed, 264 insertions(+), 245 deletions(-) diff --git a/src/renderer/src/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index 82be6b340e..3f4e98705c 100644 --- a/src/renderer/src/components/Icons/SVGIcon.tsx +++ b/src/renderer/src/components/Icons/SVGIcon.tsx @@ -263,6 +263,23 @@ export function ZhipuLogo(props: SVGProps) { ) } +export function McpLogo(props: SVGProps) { + return ( + + ModelContextProtocol + + + + ) +} + export function PoeLogo(props: SVGProps) { return ( case 'mcp': - return + return case 'files': return case 'settings': diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 83443ab842..63d77e03bf 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "Enter tags", "timeout": "Timeout", "timeoutTooltip": "Timeout in seconds for requests to this server, default is 60 seconds", - "title": "MCP", + "title": "MCP Servers", "tools": { "autoApprove": { "label": "Auto Approve", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index dc6ea92588..b3dbc9e365 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "输入标签", "timeout": "超时", "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒", - "title": "MCP", + "title": "MCP 服务器", "tools": { "autoApprove": { "label": "自动批准", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 434e7e4a39..a2c26fa399 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -2643,7 +2643,7 @@ "lanyun": "藍耘", "lmstudio": "LM Studio", "longcat": "龍貓", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "小米 MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope 魔搭", @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "輸入標籤", "timeout": "逾時", "timeoutTooltip": "對該伺服器請求的逾時時間(秒),預設為 60 秒", - "title": "MCP", + "title": "MCP 伺服器", "tools": { "autoApprove": { "label": "自動核准", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 85019563a6..c13e174b06 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Automatisch ermitteltes Git Bash wird verwendet", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Automatisch erkannt", "clear": { "button": "Benutzerdefinierten Pfad löschen" }, @@ -40,7 +40,7 @@ "error": { "description": "Git Bash ist erforderlich, um Agents unter Windows auszuführen. Der Agent kann ohne es nicht funktionieren. Bitte installieren Sie Git für Windows von", "recheck": "Überprüfe die Git Bash-Installation erneut", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "Git Bash-Pfad ist unter Windows erforderlich", "title": "Git Bash erforderlich" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "Die ausgewählte Datei ist keine gültige Git Bash ausführbare Datei (bash.exe).", "title": "Git Bash ausführbare Datei auswählen" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Wählen Sie den Pfad zu bash.exe", "success": "Git Bash erfolgreich erkannt!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Git Bash ist erforderlich, um Agenten unter Windows auszuführen. Installiere es von git-scm.com, falls es nicht verfügbar ist." }, "input": { "placeholder": "Gib hier deine Nachricht ein, senden mit {{key}} – @ Pfad auswählen, / Befehl auswählen" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Plattformübergreifende Konfiguration wiederhergestellt, aber das Notizenverzeichnis ist leer. Bitte kopieren Sie Ihre Notizdateien nach: {{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?", @@ -2643,7 +2643,7 @@ "lanyun": "Lanyun Technologie", "lmstudio": "LM Studio", "longcat": "Meißner Riesenhamster", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope", @@ -3235,40 +3235,40 @@ "connected": "Verbunden", "connection_failed": "Verbindung fehlgeschlagen", "content": "Bitte stelle sicher, dass sich dein Computer und dein Telefon im selben Netzwerk befinden, um eine LAN-Übertragung durchzuführen. Öffne die Cherry Studio App, um diesen QR-Code zu scannen.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Lokale Netzwerkgeräte", + "discovered_devices": "Entdeckte Geräte", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Datei zu groß, maximal 500 MB unterstützt", "init_failed": "Initialisierung fehlgeschlagen", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Nur ZIP-Dateien werden unterstützt", "no_file": "Keine Datei ausgewählt", "no_ip": "IP-Adresse kann nicht abgerufen werden", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Bitte vervollständigen Sie zuerst das Handshake.", "send_failed": "Fehler beim Senden der Datei" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Überweisung storniert", + "failed": "Dateiübertragung fehlgeschlagen: {{message}}", + "progress": "Senden... {{progress}}%", + "success": "Datei erfolgreich gesendet" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Handshake", + "failed": "Handshake fehlgeschlagen: {{message}}", + "in_progress": "Handshake läuft...", + "success": "Handshake mit {{device}} abgeschlossen", + "test_message_received": "Pong von {{device}} empfangen", + "test_message_sent": "Hallo-Welt-Test-Payload gesendet" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Scanvorgang pausiert. Starten Sie das Scannen, um Cherry-Studio-Peers in Ihrem LAN zu finden.", + "ip_addresses": "IP-Adressen", + "last_seen": "Zuletzt gesehen um {{time}}", + "metadata": "Metadaten", + "no_connection_warning": "Bitte öffne LAN-Transfer in der Cherry Studio Mobile-App.", + "no_devices": "Noch keine LAN-Peers gefunden", + "scan_devices": "Geräte scannen", + "scanning_hint": "Scanne dein lokales Netzwerk nach Cherry-Studio-Peers …", + "send_file": "Datei senden", "status": { "completed": "Übertragung abgeschlossen", "connected": "Verbunden", @@ -3279,9 +3279,9 @@ "preparing": "Übertragung wird vorbereitet...", "sending": "Übertrage {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Leerlauf", + "status_badge_scanning": "Scannen", + "stop_scan": "Scanvorgang stoppen", "title": "LAN-Übertragung", "transfer_progress": "Übertragungsfortschritt" }, @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "Tag eingeben", "timeout": "Timeout", "timeoutTooltip": "Timeout für Anfragen an den Server in Sekunden. Standardmäßig 60 Sekunden.", - "title": "MCP", + "title": "MCP-Server", "tools": { "autoApprove": { "label": "Automatische Genehmigung", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 1bdf109fde..8746eed716 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Χρησιμοποιείται αυτόματα εντοπισμένο Git Bash", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Αυτόματα ανακαλυφθέντα", "clear": { "button": "Διαγραφή προσαρμοσμένης διαδρομής" }, @@ -40,7 +40,7 @@ "error": { "description": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Ο πράκτορας δεν μπορεί να λειτουργήσει χωρίς αυτό. Παρακαλούμε εγκαταστήστε το Git για Windows από", "recheck": "Επανέλεγχος Εγκατάστασης του Git Bash", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "Απαιτείται διαδρομή του Git Bash στα Windows", "title": "Απαιτείται Git Bash" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "Το επιλεγμένο αρχείο δεν είναι έγκυρο εκτελέσιμο Git Bash (bash.exe).", "title": "Επιλογή εκτελέσιμου Git Bash" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Επιλέξτε τη διαδρομή του bash.exe", "success": "Το Git Bash εντοπίστηκε με επιτυχία!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Το Git Bash απαιτείται για την εκτέλεση πρακτόρων στα Windows. Εγκαταστήστε το από το git-scm.com εάν δεν είναι διαθέσιμο." }, "input": { "placeholder": "Εισάγετε το μήνυμά σας εδώ, στείλτε με {{key}} - @ επιλέξτε διαδρομή, / επιλέξτε εντολή" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Η διαμόρφωση πολλαπλών πλατφορμών έχει επαναφερθεί, αλλά ο κατάλογος σημειώσεων είναι κενός. Παρακαλώ αντιγράψτε τα αρχεία σημειώσεών σας στο: {{path}}", "delete": "διαγραφή", "delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το {{type}};", "delete_folder_confirm": "Θέλετε να διαγράψετε τον φάκελο «{{name}}» και όλο το περιεχόμενό του;", @@ -2643,7 +2643,7 @@ "lanyun": "Λανιούν Τεχνολογία", "lmstudio": "LM Studio", "longcat": "Τσίρο", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope Magpie", @@ -3235,40 +3235,40 @@ "connected": "Συνδεδεμένος", "connection_failed": "Η σύνδεση απέτυχε", "content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Τοπικές συσκευές δικτύου", + "discovered_devices": "Ανακαλυφθείσες συσκευές", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Το αρχείο είναι πολύ μεγάλο, υποστηρίζεται μέγιστο μέγεθος 500 MB", "init_failed": "Η αρχικοποίηση απέτυχε", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Μόνο αρχεία ZIP υποστηρίζονται", "no_file": "Κανένα αρχείο δεν επιλέχθηκε", "no_ip": "Αδυναμία λήψης διεύθυνσης IP", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Παρακαλώ ολοκληρώστε πρώτα τη χειραψία", "send_failed": "Αποτυχία αποστολής αρχείου" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Η μεταφορά ακυρώθηκε", + "failed": "Η μεταφορά αρχείου απέτυχε: {{message}}", + "progress": "Αποστολή... {{progress}}%", + "success": "Το αρχείο εστάλη με επιτυχία" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Χειραψία", + "failed": "Η χειραψία απέτυχε: {{message}}", + "in_progress": "Χειραψία...", + "success": "Η χειραψία ολοκληρώθηκε με τη συσκευή {{device}}", + "test_message_received": "Λήφθηκε pong από {{device}}", + "test_message_sent": "Στάλθηκε δοκιμαστικό φορτίο hello world" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Η σάρωση διακόπηκε. Ξεκινήστε τη σάρωση για να βρείτε ομότιμους του Cherry Studio στο τοπικό σας δίκτυο.", + "ip_addresses": "Διευθύνσεις IP", + "last_seen": "Τελευταία φορά εθεάθηκε στις {{time}}", + "metadata": "Μεταδεδομένα", + "no_connection_warning": "Παρακαλώ ανοίξτε τη μεταφορά LAN στο Cherry Studio mobile", + "no_devices": "Δεν βρέθηκαν ακόμα συσκευές LAN", + "scan_devices": "Σάρωση συσκευών", + "scanning_hint": "Σάρωση του τοπικού σας δικτύου για ομότιμους του Cherry Studio...", + "send_file": "Αποστολή Αρχείου", "status": { "completed": "Η μεταφορά ολοκληρώθηκε", "connected": "Συνδεδεμένος", @@ -3279,9 +3279,9 @@ "preparing": "Προετοιμασία μεταφοράς...", "sending": "Μεταφορά {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Αδρανής", + "status_badge_scanning": "Σάρωση", + "stop_scan": "Διακοπή σάρωσης", "title": "Μεταφορά τοπικού δικτύου", "transfer_progress": "Πρόοδος μεταφοράς" }, @@ -3961,7 +3961,7 @@ "mcp_auto_install": "Αυτόματη εγκατάσταση υπηρεσίας MCP (προβολή)", "memory": "Βασική υλοποίηση μόνιμης μνήμης με βάση τοπικό γράφημα γνώσης. Αυτό επιτρέπει στο μοντέλο να θυμάται πληροφορίες σχετικές με τον χρήστη ανάμεσα σε διαφορετικές συνομιλίες. Απαιτείται η ρύθμιση της μεταβλητής περιβάλλοντος MEMORY_FILE_PATH.", "no": "Χωρίς περιγραφή", - "nowledge_mem": "[to be translated]:Requires Nowledge Mem app running locally. Keeps AI chats, tools, notes, agents, and files in private memory on your computer. Download from https://mem.nowledge.co/", + "nowledge_mem": "Απαιτεί την εφαρμογή Nowledge Mem να εκτελείται τοπικά. Διατηρεί συνομιλίες με AI, εργαλεία, σημειώσεις, πράκτορες και αρχεία σε ιδιωτική μνήμη στον υπολογιστή σας. Κάντε λήψη από https://mem.nowledge.co/", "python": "Εκτελέστε κώδικα Python σε ένα ασφαλές περιβάλλον sandbox. Χρησιμοποιήστε το Pyodide για να εκτελέσετε Python, υποστηρίζοντας την πλειονότητα των βιβλιοθηκών της τυπικής βιβλιοθήκης και των πακέτων επιστημονικού υπολογισμού", "sequentialthinking": "ένας εξυπηρετητής MCP που υλοποιείται, παρέχοντας εργαλεία για δυναμική και αναστοχαστική επίλυση προβλημάτων μέσω δομημένων διαδικασιών σκέψης" }, diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ffb1be85a5..df7743694e 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Usando Git Bash detectado automáticamente", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Auto-descubierto", "clear": { "button": "Borrar ruta personalizada" }, @@ -40,7 +40,7 @@ "error": { "description": "Se requiere Git Bash para ejecutar agentes en Windows. El agente no puede funcionar sin él. Instale Git para Windows desde", "recheck": "Volver a verificar la instalación de Git Bash", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "Se requiere la ruta de Git Bash en Windows", "title": "Git Bash Requerido" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "El archivo seleccionado no es un ejecutable válido de Git Bash (bash.exe).", "title": "Seleccionar ejecutable de Git Bash" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Seleccionar la ruta de bash.exe", "success": "¡Git Bash detectado con éxito!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Se requiere Git Bash para ejecutar agentes en Windows. Instálalo desde git-scm.com si no está disponible." }, "input": { "placeholder": "Introduce tu mensaje aquí, envía con {{key}} - @ seleccionar ruta, / seleccionar comando" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Configuración multiplataforma restaurada, pero el directorio de notas está vacío. Por favor, copia tus archivos de notas en: {{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?", @@ -2643,7 +2643,7 @@ "lanyun": "Tecnología Lanyun", "lmstudio": "Estudio LM", "longcat": "Totoro", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "Minimax", "mistral": "Mistral", "modelscope": "ModelScope Módulo", @@ -3235,40 +3235,40 @@ "connected": "Conectado", "connection_failed": "Conexión fallida", "content": "Asegúrate de que el ordenador y el móvil estén en la misma red para usar la transferencia por LAN. Abre la aplicación Cherry Studio y escanea este código QR.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Dispositivos de red local", + "discovered_devices": "Dispositivos descubiertos", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Archivo demasiado grande, se admite un máximo de 500 MB", "init_failed": "Falló la inicialización", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Solo se admiten archivos ZIP", "no_file": "Ningún archivo seleccionado", "no_ip": "No se puede obtener la dirección IP", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Por favor, completa primero el apretón de manos.", "send_failed": "Error al enviar el archivo" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Transferencia cancelada", + "failed": "Error en la transferencia del archivo: {{message}}", + "progress": "Enviando... {{progress}}%", + "success": "Archivo enviado con éxito" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Apretón de manos", + "failed": "Error de handshake: {{message}}", + "in_progress": "Estrechando manos...", + "success": "Handshake completado con {{device}}", + "test_message_received": "Recibido pong de {{device}}", + "test_message_sent": "Enviado payload de prueba hello world" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Escaneo pausado. Inicia el escaneo para encontrar pares de Cherry Studio en tu red local.", + "ip_addresses": "Direcciones IP", + "last_seen": "Visto por última vez a las {{time}}", + "metadata": "Metadatos", + "no_connection_warning": "Por favor, abre Transferencia LAN en la aplicación móvil Cherry Studio.", + "no_devices": "Aún no se han encontrado pares en LAN", + "scan_devices": "Escanear dispositivos", + "scanning_hint": "Escaneando tu red local en busca de pares de Cherry Studio...", + "send_file": "Enviar archivo", "status": { "completed": "Transferencia completada", "connected": "Conectado", @@ -3279,9 +3279,9 @@ "preparing": "Preparando transferencia...", "sending": "Transfiriendo {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Ocioso", + "status_badge_scanning": "Escaneando", + "stop_scan": "Detener escaneo", "title": "Transferencia de red local", "transfer_progress": "Progreso de transferencia" }, @@ -3961,7 +3961,7 @@ "mcp_auto_install": "Instalación automática del servicio MCP (versión beta)", "memory": "Implementación básica de memoria persistente basada en un grafo de conocimiento local. Esto permite que el modelo recuerde información relevante del usuario entre diferentes conversaciones. Es necesario configurar la variable de entorno MEMORY_FILE_PATH.", "no": "sin descripción", - "nowledge_mem": "[to be translated]:Requires Nowledge Mem app running locally. Keeps AI chats, tools, notes, agents, and files in private memory on your computer. Download from https://mem.nowledge.co/", + "nowledge_mem": "Requiere que la aplicación Nowledge Mem se ejecute localmente. Mantiene chats de IA, herramientas, notas, agentes y archivos en memoria privada en tu computadora. Descárgala desde https://mem.nowledge.co/", "python": "Ejecuta código Python en un entorno sandbox seguro. Usa Pyodide para ejecutar Python, compatible con la mayoría de las bibliotecas estándar y paquetes de cálculo científico.", "sequentialthinking": "Una implementación de servidor MCP que proporciona herramientas para la resolución dinámica y reflexiva de problemas mediante un proceso de pensamiento estructurado" }, @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "Ingrese etiquetas", "timeout": "Tiempo de espera", "timeoutTooltip": "Tiempo de espera (en segundos) para las solicitudes a este servidor; el valor predeterminado es 60 segundos", - "title": "Configuración del MCP", + "title": "Servidores MCP", "tools": { "autoApprove": { "label": "Aprobación automática", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 76736c73c1..990c94a3c1 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Utilisation de Git Bash détecté automatiquement", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Auto-découvert", "clear": { "button": "Effacer le chemin personnalisé" }, @@ -40,7 +40,7 @@ "error": { "description": "Git Bash est requis pour exécuter des agents sur Windows. L'agent ne peut pas fonctionner sans. Veuillez installer Git pour Windows depuis", "recheck": "Revérifier l'installation de Git Bash", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "Le chemin Git Bash est requis sur Windows", "title": "Git Bash requis" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "Le fichier sélectionné n'est pas un exécutable Git Bash valide (bash.exe).", "title": "Sélectionner l'exécutable Git Bash" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Sélectionner le chemin de bash.exe", "success": "Git Bash détecté avec succès !", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Git Bash est nécessaire pour exécuter des agents sur Windows. Installez-le depuis git-scm.com s'il n'est pas disponible." }, "input": { "placeholder": "Entrez votre message ici, envoyez avec {{key}} - @ sélectionner le chemin, / sélectionner la commande" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Configuration multiplateforme restaurée, mais le répertoire des notes est vide. Veuillez copier vos fichiers de notes vers : {{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 ?", @@ -2643,7 +2643,7 @@ "lanyun": "Technologie Lan Yun", "lmstudio": "Studio LM", "longcat": "Mon voisin Totoro", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope MoDa", @@ -3235,40 +3235,40 @@ "connected": "Connecté", "connection_failed": "Échec de la connexion", "content": "Assurez-vous que l'ordinateur et le téléphone sont connectés au même réseau pour utiliser le transfert en réseau local. Ouvrez l'application Cherry Studio et scannez ce code QR.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Périphériques réseau locaux", + "discovered_devices": "Appareils découverts", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Fichier trop volumineux, taille maximale supportée : 500 Mo", "init_failed": "Échec de l'initialisation", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Seuls les fichiers ZIP sont pris en charge", "no_file": "Aucun fichier sélectionné", "no_ip": "Impossible d'obtenir l'adresse IP", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Veuillez d'abord terminer la poignée de main", "send_failed": "Échec de l'envoi du fichier" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Transfert annulé", + "failed": "Le transfert de fichier a échoué : {{message}}", + "progress": "Envoi en cours... {{progress}}%", + "success": "Fichier envoyé avec succès" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Poignée de main", + "failed": "Échec de la poignée de main : {{message}}", + "in_progress": "Établissement de la connexion...", + "success": "Poignée de main terminée avec {{device}}", + "test_message_received": "Pong reçu de {{device}}", + "test_message_sent": "Envoyé la charge utile de test hello world" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Analyse en pause. Lancez l’analyse pour détecter les pairs Cherry Studio sur votre réseau local.", + "ip_addresses": "Adresses IP", + "last_seen": "Vu pour la dernière fois à {{time}}", + "metadata": "Métadonnées", + "no_connection_warning": "Veuillez ouvrir le transfert LAN sur l'application mobile Cherry Studio.", + "no_devices": "Aucun pair LAN trouvé pour l'instant", + "scan_devices": "Analyser les appareils", + "scanning_hint": "Analyse de votre réseau local à la recherche d’homologues Cherry Studio…", + "send_file": "Envoyer le fichier", "status": { "completed": "Transfert terminé", "connected": "Connecté", @@ -3279,9 +3279,9 @@ "preparing": "Préparation du transfert...", "sending": "Transfert {{progress}} %" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Inactif", + "status_badge_scanning": "Numérisation", + "stop_scan": "Arrêter le scan", "title": "Transmission en réseau local", "transfer_progress": "Progression du transfert" }, @@ -3961,7 +3961,7 @@ "mcp_auto_install": "Installation automatique du service MCP (version bêta)", "memory": "Implémentation de base de mémoire persistante basée sur un graphe de connaissances local. Cela permet au modèle de se souvenir des informations relatives à l'utilisateur entre différentes conversations. Nécessite la configuration de la variable d'environnement MEMORY_FILE_PATH.", "no": "sans description", - "nowledge_mem": "[to be translated]:Requires Nowledge Mem app running locally. Keeps AI chats, tools, notes, agents, and files in private memory on your computer. Download from https://mem.nowledge.co/", + "nowledge_mem": "Nécessite l’application Nowledge Mem exécutée localement. Conserve les discussions IA, outils, notes, agents et fichiers dans une mémoire privée sur votre ordinateur. Téléchargez depuis https://mem.nowledge.co/", "python": "Exécutez du code Python dans un environnement bac à sable sécurisé. Utilisez Pyodide pour exécuter Python, prenant en charge la plupart des bibliothèques standard et des packages de calcul scientifique.", "sequentialthinking": "Un serveur MCP qui fournit des outils permettant une résolution dynamique et réflexive des problèmes à travers un processus de pensée structuré" }, @@ -4122,7 +4122,7 @@ "tagsPlaceholder": "Введите теги", "timeout": "Таймаут", "timeoutTooltip": "Таймаут запроса к серверу (в секундах), по умолчанию 60 секунд", - "title": "Paramètres MCP", + "title": "Serveurs MCP", "tools": { "autoApprove": { "label": "Approbation automatique", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 8b6c99648f..d36fddc63c 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "自動検出されたGit Bashを使用中", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "自動検出", "clear": { "button": "カスタムパスをクリア" }, @@ -40,7 +40,7 @@ "error": { "description": "Windowsでエージェントを実行するにはGit Bashが必要です。これがないとエージェントは動作しません。以下からGit for Windowsをインストールしてください。", "recheck": "Git Bashのインストールを再確認してください", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "WindowsではGit Bashのパスが必要です", "title": "Git Bashが必要です" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "選択されたファイルは有効なGit Bash実行ファイル(bash.exe)ではありません。", "title": "Git Bash実行ファイルを選択" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "bash.exeのパスを選択", "success": "Git Bashが正常に検出されました!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Windowsでエージェントを実行するにはGit Bashが必要です。まだインストールされていない場合は、git-scm.comからインストールしてください。" }, "input": { "placeholder": "メッセージをここに入力し、{{key}}で送信 - @でパスを選択、/でコマンドを選択" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "クロスプラットフォーム設定は復元されましたが、ノートディレクトリが空です。ノートファイルを次の場所にコピーしてください:{{path}}", "delete": "削除", "delete_confirm": "この{{type}}を本当に削除しますか?", "delete_folder_confirm": "「{{name}}」フォルダーとそのすべての内容を削除してもよろしいですか?", @@ -2643,7 +2643,7 @@ "lanyun": "LANYUN", "lmstudio": "LM Studio", "longcat": "トトロ", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "シャオミ・ミモ", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope", @@ -3235,40 +3235,40 @@ "connected": "接続済み", "connection_failed": "接続に失敗しました", "content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "ローカルネットワークデバイス", + "discovered_devices": "発見されたデバイス", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "ファイルが大きすぎます。最大500MBまでサポートされています。", "init_failed": "初期化に失敗しました", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "ZIPファイルのみがサポートされています", "no_file": "ファイルが選択されていません", "no_ip": "IPアドレスを取得できません", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "まずハンドシェイクを完了してください", "send_failed": "ファイルの送信に失敗しました" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "転送がキャンセルされました", + "failed": "ファイル転送に失敗しました: {{message}}", + "progress": "送信中... {{progress}}%", + "success": "ファイルは正常に送信されました" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "握手", + "failed": "ハンドシェイクに失敗しました: {{message}}", + "in_progress": "ハンドシェイク中...", + "success": "{{device}}とのハンドシェイクが完了しました", + "test_message_received": "{{device}}からpongを受信しました", + "test_message_sent": "hello world テストペイロードを送信しました" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "スキャンが一時停止されました。スキャンを開始して、LAN上のCherry Studioピアを検索してください。", + "ip_addresses": "IPアドレス", + "last_seen": "最後に見たのは{{time}}", + "metadata": "メタデータ", + "no_connection_warning": "Cherry StudioモバイルでLAN転送を開いてください", + "no_devices": "まだLANピアが見つかっていません", + "scan_devices": "デバイスをスキャン", + "scanning_hint": "ローカルネットワークでCherry Studioのピアをスキャンしています...", + "send_file": "ファイルを送信", "status": { "completed": "転送完了", "connected": "接続済み", @@ -3279,9 +3279,9 @@ "preparing": "転送準備中...", "sending": "転送中 {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "アイドル", + "status_badge_scanning": "スキャン", + "stop_scan": "スキャンを停止", "title": "LAN転送", "transfer_progress": "転送進行" }, diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 8a97fd7c2d..65783166cb 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Usando Git Bash detectado automaticamente", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Auto-descoberto", "clear": { "button": "Limpar caminho personalizado" }, @@ -55,7 +55,7 @@ }, "placeholder": "[to be translated]:Select bash.exe path", "success": "Git Bash detectado com sucesso!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "O Git Bash é necessário para executar agentes no Windows. Instale-o a partir de git-scm.com, caso não esteja disponível." }, "input": { "placeholder": "Digite sua mensagem aqui, envie com {{key}} - @ selecionar caminho, / selecionar comando" @@ -2643,7 +2643,7 @@ "lanyun": "Lanyun Tecnologia", "lmstudio": "Estúdio LM", "longcat": "Totoro", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "Minimax", "mistral": "Mistral", "modelscope": "ModelScope MôDá", @@ -3235,40 +3235,40 @@ "connected": "Conectado", "connection_failed": "Falha na conexão", "content": "Certifique-se de que o computador e o telefone estejam na mesma rede para usar a transferência via LAN. Abra o aplicativo Cherry Studio e escaneie este código QR.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Dispositivos de rede local", + "discovered_devices": "Dispositivos descobertos", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Arquivo muito grande, máximo de 500MB suportado", "init_failed": "Falha na inicialização", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Apenas arquivos ZIP são suportados", "no_file": "Nenhum arquivo selecionado", "no_ip": "Incapaz de obter endereço IP", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Por favor, complete primeiro o handshake", "send_failed": "Falha ao enviar arquivo" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Transferência cancelada", + "failed": "Falha na transferência de arquivo: {{message}}", + "progress": "Enviando... {{progress}}%", + "success": "Arquivo enviado com sucesso" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Aperto de mão", + "failed": "Falha no handshake: {{message}}", + "in_progress": "Aperto de mãos...", + "success": "Handshake concluído com {{device}}", + "test_message_received": "Recebido pong de {{device}}", + "test_message_sent": "Enviou payload de teste hello world" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Digitalização pausada. Inicie a digitalização para encontrar pares do Cherry Studio na sua rede local.", + "ip_addresses": "Endereços IP", + "last_seen": "Visto pela última vez às {{time}}", + "metadata": "Metadados", + "no_connection_warning": "Por favor, abra a Transferência LAN no Cherry Studio mobile", + "no_devices": "Ainda não foram encontrados pares de LAN.", + "scan_devices": "Escaneie dispositivos", + "scanning_hint": "Escaneando sua rede local por pares do Cherry Studio...", + "send_file": "Enviar Arquivo", "status": { "completed": "Transferência concluída", "connected": "Conectado", @@ -3279,9 +3279,9 @@ "preparing": "Preparando transferência...", "sending": "Transferindo {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Ocioso", + "status_badge_scanning": "Digitalização", + "stop_scan": "Parar digitalização", "title": "transmissão de rede local", "transfer_progress": "Progresso da transferência" }, diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index e0ecc8d091..2e245f9ff5 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -32,7 +32,7 @@ }, "gitBash": { "autoDetected": "Используется автоматически обнаруженный Git Bash", - "autoDiscoveredHint": "[to be translated]:Auto-discovered", + "autoDiscoveredHint": "Автоматически обнаруженный", "clear": { "button": "Очистить пользовательский путь" }, @@ -40,7 +40,7 @@ "error": { "description": "Для запуска агентов в Windows требуется Git Bash. Без него агент не может работать. Пожалуйста, установите Git для Windows с", "recheck": "Повторная проверка установки Git Bash", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "Требуется путь к Git Bash в Windows", "title": "Требуется Git Bash" }, "found": { @@ -53,9 +53,9 @@ "invalidPath": "Выбранный файл не является допустимым исполняемым файлом Git Bash (bash.exe).", "title": "Выберите исполняемый файл Git Bash" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Выберите путь к bash.exe", "success": "Git Bash успешно обнаружен!", - "tooltip": "[to be translated]:Git Bash is required to run agents on Windows. Install from git-scm.com if not available." + "tooltip": "Для запуска агентов в Windows требуется Git Bash. Установите его с сайта git-scm.com, если он отсутствует." }, "input": { "placeholder": "Введите ваше сообщение здесь, отправьте с помощью {{key}} — @ выбрать путь, / выбрать команду" @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Кроссплатформенная конфигурация восстановлена, но каталог заметок пуст. Пожалуйста, скопируйте файлы заметок в: {{path}}", "delete": "удалить", "delete_confirm": "Вы уверены, что хотите удалить этот объект {{type}}?", "delete_folder_confirm": "Вы уверены, что хотите удалить папку \"{{name}}\" со всем ее содержимым?", @@ -2643,7 +2643,7 @@ "lanyun": "LANYUN", "lmstudio": "LM Studio", "longcat": "Тоторо", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope", @@ -3235,40 +3235,40 @@ "connected": "Подключено", "connection_failed": "Соединение не удалось", "content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.", - "device_list_title": "[to be translated]:Local network devices", - "discovered_devices": "[to be translated]:Discovered devices", + "device_list_title": "Устройства локальной сети", + "discovered_devices": "Обнаруженные устройства", "error": { - "file_too_large": "[to be translated]:File too large, maximum 500MB supported", + "file_too_large": "Файл слишком большой, поддерживается максимум 500 МБ.", "init_failed": "Инициализация не удалась", - "invalid_file_type": "[to be translated]:Only ZIP files are supported", + "invalid_file_type": "Поддерживаются только ZIP-файлы", "no_file": "Файл не выбран", "no_ip": "Не удалось получить IP-адрес", - "not_connected": "[to be translated]:Please complete handshake first", + "not_connected": "Пожалуйста, сначала завершите рукопожатие", "send_failed": "Не удалось отправить файл" }, "file_transfer": { - "cancelled": "[to be translated]:Transfer cancelled", - "failed": "[to be translated]:File transfer failed: {{message}}", - "progress": "[to be translated]:Sending... {{progress}}%", - "success": "[to be translated]:File sent successfully" + "cancelled": "Перевод отменён", + "failed": "Передача файла не удалась: {{message}}", + "progress": "Отправка... {{progress}}%", + "success": "Файл успешно отправлен" }, "handshake": { - "button": "[to be translated]:Handshake", - "failed": "[to be translated]:Handshake failed: {{message}}", - "in_progress": "[to be translated]:Handshaking...", - "success": "[to be translated]:Handshake completed with {{device}}", - "test_message_received": "[to be translated]:Received pong from {{device}}", - "test_message_sent": "[to be translated]:Sent hello world test payload" + "button": "Рукопожатие", + "failed": "Сбой рукопожатия: {{message}}", + "in_progress": "Рукопожатие...", + "success": "Рукопожатие завершено с {{device}}", + "test_message_received": "Получен понг от {{device}}", + "test_message_sent": "Отправлен тестовый полезный груз \"hello world\"" }, - "idle_hint": "[to be translated]:Scan paused. Start scanning to find Cherry Studio peers on your LAN.", - "ip_addresses": "[to be translated]:IP addresses", - "last_seen": "[to be translated]:Last seen at {{time}}", - "metadata": "[to be translated]:Metadata", - "no_connection_warning": "[to be translated]:Please open LAN Transfer on Cherry Studio mobile", - "no_devices": "[to be translated]:No LAN peers found yet", - "scan_devices": "[to be translated]:Scan devices", - "scanning_hint": "[to be translated]:Scanning your local network for Cherry Studio peers...", - "send_file": "[to be translated]:Send File", + "idle_hint": "Сканирование приостановлено. Начните сканирование, чтобы найти пиры Cherry Studio в вашей локальной сети.", + "ip_addresses": "IP-адреса", + "last_seen": "В последний раз был(а) в сети в {{time}}", + "metadata": "Метаданные", + "no_connection_warning": "Пожалуйста, откройте LAN Transfer в мобильном приложении Cherry Studio", + "no_devices": "Пока не найдено участников локальной сети", + "scan_devices": "Сканировать устройства", + "scanning_hint": "Сканирование локальной сети на наличие узлов Cherry Studio...", + "send_file": "Отправить файл", "status": { "completed": "Перевод завершён", "connected": "Подключено", @@ -3279,9 +3279,9 @@ "preparing": "Подготовка передачи...", "sending": "Передача {{progress}}%" }, - "status_badge_idle": "[to be translated]:Idle", - "status_badge_scanning": "[to be translated]:Scanning", - "stop_scan": "[to be translated]:Stop scan", + "status_badge_idle": "Бездействие", + "status_badge_scanning": "Сканирование", + "stop_scan": "Остановить сканирование", "title": "Передача по локальной сети", "transfer_progress": "Прогресс передачи" }, diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx index bfd9992dc4..e4f01b7475 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -1,8 +1,9 @@ +import { McpLogo } from '@renderer/components/Icons' import type { MCPServer, MCPTool } from '@renderer/types' import { isToolAutoApproved } from '@renderer/utils/mcp-tools' import { Badge, Descriptions, Empty, Flex, Switch, Table, Tag, Tooltip, Typography } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { Hammer, Info, Zap } from 'lucide-react' +import { Info, Zap } from 'lucide-react' import { useTranslation } from 'react-i18next' interface MCPToolsSectionProps { @@ -136,7 +137,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M { title: ( - + {t('settings.mcp.tools.enable')} ), diff --git a/src/renderer/src/pages/settings/MCPSettings/index.tsx b/src/renderer/src/pages/settings/MCPSettings/index.tsx index 4637cf2e89..4b958dde1c 100644 --- a/src/renderer/src/pages/settings/MCPSettings/index.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/index.tsx @@ -6,6 +6,7 @@ import MCPRouterProviderLogo from '@renderer/assets/images/providers/mcprouter.w import ModelScopeProviderLogo from '@renderer/assets/images/providers/modelscope.png' import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png' import DividerWithText from '@renderer/components/DividerWithText' +import { McpLogo } from '@renderer/components/Icons' import ListItem from '@renderer/components/ListItem' import Scrollbar from '@renderer/components/Scrollbar' import { useTheme } from '@renderer/context/ThemeProvider' @@ -85,7 +86,7 @@ const MCPSettings: FC = () => { title={t('settings.mcp.servers', 'MCP Servers')} active={activeView === 'servers'} onClick={() => navigate('/settings/mcp/servers')} - icon={} + icon={} titleStyle={{ fontWeight: 500 }} /> diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index a14e10973d..cb5d8df32a 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -1,5 +1,6 @@ import { GlobalOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { McpLogo } from '@renderer/components/Icons' import Scrollbar from '@renderer/components/Scrollbar' import ModelSettings from '@renderer/pages/settings/ModelSettings/ModelSettings' import { Divider as AntDivider } from 'antd' @@ -8,7 +9,6 @@ import { Cloud, Command, FileCode, - Hammer, HardDrive, Info, MonitorCog, @@ -88,7 +88,7 @@ const SettingsPage: FC = () => { - + {t('settings.mcp.title')} From 26a3bd0259ae93f7fd4e05fdff4b9e08593c9617 Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 21 Dec 2025 20:15:17 +0800 Subject: [PATCH 07/18] feat: add openrouter support and update migration version to 188 (#12059) * feat: add openrouter support and update migration version to 188 --- src/renderer/src/pages/code/index.ts | 4 +++- .../settings/ProviderSettings/ProviderSetting.tsx | 3 ++- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 15 +++++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index dcc9f43534..78347cd2c7 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -34,7 +34,9 @@ export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = [ 'minimax', 'longcat', SystemProviderIds.qiniu, - SystemProviderIds.silicon + SystemProviderIds.silicon, + SystemProviderIds.mimo, + SystemProviderIds.openrouter ] export const CLAUDE_SUPPORTED_PROVIDERS = [ 'aihubmix', diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 049c14c0d1..13680f5547 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -81,7 +81,8 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [ SystemProviderIds.silicon, SystemProviderIds.qiniu, SystemProviderIds.dmxapi, - SystemProviderIds.mimo + SystemProviderIds.mimo, + SystemProviderIds.openrouter ] as const type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number] diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 51d70ef6de..0a079df9b5 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: 187, + version: 188, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 5fe1bc0901..af789378c8 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3053,6 +3053,21 @@ const migrateConfig = { logger.error('migrate 187 error', error as Error) return state } + }, + // 1.7.7 + '188': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.openrouter) { + provider.anthropicApiHost = 'https://openrouter.ai/api' + } + }) + logger.info('migrate 188 success') + return state + } catch (error) { + logger.error('migrate 188 error', error as Error) + return state + } } } From 7a862974c256afeef90d0d8441e6c96489edaaf9 Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 22 Dec 2025 16:13:31 +0800 Subject: [PATCH 08/18] fix(options): add support for persistent server configuration in OpenAI provider options (#12058) * fix(options): add support for persistent server configuration in OpenAI provider options * fix(options): disable storing in OpenAI provider options --- src/renderer/src/aiCore/utils/__tests__/options.test.ts | 3 ++- src/renderer/src/aiCore/utils/options.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/aiCore/utils/__tests__/options.test.ts b/src/renderer/src/aiCore/utils/__tests__/options.test.ts index 9eeeac725b..a6c9a6c95c 100644 --- a/src/renderer/src/aiCore/utils/__tests__/options.test.ts +++ b/src/renderer/src/aiCore/utils/__tests__/options.test.ts @@ -464,7 +464,8 @@ describe('options utils', () => { custom_param: 'custom_value', another_param: 123, serviceTier: undefined, - textVerbosity: undefined + textVerbosity: undefined, + store: false } }) }) diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index fd9bc590cd..4d8d4070e9 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -396,10 +396,12 @@ function buildOpenAIProviderOptions( } } + // TODO: 支持配置是否在服务端持久化 providerOptions = { ...providerOptions, serviceTier, - textVerbosity + textVerbosity, + store: false } return { From d1c93e4eae592f812bacc64046f93664e741351e Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 23 Dec 2025 12:13:01 +0800 Subject: [PATCH 09/18] fix: update default assistant settings to disable temperature (#12069) * fix: update default assistant settings to disable temperature * fix: typecheck * fix: typecheck * refactor(settings): use DEFAULT_ASSISTANT_SETTINGS constant for reset Replace hardcoded default settings with DEFAULT_ASSISTANT_SETTINGS constant to improve maintainability * fix(AssistantService): set default maxTokens to DEFAULT_MAX_TOKENS * docs(AssistantService): add jsdoc for getAssistantSettings function * refactor(AssistantService): use default settings constants for fallback values * refactor(AssistantService): update default assistant settings type Add defaultModel field and mark settings as const satisfies AssistantSettings * refactor(AssistantService): reorder and add new default assistant settings Add reasoning_effort_cache and qwenThinkMode fields * docs(AssistantService): add jsdoc comments for default assistant settings Explain purpose of DEFAULT_ASSISTANT_SETTINGS template and clarify difference between template values and actual settings * docs(AssistantService): move default assistant settings docs to function The documentation about current settings inheritance was moved from createTranslateAssistant to the dedicated getDefaultAssistantSettings function where it belongs. This improves code organization and makes the documentation more accurate by placing it with the relevant function. * docs(AssistantService): clarify getDefaultAssistant behavior in jsdoc Explain the difference between this temporary instance and the actual default assistant from Redux store * fix: change default enableTemperature value to false The default value for enableTemperature was incorrectly set to true, which could lead to unexpected behavior. This change aligns it with the intended default behavior. --------- Co-authored-by: icarus --- .../AssistantModelSettings.tsx | 4 +- .../DefaultAssistantSettings.tsx | 18 +--- src/renderer/src/services/AssistantService.ts | 95 +++++++++++++++---- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index bc594235a7..b45cecc586 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -41,7 +41,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA const [customParameters, setCustomParameters] = useState( assistant?.settings?.customParameters ?? [] ) - const [enableTemperature, setEnableTemperature] = useState(assistant?.settings?.enableTemperature ?? true) + const [enableTemperature, setEnableTemperature] = useState(assistant?.settings?.enableTemperature ?? false) const customParametersRef = useRef(customParameters) @@ -168,7 +168,7 @@ const AssistantModelSettings: FC = ({ assistant, updateAssistant, updateA const onReset = () => { setTemperature(DEFAULT_ASSISTANT_SETTINGS.temperature) - setEnableTemperature(DEFAULT_ASSISTANT_SETTINGS.enableTemperature ?? true) + setEnableTemperature(DEFAULT_ASSISTANT_SETTINGS.enableTemperature ?? false) setContextCount(DEFAULT_ASSISTANT_SETTINGS.contextCount) setEnableMaxTokens(DEFAULT_ASSISTANT_SETTINGS.enableMaxTokens ?? false) setMaxTokens(DEFAULT_ASSISTANT_SETTINGS.maxTokens ?? 0) diff --git a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index 65be642adc..0dfb121e94 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -4,9 +4,10 @@ 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 { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { useDefaultAssistant } from '@renderer/hooks/useAssistant' +import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService' import type { AssistantSettings as AssistantSettingsType } from '@renderer/types' import { getLeadingEmoji, modalConfirm } from '@renderer/utils' import { Button, Col, Divider, Flex, Input, InputNumber, Modal, Popover, Row, Slider, Switch, Tooltip } from 'antd' @@ -21,7 +22,7 @@ import { SettingContainer, SettingRow, SettingSubtitle } from '..' const AssistantSettings: FC = () => { const { defaultAssistant, updateDefaultAssistant } = useDefaultAssistant() const [temperature, setTemperature] = useState(defaultAssistant.settings?.temperature ?? DEFAULT_TEMPERATURE) - const [enableTemperature, setEnableTemperature] = useState(defaultAssistant.settings?.enableTemperature ?? true) + const [enableTemperature, setEnableTemperature] = useState(defaultAssistant.settings?.enableTemperature ?? false) const [contextCount, setContextCount] = useState(defaultAssistant.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT) const [enableMaxTokens, setEnableMaxTokens] = useState(defaultAssistant?.settings?.enableMaxTokens ?? false) const [maxTokens, setMaxTokens] = useState(defaultAssistant?.settings?.maxTokens ?? 0) @@ -81,18 +82,7 @@ const AssistantSettings: FC = () => { setToolUseMode('function') updateDefaultAssistant({ ...defaultAssistant, - settings: { - ...defaultAssistant.settings, - temperature: DEFAULT_TEMPERATURE, - enableTemperature: true, - contextCount: DEFAULT_CONTEXTCOUNT, - enableMaxTokens: false, - maxTokens: DEFAULT_MAX_TOKENS, - streamOutput: true, - topP: 1, - enableTopP: false, - toolUseMode: 'function' - } + settings: { ...DEFAULT_ASSISTANT_SETTINGS } }) } diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 3983483aff..6f4ec188da 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -27,21 +27,51 @@ import { uuid } from '@renderer/utils' const logger = loggerService.withContext('AssistantService') +/** + * Default assistant settings configuration template. + * + * **Important**: This defines the DEFAULT VALUES for assistant settings, NOT the current settings + * of the default assistant. To get the actual settings of the default assistant, use `getDefaultAssistantSettings()`. + * + * Provides sensible defaults for all assistant settings with a focus on minimal parameter usage: + * - **Temperature disabled**: Use provider defaults by default + * - **MaxTokens disabled**: Use provider defaults by default + * - **TopP disabled**: Use provider defaults by default + * - **Streaming enabled**: Provides real-time response for better UX + * - **Standard context count**: Balanced memory usage and conversation length + */ export const DEFAULT_ASSISTANT_SETTINGS = { - temperature: DEFAULT_TEMPERATURE, - enableTemperature: true, - contextCount: DEFAULT_CONTEXTCOUNT, + maxTokens: DEFAULT_MAX_TOKENS, enableMaxTokens: false, - maxTokens: 0, - streamOutput: true, + temperature: DEFAULT_TEMPERATURE, + enableTemperature: false, topP: 1, enableTopP: false, - // It would gracefully fallback to prompt if not supported by model. - toolUseMode: 'function', + contextCount: DEFAULT_CONTEXTCOUNT, + streamOutput: true, + defaultModel: undefined, customParameters: [], - reasoning_effort: 'default' + reasoning_effort: 'default', + reasoning_effort_cache: undefined, + qwenThinkMode: undefined, + // It would gracefully fallback to prompt if not supported by model. + toolUseMode: 'function' } as const satisfies AssistantSettings +/** + * Creates a temporary default assistant instance. + * + * **Important**: This creates a NEW temporary assistant instance with DEFAULT_ASSISTANT_SETTINGS, + * NOT the actual default assistant from Redux store. This is used as a template for creating + * new assistants or as a fallback when no assistant is specified. + * + * To get the actual default assistant from Redux store (with current user settings), use: + * ```typescript + * const defaultAssistant = store.getState().assistants.defaultAssistant + * ``` + * + * @returns New temporary assistant instance with default settings + */ export function getDefaultAssistant(): Assistant { return { id: 'default', @@ -56,6 +86,14 @@ export function getDefaultAssistant(): Assistant { } } +/** + * Creates a default translate assistant. + * + * @param targetLanguage - Target language for translation + * @param text - Text to be translated + * @param _settings - Optional settings to override default assistant settings + * @returns Configured translate assistant + */ export function getDefaultTranslateAssistant( targetLanguage: TranslateLanguage, text: string, @@ -106,6 +144,17 @@ export function getDefaultTranslateAssistant( return translateAssistant } +/** + * Gets the CURRENT SETTINGS of the default assistant. + * + * **Important**: This returns the actual current settings of the default assistant (user-configured), + * NOT the DEFAULT_ASSISTANT_SETTINGS template. The settings may have been modified by the user + * from their initial default values. + * + * To get the template of default values, use DEFAULT_ASSISTANT_SETTINGS directly. + * + * @returns Current settings of the default assistant from store state + */ export function getDefaultAssistantSettings() { return store.getState().assistants.defaultAssistant.settings } @@ -165,6 +214,18 @@ export function getProviderByModelId(modelId?: string) { return providers.find((p) => p.models.find((m) => m.id === _modelId)) as Provider } +/** + * Retrieves and normalizes assistant settings with special transformation handling. + * + * **Special Transformations:** + * 1. **Context Count**: Converts `MAX_CONTEXT_COUNT` to `UNLIMITED_CONTEXT_COUNT` for internal processing + * 2. **Max Tokens**: Only returns a value when `enableMaxTokens` is true, otherwise returns `undefined` + * 3. **Max Tokens Validation**: Ensures maxTokens > 0, falls back to `DEFAULT_MAX_TOKENS` if invalid + * 4. **Fallback Defaults**: Applies system defaults for all undefined/missing settings + * + * @param assistant - The assistant instance to extract settings from + * @returns Normalized assistant settings with all transformations applied + */ export const getAssistantSettings = (assistant: Assistant): AssistantSettings => { const contextCount = assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT const getAssistantMaxTokens = () => { @@ -181,16 +242,16 @@ export const getAssistantSettings = (assistant: Assistant): AssistantSettings => return { contextCount: contextCount === MAX_CONTEXT_COUNT ? UNLIMITED_CONTEXT_COUNT : contextCount, temperature: assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE, - enableTemperature: assistant?.settings?.enableTemperature ?? true, - topP: assistant?.settings?.topP ?? 1, - enableTopP: assistant?.settings?.enableTopP ?? false, - enableMaxTokens: assistant?.settings?.enableMaxTokens ?? false, + enableTemperature: assistant?.settings?.enableTemperature ?? DEFAULT_ASSISTANT_SETTINGS.enableTemperature, + topP: assistant?.settings?.topP ?? DEFAULT_ASSISTANT_SETTINGS.topP, + enableTopP: assistant?.settings?.enableTopP ?? DEFAULT_ASSISTANT_SETTINGS.enableTopP, + enableMaxTokens: assistant?.settings?.enableMaxTokens ?? DEFAULT_ASSISTANT_SETTINGS.enableMaxTokens, maxTokens: getAssistantMaxTokens(), - streamOutput: assistant?.settings?.streamOutput ?? true, - toolUseMode: assistant?.settings?.toolUseMode ?? 'function', - defaultModel: assistant?.defaultModel ?? undefined, - reasoning_effort: assistant?.settings?.reasoning_effort ?? 'default', - customParameters: assistant?.settings?.customParameters ?? [] + streamOutput: assistant?.settings?.streamOutput ?? DEFAULT_ASSISTANT_SETTINGS.streamOutput, + toolUseMode: assistant?.settings?.toolUseMode ?? DEFAULT_ASSISTANT_SETTINGS.toolUseMode, + defaultModel: assistant?.defaultModel ?? DEFAULT_ASSISTANT_SETTINGS.defaultModel, + reasoning_effort: assistant?.settings?.reasoning_effort ?? DEFAULT_ASSISTANT_SETTINGS.reasoning_effort, + customParameters: assistant?.settings?.customParameters ?? DEFAULT_ASSISTANT_SETTINGS.customParameters } } From 6bdaba8a15899204aedcd3ab7cdca4b134d818a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:16:03 +0800 Subject: [PATCH 10/18] feat: add GLM-4.7 and MiniMax-M2.1 model support (#12071) --- src/renderer/src/config/models/default.ts | 12 ++++++++++++ src/renderer/src/config/models/reasoning.ts | 4 ++-- src/renderer/src/config/models/tooluse.ts | 3 ++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index 37854c5749..f87293798d 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -617,6 +617,12 @@ export const SYSTEM_MODELS: Record = name: 'GLM-4.6', group: 'GLM-4.6' }, + { + id: 'glm-4.7', + provider: 'zhipu', + name: 'GLM-4.7', + group: 'GLM-4.7' + }, { id: 'glm-4.5', provider: 'zhipu', @@ -921,6 +927,12 @@ export const SYSTEM_MODELS: Record = provider: 'minimax', name: 'MiniMax M2 Stable', group: 'minimax-m2' + }, + { + id: 'MiniMax-M2.1', + provider: 'minimax', + name: 'MiniMax M2.1', + group: 'minimax-m2' } ], hyperbolic: [ diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 144afc52a7..27a793bf7d 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -571,7 +571,7 @@ export const isSupportedReasoningEffortPerplexityModel = (model: Model): boolean export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => { const modelId = getLowerBaseModelName(model.id, '/') - return ['glm-4.5', 'glm-4.6'].some((id) => modelId.includes(id)) + return ['glm-4.5', 'glm-4.6', 'glm-4.7'].some((id) => modelId.includes(id)) } export const isSupportedThinkingTokenMiMoModel = (model: Model): boolean => { @@ -632,7 +632,7 @@ export const isMiniMaxReasoningModel = (model?: Model): boolean => { return false } const modelId = getLowerBaseModelName(model.id, '/') - return (['minimax-m1', 'minimax-m2'] as const).some((id) => modelId.includes(id)) + return (['minimax-m1', 'minimax-m2', 'minimax-m2.1'] as const).some((id) => modelId.includes(id)) } export function isReasoningModel(model?: Model): boolean { diff --git a/src/renderer/src/config/models/tooluse.ts b/src/renderer/src/config/models/tooluse.ts index 54d371dfda..2333db94d8 100644 --- a/src/renderer/src/config/models/tooluse.ts +++ b/src/renderer/src/config/models/tooluse.ts @@ -22,6 +22,7 @@ export const FUNCTION_CALLING_MODELS = [ 'deepseek', 'glm-4(?:-[\\w-]+)?', 'glm-4.5(?:-[\\w-]+)?', + 'glm-4.7(?:-[\\w-]+)?', 'learnlm(?:-[\\w-]+)?', 'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型 'grok-3(?:-[\\w-]+)?', @@ -30,7 +31,7 @@ export const FUNCTION_CALLING_MODELS = [ 'kimi-k2(?:-[\\w-]+)?', 'ling-\\w+(?:-[\\w-]+)?', 'ring-\\w+(?:-[\\w-]+)?', - 'minimax-m2', + 'minimax-m2(?:.1)?', 'mimo-v2-flash' ] as const From 6815ab65d124fb1aabdd674b9d107d346aa082b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Tue, 23 Dec 2025 13:21:29 +0800 Subject: [PATCH 11/18] fix(memory): fix retrieval issues and enable database backup (#12073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): fix retrieval issues and enable database backup - Fix memory retrieval by storing model references instead of API client configs (baseURL was missing v1 suffix causing retrieval failures) - Move memory database to DATA_PATH/Memory for proper backup support - Add migration to convert legacy embedderApiClient/llmApiClient to model references - Simplify IPC handlers by removing unnecessary async/await wrappers - Rename and relocate MemorySettingsModal for better organization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(UserSelector): simplify user label rendering and remove unused dependencies - Update UserSelector component to directly use user IDs as labels instead of rendering them through a function. - Remove unnecessary dependency on the renderLabel function to streamline the code. * refactor(UserSelector): remove unused dependencies and simplify user avatar logic - Eliminate the getUserAvatar function and directly use user IDs for rendering. - Remove the HStack and Avatar components from the renderLabel function to streamline the UserSelector component. * refactor(ipc): simplify IPC handler for deleting all memories for a user and streamline error logging - Remove unnecessary async/await from the Memory_DeleteAllMemoriesForUser handler. - Simplify error logging in useAppInit hook for memory service configuration updates. - Update persisted reducer version from 191 to 189 in the store configuration. --------- Co-authored-by: Claude --- packages/shared/IpcChannel.ts | 1 + src/main/ipc.ts | 43 +++------- src/main/services/memory/MemoryService.ts | 51 ++++++++---- src/preload/index.ts | 3 +- .../src/aiCore/tools/MemorySearchTool.ts | 3 +- src/renderer/src/hooks/useAppInit.ts | 4 +- .../AssistantMemorySettings.tsx | 12 +-- .../MemorySettings/MemorySettings.tsx | 28 +++---- .../MemorySettings/MemorySettingsModal.tsx} | 82 +++++++------------ .../settings/MemorySettings/UserSelector.tsx | 32 ++------ src/renderer/src/services/MemoryProcessor.ts | 10 ++- src/renderer/src/services/MemoryService.ts | 29 +++++-- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/memory.ts | 2 +- src/renderer/src/store/migrate.ts | 30 +++++++ src/renderer/src/types/index.ts | 14 +--- 16 files changed, 170 insertions(+), 176 deletions(-) rename src/renderer/src/pages/{memory/settings-modal.tsx => settings/MemorySettings/MemorySettingsModal.tsx} (64%) diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index c97b258676..2ba327db93 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -318,6 +318,7 @@ export enum IpcChannel { Memory_DeleteUser = 'memory:delete-user', Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user', Memory_GetUsersList = 'memory:get-users-list', + Memory_MigrateMemoryDb = 'memory:migrate-memory-db', // TRACE TRACE_SAVE_DATA = 'trace:saveData', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0bebb62fca..a4e0fe5c53 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -686,36 +686,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota.bind(KnowledgeService)) // memory - ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => { - return await memoryService.add(messages, config) - }) - ipcMain.handle(IpcChannel.Memory_Search, async (_, query, config) => { - return await memoryService.search(query, config) - }) - ipcMain.handle(IpcChannel.Memory_List, async (_, config) => { - return await memoryService.list(config) - }) - ipcMain.handle(IpcChannel.Memory_Delete, async (_, id) => { - return await memoryService.delete(id) - }) - ipcMain.handle(IpcChannel.Memory_Update, async (_, id, memory, metadata) => { - return await memoryService.update(id, memory, metadata) - }) - ipcMain.handle(IpcChannel.Memory_Get, async (_, memoryId) => { - return await memoryService.get(memoryId) - }) - ipcMain.handle(IpcChannel.Memory_SetConfig, async (_, config) => { - memoryService.setConfig(config) - }) - ipcMain.handle(IpcChannel.Memory_DeleteUser, async (_, userId) => { - return await memoryService.deleteUser(userId) - }) - ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, async (_, userId) => { - return await memoryService.deleteAllMemoriesForUser(userId) - }) - ipcMain.handle(IpcChannel.Memory_GetUsersList, async () => { - return await memoryService.getUsersList() - }) + ipcMain.handle(IpcChannel.Memory_Add, (_, messages, config) => memoryService.add(messages, config)) + ipcMain.handle(IpcChannel.Memory_Search, (_, query, config) => memoryService.search(query, config)) + ipcMain.handle(IpcChannel.Memory_List, (_, config) => memoryService.list(config)) + ipcMain.handle(IpcChannel.Memory_Delete, (_, id) => memoryService.delete(id)) + ipcMain.handle(IpcChannel.Memory_Update, (_, id, memory, metadata) => memoryService.update(id, memory, metadata)) + ipcMain.handle(IpcChannel.Memory_Get, (_, memoryId) => memoryService.get(memoryId)) + ipcMain.handle(IpcChannel.Memory_SetConfig, (_, config) => memoryService.setConfig(config)) + ipcMain.handle(IpcChannel.Memory_DeleteUser, (_, userId) => memoryService.deleteUser(userId)) + ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, (_, userId) => + memoryService.deleteAllMemoriesForUser(userId) + ) + ipcMain.handle(IpcChannel.Memory_GetUsersList, () => memoryService.getUsersList()) + ipcMain.handle(IpcChannel.Memory_MigrateMemoryDb, () => memoryService.migrateMemoryDb()) // window ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => { diff --git a/src/main/services/memory/MemoryService.ts b/src/main/services/memory/MemoryService.ts index 3466e2c3c6..101dd54294 100644 --- a/src/main/services/memory/MemoryService.ts +++ b/src/main/services/memory/MemoryService.ts @@ -1,7 +1,9 @@ import type { Client } from '@libsql/client' import { createClient } from '@libsql/client' import { loggerService } from '@logger' +import { DATA_PATH } from '@main/config' import Embeddings from '@main/knowledge/embedjs/embeddings/Embeddings' +import { makeSureDirExists } from '@main/utils' import type { AddMemoryOptions, AssistantMessage, @@ -13,6 +15,7 @@ import type { } from '@types' import crypto from 'crypto' import { app } from 'electron' +import fs from 'fs' import path from 'path' import { MemoryQueries } from './queries' @@ -71,6 +74,21 @@ export class MemoryService { return MemoryService.instance } + /** + * Migrate the memory database from the old path to the new path + * If the old memory database exists, rename it to the new path + */ + public migrateMemoryDb(): void { + const oldMemoryDbPath = path.join(app.getPath('userData'), 'memories.db') + const memoryDbPath = path.join(DATA_PATH, 'Memory', 'memories.db') + + makeSureDirExists(path.dirname(memoryDbPath)) + + if (fs.existsSync(oldMemoryDbPath)) { + fs.renameSync(oldMemoryDbPath, memoryDbPath) + } + } + /** * Initialize the database connection and create tables */ @@ -80,11 +98,12 @@ export class MemoryService { } try { - const userDataPath = app.getPath('userData') - const dbPath = path.join(userDataPath, 'memories.db') + const memoryDbPath = path.join(DATA_PATH, 'Memory', 'memories.db') + + makeSureDirExists(path.dirname(memoryDbPath)) this.db = createClient({ - url: `file:${dbPath}`, + url: `file:${memoryDbPath}`, intMode: 'number' }) @@ -168,12 +187,13 @@ export class MemoryService { // Generate embedding if model is configured let embedding: number[] | null = null - const embedderApiClient = this.config?.embedderApiClient - if (embedderApiClient) { + const embeddingModel = this.config?.embeddingModel + + if (embeddingModel) { try { embedding = await this.generateEmbedding(trimmedMemory) logger.debug( - `Generated embedding for restored memory with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + `Generated embedding for restored memory with dimension: ${embedding.length} (target: ${this.config?.embeddingDimensions || MemoryService.UNIFIED_DIMENSION})` ) } catch (error) { logger.error('Failed to generate embedding for restored memory:', error as Error) @@ -211,11 +231,11 @@ export class MemoryService { // Generate embedding if model is configured let embedding: number[] | null = null - if (this.config?.embedderApiClient) { + if (this.config?.embeddingModel) { try { embedding = await this.generateEmbedding(trimmedMemory) logger.debug( - `Generated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + `Generated embedding with dimension: ${embedding.length} (target: ${this.config?.embeddingDimensions || MemoryService.UNIFIED_DIMENSION})` ) // Check for similar memories using vector similarity @@ -300,7 +320,7 @@ export class MemoryService { try { // If we have an embedder model configured, use vector search - if (this.config?.embedderApiClient) { + if (this.config?.embeddingModel) { try { const queryEmbedding = await this.generateEmbedding(query) return await this.hybridSearch(query, queryEmbedding, { limit, userId, agentId, filters }) @@ -497,11 +517,11 @@ export class MemoryService { // Generate new embedding if model is configured let embedding: number[] | null = null - if (this.config?.embedderApiClient) { + if (this.config?.embeddingModel) { try { embedding = await this.generateEmbedding(memory) logger.debug( - `Updated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})` + `Updated embedding with dimension: ${embedding.length} (target: ${this.config?.embeddingDimensions || MemoryService.UNIFIED_DIMENSION})` ) } catch (error) { logger.error('Failed to generate embedding for update:', error as Error) @@ -710,21 +730,22 @@ export class MemoryService { * Generate embedding for text */ private async generateEmbedding(text: string): Promise { - if (!this.config?.embedderApiClient) { + if (!this.config?.embeddingModel) { throw new Error('Embedder model not configured') } try { // Initialize embeddings instance if needed if (!this.embeddings) { - if (!this.config.embedderApiClient) { + if (!this.config.embeddingApiClient) { throw new Error('Embedder provider not configured') } this.embeddings = new Embeddings({ - embedApiClient: this.config.embedderApiClient, - dimensions: this.config.embedderDimensions + embedApiClient: this.config.embeddingApiClient, + dimensions: this.config.embeddingDimensions }) + await this.embeddings.init() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 46ce84903f..d393d4a6e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -310,7 +310,8 @@ const api = { deleteUser: (userId: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteUser, userId), deleteAllMemoriesForUser: (userId: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteAllMemoriesForUser, userId), - getUsersList: () => ipcRenderer.invoke(IpcChannel.Memory_GetUsersList) + getUsersList: () => ipcRenderer.invoke(IpcChannel.Memory_GetUsersList), + migrateMemoryDb: () => ipcRenderer.invoke(IpcChannel.Memory_MigrateMemoryDb) }, window: { setMinimumSize: (width: number, height: number) => diff --git a/src/renderer/src/aiCore/tools/MemorySearchTool.ts b/src/renderer/src/aiCore/tools/MemorySearchTool.ts index 20064dd1b2..5028f2eb4d 100644 --- a/src/renderer/src/aiCore/tools/MemorySearchTool.ts +++ b/src/renderer/src/aiCore/tools/MemorySearchTool.ts @@ -24,7 +24,8 @@ export const memorySearchTool = () => { } const memoryConfig = selectMemoryConfig(store.getState()) - if (!memoryConfig.llmApiClient || !memoryConfig.embedderApiClient) { + + if (!memoryConfig.llmModel || !memoryConfig.embeddingModel) { return [] } diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 3ee9392ce5..360f8a5e2a 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -268,9 +268,7 @@ export function useAppInit() { // Update memory service configuration when it changes useEffect(() => { const memoryService = MemoryService.getInstance() - memoryService.updateConfig().catch((error) => { - logger.error('Failed to update memory config:', error) - }) + memoryService.updateConfig().catch((error) => logger.error('Failed to update memory config:', error)) }, [memoryConfig]) useEffect(() => { diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx index 8987d31bcd..c4d579bfbf 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx @@ -1,7 +1,7 @@ import { InfoCircleOutlined } from '@ant-design/icons' import { loggerService } from '@logger' import { Box } from '@renderer/components/Layout' -import MemoriesSettingsModal from '@renderer/pages/memory/settings-modal' +import MemoriesSettingsModal from '@renderer/pages/settings/MemorySettings/MemorySettingsModal' import MemoryService from '@renderer/services/MemoryService' import { selectGlobalMemoryEnabled, selectMemoryConfig } from '@renderer/store/memory' import type { Assistant, AssistantSettings } from '@renderer/types' @@ -68,7 +68,7 @@ const AssistantMemorySettings: React.FC = ({ assistant, updateAssistant, window.location.hash = '#/settings/memory' } - const isMemoryConfigured = memoryConfig.embedderApiClient && memoryConfig.llmApiClient + const isMemoryConfigured = memoryConfig.embeddingModel && memoryConfig.llmModel const isMemoryEnabled = globalMemoryEnabled && isMemoryConfigured return ( @@ -130,16 +130,16 @@ const AssistantMemorySettings: React.FC = ({ assistant, updateAssistant, {t('memory.stored_memories')}: {memoryStats.loading ? t('common.loading') : memoryStats.count}
- {memoryConfig.embedderApiClient && ( + {memoryConfig.embeddingModel && (
{t('memory.embedding_model')}: - {memoryConfig.embedderApiClient.model} + {memoryConfig.embeddingModel.id}
)} - {memoryConfig.llmApiClient && ( + {memoryConfig.llmModel && (
{t('memory.llm_model')}: - {memoryConfig.llmApiClient.model} + {memoryConfig.llmModel.id}
)} diff --git a/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx b/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx index ecf8d74b68..6a454c5dd4 100644 --- a/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/MemorySettings.tsx @@ -5,7 +5,6 @@ import { HStack } from '@renderer/components/Layout' import TextBadge from '@renderer/components/TextBadge' import { useTheme } from '@renderer/context/ThemeProvider' import { useModel } from '@renderer/hooks/useModel' -import MemoriesSettingsModal from '@renderer/pages/memory/settings-modal' import MemoryService from '@renderer/services/MemoryService' import { selectCurrentUserId, @@ -34,6 +33,7 @@ import { SettingTitle } from '../index' import { DEFAULT_USER_ID } from './constants' +import MemorySettingsModal from './MemorySettingsModal' import UserSelector from './UserSelector' const logger = loggerService.withContext('MemorySettings') @@ -154,23 +154,17 @@ const EditMemoryModal: React.FC = ({ visible, memory, onCa open={visible} onCancel={onCancel} width={600} + centered + transitionName="animation-move-down" + okButtonProps={{ loading: loading, title: t('common.save'), onClick: () => form.submit() }} styles={{ header: { borderBottom: '0.5px solid var(--color-border)', - paddingBottom: 16 - }, - body: { - paddingTop: 24 + paddingBottom: 16, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0 } - }} - footer={[ - , - - ]}> + }}>
{ } const memoryConfig = useSelector(selectMemoryConfig) - const embedderModel = useModel(memoryConfig.embedderApiClient?.model, memoryConfig.embedderApiClient?.provider) + const embeddingModel = useModel(memoryConfig.embeddingModel?.id, memoryConfig.embeddingModel?.provider) const handleGlobalMemoryToggle = async (enabled: boolean) => { - if (enabled && !embedderModel) { + if (enabled && !embeddingModel) { window.keyv.set('memory.wait.settings', true) return setSettingsModalVisible(true) } @@ -799,7 +793,7 @@ const MemorySettings = () => { existingUsers={[...uniqueUsers, DEFAULT_USER_ID]} /> - await handleSettingsSubmit()} onCancel={handleSettingsCancel} diff --git a/src/renderer/src/pages/memory/settings-modal.tsx b/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx similarity index 64% rename from src/renderer/src/pages/memory/settings-modal.tsx rename to src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx index 996baebd1d..509e54e54a 100644 --- a/src/renderer/src/pages/memory/settings-modal.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx @@ -1,10 +1,9 @@ import { loggerService } from '@logger' -import AiProvider from '@renderer/aiCore' import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension' import ModelSelector from '@renderer/components/ModelSelector' import { InfoTooltip } from '@renderer/components/TooltipIcons' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { useModel } from '@renderer/hooks/useModel' +import { getModel, useModel } from '@renderer/hooks/useModel' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { selectMemoryConfig, updateMemoryConfig } from '@renderer/store/memory' @@ -12,12 +11,12 @@ import type { Model } from '@renderer/types' import { Flex, Form, Modal } from 'antd' import { t } from 'i18next' import type { FC } from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -const logger = loggerService.withContext('MemoriesSettingsModal') +const logger = loggerService.withContext('MemorySettingsModal') -interface MemoriesSettingsModalProps { +interface MemorySettingsModalProps { visible: boolean onSubmit: (values: any) => void onCancel: () => void @@ -26,78 +25,57 @@ interface MemoriesSettingsModalProps { type formValue = { llmModel: string - embedderModel: string - embedderDimensions: number + embeddingModel: string + embeddingDimensions: number } -const MemoriesSettingsModal: FC = ({ visible, onSubmit, onCancel, form }) => { +const MemorySettingsModal: FC = ({ visible, onSubmit, onCancel, form }) => { const { providers } = useProviders() const dispatch = useDispatch() const memoryConfig = useSelector(selectMemoryConfig) const [loading, setLoading] = useState(false) // Get all models for lookup - const allModels = useMemo(() => providers.flatMap((p) => p.models), [providers]) - const llmModel = useModel(memoryConfig.llmApiClient?.model, memoryConfig.llmApiClient?.provider) - const embedderModel = useModel(memoryConfig.embedderApiClient?.model, memoryConfig.embedderApiClient?.provider) - - const findModelById = useCallback( - (id: string | undefined) => (id ? allModels.find((m) => getModelUniqId(m) === id) : undefined), - [allModels] - ) + const llmModel = useModel(memoryConfig.llmModel?.id, memoryConfig.llmModel?.provider) + const embeddingModel = useModel(memoryConfig.embeddingModel?.id, memoryConfig.embeddingModel?.provider) // Initialize form with current memory config when modal opens useEffect(() => { if (visible && memoryConfig) { form.setFieldsValue({ llmModel: getModelUniqId(llmModel), - embedderModel: getModelUniqId(embedderModel), - embedderDimensions: memoryConfig.embedderDimensions + embeddingModel: getModelUniqId(embeddingModel), + embeddingDimensions: memoryConfig.embeddingDimensions // customFactExtractionPrompt: memoryConfig.customFactExtractionPrompt, // customUpdateMemoryPrompt: memoryConfig.customUpdateMemoryPrompt }) } - }, [visible, memoryConfig, form, llmModel, embedderModel]) + }, [embeddingModel, form, llmModel, memoryConfig, visible]) const handleFormSubmit = async (values: formValue) => { try { // Convert model IDs back to Model objects - const llmModel = findModelById(values.llmModel) - const llmProvider = providers.find((p) => p.id === llmModel?.provider) - const aiLlmProvider = new AiProvider(llmProvider!) - const embedderModel = findModelById(values.embedderModel) - const embedderProvider = providers.find((p) => p.id === embedderModel?.provider) - const aiEmbedderProvider = new AiProvider(embedderProvider!) - if (embedderModel) { + const llmModel = getModel(values.llmModel) + const embeddingModel = getModel(values.embeddingModel) + + if (embeddingModel) { setLoading(true) - const provider = providers.find((p) => p.id === embedderModel.provider) + const provider = providers.find((p) => p.id === embeddingModel.provider) if (!provider) { return } const finalDimensions = - typeof values.embedderDimensions === 'string' - ? parseInt(values.embedderDimensions) - : values.embedderDimensions + typeof values.embeddingDimensions === 'string' + ? parseInt(values.embeddingDimensions) + : values.embeddingDimensions const updatedConfig = { ...memoryConfig, - llmApiClient: { - model: llmModel?.id ?? '', - provider: llmProvider?.id ?? '', - apiKey: aiLlmProvider.getApiKey(), - baseURL: aiLlmProvider.getBaseURL(), - apiVersion: llmProvider?.apiVersion - }, - embedderApiClient: { - model: embedderModel?.id ?? '', - provider: embedderProvider?.id ?? '', - apiKey: aiEmbedderProvider.getApiKey(), - baseURL: aiEmbedderProvider.getBaseURL(), - apiVersion: embedderProvider?.apiVersion - }, - embedderDimensions: finalDimensions + llmModel, + embeddingModel, + embeddingDimensions: finalDimensions // customFactExtractionPrompt: values.customFactExtractionPrompt, // customUpdateMemoryPrompt: values.customUpdateMemoryPrompt } @@ -150,7 +128,7 @@ const MemoriesSettingsModal: FC = ({ visible, onSubm = ({ visible, onSubm prevValues.embedderModel !== currentValues.embedderModel}> + shouldUpdate={(prevValues, currentValues) => prevValues.embeddingModel !== currentValues.embeddingModel}> {({ getFieldValue }) => { - const embedderModelId = getFieldValue('embedderModel') - const embedderModel = findModelById(embedderModelId) + const embeddingModelId = getFieldValue('embeddingModel') + const embeddingModel = getModel(embeddingModelId) return ( = ({ visible, onSubm } - name="embedderDimensions" + name="embeddingDimensions" rules={[ { validator(_, value) { @@ -183,7 +161,7 @@ const MemoriesSettingsModal: FC = ({ visible, onSubm } } ]}> - + ) }} @@ -199,4 +177,4 @@ const MemoriesSettingsModal: FC = ({ visible, onSubm ) } -export default MemoriesSettingsModal +export default MemorySettingsModal diff --git a/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx b/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx index d11318c25f..2521fcad20 100644 --- a/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx @@ -1,7 +1,6 @@ -import { HStack } from '@renderer/components/Layout' -import { Avatar, Button, Select, Space, Tooltip } from 'antd' +import { Button, Select, Space, Tooltip } from 'antd' import { UserRoundPlus } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { DEFAULT_USER_ID } from './constants' @@ -16,39 +15,18 @@ interface UserSelectorProps { const UserSelector: React.FC = ({ currentUser, uniqueUsers, onUserSwitch, onAddUser }) => { const { t } = useTranslation() - const getUserAvatar = useCallback((user: string) => { - return user === DEFAULT_USER_ID ? user.slice(0, 1).toUpperCase() : user.slice(0, 2).toUpperCase() - }, []) - - const renderLabel = useCallback( - (userId: string, userName: string) => { - return ( - - - {getUserAvatar(userId)} - - {userName} - - ) - }, - [getUserAvatar] - ) - const options = useMemo(() => { const defaultOption = { value: DEFAULT_USER_ID, - label: renderLabel(DEFAULT_USER_ID, t('memory.default_user')) + label: t('memory.default_user') } const userOptions = uniqueUsers .filter((user) => user !== DEFAULT_USER_ID) - .map((user) => ({ - value: user, - label: renderLabel(user, user) - })) + .map((user) => ({ value: user, label: user })) return [defaultOption, ...userOptions] - }, [renderLabel, t, uniqueUsers]) + }, [t, uniqueUsers]) return ( diff --git a/src/renderer/src/services/MemoryProcessor.ts b/src/renderer/src/services/MemoryProcessor.ts index 01ba5eeb77..7e9291cfd9 100644 --- a/src/renderer/src/services/MemoryProcessor.ts +++ b/src/renderer/src/services/MemoryProcessor.ts @@ -40,7 +40,7 @@ export class MemoryProcessor { try { const { memoryConfig } = config - if (!memoryConfig.llmApiClient) { + if (!memoryConfig.llmModel) { throw new Error('No LLM model configured for memory processing') } @@ -53,8 +53,9 @@ export class MemoryProcessor { const responseContent = await fetchGenerate({ prompt: systemPrompt, content: userPrompt, - model: getModel(memoryConfig.llmApiClient.model, memoryConfig.llmApiClient.provider) + model: getModel(memoryConfig.llmModel.id, memoryConfig.llmModel.provider) }) + if (!responseContent || responseContent.trim() === '') { return [] } @@ -100,9 +101,10 @@ export class MemoryProcessor { const { memoryConfig, assistantId, userId, lastMessageId } = config - if (!memoryConfig.llmApiClient) { + if (!memoryConfig.llmModel) { throw new Error('No LLM model configured for memory processing') } + const existingMemoriesResult = (window.keyv.get(`memory-search-${lastMessageId}`) as MemoryItem[]) || [] const existingMemories = existingMemoriesResult.map((memory) => ({ @@ -123,7 +125,7 @@ export class MemoryProcessor { const responseContent = await fetchGenerate({ prompt: updateMemorySystemPrompt, content: updateMemoryUserPrompt, - model: getModel(memoryConfig.llmApiClient.model, memoryConfig.llmApiClient.provider) + model: getModel(memoryConfig.llmModel.id, memoryConfig.llmModel.provider) }) if (!responseContent || responseContent.trim() === '') { return [] diff --git a/src/renderer/src/services/MemoryService.ts b/src/renderer/src/services/MemoryService.ts index 8f572194df..d7d575886c 100644 --- a/src/renderer/src/services/MemoryService.ts +++ b/src/renderer/src/services/MemoryService.ts @@ -1,14 +1,19 @@ import { loggerService } from '@logger' +import { getModel } from '@renderer/hooks/useModel' import store from '@renderer/store' import { selectMemoryConfig } from '@renderer/store/memory' import type { AddMemoryOptions, AssistantMessage, + KnowledgeBase, MemoryHistoryItem, MemoryListOptions, MemorySearchOptions, MemorySearchResult } from '@types' +import { now } from 'lodash' + +import { getKnowledgeBaseParams } from './KnowledgeService' const logger = loggerService.withContext('MemoryService') @@ -203,16 +208,24 @@ class MemoryService { } const memoryConfig = selectMemoryConfig(store.getState()) - const embedderApiClient = memoryConfig.embedderApiClient - const llmApiClient = memoryConfig.llmApiClient + const embeddingModel = memoryConfig.embeddingModel - const configWithProviders = { + // Get knowledge base params for memory + const { embedApiClient: embeddingApiClient } = getKnowledgeBaseParams({ + id: 'memory', + name: 'Memory', + model: getModel(embeddingModel?.id, embeddingModel?.provider), + dimensions: memoryConfig.embeddingDimensions, + items: [], + created_at: now(), + updated_at: now(), + version: 1 + } as KnowledgeBase) + + return window.api.memory.setConfig({ ...memoryConfig, - embedderApiClient, - llmApiClient - } - - return window.api.memory.setConfig(configWithProviders) + embeddingApiClient + }) } catch (error) { logger.warn('Failed to update memory config:', error as Error) return diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 0a079df9b5..15f45648dc 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: 188, + version: 189, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/memory.ts b/src/renderer/src/store/memory.ts index 808f2154af..e28b291c19 100644 --- a/src/renderer/src/store/memory.ts +++ b/src/renderer/src/store/memory.ts @@ -17,7 +17,7 @@ export interface MemoryState { // Default memory configuration to avoid undefined errors const defaultMemoryConfig: MemoryConfig = { - embedderDimensions: 1536, + embeddingDimensions: undefined, isAutoDimensions: true, customFactExtractionPrompt: factExtractionPrompt, customUpdateMemoryPrompt: updateMemorySystemPrompt diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index af789378c8..4b9d41b9e4 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -18,6 +18,7 @@ import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import { SYSTEM_PROVIDERS } from '@renderer/config/providers' import { DEFAULT_SIDEBAR_ICONS } from '@renderer/config/sidebar' import db from '@renderer/databases' +import { getModel } from '@renderer/hooks/useModel' import i18n from '@renderer/i18n' import { DEFAULT_ASSISTANT_SETTINGS } from '@renderer/services/AssistantService' import { defaultPreprocessProviders } from '@renderer/store/preprocess' @@ -3068,6 +3069,35 @@ const migrateConfig = { logger.error('migrate 188 error', error as Error) return state } + }, + // 1.7.7 + '189': (state: RootState) => { + try { + window.api.memory.migrateMemoryDb() + // @ts-ignore + const memoryLlmApiClient = state?.memory?.memoryConfig?.llmApiClient + // @ts-ignore + const memoryEmbeddingApiClient = state?.memory?.memoryConfig?.embedderApiClient + + if (memoryLlmApiClient) { + state.memory.memoryConfig.llmModel = getModel(memoryLlmApiClient.model, memoryLlmApiClient.provider) + // @ts-ignore + delete state.memory.memoryConfig.llmApiClient + } + + if (memoryEmbeddingApiClient) { + state.memory.memoryConfig.embeddingModel = getModel( + memoryEmbeddingApiClient.model, + memoryEmbeddingApiClient.provider + ) + // @ts-ignore + delete state.memory.memoryConfig.embedderApiClient + } + return state + } catch (error) { + logger.error('migrate 189 error', error as Error) + return state + } } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index eefa380a66..126c97686e 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -915,17 +915,11 @@ export * from './tool' // Memory Service Types // ======================================================================== export interface MemoryConfig { - /** - * @deprecated use embedderApiClient instead - */ - embedderModel?: Model - embedderDimensions?: number - /** - * @deprecated use llmApiClient instead - */ + embeddingDimensions?: number + embeddingModel?: Model llmModel?: Model - embedderApiClient?: ApiClient - llmApiClient?: ApiClient + // Dynamically retrieved, not persistently stored + embeddingApiClient?: ApiClient customFactExtractionPrompt?: string customUpdateMemoryPrompt?: string /** Indicates whether embedding dimensions are automatically detected */ From 5f0006dcede235cf06bd630864f3afb13ef552bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Tue, 23 Dec 2025 13:22:02 +0800 Subject: [PATCH 12/18] refactor(websearch): redesign settings with two-column layout (#12068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor WebSearchSettings to use two-column layout (left sidebar + right content) - Add local search provider settings with internal browser window support - Add "Set as Default" button in provider settings page - Show default indicator tag in provider list - Prevent selection of providers without API key configured - Add logos for local search providers (Google, Bing, Baidu) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- src/main/ipc.ts | 4 +- src/main/services/SearchService.ts | 40 ++- src/preload/index.ts | 2 +- .../src/assets/images/search/baidu.svg | 1 + .../src/assets/images/search/bing.svg | 1 + .../src/assets/images/search/google.svg | 1 + src/renderer/src/i18n/locales/en-us.json | 15 ++ src/renderer/src/i18n/locales/zh-cn.json | 15 ++ src/renderer/src/i18n/locales/zh-tw.json | 15 ++ src/renderer/src/i18n/translate/de-de.json | 15 ++ src/renderer/src/i18n/translate/el-gr.json | 15 ++ src/renderer/src/i18n/translate/es-es.json | 15 ++ src/renderer/src/i18n/translate/fr-fr.json | 15 ++ src/renderer/src/i18n/translate/ja-jp.json | 15 ++ src/renderer/src/i18n/translate/pt-pt.json | 21 +- src/renderer/src/i18n/translate/ru-ru.json | 15 ++ .../src/pages/settings/SettingsPage.tsx | 20 +- .../WebSearchSettings/BasicSettings.tsx | 121 +++++++++- .../WebSearchGeneralSettings.tsx | 21 ++ .../WebSearchProviderSetting.tsx | 89 +++++-- .../WebSearchProviderSettings.tsx | 26 ++ .../settings/WebSearchSettings/index.tsx | 227 ++++++++++++++---- 22 files changed, 603 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/assets/images/search/baidu.svg create mode 100644 src/renderer/src/assets/images/search/bing.svg create mode 100644 src/renderer/src/assets/images/search/google.svg create mode 100644 src/renderer/src/pages/settings/WebSearchSettings/WebSearchGeneralSettings.tsx create mode 100644 src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSettings.tsx diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a4e0fe5c53..08bfbac6f8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -858,8 +858,8 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ) // search window - ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string) => { - await searchService.openSearchWindow(uid) + ipcMain.handle(IpcChannel.SearchWindow_Open, async (_, uid: string, show?: boolean) => { + await searchService.openSearchWindow(uid, show) }) ipcMain.handle(IpcChannel.SearchWindow_Close, async (_, uid: string) => { await searchService.closeSearchWindow(uid) diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 8a4e42099a..6c69f80889 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -14,38 +14,36 @@ export class SearchService { return SearchService.instance } - constructor() { - // Initialize the service - } - - private async createNewSearchWindow(uid: string): Promise { + private async createNewSearchWindow(uid: string, show: boolean = false): Promise { const newWindow = new BrowserWindow({ - width: 800, - height: 600, - show: false, + width: 1280, + height: 768, + show, webPreferences: { nodeIntegration: true, contextIsolation: false, devTools: is.dev } }) - newWindow.webContents.session.webRequest.onBeforeSendHeaders({ urls: ['*://*/*'] }, (details, callback) => { - const headers = { - ...details.requestHeaders, - 'User-Agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' - } - callback({ requestHeaders: headers }) - }) + this.searchWindows[uid] = newWindow - newWindow.on('closed', () => { - delete this.searchWindows[uid] - }) + newWindow.on('closed', () => delete this.searchWindows[uid]) + + newWindow.webContents.userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36' + return newWindow } - public async openSearchWindow(uid: string): Promise { - await this.createNewSearchWindow(uid) + public async openSearchWindow(uid: string, show: boolean = false): Promise { + const existingWindow = this.searchWindows[uid] + + if (existingWindow) { + show && existingWindow.show() + return + } + + await this.createNewSearchWindow(uid, show) } public async closeSearchWindow(uid: string): Promise { diff --git a/src/preload/index.ts b/src/preload/index.ts index d393d4a6e2..424253f8e3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -442,7 +442,7 @@ const api = { ipcRenderer.invoke(IpcChannel.Nutstore_GetDirectoryContents, token, path) }, searchService: { - openSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid), + openSearchWindow: (uid: string, show?: boolean) => ipcRenderer.invoke(IpcChannel.SearchWindow_Open, uid, show), closeSearchWindow: (uid: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_Close, uid), openUrlInSearchWindow: (uid: string, url: string) => ipcRenderer.invoke(IpcChannel.SearchWindow_OpenUrl, uid, url) }, diff --git a/src/renderer/src/assets/images/search/baidu.svg b/src/renderer/src/assets/images/search/baidu.svg new file mode 100644 index 0000000000..ead7f89822 --- /dev/null +++ b/src/renderer/src/assets/images/search/baidu.svg @@ -0,0 +1 @@ +Baidu \ No newline at end of file diff --git a/src/renderer/src/assets/images/search/bing.svg b/src/renderer/src/assets/images/search/bing.svg new file mode 100644 index 0000000000..b411a4f068 --- /dev/null +++ b/src/renderer/src/assets/images/search/bing.svg @@ -0,0 +1 @@ +Bing \ No newline at end of file diff --git a/src/renderer/src/assets/images/search/google.svg b/src/renderer/src/assets/images/search/google.svg new file mode 100644 index 0000000000..e8e0f867bd --- /dev/null +++ b/src/renderer/src/assets/images/search/google.svg @@ -0,0 +1 @@ +Google \ No newline at end of file diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 63d77e03bf..9528b4cd6b 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -4756,6 +4756,12 @@ }, "title": "Other Settings", "websearch": { + "api_key_required": { + "content": "{{provider}} requires an API key to work. Would you like to configure it now?", + "ok": "Configure", + "title": "API Key Required" + }, + "api_providers": "API Providers", "apikey": "API key", "blacklist": "Blacklist", "blacklist_description": "Results from the following websites will not appear in search results", @@ -4797,7 +4803,15 @@ }, "content_limit": "Content length limit", "content_limit_tooltip": "Limit the content length of the search results; content that exceeds the limit will be truncated.", + "default_provider": "Default Provider", "free": "Free", + "is_default": "Default", + "local_provider": { + "hint": "Log in to the website to get better search results and personalize your search settings.", + "open_settings": "Open {{provider}} Settings", + "settings": "Local Search Settings" + }, + "local_providers": "Local Providers", "no_provider_selected": "Please select a search service provider before checking.", "overwrite": "Override search service", "overwrite_tooltip": "Force use search service instead of LLM", @@ -4808,6 +4822,7 @@ "search_provider": "Search service provider", "search_provider_placeholder": "Choose a search service provider.", "search_with_time": "Search with dates included", + "set_as_default": "Set as Default", "subscribe": "Blacklist Subscription", "subscribe_add": "Add Subscription", "subscribe_add_failed": "Failed to add feed source", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b3dbc9e365..524f32c338 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4756,6 +4756,12 @@ }, "title": "其他设置", "websearch": { + "api_key_required": { + "content": "{{provider}} 需要 API 密钥才能使用。是否现在去配置?", + "ok": "去配置", + "title": "需要 API 密钥" + }, + "api_providers": "API 服务商", "apikey": "API 密钥", "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", @@ -4797,7 +4803,15 @@ }, "content_limit": "内容长度限制", "content_limit_tooltip": "限制搜索结果的内容长度, 超过限制的内容将被截断", + "default_provider": "默认搜索引擎", "free": "免费", + "is_default": "默认搜索", + "local_provider": { + "hint": "登录网站可以获得更好的搜索结果,也可以对搜索进行个性化设置。", + "open_settings": "打开 {{provider}} 设置", + "settings": "本地搜索设置" + }, + "local_providers": "本地搜索", "no_provider_selected": "请选择搜索服务商后再检测", "overwrite": "覆盖服务商搜索", "overwrite_tooltip": "强制使用搜索服务商而不是大语言模型进行搜索", @@ -4808,6 +4822,7 @@ "search_provider": "搜索服务商", "search_provider_placeholder": "选择一个搜索服务商", "search_with_time": "搜索包含日期", + "set_as_default": "设为默认", "subscribe": "黑名单订阅", "subscribe_add": "添加订阅", "subscribe_add_failed": "订阅源添加失败", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a2c26fa399..fe30018ac5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -4756,6 +4756,12 @@ }, "title": "其他設定", "websearch": { + "api_key_required": { + "content": "{{provider}} 需要 API 金鑰才能運作。您現在要設定嗎?", + "ok": "設定", + "title": "需要 API 金鑰" + }, + "api_providers": "API 服務商", "apikey": "API 金鑰", "blacklist": "黑名單", "blacklist_description": "以下網站不會出現在搜尋結果中", @@ -4797,7 +4803,15 @@ }, "content_limit": "內容長度限制", "content_limit_tooltip": "限制搜尋結果的內容長度;超過限制的內容將被截斷。", + "default_provider": "預設搜尋引擎", "free": "免費", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "登入網站以獲得更佳搜尋結果並個人化您的搜尋設定。", + "open_settings": "開啟 {{provider}} 設定", + "settings": "本地搜尋設定" + }, + "local_providers": "本地搜尋", "no_provider_selected": "請選擇搜尋供應商後再檢查", "overwrite": "覆蓋搜尋服務", "overwrite_tooltip": "強制使用搜尋服務而不是 LLM", @@ -4808,6 +4822,7 @@ "search_provider": "搜尋供應商", "search_provider_placeholder": "選擇一個搜尋供應商", "search_with_time": "搜尋包含日期", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "黑名單訂閱", "subscribe_add": "新增訂閱", "subscribe_add_failed": "訂閱來源新增失敗", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index c13e174b06..e77b9dede1 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -4756,6 +4756,12 @@ }, "title": "Weitere Einstellungen", "websearch": { + "api_key_required": { + "content": "{{provider}} erfordert einen API-Schlüssel, um zu funktionieren. Möchten Sie ihn jetzt konfigurieren?", + "ok": "Konfigurieren", + "title": "API-Schlüssel erforderlich" + }, + "api_providers": "API-Anbieter", "apikey": "API-Schlüssel", "blacklist": "Schwarze Liste", "blacklist_description": "Folgende Websites werden nicht in Suchergebnissen angezeigt", @@ -4797,7 +4803,15 @@ }, "content_limit": "Inhaltslängenbegrenzung", "content_limit_tooltip": "Begrenzen Sie die Länge der Suchergebnisse, überschreitende Inhalte werden abgeschnitten", + "default_provider": "Standardanbieter", "free": "Kostenlos", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Melden Sie sich auf der Website an, um bessere Suchergebnisse zu erhalten und Ihre Sucheinstellungen zu personalisieren.", + "open_settings": "{{provider}}-Einstellungen öffnen", + "settings": "Lokale Sucheinstellungen" + }, + "local_providers": "Lokale Anbieter", "no_provider_selected": "Wählen Sie einen Suchanbieter aus, bevor Sie suchen", "overwrite": "Suchanbieter statt LLM für Suche erzwingen", "overwrite_tooltip": "Suchanbieter statt LLM für Suche erzwingen", @@ -4808,6 +4822,7 @@ "search_provider": "Suchanbieter", "search_provider_placeholder": "Einen Suchanbieter auswählen", "search_with_time": "Suche mit Datum", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Schwarze Liste-Abonnement", "subscribe_add": "Abonnement hinzufügen", "subscribe_add_failed": "Abonnement-Quelle hinzufügen fehlgeschlagen", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 8746eed716..1593099707 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -4756,6 +4756,12 @@ }, "title": "Ρυθμίσεις Εργαλείων", "websearch": { + "api_key_required": { + "content": "Ο {{provider}} απαιτεί κλειδί API για να λειτουργήσει. Θα θέλατε να το διαμορφώσετε τώρα;", + "ok": "Ρυθμίστε", + "title": "Απαιτείται κλειδί API" + }, + "api_providers": "Πάροχοι API", "apikey": "Κλειδί API", "blacklist": "Μαύρη Λίστα", "blacklist_description": "Τα αποτελέσματα από τους παρακάτω ιστότοπους δεν θα εμφανίζονται στα αποτελέσματα αναζήτησης", @@ -4797,7 +4803,15 @@ }, "content_limit": "Όριο μήκους περιεχομένου", "content_limit_tooltip": "Περιορίζει το μήκος του περιεχομένου των αποτελεσμάτων αναζήτησης, το περιεχόμενο πέραν του ορίου θα περικοπεί", + "default_provider": "Προεπιλεγμένος Πάροχος", "free": "Δωρεάν", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Συνδεθείτε στην ιστοσελίδα για να λάβετε καλύτερα αποτελέσματα αναζήτησης και να εξατομικεύσετε τις ρυθμίσεις αναζήτησής σας.", + "open_settings": "Άνοιγμα Ρυθμίσεων {{provider}}", + "settings": "Ρυθμίσεις τοπικής αναζήτησης" + }, + "local_providers": "Τοπικοί Πάροχοι", "no_provider_selected": "Παρακαλώ επιλέξτε πάροχο αναζήτησης πριν τον έλεγχο", "overwrite": "Αντικατάσταση αναζήτησης παρόχου", "overwrite_tooltip": "Εξαναγκάζει τη χρήση του παρόχου αναζήτησης αντί για μοντέλο μεγάλης γλώσσας για αναζήτηση", @@ -4808,6 +4822,7 @@ "search_provider": "Πάροχος αναζήτησης", "search_provider_placeholder": "Επιλέξτε έναν πάροχο αναζήτησης", "search_with_time": "Αναζήτηση με ημερομηνία", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Εγγραφή σε μαύρη λίστα", "subscribe_add": "Προσθήκη εγγραφής", "subscribe_add_failed": "Η προσθήκη της ροής συνδρομής απέτυχε", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index df7743694e..56f06b1b53 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -4756,6 +4756,12 @@ }, "title": "Configuración de Herramientas", "websearch": { + "api_key_required": { + "content": "{{provider}} requiere una clave de API para funcionar. ¿Te gustaría configurarla ahora?", + "ok": "Configurar", + "title": "Se requiere clave de API" + }, + "api_providers": "Proveedores de API", "apikey": "Clave API", "blacklist": "Lista negra", "blacklist_description": "Los resultados de los siguientes sitios web no aparecerán en los resultados de búsqueda", @@ -4797,7 +4803,15 @@ }, "content_limit": "Límite de longitud del contenido", "content_limit_tooltip": "Limita la longitud del contenido en los resultados de búsqueda; el contenido que exceda el límite será truncado", + "default_provider": "Proveedor Predeterminado", "free": "Gratis", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Inicia sesión en el sitio web para obtener mejores resultados de búsqueda y personalizar tu configuración de búsqueda.", + "open_settings": "Abrir configuración de {{provider}}", + "settings": "Configuración de búsqueda local" + }, + "local_providers": "Proveedores locales", "no_provider_selected": "Seleccione un proveedor de búsqueda antes de comprobar", "overwrite": "Sobrescribir búsqueda del proveedor", "overwrite_tooltip": "Forzar el uso del proveedor de búsqueda en lugar del modelo de lenguaje grande", @@ -4808,6 +4822,7 @@ "search_provider": "Proveedor de búsqueda", "search_provider_placeholder": "Seleccione un proveedor de búsqueda", "search_with_time": "Buscar con fecha", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Suscripción a lista negra", "subscribe_add": "Añadir suscripción", "subscribe_add_failed": "Error al agregar la fuente de suscripción", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 990c94a3c1..4e8f2ac8e6 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -4756,6 +4756,12 @@ }, "title": "Paramètres des outils", "websearch": { + "api_key_required": { + "content": "{{provider}} nécessite une clé API pour fonctionner. Souhaitez-vous la configurer maintenant ?", + "ok": "Configurer", + "title": "Clé API requise" + }, + "api_providers": "Fournisseurs d'API", "apikey": "Clé API", "blacklist": "Liste noire", "blacklist_description": "Les résultats provenant des sites suivants n'apparaîtront pas dans les résultats de recherche", @@ -4797,7 +4803,15 @@ }, "content_limit": "Limite de longueur du contenu", "content_limit_tooltip": "Limiter la longueur du contenu des résultats de recherche ; le contenu dépassant cette limite sera tronqué", + "default_provider": "Fournisseur par défaut", "free": "Gratuit", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Connectez-vous au site Web pour obtenir de meilleurs résultats de recherche et personnaliser vos paramètres de recherche.", + "open_settings": "Ouvrir les paramètres de {{provider}}", + "settings": "Paramètres de recherche locale" + }, + "local_providers": "Fournisseurs locaux", "no_provider_selected": "Veuillez sélectionner un fournisseur de recherche avant de vérifier", "overwrite": "Remplacer la recherche du fournisseur", "overwrite_tooltip": "Forcer l'utilisation du fournisseur de recherche au lieu du grand modèle linguistique", @@ -4808,6 +4822,7 @@ "search_provider": "Fournisseur de recherche", "search_provider_placeholder": "Sélectionnez un fournisseur de recherche", "search_with_time": "Rechercher avec date", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Abonnement à la liste noire", "subscribe_add": "Ajouter un abonnement", "subscribe_add_failed": "Échec de l'ajout de la source d'abonnement", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index d36fddc63c..58ee184061 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -4756,6 +4756,12 @@ }, "title": "その他の設定", "websearch": { + "api_key_required": { + "content": "{{provider}}はAPIキーが必要です。今すぐ設定しますか?", + "ok": "設定", + "title": "APIキーが必要" + }, + "api_providers": "APIプロバイダー", "apikey": "APIキー", "blacklist": "ブラックリスト", "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", @@ -4797,7 +4803,15 @@ }, "content_limit": "コンテンツ制限", "content_limit_tooltip": "検索結果のコンテンツの長さを制限します。制限を超えるコンテンツは切り捨てられます。", + "default_provider": "デフォルトプロバイダー", "free": "無料", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "ウェブサイトにログインして、より良い検索結果を得て、検索設定をパーソナライズしてください。", + "open_settings": "{{provider}}設定を開く", + "settings": "ローカル検索設定" + }, + "local_providers": "地元のプロバイダー", "no_provider_selected": "検索サービスプロバイダーを選択してから再確認してください。", "overwrite": "検索サービスを上書き", "overwrite_tooltip": "LLMの代わりに検索サービスを強制的に使用する", @@ -4808,6 +4822,7 @@ "search_provider": "検索サービスプロバイダー", "search_provider_placeholder": "検索サービスプロバイダーを選択する", "search_with_time": "日付を含む検索", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "ブラックリスト購読", "subscribe_add": "購読を追加", "subscribe_add_failed": "購読ソースの追加に失敗しました", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 65783166cb..553795f6b3 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -40,7 +40,7 @@ "error": { "description": "O Git Bash é necessário para executar agentes no Windows. O agente não pode funcionar sem ele. Por favor, instale o Git para Windows a partir de", "recheck": "Reverificar a Instalação do Git Bash", - "required": "[to be translated]:Git Bash path is required on Windows", + "required": "O caminho do Git Bash é necessário no Windows", "title": "Git Bash Necessário" }, "found": { @@ -53,7 +53,7 @@ "invalidPath": "O arquivo selecionado não é um executável válido do Git Bash (bash.exe).", "title": "Selecionar executável do Git Bash" }, - "placeholder": "[to be translated]:Select bash.exe path", + "placeholder": "Selecione o caminho do bash.exe", "success": "Git Bash detectado com sucesso!", "tooltip": "O Git Bash é necessário para executar agentes no Windows. Instale-o a partir de git-scm.com, caso não esteja disponível." }, @@ -2198,7 +2198,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}}", + "crossPlatformRestoreWarning": "Configuração multiplataforma restaurada, mas o diretório de notas está vazio. Por favor, copie seus arquivos de nota para: {{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?", @@ -4756,6 +4756,12 @@ }, "title": "Configurações de Ferramentas", "websearch": { + "api_key_required": { + "content": "{{provider}} requer uma chave de API para funcionar. Você gostaria de configurá-la agora?", + "ok": "Configurar", + "title": "Chave de API Necessária" + }, + "api_providers": "Provedores de API", "apikey": "Chave API", "blacklist": "Lista Negra", "blacklist_description": "Os resultados dos seguintes sites não aparecerão nos resultados de pesquisa", @@ -4797,7 +4803,15 @@ }, "content_limit": "Limite de comprimento do conteúdo", "content_limit_tooltip": "Limita o comprimento do conteúdo dos resultados de pesquisa; o conteúdo excedente será truncado", + "default_provider": "Provedor Padrão", "free": "Grátis", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Faça login no site para obter melhores resultados de pesquisa e personalizar suas configurações de busca.", + "open_settings": "Abrir Configurações do {{provider}}", + "settings": "Configurações de Pesquisa Local" + }, + "local_providers": "Fornecedores Locais", "no_provider_selected": "Por favor, selecione um provedor de pesquisa antes de verificar", "overwrite": "Substituir busca do provedor", "overwrite_tooltip": "Força o uso do provedor de pesquisa em vez do modelo de linguagem grande", @@ -4808,6 +4822,7 @@ "search_provider": "Provedor de pesquisa", "search_provider_placeholder": "Selecione um provedor de pesquisa", "search_with_time": "Pesquisar com data", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Assinatura de lista negra", "subscribe_add": "Adicionar assinatura", "subscribe_add_failed": "Falha ao adicionar a fonte de subscrição", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 2e245f9ff5..489e8b4695 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -4756,6 +4756,12 @@ }, "title": "Другие настройки", "websearch": { + "api_key_required": { + "content": "{{provider}} требует API-ключ для работы. Хотите настроить его сейчас?", + "ok": "Настроить", + "title": "Требуется ключ API" + }, + "api_providers": "Поставщики API", "apikey": "API ключ", "blacklist": "Черный список", "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", @@ -4797,7 +4803,15 @@ }, "content_limit": "Ограничение длины контента", "content_limit_tooltip": "Ограничить длину контента в результатах поиска; контент, превышающий лимит, будет усечен.", + "default_provider": "Поставщик по умолчанию", "free": "Бесплатно", + "is_default": "[to be translated]:Default", + "local_provider": { + "hint": "Войдите на сайт, чтобы получать более точные результаты поиска и настроить параметры поиска под себя.", + "open_settings": "Открыть настройки {{provider}}", + "settings": "Настройки локального поиска" + }, + "local_providers": "Местные поставщики", "no_provider_selected": "Пожалуйста, выберите поставщика поисковых услуг, затем проверьте.", "overwrite": "Переопределить поисковый сервис", "overwrite_tooltip": "Принудительно использовать поисковый сервис вместо LLM", @@ -4808,6 +4822,7 @@ "search_provider": "поиск сервисного провайдера", "search_provider_placeholder": "Выберите поставщика поисковых услуг", "search_with_time": "Поиск, содержащий дату", + "set_as_default": "[to be translated]:Set as Default", "subscribe": "Подписка на черный список", "subscribe_add": "Добавить подписку", "subscribe_add_failed": "Не удалось добавить источник подписки", diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index cb5d8df32a..0f7659ddac 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -1,4 +1,3 @@ -import { GlobalOutlined } from '@ant-design/icons' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { McpLogo } from '@renderer/components/Icons' import Scrollbar from '@renderer/components/Scrollbar' @@ -15,6 +14,7 @@ import { NotebookPen, Package, PictureInPicture2, + Search, Server, Settings2, TextCursorInput, @@ -88,19 +88,13 @@ const SettingsPage: FC = () => { - + {t('settings.mcp.title')} - - - - {t('notes.settings.title')} - - - + {t('settings.tool.websearch.title')} @@ -122,6 +116,12 @@ const SettingsPage: FC = () => { {t('settings.tool.preprocess.title')} + + + + {t('notes.settings.title')} + + @@ -159,7 +159,7 @@ const SettingsPage: FC = () => { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index 95c0749126..e4db2caf22 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -1,22 +1,138 @@ +import BaiduLogo from '@renderer/assets/images/search/baidu.svg' +import BingLogo from '@renderer/assets/images/search/bing.svg' +import BochaLogo from '@renderer/assets/images/search/bocha.webp' +import ExaLogo from '@renderer/assets/images/search/exa.png' +import GoogleLogo from '@renderer/assets/images/search/google.svg' +import SearxngLogo from '@renderer/assets/images/search/searxng.svg' +import TavilyLogo from '@renderer/assets/images/search/tavily.png' +import ZhipuLogo from '@renderer/assets/images/search/zhipu.png' +import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' -import { useWebSearchSettings } from '@renderer/hooks/useWebSearchProviders' +import { + useDefaultWebSearchProvider, + useWebSearchProviders, + useWebSearchSettings +} from '@renderer/hooks/useWebSearchProviders' import { useAppDispatch } from '@renderer/store' import { setMaxResult, setSearchWithTime } from '@renderer/store/websearch' +import type { WebSearchProvider, WebSearchProviderId } from '@renderer/types' +import { hasObjectKey } from '@renderer/utils' import { Slider, Switch, Tooltip } from 'antd' -import { t } from 'i18next' import { Info } from 'lucide-react' import type { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' +// Provider logos map +const getProviderLogo = (providerId: WebSearchProviderId): string | undefined => { + switch (providerId) { + case 'zhipu': + return ZhipuLogo + case 'tavily': + return TavilyLogo + case 'searxng': + return SearxngLogo + case 'exa': + case 'exa-mcp': + return ExaLogo + case 'bocha': + return BochaLogo + case 'local-google': + return GoogleLogo + case 'local-bing': + return BingLogo + case 'local-baidu': + return BaiduLogo + default: + return undefined + } +} + const BasicSettings: FC = () => { const { theme } = useTheme() + const { t } = useTranslation() + const { providers } = useWebSearchProviders() + const { provider: defaultProvider, setDefaultProvider } = useDefaultWebSearchProvider() const { searchWithTime, maxResults, compressionConfig } = useWebSearchSettings() + const navigate = useNavigate() const dispatch = useAppDispatch() + const updateSelectedWebSearchProvider = (providerId: string) => { + const provider = providers.find((p) => p.id === providerId) + if (provider) { + // Check if provider needs API key but doesn't have one + const needsApiKey = hasObjectKey(provider, 'apiKey') + const hasApiKey = provider.apiKey && provider.apiKey.trim() !== '' + + if (needsApiKey && !hasApiKey) { + // Don't allow selection, show modal to configure + window.modal.confirm({ + title: t('settings.tool.websearch.api_key_required.title'), + content: t('settings.tool.websearch.api_key_required.content', { provider: provider.name }), + okText: t('settings.tool.websearch.api_key_required.ok'), + cancelText: t('common.cancel'), + centered: true, + onOk: () => { + navigate(`/settings/websearch/provider/${provider.id}`) + } + }) + return + } + + setDefaultProvider(provider as WebSearchProvider) + } + } + + // Sort providers: API providers first, then local providers + const sortedProviders = [...providers].sort((a, b) => { + const aIsLocal = a.id.startsWith('local') + const bIsLocal = b.id.startsWith('local') + if (aIsLocal && !bIsLocal) return 1 + if (!aIsLocal && bIsLocal) return -1 + return 0 + }) + + const renderProviderLabel = (provider: WebSearchProvider) => { + const logo = getProviderLogo(provider.id) + const needsApiKey = hasObjectKey(provider, 'apiKey') + + return ( +
+ {logo ? ( + {provider.name} + ) : ( +
+ )} + + {provider.name} + {needsApiKey && ` (${t('settings.tool.websearch.apikey')})`} + +
+ ) + } + return ( <> + + {t('settings.tool.websearch.search_provider')} + + + {t('settings.tool.websearch.default_provider')} + updateSelectedWebSearchProvider(value)} + placeholder={t('settings.tool.websearch.search_provider_placeholder')} + options={sortedProviders.map((p) => ({ + value: p.id, + label: renderProviderLabel(p) + }))} + /> + + {t('settings.general.title')} @@ -48,4 +164,5 @@ const BasicSettings: FC = () => { ) } + export default BasicSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchGeneralSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchGeneralSettings.tsx new file mode 100644 index 0000000000..0af3fb4332 --- /dev/null +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchGeneralSettings.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import type { FC } from 'react' + +import { SettingContainer } from '..' +import BasicSettings from './BasicSettings' +import BlacklistSettings from './BlacklistSettings' +import CompressionSettings from './CompressionSettings' + +const WebSearchGeneralSettings: FC = () => { + const { theme } = useTheme() + + return ( + + + + + + ) +} + +export default WebSearchGeneralSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx index a92b8646c1..823f6fac81 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,7 +1,10 @@ import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' import { loggerService } from '@logger' +import BaiduLogo from '@renderer/assets/images/search/baidu.svg' +import BingLogo from '@renderer/assets/images/search/bing.svg' import BochaLogo from '@renderer/assets/images/search/bocha.webp' import ExaLogo from '@renderer/assets/images/search/exa.png' +import GoogleLogo from '@renderer/assets/images/search/google.svg' import SearxngLogo from '@renderer/assets/images/search/searxng.svg' import TavilyLogo from '@renderer/assets/images/search/tavily.png' import ZhipuLogo from '@renderer/assets/images/search/zhipu.png' @@ -9,7 +12,7 @@ import { HStack } from '@renderer/components/Layout' import ApiKeyListPopup from '@renderer/components/Popups/ApiKeyListPopup/popup' import { WEB_SEARCH_PROVIDER_CONFIG } from '@renderer/config/webSearchProviders' import { useTimer } from '@renderer/hooks/useTimer' -import { useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' +import { useDefaultWebSearchProvider, useWebSearchProvider } from '@renderer/hooks/useWebSearchProviders' import WebSearchService from '@renderer/services/WebSearchService' import type { WebSearchProviderId } from '@renderer/types' import { formatApiKeys, hasObjectKey } from '@renderer/utils' @@ -30,6 +33,7 @@ interface Props { const WebSearchProviderSetting: FC = ({ providerId }) => { const { provider, updateProvider } = useWebSearchProvider(providerId) + const { provider: defaultProvider, setDefaultProvider } = useDefaultWebSearchProvider() const { t } = useTranslation() const [apiKey, setApiKey] = useState(provider.apiKey || '') const [apiHost, setApiHost] = useState(provider.apiHost || '') @@ -149,26 +153,79 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { return ExaLogo case 'bocha': return BochaLogo + case 'local-google': + return GoogleLogo + case 'local-bing': + return BingLogo + case 'local-baidu': + return BaiduLogo default: return undefined } } + const isLocalProvider = provider.id.startsWith('local') + + const openLocalProviderSettings = async () => { + if (officialWebsite) { + await window.api.searchService.openSearchWindow(provider.id, true) + await window.api.searchService.openUrlInSearchWindow(provider.id, officialWebsite) + } + } + + const providerLogo = getWebSearchProviderLogo(provider.id) + + // Check if this provider is already the default + const isDefault = defaultProvider?.id === provider.id + + // Check if provider needs API key but doesn't have one configured + const needsApiKey = hasObjectKey(provider, 'apiKey') + const hasApiKey = provider.apiKey && provider.apiKey.trim() !== '' + const canSetAsDefault = !isDefault && (!needsApiKey || hasApiKey) + + const handleSetAsDefault = () => { + if (canSetAsDefault) { + setDefaultProvider(provider) + } + } + return ( <> - - - {provider.name} - {officialWebsite && webSearchProviderConfig?.websites && ( - - - - )} + + + {providerLogo ? ( + {provider.name} + ) : ( +
+ )} + {provider.name} + {officialWebsite && webSearchProviderConfig?.websites && ( + + + + )} + + - {hasObjectKey(provider, 'apiKey') && ( + {isLocalProvider && ( + <> + + {t('settings.tool.websearch.local_provider.settings')} + + + + {t('settings.tool.websearch.local_provider.hint')} + + + )} + {!isLocalProvider && hasObjectKey(provider, 'apiKey') && ( <> = ({ providerId }) => { )} - {hasObjectKey(provider, 'apiHost') && ( + {!isLocalProvider && hasObjectKey(provider, 'apiHost') && ( <> {t('settings.provider.api_host')} @@ -234,10 +291,11 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { )} - {hasObjectKey(provider, 'basicAuthUsername') && ( + {!isLocalProvider && hasObjectKey(provider, 'basicAuthUsername') && ( <> - + {t('settings.provider.basic_auth.label')} @@ -291,10 +349,5 @@ const ProviderName = styled.span` font-size: 14px; font-weight: 500; ` -const ProviderLogo = styled.img` - width: 20px; - height: 20px; - object-fit: contain; -` export default WebSearchProviderSetting diff --git a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSettings.tsx new file mode 100644 index 0000000000..884c43e6b4 --- /dev/null +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSettings.tsx @@ -0,0 +1,26 @@ +import { useTheme } from '@renderer/context/ThemeProvider' +import type { WebSearchProviderId } from '@renderer/types' +import type { FC } from 'react' +import { useParams } from 'react-router' + +import { SettingContainer, SettingGroup } from '..' +import WebSearchProviderSetting from './WebSearchProviderSetting' + +const WebSearchProviderSettings: FC = () => { + const { providerId } = useParams<{ providerId: string }>() + const { theme } = useTheme() + + if (!providerId) { + return null + } + + return ( + + + + + + ) +} + +export default WebSearchProviderSettings diff --git a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx index 7867cb57e0..a21de63764 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/index.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/index.tsx @@ -1,66 +1,195 @@ -import Selector from '@renderer/components/Selector' -import { useTheme } from '@renderer/context/ThemeProvider' +import BaiduLogo from '@renderer/assets/images/search/baidu.svg' +import BingLogo from '@renderer/assets/images/search/bing.svg' +import BochaLogo from '@renderer/assets/images/search/bocha.webp' +import ExaLogo from '@renderer/assets/images/search/exa.png' +import GoogleLogo from '@renderer/assets/images/search/google.svg' +import SearxngLogo from '@renderer/assets/images/search/searxng.svg' +import TavilyLogo from '@renderer/assets/images/search/tavily.png' +import ZhipuLogo from '@renderer/assets/images/search/zhipu.png' +import DividerWithText from '@renderer/components/DividerWithText' +import ListItem from '@renderer/components/ListItem' +import Scrollbar from '@renderer/components/Scrollbar' import { useDefaultWebSearchProvider, useWebSearchProviders } from '@renderer/hooks/useWebSearchProviders' -import type { WebSearchProvider } from '@renderer/types' +import type { WebSearchProviderId } from '@renderer/types' import { hasObjectKey } from '@renderer/utils' +import { Flex, Tag } from 'antd' +import { Search } from 'lucide-react' import type { FC } from 'react' -import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' +import styled from 'styled-components' -import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' -import BasicSettings from './BasicSettings' -import BlacklistSettings from './BlacklistSettings' -import CompressionSettings from './CompressionSettings' -import WebSearchProviderSetting from './WebSearchProviderSetting' +import WebSearchGeneralSettings from './WebSearchGeneralSettings' +import WebSearchProviderSettings from './WebSearchProviderSettings' const WebSearchSettings: FC = () => { - const { providers } = useWebSearchProviders() - const { provider: defaultProvider, setDefaultProvider } = useDefaultWebSearchProvider() const { t } = useTranslation() - const [selectedProvider, setSelectedProvider] = useState(defaultProvider) - const { theme: themeMode } = useTheme() + const { providers } = useWebSearchProviders() + const { provider: defaultProvider } = useDefaultWebSearchProvider() + const navigate = useNavigate() + const location = useLocation() - const isLocalProvider = selectedProvider?.id.startsWith('local') + // Get the currently active view + const getActiveView = () => { + const path = location.pathname - function updateSelectedWebSearchProvider(providerId: string) { - const provider = providers.find((p) => p.id === providerId) - if (!provider) { - return + if (path === '/settings/websearch/general' || path === '/settings/websearch') { + return 'general' + } + + // Check if it's a provider page + for (const provider of providers) { + if (path === `/settings/websearch/provider/${provider.id}`) { + return provider.id + } + } + + return 'general' + } + + const activeView = getActiveView() + + // Filter providers that have API settings (apiKey or apiHost) + const apiProviders = providers.filter((p) => hasObjectKey(p, 'apiKey') || hasObjectKey(p, 'apiHost')) + const localProviders = providers.filter((p) => p.id.startsWith('local')) + + // Provider logos map + const getProviderLogo = (providerId: WebSearchProviderId): string | undefined => { + switch (providerId) { + case 'zhipu': + return ZhipuLogo + case 'tavily': + return TavilyLogo + case 'searxng': + return SearxngLogo + case 'exa': + case 'exa-mcp': + return ExaLogo + case 'bocha': + return BochaLogo + case 'local-google': + return GoogleLogo + case 'local-bing': + return BingLogo + case 'local-baidu': + return BaiduLogo + default: + return undefined } - setSelectedProvider(provider) - setDefaultProvider(provider) } return ( - - - {t('settings.tool.websearch.title')} - - - {t('settings.tool.websearch.search_provider')} -
- updateSelectedWebSearchProvider(value)} - placeholder={t('settings.tool.websearch.search_provider_placeholder')} - options={providers.map((p) => ({ - value: p.id, - label: `${p.name} (${hasObjectKey(p, 'apiKey') ? t('settings.tool.websearch.apikey') : t('settings.tool.websearch.free')})` - }))} - /> -
-
-
- {!isLocalProvider && ( - - {selectedProvider && } - - )} - - - -
+ + + + navigate('/settings/websearch/general')} + icon={} + titleStyle={{ fontWeight: 500 }} + /> + + {apiProviders.map((provider) => { + const logo = getProviderLogo(provider.id) + const isDefault = defaultProvider?.id === provider.id + return ( + navigate(`/settings/websearch/provider/${provider.id}`)} + icon={ + logo ? ( + {provider.name} + ) : ( +
+ ) + } + titleStyle={{ fontWeight: 500 }} + rightContent={ + isDefault ? ( + + {t('common.default')} + + ) : undefined + } + /> + ) + })} + {localProviders.length > 0 && ( + <> + + {localProviders.map((provider) => { + const logo = getProviderLogo(provider.id) + const isDefault = defaultProvider?.id === provider.id + return ( + navigate(`/settings/websearch/provider/${provider.id}`)} + icon={ + logo ? ( + {provider.name} + ) : ( +
+ ) + } + titleStyle={{ fontWeight: 500 }} + rightContent={ + isDefault ? ( + + {t('common.default')} + + ) : undefined + } + /> + ) + })} + + )} + + + + } /> + } /> + } /> + + + + ) } + +const Container = styled(Flex)` + flex: 1; +` + +const MainContainer = styled.div` + display: flex; + flex: 1; + flex-direction: row; + width: 100%; + height: calc(100vh - var(--navbar-height) - 6px); + overflow: hidden; +` + +const MenuList = styled(Scrollbar)` + display: flex; + flex-direction: column; + gap: 5px; + width: var(--settings-width); + padding: 12px; + padding-bottom: 48px; + border-right: 0.5px solid var(--color-border); + height: calc(100vh - var(--navbar-height)); +` + +const RightContainer = styled.div` + flex: 1; + position: relative; + display: flex; +` + export default WebSearchSettings From 265934be5a0e1a6a7f43a96b858c5224996f1905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=A2=E5=A5=8B=E7=8C=AB?= Date: Tue, 23 Dec 2025 14:57:03 +0800 Subject: [PATCH 13/18] refactor(notes): move notes settings to popup in NotesPage (#12075) * refactor(notes): move notes settings to popup in NotesPage - Move NotesSettings.tsx from settings directory to notes directory - Add "More Settings" menu item to notes dropdown menu - Show settings in GeneralPopup when clicking "More Settings" - Remove notes settings entry from SettingsPage sidebar and routes * fix(notes): adjust margin in NotesSidebar component for improved layout - Update margin-bottom from 20px to 12px in the NotesSidebar component to enhance visual spacing. * refactor(notes): simplify styles object in HeaderNavbar component - Consolidate styles object for body padding in HeaderNavbar to improve readability and maintainability. --------- Co-authored-by: Claude --- src/renderer/src/pages/notes/HeaderNavbar.tsx | 16 +++++++++++++++- src/renderer/src/pages/notes/MenuConfig.tsx | 14 +++++++++++++- .../{settings => notes}/NotesSettings.tsx | 19 +++++++++---------- src/renderer/src/pages/notes/NotesSidebar.tsx | 2 +- .../src/pages/settings/SettingsPage.tsx | 3 --- 5 files changed, 38 insertions(+), 16 deletions(-) rename src/renderer/src/pages/{settings => notes}/NotesSettings.tsx (98%) diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index a2d6e66039..92c66ba98d 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar' import { HStack } from '@renderer/components/Layout' +import GeneralPopup from '@renderer/components/Popups/GeneralPopup' import { useActiveNode } from '@renderer/hooks/useNotesQuery' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' import { useShowWorkspace } from '@renderer/hooks/useShowWorkspace' @@ -12,6 +13,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import styled from 'styled-components' import { menuItems } from './MenuConfig' +import NotesSettings from './NotesSettings' const logger = loggerService.withContext('HeaderNavbar') @@ -51,6 +53,16 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand } }, [getCurrentNoteContent]) + const handleShowSettings = useCallback(() => { + GeneralPopup.show({ + title: t('notes.settings.title'), + content: , + footer: null, + width: 600, + styles: { body: { padding: 0 } } + }) + }, []) + const handleBreadcrumbClick = useCallback( (item: { treePath: string; isFolder: boolean }) => { if (item.isFolder && onExpandPath) { @@ -130,6 +142,8 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand onClick: () => { if (item.copyAction) { handleCopyContent() + } else if (item.showSettingsPopup) { + handleShowSettings() } else if (item.action) { item.action(settings, updateSettings) } @@ -308,7 +322,7 @@ export const StarButton = styled.div` transition: all 0.2s ease-in-out; cursor: pointer; svg { - color: inherit; + color: var(--color-icon); } &:hover { diff --git a/src/renderer/src/pages/notes/MenuConfig.tsx b/src/renderer/src/pages/notes/MenuConfig.tsx index 0f8f2b0128..c157daa417 100644 --- a/src/renderer/src/pages/notes/MenuConfig.tsx +++ b/src/renderer/src/pages/notes/MenuConfig.tsx @@ -1,5 +1,5 @@ import type { NotesSettings } from '@renderer/store/note' -import { Copy, MonitorSpeaker, Type } from 'lucide-react' +import { Copy, MonitorSpeaker, Settings, Type } from 'lucide-react' import type { ReactNode } from 'react' export interface MenuItem { @@ -12,6 +12,7 @@ export interface MenuItem { isActive?: (settings: NotesSettings) => boolean component?: (settings: NotesSettings, updateSettings: (newSettings: Partial) => void) => ReactNode copyAction?: boolean + showSettingsPopup?: boolean } export const menuItems: MenuItem[] = [ @@ -86,5 +87,16 @@ export const menuItems: MenuItem[] = [ isActive: (settings) => settings.fontSize === 20 } ] + }, + { + key: 'divider-settings', + type: 'divider', + labelKey: '' + }, + { + key: 'more-settings', + labelKey: 'settings.moresetting.label', + icon: Settings, + showSettingsPopup: true } ] diff --git a/src/renderer/src/pages/settings/NotesSettings.tsx b/src/renderer/src/pages/notes/NotesSettings.tsx similarity index 98% rename from src/renderer/src/pages/settings/NotesSettings.tsx rename to src/renderer/src/pages/notes/NotesSettings.tsx index be36c0fe6e..114fd844ba 100644 --- a/src/renderer/src/pages/settings/NotesSettings.tsx +++ b/src/renderer/src/pages/notes/NotesSettings.tsx @@ -2,14 +2,6 @@ import { loggerService } from '@logger' import Selector from '@renderer/components/Selector' import { useTheme } from '@renderer/context/ThemeProvider' import { useNotesSettings } from '@renderer/hooks/useNotesSettings' -import type { EditorView } from '@renderer/types' -import { Button, Input, message, Slider, Switch } from 'antd' -import { FolderOpen } from 'lucide-react' -import type { FC } from 'react' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import styled from 'styled-components' - import { SettingContainer, SettingDivider, @@ -18,7 +10,14 @@ import { SettingRow, SettingRowTitle, SettingTitle -} from '.' +} from '@renderer/pages/settings' +import type { EditorView } from '@renderer/types' +import { Button, Input, message, Slider, Switch } from 'antd' +import { FolderOpen } from 'lucide-react' +import type { FC } from 'react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' const logger = loggerService.withContext('NotesSettings') @@ -92,7 +91,7 @@ const NotesSettings: FC = () => { const isPathChanged = tempPath !== notesPath return ( - + {t('notes.settings.data.title')} diff --git a/src/renderer/src/pages/notes/NotesSidebar.tsx b/src/renderer/src/pages/notes/NotesSidebar.tsx index 6ed144dd7e..097ffd0f46 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -412,7 +412,7 @@ const NotesSidebar: FC = ({ {!isShowStarred && !isShowSearch && ( -
+
{ } /> } /> } /> - } /> } /> From e093a18debcb4f124f01a1b7f4253eb21e190baf Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Tue, 23 Dec 2025 15:01:58 +0800 Subject: [PATCH 14/18] refactor(settings): update MCP logo opacity and remove unused notes settings - Adjust MCP logo opacity in MCPSettings and McpTool components for improved visual consistency. - Remove notes settings entry from SettingsPage to streamline the settings interface. --- src/renderer/src/components/Tab/TabContainer.tsx | 3 --- src/renderer/src/pages/settings/MCPSettings/McpTool.tsx | 2 +- src/renderer/src/pages/settings/MCPSettings/index.tsx | 2 +- src/renderer/src/pages/settings/SettingsPage.tsx | 6 ------ 4 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 88091c6831..5ac1353be8 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -39,7 +39,6 @@ import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' import styled from 'styled-components' -import { McpLogo } from '../Icons' import MinAppIcon from '../Icons/MinAppIcon' import MinAppTabsPool from '../MinApp/MinAppTabsPool' import WindowControls from '../WindowControls' @@ -99,8 +98,6 @@ const getTabIcon = ( return case 'knowledge': return - case 'mcp': - return case 'files': return case 'settings': diff --git a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx index e4f01b7475..8aa4faf6d8 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpTool.tsx @@ -137,7 +137,7 @@ const MCPToolsSection = ({ tools, server, onToggleTool, onToggleAutoApprove }: M { title: ( - + {t('settings.mcp.tools.enable')} ), diff --git a/src/renderer/src/pages/settings/MCPSettings/index.tsx b/src/renderer/src/pages/settings/MCPSettings/index.tsx index 4b958dde1c..5c11b7b261 100644 --- a/src/renderer/src/pages/settings/MCPSettings/index.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/index.tsx @@ -86,7 +86,7 @@ const MCPSettings: FC = () => { title={t('settings.mcp.servers', 'MCP Servers')} active={activeView === 'servers'} onClick={() => navigate('/settings/mcp/servers')} - icon={} + icon={} titleStyle={{ fontWeight: 500 }} /> diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index e4aba4b21f..f1b1186c43 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -114,12 +114,6 @@ const SettingsPage: FC = () => { {t('settings.tool.preprocess.title')} - - - - {t('notes.settings.title')} - - From 09e58d37560a6ffd61c819dacd6ae7ac2429a00f Mon Sep 17 00:00:00 2001 From: SuYao Date: Tue, 23 Dec 2025 20:08:53 +0800 Subject: [PATCH 15/18] fix: interleaved thinking support (#12084) * fix: update @ai-sdk/openai-compatible to version 1.0.28 and adjust related patches * fix: add sendReasoning option to OpenAICompatibleProviderOptions and update message conversion logic * fix: add interval thinking model support and related tests * fix: add sendReasoning option to OpenAICompatibleProviderOptions and update related logic * fix: remove MiniMax reasoning model support and update interval thinking model regex * chore: add comment * fix: rename interval thinking model references to interleaved thinking model --- ...nai-compatible-npm-1.0.27-06f74278cf.patch | 140 --------- ...nai-compatible-npm-1.0.28-5705188855.patch | 266 ++++++++++++++++++ package.json | 4 +- packages/ai-sdk-provider/package.json | 2 +- packages/aiCore/package.json | 2 +- src/renderer/src/aiCore/utils/options.ts | 11 +- .../config/models/__tests__/reasoning.test.ts | 103 +++++++ src/renderer/src/config/models/reasoning.ts | 17 ++ yarn.lock | 28 +- 9 files changed, 409 insertions(+), 164 deletions(-) delete mode 100644 .yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch create mode 100644 .yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch diff --git a/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch b/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch deleted file mode 100644 index 2a13c33a78..0000000000 --- a/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch +++ /dev/null @@ -1,140 +0,0 @@ -diff --git a/dist/index.js b/dist/index.js -index 73045a7d38faafdc7f7d2cd79d7ff0e2b031056b..8d948c9ac4ea4b474db9ef3c5491961e7fcf9a07 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -421,6 +421,17 @@ var OpenAICompatibleChatLanguageModel = class { - text: reasoning - }); - } -+ if (choice.message.images) { -+ for (const image of choice.message.images) { -+ const match1 = image.image_url.url.match(/^data:([^;]+)/) -+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); -+ content.push({ -+ type: 'file', -+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', -+ data: match2 ? match2[1] : image.image_url.url, -+ }); -+ } -+ } - if (choice.message.tool_calls != null) { - for (const toolCall of choice.message.tool_calls) { - content.push({ -@@ -598,6 +609,17 @@ var OpenAICompatibleChatLanguageModel = class { - delta: delta.content - }); - } -+ if (delta.images) { -+ for (const image of delta.images) { -+ const match1 = image.image_url.url.match(/^data:([^;]+)/) -+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); -+ controller.enqueue({ -+ type: 'file', -+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', -+ data: match2 ? match2[1] : image.image_url.url, -+ }); -+ } -+ } - if (delta.tool_calls != null) { - for (const toolCallDelta of delta.tool_calls) { - const index = toolCallDelta.index; -@@ -765,6 +787,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({ - arguments: import_v43.z.string() - }) - }) -+ ).nullish(), -+ images: import_v43.z.array( -+ import_v43.z.object({ -+ type: import_v43.z.literal('image_url'), -+ image_url: import_v43.z.object({ -+ url: import_v43.z.string(), -+ }) -+ }) - ).nullish() - }), - finish_reason: import_v43.z.string().nullish() -@@ -795,6 +825,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union( - arguments: import_v43.z.string().nullish() - }) - }) -+ ).nullish(), -+ images: import_v43.z.array( -+ import_v43.z.object({ -+ type: import_v43.z.literal('image_url'), -+ image_url: import_v43.z.object({ -+ url: import_v43.z.string(), -+ }) -+ }) - ).nullish() - }).nullish(), - finish_reason: import_v43.z.string().nullish() -diff --git a/dist/index.mjs b/dist/index.mjs -index 1c2b9560bbfbfe10cb01af080aeeed4ff59db29c..2c8ddc4fc9bfc5e7e06cfca105d197a08864c427 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -405,6 +405,17 @@ var OpenAICompatibleChatLanguageModel = class { - text: reasoning - }); - } -+ if (choice.message.images) { -+ for (const image of choice.message.images) { -+ const match1 = image.image_url.url.match(/^data:([^;]+)/) -+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); -+ content.push({ -+ type: 'file', -+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', -+ data: match2 ? match2[1] : image.image_url.url, -+ }); -+ } -+ } - if (choice.message.tool_calls != null) { - for (const toolCall of choice.message.tool_calls) { - content.push({ -@@ -582,6 +593,17 @@ var OpenAICompatibleChatLanguageModel = class { - delta: delta.content - }); - } -+ if (delta.images) { -+ for (const image of delta.images) { -+ const match1 = image.image_url.url.match(/^data:([^;]+)/) -+ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); -+ controller.enqueue({ -+ type: 'file', -+ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', -+ data: match2 ? match2[1] : image.image_url.url, -+ }); -+ } -+ } - if (delta.tool_calls != null) { - for (const toolCallDelta of delta.tool_calls) { - const index = toolCallDelta.index; -@@ -749,6 +771,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({ - arguments: z3.string() - }) - }) -+ ).nullish(), -+ images: z3.array( -+ z3.object({ -+ type: z3.literal('image_url'), -+ image_url: z3.object({ -+ url: z3.string(), -+ }) -+ }) - ).nullish() - }), - finish_reason: z3.string().nullish() -@@ -779,6 +809,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([ - arguments: z3.string().nullish() - }) - }) -+ ).nullish(), -+ images: z3.array( -+ z3.object({ -+ type: z3.literal('image_url'), -+ image_url: z3.object({ -+ url: z3.string(), -+ }) -+ }) - ).nullish() - }).nullish(), - finish_reason: z3.string().nullish() diff --git a/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch b/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch new file mode 100644 index 0000000000..c17729ef93 --- /dev/null +++ b/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch @@ -0,0 +1,266 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 48e2f6263c6ee4c75d7e5c28733e64f6ebe92200..00d0729c4a3cbf9a48e8e1e962c7e2b256b75eba 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -7,6 +7,7 @@ declare const openaiCompatibleProviderOptions: z.ZodObject<{ + user: z.ZodOptional; + reasoningEffort: z.ZodOptional; + textVerbosity: z.ZodOptional; ++ sendReasoning: z.ZodOptional; + }, z.core.$strip>; + type OpenAICompatibleProviderOptions = z.infer; + +diff --git a/dist/index.js b/dist/index.js +index da237bb35b7fa8e24b37cd861ee73dfc51cdfc72..b3060fbaf010e30b64df55302807828e5bfe0f9a 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -41,7 +41,7 @@ function getOpenAIMetadata(message) { + var _a, _b; + return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {}; + } +-function convertToOpenAICompatibleChatMessages(prompt) { ++function convertToOpenAICompatibleChatMessages({prompt, options}) { + const messages = []; + for (const { role, content, ...message } of prompt) { + const metadata = getOpenAIMetadata({ ...message }); +@@ -91,6 +91,7 @@ function convertToOpenAICompatibleChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ let reasoning_text = ""; + const toolCalls = []; + for (const part of content) { + const partMetadata = getOpenAIMetadata(part); +@@ -99,6 +100,12 @@ function convertToOpenAICompatibleChatMessages(prompt) { + text += part.text; + break; + } ++ case "reasoning": { ++ if (options.sendReasoning) { ++ reasoning_text += part.text; ++ } ++ break; ++ } + case "tool-call": { + toolCalls.push({ + id: part.toolCallId, +@@ -116,6 +123,7 @@ function convertToOpenAICompatibleChatMessages(prompt) { + messages.push({ + role: "assistant", + content: text, ++ reasoning_content: reasoning_text ?? undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0, + ...metadata + }); +@@ -200,7 +208,8 @@ var openaiCompatibleProviderOptions = import_v4.z.object({ + /** + * Controls the verbosity of the generated text. Defaults to `medium`. + */ +- textVerbosity: import_v4.z.string().optional() ++ textVerbosity: import_v4.z.string().optional(), ++ sendReasoning: import_v4.z.boolean().optional() + }); + + // src/openai-compatible-error.ts +@@ -378,7 +387,7 @@ var OpenAICompatibleChatLanguageModel = class { + reasoning_effort: compatibleOptions.reasoningEffort, + verbosity: compatibleOptions.textVerbosity, + // messages: +- messages: convertToOpenAICompatibleChatMessages(prompt), ++ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}), + // tools: + tools: openaiTools, + tool_choice: openaiToolChoice +@@ -421,6 +430,17 @@ var OpenAICompatibleChatLanguageModel = class { + text: reasoning + }); + } ++ if (choice.message.images) { ++ for (const image of choice.message.images) { ++ const match1 = image.image_url.url.match(/^data:([^;]+)/) ++ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); ++ content.push({ ++ type: 'file', ++ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', ++ data: match2 ? match2[1] : image.image_url.url, ++ }); ++ } ++ } + if (choice.message.tool_calls != null) { + for (const toolCall of choice.message.tool_calls) { + content.push({ +@@ -598,6 +618,17 @@ var OpenAICompatibleChatLanguageModel = class { + delta: delta.content + }); + } ++ if (delta.images) { ++ for (const image of delta.images) { ++ const match1 = image.image_url.url.match(/^data:([^;]+)/) ++ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); ++ controller.enqueue({ ++ type: 'file', ++ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', ++ data: match2 ? match2[1] : image.image_url.url, ++ }); ++ } ++ } + if (delta.tool_calls != null) { + for (const toolCallDelta of delta.tool_calls) { + const index = toolCallDelta.index; +@@ -765,6 +796,14 @@ var OpenAICompatibleChatResponseSchema = import_v43.z.object({ + arguments: import_v43.z.string() + }) + }) ++ ).nullish(), ++ images: import_v43.z.array( ++ import_v43.z.object({ ++ type: import_v43.z.literal('image_url'), ++ image_url: import_v43.z.object({ ++ url: import_v43.z.string(), ++ }) ++ }) + ).nullish() + }), + finish_reason: import_v43.z.string().nullish() +@@ -795,6 +834,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => import_v43.z.union( + arguments: import_v43.z.string().nullish() + }) + }) ++ ).nullish(), ++ images: import_v43.z.array( ++ import_v43.z.object({ ++ type: import_v43.z.literal('image_url'), ++ image_url: import_v43.z.object({ ++ url: import_v43.z.string(), ++ }) ++ }) + ).nullish() + }).nullish(), + finish_reason: import_v43.z.string().nullish() +diff --git a/dist/index.mjs b/dist/index.mjs +index a809a7aa0e148bfd43e01dd7b018568b151c8ad5..565b605eeacd9830b2b0e817e58ad0c5700264de 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -23,7 +23,7 @@ function getOpenAIMetadata(message) { + var _a, _b; + return (_b = (_a = message == null ? void 0 : message.providerOptions) == null ? void 0 : _a.openaiCompatible) != null ? _b : {}; + } +-function convertToOpenAICompatibleChatMessages(prompt) { ++function convertToOpenAICompatibleChatMessages({prompt, options}) { + const messages = []; + for (const { role, content, ...message } of prompt) { + const metadata = getOpenAIMetadata({ ...message }); +@@ -73,6 +73,7 @@ function convertToOpenAICompatibleChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ let reasoning_text = ""; + const toolCalls = []; + for (const part of content) { + const partMetadata = getOpenAIMetadata(part); +@@ -81,6 +82,12 @@ function convertToOpenAICompatibleChatMessages(prompt) { + text += part.text; + break; + } ++ case "reasoning": { ++ if (options.sendReasoning) { ++ reasoning_text += part.text; ++ } ++ break; ++ } + case "tool-call": { + toolCalls.push({ + id: part.toolCallId, +@@ -98,6 +105,7 @@ function convertToOpenAICompatibleChatMessages(prompt) { + messages.push({ + role: "assistant", + content: text, ++ reasoning_content: reasoning_text ?? undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0, + ...metadata + }); +@@ -182,7 +190,8 @@ var openaiCompatibleProviderOptions = z.object({ + /** + * Controls the verbosity of the generated text. Defaults to `medium`. + */ +- textVerbosity: z.string().optional() ++ textVerbosity: z.string().optional(), ++ sendReasoning: z.boolean().optional() + }); + + // src/openai-compatible-error.ts +@@ -362,7 +371,7 @@ var OpenAICompatibleChatLanguageModel = class { + reasoning_effort: compatibleOptions.reasoningEffort, + verbosity: compatibleOptions.textVerbosity, + // messages: +- messages: convertToOpenAICompatibleChatMessages(prompt), ++ messages: convertToOpenAICompatibleChatMessages({prompt, options: compatibleOptions}), + // tools: + tools: openaiTools, + tool_choice: openaiToolChoice +@@ -405,6 +414,17 @@ var OpenAICompatibleChatLanguageModel = class { + text: reasoning + }); + } ++ if (choice.message.images) { ++ for (const image of choice.message.images) { ++ const match1 = image.image_url.url.match(/^data:([^;]+)/) ++ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); ++ content.push({ ++ type: 'file', ++ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', ++ data: match2 ? match2[1] : image.image_url.url, ++ }); ++ } ++ } + if (choice.message.tool_calls != null) { + for (const toolCall of choice.message.tool_calls) { + content.push({ +@@ -582,6 +602,17 @@ var OpenAICompatibleChatLanguageModel = class { + delta: delta.content + }); + } ++ if (delta.images) { ++ for (const image of delta.images) { ++ const match1 = image.image_url.url.match(/^data:([^;]+)/) ++ const match2 = image.image_url.url.match(/^data:[^;]*;base64,(.+)$/); ++ controller.enqueue({ ++ type: 'file', ++ mediaType: match1 ? (match1[1] ?? 'image/jpeg') : 'image/jpeg', ++ data: match2 ? match2[1] : image.image_url.url, ++ }); ++ } ++ } + if (delta.tool_calls != null) { + for (const toolCallDelta of delta.tool_calls) { + const index = toolCallDelta.index; +@@ -749,6 +780,14 @@ var OpenAICompatibleChatResponseSchema = z3.object({ + arguments: z3.string() + }) + }) ++ ).nullish(), ++ images: z3.array( ++ z3.object({ ++ type: z3.literal('image_url'), ++ image_url: z3.object({ ++ url: z3.string(), ++ }) ++ }) + ).nullish() + }), + finish_reason: z3.string().nullish() +@@ -779,6 +818,14 @@ var createOpenAICompatibleChatChunkSchema = (errorSchema) => z3.union([ + arguments: z3.string().nullish() + }) + }) ++ ).nullish(), ++ images: z3.array( ++ z3.object({ ++ type: z3.literal('image_url'), ++ image_url: z3.object({ ++ url: z3.string(), ++ }) ++ }) + ).nullish() + }).nullish(), + finish_reason: z3.string().nullish() diff --git a/package.json b/package.json index 9560089333..3cf60b9f18 100644 --- a/package.json +++ b/package.json @@ -416,7 +416,9 @@ "@ai-sdk/openai@npm:^2.0.42": "patch:@ai-sdk/openai@npm%3A2.0.85#~/.yarn/patches/@ai-sdk-openai-npm-2.0.85-27483d1d6a.patch", "@ai-sdk/google@npm:^2.0.40": "patch:@ai-sdk/google@npm%3A2.0.40#~/.yarn/patches/@ai-sdk-google-npm-2.0.40-47e0eeee83.patch", "@ai-sdk/openai-compatible@npm:^1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch", - "@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch" + "@ai-sdk/google@npm:2.0.49": "patch:@ai-sdk/google@npm%3A2.0.49#~/.yarn/patches/@ai-sdk-google-npm-2.0.49-84720f41bd.patch", + "@ai-sdk/openai-compatible@npm:1.0.27": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch", + "@ai-sdk/openai-compatible@npm:^1.0.19": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch" }, "packageManager": "yarn@4.9.1", "lint-staged": { diff --git a/packages/ai-sdk-provider/package.json b/packages/ai-sdk-provider/package.json index 25864f3b1f..e635f93aeb 100644 --- a/packages/ai-sdk-provider/package.json +++ b/packages/ai-sdk-provider/package.json @@ -41,7 +41,7 @@ "ai": "^5.0.26" }, "dependencies": { - "@ai-sdk/openai-compatible": "^1.0.28", + "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch", "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17" }, diff --git a/packages/aiCore/package.json b/packages/aiCore/package.json index 6fc0f53344..e73a843b1d 100644 --- a/packages/aiCore/package.json +++ b/packages/aiCore/package.json @@ -42,7 +42,7 @@ "@ai-sdk/anthropic": "^2.0.49", "@ai-sdk/azure": "^2.0.87", "@ai-sdk/deepseek": "^1.0.31", - "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch", + "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch", "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.17", "@ai-sdk/xai": "^2.0.36", diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 4d8d4070e9..36778b7570 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -10,6 +10,7 @@ import { isAnthropicModel, isGeminiModel, isGrokModel, + isInterleavedThinkingModel, isOpenAIModel, isOpenAIOpenWeightModel, isQwenMTModel, @@ -603,7 +604,7 @@ function buildGenericProviderOptions( enableGenerateImage: boolean } ): Record { - const { enableWebSearch } = capabilities + const { enableWebSearch, enableReasoning } = capabilities let providerOptions: Record = {} const reasoningParams = getReasoningEffort(assistant, model) @@ -611,6 +612,14 @@ function buildGenericProviderOptions( ...providerOptions, ...reasoningParams } + if (enableReasoning) { + if (isInterleavedThinkingModel(model)) { + providerOptions = { + ...providerOptions, + sendReasoning: true + } + } + } if (enableWebSearch) { const webSearchParams = getWebSearchParams(model) diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 783cb39993..6b00a8912b 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -17,6 +17,7 @@ import { isGeminiReasoningModel, isGrok4FastReasoningModel, isHunyuanReasoningModel, + isInterleavedThinkingModel, isLingReasoningModel, isMiniMaxReasoningModel, isPerplexityReasoningModel, @@ -2157,3 +2158,105 @@ describe('getModelSupportedReasoningEffortOptions', () => { }) }) }) + +describe('isInterleavedThinkingModel', () => { + describe('MiniMax models', () => { + it('should return true for minimax-m2', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2' }))).toBe(true) + }) + + it('should return true for minimax-m2.1', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2.1' }))).toBe(true) + }) + + it('should return true for minimax-m2 with suffixes', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2-pro' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2-preview' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2-lite' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2-ultra-lite' }))).toBe(true) + }) + + it('should return true for minimax-m2.x with suffixes', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2.1-pro' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2.2-preview' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m2.5-lite' }))).toBe(true) + }) + + it('should return false for non-m2 minimax models', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m1' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-m3' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'minimax-pro' }))).toBe(false) + }) + + it('should handle case insensitivity', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'MiniMax-M2' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'MINIMAX-M2.1' }))).toBe(true) + }) + }) + + describe('MiMo models', () => { + it('should return true for mimo-v2-flash', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'mimo-v2-flash' }))).toBe(true) + }) + + it('should return false for other mimo models', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'mimo-v1-flash' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'mimo-v2' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'mimo-v2-pro' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'mimo-flash' }))).toBe(false) + }) + + it('should handle case insensitivity', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'MiMo-V2-Flash' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'MIMO-V2-FLASH' }))).toBe(true) + }) + }) + + describe('Zhipu GLM models', () => { + it('should return true for glm-4.5', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.5' }))).toBe(true) + }) + + it('should return true for glm-4.6', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.6' }))).toBe(true) + }) + + it('should return true for glm-4.7 and higher versions', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.7' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.8' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.9' }))).toBe(true) + }) + + it('should return true for glm-4.x with suffixes', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.5-pro' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.6-preview' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.7-lite' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4.8-ultra' }))).toBe(true) + }) + + it('should return false for glm-4 without decimal version', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-4-pro' }))).toBe(false) + }) + + it('should return false for other glm models', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'glm-3.5' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-5.0' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'glm-zero-preview' }))).toBe(false) + }) + + it('should handle case insensitivity', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'GLM-4.5' }))).toBe(true) + expect(isInterleavedThinkingModel(createModel({ id: 'Glm-4.6-Pro' }))).toBe(true) + }) + }) + + describe('Non-matching models', () => { + it('should return false for unrelated models', () => { + expect(isInterleavedThinkingModel(createModel({ id: 'gpt-4' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'claude-3-opus' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'gemini-pro' }))).toBe(false) + expect(isInterleavedThinkingModel(createModel({ id: 'deepseek-v3' }))).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 27a793bf7d..5d48e9a122 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -738,3 +738,20 @@ export const findTokenLimit = (modelId: string): { min: number; max: number } | */ export const isFixedReasoningModel = (model: Model) => isReasoningModel(model) && !isSupportedThinkingTokenModel(model) && !isSupportedReasoningEffortModel(model) + +// https://platform.minimaxi.com/docs/guides/text-m2-function-call#openai-sdk +// https://docs.z.ai/guides/capabilities/thinking-mode +// https://platform.moonshot.cn/docs/guide/use-kimi-k2-thinking-model#%E5%A4%9A%E6%AD%A5%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8 +const INTERLEAVED_THINKING_MODEL_REGEX = + /minimax-m2(.(\d+))?(?:-[\w-]+)?|mimo-v2-flash|glm-4.(\d+)(?:-[\w-]+)?|kimi-k2-thinking?$/i + +/** + * Determines whether the given model supports interleaved thinking. + * + * @param model - The model object to check. + * @returns `true` if the model's ID matches the interleaved thinking model pattern; otherwise, `false`. + */ +export const isInterleavedThinkingModel = (model: Model) => { + const modelId = getLowerBaseModelName(model.id) + return INTERLEAVED_THINKING_MODEL_REGEX.test(modelId) +} diff --git a/yarn.lock b/yarn.lock index 8c856e8cec..1dffdbeb42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,19 +242,7 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/openai-compatible@npm:1.0.27, @ai-sdk/openai-compatible@npm:^1.0.19": - version: 1.0.27 - resolution: "@ai-sdk/openai-compatible@npm:1.0.27" - dependencies: - "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.17" - peerDependencies: - zod: ^3.25.76 || ^4.1.8 - checksum: 10c0/9f656e4f2ea4d714dc05be588baafd962b2e0360e9195fef373e745efeb20172698ea87e1033c0c5e1f1aa6e0db76a32629427bc8433eb42bd1a0ee00e04af0c - languageName: node - linkType: hard - -"@ai-sdk/openai-compatible@npm:^1.0.28": +"@ai-sdk/openai-compatible@npm:1.0.28": version: 1.0.28 resolution: "@ai-sdk/openai-compatible@npm:1.0.28" dependencies: @@ -266,15 +254,15 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/openai-compatible@patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch": - version: 1.0.27 - resolution: "@ai-sdk/openai-compatible@patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch::version=1.0.27&hash=c44b76" +"@ai-sdk/openai-compatible@patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch": + version: 1.0.28 + resolution: "@ai-sdk/openai-compatible@patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch::version=1.0.28&hash=f2cb20" dependencies: "@ai-sdk/provider": "npm:2.0.0" - "@ai-sdk/provider-utils": "npm:3.0.17" + "@ai-sdk/provider-utils": "npm:3.0.18" peerDependencies: zod: ^3.25.76 || ^4.1.8 - checksum: 10c0/80c8331bc5fc62dc23d99d861bdc76e4eaf8b4b071d0b2bfa42fbd87f50b1bcdfa5ce4a4deaf7026a603a1ba6eaf5c884d87e3c58b4d6515c220121d3f421de5 + checksum: 10c0/0b1d99fe8ce506e5c0a3703ae0511ac2017781584074d41faa2df82923c64eb1229ffe9f036de150d0248923613c761a463fe89d5923493983e0463a1101e792 languageName: node linkType: hard @@ -1880,7 +1868,7 @@ __metadata: "@ai-sdk/anthropic": "npm:^2.0.49" "@ai-sdk/azure": "npm:^2.0.87" "@ai-sdk/deepseek": "npm:^1.0.31" - "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.27#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.27-06f74278cf.patch" + "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch" "@ai-sdk/provider": "npm:^2.0.0" "@ai-sdk/provider-utils": "npm:^3.0.17" "@ai-sdk/xai": "npm:^2.0.36" @@ -1900,7 +1888,7 @@ __metadata: version: 0.0.0-use.local resolution: "@cherrystudio/ai-sdk-provider@workspace:packages/ai-sdk-provider" dependencies: - "@ai-sdk/openai-compatible": "npm:^1.0.28" + "@ai-sdk/openai-compatible": "patch:@ai-sdk/openai-compatible@npm%3A1.0.28#~/.yarn/patches/@ai-sdk-openai-compatible-npm-1.0.28-5705188855.patch" "@ai-sdk/provider": "npm:^2.0.0" "@ai-sdk/provider-utils": "npm:^3.0.17" tsdown: "npm:^0.13.3" From 89a6d817f1454b2f1e9bc637949eecaaac616447 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Wed, 24 Dec 2025 13:25:37 +0800 Subject: [PATCH 16/18] fix(display): improve font selector for long font names (#12100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(display): improve font selector for long font names - Increase Select width from 200px to 280px - Increase SelectRow container width from 300px to 380px - Add Tooltip to show full font name on hover - Add text-overflow ellipsis for long font names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * refactor(DisplaySettings): replace span with div and use CSS class for truncation --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: icarus --- .../DisplaySettings/DisplaySettings.tsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx index 49b3b386a9..444fba569f 100644 --- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx +++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx @@ -18,7 +18,7 @@ import { setSidebarIcons } from '@renderer/store/settings' import { ThemeMode } from '@renderer/types' -import { Button, ColorPicker, Segmented, Select, Switch } from 'antd' +import { Button, ColorPicker, Segmented, Select, Switch, Tooltip } from 'antd' import { Minus, Monitor, Moon, Plus, Sun } from 'lucide-react' import type { FC } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -196,6 +196,21 @@ const DisplaySettings: FC = () => { [t] ) + const renderFontOption = useCallback( + (font: string) => ( + +
+ {font} +
+
+ ), + [] + ) + return ( @@ -292,7 +307,7 @@ const DisplaySettings: FC = () => { {t('settings.display.font.global')} { ), value: '' }, - ...fontList.map((font) => ({ label: {font}, value: font })) + ...fontList.map((font) => ({ label: renderFontOption(font), value: font })) ]} value={userTheme.userCodeFontFamily || ''} onChange={(font) => handleUserCodeFontChange(font)} @@ -480,7 +495,7 @@ const SelectRow = styled.div` display: flex; align-items: center; justify-content: flex-end; - width: 300px; + width: 380px; ` export default DisplaySettings From d9171e0596aa6956f9cae77b99ff1a4402bac7b1 Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 24 Dec 2025 14:18:41 +0800 Subject: [PATCH 17/18] fix(openrouter): support GPT-5.1/5.2 reasoning effort 'none' for OpenRouter and improve error handling (#12088) --- src/renderer/src/aiCore/utils/reasoning.ts | 12 +- .../config/models/__tests__/openai.test.ts | 139 ++++++++++++++++++ src/renderer/src/config/models/openai.ts | 28 ++++ .../src/pages/translate/TranslatePage.tsx | 21 ++- src/renderer/src/services/TranslateService.ts | 8 +- src/renderer/src/utils/error.ts | 18 ++- 6 files changed, 198 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/config/models/__tests__/openai.test.ts diff --git a/src/renderer/src/aiCore/utils/reasoning.ts b/src/renderer/src/aiCore/utils/reasoning.ts index a7d6028857..ab8a0b7983 100644 --- a/src/renderer/src/aiCore/utils/reasoning.ts +++ b/src/renderer/src/aiCore/utils/reasoning.ts @@ -14,7 +14,6 @@ import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel, isGemini3ThinkingTokenModel, - isGPT51SeriesModel, isGrok4FastReasoningModel, isOpenAIDeepResearchModel, isOpenAIModel, @@ -32,7 +31,8 @@ import { isSupportedThinkingTokenMiMoModel, isSupportedThinkingTokenModel, isSupportedThinkingTokenQwenModel, - isSupportedThinkingTokenZhipuModel + isSupportedThinkingTokenZhipuModel, + isSupportNoneReasoningEffortModel } from '@renderer/config/models' import { getStoreSetting } from '@renderer/hooks/useSettings' import { getAssistantSettings, getProviderByModel } from '@renderer/services/AssistantService' @@ -74,9 +74,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin if (reasoningEffort === 'none') { // openrouter: use reasoning if (model.provider === SystemProviderIds.openrouter) { - // 'none' is not an available value for effort for now. - // I think they should resolve this issue soon, so I'll just go ahead and use this value. - if (isGPT51SeriesModel(model) && reasoningEffort === 'none') { + if (isSupportNoneReasoningEffortModel(model) && reasoningEffort === 'none') { return { reasoning: { effort: 'none' } } } return { reasoning: { enabled: false, exclude: true } } @@ -120,8 +118,8 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin return { thinking: { type: 'disabled' } } } - // Specially for GPT-5.1. Suppose this is a OpenAI Compatible provider - if (isGPT51SeriesModel(model)) { + // GPT 5.1, GPT 5.2, or newer + if (isSupportNoneReasoningEffortModel(model)) { return { reasoningEffort: 'none' } diff --git a/src/renderer/src/config/models/__tests__/openai.test.ts b/src/renderer/src/config/models/__tests__/openai.test.ts new file mode 100644 index 0000000000..8c8e8b6671 --- /dev/null +++ b/src/renderer/src/config/models/__tests__/openai.test.ts @@ -0,0 +1,139 @@ +import type { Model } from '@renderer/types' +import { describe, expect, it, vi } from 'vitest' + +import { isSupportNoneReasoningEffortModel } from '../openai' + +// Mock store and settings to avoid initialization issues +vi.mock('@renderer/store', () => ({ + __esModule: true, + default: { + getState: () => ({ + llm: { providers: [] }, + settings: {} + }) + } +})) + +vi.mock('@renderer/hooks/useStore', () => ({ + getStoreProviders: vi.fn(() => []) +})) + +const createModel = (overrides: Partial = {}): Model => ({ + id: 'gpt-4o', + name: 'gpt-4o', + provider: 'openai', + group: 'OpenAI', + ...overrides +}) + +describe('OpenAI Model Detection', () => { + describe('isSupportNoneReasoningEffortModel', () => { + describe('should return true for GPT-5.1 and GPT-5.2 reasoning models', () => { + it('returns true for GPT-5.1 base model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1' }))).toBe(true) + }) + + it('returns true for GPT-5.1 mini model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-mini' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-mini-preview' }))).toBe(true) + }) + + it('returns true for GPT-5.1 preview model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-preview' }))).toBe(true) + }) + + it('returns true for GPT-5.2 base model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2' }))).toBe(true) + }) + + it('returns true for GPT-5.2 mini model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-mini' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-mini-preview' }))).toBe(true) + }) + + it('returns true for GPT-5.2 preview model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-preview' }))).toBe(true) + }) + }) + + describe('should return false for pro variants', () => { + it('returns false for GPT-5.1-pro models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-Pro' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro-preview' }))).toBe(false) + }) + + it('returns false for GPT-5.2-pro models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-pro' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-Pro' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-pro-preview' }))).toBe(false) + }) + }) + + describe('should return false for chat variants', () => { + it('returns false for GPT-5.1-chat models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-chat' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-Chat' }))).toBe(false) + }) + + it('returns false for GPT-5.2-chat models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-chat' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-Chat' }))).toBe(false) + }) + }) + + describe('should return false for GPT-5 series (non-5.1/5.2)', () => { + it('returns false for GPT-5 base model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5' }))).toBe(false) + }) + + it('returns false for GPT-5 pro model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5-pro' }))).toBe(false) + }) + + it('returns false for GPT-5 preview model', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5-preview' }))).toBe(false) + }) + }) + + describe('should return false for other OpenAI models', () => { + it('returns false for GPT-4 models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-4o' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-4-turbo' }))).toBe(false) + }) + + it('returns false for o1 models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1-mini' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o1-preview' }))).toBe(false) + }) + + it('returns false for o3 models', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o3' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'o3-mini' }))).toBe(false) + }) + }) + + describe('edge cases', () => { + it('handles models with version suffixes', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-2025-01-01' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.2-latest' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'gpt-5.1-pro-2025-01-01' }))).toBe(false) + }) + + it('handles models with OpenRouter prefixes', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.2-mini' }))).toBe(true) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1-pro' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'openai/gpt-5.1-chat' }))).toBe(false) + }) + + it('handles mixed case with chat and pro', () => { + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.1-CHAT' }))).toBe(false) + expect(isSupportNoneReasoningEffortModel(createModel({ id: 'GPT-5.2-PRO' }))).toBe(false) + }) + }) + }) +}) diff --git a/src/renderer/src/config/models/openai.ts b/src/renderer/src/config/models/openai.ts index 86601659e2..ebad589d53 100644 --- a/src/renderer/src/config/models/openai.ts +++ b/src/renderer/src/config/models/openai.ts @@ -77,6 +77,34 @@ export function isSupportVerbosityModel(model: Model): boolean { ) } +/** + * Determines if a model supports the "none" reasoning effort parameter. + * + * This applies to GPT-5.1 and GPT-5.2 series reasoning models (non-chat, non-pro variants). + * These models allow setting reasoning_effort to "none" to skip reasoning steps. + * + * @param model - The model to check + * @returns true if the model supports "none" reasoning effort, false otherwise + * + * @example + * ```ts + * // Returns true + * isSupportNoneReasoningEffortModel({ id: 'gpt-5.1', provider: 'openai' }) + * isSupportNoneReasoningEffortModel({ id: 'gpt-5.2-mini', provider: 'openai' }) + * + * // Returns false + * isSupportNoneReasoningEffortModel({ id: 'gpt-5.1-pro', provider: 'openai' }) + * isSupportNoneReasoningEffortModel({ id: 'gpt-5.1-chat', provider: 'openai' }) + * isSupportNoneReasoningEffortModel({ id: 'gpt-5-pro', provider: 'openai' }) + * ``` + */ +export function isSupportNoneReasoningEffortModel(model: Model): boolean { + const modelId = getLowerBaseModelName(model.id) + return ( + (isGPT51SeriesModel(model) || isGPT52SeriesModel(model)) && !modelId.includes('chat') && !modelId.includes('pro') + ) +} + export function isOpenAIChatCompletionOnlyModel(model: Model): boolean { if (!model) { return false diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index dd47d41c9b..ce4a4625c1 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -30,8 +30,7 @@ import { } from '@renderer/types' import { getFileExtension, isTextFile, runAsyncFunction, uuid } from '@renderer/utils' import { abortCompletion } from '@renderer/utils/abortController' -import { isAbortError } from '@renderer/utils/error' -import { formatErrorMessage } from '@renderer/utils/error' +import { formatErrorMessageWithPrefix, isAbortError } from '@renderer/utils/error' import { getFilesFromDropEvent, getTextFromDropEvent } from '@renderer/utils/input' import { createInputScrollHandler, @@ -181,7 +180,7 @@ const TranslatePage: FC = () => { window.toast.info(t('translate.info.aborted')) } else { logger.error('Failed to translate text', e as Error) - window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.failed'))) } setTranslating(false) return @@ -202,11 +201,11 @@ const TranslatePage: FC = () => { await saveTranslateHistory(text, translated, actualSourceLanguage.langCode, actualTargetLanguage.langCode) } catch (e) { logger.error('Failed to save translate history', e as Error) - window.toast.error(t('translate.history.error.save') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.history.error.save'))) } } catch (e) { logger.error('Failed to translate', e as Error) - window.toast.error(t('translate.error.unknown') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.unknown'))) } }, [autoCopy, copy, dispatch, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] @@ -266,7 +265,7 @@ const TranslatePage: FC = () => { await translate(text, actualSourceLanguage, actualTargetLanguage) } catch (error) { logger.error('Translation error:', error as Error) - window.toast.error(t('translate.error.failed') + ': ' + formatErrorMessage(error)) + window.toast.error(formatErrorMessageWithPrefix(error, t('translate.error.failed'))) return } finally { setTranslating(false) @@ -427,7 +426,7 @@ const TranslatePage: FC = () => { setAutoDetectionMethod(method) } catch (e) { logger.error('Failed to update auto detection method setting.', e as Error) - window.toast.error(t('translate.error.detect.update_setting') + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.error.detect.update_setting'))) } } @@ -498,7 +497,7 @@ const TranslatePage: FC = () => { isText = await isTextFile(file.path) } catch (e) { logger.error('Failed to check file type.', e as Error) - window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.check_type'))) return } } else { @@ -530,11 +529,11 @@ const TranslatePage: FC = () => { setText(text + result) } catch (e) { logger.error('Failed to read file.', e as Error) - window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown'))) } } catch (e) { logger.error('Failed to read file.', e as Error) - window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown'))) } } const promise = _readFile() @@ -578,7 +577,7 @@ const TranslatePage: FC = () => { await processFile(file) } catch (e) { logger.error('Unknown error when selecting file.', e as Error) - window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) + window.toast.error(formatErrorMessageWithPrefix(e, t('translate.files.error.unknown'))) } finally { clearFiles() setIsProcessing(false) diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 328f1a8edf..67e4f66bc3 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -42,7 +42,7 @@ export const translateText = async ( abortKey?: string, options?: TranslateOptions ) => { - let abortError + let error const assistantSettings: Partial | undefined = options ? { reasoning_effort: options?.reasoningEffort } : undefined @@ -58,8 +58,8 @@ export const translateText = async ( } else if (chunk.type === ChunkType.TEXT_COMPLETE) { completed = true } else if (chunk.type === ChunkType.ERROR) { + error = chunk.error if (isAbortError(chunk.error)) { - abortError = chunk.error completed = true } } @@ -84,8 +84,8 @@ export const translateText = async ( } } - if (abortError) { - throw abortError + if (error !== undefined && !isAbortError(error)) { + throw error } const trimmedText = translatedText.trim() diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index d4ea2979e2..ec2e15f6d8 100644 --- a/src/renderer/src/utils/error.ts +++ b/src/renderer/src/utils/error.ts @@ -1,3 +1,4 @@ +import { loggerService } from '@logger' import type { McpError } from '@modelcontextprotocol/sdk/types.js' import type { AgentServerError } from '@renderer/types' import { AgentServerErrorSchema } from '@renderer/types' @@ -20,7 +21,7 @@ import { ZodError } from 'zod' import { parseJSON } from './json' import { safeSerialize } from './serialize' -// const logger = loggerService.withContext('Utils:error') +const logger = loggerService.withContext('Utils:error') export function getErrorDetails(err: any, seen = new WeakSet()): any { // Handle circular references @@ -65,11 +66,16 @@ export function formatErrorMessage(error: unknown): string { delete detailedError?.stack delete detailedError?.request_id - const formattedJson = JSON.stringify(detailedError, null, 2) - .split('\n') - .map((line) => ` ${line}`) - .join('\n') - return detailedError.message ? detailedError.message : `Error Details:\n${formattedJson}` + if (detailedError) { + const formattedJson = JSON.stringify(detailedError, null, 2) + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + return detailedError.message ? detailedError.message : `Error Details:\n${formattedJson}` + } else { + logger.warn('Get detailed error failed.') + return '' + } } export function getErrorMessage(error: unknown): string { From f7312697e7c4700dd035fb4b78b7d07dedc93dd0 Mon Sep 17 00:00:00 2001 From: Kejiang Ma Date: Wed, 24 Dec 2025 15:26:19 +0800 Subject: [PATCH 18/18] feat: close ovms process when app quit (#12101) * feat:close ovms process while app quit * add await for execAsync * update 'will-quit' event --- src/main/index.ts | 4 ++++ src/main/ipc.ts | 3 +-- src/main/services/OvmsManager.ts | 33 ++++++-------------------------- 3 files changed, 11 insertions(+), 29 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 657c31dfc4..ec16475d3f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -37,6 +37,7 @@ import { versionService } from './services/VersionService' import { windowService } from './services/WindowService' import { initWebviewHotkeys } from './services/WebviewService' import { runAsyncFunction } from './utils' +import { ovmsManager } from './services/OvmsManager' const logger = loggerService.withContext('MainEntry') @@ -247,12 +248,15 @@ if (!app.requestSingleInstanceLock()) { app.on('will-quit', async () => { // 简单的资源清理,不阻塞退出流程 + await ovmsManager.stopOvms() + try { await mcpService.cleanup() await apiServerService.stop() } catch (error) { logger.warn('Error cleaning up MCP service:', error as Error) } + // finish the logger logger.finish() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 08bfbac6f8..8f86a93075 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -59,7 +59,7 @@ import NotificationService from './services/NotificationService' import * as NutstoreService from './services/NutstoreService' import ObsidianVaultService from './services/ObsidianVaultService' import { ocrService } from './services/ocr/OcrService' -import OvmsManager from './services/OvmsManager' +import { ovmsManager } from './services/OvmsManager' import powerMonitorService from './services/PowerMonitorService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' @@ -107,7 +107,6 @@ const obsidianVaultService = new ObsidianVaultService() const vertexAIService = VertexAIService.getInstance() const memoryService = MemoryService.getInstance() const dxtService = new DxtService() -const ovmsManager = new OvmsManager() const pluginService = PluginService.getInstance() function normalizeError(error: unknown): Error { diff --git a/src/main/services/OvmsManager.ts b/src/main/services/OvmsManager.ts index 3a32d74ecf..54e0a1bb8b 100644 --- a/src/main/services/OvmsManager.ts +++ b/src/main/services/OvmsManager.ts @@ -102,32 +102,10 @@ class OvmsManager { */ public async stopOvms(): Promise<{ success: boolean; message?: string }> { try { - // Check if OVMS process is running - const psCommand = `Get-Process -Name "ovms" -ErrorAction SilentlyContinue | Select-Object Id, Path | ConvertTo-Json` - const { stdout } = await execAsync(`powershell -Command "${psCommand}"`) - - if (!stdout.trim()) { - logger.info('OVMS process is not running') - return { success: true, message: 'OVMS process is not running' } - } - - const processes = JSON.parse(stdout) - const processList = Array.isArray(processes) ? processes : [processes] - - if (processList.length === 0) { - logger.info('OVMS process is not running') - return { success: true, message: 'OVMS process is not running' } - } - - // Terminate all OVMS processes using terminalProcess - for (const process of processList) { - const result = await this.terminalProcess(process.Id) - if (!result.success) { - logger.error(`Failed to terminate OVMS process with PID: ${process.Id}, ${result.message}`) - return { success: false, message: `Failed to terminate OVMS process: ${result.message}` } - } - logger.info(`Terminated OVMS process with PID: ${process.Id}`) - } + // close the OVMS process + await execAsync( + `powershell -Command "Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -like 'ovms.exe*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"` + ) // Reset the ovms instance this.ovms = null @@ -584,4 +562,5 @@ class OvmsManager { } } -export default OvmsManager +// Export singleton instance +export const ovmsManager = new OvmsManager()