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 001/116] 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 002/116] 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 003/116] 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 004/116] 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 005/116] 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 006/116] 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 007/116] 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 008/116] 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 009/116] 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 010/116] 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 011/116] 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 012/116] 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 013/116] 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 014/116] 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 015/116] 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 016/116] 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 017/116] 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 018/116] 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() From 1b9d8fe24ac02a4c38faad5bce31bf277f5e8bca Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Wed, 24 Dec 2025 23:19:25 +0800 Subject: [PATCH 019/116] feat(database): add user data schemas for topic, message, group, and tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add topicTable schema with group organization and pinning support - Add messageTable schema with tree structure (adjacency list pattern) - Add groupTable schema for organizing entities by type - Add tagTable and entityTagTable schemas for tagging system - Add FTS5 full-text search support for message content - Update preferenceTable to use composite primary key (scope, key) - Regenerate initial migration with all tables Changes Summary | Type | Files | |-------------|---------------------------------------------------------------------| | New schemas | topic.ts, message.ts, group.ts, tag.ts, entityTag.ts, messageFts.ts | | Modified | preference.ts (index → composite PK) | | Migration | Renamed 0000_solid_lord_hawal.sql → 0000_init.sql with all tables | This is part of the data refactoring project - adding core user data table schemas. --- migrations/sqlite-drizzle/0000_init.sql | 145 ++++ .../sqlite-drizzle/0000_solid_lord_hawal.sql | 17 - .../sqlite-drizzle/meta/0000_snapshot.json | 646 ++++++++++++++++-- migrations/sqlite-drizzle/meta/_journal.json | 10 +- src/main/data/db/schemas/entityTag.ts | 26 + src/main/data/db/schemas/group.ts | 24 + src/main/data/db/schemas/message.ts | 60 ++ src/main/data/db/schemas/messageFts.ts | 94 +++ src/main/data/db/schemas/preference.ts | 6 +- src/main/data/db/schemas/tag.ts | 18 + src/main/data/db/schemas/topic.ts | 41 ++ 11 files changed, 988 insertions(+), 99 deletions(-) create mode 100644 migrations/sqlite-drizzle/0000_init.sql delete mode 100644 migrations/sqlite-drizzle/0000_solid_lord_hawal.sql create mode 100644 src/main/data/db/schemas/entityTag.ts create mode 100644 src/main/data/db/schemas/group.ts create mode 100644 src/main/data/db/schemas/message.ts create mode 100644 src/main/data/db/schemas/messageFts.ts create mode 100644 src/main/data/db/schemas/tag.ts create mode 100644 src/main/data/db/schemas/topic.ts diff --git a/migrations/sqlite-drizzle/0000_init.sql b/migrations/sqlite-drizzle/0000_init.sql new file mode 100644 index 0000000000..d7d9a36095 --- /dev/null +++ b/migrations/sqlite-drizzle/0000_init.sql @@ -0,0 +1,145 @@ +CREATE TABLE `app_state` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `description` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE TABLE `entity_tag` ( + `entity_type` text NOT NULL, + `entity_id` text NOT NULL, + `tag_id` text NOT NULL, + `created_at` integer, + `updated_at` integer, + PRIMARY KEY(`entity_type`, `entity_id`, `tag_id`), + FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `entity_tag_tag_id_idx` ON `entity_tag` (`tag_id`);--> statement-breakpoint +CREATE TABLE `group` ( + `id` text PRIMARY KEY NOT NULL, + `entity_type` text NOT NULL, + `name` text NOT NULL, + `sort_order` integer DEFAULT 0, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE INDEX `group_entity_sort_idx` ON `group` (`entity_type`,`sort_order`);--> statement-breakpoint +CREATE TABLE `message` ( + `id` text PRIMARY KEY NOT NULL, + `topic_id` text NOT NULL, + `parent_id` text, + `response_group_id` integer DEFAULT 0, + `role` text NOT NULL, + `status` text NOT NULL, + `assistant_id` text, + `assistant_meta` text, + `model_id` text, + `model_meta` text, + `data` text NOT NULL, + `usage` text, + `metrics` text, + `trace_id` text, + `searchable_text` text, + `created_at` integer, + `updated_at` integer, + `deleted_at` integer, + FOREIGN KEY (`topic_id`) REFERENCES `topic`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`parent_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE set null, + CONSTRAINT "message_role_check" CHECK("message"."role" IN ('user', 'assistant', 'system')), + CONSTRAINT "message_status_check" CHECK("message"."status" IN ('success', 'error', 'paused')) +); +--> statement-breakpoint +CREATE INDEX `message_parent_id_idx` ON `message` (`parent_id`);--> statement-breakpoint +CREATE INDEX `message_topic_created_idx` ON `message` (`topic_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `message_trace_id_idx` ON `message` (`trace_id`);--> statement-breakpoint +CREATE TABLE `preference` ( + `scope` text DEFAULT 'default' NOT NULL, + `key` text NOT NULL, + `value` text, + `created_at` integer, + `updated_at` integer, + PRIMARY KEY(`scope`, `key`) +); +--> statement-breakpoint +CREATE TABLE `tag` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `color` text, + `created_at` integer, + `updated_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint +CREATE TABLE `topic` ( + `id` text PRIMARY KEY NOT NULL, + `name` text, + `assistant_id` text, + `assistant_meta` text, + `prompt` text, + `group_id` text, + `is_pinned` integer DEFAULT false, + `pinned_order` integer DEFAULT 0, + `sort_order` integer DEFAULT 0, + `is_name_manually_edited` integer DEFAULT false, + `created_at` integer, + `updated_at` integer, + `deleted_at` integer, + FOREIGN KEY (`group_id`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `topic_group_updated_idx` ON `topic` (`group_id`,`updated_at`);--> statement-breakpoint +CREATE INDEX `topic_group_sort_idx` ON `topic` (`group_id`,`sort_order`);--> statement-breakpoint +CREATE INDEX `topic_updated_at_idx` ON `topic` (`updated_at`);--> statement-breakpoint +CREATE INDEX `topic_is_pinned_idx` ON `topic` (`is_pinned`,`pinned_order`);--> statement-breakpoint +CREATE INDEX `topic_assistant_id_idx` ON `topic` (`assistant_id`); +--> statement-breakpoint +-- ============================================================ +-- FTS5 Virtual Table and Triggers for Message Full-Text Search +-- ============================================================ + +-- 1. Create FTS5 virtual table with external content +-- Links to message table's searchable_text column +CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( + searchable_text, + content='message', + content_rowid='rowid', + tokenize='trigram' +);--> statement-breakpoint + +-- 2. Trigger: populate searchable_text and sync FTS on INSERT +CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN + -- Extract searchable text from data.blocks + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + -- Sync to FTS5 + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; +END;--> statement-breakpoint + +-- 3. Trigger: sync FTS on DELETE +CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); +END;--> statement-breakpoint + +-- 4. Trigger: update searchable_text and sync FTS on UPDATE OF data +CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN + -- Remove old FTS entry + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); + -- Update searchable_text + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + -- Add new FTS entry + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; +END; \ No newline at end of file diff --git a/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql b/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql deleted file mode 100644 index 9e52692966..0000000000 --- a/migrations/sqlite-drizzle/0000_solid_lord_hawal.sql +++ /dev/null @@ -1,17 +0,0 @@ -CREATE TABLE `app_state` ( - `key` text PRIMARY KEY NOT NULL, - `value` text NOT NULL, - `description` text, - `created_at` integer, - `updated_at` integer -); ---> statement-breakpoint -CREATE TABLE `preference` ( - `scope` text NOT NULL, - `key` text NOT NULL, - `value` text, - `created_at` integer, - `updated_at` integer -); ---> statement-breakpoint -CREATE INDEX `scope_name_idx` ON `preference` (`scope`,`key`); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0000_snapshot.json b/migrations/sqlite-drizzle/meta/0000_snapshot.json index 51c5ed6cba..eb3f54f553 100644 --- a/migrations/sqlite-drizzle/meta/0000_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0000_snapshot.json @@ -1,114 +1,612 @@ { - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - }, + "version": "6", "dialect": "sqlite", - "enums": {}, - "id": "de8009d7-95b9-4f99-99fa-4b8795708f21", - "internal": { - "indexes": {} - }, + "id": "62a198e0-bfc2-4db1-af58-7e479fedd7b9", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "app_state": { - "checkConstraints": {}, + "name": "app_state", "columns": { - "created_at": { - "autoincrement": false, - "name": "created_at", - "notNull": false, + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", "primaryKey": false, - "type": "integer" + "notNull": true, + "autoincrement": false }, "description": { - "autoincrement": false, "name": "description", - "notNull": false, + "type": "text", "primaryKey": false, - "type": "text" + "notNull": false, + "autoincrement": false }, - "key": { - "autoincrement": false, - "name": "key", - "notNull": true, - "primaryKey": true, - "type": "text" + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false }, "updated_at": { - "autoincrement": false, "name": "updated_at", + "type": "integer", + "primaryKey": false, "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", "primaryKey": false, - "type": "integer" - }, - "value": { - "autoincrement": false, - "name": "value", "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", "primaryKey": false, - "type": "text" + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": ["tag_id"], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": ["entity_type", "entity_id", "tag_id"], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": ["entity_type", "sort_order"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_group_id": { + "name": "response_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metrics": { + "name": "metrics", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": ["topic_id", "created_at"], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": ["trace_id"], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": {}, - "name": "app_state", - "uniqueConstraints": {} + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" + } + } }, "preference": { - "checkConstraints": {}, + "name": "preference", "columns": { - "created_at": { - "autoincrement": false, - "name": "created_at", - "notNull": false, + "scope": { + "name": "scope", + "type": "text", "primaryKey": false, - "type": "integer" + "notNull": true, + "autoincrement": false, + "default": "'default'" }, "key": { - "autoincrement": false, "name": "key", + "type": "text", + "primaryKey": false, "notNull": true, - "primaryKey": false, - "type": "text" - }, - "scope": { - "autoincrement": false, - "name": "scope", - "notNull": true, - "primaryKey": false, - "type": "text" - }, - "updated_at": { - "autoincrement": false, - "name": "updated_at", - "notNull": false, - "primaryKey": false, - "type": "integer" + "autoincrement": false }, "value": { - "autoincrement": false, "name": "value", - "notNull": false, + "type": "text", "primaryKey": false, - "type": "text" + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": ["scope", "key"], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": ["group_id", "updated_at"], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": ["group_id", "sort_order"], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": ["updated_at"], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": ["is_pinned", "pinned_order"], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": ["assistant_id"], + "isUnique": false + } + }, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "foreignKeys": {}, - "indexes": { - "scope_name_idx": { - "columns": ["scope", "key"], - "isUnique": false, - "name": "scope_name_idx" - } - }, - "name": "preference", - "uniqueConstraints": {} + "uniqueConstraints": {}, + "checkConstraints": {} } }, - "version": "6", - "views": {} + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } } diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index db2791fd7f..781e2d3d99 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -1,13 +1,13 @@ { + "version": "7", "dialect": "sqlite", "entries": [ { - "breakpoints": true, "idx": 0, - "tag": "0000_solid_lord_hawal", "version": "6", - "when": 1754745234572 + "when": 1766588456958, + "tag": "0000_init", + "breakpoints": true } - ], - "version": "7" + ] } diff --git a/src/main/data/db/schemas/entityTag.ts b/src/main/data/db/schemas/entityTag.ts new file mode 100644 index 0000000000..e041d771db --- /dev/null +++ b/src/main/data/db/schemas/entityTag.ts @@ -0,0 +1,26 @@ +import { index, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps } from './columnHelpers' +import { tagTable } from './tag' + +/** + * Entity-Tag join table - associates tags with entities + * + * Supports many-to-many relationship between tags and + * various entity types (topic, session, assistant). + */ +export const entityTagTable = sqliteTable( + 'entity_tag', + { + // Entity type: topic, session, assistant + entityType: text().notNull(), + // FK to the entity + entityId: text().notNull(), + // FK to tag table - CASCADE: delete association when tag is deleted + tagId: text() + .notNull() + .references(() => tagTable.id, { onDelete: 'cascade' }), + ...createUpdateTimestamps + }, + (t) => [primaryKey({ columns: [t.entityType, t.entityId, t.tagId] }), index('entity_tag_tag_id_idx').on(t.tagId)] +) diff --git a/src/main/data/db/schemas/group.ts b/src/main/data/db/schemas/group.ts new file mode 100644 index 0000000000..dc7bd088c2 --- /dev/null +++ b/src/main/data/db/schemas/group.ts @@ -0,0 +1,24 @@ +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps } from './columnHelpers' + +/** + * Group table - general-purpose grouping for entities + * + * Supports grouping of topics, sessions, and assistants. + * Each group belongs to a specific entity type. + */ +export const groupTable = sqliteTable( + 'group', + { + id: text().primaryKey(), + // Entity type this group belongs to: topic, session, assistant + entityType: text().notNull(), + // Display name of the group + name: text().notNull(), + // Sort order for display + sortOrder: integer().default(0), + ...createUpdateTimestamps + }, + (t) => [index('group_entity_sort_idx').on(t.entityType, t.sortOrder)] +) diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts new file mode 100644 index 0000000000..d5a644bcce --- /dev/null +++ b/src/main/data/db/schemas/message.ts @@ -0,0 +1,60 @@ +import { sql } from 'drizzle-orm' +import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateDeleteTimestamps } from './columnHelpers' +import { topicTable } from './topic' + +/** + * Message table - stores chat messages with tree structure + * + * Uses adjacency list pattern (parentId) for tree navigation. + * Block content is stored as JSON in the data field. + * searchableText is a generated column for FTS5 indexing. + */ +export const messageTable = sqliteTable( + 'message', + { + id: text().primaryKey(), + // FK to topic - CASCADE: delete messages when topic is deleted + topicId: text() + .notNull() + .references(() => topicTable.id, { onDelete: 'cascade' }), + // Adjacency list parent reference for tree structure + // SET NULL: preserve child messages when parent is deleted + parentId: text().references(() => messageTable.id, { onDelete: 'set null' }), + // Group ID for multi-model responses (0 = normal branch) + responseGroupId: integer().default(0), + // Message role: user, assistant, system + role: text().notNull(), + // Final status: SUCCESS, ERROR, PAUSED + status: text().notNull(), + // FK to assistant + assistantId: text(), + // Preserved assistant info for display + assistantMeta: text({ mode: 'json' }), + // Model identifier + modelId: text(), + // Preserved model info (provider, name) + modelMeta: text({ mode: 'json' }), + // Main content - contains blocks[], mentions, etc. + data: text({ mode: 'json' }).notNull(), + // Token usage statistics + usage: text({ mode: 'json' }), + // Performance metrics + metrics: text({ mode: 'json' }), + // Trace ID for tracking + traceId: text(), + // Searchable text extracted from data.blocks (populated by trigger, used for FTS5) + searchableText: text(), + ...createUpdateDeleteTimestamps + }, + (t) => [ + // Indexes + index('message_parent_id_idx').on(t.parentId), + index('message_topic_created_idx').on(t.topicId, t.createdAt), + index('message_trace_id_idx').on(t.traceId), + // Check constraints for enum fields + check('message_role_check', sql`${t.role} IN ('user', 'assistant', 'system')`), + check('message_status_check', sql`${t.status} IN ('success', 'error', 'paused')`) + ] +) diff --git a/src/main/data/db/schemas/messageFts.ts b/src/main/data/db/schemas/messageFts.ts new file mode 100644 index 0000000000..e87bcf0010 --- /dev/null +++ b/src/main/data/db/schemas/messageFts.ts @@ -0,0 +1,94 @@ +/** + * FTS5 SQL statements for message full-text search + * + * This file contains SQL statements that must be manually added to migration files. + * Drizzle does not auto-generate virtual tables or triggers. + * + * Architecture: + * 1. message.searchable_text - regular column populated by trigger + * 2. message_fts - FTS5 virtual table with external content + * 3. Triggers sync both searchable_text and FTS5 index + * + * Usage: + * - Copy MESSAGE_FTS_MIGRATION_SQL to migration file when generating migrations + */ + +/** + * SQL expression to extract searchable text from data.blocks + * Concatenates content from all main_text type blocks + */ +export const SEARCHABLE_TEXT_EXPRESSION = ` + (SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text') +` + +/** + * Migration SQL - Copy these statements to migration file + */ +export const MESSAGE_FTS_MIGRATION_SQL = ` +--> statement-breakpoint +-- ============================================================ +-- FTS5 Virtual Table and Triggers for Message Full-Text Search +-- ============================================================ + +-- 1. Create FTS5 virtual table with external content +-- Links to message table's searchable_text column +CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( + searchable_text, + content='message', + content_rowid='rowid', + tokenize='trigram' +);--> statement-breakpoint + +-- 2. Trigger: populate searchable_text and sync FTS on INSERT +CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN + -- Extract searchable text from data.blocks + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + -- Sync to FTS5 + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; +END;--> statement-breakpoint + +-- 3. Trigger: sync FTS on DELETE +CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); +END;--> statement-breakpoint + +-- 4. Trigger: update searchable_text and sync FTS on UPDATE OF data +CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN + -- Remove old FTS entry + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); + -- Update searchable_text + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + -- Add new FTS entry + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; +END; +` + +/** + * Rebuild FTS index (run manually if needed) + */ +export const REBUILD_FTS_SQL = `INSERT INTO message_fts(message_fts) VALUES ('rebuild')` + +/** + * Example search query + */ +export const EXAMPLE_SEARCH_SQL = ` +SELECT m.* +FROM message m +JOIN message_fts fts ON m.rowid = fts.rowid +WHERE message_fts MATCH ? +ORDER BY rank +` diff --git a/src/main/data/db/schemas/preference.ts b/src/main/data/db/schemas/preference.ts index f41cf175c4..5ca9b2f14a 100644 --- a/src/main/data/db/schemas/preference.ts +++ b/src/main/data/db/schemas/preference.ts @@ -1,14 +1,14 @@ -import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { createUpdateTimestamps } from './columnHelpers' export const preferenceTable = sqliteTable( 'preference', { - scope: text().notNull(), // scope is reserved for future use, now only 'default' is supported + scope: text().notNull().default('default'), // scope is reserved for future use, now only 'default' is supported key: text().notNull(), value: text({ mode: 'json' }), ...createUpdateTimestamps }, - (t) => [index('scope_name_idx').on(t.scope, t.key)] + (t) => [primaryKey({ columns: [t.scope, t.key] })] ) diff --git a/src/main/data/db/schemas/tag.ts b/src/main/data/db/schemas/tag.ts new file mode 100644 index 0000000000..8a84e6d704 --- /dev/null +++ b/src/main/data/db/schemas/tag.ts @@ -0,0 +1,18 @@ +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps } from './columnHelpers' + +/** + * Tag table - general-purpose tags for entities + * + * Tags can be applied to topics, sessions, and assistants + * via the entity_tag join table. + */ +export const tagTable = sqliteTable('tag', { + id: text().primaryKey(), + // Unique tag name + name: text().notNull().unique(), + // Display color (hex code) + color: text(), + ...createUpdateTimestamps +}) diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts new file mode 100644 index 0000000000..2f08d58fd2 --- /dev/null +++ b/src/main/data/db/schemas/topic.ts @@ -0,0 +1,41 @@ +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateDeleteTimestamps } from './columnHelpers' +import { groupTable } from './group' + +/** + * Topic table - stores conversation topics/threads + * + * Topics are containers for messages and belong to assistants. + * They can be organized into groups and have tags for categorization. + */ +export const topicTable = sqliteTable( + 'topic', + { + id: text().primaryKey(), + name: text(), + assistantId: text(), + // Preserved assistant info for display when assistant is deleted + assistantMeta: text({ mode: 'json' }), + // Topic-specific prompt override + prompt: text(), + // FK to group table for organization + // SET NULL: preserve topic when group is deleted + groupId: text().references(() => groupTable.id, { onDelete: 'set null' }), + // Pinning state and order + isPinned: integer({ mode: 'boolean' }).default(false), + pinnedOrder: integer().default(0), + // Sort order within group + sortOrder: integer().default(0), + // Whether the name was manually edited by user + isNameManuallyEdited: integer({ mode: 'boolean' }).default(false), + ...createUpdateDeleteTimestamps + }, + (t) => [ + index('topic_group_updated_idx').on(t.groupId, t.updatedAt), + index('topic_group_sort_idx').on(t.groupId, t.sortOrder), + index('topic_updated_at_idx').on(t.updatedAt), + index('topic_is_pinned_idx').on(t.isPinned, t.pinnedOrder), + index('topic_assistant_id_idx').on(t.assistantId) + ] +) From 27ab7ea35cf707221ac6a140ae85973beabfcb03 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 25 Dec 2025 08:42:35 +0800 Subject: [PATCH 020/116] feat(database): enhance app lifecycle management with error handling during initialization - Added error handling for database initialization and migration processes. - Introduced user feedback via dialog box for initialization failures, guiding users to delete the database file if necessary. - Marked a temporary solution for data migration v2, indicating future refactoring plans for app lifecycle management. --- src/main/index.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index be9eae582a..ecbe17a5ba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,10 +135,25 @@ if (!app.requestSingleInstanceLock()) { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { + + //TODO v2 Data Refactor: App Lifecycle Management + // This is the temporary solution for the data migration v2. + // We will refactor the app lifecycle management after the data migration v2 is stable. + // First of all, init & migrate the database - await dbService.init() - await dbService.migrateDb() - await dbService.migrateSeed('preference') + try { + + await dbService.init() + await dbService.migrateDb() + await dbService.migrateSeed('preference') + + } catch (error) { + logger.error('Failed to initialize database', error as Error) + //TODO for v2 testing only: + await dialog.showErrorBox('Database Initialization Failed', 'Before the official release of the alpha version, the database structure may change at any time. To maintain simplicity, the database migration files will be periodically reinitialized, which may cause the application to fail. If this occurs, please delete the cherrystudio.sqlite file located in the user data directory.') + app.quit() + return + } // Data Migration v2 // Check if data migration is needed BEFORE creating any windows From 663308233509aa9736889b9140daf109afa43de1 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 25 Dec 2025 08:43:27 +0800 Subject: [PATCH 021/116] fix: format --- src/main/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ecbe17a5ba..3d5edc3946 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,22 +135,22 @@ if (!app.requestSingleInstanceLock()) { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(async () => { - //TODO v2 Data Refactor: App Lifecycle Management // This is the temporary solution for the data migration v2. // We will refactor the app lifecycle management after the data migration v2 is stable. // First of all, init & migrate the database try { - await dbService.init() await dbService.migrateDb() await dbService.migrateSeed('preference') - } catch (error) { logger.error('Failed to initialize database', error as Error) //TODO for v2 testing only: - await dialog.showErrorBox('Database Initialization Failed', 'Before the official release of the alpha version, the database structure may change at any time. To maintain simplicity, the database migration files will be periodically reinitialized, which may cause the application to fail. If this occurs, please delete the cherrystudio.sqlite file located in the user data directory.') + await dialog.showErrorBox( + 'Database Initialization Failed', + 'Before the official release of the alpha version, the database structure may change at any time. To maintain simplicity, the database migration files will be periodically reinitialized, which may cause the application to fail. If this occurs, please delete the cherrystudio.sqlite file located in the user data directory.' + ) app.quit() return } From 4ba0f2d25c7746fd1215892412a4ebc6b597311e Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 25 Dec 2025 13:26:32 +0800 Subject: [PATCH 022/116] fix: correct aihubmix anthropic API path (#12115) Remove incorrect /anthropic suffix from aihubmix provider's anthropicApiHost configuration. The correct API endpoint should be https://aihubmix.com/v1/messages, not https://aihubmix.com/anthropic/v1/messages. Fixes issue where Claude API requests to aihubmix provider were failing due to incorrect URL path. --- src/renderer/src/config/providers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 1adeb58ad0..ed618f909c 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -107,7 +107,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://aihubmix.com', - anthropicApiHost: 'https://aihubmix.com/anthropic', + anthropicApiHost: 'https://aihubmix.com', models: SYSTEM_MODELS.aihubmix, isSystem: true, enabled: false From 0669253abbada00fe81742b166bea8cc43bf0042 Mon Sep 17 00:00:00 2001 From: Caelan <79105826+jin-wang-c@users.noreply.github.com> Date: Thu, 25 Dec 2025 13:46:33 +0800 Subject: [PATCH 023/116] feat:dmx-painting-add-extend_params (#12098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dmx-painting-add-extend_params * format-code * 更新类型 --- .../src/pages/paintings/DmxapiPage.tsx | 31 +++++++++++++------ .../pages/paintings/config/DmxapiConfig.ts | 2 +- src/renderer/src/types/index.ts | 1 + 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx index 560e3857ba..e4f8323655 100644 --- a/src/renderer/src/pages/paintings/DmxapiPage.tsx +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -140,11 +140,14 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { let model = '' let priceModel = '' let image_size = '' + let extend_params = {} + for (const provider of Object.keys(modelGroups)) { if (modelGroups[provider] && modelGroups[provider].length > 0) { model = modelGroups[provider][0].id priceModel = modelGroups[provider][0].price image_size = modelGroups[provider][0].image_sizes[0].value + extend_params = modelGroups[provider][0].extend_params break } } @@ -153,7 +156,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { model, priceModel, image_size, - modelGroups + modelGroups, + extend_params } } @@ -162,7 +166,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const generationMode = params?.generationMode || painting?.generationMode || MODEOPTIONS[0].value - const { model, priceModel, image_size, modelGroups } = getFirstModelInfo(generationMode) + const { model, priceModel, image_size, modelGroups, extend_params } = getFirstModelInfo(generationMode) return { ...DEFAULT_PAINTING, @@ -173,6 +177,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { modelGroups, priceModel, image_size, + extend_params, ...params } } @@ -190,7 +195,12 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const onSelectModel = (modelId: string) => { const model = allModels.find((m) => m.id === modelId) if (model) { - updatePaintingState({ model: modelId, priceModel: model.price, image_size: model.image_sizes[0].value }) + updatePaintingState({ + model: modelId, + priceModel: model.price, + image_size: model.image_sizes[0].value, + extend_params: model.extend_params + }) } } @@ -293,7 +303,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { clearImages() - const { model, priceModel, image_size, modelGroups } = getFirstModelInfo(v) + const { model, priceModel, image_size, modelGroups, extend_params } = getFirstModelInfo(v) setModelOptions(modelGroups) @@ -309,9 +319,10 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { // 否则更新当前painting updatePaintingState({ generationMode: v, - model: model, - image_size: image_size, - priceModel: priceModel + model, + image_size, + priceModel, + extend_params }) } } @@ -355,7 +366,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const params = { prompt, model: painting.model, - n: painting.n + n: painting.n, + ...painting?.extend_params } const headerExpand = { @@ -397,7 +409,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const params = { prompt, n: painting.n, - model: painting.model + model: painting.model, + ...painting?.extend_params } if (painting.image_size) { diff --git a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts index 7880f6305c..52af9490c8 100644 --- a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts +++ b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts @@ -84,7 +84,7 @@ export const MODEOPTIONS = [ // 获取模型分组数据 export const GetModelGroup = async (): Promise => { try { - const response = await fetch('https://dmxapi.cn/cherry_painting_models_v2.json') + const response = await fetch('https://dmxapi.cn/cherry_painting_models_v3.json') if (response.ok) { const data = await response.json() diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 126c97686e..a75fc1ed3e 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -395,6 +395,7 @@ export interface DmxapiPainting extends PaintingParams { autoCreate?: boolean generationMode?: generationModeType priceModel?: string + extend_params?: Record } export interface TokenFluxPainting extends PaintingParams { From 05dfb459a6c8faf52868e57bf5c2bc40f928157a Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Thu, 25 Dec 2025 14:41:52 +0800 Subject: [PATCH 024/116] chore: release v1.7.7 --- electron-builder.yml | 74 +++++++++++++++++++++++++++++++------------- package.json | 2 +- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index f362542b9a..11dce735c5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,38 +134,68 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.6 - New Models & MCP Enhancements + Cherry Studio 1.7.7 - New Models & UI Improvements - This release adds support for new AI models and includes a new MCP server for memory management. + This release adds new AI model support, OpenRouter integration, and UI redesigns. ✨ New Features - - [Models] Add support for Xiaomi MiMo model - - [Models] Add support for Gemini 3 Flash and Pro model detection - - [Models] Add support for Volcengine Doubao-Seed-1.8 model - - [MCP] Add Nowledge Mem builtin MCP server for memory management - - [Settings] Add default reasoning effort option to resolve confusion between undefined and none + - [Models] Add GLM-4.7 and MiniMax-M2.1 model support + - [Provider] Add OpenRouter provider support + - [OVMS] Upgrade to 2025.4 with Qwen3-4B-int4-ov preset model + - [OVMS] Close OVMS process when app quits + - [Search] Show keyword-adjacent snippets in history search + - [Painting] Add extend_params support for DMX painting + - [UI] Add MCP logo and replace Hammer icon + + 🎨 UI Improvements + - [Notes] Move notes settings to popup in NotesPage for quick access + - [WebSearch] Redesign settings with two-column layout and "Set as Default" button + - [Display] Improve font selector for long font names + - [Transfer] Rename LanDrop to LanTransfer 🐛 Bug Fixes - - [Azure] Restore deployment-based URLs for non-v1 apiVersion - - [Translation] Disable reasoning mode for translation to improve efficiency - - [Image] Update API path for image generation requests in OpenAIBaseClient - - [Windows] Auto-discover and persist Git Bash path on Windows for scoop users + - [API] Correct aihubmix Anthropic API path + - [OpenRouter] Support GPT-5.1/5.2 reasoning effort 'none' and improve error handling + - [Thinking] Fix interleaved thinking support + - [Memory] Fix retrieval issues and enable database backup + - [Settings] Update default assistant settings to disable temperature + - [OpenAI] Add persistent server configuration support + - [Azure] Normalize Azure endpoint + - [MCP] Check system npx/uvx before falling back to bundled binaries + - [Prompt] Improve language instruction clarity + - [Models] Include GPT5.2 series in verbosity check + - [URL] Enhance urlContext validation for supported providers and models - Cherry Studio 1.7.6 - 新模型与 MCP 增强 + Cherry Studio 1.7.7 - 新模型与界面改进 - 本次更新添加了多个新 AI 模型支持,并新增记忆管理 MCP 服务器。 + 本次更新添加了新 AI 模型支持、OpenRouter 集成以及界面重新设计。 ✨ 新功能 - - [模型] 添加小米 MiMo 模型支持 - - [模型] 添加 Gemini 3 Flash 和 Pro 模型检测支持 - - [模型] 添加火山引擎 Doubao-Seed-1.8 模型支持 - - [MCP] 新增 Nowledge Mem 内置 MCP 服务器,用于记忆管理 - - [设置] 添加默认推理强度选项,解决 undefined 和 none 之间的混淆 + - [模型] 添加 GLM-4.7 和 MiniMax-M2.1 模型支持 + - [服务商] 添加 OpenRouter 服务商支持 + - [OVMS] 升级至 2025.4,新增 Qwen3-4B-int4-ov 预设模型 + - [OVMS] 应用退出时关闭 OVMS 进程 + - [搜索] 历史搜索显示关键词上下文片段 + - [绘图] DMX 绘图添加扩展参数支持 + - [界面] 添加 MCP 图标并替换锤子图标 + + 🎨 界面改进 + - [笔记] 将笔记设置移至笔记页弹窗,快速访问无需离开当前页面 + - [网页搜索] 采用两栏布局重新设计设置界面,添加"设为默认"按钮 + - [显示] 改进长字体名称的字体选择器 + - [传输] LanDrop 重命名为 LanTransfer 🐛 问题修复 - - [Azure] 修复非 v1 apiVersion 的部署 URL 问题 - - [翻译] 禁用翻译时的推理模式以提高效率 - - [图像] 更新 OpenAIBaseClient 中图像生成请求的 API 路径 - - [Windows] 自动发现并保存 Windows scoop 用户的 Git Bash 路径 + - [API] 修复 aihubmix Anthropic API 路径 + - [OpenRouter] 支持 GPT-5.1/5.2 reasoning effort 'none' 并改进错误处理 + - [思考] 修复交错思考支持 + - [记忆] 修复检索问题并启用数据库备份 + - [设置] 更新默认助手设置禁用温度 + - [OpenAI] 添加持久化服务器配置支持 + - [Azure] 规范化 Azure 端点 + - [MCP] 优先检查系统 npx/uvx 再回退到内置二进制文件 + - [提示词] 改进语言指令清晰度 + - [模型] GPT5.2 系列添加到 verbosity 检查 + - [URL] 增强 urlContext 对支持的服务商和模型的验证 diff --git a/package.json b/package.json index 3cf60b9f18..7ad4140af7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.6", + "version": "1.7.7", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 4ae9bf8ff49fd910402bd38b6e91d3ee27ff122b Mon Sep 17 00:00:00 2001 From: jardel Date: Thu, 25 Dec 2025 16:59:13 +0800 Subject: [PATCH 025/116] fix: allow more file extensions (#12099) Co-authored-by: icarus --- src/main/services/FileStorage.ts | 38 +++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 78bffa6692..f0b7ce32b0 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { checkName, getFilesDir, - getFileType, + getFileType as getFileTypeByExt, getName, getNotesDir, getTempDir, @@ -11,13 +11,13 @@ import { } from '@main/utils/file' import { documentExts, imageExts, KB, MB } from '@shared/config/constant' import type { FileMetadata, NotesTreeNode } from '@types' +import { FileTypes } from '@types' import chardet from 'chardet' import type { FSWatcher } from 'chokidar' import chokidar from 'chokidar' import * as crypto from 'crypto' import type { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' -import { app } from 'electron' -import { dialog, net, shell } from 'electron' +import { app, dialog, net, shell } from 'electron' import * as fs from 'fs' import { writeFileSync } from 'fs' import { readFile } from 'fs/promises' @@ -185,7 +185,7 @@ class FileStorage { }) } - findDuplicateFile = async (filePath: string): Promise => { + private findDuplicateFile = async (filePath: string): Promise => { const stats = fs.statSync(filePath) logger.debug(`stats: ${stats}, filePath: ${filePath}`) const fileSize = stats.size @@ -204,6 +204,8 @@ class FileStorage { if (originalHash === storedHash) { const ext = path.extname(file) const id = path.basename(file, ext) + const type = await this.getFileType(filePath) + return { id, origin_name: file, @@ -212,7 +214,7 @@ class FileStorage { created_at: storedStats.birthtime.toISOString(), size: storedStats.size, ext, - type: getFileType(ext), + type, count: 2 } } @@ -222,6 +224,13 @@ class FileStorage { return null } + public getFileType = async (filePath: string): Promise => { + const ext = path.extname(filePath) + const fileType = getFileTypeByExt(ext) + + return fileType === FileTypes.OTHER && (await this._isTextFile(filePath)) ? FileTypes.TEXT : fileType + } + public selectFile = async ( _: Electron.IpcMainInvokeEvent, options?: OpenDialogOptions @@ -241,7 +250,7 @@ class FileStorage { const fileMetadataPromises = result.filePaths.map(async (filePath) => { const stats = fs.statSync(filePath) const ext = path.extname(filePath) - const fileType = getFileType(ext) + const fileType = await this.getFileType(filePath) return { id: uuidv4(), @@ -307,7 +316,7 @@ class FileStorage { } const stats = await fs.promises.stat(destPath) - const fileType = getFileType(ext) + const fileType = await this.getFileType(destPath) const fileMetadata: FileMetadata = { id: uuid, @@ -332,8 +341,7 @@ class FileStorage { } const stats = fs.statSync(filePath) - const ext = path.extname(filePath) - const fileType = getFileType(ext) + const fileType = await this.getFileType(filePath) return { id: uuidv4(), @@ -342,7 +350,7 @@ class FileStorage { path: filePath, created_at: stats.birthtime.toISOString(), size: stats.size, - ext: ext, + ext: path.extname(filePath), type: fileType, count: 1 } @@ -690,7 +698,7 @@ class FileStorage { created_at: new Date().toISOString(), size: buffer.length, ext: ext.slice(1), - type: getFileType(ext), + type: getFileTypeByExt(ext), count: 1 } } catch (error) { @@ -740,7 +748,7 @@ class FileStorage { created_at: new Date().toISOString(), size: stats.size, ext: ext.slice(1), - type: getFileType(ext), + type: getFileTypeByExt(ext), count: 1 } } catch (error) { @@ -1317,7 +1325,7 @@ class FileStorage { await fs.promises.writeFile(destPath, buffer) const stats = await fs.promises.stat(destPath) - const fileType = getFileType(ext) + const fileType = await this.getFileType(destPath) return { id: uuid, @@ -1604,6 +1612,10 @@ class FileStorage { } public isTextFile = async (_: Electron.IpcMainInvokeEvent, filePath: string): Promise => { + return this._isTextFile(filePath) + } + + private _isTextFile = async (filePath: string): Promise => { try { const isBinary = await isBinaryFile(filePath) if (isBinary) { From 8292958c0d3623f473c55612aede23dc757e1f7c Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 25 Dec 2025 21:52:07 +0800 Subject: [PATCH 026/116] feat(database): `message`.`stats` and related message type definitions - Changed migration command from `yarn run migrations:generate` to `yarn run db:migrations:generate` for consistency across the project. - Updated related documentation in `CLAUDE.md`, `migrations/README.md`, and `src/main/data/README.md` to reflect the new command. - Added a notice in `migrations/README.md` regarding potential database structure changes before the alpha release. --- CLAUDE.md | 2 +- migrations/README.md | 6 +- .../sqlite-drizzle/0001_faulty_ogun.sql | 3 + .../sqlite-drizzle/meta/0001_snapshot.json | 655 ++++++++++++++++++ migrations/sqlite-drizzle/meta/_journal.json | 9 +- package.json | 2 +- packages/shared/data/README.md | 35 +- packages/shared/data/types/message.ts | 171 +++++ src/main/data/README.md | 2 +- src/main/data/db/schemas/message.ts | 9 +- src/renderer/src/types/newMessage.ts | 2 + 11 files changed, 874 insertions(+), 22 deletions(-) create mode 100644 migrations/sqlite-drizzle/0001_faulty_ogun.sql create mode 100644 migrations/sqlite-drizzle/meta/0001_snapshot.json create mode 100644 packages/shared/data/types/message.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5c942b83d4..baa91b4834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,7 @@ UI Library: `@packages/ui` - **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition - **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically - **Timestamps**: Use existing `crudTimestamps` utility -- **Migrations**: Generate via `yarn run migrations:generate` +- **Migrations**: Generate via `yarn run db:migrations:generate` ## Data Access Patterns diff --git a/migrations/README.md b/migrations/README.md index fc11adc188..5ade119ec8 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,6 +1,10 @@ **THIS DIRECTORY IS NOT FOR RUNTIME USE** +**v2 Data Refactoring Notice** +Before the official release of the alpha version, the database structure may change at any time. To maintain simplicity, the database migration files will be periodically reinitialized, which may cause the application to fail. If this occurs, please delete the `cherrystudio.sqlite` file located in the user data directory. + - Using `libsql` as the `sqlite3` driver, and `drizzle` as the ORM and database migration tool +- Table schemas are defined in `src\main\data\db\schemas` - `migrations/sqlite-drizzle` contains auto-generated migration data. Please **DO NOT** modify it. - If table structure changes, we should run migrations. -- To generate migrations, use the command `yarn run migrations:generate` +- To generate migrations, use the command `yarn run db:migrations:generate` diff --git a/migrations/sqlite-drizzle/0001_faulty_ogun.sql b/migrations/sqlite-drizzle/0001_faulty_ogun.sql new file mode 100644 index 0000000000..969e386dcb --- /dev/null +++ b/migrations/sqlite-drizzle/0001_faulty_ogun.sql @@ -0,0 +1,3 @@ +ALTER TABLE `message` ADD `stats` text;--> statement-breakpoint +ALTER TABLE `message` DROP COLUMN `usage`;--> statement-breakpoint +ALTER TABLE `message` DROP COLUMN `metrics`; \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..533c642b04 --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0001_snapshot.json @@ -0,0 +1,655 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ae53858a-1786-4059-9ff7-9e87267911b6", + "prevId": "62a198e0-bfc2-4db1-af58-7e479fedd7b9", + "tables": { + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": [ + "entity_type", + "entity_id", + "tag_id" + ], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": [ + "entity_type", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_group_id": { + "name": "response_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": [ + "topic_id", + "created_at" + ], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" + } + } + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": [ + "scope", + "key" + ], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": [ + "group_id", + "updated_at" + ], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": [ + "group_id", + "sort_order" + ], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": [ + "is_pinned", + "pinned_order" + ], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": [ + "assistant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index 781e2d3d99..c42fbfed83 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1766588456958, "tag": "0000_init", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1766670360754, + "tag": "0001_faulty_ogun", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index fe81ff1ebf..d1769d1f1c 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "format:check": "biome format && biome lint", "prepare": "git config blame.ignoreRevsFile .git-blame-ignore-revs && husky", "claude": "dotenv -e .env -- claude", - "migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts", + "db:migrations:generate": "drizzle-kit generate --config ./migrations/sqlite-drizzle.config.ts", "release:aicore:alpha": "yarn workspace @cherrystudio/ai-core version prerelease --preid alpha --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag alpha --access public", "release:aicore:beta": "yarn workspace @cherrystudio/ai-core version prerelease --preid beta --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --tag beta --access public", "release:aicore": "yarn workspace @cherrystudio/ai-core version patch --immediate && yarn workspace @cherrystudio/ai-core build && yarn workspace @cherrystudio/ai-core npm publish --access public", diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md index b65af18e33..c522d3138c 100644 --- a/packages/shared/data/README.md +++ b/packages/shared/data/README.md @@ -7,25 +7,31 @@ This directory contains shared type definitions and schemas for the Cherry Studi ``` packages/shared/data/ ├── api/ # Data API type system -│ ├── index.ts # Barrel exports for clean imports -│ ├── apiSchemas.ts # API endpoint definitions and mappings -│ ├── apiTypes.ts # Core request/response infrastructure types -│ ├── apiModels.ts # Business entity types and DTOs -│ ├── apiPaths.ts # API path definitions and utilities -│ └── errorCodes.ts # Standardized error handling +│ ├── index.ts # Barrel exports for clean imports +│ ├── apiSchemas.ts # API endpoint definitions and mappings +│ ├── apiTypes.ts # Core request/response infrastructure types +│ ├── apiModels.ts # Business entity types and DTOs +│ ├── apiPaths.ts # API path definitions and utilities +│ └── errorCodes.ts # Standardized error handling ├── cache/ # Cache system type definitions -│ ├── cacheTypes.ts # Core cache infrastructure types -│ ├── cacheSchemas.ts # Cache key schemas and type mappings -│ └── cacheValueTypes.ts # Cache value type definitions +│ ├── cacheTypes.ts # Core cache infrastructure types +│ ├── cacheSchemas.ts # Cache key schemas and type mappings +│ └── cacheValueTypes.ts # Cache value type definitions ├── preference/ # Preference system type definitions -│ ├── preferenceTypes.ts # Core preference system types +│ ├── preferenceTypes.ts # Core preference system types │ └── preferenceSchemas.ts # Preference schemas and default values -└── README.md # This file +├── types/ # Shared data types for Main/Renderer +└── README.md # This file ``` ## 🏗️ System Overview -This directory provides type definitions for three main data management systems: +This directory provides type definitions for four main data management systems: + +### Types System (`types/`) +- **Purpose**: Shared data types for cross-process (Main/Renderer) communication and database schemas +- **Features**: Database table field types, business entity definitions +- **Usage**: Used in Drizzle ORM schemas via `.$type()` and runtime type checking ### API System (`api/`) - **Purpose**: Type-safe IPC communication between Main and Renderer processes @@ -72,6 +78,11 @@ import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data ## 🔧 Development Guidelines +### Adding Shared Types +1. Create or update type file in `types/` directory +2. Use camelCase for field names +3. Reference types in Drizzle schemas using `.$type()` + ### Adding Cache Types 1. Add cache key to `cache/cacheSchemas.ts` 2. Define value type in `cache/cacheValueTypes.ts` diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts new file mode 100644 index 0000000000..2c67d818e2 --- /dev/null +++ b/packages/shared/data/types/message.ts @@ -0,0 +1,171 @@ +/** + * Message Statistics - combines token usage and performance metrics + * Replaces the separate `usage` and `metrics` fields + */ +export interface MessageStats { + // Token consumption (from API response) + promptTokens?: number + completionTokens?: number + totalTokens?: number + thoughtsTokens?: number + + // Cost (calculated at message completion time) + cost?: number + + // Performance metrics (measured locally) + timeFirstTokenMs?: number + timeCompletionMs?: number + timeThinkingMs?: number +} + +// ============================================================================ +// Message Data +// ============================================================================ + +/** + * Message data field structure + * This is the type for the `data` column in the message table + */ +export interface MessageData { + blocks: MessageDataBlock[] +} + +//FIXME [v2] 注意,以下类型只是占位,接口未稳定,随时会变 + +// ============================================================================ +// Message Block +// ============================================================================ + +export enum BlockType { + UNKNOWN = 'unknown', + MAIN_TEXT = 'main_text', + THINKING = 'thinking', + TRANSLATION = 'translation', + IMAGE = 'image', + CODE = 'code', + TOOL = 'tool', + FILE = 'file', + ERROR = 'error', + CITATION = 'citation', + VIDEO = 'video', + COMPACT = 'compact' +} + +/** + * Base message block data structure + */ +export interface BaseBlock { + type: BlockType + createdAt: number // timestamp + updatedAt?: number + modelId?: string + metadata?: Record + error?: SerializedErrorData +} + +/** + * Serialized error for storage + */ +export interface SerializedErrorData { + name?: string + message: string + code?: string + stack?: string + cause?: unknown +} + +// Block type specific interfaces + +export interface UnknownBlock extends BaseBlock { + type: BlockType.UNKNOWN + content?: string +} + +export interface MainTextBlock extends BaseBlock { + type: BlockType.MAIN_TEXT + content: string + knowledgeBaseIds?: string[] + citationReferences?: { + citationBlockId?: string + citationBlockSource?: string + }[] +} + +export interface ThinkingBlock extends BaseBlock { + type: BlockType.THINKING + content: string + thinkingMs: number +} + +export interface TranslationBlock extends BaseBlock { + type: BlockType.TRANSLATION + content: string + sourceBlockId?: string + sourceLanguage?: string + targetLanguage: string +} + +export interface CodeBlock extends BaseBlock { + type: BlockType.CODE + content: string + language: string +} + +export interface ImageBlock extends BaseBlock { + type: BlockType.IMAGE + url?: string + fileId?: string +} + +export interface ToolBlock extends BaseBlock { + type: BlockType.TOOL + toolId: string + toolName?: string + arguments?: Record + content?: string | object +} + +export interface CitationBlock extends BaseBlock { + type: BlockType.CITATION + responseData?: unknown + knowledgeData?: unknown + memoriesData?: unknown +} + +export interface FileBlock extends BaseBlock { + type: BlockType.FILE + fileId: string +} + +export interface VideoBlock extends BaseBlock { + type: BlockType.VIDEO + url?: string + filePath?: string +} + +export interface ErrorBlock extends BaseBlock { + type: BlockType.ERROR +} + +export interface CompactBlock extends BaseBlock { + type: BlockType.COMPACT + content: string + compactedContent: string +} + +/** + * Union type of all message block data types + */ +export type MessageDataBlock = + | UnknownBlock + | MainTextBlock + | ThinkingBlock + | TranslationBlock + | CodeBlock + | ImageBlock + | ToolBlock + | CitationBlock + | FileBlock + | VideoBlock + | ErrorBlock + | CompactBlock diff --git a/src/main/data/README.md b/src/main/data/README.md index 7efff10113..be16e72bf3 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -239,7 +239,7 @@ import { dataApiService } from '@/data/DataApiService' ### Adding Database Tables 1. Create schema in `db/schemas/{tableName}.ts` -2. Generate migration: `yarn run migrations:generate` +2. Generate migration: `yarn run db:migrations:generate` 3. Add seeding data in `db/seeding/` if needed 4. Decide: Repository pattern or direct Drizzle? - Complex domain → Create repository in `repositories/` diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index d5a644bcce..91692ebd3f 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -1,3 +1,4 @@ +import type { MessageData, MessageStats } from '@shared/data/types/message' import { sql } from 'drizzle-orm' import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' @@ -37,11 +38,9 @@ export const messageTable = sqliteTable( // Preserved model info (provider, name) modelMeta: text({ mode: 'json' }), // Main content - contains blocks[], mentions, etc. - data: text({ mode: 'json' }).notNull(), - // Token usage statistics - usage: text({ mode: 'json' }), - // Performance metrics - metrics: text({ mode: 'json' }), + data: text({ mode: 'json' }).$type().notNull(), + // Statistics: token usage, performance metrics, etc. + stats: text({ mode: 'json' }).$type(), // Trace ID for tracking traceId: text(), // Searchable text extracted from data.blocks (populated by trigger, used for FTS5) diff --git a/src/renderer/src/types/newMessage.ts b/src/renderer/src/types/newMessage.ts index ef7179527d..4d63c2a8b4 100644 --- a/src/renderer/src/types/newMessage.ts +++ b/src/renderer/src/types/newMessage.ts @@ -1,3 +1,5 @@ +//TODO [v2] 类型将转移至 packages/shared/data/types/message.ts。 转移后此文件将废弃(deprecated) + import type { CompletionUsage } from '@cherrystudio/openai/resources' import type { ProviderMetadata } from 'ai' From 0f0e18231d1e384a6c0962390f5625a3cf4bb11d Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Fri, 26 Dec 2025 11:44:30 +0800 Subject: [PATCH 027/116] fix: update ollama provider type and increment store version to 190 - Changed ollama provider type from 'openai' to 'ollama' in SYSTEM_PROVIDERS_CONFIG. - Incremented persisted reducer version from 189 to 190. - Added migration logic for version 190 to update existing provider types in state. --- src/renderer/src/config/providers.ts | 2 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index ed618f909c..bae473a7d7 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -289,7 +289,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = ollama: { id: 'ollama', name: 'Ollama', - type: 'openai', + type: 'ollama', apiKey: '', apiHost: 'http://localhost:11434', models: SYSTEM_MODELS.ollama, diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 15f45648dc..8d8c793c21 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: 189, + version: 190, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 4b9d41b9e4..e0d3524f68 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3098,6 +3098,21 @@ const migrateConfig = { logger.error('migrate 189 error', error as Error) return state } + }, + // 1.7.8 + '190': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === SystemProviderIds.ollama) { + provider.type = 'ollama' + } + }) + logger.info('migrate 190 success') + return state + } catch (error) { + logger.error('migrate 190 error', error as Error) + return state + } } } From 18df6085d7d859bd5c87b19a9fcd90d8a52dad59 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 26 Dec 2025 12:52:32 +0800 Subject: [PATCH 028/116] refactor(dataApi): streamline Data API schema and type definitions - Removed outdated API model and schema files to simplify the structure. - Consolidated API types and schemas for better organization and clarity. - Updated import paths across the codebase to reflect the new structure. - Enhanced documentation in related README files to guide usage of the new API schema organization. --- .../sqlite-drizzle/meta/0001_snapshot.json | 94 +--- migrations/sqlite-drizzle/meta/_journal.json | 2 +- packages/shared/data/README.md | 66 +-- packages/shared/data/api/README.md | 189 +++++++ packages/shared/data/api/apiModels.ts | 107 ---- packages/shared/data/api/apiPaths.ts | 2 +- packages/shared/data/api/apiSchemas.ts | 487 ------------------ packages/shared/data/api/apiTypes.ts | 260 +++++++++- packages/shared/data/api/index.ts | 109 ++-- packages/shared/data/api/schemas/batch.ts | 140 +++++ packages/shared/data/api/schemas/index.ts | 43 ++ packages/shared/data/api/schemas/test.ts | 322 ++++++++++++ src/main/data/README.md | 51 +- src/main/data/api/core/ApiServer.ts | 2 +- src/main/data/api/handlers/batch.ts | 55 ++ src/main/data/api/handlers/index.ts | 224 +------- src/main/data/api/handlers/test.ts | 185 +++++++ src/main/data/api/index.ts | 2 +- src/renderer/src/data/DataApiService.ts | 2 +- src/renderer/src/data/hooks/useDataApi.ts | 2 +- tests/__mocks__/renderer/DataApiService.ts | 2 +- tests/__mocks__/renderer/useDataApi.ts | 2 +- 22 files changed, 1331 insertions(+), 1017 deletions(-) create mode 100644 packages/shared/data/api/README.md delete mode 100644 packages/shared/data/api/apiModels.ts delete mode 100644 packages/shared/data/api/apiSchemas.ts create mode 100644 packages/shared/data/api/schemas/batch.ts create mode 100644 packages/shared/data/api/schemas/index.ts create mode 100644 packages/shared/data/api/schemas/test.ts create mode 100644 src/main/data/api/handlers/batch.ts create mode 100644 src/main/data/api/handlers/test.ts diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json index 533c642b04..83ac3db1ac 100644 --- a/migrations/sqlite-drizzle/meta/0001_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0001_snapshot.json @@ -91,9 +91,7 @@ "indexes": { "entity_tag_tag_id_idx": { "name": "entity_tag_tag_id_idx", - "columns": [ - "tag_id" - ], + "columns": ["tag_id"], "isUnique": false } }, @@ -102,23 +100,15 @@ "name": "entity_tag_tag_id_tag_id_fk", "tableFrom": "entity_tag", "tableTo": "tag", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": { "entity_tag_entity_type_entity_id_tag_id_pk": { - "columns": [ - "entity_type", - "entity_id", - "tag_id" - ], + "columns": ["entity_type", "entity_id", "tag_id"], "name": "entity_tag_entity_type_entity_id_tag_id_pk" } }, @@ -175,10 +165,7 @@ "indexes": { "group_entity_sort_idx": { "name": "group_entity_sort_idx", - "columns": [ - "entity_type", - "sort_order" - ], + "columns": ["entity_type", "sort_order"], "isUnique": false } }, @@ -314,24 +301,17 @@ "indexes": { "message_parent_id_idx": { "name": "message_parent_id_idx", - "columns": [ - "parent_id" - ], + "columns": ["parent_id"], "isUnique": false }, "message_topic_created_idx": { "name": "message_topic_created_idx", - "columns": [ - "topic_id", - "created_at" - ], + "columns": ["topic_id", "created_at"], "isUnique": false }, "message_trace_id_idx": { "name": "message_trace_id_idx", - "columns": [ - "trace_id" - ], + "columns": ["trace_id"], "isUnique": false } }, @@ -340,12 +320,8 @@ "name": "message_topic_id_topic_id_fk", "tableFrom": "message", "tableTo": "topic", - "columnsFrom": [ - "topic_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -353,12 +329,8 @@ "name": "message_parent_id_message_id_fk", "tableFrom": "message", "tableTo": "message", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -420,10 +392,7 @@ "foreignKeys": {}, "compositePrimaryKeys": { "preference_scope_key_pk": { - "columns": [ - "scope", - "key" - ], + "columns": ["scope", "key"], "name": "preference_scope_key_pk" } }, @@ -472,9 +441,7 @@ "indexes": { "tag_name_unique": { "name": "tag_name_unique", - "columns": [ - "name" - ], + "columns": ["name"], "isUnique": true } }, @@ -585,40 +552,27 @@ "indexes": { "topic_group_updated_idx": { "name": "topic_group_updated_idx", - "columns": [ - "group_id", - "updated_at" - ], + "columns": ["group_id", "updated_at"], "isUnique": false }, "topic_group_sort_idx": { "name": "topic_group_sort_idx", - "columns": [ - "group_id", - "sort_order" - ], + "columns": ["group_id", "sort_order"], "isUnique": false }, "topic_updated_at_idx": { "name": "topic_updated_at_idx", - "columns": [ - "updated_at" - ], + "columns": ["updated_at"], "isUnique": false }, "topic_is_pinned_idx": { "name": "topic_is_pinned_idx", - "columns": [ - "is_pinned", - "pinned_order" - ], + "columns": ["is_pinned", "pinned_order"], "isUnique": false }, "topic_assistant_id_idx": { "name": "topic_assistant_id_idx", - "columns": [ - "assistant_id" - ], + "columns": ["assistant_id"], "isUnique": false } }, @@ -627,12 +581,8 @@ "name": "topic_group_id_group_id_fk", "tableFrom": "topic", "tableTo": "group", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["group_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -652,4 +602,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index c42fbfed83..960ba8a1e0 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md index c522d3138c..1be09a01c1 100644 --- a/packages/shared/data/README.md +++ b/packages/shared/data/README.md @@ -2,17 +2,20 @@ This directory contains shared type definitions and schemas for the Cherry Studio data management systems. These files provide type safety and consistency across the entire application. -## 📁 Directory Structure +## Directory Structure ``` packages/shared/data/ -├── api/ # Data API type system -│ ├── index.ts # Barrel exports for clean imports -│ ├── apiSchemas.ts # API endpoint definitions and mappings -│ ├── apiTypes.ts # Core request/response infrastructure types -│ ├── apiModels.ts # Business entity types and DTOs -│ ├── apiPaths.ts # API path definitions and utilities -│ └── errorCodes.ts # Standardized error handling +├── api/ # Data API type system (see api/README.md) +│ ├── index.ts # Barrel exports for infrastructure types +│ ├── apiTypes.ts # Core request/response types and utilities +│ ├── apiPaths.ts # Path template literal type utilities +│ ├── errorCodes.ts # Error handling utilities +│ ├── schemas/ # Domain-specific API schemas +│ │ ├── index.ts # Schema composition +│ │ ├── test.ts # Test API schema and DTOs +│ │ └── batch.ts # Batch/transaction operations +│ └── README.md # Detailed API documentation ├── cache/ # Cache system type definitions │ ├── cacheTypes.ts # Core cache infrastructure types │ ├── cacheSchemas.ts # Cache key schemas and type mappings @@ -24,7 +27,7 @@ packages/shared/data/ └── README.md # This file ``` -## 🏗️ System Overview +## System Overview This directory provides type definitions for four main data management systems: @@ -35,8 +38,8 @@ This directory provides type definitions for four main data management systems: ### API System (`api/`) - **Purpose**: Type-safe IPC communication between Main and Renderer processes -- **Features**: RESTful patterns, error handling, business entity definitions -- **Usage**: Ensures type safety for all data API operations +- **Features**: RESTful patterns, modular schema design, error handling +- **Documentation**: See [`api/README.md`](./api/README.md) for detailed usage ### Cache System (`cache/`) - **Purpose**: Type definitions for three-layer caching architecture @@ -48,7 +51,7 @@ This directory provides type definitions for four main data management systems: - **Features**: 158 configuration items, default values, nested key support - **Usage**: Type-safe preference access and synchronization -## 📋 File Categories +## File Categories **Framework Infrastructure** - These are TypeScript type definitions that: - ✅ Exist only at compile time @@ -56,12 +59,16 @@ This directory provides type definitions for four main data management systems: - ✅ Define contracts between application layers - ✅ Enable static analysis and error detection -## 📖 Usage Examples +## Usage Examples ### API Types ```typescript -// Import API types -import type { DataRequest, DataResponse, ApiSchemas } from '@shared/data/api' +// Infrastructure types from barrel export +import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api' +import { DataApiErrorFactory, ErrorCode } from '@shared/data/api' + +// Domain DTOs directly from schema files +import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' ``` ### Cache Types @@ -76,7 +83,7 @@ import type { UseCacheKey, UseSharedCacheKey } from '@shared/data/cache' import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data/preference' ``` -## 🔧 Development Guidelines +## Development Guidelines ### Adding Shared Types 1. Create or update type file in `types/` directory @@ -94,24 +101,25 @@ import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data 3. Preference system automatically picks up new keys ### Adding API Types -1. Define business entities in `api/apiModels.ts` -2. Add endpoint definitions to `api/apiSchemas.ts` -3. Export types from `api/index.ts` +1. Create schema file in `api/schemas/` (e.g., `topic.ts`) +2. Define domain models, DTOs, and API schema in the file +3. Register schema in `api/schemas/index.ts` using intersection type +4. See [`api/README.md`](./api/README.md) for detailed guide ### Best Practices - Use `import type` for type-only imports +- Infrastructure types from barrel, domain DTOs from schema files - Follow existing naming conventions - Document complex types with JSDoc -- Maintain type safety across all imports -## 🔗 Related Implementation +## Related Implementation -### Main Process Services -- `src/main/data/CacheService.ts` - Main process cache management -- `src/main/data/PreferenceService.ts` - Preference management service -- `src/main/data/DataApiService.ts` - Data API coordination service +### Main Process +- `src/main/data/api/` - API server, handlers, and IPC adapter +- `src/main/data/cache/` - Cache service implementation +- `src/main/data/preference/` - Preference service implementation -### Renderer Process Services -- `src/renderer/src/data/CacheService.ts` - Renderer cache service -- `src/renderer/src/data/PreferenceService.ts` - Renderer preference service -- `src/renderer/src/data/DataApiService.ts` - Renderer API client \ No newline at end of file +### Renderer Process +- `src/renderer/src/services/DataApiService.ts` - API client +- `src/renderer/src/services/CacheService.ts` - Cache service +- `src/renderer/src/services/PreferenceService.ts` - Preference service \ No newline at end of file diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md new file mode 100644 index 0000000000..b2e9c7da1e --- /dev/null +++ b/packages/shared/data/api/README.md @@ -0,0 +1,189 @@ +# Data API Type System + +This directory contains the type definitions and utilities for Cherry Studio's Data API system, which provides type-safe IPC communication between renderer and main processes. + +## Directory Structure + +``` +packages/shared/data/api/ +├── index.ts # Barrel export for infrastructure types +├── apiTypes.ts # Core request/response types and API utilities +├── apiPaths.ts # Path template literal type utilities +├── errorCodes.ts # Error handling utilities and factories +└── schemas/ + ├── index.ts # Schema composition (merges all domain schemas) + ├── test.ts # Test API schema and DTOs + └── batch.ts # Batch/transaction API schema +``` + +## File Responsibilities + +| File | Purpose | +|------|---------| +| `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities | +| `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) | +| `errorCodes.ts` | `DataApiErrorFactory`, error codes, and error handling utilities | +| `index.ts` | Unified export of infrastructure types (not domain DTOs) | +| `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types | +| `schemas/*.ts` | Domain-specific API definitions and DTOs | + +## Import Conventions + +### Infrastructure Types (via barrel export) + +Use the barrel export for common API infrastructure: + +```typescript +import type { + DataRequest, + DataResponse, + ApiClient, + PaginatedResponse, + ErrorCode +} from '@shared/data/api' + +import { DataApiErrorFactory, isDataApiError } from '@shared/data/api' +``` + +### Domain DTOs (directly from schema files) + +Import domain-specific types directly from their schema files: + +```typescript +// Topic domain +import type { Topic, CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topic' + +// Message domain +import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message' + +// Test domain (development) +import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' +``` + +## Adding a New Domain Schema + +1. Create the schema file (e.g., `schemas/topic.ts`): + +```typescript +import type { PaginatedResponse } from '../apiTypes' + +// Domain models +export interface Topic { + id: string + name: string + createdAt: string +} + +export interface CreateTopicDto { + name: string +} + +// API Schema - validation happens via AssertValidSchemas in index.ts +export interface TopicSchemas { + '/topics': { + GET: { + response: PaginatedResponse // response is required + } + POST: { + body: CreateTopicDto + response: Topic + } + } + '/topics/:id': { + GET: { + params: { id: string } + response: Topic + } + } +} +``` + +**Validation**: Schemas are validated at composition level via `AssertValidSchemas` in `schemas/index.ts`: +- Ensures only valid HTTP methods (GET, POST, PUT, DELETE, PATCH) +- Requires `response` field for each endpoint +- Invalid schemas cause TypeScript errors at the composition point + +2. Register in `schemas/index.ts`: + +```typescript +import type { TopicSchemas } from './topic' + +// AssertValidSchemas provides fallback validation even if ValidateSchema is forgotten +export type ApiSchemas = AssertValidSchemas +``` + +3. Implement handlers in `src/main/data/api/handlers/` + +## Type Safety Features + +### Path Resolution + +The system uses template literal types to map concrete paths to schema paths: + +```typescript +// Concrete path '/topics/abc123' maps to schema path '/topics/:id' +api.get('/topics/abc123') // TypeScript knows this returns Topic +``` + +### Exhaustive Handler Checking + +`ApiImplementation` type ensures all schema endpoints have handlers: + +```typescript +// TypeScript will error if any endpoint is missing +const handlers: ApiImplementation = { + '/topics': { + GET: async () => { /* ... */ }, + POST: async ({ body }) => { /* ... */ } + } + // Missing '/topics/:id' would cause compile error +} +``` + +### Type-Safe Client + +`ApiClient` provides fully typed methods: + +```typescript +const topic = await api.get('/topics/123') // Returns Topic +const topics = await api.get('/topics', { query: { page: 1 } }) // Returns PaginatedResponse +await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto +``` + +## Error Handling + +Use `DataApiErrorFactory` for consistent error creation: + +```typescript +import { DataApiErrorFactory, ErrorCode } from '@shared/data/api' + +// Create errors +throw DataApiErrorFactory.notFound('Topic', id) +throw DataApiErrorFactory.validationError('Name is required') +throw DataApiErrorFactory.fromCode(ErrorCode.DATABASE_ERROR, 'Connection failed') + +// Check errors +if (isDataApiError(error)) { + console.log(error.code, error.status) +} +``` + +## Architecture Overview + +``` +Renderer Main +──────────────────────────────────────────────────── +DataApiService ──IPC──► IpcAdapter ──► ApiServer + │ │ + │ ▼ + ApiClient MiddlewareEngine + (typed) │ + ▼ + Handlers + (typed) +``` + +- **Renderer**: Uses `DataApiService` with type-safe `ApiClient` interface +- **IPC**: Requests serialized via `IpcAdapter` +- **Main**: `ApiServer` routes to handlers through `MiddlewareEngine` +- **Type Safety**: End-to-end types from client call to handler implementation diff --git a/packages/shared/data/api/apiModels.ts b/packages/shared/data/api/apiModels.ts deleted file mode 100644 index 08107a9729..0000000000 --- a/packages/shared/data/api/apiModels.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Generic test model definitions - * Contains flexible types for comprehensive API testing - */ - -/** - * Generic test item entity - flexible structure for testing various scenarios - */ -export interface TestItem { - /** Unique identifier */ - id: string - /** Item title */ - title: string - /** Optional description */ - description?: string - /** Type category */ - type: string - /** Current status */ - status: string - /** Priority level */ - priority: string - /** Associated tags */ - tags: string[] - /** Creation timestamp */ - createdAt: string - /** Last update timestamp */ - updatedAt: string - /** Additional metadata */ - metadata: Record -} - -/** - * Data Transfer Objects (DTOs) for test operations - */ - -/** - * DTO for creating a new test item - */ -export interface CreateTestItemDto { - /** Item title */ - title: string - /** Optional description */ - description?: string - /** Type category */ - type?: string - /** Current status */ - status?: string - /** Priority level */ - priority?: string - /** Associated tags */ - tags?: string[] - /** Additional metadata */ - metadata?: Record -} - -/** - * DTO for updating an existing test item - */ -export interface UpdateTestItemDto { - /** Updated title */ - title?: string - /** Updated description */ - description?: string - /** Updated type */ - type?: string - /** Updated status */ - status?: string - /** Updated priority */ - priority?: string - /** Updated tags */ - tags?: string[] - /** Updated metadata */ - metadata?: Record -} - -/** - * Bulk operation types for batch processing - */ - -/** - * Request for bulk operations on multiple items - */ -export interface BulkOperationRequest { - /** Type of bulk operation to perform */ - operation: 'create' | 'update' | 'delete' | 'archive' | 'restore' - /** Array of data items to process */ - data: TData[] -} - -/** - * Response from a bulk operation - */ -export interface BulkOperationResponse { - /** Number of successfully processed items */ - successful: number - /** Number of items that failed processing */ - failed: number - /** Array of errors that occurred during processing */ - errors: Array<{ - /** Index of the item that failed */ - index: number - /** Error message */ - error: string - /** Optional additional error data */ - data?: any - }> -} diff --git a/packages/shared/data/api/apiPaths.ts b/packages/shared/data/api/apiPaths.ts index a947157869..7cd5397e02 100644 --- a/packages/shared/data/api/apiPaths.ts +++ b/packages/shared/data/api/apiPaths.ts @@ -1,4 +1,4 @@ -import type { ApiSchemas } from './apiSchemas' +import type { ApiSchemas } from './schemas' /** * Template literal type utilities for converting parameterized paths to concrete paths diff --git a/packages/shared/data/api/apiSchemas.ts b/packages/shared/data/api/apiSchemas.ts deleted file mode 100644 index e405af806e..0000000000 --- a/packages/shared/data/api/apiSchemas.ts +++ /dev/null @@ -1,487 +0,0 @@ -// NOTE: Types are defined inline in the schema for simplicity -// If needed, specific types can be imported from './apiModels' -import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths' -import type { HttpMethod, PaginatedResponse, PaginationParams } from './apiTypes' - -// Re-export for external use -export type { ConcreteApiPaths } from './apiPaths' - -/** - * Complete API Schema definitions for Test API - * - * Each path defines the supported HTTP methods with their: - * - Request parameters (params, query, body) - * - Response types - * - Type safety guarantees - * - * This schema serves as the contract between renderer and main processes, - * enabling full TypeScript type checking across IPC boundaries. - */ -export interface ApiSchemas { - /** - * Test items collection endpoint - * @example GET /test/items?page=1&limit=10&search=hello - * @example POST /test/items { "title": "New Test Item" } - */ - '/test/items': { - /** List all test items with optional filtering and pagination */ - GET: { - query?: PaginationParams & { - /** Search items by title or description */ - search?: string - /** Filter by item type */ - type?: string - /** Filter by status */ - status?: string - } - response: PaginatedResponse - } - /** Create a new test item */ - POST: { - body: { - title: string - description?: string - type?: string - status?: string - priority?: string - tags?: string[] - metadata?: Record - } - response: any - } - } - - /** - * Individual test item endpoint - * @example GET /test/items/123 - * @example PUT /test/items/123 { "title": "Updated Title" } - * @example DELETE /test/items/123 - */ - '/test/items/:id': { - /** Get a specific test item by ID */ - GET: { - params: { id: string } - response: any - } - /** Update a specific test item */ - PUT: { - params: { id: string } - body: { - title?: string - description?: string - type?: string - status?: string - priority?: string - tags?: string[] - metadata?: Record - } - response: any - } - /** Delete a specific test item */ - DELETE: { - params: { id: string } - response: void - } - } - - /** - * Test search endpoint - * @example GET /test/search?query=hello&page=1&limit=20 - */ - '/test/search': { - /** Search test items */ - GET: { - query: { - /** Search query string */ - query: string - /** Page number for pagination */ - page?: number - /** Number of results per page */ - limit?: number - /** Additional filters */ - type?: string - status?: string - } - response: PaginatedResponse - } - } - - /** - * Test statistics endpoint - * @example GET /test/stats - */ - '/test/stats': { - /** Get comprehensive test statistics */ - GET: { - response: { - /** Total number of items */ - total: number - /** Item count grouped by type */ - byType: Record - /** Item count grouped by status */ - byStatus: Record - /** Item count grouped by priority */ - byPriority: Record - /** Recent activity timeline */ - recentActivity: Array<{ - /** Date of activity */ - date: string - /** Number of items on that date */ - count: number - }> - } - } - } - - /** - * Test bulk operations endpoint - * @example POST /test/bulk { "operation": "create", "data": [...] } - */ - '/test/bulk': { - /** Perform bulk operations on test items */ - POST: { - body: { - /** Operation type */ - operation: 'create' | 'update' | 'delete' - /** Array of data items to process */ - data: any[] - } - response: { - successful: number - failed: number - errors: string[] - } - } - } - - /** - * Test error simulation endpoint - * @example POST /test/error { "errorType": "timeout" } - */ - '/test/error': { - /** Simulate various error scenarios for testing */ - POST: { - body: { - /** Type of error to simulate */ - errorType: - | 'timeout' - | 'network' - | 'server' - | 'notfound' - | 'validation' - | 'unauthorized' - | 'ratelimit' - | 'generic' - } - response: never - } - } - - /** - * Test slow response endpoint - * @example POST /test/slow { "delay": 2000 } - */ - '/test/slow': { - /** Test slow response for performance testing */ - POST: { - body: { - /** Delay in milliseconds */ - delay: number - } - response: { - message: string - delay: number - timestamp: string - } - } - } - - /** - * Test data reset endpoint - * @example POST /test/reset - */ - '/test/reset': { - /** Reset all test data to initial state */ - POST: { - response: { - message: string - timestamp: string - } - } - } - - /** - * Test config endpoint - * @example GET /test/config - * @example PUT /test/config { "setting": "value" } - */ - '/test/config': { - /** Get test configuration */ - GET: { - response: Record - } - /** Update test configuration */ - PUT: { - body: Record - response: Record - } - } - - /** - * Test status endpoint - * @example GET /test/status - */ - '/test/status': { - /** Get system test status */ - GET: { - response: { - status: string - timestamp: string - version: string - uptime: number - environment: string - } - } - } - - /** - * Test performance endpoint - * @example GET /test/performance - */ - '/test/performance': { - /** Get performance metrics */ - GET: { - response: { - requestsPerSecond: number - averageLatency: number - memoryUsage: number - cpuUsage: number - uptime: number - } - } - } - - /** - * Batch execution of multiple requests - * @example POST /batch { "requests": [...], "parallel": true } - */ - '/batch': { - /** Execute multiple API requests in a single call */ - POST: { - body: { - /** Array of requests to execute */ - requests: Array<{ - /** HTTP method for the request */ - method: HttpMethod - /** API path for the request */ - path: string - /** URL parameters */ - params?: any - /** Request body */ - body?: any - }> - /** Execute requests in parallel vs sequential */ - parallel?: boolean - } - response: { - /** Results array matching input order */ - results: Array<{ - /** HTTP status code */ - status: number - /** Response data if successful */ - data?: any - /** Error information if failed */ - error?: any - }> - /** Batch execution metadata */ - metadata: { - /** Total execution duration in ms */ - duration: number - /** Number of successful requests */ - successCount: number - /** Number of failed requests */ - errorCount: number - } - } - } - } - - /** - * Atomic transaction of multiple operations - * @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } } - */ - '/transaction': { - /** Execute multiple operations in a database transaction */ - POST: { - body: { - /** Array of operations to execute atomically */ - operations: Array<{ - /** HTTP method for the operation */ - method: HttpMethod - /** API path for the operation */ - path: string - /** URL parameters */ - params?: any - /** Request body */ - body?: any - }> - /** Transaction configuration options */ - options?: { - /** Database isolation level */ - isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' - /** Rollback all operations on any error */ - rollbackOnError?: boolean - /** Transaction timeout in milliseconds */ - timeout?: number - } - } - response: Array<{ - /** HTTP status code */ - status: number - /** Response data if successful */ - data?: any - /** Error information if failed */ - error?: any - }> - } - } -} - -/** - * Simplified type extraction helpers - */ -export type ApiPaths = keyof ApiSchemas -export type ApiMethods = keyof ApiSchemas[TPath] & HttpMethod -export type ApiResponse = TPath extends keyof ApiSchemas - ? TMethod extends keyof ApiSchemas[TPath] - ? ApiSchemas[TPath][TMethod] extends { response: infer R } - ? R - : never - : never - : never - -export type ApiParams = TPath extends keyof ApiSchemas - ? TMethod extends keyof ApiSchemas[TPath] - ? ApiSchemas[TPath][TMethod] extends { params: infer P } - ? P - : never - : never - : never - -export type ApiQuery = TPath extends keyof ApiSchemas - ? TMethod extends keyof ApiSchemas[TPath] - ? ApiSchemas[TPath][TMethod] extends { query: infer Q } - ? Q - : never - : never - : never - -export type ApiBody = TPath extends keyof ApiSchemas - ? TMethod extends keyof ApiSchemas[TPath] - ? ApiSchemas[TPath][TMethod] extends { body: infer B } - ? B - : never - : never - : never - -/** - * Type-safe API client interface using concrete paths - * Accepts actual paths like '/test/items/123' instead of '/test/items/:id' - * Automatically infers query, body, and response types from ApiSchemas - */ -export interface ApiClient { - get( - path: TPath, - options?: { - query?: QueryParamsForPath - headers?: Record - } - ): Promise> - - post( - path: TPath, - options: { - body?: BodyForPath - query?: Record - headers?: Record - } - ): Promise> - - put( - path: TPath, - options: { - body: BodyForPath - query?: Record - headers?: Record - } - ): Promise> - - delete( - path: TPath, - options?: { - query?: Record - headers?: Record - } - ): Promise> - - patch( - path: TPath, - options: { - body?: BodyForPath - query?: Record - headers?: Record - } - ): Promise> -} - -/** - * Helper types to determine if parameters are required based on schema - */ -type HasRequiredQuery> = Path extends keyof ApiSchemas - ? Method extends keyof ApiSchemas[Path] - ? ApiSchemas[Path][Method] extends { query: any } - ? true - : false - : false - : false - -type HasRequiredBody> = Path extends keyof ApiSchemas - ? Method extends keyof ApiSchemas[Path] - ? ApiSchemas[Path][Method] extends { body: any } - ? true - : false - : false - : false - -type HasRequiredParams> = Path extends keyof ApiSchemas - ? Method extends keyof ApiSchemas[Path] - ? ApiSchemas[Path][Method] extends { params: any } - ? true - : false - : false - : false - -/** - * Handler function for a specific API endpoint - * Provides type-safe parameter extraction based on ApiSchemas - * Parameters are required or optional based on the schema definition - */ -export type ApiHandler> = ( - params: (HasRequiredParams extends true - ? { params: ApiParams } - : { params?: ApiParams }) & - (HasRequiredQuery extends true - ? { query: ApiQuery } - : { query?: ApiQuery }) & - (HasRequiredBody extends true ? { body: ApiBody } : { body?: ApiBody }) -) => Promise> - -/** - * Complete API implementation that must match ApiSchemas structure - * TypeScript will error if any endpoint is missing - this ensures exhaustive coverage - */ -export type ApiImplementation = { - [Path in ApiPaths]: { - [Method in ApiMethods]: ApiHandler - } -} diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index e45c45603c..e6e1217c5f 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -8,6 +8,75 @@ */ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' +// ============================================================================ +// Schema Constraint Types +// ============================================================================ + +/** + * Constraint for a single endpoint method definition. + * Requires `response` field, allows optional `params`, `query`, and `body`. + */ +export type EndpointMethodConstraint = { + params?: Record + query?: Record + body?: any + response: any // response is required +} + +/** + * Constraint for a single API path - only allows valid HTTP methods. + */ +export type EndpointConstraint = { + [Method in HttpMethod]?: EndpointMethodConstraint +} + +/** + * Validates that a schema only contains valid HTTP methods. + * Used in AssertValidSchemas for compile-time validation. + */ +type ValidateMethods = { + [Path in keyof T]: { + [Method in keyof T[Path]]: Method extends HttpMethod ? T[Path][Method] : never + } +} + +/** + * Validates that all endpoints have a `response` field. + * Returns the original type if valid, or `never` if any endpoint lacks response. + */ +type ValidateResponses = { + [Path in keyof T]: { + [Method in keyof T[Path]]: T[Path][Method] extends { response: any } + ? T[Path][Method] + : { error: `Endpoint ${Path & string}.${Method & string} is missing 'response' field` } + } +} + +/** + * Validates that a schema conforms to expected structure: + * 1. All methods must be valid HTTP methods (GET, POST, PUT, DELETE, PATCH) + * 2. All endpoints must have a `response` field + * + * This is applied at the composition level (schemas/index.ts) to catch + * invalid schemas even if individual schema files don't use validation. + * + * @example + * ```typescript + * // In schemas/index.ts + * export type ApiSchemas = AssertValidSchemas + * + * // Invalid method will cause error: + * // Type 'never' is not assignable to type... + * ``` + */ +export type AssertValidSchemas = ValidateMethods & ValidateResponses extends infer R + ? { [K in keyof R]: R[K] } + : never + +// ============================================================================ +// Core Request/Response Types +// ============================================================================ + /** * Request object structure for Data API calls */ @@ -30,8 +99,6 @@ export interface DataRequest { timestamp: number /** OpenTelemetry span context for tracing */ spanContext?: any - /** Cache options for this specific request */ - cache?: CacheOptions } } @@ -101,22 +168,6 @@ export enum ErrorCode { CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION' } -/** - * Cache configuration options - */ -export interface CacheOptions { - /** Cache TTL in seconds */ - ttl?: number - /** Return stale data while revalidating in background */ - staleWhileRevalidate?: boolean - /** Custom cache key override */ - cacheKey?: string - /** Operations that should invalidate this cache entry */ - invalidateOn?: string[] - /** Whether to bypass cache entirely */ - noCache?: boolean -} - /** * Transaction request wrapper for atomic operations */ @@ -274,16 +325,169 @@ export interface ServiceOptions { metadata?: Record } +// ============================================================================ +// API Schema Type Utilities +// ============================================================================ + +import type { BodyForPath, ConcreteApiPaths, QueryParamsForPath, ResponseForPath } from './apiPaths' +import type { ApiSchemas } from './schemas' + +// Re-export for external use +export type { ConcreteApiPaths } from './apiPaths' +export type { ApiSchemas } from './schemas' + /** - * Standard service response wrapper + * All available API paths */ -export interface ServiceResult { - /** Whether operation was successful */ - success: boolean - /** Result data if successful */ - data?: T - /** Error information if failed */ - error?: DataApiError - /** Additional metadata */ - metadata?: Record +export type ApiPaths = keyof ApiSchemas + +/** + * Available HTTP methods for a specific path + */ +export type ApiMethods = keyof ApiSchemas[TPath] & HttpMethod + +/** + * Response type for a specific path and method + */ +export type ApiResponse = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { response: infer R } + ? R + : never + : never + : never + +/** + * URL params type for a specific path and method + */ +export type ApiParams = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { params: infer P } + ? P + : never + : never + : never + +/** + * Query params type for a specific path and method + */ +export type ApiQuery = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { query: infer Q } + ? Q + : never + : never + : never + +/** + * Request body type for a specific path and method + */ +export type ApiBody = TPath extends keyof ApiSchemas + ? TMethod extends keyof ApiSchemas[TPath] + ? ApiSchemas[TPath][TMethod] extends { body: infer B } + ? B + : never + : never + : never + +/** + * Type-safe API client interface using concrete paths + * Accepts actual paths like '/test/items/123' instead of '/test/items/:id' + * Automatically infers query, body, and response types from ApiSchemas + */ +export interface ApiClient { + get( + path: TPath, + options?: { + query?: QueryParamsForPath + headers?: Record + } + ): Promise> + + post( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + } + ): Promise> + + put( + path: TPath, + options: { + body: BodyForPath + query?: Record + headers?: Record + } + ): Promise> + + delete( + path: TPath, + options?: { + query?: Record + headers?: Record + } + ): Promise> + + patch( + path: TPath, + options: { + body?: BodyForPath + query?: Record + headers?: Record + } + ): Promise> +} + +/** + * Helper types to determine if parameters are required based on schema + */ +type HasRequiredQuery> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { query: any } + ? true + : false + : false + : false + +type HasRequiredBody> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { body: any } + ? true + : false + : false + : false + +type HasRequiredParams> = Path extends keyof ApiSchemas + ? Method extends keyof ApiSchemas[Path] + ? ApiSchemas[Path][Method] extends { params: any } + ? true + : false + : false + : false + +/** + * Handler function for a specific API endpoint + * Provides type-safe parameter extraction based on ApiSchemas + * Parameters are required or optional based on the schema definition + */ +export type ApiHandler> = ( + params: (HasRequiredParams extends true + ? { params: ApiParams } + : { params?: ApiParams }) & + (HasRequiredQuery extends true + ? { query: ApiQuery } + : { query?: ApiQuery }) & + (HasRequiredBody extends true ? { body: ApiBody } : { body?: ApiBody }) +) => Promise> + +/** + * Complete API implementation that must match ApiSchemas structure + * TypeScript will error if any endpoint is missing - this ensures exhaustive coverage + */ +export type ApiImplementation = { + [Path in ApiPaths]: { + [Method in ApiMethods]: ApiHandler + } } diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index 3b00e37473..d3c4c8afde 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -1,70 +1,71 @@ /** * Cherry Studio Data API - Barrel Exports * - * This file provides a centralized entry point for all data API types, - * schemas, and utilities. Import everything you need from this single location. + * Exports common infrastructure types for the Data API system. + * Domain-specific DTOs should be imported directly from their schema files. * * @example * ```typescript - * import { Topic, CreateTopicDto, ApiSchemas, DataRequest, ErrorCode } from '@/shared/data' + * // Infrastructure types from barrel export + * import { DataRequest, DataResponse, ErrorCode, ApiClient } from '@shared/data/api' + * + * // Domain DTOs from schema files directly + * import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' * ``` */ -// Core data API types and infrastructure +// ============================================================================ +// Core Request/Response Types +// ============================================================================ + export type { BatchRequest, BatchResponse, - CacheOptions, DataApiError, DataRequest, DataResponse, HttpMethod, - Middleware, PaginatedResponse, PaginationParams, - RequestContext, - ServiceOptions, - ServiceResult, - SubscriptionCallback, - SubscriptionOptions, TransactionRequest } from './apiTypes' -export { ErrorCode, SubscriptionEvent } from './apiTypes' -// Domain models and DTOs -export type { - BulkOperationRequest, - BulkOperationResponse, - CreateTestItemDto, - TestItem, - UpdateTestItemDto -} from './apiModels' +// ============================================================================ +// API Schema Type Utilities +// ============================================================================ -// API schema definitions and type helpers export type { ApiBody, ApiClient, + ApiHandler, + ApiImplementation, ApiMethods, ApiParams, ApiPaths, ApiQuery, ApiResponse, - ApiSchemas -} from './apiSchemas' + ApiSchemas, + ConcreteApiPaths +} from './apiTypes' + +// ============================================================================ +// Path Resolution Utilities +// ============================================================================ -// Path type utilities for template literal types export type { BodyForPath, - ConcreteApiPaths, MatchApiPath, QueryParamsForPath, ResolvedPath, ResponseForPath } from './apiPaths' -// Error handling utilities +// ============================================================================ +// Error Handling +// ============================================================================ + +export { ErrorCode, SubscriptionEvent } from './apiTypes' export { - ErrorCode as DataApiErrorCode, DataApiErrorFactory, ERROR_MESSAGES, ERROR_STATUS_MAP, @@ -72,50 +73,14 @@ export { toDataApiError } from './errorCodes' -/** - * Re-export commonly used type combinations for convenience - */ +// ============================================================================ +// Subscription & Middleware (for advanced usage) +// ============================================================================ -// Import types for re-export convenience types -import type { CreateTestItemDto, TestItem, UpdateTestItemDto } from './apiModels' -import type { - BatchRequest, - BatchResponse, - DataApiError, - DataRequest, - DataResponse, - ErrorCode, - PaginatedResponse, - PaginationParams, - TransactionRequest +export type { + Middleware, + RequestContext, + ServiceOptions, + SubscriptionCallback, + SubscriptionOptions } from './apiTypes' -import type { DataApiErrorFactory } from './errorCodes' - -/** All test item-related types */ -export type TestItemTypes = { - TestItem: TestItem - CreateTestItemDto: CreateTestItemDto - UpdateTestItemDto: UpdateTestItemDto -} - -/** All error-related types and utilities */ -export type ErrorTypes = { - DataApiError: DataApiError - ErrorCode: ErrorCode - ErrorFactory: typeof DataApiErrorFactory -} - -/** All request/response types */ -export type RequestTypes = { - DataRequest: DataRequest - DataResponse: DataResponse - BatchRequest: BatchRequest - BatchResponse: BatchResponse - TransactionRequest: TransactionRequest -} - -/** All pagination-related types */ -export type PaginationTypes = { - PaginationParams: PaginationParams - PaginatedResponse: PaginatedResponse -} diff --git a/packages/shared/data/api/schemas/batch.ts b/packages/shared/data/api/schemas/batch.ts new file mode 100644 index 0000000000..c2fc3e0162 --- /dev/null +++ b/packages/shared/data/api/schemas/batch.ts @@ -0,0 +1,140 @@ +/** + * Batch and Transaction API Schema definitions + * + * Contains cross-domain operations for batch processing and atomic transactions. + * These endpoints are domain-agnostic and work with any API path. + */ + +import type { HttpMethod } from '../apiTypes' + +// ============================================================================ +// Domain Models & DTOs +// ============================================================================ + +/** + * Request for bulk operations on multiple items + */ +export interface BulkOperationRequest { + /** Type of bulk operation to perform */ + operation: 'create' | 'update' | 'delete' | 'archive' | 'restore' + /** Array of data items to process */ + data: TData[] +} + +/** + * Response from a bulk operation + */ +export interface BulkOperationResponse { + /** Number of successfully processed items */ + successful: number + /** Number of items that failed processing */ + failed: number + /** Array of errors that occurred during processing */ + errors: Array<{ + /** Index of the item that failed */ + index: number + /** Error message */ + error: string + /** Optional additional error data */ + data?: any + }> +} + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Batch and Transaction API Schema definitions + * + * Validation is performed at composition level via AssertValidSchemas + * in schemas/index.ts, which ensures: + * - All methods are valid HTTP methods (GET, POST, PUT, DELETE, PATCH) + * - All endpoints have a `response` field + */ +export interface BatchSchemas { + /** + * Batch execution of multiple requests + * @example POST /batch { "requests": [...], "parallel": true } + */ + '/batch': { + /** Execute multiple API requests in a single call */ + POST: { + body: { + /** Array of requests to execute */ + requests: Array<{ + /** HTTP method for the request */ + method: HttpMethod + /** API path for the request */ + path: string + /** URL parameters */ + params?: any + /** Request body */ + body?: any + }> + /** Execute requests in parallel vs sequential */ + parallel?: boolean + } + response: { + /** Results array matching input order */ + results: Array<{ + /** HTTP status code */ + status: number + /** Response data if successful */ + data?: any + /** Error information if failed */ + error?: any + }> + /** Batch execution metadata */ + metadata: { + /** Total execution duration in ms */ + duration: number + /** Number of successful requests */ + successCount: number + /** Number of failed requests */ + errorCount: number + } + } + } + } + + /** + * Atomic transaction of multiple operations + * @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } } + */ + '/transaction': { + /** Execute multiple operations in a database transaction */ + POST: { + body: { + /** Array of operations to execute atomically */ + operations: Array<{ + /** HTTP method for the operation */ + method: HttpMethod + /** API path for the operation */ + path: string + /** URL parameters */ + params?: any + /** Request body */ + body?: any + }> + /** Transaction configuration options */ + options?: { + /** Database isolation level */ + isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' + /** Rollback all operations on any error */ + rollbackOnError?: boolean + /** Transaction timeout in milliseconds */ + timeout?: number + } + } + response: Array<{ + /** HTTP status code */ + status: number + /** Response data if successful */ + data?: any + /** Error information if failed */ + error?: any + }> + } + } +} diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts new file mode 100644 index 0000000000..0af34a84e5 --- /dev/null +++ b/packages/shared/data/api/schemas/index.ts @@ -0,0 +1,43 @@ +/** + * Schema Index - Composes all domain schemas into unified ApiSchemas + * + * This file has ONE responsibility: compose domain schemas into ApiSchemas. + * + * Import conventions (see api/README.md for details): + * - Infrastructure types: import from '@shared/data/api' + * - Domain DTOs: import directly from schema files (e.g., '@shared/data/api/schemas/topic') + * + * @example + * ```typescript + * // Infrastructure types via barrel export + * import type { ApiSchemas, DataRequest } from '@shared/data/api' + * + * // Domain DTOs directly from schema files + * import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' + * import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' + * ``` + */ + +import type { AssertValidSchemas } from '../apiTypes' +import type { BatchSchemas } from './batch' +import type { TestSchemas } from './test' + +/** + * Merged API Schemas - single source of truth for all API endpoints + * + * All domain schemas are composed here using intersection types. + * AssertValidSchemas provides compile-time validation: + * - Invalid HTTP methods become `never` type + * - Missing `response` field causes type errors + * + * When adding a new domain: + * 1. Create the schema file (e.g., topic.ts) + * 2. Import and add to intersection below + * + * @example + * ```typescript + * import type { TopicSchemas } from './topic' + * export type ApiSchemas = AssertValidSchemas + * ``` + */ +export type ApiSchemas = AssertValidSchemas diff --git a/packages/shared/data/api/schemas/test.ts b/packages/shared/data/api/schemas/test.ts new file mode 100644 index 0000000000..6fd8681633 --- /dev/null +++ b/packages/shared/data/api/schemas/test.ts @@ -0,0 +1,322 @@ +/** + * Test API Schema definitions + * + * Contains all test-related endpoints for development and testing purposes. + * These endpoints demonstrate the API patterns and provide testing utilities. + */ + +import type { PaginatedResponse, PaginationParams } from '../apiTypes' + +// ============================================================================ +// Domain Models & DTOs +// ============================================================================ + +/** + * Generic test item entity - flexible structure for testing various scenarios + */ +export interface TestItem { + /** Unique identifier */ + id: string + /** Item title */ + title: string + /** Optional description */ + description?: string + /** Type category */ + type: string + /** Current status */ + status: string + /** Priority level */ + priority: string + /** Associated tags */ + tags: string[] + /** Creation timestamp */ + createdAt: string + /** Last update timestamp */ + updatedAt: string + /** Additional metadata */ + metadata: Record +} + +/** + * DTO for creating a new test item + */ +export interface CreateTestItemDto { + /** Item title */ + title: string + /** Optional description */ + description?: string + /** Type category */ + type?: string + /** Current status */ + status?: string + /** Priority level */ + priority?: string + /** Associated tags */ + tags?: string[] + /** Additional metadata */ + metadata?: Record +} + +/** + * DTO for updating an existing test item + */ +export interface UpdateTestItemDto { + /** Updated title */ + title?: string + /** Updated description */ + description?: string + /** Updated type */ + type?: string + /** Updated status */ + status?: string + /** Updated priority */ + priority?: string + /** Updated tags */ + tags?: string[] + /** Updated metadata */ + metadata?: Record +} + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Test API Schema definitions + * + * Validation is performed at composition level via AssertValidSchemas + * in schemas/index.ts, which ensures: + * - All methods are valid HTTP methods (GET, POST, PUT, DELETE, PATCH) + * - All endpoints have a `response` field + */ +export interface TestSchemas { + /** + * Test items collection endpoint + * @example GET /test/items?page=1&limit=10&search=hello + * @example POST /test/items { "title": "New Test Item" } + */ + '/test/items': { + /** List all test items with optional filtering and pagination */ + GET: { + query?: PaginationParams & { + /** Search items by title or description */ + search?: string + /** Filter by item type */ + type?: string + /** Filter by status */ + status?: string + } + response: PaginatedResponse + } + /** Create a new test item */ + POST: { + body: CreateTestItemDto + response: TestItem + } + } + + /** + * Individual test item endpoint + * @example GET /test/items/123 + * @example PUT /test/items/123 { "title": "Updated Title" } + * @example DELETE /test/items/123 + */ + '/test/items/:id': { + /** Get a specific test item by ID */ + GET: { + params: { id: string } + response: TestItem + } + /** Update a specific test item */ + PUT: { + params: { id: string } + body: UpdateTestItemDto + response: TestItem + } + /** Delete a specific test item */ + DELETE: { + params: { id: string } + response: void + } + } + + /** + * Test search endpoint + * @example GET /test/search?query=hello&page=1&limit=20 + */ + '/test/search': { + /** Search test items */ + GET: { + query: { + /** Search query string */ + query: string + /** Page number for pagination */ + page?: number + /** Number of results per page */ + limit?: number + /** Additional filters */ + type?: string + status?: string + } + response: PaginatedResponse + } + } + + /** + * Test statistics endpoint + * @example GET /test/stats + */ + '/test/stats': { + /** Get comprehensive test statistics */ + GET: { + response: { + /** Total number of items */ + total: number + /** Item count grouped by type */ + byType: Record + /** Item count grouped by status */ + byStatus: Record + /** Item count grouped by priority */ + byPriority: Record + /** Recent activity timeline */ + recentActivity: Array<{ + /** Date of activity */ + date: string + /** Number of items on that date */ + count: number + }> + } + } + } + + /** + * Test bulk operations endpoint + * @example POST /test/bulk { "operation": "create", "data": [...] } + */ + '/test/bulk': { + /** Perform bulk operations on test items */ + POST: { + body: { + /** Operation type */ + operation: 'create' | 'update' | 'delete' + /** Array of data items to process */ + data: Array + } + response: { + /** Number of successfully processed items */ + successful: number + /** Number of items that failed processing */ + failed: number + /** Array of error messages */ + errors: string[] + } + } + } + + /** + * Test error simulation endpoint + * @example POST /test/error { "errorType": "timeout" } + */ + '/test/error': { + /** Simulate various error scenarios for testing */ + POST: { + body: { + /** Type of error to simulate */ + errorType: + | 'timeout' + | 'network' + | 'server' + | 'notfound' + | 'validation' + | 'unauthorized' + | 'ratelimit' + | 'generic' + } + response: never + } + } + + /** + * Test slow response endpoint + * @example POST /test/slow { "delay": 2000 } + */ + '/test/slow': { + /** Test slow response for performance testing */ + POST: { + body: { + /** Delay in milliseconds */ + delay: number + } + response: { + message: string + delay: number + timestamp: string + } + } + } + + /** + * Test data reset endpoint + * @example POST /test/reset + */ + '/test/reset': { + /** Reset all test data to initial state */ + POST: { + response: { + message: string + timestamp: string + } + } + } + + /** + * Test config endpoint + * @example GET /test/config + * @example PUT /test/config { "setting": "value" } + */ + '/test/config': { + /** Get test configuration */ + GET: { + response: Record + } + /** Update test configuration */ + PUT: { + body: Record + response: Record + } + } + + /** + * Test status endpoint + * @example GET /test/status + */ + '/test/status': { + /** Get system test status */ + GET: { + response: { + status: string + timestamp: string + version: string + uptime: number + environment: string + } + } + } + + /** + * Test performance endpoint + * @example GET /test/performance + */ + '/test/performance': { + /** Get performance metrics */ + GET: { + response: { + requestsPerSecond: number + averageLatency: number + memoryUsage: number + cpuUsage: number + uptime: number + } + } + } +} diff --git a/src/main/data/README.md b/src/main/data/README.md index be16e72bf3..e6d52e83f2 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -12,8 +12,10 @@ src/main/data/ │ │ ├── MiddlewareEngine.ts # Request/response middleware │ │ └── adapters/ # Communication adapters (IPC) │ ├── handlers/ # API endpoint implementations -│ │ └── index.ts # Thin handlers: param extraction, DTO conversion -│ └── index.ts # API framework exports +│ │ ├── index.ts # Handler aggregation and exports +│ │ ├── test.ts # Test endpoint handlers +│ │ └── batch.ts # Batch/transaction handlers +│ └── index.ts # API framework exports │ ├── services/ # Business logic layer │ ├── base/ # Service base classes and interfaces @@ -34,6 +36,12 @@ src/main/data/ │ ├── schemas/ # Drizzle table definitions │ │ ├── preference.ts # Preference configuration table │ │ ├── appState.ts # Application state table +│ │ ├── topic.ts # Topic/conversation table +│ │ ├── message.ts # Message table +│ │ ├── group.ts # Group table +│ │ ├── tag.ts # Tag table +│ │ ├── entityTag.ts # Entity-tag relationship table +│ │ ├── messageFts.ts # Message full-text search table │ │ └── columnHelpers.ts # Reusable column definitions │ ├── seeding/ # Database initialization │ └── DbService.ts # Database connection and management @@ -94,8 +102,8 @@ The API framework provides the interface layer for data access: - Delegating to business services - Transforming responses for IPC - **Anti-pattern**: Do NOT put business logic in handlers -- **Currently**: Contains test handlers (production handlers pending) -- **Type Safety**: Must implement all endpoints defined in `@shared/data/api` +- **Currently**: Contains test and batch handlers (business handlers pending) +- **Type Safety**: Must implement all endpoints defined in `@shared/data/api/schemas/` ### Business Logic Layer (`services/`) @@ -217,6 +225,12 @@ export class SimpleService extends BaseService { ### Current Tables - `preference`: User configuration storage - `appState`: Application state persistence +- `topic`: Conversation/topic storage +- `message`: Message storage with full-text search +- `group`: Group organization +- `tag`: Tag definitions +- `entityTag`: Entity-tag relationships +- `messageFts`: Message full-text search index ## Usage Examples @@ -231,11 +245,12 @@ import { dataApiService } from '@/data/DataApiService' ``` ### Adding New API Endpoints -1. Define endpoint in `@shared/data/api/apiSchemas.ts` -2. Implement handler in `api/handlers/index.ts` (thin layer, delegate to service) -3. Create business service in `services/` for domain logic -4. Create repository in `repositories/` if domain is complex (optional) -5. Add database schema in `db/schemas/` if required +1. Create or update schema in `@shared/data/api/schemas/` (see `@shared/data/api/README.md`) +2. Register schema in `@shared/data/api/schemas/index.ts` +3. Implement handler in `api/handlers/` (thin layer, delegate to service) +4. Create business service in `services/` for domain logic +5. Create repository in `repositories/` if domain is complex (optional) +6. Add database schema in `db/schemas/` if required ### Adding Database Tables 1. Create schema in `db/schemas/{tableName}.ts` @@ -271,11 +286,13 @@ export class ExampleService { } // 3. Create handler: api/handlers/example.ts -import { ExampleService } from '../../services/ExampleService' +import { ExampleService } from '@data/services/ExampleService' -export const exampleHandlers = { - 'POST /examples': async ({ body }) => { - return await ExampleService.getInstance().createExample(body) +export const exampleHandlers: Partial = { + '/examples': { + POST: async ({ body }) => { + return await ExampleService.getInstance().createExample(body) + } } } ``` @@ -294,9 +311,11 @@ export class SimpleService extends BaseService { } // 2. Create handler: api/handlers/simple.ts -export const simpleHandlers = { - 'GET /items/:id': async ({ params }) => { - return await SimpleService.getInstance().getItem(params.id) +export const simpleHandlers: Partial = { + '/items/:id': { + GET: async ({ params }) => { + return await SimpleService.getInstance().getItem(params.id) + } } } ``` diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index 038f7cc1b8..b2317e1395 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -1,5 +1,5 @@ import { loggerService } from '@logger' -import type { ApiImplementation } from '@shared/data/api/apiSchemas' +import type { ApiImplementation } from '@shared/data/api/apiTypes' import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes' import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes' diff --git a/src/main/data/api/handlers/batch.ts b/src/main/data/api/handlers/batch.ts new file mode 100644 index 0000000000..8ae302462e --- /dev/null +++ b/src/main/data/api/handlers/batch.ts @@ -0,0 +1,55 @@ +/** + * Batch and Transaction API Handlers + * + * Implements cross-domain batch processing and atomic transaction operations. + */ + +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import type { BatchSchemas } from '@shared/data/api/schemas/batch' + +/** + * Handler type for a specific batch endpoint + */ +type BatchHandler> = ApiHandler + +/** + * Batch API handlers implementation + */ +export const batchHandlers: { + [Path in keyof BatchSchemas]: { + [Method in keyof BatchSchemas[Path]]: BatchHandler> + } +} = { + '/batch': { + POST: async ({ body }) => { + // Mock batch implementation - can be enhanced with actual batch processing + const { requests } = body + + const results = requests.map(() => ({ + status: 200, + data: { processed: true, timestamp: new Date().toISOString() } + })) + + return { + results, + metadata: { + duration: Math.floor(Math.random() * 500) + 100, + successCount: requests.length, + errorCount: 0 + } + } + } + }, + + '/transaction': { + POST: async ({ body }) => { + // Mock transaction implementation - can be enhanced with actual transaction support + const { operations } = body + + return operations.map(() => ({ + status: 200, + data: { executed: true, timestamp: new Date().toISOString() } + })) + } + } +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index 817a882be8..79824b209a 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -1,210 +1,38 @@ /** - * Complete API handler implementation + * API Handlers Index * - * This file implements ALL endpoints defined in ApiSchemas. - * TypeScript will error if any endpoint is missing. + * Combines all domain-specific handlers into a unified apiHandlers object. + * TypeScript will error if any endpoint from ApiSchemas is missing. + * + * Handler files are organized by domain: + * - test.ts - Test API handlers + * - batch.ts - Batch and transaction handlers + * + * @example Adding a new domain: + * ```typescript + * import { topicHandlers } from './topic' + * + * export const apiHandlers: ApiImplementation = { + * ...testHandlers, + * ...batchHandlers, + * ...topicHandlers // Add new domain handlers here + * } + * ``` */ -import { TestService } from '@data/services/TestService' -import type { ApiImplementation } from '@shared/data/api/apiSchemas' +import type { ApiImplementation } from '@shared/data/api/apiTypes' -// Service instances -const testService = TestService.getInstance() +import { batchHandlers } from './batch' +import { testHandlers } from './test' /** * Complete API handlers implementation * Must implement every path+method combination from ApiSchemas + * + * Handlers are spread from individual domain modules for maintainability. + * TypeScript ensures exhaustive coverage - missing handlers cause compile errors. */ export const apiHandlers: ApiImplementation = { - '/test/items': { - GET: async ({ query }) => { - return await testService.getItems({ - page: (query as any)?.page, - limit: (query as any)?.limit, - search: (query as any)?.search, - type: (query as any)?.type, - status: (query as any)?.status - }) - }, - - POST: async ({ body }) => { - return await testService.createItem({ - title: body.title, - description: body.description, - type: body.type, - status: body.status, - priority: body.priority, - tags: body.tags, - metadata: body.metadata - }) - } - }, - - '/test/items/:id': { - GET: async ({ params }) => { - const item = await testService.getItemById(params.id) - if (!item) { - throw new Error(`Test item not found: ${params.id}`) - } - return item - }, - - PUT: async ({ params, body }) => { - const item = await testService.updateItem(params.id, { - title: body.title, - description: body.description, - type: body.type, - status: body.status, - priority: body.priority, - tags: body.tags, - metadata: body.metadata - }) - if (!item) { - throw new Error(`Test item not found: ${params.id}`) - } - return item - }, - - DELETE: async ({ params }) => { - const deleted = await testService.deleteItem(params.id) - if (!deleted) { - throw new Error(`Test item not found: ${params.id}`) - } - return undefined - } - }, - - '/test/search': { - GET: async ({ query }) => { - return await testService.searchItems(query.query, { - page: query.page, - limit: query.limit, - filters: { - type: query.type, - status: query.status - } - }) - } - }, - - '/test/stats': { - GET: async () => { - return await testService.getStats() - } - }, - - '/test/bulk': { - POST: async ({ body }) => { - return await testService.bulkOperation(body.operation, body.data) - } - }, - - '/test/error': { - POST: async ({ body }) => { - return await testService.simulateError(body.errorType) - } - }, - - '/test/slow': { - POST: async ({ body }) => { - const delay = body.delay - await new Promise((resolve) => setTimeout(resolve, delay)) - return { - message: `Slow response completed after ${delay}ms`, - delay, - timestamp: new Date().toISOString() - } - } - }, - - '/test/reset': { - POST: async () => { - await testService.resetData() - return { - message: 'Test data reset successfully', - timestamp: new Date().toISOString() - } - } - }, - - '/test/config': { - GET: async () => { - return { - environment: 'test', - version: '1.0.0', - debug: true, - features: { - bulkOperations: true, - search: true, - statistics: true - } - } - }, - - PUT: async ({ body }) => { - return { - ...body, - updated: true, - timestamp: new Date().toISOString() - } - } - }, - - '/test/status': { - GET: async () => { - return { - status: 'healthy', - timestamp: new Date().toISOString(), - version: '1.0.0', - uptime: Math.floor(process.uptime()), - environment: 'test' - } - } - }, - - '/test/performance': { - GET: async () => { - const memUsage = process.memoryUsage() - return { - requestsPerSecond: Math.floor(Math.random() * 100) + 50, - averageLatency: Math.floor(Math.random() * 200) + 50, - memoryUsage: memUsage.heapUsed / 1024 / 1024, // MB - cpuUsage: Math.random() * 100, - uptime: Math.floor(process.uptime()) - } - } - }, - - '/batch': { - POST: async ({ body }) => { - // Mock batch implementation - can be enhanced with actual batch processing - const { requests } = body - - const results = requests.map(() => ({ - status: 200, - data: { processed: true, timestamp: new Date().toISOString() } - })) - - return { - results, - metadata: { - duration: Math.floor(Math.random() * 500) + 100, - successCount: requests.length, - errorCount: 0 - } - } - } - }, - - '/transaction': { - POST: async ({ body }) => { - // Mock transaction implementation - can be enhanced with actual transaction support - const { operations } = body - - return operations.map(() => ({ - status: 200, - data: { executed: true, timestamp: new Date().toISOString() } - })) - } - } + ...testHandlers, + ...batchHandlers } diff --git a/src/main/data/api/handlers/test.ts b/src/main/data/api/handlers/test.ts new file mode 100644 index 0000000000..a9522cf2a9 --- /dev/null +++ b/src/main/data/api/handlers/test.ts @@ -0,0 +1,185 @@ +/** + * Test API Handlers + * + * Implements all test-related API endpoints for development and testing purposes. + */ + +import { TestService } from '@data/services/TestService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import type { TestSchemas } from '@shared/data/api/schemas/test' + +// Service instance +const testService = TestService.getInstance() + +/** + * Handler type for a specific test endpoint + */ +type TestHandler> = ApiHandler + +/** + * Test API handlers implementation + */ +export const testHandlers: { + [Path in keyof TestSchemas]: { + [Method in keyof TestSchemas[Path]]: TestHandler> + } +} = { + '/test/items': { + GET: async ({ query }) => { + return await testService.getItems({ + page: (query as any)?.page, + limit: (query as any)?.limit, + search: (query as any)?.search, + type: (query as any)?.type, + status: (query as any)?.status + }) + }, + + POST: async ({ body }) => { + return await testService.createItem({ + title: body.title, + description: body.description, + type: body.type, + status: body.status, + priority: body.priority, + tags: body.tags, + metadata: body.metadata + }) + } + }, + + '/test/items/:id': { + GET: async ({ params }) => { + const item = await testService.getItemById(params.id) + if (!item) { + throw new Error(`Test item not found: ${params.id}`) + } + return item + }, + + PUT: async ({ params, body }) => { + const item = await testService.updateItem(params.id, { + title: body.title, + description: body.description, + type: body.type, + status: body.status, + priority: body.priority, + tags: body.tags, + metadata: body.metadata + }) + if (!item) { + throw new Error(`Test item not found: ${params.id}`) + } + return item + }, + + DELETE: async ({ params }) => { + const deleted = await testService.deleteItem(params.id) + if (!deleted) { + throw new Error(`Test item not found: ${params.id}`) + } + return undefined + } + }, + + '/test/search': { + GET: async ({ query }) => { + return await testService.searchItems(query.query, { + page: query.page, + limit: query.limit, + filters: { + type: query.type, + status: query.status + } + }) + } + }, + + '/test/stats': { + GET: async () => { + return await testService.getStats() + } + }, + + '/test/bulk': { + POST: async ({ body }) => { + return await testService.bulkOperation(body.operation, body.data) + } + }, + + '/test/error': { + POST: async ({ body }) => { + return await testService.simulateError(body.errorType) + } + }, + + '/test/slow': { + POST: async ({ body }) => { + const delay = body.delay + await new Promise((resolve) => setTimeout(resolve, delay)) + return { + message: `Slow response completed after ${delay}ms`, + delay, + timestamp: new Date().toISOString() + } + } + }, + + '/test/reset': { + POST: async () => { + await testService.resetData() + return { + message: 'Test data reset successfully', + timestamp: new Date().toISOString() + } + } + }, + + '/test/config': { + GET: async () => { + return { + environment: 'test', + version: '1.0.0', + debug: true, + features: { + bulkOperations: true, + search: true, + statistics: true + } + } + }, + + PUT: async ({ body }) => { + return { + ...body, + updated: true, + timestamp: new Date().toISOString() + } + } + }, + + '/test/status': { + GET: async () => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + version: '1.0.0', + uptime: Math.floor(process.uptime()), + environment: 'test' + } + } + }, + + '/test/performance': { + GET: async () => { + const memUsage = process.memoryUsage() + return { + requestsPerSecond: Math.floor(Math.random() * 100) + 50, + averageLatency: Math.floor(Math.random() * 200) + 50, + memoryUsage: memUsage.heapUsed / 1024 / 1024, // MB + cpuUsage: Math.random() * 100, + uptime: Math.floor(process.uptime()) + } + } + } +} diff --git a/src/main/data/api/index.ts b/src/main/data/api/index.ts index 1bd4d3b7a5..c479db0050 100644 --- a/src/main/data/api/index.ts +++ b/src/main/data/api/index.ts @@ -20,7 +20,6 @@ export { apiHandlers } from './handlers' export { TestService } from '@data/services/TestService' // Re-export types for convenience -export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api' export type { DataRequest, DataResponse, @@ -30,3 +29,4 @@ export type { RequestContext, ServiceOptions } from '@shared/data/api/apiTypes' +export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api/schemas/test' diff --git a/src/renderer/src/data/DataApiService.ts b/src/renderer/src/data/DataApiService.ts index 2d3791139a..008a6e5baa 100644 --- a/src/renderer/src/data/DataApiService.ts +++ b/src/renderer/src/data/DataApiService.ts @@ -31,7 +31,7 @@ */ import { loggerService } from '@logger' -import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { BatchRequest, BatchResponse, diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index de30473ab0..599a7aee42 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,5 +1,5 @@ import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' -import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { PaginatedResponse } from '@shared/data/api/apiTypes' import { useState } from 'react' import type { KeyedMutator } from 'swr' diff --git a/tests/__mocks__/renderer/DataApiService.ts b/tests/__mocks__/renderer/DataApiService.ts index 122a2c03d0..e1bc58ed36 100644 --- a/tests/__mocks__/renderer/DataApiService.ts +++ b/tests/__mocks__/renderer/DataApiService.ts @@ -1,4 +1,4 @@ -import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { DataResponse } from '@shared/data/api/apiTypes' import { vi } from 'vitest' diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 9b85d25550..53048738c8 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -1,4 +1,4 @@ -import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' +import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { PaginatedResponse } from '@shared/data/api/apiTypes' import { vi } from 'vitest' From 0b350294049f8e5eed20f25752d2b6c01dd7e89a Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 26 Dec 2025 14:17:08 +0800 Subject: [PATCH 029/116] refactor(dataApi): remove batch and transaction support from Data API - Deleted batch and transaction related schemas, handlers, and IPC channels to streamline the Data API. - Updated related type definitions and import paths to reflect the removal of batch and transaction functionalities. - Simplified the API server and adapter logic by eliminating unused methods and handlers. --- packages/shared/IpcChannel.ts | 2 - packages/shared/data/README.md | 3 +- packages/shared/data/api/README.md | 5 +- packages/shared/data/api/apiTypes.ts | 46 ------ packages/shared/data/api/index.ts | 5 +- packages/shared/data/api/schemas/batch.ts | 140 ------------------ packages/shared/data/api/schemas/index.ts | 5 +- src/main/data/README.md | 5 +- src/main/data/api/core/ApiServer.ts | 31 ---- src/main/data/api/core/adapters/IpcAdapter.ts | 51 ------- src/main/data/api/handlers/batch.ts | 55 ------- src/main/data/api/handlers/index.ts | 6 +- src/preload/index.ts | 2 - src/renderer/src/data/DataApiService.ts | 31 +--- src/renderer/src/data/README.md | 9 +- tests/__mocks__/README.md | 1 - 16 files changed, 11 insertions(+), 386 deletions(-) delete mode 100644 packages/shared/data/api/schemas/batch.ts delete mode 100644 src/main/data/api/handlers/batch.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index ea7164a0d0..c6611f19a4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -346,8 +346,6 @@ export enum IpcChannel { // Data: API Channels DataApi_Request = 'data-api:request', - DataApi_Batch = 'data-api:batch', - DataApi_Transaction = 'data-api:transaction', DataApi_Subscribe = 'data-api:subscribe', DataApi_Unsubscribe = 'data-api:unsubscribe', DataApi_Stream = 'data-api:stream', diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md index 1be09a01c1..9428522575 100644 --- a/packages/shared/data/README.md +++ b/packages/shared/data/README.md @@ -13,8 +13,7 @@ packages/shared/data/ │ ├── errorCodes.ts # Error handling utilities │ ├── schemas/ # Domain-specific API schemas │ │ ├── index.ts # Schema composition -│ │ ├── test.ts # Test API schema and DTOs -│ │ └── batch.ts # Batch/transaction operations +│ │ └── test.ts # Test API schema and DTOs │ └── README.md # Detailed API documentation ├── cache/ # Cache system type definitions │ ├── cacheTypes.ts # Core cache infrastructure types diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md index b2e9c7da1e..059cbcf92d 100644 --- a/packages/shared/data/api/README.md +++ b/packages/shared/data/api/README.md @@ -12,8 +12,7 @@ packages/shared/data/api/ ├── errorCodes.ts # Error handling utilities and factories └── schemas/ ├── index.ts # Schema composition (merges all domain schemas) - ├── test.ts # Test API schema and DTOs - └── batch.ts # Batch/transaction API schema + └── test.ts # Test API schema and DTOs ``` ## File Responsibilities @@ -109,7 +108,7 @@ export interface TopicSchemas { import type { TopicSchemas } from './topic' // AssertValidSchemas provides fallback validation even if ValidateSchema is forgotten -export type ApiSchemas = AssertValidSchemas +export type ApiSchemas = AssertValidSchemas ``` 3. Implement handlers in `src/main/data/api/handlers/` diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index e6e1217c5f..e89a769619 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -168,52 +168,6 @@ export enum ErrorCode { CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION' } -/** - * Transaction request wrapper for atomic operations - */ -export interface TransactionRequest { - /** List of operations to execute in transaction */ - operations: DataRequest[] - /** Transaction options */ - options?: { - /** Database isolation level */ - isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' - /** Whether to rollback entire transaction on any error */ - rollbackOnError?: boolean - /** Transaction timeout in milliseconds */ - timeout?: number - } -} - -/** - * Batch request for multiple operations - */ -export interface BatchRequest { - /** List of requests to execute */ - requests: DataRequest[] - /** Whether to execute requests in parallel */ - parallel?: boolean - /** Stop on first error */ - stopOnError?: boolean -} - -/** - * Batch response containing results for all requests - */ -export interface BatchResponse { - /** Individual response for each request */ - results: DataResponse[] - /** Overall batch execution metadata */ - metadata: { - /** Total execution time */ - duration: number - /** Number of successful operations */ - successCount: number - /** Number of failed operations */ - errorCount: number - } -} - /** * Pagination parameters for list operations */ diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index d3c4c8afde..f4404af011 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -19,15 +19,12 @@ // ============================================================================ export type { - BatchRequest, - BatchResponse, DataApiError, DataRequest, DataResponse, HttpMethod, PaginatedResponse, - PaginationParams, - TransactionRequest + PaginationParams } from './apiTypes' // ============================================================================ diff --git a/packages/shared/data/api/schemas/batch.ts b/packages/shared/data/api/schemas/batch.ts deleted file mode 100644 index c2fc3e0162..0000000000 --- a/packages/shared/data/api/schemas/batch.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Batch and Transaction API Schema definitions - * - * Contains cross-domain operations for batch processing and atomic transactions. - * These endpoints are domain-agnostic and work with any API path. - */ - -import type { HttpMethod } from '../apiTypes' - -// ============================================================================ -// Domain Models & DTOs -// ============================================================================ - -/** - * Request for bulk operations on multiple items - */ -export interface BulkOperationRequest { - /** Type of bulk operation to perform */ - operation: 'create' | 'update' | 'delete' | 'archive' | 'restore' - /** Array of data items to process */ - data: TData[] -} - -/** - * Response from a bulk operation - */ -export interface BulkOperationResponse { - /** Number of successfully processed items */ - successful: number - /** Number of items that failed processing */ - failed: number - /** Array of errors that occurred during processing */ - errors: Array<{ - /** Index of the item that failed */ - index: number - /** Error message */ - error: string - /** Optional additional error data */ - data?: any - }> -} - -// ============================================================================ -// API Schema Definitions -// ============================================================================ - -/** - * Batch and Transaction API Schema definitions - * - * Validation is performed at composition level via AssertValidSchemas - * in schemas/index.ts, which ensures: - * - All methods are valid HTTP methods (GET, POST, PUT, DELETE, PATCH) - * - All endpoints have a `response` field - */ -export interface BatchSchemas { - /** - * Batch execution of multiple requests - * @example POST /batch { "requests": [...], "parallel": true } - */ - '/batch': { - /** Execute multiple API requests in a single call */ - POST: { - body: { - /** Array of requests to execute */ - requests: Array<{ - /** HTTP method for the request */ - method: HttpMethod - /** API path for the request */ - path: string - /** URL parameters */ - params?: any - /** Request body */ - body?: any - }> - /** Execute requests in parallel vs sequential */ - parallel?: boolean - } - response: { - /** Results array matching input order */ - results: Array<{ - /** HTTP status code */ - status: number - /** Response data if successful */ - data?: any - /** Error information if failed */ - error?: any - }> - /** Batch execution metadata */ - metadata: { - /** Total execution duration in ms */ - duration: number - /** Number of successful requests */ - successCount: number - /** Number of failed requests */ - errorCount: number - } - } - } - } - - /** - * Atomic transaction of multiple operations - * @example POST /transaction { "operations": [...], "options": { "rollbackOnError": true } } - */ - '/transaction': { - /** Execute multiple operations in a database transaction */ - POST: { - body: { - /** Array of operations to execute atomically */ - operations: Array<{ - /** HTTP method for the operation */ - method: HttpMethod - /** API path for the operation */ - path: string - /** URL parameters */ - params?: any - /** Request body */ - body?: any - }> - /** Transaction configuration options */ - options?: { - /** Database isolation level */ - isolation?: 'read-uncommitted' | 'read-committed' | 'repeatable-read' | 'serializable' - /** Rollback all operations on any error */ - rollbackOnError?: boolean - /** Transaction timeout in milliseconds */ - timeout?: number - } - } - response: Array<{ - /** HTTP status code */ - status: number - /** Response data if successful */ - data?: any - /** Error information if failed */ - error?: any - }> - } - } -} diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts index 0af34a84e5..4697880783 100644 --- a/packages/shared/data/api/schemas/index.ts +++ b/packages/shared/data/api/schemas/index.ts @@ -19,7 +19,6 @@ */ import type { AssertValidSchemas } from '../apiTypes' -import type { BatchSchemas } from './batch' import type { TestSchemas } from './test' /** @@ -37,7 +36,7 @@ import type { TestSchemas } from './test' * @example * ```typescript * import type { TopicSchemas } from './topic' - * export type ApiSchemas = AssertValidSchemas + * export type ApiSchemas = AssertValidSchemas * ``` */ -export type ApiSchemas = AssertValidSchemas +export type ApiSchemas = AssertValidSchemas diff --git a/src/main/data/README.md b/src/main/data/README.md index e6d52e83f2..a8c49346a4 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -13,8 +13,7 @@ src/main/data/ │ │ └── adapters/ # Communication adapters (IPC) │ ├── handlers/ # API endpoint implementations │ │ ├── index.ts # Handler aggregation and exports -│ │ ├── test.ts # Test endpoint handlers -│ │ └── batch.ts # Batch/transaction handlers +│ │ └── test.ts # Test endpoint handlers │ └── index.ts # API framework exports │ ├── services/ # Business logic layer @@ -102,7 +101,7 @@ The API framework provides the interface layer for data access: - Delegating to business services - Transforming responses for IPC - **Anti-pattern**: Do NOT put business logic in handlers -- **Currently**: Contains test and batch handlers (business handlers pending) +- **Currently**: Contains test handlers (business handlers pending) - **Type Safety**: Must implement all endpoints defined in `@shared/data/api/schemas/` ### Business Logic Layer (`services/`) diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index b2317e1395..57fe86add5 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -105,37 +105,6 @@ export class ApiServer { } } - /** - * Handle batch requests - */ - async handleBatchRequest(batchRequest: DataRequest): Promise { - const requests = batchRequest.body?.requests || [] - - if (!Array.isArray(requests)) { - throw DataApiErrorFactory.create(ErrorCode.VALIDATION_ERROR, 'Batch request body must contain requests array') - } - - logger.debug(`Processing batch request with ${requests.length} requests`) - - // Use the batch handler from our handlers - const batchHandler = this.handlers['/batch']?.POST - if (!batchHandler) { - throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, 'Batch handler not found') - } - - const result = await batchHandler({ body: batchRequest.body }) - - return { - id: batchRequest.id, - status: 200, - data: result, - metadata: { - duration: 0, - timestamp: Date.now() - } - } - } - /** * Find handler for given path and method */ diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts index 7d17264388..a37a33cbd3 100644 --- a/src/main/data/api/core/adapters/IpcAdapter.ts +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -57,55 +57,6 @@ export class IpcAdapter { } }) - // Batch request handler - ipcMain.handle(IpcChannel.DataApi_Batch, async (_event, batchRequest: DataRequest): Promise => { - try { - logger.debug('Handling batch request', { requestCount: batchRequest.body?.requests?.length }) - - const response = await this.apiServer.handleBatchRequest(batchRequest) - return response - } catch (error) { - logger.error('Batch request failed', error as Error) - - const apiError = toDataApiError(error, 'batch request') - return { - id: batchRequest.id, - status: apiError.status, - error: apiError, - metadata: { - duration: 0, - timestamp: Date.now() - } - } - } - }) - - // Transaction handler (placeholder) - ipcMain.handle( - IpcChannel.DataApi_Transaction, - async (_event, transactionRequest: DataRequest): Promise => { - try { - logger.debug('Handling transaction request') - - // TODO: Implement transaction support - throw new Error('Transaction support not yet implemented') - } catch (error) { - logger.error('Transaction request failed', error as Error) - - const apiError = toDataApiError(error, 'transaction request') - return { - id: transactionRequest.id, - status: apiError.status, - error: apiError, - metadata: { - duration: 0, - timestamp: Date.now() - } - } - } - } - ) - // Subscription handlers (placeholder for future real-time features) ipcMain.handle(IpcChannel.DataApi_Subscribe, async (_event, path: string) => { logger.debug(`Data subscription request: ${path}`) @@ -134,8 +85,6 @@ export class IpcAdapter { logger.debug('Removing IPC handlers...') ipcMain.removeHandler(IpcChannel.DataApi_Request) - ipcMain.removeHandler(IpcChannel.DataApi_Batch) - ipcMain.removeHandler(IpcChannel.DataApi_Transaction) ipcMain.removeHandler(IpcChannel.DataApi_Subscribe) ipcMain.removeHandler(IpcChannel.DataApi_Unsubscribe) diff --git a/src/main/data/api/handlers/batch.ts b/src/main/data/api/handlers/batch.ts deleted file mode 100644 index 8ae302462e..0000000000 --- a/src/main/data/api/handlers/batch.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Batch and Transaction API Handlers - * - * Implements cross-domain batch processing and atomic transaction operations. - */ - -import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' -import type { BatchSchemas } from '@shared/data/api/schemas/batch' - -/** - * Handler type for a specific batch endpoint - */ -type BatchHandler> = ApiHandler - -/** - * Batch API handlers implementation - */ -export const batchHandlers: { - [Path in keyof BatchSchemas]: { - [Method in keyof BatchSchemas[Path]]: BatchHandler> - } -} = { - '/batch': { - POST: async ({ body }) => { - // Mock batch implementation - can be enhanced with actual batch processing - const { requests } = body - - const results = requests.map(() => ({ - status: 200, - data: { processed: true, timestamp: new Date().toISOString() } - })) - - return { - results, - metadata: { - duration: Math.floor(Math.random() * 500) + 100, - successCount: requests.length, - errorCount: 0 - } - } - } - }, - - '/transaction': { - POST: async ({ body }) => { - // Mock transaction implementation - can be enhanced with actual transaction support - const { operations } = body - - return operations.map(() => ({ - status: 200, - data: { executed: true, timestamp: new Date().toISOString() } - })) - } - } -} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index 79824b209a..a79f4c6707 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -6,7 +6,6 @@ * * Handler files are organized by domain: * - test.ts - Test API handlers - * - batch.ts - Batch and transaction handlers * * @example Adding a new domain: * ```typescript @@ -14,7 +13,6 @@ * * export const apiHandlers: ApiImplementation = { * ...testHandlers, - * ...batchHandlers, * ...topicHandlers // Add new domain handlers here * } * ``` @@ -22,7 +20,6 @@ import type { ApiImplementation } from '@shared/data/api/apiTypes' -import { batchHandlers } from './batch' import { testHandlers } from './test' /** @@ -33,6 +30,5 @@ import { testHandlers } from './test' * TypeScript ensures exhaustive coverage - missing handlers cause compile errors. */ export const apiHandlers: ApiImplementation = { - ...testHandlers, - ...batchHandlers + ...testHandlers } diff --git a/src/preload/index.ts b/src/preload/index.ts index 47122439d0..2a0cbfe366 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -605,8 +605,6 @@ const api = { // Data API related APIs dataApi: { request: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Request, req), - batch: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Batch, req), - transaction: (req: any) => ipcRenderer.invoke(IpcChannel.DataApi_Transaction, req), subscribe: (path: string, callback: (data: any, event: string) => void) => { const channel = `${IpcChannel.DataApi_Stream}:${path}` const listener = (_: any, data: any, event: string) => callback(data, event) diff --git a/src/renderer/src/data/DataApiService.ts b/src/renderer/src/data/DataApiService.ts index 008a6e5baa..35a7fdac3b 100644 --- a/src/renderer/src/data/DataApiService.ts +++ b/src/renderer/src/data/DataApiService.ts @@ -15,7 +15,6 @@ * - Type-safe requests with full TypeScript inference * - Automatic retry with exponential backoff (network, timeout, 500/503 errors) * - Request timeout management (3s default) - * - Batch request support for performance * - Subscription management (real-time updates) * * Architecture: @@ -33,15 +32,11 @@ import { loggerService } from '@logger' import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { - BatchRequest, - BatchResponse, DataRequest, - DataResponse, HttpMethod, SubscriptionCallback, SubscriptionEvent, - SubscriptionOptions, - TransactionRequest + SubscriptionOptions } from '@shared/data/api/apiTypes' import { toDataApiError } from '@shared/data/api/errorCodes' @@ -311,30 +306,6 @@ export class DataApiService implements ApiClient { }) } - /** - * Execute multiple requests in batch - */ - async batch(requests: DataRequest[], options: { parallel?: boolean } = {}): Promise { - const batchRequest: BatchRequest = { - requests, - parallel: options.parallel ?? true - } - - return this.makeRequest('POST', '/batch', { body: batchRequest }) - } - - /** - * Execute requests in a transaction - */ - async transaction(operations: DataRequest[], options?: TransactionRequest['options']): Promise { - const transactionRequest: TransactionRequest = { - operations, - options - } - - return this.makeRequest('POST', '/transaction', { body: transactionRequest }) - } - /** * Subscribe to real-time updates */ diff --git a/src/renderer/src/data/README.md b/src/renderer/src/data/README.md index 850d887488..bf4068de06 100644 --- a/src/renderer/src/data/README.md +++ b/src/renderer/src/data/README.md @@ -82,9 +82,8 @@ const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files') **Key Features**: - Type-safe request/response handling - Automatic retry with exponential backoff -- Batch operations and transactions - Real-time subscriptions -- Request cancellation and timeout handling +- Request timeout handling **Basic Usage**: ```typescript @@ -97,12 +96,6 @@ const topics = await dataApiService.get('/topics') const newTopic = await dataApiService.post('/topics', { body: { title: 'Hello', content: 'World' } }) - -// Batch operations -const responses = await dataApiService.batch([ - { method: 'GET', path: '/topics' }, - { method: 'GET', path: '/messages' } -]) ``` ### PreferenceService diff --git a/tests/__mocks__/README.md b/tests/__mocks__/README.md index 2ef64b7230..89ac4d348a 100644 --- a/tests/__mocks__/README.md +++ b/tests/__mocks__/README.md @@ -252,7 +252,6 @@ export const mockPreferenceDefaults: Record = { ### 功能特性 - **完整HTTP支持**: GET, POST, PUT, PATCH, DELETE -- **批量操作**: batch() 和 transaction() 支持 - **订阅系统**: subscribe/unsubscribe 模拟 - **连接管理**: connect/disconnect/ping 方法 - **智能模拟数据**: 基于路径自动生成合理的响应 From ab3bce33b8154c61e2c06404e4f111a89882cd15 Mon Sep 17 00:00:00 2001 From: Shemol Date: Fri, 26 Dec 2025 17:05:45 +0800 Subject: [PATCH 030/116] docs: fix copy -> cp in development guide (#12142) Signed-off-by: SherlockShemol --- docs/en/guides/development.md | 2 +- docs/zh/guides/development.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/en/guides/development.md b/docs/en/guides/development.md index fe67742768..032a515f61 100644 --- a/docs/en/guides/development.md +++ b/docs/en/guides/development.md @@ -36,7 +36,7 @@ yarn install ### ENV ```bash -copy .env.example .env +cp .env.example .env ``` ### Start diff --git a/docs/zh/guides/development.md b/docs/zh/guides/development.md index fe67742768..032a515f61 100644 --- a/docs/zh/guides/development.md +++ b/docs/zh/guides/development.md @@ -36,7 +36,7 @@ yarn install ### ENV ```bash -copy .env.example .env +cp .env.example .env ``` ### Start From 99b431ec9299add599e6f74067f058a954f83d02 Mon Sep 17 00:00:00 2001 From: defi-failure <159208748+defi-failure@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:37:58 +0800 Subject: [PATCH 031/116] fix: remove trailing api version in ANTHROPIC_BASE_URL (#12145) --- packages/shared/utils.ts | 53 +++++++++++++++++++ .../agents/services/claudecode/index.ts | 10 +++- .../legacy/clients/gemini/GeminiAPIClient.ts | 2 +- .../aiCore/legacy/clients/ovms/OVMSClient.ts | 3 +- src/renderer/src/utils/__tests__/api.test.ts | 3 +- src/renderer/src/utils/api.ts | 53 ------------------- 6 files changed, 66 insertions(+), 58 deletions(-) diff --git a/packages/shared/utils.ts b/packages/shared/utils.ts index a14f78958d..7e90624aba 100644 --- a/packages/shared/utils.ts +++ b/packages/shared/utils.ts @@ -35,3 +35,56 @@ export const defaultAppHeaders = () => { // return value // } // } + +/** + * Extracts the trailing API version segment from a URL path. + * + * This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL. + * Only versions at the end of the path are extracted, not versions in the middle. + * The returned version string does not include leading or trailing slashes. + * + * @param {string} url - The URL string to parse. + * @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found. + * + * @example + * getTrailingApiVersion('https://api.example.com/v1') // 'v1' + * getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta' + * getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end) + * getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta' + * getTrailingApiVersion('https://api.example.com') // undefined + */ +export function getTrailingApiVersion(url: string): string | undefined { + const match = url.match(TRAILING_VERSION_REGEX) + + if (match) { + // Extract version without leading slash and trailing slash + return match[0].replace(/^\//, '').replace(/\/$/, '') + } + + return undefined +} + +/** + * Matches an API version at the end of a URL (with optional trailing slash). + * Used to detect and extract versions only from the trailing position. + */ +const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i + +/** + * Removes the trailing API version segment from a URL path. + * + * This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL. + * Only versions at the end of the path are removed, not versions in the middle. + * + * @param {string} url - The URL string to process. + * @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found. + * + * @example + * withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com' + * withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com' + * withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change) + * withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com' + */ +export function withoutTrailingApiVersion(url: string): string { + return url.replace(TRAILING_VERSION_REGEX, '') +} diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 45cecb049f..69266f5a61 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -18,6 +18,7 @@ import { validateModelId } from '@main/apiServer/utils' import { isWin } from '@main/constant' import { autoDiscoverGitBash } from '@main/utils/process' import getLoginShellEnvironment from '@main/utils/shell-env' +import { withoutTrailingApiVersion } from '@shared/utils' import { app } from 'electron' import type { GetAgentSessionResponse } from '../..' @@ -112,6 +113,13 @@ class ClaudeCodeService implements AgentServiceInterface { // Auto-discover Git Bash path on Windows (already logs internally) const customGitBashPath = isWin ? autoDiscoverGitBash() : null + // Claude Agent SDK builds the final endpoint as `${ANTHROPIC_BASE_URL}/v1/messages`. + // To avoid malformed URLs like `/v1/v1/messages`, we normalize the provider host + // by stripping any trailing API version (e.g. `/v1`). + const anthropicBaseUrl = withoutTrailingApiVersion( + modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost + ) + const env = { ...loginShellEnvWithoutProxies, // TODO: fix the proxy api server @@ -120,7 +128,7 @@ class ClaudeCodeService implements AgentServiceInterface { // ANTHROPIC_BASE_URL: `http://${apiConfig.host}:${apiConfig.port}/${modelInfo.provider.id}`, ANTHROPIC_API_KEY: modelInfo.provider.apiKey, ANTHROPIC_AUTH_TOKEN: modelInfo.provider.apiKey, - ANTHROPIC_BASE_URL: modelInfo.provider.anthropicApiHost?.trim() || modelInfo.provider.apiHost, + ANTHROPIC_BASE_URL: anthropicBaseUrl, ANTHROPIC_MODEL: modelInfo.modelId, ANTHROPIC_DEFAULT_OPUS_MODEL: modelInfo.modelId, ANTHROPIC_DEFAULT_SONNET_MODEL: modelInfo.modelId, diff --git a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts index ac10106f37..d7f14326f6 100644 --- a/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/gemini/GeminiAPIClient.ts @@ -46,7 +46,6 @@ import type { GeminiSdkRawOutput, GeminiSdkToolCall } from '@renderer/types/sdk' -import { getTrailingApiVersion, withoutTrailingApiVersion } from '@renderer/utils' import { isToolUseModeFunction } from '@renderer/utils/assistant' import { geminiFunctionCallToMcpTool, @@ -56,6 +55,7 @@ import { } from '@renderer/utils/mcp-tools' import { findFileBlocks, findImageBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' import { defaultTimeout, MB } from '@shared/config/constant' +import { getTrailingApiVersion, withoutTrailingApiVersion } from '@shared/utils' import { t } from 'i18next' import type { GenericChunk } from '../../middleware/schemas' diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 02ac6de091..4936b693ee 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -3,7 +3,8 @@ import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' import type { Provider } from '@renderer/types' import { objectKeys } from '@renderer/types' -import { formatApiHost, withoutTrailingApiVersion } from '@renderer/utils' +import { formatApiHost } from '@renderer/utils' +import { withoutTrailingApiVersion } from '@shared/utils' import { OpenAIAPIClient } from '../openai/OpenAIApiClient' diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts index f5251b8393..ad64dc0d73 100644 --- a/src/renderer/src/utils/__tests__/api.test.ts +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -1,5 +1,6 @@ import store from '@renderer/store' import type { VertexProvider } from '@renderer/types' +import { getTrailingApiVersion, withoutTrailingApiVersion } from '@shared/utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { @@ -8,14 +9,12 @@ import { formatAzureOpenAIApiHost, formatOllamaApiHost, formatVertexApiHost, - getTrailingApiVersion, hasAPIVersion, isWithTrailingSharp, maskApiKey, routeToEndpoint, splitApiKeyString, validateApiHost, - withoutTrailingApiVersion, withoutTrailingSharp } from '../api' diff --git a/src/renderer/src/utils/api.ts b/src/renderer/src/utils/api.ts index 25a73dcb16..fd470d5406 100644 --- a/src/renderer/src/utils/api.ts +++ b/src/renderer/src/utils/api.ts @@ -19,12 +19,6 @@ export function formatApiKeys(value: string): string { */ const VERSION_REGEX_PATTERN = '\\/v\\d+(?:alpha|beta)?(?=\\/|$)' -/** - * Matches an API version at the end of a URL (with optional trailing slash). - * Used to detect and extract versions only from the trailing position. - */ -const TRAILING_VERSION_REGEX = /\/v\d+(?:alpha|beta)?\/?$/i - /** * 判断 host 的 path 中是否包含形如版本的字符串(例如 /v1、/v2beta 等), * @@ -272,50 +266,3 @@ export function splitApiKeyString(keyStr: string): string[] { .map((k) => k.replace(/\\,/g, ',')) .filter((k) => k) } - -/** - * Extracts the trailing API version segment from a URL path. - * - * This function extracts API version patterns (e.g., `v1`, `v2beta`) from the end of a URL. - * Only versions at the end of the path are extracted, not versions in the middle. - * The returned version string does not include leading or trailing slashes. - * - * @param {string} url - The URL string to parse. - * @returns {string | undefined} The trailing API version found (e.g., 'v1', 'v2beta'), or undefined if none found. - * - * @example - * getTrailingApiVersion('https://api.example.com/v1') // 'v1' - * getTrailingApiVersion('https://api.example.com/v2beta/') // 'v2beta' - * getTrailingApiVersion('https://api.example.com/v1/chat') // undefined (version not at end) - * getTrailingApiVersion('https://gateway.ai.cloudflare.com/v1/xxx/v1beta') // 'v1beta' - * getTrailingApiVersion('https://api.example.com') // undefined - */ -export function getTrailingApiVersion(url: string): string | undefined { - const match = url.match(TRAILING_VERSION_REGEX) - - if (match) { - // Extract version without leading slash and trailing slash - return match[0].replace(/^\//, '').replace(/\/$/, '') - } - - return undefined -} - -/** - * Removes the trailing API version segment from a URL path. - * - * This function removes API version patterns (e.g., `/v1`, `/v2beta`) from the end of a URL. - * Only versions at the end of the path are removed, not versions in the middle. - * - * @param {string} url - The URL string to process. - * @returns {string} The URL with the trailing API version removed, or the original URL if no trailing version found. - * - * @example - * withoutTrailingApiVersion('https://api.example.com/v1') // 'https://api.example.com' - * withoutTrailingApiVersion('https://api.example.com/v2beta/') // 'https://api.example.com' - * withoutTrailingApiVersion('https://api.example.com/v1/chat') // 'https://api.example.com/v1/chat' (no change) - * withoutTrailingApiVersion('https://api.example.com') // 'https://api.example.com' - */ -export function withoutTrailingApiVersion(url: string): string { - return url.replace(TRAILING_VERSION_REGEX, '') -} From 61e80f2e7f0f2d67a8426306c2012b1fab65c39b Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 26 Dec 2025 19:21:42 +0800 Subject: [PATCH 032/116] feat(database): enhance message and topic schemas with new fields - Added `siblingsGroupId` to `message` schema for better message organization. - Introduced `activeNodeId` in `topic` schema to manage message tree structure. - Updated `assistantMeta` and `modelMeta` fields to use specific types for improved type safety. - Included `isNameManuallyEdited` and `sortOrder` in `topic` schema for enhanced topic management. - Added a new entry in the migration journal for version tracking. --- .../sqlite-drizzle/0002_noisy_zzzax.sql | 2 + .../sqlite-drizzle/meta/0002_snapshot.json | 677 ++++++++++++++++++ migrations/sqlite-drizzle/meta/_journal.json | 9 +- packages/shared/data/types/meta.ts | 36 + src/main/data/db/schemas/message.ts | 31 +- src/main/data/db/schemas/topic.ts | 18 +- 6 files changed, 754 insertions(+), 19 deletions(-) create mode 100644 migrations/sqlite-drizzle/0002_noisy_zzzax.sql create mode 100644 migrations/sqlite-drizzle/meta/0002_snapshot.json create mode 100644 packages/shared/data/types/meta.ts diff --git a/migrations/sqlite-drizzle/0002_noisy_zzzax.sql b/migrations/sqlite-drizzle/0002_noisy_zzzax.sql new file mode 100644 index 0000000000..b9c2b04d57 --- /dev/null +++ b/migrations/sqlite-drizzle/0002_noisy_zzzax.sql @@ -0,0 +1,2 @@ +ALTER TABLE `message` RENAME COLUMN "response_group_id" TO "siblings_group_id";--> statement-breakpoint +ALTER TABLE `topic` ADD `active_node_id` text REFERENCES message(id); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0002_snapshot.json b/migrations/sqlite-drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000000..4d56d6a4f2 --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0002_snapshot.json @@ -0,0 +1,677 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b4613090-1bbb-4986-a27b-f58b638f540b", + "prevId": "ae53858a-1786-4059-9ff7-9e87267911b6", + "tables": { + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": [ + "entity_type", + "entity_id", + "tag_id" + ], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": [ + "entity_type", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "siblings_group_id": { + "name": "siblings_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": [ + "topic_id", + "created_at" + ], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" + } + } + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": [ + "scope", + "key" + ], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_node_id": { + "name": "active_node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": [ + "group_id", + "updated_at" + ], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": [ + "group_id", + "sort_order" + ], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": [ + "is_pinned", + "pinned_order" + ], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": [ + "assistant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topic_active_node_id_message_id_fk": { + "name": "topic_active_node_id_message_id_fk", + "tableFrom": "topic", + "tableTo": "message", + "columnsFrom": [ + "active_node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": { + "\"message\".\"response_group_id\"": "\"message\".\"siblings_group_id\"" + } + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index 960ba8a1e0..b83fe4ed13 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1766670360754, "tag": "0001_faulty_ogun", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1766748070409, + "tag": "0002_noisy_zzzax", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/shared/data/types/meta.ts b/packages/shared/data/types/meta.ts new file mode 100644 index 0000000000..2bba74d700 --- /dev/null +++ b/packages/shared/data/types/meta.ts @@ -0,0 +1,36 @@ +/** + * Soft reference metadata types + * + * These types store snapshots of referenced entities at creation time, + * preserving display information even if the original entity is deleted. + */ + +/** + * Preserved assistant info for display when assistant is deleted + * Used in: message.assistantMeta, topic.assistantMeta + */ +export interface AssistantMeta { + /** Original assistant ID, used to attempt reference recovery */ + id: string + /** Assistant display name shown in UI */ + name: string + /** Assistant icon emoji for visual identification */ + emoji?: string + /** Assistant type, e.g., 'default', 'custom', 'agent' */ + type?: string +} + +/** + * Preserved model info for display when model is unavailable + * Used in: message.modelMeta + */ +export interface ModelMeta { + /** Original model ID, used to attempt reference recovery */ + id: string + /** Model display name, e.g., "GPT-4o", "Claude 3.5 Sonnet" */ + name: string + /** Provider identifier, e.g., "openai", "anthropic", "google" */ + provider: string + /** Model family/group, e.g., "gpt-4", "claude-3", useful for grouping in UI */ + group?: string +} diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index 91692ebd3f..b5c1081652 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -1,4 +1,5 @@ import type { MessageData, MessageStats } from '@shared/data/types/message' +import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' import { sql } from 'drizzle-orm' import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' @@ -16,35 +17,39 @@ export const messageTable = sqliteTable( 'message', { id: text().primaryKey(), + // Adjacency list parent reference for tree structure + // SET NULL: preserve child messages when parent is deleted + parentId: text().references(() => messageTable.id, { onDelete: 'set null' }), // FK to topic - CASCADE: delete messages when topic is deleted topicId: text() .notNull() .references(() => topicTable.id, { onDelete: 'cascade' }), - // Adjacency list parent reference for tree structure - // SET NULL: preserve child messages when parent is deleted - parentId: text().references(() => messageTable.id, { onDelete: 'set null' }), - // Group ID for multi-model responses (0 = normal branch) - responseGroupId: integer().default(0), // Message role: user, assistant, system role: text().notNull(), + // Main content - contains blocks[], mentions, etc. + data: text({ mode: 'json' }).$type().notNull(), + // Searchable text extracted from data.blocks (populated by trigger, used for FTS5) + searchableText: text(), + // Final status: SUCCESS, ERROR, PAUSED status: text().notNull(), + + // Group ID for siblings (0 = normal branch) + siblingsGroupId: integer().default(0), // FK to assistant assistantId: text(), // Preserved assistant info for display - assistantMeta: text({ mode: 'json' }), + assistantMeta: text({ mode: 'json' }).$type(), // Model identifier modelId: text(), // Preserved model info (provider, name) - modelMeta: text({ mode: 'json' }), - // Main content - contains blocks[], mentions, etc. - data: text({ mode: 'json' }).$type().notNull(), + modelMeta: text({ mode: 'json' }).$type(), + // Trace ID for tracking + + traceId: text(), // Statistics: token usage, performance metrics, etc. stats: text({ mode: 'json' }).$type(), - // Trace ID for tracking - traceId: text(), - // Searchable text extracted from data.blocks (populated by trigger, used for FTS5) - searchableText: text(), + ...createUpdateDeleteTimestamps }, (t) => [ diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts index 2f08d58fd2..74a0587107 100644 --- a/src/main/data/db/schemas/topic.ts +++ b/src/main/data/db/schemas/topic.ts @@ -1,7 +1,9 @@ +import type { AssistantMeta } from '@shared/data/types/meta' import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { createUpdateDeleteTimestamps } from './columnHelpers' import { groupTable } from './group' +import { messageTable } from './message' /** * Topic table - stores conversation topics/threads @@ -14,21 +16,27 @@ export const topicTable = sqliteTable( { id: text().primaryKey(), name: text(), + // Whether the name was manually edited by user + isNameManuallyEdited: integer({ mode: 'boolean' }).default(false), + // FK to assistant table assistantId: text(), // Preserved assistant info for display when assistant is deleted - assistantMeta: text({ mode: 'json' }), + assistantMeta: text({ mode: 'json' }).$type(), // Topic-specific prompt override prompt: text(), + // Active node ID in the message tree + // SET NULL: reset to null when the referenced message is deleted + activeNodeId: text().references(() => messageTable.id, { onDelete: 'set null' }), + // FK to group table for organization // SET NULL: preserve topic when group is deleted groupId: text().references(() => groupTable.id, { onDelete: 'set null' }), + // Sort order within group + sortOrder: integer().default(0), // Pinning state and order isPinned: integer({ mode: 'boolean' }).default(false), pinnedOrder: integer().default(0), - // Sort order within group - sortOrder: integer().default(0), - // Whether the name was manually edited by user - isNameManuallyEdited: integer({ mode: 'boolean' }).default(false), + ...createUpdateDeleteTimestamps }, (t) => [ From fe7358a33c3e5e3590838d5380971790f2decc1f Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 26 Dec 2025 21:02:20 +0800 Subject: [PATCH 033/116] docs(api): add design guidelines reference to README - Included a note to review the API Design Guidelines before creating new schemas, emphasizing path naming, HTTP methods, and error handling conventions. --- packages/shared/data/api/README.md | 2 + .../shared/data/api/api-design-guidelines.md | 157 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 packages/shared/data/api/api-design-guidelines.md diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md index 059cbcf92d..bd45b67aa2 100644 --- a/packages/shared/data/api/README.md +++ b/packages/shared/data/api/README.md @@ -102,6 +102,8 @@ export interface TopicSchemas { - Requires `response` field for each endpoint - Invalid schemas cause TypeScript errors at the composition point +> **Design Guidelines**: Before creating new schemas, review the [API Design Guidelines](./api-design-guidelines.md) for path naming, HTTP methods, and error handling conventions. + 2. Register in `schemas/index.ts`: ```typescript diff --git a/packages/shared/data/api/api-design-guidelines.md b/packages/shared/data/api/api-design-guidelines.md new file mode 100644 index 0000000000..925a8b35bb --- /dev/null +++ b/packages/shared/data/api/api-design-guidelines.md @@ -0,0 +1,157 @@ +# API Design Guidelines + +Guidelines for designing RESTful APIs in the Cherry Studio Data API system. + +## Path Naming + +| Rule | Example | Notes | +|------|---------|-------| +| Use plural nouns for collections | `/topics`, `/messages` | Resources are collections | +| Use kebab-case for multi-word paths | `/user-settings` | Not camelCase or snake_case | +| Express hierarchy via nesting | `/topics/:topicId/messages` | Parent-child relationships | +| Avoid verbs for CRUD operations | `/topics` not `/getTopics` | HTTP methods express action | + +## HTTP Method Semantics + +| Method | Purpose | Idempotent | Typical Response | +|--------|---------|------------|------------------| +| GET | Retrieve resource(s) | Yes | 200 + data | +| POST | Create resource | No | 201 + created entity | +| PUT | Replace entire resource | Yes | 200 + updated entity | +| PATCH | Partial update | Yes | 200 + updated entity | +| DELETE | Remove resource | Yes | 204 / void | + +## Standard Endpoint Patterns + +```typescript +// Collection operations +'/topics': { + GET: { ... } // List with pagination/filtering + POST: { ... } // Create new resource +} + +// Individual resource operations +'/topics/:id': { + GET: { ... } // Get single resource + PUT: { ... } // Replace resource + PATCH: { ... } // Partial update + DELETE: { ... } // Remove resource +} + +// Nested resources (use for parent-child relationships) +'/topics/:topicId/messages': { + GET: { ... } // List messages under topic + POST: { ... } // Create message in topic +} +``` + +## Non-CRUD Operations + +Use verb-based paths for operations that don't fit CRUD semantics: + +```typescript +// Search +'/topics/search': { + GET: { query: { q: string } } +} + +// Statistics / Aggregations +'/topics/stats': { + GET: { response: { total: number, ... } } +} + +// Resource actions (state changes, triggers) +'/topics/:id/archive': { + POST: { response: { archived: boolean } } +} + +'/topics/:id/duplicate': { + POST: { response: Topic } +} +``` + +## Query Parameters + +| Purpose | Pattern | Example | +|---------|---------|---------| +| Pagination | `page` + `limit` | `?page=1&limit=20` | +| Sorting | `orderBy` + `order` | `?orderBy=createdAt&order=desc` | +| Filtering | direct field names | `?status=active&type=chat` | +| Search | `q` or `search` | `?q=keyword` | + +## Response Status Codes + +Use standard HTTP status codes consistently: + +| Status | Usage | Example | +|--------|-------|---------| +| 200 OK | Successful GET/PUT/PATCH | Return updated resource | +| 201 Created | Successful POST | Return created resource | +| 204 No Content | Successful DELETE | No body | +| 400 Bad Request | Invalid request format | Malformed JSON | +| 401 Unauthorized | Authentication required | Missing/invalid token | +| 403 Forbidden | Permission denied | Insufficient access | +| 404 Not Found | Resource not found | Invalid ID | +| 409 Conflict | Concurrent modification | Version conflict | +| 422 Unprocessable | Validation failed | Invalid field values | +| 429 Too Many Requests | Rate limit exceeded | Throttling | +| 500 Internal Error | Server error | Unexpected failure | + +## Error Response Format + +All error responses follow the `DataApiError` structure: + +```typescript +interface DataApiError { + code: string // ErrorCode enum value (e.g., 'NOT_FOUND') + message: string // Human-readable error message + status: number // HTTP status code + details?: any // Additional context (e.g., field errors) + stack?: string // Stack trace (development only) +} +``` + +**Examples:** + +```typescript +// 404 Not Found +{ + code: 'NOT_FOUND', + message: "Topic with id 'abc123' not found", + status: 404, + details: { resource: 'Topic', id: 'abc123' } +} + +// 422 Validation Error +{ + code: 'VALIDATION_ERROR', + message: 'Request validation failed', + status: 422, + details: { + fieldErrors: { + name: ['Name is required', 'Name must be at least 3 characters'], + email: ['Invalid email format'] + } + } +} +``` + +Use `DataApiErrorFactory` utilities to create consistent errors: + +```typescript +import { DataApiErrorFactory } from '@shared/data/api' + +throw DataApiErrorFactory.notFound('Topic', id) +throw DataApiErrorFactory.validation({ name: ['Required'] }) +throw DataApiErrorFactory.database(error, 'insert topic') +``` + +## Naming Conventions Summary + +| Element | Case | Example | +|---------|------|---------| +| Paths | kebab-case, plural | `/user-settings`, `/topics` | +| Path params | camelCase | `:topicId`, `:messageId` | +| Query params | camelCase | `orderBy`, `pageSize` | +| Body fields | camelCase | `createdAt`, `userName` | +| Error codes | SCREAMING_SNAKE | `NOT_FOUND`, `VALIDATION_ERROR` | From c16789f6977eaf60f2144a3ad19aed577c7fd796 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 26 Dec 2025 22:45:13 +0800 Subject: [PATCH 034/116] feat(database): update README and column helpers for schema guidelines - Expanded the README with detailed database schema guidelines, including naming conventions for tables, columns, and export names. - Introduced new column helper functions for UUID primary keys (v4 and v7) to streamline table definitions. - Updated existing schemas (group, message, tag, topic) to utilize the new UUID primary key helpers for improved consistency and auto-generation. --- .../sqlite-drizzle/meta/0002_snapshot.json | 102 ++++------------- migrations/sqlite-drizzle/meta/_journal.json | 2 +- src/main/data/db/README.md | 107 +++++++++++++++++- src/main/data/db/schemas/columnHelpers.ts | 21 +++- src/main/data/db/schemas/group.ts | 4 +- src/main/data/db/schemas/message.ts | 4 +- src/main/data/db/schemas/tag.ts | 4 +- src/main/data/db/schemas/topic.ts | 4 +- 8 files changed, 158 insertions(+), 90 deletions(-) diff --git a/migrations/sqlite-drizzle/meta/0002_snapshot.json b/migrations/sqlite-drizzle/meta/0002_snapshot.json index 4d56d6a4f2..2a8330fec1 100644 --- a/migrations/sqlite-drizzle/meta/0002_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0002_snapshot.json @@ -91,9 +91,7 @@ "indexes": { "entity_tag_tag_id_idx": { "name": "entity_tag_tag_id_idx", - "columns": [ - "tag_id" - ], + "columns": ["tag_id"], "isUnique": false } }, @@ -102,23 +100,15 @@ "name": "entity_tag_tag_id_tag_id_fk", "tableFrom": "entity_tag", "tableTo": "tag", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": { "entity_tag_entity_type_entity_id_tag_id_pk": { - "columns": [ - "entity_type", - "entity_id", - "tag_id" - ], + "columns": ["entity_type", "entity_id", "tag_id"], "name": "entity_tag_entity_type_entity_id_tag_id_pk" } }, @@ -175,10 +165,7 @@ "indexes": { "group_entity_sort_idx": { "name": "group_entity_sort_idx", - "columns": [ - "entity_type", - "sort_order" - ], + "columns": ["entity_type", "sort_order"], "isUnique": false } }, @@ -314,24 +301,17 @@ "indexes": { "message_parent_id_idx": { "name": "message_parent_id_idx", - "columns": [ - "parent_id" - ], + "columns": ["parent_id"], "isUnique": false }, "message_topic_created_idx": { "name": "message_topic_created_idx", - "columns": [ - "topic_id", - "created_at" - ], + "columns": ["topic_id", "created_at"], "isUnique": false }, "message_trace_id_idx": { "name": "message_trace_id_idx", - "columns": [ - "trace_id" - ], + "columns": ["trace_id"], "isUnique": false } }, @@ -340,12 +320,8 @@ "name": "message_parent_id_message_id_fk", "tableFrom": "message", "tableTo": "message", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -353,12 +329,8 @@ "name": "message_topic_id_topic_id_fk", "tableFrom": "message", "tableTo": "topic", - "columnsFrom": [ - "topic_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -420,10 +392,7 @@ "foreignKeys": {}, "compositePrimaryKeys": { "preference_scope_key_pk": { - "columns": [ - "scope", - "key" - ], + "columns": ["scope", "key"], "name": "preference_scope_key_pk" } }, @@ -472,9 +441,7 @@ "indexes": { "tag_name_unique": { "name": "tag_name_unique", - "columns": [ - "name" - ], + "columns": ["name"], "isUnique": true } }, @@ -592,40 +559,27 @@ "indexes": { "topic_group_updated_idx": { "name": "topic_group_updated_idx", - "columns": [ - "group_id", - "updated_at" - ], + "columns": ["group_id", "updated_at"], "isUnique": false }, "topic_group_sort_idx": { "name": "topic_group_sort_idx", - "columns": [ - "group_id", - "sort_order" - ], + "columns": ["group_id", "sort_order"], "isUnique": false }, "topic_updated_at_idx": { "name": "topic_updated_at_idx", - "columns": [ - "updated_at" - ], + "columns": ["updated_at"], "isUnique": false }, "topic_is_pinned_idx": { "name": "topic_is_pinned_idx", - "columns": [ - "is_pinned", - "pinned_order" - ], + "columns": ["is_pinned", "pinned_order"], "isUnique": false }, "topic_assistant_id_idx": { "name": "topic_assistant_id_idx", - "columns": [ - "assistant_id" - ], + "columns": ["assistant_id"], "isUnique": false } }, @@ -634,12 +588,8 @@ "name": "topic_active_node_id_message_id_fk", "tableFrom": "topic", "tableTo": "message", - "columnsFrom": [ - "active_node_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["active_node_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -647,12 +597,8 @@ "name": "topic_group_id_group_id_fk", "tableFrom": "topic", "tableTo": "group", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["group_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -674,4 +620,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index b83fe4ed13..c3aa7c7c47 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -24,4 +24,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 8bc38b01c4..720348e666 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -1,2 +1,105 @@ -- All the database table names use **singular** form, snake_casing -- Export table names use `xxxxTable` +# Database Schema Guidelines + +## Naming Conventions + +- **Table names**: Use **singular** form with snake_case (e.g., `topic`, `message`, `app_state`) +- **Export names**: Use `xxxTable` pattern (e.g., `topicTable`, `messageTable`) +- **Column names**: Drizzle auto-infers from property names, no need to specify explicitly + +## Column Helpers + +All helpers are exported from `./schemas/columnHelpers.ts`. + +### Primary Keys + +| Helper | UUID Version | Use Case | +|--------|--------------|----------| +| `uuidPrimaryKey()` | v4 (random) | General purpose tables | +| `uuidPrimaryKeyOrdered()` | v7 (time-ordered) | Large tables with time-based queries | + +**Usage:** + +```typescript +import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers' + +// General purpose table +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + name: text(), + ... +}) + +// Large table with time-ordered data +export const messageTable = sqliteTable('message', { + id: uuidPrimaryKeyOrdered(), + content: text(), + ... +}) +``` + +**Behavior:** + +- ID is auto-generated if not provided during insert +- Can be manually specified for migration scenarios +- Use `.returning()` to get the generated ID after insert + +### Timestamps + +| Helper | Fields | Use Case | +|--------|--------|----------| +| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete | +| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete | + +**Usage:** + +```typescript +import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers' + +// Without soft delete +export const tagTable = sqliteTable('tag', { + id: uuidPrimaryKey(), + name: text(), + ...createUpdateTimestamps +}) + +// With soft delete +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + name: text(), + ...createUpdateDeleteTimestamps +}) +``` + +**Behavior:** + +- `createdAt`: Auto-set to `Date.now()` on insert +- `updatedAt`: Auto-set on insert, auto-updated on update +- `deletedAt`: `null` by default, set to timestamp for soft delete + +## JSON Fields + +For JSON column support, use `{ mode: 'json' }`: + +```typescript +data: text({ mode: 'json' }).$type() +``` + +Drizzle handles JSON serialization/deserialization automatically. + +## Foreign Keys + +```typescript +// SET NULL: preserve record when referenced record is deleted +groupId: text().references(() => groupTable.id, { onDelete: 'set null' }) + +// CASCADE: delete record when referenced record is deleted +topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }) +``` + +## Migrations + +Generate migrations after schema changes: + +```bash +yarn db:migrations:generate +``` diff --git a/src/main/data/db/schemas/columnHelpers.ts b/src/main/data/db/schemas/columnHelpers.ts index 7623afd0ed..c5ea83804c 100644 --- a/src/main/data/db/schemas/columnHelpers.ts +++ b/src/main/data/db/schemas/columnHelpers.ts @@ -1,4 +1,23 @@ -import { integer } from 'drizzle-orm/sqlite-core' +import { integer, text } from 'drizzle-orm/sqlite-core' +import { v4 as uuidv4, v7 as uuidv7 } from 'uuid' + +/** + * UUID v4 primary key with auto-generation + * Use for general purpose tables + */ +export const uuidPrimaryKey = () => + text() + .primaryKey() + .$defaultFn(() => uuidv4()) + +/** + * UUID v7 primary key with auto-generation (time-ordered) + * Use for tables with large datasets that benefit from sequential inserts + */ +export const uuidPrimaryKeyOrdered = () => + text() + .primaryKey() + .$defaultFn(() => uuidv7()) const createTimestamp = () => { return Date.now() diff --git a/src/main/data/db/schemas/group.ts b/src/main/data/db/schemas/group.ts index dc7bd088c2..6ef06c522f 100644 --- a/src/main/data/db/schemas/group.ts +++ b/src/main/data/db/schemas/group.ts @@ -1,6 +1,6 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { createUpdateTimestamps } from './columnHelpers' +import { createUpdateTimestamps, uuidPrimaryKey } from './columnHelpers' /** * Group table - general-purpose grouping for entities @@ -11,7 +11,7 @@ import { createUpdateTimestamps } from './columnHelpers' export const groupTable = sqliteTable( 'group', { - id: text().primaryKey(), + id: uuidPrimaryKey(), // Entity type this group belongs to: topic, session, assistant entityType: text().notNull(), // Display name of the group diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index b5c1081652..08545b9f9b 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -3,7 +3,7 @@ import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' import { sql } from 'drizzle-orm' import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { createUpdateDeleteTimestamps } from './columnHelpers' +import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } from './columnHelpers' import { topicTable } from './topic' /** @@ -16,7 +16,7 @@ import { topicTable } from './topic' export const messageTable = sqliteTable( 'message', { - id: text().primaryKey(), + id: uuidPrimaryKeyOrdered(), // Adjacency list parent reference for tree structure // SET NULL: preserve child messages when parent is deleted parentId: text().references(() => messageTable.id, { onDelete: 'set null' }), diff --git a/src/main/data/db/schemas/tag.ts b/src/main/data/db/schemas/tag.ts index 8a84e6d704..87820fadf9 100644 --- a/src/main/data/db/schemas/tag.ts +++ b/src/main/data/db/schemas/tag.ts @@ -1,6 +1,6 @@ import { sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { createUpdateTimestamps } from './columnHelpers' +import { createUpdateTimestamps, uuidPrimaryKey } from './columnHelpers' /** * Tag table - general-purpose tags for entities @@ -9,7 +9,7 @@ import { createUpdateTimestamps } from './columnHelpers' * via the entity_tag join table. */ export const tagTable = sqliteTable('tag', { - id: text().primaryKey(), + id: uuidPrimaryKey(), // Unique tag name name: text().notNull().unique(), // Display color (hex code) diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts index 74a0587107..b121c5405d 100644 --- a/src/main/data/db/schemas/topic.ts +++ b/src/main/data/db/schemas/topic.ts @@ -1,7 +1,7 @@ import type { AssistantMeta } from '@shared/data/types/meta' import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { createUpdateDeleteTimestamps } from './columnHelpers' +import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers' import { groupTable } from './group' import { messageTable } from './message' @@ -14,7 +14,7 @@ import { messageTable } from './message' export const topicTable = sqliteTable( 'topic', { - id: text().primaryKey(), + id: uuidPrimaryKey(), name: text(), // Whether the name was manually edited by user isNameManuallyEdited: integer({ mode: 'boolean' }).default(false), From 401d66f3ddbb3c1bdecbb23f6fcf69dafae83252 Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Fri, 26 Dec 2025 23:43:38 +0800 Subject: [PATCH 035/116] fix(windows): remember size not working for SelectionAction window (#12132) Co-authored-by: Claude Opus 4.5 --- src/main/services/SelectionService.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 695026003b..629e67401c 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1435,6 +1435,12 @@ export class SelectionService { } actionWindow.setBounds({ x, y, width, height }) + + // [Windows only] Update remembered window size for custom resize + // setBounds() may not trigger the 'resized' event, so we need to update manually + if (this.isRemeberWinSize) { + this.lastActionWindowSize = { width, height } + } } /** From e4ec7bba7c75fc4d13b2a6fbe364081d2f097907 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 27 Dec 2025 11:17:47 +0800 Subject: [PATCH 036/116] docs(api): add PATCH vs Dedicated Endpoints section to API design guidelines - Introduced a decision tree to help determine when to use PATCH versus dedicated endpoints based on operation characteristics. - Added guidelines for naming dedicated endpoints and provided examples for various scenarios, enhancing clarity on API design practices. --- .../shared/data/api/api-design-guidelines.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/shared/data/api/api-design-guidelines.md b/packages/shared/data/api/api-design-guidelines.md index 925a8b35bb..0cd8ccbe8b 100644 --- a/packages/shared/data/api/api-design-guidelines.md +++ b/packages/shared/data/api/api-design-guidelines.md @@ -45,6 +45,63 @@ Guidelines for designing RESTful APIs in the Cherry Studio Data API system. } ``` +## PATCH vs Dedicated Endpoints + +### Decision Criteria + +Use this decision tree to determine the appropriate approach: + +``` +Operation characteristics: +├── Simple field update with no side effects? +│ └── Yes → Use PATCH +├── High-frequency operation with clear business meaning? +│ └── Yes → Use dedicated endpoint (noun-based sub-resource) +├── Operation triggers complex side effects or validation? +│ └── Yes → Use dedicated endpoint +├── Operation creates new resources? +│ └── Yes → Use POST to dedicated endpoint +└── Default → Use PATCH +``` + +### Guidelines + +| Scenario | Approach | Example | +|----------|----------|---------| +| Simple field update | PATCH | `PATCH /messages/:id { data: {...} }` | +| High-frequency + business meaning | Dedicated sub-resource | `PUT /topics/:id/active-node { nodeId }` | +| Complex validation/side effects | Dedicated endpoint | `POST /messages/:id/move { newParentId }` | +| Creates new resources | POST dedicated | `POST /messages/:id/duplicate` | + +### Naming for Dedicated Endpoints + +- **Prefer noun-based paths** over verb-based when possible +- Treat the operation target as a sub-resource: `/topics/:id/active-node` not `/topics/:id/switch-branch` +- Use POST for actions that create resources or have non-idempotent side effects +- Use PUT for setting/replacing a sub-resource value + +### Examples + +```typescript +// ✅ Good: Noun-based sub-resource for high-frequency operation +PUT /topics/:id/active-node +{ nodeId: string } + +// ✅ Good: Simple field update via PATCH +PATCH /messages/:id +{ data: MessageData } + +// ✅ Good: POST for resource creation +POST /messages/:id/duplicate +{ includeDescendants?: boolean } + +// ❌ Avoid: Verb in path when noun works +POST /topics/:id/switch-branch // Use PUT /topics/:id/active-node instead + +// ❌ Avoid: Dedicated endpoint for simple updates +POST /messages/:id/update-content // Use PATCH /messages/:id instead +``` + ## Non-CRUD Operations Use verb-based paths for operations that don't fit CRUD semantics: From 9586f381577f3bb0e8e1768c56151fb1d2dc6c0c Mon Sep 17 00:00:00 2001 From: Phantom Date: Sat, 27 Dec 2025 12:27:11 +0800 Subject: [PATCH 037/116] build: upgrade electron-vite to 5.0.0 with HMR support (#12120) --- electron.vite.config.ts | 7 +- package.json | 7 +- yarn.lock | 486 +++++++++++++++++++++++++++++----------- 3 files changed, 356 insertions(+), 144 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 172d48ca9a..89c0cf2f9b 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,6 +1,6 @@ import react from '@vitejs/plugin-react-swc' import { CodeInspectorPlugin } from 'code-inspector-plugin' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import { defineConfig } from 'electron-vite' import { resolve } from 'path' import { visualizer } from 'rollup-plugin-visualizer' @@ -17,7 +17,7 @@ const isProd = process.env.NODE_ENV === 'production' export default defineConfig({ main: { - plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')], + plugins: [...visualizerPlugin('main')], resolve: { alias: { '@main': resolve('src/main'), @@ -51,8 +51,7 @@ export default defineConfig({ plugins: [ react({ tsDecorators: true - }), - externalizeDepsPlugin() + }) ], resolve: { alias: { diff --git a/package.json b/package.json index 7ad4140af7..2c3c05daf2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "scripts": { "start": "electron-vite preview", "dev": "dotenv electron-vite dev", + "dev:watch": "dotenv electron-vite dev -- -w", "debug": "electron-vite -- --inspect --sourcemap --remote-debugging-port=9222", "build": "npm run typecheck && electron-vite build", "build:check": "yarn lint && yarn test", @@ -273,7 +274,7 @@ "electron-reload": "^2.0.0-alpha.1", "electron-store": "^8.2.0", "electron-updater": "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch", - "electron-vite": "4.0.1", + "electron-vite": "5.0.0", "electron-window-state": "^5.0.3", "emittery": "^1.0.3", "emoji-picker-element": "^1.22.1", @@ -370,7 +371,7 @@ "undici": "6.21.2", "unified": "^11.0.5", "uuid": "^13.0.0", - "vite": "npm:rolldown-vite@7.1.5", + "vite": "npm:rolldown-vite@7.3.0", "vitest": "^3.2.4", "webdav": "^5.8.0", "winston": "^3.17.0", @@ -400,7 +401,7 @@ "pkce-challenge@npm:^4.1.0": "patch:pkce-challenge@npm%3A4.1.0#~/.yarn/patches/pkce-challenge-npm-4.1.0-fbc51695a3.patch", "tar-fs": "^2.1.4", "undici": "6.21.2", - "vite": "npm:rolldown-vite@7.1.5", + "vite": "npm:rolldown-vite@7.3.0", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "@ai-sdk/openai@npm:^2.0.52": "patch:@ai-sdk/openai@npm%3A2.0.52#~/.yarn/patches/@ai-sdk-openai-npm-2.0.52-b36d949c76.patch", "@img/sharp-darwin-arm64": "0.34.3", diff --git a/yarn.lock b/yarn.lock index 1dffdbeb42..b6b87c568a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -381,7 +381,7 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": +"@ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -1524,26 +1524,26 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.27.7": - version: 7.28.0 - resolution: "@babel/core@npm:7.28.0" +"@babel/core@npm:^7.28.4": + version: 7.28.5 + resolution: "@babel/core@npm:7.28.5" dependencies: - "@ampproject/remapping": "npm:^2.2.0" "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.0" + "@babel/generator": "npm:^7.28.5" "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.27.3" - "@babel/helpers": "npm:^7.27.6" - "@babel/parser": "npm:^7.28.0" + "@babel/helper-module-transforms": "npm:^7.28.3" + "@babel/helpers": "npm:^7.28.4" + "@babel/parser": "npm:^7.28.5" "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.0" - "@babel/types": "npm:^7.28.0" + "@babel/traverse": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10c0/423302e7c721e73b1c096217880272e02020dfb697a55ccca60ad01bba90037015f84d0c20c6ce297cf33a19bb704bc5c2b3d3095f5284dfa592bd1de0b9e8c3 + checksum: 10c0/535f82238027621da6bdffbdbe896ebad3558b311d6f8abc680637a9859b96edbf929ab010757055381570b29cf66c4a295b5618318d27a4273c0e2033925e72 languageName: node linkType: hard @@ -1560,6 +1560,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/generator@npm:7.28.5" + dependencies: + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/9f219fe1d5431b6919f1a5c60db8d5d34fe546c0d8f5a8511b32f847569234ffc8032beb9e7404649a143f54e15224ecb53a3d11b6bb85c3203e573d91fca752 + languageName: node + linkType: hard + "@babel/helper-compilation-targets@npm:^7.27.2": version: 7.27.2 resolution: "@babel/helper-compilation-targets@npm:7.27.2" @@ -1590,16 +1603,16 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.27.3": - version: 7.27.3 - resolution: "@babel/helper-module-transforms@npm:7.27.3" +"@babel/helper-module-transforms@npm:^7.28.3": + version: 7.28.3 + resolution: "@babel/helper-module-transforms@npm:7.28.3" dependencies: "@babel/helper-module-imports": "npm:^7.27.1" "@babel/helper-validator-identifier": "npm:^7.27.1" - "@babel/traverse": "npm:^7.27.3" + "@babel/traverse": "npm:^7.28.3" peerDependencies: "@babel/core": ^7.0.0 - checksum: 10c0/fccb4f512a13b4c069af51e1b56b20f54024bcf1591e31e978a30f3502567f34f90a80da6a19a6148c249216292a8074a0121f9e52602510ef0f32dbce95ca01 + checksum: 10c0/549be62515a6d50cd4cfefcab1b005c47f89bd9135a22d602ee6a5e3a01f27571868ada10b75b033569f24dc4a2bb8d04bfa05ee75c16da7ade2d0db1437fcdb languageName: node linkType: hard @@ -1624,6 +1637,13 @@ __metadata: languageName: node linkType: hard +"@babel/helper-validator-identifier@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/helper-validator-identifier@npm:7.28.5" + checksum: 10c0/42aaebed91f739a41f3d80b72752d1f95fd7c72394e8e4bd7cdd88817e0774d80a432451bcba17c2c642c257c483bf1d409dd4548883429ea9493a3bc4ab0847 + languageName: node + linkType: hard + "@babel/helper-validator-option@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-validator-option@npm:7.27.1" @@ -1631,13 +1651,13 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.27.6": - version: 7.27.6 - resolution: "@babel/helpers@npm:7.27.6" +"@babel/helpers@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/helpers@npm:7.28.4" dependencies: "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.27.6" - checksum: 10c0/448bac96ef8b0f21f2294a826df9de6bf4026fd023f8a6bb6c782fe3e61946801ca24381490b8e58d861fee75cd695a1882921afbf1f53b0275ee68c938bd6d3 + "@babel/types": "npm:^7.28.4" + checksum: 10c0/aaa5fb8098926dfed5f223adf2c5e4c7fbba4b911b73dfec2d7d3083f8ba694d201a206db673da2d9b3ae8c01793e795767654558c450c8c14b4c2175b4fcb44 languageName: node linkType: hard @@ -1652,6 +1672,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/parser@npm:7.28.5" + dependencies: + "@babel/types": "npm:^7.28.5" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/5bbe48bf2c79594ac02b490a41ffde7ef5aa22a9a88ad6bcc78432a6ba8a9d638d531d868bd1f104633f1f6bba9905746e15185b8276a3756c42b765d131b1ef + languageName: node + linkType: hard + "@babel/plugin-transform-arrow-functions@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" @@ -1695,7 +1726,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.27.3, @babel/traverse@npm:^7.28.0": +"@babel/traverse@npm:^7.27.1": version: 7.28.0 resolution: "@babel/traverse@npm:7.28.0" dependencies: @@ -1710,7 +1741,22 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.6, @babel/types@npm:^7.28.0": +"@babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/traverse@npm:7.28.5" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@babel/generator": "npm:^7.28.5" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.28.5" + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.28.5" + debug: "npm:^4.3.1" + checksum: 10c0/f6c4a595993ae2b73f2d4cd9c062f2e232174d293edd4abe1d715bd6281da8d99e47c65857e8d0917d9384c65972f4acdebc6749a7c40a8fcc38b3c7fb3e706f + languageName: node + linkType: hard + +"@babel/types@npm:^7.25.4, @babel/types@npm:^7.27.1, @babel/types@npm:^7.28.0": version: 7.28.1 resolution: "@babel/types@npm:7.28.1" dependencies: @@ -1730,6 +1776,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": + version: 7.28.5 + resolution: "@babel/types@npm:7.28.5" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/a5a483d2100befbf125793640dec26b90b95fd233a94c19573325898a5ce1e52cdfa96e495c7dcc31b5eca5b66ce3e6d4a0f5a4a62daec271455959f208ab08a + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^1.0.2": version: 1.0.2 resolution: "@bcoe/v8-coverage@npm:1.0.2" @@ -2887,6 +2943,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.7.1": + version: 1.7.1 + resolution: "@emnapi/core@npm:1.7.1" + dependencies: + "@emnapi/wasi-threads": "npm:1.1.0" + tslib: "npm:^2.4.0" + checksum: 10c0/f3740be23440b439333e3ae3832163f60c96c4e35337f3220ceba88f36ee89a57a871d27c94eb7a9ff98a09911ed9a2089e477ab549f4d30029f8b907f84a351 + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.4.4, @emnapi/runtime@npm:^1.4.5": version: 1.4.5 resolution: "@emnapi/runtime@npm:1.4.5" @@ -2896,6 +2962,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.7.1": + version: 1.7.1 + resolution: "@emnapi/runtime@npm:1.7.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/26b851cd3e93877d8732a985a2ebf5152325bbacc6204ef5336a47359dedcc23faeb08cdfcb8bb389b5401b3e894b882bc1a1e55b4b7c1ed1e67c991a760ddd5 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.0.4": version: 1.0.4 resolution: "@emnapi/wasi-threads@npm:1.0.4" @@ -2905,7 +2980,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/wasi-threads@npm:^1.0.4": +"@emnapi/wasi-threads@npm:1.1.0, @emnapi/wasi-threads@npm:^1.0.4": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" dependencies: @@ -3883,7 +3958,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/remapping@npm:^2.3.4": +"@jridgewell/remapping@npm:^2.3.4, @jridgewell/remapping@npm:^2.3.5": version: 2.3.5 resolution: "@jridgewell/remapping@npm:2.3.5" dependencies: @@ -4987,14 +5062,14 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.0.3": - version: 1.0.3 - resolution: "@napi-rs/wasm-runtime@npm:1.0.3" +"@napi-rs/wasm-runtime@npm:^1.1.0": + version: 1.1.0 + resolution: "@napi-rs/wasm-runtime@npm:1.1.0" dependencies: - "@emnapi/core": "npm:^1.4.5" - "@emnapi/runtime": "npm:^1.4.5" - "@tybys/wasm-util": "npm:^0.10.0" - checksum: 10c0/7918d82477e75931b6e35bb003464382eb93e526362f81a98bf8610407a67b10f4d041931015ad48072c89db547deb7e471dfb91f4ab11ac63a24d8580297f75 + "@emnapi/core": "npm:^1.7.1" + "@emnapi/runtime": "npm:^1.7.1" + "@tybys/wasm-util": "npm:^0.10.1" + checksum: 10c0/ee351052123bfc635c4cef03ac273a686522394ccd513b1e5b7b3823cecd6abb4a31f23a3a962933192b87eb7b7c3eb3def7748bd410edc66f932d90cf44e9ab languageName: node linkType: hard @@ -5329,6 +5404,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/runtime@npm:0.101.0": + version: 0.101.0 + resolution: "@oxc-project/runtime@npm:0.101.0" + checksum: 10c0/86fd7bb37e94986e7a09bde07a16fa63cebeaada6bcb8963bc07087d54c107d1a128e1c4a5d27b9b593354c092b8976d7653b6700fbb0da0a2b925fb3de4b34c + languageName: node + linkType: hard + "@oxc-project/runtime@npm:0.71.0": version: 0.71.0 resolution: "@oxc-project/runtime@npm:0.71.0" @@ -5336,13 +5418,6 @@ __metadata: languageName: node linkType: hard -"@oxc-project/runtime@npm:=0.82.3": - version: 0.82.3 - resolution: "@oxc-project/runtime@npm:0.82.3" - checksum: 10c0/48fd0577a9bd146da7eefea8e61a7c855f8947ef6233fe7db2921e5c1f07d73459d8fb4d2d9e45f4d522d5bb31af8157c96020860154fdf7223a9cb0957e36c0 - languageName: node - linkType: hard - "@oxc-project/types@npm:0.71.0": version: 0.71.0 resolution: "@oxc-project/types@npm:0.71.0" @@ -5350,10 +5425,10 @@ __metadata: languageName: node linkType: hard -"@oxc-project/types@npm:=0.82.3": - version: 0.82.3 - resolution: "@oxc-project/types@npm:0.82.3" - checksum: 10c0/17dffc91dc3b726be67b7333d251e811bf4badce8ae77269d1626a107cd7cb673674a3fd6e0f127e40951d630281b9a164fee787a1a0cad12e7372a14b89d7cf +"@oxc-project/types@npm:=0.101.0": + version: 0.101.0 + resolution: "@oxc-project/types@npm:0.101.0" + checksum: 10c0/e4e98da6e34ef0163a652e842e795bda77b703d8282fed4984292ff7b289c4e03d848ed8762e549445e33a142d3883e1013cd9ed43156f6eba34c151b8f599c1 languageName: node linkType: hard @@ -6265,16 +6340,16 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.34" +"@rolldown/binding-android-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.53" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.34" +"@rolldown/binding-darwin-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.53" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -6286,9 +6361,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.34" +"@rolldown/binding-darwin-x64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.53" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -6300,9 +6375,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.34" +"@rolldown/binding-freebsd-x64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.53" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -6314,9 +6389,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.34" +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.53" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -6328,9 +6403,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.34" +"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.53" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -6342,9 +6417,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.34" +"@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.53" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -6356,9 +6431,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.34" +"@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.53" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -6370,9 +6445,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.34" +"@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.53" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -6384,18 +6459,18 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.34" +"@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.53" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.34" +"@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.53" dependencies: - "@napi-rs/wasm-runtime": "npm:^1.0.3" + "@napi-rs/wasm-runtime": "npm:^1.1.0" conditions: cpu=wasm32 languageName: node linkType: hard @@ -6409,9 +6484,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.34" +"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.53" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -6423,13 +6498,6 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.34" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.9-commit.d91dfb5": version: 1.0.0-beta.9-commit.d91dfb5 resolution: "@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.9-commit.d91dfb5" @@ -6437,9 +6505,9 @@ __metadata: languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.34" +"@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.53" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -6458,10 +6526,10 @@ __metadata: languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.34" - checksum: 10c0/96565287991825ecd90b60607dae908ebfdde233661fc589c98547a75c1fd0282b2e2a7849c3eb0c9941e2fba34667a8d5cdb8d597370815c19c2f29b4c157b4 +"@rolldown/pluginutils@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.53" + checksum: 10c0/e8b0a7eb76be22f6f103171f28072de821525a4e400454850516da91a7381957932ff0ce495f227bcb168e86815788b0c1d249ca9e34dca366a82c8825b714ce languageName: node linkType: hard @@ -8204,6 +8272,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.1": + version: 0.10.1 + resolution: "@tybys/wasm-util@npm:0.10.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/b255094f293794c6d2289300c5fbcafbb5532a3aed3a5ffd2f8dc1828e639b88d75f6a376dd8f94347a44813fd7a7149d8463477a9a49525c8b2dcaa38c2d1e8 + languageName: node + linkType: hard + "@types/aria-query@npm:^5.0.1": version: 5.0.4 resolution: "@types/aria-query@npm:5.0.4" @@ -10209,7 +10286,7 @@ __metadata: electron-reload: "npm:^2.0.0-alpha.1" electron-store: "npm:^8.2.0" electron-updater: "patch:electron-updater@npm%3A6.7.0#~/.yarn/patches/electron-updater-npm-6.7.0-47b11bb0d4.patch" - electron-vite: "npm:4.0.1" + electron-vite: "npm:5.0.0" electron-window-state: "npm:^5.0.3" emittery: "npm:^1.0.3" emoji-picker-element: "npm:^1.22.1" @@ -10322,7 +10399,7 @@ __metadata: undici: "npm:6.21.2" unified: "npm:^11.0.5" uuid: "npm:^13.0.0" - vite: "npm:rolldown-vite@7.1.5" + vite: "npm:rolldown-vite@7.3.0" vitest: "npm:^3.2.4" webdav: "npm:^5.8.0" winston: "npm:^3.17.0" @@ -13773,15 +13850,15 @@ __metadata: languageName: node linkType: hard -"electron-vite@npm:4.0.1": - version: 4.0.1 - resolution: "electron-vite@npm:4.0.1" +"electron-vite@npm:5.0.0": + version: 5.0.0 + resolution: "electron-vite@npm:5.0.0" dependencies: - "@babel/core": "npm:^7.27.7" + "@babel/core": "npm:^7.28.4" "@babel/plugin-transform-arrow-functions": "npm:^7.27.1" cac: "npm:^6.7.14" - esbuild: "npm:^0.25.5" - magic-string: "npm:^0.30.17" + esbuild: "npm:^0.25.11" + magic-string: "npm:^0.30.19" picocolors: "npm:^1.1.1" peerDependencies: "@swc/core": ^1.0.0 @@ -13791,7 +13868,7 @@ __metadata: optional: true bin: electron-vite: bin/electron-vite.js - checksum: 10c0/4e81ac4e4ede6060ffec56ba9b1d5ff95bb263496e62527345e8c79542924c54c54def39de9b466a81ed250b68774792c2106b93274c790b4cd8e7be448f6af8 + checksum: 10c0/e7797910b23f23f39c12ded92d07d7164c5c6adab294aa13278c1b49ada3b12868b13ace8546d2656db4dbab89978cf8368a659d1ce6a2fb9f1aeddb1c8de557 languageName: node linkType: hard @@ -17573,6 +17650,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-android-arm64@npm:1.30.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-darwin-arm64@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-darwin-arm64@npm:1.30.1" @@ -17580,6 +17664,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-arm64@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-darwin-arm64@npm:1.30.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-darwin-x64@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-darwin-x64@npm:1.30.1" @@ -17587,6 +17678,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-darwin-x64@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-darwin-x64@npm:1.30.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "lightningcss-freebsd-x64@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-freebsd-x64@npm:1.30.1" @@ -17594,6 +17692,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-freebsd-x64@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-freebsd-x64@npm:1.30.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "lightningcss-linux-arm-gnueabihf@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-linux-arm-gnueabihf@npm:1.30.1" @@ -17601,6 +17706,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm-gnueabihf@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.30.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "lightningcss-linux-arm64-gnu@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-linux-arm64-gnu@npm:1.30.1" @@ -17608,6 +17720,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-gnu@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-linux-arm64-gnu@npm:1.30.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-arm64-musl@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-linux-arm64-musl@npm:1.30.1" @@ -17615,6 +17734,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-arm64-musl@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-linux-arm64-musl@npm:1.30.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + "lightningcss-linux-x64-gnu@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-linux-x64-gnu@npm:1.30.1" @@ -17622,6 +17748,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-gnu@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-linux-x64-gnu@npm:1.30.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "lightningcss-linux-x64-musl@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-linux-x64-musl@npm:1.30.1" @@ -17629,6 +17762,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-linux-x64-musl@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-linux-x64-musl@npm:1.30.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + "lightningcss-win32-arm64-msvc@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-win32-arm64-msvc@npm:1.30.1" @@ -17636,6 +17776,13 @@ __metadata: languageName: node linkType: hard +"lightningcss-win32-arm64-msvc@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-win32-arm64-msvc@npm:1.30.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "lightningcss-win32-x64-msvc@npm:1.30.1": version: 1.30.1 resolution: "lightningcss-win32-x64-msvc@npm:1.30.1" @@ -17643,7 +17790,14 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:1.30.1, lightningcss@npm:^1.30.1": +"lightningcss-win32-x64-msvc@npm:1.30.2": + version: 1.30.2 + resolution: "lightningcss-win32-x64-msvc@npm:1.30.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.30.1": version: 1.30.1 resolution: "lightningcss@npm:1.30.1" dependencies: @@ -17683,6 +17837,49 @@ __metadata: languageName: node linkType: hard +"lightningcss@npm:^1.30.2": + version: 1.30.2 + resolution: "lightningcss@npm:1.30.2" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.30.2" + lightningcss-darwin-arm64: "npm:1.30.2" + lightningcss-darwin-x64: "npm:1.30.2" + lightningcss-freebsd-x64: "npm:1.30.2" + lightningcss-linux-arm-gnueabihf: "npm:1.30.2" + lightningcss-linux-arm64-gnu: "npm:1.30.2" + lightningcss-linux-arm64-musl: "npm:1.30.2" + lightningcss-linux-x64-gnu: "npm:1.30.2" + lightningcss-linux-x64-musl: "npm:1.30.2" + lightningcss-win32-arm64-msvc: "npm:1.30.2" + lightningcss-win32-x64-msvc: "npm:1.30.2" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/5c0c73a33946dab65908d5cd1325df4efa290efb77f940b60f40448b5ab9a87d3ea665ef9bcf00df4209705050ecf2f7ecc649f44d6dfa5905bb50f15717e78d + languageName: node + linkType: hard + "lilconfig@npm:^3.1.3": version: 3.1.3 resolution: "lilconfig@npm:3.1.3" @@ -18046,6 +18243,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.19": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + "magicast@npm:^0.3.5": version: 0.3.5 resolution: "magicast@npm:0.3.5" @@ -22873,28 +23079,25 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:1.0.0-beta.34": - version: 1.0.0-beta.34 - resolution: "rolldown@npm:1.0.0-beta.34" +"rolldown@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "rolldown@npm:1.0.0-beta.53" dependencies: - "@oxc-project/runtime": "npm:=0.82.3" - "@oxc-project/types": "npm:=0.82.3" - "@rolldown/binding-android-arm64": "npm:1.0.0-beta.34" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.34" - "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.34" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.34" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.34" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.34" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.34" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.34" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.34" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.34" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.34" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.34" - "@rolldown/binding-win32-ia32-msvc": "npm:1.0.0-beta.34" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.34" - "@rolldown/pluginutils": "npm:1.0.0-beta.34" - ansis: "npm:^4.0.0" + "@oxc-project/types": "npm:=0.101.0" + "@rolldown/binding-android-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.53" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.53" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.53" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.53" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.53" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.53" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.53" + "@rolldown/pluginutils": "npm:1.0.0-beta.53" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -22920,13 +23123,11 @@ __metadata: optional: true "@rolldown/binding-win32-arm64-msvc": optional: true - "@rolldown/binding-win32-ia32-msvc": - optional: true "@rolldown/binding-win32-x64-msvc": optional: true bin: rolldown: bin/cli.mjs - checksum: 10c0/3fdaa36b3bfcdd6913973ef8d785a7e7eeb8c181626ac0d0b8a75aecca2ba3d536ff29a3f5c003f692d7c422e022d0357d7d564ab4aa67cf128230ca137473e8 + checksum: 10c0/363109aa38b31254e682e69aa9f199074d98b823b437faac6d05fd1b4a2b73168b9434043a060fecfc25d3e1d441e2d3b757e92621bc1e843a3e916e2b0d3f58 languageName: node linkType: hard @@ -24478,6 +24679,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + languageName: node + linkType: hard + "tinypool@npm:^1.1.1": version: 1.1.1 resolution: "tinypool@npm:1.1.1" @@ -25590,20 +25801,21 @@ __metadata: languageName: node linkType: hard -"vite@npm:rolldown-vite@7.1.5": - version: 7.1.5 - resolution: "rolldown-vite@npm:7.1.5" +"vite@npm:rolldown-vite@7.3.0": + version: 7.3.0 + resolution: "rolldown-vite@npm:7.3.0" dependencies: + "@oxc-project/runtime": "npm:0.101.0" fdir: "npm:^6.5.0" fsevents: "npm:~2.3.3" - lightningcss: "npm:^1.30.1" + lightningcss: "npm:^1.30.2" picomatch: "npm:^4.0.3" postcss: "npm:^8.5.6" - rolldown: "npm:1.0.0-beta.34" - tinyglobby: "npm:^0.2.14" + rolldown: "npm:1.0.0-beta.53" + tinyglobby: "npm:^0.2.15" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 - esbuild: ^0.25.0 + esbuild: ^0.27.0 jiti: ">=1.21.0" less: ^4.0.0 sass: ^1.70.0 @@ -25641,7 +25853,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/55f6648a8700345700382adac4877208eedcfff5757debba74851227dbc50eae3cc7ccea86bcfda689a9855fbbd2c7e7dd020ffc0c01bfb815dbc6bf65991cbd + checksum: 10c0/7098ba9be029e6530baf6a08e786859910e502e14f18a6fdda856b149fe676ff81d5cb069b8b42f3e88e791fff17f77f9f067c26159fb85a7aab4e4b8692bbb2 languageName: node linkType: hard From 723fa116477f5e36129d6c6e36dc587dadc5d44d Mon Sep 17 00:00:00 2001 From: Shemol Date: Sat, 27 Dec 2025 13:57:33 +0800 Subject: [PATCH 038/116] perf(ModelList): use Map for O(1) model status lookup (#12161) - Replace Array.find() with Map.get() for modelStatus lookup - Add useMemo to create modelStatusMap from modelStatuses array - Stabilize onEditModel callback with useCallback to prevent memo invalidation Fixes #12035 Signed-off-by: SherlockShemol --- .../ProviderSettings/ModelList/ModelList.tsx | 12 ++++++++++-- .../ProviderSettings/ModelList/ModelListGroup.tsx | 7 ++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index b2455a8ad5..a8eb888813 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx @@ -49,6 +49,9 @@ const ModelList: React.FC = ({ providerId }) => { const { t } = useTranslation() const { provider, models, removeModel } = useProvider(providerId) + // 稳定的编辑模型回调,避免内联函数导致子组件 memo 失效 + const handleEditModel = useCallback((model: Model) => EditModelPopup.show({ provider, model }), [provider]) + const providerConfig = PROVIDER_URLS[provider.id] const docsWebsite = providerConfig?.websites?.docs const modelsWebsite = providerConfig?.websites?.models @@ -63,6 +66,11 @@ const ModelList: React.FC = ({ providerId }) => { const { isChecking: isHealthChecking, modelStatuses, runHealthCheck } = useHealthCheck(provider, models) + // 将 modelStatuses 数组转换为 Map,实现 O(1) 查找 + const modelStatusMap = useMemo(() => { + return new Map(modelStatuses.map((status) => [status.model.id, status])) + }, [modelStatuses]) + const setSearchText = useCallback((text: string) => { startTransition(() => { _setSearchText(text) @@ -138,9 +146,9 @@ const ModelList: React.FC = ({ providerId }) => { key={group} groupName={group} models={displayedModelGroups[group]} - modelStatuses={modelStatuses} + modelStatusMap={modelStatusMap} defaultOpen={i <= 5} - onEditModel={(model) => EditModelPopup.show({ provider, model })} + onEditModel={handleEditModel} onRemoveModel={removeModel} onRemoveGroup={() => displayedModelGroups[group].forEach((model) => removeModel(model))} /> diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListGroup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListGroup.tsx index 0185ef597d..190a49cebd 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListGroup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelListGroup.tsx @@ -15,7 +15,8 @@ const MAX_SCROLLER_HEIGHT = 390 interface ModelListGroupProps { groupName: string models: Model[] - modelStatuses: ModelWithStatus[] + /** 使用 Map 实现 O(1) 查找,替代原来的数组线性搜索 */ + modelStatusMap: Map defaultOpen: boolean disabled?: boolean onEditModel: (model: Model) => void @@ -26,7 +27,7 @@ interface ModelListGroupProps { const ModelListGroup: React.FC = ({ groupName, models, - modelStatuses, + modelStatusMap, defaultOpen, disabled, onEditModel, @@ -89,7 +90,7 @@ const ModelListGroup: React.FC = ({ {(model) => ( status.model.id === model.id)} + modelStatus={modelStatusMap.get(model.id)} onEdit={onEditModel} onRemove={onRemoveModel} disabled={disabled} From 2008d70707bd66ded6de1d86574dd47aacd35733 Mon Sep 17 00:00:00 2001 From: Shemol Date: Sat, 27 Dec 2025 18:00:20 +0800 Subject: [PATCH 039/116] fix(memory): fix global memory settings submit failure (#12147) --- .../MemorySettings/MemorySettingsModal.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx b/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx index 509e54e54a..680bfbec8e 100644 --- a/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx @@ -3,7 +3,7 @@ import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimensio import ModelSelector from '@renderer/components/ModelSelector' import { InfoTooltip } from '@renderer/components/TooltipIcons' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' -import { getModel, useModel } from '@renderer/hooks/useModel' +import { useModel } from '@renderer/hooks/useModel' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' import { selectMemoryConfig, updateMemoryConfig } from '@renderer/store/memory' @@ -55,8 +55,12 @@ const MemorySettingsModal: FC = ({ visible, onSubmit, const handleFormSubmit = async (values: formValue) => { try { // Convert model IDs back to Model objects - const llmModel = getModel(values.llmModel) - const embeddingModel = getModel(values.embeddingModel) + // values.llmModel and values.embeddingModel are JSON strings from getModelUniqId() + // e.g., '{"id":"gpt-4","provider":"openai"}' + // We need to find models by comparing with getModelUniqId() result + const allModels = providers.flatMap((p) => p.models) + const llmModel = allModels.find((m) => getModelUniqId(m) === values.llmModel) + const embeddingModel = allModels.find((m) => getModelUniqId(m) === values.embeddingModel) if (embeddingModel) { setLoading(true) @@ -141,7 +145,9 @@ const MemorySettingsModal: FC = ({ visible, onSubmit, shouldUpdate={(prevValues, currentValues) => prevValues.embeddingModel !== currentValues.embeddingModel}> {({ getFieldValue }) => { const embeddingModelId = getFieldValue('embeddingModel') - const embeddingModel = getModel(embeddingModelId) + // embeddingModelId is a JSON string from getModelUniqId(), find model by comparing + const allModels = providers.flatMap((p) => p.models) + const embeddingModel = allModels.find((m) => getModelUniqId(m) === embeddingModelId) return ( Date: Sat, 27 Dec 2025 20:04:51 +0800 Subject: [PATCH 040/116] fix: shortcut icons sorting disorder (#12151) --- src/renderer/src/pages/home/Inputbar/InputbarTools.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index 2dc5ee88a9..1b56b37eb4 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -261,9 +261,12 @@ const InputbarTools = ({ scope, assistantId, session }: InputbarToolsNewProps) = const sourceId = source.droppableId const destinationId = destination.droppableId + const visibleKeys = visibleTools.map((t) => t.key) + const hiddenKeys = hiddenTools.map((t) => t.key) + const newToolOrder: ToolOrderConfig = { - visible: [...toolOrder.visible], - hidden: [...toolOrder.hidden] + visible: [...visibleKeys], + hidden: [...hiddenKeys] } const sourceArray = sourceId === 'inputbar-tools-visible' ? 'visible' : 'hidden' From 939100d495bd8454b30da454891a25bfacbfa73d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 12:09:52 +0800 Subject: [PATCH 041/116] refactor(api): consolidate error handling and update API error structures - Replaced `DataApiError` with `SerializedDataApiError` for improved error serialization and IPC transmission. - Enhanced error response format with additional fields for better context and debugging. - Updated error handling utilities to streamline error creation and retry logic. - Removed the deprecated `errorCodes.ts` file and migrated relevant functionality to `apiErrors.ts`. - Updated documentation to reflect changes in error handling practices and structures. --- packages/shared/data/api/README.md | 74 +- .../shared/data/api/api-design-guidelines.md | 48 +- packages/shared/data/api/apiErrors.ts | 776 ++++++++++++++++++ packages/shared/data/api/apiTypes.ts | 46 +- packages/shared/data/api/errorCodes.ts | 194 ----- packages/shared/data/api/index.ts | 46 +- src/main/data/api/core/ApiServer.ts | 18 +- src/main/data/api/core/MiddlewareEngine.ts | 4 +- src/main/data/api/core/adapters/IpcAdapter.ts | 4 +- src/renderer/src/data/DataApiService.ts | 60 +- 10 files changed, 965 insertions(+), 305 deletions(-) create mode 100644 packages/shared/data/api/apiErrors.ts delete mode 100644 packages/shared/data/api/errorCodes.ts diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md index bd45b67aa2..452b3a22c0 100644 --- a/packages/shared/data/api/README.md +++ b/packages/shared/data/api/README.md @@ -9,7 +9,7 @@ packages/shared/data/api/ ├── index.ts # Barrel export for infrastructure types ├── apiTypes.ts # Core request/response types and API utilities ├── apiPaths.ts # Path template literal type utilities -├── errorCodes.ts # Error handling utilities and factories +├── apiErrors.ts # Error handling: ErrorCode, DataApiError class, factory └── schemas/ ├── index.ts # Schema composition (merges all domain schemas) └── test.ts # Test API schema and DTOs @@ -21,7 +21,7 @@ packages/shared/data/api/ |------|---------| | `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities | | `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) | -| `errorCodes.ts` | `DataApiErrorFactory`, error codes, and error handling utilities | +| `apiErrors.ts` | `ErrorCode` enum, `DataApiError` class, `DataApiErrorFactory`, retryability config | | `index.ts` | Unified export of infrastructure types (not domain DTOs) | | `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types | | `schemas/*.ts` | Domain-specific API definitions and DTOs | @@ -37,11 +37,16 @@ import type { DataRequest, DataResponse, ApiClient, - PaginatedResponse, - ErrorCode + PaginatedResponse } from '@shared/data/api' -import { DataApiErrorFactory, isDataApiError } from '@shared/data/api' +import { + ErrorCode, + DataApiError, + DataApiErrorFactory, + isDataApiError, + toDataApiError +} from '@shared/data/api' ``` ### Domain DTOs (directly from schema files) @@ -153,22 +158,65 @@ await api.post('/topics', { body: { name: 'New' } }) // Body is typed as Create ## Error Handling -Use `DataApiErrorFactory` for consistent error creation: +The error system provides type-safe error handling with automatic retryability detection: ```typescript -import { DataApiErrorFactory, ErrorCode } from '@shared/data/api' +import { + DataApiError, + DataApiErrorFactory, + ErrorCode, + isDataApiError, + toDataApiError +} from '@shared/data/api' -// Create errors +// Create errors using the factory (recommended) throw DataApiErrorFactory.notFound('Topic', id) -throw DataApiErrorFactory.validationError('Name is required') -throw DataApiErrorFactory.fromCode(ErrorCode.DATABASE_ERROR, 'Connection failed') +throw DataApiErrorFactory.validation({ name: ['Name is required'] }) +throw DataApiErrorFactory.timeout('fetch topics', 3000) +throw DataApiErrorFactory.database(originalError, 'insert topic') -// Check errors -if (isDataApiError(error)) { - console.log(error.code, error.status) +// Or create directly with the class +throw new DataApiError( + ErrorCode.NOT_FOUND, + 'Topic not found', + 404, + { resource: 'Topic', id: 'abc123' } +) + +// Check if error is retryable (for automatic retry logic) +if (error instanceof DataApiError && error.isRetryable) { + await retry(operation) } + +// Check error type +if (error instanceof DataApiError) { + if (error.isClientError) { + // 4xx - issue with the request + } else if (error.isServerError) { + // 5xx - server-side issue + } +} + +// Convert any error to DataApiError +const apiError = toDataApiError(unknownError, 'context') + +// Serialize for IPC (Main → Renderer) +const serialized = apiError.toJSON() + +// Reconstruct from IPC response (Renderer) +const reconstructed = DataApiError.fromJSON(response.error) ``` +### Retryable Error Codes + +The following errors are automatically considered retryable: +- `SERVICE_UNAVAILABLE` (503) +- `TIMEOUT` (504) +- `RATE_LIMIT_EXCEEDED` (429) +- `DATABASE_ERROR` (500) +- `INTERNAL_SERVER_ERROR` (500) +- `RESOURCE_LOCKED` (423) + ## Architecture Overview ``` diff --git a/packages/shared/data/api/api-design-guidelines.md b/packages/shared/data/api/api-design-guidelines.md index 0cd8ccbe8b..d508260aa2 100644 --- a/packages/shared/data/api/api-design-guidelines.md +++ b/packages/shared/data/api/api-design-guidelines.md @@ -147,24 +147,33 @@ Use standard HTTP status codes consistently: | 204 No Content | Successful DELETE | No body | | 400 Bad Request | Invalid request format | Malformed JSON | | 401 Unauthorized | Authentication required | Missing/invalid token | -| 403 Forbidden | Permission denied | Insufficient access | +| 403 Permission Denied | Insufficient permissions | Access denied to resource | | 404 Not Found | Resource not found | Invalid ID | -| 409 Conflict | Concurrent modification | Version conflict | +| 409 Conflict | Concurrent modification or data inconsistency | Version conflict, data corruption | | 422 Unprocessable | Validation failed | Invalid field values | +| 423 Locked | Resource temporarily locked | File being exported | | 429 Too Many Requests | Rate limit exceeded | Throttling | | 500 Internal Error | Server error | Unexpected failure | +| 503 Service Unavailable | Service temporarily down | Maintenance mode | +| 504 Timeout | Request timed out | Long-running operation | ## Error Response Format -All error responses follow the `DataApiError` structure: +All error responses follow the `SerializedDataApiError` structure (transmitted via IPC): ```typescript -interface DataApiError { - code: string // ErrorCode enum value (e.g., 'NOT_FOUND') - message: string // Human-readable error message - status: number // HTTP status code - details?: any // Additional context (e.g., field errors) - stack?: string // Stack trace (development only) +interface SerializedDataApiError { + code: ErrorCode | string // ErrorCode enum value (e.g., 'NOT_FOUND') + message: string // Human-readable error message + status: number // HTTP status code + details?: Record // Additional context (e.g., field errors) + requestContext?: { // Request context for debugging + requestId: string + path: string + method: HttpMethod + timestamp?: number + } + // Note: stack trace is NOT transmitted via IPC - rely on Main process logs } ``` @@ -176,7 +185,8 @@ interface DataApiError { code: 'NOT_FOUND', message: "Topic with id 'abc123' not found", status: 404, - details: { resource: 'Topic', id: 'abc123' } + details: { resource: 'Topic', id: 'abc123' }, + requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' } } // 422 Validation Error @@ -191,16 +201,32 @@ interface DataApiError { } } } + +// 504 Timeout +{ + code: 'TIMEOUT', + message: 'Request timeout: fetch topics (3000ms)', + status: 504, + details: { operation: 'fetch topics', timeoutMs: 3000 } +} ``` Use `DataApiErrorFactory` utilities to create consistent errors: ```typescript -import { DataApiErrorFactory } from '@shared/data/api' +import { DataApiErrorFactory, DataApiError } from '@shared/data/api' +// Using factory methods (recommended) throw DataApiErrorFactory.notFound('Topic', id) throw DataApiErrorFactory.validation({ name: ['Required'] }) throw DataApiErrorFactory.database(error, 'insert topic') +throw DataApiErrorFactory.timeout('fetch topics', 3000) +throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken') + +// Check if error is retryable +if (error instanceof DataApiError && error.isRetryable) { + await retry(operation) +} ``` ## Naming Conventions Summary diff --git a/packages/shared/data/api/apiErrors.ts b/packages/shared/data/api/apiErrors.ts new file mode 100644 index 0000000000..71d710a2f4 --- /dev/null +++ b/packages/shared/data/api/apiErrors.ts @@ -0,0 +1,776 @@ +/** + * @fileoverview Centralized error handling for the Data API system + * + * This module provides comprehensive error management including: + * - ErrorCode enum with HTTP status mapping + * - Type-safe error details for each error type + * - DataApiError class for structured error handling + * - DataApiErrorFactory for convenient error creation + * - Retryability configuration for automatic retry logic + * + * @example + * ```typescript + * import { DataApiError, DataApiErrorFactory, ErrorCode } from '@shared/data/api' + * + * // Create and throw an error + * throw DataApiErrorFactory.notFound('Topic', 'abc123') + * + * // Check if error is retryable + * if (error instanceof DataApiError && error.isRetryable) { + * await retry(operation) + * } + * ``` + */ + +import type { HttpMethod } from './apiTypes' + +// ============================================================================ +// Error Code Enum +// ============================================================================ + +/** + * Standard error codes for the Data API system. + * Maps to HTTP status codes via ERROR_STATUS_MAP. + */ +export enum ErrorCode { + // ───────────────────────────────────────────────────────────────── + // Client errors (4xx) - Issues with the request itself + // ───────────────────────────────────────────────────────────────── + + /** 400 - Malformed request syntax or invalid parameters */ + BAD_REQUEST = 'BAD_REQUEST', + + /** 401 - Authentication required or credentials invalid */ + UNAUTHORIZED = 'UNAUTHORIZED', + + /** 404 - Requested resource does not exist */ + NOT_FOUND = 'NOT_FOUND', + + /** 405 - HTTP method not supported for this endpoint */ + METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', + + /** 422 - Request body fails validation rules */ + VALIDATION_ERROR = 'VALIDATION_ERROR', + + /** 429 - Too many requests, retry after delay */ + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + + /** 403 - Authenticated but lacks required permissions */ + PERMISSION_DENIED = 'PERMISSION_DENIED', + + // ───────────────────────────────────────────────────────────────── + // Server errors (5xx) - Issues on the server side + // ───────────────────────────────────────────────────────────────── + + /** 500 - Unexpected server error */ + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + + /** 500 - Database operation failed (connection, query, constraint) */ + DATABASE_ERROR = 'DATABASE_ERROR', + + /** 503 - Service temporarily unavailable, retry later */ + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + + /** 504 - Request timed out waiting for response */ + TIMEOUT = 'TIMEOUT', + + // ───────────────────────────────────────────────────────────────── + // Application-specific errors + // ───────────────────────────────────────────────────────────────── + + /** 500 - Data migration process failed */ + MIGRATION_ERROR = 'MIGRATION_ERROR', + + /** + * 423 - Resource is temporarily locked by another operation. + * Use when: file being exported, data migration in progress, + * or resource held by background process. + * Retryable: Yes (resource may be released) + */ + RESOURCE_LOCKED = 'RESOURCE_LOCKED', + + /** + * 409 - Optimistic lock conflict, resource was modified after read. + * Use when: multi-window editing same topic, version mismatch + * on update, or stale data detected during save. + * Client should: refresh data and retry or notify user. + */ + CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION', + + /** + * 409 - Data integrity violation or inconsistent state detected. + * Use when: referential integrity broken, computed values mismatch, + * or data corruption found during validation. + * Not retryable: requires investigation or data repair. + */ + DATA_INCONSISTENT = 'DATA_INCONSISTENT' +} + +// ============================================================================ +// Error Code Mappings +// ============================================================================ + +/** + * Maps error codes to HTTP status codes. + * Used by DataApiError and DataApiErrorFactory. + */ +export const ERROR_STATUS_MAP: Record = { + // Client errors (4xx) + [ErrorCode.BAD_REQUEST]: 400, + [ErrorCode.UNAUTHORIZED]: 401, + [ErrorCode.NOT_FOUND]: 404, + [ErrorCode.METHOD_NOT_ALLOWED]: 405, + [ErrorCode.VALIDATION_ERROR]: 422, + [ErrorCode.RATE_LIMIT_EXCEEDED]: 429, + [ErrorCode.PERMISSION_DENIED]: 403, + + // Server errors (5xx) + [ErrorCode.INTERNAL_SERVER_ERROR]: 500, + [ErrorCode.DATABASE_ERROR]: 500, + [ErrorCode.SERVICE_UNAVAILABLE]: 503, + [ErrorCode.TIMEOUT]: 504, + + // Application-specific errors + [ErrorCode.MIGRATION_ERROR]: 500, + [ErrorCode.RESOURCE_LOCKED]: 423, + [ErrorCode.CONCURRENT_MODIFICATION]: 409, + [ErrorCode.DATA_INCONSISTENT]: 409 +} + +/** + * Default error messages for each error code. + * Used when no custom message is provided. + */ +export const ERROR_MESSAGES: Record = { + [ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters', + [ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required', + [ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist', + [ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint', + [ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements', + [ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests', + [ErrorCode.PERMISSION_DENIED]: 'Permission denied: Insufficient permissions for this operation', + + [ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred', + [ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data', + [ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable', + [ErrorCode.TIMEOUT]: 'Timeout: Request timed out waiting for response', + + [ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data', + [ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation', + [ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user', + [ErrorCode.DATA_INCONSISTENT]: 'Data inconsistent: Data integrity violation detected' +} + +// ============================================================================ +// Request Context +// ============================================================================ + +/** + * Request context attached to errors for debugging and logging. + * Always transmitted via IPC for frontend display. + */ +export interface RequestContext { + /** Unique identifier for request correlation */ + requestId: string + /** API path that was called */ + path: string + /** HTTP method used */ + method: HttpMethod + /** Timestamp when request was initiated */ + timestamp?: number +} + +// ============================================================================ +// Error-specific Detail Types +// ============================================================================ + +/** + * Details for VALIDATION_ERROR - field-level validation failures. + * Maps field names to arrays of error messages. + */ +export interface ValidationErrorDetails { + fieldErrors: Record +} + +/** + * Details for NOT_FOUND - which resource was not found. + */ +export interface NotFoundErrorDetails { + resource: string + id?: string +} + +/** + * Details for DATABASE_ERROR - underlying database failure info. + */ +export interface DatabaseErrorDetails { + originalError: string + operation?: string +} + +/** + * Details for TIMEOUT - what operation timed out. + */ +export interface TimeoutErrorDetails { + operation?: string + timeoutMs?: number +} + +/** + * Details for DATA_INCONSISTENT - what data is inconsistent. + */ +export interface DataInconsistentErrorDetails { + resource: string + description?: string +} + +/** + * Details for PERMISSION_DENIED - what action was denied. + */ +export interface PermissionDeniedErrorDetails { + action: string + resource?: string +} + +/** + * Details for RESOURCE_LOCKED - which resource is locked. + */ +export interface ResourceLockedErrorDetails { + resource: string + id: string + lockedBy?: string +} + +/** + * Details for CONCURRENT_MODIFICATION - which resource was concurrently modified. + */ +export interface ConcurrentModificationErrorDetails { + resource: string + id: string +} + +/** + * Details for INTERNAL_SERVER_ERROR - context about the failure. + */ +export interface InternalErrorDetails { + originalError: string + context?: string +} + +// ============================================================================ +// Type Mapping for Error Details +// ============================================================================ + +/** + * Maps error codes to their specific detail types. + * Only define for error codes that have structured details. + */ +export type ErrorDetailsMap = { + [ErrorCode.VALIDATION_ERROR]: ValidationErrorDetails + [ErrorCode.NOT_FOUND]: NotFoundErrorDetails + [ErrorCode.DATABASE_ERROR]: DatabaseErrorDetails + [ErrorCode.TIMEOUT]: TimeoutErrorDetails + [ErrorCode.DATA_INCONSISTENT]: DataInconsistentErrorDetails + [ErrorCode.PERMISSION_DENIED]: PermissionDeniedErrorDetails + [ErrorCode.RESOURCE_LOCKED]: ResourceLockedErrorDetails + [ErrorCode.CONCURRENT_MODIFICATION]: ConcurrentModificationErrorDetails + [ErrorCode.INTERNAL_SERVER_ERROR]: InternalErrorDetails +} + +/** + * Get the detail type for a specific error code. + * Falls back to generic Record for unmapped codes. + */ +export type DetailsForCode = T extends keyof ErrorDetailsMap + ? ErrorDetailsMap[T] + : Record | undefined + +// ============================================================================ +// Retryability Configuration +// ============================================================================ + +/** + * Set of error codes that are safe to retry automatically. + * These represent temporary failures that may succeed on retry. + */ +export const RETRYABLE_ERROR_CODES: ReadonlySet = new Set([ + ErrorCode.SERVICE_UNAVAILABLE, // 503 - Service temporarily down + ErrorCode.TIMEOUT, // 504 - Request timed out + ErrorCode.RATE_LIMIT_EXCEEDED, // 429 - Can retry after delay + ErrorCode.DATABASE_ERROR, // 500 - Temporary DB issues + ErrorCode.INTERNAL_SERVER_ERROR, // 500 - May be transient + ErrorCode.RESOURCE_LOCKED // 423 - Lock may be released +]) + +/** + * Check if an error code represents a retryable condition. + * @param code - The error code to check + * @returns true if the error is safe to retry + */ +export function isRetryableErrorCode(code: ErrorCode): boolean { + return RETRYABLE_ERROR_CODES.has(code) +} + +// ============================================================================ +// Serialized Error Interface (for IPC transmission) +// ============================================================================ + +/** + * Serialized error structure for IPC transmission. + * Used in DataResponse.error field. + * Note: Does not include stack trace - rely on Main process logs. + */ +export interface SerializedDataApiError { + /** Error code from ErrorCode enum */ + code: ErrorCode | string + /** Human-readable error message */ + message: string + /** HTTP status code */ + status: number + /** Structured error details */ + details?: Record + /** Request context for debugging */ + requestContext?: RequestContext +} + +// ============================================================================ +// DataApiError Class +// ============================================================================ + +/** + * Custom error class for Data API errors. + * + * Provides type-safe error handling with: + * - Typed error codes and details + * - Retryability checking via `isRetryable` getter + * - IPC serialization via `toJSON()` / `fromJSON()` + * - Request context for debugging + * + * @example + * ```typescript + * // Throw a typed error + * throw new DataApiError( + * ErrorCode.NOT_FOUND, + * 'Topic not found', + * 404, + * { resource: 'Topic', id: 'abc123' } + * ) + * + * // Check if error is retryable + * if (error.isRetryable) { + * await retry(operation) + * } + * ``` + */ +export class DataApiError extends Error { + /** Error code from ErrorCode enum */ + public readonly code: T + /** HTTP status code */ + public readonly status: number + /** Structured error details (type depends on error code) */ + public readonly details?: DetailsForCode + /** Request context for debugging */ + public readonly requestContext?: RequestContext + + constructor(code: T, message: string, status: number, details?: DetailsForCode, requestContext?: RequestContext) { + super(message) + this.name = 'DataApiError' + this.code = code + this.status = status + this.details = details + this.requestContext = requestContext + + // Maintains proper stack trace for where error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, DataApiError) + } + } + + /** + * Whether this error is safe to retry automatically. + * Based on the RETRYABLE_ERROR_CODES configuration. + */ + get isRetryable(): boolean { + return isRetryableErrorCode(this.code) + } + + /** + * Whether this is a client error (4xx status). + * Client errors typically indicate issues with the request itself. + */ + get isClientError(): boolean { + return this.status >= 400 && this.status < 500 + } + + /** + * Whether this is a server error (5xx status). + * Server errors typically indicate issues on the server side. + */ + get isServerError(): boolean { + return this.status >= 500 && this.status < 600 + } + + /** + * Serialize for IPC transmission. + * Note: Stack trace is NOT included - rely on Main process logs. + * @returns Serialized error object for IPC + */ + toJSON(): SerializedDataApiError { + return { + code: this.code, + message: this.message, + status: this.status, + details: this.details as Record | undefined, + requestContext: this.requestContext + } + } + + /** + * Reconstruct DataApiError from IPC response. + * @param error - Serialized error from IPC + * @returns DataApiError instance + */ + static fromJSON(error: SerializedDataApiError): DataApiError { + return new DataApiError(error.code as ErrorCode, error.message, error.status, error.details, error.requestContext) + } + + /** + * Create DataApiError from a generic Error. + * @param error - Original error + * @param code - Error code to use (defaults to INTERNAL_SERVER_ERROR) + * @param requestContext - Optional request context + * @returns DataApiError instance + */ + static fromError( + error: Error, + code: ErrorCode = ErrorCode.INTERNAL_SERVER_ERROR, + requestContext?: RequestContext + ): DataApiError { + return new DataApiError( + code, + error.message, + ERROR_STATUS_MAP[code], + { originalError: error.message, context: error.name } as DetailsForCode, + requestContext + ) + } +} + +// ============================================================================ +// DataApiErrorFactory +// ============================================================================ + +/** + * Factory for creating standardized DataApiError instances. + * Provides convenience methods for common error types with proper typing. + * + * @example + * ```typescript + * // Create a not found error + * throw DataApiErrorFactory.notFound('Topic', 'abc123') + * + * // Create a validation error + * throw DataApiErrorFactory.validation({ + * name: ['Name is required'], + * email: ['Invalid email format'] + * }) + * ``` + */ +export class DataApiErrorFactory { + /** + * Create a DataApiError with any error code. + * Use specialized methods when available for better type safety. + * @param code - Error code from ErrorCode enum + * @param customMessage - Optional custom error message + * @param details - Optional structured error details + * @param requestContext - Optional request context + * @returns DataApiError instance + */ + static create( + code: T, + customMessage?: string, + details?: DetailsForCode, + requestContext?: RequestContext + ): DataApiError { + return new DataApiError( + code, + customMessage || ERROR_MESSAGES[code], + ERROR_STATUS_MAP[code], + details, + requestContext + ) + } + + /** + * Create a validation error with field-specific error messages. + * @param fieldErrors - Map of field names to error messages + * @param message - Optional custom message + * @param requestContext - Optional request context + * @returns DataApiError with VALIDATION_ERROR code + */ + static validation( + fieldErrors: Record, + message?: string, + requestContext?: RequestContext + ): DataApiError { + return new DataApiError( + ErrorCode.VALIDATION_ERROR, + message || 'Request validation failed', + ERROR_STATUS_MAP[ErrorCode.VALIDATION_ERROR], + { fieldErrors }, + requestContext + ) + } + + /** + * Create a not found error for a specific resource. + * @param resource - Resource type name (e.g., 'Topic', 'Message') + * @param id - Optional resource identifier + * @param requestContext - Optional request context + * @returns DataApiError with NOT_FOUND code + */ + static notFound(resource: string, id?: string, requestContext?: RequestContext): DataApiError { + const message = id ? `${resource} with id '${id}' not found` : `${resource} not found` + return new DataApiError( + ErrorCode.NOT_FOUND, + message, + ERROR_STATUS_MAP[ErrorCode.NOT_FOUND], + { resource, id }, + requestContext + ) + } + + /** + * Create a database error from an original error. + * @param originalError - The underlying database error + * @param operation - Description of the failed operation + * @param requestContext - Optional request context + * @returns DataApiError with DATABASE_ERROR code + */ + static database( + originalError: Error, + operation?: string, + requestContext?: RequestContext + ): DataApiError { + return new DataApiError( + ErrorCode.DATABASE_ERROR, + `Database operation failed${operation ? `: ${operation}` : ''}`, + ERROR_STATUS_MAP[ErrorCode.DATABASE_ERROR], + { originalError: originalError.message, operation }, + requestContext + ) + } + + /** + * Create an internal server error from an unexpected error. + * @param originalError - The underlying error + * @param context - Additional context about where the error occurred + * @param requestContext - Optional request context + * @returns DataApiError with INTERNAL_SERVER_ERROR code + */ + static internal( + originalError: Error, + context?: string, + requestContext?: RequestContext + ): DataApiError { + const message = context + ? `Internal error in ${context}: ${originalError.message}` + : `Internal error: ${originalError.message}` + return new DataApiError( + ErrorCode.INTERNAL_SERVER_ERROR, + message, + ERROR_STATUS_MAP[ErrorCode.INTERNAL_SERVER_ERROR], + { originalError: originalError.message, context }, + requestContext + ) + } + + /** + * Create a permission denied error. + * @param action - The action that was denied + * @param resource - Optional resource that access was denied to + * @param requestContext - Optional request context + * @returns DataApiError with PERMISSION_DENIED code + */ + static permissionDenied( + action: string, + resource?: string, + requestContext?: RequestContext + ): DataApiError { + const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}` + return new DataApiError( + ErrorCode.PERMISSION_DENIED, + message, + ERROR_STATUS_MAP[ErrorCode.PERMISSION_DENIED], + { action, resource }, + requestContext + ) + } + + /** + * Create a timeout error. + * @param operation - Description of the operation that timed out + * @param timeoutMs - The timeout duration in milliseconds + * @param requestContext - Optional request context + * @returns DataApiError with TIMEOUT code + */ + static timeout( + operation?: string, + timeoutMs?: number, + requestContext?: RequestContext + ): DataApiError { + const message = operation + ? `Request timeout: ${operation}${timeoutMs ? ` (${timeoutMs}ms)` : ''}` + : `Request timeout${timeoutMs ? ` (${timeoutMs}ms)` : ''}` + return new DataApiError( + ErrorCode.TIMEOUT, + message, + ERROR_STATUS_MAP[ErrorCode.TIMEOUT], + { operation, timeoutMs }, + requestContext + ) + } + + /** + * Create a data inconsistency error. + * @param resource - The resource with inconsistent data + * @param description - Description of the inconsistency + * @param requestContext - Optional request context + * @returns DataApiError with DATA_INCONSISTENT code + */ + static dataInconsistent( + resource: string, + description?: string, + requestContext?: RequestContext + ): DataApiError { + const message = description + ? `Data inconsistent in ${resource}: ${description}` + : `Data inconsistent in ${resource}` + return new DataApiError( + ErrorCode.DATA_INCONSISTENT, + message, + ERROR_STATUS_MAP[ErrorCode.DATA_INCONSISTENT], + { resource, description }, + requestContext + ) + } + + /** + * Create a resource locked error. + * Use when a resource is temporarily unavailable due to: + * - File being exported + * - Data migration in progress + * - Resource held by background process + * + * @param resource - Resource type name + * @param id - Resource identifier + * @param lockedBy - Optional description of what's holding the lock + * @param requestContext - Optional request context + * @returns DataApiError with RESOURCE_LOCKED code + */ + static resourceLocked( + resource: string, + id: string, + lockedBy?: string, + requestContext?: RequestContext + ): DataApiError { + const message = lockedBy + ? `${resource} '${id}' is locked by ${lockedBy}` + : `${resource} '${id}' is currently locked` + return new DataApiError( + ErrorCode.RESOURCE_LOCKED, + message, + ERROR_STATUS_MAP[ErrorCode.RESOURCE_LOCKED], + { resource, id, lockedBy }, + requestContext + ) + } + + /** + * Create a concurrent modification error. + * Use when an optimistic lock conflict occurs: + * - Multi-window editing same topic + * - Version mismatch on update + * - Stale data detected during save + * + * Client should refresh data and retry or notify user. + * + * @param resource - Resource type name + * @param id - Resource identifier + * @param requestContext - Optional request context + * @returns DataApiError with CONCURRENT_MODIFICATION code + */ + static concurrentModification( + resource: string, + id: string, + requestContext?: RequestContext + ): DataApiError { + return new DataApiError( + ErrorCode.CONCURRENT_MODIFICATION, + `${resource} '${id}' was modified by another user`, + ERROR_STATUS_MAP[ErrorCode.CONCURRENT_MODIFICATION], + { resource, id }, + requestContext + ) + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Check if an error is a DataApiError instance. + * @param error - Any error object + * @returns true if the error is a DataApiError + */ +export function isDataApiError(error: unknown): error is DataApiError { + return error instanceof DataApiError +} + +/** + * Check if an object is a serialized DataApiError. + * @param error - Any object + * @returns true if the object has DataApiError structure + */ +export function isSerializedDataApiError(error: unknown): error is SerializedDataApiError { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + 'status' in error && + typeof (error as SerializedDataApiError).code === 'string' && + typeof (error as SerializedDataApiError).message === 'string' && + typeof (error as SerializedDataApiError).status === 'number' + ) +} + +/** + * Convert any error to a DataApiError. + * If already a DataApiError, returns as-is. + * Otherwise, wraps in an INTERNAL_SERVER_ERROR. + * + * @param error - Any error + * @param context - Optional context description + * @returns DataApiError instance + */ +export function toDataApiError(error: unknown, context?: string): DataApiError { + if (isDataApiError(error)) { + return error + } + + if (isSerializedDataApiError(error)) { + return DataApiError.fromJSON(error) + } + + if (error instanceof Error) { + return DataApiErrorFactory.internal(error, context) + } + + return DataApiErrorFactory.create( + ErrorCode.INTERNAL_SERVER_ERROR, + `Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`, + { originalError: String(error), context } as DetailsForCode + ) +} diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index e89a769619..207703e46a 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -113,7 +113,7 @@ export interface DataResponse { /** Response data if successful */ data?: T /** Error information if request failed */ - error?: DataApiError + error?: SerializedDataApiError /** Response metadata */ metadata?: { /** Request processing duration in milliseconds */ @@ -127,46 +127,12 @@ export interface DataResponse { } } -/** - * Standardized error structure for Data API - */ -export interface DataApiError { - /** Error code for programmatic handling */ - code: string - /** Human-readable error message */ - message: string - /** HTTP status code */ - status: number - /** Additional error details */ - details?: any - /** Error stack trace (development mode only) */ - stack?: string -} +// Note: Error types have been moved to apiErrors.ts +// Import from there: ErrorCode, DataApiError, SerializedDataApiError, DataApiErrorFactory +import type { SerializedDataApiError } from './apiErrors' -/** - * Standard error codes for Data API - */ -export enum ErrorCode { - // Client errors (4xx) - BAD_REQUEST = 'BAD_REQUEST', - UNAUTHORIZED = 'UNAUTHORIZED', - FORBIDDEN = 'FORBIDDEN', - NOT_FOUND = 'NOT_FOUND', - METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', - VALIDATION_ERROR = 'VALIDATION_ERROR', - RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', - - // Server errors (5xx) - INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', - DATABASE_ERROR = 'DATABASE_ERROR', - SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', - - // Custom application errors - MIGRATION_ERROR = 'MIGRATION_ERROR', - PERMISSION_DENIED = 'PERMISSION_DENIED', - RESOURCE_LOCKED = 'RESOURCE_LOCKED', - CONCURRENT_MODIFICATION = 'CONCURRENT_MODIFICATION' -} +// Re-export for backwards compatibility in DataResponse +export type { SerializedDataApiError } from './apiErrors' /** * Pagination parameters for list operations diff --git a/packages/shared/data/api/errorCodes.ts b/packages/shared/data/api/errorCodes.ts deleted file mode 100644 index 7ccb96c8c9..0000000000 --- a/packages/shared/data/api/errorCodes.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Centralized error code definitions for the Data API system - * Provides consistent error handling across renderer and main processes - */ - -import type { DataApiError } from './apiTypes' -import { ErrorCode } from './apiTypes' - -// Re-export ErrorCode for convenience -export { ErrorCode } from './apiTypes' - -/** - * Error code to HTTP status mapping - */ -export const ERROR_STATUS_MAP: Record = { - // Client errors (4xx) - [ErrorCode.BAD_REQUEST]: 400, - [ErrorCode.UNAUTHORIZED]: 401, - [ErrorCode.FORBIDDEN]: 403, - [ErrorCode.NOT_FOUND]: 404, - [ErrorCode.METHOD_NOT_ALLOWED]: 405, - [ErrorCode.VALIDATION_ERROR]: 422, - [ErrorCode.RATE_LIMIT_EXCEEDED]: 429, - - // Server errors (5xx) - [ErrorCode.INTERNAL_SERVER_ERROR]: 500, - [ErrorCode.DATABASE_ERROR]: 500, - [ErrorCode.SERVICE_UNAVAILABLE]: 503, - - // Custom application errors (5xx) - [ErrorCode.MIGRATION_ERROR]: 500, - [ErrorCode.PERMISSION_DENIED]: 403, - [ErrorCode.RESOURCE_LOCKED]: 423, - [ErrorCode.CONCURRENT_MODIFICATION]: 409 -} - -/** - * Default error messages for each error code - */ -export const ERROR_MESSAGES: Record = { - [ErrorCode.BAD_REQUEST]: 'Bad request: Invalid request format or parameters', - [ErrorCode.UNAUTHORIZED]: 'Unauthorized: Authentication required', - [ErrorCode.FORBIDDEN]: 'Forbidden: Insufficient permissions', - [ErrorCode.NOT_FOUND]: 'Not found: Requested resource does not exist', - [ErrorCode.METHOD_NOT_ALLOWED]: 'Method not allowed: HTTP method not supported for this endpoint', - [ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements', - [ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests', - - [ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred', - [ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data', - [ErrorCode.SERVICE_UNAVAILABLE]: 'Service unavailable: The service is temporarily unavailable', - - [ErrorCode.MIGRATION_ERROR]: 'Migration error: Failed to migrate data', - [ErrorCode.PERMISSION_DENIED]: 'Permission denied: Operation not allowed for current user', - [ErrorCode.RESOURCE_LOCKED]: 'Resource locked: Resource is currently locked by another operation', - [ErrorCode.CONCURRENT_MODIFICATION]: 'Concurrent modification: Resource was modified by another user' -} - -/** - * Utility class for creating standardized Data API errors - */ -export class DataApiErrorFactory { - /** - * Create a DataApiError with standard properties - */ - static create(code: ErrorCode, customMessage?: string, details?: any, stack?: string): DataApiError { - return { - code, - message: customMessage || ERROR_MESSAGES[code], - status: ERROR_STATUS_MAP[code], - details, - stack: stack || undefined - } - } - - /** - * Create a validation error with field-specific details - */ - static validation(fieldErrors: Record, message?: string): DataApiError { - return this.create(ErrorCode.VALIDATION_ERROR, message || 'Request validation failed', { fieldErrors }) - } - - /** - * Create a not found error for specific resource - */ - static notFound(resource: string, id?: string): DataApiError { - const message = id ? `${resource} with id '${id}' not found` : `${resource} not found` - - return this.create(ErrorCode.NOT_FOUND, message, { resource, id }) - } - - /** - * Create a database error with query details - */ - static database(originalError: Error, operation?: string): DataApiError { - return this.create( - ErrorCode.DATABASE_ERROR, - `Database operation failed${operation ? `: ${operation}` : ''}`, - { - originalError: originalError.message, - operation - }, - originalError.stack - ) - } - - /** - * Create a permission denied error - */ - static permissionDenied(action: string, resource?: string): DataApiError { - const message = resource ? `Permission denied: Cannot ${action} ${resource}` : `Permission denied: Cannot ${action}` - - return this.create(ErrorCode.PERMISSION_DENIED, message, { action, resource }) - } - - /** - * Create an internal server error from an unexpected error - */ - static internal(originalError: Error, context?: string): DataApiError { - const message = context - ? `Internal error in ${context}: ${originalError.message}` - : `Internal error: ${originalError.message}` - - return this.create( - ErrorCode.INTERNAL_SERVER_ERROR, - message, - { originalError: originalError.message, context }, - originalError.stack - ) - } - - /** - * Create a rate limit exceeded error - */ - static rateLimit(limit: number, windowMs: number): DataApiError { - return this.create(ErrorCode.RATE_LIMIT_EXCEEDED, `Rate limit exceeded: ${limit} requests per ${windowMs}ms`, { - limit, - windowMs - }) - } - - /** - * Create a resource locked error - */ - static resourceLocked(resource: string, id: string, lockedBy?: string): DataApiError { - const message = lockedBy - ? `${resource} '${id}' is locked by ${lockedBy}` - : `${resource} '${id}' is currently locked` - - return this.create(ErrorCode.RESOURCE_LOCKED, message, { resource, id, lockedBy }) - } - - /** - * Create a concurrent modification error - */ - static concurrentModification(resource: string, id: string): DataApiError { - return this.create(ErrorCode.CONCURRENT_MODIFICATION, `${resource} '${id}' was modified by another user`, { - resource, - id - }) - } -} - -/** - * Check if an error is a Data API error - */ -export function isDataApiError(error: any): error is DataApiError { - return ( - error && - typeof error === 'object' && - typeof error.code === 'string' && - typeof error.message === 'string' && - typeof error.status === 'number' - ) -} - -/** - * Convert a generic error to a DataApiError - */ -export function toDataApiError(error: unknown, context?: string): DataApiError { - if (isDataApiError(error)) { - return error - } - - if (error instanceof Error) { - return DataApiErrorFactory.internal(error, context) - } - - return DataApiErrorFactory.create( - ErrorCode.INTERNAL_SERVER_ERROR, - `Unknown error${context ? ` in ${context}` : ''}: ${String(error)}`, - { originalError: error, context } - ) -} diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index f4404af011..bb04eff0fb 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -7,7 +7,7 @@ * @example * ```typescript * // Infrastructure types from barrel export - * import { DataRequest, DataResponse, ErrorCode, ApiClient } from '@shared/data/api' + * import { DataRequest, DataResponse, ErrorCode, DataApiError } from '@shared/data/api' * * // Domain DTOs from schema files directly * import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' @@ -19,7 +19,6 @@ // ============================================================================ export type { - DataApiError, DataRequest, DataResponse, HttpMethod, @@ -58,26 +57,47 @@ export type { } from './apiPaths' // ============================================================================ -// Error Handling +// Error Handling (from apiErrors.ts) // ============================================================================ -export { ErrorCode, SubscriptionEvent } from './apiTypes' +// Error code enum and mappings export { - DataApiErrorFactory, ERROR_MESSAGES, ERROR_STATUS_MAP, + ErrorCode, + isRetryableErrorCode, + RETRYABLE_ERROR_CODES +} from './apiErrors' + +// DataApiError class and factory +export { + DataApiError, + DataApiErrorFactory, isDataApiError, + isSerializedDataApiError, toDataApiError -} from './errorCodes' +} from './apiErrors' + +// Error-related types +export type { + ConcurrentModificationErrorDetails, + DatabaseErrorDetails, + DataInconsistentErrorDetails, + DetailsForCode, + ErrorDetailsMap, + InternalErrorDetails, + NotFoundErrorDetails, + PermissionDeniedErrorDetails, + RequestContext, + ResourceLockedErrorDetails, + SerializedDataApiError, + TimeoutErrorDetails, + ValidationErrorDetails +} from './apiErrors' // ============================================================================ // Subscription & Middleware (for advanced usage) // ============================================================================ -export type { - Middleware, - RequestContext, - ServiceOptions, - SubscriptionCallback, - SubscriptionOptions -} from './apiTypes' +export type { Middleware, ServiceOptions, SubscriptionCallback, SubscriptionOptions } from './apiTypes' +export { SubscriptionEvent } from './apiTypes' diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index 57fe86add5..df92c783b9 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -1,7 +1,8 @@ import { loggerService } from '@logger' +import type { RequestContext as ErrorRequestContext } from '@shared/data/api/apiErrors' +import { DataApiError, DataApiErrorFactory, toDataApiError } from '@shared/data/api/apiErrors' import type { ApiImplementation } from '@shared/data/api/apiTypes' import type { DataRequest, DataResponse, HttpMethod, RequestContext } from '@shared/data/api/apiTypes' -import { DataApiErrorFactory, ErrorCode } from '@shared/data/api/errorCodes' import { MiddlewareEngine } from './MiddlewareEngine' @@ -59,6 +60,14 @@ export class ApiServer { const { method, path } = request const startTime = Date.now() + // Build error request context for tracking + const errorContext: ErrorRequestContext = { + requestId: request.id, + path, + method: method as HttpMethod, + timestamp: startTime + } + logger.debug(`Processing request: ${method} ${path}`) try { @@ -66,7 +75,7 @@ export class ApiServer { const handlerMatch = this.findHandler(path, method as HttpMethod) if (!handlerMatch) { - throw DataApiErrorFactory.create(ErrorCode.NOT_FOUND, `Handler not found: ${method} ${path}`) + throw DataApiErrorFactory.notFound('Handler', `${method} ${path}`, errorContext) } // Create request context @@ -91,12 +100,13 @@ export class ApiServer { } catch (error) { logger.error(`Request handling failed: ${method} ${path}`, error as Error) - const apiError = DataApiErrorFactory.create(ErrorCode.INTERNAL_SERVER_ERROR, (error as Error).message) + // Convert to DataApiError and serialize for IPC + const apiError = error instanceof DataApiError ? error : toDataApiError(error, `${method} ${path}`) return { id: request.id, status: apiError.status, - error: apiError, + error: apiError.toJSON(), // Serialize for IPC transmission metadata: { duration: Date.now() - startTime, timestamp: Date.now() diff --git a/src/main/data/api/core/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts index 1f6bf1915d..d8af7bd3ef 100644 --- a/src/main/data/api/core/MiddlewareEngine.ts +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' +import { toDataApiError } from '@shared/data/api/apiErrors' import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes' -import { toDataApiError } from '@shared/data/api/errorCodes' const logger = loggerService.withContext('MiddlewareEngine') @@ -82,7 +82,7 @@ export class MiddlewareEngine { logger.error(`Request error: ${req.method} ${req.path}`, error as Error) const apiError = toDataApiError(error, `${req.method} ${req.path}`) - res.error = apiError + res.error = apiError.toJSON() // Serialize for IPC transmission res.status = apiError.status } } diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts index a37a33cbd3..b4d8e56bfe 100644 --- a/src/main/data/api/core/adapters/IpcAdapter.ts +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' +import { toDataApiError } from '@shared/data/api/apiErrors' import type { DataRequest, DataResponse } from '@shared/data/api/apiTypes' -import { toDataApiError } from '@shared/data/api/errorCodes' import { IpcChannel } from '@shared/IpcChannel' import { ipcMain } from 'electron' @@ -46,7 +46,7 @@ export class IpcAdapter { const errorResponse: DataResponse = { id: request.id, status: apiError.status, - error: apiError, + error: apiError.toJSON(), // Serialize for IPC transmission metadata: { duration: 0, timestamp: Date.now() diff --git a/src/renderer/src/data/DataApiService.ts b/src/renderer/src/data/DataApiService.ts index 35a7fdac3b..8b2b8a7f62 100644 --- a/src/renderer/src/data/DataApiService.ts +++ b/src/renderer/src/data/DataApiService.ts @@ -30,6 +30,8 @@ */ import { loggerService } from '@logger' +import type { RequestContext } from '@shared/data/api/apiErrors' +import { DataApiError, DataApiErrorFactory, ErrorCode, toDataApiError } from '@shared/data/api/apiErrors' import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' import type { DataRequest, @@ -38,18 +40,20 @@ import type { SubscriptionEvent, SubscriptionOptions } from '@shared/data/api/apiTypes' -import { toDataApiError } from '@shared/data/api/errorCodes' const logger = loggerService.withContext('DataApiService') /** - * Retry options interface + * Retry options interface. + * Retryability is now determined by DataApiError.isRetryable getter. */ interface RetryOptions { + /** Maximum number of retry attempts */ maxRetries: number + /** Initial delay between retries in milliseconds */ retryDelay: number + /** Multiplier for exponential backoff */ backoffMultiplier: number - retryCondition: (error: Error) => boolean } /** @@ -71,22 +75,11 @@ export class DataApiService implements ApiClient { >() // Default retry options + // Retryability is determined by DataApiError.isRetryable private defaultRetryOptions: RetryOptions = { maxRetries: 2, retryDelay: 1000, - backoffMultiplier: 2, - retryCondition: (error: Error) => { - // Retry on network errors or temporary failures - const message = error.message.toLowerCase() - return ( - message.includes('timeout') || - message.includes('network') || - message.includes('connection') || - message.includes('unavailable') || - message.includes('500') || - message.includes('503') - ) - } + backoffMultiplier: 2 } private constructor() { @@ -131,11 +124,20 @@ export class DataApiService implements ApiClient { } /** - * Send request via IPC with direct return and retry logic + * Send request via IPC with direct return and retry logic. + * Uses DataApiError.isRetryable to determine if retry is appropriate. */ private async sendRequest(request: DataRequest, retryCount = 0): Promise { if (!window.api.dataApi.request) { - throw new Error('Data API not available') + throw DataApiErrorFactory.create(ErrorCode.SERVICE_UNAVAILABLE, 'Data API not available') + } + + // Build request context for error tracking + const requestContext: RequestContext = { + requestId: request.id, + path: request.path, + method: request.method as HttpMethod, + timestamp: Date.now() } try { @@ -144,11 +146,14 @@ export class DataApiService implements ApiClient { // Direct IPC call with timeout const response = await Promise.race([ window.api.dataApi.request(request), - new Promise((_, reject) => setTimeout(() => reject(new Error(`Request timeout: ${request.path}`)), 3000)) + new Promise((_, reject) => + setTimeout(() => reject(DataApiErrorFactory.timeout(request.path, 3000, requestContext)), 3000) + ) ]) if (response.error) { - throw new Error(response.error.message) + // Reconstruct DataApiError from serialized response + throw DataApiError.fromJSON(response.error) } logger.debug(`Request succeeded: ${request.method} ${request.path}`, { @@ -158,14 +163,17 @@ export class DataApiService implements ApiClient { return response.data as T } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error' - logger.debug(`Request failed: ${request.method} ${request.path}`, error as Error) + // Ensure we have a DataApiError for consistent handling + const apiError = + error instanceof DataApiError ? error : toDataApiError(error, `${request.method} ${request.path}`) - // Check if should retry - if (retryCount < this.defaultRetryOptions.maxRetries && this.defaultRetryOptions.retryCondition(error as Error)) { + logger.debug(`Request failed: ${request.method} ${request.path}`, apiError) + + // Check if should retry using the error's built-in isRetryable getter + if (retryCount < this.defaultRetryOptions.maxRetries && apiError.isRetryable) { logger.debug( `Retrying request attempt ${retryCount + 1}/${this.defaultRetryOptions.maxRetries}: ${request.path}`, - { error: errorMessage } + { error: apiError.message, code: apiError.code } ) // Calculate delay with exponential backoff @@ -179,7 +187,7 @@ export class DataApiService implements ApiClient { return this.sendRequest(retryRequest, retryCount + 1) } - throw error + throw apiError } } From 7faff7ad4bddb71583827f07b53f8e42a7a32559 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 12:54:06 +0800 Subject: [PATCH 042/116] feat(api): implement message branching API with tree structure support - Add Topic and Message API endpoints for CRUD operations - Implement tree visualization queries (GET /topics/:id/tree) - Implement branch message queries with pagination (GET /topics/:id/messages) - Add multi-model response grouping via siblingsGroupId - Support topic forking from existing message nodes - Add INVALID_OPERATION error code for business rule violations - Update API design guidelines documentation --- .../shared/data/api/api-design-guidelines.md | 10 + packages/shared/data/api/apiErrors.ts | 41 ++ packages/shared/data/api/index.ts | 1 + packages/shared/data/api/schemas/index.ts | 13 +- packages/shared/data/api/schemas/messages.ts | 175 ++++++ packages/shared/data/api/schemas/topics.ts | 133 +++++ packages/shared/data/types/message.ts | 133 +++++ packages/shared/data/types/topic.ts | 40 ++ src/main/data/api/handlers/index.ts | 18 +- src/main/data/api/handlers/messages.ts | 69 +++ src/main/data/api/handlers/topics.ts | 52 ++ src/main/data/services/MessageService.ts | 543 ++++++++++++++++++ src/main/data/services/TopicService.ts | 244 ++++++++ 13 files changed, 1453 insertions(+), 19 deletions(-) create mode 100644 packages/shared/data/api/schemas/messages.ts create mode 100644 packages/shared/data/api/schemas/topics.ts create mode 100644 packages/shared/data/types/topic.ts create mode 100644 src/main/data/api/handlers/messages.ts create mode 100644 src/main/data/api/handlers/topics.ts create mode 100644 src/main/data/services/MessageService.ts create mode 100644 src/main/data/services/TopicService.ts diff --git a/packages/shared/data/api/api-design-guidelines.md b/packages/shared/data/api/api-design-guidelines.md index d508260aa2..6c6da6eba0 100644 --- a/packages/shared/data/api/api-design-guidelines.md +++ b/packages/shared/data/api/api-design-guidelines.md @@ -146,6 +146,7 @@ Use standard HTTP status codes consistently: | 201 Created | Successful POST | Return created resource | | 204 No Content | Successful DELETE | No body | | 400 Bad Request | Invalid request format | Malformed JSON | +| 400 Invalid Operation | Business rule violation | Delete root without cascade, cycle creation | | 401 Unauthorized | Authentication required | Missing/invalid token | | 403 Permission Denied | Insufficient permissions | Access denied to resource | | 404 Not Found | Resource not found | Invalid ID | @@ -209,6 +210,14 @@ interface SerializedDataApiError { status: 504, details: { operation: 'fetch topics', timeoutMs: 3000 } } + +// 400 Invalid Operation +{ + code: 'INVALID_OPERATION', + message: 'Invalid operation: delete root message - cascade=true required', + status: 400, + details: { operation: 'delete root message', reason: 'cascade=true required' } +} ``` Use `DataApiErrorFactory` utilities to create consistent errors: @@ -222,6 +231,7 @@ throw DataApiErrorFactory.validation({ name: ['Required'] }) throw DataApiErrorFactory.database(error, 'insert topic') throw DataApiErrorFactory.timeout('fetch topics', 3000) throw DataApiErrorFactory.dataInconsistent('Topic', 'parent reference broken') +throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') // Check if error is retryable if (error instanceof DataApiError && error.isRetryable) { diff --git a/packages/shared/data/api/apiErrors.ts b/packages/shared/data/api/apiErrors.ts index 71d710a2f4..4819ac32e2 100644 --- a/packages/shared/data/api/apiErrors.ts +++ b/packages/shared/data/api/apiErrors.ts @@ -58,6 +58,13 @@ export enum ErrorCode { /** 403 - Authenticated but lacks required permissions */ PERMISSION_DENIED = 'PERMISSION_DENIED', + /** + * 400 - Operation is not valid in current state. + * Use when: deleting root message without cascade, moving node would create cycle, + * or any operation that violates business rules but isn't a validation error. + */ + INVALID_OPERATION = 'INVALID_OPERATION', + // ───────────────────────────────────────────────────────────────── // Server errors (5xx) - Issues on the server side // ───────────────────────────────────────────────────────────────── @@ -123,6 +130,7 @@ export const ERROR_STATUS_MAP: Record = { [ErrorCode.VALIDATION_ERROR]: 422, [ErrorCode.RATE_LIMIT_EXCEEDED]: 429, [ErrorCode.PERMISSION_DENIED]: 403, + [ErrorCode.INVALID_OPERATION]: 400, // Server errors (5xx) [ErrorCode.INTERNAL_SERVER_ERROR]: 500, @@ -149,6 +157,7 @@ export const ERROR_MESSAGES: Record = { [ErrorCode.VALIDATION_ERROR]: 'Validation error: Request data does not meet requirements', [ErrorCode.RATE_LIMIT_EXCEEDED]: 'Rate limit exceeded: Too many requests', [ErrorCode.PERMISSION_DENIED]: 'Permission denied: Insufficient permissions for this operation', + [ErrorCode.INVALID_OPERATION]: 'Invalid operation: Operation not allowed in current state', [ErrorCode.INTERNAL_SERVER_ERROR]: 'Internal server error: An unexpected error occurred', [ErrorCode.DATABASE_ERROR]: 'Database error: Failed to access or modify data', @@ -232,6 +241,14 @@ export interface PermissionDeniedErrorDetails { resource?: string } +/** + * Details for INVALID_OPERATION - what operation was invalid. + */ +export interface InvalidOperationErrorDetails { + operation: string + reason?: string +} + /** * Details for RESOURCE_LOCKED - which resource is locked. */ @@ -272,6 +289,7 @@ export type ErrorDetailsMap = { [ErrorCode.TIMEOUT]: TimeoutErrorDetails [ErrorCode.DATA_INCONSISTENT]: DataInconsistentErrorDetails [ErrorCode.PERMISSION_DENIED]: PermissionDeniedErrorDetails + [ErrorCode.INVALID_OPERATION]: InvalidOperationErrorDetails [ErrorCode.RESOURCE_LOCKED]: ResourceLockedErrorDetails [ErrorCode.CONCURRENT_MODIFICATION]: ConcurrentModificationErrorDetails [ErrorCode.INTERNAL_SERVER_ERROR]: InternalErrorDetails @@ -631,6 +649,29 @@ export class DataApiErrorFactory { ) } + /** + * Create an invalid operation error. + * Use when an operation violates business rules but isn't a validation error. + * @param operation - Description of the invalid operation + * @param reason - Optional reason why the operation is invalid + * @param requestContext - Optional request context + * @returns DataApiError with INVALID_OPERATION code + */ + static invalidOperation( + operation: string, + reason?: string, + requestContext?: RequestContext + ): DataApiError { + const message = reason ? `Invalid operation: ${operation} - ${reason}` : `Invalid operation: ${operation}` + return new DataApiError( + ErrorCode.INVALID_OPERATION, + message, + ERROR_STATUS_MAP[ErrorCode.INVALID_OPERATION], + { operation, reason }, + requestContext + ) + } + /** * Create a data inconsistency error. * @param resource - The resource with inconsistent data diff --git a/packages/shared/data/api/index.ts b/packages/shared/data/api/index.ts index bb04eff0fb..33e878a653 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -86,6 +86,7 @@ export type { DetailsForCode, ErrorDetailsMap, InternalErrorDetails, + InvalidOperationErrorDetails, NotFoundErrorDetails, PermissionDeniedErrorDetails, RequestContext, diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts index 4697880783..703b92ff24 100644 --- a/packages/shared/data/api/schemas/index.ts +++ b/packages/shared/data/api/schemas/index.ts @@ -14,12 +14,15 @@ * * // Domain DTOs directly from schema files * import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' - * import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' + * import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topics' + * import type { Message, CreateMessageDto } from '@shared/data/api/schemas/messages' * ``` */ import type { AssertValidSchemas } from '../apiTypes' +import type { MessageSchemas } from './messages' import type { TestSchemas } from './test' +import type { TopicSchemas } from './topics' /** * Merged API Schemas - single source of truth for all API endpoints @@ -32,11 +35,5 @@ import type { TestSchemas } from './test' * When adding a new domain: * 1. Create the schema file (e.g., topic.ts) * 2. Import and add to intersection below - * - * @example - * ```typescript - * import type { TopicSchemas } from './topic' - * export type ApiSchemas = AssertValidSchemas - * ``` */ -export type ApiSchemas = AssertValidSchemas +export type ApiSchemas = AssertValidSchemas diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts new file mode 100644 index 0000000000..4b144d87ae --- /dev/null +++ b/packages/shared/data/api/schemas/messages.ts @@ -0,0 +1,175 @@ +/** + * Message API Schema definitions + * + * Contains all message-related endpoints for tree operations and message management. + * Includes endpoints for tree visualization and conversation view. + */ + +import type { + BranchMessagesResponse, + Message, + MessageData, + MessageRole, + MessageStats, + TreeResponse +} from '@shared/data/types/message' +import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' + +// ============================================================================ +// DTOs +// ============================================================================ + +/** + * DTO for creating a new message + */ +export interface CreateMessageDto { + /** Parent message ID (null for root) */ + parentId: string | null + /** Message role */ + role: MessageRole + /** Message content */ + data: MessageData + /** Message status */ + status?: 'success' | 'error' | 'paused' + /** Siblings group ID (0 = normal, >0 = multi-model group) */ + siblingsGroupId?: number + /** Assistant ID */ + assistantId?: string + /** Preserved assistant info */ + assistantMeta?: AssistantMeta + /** Model identifier */ + modelId?: string + /** Preserved model info */ + modelMeta?: ModelMeta + /** Trace ID */ + traceId?: string + /** Statistics */ + stats?: MessageStats +} + +/** + * DTO for updating an existing message + */ +export interface UpdateMessageDto { + /** Updated message content */ + data?: MessageData + /** Move message to new parent */ + parentId?: string | null + /** Change siblings group */ + siblingsGroupId?: number + /** Update status */ + status?: 'success' | 'error' | 'paused' +} + +/** + * Response for delete operation + */ +export interface DeleteMessageResponse { + /** IDs of deleted messages */ + deletedIds: string[] + /** IDs of reparented children (only when cascade=false) */ + reparentedIds?: string[] +} + +// ============================================================================ +// Query Parameters +// ============================================================================ + +/** + * Query parameters for GET /topics/:id/tree + */ +export interface TreeQueryParams { + /** Root node ID (defaults to tree root) */ + rootId?: string + /** End node ID (defaults to topic.activeNodeId) */ + nodeId?: string + /** Depth to expand beyond active path (-1 = all, 0 = path only, 1+ = layers) */ + depth?: number +} + +/** + * Query parameters for GET /topics/:id/messages + */ +export interface BranchMessagesQueryParams { + /** End node ID (defaults to topic.activeNodeId) */ + nodeId?: string + /** Pagination cursor: return messages before this node */ + beforeNodeId?: string + /** Number of messages to return */ + limit?: number + /** Whether to include siblingsGroup in response */ + includeSiblings?: boolean +} + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Message API Schema definitions + * + * Organized by domain responsibility: + * - /topics/:id/tree - Tree visualization + * - /topics/:id/messages - Branch messages for conversation + * - /messages/:id - Individual message operations + */ +export interface MessageSchemas { + /** + * Tree query endpoint for visualization + * @example GET /topics/abc123/tree?depth=1 + */ + '/topics/:topicId/tree': { + /** Get tree structure for visualization */ + GET: { + params: { topicId: string } + query?: TreeQueryParams + response: TreeResponse + } + } + + /** + * Branch messages endpoint for conversation view + * @example GET /topics/abc123/messages?limit=20 + * @example POST /topics/abc123/messages { "parentId": "msg1", "role": "user", "data": {...} } + */ + '/topics/:topicId/messages': { + /** Get messages along active branch with pagination */ + GET: { + params: { topicId: string } + query?: BranchMessagesQueryParams + response: BranchMessagesResponse + } + /** Create a new message in the topic */ + POST: { + params: { topicId: string } + body: CreateMessageDto + response: Message + } + } + + /** + * Individual message endpoint + * @example GET /messages/msg123 + * @example PATCH /messages/msg123 { "data": {...} } + * @example DELETE /messages/msg123?cascade=true + */ + '/messages/:id': { + /** Get a single message by ID */ + GET: { + params: { id: string } + response: Message + } + /** Update a message (content, move to new parent, etc.) */ + PATCH: { + params: { id: string } + body: UpdateMessageDto + response: Message + } + /** Delete a message (cascade=true deletes descendants, cascade=false reparents children) */ + DELETE: { + params: { id: string } + query?: { cascade?: boolean } + response: DeleteMessageResponse + } + } +} diff --git a/packages/shared/data/api/schemas/topics.ts b/packages/shared/data/api/schemas/topics.ts new file mode 100644 index 0000000000..3a4d82b5ec --- /dev/null +++ b/packages/shared/data/api/schemas/topics.ts @@ -0,0 +1,133 @@ +/** + * Topic API Schema definitions + * + * Contains all topic-related endpoints for CRUD operations and branch switching. + */ + +import type { AssistantMeta } from '@shared/data/types/meta' +import type { Topic } from '@shared/data/types/topic' + +// ============================================================================ +// DTOs +// ============================================================================ + +/** + * DTO for creating a new topic + */ +export interface CreateTopicDto { + /** Topic name */ + name?: string + /** Associated assistant ID */ + assistantId?: string + /** Preserved assistant info */ + assistantMeta?: AssistantMeta + /** Topic-specific prompt */ + prompt?: string + /** Group ID for organization */ + groupId?: string + /** + * Source node ID for fork operation. + * When provided, copies the path from root to this node into the new topic. + */ + sourceNodeId?: string +} + +/** + * DTO for updating an existing topic + */ +export interface UpdateTopicDto { + /** Updated topic name */ + name?: string + /** Mark name as manually edited */ + isNameManuallyEdited?: boolean + /** Updated assistant ID */ + assistantId?: string + /** Updated assistant meta */ + assistantMeta?: AssistantMeta + /** Updated prompt */ + prompt?: string + /** Updated group ID */ + groupId?: string + /** Updated sort order */ + sortOrder?: number + /** Updated pin state */ + isPinned?: boolean + /** Updated pin order */ + pinnedOrder?: number +} + +/** + * DTO for setting active node + */ +export interface SetActiveNodeDto { + /** Node ID to set as active */ + nodeId: string +} + +/** + * Response for active node update + */ +export interface ActiveNodeResponse { + /** The new active node ID */ + activeNodeId: string +} + +// ============================================================================ +// API Schema Definitions +// ============================================================================ + +/** + * Topic API Schema definitions + */ +export interface TopicSchemas { + /** + * Topics collection endpoint + * @example POST /topics { "name": "New Topic", "assistantId": "asst_123" } + */ + '/topics': { + /** Create a new topic (optionally fork from existing node) */ + POST: { + body: CreateTopicDto + response: Topic + } + } + + /** + * Individual topic endpoint + * @example GET /topics/abc123 + * @example PATCH /topics/abc123 { "name": "Updated Name" } + * @example DELETE /topics/abc123 + */ + '/topics/:id': { + /** Get a topic by ID */ + GET: { + params: { id: string } + response: Topic + } + /** Update a topic */ + PATCH: { + params: { id: string } + body: UpdateTopicDto + response: Topic + } + /** Delete a topic and all its messages */ + DELETE: { + params: { id: string } + response: void + } + } + + /** + * Active node sub-resource endpoint + * High-frequency operation for branch switching + * @example PUT /topics/abc123/active-node { "nodeId": "msg456" } + */ + '/topics/:id/active-node': { + /** Set the active node for a topic */ + PUT: { + params: { id: string } + body: SetActiveNodeDto + response: ActiveNodeResponse + } + } +} diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts index 2c67d818e2..ab121d6346 100644 --- a/packages/shared/data/types/message.ts +++ b/packages/shared/data/types/message.ts @@ -169,3 +169,136 @@ export type MessageDataBlock = | VideoBlock | ErrorBlock | CompactBlock + +// ============================================================================ +// Message Entity Types +// ============================================================================ + +import type { AssistantMeta, ModelMeta } from './meta' + +/** + * Message role - user, assistant, or system + */ +export type MessageRole = 'user' | 'assistant' | 'system' + +/** + * Message status - final state after processing + */ +export type MessageStatus = 'success' | 'error' | 'paused' + +/** + * Complete message entity as stored in database + */ +export interface Message { + /** Message ID (UUIDv7) */ + id: string + /** Topic ID this message belongs to */ + topicId: string + /** Parent message ID (null for root) */ + parentId: string | null + /** Message role */ + role: MessageRole + /** Message content (blocks, mentions, etc.) */ + data: MessageData + /** Searchable text extracted from data.blocks */ + searchableText?: string | null + /** Message status */ + status: MessageStatus + /** Siblings group ID (0 = normal branch, >0 = multi-model response group) */ + siblingsGroupId: number + /** Assistant ID */ + assistantId?: string | null + /** Preserved assistant info for display */ + assistantMeta?: AssistantMeta | null + /** Model identifier */ + modelId?: string | null + /** Preserved model info (provider, name) */ + modelMeta?: ModelMeta | null + /** Trace ID for tracking */ + traceId?: string | null + /** Statistics: token usage, performance metrics */ + stats?: MessageStats | null + /** Creation timestamp (ISO string) */ + createdAt: string + /** Last update timestamp (ISO string) */ + updatedAt: string +} + +// ============================================================================ +// Tree Structure Types +// ============================================================================ + +/** + * Lightweight tree node for tree visualization (ReactFlow) + * Contains only essential display info, not full message content + */ +export interface TreeNode { + /** Message ID */ + id: string + /** Parent message ID (null for root, omitted in SiblingsGroup.nodes) */ + parentId?: string | null + /** Message role */ + role: MessageRole + /** Content preview (first 50 characters) */ + preview: string + /** Model identifier */ + modelId?: string | null + /** Model display info */ + modelMeta?: ModelMeta | null + /** Message status */ + status: MessageStatus + /** Creation timestamp (ISO string) */ + createdAt: string + /** Whether this node has children (for expand indicator) */ + hasChildren: boolean +} + +/** + * Group of sibling nodes with same parentId and siblingsGroupId + * Used for multi-model responses in tree view + */ +export interface SiblingsGroup { + /** Parent message ID */ + parentId: string + /** Siblings group ID (non-zero) */ + siblingsGroupId: number + /** Nodes in this group (parentId omitted to avoid redundancy) */ + nodes: Omit[] +} + +/** + * Tree query response structure + */ +export interface TreeResponse { + /** Regular nodes (siblingsGroupId = 0) */ + nodes: TreeNode[] + /** Multi-model response groups (siblingsGroupId != 0) */ + siblingsGroups: SiblingsGroup[] + /** Current active node ID */ + activeNodeId: string | null +} + +// ============================================================================ +// Branch Message Types +// ============================================================================ + +/** + * Message with optional siblings group for conversation view + * Used in GET /topics/:id/messages response + */ +export interface BranchMessage { + /** The message itself */ + message: Message + /** Other messages in the same siblings group (only when siblingsGroupId != 0 and includeSiblings=true) */ + siblingsGroup?: Message[] +} + +/** + * Branch messages response structure + */ +export interface BranchMessagesResponse { + /** Messages in root-to-leaf order */ + messages: BranchMessage[] + /** Current active node ID */ + activeNodeId: string | null +} diff --git a/packages/shared/data/types/topic.ts b/packages/shared/data/types/topic.ts new file mode 100644 index 0000000000..f03981f771 --- /dev/null +++ b/packages/shared/data/types/topic.ts @@ -0,0 +1,40 @@ +/** + * Topic entity types + * + * Topics are containers for messages and belong to assistants. + * They can be organized into groups and have tags for categorization. + */ + +import type { AssistantMeta } from './meta' + +/** + * Complete topic entity as stored in database + */ +export interface Topic { + /** Topic ID */ + id: string + /** Topic name */ + name?: string | null + /** Whether the name was manually edited by user */ + isNameManuallyEdited: boolean + /** Associated assistant ID */ + assistantId?: string | null + /** Preserved assistant info for display when assistant is deleted */ + assistantMeta?: AssistantMeta | null + /** Topic-specific prompt override */ + prompt?: string | null + /** Active node ID in the message tree */ + activeNodeId?: string | null + /** Group ID for organization */ + groupId?: string | null + /** Sort order within group */ + sortOrder: number + /** Whether topic is pinned */ + isPinned: boolean + /** Pinned order */ + pinnedOrder: number + /** Creation timestamp (ISO string) */ + createdAt: string + /** Last update timestamp (ISO string) */ + updatedAt: string +} diff --git a/src/main/data/api/handlers/index.ts b/src/main/data/api/handlers/index.ts index a79f4c6707..87072fdfc0 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -6,21 +6,15 @@ * * Handler files are organized by domain: * - test.ts - Test API handlers - * - * @example Adding a new domain: - * ```typescript - * import { topicHandlers } from './topic' - * - * export const apiHandlers: ApiImplementation = { - * ...testHandlers, - * ...topicHandlers // Add new domain handlers here - * } - * ``` + * - topics.ts - Topic API handlers + * - messages.ts - Message API handlers */ import type { ApiImplementation } from '@shared/data/api/apiTypes' +import { messageHandlers } from './messages' import { testHandlers } from './test' +import { topicHandlers } from './topics' /** * Complete API handlers implementation @@ -30,5 +24,7 @@ import { testHandlers } from './test' * TypeScript ensures exhaustive coverage - missing handlers cause compile errors. */ export const apiHandlers: ApiImplementation = { - ...testHandlers + ...testHandlers, + ...topicHandlers, + ...messageHandlers } diff --git a/src/main/data/api/handlers/messages.ts b/src/main/data/api/handlers/messages.ts new file mode 100644 index 0000000000..0dde772331 --- /dev/null +++ b/src/main/data/api/handlers/messages.ts @@ -0,0 +1,69 @@ +/** + * Message API Handlers + * + * Implements all message-related API endpoints including: + * - Tree visualization queries + * - Branch message queries with pagination + * - Message CRUD operations + */ + +import { messageService } from '@data/services/MessageService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import type { BranchMessagesQueryParams, MessageSchemas, TreeQueryParams } from '@shared/data/api/schemas/messages' + +/** + * Handler type for a specific message endpoint + */ +type MessageHandler> = ApiHandler + +/** + * Message API handlers implementation + */ +export const messageHandlers: { + [Path in keyof MessageSchemas]: { + [Method in keyof MessageSchemas[Path]]: MessageHandler> + } +} = { + '/topics/:topicId/tree': { + GET: async ({ params, query }) => { + const q = (query || {}) as TreeQueryParams + return await messageService.getTree(params.topicId, { + rootId: q.rootId, + nodeId: q.nodeId, + depth: q.depth + }) + } + }, + + '/topics/:topicId/messages': { + GET: async ({ params, query }) => { + const q = (query || {}) as BranchMessagesQueryParams + return await messageService.getBranchMessages(params.topicId, { + nodeId: q.nodeId, + beforeNodeId: q.beforeNodeId, + limit: q.limit, + includeSiblings: q.includeSiblings + }) + }, + + POST: async ({ params, body }) => { + return await messageService.create(params.topicId, body) + } + }, + + '/messages/:id': { + GET: async ({ params }) => { + return await messageService.getById(params.id) + }, + + PATCH: async ({ params, body }) => { + return await messageService.update(params.id, body) + }, + + DELETE: async ({ params, query }) => { + const q = (query || {}) as { cascade?: boolean } + const cascade = q.cascade ?? false + return await messageService.delete(params.id, cascade) + } + } +} diff --git a/src/main/data/api/handlers/topics.ts b/src/main/data/api/handlers/topics.ts new file mode 100644 index 0000000000..45fbabac1b --- /dev/null +++ b/src/main/data/api/handlers/topics.ts @@ -0,0 +1,52 @@ +/** + * Topic API Handlers + * + * Implements all topic-related API endpoints including: + * - Topic CRUD operations + * - Active node switching for branch navigation + */ + +import { topicService } from '@data/services/TopicService' +import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' +import type { TopicSchemas } from '@shared/data/api/schemas/topics' + +/** + * Handler type for a specific topic endpoint + */ +type TopicHandler> = ApiHandler + +/** + * Topic API handlers implementation + */ +export const topicHandlers: { + [Path in keyof TopicSchemas]: { + [Method in keyof TopicSchemas[Path]]: TopicHandler> + } +} = { + '/topics': { + POST: async ({ body }) => { + return await topicService.create(body) + } + }, + + '/topics/:id': { + GET: async ({ params }) => { + return await topicService.getById(params.id) + }, + + PATCH: async ({ params, body }) => { + return await topicService.update(params.id, body) + }, + + DELETE: async ({ params }) => { + await topicService.delete(params.id) + return undefined + } + }, + + '/topics/:id/active-node': { + PUT: async ({ params, body }) => { + return await topicService.setActiveNode(params.id, body.nodeId) + } + } +} diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts new file mode 100644 index 0000000000..449631f3d8 --- /dev/null +++ b/src/main/data/services/MessageService.ts @@ -0,0 +1,543 @@ +/** + * Message Service - handles message CRUD and tree operations + * + * Provides business logic for: + * - Tree visualization queries + * - Branch message queries with pagination + * - Message CRUD with tree structure maintenance + * - Cascade delete and reparenting + */ + +import { dbService } from '@data/db/DbService' +import { messageTable } from '@data/db/schemas/message' +import { topicTable } from '@data/db/schemas/topic' +import { loggerService } from '@logger' +import { DataApiErrorFactory } from '@shared/data/api' +import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages' +import type { + BranchMessage, + BranchMessagesResponse, + Message, + SiblingsGroup, + TreeNode, + TreeResponse +} from '@shared/data/types/message' +import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { v7 as uuidv7 } from 'uuid' + +const logger = loggerService.withContext('MessageService') + +/** + * Preview length for tree nodes + */ +const PREVIEW_LENGTH = 50 + +/** + * Default pagination limit + */ +const DEFAULT_LIMIT = 20 + +/** + * Convert database row to Message entity + */ +function rowToMessage(row: typeof messageTable.$inferSelect): Message { + return { + id: row.id, + topicId: row.topicId, + parentId: row.parentId, + role: row.role as Message['role'], + data: row.data, + searchableText: row.searchableText, + status: row.status as Message['status'], + siblingsGroupId: row.siblingsGroupId ?? 0, + assistantId: row.assistantId, + assistantMeta: row.assistantMeta, + modelId: row.modelId, + modelMeta: row.modelMeta, + traceId: row.traceId, + stats: row.stats, + createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : new Date().toISOString(), + updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : new Date().toISOString() + } +} + +/** + * Extract preview text from message data + */ +function extractPreview(message: Message): string { + const blocks = message.data?.blocks || [] + for (const block of blocks) { + if ('content' in block && typeof block.content === 'string') { + const text = block.content.trim() + if (text.length > 0) { + return text.length > PREVIEW_LENGTH ? text.substring(0, PREVIEW_LENGTH) + '...' : text + } + } + } + return '' +} + +/** + * Convert Message to TreeNode + */ +function messageToTreeNode(message: Message, hasChildren: boolean): TreeNode { + return { + id: message.id, + parentId: message.parentId, + role: message.role === 'system' ? 'assistant' : message.role, + preview: extractPreview(message), + modelId: message.modelId, + modelMeta: message.modelMeta, + status: message.status, + createdAt: message.createdAt, + hasChildren + } +} + +export class MessageService { + private static instance: MessageService + + private constructor() {} + + public static getInstance(): MessageService { + if (!MessageService.instance) { + MessageService.instance = new MessageService() + } + return MessageService.instance + } + + /** + * Get tree structure for visualization + */ + async getTree( + topicId: string, + options: { rootId?: string; nodeId?: string; depth?: number } = {} + ): Promise { + const db = dbService.getDb() + const { depth = 1 } = options + + // Get topic to verify existence and get activeNodeId + const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) + + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', topicId) + } + + const activeNodeId = options.nodeId || topic.activeNodeId + + // Get all messages for this topic + const allMessages = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) + + if (allMessages.length === 0) { + return { nodes: [], siblingsGroups: [], activeNodeId: null } + } + + const messagesById = new Map() + const childrenMap = new Map() + + for (const row of allMessages) { + const message = rowToMessage(row) + messagesById.set(message.id, message) + + const parentId = message.parentId || 'root' + if (!childrenMap.has(parentId)) { + childrenMap.set(parentId, []) + } + childrenMap.get(parentId)!.push(message.id) + } + + // Find root node(s) and build active path + const rootIds = childrenMap.get('root') || [] + const rootId = options.rootId || rootIds[0] + + // Build path from rootId to activeNodeId + const activePath = new Set() + if (activeNodeId) { + let currentId: string | null = activeNodeId + while (currentId) { + activePath.add(currentId) + const message = messagesById.get(currentId) + currentId = message?.parentId || null + } + } + + // Collect nodes based on depth + const resultNodes: TreeNode[] = [] + const siblingsGroups: SiblingsGroup[] = [] + const visitedGroups = new Set() + + const collectNodes = (nodeId: string, currentDepth: number, isOnActivePath: boolean) => { + const message = messagesById.get(nodeId) + if (!message) return + + const children = childrenMap.get(nodeId) || [] + const hasChildren = children.length > 0 + + // Check if this message is part of a siblings group + if (message.siblingsGroupId !== 0) { + const groupKey = `${message.parentId}-${message.siblingsGroupId}` + if (!visitedGroups.has(groupKey)) { + visitedGroups.add(groupKey) + + // Find all siblings in this group + const parentChildren = childrenMap.get(message.parentId || 'root') || [] + const groupMembers = parentChildren + .map((id) => messagesById.get(id)!) + .filter((m) => m.siblingsGroupId === message.siblingsGroupId) + + if (groupMembers.length > 1) { + siblingsGroups.push({ + parentId: message.parentId!, + siblingsGroupId: message.siblingsGroupId, + nodes: groupMembers.map((m) => { + const memberChildren = childrenMap.get(m.id) || [] + const node = messageToTreeNode(m, memberChildren.length > 0) + const { parentId: _parentId, ...rest } = node + void _parentId // Intentionally unused - removing parentId from TreeNode for SiblingsGroup + return rest + }) + }) + } else { + // Single member, add as regular node + resultNodes.push(messageToTreeNode(message, hasChildren)) + } + } + } else { + resultNodes.push(messageToTreeNode(message, hasChildren)) + } + + // Recurse to children + const shouldExpand = isOnActivePath || (depth === -1 ? true : currentDepth < depth) + if (shouldExpand) { + for (const childId of children) { + const childOnPath = activePath.has(childId) + collectNodes(childId, isOnActivePath ? 0 : currentDepth + 1, childOnPath) + } + } + } + + // Start from root + if (rootId) { + collectNodes(rootId, 0, activePath.has(rootId)) + } + + return { + nodes: resultNodes, + siblingsGroups, + activeNodeId + } + } + + /** + * Get branch messages for conversation view + */ + async getBranchMessages( + topicId: string, + options: { nodeId?: string; beforeNodeId?: string; limit?: number; includeSiblings?: boolean } = {} + ): Promise { + const db = dbService.getDb() + const { limit = DEFAULT_LIMIT, includeSiblings = true } = options + + // Get topic + const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) + + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', topicId) + } + + // Get all messages for this topic + const allMessages = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) + + if (allMessages.length === 0) { + return { messages: [], activeNodeId: null } + } + + // Check for data inconsistency + if (!topic.activeNodeId) { + throw DataApiErrorFactory.dataInconsistent('Topic', 'has messages but no active node') + } + + const nodeId = options.nodeId || topic.activeNodeId + const messagesById = new Map() + + for (const row of allMessages) { + messagesById.set(row.id, rowToMessage(row)) + } + + // Build path from root to nodeId + const path: string[] = [] + let currentId: string | null = nodeId + while (currentId) { + path.unshift(currentId) + const message = messagesById.get(currentId) + if (!message) { + throw DataApiErrorFactory.notFound('Message', currentId) + } + currentId = message.parentId + } + + // Apply pagination + let startIndex = 0 + if (options.beforeNodeId) { + const beforeIndex = path.indexOf(options.beforeNodeId) + if (beforeIndex === -1) { + throw DataApiErrorFactory.notFound('Message', options.beforeNodeId) + } + startIndex = Math.max(0, beforeIndex - limit) + } else { + startIndex = Math.max(0, path.length - limit) + } + + const endIndex = options.beforeNodeId ? path.indexOf(options.beforeNodeId) : path.length + + const resultPath = path.slice(startIndex, endIndex) + + // Build result with optional siblings + const result: BranchMessage[] = [] + + for (const msgId of resultPath) { + const message = messagesById.get(msgId)! + + let siblingsGroup: Message[] | undefined + if (includeSiblings && message.siblingsGroupId !== 0) { + // Find siblings with same parentId and siblingsGroupId + siblingsGroup = allMessages + .filter( + (row) => + row.parentId === message.parentId && + row.siblingsGroupId === message.siblingsGroupId && + row.id !== message.id + ) + .map(rowToMessage) + } + + result.push({ + message, + siblingsGroup + }) + } + + return { + messages: result, + activeNodeId: topic.activeNodeId + } + } + + /** + * Get a single message by ID + */ + async getById(id: string): Promise { + const db = dbService.getDb() + + const [row] = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.id, id), isNull(messageTable.deletedAt))) + .limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Message', id) + } + + return rowToMessage(row) + } + + /** + * Create a new message + */ + async create(topicId: string, dto: CreateMessageDto): Promise { + const db = dbService.getDb() + + // Verify topic exists + const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) + + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', topicId) + } + + // Verify parent exists if specified + if (dto.parentId) { + const [parent] = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt))) + .limit(1) + + if (!parent) { + throw DataApiErrorFactory.notFound('Message', dto.parentId) + } + } + + const now = Date.now() + const id = uuidv7() + + await db.insert(messageTable).values({ + id, + topicId, + parentId: dto.parentId, + role: dto.role, + data: dto.data, + status: dto.status || 'success', + siblingsGroupId: dto.siblingsGroupId ?? 0, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + modelId: dto.modelId, + modelMeta: dto.modelMeta, + traceId: dto.traceId, + stats: dto.stats, + createdAt: now, + updatedAt: now + }) + + logger.info('Created message', { id, topicId, role: dto.role }) + + return this.getById(id) + } + + /** + * Update a message + */ + async update(id: string, dto: UpdateMessageDto): Promise { + const db = dbService.getDb() + + // Get existing message + const existing = await this.getById(id) + + // Check for cycle if moving to new parent + if (dto.parentId !== undefined && dto.parentId !== existing.parentId) { + if (dto.parentId !== null) { + // Check that new parent is not a descendant + const descendants = await this.getDescendantIds(id) + if (descendants.includes(dto.parentId)) { + throw DataApiErrorFactory.invalidOperation('move message', 'would create cycle') + } + + // Verify new parent exists + const [parent] = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt))) + .limit(1) + + if (!parent) { + throw DataApiErrorFactory.notFound('Message', dto.parentId) + } + } + } + + // Build update object + const updates: Partial = { + updatedAt: Date.now() + } + + if (dto.data !== undefined) updates.data = dto.data + if (dto.parentId !== undefined) updates.parentId = dto.parentId + if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId + if (dto.status !== undefined) updates.status = dto.status + + await db.update(messageTable).set(updates).where(eq(messageTable.id, id)) + + logger.info('Updated message', { id, changes: Object.keys(dto) }) + + return this.getById(id) + } + + /** + * Delete a message + */ + async delete(id: string, cascade: boolean = false): Promise<{ deletedIds: string[]; reparentedIds?: string[] }> { + const db = dbService.getDb() + + // Get the message + const message = await this.getById(id) + + // Check if it's a root message + const isRoot = message.parentId === null + + if (isRoot && !cascade) { + throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') + } + + const now = Date.now() + + if (cascade) { + // Get all descendants + const descendantIds = await this.getDescendantIds(id) + const allIds = [id, ...descendantIds] + + // Soft delete all + await db.update(messageTable).set({ deletedAt: now }).where(inArray(messageTable.id, allIds)) + + logger.info('Cascade deleted messages', { rootId: id, count: allIds.length }) + + return { deletedIds: allIds } + } else { + // Reparent children to this message's parent + const children = await db + .select({ id: messageTable.id }) + .from(messageTable) + .where(and(eq(messageTable.parentId, id), isNull(messageTable.deletedAt))) + + const childIds = children.map((c) => c.id) + + if (childIds.length > 0) { + await db + .update(messageTable) + .set({ parentId: message.parentId, updatedAt: now }) + .where(inArray(messageTable.id, childIds)) + } + + // Soft delete this message + await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.id, id)) + + logger.info('Deleted message with reparenting', { id, reparentedCount: childIds.length }) + + return { deletedIds: [id], reparentedIds: childIds } + } + } + + /** + * Get all descendant IDs of a message + */ + private async getDescendantIds(id: string): Promise { + const db = dbService.getDb() + + // Use recursive query to get all descendants + const result = await db.all<{ id: string }>(sql` + WITH RECURSIVE descendants AS ( + SELECT id FROM message WHERE parent_id = ${id} AND deleted_at IS NULL + UNION ALL + SELECT m.id FROM message m + INNER JOIN descendants d ON m.parent_id = d.id + WHERE m.deleted_at IS NULL + ) + SELECT id FROM descendants + `) + + return result.map((r) => r.id) + } + + /** + * Get path from root to a node + */ + async getPathToNode(nodeId: string): Promise { + const path: Message[] = [] + let currentId: string | null = nodeId + + while (currentId) { + const message = await this.getById(currentId) + path.unshift(message) + currentId = message.parentId + } + + return path + } +} + +export const messageService = MessageService.getInstance() diff --git a/src/main/data/services/TopicService.ts b/src/main/data/services/TopicService.ts new file mode 100644 index 0000000000..5213132e55 --- /dev/null +++ b/src/main/data/services/TopicService.ts @@ -0,0 +1,244 @@ +/** + * Topic Service - handles topic CRUD and branch switching + * + * Provides business logic for: + * - Topic CRUD operations + * - Fork from existing conversation + * - Active node switching + */ + +import { dbService } from '@data/db/DbService' +import { messageTable } from '@data/db/schemas/message' +import { topicTable } from '@data/db/schemas/topic' +import { loggerService } from '@logger' +import { DataApiErrorFactory } from '@shared/data/api' +import type { CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topics' +import type { Topic } from '@shared/data/types/topic' +import { and, eq, isNull } from 'drizzle-orm' +import { v4 as uuidv4, v7 as uuidv7 } from 'uuid' + +import { messageService } from './MessageService' + +const logger = loggerService.withContext('TopicService') + +/** + * Convert database row to Topic entity + */ +function rowToTopic(row: typeof topicTable.$inferSelect): Topic { + return { + id: row.id, + name: row.name, + isNameManuallyEdited: row.isNameManuallyEdited ?? false, + assistantId: row.assistantId, + assistantMeta: row.assistantMeta, + prompt: row.prompt, + activeNodeId: row.activeNodeId, + groupId: row.groupId, + sortOrder: row.sortOrder ?? 0, + isPinned: row.isPinned ?? false, + pinnedOrder: row.pinnedOrder ?? 0, + createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : new Date().toISOString(), + updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : new Date().toISOString() + } +} + +export class TopicService { + private static instance: TopicService + + private constructor() {} + + public static getInstance(): TopicService { + if (!TopicService.instance) { + TopicService.instance = new TopicService() + } + return TopicService.instance + } + + /** + * Get a topic by ID + */ + async getById(id: string): Promise { + const db = dbService.getDb() + + const [row] = await db + .select() + .from(topicTable) + .where(and(eq(topicTable.id, id), isNull(topicTable.deletedAt))) + .limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Topic', id) + } + + return rowToTopic(row) + } + + /** + * Create a new topic + */ + async create(dto: CreateTopicDto): Promise { + const db = dbService.getDb() + const now = Date.now() + const id = uuidv4() + + // If forking from existing node, copy the path + let activeNodeId: string | null = null + + if (dto.sourceNodeId) { + // Verify source node exists + try { + await messageService.getById(dto.sourceNodeId) + } catch { + throw DataApiErrorFactory.notFound('Message', dto.sourceNodeId) + } + + // Get path from root to source node + const path = await messageService.getPathToNode(dto.sourceNodeId) + + // Create new topic first + await db.insert(topicTable).values({ + id, + name: dto.name, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + prompt: dto.prompt, + groupId: dto.groupId, + createdAt: now, + updatedAt: now + }) + + // Copy messages with new IDs + const idMapping = new Map() + + for (const message of path) { + const newId = uuidv7() + const newParentId = message.parentId ? idMapping.get(message.parentId) || null : null + + idMapping.set(message.id, newId) + + await db.insert(messageTable).values({ + id: newId, + topicId: id, + parentId: newParentId, + role: message.role, + data: message.data, + status: message.status, + siblingsGroupId: 0, // Simplify multi-model to normal node + assistantId: message.assistantId, + assistantMeta: message.assistantMeta, + modelId: message.modelId, + modelMeta: message.modelMeta, + traceId: null, // Clear trace ID + stats: null, // Clear stats + createdAt: now, + updatedAt: now + }) + + // Last node becomes the active node + activeNodeId = newId + } + + // Update topic with active node + await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, id)) + + logger.info('Created topic by forking', { id, sourceNodeId: dto.sourceNodeId, messageCount: path.length }) + } else { + // Create empty topic + await db.insert(topicTable).values({ + id, + name: dto.name, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + prompt: dto.prompt, + groupId: dto.groupId, + createdAt: now, + updatedAt: now + }) + + logger.info('Created empty topic', { id }) + } + + return this.getById(id) + } + + /** + * Update a topic + */ + async update(id: string, dto: UpdateTopicDto): Promise { + const db = dbService.getDb() + + // Verify topic exists + await this.getById(id) + + // Build update object + const updates: Partial = { + updatedAt: Date.now() + } + + if (dto.name !== undefined) updates.name = dto.name + if (dto.isNameManuallyEdited !== undefined) updates.isNameManuallyEdited = dto.isNameManuallyEdited + if (dto.assistantId !== undefined) updates.assistantId = dto.assistantId + if (dto.assistantMeta !== undefined) updates.assistantMeta = dto.assistantMeta + if (dto.prompt !== undefined) updates.prompt = dto.prompt + if (dto.groupId !== undefined) updates.groupId = dto.groupId + if (dto.sortOrder !== undefined) updates.sortOrder = dto.sortOrder + if (dto.isPinned !== undefined) updates.isPinned = dto.isPinned + if (dto.pinnedOrder !== undefined) updates.pinnedOrder = dto.pinnedOrder + + await db.update(topicTable).set(updates).where(eq(topicTable.id, id)) + + logger.info('Updated topic', { id, changes: Object.keys(dto) }) + + return this.getById(id) + } + + /** + * Delete a topic and all its messages + */ + async delete(id: string): Promise { + const db = dbService.getDb() + + // Verify topic exists + await this.getById(id) + + const now = Date.now() + + // Soft delete all messages + await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.topicId, id)) + + // Soft delete topic + await db.update(topicTable).set({ deletedAt: now }).where(eq(topicTable.id, id)) + + logger.info('Deleted topic', { id }) + } + + /** + * Set the active node for a topic + */ + async setActiveNode(topicId: string, nodeId: string): Promise<{ activeNodeId: string }> { + const db = dbService.getDb() + + // Verify topic exists + await this.getById(topicId) + + // Verify node exists and belongs to this topic + const [message] = await db + .select() + .from(messageTable) + .where(and(eq(messageTable.id, nodeId), eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) + .limit(1) + + if (!message) { + throw DataApiErrorFactory.notFound('Message', nodeId) + } + + // Update active node + await db.update(topicTable).set({ activeNodeId: nodeId, updatedAt: Date.now() }).where(eq(topicTable.id, topicId)) + + logger.info('Set active node', { topicId, nodeId }) + + return { activeNodeId: nodeId } + } +} + +export const topicService = TopicService.getInstance() From b78df05f288bf85ad6bd15cf448c2645a7bc9c57 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 28 Dec 2025 15:30:01 +0800 Subject: [PATCH 043/116] fix(AssistantsTab): prevent deleting last assistant and add error message (#12162) feat(AssistantsTab): prevent deleting last assistant and add error message Add validation to prevent deleting the last assistant and show an error message when attempted. Also simplify the active assistant assignment logic when deleting an assistant. --- src/renderer/src/i18n/locales/en-us.json | 3 +++ src/renderer/src/i18n/locales/zh-cn.json | 3 +++ src/renderer/src/i18n/locales/zh-tw.json | 7 +++++-- src/renderer/src/i18n/translate/de-de.json | 7 +++++-- src/renderer/src/i18n/translate/el-gr.json | 7 +++++-- src/renderer/src/i18n/translate/es-es.json | 7 +++++-- src/renderer/src/i18n/translate/fr-fr.json | 7 +++++-- src/renderer/src/i18n/translate/ja-jp.json | 7 +++++-- src/renderer/src/i18n/translate/pt-pt.json | 7 +++++-- src/renderer/src/i18n/translate/ru-ru.json | 7 +++++-- src/renderer/src/pages/home/Tabs/AssistantsTab.tsx | 11 +++++++++-- 11 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9528b4cd6b..9e60f31f00 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Deleting an assistant will delete all topics and files under the assistant. Are you sure you want to delete it?", + "error": { + "remain_one": "Not allowed to delete the last one assistant" + }, "title": "Delete Assistant" }, "edit": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 524f32c338..b9b07a596c 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -420,6 +420,9 @@ }, "delete": { "content": "删除助手会删除所有该助手下的话题和文件,确定要继续吗?", + "error": { + "remain_one": "不允许删除最后一个助手" + }, "title": "删除助手" }, "edit": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index fe30018ac5..3d613f00f4 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -420,6 +420,9 @@ }, "delete": { "content": "刪除助手會刪除所有該助手下的話題和檔案,確定要繼續嗎?", + "error": { + "remain_one": "不允許刪除最後一個助手" + }, "title": "刪除助手" }, "edit": { @@ -4805,7 +4808,7 @@ "content_limit_tooltip": "限制搜尋結果的內容長度;超過限制的內容將被截斷。", "default_provider": "預設搜尋引擎", "free": "免費", - "is_default": "[to be translated]:Default", + "is_default": "預設", "local_provider": { "hint": "登入網站以獲得更佳搜尋結果並個人化您的搜尋設定。", "open_settings": "開啟 {{provider}} 設定", @@ -4822,7 +4825,7 @@ "search_provider": "搜尋供應商", "search_provider_placeholder": "選擇一個搜尋供應商", "search_with_time": "搜尋包含日期", - "set_as_default": "[to be translated]:Set as Default", + "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 e77b9dede1..402437f1e8 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Das Löschen des Assistenten löscht alle Themen und Dateien unter diesem Assistenten. Möchten Sie fortfahren?", + "error": { + "remain_one": "Man darf den letzten Assistenten nicht löschen." + }, "title": "Assistent löschen" }, "edit": { @@ -4805,7 +4808,7 @@ "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", + "is_default": "Standard", "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", @@ -4822,7 +4825,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", + "set_as_default": "Als Standard festlegen", "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 1593099707..1fb0b08abb 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Η διαγραφή του βοηθού θα διαγράψει όλα τα θέματα και τα αρχεία που είναι συνδεδεμένα με αυτόν. Είστε σίγουροι πως θέλετε να συνεχίσετε;", + "error": { + "remain_one": "Δεν επιτρέπεται η διαγραφή του τελευταίου βοηθού" + }, "title": "Διαγραφή βοηθού" }, "edit": { @@ -4805,7 +4808,7 @@ "content_limit_tooltip": "Περιορίζει το μήκος του περιεχομένου των αποτελεσμάτων αναζήτησης, το περιεχόμενο πέραν του ορίου θα περικοπεί", "default_provider": "Προεπιλεγμένος Πάροχος", "free": "Δωρεάν", - "is_default": "[to be translated]:Default", + "is_default": "Προεπιλογή", "local_provider": { "hint": "Συνδεθείτε στην ιστοσελίδα για να λάβετε καλύτερα αποτελέσματα αναζήτησης και να εξατομικεύσετε τις ρυθμίσεις αναζήτησής σας.", "open_settings": "Άνοιγμα Ρυθμίσεων {{provider}}", @@ -4822,7 +4825,7 @@ "search_provider": "Πάροχος αναζήτησης", "search_provider_placeholder": "Επιλέξτε έναν πάροχο αναζήτησης", "search_with_time": "Αναζήτηση με ημερομηνία", - "set_as_default": "[to be translated]:Set as Default", + "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 56f06b1b53..1aa78e82dd 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Eliminar el asistente borrará todos los temas y archivos asociados. ¿Está seguro de que desea continuar?", + "error": { + "remain_one": "No se puede eliminar el último asistente" + }, "title": "Eliminar Asistente" }, "edit": { @@ -4805,7 +4808,7 @@ "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", + "is_default": "Por defecto", "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}}", @@ -4822,7 +4825,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", + "set_as_default": "Establecer como predeterminado", "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 4e8f2ac8e6..4906109228 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -420,6 +420,9 @@ }, "delete": { "content": "La suppression de l'aide supprimera tous les sujets et fichiers sous l'aide. Êtes-vous sûr de vouloir la supprimer ?", + "error": { + "remain_one": "Interdiction de supprimer le dernier assistant" + }, "title": "Supprimer l'Aide" }, "edit": { @@ -4805,7 +4808,7 @@ "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", + "is_default": "Défaut", "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}}", @@ -4822,7 +4825,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", + "set_as_default": "Définir par défaut", "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 58ee184061..950fef7130 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -420,6 +420,9 @@ }, "delete": { "content": "アシスタントを削除すると、そのアシスタントのすべてのトピックとファイルが削除されます。削除しますか?", + "error": { + "remain_one": "最後の1人のアシスタントは削除できません" + }, "title": "アシスタントを削除" }, "edit": { @@ -4805,7 +4808,7 @@ "content_limit_tooltip": "検索結果のコンテンツの長さを制限します。制限を超えるコンテンツは切り捨てられます。", "default_provider": "デフォルトプロバイダー", "free": "無料", - "is_default": "[to be translated]:Default", + "is_default": "デフォルト", "local_provider": { "hint": "ウェブサイトにログインして、より良い検索結果を得て、検索設定をパーソナライズしてください。", "open_settings": "{{provider}}設定を開く", @@ -4822,7 +4825,7 @@ "search_provider": "検索サービスプロバイダー", "search_provider_placeholder": "検索サービスプロバイダーを選択する", "search_with_time": "日付を含む検索", - "set_as_default": "[to be translated]:Set as Default", + "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 553795f6b3..73c8e28e4d 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Excluir o assistente removerá todos os tópicos e arquivos sob esse assistente. Tem certeza de que deseja continuar?", + "error": { + "remain_one": "Não é permitido apagar o último assistente." + }, "title": "Excluir Assistente" }, "edit": { @@ -4805,7 +4808,7 @@ "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", + "is_default": "Padrão", "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}}", @@ -4822,7 +4825,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", + "set_as_default": "Definir como Padrão", "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 489e8b4695..200b03e6c1 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Удаление ассистента удалит все топики и файлы под ассистентом. Вы уверены, что хотите удалить его?", + "error": { + "remain_one": "Нельзя удалить последнего помощника" + }, "title": "Удалить ассистента" }, "edit": { @@ -4805,7 +4808,7 @@ "content_limit_tooltip": "Ограничить длину контента в результатах поиска; контент, превышающий лимит, будет усечен.", "default_provider": "Поставщик по умолчанию", "free": "Бесплатно", - "is_default": "[to be translated]:Default", + "is_default": "По умолчанию", "local_provider": { "hint": "Войдите на сайт, чтобы получать более точные результаты поиска и настроить параметры поиска под себя.", "open_settings": "Открыть настройки {{provider}}", @@ -4822,7 +4825,7 @@ "search_provider": "поиск сервисного провайдера", "search_provider_placeholder": "Выберите поставщика поисковых услуг", "search_with_time": "Поиск, содержащий дату", - "set_as_default": "[to be translated]:Set as Default", + "set_as_default": "Установить по умолчанию", "subscribe": "Подписка на черный список", "subscribe_add": "Добавить подписку", "subscribe_add_failed": "Не удалось добавить источник подписки", diff --git a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 3c0c9cf802..79d7f64d7a 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -9,6 +9,7 @@ import { useTags } from '@renderer/hooks/useTags' import type { Assistant, AssistantsSortType, Topic } from '@renderer/types' import type { FC } from 'react' import { useCallback, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import styled from 'styled-components' import UnifiedAddButton from './components/UnifiedAddButton' @@ -32,6 +33,7 @@ const AssistantsTab: FC = (props) => { const { apiServerConfig } = useApiServer() const apiServerEnabled = apiServerConfig.enabled const { chat } = useRuntime() + const { t } = useTranslation() // Agent related hooks const { agents, deleteAgent, isLoading: agentsLoading, error: agentsError } = useAgents() @@ -75,13 +77,18 @@ const AssistantsTab: FC = (props) => { const onDeleteAssistant = useCallback( (assistant: Assistant) => { const remaining = assistants.filter((a) => a.id !== assistant.id) + if (remaining.length === 0) { + window.toast.error(t('assistants.delete.error.remain_one')) + return + } + if (assistant.id === activeAssistant?.id) { const newActive = remaining[remaining.length - 1] - newActive ? setActiveAssistant(newActive) : onCreateDefaultAssistant() + setActiveAssistant(newActive) } removeAssistant(assistant.id) }, - [activeAssistant, assistants, removeAssistant, setActiveAssistant, onCreateDefaultAssistant] + [assistants, activeAssistant?.id, removeAssistant, t, setActiveAssistant] ) const handleSortByChange = useCallback( From 5ff173fcc7ffa39f0c45b84fbdacefe55432320b Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 28 Dec 2025 17:04:45 +0800 Subject: [PATCH 044/116] fix(ollama): improve reasoningEffort handling in providerOptions (#12089) * fix(ollama): improve reasoningEffort handling in providerOptions * fix(ollama): update reasoning effort handling and add support for gpt-oss models * fix(ollama): update think option to support 'low', 'medium', and 'high' values * fix(ollama): update comment to clarify accepted reasoning effort values for gpt-oss models --- ...-ai-provider-v2-npm-1.5.5-8bef249af9.patch | 12 +++++----- src/renderer/src/aiCore/utils/options.ts | 6 +++-- .../config/models/__tests__/reasoning.test.ts | 22 ++++++++++++++++++- src/renderer/src/config/models/reasoning.ts | 5 +++++ src/renderer/src/types/index.ts | 1 + yarn.lock | 4 ++-- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch b/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch index ea14381539..c306bef6e5 100644 --- a/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch +++ b/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch @@ -7,7 +7,7 @@ index 8dd9b498050dbecd8dd6b901acf1aa8ca38a49af..ed644349c9d38fe2a66b2fb44214f7c1 type OllamaChatModelId = "athene-v2" | "athene-v2:72b" | "aya-expanse" | "aya-expanse:8b" | "aya-expanse:32b" | "codegemma" | "codegemma:2b" | "codegemma:7b" | "codellama" | "codellama:7b" | "codellama:13b" | "codellama:34b" | "codellama:70b" | "codellama:code" | "codellama:python" | "command-r" | "command-r:35b" | "command-r-plus" | "command-r-plus:104b" | "command-r7b" | "command-r7b:7b" | "deepseek-r1" | "deepseek-r1:1.5b" | "deepseek-r1:7b" | "deepseek-r1:8b" | "deepseek-r1:14b" | "deepseek-r1:32b" | "deepseek-r1:70b" | "deepseek-r1:671b" | "deepseek-coder-v2" | "deepseek-coder-v2:16b" | "deepseek-coder-v2:236b" | "deepseek-v3" | "deepseek-v3:671b" | "devstral" | "devstral:24b" | "dolphin3" | "dolphin3:8b" | "exaone3.5" | "exaone3.5:2.4b" | "exaone3.5:7.8b" | "exaone3.5:32b" | "falcon2" | "falcon2:11b" | "falcon3" | "falcon3:1b" | "falcon3:3b" | "falcon3:7b" | "falcon3:10b" | "firefunction-v2" | "firefunction-v2:70b" | "gemma" | "gemma:2b" | "gemma:7b" | "gemma2" | "gemma2:2b" | "gemma2:9b" | "gemma2:27b" | "gemma3" | "gemma3:1b" | "gemma3:4b" | "gemma3:12b" | "gemma3:27b" | "granite3-dense" | "granite3-dense:2b" | "granite3-dense:8b" | "granite3-guardian" | "granite3-guardian:2b" | "granite3-guardian:8b" | "granite3-moe" | "granite3-moe:1b" | "granite3-moe:3b" | "granite3.1-dense" | "granite3.1-dense:2b" | "granite3.1-dense:8b" | "granite3.1-moe" | "granite3.1-moe:1b" | "granite3.1-moe:3b" | "llama2" | "llama2:7b" | "llama2:13b" | "llama2:70b" | "llama3" | "llama3:8b" | "llama3:70b" | "llama3-chatqa" | "llama3-chatqa:8b" | "llama3-chatqa:70b" | "llama3-gradient" | "llama3-gradient:8b" | "llama3-gradient:70b" | "llama3.1" | "llama3.1:8b" | "llama3.1:70b" | "llama3.1:405b" | "llama3.2" | "llama3.2:1b" | "llama3.2:3b" | "llama3.2-vision" | "llama3.2-vision:11b" | "llama3.2-vision:90b" | "llama3.3" | "llama3.3:70b" | "llama4" | "llama4:16x17b" | "llama4:128x17b" | "llama-guard3" | "llama-guard3:1b" | "llama-guard3:8b" | "llava" | "llava:7b" | "llava:13b" | "llava:34b" | "llava-llama3" | "llava-llama3:8b" | "llava-phi3" | "llava-phi3:3.8b" | "marco-o1" | "marco-o1:7b" | "mistral" | "mistral:7b" | "mistral-large" | "mistral-large:123b" | "mistral-nemo" | "mistral-nemo:12b" | "mistral-small" | "mistral-small:22b" | "mixtral" | "mixtral:8x7b" | "mixtral:8x22b" | "moondream" | "moondream:1.8b" | "openhermes" | "openhermes:v2.5" | "nemotron" | "nemotron:70b" | "nemotron-mini" | "nemotron-mini:4b" | "olmo" | "olmo:7b" | "olmo:13b" | "opencoder" | "opencoder:1.5b" | "opencoder:8b" | "phi3" | "phi3:3.8b" | "phi3:14b" | "phi3.5" | "phi3.5:3.8b" | "phi4" | "phi4:14b" | "qwen" | "qwen:7b" | "qwen:14b" | "qwen:32b" | "qwen:72b" | "qwen:110b" | "qwen2" | "qwen2:0.5b" | "qwen2:1.5b" | "qwen2:7b" | "qwen2:72b" | "qwen2.5" | "qwen2.5:0.5b" | "qwen2.5:1.5b" | "qwen2.5:3b" | "qwen2.5:7b" | "qwen2.5:14b" | "qwen2.5:32b" | "qwen2.5:72b" | "qwen2.5-coder" | "qwen2.5-coder:0.5b" | "qwen2.5-coder:1.5b" | "qwen2.5-coder:3b" | "qwen2.5-coder:7b" | "qwen2.5-coder:14b" | "qwen2.5-coder:32b" | "qwen3" | "qwen3:0.6b" | "qwen3:1.7b" | "qwen3:4b" | "qwen3:8b" | "qwen3:14b" | "qwen3:30b" | "qwen3:32b" | "qwen3:235b" | "qwq" | "qwq:32b" | "sailor2" | "sailor2:1b" | "sailor2:8b" | "sailor2:20b" | "shieldgemma" | "shieldgemma:2b" | "shieldgemma:9b" | "shieldgemma:27b" | "smallthinker" | "smallthinker:3b" | "smollm" | "smollm:135m" | "smollm:360m" | "smollm:1.7b" | "tinyllama" | "tinyllama:1.1b" | "tulu3" | "tulu3:8b" | "tulu3:70b" | (string & {}); declare const ollamaProviderOptions: z.ZodObject<{ - think: z.ZodOptional; -+ think: z.ZodOptional]>>; ++ think: z.ZodOptional, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>; options: z.ZodOptional; repeat_last_n: z.ZodOptional; @@ -29,7 +29,7 @@ index 8dd9b498050dbecd8dd6b901acf1aa8ca38a49af..ed644349c9d38fe2a66b2fb44214f7c1 declare const ollamaCompletionProviderOptions: z.ZodObject<{ - think: z.ZodOptional; -+ think: z.ZodOptional]>>; ++ think: z.ZodOptional, z.ZodLiteral<"medium">, z.ZodLiteral<"high">]>>; user: z.ZodOptional; suffix: z.ZodOptional; echo: z.ZodOptional; @@ -42,7 +42,7 @@ index 35b5142ce8476ce2549ed7c2ec48e7d8c46c90d9..2ef64dc9a4c2be043e6af608241a6a83 // src/completion/ollama-completion-language-model.ts var ollamaCompletionProviderOptions = import_v42.z.object({ - think: import_v42.z.boolean().optional(), -+ think: import_v42.z.union([import_v42.z.boolean(), import_v42.z.enum(['low', 'medium', 'high'])]).optional(), ++ think: import_v42.z.union([import_v42.z.boolean(), import_v42.z.literal('low'), import_v42.z.literal('medium'), import_v42.z.literal('high')]).optional(), user: import_v42.z.string().optional(), suffix: import_v42.z.string().optional(), echo: import_v42.z.boolean().optional() @@ -64,7 +64,7 @@ index 35b5142ce8476ce2549ed7c2ec48e7d8c46c90d9..2ef64dc9a4c2be043e6af608241a6a83 * Only supported by certain models like DeepSeek R1 and Qwen 3. */ - think: import_v44.z.boolean().optional(), -+ think: import_v44.z.union([import_v44.z.boolean(), import_v44.z.enum(['low', 'medium', 'high'])]).optional(), ++ think: import_v44.z.union([import_v44.z.boolean(), import_v44.z.literal('low'), import_v44.z.literal('medium'), import_v44.z.literal('high')]).optional(), options: import_v44.z.object({ num_ctx: import_v44.z.number().optional(), repeat_last_n: import_v44.z.number().optional(), @@ -97,7 +97,7 @@ index e2a634a78d80ac9542f2cc4f96cf2291094b10cf..67b23efce3c1cf4f026693d3ff924698 // src/completion/ollama-completion-language-model.ts var ollamaCompletionProviderOptions = z2.object({ - think: z2.boolean().optional(), -+ think: z2.union([z2.boolean(), z2.enum(['low', 'medium', 'high'])]).optional(), ++ think: z2.union([z2.boolean(), z2.literal('low'), z2.literal('medium'), z2.literal('high')]).optional(), user: z2.string().optional(), suffix: z2.string().optional(), echo: z2.boolean().optional() @@ -119,7 +119,7 @@ index e2a634a78d80ac9542f2cc4f96cf2291094b10cf..67b23efce3c1cf4f026693d3ff924698 * Only supported by certain models like DeepSeek R1 and Qwen 3. */ - think: z4.boolean().optional(), -+ think: z4.union([z4.boolean(), z4.enum(['low', 'medium', 'high'])]).optional(), ++ think: z4.union([z4.boolean(), z4.literal('low'), z4.literal('medium'), z4.literal('high')]).optional(), options: z4.object({ num_ctx: z4.number().optional(), repeat_last_n: z4.number().optional(), diff --git a/src/renderer/src/aiCore/utils/options.ts b/src/renderer/src/aiCore/utils/options.ts index 36778b7570..8dc7a10af9 100644 --- a/src/renderer/src/aiCore/utils/options.ts +++ b/src/renderer/src/aiCore/utils/options.ts @@ -580,8 +580,10 @@ function buildOllamaProviderOptions( const reasoningEffort = assistant.settings?.reasoning_effort if (enableReasoning) { if (isOpenAIOpenWeightModel(model)) { - // @ts-ignore upstream type error - providerOptions.think = reasoningEffort as any + // For gpt-oss models, Ollama accepts: 'low' | 'medium' | 'high' + if (reasoningEffort === 'low' || reasoningEffort === 'medium' || reasoningEffort === 'high') { + providerOptions.think = reasoningEffort + } } else { providerOptions.think = !['none', undefined].includes(reasoningEffort) } diff --git a/src/renderer/src/config/models/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 6b00a8912b..56f9cd0b60 100644 --- a/src/renderer/src/config/models/__tests__/reasoning.test.ts +++ b/src/renderer/src/config/models/__tests__/reasoning.test.ts @@ -680,7 +680,12 @@ describe('getThinkModelType - Comprehensive Coverage', () => { expect(getThinkModelType(createModel({ id: 'o3' }))).toBe('o') expect(getThinkModelType(createModel({ id: 'o3-mini' }))).toBe('o') expect(getThinkModelType(createModel({ id: 'o4' }))).toBe('o') - expect(getThinkModelType(createModel({ id: 'gpt-oss-reasoning' }))).toBe('o') + }) + + it('should return gpt_oss for gpt-oss models', () => { + expect(getThinkModelType(createModel({ id: 'gpt-oss' }))).toBe('gpt_oss') + expect(getThinkModelType(createModel({ id: 'gpt-oss:20b' }))).toBe('gpt_oss') + expect(getThinkModelType(createModel({ id: 'gpt-oss-reasoning' }))).toBe('gpt_oss') }) }) @@ -1763,6 +1768,21 @@ describe('getModelSupportedReasoningEffortOptions', () => { 'medium', 'high' ]) + }) + + it('should return correct options for gpt-oss models', () => { + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-oss' }))).toEqual([ + 'default', + 'low', + 'medium', + 'high' + ]) + expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-oss:20b' }))).toEqual([ + 'default', + 'low', + 'medium', + 'high' + ]) expect(getModelSupportedReasoningEffortOptions(createModel({ id: 'gpt-oss-reasoning' }))).toEqual([ 'default', 'low', diff --git a/src/renderer/src/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 5d48e9a122..b2b6119b76 100644 --- a/src/renderer/src/config/models/reasoning.ts +++ b/src/renderer/src/config/models/reasoning.ts @@ -17,6 +17,7 @@ import { isGPT52ProModel, isGPT52SeriesModel, isOpenAIDeepResearchModel, + isOpenAIOpenWeightModel, isOpenAIReasoningModel, isSupportedReasoningEffortOpenAIModel } from './openai' @@ -41,6 +42,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT = { gpt5_2: ['none', 'low', 'medium', 'high', 'xhigh'] as const, gpt5pro: ['high'] as const, gpt52pro: ['medium', 'high', 'xhigh'] as const, + gpt_oss: ['low', 'medium', 'high'] as const, grok: ['low', 'high'] as const, grok4_fast: ['auto'] as const, gemini2_flash: ['low', 'medium', 'high', 'auto'] as const, @@ -72,6 +74,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = { gpt5_2: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt5_2] as const, gpt5_1_codex_max: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt5_1_codex_max] as const, gpt52pro: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt52pro] as const, + gpt_oss: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.gpt_oss] as const, grok: ['default', ...MODEL_SUPPORTED_REASONING_EFFORT.grok] as const, grok4_fast: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.grok4_fast] as const, gemini2_flash: ['default', 'none', ...MODEL_SUPPORTED_REASONING_EFFORT.gemini2_flash] as const, @@ -127,6 +130,8 @@ const _getThinkModelType = (model: Model): ThinkingModelType => { thinkingModelType = 'gpt5pro' } } + } else if (isOpenAIOpenWeightModel(model)) { + thinkingModelType = 'gpt_oss' } else if (isSupportedReasoningEffortOpenAIModel(model)) { thinkingModelType = 'o' } else if (isGrok4FastReasoningModel(model)) { diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index a75fc1ed3e..b87271930b 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -92,6 +92,7 @@ const ThinkModelTypes = [ 'gpt5_2', 'gpt5pro', 'gpt52pro', + 'gpt_oss', 'grok', 'grok4_fast', 'gemini2_flash', diff --git a/yarn.lock b/yarn.lock index b6b87c568a..3fe6d2c151 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20209,13 +20209,13 @@ __metadata: "ollama-ai-provider-v2@patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch": version: 1.5.5 - resolution: "ollama-ai-provider-v2@patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch::version=1.5.5&hash=16c016" + resolution: "ollama-ai-provider-v2@patch:ollama-ai-provider-v2@npm%3A1.5.5#~/.yarn/patches/ollama-ai-provider-v2-npm-1.5.5-8bef249af9.patch::version=1.5.5&hash=0aef28" dependencies: "@ai-sdk/provider": "npm:^2.0.0" "@ai-sdk/provider-utils": "npm:^3.0.17" peerDependencies: zod: ^4.0.16 - checksum: 10c0/aa6bd3415d08f7bbd1a3051f45b1cd3a8fa8bb01413e98de45e8888f64e6b12bca6e340453a3e82e4193ca5354397f524c6c0f7b3e9996d70f53c81374c69180 + checksum: 10c0/32ca1f543ee791ac96061a5f6d8899c00644eeb774b3b951ca1e3e3810b60753acacf8229b2c1ba099b25a01732c54e51e0df44d11f4d90ae201f483d41aa149 languageName: node linkType: hard From cb93eee29d6a90f994687b2734bd9414d8b296e8 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 17:38:37 +0800 Subject: [PATCH 045/116] chore: mark multiple services and components as 'will deprecated' for v2 refactor - Added deprecation notices to various services and components, indicating they are scheduled for removal in v2.0.0. - Noted that feature PRs affecting these files are currently blocked, and only critical bug fixes will be accepted during the migration phase. - Provided context and status links for ongoing v2 refactoring efforts. This change is part of the preparation for the upcoming major version update. --- src/main/services/BackupManager.ts | 16 ++++++++++++++++ src/main/services/CacheService.ts | 16 ++++++++++++++++ src/main/services/ConfigManager.ts | 16 ++++++++++++++++ src/main/services/ReduxService.ts | 16 ++++++++++++++++ src/main/services/ShortcutService.ts | 16 ++++++++++++++++ src/main/services/StoreSyncService.ts | 16 ++++++++++++++++ .../services/agents/database/DatabaseManager.ts | 16 ++++++++++++++++ src/main/services/agents/drizzle.config.ts | 16 ++++++++++++++++ src/renderer/src/databases/index.ts | 16 ++++++++++++++++ src/renderer/src/databases/upgrades.ts | 16 ++++++++++++++++ src/renderer/src/hooks/useSettings.ts | 16 ++++++++++++++++ src/renderer/src/hooks/useShortcuts.ts | 16 ++++++++++++++++ src/renderer/src/hooks/useStore.ts | 16 ++++++++++++++++ .../src/services/db/AgentMessageDataSource.ts | 16 ++++++++++++++++ src/renderer/src/services/db/DbService.ts | 16 ++++++++++++++++ .../src/services/db/DexieMessageDataSource.ts | 16 ++++++++++++++++ src/renderer/src/services/db/index.ts | 16 ++++++++++++++++ src/renderer/src/store/assistants.ts | 16 ++++++++++++++++ src/renderer/src/store/backup.ts | 16 ++++++++++++++++ src/renderer/src/store/codeTools.ts | 16 ++++++++++++++++ src/renderer/src/store/copilot.ts | 16 ++++++++++++++++ src/renderer/src/store/index.ts | 16 ++++++++++++++++ src/renderer/src/store/inputTools.ts | 16 ++++++++++++++++ src/renderer/src/store/knowledge.ts | 16 ++++++++++++++++ src/renderer/src/store/llm.ts | 16 ++++++++++++++++ src/renderer/src/store/mcp.ts | 16 ++++++++++++++++ src/renderer/src/store/memory.ts | 16 ++++++++++++++++ src/renderer/src/store/messageBlock.ts | 16 ++++++++++++++++ src/renderer/src/store/migrate.ts | 16 ++++++++++++++++ src/renderer/src/store/minapps.ts | 16 ++++++++++++++++ src/renderer/src/store/newMessage.ts | 16 ++++++++++++++++ src/renderer/src/store/note.ts | 16 ++++++++++++++++ src/renderer/src/store/nutstore.ts | 16 ++++++++++++++++ src/renderer/src/store/ocr.ts | 16 ++++++++++++++++ src/renderer/src/store/paintings.ts | 16 ++++++++++++++++ src/renderer/src/store/preprocess.ts | 16 ++++++++++++++++ src/renderer/src/store/runtime.ts | 16 ++++++++++++++++ src/renderer/src/store/selectionStore.ts | 16 ++++++++++++++++ src/renderer/src/store/settings.ts | 16 ++++++++++++++++ src/renderer/src/store/shortcuts.ts | 16 ++++++++++++++++ src/renderer/src/store/tabs.ts | 16 ++++++++++++++++ src/renderer/src/store/thunk/knowledgeThunk.ts | 16 ++++++++++++++++ src/renderer/src/store/thunk/messageThunk.ts | 16 ++++++++++++++++ src/renderer/src/store/thunk/messageThunk.v2.ts | 16 ++++++++++++++++ src/renderer/src/store/toolPermissions.ts | 16 ++++++++++++++++ src/renderer/src/store/translate.ts | 16 ++++++++++++++++ src/renderer/src/store/websearch.ts | 16 ++++++++++++++++ 47 files changed, 752 insertions(+) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index 46b78ed5a9..e08bbd4d7b 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' import type { WebDavConfig } from '@types' diff --git a/src/main/services/CacheService.ts b/src/main/services/CacheService.ts index d2984a9984..4f2e2f8b20 100644 --- a/src/main/services/CacheService.ts +++ b/src/main/services/CacheService.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ interface CacheItem { data: T timestamp: number diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 6f2bbd44a4..98537c85a1 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { UpgradeChannel } from '@shared/config/constant' import { defaultLanguage, ZOOM_SHORTCUTS } from '@shared/config/constant' import type { LanguageVarious, Shortcut } from '@types' diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts index cdbaff42bf..8880691a24 100644 --- a/src/main/services/ReduxService.ts +++ b/src/main/services/ReduxService.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { IpcChannel } from '@shared/IpcChannel' import { ipcMain } from 'electron' diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index a84d8ac248..c99e0b5dc0 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { handleZoomFactor } from '@main/utils/zoom' import type { Shortcut } from '@types' diff --git a/src/main/services/StoreSyncService.ts b/src/main/services/StoreSyncService.ts index 57f07195b6..6013afdd57 100644 --- a/src/main/services/StoreSyncService.ts +++ b/src/main/services/StoreSyncService.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { IpcChannel } from '@shared/IpcChannel' import type { StoreSyncAction } from '@types' import { BrowserWindow, ipcMain } from 'electron' diff --git a/src/main/services/agents/database/DatabaseManager.ts b/src/main/services/agents/database/DatabaseManager.ts index f4b13971c7..913f9e4a66 100644 --- a/src/main/services/agents/database/DatabaseManager.ts +++ b/src/main/services/agents/database/DatabaseManager.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { type Client, createClient } from '@libsql/client' import { loggerService } from '@logger' import type { LibSQLDatabase } from 'drizzle-orm/libsql' diff --git a/src/main/services/agents/drizzle.config.ts b/src/main/services/agents/drizzle.config.ts index e12518c069..7278883c11 100644 --- a/src/main/services/agents/drizzle.config.ts +++ b/src/main/services/agents/drizzle.config.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ /** * Drizzle Kit configuration for agents database */ diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index fc47e37cb7..f70b81673f 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { CustomTranslateLanguage, FileMetadata, diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index 8f952e245b..83e77e7c42 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { LanguagesEnum } from '@renderer/config/translate' import type { LegacyMessage as OldMessage, Topic, TranslateLanguageCode } from '@renderer/types' diff --git a/src/renderer/src/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 55d40e435d..3a3de7f89a 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import store, { useAppDispatch, useAppSelector } from '@renderer/store' import type { AssistantIconType, SendMessageShortcut, SettingsState } from '@renderer/store/settings' import { diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ef92a5f970..ea1c0cab67 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { isMac, isWin } from '@renderer/config/constant' import { useAppSelector } from '@renderer/store' import { orderBy } from 'lodash' diff --git a/src/renderer/src/hooks/useStore.ts b/src/renderer/src/hooks/useStore.ts index bb77e1f0da..55720b60e6 100644 --- a/src/renderer/src/hooks/useStore.ts +++ b/src/renderer/src/hooks/useStore.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { diff --git a/src/renderer/src/services/db/AgentMessageDataSource.ts b/src/renderer/src/services/db/AgentMessageDataSource.ts index 4ba93d2cd5..7af7a257f8 100644 --- a/src/renderer/src/services/db/AgentMessageDataSource.ts +++ b/src/renderer/src/services/db/AgentMessageDataSource.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import store from '@renderer/store' import type { AgentPersistedMessage } from '@renderer/types/agent' diff --git a/src/renderer/src/services/db/DbService.ts b/src/renderer/src/services/db/DbService.ts index cff7cb1a45..64ff945958 100644 --- a/src/renderer/src/services/db/DbService.ts +++ b/src/renderer/src/services/db/DbService.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import store from '@renderer/store' import type { Message, MessageBlock } from '@renderer/types/newMessage' diff --git a/src/renderer/src/services/db/DexieMessageDataSource.ts b/src/renderer/src/services/db/DexieMessageDataSource.ts index cbc015984a..f8bad4476f 100644 --- a/src/renderer/src/services/db/DexieMessageDataSource.ts +++ b/src/renderer/src/services/db/DexieMessageDataSource.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import db from '@renderer/databases' import FileManager from '@renderer/services/FileManager' diff --git a/src/renderer/src/services/db/index.ts b/src/renderer/src/services/db/index.ts index 9b681dc6c6..a29eeb6c04 100644 --- a/src/renderer/src/services/db/index.ts +++ b/src/renderer/src/services/db/index.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ /** * Unified data access layer for messages * Provides a consistent API for accessing messages from different sources diff --git a/src/renderer/src/store/assistants.ts b/src/renderer/src/store/assistants.ts index 51638be9f6..aaac1810ab 100644 --- a/src/renderer/src/store/assistants.ts +++ b/src/renderer/src/store/assistants.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ // @ts-nocheck import type { PayloadAction } from '@reduxjs/toolkit' import { createSelector, createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index fbb3853a12..d2986b11bf 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/codeTools.ts b/src/renderer/src/store/codeTools.ts index 44070a76e4..dc3889abb1 100644 --- a/src/renderer/src/store/codeTools.ts +++ b/src/renderer/src/store/codeTools.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { Model } from '@renderer/types' diff --git a/src/renderer/src/store/copilot.ts b/src/renderer/src/store/copilot.ts index ab7e50ee84..88f9523e65 100644 --- a/src/renderer/src/store/copilot.ts +++ b/src/renderer/src/store/copilot.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8d8c793c21..e962a52431 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { combineReducers, configureStore } from '@reduxjs/toolkit' import { useDispatch, useSelector, useStore } from 'react-redux' diff --git a/src/renderer/src/store/inputTools.ts b/src/renderer/src/store/inputTools.ts index aad87dba9f..b9d2506523 100644 --- a/src/renderer/src/store/inputTools.ts +++ b/src/renderer/src/store/inputTools.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { InputbarScope } from '@renderer/pages/home/Inputbar/types' diff --git a/src/renderer/src/store/knowledge.ts b/src/renderer/src/store/knowledge.ts index 6280a99e8d..a6a5026952 100644 --- a/src/renderer/src/store/knowledge.ts +++ b/src/renderer/src/store/knowledge.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index 15f256382e..7e53f081bd 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { isLocalAi } from '@renderer/config/env' diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 5b8d5bcdcf..3b94248401 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { createSlice, nanoid, type PayloadAction } from '@reduxjs/toolkit' import { type BuiltinMCPServer, BuiltinMCPServerNames, type MCPConfig, type MCPServer } from '@renderer/types' diff --git a/src/renderer/src/store/memory.ts b/src/renderer/src/store/memory.ts index e28b291c19..c4976d874a 100644 --- a/src/renderer/src/store/memory.ts +++ b/src/renderer/src/store/memory.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { factExtractionPrompt, updateMemorySystemPrompt } from '@renderer/utils/memory-prompts' import type { MemoryConfig } from '@types' diff --git a/src/renderer/src/store/messageBlock.ts b/src/renderer/src/store/messageBlock.ts index ba0e11be0a..c2719cdb13 100644 --- a/src/renderer/src/store/messageBlock.ts +++ b/src/renderer/src/store/messageBlock.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources' import type OpenAI from '@cherrystudio/openai' import type { GroundingMetadata } from '@google/genai' diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index e0d3524f68..fdb6b0db14 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' import { diff --git a/src/renderer/src/store/minapps.ts b/src/renderer/src/store/minapps.ts index 8ca59a5bd2..ac2a83440b 100644 --- a/src/renderer/src/store/minapps.ts +++ b/src/renderer/src/store/minapps.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { DEFAULT_MIN_APPS } from '@renderer/config/minapps' diff --git a/src/renderer/src/store/newMessage.ts b/src/renderer/src/store/newMessage.ts index cd8c0dde83..918ae3dc5b 100644 --- a/src/renderer/src/store/newMessage.ts +++ b/src/renderer/src/store/newMessage.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import type { EntityState, PayloadAction } from '@reduxjs/toolkit' import { createEntityAdapter, createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/note.ts b/src/renderer/src/store/note.ts index 25347a8764..d571552831 100644 --- a/src/renderer/src/store/note.ts +++ b/src/renderer/src/store/note.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { RootState } from '@renderer/store/index' diff --git a/src/renderer/src/store/nutstore.ts b/src/renderer/src/store/nutstore.ts index d494ec269f..bb9d426d8e 100644 --- a/src/renderer/src/store/nutstore.ts +++ b/src/renderer/src/store/nutstore.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/ocr.ts b/src/renderer/src/store/ocr.ts index 8e997bd6d5..29ee4085b7 100644 --- a/src/renderer/src/store/ocr.ts +++ b/src/renderer/src/store/ocr.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { BUILTIN_OCR_PROVIDERS, DEFAULT_OCR_PROVIDER } from '@renderer/config/ocr' diff --git a/src/renderer/src/store/paintings.ts b/src/renderer/src/store/paintings.ts index e5fc6f59e2..a7b509f531 100644 --- a/src/renderer/src/store/paintings.ts +++ b/src/renderer/src/store/paintings.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/preprocess.ts b/src/renderer/src/store/preprocess.ts index 29fc2993b7..8fee31b0ef 100644 --- a/src/renderer/src/store/preprocess.ts +++ b/src/renderer/src/store/preprocess.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { PreprocessProvider } from '@renderer/types' diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 2ee7719469..66fd161dcd 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' diff --git a/src/renderer/src/store/selectionStore.ts b/src/renderer/src/store/selectionStore.ts index fe63ae230e..89f44dd07a 100644 --- a/src/renderer/src/store/selectionStore.ts +++ b/src/renderer/src/store/selectionStore.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { ActionItem, FilterMode, SelectionState, TriggerMode } from '@renderer/types/selectionTypes' diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 572f722746..3ba3cc4da8 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { DEFAULT_STREAM_OPTIONS_INCLUDE_USAGE, isMac } from '@renderer/config/constant' diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index 9b4cc1341a..c8fabf8b04 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import type { Shortcut } from '@renderer/types' diff --git a/src/renderer/src/store/tabs.ts b/src/renderer/src/store/tabs.ts index 87d7342779..c539cf20a0 100644 --- a/src/renderer/src/store/tabs.ts +++ b/src/renderer/src/store/tabs.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/thunk/knowledgeThunk.ts b/src/renderer/src/store/thunk/knowledgeThunk.ts index 97c435d169..b353e0af51 100644 --- a/src/renderer/src/store/thunk/knowledgeThunk.ts +++ b/src/renderer/src/store/thunk/knowledgeThunk.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { db } from '@renderer/databases' import { addFiles as addFilesAction, addItem, updateNotes } from '@renderer/store/knowledge' diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 8219fa0cce..45d7fd760a 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import { loggerService } from '@logger' import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter' import { AgentApiClient } from '@renderer/api/agent' diff --git a/src/renderer/src/store/thunk/messageThunk.v2.ts b/src/renderer/src/store/thunk/messageThunk.v2.ts index ec0aed947b..587a9baf68 100644 --- a/src/renderer/src/store/thunk/messageThunk.v2.ts +++ b/src/renderer/src/store/thunk/messageThunk.v2.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ /** * V2 implementations of message thunk functions using the unified DbService * These implementations will be gradually rolled out using feature flags diff --git a/src/renderer/src/store/toolPermissions.ts b/src/renderer/src/store/toolPermissions.ts index cd31b16af8..a283956daa 100644 --- a/src/renderer/src/store/toolPermissions.ts +++ b/src/renderer/src/store/toolPermissions.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/translate.ts b/src/renderer/src/store/translate.ts index 0e4c56e731..752a067739 100644 --- a/src/renderer/src/store/translate.ts +++ b/src/renderer/src/store/translate.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' diff --git a/src/renderer/src/store/websearch.ts b/src/renderer/src/store/websearch.ts index f166bb1949..a43db4947b 100644 --- a/src/renderer/src/store/websearch.ts +++ b/src/renderer/src/store/websearch.ts @@ -1,3 +1,19 @@ +/** + * @deprecated Scheduled for removal in v2.0.0 + * -------------------------------------------------------------------------- + * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) + * -------------------------------------------------------------------------- + * STOP: Feature PRs affecting this file are currently BLOCKED. + * Only critical bug fixes are accepted during this migration phase. + * + * This file is being refactored to v2 standards. + * Any non-critical changes will conflict with the ongoing work. + * + * 🔗 Context & Status: + * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 + * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 + * -------------------------------------------------------------------------- + */ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { WEB_SEARCH_PROVIDERS } from '@renderer/config/webSearchProviders' From c242860abcfefcf61d397a0350a14998b757e55e Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 28 Dec 2025 17:39:57 +0800 Subject: [PATCH 046/116] chore(release): v1.7.8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- electron-builder.yml | 78 ++++++++++++++------------------------------ package.json | 2 +- 2 files changed, 25 insertions(+), 55 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 11dce735c5..8af4642f05 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,68 +134,38 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.7 - New Models & UI Improvements + Cherry Studio 1.7.8 - Bug Fixes & Performance Improvements - This release adds new AI model support, OpenRouter integration, and UI redesigns. + This release focuses on bug fixes and performance optimizations. - ✨ New Features - - [Models] Add GLM-4.7 and MiniMax-M2.1 model support - - [Provider] Add OpenRouter provider support - - [OVMS] Upgrade to 2025.4 with Qwen3-4B-int4-ov preset model - - [OVMS] Close OVMS process when app quits - - [Search] Show keyword-adjacent snippets in history search - - [Painting] Add extend_params support for DMX painting - - [UI] Add MCP logo and replace Hammer icon - - 🎨 UI Improvements - - [Notes] Move notes settings to popup in NotesPage for quick access - - [WebSearch] Redesign settings with two-column layout and "Set as Default" button - - [Display] Improve font selector for long font names - - [Transfer] Rename LanDrop to LanTransfer + ⚡ Performance + - [ModelList] Improve model list loading performance 🐛 Bug Fixes - - [API] Correct aihubmix Anthropic API path - - [OpenRouter] Support GPT-5.1/5.2 reasoning effort 'none' and improve error handling - - [Thinking] Fix interleaved thinking support - - [Memory] Fix retrieval issues and enable database backup - - [Settings] Update default assistant settings to disable temperature - - [OpenAI] Add persistent server configuration support - - [Azure] Normalize Azure endpoint - - [MCP] Check system npx/uvx before falling back to bundled binaries - - [Prompt] Improve language instruction clarity - - [Models] Include GPT5.2 series in verbosity check - - [URL] Enhance urlContext validation for supported providers and models + - [Ollama] Fix new users unable to use Ollama models + - [Ollama] Improve reasoningEffort handling + - [Assistants] Prevent deleting last assistant and add error message + - [Shortcut] Fix shortcut icons sorting disorder + - [Memory] Fix global memory settings submit failure + - [Windows] Fix remember size not working for SelectionAction window + - [Anthropic] Fix API base URL handling + - [Files] Allow more file extensions - Cherry Studio 1.7.7 - 新模型与界面改进 + Cherry Studio 1.7.8 - 问题修复与性能优化 - 本次更新添加了新 AI 模型支持、OpenRouter 集成以及界面重新设计。 + 本次更新专注于问题修复和性能优化。 - ✨ 新功能 - - [模型] 添加 GLM-4.7 和 MiniMax-M2.1 模型支持 - - [服务商] 添加 OpenRouter 服务商支持 - - [OVMS] 升级至 2025.4,新增 Qwen3-4B-int4-ov 预设模型 - - [OVMS] 应用退出时关闭 OVMS 进程 - - [搜索] 历史搜索显示关键词上下文片段 - - [绘图] DMX 绘图添加扩展参数支持 - - [界面] 添加 MCP 图标并替换锤子图标 - - 🎨 界面改进 - - [笔记] 将笔记设置移至笔记页弹窗,快速访问无需离开当前页面 - - [网页搜索] 采用两栏布局重新设计设置界面,添加"设为默认"按钮 - - [显示] 改进长字体名称的字体选择器 - - [传输] LanDrop 重命名为 LanTransfer + ⚡ 性能优化 + - [模型列表] 提升模型列表加载性能 🐛 问题修复 - - [API] 修复 aihubmix Anthropic API 路径 - - [OpenRouter] 支持 GPT-5.1/5.2 reasoning effort 'none' 并改进错误处理 - - [思考] 修复交错思考支持 - - [记忆] 修复检索问题并启用数据库备份 - - [设置] 更新默认助手设置禁用温度 - - [OpenAI] 添加持久化服务器配置支持 - - [Azure] 规范化 Azure 端点 - - [MCP] 优先检查系统 npx/uvx 再回退到内置二进制文件 - - [提示词] 改进语言指令清晰度 - - [模型] GPT5.2 系列添加到 verbosity 检查 - - [URL] 增强 urlContext 对支持的服务商和模型的验证 + - [Ollama] 修复新用户无法使用 Ollama 模型的问题 + - [Ollama] 改进推理参数处理 + - [助手] 防止删除最后一个助手并添加错误提示 + - [快捷方式] 修复快捷方式图标排序混乱 + - [记忆] 修复全局记忆设置提交失败 + - [窗口] 修复 SelectionAction 窗口记住尺寸不生效 + - [Anthropic] 修复 API 地址处理 + - [文件] 允许更多文件扩展名 diff --git a/package.json b/package.json index 2c3c05daf2..250abf1b9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.7", + "version": "1.7.8", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", From 5061472850dcf3a9f5f49d4e678b3b5b6a429f7b Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 18:59:56 +0800 Subject: [PATCH 047/116] fix: remove heroui - Consolidated imports from '@cherrystudio/ui' for better readability. - Replaced RadioGroup with Radio component from 'antd' for consistency in the SelectionAssistantSettings file. - Adjusted event handling for trigger and filter modes to align with the new Radio component structure. --- .../AssistantPromptSettings.tsx | 15 +++++++++++---- .../SelectionAssistantSettings.tsx | 19 ++++++------------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx index 424e852512..9f0c7d33e7 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx @@ -1,11 +1,18 @@ import 'emoji-picker-element' import CloseCircleFilled from '@ant-design/icons/lib/icons/CloseCircleFilled' -import { Box, RowFlex, SpaceBetweenRowFlex } from '@cherrystudio/ui' -import { CodeEditor } from '@cherrystudio/ui' -import { Button } from '@cherrystudio/ui' +import { + Box, + Button, + CodeEditor, + Popover, + PopoverContent, + PopoverTrigger, + RowFlex, + SpaceBetweenRowFlex, + Tooltip +} from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' -import { Popover, PopoverContent, PopoverTrigger, Tooltip } from '@heroui/react' import EmojiPicker from '@renderer/components/EmojiPicker' import type { RichEditorRef } from '@renderer/components/RichEditor/types' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx index 55bdb2ed0e..ce9e208920 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -1,12 +1,11 @@ import { Button, Switch, Tooltip } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' -import { Radio, RadioGroup } from '@heroui/react' import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' import { getSelectionDescriptionLabel } from '@renderer/i18n/label' import SelectionToolbar from '@renderer/windows/selection/toolbar/SelectionToolbar' import type { SelectionFilterMode, SelectionTriggerMode } from '@shared/data/preference/preferenceTypes' -import { Row, Slider } from 'antd' +import { Radio, Row, Slider } from 'antd' import { CircleHelp, Edit2 } from 'lucide-react' import type { FC } from 'react' import { useEffect, useState } from 'react' @@ -129,11 +128,7 @@ const SelectionAssistantSettings: FC = () => { {t('selection.settings.toolbar.trigger_mode.description')} - setTriggerMode(value as SelectionTriggerMode)}> + setTriggerMode(e.target.value as SelectionTriggerMode)}> {t('selection.settings.toolbar.trigger_mode.selected')} @@ -154,7 +149,7 @@ const SelectionAssistantSettings: FC = () => { }> {t('selection.settings.toolbar.trigger_mode.shortcut')} - + @@ -230,15 +225,13 @@ const SelectionAssistantSettings: FC = () => { {t('selection.settings.advanced.filter_mode.title')} {t('selection.settings.advanced.filter_mode.description')} - setFilterMode(value as SelectionFilterMode)}> + onChange={(e) => setFilterMode(e.target.value as SelectionFilterMode)}> {t('selection.settings.advanced.filter_mode.default')} {t('selection.settings.advanced.filter_mode.whitelist')} {t('selection.settings.advanced.filter_mode.blacklist')} - + {filterMode && filterMode !== 'default' && ( From cccf9bb7be98469052e076ea00a6a1f596adb059 Mon Sep 17 00:00:00 2001 From: tylinux Date: Sun, 28 Dec 2025 19:11:08 +0800 Subject: [PATCH 048/116] feat: add latest zhipu models (#12169) --- .../legacy/clients/zhipu/ZhipuAPIClient.ts | 5 +++++ src/renderer/src/config/models/default.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts index ea6c141e31..9c590996f1 100644 --- a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts @@ -66,6 +66,11 @@ export class ZhipuAPIClient extends OpenAIAPIClient { public async listModels(): Promise { const models = [ + 'glm-4.7', + 'glm-4.6', + 'glm-4.6v', + 'glm-4.6v-flash', + 'glm-4.6v-flashx', 'glm-4.5', 'glm-4.5-x', 'glm-4.5-air', diff --git a/src/renderer/src/config/models/default.ts b/src/renderer/src/config/models/default.ts index f87293798d..1223d0c92c 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -617,6 +617,24 @@ export const SYSTEM_MODELS: Record = name: 'GLM-4.6', group: 'GLM-4.6' }, + { + id: 'glm-4.6v', + provider: 'zhipu', + name: 'GLM-4.6V', + group: 'GLM-4.6V' + }, + { + id: 'glm-4.6v-flash', + provider: 'zhipu', + name: 'GLM-4.6V-Flash', + group: 'GLM-4.6V' + }, + { + id: 'glm-4.6v-flashx', + provider: 'zhipu', + name: 'GLM-4.6V-FlashX', + group: 'GLM-4.6V' + }, { id: 'glm-4.7', provider: 'zhipu', From 942e014d9224ee1d02e8c7f4b4f2ec457cad16a5 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 21:24:00 +0800 Subject: [PATCH 049/116] fix(api): enhance message status handling - Updated MessageStatus type to include 'pending' as a new state, reflecting the message processing lifecycle. - Modified CreateMessageDto and UpdateMessageDto interfaces to utilize the updated MessageStatus type for improved clarity and consistency in message status management. --- packages/shared/data/api/schemas/messages.ts | 5 +++-- packages/shared/data/types/message.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index 4b144d87ae..6cc0417fa6 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -11,6 +11,7 @@ import type { MessageData, MessageRole, MessageStats, + MessageStatus, TreeResponse } from '@shared/data/types/message' import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' @@ -30,7 +31,7 @@ export interface CreateMessageDto { /** Message content */ data: MessageData /** Message status */ - status?: 'success' | 'error' | 'paused' + status?: MessageStatus /** Siblings group ID (0 = normal, >0 = multi-model group) */ siblingsGroupId?: number /** Assistant ID */ @@ -58,7 +59,7 @@ export interface UpdateMessageDto { /** Change siblings group */ siblingsGroupId?: number /** Update status */ - status?: 'success' | 'error' | 'paused' + status?: MessageStatus } /** diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts index ab121d6346..be0bc3cb7e 100644 --- a/packages/shared/data/types/message.ts +++ b/packages/shared/data/types/message.ts @@ -182,9 +182,13 @@ import type { AssistantMeta, ModelMeta } from './meta' export type MessageRole = 'user' | 'assistant' | 'system' /** - * Message status - final state after processing + * Message status + * - pending: Placeholder created, streaming in progress + * - success: Completed successfully + * - error: Failed with error + * - paused: User stopped generation */ -export type MessageStatus = 'success' | 'error' | 'paused' +export type MessageStatus = 'pending' | 'success' | 'error' | 'paused' /** * Complete message entity as stored in database From 425f81a882df2c973b5fa676830bb4291d98da97 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 28 Dec 2025 21:34:41 +0800 Subject: [PATCH 050/116] fix(MessageService): update default message status to 'pending' - Changed the default status for messages from 'success' to 'pending' to better reflect the message processing state. This aligns with recent updates to the MessageStatus type. --- src/main/data/services/MessageService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 449631f3d8..919a5b1df0 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -383,7 +383,7 @@ export class MessageService { parentId: dto.parentId, role: dto.role, data: dto.data, - status: dto.status || 'success', + status: dto.status ?? 'pending', siblingsGroupId: dto.siblingsGroupId ?? 0, assistantId: dto.assistantId, assistantMeta: dto.assistantMeta, From 9c4793771410c8b89146b7362f317abb37820b27 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 00:42:55 +0800 Subject: [PATCH 051/116] feat(api): enhance message and topic schemas with new features - Added `setAsActive` property to `CreateMessageDto` for controlling active node status in topics. - Updated `messageTable` and `topicTable` schemas to include foreign key constraints and improved handling of active node references. - Refactored message and topic service methods to utilize `.returning()` for better data retrieval after inserts and updates. - Implemented hard delete functionality for messages and topics, replacing soft delete logic to ensure data integrity. --- packages/shared/data/api/schemas/messages.ts | 2 + src/main/data/README.md | 1 + src/main/data/db/README.md | 29 ++++ src/main/data/db/schemas/columnHelpers.ts | 9 ++ src/main/data/db/schemas/message.ts | 7 +- src/main/data/db/schemas/topic.ts | 5 +- src/main/data/services/MessageService.ts | 108 +++++--------- src/main/data/services/TopicService.ts | 149 +++++++++---------- 8 files changed, 157 insertions(+), 153 deletions(-) diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index 6cc0417fa6..f659491f93 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -46,6 +46,8 @@ export interface CreateMessageDto { traceId?: string /** Statistics */ stats?: MessageStats + /** Set this message as the active node in the topic (default: true) */ + setAsActive?: boolean } /** diff --git a/src/main/data/README.md b/src/main/data/README.md index a8c49346a4..dde7f8188e 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -220,6 +220,7 @@ export class SimpleService extends BaseService { - Table definitions using Drizzle ORM - Follow naming convention: `{entity}Table` exports - Use `crudTimestamps` helper for timestamp fields +- See [db/README.md](./db/README.md#field-generation-rules) for detailed field generation rules and `.returning()` pattern ### Current Tables - `preference`: User configuration storage diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 720348e666..ab772615dc 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -103,3 +103,32 @@ Generate migrations after schema changes: ```bash yarn db:migrations:generate ``` + +## Field Generation Rules + +The schema uses Drizzle's auto-generation features. Follow these rules: + +### Auto-generated fields (NEVER set manually) + +- `id`: Uses `$defaultFn()` with UUID v4/v7, auto-generated on insert +- `createdAt`: Uses `$defaultFn()` with `Date.now()`, auto-generated on insert +- `updatedAt`: Uses `$defaultFn()` and `$onUpdateFn()`, auto-updated on every update + +### Using `.returning()` pattern + +Always use `.returning()` to get inserted/updated data instead of re-querying: + +```typescript +// Good: Use returning() +const [row] = await db.insert(table).values(data).returning() +return rowToEntity(row) + +// Avoid: Re-query after insert (unnecessary database round-trip) +await db.insert(table).values({ id, ...data }) +return this.getById(id) +``` + +### Soft delete support + +The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`). +Business logic can choose to use soft delete or hard delete based on requirements. diff --git a/src/main/data/db/schemas/columnHelpers.ts b/src/main/data/db/schemas/columnHelpers.ts index c5ea83804c..61a596602d 100644 --- a/src/main/data/db/schemas/columnHelpers.ts +++ b/src/main/data/db/schemas/columnHelpers.ts @@ -1,3 +1,12 @@ +/** + * Column helper utilities for Drizzle schemas + * + * USAGE RULES: + * - DO NOT manually set id, createdAt, or updatedAt - they are auto-generated + * - Use .returning() to get inserted/updated rows instead of re-querying + * - See db/README.md for detailed field generation rules + */ + import { integer, text } from 'drizzle-orm/sqlite-core' import { v4 as uuidv4, v7 as uuidv7 } from 'uuid' diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index 08545b9f9b..bbb07277e7 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -1,7 +1,7 @@ import type { MessageData, MessageStats } from '@shared/data/types/message' import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' import { sql } from 'drizzle-orm' -import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { check, foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } from './columnHelpers' import { topicTable } from './topic' @@ -18,8 +18,7 @@ export const messageTable = sqliteTable( { id: uuidPrimaryKeyOrdered(), // Adjacency list parent reference for tree structure - // SET NULL: preserve child messages when parent is deleted - parentId: text().references(() => messageTable.id, { onDelete: 'set null' }), + parentId: text(), // FK to topic - CASCADE: delete messages when topic is deleted topicId: text() .notNull() @@ -53,6 +52,8 @@ export const messageTable = sqliteTable( ...createUpdateDeleteTimestamps }, (t) => [ + // Foreign keys + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null'), // Indexes index('message_parent_id_idx').on(t.parentId), index('message_topic_created_idx').on(t.topicId, t.createdAt), diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts index b121c5405d..3f19ae7a7f 100644 --- a/src/main/data/db/schemas/topic.ts +++ b/src/main/data/db/schemas/topic.ts @@ -3,7 +3,7 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers' import { groupTable } from './group' -import { messageTable } from './message' +// import { messageTable } from './message' /** * Topic table - stores conversation topics/threads @@ -26,7 +26,8 @@ export const topicTable = sqliteTable( prompt: text(), // Active node ID in the message tree // SET NULL: reset to null when the referenced message is deleted - activeNodeId: text().references(() => messageTable.id, { onDelete: 'set null' }), + activeNodeId: text(), + // .references(() => messageTable.id, { onDelete: 'set null' }), // FK to group table for organization // SET NULL: preserve topic when group is deleted diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 919a5b1df0..4093aad802 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -22,8 +22,7 @@ import type { TreeNode, TreeResponse } from '@shared/data/types/message' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' -import { v7 as uuidv7 } from 'uuid' +import { eq, inArray, sql } from 'drizzle-orm' const logger = loggerService.withContext('MessageService') @@ -126,10 +125,7 @@ export class MessageService { const activeNodeId = options.nodeId || topic.activeNodeId // Get all messages for this topic - const allMessages = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) + const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId)) if (allMessages.length === 0) { return { nodes: [], siblingsGroups: [], activeNodeId: null } @@ -249,10 +245,7 @@ export class MessageService { } // Get all messages for this topic - const allMessages = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) + const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId)) if (allMessages.length === 0) { return { messages: [], activeNodeId: null } @@ -335,11 +328,7 @@ export class MessageService { async getById(id: string): Promise { const db = dbService.getDb() - const [row] = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.id, id), isNull(messageTable.deletedAt))) - .limit(1) + const [row] = await db.select().from(messageTable).where(eq(messageTable.id, id)).limit(1) if (!row) { throw DataApiErrorFactory.notFound('Message', id) @@ -363,41 +352,39 @@ export class MessageService { // Verify parent exists if specified if (dto.parentId) { - const [parent] = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt))) - .limit(1) + const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) if (!parent) { throw DataApiErrorFactory.notFound('Message', dto.parentId) } } - const now = Date.now() - const id = uuidv7() + const [row] = await db + .insert(messageTable) + .values({ + topicId, + parentId: dto.parentId, + role: dto.role, + data: dto.data, + status: dto.status ?? 'pending', + siblingsGroupId: dto.siblingsGroupId ?? 0, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + modelId: dto.modelId, + modelMeta: dto.modelMeta, + traceId: dto.traceId, + stats: dto.stats + }) + .returning() - await db.insert(messageTable).values({ - id, - topicId, - parentId: dto.parentId, - role: dto.role, - data: dto.data, - status: dto.status ?? 'pending', - siblingsGroupId: dto.siblingsGroupId ?? 0, - assistantId: dto.assistantId, - assistantMeta: dto.assistantMeta, - modelId: dto.modelId, - modelMeta: dto.modelMeta, - traceId: dto.traceId, - stats: dto.stats, - createdAt: now, - updatedAt: now - }) + // Update activeNodeId if setAsActive is not explicitly false + if (dto.setAsActive !== false) { + await db.update(topicTable).set({ activeNodeId: row.id }).where(eq(topicTable.id, topicId)) + } - logger.info('Created message', { id, topicId, role: dto.role }) + logger.info('Created message', { id: row.id, topicId, role: dto.role, setAsActive: dto.setAsActive !== false }) - return this.getById(id) + return rowToMessage(row) } /** @@ -419,11 +406,7 @@ export class MessageService { } // Verify new parent exists - const [parent] = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.id, dto.parentId), isNull(messageTable.deletedAt))) - .limit(1) + const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) if (!parent) { throw DataApiErrorFactory.notFound('Message', dto.parentId) @@ -432,24 +415,22 @@ export class MessageService { } // Build update object - const updates: Partial = { - updatedAt: Date.now() - } + const updates: Partial = {} if (dto.data !== undefined) updates.data = dto.data if (dto.parentId !== undefined) updates.parentId = dto.parentId if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId if (dto.status !== undefined) updates.status = dto.status - await db.update(messageTable).set(updates).where(eq(messageTable.id, id)) + const [row] = await db.update(messageTable).set(updates).where(eq(messageTable.id, id)).returning() logger.info('Updated message', { id, changes: Object.keys(dto) }) - return this.getById(id) + return rowToMessage(row) } /** - * Delete a message + * Delete a message (hard delete) */ async delete(id: string, cascade: boolean = false): Promise<{ deletedIds: string[]; reparentedIds?: string[] }> { const db = dbService.getDb() @@ -464,37 +445,29 @@ export class MessageService { throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') } - const now = Date.now() - if (cascade) { // Get all descendants const descendantIds = await this.getDescendantIds(id) const allIds = [id, ...descendantIds] - // Soft delete all - await db.update(messageTable).set({ deletedAt: now }).where(inArray(messageTable.id, allIds)) + // Hard delete all + await db.delete(messageTable).where(inArray(messageTable.id, allIds)) logger.info('Cascade deleted messages', { rootId: id, count: allIds.length }) return { deletedIds: allIds } } else { // Reparent children to this message's parent - const children = await db - .select({ id: messageTable.id }) - .from(messageTable) - .where(and(eq(messageTable.parentId, id), isNull(messageTable.deletedAt))) + const children = await db.select({ id: messageTable.id }).from(messageTable).where(eq(messageTable.parentId, id)) const childIds = children.map((c) => c.id) if (childIds.length > 0) { - await db - .update(messageTable) - .set({ parentId: message.parentId, updatedAt: now }) - .where(inArray(messageTable.id, childIds)) + await db.update(messageTable).set({ parentId: message.parentId }).where(inArray(messageTable.id, childIds)) } - // Soft delete this message - await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.id, id)) + // Hard delete this message + await db.delete(messageTable).where(eq(messageTable.id, id)) logger.info('Deleted message with reparenting', { id, reparentedCount: childIds.length }) @@ -511,11 +484,10 @@ export class MessageService { // Use recursive query to get all descendants const result = await db.all<{ id: string }>(sql` WITH RECURSIVE descendants AS ( - SELECT id FROM message WHERE parent_id = ${id} AND deleted_at IS NULL + SELECT id FROM message WHERE parent_id = ${id} UNION ALL SELECT m.id FROM message m INNER JOIN descendants d ON m.parent_id = d.id - WHERE m.deleted_at IS NULL ) SELECT id FROM descendants `) diff --git a/src/main/data/services/TopicService.ts b/src/main/data/services/TopicService.ts index 5213132e55..b660f6f09a 100644 --- a/src/main/data/services/TopicService.ts +++ b/src/main/data/services/TopicService.ts @@ -14,8 +14,7 @@ import { loggerService } from '@logger' import { DataApiErrorFactory } from '@shared/data/api' import type { CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topics' import type { Topic } from '@shared/data/types/topic' -import { and, eq, isNull } from 'drizzle-orm' -import { v4 as uuidv4, v7 as uuidv7 } from 'uuid' +import { eq } from 'drizzle-orm' import { messageService } from './MessageService' @@ -60,11 +59,7 @@ export class TopicService { async getById(id: string): Promise { const db = dbService.getDb() - const [row] = await db - .select() - .from(topicTable) - .where(and(eq(topicTable.id, id), isNull(topicTable.deletedAt))) - .limit(1) + const [row] = await db.select().from(topicTable).where(eq(topicTable.id, id)).limit(1) if (!row) { throw DataApiErrorFactory.notFound('Topic', id) @@ -78,12 +73,8 @@ export class TopicService { */ async create(dto: CreateTopicDto): Promise { const db = dbService.getDb() - const now = Date.now() - const id = uuidv4() // If forking from existing node, copy the path - let activeNodeId: string | null = null - if (dto.sourceNodeId) { // Verify source node exists try { @@ -95,70 +86,76 @@ export class TopicService { // Get path from root to source node const path = await messageService.getPathToNode(dto.sourceNodeId) - // Create new topic first - await db.insert(topicTable).values({ - id, - name: dto.name, - assistantId: dto.assistantId, - assistantMeta: dto.assistantMeta, - prompt: dto.prompt, - groupId: dto.groupId, - createdAt: now, - updatedAt: now - }) + // Create new topic first using returning() to get the id + const [topicRow] = await db + .insert(topicTable) + .values({ + name: dto.name, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + prompt: dto.prompt, + groupId: dto.groupId + }) + .returning() - // Copy messages with new IDs + const topicId = topicRow.id + + // Copy messages with new IDs using returning() const idMapping = new Map() + let activeNodeId: string | null = null for (const message of path) { - const newId = uuidv7() const newParentId = message.parentId ? idMapping.get(message.parentId) || null : null - idMapping.set(message.id, newId) + const [messageRow] = await db + .insert(messageTable) + .values({ + topicId, + parentId: newParentId, + role: message.role, + data: message.data, + status: message.status, + siblingsGroupId: 0, // Simplify multi-model to normal node + assistantId: message.assistantId, + assistantMeta: message.assistantMeta, + modelId: message.modelId, + modelMeta: message.modelMeta, + traceId: null, + stats: null + }) + .returning() - await db.insert(messageTable).values({ - id: newId, - topicId: id, - parentId: newParentId, - role: message.role, - data: message.data, - status: message.status, - siblingsGroupId: 0, // Simplify multi-model to normal node - assistantId: message.assistantId, - assistantMeta: message.assistantMeta, - modelId: message.modelId, - modelMeta: message.modelMeta, - traceId: null, // Clear trace ID - stats: null, // Clear stats - createdAt: now, - updatedAt: now - }) - - // Last node becomes the active node - activeNodeId = newId + idMapping.set(message.id, messageRow.id) + activeNodeId = messageRow.id } // Update topic with active node - await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, id)) + await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, topicId)) - logger.info('Created topic by forking', { id, sourceNodeId: dto.sourceNodeId, messageCount: path.length }) - } else { - // Create empty topic - await db.insert(topicTable).values({ - id, - name: dto.name, - assistantId: dto.assistantId, - assistantMeta: dto.assistantMeta, - prompt: dto.prompt, - groupId: dto.groupId, - createdAt: now, - updatedAt: now + logger.info('Created topic by forking', { + id: topicId, + sourceNodeId: dto.sourceNodeId, + messageCount: path.length }) - logger.info('Created empty topic', { id }) - } + return this.getById(topicId) + } else { + // Create empty topic using returning() + const [row] = await db + .insert(topicTable) + .values({ + name: dto.name, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + prompt: dto.prompt, + groupId: dto.groupId + }) + .returning() - return this.getById(id) + logger.info('Created empty topic', { id: row.id }) + + return rowToTopic(row) + } } /** @@ -171,9 +168,7 @@ export class TopicService { await this.getById(id) // Build update object - const updates: Partial = { - updatedAt: Date.now() - } + const updates: Partial = {} if (dto.name !== undefined) updates.name = dto.name if (dto.isNameManuallyEdited !== undefined) updates.isNameManuallyEdited = dto.isNameManuallyEdited @@ -185,15 +180,15 @@ export class TopicService { if (dto.isPinned !== undefined) updates.isPinned = dto.isPinned if (dto.pinnedOrder !== undefined) updates.pinnedOrder = dto.pinnedOrder - await db.update(topicTable).set(updates).where(eq(topicTable.id, id)) + const [row] = await db.update(topicTable).set(updates).where(eq(topicTable.id, id)).returning() logger.info('Updated topic', { id, changes: Object.keys(dto) }) - return this.getById(id) + return rowToTopic(row) } /** - * Delete a topic and all its messages + * Delete a topic and all its messages (hard delete) */ async delete(id: string): Promise { const db = dbService.getDb() @@ -201,13 +196,11 @@ export class TopicService { // Verify topic exists await this.getById(id) - const now = Date.now() + // Hard delete all messages first (due to foreign key) + await db.delete(messageTable).where(eq(messageTable.topicId, id)) - // Soft delete all messages - await db.update(messageTable).set({ deletedAt: now }).where(eq(messageTable.topicId, id)) - - // Soft delete topic - await db.update(topicTable).set({ deletedAt: now }).where(eq(topicTable.id, id)) + // Hard delete topic + await db.delete(topicTable).where(eq(topicTable.id, id)) logger.info('Deleted topic', { id }) } @@ -222,18 +215,14 @@ export class TopicService { await this.getById(topicId) // Verify node exists and belongs to this topic - const [message] = await db - .select() - .from(messageTable) - .where(and(eq(messageTable.id, nodeId), eq(messageTable.topicId, topicId), isNull(messageTable.deletedAt))) - .limit(1) + const [message] = await db.select().from(messageTable).where(eq(messageTable.id, nodeId)).limit(1) - if (!message) { + if (!message || message.topicId !== topicId) { throw DataApiErrorFactory.notFound('Message', nodeId) } // Update active node - await db.update(topicTable).set({ activeNodeId: nodeId, updatedAt: Date.now() }).where(eq(topicTable.id, topicId)) + await db.update(topicTable).set({ activeNodeId: nodeId }).where(eq(topicTable.id, topicId)) logger.info('Set active node', { topicId, nodeId }) From 44b85fa661cb8bba4c6e844da01ae4c549e8436d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 12:00:24 +0800 Subject: [PATCH 052/116] docs(README): enhance foreign key documentation with usage examples - Added sections on basic usage of foreign keys, self-referencing foreign keys, and circular foreign key references. - Provided TypeScript code examples to illustrate best practices and avoid common pitfalls. - Explained the rationale behind using soft references in SQLite for improved data integrity and simplified operations. --- src/main/data/db/README.md | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index ab772615dc..10d3a44593 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -88,6 +88,8 @@ Drizzle handles JSON serialization/deserialization automatically. ## Foreign Keys +### Basic Usage + ```typescript // SET NULL: preserve record when referenced record is deleted groupId: text().references(() => groupTable.id, { onDelete: 'set null' }) @@ -96,6 +98,69 @@ groupId: text().references(() => groupTable.id, { onDelete: 'set null' }) topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }) ``` +### Self-Referencing Foreign Keys + +For self-referencing foreign keys (e.g., tree structures with parentId), **always use the `foreignKey` operator** in the table's third parameter: + +```typescript +import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const messageTable = sqliteTable( + 'message', + { + id: uuidPrimaryKeyOrdered(), + parentId: text(), // Do NOT use .references() here + // ...other fields + }, + (t) => [ + // Use foreignKey operator for self-referencing + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null') + ] +) +``` + +**Why this approach:** +- Avoids TypeScript circular reference issues (no need for `AnySQLiteColumn` type annotation) +- More explicit and readable +- Allows chaining `.onDelete()` / `.onUpdate()` actions + +### Circular Foreign Key References + +**Avoid circular foreign key references between tables.** For example: + +```typescript +// ❌ BAD: Circular FK between tables +// tableA.currentItemId -> tableB.id +// tableB.ownerId -> tableA.id +``` + +If you encounter a scenario that seems to require circular references: + +1. **Identify which relationship is "weaker"** - typically the one that can be null or is less critical for data integrity +2. **Remove the FK constraint from the weaker side** - let the application layer handle validation and consistency (this is known as "soft references" pattern) +3. **Document the application-layer constraint** in code comments + +```typescript +// ✅ GOOD: Break the cycle by handling one side at application layer +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + // Application-managed reference (no FK constraint) + // Validated by TopicService.setCurrentMessage() + currentMessageId: text(), +}) + +export const messageTable = sqliteTable('message', { + id: uuidPrimaryKeyOrdered(), + // Database-enforced FK + topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }), +}) +``` + +**Why soft references for SQLite:** +- SQLite does not support `DEFERRABLE` constraints (unlike PostgreSQL/Oracle) +- Application-layer validation provides equivalent data integrity +- Simplifies insert/update operations without transaction ordering concerns + ## Migrations Generate migrations after schema changes: From 3d0e7a6c15b06e5bf06b1ff8ae7d1d997a71c75e Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 13:42:05 +0800 Subject: [PATCH 053/116] feat(api): enhance message deletion functionality with activeNodeId management - Introduced `ActiveNodeStrategy` type to define strategies for updating `activeNodeId` when a message is deleted. - Updated `DeleteMessageResponse` to include `newActiveNodeId` for tracking changes to the active node after deletion. - Modified the `DELETE` endpoint to accept `activeNodeStrategy` as a query parameter, allowing for flexible handling of active node updates. - Enhanced the `delete` method in `MessageService` to implement the new strategies, ensuring consistent behavior during message deletions. --- packages/shared/data/api/schemas/messages.ts | 20 +++- src/main/data/api/handlers/messages.ts | 12 +- src/main/data/db/schemas/topic.ts | 3 - src/main/data/services/MessageService.ts | 116 +++++++++++++++---- 4 files changed, 123 insertions(+), 28 deletions(-) diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index f659491f93..b952156da5 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -64,6 +64,11 @@ export interface UpdateMessageDto { status?: MessageStatus } +/** + * Strategy for updating activeNodeId when the active message is deleted + */ +export type ActiveNodeStrategy = 'parent' | 'clear' + /** * Response for delete operation */ @@ -72,6 +77,8 @@ export interface DeleteMessageResponse { deletedIds: string[] /** IDs of reparented children (only when cascade=false) */ reparentedIds?: string[] + /** New activeNodeId for the topic (only if activeNodeId was affected by deletion) */ + newActiveNodeId?: string | null } // ============================================================================ @@ -168,10 +175,19 @@ export interface MessageSchemas { body: UpdateMessageDto response: Message } - /** Delete a message (cascade=true deletes descendants, cascade=false reparents children) */ + /** + * Delete a message + * - cascade=true: deletes message and all descendants + * - cascade=false: reparents children to grandparent + * - activeNodeStrategy='parent' (default): sets activeNodeId to parent if affected + * - activeNodeStrategy='clear': sets activeNodeId to null if affected + */ DELETE: { params: { id: string } - query?: { cascade?: boolean } + query?: { + cascade?: boolean + activeNodeStrategy?: ActiveNodeStrategy + } response: DeleteMessageResponse } } diff --git a/src/main/data/api/handlers/messages.ts b/src/main/data/api/handlers/messages.ts index 0dde772331..2f7faaded3 100644 --- a/src/main/data/api/handlers/messages.ts +++ b/src/main/data/api/handlers/messages.ts @@ -9,7 +9,12 @@ import { messageService } from '@data/services/MessageService' import type { ApiHandler, ApiMethods } from '@shared/data/api/apiTypes' -import type { BranchMessagesQueryParams, MessageSchemas, TreeQueryParams } from '@shared/data/api/schemas/messages' +import type { + ActiveNodeStrategy, + BranchMessagesQueryParams, + MessageSchemas, + TreeQueryParams +} from '@shared/data/api/schemas/messages' /** * Handler type for a specific message endpoint @@ -61,9 +66,10 @@ export const messageHandlers: { }, DELETE: async ({ params, query }) => { - const q = (query || {}) as { cascade?: boolean } + const q = (query || {}) as { cascade?: boolean; activeNodeStrategy?: ActiveNodeStrategy } const cascade = q.cascade ?? false - return await messageService.delete(params.id, cascade) + const activeNodeStrategy = q.activeNodeStrategy ?? 'parent' + return await messageService.delete(params.id, cascade, activeNodeStrategy) } } } diff --git a/src/main/data/db/schemas/topic.ts b/src/main/data/db/schemas/topic.ts index 3f19ae7a7f..68078d8f86 100644 --- a/src/main/data/db/schemas/topic.ts +++ b/src/main/data/db/schemas/topic.ts @@ -3,7 +3,6 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { createUpdateDeleteTimestamps, uuidPrimaryKey } from './columnHelpers' import { groupTable } from './group' -// import { messageTable } from './message' /** * Topic table - stores conversation topics/threads @@ -25,9 +24,7 @@ export const topicTable = sqliteTable( // Topic-specific prompt override prompt: text(), // Active node ID in the message tree - // SET NULL: reset to null when the referenced message is deleted activeNodeId: text(), - // .references(() => messageTable.id, { onDelete: 'set null' }), // FK to group table for organization // SET NULL: preserve topic when group is deleted diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 4093aad802..9956983b6a 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -13,7 +13,12 @@ import { messageTable } from '@data/db/schemas/message' import { topicTable } from '@data/db/schemas/topic' import { loggerService } from '@logger' import { DataApiErrorFactory } from '@shared/data/api' -import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages' +import type { + ActiveNodeStrategy, + CreateMessageDto, + DeleteMessageResponse, + UpdateMessageDto +} from '@shared/data/api/schemas/messages' import type { BranchMessage, BranchMessagesResponse, @@ -431,13 +436,42 @@ export class MessageService { /** * Delete a message (hard delete) + * + * Supports two modes: + * - cascade=true: Delete the message and all its descendants + * - cascade=false: Delete only this message, reparent children to grandparent + * + * When the deleted message(s) include the topic's activeNodeId, it will be + * automatically updated based on activeNodeStrategy: + * - 'parent' (default): Sets activeNodeId to the deleted message's parent + * - 'clear': Sets activeNodeId to null + * + * All operations are performed within a transaction for consistency. + * + * @param id - Message ID to delete + * @param cascade - If true, delete descendants; if false, reparent children (default: false) + * @param activeNodeStrategy - Strategy for updating activeNodeId if affected (default: 'parent') + * @returns Deletion result including deletedIds, reparentedIds, and newActiveNodeId + * @throws NOT_FOUND if message doesn't exist + * @throws INVALID_OPERATION if deleting root without cascade=true */ - async delete(id: string, cascade: boolean = false): Promise<{ deletedIds: string[]; reparentedIds?: string[] }> { + async delete( + id: string, + cascade: boolean = false, + activeNodeStrategy: ActiveNodeStrategy = 'parent' + ): Promise { const db = dbService.getDb() // Get the message const message = await this.getById(id) + // Get topic to check activeNodeId + const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, message.topicId)).limit(1) + + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', message.topicId) + } + // Check if it's a root message const isRoot = message.parentId === null @@ -445,34 +479,76 @@ export class MessageService { throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') } + // Get all descendant IDs before transaction (for cascade delete) + let descendantIds: string[] = [] if (cascade) { - // Get all descendants - const descendantIds = await this.getDescendantIds(id) - const allIds = [id, ...descendantIds] + descendantIds = await this.getDescendantIds(id) + } - // Hard delete all - await db.delete(messageTable).where(inArray(messageTable.id, allIds)) + // Use transaction for atomic delete + activeNodeId update + return await db.transaction(async (tx) => { + let deletedIds: string[] + let reparentedIds: string[] | undefined + let newActiveNodeId: string | null | undefined - logger.info('Cascade deleted messages', { rootId: id, count: allIds.length }) + if (cascade) { + deletedIds = [id, ...descendantIds] - return { deletedIds: allIds } - } else { - // Reparent children to this message's parent - const children = await db.select({ id: messageTable.id }).from(messageTable).where(eq(messageTable.parentId, id)) + // Check if activeNodeId is affected + if (topic.activeNodeId && deletedIds.includes(topic.activeNodeId)) { + newActiveNodeId = activeNodeStrategy === 'clear' ? null : message.parentId + } - const childIds = children.map((c) => c.id) + // Hard delete all + await tx.delete(messageTable).where(inArray(messageTable.id, deletedIds)) - if (childIds.length > 0) { - await db.update(messageTable).set({ parentId: message.parentId }).where(inArray(messageTable.id, childIds)) + logger.info('Cascade deleted messages', { rootId: id, count: deletedIds.length }) + } else { + // Reparent children to this message's parent + const children = await tx + .select({ id: messageTable.id }) + .from(messageTable) + .where(eq(messageTable.parentId, id)) + + reparentedIds = children.map((c) => c.id) + + if (reparentedIds.length > 0) { + await tx + .update(messageTable) + .set({ parentId: message.parentId }) + .where(inArray(messageTable.id, reparentedIds)) + } + + deletedIds = [id] + + // Check if activeNodeId is affected + if (topic.activeNodeId === id) { + newActiveNodeId = activeNodeStrategy === 'clear' ? null : message.parentId + } + + // Hard delete this message + await tx.delete(messageTable).where(eq(messageTable.id, id)) + + logger.info('Deleted message with reparenting', { id, reparentedCount: reparentedIds.length }) } - // Hard delete this message - await db.delete(messageTable).where(eq(messageTable.id, id)) + // Update topic.activeNodeId if needed + if (newActiveNodeId !== undefined) { + await tx.update(topicTable).set({ activeNodeId: newActiveNodeId }).where(eq(topicTable.id, message.topicId)) - logger.info('Deleted message with reparenting', { id, reparentedCount: childIds.length }) + logger.info('Updated topic activeNodeId after message deletion', { + topicId: message.topicId, + oldActiveNodeId: topic.activeNodeId, + newActiveNodeId + }) + } - return { deletedIds: [id], reparentedIds: childIds } - } + return { + deletedIds, + reparentedIds: reparentedIds?.length ? reparentedIds : undefined, + newActiveNodeId + } + }) } /** From e4fd1af1b8845c59f189857bc405129370f983c4 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 16:59:00 +0800 Subject: [PATCH 054/116] feat(MessageService): optimize message retrieval with CTEs for improved performance - Enhanced `getTree` method to utilize Common Table Expressions (CTEs) for fetching active paths and tree structures in a single query, reducing database load. - Updated `getBranchMessages` to implement a similar optimization, allowing for efficient retrieval of message paths without loading all messages. - Refactored `getPathToNode` to use a recursive CTE for fetching ancestors, addressing the N+1 query problem in deep message trees. - Introduced transaction handling in `create` and `update` methods to ensure atomic operations and data integrity during message modifications. --- src/main/data/services/MessageService.ts | 439 +++++++++++++++-------- 1 file changed, 292 insertions(+), 147 deletions(-) diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 9956983b6a..5f1c05e749 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -27,7 +27,7 @@ import type { TreeNode, TreeResponse } from '@shared/data/types/message' -import { eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, or, sql } from 'drizzle-orm' const logger = loggerService.withContext('MessageService') @@ -112,6 +112,11 @@ export class MessageService { /** * Get tree structure for visualization + * + * Optimized to avoid loading all messages: + * 1. Uses CTE to get active path (single query) + * 2. Uses CTE to get tree nodes within depth limit (single query) + * 3. Fetches additional nodes for active path if beyond depth limit */ async getTree( topicId: string, @@ -129,19 +134,108 @@ export class MessageService { const activeNodeId = options.nodeId || topic.activeNodeId - // Get all messages for this topic - const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId)) + // Find root node if not specified + let rootId = options.rootId + if (!rootId) { + const [root] = await db + .select({ id: messageTable.id }) + .from(messageTable) + .where(and(eq(messageTable.topicId, topicId), sql`${messageTable.parentId} IS NULL`)) + .limit(1) + rootId = root?.id + } - if (allMessages.length === 0) { + if (!rootId) { return { nodes: [], siblingsGroups: [], activeNodeId: null } } + // Build active path via CTE (single query) + const activePath = new Set() + if (activeNodeId) { + const pathRows = await db.all<{ id: string }>(sql` + WITH RECURSIVE path AS ( + SELECT id, parent_id FROM message WHERE id = ${activeNodeId} + UNION ALL + SELECT m.id, m.parent_id FROM message m + INNER JOIN path p ON m.id = p.parent_id + ) + SELECT id FROM path + `) + pathRows.forEach((r) => activePath.add(r.id)) + } + + // Get tree with depth limit via CTE + // Use a large depth for unlimited (-1) + const maxDepth = depth === -1 ? 999 : depth + + const treeRows = await db.all(sql` + WITH RECURSIVE tree AS ( + SELECT *, 0 as tree_depth FROM message WHERE id = ${rootId} + UNION ALL + SELECT m.*, t.tree_depth + 1 FROM message m + INNER JOIN tree t ON m.parent_id = t.id + WHERE t.tree_depth < ${maxDepth} + ) + SELECT * FROM tree + `) + + // Also fetch active path nodes that might be beyond depth limit + const treeNodeIds = new Set(treeRows.map((r) => r.id)) + const missingActivePathIds = [...activePath].filter((id) => !treeNodeIds.has(id)) + + if (missingActivePathIds.length > 0) { + const additionalRows = await db.select().from(messageTable).where(inArray(messageTable.id, missingActivePathIds)) + treeRows.push(...additionalRows.map((r) => ({ ...r, tree_depth: maxDepth + 1 }))) + } + + // Also need children of active path nodes for proper tree building + // Get all children of active path nodes that we haven't loaded yet + const activePathArray = [...activePath] + if (activePathArray.length > 0 && treeNodeIds.size > 0) { + const childrenRows = await db + .select() + .from(messageTable) + .where( + and( + inArray(messageTable.parentId, activePathArray), + sql`${messageTable.id} NOT IN (${sql.join( + [...treeNodeIds].map((id) => sql`${id}`), + sql`, ` + )})` + ) + ) + + for (const row of childrenRows) { + if (!treeNodeIds.has(row.id)) { + treeRows.push({ ...row, tree_depth: maxDepth + 1 }) + treeNodeIds.add(row.id) + } + } + } else if (activePathArray.length > 0) { + // No tree nodes loaded yet, just get all children of active path + const childrenRows = await db.select().from(messageTable).where(inArray(messageTable.parentId, activePathArray)) + + for (const row of childrenRows) { + if (!treeNodeIds.has(row.id)) { + treeRows.push({ ...row, tree_depth: maxDepth + 1 }) + treeNodeIds.add(row.id) + } + } + } + + if (treeRows.length === 0) { + return { nodes: [], siblingsGroups: [], activeNodeId: null } + } + + // Build maps for tree processing const messagesById = new Map() const childrenMap = new Map() + const depthMap = new Map() - for (const row of allMessages) { + for (const row of treeRows) { const message = rowToMessage(row) messagesById.set(message.id, message) + depthMap.set(message.id, row.tree_depth) const parentId = message.parentId || 'root' if (!childrenMap.has(parentId)) { @@ -150,21 +244,6 @@ export class MessageService { childrenMap.get(parentId)!.push(message.id) } - // Find root node(s) and build active path - const rootIds = childrenMap.get('root') || [] - const rootId = options.rootId || rootIds[0] - - // Build path from rootId to activeNodeId - const activePath = new Set() - if (activeNodeId) { - let currentId: string | null = activeNodeId - while (currentId) { - activePath.add(currentId) - const message = messagesById.get(currentId) - currentId = message?.parentId || null - } - } - // Collect nodes based on depth const resultNodes: TreeNode[] = [] const siblingsGroups: SiblingsGroup[] = [] @@ -187,7 +266,7 @@ export class MessageService { const parentChildren = childrenMap.get(message.parentId || 'root') || [] const groupMembers = parentChildren .map((id) => messagesById.get(id)!) - .filter((m) => m.siblingsGroupId === message.siblingsGroupId) + .filter((m) => m && m.siblingsGroupId === message.siblingsGroupId) if (groupMembers.length > 1) { siblingsGroups.push({ @@ -221,9 +300,7 @@ export class MessageService { } // Start from root - if (rootId) { - collectNodes(rootId, 0, activePath.has(rootId)) - } + collectNodes(rootId, 0, activePath.has(rootId)) return { nodes: resultNodes, @@ -234,6 +311,10 @@ export class MessageService { /** * Get branch messages for conversation view + * + * Optimized implementation using recursive CTE to fetch only the path + * from nodeId to root, avoiding loading all messages for large topics. + * Siblings are batch-queried in a single additional query. */ async getBranchMessages( topicId: string, @@ -249,76 +330,108 @@ export class MessageService { throw DataApiErrorFactory.notFound('Topic', topicId) } - // Get all messages for this topic - const allMessages = await db.select().from(messageTable).where(eq(messageTable.topicId, topicId)) + const nodeId = options.nodeId || topic.activeNodeId - if (allMessages.length === 0) { + // Return empty if no active node + if (!nodeId) { return { messages: [], activeNodeId: null } } - // Check for data inconsistency - if (!topic.activeNodeId) { - throw DataApiErrorFactory.dataInconsistent('Topic', 'has messages but no active node') + // Use recursive CTE to get path from nodeId to root (single query) + const pathMessages = await db.all(sql` + WITH RECURSIVE path AS ( + SELECT * FROM message WHERE id = ${nodeId} + UNION ALL + SELECT m.* FROM message m + INNER JOIN path p ON m.id = p.parent_id + ) + SELECT * FROM path + `) + + if (pathMessages.length === 0) { + throw DataApiErrorFactory.notFound('Message', nodeId) } - const nodeId = options.nodeId || topic.activeNodeId - const messagesById = new Map() - - for (const row of allMessages) { - messagesById.set(row.id, rowToMessage(row)) - } - - // Build path from root to nodeId - const path: string[] = [] - let currentId: string | null = nodeId - while (currentId) { - path.unshift(currentId) - const message = messagesById.get(currentId) - if (!message) { - throw DataApiErrorFactory.notFound('Message', currentId) - } - currentId = message.parentId - } + // Reverse to get root->nodeId order + const fullPath = pathMessages.reverse() // Apply pagination let startIndex = 0 + let endIndex = fullPath.length + if (options.beforeNodeId) { - const beforeIndex = path.indexOf(options.beforeNodeId) + const beforeIndex = fullPath.findIndex((m) => m.id === options.beforeNodeId) if (beforeIndex === -1) { throw DataApiErrorFactory.notFound('Message', options.beforeNodeId) } startIndex = Math.max(0, beforeIndex - limit) + endIndex = beforeIndex } else { - startIndex = Math.max(0, path.length - limit) + startIndex = Math.max(0, fullPath.length - limit) } - const endIndex = options.beforeNodeId ? path.indexOf(options.beforeNodeId) : path.length - - const resultPath = path.slice(startIndex, endIndex) + const paginatedPath = fullPath.slice(startIndex, endIndex) // Build result with optional siblings const result: BranchMessage[] = [] - for (const msgId of resultPath) { - const message = messagesById.get(msgId)! + if (includeSiblings) { + // Collect unique (parentId, siblingsGroupId) pairs that need siblings + const uniqueGroups = new Set() + const groupsToQuery: Array<{ parentId: string; siblingsGroupId: number }> = [] - let siblingsGroup: Message[] | undefined - if (includeSiblings && message.siblingsGroupId !== 0) { - // Find siblings with same parentId and siblingsGroupId - siblingsGroup = allMessages - .filter( - (row) => - row.parentId === message.parentId && - row.siblingsGroupId === message.siblingsGroupId && - row.id !== message.id - ) - .map(rowToMessage) + for (const msg of paginatedPath) { + if (msg.siblingsGroupId && msg.siblingsGroupId !== 0 && msg.parentId) { + const key = `${msg.parentId}-${msg.siblingsGroupId}` + if (!uniqueGroups.has(key)) { + uniqueGroups.add(key) + groupsToQuery.push({ parentId: msg.parentId, siblingsGroupId: msg.siblingsGroupId }) + } + } } - result.push({ - message, - siblingsGroup - }) + // Batch query all siblings if needed + const siblingsMap = new Map() + + if (groupsToQuery.length > 0) { + // Build OR conditions for batch query + const orConditions = groupsToQuery.map((g) => + and(eq(messageTable.parentId, g.parentId), eq(messageTable.siblingsGroupId, g.siblingsGroupId)) + ) + + const siblingsRows = await db + .select() + .from(messageTable) + .where(or(...orConditions)) + + // Group results by parentId-siblingsGroupId + for (const row of siblingsRows) { + const key = `${row.parentId}-${row.siblingsGroupId}` + if (!siblingsMap.has(key)) siblingsMap.set(key, []) + siblingsMap.get(key)!.push(rowToMessage(row)) + } + } + + // Build result with siblings from map + for (const msg of paginatedPath) { + const message = rowToMessage(msg) + let siblingsGroup: Message[] | undefined + + if (msg.siblingsGroupId !== 0 && msg.parentId) { + const key = `${msg.parentId}-${msg.siblingsGroupId}` + const group = siblingsMap.get(key) + if (group && group.length > 1) { + siblingsGroup = group.filter((m) => m.id !== message.id) + } + } + + result.push({ message, siblingsGroup }) + } + } else { + // No siblings needed, just map messages + for (const msg of paginatedPath) { + result.push({ message: rowToMessage(msg) }) + } } return { @@ -344,94 +457,114 @@ export class MessageService { /** * Create a new message + * + * Uses transaction to ensure atomicity of: + * - Topic existence validation + * - Parent message validation (if specified) + * - Message insertion + * - Topic activeNodeId update */ async create(topicId: string, dto: CreateMessageDto): Promise { const db = dbService.getDb() - // Verify topic exists - const [topic] = await db.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) + return await db.transaction(async (tx) => { + // Verify topic exists + const [topic] = await tx.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) - if (!topic) { - throw DataApiErrorFactory.notFound('Topic', topicId) - } - - // Verify parent exists if specified - if (dto.parentId) { - const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) - - if (!parent) { - throw DataApiErrorFactory.notFound('Message', dto.parentId) + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', topicId) } - } - const [row] = await db - .insert(messageTable) - .values({ - topicId, - parentId: dto.parentId, - role: dto.role, - data: dto.data, - status: dto.status ?? 'pending', - siblingsGroupId: dto.siblingsGroupId ?? 0, - assistantId: dto.assistantId, - assistantMeta: dto.assistantMeta, - modelId: dto.modelId, - modelMeta: dto.modelMeta, - traceId: dto.traceId, - stats: dto.stats - }) - .returning() - - // Update activeNodeId if setAsActive is not explicitly false - if (dto.setAsActive !== false) { - await db.update(topicTable).set({ activeNodeId: row.id }).where(eq(topicTable.id, topicId)) - } - - logger.info('Created message', { id: row.id, topicId, role: dto.role, setAsActive: dto.setAsActive !== false }) - - return rowToMessage(row) - } - - /** - * Update a message - */ - async update(id: string, dto: UpdateMessageDto): Promise { - const db = dbService.getDb() - - // Get existing message - const existing = await this.getById(id) - - // Check for cycle if moving to new parent - if (dto.parentId !== undefined && dto.parentId !== existing.parentId) { - if (dto.parentId !== null) { - // Check that new parent is not a descendant - const descendants = await this.getDescendantIds(id) - if (descendants.includes(dto.parentId)) { - throw DataApiErrorFactory.invalidOperation('move message', 'would create cycle') - } - - // Verify new parent exists - const [parent] = await db.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) + // Verify parent exists if specified + if (dto.parentId) { + const [parent] = await tx.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) if (!parent) { throw DataApiErrorFactory.notFound('Message', dto.parentId) } } + + const [row] = await tx + .insert(messageTable) + .values({ + topicId, + parentId: dto.parentId, + role: dto.role, + data: dto.data, + status: dto.status ?? 'pending', + siblingsGroupId: dto.siblingsGroupId ?? 0, + assistantId: dto.assistantId, + assistantMeta: dto.assistantMeta, + modelId: dto.modelId, + modelMeta: dto.modelMeta, + traceId: dto.traceId, + stats: dto.stats + }) + .returning() + + // Update activeNodeId if setAsActive is not explicitly false + if (dto.setAsActive !== false) { + await tx.update(topicTable).set({ activeNodeId: row.id }).where(eq(topicTable.id, topicId)) + } + + logger.info('Created message', { id: row.id, topicId, role: dto.role, setAsActive: dto.setAsActive !== false }) + + return rowToMessage(row) + }) + } + + /** + * Update a message + * + * Uses transaction to ensure atomicity of validation and update. + * Cycle check is performed outside transaction as a read-only safety check. + */ + async update(id: string, dto: UpdateMessageDto): Promise { + const db = dbService.getDb() + + // Pre-transaction: Check for cycle if moving to new parent + // This is done outside transaction since getDescendantIds uses its own db context + // and cycle check is a safety check (worst case: reject valid operation) + if (dto.parentId !== undefined && dto.parentId !== null) { + const descendants = await this.getDescendantIds(id) + if (descendants.includes(dto.parentId)) { + throw DataApiErrorFactory.invalidOperation('move message', 'would create cycle') + } } - // Build update object - const updates: Partial = {} + return await db.transaction(async (tx) => { + // Get existing message within transaction + const [existingRow] = await tx.select().from(messageTable).where(eq(messageTable.id, id)).limit(1) - if (dto.data !== undefined) updates.data = dto.data - if (dto.parentId !== undefined) updates.parentId = dto.parentId - if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId - if (dto.status !== undefined) updates.status = dto.status + if (!existingRow) { + throw DataApiErrorFactory.notFound('Message', id) + } - const [row] = await db.update(messageTable).set(updates).where(eq(messageTable.id, id)).returning() + const existing = rowToMessage(existingRow) - logger.info('Updated message', { id, changes: Object.keys(dto) }) + // Verify new parent exists if changing parent + if (dto.parentId !== undefined && dto.parentId !== existing.parentId && dto.parentId !== null) { + const [parent] = await tx.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) - return rowToMessage(row) + if (!parent) { + throw DataApiErrorFactory.notFound('Message', dto.parentId) + } + } + + // Build update object + const updates: Partial = {} + + if (dto.data !== undefined) updates.data = dto.data + if (dto.parentId !== undefined) updates.parentId = dto.parentId + if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId + if (dto.status !== undefined) updates.status = dto.status + + const [row] = await tx.update(messageTable).set(updates).where(eq(messageTable.id, id)).returning() + + logger.info('Updated message', { id, changes: Object.keys(dto) }) + + return rowToMessage(row) + }) } /** @@ -573,18 +706,30 @@ export class MessageService { /** * Get path from root to a node + * + * Uses recursive CTE to fetch all ancestors in a single query, + * avoiding N+1 query problem for deep message trees. */ async getPathToNode(nodeId: string): Promise { - const path: Message[] = [] - let currentId: string | null = nodeId + const db = dbService.getDb() - while (currentId) { - const message = await this.getById(currentId) - path.unshift(message) - currentId = message.parentId + // Use recursive CTE to get all ancestors in one query + const result = await db.all(sql` + WITH RECURSIVE ancestors AS ( + SELECT * FROM message WHERE id = ${nodeId} + UNION ALL + SELECT m.* FROM message m + INNER JOIN ancestors a ON m.id = a.parent_id + ) + SELECT * FROM ancestors + `) + + if (result.length === 0) { + throw DataApiErrorFactory.notFound('Message', nodeId) } - return path + // Result is from nodeId to root, reverse to get root to nodeId + return result.reverse().map(rowToMessage) } } From 819c209821d0e2a154aa8ccd51a485fa38075a3c Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 17:15:06 +0800 Subject: [PATCH 055/116] docs(data): update README and remove outdated API design guidelines - Revised the README files for shared data and main data layers to improve clarity and structure. - Consolidated documentation on shared data types and API types, removing the now-deleted `api-design-guidelines.md`. - Streamlined directory structure descriptions and updated links to relevant documentation. - Enhanced quick reference sections for better usability and understanding of the data architecture. --- .github/CODEOWNERS | 2 + docs/en/references/data/README.md | 193 ++++++++ .../references/data}/api-design-guidelines.md | 0 docs/en/references/data/api-types.md | 238 ++++++++++ docs/en/references/data/cache-overview.md | 125 +++++ docs/en/references/data/cache-usage.md | 246 ++++++++++ docs/en/references/data/data-api-in-main.md | 360 +++++++++++++++ .../references/data/data-api-in-renderer.md | 298 ++++++++++++ docs/en/references/data/data-api-overview.md | 158 +++++++ docs/en/references/data/database-patterns.md | 199 ++++++++ .../en/references/data/preference-overview.md | 144 ++++++ docs/en/references/data/preference-usage.md | 260 +++++++++++ docs/en/references/data/v2-migration-guide.md | 64 +++ packages/shared/data/README.md | 138 ++---- packages/shared/data/api/README.md | 242 +--------- src/main/data/README.md | 415 ++--------------- src/main/data/db/README.md | 207 ++------- src/main/data/migration/v2/README.md | 75 +-- src/renderer/src/data/README.md | 429 +----------------- 19 files changed, 2438 insertions(+), 1355 deletions(-) create mode 100644 docs/en/references/data/README.md rename {packages/shared/data/api => docs/en/references/data}/api-design-guidelines.md (100%) create mode 100644 docs/en/references/data/api-types.md create mode 100644 docs/en/references/data/cache-overview.md create mode 100644 docs/en/references/data/cache-usage.md create mode 100644 docs/en/references/data/data-api-in-main.md create mode 100644 docs/en/references/data/data-api-in-renderer.md create mode 100644 docs/en/references/data/data-api-overview.md create mode 100644 docs/en/references/data/database-patterns.md create mode 100644 docs/en/references/data/preference-overview.md create mode 100644 docs/en/references/data/preference-usage.md create mode 100644 docs/en/references/data/v2-migration-guide.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b178b306bf..b1b052f90c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,6 +9,8 @@ /src/main/data/ @0xfullex /src/renderer/src/data/ @0xfullex /v2-refactor-temp/ @0xfullex +/docs/en/references/data/ @0xfullex +/docs/zh/references/data/ @0xfullex /packages/ui/ @MyPrototypeWhat diff --git a/docs/en/references/data/README.md b/docs/en/references/data/README.md new file mode 100644 index 0000000000..cd247160c5 --- /dev/null +++ b/docs/en/references/data/README.md @@ -0,0 +1,193 @@ +# Data System Reference + +This is the main entry point for Cherry Studio's data management documentation. The application uses three distinct data systems based on data characteristics. + +## Quick Navigation + +### System Overview (Architecture) +- [Cache Overview](./cache-overview.md) - Three-tier caching architecture +- [Preference Overview](./preference-overview.md) - User settings management +- [DataApi Overview](./data-api-overview.md) - Business data API architecture + +### Usage Guides (Code Examples) +- [Cache Usage](./cache-usage.md) - useCache hooks, CacheService examples +- [Preference Usage](./preference-usage.md) - usePreference hook, PreferenceService examples +- [DataApi in Renderer](./data-api-in-renderer.md) - useQuery/useMutation, DataApiService +- [DataApi in Main](./data-api-in-main.md) - Handlers, Services, Repositories patterns + +### Reference Guides (Coding Standards) +- [API Design Guidelines](./api-design-guidelines.md) - RESTful design rules +- [Database Patterns](./database-patterns.md) - DB naming, schema patterns +- [API Types](./api-types.md) - API type system, schemas, error handling +- [V2 Migration Guide](./v2-migration-guide.md) - Migration system + +--- + +## Choosing the Right System + +### Quick Decision Table + +| Service | Data Characteristics | Lifecycle | Data Loss Impact | Examples | +|---------|---------------------|-----------|------------------|----------| +| **CacheService** | Regenerable, temporary | ≤ App process or survives restart | None to minimal | API responses, computed results, UI state | +| **PreferenceService** | User settings, key-value | Permanent until changed | Low (can rebuild) | Theme, language, font size, shortcuts | +| **DataApiService** | Business data, structured | Permanent | **Severe** (irreplaceable) | Topics, messages, files, knowledge base | + +### Decision Flowchart + +Ask these questions in order: + +1. **Can this data be regenerated or lost without affecting the user?** + - Yes → **CacheService** + - No → Continue to #2 + +2. **Is this a user-configurable setting that affects app behavior?** + - Yes → Does it have a fixed key and stable value structure? + - Yes → **PreferenceService** + - No (structure changes often) → **DataApiService** + - No → Continue to #3 + +3. **Is this business data created/accumulated through user activity?** + - Yes → **DataApiService** + - No → Reconsider #1 (most data falls into one of these categories) + +--- + +## System Characteristics + +### CacheService - Runtime & Cache Data + +Use CacheService when: +- Data can be **regenerated or lost without user impact** +- No backup or cross-device synchronization needed +- Lifecycle is tied to component, window, or app session + +**Two sub-categories**: +1. **Performance cache**: Computed results, API responses, expensive calculations +2. **UI state cache**: Temporary settings, scroll positions, panel states + +**Three tiers based on persistence needs**: +- `useCache` (memory): Lost on app restart, component-level sharing +- `useSharedCache` (shared): Cross-window sharing, lost on restart +- `usePersistCache` (persist): Survives app restarts via localStorage + +```typescript +// Good: Temporary computed results +const [searchResults, setSearchResults] = useCache('search.results', []) + +// Good: UI state that can be lost +const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false) + +// Good: Recent items (nice to have, not critical) +const [recentSearches, setRecentSearches] = usePersistCache('search.recent', []) +``` + +### PreferenceService - User Preferences + +Use PreferenceService when: +- Data is a **user-modifiable setting that affects app behavior** +- Structure is key-value with **predefined keys** (users modify values, not keys) +- **Value structure is stable** (won't change frequently) +- Data loss has **low impact** (user can reconfigure) + +**Key characteristics**: +- Auto-syncs across all windows +- Each preference item should be **atomic** (one setting = one key) +- Values are typically: boolean, string, number, or simple array/object + +```typescript +// Good: App behavior settings +const [theme, setTheme] = usePreference('app.theme.mode') +const [language, setLanguage] = usePreference('app.language') +const [fontSize, setFontSize] = usePreference('chat.message.font_size') + +// Good: Feature toggles +const [showTimestamp, setShowTimestamp] = usePreference('chat.display.show_timestamp') +``` + +### DataApiService - User Data + +Use DataApiService when: +- Data is **business data accumulated through user activity** +- Data is **structured with dedicated schemas/tables** +- Users can **create, delete, modify records** (no fixed limit) +- Data loss would be **severe and irreplaceable** +- Data volume can be **large** (potentially GBs) + +**Key characteristics**: +- No automatic window sync (fetch on demand for fresh data) +- May contain sensitive data (encryption consideration) +- Requires proper CRUD operations and transactions + +```typescript +// Good: User-generated business data +const { data: topics } = useQuery('/topics') +const { trigger: createTopic } = useMutation('/topics', 'POST') + +// Good: Conversation history (irreplaceable) +const { data: messages } = useQuery('/messages', { query: { topicId } }) + +// Good: User files and knowledge base +const { data: files } = useQuery('/files') +``` + +--- + +## Common Anti-patterns + +| Wrong Choice | Why It's Wrong | Correct Choice | +|--------------|----------------|----------------| +| Storing AI provider configs in Cache | User loses configured providers on restart | **PreferenceService** | +| Storing conversation history in Preferences | Unbounded growth, complex structure | **DataApiService** | +| Storing topic list in Preferences | User-created records, can grow large | **DataApiService** | +| Storing theme/language in DataApi | Overkill for simple key-value settings | **PreferenceService** | +| Storing API responses in DataApi | Regenerable data, doesn't need persistence | **CacheService** | +| Storing window positions in Preferences | Can be lost without impact | **CacheService** (persist tier) | + +## Edge Cases + +- **Recently used items** (e.g., recent files, recent searches): Use `usePersistCache` - nice to have but not critical if lost +- **Draft content** (e.g., unsaved message): Use `useSharedCache` for cross-window, consider auto-save to DataApi for recovery +- **Computed statistics**: Use `useCache` with TTL - regenerate when expired +- **User-created templates/presets**: Use **DataApiService** - user-generated content that can grow + +--- + +## Architecture Overview + +``` +┌─────────────────┐ +│ React Components│ +└─────────┬───────┘ + │ +┌─────────▼───────┐ +│ React Hooks │ ← useDataApi, usePreference, useCache +└─────────┬───────┘ + │ +┌─────────▼───────┐ +│ Services │ ← DataApiService, PreferenceService, CacheService +└─────────┬───────┘ + │ +┌─────────▼───────┐ +│ IPC Layer │ ← Main Process Communication +└─────────────────┘ +``` + +## Related Source Code + +### Type Definitions +- `packages/shared/data/api/` - API type system +- `packages/shared/data/cache/` - Cache type definitions +- `packages/shared/data/preference/` - Preference type definitions + +### Main Process Implementation +- `src/main/data/api/` - API server and handlers +- `src/main/data/CacheService.ts` - Cache service +- `src/main/data/PreferenceService.ts` - Preference service +- `src/main/data/db/` - Database schemas + +### Renderer Process Implementation +- `src/renderer/src/data/DataApiService.ts` - API client +- `src/renderer/src/data/CacheService.ts` - Cache service +- `src/renderer/src/data/PreferenceService.ts` - Preference service +- `src/renderer/src/data/hooks/` - React hooks diff --git a/packages/shared/data/api/api-design-guidelines.md b/docs/en/references/data/api-design-guidelines.md similarity index 100% rename from packages/shared/data/api/api-design-guidelines.md rename to docs/en/references/data/api-design-guidelines.md diff --git a/docs/en/references/data/api-types.md b/docs/en/references/data/api-types.md new file mode 100644 index 0000000000..452b3a22c0 --- /dev/null +++ b/docs/en/references/data/api-types.md @@ -0,0 +1,238 @@ +# Data API Type System + +This directory contains the type definitions and utilities for Cherry Studio's Data API system, which provides type-safe IPC communication between renderer and main processes. + +## Directory Structure + +``` +packages/shared/data/api/ +├── index.ts # Barrel export for infrastructure types +├── apiTypes.ts # Core request/response types and API utilities +├── apiPaths.ts # Path template literal type utilities +├── apiErrors.ts # Error handling: ErrorCode, DataApiError class, factory +└── schemas/ + ├── index.ts # Schema composition (merges all domain schemas) + └── test.ts # Test API schema and DTOs +``` + +## File Responsibilities + +| File | Purpose | +|------|---------| +| `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities | +| `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) | +| `apiErrors.ts` | `ErrorCode` enum, `DataApiError` class, `DataApiErrorFactory`, retryability config | +| `index.ts` | Unified export of infrastructure types (not domain DTOs) | +| `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types | +| `schemas/*.ts` | Domain-specific API definitions and DTOs | + +## Import Conventions + +### Infrastructure Types (via barrel export) + +Use the barrel export for common API infrastructure: + +```typescript +import type { + DataRequest, + DataResponse, + ApiClient, + PaginatedResponse +} from '@shared/data/api' + +import { + ErrorCode, + DataApiError, + DataApiErrorFactory, + isDataApiError, + toDataApiError +} from '@shared/data/api' +``` + +### Domain DTOs (directly from schema files) + +Import domain-specific types directly from their schema files: + +```typescript +// Topic domain +import type { Topic, CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topic' + +// Message domain +import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message' + +// Test domain (development) +import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' +``` + +## Adding a New Domain Schema + +1. Create the schema file (e.g., `schemas/topic.ts`): + +```typescript +import type { PaginatedResponse } from '../apiTypes' + +// Domain models +export interface Topic { + id: string + name: string + createdAt: string +} + +export interface CreateTopicDto { + name: string +} + +// API Schema - validation happens via AssertValidSchemas in index.ts +export interface TopicSchemas { + '/topics': { + GET: { + response: PaginatedResponse // response is required + } + POST: { + body: CreateTopicDto + response: Topic + } + } + '/topics/:id': { + GET: { + params: { id: string } + response: Topic + } + } +} +``` + +**Validation**: Schemas are validated at composition level via `AssertValidSchemas` in `schemas/index.ts`: +- Ensures only valid HTTP methods (GET, POST, PUT, DELETE, PATCH) +- Requires `response` field for each endpoint +- Invalid schemas cause TypeScript errors at the composition point + +> **Design Guidelines**: Before creating new schemas, review the [API Design Guidelines](./api-design-guidelines.md) for path naming, HTTP methods, and error handling conventions. + +2. Register in `schemas/index.ts`: + +```typescript +import type { TopicSchemas } from './topic' + +// AssertValidSchemas provides fallback validation even if ValidateSchema is forgotten +export type ApiSchemas = AssertValidSchemas +``` + +3. Implement handlers in `src/main/data/api/handlers/` + +## Type Safety Features + +### Path Resolution + +The system uses template literal types to map concrete paths to schema paths: + +```typescript +// Concrete path '/topics/abc123' maps to schema path '/topics/:id' +api.get('/topics/abc123') // TypeScript knows this returns Topic +``` + +### Exhaustive Handler Checking + +`ApiImplementation` type ensures all schema endpoints have handlers: + +```typescript +// TypeScript will error if any endpoint is missing +const handlers: ApiImplementation = { + '/topics': { + GET: async () => { /* ... */ }, + POST: async ({ body }) => { /* ... */ } + } + // Missing '/topics/:id' would cause compile error +} +``` + +### Type-Safe Client + +`ApiClient` provides fully typed methods: + +```typescript +const topic = await api.get('/topics/123') // Returns Topic +const topics = await api.get('/topics', { query: { page: 1 } }) // Returns PaginatedResponse +await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto +``` + +## Error Handling + +The error system provides type-safe error handling with automatic retryability detection: + +```typescript +import { + DataApiError, + DataApiErrorFactory, + ErrorCode, + isDataApiError, + toDataApiError +} from '@shared/data/api' + +// Create errors using the factory (recommended) +throw DataApiErrorFactory.notFound('Topic', id) +throw DataApiErrorFactory.validation({ name: ['Name is required'] }) +throw DataApiErrorFactory.timeout('fetch topics', 3000) +throw DataApiErrorFactory.database(originalError, 'insert topic') + +// Or create directly with the class +throw new DataApiError( + ErrorCode.NOT_FOUND, + 'Topic not found', + 404, + { resource: 'Topic', id: 'abc123' } +) + +// Check if error is retryable (for automatic retry logic) +if (error instanceof DataApiError && error.isRetryable) { + await retry(operation) +} + +// Check error type +if (error instanceof DataApiError) { + if (error.isClientError) { + // 4xx - issue with the request + } else if (error.isServerError) { + // 5xx - server-side issue + } +} + +// Convert any error to DataApiError +const apiError = toDataApiError(unknownError, 'context') + +// Serialize for IPC (Main → Renderer) +const serialized = apiError.toJSON() + +// Reconstruct from IPC response (Renderer) +const reconstructed = DataApiError.fromJSON(response.error) +``` + +### Retryable Error Codes + +The following errors are automatically considered retryable: +- `SERVICE_UNAVAILABLE` (503) +- `TIMEOUT` (504) +- `RATE_LIMIT_EXCEEDED` (429) +- `DATABASE_ERROR` (500) +- `INTERNAL_SERVER_ERROR` (500) +- `RESOURCE_LOCKED` (423) + +## Architecture Overview + +``` +Renderer Main +──────────────────────────────────────────────────── +DataApiService ──IPC──► IpcAdapter ──► ApiServer + │ │ + │ ▼ + ApiClient MiddlewareEngine + (typed) │ + ▼ + Handlers + (typed) +``` + +- **Renderer**: Uses `DataApiService` with type-safe `ApiClient` interface +- **IPC**: Requests serialized via `IpcAdapter` +- **Main**: `ApiServer` routes to handlers through `MiddlewareEngine` +- **Type Safety**: End-to-end types from client call to handler implementation diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md new file mode 100644 index 0000000000..6e28e061fb --- /dev/null +++ b/docs/en/references/data/cache-overview.md @@ -0,0 +1,125 @@ +# Cache System Overview + +The Cache system provides a three-tier caching architecture for temporary and regenerable data across the Cherry Studio application. + +## Purpose + +CacheService handles data that: +- Can be **regenerated or lost without user impact** +- Requires no backup or cross-device synchronization +- Has lifecycle tied to component, window, or app session + +## Three-Tier Architecture + +| Tier | Scope | Persistence | Use Case | +|------|-------|-------------|----------| +| **Memory Cache** | Component-level | Lost on app restart | API responses, computed results | +| **Shared Cache** | Cross-window | Lost on app restart | Window state, cross-window coordination | +| **Persist Cache** | Cross-window + localStorage | Survives app restarts | Recent items, non-critical preferences | + +### Memory Cache +- Fastest access, in-process memory +- Isolated per renderer process +- Best for: expensive computations, API response caching + +### Shared Cache +- Synchronized across all windows via IPC +- Main process acts as the source of truth +- Best for: window layouts, shared UI state + +### Persist Cache +- Backed by localStorage in renderer +- Main process maintains authoritative copy +- Best for: recent files, search history, non-critical state + +## Key Features + +### TTL (Time To Live) Support +```typescript +// Cache with 30-second expiration +cacheService.set('temp.calculation', result, 30000) +``` + +### Hook Reference Tracking +- Prevents deletion of cache entries while React hooks are subscribed +- Automatic cleanup when components unmount + +### Cross-Window Synchronization +- Shared and Persist caches sync across all windows +- Uses IPC broadcast for real-time updates +- Main process resolves conflicts + +### Type Safety +- Schema-based keys for compile-time checking +- Casual methods for dynamic keys with manual typing + +## Data Categories + +### Performance Cache (Memory tier) +- Computed results from expensive operations +- API response caching +- Parsed/transformed data + +### UI State Cache (Shared tier) +- Sidebar collapsed state +- Panel dimensions +- Scroll positions + +### Non-Critical Persistence (Persist tier) +- Recently used items +- Search history +- User-customized but regenerable data + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ useCache │ │useSharedCache│ │usePersistCache│ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ CacheService │ │ +│ │ (Renderer) │ │ +│ └──────────┬──────────┘ │ +└─────────────────────────┼────────────────────────────────────┘ + │ IPC (shared/persist only) +┌─────────────────────────┼────────────────────────────────────┐ +│ Main Process ▼ │ +│ ┌─────────────────────┐ │ +│ │ CacheService │ │ +│ │ (Main) │ │ +│ └─────────────────────┘ │ +│ - Source of truth for shared/persist │ +│ - Broadcasts updates to all windows │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Main vs Renderer Responsibilities + +### Main Process CacheService +- Manages shared and persist cache storage +- Handles IPC requests from renderers +- Broadcasts updates to all windows +- Manages TTL expiration for shared caches + +### Renderer Process CacheService +- Manages local memory cache +- Proxies shared/persist operations to Main +- Handles hook subscriptions and updates +- Local TTL management for memory cache + +## Usage Summary + +For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md). + +| Method | Tier | Type Safety | +|--------|------|-------------| +| `useCache` / `get` / `set` | Memory | Schema-based keys | +| `getCasual` / `setCasual` | Memory | Dynamic keys (manual typing) | +| `useSharedCache` / `getShared` / `setShared` | Shared | Schema-based keys | +| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys (manual typing) | +| `usePersistCache` / `getPersist` / `setPersist` | Persist | Schema-based keys only | diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md new file mode 100644 index 0000000000..2dfd0f8453 --- /dev/null +++ b/docs/en/references/data/cache-usage.md @@ -0,0 +1,246 @@ +# Cache Usage Guide + +This guide covers how to use the Cache system in React components and services. + +## React Hooks + +### useCache (Memory Cache) + +Memory cache is lost on app restart. Best for temporary computed results. + +```typescript +import { useCache } from '@data/hooks/useCache' + +// Basic usage with default value +const [counter, setCounter] = useCache('ui.counter', 0) + +// Update the value +setCounter(counter + 1) + +// With TTL (30 seconds) +const [searchResults, setSearchResults] = useCache('search.results', [], { ttl: 30000 }) +``` + +### useSharedCache (Cross-Window Cache) + +Shared cache syncs across all windows, lost on app restart. + +```typescript +import { useSharedCache } from '@data/hooks/useCache' + +// Cross-window state +const [layout, setLayout] = useSharedCache('window.layout', defaultLayout) + +// Sidebar state shared between windows +const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false) +``` + +### usePersistCache (Persistent Cache) + +Persist cache survives app restarts via localStorage. + +```typescript +import { usePersistCache } from '@data/hooks/useCache' + +// Recent files list (survives restart) +const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files', []) + +// Search history +const [searchHistory, setSearchHistory] = usePersistCache('search.history', []) +``` + +## CacheService Direct Usage + +For non-React code or more control, use CacheService directly. + +### Memory Cache + +```typescript +import { cacheService } from '@data/CacheService' + +// Type-safe (schema key) +cacheService.set('temp.calculation', result) +const result = cacheService.get('temp.calculation') + +// With TTL (30 seconds) +cacheService.set('temp.calculation', result, 30000) + +// Casual (dynamic key, manual type) +cacheService.setCasual(`topic:${id}`, topicData) +const topic = cacheService.getCasual(`topic:${id}`) + +// Check existence +if (cacheService.has('temp.calculation')) { + // ... +} + +// Delete +cacheService.delete('temp.calculation') +cacheService.deleteCasual(`topic:${id}`) +``` + +### Shared Cache + +```typescript +// Type-safe (schema key) +cacheService.setShared('window.layout', layoutConfig) +const layout = cacheService.getShared('window.layout') + +// Casual (dynamic key) +cacheService.setSharedCasual(`window:${windowId}`, state) +const state = cacheService.getSharedCasual(`window:${windowId}`) + +// Delete +cacheService.deleteShared('window.layout') +cacheService.deleteSharedCasual(`window:${windowId}`) +``` + +### Persist Cache + +```typescript +// Schema keys only (no Casual methods for persist) +cacheService.setPersist('app.recent_files', recentFiles) +const files = cacheService.getPersist('app.recent_files') + +// Delete +cacheService.deletePersist('app.recent_files') +``` + +## Type-Safe vs Casual Methods + +### Type-Safe Methods +- Use predefined keys from cache schema +- Full auto-completion and type inference +- Compile-time key validation + +```typescript +// Key 'ui.counter' must exist in schema +const [counter, setCounter] = useCache('ui.counter', 0) +``` + +### Casual Methods +- Use dynamically constructed keys +- Require manual type specification via generics +- No compile-time key validation + +```typescript +// Dynamic key, must specify type +const topic = cacheService.getCasual(`topic:${id}`) +``` + +### When to Use Which + +| Scenario | Method | Example | +|----------|--------|---------| +| Fixed cache keys | Type-safe | `useCache('ui.counter')` | +| Entity caching by ID | Casual | `getCasual(\`topic:${id}\`)` | +| Session-based keys | Casual | `setCasual(\`session:${sessionId}\`)` | +| UI state | Type-safe | `useSharedCache('window.layout')` | + +## Common Patterns + +### Caching Expensive Computations + +```typescript +function useExpensiveData(input: string) { + const [cached, setCached] = useCache(`computed:${input}`, null) + + useEffect(() => { + if (cached === null) { + const result = expensiveComputation(input) + setCached(result) + } + }, [input, cached, setCached]) + + return cached +} +``` + +### Cross-Window Coordination + +```typescript +// Window A: Update shared state +const [activeFile, setActiveFile] = useSharedCache('editor.activeFile', null) +setActiveFile(selectedFile) + +// Window B: Reacts to change automatically +const [activeFile] = useSharedCache('editor.activeFile', null) +// activeFile updates when Window A changes it +``` + +### Recent Items with Limit + +```typescript +const [recentItems, setRecentItems] = usePersistCache('app.recentItems', []) + +const addRecentItem = (item: Item) => { + setRecentItems(prev => { + const filtered = prev.filter(i => i.id !== item.id) + return [item, ...filtered].slice(0, 10) // Keep last 10 + }) +} +``` + +### Cache with Expiration Check + +```typescript +interface CachedData { + data: T + timestamp: number +} + +function useCachedWithExpiry(key: string, fetcher: () => Promise, maxAge: number) { + const [cached, setCached] = useCache | null>(key, null) + const [data, setData] = useState(cached?.data ?? null) + + useEffect(() => { + const isExpired = !cached || Date.now() - cached.timestamp > maxAge + + if (isExpired) { + fetcher().then(result => { + setCached({ data: result, timestamp: Date.now() }) + setData(result) + }) + } + }, [key, maxAge]) + + return data +} +``` + +## Adding New Cache Keys + +### 1. Add to Cache Schema + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export interface CacheSchema { + // Existing keys... + 'myFeature.data': MyDataType +} +``` + +### 2. Define Value Type (if complex) + +```typescript +// packages/shared/data/cache/cacheValueTypes.ts +export interface MyDataType { + items: string[] + lastUpdated: number +} +``` + +### 3. Use in Code + +```typescript +// Now type-safe +const [data, setData] = useCache('myFeature.data', defaultValue) +``` + +## Best Practices + +1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival +2. **Use TTL for stale data**: Prevent serving outdated cached values +3. **Prefer type-safe keys**: Add to schema when possible +4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed +5. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) diff --git a/docs/en/references/data/data-api-in-main.md b/docs/en/references/data/data-api-in-main.md new file mode 100644 index 0000000000..90ca93ad0d --- /dev/null +++ b/docs/en/references/data/data-api-in-main.md @@ -0,0 +1,360 @@ +# DataApi in Main Process + +This guide covers how to implement API handlers, services, and repositories in the Main process. + +## Architecture Layers + +``` +Handlers → Services → Repositories → Database +``` + +- **Handlers**: Thin layer, extract params, call service, transform response +- **Services**: Business logic, validation, transaction coordination +- **Repositories**: Data access (for complex domains) +- **Database**: Drizzle ORM + SQLite + +## Implementing Handlers + +### Location +`src/main/data/api/handlers/` + +### Handler Responsibilities +- Extract parameters from request +- Delegate to business service +- Transform response for IPC +- **NO business logic here** + +### Example Handler + +```typescript +// handlers/topic.ts +import type { ApiImplementation } from '@shared/data/api' +import { TopicService } from '@data/services/TopicService' + +export const topicHandlers: Partial = { + '/topics': { + GET: async ({ query }) => { + const { page = 1, limit = 20 } = query ?? {} + return await TopicService.getInstance().list({ page, limit }) + }, + POST: async ({ body }) => { + return await TopicService.getInstance().create(body) + } + }, + '/topics/:id': { + GET: async ({ params }) => { + return await TopicService.getInstance().getById(params.id) + }, + PUT: async ({ params, body }) => { + return await TopicService.getInstance().replace(params.id, body) + }, + PATCH: async ({ params, body }) => { + return await TopicService.getInstance().update(params.id, body) + }, + DELETE: async ({ params }) => { + await TopicService.getInstance().delete(params.id) + } + } +} +``` + +### Register Handlers + +```typescript +// handlers/index.ts +import { topicHandlers } from './topic' +import { messageHandlers } from './message' + +export const allHandlers: ApiImplementation = { + ...topicHandlers, + ...messageHandlers +} +``` + +## Implementing Services + +### Location +`src/main/data/services/` + +### Service Responsibilities +- Business validation +- Transaction coordination +- Domain workflows +- Call repositories or direct Drizzle + +### Example Service + +```typescript +// services/TopicService.ts +import { DbService } from '@data/db/DbService' +import { TopicRepository } from '@data/repositories/TopicRepository' +import { DataApiErrorFactory } from '@shared/data/api' + +export class TopicService { + private static instance: TopicService + private topicRepo: TopicRepository + + private constructor() { + this.topicRepo = new TopicRepository() + } + + static getInstance(): TopicService { + if (!this.instance) { + this.instance = new TopicService() + } + return this.instance + } + + async list(options: { page: number; limit: number }) { + return await this.topicRepo.findAll(options) + } + + async getById(id: string) { + const topic = await this.topicRepo.findById(id) + if (!topic) { + throw DataApiErrorFactory.notFound('Topic', id) + } + return topic + } + + async create(data: CreateTopicDto) { + // Business validation + this.validateTopicData(data) + + return await this.topicRepo.create(data) + } + + async update(id: string, data: Partial) { + const existing = await this.getById(id) // Throws if not found + + return await this.topicRepo.update(id, data) + } + + async delete(id: string) { + await this.getById(id) // Throws if not found + await this.topicRepo.delete(id) + } + + private validateTopicData(data: CreateTopicDto) { + if (!data.name?.trim()) { + throw DataApiErrorFactory.validation({ name: ['Name is required'] }) + } + } +} +``` + +### Service with Transaction + +```typescript +async createTopicWithMessage(data: CreateTopicWithMessageDto) { + return await DbService.transaction(async (tx) => { + // Create topic + const topic = await this.topicRepo.create(data.topic, tx) + + // Create initial message + const message = await this.messageRepo.create({ + ...data.message, + topicId: topic.id + }, tx) + + return { topic, message } + }) +} +``` + +## Implementing Repositories + +### When to Use Repository Pattern + +Use repositories for **complex domains**: +- ✅ Complex queries (joins, subqueries, aggregations) +- ✅ GB-scale data requiring pagination +- ✅ Complex transactions involving multiple tables +- ✅ Reusable data access patterns +- ✅ High testing requirements + +### When to Use Direct Drizzle + +Use direct Drizzle for **simple domains**: +- ✅ Simple CRUD operations +- ✅ Small datasets (< 100MB) +- ✅ Domain-specific queries with no reuse +- ✅ Fast development is priority + +### Example Repository + +```typescript +// repositories/TopicRepository.ts +import { eq, desc, sql } from 'drizzle-orm' +import { DbService } from '@data/db/DbService' +import { topicTable } from '@data/db/schemas/topic' + +export class TopicRepository { + async findAll(options: { page: number; limit: number }) { + const { page, limit } = options + const offset = (page - 1) * limit + + const [items, countResult] = await Promise.all([ + DbService.db + .select() + .from(topicTable) + .orderBy(desc(topicTable.updatedAt)) + .limit(limit) + .offset(offset), + DbService.db + .select({ count: sql`count(*)` }) + .from(topicTable) + ]) + + return { + items, + total: countResult[0].count, + page, + limit + } + } + + async findById(id: string, tx?: Transaction) { + const db = tx || DbService.db + const [topic] = await db + .select() + .from(topicTable) + .where(eq(topicTable.id, id)) + .limit(1) + return topic ?? null + } + + async create(data: CreateTopicDto, tx?: Transaction) { + const db = tx || DbService.db + const [topic] = await db + .insert(topicTable) + .values(data) + .returning() + return topic + } + + async update(id: string, data: Partial, tx?: Transaction) { + const db = tx || DbService.db + const [topic] = await db + .update(topicTable) + .set(data) + .where(eq(topicTable.id, id)) + .returning() + return topic + } + + async delete(id: string, tx?: Transaction) { + const db = tx || DbService.db + await db + .delete(topicTable) + .where(eq(topicTable.id, id)) + } +} +``` + +### Example: Direct Drizzle in Service + +For simple domains, skip the repository: + +```typescript +// services/TagService.ts +import { eq } from 'drizzle-orm' +import { DbService } from '@data/db/DbService' +import { tagTable } from '@data/db/schemas/tag' + +export class TagService { + async getAll() { + return await DbService.db.select().from(tagTable) + } + + async create(name: string) { + const [tag] = await DbService.db + .insert(tagTable) + .values({ name }) + .returning() + return tag + } + + async delete(id: string) { + await DbService.db + .delete(tagTable) + .where(eq(tagTable.id, id)) + } +} +``` + +## Error Handling + +### Using DataApiErrorFactory + +```typescript +import { DataApiErrorFactory } from '@shared/data/api' + +// Not found +throw DataApiErrorFactory.notFound('Topic', id) + +// Validation error +throw DataApiErrorFactory.validation({ + name: ['Name is required', 'Name must be at least 3 characters'], + email: ['Invalid email format'] +}) + +// Database error +try { + await db.insert(table).values(data) +} catch (error) { + throw DataApiErrorFactory.database(error, 'insert topic') +} + +// Invalid operation +throw DataApiErrorFactory.invalidOperation( + 'delete root message', + 'cascade=true required' +) + +// Conflict +throw DataApiErrorFactory.conflict('Topic name already exists') + +// Timeout +throw DataApiErrorFactory.timeout('fetch topics', 3000) +``` + +## Adding New Endpoints + +### Step-by-Step + +1. **Define schema** in `packages/shared/data/api/schemas/` + +```typescript +// schemas/topic.ts +export interface TopicSchemas { + '/topics': { + GET: { response: PaginatedResponse } + POST: { body: CreateTopicDto; response: Topic } + } +} +``` + +2. **Register schema** in `schemas/index.ts` + +```typescript +export type ApiSchemas = AssertValidSchemas +``` + +3. **Create service** in `services/` + +4. **Create repository** (if complex) in `repositories/` + +5. **Implement handler** in `handlers/` + +6. **Register handler** in `handlers/index.ts` + +## Best Practices + +1. **Keep handlers thin**: Only extract params and call services +2. **Put logic in services**: All business rules belong in services +3. **Use repositories selectively**: Simple CRUD doesn't need a repository +4. **Always use `.returning()`**: Get inserted/updated data without re-querying +5. **Support transactions**: Accept optional `tx` parameter in repositories +6. **Validate in services**: Business validation belongs in the service layer +7. **Use error factory**: Consistent error creation with `DataApiErrorFactory` diff --git a/docs/en/references/data/data-api-in-renderer.md b/docs/en/references/data/data-api-in-renderer.md new file mode 100644 index 0000000000..abe79ca8c1 --- /dev/null +++ b/docs/en/references/data/data-api-in-renderer.md @@ -0,0 +1,298 @@ +# DataApi in Renderer + +This guide covers how to use the DataApi system in React components and the renderer process. + +## React Hooks + +### useQuery (GET Requests) + +Fetch data with automatic caching and revalidation via SWR. + +```typescript +import { useQuery } from '@data/hooks/useDataApi' + +// Basic usage +const { data, loading, error } = useQuery('/topics') + +// With query parameters +const { data: messages } = useQuery('/messages', { + query: { topicId: 'abc123', page: 1, limit: 20 } +}) + +// With path parameters (inferred from path) +const { data: topic } = useQuery('/topics/abc123') + +// Conditional fetching +const { data } = useQuery(topicId ? `/topics/${topicId}` : null) + +// With refresh callback +const { data, mutate } = useQuery('/topics') +// Refresh data +await mutate() +``` + +### useMutation (POST/PUT/PATCH/DELETE) + +Perform data modifications with loading states. + +```typescript +import { useMutation } from '@data/hooks/useDataApi' + +// Create (POST) +const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') +const newTopic = await createTopic({ body: { name: 'New Topic' } }) + +// Update (PUT - full replacement) +const { trigger: replaceTopic } = useMutation('/topics/abc123', 'PUT') +await replaceTopic({ body: { name: 'Updated Name', description: '...' } }) + +// Partial Update (PATCH) +const { trigger: updateTopic } = useMutation('/topics/abc123', 'PATCH') +await updateTopic({ body: { name: 'New Name' } }) + +// Delete +const { trigger: deleteTopic } = useMutation('/topics/abc123', 'DELETE') +await deleteTopic() +``` + +## DataApiService Direct Usage + +For non-React code or more control. + +```typescript +import { dataApiService } from '@data/DataApiService' + +// GET request +const topics = await dataApiService.get('/topics') +const topic = await dataApiService.get('/topics/abc123') +const messages = await dataApiService.get('/topics/abc123/messages', { + query: { page: 1, limit: 20 } +}) + +// POST request +const newTopic = await dataApiService.post('/topics', { + body: { name: 'New Topic' } +}) + +// PUT request (full replacement) +const updatedTopic = await dataApiService.put('/topics/abc123', { + body: { name: 'Updated', description: 'Full update' } +}) + +// PATCH request (partial update) +const patchedTopic = await dataApiService.patch('/topics/abc123', { + body: { name: 'Just update name' } +}) + +// DELETE request +await dataApiService.delete('/topics/abc123') +``` + +## Error Handling + +### With Hooks + +```typescript +function TopicList() { + const { data, loading, error } = useQuery('/topics') + + if (loading) return + if (error) { + if (error.code === ErrorCode.NOT_FOUND) { + return + } + return + } + + return +} +``` + +### With Try-Catch + +```typescript +import { DataApiError, ErrorCode } from '@shared/data/api' + +try { + await dataApiService.post('/topics', { body: data }) +} catch (error) { + if (error instanceof DataApiError) { + switch (error.code) { + case ErrorCode.VALIDATION_ERROR: + // Handle validation errors + const fieldErrors = error.details?.fieldErrors + break + case ErrorCode.NOT_FOUND: + // Handle not found + break + case ErrorCode.CONFLICT: + // Handle conflict + break + default: + // Handle other errors + } + } +} +``` + +### Retryable Errors + +```typescript +if (error instanceof DataApiError && error.isRetryable) { + // Safe to retry: SERVICE_UNAVAILABLE, TIMEOUT, etc. + await retry(operation) +} +``` + +## Common Patterns + +### List with Pagination + +```typescript +function TopicListWithPagination() { + const [page, setPage] = useState(1) + const { data, loading } = useQuery('/topics', { + query: { page, limit: 20 } + }) + + return ( + <> + + + + ) +} +``` + +### Create Form + +```typescript +function CreateTopicForm() { + const { trigger: createTopic, isMutating } = useMutation('/topics', 'POST') + const { mutate } = useQuery('/topics') // For revalidation + + const handleSubmit = async (data: CreateTopicDto) => { + try { + await createTopic({ body: data }) + await mutate() // Refresh list + toast.success('Topic created') + } catch (error) { + toast.error('Failed to create topic') + } + } + + return ( + + {/* form fields */} + + + ) +} +``` + +### Optimistic Updates + +```typescript +function TopicItem({ topic }: { topic: Topic }) { + const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH') + const { mutate } = useQuery('/topics') + + const handleToggleStar = async () => { + // Optimistically update the cache + await mutate( + current => ({ + ...current, + items: current.items.map(t => + t.id === topic.id ? { ...t, starred: !t.starred } : t + ) + }), + { revalidate: false } + ) + + try { + await updateTopic({ body: { starred: !topic.starred } }) + } catch (error) { + // Revert on failure + await mutate() + toast.error('Failed to update') + } + } + + return ( +
+ {topic.name} + +
+ ) +} +``` + +### Dependent Queries + +```typescript +function MessageList({ topicId }: { topicId: string }) { + // First query: get topic + const { data: topic } = useQuery(`/topics/${topicId}`) + + // Second query: depends on first (only runs when topic exists) + const { data: messages } = useQuery( + topic ? `/topics/${topicId}/messages` : null + ) + + if (!topic) return + + return ( +
+

{topic.name}

+ +
+ ) +} +``` + +### Polling for Updates + +```typescript +function LiveTopicList() { + const { data } = useQuery('/topics', { + refreshInterval: 5000 // Poll every 5 seconds + }) + + return +} +``` + +## Type Safety + +The API is fully typed based on schema definitions: + +```typescript +// Types are inferred from schema +const { data } = useQuery('/topics') +// data is typed as PaginatedResponse + +const { trigger } = useMutation('/topics', 'POST') +// trigger expects { body: CreateTopicDto } +// returns Topic + +// Path parameters are type-checked +const { data: topic } = useQuery('/topics/abc123') +// TypeScript knows this returns Topic +``` + +## Best Practices + +1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states +2. **Handle loading states**: Always show feedback while data is loading +3. **Handle errors gracefully**: Provide meaningful error messages to users +4. **Revalidate after mutations**: Keep the UI in sync with the database +5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready +6. **Batch related operations**: Consider using transactions for multiple updates diff --git a/docs/en/references/data/data-api-overview.md b/docs/en/references/data/data-api-overview.md new file mode 100644 index 0000000000..883bd57f49 --- /dev/null +++ b/docs/en/references/data/data-api-overview.md @@ -0,0 +1,158 @@ +# DataApi System Overview + +The DataApi system provides type-safe IPC communication for business data operations between the Renderer and Main processes. + +## Purpose + +DataApiService handles data that: +- Is **business data accumulated through user activity** +- Has **dedicated database schemas/tables** +- Users can **create, delete, modify records** without fixed limits +- Would be **severe and irreplaceable** if lost +- Can grow to **large volumes** (potentially GBs) + +## Key Characteristics + +### Type-Safe Communication +- End-to-end TypeScript types from client call to handler +- Path parameter inference from route definitions +- Compile-time validation of request/response shapes + +### RESTful-Style API +- Familiar HTTP semantics (GET, POST, PUT, PATCH, DELETE) +- Resource-based URL patterns (`/topics/:id/messages`) +- Standard status codes and error responses + +### On-Demand Data Access +- No automatic caching (fetch fresh data when needed) +- Explicit cache control via query options +- Supports large datasets with pagination + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ React Components │ │ +│ │ - useQuery('/topics') │ │ +│ │ - useMutation('/topics', 'POST') │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ DataApiService (Renderer) │ │ +│ │ - Type-safe ApiClient interface │ │ +│ │ - Request serialization │ │ +│ │ - Automatic retry with exponential backoff │ │ +│ │ - Error handling and transformation │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +└──────────────────────────────┼───────────────────────────────────┘ + │ IPC +┌──────────────────────────────┼───────────────────────────────────┐ +│ Main Process ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ IpcAdapter │ │ +│ │ - Receives IPC requests │ │ +│ │ - Routes to ApiServer │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ApiServer │ │ +│ │ - Request routing by path and method │ │ +│ │ - Middleware pipeline processing │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Handlers (api/handlers/) │ │ +│ │ - Thin layer: extract params, call service, transform │ │ +│ │ - NO business logic here │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Services (services/) │ │ +│ │ - Business logic and validation │ │ +│ │ - Transaction coordination │ │ +│ │ - Domain workflows │ │ +│ └──────────────────────────┬──────────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────┴─────────────────────┐ │ +│ ▼ ▼ │ +│ ┌───────────────┐ ┌─────────────────────┐ │ +│ │ Repositories │ │ Direct Drizzle │ │ +│ │ (Complex) │ │ (Simple domains) │ │ +│ │ - Query logic │ │ - Inline queries │ │ +│ └───────┬───────┘ └──────────┬──────────┘ │ +│ │ │ │ +│ └────────────────────┬───────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ SQLite Database (via Drizzle ORM) │ │ +│ │ - topic, message, file tables │ │ +│ │ - Full-text search indexes │ │ +│ └─────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Four-Layer Architecture + +### 1. API Layer (Handlers) +- **Location**: `src/main/data/api/handlers/` +- **Responsibility**: HTTP-like interface layer +- **Does**: Extract parameters, call services, transform responses +- **Does NOT**: Contain business logic + +### 2. Business Logic Layer (Services) +- **Location**: `src/main/data/services/` +- **Responsibility**: Domain logic and workflows +- **Does**: Validation, transaction coordination, orchestration +- **Uses**: Repositories or direct Drizzle queries + +### 3. Data Access Layer (Repositories) +- **Location**: `src/main/data/repositories/` +- **Responsibility**: Complex data operations +- **When to use**: Complex queries, large datasets, reusable patterns +- **Alternative**: Direct Drizzle for simple CRUD + +### 4. Database Layer +- **Location**: `src/main/data/db/` +- **Technology**: SQLite + Drizzle ORM +- **Schemas**: `db/schemas/` directory + +## Data Access Pattern Decision + +### Use Repository Pattern When: +- ✅ Complex queries (joins, subqueries, aggregations) +- ✅ GB-scale data requiring optimization and pagination +- ✅ Complex transactions involving multiple tables +- ✅ Reusable data access patterns across services +- ✅ High testing requirements (mock data access) + +### Use Direct Drizzle When: +- ✅ Simple CRUD operations +- ✅ Small datasets (< 100MB) +- ✅ Domain-specific queries with no reuse potential +- ✅ Fast development is priority + +## Key Features + +### Automatic Retry +- Exponential backoff for transient failures +- Configurable retry count and delays +- Skips retry for client errors (4xx) + +### Error Handling +- Typed error codes (`ErrorCode` enum) +- `DataApiError` class with retryability detection +- Factory methods for consistent error creation + +### Request Timeout +- Configurable per-request timeouts +- Automatic cancellation of stale requests + +## Usage Summary + +For detailed code examples, see: +- [DataApi in Renderer](./data-api-in-renderer.md) - Client-side usage +- [DataApi in Main](./data-api-in-main.md) - Server-side implementation +- [API Design Guidelines](./api-design-guidelines.md) - RESTful conventions +- [API Types](./api-types.md) - Type system details diff --git a/docs/en/references/data/database-patterns.md b/docs/en/references/data/database-patterns.md new file mode 100644 index 0000000000..10d3a44593 --- /dev/null +++ b/docs/en/references/data/database-patterns.md @@ -0,0 +1,199 @@ +# Database Schema Guidelines + +## Naming Conventions + +- **Table names**: Use **singular** form with snake_case (e.g., `topic`, `message`, `app_state`) +- **Export names**: Use `xxxTable` pattern (e.g., `topicTable`, `messageTable`) +- **Column names**: Drizzle auto-infers from property names, no need to specify explicitly + +## Column Helpers + +All helpers are exported from `./schemas/columnHelpers.ts`. + +### Primary Keys + +| Helper | UUID Version | Use Case | +|--------|--------------|----------| +| `uuidPrimaryKey()` | v4 (random) | General purpose tables | +| `uuidPrimaryKeyOrdered()` | v7 (time-ordered) | Large tables with time-based queries | + +**Usage:** + +```typescript +import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers' + +// General purpose table +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + name: text(), + ... +}) + +// Large table with time-ordered data +export const messageTable = sqliteTable('message', { + id: uuidPrimaryKeyOrdered(), + content: text(), + ... +}) +``` + +**Behavior:** + +- ID is auto-generated if not provided during insert +- Can be manually specified for migration scenarios +- Use `.returning()` to get the generated ID after insert + +### Timestamps + +| Helper | Fields | Use Case | +|--------|--------|----------| +| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete | +| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete | + +**Usage:** + +```typescript +import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers' + +// Without soft delete +export const tagTable = sqliteTable('tag', { + id: uuidPrimaryKey(), + name: text(), + ...createUpdateTimestamps +}) + +// With soft delete +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + name: text(), + ...createUpdateDeleteTimestamps +}) +``` + +**Behavior:** + +- `createdAt`: Auto-set to `Date.now()` on insert +- `updatedAt`: Auto-set on insert, auto-updated on update +- `deletedAt`: `null` by default, set to timestamp for soft delete + +## JSON Fields + +For JSON column support, use `{ mode: 'json' }`: + +```typescript +data: text({ mode: 'json' }).$type() +``` + +Drizzle handles JSON serialization/deserialization automatically. + +## Foreign Keys + +### Basic Usage + +```typescript +// SET NULL: preserve record when referenced record is deleted +groupId: text().references(() => groupTable.id, { onDelete: 'set null' }) + +// CASCADE: delete record when referenced record is deleted +topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }) +``` + +### Self-Referencing Foreign Keys + +For self-referencing foreign keys (e.g., tree structures with parentId), **always use the `foreignKey` operator** in the table's third parameter: + +```typescript +import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +export const messageTable = sqliteTable( + 'message', + { + id: uuidPrimaryKeyOrdered(), + parentId: text(), // Do NOT use .references() here + // ...other fields + }, + (t) => [ + // Use foreignKey operator for self-referencing + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null') + ] +) +``` + +**Why this approach:** +- Avoids TypeScript circular reference issues (no need for `AnySQLiteColumn` type annotation) +- More explicit and readable +- Allows chaining `.onDelete()` / `.onUpdate()` actions + +### Circular Foreign Key References + +**Avoid circular foreign key references between tables.** For example: + +```typescript +// ❌ BAD: Circular FK between tables +// tableA.currentItemId -> tableB.id +// tableB.ownerId -> tableA.id +``` + +If you encounter a scenario that seems to require circular references: + +1. **Identify which relationship is "weaker"** - typically the one that can be null or is less critical for data integrity +2. **Remove the FK constraint from the weaker side** - let the application layer handle validation and consistency (this is known as "soft references" pattern) +3. **Document the application-layer constraint** in code comments + +```typescript +// ✅ GOOD: Break the cycle by handling one side at application layer +export const topicTable = sqliteTable('topic', { + id: uuidPrimaryKey(), + // Application-managed reference (no FK constraint) + // Validated by TopicService.setCurrentMessage() + currentMessageId: text(), +}) + +export const messageTable = sqliteTable('message', { + id: uuidPrimaryKeyOrdered(), + // Database-enforced FK + topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }), +}) +``` + +**Why soft references for SQLite:** +- SQLite does not support `DEFERRABLE` constraints (unlike PostgreSQL/Oracle) +- Application-layer validation provides equivalent data integrity +- Simplifies insert/update operations without transaction ordering concerns + +## Migrations + +Generate migrations after schema changes: + +```bash +yarn db:migrations:generate +``` + +## Field Generation Rules + +The schema uses Drizzle's auto-generation features. Follow these rules: + +### Auto-generated fields (NEVER set manually) + +- `id`: Uses `$defaultFn()` with UUID v4/v7, auto-generated on insert +- `createdAt`: Uses `$defaultFn()` with `Date.now()`, auto-generated on insert +- `updatedAt`: Uses `$defaultFn()` and `$onUpdateFn()`, auto-updated on every update + +### Using `.returning()` pattern + +Always use `.returning()` to get inserted/updated data instead of re-querying: + +```typescript +// Good: Use returning() +const [row] = await db.insert(table).values(data).returning() +return rowToEntity(row) + +// Avoid: Re-query after insert (unnecessary database round-trip) +await db.insert(table).values({ id, ...data }) +return this.getById(id) +``` + +### Soft delete support + +The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`). +Business logic can choose to use soft delete or hard delete based on requirements. diff --git a/docs/en/references/data/preference-overview.md b/docs/en/references/data/preference-overview.md new file mode 100644 index 0000000000..755571c659 --- /dev/null +++ b/docs/en/references/data/preference-overview.md @@ -0,0 +1,144 @@ +# Preference System Overview + +The Preference system provides centralized management for user configuration and application settings with cross-window synchronization. + +## Purpose + +PreferenceService handles data that: +- Is a **user-modifiable setting that affects app behavior** +- Has a **fixed key structure** with stable value types +- Needs to **persist permanently** until explicitly changed +- Should **sync automatically** across all application windows + +## Key Characteristics + +### Fixed Key Structure +- Predefined keys in the schema (users modify values, not keys) +- Supports 158 configuration items +- Nested key paths supported (e.g., `app.theme.mode`) + +### Atomic Values +- Each preference item represents one logical setting +- Values are typically: boolean, string, number, or simple array/object +- Changes are independent (updating one doesn't affect others) + +### Cross-Window Synchronization +- Changes automatically broadcast to all windows +- Consistent state across main window, mini window, etc. +- Conflict resolution handled by Main process + +## Update Strategies + +### Optimistic Updates (Default) +```typescript +// UI updates immediately, then syncs to database +await preferenceService.set('app.theme.mode', 'dark') +``` +- Best for: frequent, non-critical settings +- Behavior: Local state updates first, then persists +- Rollback: Automatic revert if persistence fails + +### Pessimistic Updates +```typescript +// Waits for database confirmation before updating UI +await preferenceService.set('api.key', 'secret', { optimistic: false }) +``` +- Best for: critical settings (API keys, security options) +- Behavior: Persists first, then updates local state +- No rollback needed: UI only updates on success + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ usePreference Hook │ │ +│ │ - Subscribe to preference changes │ │ +│ │ - Optimistic/pessimistic update support │ │ +│ └──────────────────────┬──────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ PreferenceService (Renderer) │ │ +│ │ - Local cache for fast reads │ │ +│ │ - IPC proxy to Main process │ │ +│ │ - Subscription management │ │ +│ └──────────────────────┬──────────────────────────┘ │ +└─────────────────────────┼────────────────────────────────────┘ + │ IPC +┌─────────────────────────┼────────────────────────────────────┐ +│ Main Process ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ PreferenceService (Main) │ │ +│ │ - Full memory cache of all preferences │ │ +│ │ - SQLite persistence via Drizzle ORM │ │ +│ │ - Cross-window broadcast │ │ +│ └──────────────────────┬──────────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ SQLite Database (preference table) │ │ +│ │ - scope + key structure │ │ +│ │ - JSON value storage │ │ +│ └─────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Main vs Renderer Responsibilities + +### Main Process PreferenceService +- **Source of truth** for all preferences +- Full memory cache for fast access +- SQLite persistence via preference table +- Broadcasts changes to all renderer windows +- Handles batch operations and transactions + +### Renderer Process PreferenceService +- Local cache for read performance +- Proxies write operations to Main +- Manages React hook subscriptions +- Handles optimistic update rollbacks +- Listens for cross-window updates + +## Database Schema + +Preferences are stored in the `preference` table: + +```typescript +// Simplified schema +{ + scope: string // e.g., 'default', 'user' + key: string // e.g., 'app.theme.mode' + value: json // The preference value + createdAt: number + updatedAt: number +} +``` + +## Preference Categories + +### Application Settings +- Theme mode, language, font sizes +- Window behavior, startup options + +### Feature Toggles +- Show/hide UI elements +- Enable/disable features + +### User Customization +- Keyboard shortcuts +- Default values for operations + +### Provider Configuration +- AI provider settings +- API endpoints and tokens + +## Usage Summary + +For detailed code examples and API usage, see [Preference Usage Guide](./preference-usage.md). + +| Operation | Hook | Service Method | +|-----------|------|----------------| +| Read single | `usePreference(key)` | `preferenceService.get(key)` | +| Write single | `setPreference(value)` | `preferenceService.set(key, value)` | +| Read multiple | `usePreferences([...keys])` | `preferenceService.getMultiple([...keys])` | +| Write multiple | - | `preferenceService.setMultiple({...})` | diff --git a/docs/en/references/data/preference-usage.md b/docs/en/references/data/preference-usage.md new file mode 100644 index 0000000000..70a0586724 --- /dev/null +++ b/docs/en/references/data/preference-usage.md @@ -0,0 +1,260 @@ +# Preference Usage Guide + +This guide covers how to use the Preference system in React components and services. + +## React Hooks + +### usePreference (Single Preference) + +```typescript +import { usePreference } from '@data/hooks/usePreference' + +// Basic usage - optimistic updates (default) +const [theme, setTheme] = usePreference('app.theme.mode') + +// Update the value +await setTheme('dark') + +// With pessimistic updates (wait for confirmation) +const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false }) +``` + +### usePreferences (Multiple Preferences) + +```typescript +import { usePreferences } from '@data/hooks/usePreference' + +// Read multiple preferences at once +const { theme, language, fontSize } = usePreferences([ + 'app.theme.mode', + 'app.language', + 'chat.message.font_size' +]) +``` + +## Update Strategies + +### Optimistic Updates (Default) + +UI updates immediately, then syncs to database. Automatic rollback on failure. + +```typescript +const [theme, setTheme] = usePreference('app.theme.mode') + +const handleThemeChange = async (newTheme: string) => { + try { + await setTheme(newTheme) // UI updates immediately + } catch (error) { + // UI automatically rolls back + console.error('Theme update failed:', error) + } +} +``` + +**Best for:** +- Frequent changes (theme, font size) +- Non-critical settings +- Better perceived performance + +### Pessimistic Updates + +Waits for database confirmation before updating UI. + +```typescript +const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false }) + +const handleApiKeyChange = async (newKey: string) => { + try { + await setApiKey(newKey) // Waits for DB confirmation + toast.success('API key saved') + } catch (error) { + toast.error('Failed to save API key') + } +} +``` + +**Best for:** +- Security-sensitive settings (API keys, passwords) +- Settings that affect external services +- When confirmation feedback is important + +## PreferenceService Direct Usage + +For non-React code or batch operations. + +### Get Preferences + +```typescript +import { preferenceService } from '@data/PreferenceService' + +// Get single preference +const theme = await preferenceService.get('app.theme.mode') + +// Get multiple preferences +const settings = await preferenceService.getMultiple([ + 'app.theme.mode', + 'app.language' +]) +// Returns: { 'app.theme.mode': 'dark', 'app.language': 'en' } + +// Get with default value +const fontSize = await preferenceService.get('chat.message.font_size') ?? 14 +``` + +### Set Preferences + +```typescript +// Set single preference (optimistic by default) +await preferenceService.set('app.theme.mode', 'dark') + +// Set with pessimistic update +await preferenceService.set('api.key', 'secret', { optimistic: false }) + +// Set multiple preferences at once +await preferenceService.setMultiple({ + 'app.theme.mode': 'dark', + 'app.language': 'en', + 'chat.message.font_size': 16 +}) +``` + +### Subscribe to Changes + +```typescript +// Subscribe to preference changes (useful in services) +const unsubscribe = preferenceService.subscribe('app.theme.mode', (newValue) => { + console.log('Theme changed to:', newValue) +}) + +// Cleanup when done +unsubscribe() +``` + +## Common Patterns + +### Settings Form + +```typescript +function SettingsForm() { + const [theme, setTheme] = usePreference('app.theme.mode') + const [language, setLanguage] = usePreference('app.language') + const [fontSize, setFontSize] = usePreference('chat.message.font_size') + + return ( +
+ + + + + setFontSize(Number(e.target.value))} + min={12} + max={24} + /> +
+ ) +} +``` + +### Feature Toggle + +```typescript +function ChatMessage({ message }) { + const [showTimestamp] = usePreference('chat.display.show_timestamp') + + return ( +
+

{message.content}

+ {showTimestamp && {message.createdAt}} +
+ ) +} +``` + +### Conditional Rendering Based on Settings + +```typescript +function App() { + const [theme] = usePreference('app.theme.mode') + const [sidebarPosition] = usePreference('app.sidebar.position') + + return ( +
+ {sidebarPosition === 'left' && } + + {sidebarPosition === 'right' && } +
+ ) +} +``` + +### Batch Settings Update + +```typescript +async function resetToDefaults() { + await preferenceService.setMultiple({ + 'app.theme.mode': 'system', + 'app.language': 'en', + 'chat.message.font_size': 14, + 'chat.display.show_timestamp': true + }) +} +``` + +## Adding New Preference Keys + +### 1. Add to Preference Schema + +```typescript +// packages/shared/data/preference/preferenceSchemas.ts +export interface PreferenceSchema { + // Existing keys... + 'myFeature.enabled': boolean + 'myFeature.options': MyFeatureOptions +} +``` + +### 2. Set Default Value + +```typescript +// Same file or separate defaults file +export const preferenceDefaults: Partial = { + // Existing defaults... + 'myFeature.enabled': true, + 'myFeature.options': { mode: 'auto', limit: 100 } +} +``` + +### 3. Use in Code + +```typescript +// Now type-safe with auto-completion +const [enabled, setEnabled] = usePreference('myFeature.enabled') +``` + +## Best Practices + +1. **Choose update strategy wisely**: Optimistic for UX, pessimistic for critical settings +2. **Batch related updates**: Use `setMultiple` when changing multiple related settings +3. **Provide sensible defaults**: All preferences should have default values +4. **Keep values atomic**: One preference = one logical setting +5. **Use consistent naming**: Follow `domain.feature.setting` pattern + +## Preference vs Other Storage + +| Scenario | Use | +|----------|-----| +| User theme preference | `usePreference('app.theme.mode')` | +| Window position | `usePersistCache` (can be lost without impact) | +| API key | `usePreference` with pessimistic updates | +| Search history | `usePersistCache` (nice to have) | +| Conversation history | `DataApiService` (business data) | diff --git a/docs/en/references/data/v2-migration-guide.md b/docs/en/references/data/v2-migration-guide.md new file mode 100644 index 0000000000..86d597223e --- /dev/null +++ b/docs/en/references/data/v2-migration-guide.md @@ -0,0 +1,64 @@ +# Migration V2 (Main Process) + +Architecture for the new one-shot migration from the legacy Dexie + Redux Persist stores into the SQLite schema. This module owns orchestration, data access helpers, migrator plugins, and IPC entry points used by the renderer migration window. + +## Directory Layout + +``` +src/main/data/migration/v2/ +├── core/ # Engine + shared context +├── migrators/ # Domain-specific migrators and mappings +├── utils/ # Data source readers (Redux, Dexie, streaming JSON) +├── window/ # IPC handlers + migration window manager +└── index.ts # Public exports for main process +``` + +## Core Contracts + +- `core/MigrationEngine.ts` coordinates all migrators in order, surfaces progress to the UI, and marks status in `app_state.key = 'migration_v2_status'`. It will clear new-schema tables before running and abort on any validation failure. +- `core/MigrationContext.ts` builds the shared context passed to every migrator: + - `sources`: `ConfigManager` (ElectronStore), `ReduxStateReader` (parsed Redux Persist data), `DexieFileReader` (JSON exports) + - `db`: current SQLite connection + - `sharedData`: `Map` for passing cross-cutting info between migrators + - `logger`: `loggerService` scoped to migration +- `@shared/data/migration/v2/types` defines stages, results, and validation stats used across main and renderer. + +## Migrators + +- Base contract: extend `migrators/BaseMigrator.ts` and implement: + - `id`, `name`, `description`, `order` (lower runs first) + - `prepare(ctx)`: dry-run checks, counts, and staging data; return `PrepareResult` + - `execute(ctx)`: perform inserts/updates; manage your own transactions; report progress via `reportProgress` + - `validate(ctx)`: verify counts and integrity; return `ValidateResult` with stats (`sourceCount`, `targetCount`, `skippedCount`) and any `errors` +- Registration: list migrators (in order) in `migrators/index.ts` so the engine can sort and run them. +- Current migrators: + - `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`. + - `AssistantMigrator`, `KnowledgeMigrator`, `ChatMigrator` (placeholders): scaffolding and TODO notes for future tables. +- Conventions: + - All logging goes through `loggerService` with a migrator-specific context. + - Use `MigrationContext.sources` instead of accessing raw files/stores directly. + - Use `sharedData` to pass IDs or lookup tables between migrators (e.g., assistant -> chat references) instead of re-reading sources. + - Stream large Dexie exports (`JSONStreamReader`) and batch inserts to avoid memory spikes. + - Count validation is mandatory; engine will fail the run if `targetCount < sourceCount - skippedCount` or if `ValidateResult.errors` is non-empty. + - Keep migrations idempotent per run—engine clears target tables before it starts, but each migrator should tolerate retries within the same run. + +## Utilities + +- `utils/ReduxStateReader.ts`: safe accessor for categorized Redux Persist data with dot-path lookup. +- `utils/DexieFileReader.ts`: reads exported Dexie JSON tables; can stream large tables. +- `utils/JSONStreamReader.ts`: streaming reader with batching, counting, and sampling helpers for very large arrays. + +## Window & IPC Integration + +- `window/MigrationIpcHandler.ts` exposes IPC channels for the migration UI: + - Receives Redux data and Dexie export path, starts the engine, and streams progress back to renderer. + - Manages backup flow (dialogs via `BackupManager`) and retry/cancel/restart actions. +- `window/MigrationWindowManager.ts` creates the frameless migration window, handles lifecycle, and relaunch instructions after completion in production. + +## Implementation Checklist for New Migrators + +- [ ] Add mapping definitions (if needed) under `migrators/mappings/`. +- [ ] Implement `prepare/execute/validate` with explicit counts, batch inserts, and integrity checks. +- [ ] Wire progress updates through `reportProgress` so UI shows per-migrator progress. +- [ ] Register the migrator in `migrators/index.ts` with the correct `order`. +- [ ] Add any new target tables to `MigrationEngine.verifyAndClearNewTables` once those tables exist. diff --git a/packages/shared/data/README.md b/packages/shared/data/README.md index 9428522575..30d30ff54d 100644 --- a/packages/shared/data/README.md +++ b/packages/shared/data/README.md @@ -1,124 +1,50 @@ -# Cherry Studio Shared Data +# Shared Data Types -This directory contains shared type definitions and schemas for the Cherry Studio data management systems. These files provide type safety and consistency across the entire application. +This directory contains shared type definitions for Cherry Studio's data layer. + +## Documentation + +For comprehensive documentation, see: +- **Overview**: [docs/en/references/data/README.md](../../../docs/en/references/data/README.md) +- **Cache Types**: [cache-overview.md](../../../docs/en/references/data/cache-overview.md) +- **Preference Types**: [preference-overview.md](../../../docs/en/references/data/preference-overview.md) +- **API Types**: [api-types.md](../../../docs/en/references/data/api-types.md) ## Directory Structure ``` packages/shared/data/ -├── api/ # Data API type system (see api/README.md) -│ ├── index.ts # Barrel exports for infrastructure types -│ ├── apiTypes.ts # Core request/response types and utilities -│ ├── apiPaths.ts # Path template literal type utilities -│ ├── errorCodes.ts # Error handling utilities -│ ├── schemas/ # Domain-specific API schemas -│ │ ├── index.ts # Schema composition -│ │ └── test.ts # Test API schema and DTOs -│ └── README.md # Detailed API documentation +├── api/ # Data API type system +│ ├── index.ts # Barrel exports +│ ├── apiTypes.ts # Core request/response types +│ ├── apiPaths.ts # Path template utilities +│ ├── apiErrors.ts # Error handling +│ └── schemas/ # Domain-specific API schemas ├── cache/ # Cache system type definitions -│ ├── cacheTypes.ts # Core cache infrastructure types -│ ├── cacheSchemas.ts # Cache key schemas and type mappings -│ └── cacheValueTypes.ts # Cache value type definitions +│ ├── cacheTypes.ts # Core cache types +│ ├── cacheSchemas.ts # Cache key schemas +│ └── cacheValueTypes.ts # Cache value types ├── preference/ # Preference system type definitions -│ ├── preferenceTypes.ts # Core preference system types -│ └── preferenceSchemas.ts # Preference schemas and default values -├── types/ # Shared data types for Main/Renderer -└── README.md # This file +│ ├── preferenceTypes.ts # Core preference types +│ └── preferenceSchemas.ts # Preference schemas +└── types/ # Shared data types ``` -## System Overview +## Quick Reference -This directory provides type definitions for four main data management systems: +### Import Conventions -### Types System (`types/`) -- **Purpose**: Shared data types for cross-process (Main/Renderer) communication and database schemas -- **Features**: Database table field types, business entity definitions -- **Usage**: Used in Drizzle ORM schemas via `.$type()` and runtime type checking - -### API System (`api/`) -- **Purpose**: Type-safe IPC communication between Main and Renderer processes -- **Features**: RESTful patterns, modular schema design, error handling -- **Documentation**: See [`api/README.md`](./api/README.md) for detailed usage - -### Cache System (`cache/`) -- **Purpose**: Type definitions for three-layer caching architecture -- **Features**: Memory/shared/persist cache schemas, TTL support, hook integration -- **Usage**: Type-safe caching operations across the application - -### Preference System (`preference/`) -- **Purpose**: User configuration and settings management -- **Features**: 158 configuration items, default values, nested key support -- **Usage**: Type-safe preference access and synchronization - -## File Categories - -**Framework Infrastructure** - These are TypeScript type definitions that: -- ✅ Exist only at compile time -- ✅ Provide type safety and IntelliSense support -- ✅ Define contracts between application layers -- ✅ Enable static analysis and error detection - -## Usage Examples - -### API Types ```typescript -// Infrastructure types from barrel export +// API infrastructure types (from barrel) import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api' -import { DataApiErrorFactory, ErrorCode } from '@shared/data/api' +import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api' -// Domain DTOs directly from schema files -import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' -``` +// Domain DTOs (from schema files) +import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' -### Cache Types -```typescript -// Import cache types +// Cache types import type { UseCacheKey, UseSharedCacheKey } from '@shared/data/cache' + +// Preference types +import type { PreferenceKeyType } from '@shared/data/preference' ``` - -### Preference Types -```typescript -// Import preference types -import type { PreferenceKeyType, PreferenceDefaultScopeType } from '@shared/data/preference' -``` - -## Development Guidelines - -### Adding Shared Types -1. Create or update type file in `types/` directory -2. Use camelCase for field names -3. Reference types in Drizzle schemas using `.$type()` - -### Adding Cache Types -1. Add cache key to `cache/cacheSchemas.ts` -2. Define value type in `cache/cacheValueTypes.ts` -3. Update type mappings for type safety - -### Adding Preference Types -1. Add preference key to `preference/preferenceSchemas.ts` -2. Define default value and type -3. Preference system automatically picks up new keys - -### Adding API Types -1. Create schema file in `api/schemas/` (e.g., `topic.ts`) -2. Define domain models, DTOs, and API schema in the file -3. Register schema in `api/schemas/index.ts` using intersection type -4. See [`api/README.md`](./api/README.md) for detailed guide - -### Best Practices -- Use `import type` for type-only imports -- Infrastructure types from barrel, domain DTOs from schema files -- Follow existing naming conventions -- Document complex types with JSDoc - -## Related Implementation - -### Main Process -- `src/main/data/api/` - API server, handlers, and IPC adapter -- `src/main/data/cache/` - Cache service implementation -- `src/main/data/preference/` - Preference service implementation - -### Renderer Process -- `src/renderer/src/services/DataApiService.ts` - API client -- `src/renderer/src/services/CacheService.ts` - Cache service -- `src/renderer/src/services/PreferenceService.ts` - Preference service \ No newline at end of file diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md index 452b3a22c0..eb06824d87 100644 --- a/packages/shared/data/api/README.md +++ b/packages/shared/data/api/README.md @@ -1,238 +1,42 @@ # Data API Type System -This directory contains the type definitions and utilities for Cherry Studio's Data API system, which provides type-safe IPC communication between renderer and main processes. +This directory contains type definitions for the DataApi system. + +## Documentation + +- **DataApi Overview**: [docs/en/references/data/data-api-overview.md](../../../../docs/en/references/data/data-api-overview.md) +- **API Types**: [api-types.md](../../../../docs/en/references/data/api-types.md) +- **API Design Guidelines**: [api-design-guidelines.md](../../../../docs/en/references/data/api-design-guidelines.md) ## Directory Structure ``` packages/shared/data/api/ -├── index.ts # Barrel export for infrastructure types -├── apiTypes.ts # Core request/response types and API utilities -├── apiPaths.ts # Path template literal type utilities -├── apiErrors.ts # Error handling: ErrorCode, DataApiError class, factory +├── index.ts # Barrel exports +├── apiTypes.ts # Core request/response types +├── apiPaths.ts # Path template utilities +├── apiErrors.ts # Error handling └── schemas/ - ├── index.ts # Schema composition (merges all domain schemas) - └── test.ts # Test API schema and DTOs + ├── index.ts # Schema composition + └── *.ts # Domain-specific schemas ``` -## File Responsibilities +## Quick Reference -| File | Purpose | -|------|---------| -| `apiTypes.ts` | Core types (`DataRequest`, `DataResponse`, `ApiClient`) and schema utilities | -| `apiPaths.ts` | Template literal types for path resolution (`/items/:id` → `/items/${string}`) | -| `apiErrors.ts` | `ErrorCode` enum, `DataApiError` class, `DataApiErrorFactory`, retryability config | -| `index.ts` | Unified export of infrastructure types (not domain DTOs) | -| `schemas/index.ts` | Composes all domain schemas into `ApiSchemas` using intersection types | -| `schemas/*.ts` | Domain-specific API definitions and DTOs | - -## Import Conventions - -### Infrastructure Types (via barrel export) - -Use the barrel export for common API infrastructure: +### Import Conventions ```typescript -import type { - DataRequest, - DataResponse, - ApiClient, - PaginatedResponse -} from '@shared/data/api' +// Infrastructure types (via barrel) +import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api' +import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api' -import { - ErrorCode, - DataApiError, - DataApiErrorFactory, - isDataApiError, - toDataApiError -} from '@shared/data/api' -``` - -### Domain DTOs (directly from schema files) - -Import domain-specific types directly from their schema files: - -```typescript -// Topic domain -import type { Topic, CreateTopicDto, UpdateTopicDto } from '@shared/data/api/schemas/topic' - -// Message domain +// Domain DTOs (directly from schema files) +import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' import type { Message, CreateMessageDto } from '@shared/data/api/schemas/message' - -// Test domain (development) -import type { TestItem, CreateTestItemDto } from '@shared/data/api/schemas/test' ``` -## Adding a New Domain Schema - -1. Create the schema file (e.g., `schemas/topic.ts`): - -```typescript -import type { PaginatedResponse } from '../apiTypes' - -// Domain models -export interface Topic { - id: string - name: string - createdAt: string -} - -export interface CreateTopicDto { - name: string -} - -// API Schema - validation happens via AssertValidSchemas in index.ts -export interface TopicSchemas { - '/topics': { - GET: { - response: PaginatedResponse // response is required - } - POST: { - body: CreateTopicDto - response: Topic - } - } - '/topics/:id': { - GET: { - params: { id: string } - response: Topic - } - } -} -``` - -**Validation**: Schemas are validated at composition level via `AssertValidSchemas` in `schemas/index.ts`: -- Ensures only valid HTTP methods (GET, POST, PUT, DELETE, PATCH) -- Requires `response` field for each endpoint -- Invalid schemas cause TypeScript errors at the composition point - -> **Design Guidelines**: Before creating new schemas, review the [API Design Guidelines](./api-design-guidelines.md) for path naming, HTTP methods, and error handling conventions. - -2. Register in `schemas/index.ts`: - -```typescript -import type { TopicSchemas } from './topic' - -// AssertValidSchemas provides fallback validation even if ValidateSchema is forgotten -export type ApiSchemas = AssertValidSchemas -``` +### Adding New Schemas +1. Create schema file in `schemas/` (e.g., `topic.ts`) +2. Register in `schemas/index.ts` using intersection type 3. Implement handlers in `src/main/data/api/handlers/` - -## Type Safety Features - -### Path Resolution - -The system uses template literal types to map concrete paths to schema paths: - -```typescript -// Concrete path '/topics/abc123' maps to schema path '/topics/:id' -api.get('/topics/abc123') // TypeScript knows this returns Topic -``` - -### Exhaustive Handler Checking - -`ApiImplementation` type ensures all schema endpoints have handlers: - -```typescript -// TypeScript will error if any endpoint is missing -const handlers: ApiImplementation = { - '/topics': { - GET: async () => { /* ... */ }, - POST: async ({ body }) => { /* ... */ } - } - // Missing '/topics/:id' would cause compile error -} -``` - -### Type-Safe Client - -`ApiClient` provides fully typed methods: - -```typescript -const topic = await api.get('/topics/123') // Returns Topic -const topics = await api.get('/topics', { query: { page: 1 } }) // Returns PaginatedResponse -await api.post('/topics', { body: { name: 'New' } }) // Body is typed as CreateTopicDto -``` - -## Error Handling - -The error system provides type-safe error handling with automatic retryability detection: - -```typescript -import { - DataApiError, - DataApiErrorFactory, - ErrorCode, - isDataApiError, - toDataApiError -} from '@shared/data/api' - -// Create errors using the factory (recommended) -throw DataApiErrorFactory.notFound('Topic', id) -throw DataApiErrorFactory.validation({ name: ['Name is required'] }) -throw DataApiErrorFactory.timeout('fetch topics', 3000) -throw DataApiErrorFactory.database(originalError, 'insert topic') - -// Or create directly with the class -throw new DataApiError( - ErrorCode.NOT_FOUND, - 'Topic not found', - 404, - { resource: 'Topic', id: 'abc123' } -) - -// Check if error is retryable (for automatic retry logic) -if (error instanceof DataApiError && error.isRetryable) { - await retry(operation) -} - -// Check error type -if (error instanceof DataApiError) { - if (error.isClientError) { - // 4xx - issue with the request - } else if (error.isServerError) { - // 5xx - server-side issue - } -} - -// Convert any error to DataApiError -const apiError = toDataApiError(unknownError, 'context') - -// Serialize for IPC (Main → Renderer) -const serialized = apiError.toJSON() - -// Reconstruct from IPC response (Renderer) -const reconstructed = DataApiError.fromJSON(response.error) -``` - -### Retryable Error Codes - -The following errors are automatically considered retryable: -- `SERVICE_UNAVAILABLE` (503) -- `TIMEOUT` (504) -- `RATE_LIMIT_EXCEEDED` (429) -- `DATABASE_ERROR` (500) -- `INTERNAL_SERVER_ERROR` (500) -- `RESOURCE_LOCKED` (423) - -## Architecture Overview - -``` -Renderer Main -──────────────────────────────────────────────────── -DataApiService ──IPC──► IpcAdapter ──► ApiServer - │ │ - │ ▼ - ApiClient MiddlewareEngine - (typed) │ - ▼ - Handlers - (typed) -``` - -- **Renderer**: Uses `DataApiService` with type-safe `ApiClient` interface -- **IPC**: Requests serialized via `IpcAdapter` -- **Main**: `ApiServer` routes to handlers through `MiddlewareEngine` -- **Type Safety**: End-to-end types from client call to handler implementation diff --git a/src/main/data/README.md b/src/main/data/README.md index dde7f8188e..e596b87434 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -1,405 +1,44 @@ # Main Data Layer -This directory contains the main process data management system, providing unified data access for the entire application. +This directory contains the main process data management implementation. + +## Documentation + +- **Overview**: [docs/en/references/data/README.md](../../../docs/en/references/data/README.md) +- **DataApi in Main**: [data-api-in-main.md](../../../docs/en/references/data/data-api-in-main.md) +- **Database Patterns**: [database-patterns.md](../../../docs/en/references/data/database-patterns.md) ## Directory Structure ``` src/main/data/ -├── api/ # Data API framework (interface layer) -│ ├── core/ # Core API infrastructure -│ │ ├── ApiServer.ts # Request routing and handler execution -│ │ ├── MiddlewareEngine.ts # Request/response middleware -│ │ └── adapters/ # Communication adapters (IPC) -│ ├── handlers/ # API endpoint implementations -│ │ ├── index.ts # Handler aggregation and exports -│ │ └── test.ts # Test endpoint handlers -│ └── index.ts # API framework exports -│ +├── api/ # Data API framework +│ ├── core/ # ApiServer, MiddlewareEngine, adapters +│ └── handlers/ # API endpoint implementations ├── services/ # Business logic layer -│ ├── base/ # Service base classes and interfaces -│ │ └── IBaseService.ts # Service interface definitions -│ └── TestService.ts # Test service (placeholder for real services) -│ # Future business services: -│ # - TopicService.ts # Topic business logic -│ # - MessageService.ts # Message business logic -│ # - FileService.ts # File business logic -│ ├── repositories/ # Data access layer (selective usage) -│ # Repository pattern used selectively for complex domains -│ # Future repositories: -│ # - TopicRepository.ts # Complex: Topic data access -│ # - MessageRepository.ts # Complex: Message data access -│ -├── db/ # Database layer -│ ├── schemas/ # Drizzle table definitions -│ │ ├── preference.ts # Preference configuration table -│ │ ├── appState.ts # Application state table -│ │ ├── topic.ts # Topic/conversation table -│ │ ├── message.ts # Message table -│ │ ├── group.ts # Group table -│ │ ├── tag.ts # Tag table -│ │ ├── entityTag.ts # Entity-tag relationship table -│ │ ├── messageFts.ts # Message full-text search table -│ │ └── columnHelpers.ts # Reusable column definitions -│ ├── seeding/ # Database initialization -│ └── DbService.ts # Database connection and management -│ -├── migration/ # Data migration system -│ └── v2/ # v2 data refactoring migration tools -│ -├── CacheService.ts # Infrastructure: Cache management -├── DataApiService.ts # Infrastructure: API coordination -└── PreferenceService.ts # System service: User preferences +├── db/ # Database layer +│ ├── schemas/ # Drizzle table definitions +│ ├── seeding/ # Database initialization +│ └── DbService.ts # Database connection management +├── migration/ # Data migration system +├── CacheService.ts # Cache management +├── DataApiService.ts # API coordination +└── PreferenceService.ts # User preferences ``` -## Core Components - -### Naming Note - -Three components at the root of `data/` use the "Service" suffix but serve different purposes: - -#### CacheService (Infrastructure Component) -- **True Nature**: Cache Manager / Infrastructure Utility -- **Purpose**: Multi-tier caching system (memory/shared/persist) -- **Features**: TTL support, IPC synchronization, cross-window broadcasting -- **Characteristics**: Zero business logic, purely technical functionality -- **Note**: Named "Service" for management consistency, but is actually infrastructure - -#### DataApiService (Coordinator Component) -- **True Nature**: API Coordinator (Main) / API Client (Renderer) -- **Main Process Purpose**: Coordinates ApiServer and IpcAdapter initialization -- **Renderer Purpose**: HTTP-like client for IPC communication -- **Characteristics**: Zero business logic, purely coordination/communication plumbing -- **Note**: Named "Service" for management consistency, but is actually coordinator/client - -#### PreferenceService (System Service) -- **True Nature**: System-level Data Access Service -- **Purpose**: User configuration management with caching and multi-window sync -- **Features**: SQLite persistence, full memory cache, cross-window synchronization -- **Characteristics**: Minimal business logic (validation, defaults), primarily data access -- **Note**: Hybrid between data access and infrastructure, "Service" naming is acceptable - -**Key Takeaway**: Despite all being named "Service", these are infrastructure/coordination components, not business services. The "Service" suffix is kept for consistency with existing codebase conventions. - -## Architecture Layers - -### API Framework Layer (`api/`) - -The API framework provides the interface layer for data access: - -#### API Server (`api/core/ApiServer.ts`) -- Request routing and handler execution -- Middleware pipeline processing -- Type-safe endpoint definitions - -#### Handlers (`api/handlers/`) -- **Purpose**: Thin API endpoint implementations -- **Responsibilities**: - - HTTP-like parameter extraction from requests - - DTO/domain model conversion - - Delegating to business services - - Transforming responses for IPC -- **Anti-pattern**: Do NOT put business logic in handlers -- **Currently**: Contains test handlers (business handlers pending) -- **Type Safety**: Must implement all endpoints defined in `@shared/data/api/schemas/` - -### Business Logic Layer (`services/`) - -Business services implement domain logic and workflows: - -#### When to Create a Service -- Contains business rules and validation -- Orchestrates multiple repositories or data sources -- Implements complex workflows -- Manages transactions across multiple operations - -#### Service Pattern - -Just an example for understanding. - -```typescript -// services/TopicService.ts -export class TopicService { - constructor( - private topicRepo: TopicRepository, // Use repository for complex data access - private cacheService: CacheService // Use infrastructure utilities - ) {} - - async createTopicWithMessage(data: CreateTopicDto) { - // Business validation - this.validateTopicData(data) - - // Transaction coordination - return await DbService.transaction(async (tx) => { - const topic = await this.topicRepo.create(data.topic, tx) - const message = await this.messageRepo.create(data.message, tx) - return { topic, message } - }) - } -} -``` - -#### Current Services -- `TestService`: Placeholder service for testing API framework -- More business services will be added as needed (TopicService, MessageService, etc.) - -### Data Access Layer (`repositories/`) - -Repositories handle database operations with a **selective usage pattern**: - -#### When to Use Repository Pattern -Use repositories for **complex domains** that meet multiple criteria: -- ✅ Complex queries (joins, subqueries, aggregations) -- ✅ GB-scale data requiring optimization and pagination -- ✅ Complex transactions involving multiple tables -- ✅ Reusable data access patterns across services -- ✅ High testing requirements (mock data access in tests) - -#### When to Use Direct Drizzle in Services -Skip repository layer for **simple domains**: -- ✅ Simple CRUD operations -- ✅ Small datasets (< 100MB) -- ✅ Domain-specific queries with no reuse potential -- ✅ Fast development is priority - -#### Repository Pattern - -Just an example for understanding. - -```typescript -// repositories/TopicRepository.ts -export class TopicRepository { - async findById(id: string, tx?: Transaction): Promise { - const db = tx || DbService.db - return await db.select() - .from(topicTable) - .where(eq(topicTable.id, id)) - .limit(1) - } - - async findByIdWithMessages( - topicId: string, - pagination: PaginationOptions - ): Promise { - // Complex join query with pagination - // Handles GB-scale data efficiently - } -} -``` - -#### Direct Drizzle Pattern (Simple Services) -```typescript -// services/SimpleService.ts -export class SimpleService extends BaseService { - async getItem(id: string) { - // Direct Drizzle query for simple operations - return await this.database - .select() - .from(itemTable) - .where(eq(itemTable.id, id)) - } -} -``` - -#### Planned Repositories -- **TopicRepository**: Complex topic data access with message relationships -- **MessageRepository**: GB-scale message queries with pagination -- **FileRepository**: File reference counting and cleanup logic - -**Decision Principle**: Use the simplest approach that solves the problem. Add repository abstraction only when complexity demands it. - -## Database Layer - -### DbService -- SQLite database connection management -- Automatic migrations and seeding -- Drizzle ORM integration - -### Schemas (`db/schemas/`) -- Table definitions using Drizzle ORM -- Follow naming convention: `{entity}Table` exports -- Use `crudTimestamps` helper for timestamp fields -- See [db/README.md](./db/README.md#field-generation-rules) for detailed field generation rules and `.returning()` pattern - -### Current Tables -- `preference`: User configuration storage -- `appState`: Application state persistence -- `topic`: Conversation/topic storage -- `message`: Message storage with full-text search -- `group`: Group organization -- `tag`: Tag definitions -- `entityTag`: Entity-tag relationships -- `messageFts`: Message full-text search index - -## Usage Examples - -### Accessing Services -```typescript -// Get service instances -import { cacheService } from '@/data/CacheService' -import { preferenceService } from '@/data/PreferenceService' -import { dataApiService } from '@/data/DataApiService' - -// Services are singletons, initialized at app startup -``` +## Quick Reference ### Adding New API Endpoints -1. Create or update schema in `@shared/data/api/schemas/` (see `@shared/data/api/README.md`) -2. Register schema in `@shared/data/api/schemas/index.ts` -3. Implement handler in `api/handlers/` (thin layer, delegate to service) -4. Create business service in `services/` for domain logic -5. Create repository in `repositories/` if domain is complex (optional) -6. Add database schema in `db/schemas/` if required -### Adding Database Tables -1. Create schema in `db/schemas/{tableName}.ts` -2. Generate migration: `yarn run db:migrations:generate` -3. Add seeding data in `db/seeding/` if needed -4. Decide: Repository pattern or direct Drizzle? - - Complex domain → Create repository in `repositories/` - - Simple domain → Use direct Drizzle in service -5. Create business service in `services/` -6. Implement API handler in `api/handlers/` +1. Define schema in `@shared/data/api/schemas/` +2. Implement handler in `api/handlers/` +3. Create business service in `services/` +4. Create repository in `repositories/` (if complex domain) -### Creating a New Business Service +### Database Commands -**For complex domains (with repository)**: -```typescript -// 1. Create repository: repositories/ExampleRepository.ts -export class ExampleRepository { - async findById(id: string, tx?: Transaction) { /* ... */ } - async create(data: CreateDto, tx?: Transaction) { /* ... */ } -} - -// 2. Create service: services/ExampleService.ts -export class ExampleService { - constructor(private exampleRepo: ExampleRepository) {} - - async createExample(data: CreateDto) { - // Business validation - this.validate(data) - - // Use repository - return await this.exampleRepo.create(data) - } -} - -// 3. Create handler: api/handlers/example.ts -import { ExampleService } from '@data/services/ExampleService' - -export const exampleHandlers: Partial = { - '/examples': { - POST: async ({ body }) => { - return await ExampleService.getInstance().createExample(body) - } - } -} +```bash +# Generate migrations +yarn db:migrations:generate ``` - -**For simple domains (direct Drizzle)**: -```typescript -// 1. Create service: services/SimpleService.ts -export class SimpleService extends BaseService { - async getItem(id: string) { - // Direct database access - return await this.database - .select() - .from(itemTable) - .where(eq(itemTable.id, id)) - } -} - -// 2. Create handler: api/handlers/simple.ts -export const simpleHandlers: Partial = { - '/items/:id': { - GET: async ({ params }) => { - return await SimpleService.getInstance().getItem(params.id) - } - } -} -``` - -## Data Flow - -### Complete Request Flow - -``` -┌─────────────────────────────────────────────────────┐ -│ Renderer Process │ -│ React Component → useDataApi Hook │ -└────────────────┬────────────────────────────────────┘ - │ IPC Request -┌────────────────▼────────────────────────────────────┐ -│ Infrastructure Layer │ -│ DataApiService (coordinator) │ -│ ↓ │ -│ ApiServer (routing) → MiddlewareEngine │ -└────────────────┬────────────────────────────────────┘ - │ -┌────────────────▼────────────────────────────────────┐ -│ API Layer (api/handlers/) │ -│ Handler: Thin layer │ -│ - Extract parameters │ -│ - Call business service │ -│ - Transform response │ -└────────────────┬────────────────────────────────────┘ - │ -┌────────────────▼────────────────────────────────────┐ -│ Business Logic Layer (services/) │ -│ Service: Domain logic │ -│ - Business validation │ -│ - Transaction coordination │ -│ - Call repository or direct DB │ -└────────────────┬────────────────────────────────────┘ - │ - ┌──────────┴──────────┐ - │ │ -┌─────▼─────────┐ ┌──────▼──────────────────────────┐ -│ repositories/ │ │ Direct Drizzle │ -│ (Complex) │ │ (Simple domains) │ -│ - Repository │ │ - Service uses DbService.db │ -│ - Query logic │ │ - Inline queries │ -└─────┬─────────┘ └──────┬──────────────────────────┘ - │ │ - └──────────┬─────────┘ - │ -┌────────────────▼────────────────────────────────────┐ -│ Database Layer (db/) │ -│ DbService → SQLite (Drizzle ORM) │ -└─────────────────────────────────────────────────────┘ -``` - -### Architecture Principles - -1. **Separation of Concerns** - - Handlers: Request/response transformation only - - Services: Business logic and orchestration - - Repositories: Data access (when complexity demands it) - -2. **Dependency Flow** (top to bottom only) - - Handlers depend on Services - - Services depend on Repositories (or DbService directly) - - Repositories depend on DbService - - **Never**: Services depend on Handlers - - **Never**: Repositories contain business logic - -3. **Selective Repository Usage** - - Use Repository: Complex domains (Topic, Message, File) - - Direct Drizzle: Simple domains (Agent, Session, Translate) - - Decision based on: query complexity, data volume, testing needs - -## Development Guidelines - -- All services use singleton pattern -- Database operations must be type-safe (Drizzle) -- API endpoints require complete type definitions -- Services should handle errors gracefully -- Use existing logging system (`@logger`) - -## Integration Points - -- **IPC Communication**: All services expose IPC handlers for renderer communication -- **Type Safety**: Shared types in `@shared/data` ensure end-to-end type safety -- **Error Handling**: Standardized error codes and handling across all services -- **Logging**: Comprehensive logging for debugging and monitoring \ No newline at end of file diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 10d3a44593..0e43e760eb 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -1,199 +1,46 @@ -# Database Schema Guidelines +# Database Layer -## Naming Conventions +This directory contains database schemas and configuration. -- **Table names**: Use **singular** form with snake_case (e.g., `topic`, `message`, `app_state`) -- **Export names**: Use `xxxTable` pattern (e.g., `topicTable`, `messageTable`) -- **Column names**: Drizzle auto-infers from property names, no need to specify explicitly +## Documentation -## Column Helpers +- **Database Patterns**: [docs/en/references/data/database-patterns.md](../../../../docs/en/references/data/database-patterns.md) -All helpers are exported from `./schemas/columnHelpers.ts`. +## Directory Structure -### Primary Keys - -| Helper | UUID Version | Use Case | -|--------|--------------|----------| -| `uuidPrimaryKey()` | v4 (random) | General purpose tables | -| `uuidPrimaryKeyOrdered()` | v7 (time-ordered) | Large tables with time-based queries | - -**Usage:** - -```typescript -import { uuidPrimaryKey, uuidPrimaryKeyOrdered } from './columnHelpers' - -// General purpose table -export const topicTable = sqliteTable('topic', { - id: uuidPrimaryKey(), - name: text(), - ... -}) - -// Large table with time-ordered data -export const messageTable = sqliteTable('message', { - id: uuidPrimaryKeyOrdered(), - content: text(), - ... -}) +``` +src/main/data/db/ +├── schemas/ # Drizzle table definitions +│ ├── columnHelpers.ts # Reusable column definitions +│ ├── topic.ts # Topic table +│ ├── message.ts # Message table +│ └── ... # Other tables +├── seeding/ # Database initialization +└── DbService.ts # Database connection management ``` -**Behavior:** +## Quick Reference -- ID is auto-generated if not provided during insert -- Can be manually specified for migration scenarios -- Use `.returning()` to get the generated ID after insert +### Naming Conventions -### Timestamps +- **Table names**: Singular snake_case (`topic`, `message`, `app_state`) +- **Export names**: `xxxTable` pattern (`topicTable`, `messageTable`) -| Helper | Fields | Use Case | -|--------|--------|----------| -| `createUpdateTimestamps` | `createdAt`, `updatedAt` | Tables without soft delete | -| `createUpdateDeleteTimestamps` | `createdAt`, `updatedAt`, `deletedAt` | Tables with soft delete | +### Common Commands -**Usage:** +```bash +# Generate migrations after schema changes +yarn db:migrations:generate +``` + +### Column Helpers ```typescript -import { createUpdateTimestamps, createUpdateDeleteTimestamps } from './columnHelpers' +import { uuidPrimaryKey, createUpdateTimestamps } from './columnHelpers' -// Without soft delete -export const tagTable = sqliteTable('tag', { +export const myTable = sqliteTable('my_table', { id: uuidPrimaryKey(), name: text(), ...createUpdateTimestamps }) - -// With soft delete -export const topicTable = sqliteTable('topic', { - id: uuidPrimaryKey(), - name: text(), - ...createUpdateDeleteTimestamps -}) ``` - -**Behavior:** - -- `createdAt`: Auto-set to `Date.now()` on insert -- `updatedAt`: Auto-set on insert, auto-updated on update -- `deletedAt`: `null` by default, set to timestamp for soft delete - -## JSON Fields - -For JSON column support, use `{ mode: 'json' }`: - -```typescript -data: text({ mode: 'json' }).$type() -``` - -Drizzle handles JSON serialization/deserialization automatically. - -## Foreign Keys - -### Basic Usage - -```typescript -// SET NULL: preserve record when referenced record is deleted -groupId: text().references(() => groupTable.id, { onDelete: 'set null' }) - -// CASCADE: delete record when referenced record is deleted -topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }) -``` - -### Self-Referencing Foreign Keys - -For self-referencing foreign keys (e.g., tree structures with parentId), **always use the `foreignKey` operator** in the table's third parameter: - -```typescript -import { foreignKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' - -export const messageTable = sqliteTable( - 'message', - { - id: uuidPrimaryKeyOrdered(), - parentId: text(), // Do NOT use .references() here - // ...other fields - }, - (t) => [ - // Use foreignKey operator for self-referencing - foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }).onDelete('set null') - ] -) -``` - -**Why this approach:** -- Avoids TypeScript circular reference issues (no need for `AnySQLiteColumn` type annotation) -- More explicit and readable -- Allows chaining `.onDelete()` / `.onUpdate()` actions - -### Circular Foreign Key References - -**Avoid circular foreign key references between tables.** For example: - -```typescript -// ❌ BAD: Circular FK between tables -// tableA.currentItemId -> tableB.id -// tableB.ownerId -> tableA.id -``` - -If you encounter a scenario that seems to require circular references: - -1. **Identify which relationship is "weaker"** - typically the one that can be null or is less critical for data integrity -2. **Remove the FK constraint from the weaker side** - let the application layer handle validation and consistency (this is known as "soft references" pattern) -3. **Document the application-layer constraint** in code comments - -```typescript -// ✅ GOOD: Break the cycle by handling one side at application layer -export const topicTable = sqliteTable('topic', { - id: uuidPrimaryKey(), - // Application-managed reference (no FK constraint) - // Validated by TopicService.setCurrentMessage() - currentMessageId: text(), -}) - -export const messageTable = sqliteTable('message', { - id: uuidPrimaryKeyOrdered(), - // Database-enforced FK - topicId: text().references(() => topicTable.id, { onDelete: 'cascade' }), -}) -``` - -**Why soft references for SQLite:** -- SQLite does not support `DEFERRABLE` constraints (unlike PostgreSQL/Oracle) -- Application-layer validation provides equivalent data integrity -- Simplifies insert/update operations without transaction ordering concerns - -## Migrations - -Generate migrations after schema changes: - -```bash -yarn db:migrations:generate -``` - -## Field Generation Rules - -The schema uses Drizzle's auto-generation features. Follow these rules: - -### Auto-generated fields (NEVER set manually) - -- `id`: Uses `$defaultFn()` with UUID v4/v7, auto-generated on insert -- `createdAt`: Uses `$defaultFn()` with `Date.now()`, auto-generated on insert -- `updatedAt`: Uses `$defaultFn()` and `$onUpdateFn()`, auto-updated on every update - -### Using `.returning()` pattern - -Always use `.returning()` to get inserted/updated data instead of re-querying: - -```typescript -// Good: Use returning() -const [row] = await db.insert(table).values(data).returning() -return rowToEntity(row) - -// Avoid: Re-query after insert (unnecessary database round-trip) -await db.insert(table).values({ id, ...data }) -return this.getById(id) -``` - -### Soft delete support - -The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`). -Business logic can choose to use soft delete or hard delete based on requirements. diff --git a/src/main/data/migration/v2/README.md b/src/main/data/migration/v2/README.md index 86d597223e..6e5e071f7d 100644 --- a/src/main/data/migration/v2/README.md +++ b/src/main/data/migration/v2/README.md @@ -1,64 +1,33 @@ -# Migration V2 (Main Process) +# Data Migration System -Architecture for the new one-shot migration from the legacy Dexie + Redux Persist stores into the SQLite schema. This module owns orchestration, data access helpers, migrator plugins, and IPC entry points used by the renderer migration window. +This directory contains the v2 data migration implementation. -## Directory Layout +## Documentation + +- **Migration Guide**: [docs/en/references/data/v2-migration-guide.md](../../../../../docs/en/references/data/v2-migration-guide.md) + +## Directory Structure ``` src/main/data/migration/v2/ -├── core/ # Engine + shared context -├── migrators/ # Domain-specific migrators and mappings -├── utils/ # Data source readers (Redux, Dexie, streaming JSON) -├── window/ # IPC handlers + migration window manager -└── index.ts # Public exports for main process +├── core/ # MigrationEngine, MigrationContext +├── migrators/ # Domain-specific migrators +│ └── mappings/ # Mapping definitions +├── utils/ # ReduxStateReader, DexieFileReader, JSONStreamReader +├── window/ # IPC handlers, window manager +└── index.ts # Public exports ``` -## Core Contracts +## Quick Reference -- `core/MigrationEngine.ts` coordinates all migrators in order, surfaces progress to the UI, and marks status in `app_state.key = 'migration_v2_status'`. It will clear new-schema tables before running and abort on any validation failure. -- `core/MigrationContext.ts` builds the shared context passed to every migrator: - - `sources`: `ConfigManager` (ElectronStore), `ReduxStateReader` (parsed Redux Persist data), `DexieFileReader` (JSON exports) - - `db`: current SQLite connection - - `sharedData`: `Map` for passing cross-cutting info between migrators - - `logger`: `loggerService` scoped to migration -- `@shared/data/migration/v2/types` defines stages, results, and validation stats used across main and renderer. +### Creating a New Migrator -## Migrators +1. Extend `BaseMigrator` in `migrators/` +2. Implement `prepare`, `execute`, `validate` methods +3. Register in `migrators/index.ts` -- Base contract: extend `migrators/BaseMigrator.ts` and implement: - - `id`, `name`, `description`, `order` (lower runs first) - - `prepare(ctx)`: dry-run checks, counts, and staging data; return `PrepareResult` - - `execute(ctx)`: perform inserts/updates; manage your own transactions; report progress via `reportProgress` - - `validate(ctx)`: verify counts and integrity; return `ValidateResult` with stats (`sourceCount`, `targetCount`, `skippedCount`) and any `errors` -- Registration: list migrators (in order) in `migrators/index.ts` so the engine can sort and run them. -- Current migrators: - - `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`. - - `AssistantMigrator`, `KnowledgeMigrator`, `ChatMigrator` (placeholders): scaffolding and TODO notes for future tables. -- Conventions: - - All logging goes through `loggerService` with a migrator-specific context. - - Use `MigrationContext.sources` instead of accessing raw files/stores directly. - - Use `sharedData` to pass IDs or lookup tables between migrators (e.g., assistant -> chat references) instead of re-reading sources. - - Stream large Dexie exports (`JSONStreamReader`) and batch inserts to avoid memory spikes. - - Count validation is mandatory; engine will fail the run if `targetCount < sourceCount - skippedCount` or if `ValidateResult.errors` is non-empty. - - Keep migrations idempotent per run—engine clears target tables before it starts, but each migrator should tolerate retries within the same run. +### Key Contracts -## Utilities - -- `utils/ReduxStateReader.ts`: safe accessor for categorized Redux Persist data with dot-path lookup. -- `utils/DexieFileReader.ts`: reads exported Dexie JSON tables; can stream large tables. -- `utils/JSONStreamReader.ts`: streaming reader with batching, counting, and sampling helpers for very large arrays. - -## Window & IPC Integration - -- `window/MigrationIpcHandler.ts` exposes IPC channels for the migration UI: - - Receives Redux data and Dexie export path, starts the engine, and streams progress back to renderer. - - Manages backup flow (dialogs via `BackupManager`) and retry/cancel/restart actions. -- `window/MigrationWindowManager.ts` creates the frameless migration window, handles lifecycle, and relaunch instructions after completion in production. - -## Implementation Checklist for New Migrators - -- [ ] Add mapping definitions (if needed) under `migrators/mappings/`. -- [ ] Implement `prepare/execute/validate` with explicit counts, batch inserts, and integrity checks. -- [ ] Wire progress updates through `reportProgress` so UI shows per-migrator progress. -- [ ] Register the migrator in `migrators/index.ts` with the correct `order`. -- [ ] Add any new target tables to `MigrationEngine.verifyAndClearNewTables` once those tables exist. +- `prepare(ctx)`: Dry-run checks, return counts +- `execute(ctx)`: Perform inserts, report progress +- `validate(ctx)`: Verify counts and integrity diff --git a/src/renderer/src/data/README.md b/src/renderer/src/data/README.md index bf4068de06..4cafe2284e 100644 --- a/src/renderer/src/data/README.md +++ b/src/renderer/src/data/README.md @@ -1,429 +1,40 @@ -# Data Layer - Renderer Process +# Renderer Data Layer -This directory contains the unified data access layer for Cherry Studio's renderer process, providing type-safe interfaces for data operations, preference management, and caching. +This directory contains the renderer process data services. -## Overview +## Documentation -The `src/renderer/src/data` directory implements the new data architecture as part of the ongoing database refactoring project. It provides three core services that handle all data operations in the renderer process: +- **Overview**: [docs/en/references/data/README.md](../../../../docs/en/references/data/README.md) +- **Cache**: [cache-overview.md](../../../../docs/en/references/data/cache-overview.md) | [cache-usage.md](../../../../docs/en/references/data/cache-usage.md) +- **Preference**: [preference-overview.md](../../../../docs/en/references/data/preference-overview.md) | [preference-usage.md](../../../../docs/en/references/data/preference-usage.md) +- **DataApi**: [data-api-in-renderer.md](../../../../docs/en/references/data/data-api-in-renderer.md) -- **DataApiService**: RESTful-style API for communication with the main process -- **PreferenceService**: Unified preference/configuration management with real-time sync -- **CacheService**: Three-tier caching system for optimal performance - -## Architecture +## Directory Structure ``` -┌─────────────────┐ -│ React Components│ -└─────────┬───────┘ - │ -┌─────────▼───────┐ -│ React Hooks │ ← useDataApi, usePreference, useCache -└─────────┬───────┘ - │ -┌─────────▼───────┐ -│ Services │ ← DataApiService, PreferenceService, CacheService -└─────────┬───────┘ - │ -┌─────────▼───────┐ -│ IPC Layer │ ← Main Process Communication -└─────────────────┘ +src/renderer/src/data/ +├── DataApiService.ts # User Data API service +├── PreferenceService.ts # Preferences management +├── CacheService.ts # Three-tier caching system +└── hooks/ + ├── useDataApi.ts # useQuery, useMutation + ├── usePreference.ts # usePreference, usePreferences + └── useCache.ts # useCache, useSharedCache, usePersistCache ``` ## Quick Start -### Data API Operations - ```typescript +// Data API import { useQuery, useMutation } from '@data/hooks/useDataApi' - -// Fetch data with auto-retry and caching -const { data, loading, error } = useQuery('/topics') - -// Create/update data with optimistic updates +const { data } = useQuery('/topics') const { trigger: createTopic } = useMutation('/topics', 'POST') -await createTopic({ title: 'New Topic', content: 'Hello World' }) -``` -### Preference Management - -```typescript +// Preferences import { usePreference } from '@data/hooks/usePreference' - -// Manage user preferences with real-time sync -const [theme, setTheme] = usePreference('app.theme.mode') -const [fontSize, setFontSize] = usePreference('chat.message.font_size') - -// Optimistic updates (default) -await setTheme('dark') // UI updates immediately, syncs to database -``` - -### Cache Management - -```typescript -import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache' - -// Component-level cache (lost on app restart) -const [count, setCount] = useCache('ui.counter') - -// Cross-window cache (shared between all windows) -const [windowState, setWindowState] = useSharedCache('window.layout') - -// Persistent cache (survives app restarts) -const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files') -``` - -## Core Services - -### DataApiService - -**Purpose**: Type-safe communication with the main process using RESTful-style APIs. - -**Key Features**: -- Type-safe request/response handling -- Automatic retry with exponential backoff -- Real-time subscriptions -- Request timeout handling - -**Basic Usage**: -```typescript -import { dataApiService } from '@data/DataApiService' - -// Simple GET request -const topics = await dataApiService.get('/topics') - -// POST with body -const newTopic = await dataApiService.post('/topics', { - body: { title: 'Hello', content: 'World' } -}) -``` - -### PreferenceService - -**Purpose**: Centralized preference/configuration management with cross-window synchronization. - -**Key Features**: -- Optimistic and pessimistic update strategies -- Real-time cross-window synchronization -- Local caching for performance -- Race condition handling -- Batch operations for multiple preferences - -**Basic Usage**: -```typescript -import { preferenceService } from '@data/PreferenceService' - -// Get single preference -const theme = await preferenceService.get('app.theme.mode') - -// Set with optimistic updates (default) -await preferenceService.set('app.theme.mode', 'dark') - -// Set with pessimistic updates -await preferenceService.set('api.key', 'secret', { optimistic: false }) - -// Batch operations -await preferenceService.setMultiple({ - 'app.theme.mode': 'dark', - 'chat.message.font_size': 14 -}) -``` - -### CacheService - -**Purpose**: Three-tier caching system for different data persistence needs. - -**Cache Tiers**: -1. **Memory Cache**: Component-level, lost on app restart -2. **Shared Cache**: Cross-window, lost on app restart -3. **Persist Cache**: Cross-window + localStorage, survives restarts - -**Key Features**: -- TTL (Time To Live) support -- Hook reference tracking (prevents deletion of active data) -- Cross-window synchronization -- Type-safe cache schemas -- Automatic default value handling - -**Basic Usage**: -```typescript -import { cacheService } from '@data/CacheService' - -// Memory cache - Type-safe (schema key, with auto-completion) -cacheService.set('temp.calculation', result, 30000) // 30s TTL -const result = cacheService.get('temp.calculation') - -// Memory cache - Casual (dynamic key, requires manual type) -cacheService.setCasual(`topic:${id}`, topicData) -const topic = cacheService.getCasual(`topic:${id}`) - -// Shared cache - Type-safe (schema key) -cacheService.setShared('window.layout', layoutConfig) -const layout = cacheService.getShared('window.layout') - -// Shared cache - Casual (dynamic key) -cacheService.setSharedCasual(`window:${windowId}`, state) -const state = cacheService.getSharedCasual(`window:${windowId}`) - -// Persist cache (survives restarts, schema keys only) -cacheService.setPersist('app.recent_files', recentFiles) -const files = cacheService.getPersist('app.recent_files') -``` - -**When to Use Type-safe vs Casual Methods**: -- **Type-safe** (`get`, `set`, `getShared`, `setShared`): Use when the key is predefined in the cache schema. Provides auto-completion and type inference. -- **Casual** (`getCasual`, `setCasual`, `getSharedCasual`, `setSharedCasual`): Use when the key is dynamically constructed (e.g., `topic:${id}`). Requires manual type specification via generics. -- **Persist Cache**: Only supports schema keys (no Casual methods) to ensure data integrity. - -## React Hooks - -### useDataApi - -Type-safe data fetching with SWR integration. - -```typescript -import { useQuery, useMutation } from '@data/hooks/useDataApi' - -// GET requests with auto-caching -const { data, loading, error, mutate } = useQuery('/topics', { - query: { page: 1, limit: 20 } -}) - -// Mutations with optimistic updates -const { trigger: updateTopic, isMutating } = useMutation('/topics/123', 'PUT') -await updateTopic({ title: 'Updated Title' }) -``` - -### usePreference - -Reactive preference management with automatic synchronization. - -```typescript -import { usePreference } from '@data/hooks/usePreference' - -// Basic usage with optimistic updates const [theme, setTheme] = usePreference('app.theme.mode') -// Pessimistic updates for critical settings -const [apiKey, setApiKey] = usePreference('api.key', { optimistic: false }) - -// Handle updates -const handleThemeChange = async (newTheme) => { - try { - await setTheme(newTheme) // Auto-rollback on failure - } catch (error) { - console.error('Theme update failed:', error) - } -} -``` - -### useCache Hooks - -Component-friendly cache management with automatic lifecycle handling. - -```typescript +// Cache import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache' - -// Memory cache (useState-like, but shared between components) const [counter, setCounter] = useCache('ui.counter', 0) - -// Shared cache (cross-window) -const [layout, setLayout] = useSharedCache('window.layout') - -// Persistent cache (survives restarts) -const [recentFiles, setRecentFiles] = usePersistCache('app.recent_files') ``` - -## Best Practices - -### When to Use Which Service - -The three services map to distinct data categories based on the original architecture design. Use the following guide to choose the right service. - -#### Quick Decision Table - -| Service | Data Characteristics | Lifecycle | Data Loss Impact | Examples | -|---------|---------------------|-----------|------------------|----------| -| **CacheService** | Regenerable, temporary | ≤ App process or survives restart | None to minimal | API responses, computed results, UI state | -| **PreferenceService** | User settings, key-value | Permanent until changed | Low (can rebuild) | Theme, language, font size, shortcuts | -| **DataApiService** | Business data, structured | Permanent | **Severe** (irreplaceable) | Topics, messages, files, knowledge base | - -#### CacheService - Runtime & Cache Data - -Use CacheService when: -- Data can be **regenerated or lost without user impact** -- No backup or cross-device synchronization needed -- Lifecycle is tied to component, window, or app session - -**Two sub-categories**: -1. **Performance cache**: Computed results, API responses, expensive calculations -2. **UI state cache**: Temporary settings, scroll positions, panel states - -**Three tiers based on persistence needs**: -- `useCache` (memory): Lost on app restart, component-level sharing -- `useSharedCache` (shared): Cross-window sharing, lost on restart -- `usePersistCache` (persist): Survives app restarts via localStorage - -```typescript -// Good: Temporary computed results -const [searchResults, setSearchResults] = useCache('search.results', []) - -// Good: UI state that can be lost -const [sidebarCollapsed, setSidebarCollapsed] = useSharedCache('ui.sidebar.collapsed', false) - -// Good: Recent items (nice to have, not critical) -const [recentSearches, setRecentSearches] = usePersistCache('search.recent', []) -``` - -#### PreferenceService - User Preferences - -Use PreferenceService when: -- Data is a **user-modifiable setting that affects app behavior** -- Structure is key-value with **predefined keys** (users modify values, not keys) -- **Value structure is stable** (won't change frequently) -- Data loss has **low impact** (user can reconfigure) - -**Key characteristics**: -- Auto-syncs across all windows -- Each preference item should be **atomic** (one setting = one key) -- Values are typically: boolean, string, number, or simple array/object - -```typescript -// Good: App behavior settings -const [theme, setTheme] = usePreference('app.theme.mode') -const [language, setLanguage] = usePreference('app.language') -const [fontSize, setFontSize] = usePreference('chat.message.font_size') - -// Good: Feature toggles -const [showTimestamp, setShowTimestamp] = usePreference('chat.display.show_timestamp') -``` - -#### DataApiService - User Data - -Use DataApiService when: -- Data is **business data accumulated through user activity** -- Data is **structured with dedicated schemas/tables** -- Users can **create, delete, modify records** (no fixed limit) -- Data loss would be **severe and irreplaceable** -- Data volume can be **large** (potentially GBs) - -**Key characteristics**: -- No automatic window sync (fetch on demand for fresh data) -- May contain sensitive data (encryption consideration) -- Requires proper CRUD operations and transactions - -```typescript -// Good: User-generated business data -const { data: topics } = useQuery('/topics') -const { trigger: createTopic } = useMutation('/topics', 'POST') - -// Good: Conversation history (irreplaceable) -const { data: messages } = useQuery('/messages', { query: { topicId } }) - -// Good: User files and knowledge base -const { data: files } = useQuery('/files') -``` - -#### Decision Flowchart - -Ask these questions in order: - -1. **Can this data be regenerated or lost without affecting the user?** - - Yes → **CacheService** - - No → Continue to #2 - -2. **Is this a user-configurable setting that affects app behavior?** - - Yes → Does it have a fixed key and stable value structure? - - Yes → **PreferenceService** - - No (structure changes often) → **DataApiService** - - No → Continue to #3 - -3. **Is this business data created/accumulated through user activity?** - - Yes → **DataApiService** - - No → Reconsider #1 (most data falls into one of these categories) - -#### Common Anti-patterns - -| Wrong Choice | Why It's Wrong | Correct Choice | -|--------------|----------------|----------------| -| Storing AI provider configs in Cache | User loses configured providers on restart | **PreferenceService** | -| Storing conversation history in Preferences | Unbounded growth, complex structure | **DataApiService** | -| Storing topic list in Preferences | User-created records, can grow large | **DataApiService** | -| Storing theme/language in DataApi | Overkill for simple key-value settings | **PreferenceService** | -| Storing API responses in DataApi | Regenerable data, doesn't need persistence | **CacheService** | -| Storing window positions in Preferences | Can be lost without impact | **CacheService** (persist tier) | - -#### Edge Cases - -- **Recently used items** (e.g., recent files, recent searches): Use `usePersistCache` - nice to have but not critical if lost -- **Draft content** (e.g., unsaved message): Use `useSharedCache` for cross-window, consider auto-save to DataApi for recovery -- **Computed statistics**: Use `useCache` with TTL - regenerate when expired -- **User-created templates/presets**: Use **DataApiService** - user-generated content that can grow - -### Performance Guidelines - -1. **Prefer React Hooks**: Use `useQuery`, `usePreference`, `useCache` for component integration -2. **Batch Operations**: Use `setMultiple()` for updating multiple preferences -3. **Cache Strategically**: Use appropriate cache tiers based on data lifetime needs -4. **Optimize Re-renders**: SWR and useSyncExternalStore minimize unnecessary re-renders - -### Common Patterns - -```typescript -// Loading states with error handling -const { data, loading, error } = useQuery('/topics') -if (loading) return -if (error) return - -// Form handling with preferences -const [fontSize, setFontSize] = usePreference('chat.message.font_size') -const handleChange = (e) => setFontSize(Number(e.target.value)) - -// Temporary state with caching -const [searchQuery, setSearchQuery] = useCache('search.current_query', '') -const [searchResults, setSearchResults] = useCache('search.results', []) -``` - -## Type Safety - -All services provide full TypeScript support with auto-completion and type checking: - -- **API Types**: Defined in `@shared/data/api/` -- **Preference Types**: Defined in `@shared/data/preference/` -- **Cache Types**: Defined in `@shared/data/cache/` - -Type definitions are automatically inferred, providing: -- Request/response type safety -- Preference key validation -- Cache schema enforcement -- Auto-completion in IDEs - -## Migration from Legacy Systems - -This new data layer replaces multiple legacy systems: -- Redux-persist slices → PreferenceService -- localStorage direct access → CacheService -- Direct IPC calls → DataApiService -- Dexie database operations → DataApiService - -For migration guidelines, see the project's `.claude/` directory documentation. - -## File Structure - -``` -src/renderer/src/data/ -├── DataApiService.ts # User Data API querying service -├── PreferenceService.ts # Preferences management -├── CacheService.ts # Three-tier caching system -└── hooks/ - ├── useDataApi.ts # React hooks for user data operations - ├── usePreference.ts # React hooks for preferences - └── useCache.ts # React hooks for caching -``` - -## Related Documentation - -- **API Schemas**: `packages/shared/data/` - Type definitions and API contracts -- **Architecture Design**: `.claude/data-architecture.md` - Detailed system design -- **Migration Guide**: `.claude/migration-planning.md` - Legacy system migration -- **Project Overview**: `CLAUDE.local.md` - Complete refactoring context \ No newline at end of file From 6feb322be889e141a1b4bc2256efc43aa8da9689 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 17:16:07 +0800 Subject: [PATCH 056/116] docs(architecture): restructure and enhance data management documentation - Removed outdated sections on database architecture and data access patterns for clarity. - Introduced a new table format for data management systems, detailing use cases and APIs. - Updated references to the database schema and migration processes for better guidance. - Consolidated key architectural components to streamline the documentation structure. --- CLAUDE.md | 75 ++++++------------------------------------------------- 1 file changed, 7 insertions(+), 68 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index baa91b4834..ada5fe5040 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,34 +48,17 @@ When creating a Pull Request, you MUST: ### Key Architectural Components - -#### Main Process Services (`src/main/services/`) - -- **MCPService**: Model Context Protocol server management -- **KnowledgeService**: Document processing and knowledge base management -- **FileStorage/S3Storage/WebDav**: Multiple storage backends -- **WindowService**: Multi-window management (main, mini, selection windows) -- **ProxyManager**: Network proxy handling -- **SearchService**: Full-text search capabilities - -#### AI Core (`src/renderer/src/aiCore/`) - -- **Middleware System**: Composable pipeline for AI request processing -- **Client Factory**: Supports multiple AI providers (OpenAI, Anthropic, Gemini, etc.) -- **Stream Processing**: Real-time response handling - #### Data Management -- **Cache System**: Three-layer caching (memory/shared/persist) with React hooks integration -- **Preferences**: Type-safe configuration management with multi-window synchronization -- **User Data**: SQLite-based storage with Drizzle ORM for business data +**MUST READ**: [docs/en/references/data/README.md](docs/en/references/data/README.md) for system selection, architecture, and patterns. -#### Knowledge Management +| System | Use Case | APIs | +|--------|----------|------| +| Cache | Temp data (can lose) | `useCache`, `useSharedCache`, `usePersistCache` | +| Preference | User settings | `usePreference` | +| DataApi | Business data (**critical**) | `useQuery`, `useMutation` | -- **Embeddings**: Vector search with multiple providers (OpenAI, Voyage, etc.) -- **OCR**: Document text extraction (system OCR, Doc2x, Mineru) -- **Preprocessing**: Document preparation pipeline -- **Loaders**: Support for various file formats (PDF, DOCX, EPUB, etc.) +Database: SQLite + Drizzle ORM, schemas in `src/main/data/db/schemas/`, migrations via `yarn db:migrations:generate` ### Build System @@ -106,50 +89,6 @@ The project is in the process of migrating from antd & styled-components to Tail UI Library: `@packages/ui` -### Database Architecture - -- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver -- **ORM**: Drizzle ORM with comprehensive migration system -- **Schemas**: Located in `src/main/data/db/schemas/` directory - -#### Database Standards - -- **Table Naming**: Use singular form with snake_case (e.g., `topic`, `message`, `app_state`) -- **Schema Exports**: Export using `xxxTable` pattern (e.g., `topicTable`, `appStateTable`) -- **Field Definition**: Drizzle auto-infers field names, no need to add default field names -- **JSON Fields**: For JSON support, add `{ mode: 'json' }`, refer to `preference.ts` table definition -- **JSON Serialization**: For JSON fields, no need to manually serialize/deserialize when reading/writing to database, Drizzle handles this automatically -- **Timestamps**: Use existing `crudTimestamps` utility -- **Migrations**: Generate via `yarn run db:migrations:generate` - -## Data Access Patterns - -The application uses three distinct data management systems. Choose the appropriate system based on data characteristics: - -### Cache System -- **Purpose**: Temporary data that can be regenerated -- **Lifecycle**: Component-level (memory), window-level (shared), or persistent (survives restart) -- **Use Cases**: API response caching, computed results, temporary UI state -- **APIs**: `useCache`, `useSharedCache`, `usePersistCache` hooks, or `cacheService` - -### Preference System -- **Purpose**: User configuration and application settings -- **Lifecycle**: Permanent until user changes -- **Use Cases**: Theme, language, editor settings, user preferences -- **APIs**: `usePreference`, `usePreferences` hooks, or `preferenceService` - -### User Data API -- **Purpose**: Core business data (conversations, files, notes, etc.) -- **Lifecycle**: Permanent business records -- **Use Cases**: Topics, messages, files, knowledge base, user-generated content -- **APIs**: `useDataApi` hook or `dataApiService` for direct calls - -### Selection Guidelines - -- **Use Cache** for data that can be lost without impact (computed values, API responses) -- **Use Preferences** for user settings that affect app behavior (UI configuration, feature flags) -- **Use User Data API** for irreplaceable business data (conversations, documents, user content) - ## Logging Standards ### Usage From efbe64e5dab7da2c91305f8ef83232cd4f2d6e54 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:24:57 +0800 Subject: [PATCH 057/116] feat(tokenflux): add Anthropic host support using OpenRouter package (#12188) * feat(tokenflux): add Anthropic host support using OpenRouter package - Add anthropicApiHost to TokenFlux provider config - Map TokenFlux to OpenRouter in STATIC_PROVIDER_MAPPING for full compatibility * feat(tokenflux): update API URLs and add migration - Update apiHost to https://api.tokenflux.ai/openai/v1 - Update anthropicApiHost to https://api.tokenflux.ai/anthropic - Add migration 191 to update existing TokenFlux users * fix(tokenflux): add to Anthropic compatible providers list Enable Anthropic API host configuration in TokenFlux provider settings UI --- src/renderer/src/aiCore/provider/factory.ts | 3 ++- src/renderer/src/config/providers.ts | 3 ++- .../settings/ProviderSettings/ProviderSetting.tsx | 3 ++- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index ff100051b7..d18aa02eeb 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -31,7 +31,8 @@ const STATIC_PROVIDER_MAPPING: Record = { 'azure-openai': 'azure', // Azure OpenAI -> azure 'openai-response': 'openai', // OpenAI Responses -> openai grok: 'xai', // Grok -> xai - copilot: 'github-copilot-openai-compatible' + copilot: 'github-copilot-openai-compatible', + tokenflux: 'openrouter' // TokenFlux -> openrouter (fully compatible) } /** diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index bae473a7d7..88aceda957 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -200,7 +200,8 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = name: 'TokenFlux', type: 'openai', apiKey: '', - apiHost: 'https://tokenflux.ai', + apiHost: 'https://api.tokenflux.ai/openai/v1', + anthropicApiHost: 'https://api.tokenflux.ai/anthropic', models: SYSTEM_MODELS.tokenflux, isSystem: true, enabled: false diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 13680f5547..777bc61984 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -82,7 +82,8 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [ SystemProviderIds.qiniu, SystemProviderIds.dmxapi, SystemProviderIds.mimo, - SystemProviderIds.openrouter + SystemProviderIds.openrouter, + SystemProviderIds.tokenflux ] 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 e962a52431..3d21d12cc7 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -83,7 +83,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 190, + version: 191, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index fdb6b0db14..9375dc3b75 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3129,6 +3129,21 @@ const migrateConfig = { logger.error('migrate 190 error', error as Error) return state } + }, + '191': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === 'tokenflux') { + provider.apiHost = 'https://api.tokenflux.ai/openai/v1' + provider.anthropicApiHost = 'https://api.tokenflux.ai/anthropic' + } + }) + logger.info('migrate 191 success') + return state + } catch (error) { + logger.error('migrate 191 error', error as Error) + return state + } } } From 528d6d37f23609aefc190d1ee314a59080cd4322 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:52:58 +0800 Subject: [PATCH 058/116] refactor: simplify buildFunctionCallToolName to use mcp__{server}__{tool} format (#12186) --- src/main/services/MCPService.ts | 2 +- src/main/utils/__tests__/mcp.test.ts | 355 +++++++++++++++------------ src/main/utils/mcp.ts | 72 ++---- 3 files changed, 215 insertions(+), 214 deletions(-) diff --git a/src/main/services/MCPService.ts b/src/main/services/MCPService.ts index 5fc5cf8682..7d36e6d7e3 100644 --- a/src/main/services/MCPService.ts +++ b/src/main/services/MCPService.ts @@ -785,7 +785,7 @@ class McpService { ...tool, inputSchema: z.parse(MCPToolInputSchema, tool.inputSchema), outputSchema: tool.outputSchema ? z.parse(MCPToolOutputSchema, tool.outputSchema) : undefined, - id: buildFunctionCallToolName(server.name, tool.name, server.id), + id: buildFunctionCallToolName(server.name, tool.name), serverId: server.id, serverName: server.name, type: 'mcp' diff --git a/src/main/utils/__tests__/mcp.test.ts b/src/main/utils/__tests__/mcp.test.ts index b1a35f925e..706a44bc84 100644 --- a/src/main/utils/__tests__/mcp.test.ts +++ b/src/main/utils/__tests__/mcp.test.ts @@ -3,194 +3,223 @@ import { describe, expect, it } from 'vitest' import { buildFunctionCallToolName } from '../mcp' describe('buildFunctionCallToolName', () => { - describe('basic functionality', () => { - it('should combine server name and tool name', () => { + describe('basic format', () => { + it('should return format mcp__{server}__{tool}', () => { const result = buildFunctionCallToolName('github', 'search_issues') - expect(result).toContain('github') - expect(result).toContain('search') + expect(result).toBe('mcp__github__search_issues') }) - it('should sanitize names by replacing dashes with underscores', () => { - const result = buildFunctionCallToolName('my-server', 'my-tool') - // Input dashes are replaced, but the separator between server and tool is a dash - expect(result).toBe('my_serv-my_tool') - expect(result).toContain('_') - }) - - it('should handle empty server names gracefully', () => { - const result = buildFunctionCallToolName('', 'tool') - expect(result).toBeTruthy() + it('should handle simple server and tool names', () => { + expect(buildFunctionCallToolName('fetch', 'get_page')).toBe('mcp__fetch__get_page') + expect(buildFunctionCallToolName('database', 'query')).toBe('mcp__database__query') + expect(buildFunctionCallToolName('cherry_studio', 'search')).toBe('mcp__cherry_studio__search') }) }) - describe('uniqueness with serverId', () => { - it('should generate different IDs for same server name but different serverIds', () => { - const serverId1 = 'server-id-123456' - const serverId2 = 'server-id-789012' - const serverName = 'github' - const toolName = 'search_repos' - - const result1 = buildFunctionCallToolName(serverName, toolName, serverId1) - const result2 = buildFunctionCallToolName(serverName, toolName, serverId2) - - expect(result1).not.toBe(result2) - expect(result1).toContain('123456') - expect(result2).toContain('789012') + describe('valid JavaScript identifier', () => { + it('should always start with mcp__ prefix (valid JS identifier start)', () => { + const result = buildFunctionCallToolName('123server', '456tool') + expect(result).toMatch(/^mcp__/) + expect(result).toBe('mcp__123server__456tool') }) - it('should generate same ID when serverId is not provided', () => { + it('should only contain alphanumeric chars and underscores', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool') + expect(result).toBe('mcp__my_server__my_tool') + expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_]*$/) + }) + + it('should be a valid JavaScript identifier', () => { + const testCases = [ + ['github', 'create_issue'], + ['my-server', 'fetch-data'], + ['test@server', 'tool#name'], + ['server.name', 'tool.action'], + ['123abc', 'def456'] + ] + + for (const [server, tool] of testCases) { + const result = buildFunctionCallToolName(server, tool) + // Valid JS identifiers match this pattern + expect(result).toMatch(/^[a-zA-Z_][a-zA-Z0-9_]*$/) + } + }) + }) + + describe('character sanitization', () => { + it('should replace dashes with underscores', () => { + const result = buildFunctionCallToolName('my-server', 'my-tool-name') + expect(result).toBe('mcp__my_server__my_tool_name') + }) + + it('should replace special characters with underscores', () => { + const result = buildFunctionCallToolName('test@server!', 'tool#name$') + expect(result).toBe('mcp__test_server__tool_name') + }) + + it('should replace dots with underscores', () => { + const result = buildFunctionCallToolName('server.name', 'tool.action') + expect(result).toBe('mcp__server_name__tool_action') + }) + + it('should replace spaces with underscores', () => { + const result = buildFunctionCallToolName('my server', 'my tool') + expect(result).toBe('mcp__my_server__my_tool') + }) + + it('should collapse consecutive underscores', () => { + const result = buildFunctionCallToolName('my--server', 'my___tool') + expect(result).toBe('mcp__my_server__my_tool') + expect(result).not.toMatch(/_{3,}/) + }) + + it('should trim leading and trailing underscores from parts', () => { + const result = buildFunctionCallToolName('_server_', '_tool_') + expect(result).toBe('mcp__server__tool') + }) + + it('should handle names with only special characters', () => { + const result = buildFunctionCallToolName('---', '###') + expect(result).toBe('mcp____') + }) + }) + + describe('length constraints', () => { + it('should not exceed 63 characters', () => { + const longServerName = 'a'.repeat(50) + const longToolName = 'b'.repeat(50) + const result = buildFunctionCallToolName(longServerName, longToolName) + + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should truncate server name to max 20 chars', () => { + const longServerName = 'abcdefghijklmnopqrstuvwxyz' // 26 chars + const result = buildFunctionCallToolName(longServerName, 'tool') + + expect(result).toBe('mcp__abcdefghijklmnopqrst__tool') + expect(result).toContain('abcdefghijklmnopqrst') // First 20 chars + expect(result).not.toContain('uvwxyz') // Truncated + }) + + it('should truncate tool name to max 35 chars', () => { + const longToolName = 'a'.repeat(40) + const result = buildFunctionCallToolName('server', longToolName) + + const expectedTool = 'a'.repeat(35) + expect(result).toBe(`mcp__server__${expectedTool}`) + }) + + it('should not end with underscores after truncation', () => { + // Create a name that would end with underscores after truncation + const longServerName = 'a'.repeat(20) + const longToolName = 'b'.repeat(35) + '___extra' + const result = buildFunctionCallToolName(longServerName, longToolName) + + expect(result).not.toMatch(/_+$/) + expect(result.length).toBeLessThanOrEqual(63) + }) + + it('should handle max length edge case exactly', () => { + // mcp__ (5) + server (20) + __ (2) + tool (35) = 62 chars + const server = 'a'.repeat(20) + const tool = 'b'.repeat(35) + const result = buildFunctionCallToolName(server, tool) + + expect(result.length).toBe(62) + expect(result).toBe(`mcp__${'a'.repeat(20)}__${'b'.repeat(35)}`) + }) + }) + + describe('edge cases', () => { + it('should handle empty server name', () => { + const result = buildFunctionCallToolName('', 'tool') + expect(result).toBe('mcp____tool') + }) + + it('should handle empty tool name', () => { + const result = buildFunctionCallToolName('server', '') + expect(result).toBe('mcp__server__') + }) + + it('should handle both empty names', () => { + const result = buildFunctionCallToolName('', '') + expect(result).toBe('mcp____') + }) + + it('should handle whitespace-only names', () => { + const result = buildFunctionCallToolName(' ', ' ') + expect(result).toBe('mcp____') + }) + + it('should trim whitespace from names', () => { + const result = buildFunctionCallToolName(' server ', ' tool ') + expect(result).toBe('mcp__server__tool') + }) + + it('should handle unicode characters', () => { + const result = buildFunctionCallToolName('服务器', '工具') + // Unicode chars are replaced with underscores, then collapsed + expect(result).toMatch(/^mcp__/) + }) + + it('should handle mixed case', () => { + const result = buildFunctionCallToolName('MyServer', 'MyTool') + expect(result).toBe('mcp__MyServer__MyTool') + }) + }) + + describe('deterministic output', () => { + it('should produce consistent results for same input', () => { const serverName = 'github' const toolName = 'search_repos' const result1 = buildFunctionCallToolName(serverName, toolName) const result2 = buildFunctionCallToolName(serverName, toolName) + const result3 = buildFunctionCallToolName(serverName, toolName) expect(result1).toBe(result2) + expect(result2).toBe(result3) }) - it('should include serverId suffix when provided', () => { - const serverId = 'abc123def456' - const result = buildFunctionCallToolName('server', 'tool', serverId) + it('should produce different results for different inputs', () => { + const result1 = buildFunctionCallToolName('server1', 'tool') + const result2 = buildFunctionCallToolName('server2', 'tool') + const result3 = buildFunctionCallToolName('server', 'tool1') + const result4 = buildFunctionCallToolName('server', 'tool2') - // Should include last 6 chars of serverId - expect(result).toContain('ef456') - }) - }) - - describe('character sanitization', () => { - it('should replace invalid characters with underscores', () => { - const result = buildFunctionCallToolName('test@server', 'tool#name') - expect(result).not.toMatch(/[@#]/) - expect(result).toMatch(/^[a-zA-Z0-9_-]+$/) - }) - - it('should ensure name starts with a letter', () => { - const result = buildFunctionCallToolName('123server', '456tool') - expect(result).toMatch(/^[a-zA-Z]/) - }) - - it('should handle consecutive underscores/dashes', () => { - const result = buildFunctionCallToolName('my--server', 'my__tool') - expect(result).not.toMatch(/[_-]{2,}/) - }) - }) - - describe('length constraints', () => { - it('should truncate names longer than 63 characters', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') - - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should not end with underscore or dash after truncation', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const result = buildFunctionCallToolName(longServerName, longToolName, 'id123456') - - expect(result).not.toMatch(/[_-]$/) - }) - - it('should preserve serverId suffix even with long server/tool names', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const serverId = 'server-id-xyz789' - - const result = buildFunctionCallToolName(longServerName, longToolName, serverId) - - // The suffix should be preserved and not truncated - expect(result).toContain('xyz789') - expect(result.length).toBeLessThanOrEqual(63) - }) - - it('should ensure two long-named servers with different IDs produce different results', () => { - const longServerName = 'a'.repeat(50) - const longToolName = 'b'.repeat(50) - const serverId1 = 'server-id-abc123' - const serverId2 = 'server-id-def456' - - const result1 = buildFunctionCallToolName(longServerName, longToolName, serverId1) - const result2 = buildFunctionCallToolName(longServerName, longToolName, serverId2) - - // Both should be within limit - expect(result1.length).toBeLessThanOrEqual(63) - expect(result2.length).toBeLessThanOrEqual(63) - - // They should be different due to preserved suffix expect(result1).not.toBe(result2) - }) - }) - - describe('edge cases with serverId', () => { - it('should handle serverId with only non-alphanumeric characters', () => { - const serverId = '------' // All dashes - const result = buildFunctionCallToolName('server', 'tool', serverId) - - // Should still produce a valid unique suffix via fallback hash - expect(result).toBeTruthy() - expect(result.length).toBeLessThanOrEqual(63) - expect(result).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) - // Should have a suffix (underscore followed by something) - expect(result).toMatch(/_[a-z0-9]+$/) - }) - - it('should produce different results for different non-alphanumeric serverIds', () => { - const serverId1 = '------' - const serverId2 = '!!!!!!' - - const result1 = buildFunctionCallToolName('server', 'tool', serverId1) - const result2 = buildFunctionCallToolName('server', 'tool', serverId2) - - // Should be different because the hash fallback produces different values - expect(result1).not.toBe(result2) - }) - - it('should handle empty string serverId differently from undefined', () => { - const resultWithEmpty = buildFunctionCallToolName('server', 'tool', '') - const resultWithUndefined = buildFunctionCallToolName('server', 'tool', undefined) - - // Empty string is falsy, so both should behave the same (no suffix) - expect(resultWithEmpty).toBe(resultWithUndefined) - }) - - it('should handle serverId with mixed alphanumeric and special chars', () => { - const serverId = 'ab@#cd' // Mixed chars, last 6 chars contain some alphanumeric - const result = buildFunctionCallToolName('server', 'tool', serverId) - - // Should extract alphanumeric chars: 'abcd' from 'ab@#cd' - expect(result).toContain('abcd') + expect(result3).not.toBe(result4) }) }) describe('real-world scenarios', () => { - it('should handle GitHub MCP server instances correctly', () => { - const serverName = 'github' - const toolName = 'search_repositories' - - const githubComId = 'server-github-com-abc123' - const gheId = 'server-ghe-internal-xyz789' - - const tool1 = buildFunctionCallToolName(serverName, toolName, githubComId) - const tool2 = buildFunctionCallToolName(serverName, toolName, gheId) - - // Should be different - expect(tool1).not.toBe(tool2) - - // Both should be valid identifiers - expect(tool1).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) - expect(tool2).toMatch(/^[a-zA-Z][a-zA-Z0-9_-]*$/) - - // Both should be <= 63 chars - expect(tool1.length).toBeLessThanOrEqual(63) - expect(tool2.length).toBeLessThanOrEqual(63) + it('should handle GitHub MCP server', () => { + expect(buildFunctionCallToolName('github', 'create_issue')).toBe('mcp__github__create_issue') + expect(buildFunctionCallToolName('github', 'search_repositories')).toBe('mcp__github__search_repositories') + expect(buildFunctionCallToolName('github', 'get_pull_request')).toBe('mcp__github__get_pull_request') }) - it('should handle tool names that already include server name prefix', () => { - const result = buildFunctionCallToolName('github', 'github_search_repos') - expect(result).toBeTruthy() - // Should not double the server name - expect(result.split('github').length - 1).toBeLessThanOrEqual(2) + it('should handle filesystem MCP server', () => { + expect(buildFunctionCallToolName('filesystem', 'read_file')).toBe('mcp__filesystem__read_file') + expect(buildFunctionCallToolName('filesystem', 'write_file')).toBe('mcp__filesystem__write_file') + expect(buildFunctionCallToolName('filesystem', 'list_directory')).toBe('mcp__filesystem__list_directory') + }) + + it('should handle hyphenated server names (common in npm packages)', () => { + expect(buildFunctionCallToolName('cherry-fetch', 'get_page')).toBe('mcp__cherry_fetch__get_page') + expect(buildFunctionCallToolName('mcp-server-github', 'search')).toBe('mcp__mcp_server_github__search') + }) + + it('should handle scoped npm package style names', () => { + const result = buildFunctionCallToolName('@anthropic/mcp-server', 'chat') + expect(result).toBe('mcp__anthropic_mcp_server__chat') + }) + + it('should handle tools with long descriptive names', () => { + const result = buildFunctionCallToolName('github', 'search_repositories_by_language_and_stars') + expect(result.length).toBeLessThanOrEqual(63) + expect(result).toMatch(/^mcp__github__search_repositories_by_lan/) }) }) }) diff --git a/src/main/utils/mcp.ts b/src/main/utils/mcp.ts index cfa700f2e6..34eb0e63e7 100644 --- a/src/main/utils/mcp.ts +++ b/src/main/utils/mcp.ts @@ -1,56 +1,28 @@ -export function buildFunctionCallToolName(serverName: string, toolName: string, serverId?: string) { - const sanitizedServer = serverName.trim().replace(/-/g, '_') - const sanitizedTool = toolName.trim().replace(/-/g, '_') +/** + * Builds a valid JavaScript function name for MCP tool calls. + * Format: mcp__{server_name}__{tool_name} + * + * @param serverName - The MCP server name + * @param toolName - The tool name from the server + * @returns A valid JS identifier in format mcp__{server}__{tool}, max 63 chars + */ +export function buildFunctionCallToolName(serverName: string, toolName: string): string { + // Sanitize to valid JS identifier chars (alphanumeric + underscore only) + const sanitize = (str: string): string => + str + .trim() + .replace(/[^a-zA-Z0-9]/g, '_') // Replace all non-alphanumeric with underscore + .replace(/_{2,}/g, '_') // Collapse multiple underscores + .replace(/^_+|_+$/g, '') // Trim leading/trailing underscores - // Calculate suffix first to reserve space for it - // Suffix format: "_" + 6 alphanumeric chars = 7 chars total - let serverIdSuffix = '' - if (serverId) { - // Take the last 6 characters of the serverId for brevity - serverIdSuffix = serverId.slice(-6).replace(/[^a-zA-Z0-9]/g, '') + const server = sanitize(serverName).slice(0, 20) // Keep server name short + const tool = sanitize(toolName).slice(0, 35) // More room for tool name - // Fallback: if suffix becomes empty (all non-alphanumeric chars), use a simple hash - if (!serverIdSuffix) { - const hash = serverId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - serverIdSuffix = hash.toString(36).slice(-6) || 'x' - } - } + let name = `mcp__${server}__${tool}` - // Reserve space for suffix when calculating max base name length - const SUFFIX_LENGTH = serverIdSuffix ? serverIdSuffix.length + 1 : 0 // +1 for underscore - const MAX_BASE_LENGTH = 63 - SUFFIX_LENGTH - - // Combine server name and tool name - let name = sanitizedTool - if (!sanitizedTool.includes(sanitizedServer.slice(0, 7))) { - name = `${sanitizedServer.slice(0, 7) || ''}-${sanitizedTool || ''}` - } - - // Replace invalid characters with underscores or dashes - // Keep a-z, A-Z, 0-9, underscores and dashes - name = name.replace(/[^a-zA-Z0-9_-]/g, '_') - - // Ensure name starts with a letter or underscore (for valid JavaScript identifier) - if (!/^[a-zA-Z]/.test(name)) { - name = `tool-${name}` - } - - // Remove consecutive underscores/dashes (optional improvement) - name = name.replace(/[_-]{2,}/g, '_') - - // Truncate base name BEFORE adding suffix to ensure suffix is never cut off - if (name.length > MAX_BASE_LENGTH) { - name = name.slice(0, MAX_BASE_LENGTH) - } - - // Handle edge case: ensure we still have a valid name if truncation left invalid chars at edges - if (name.endsWith('_') || name.endsWith('-')) { - name = name.slice(0, -1) - } - - // Now append the suffix - it will always fit within 63 chars - if (serverIdSuffix) { - name = `${name}_${serverIdSuffix}` + // Ensure max 63 chars and clean trailing underscores + if (name.length > 63) { + name = name.slice(0, 63).replace(/_+$/, '') } return name From 05c26fbff26c9b06a539efb5f8cc73a2bbd6f4ea Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 29 Dec 2025 18:57:44 +0800 Subject: [PATCH 059/116] refactor(ui): update utility imports to use internal lib - Changed utility imports from '@cherrystudio/ui/utils' to '@cherrystudio/ui/lib/utils' across multiple components for better organization. - Introduced a new internal utility module for class name merging, ensuring consistent usage across the UI components. - Updated relevant components to reflect the new import paths, enhancing maintainability and clarity. --- packages/ui/components.json | 2 +- .../components/composites/Ellipsis/index.tsx | 3 +-- .../src/components/composites/Flex/index.tsx | 3 +-- .../src/components/composites/Input/input.tsx | 3 ++- .../components/composites/ListItem/index.tsx | 3 +-- .../composites/Sortable/ItemRenderer.tsx | 2 +- .../composites/ThinkingEffect/index.tsx | 2 +- .../primitives/Avatar/EmojiAvatar.tsx | 3 +-- .../components/primitives/Avatar/index.tsx | 2 +- .../ui/src/components/primitives/badge.tsx | 2 +- .../src/components/primitives/breadcrumb.tsx | 2 +- .../ui/src/components/primitives/button.tsx | 2 +- .../ui/src/components/primitives/checkbox.tsx | 2 +- .../ui/src/components/primitives/combobox.tsx | 2 +- .../ui/src/components/primitives/command.tsx | 2 +- .../ui/src/components/primitives/dialog.tsx | 2 +- .../src/components/primitives/input-group.tsx | 2 +- .../ui/src/components/primitives/input.tsx | 2 +- packages/ui/src/components/primitives/kbd.tsx | 2 +- .../src/components/primitives/pagination.tsx | 2 +- .../ui/src/components/primitives/popover.tsx | 2 +- .../src/components/primitives/radioGroup.tsx | 2 +- .../ui/src/components/primitives/select.tsx | 2 +- .../primitives/shadcn-io/dropzone/index.tsx | 2 +- .../ui/src/components/primitives/switch.tsx | 2 +- .../ui/src/components/primitives/tabs.tsx | 2 +- .../ui/src/components/primitives/textarea.tsx | 2 +- .../src/components/primitives/tooltip_new.tsx | 2 +- packages/ui/src/lib/utils.ts | 23 +++++++++++++++++++ packages/ui/src/utils/index.ts | 14 +++++------ .../src/components/Avatar/ModelAvatar.tsx | 3 ++- .../components/Buttons/ActionIconButton.tsx | 3 ++- .../src/components/ProviderAvatar.tsx | 3 ++- .../pages/home/Messages/NewTopicButton.tsx | 3 ++- .../pages/home/Tabs/components/AddButton.tsx | 3 ++- src/renderer/src/pages/settings/index.tsx | 2 +- 36 files changed, 69 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/lib/utils.ts diff --git a/packages/ui/components.json b/packages/ui/components.json index b5c2f24eff..a6c7c26b0c 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -5,7 +5,7 @@ "hooks": "@cherrystudio/ui/hooks", "lib": "@cherrystudio/ui/lib", "ui": "@cherrystudio/ui/components/primitives", - "utils": "@cherrystudio/ui/utils" + "utils": "@cherrystudio/ui/lib/utils" }, "iconLibrary": "lucide", "rsc": false, diff --git a/packages/ui/src/components/composites/Ellipsis/index.tsx b/packages/ui/src/components/composites/Ellipsis/index.tsx index c4c296079c..c5c3a5fd72 100644 --- a/packages/ui/src/components/composites/Ellipsis/index.tsx +++ b/packages/ui/src/components/composites/Ellipsis/index.tsx @@ -1,8 +1,7 @@ // Original: src/renderer/src/components/Ellipsis/index.tsx +import { cn } from '@cherrystudio/ui/lib/utils' import type { HTMLAttributes } from 'react' -import { cn } from '../../../utils' - type Props = { maxLine?: number className?: string diff --git a/packages/ui/src/components/composites/Flex/index.tsx b/packages/ui/src/components/composites/Flex/index.tsx index 522a5574d7..6aa34293e1 100644 --- a/packages/ui/src/components/composites/Flex/index.tsx +++ b/packages/ui/src/components/composites/Flex/index.tsx @@ -1,7 +1,6 @@ +import { cn } from '@cherrystudio/ui/lib/utils' import React from 'react' -import { cn } from '../../../utils' - export interface BoxProps extends React.ComponentProps<'div'> {} export const Box = ({ children, className, ...props }: BoxProps & { children?: React.ReactNode }) => { diff --git a/packages/ui/src/components/composites/Input/input.tsx b/packages/ui/src/components/composites/Input/input.tsx index 80c83fe15e..1d792f2039 100644 --- a/packages/ui/src/components/composites/Input/input.tsx +++ b/packages/ui/src/components/composites/Input/input.tsx @@ -1,4 +1,5 @@ -import { cn, toUndefinedIfNull } from '@cherrystudio/ui/utils' +import { cn } from '@cherrystudio/ui/lib/utils' +import { toUndefinedIfNull } from '@cherrystudio/ui/utils/index' import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' import { Edit2Icon, EyeIcon, EyeOffIcon } from 'lucide-react' diff --git a/packages/ui/src/components/composites/ListItem/index.tsx b/packages/ui/src/components/composites/ListItem/index.tsx index 196fdb2949..327dda27e0 100644 --- a/packages/ui/src/components/composites/ListItem/index.tsx +++ b/packages/ui/src/components/composites/ListItem/index.tsx @@ -1,9 +1,8 @@ // Original path: src/renderer/src/components/ListItem/index.tsx +import { cn } from '@cherrystudio/ui/lib/utils' import { Tooltip } from '@heroui/react' import type { ReactNode } from 'react' -import { cn } from '../../../utils' - interface ListItemProps { active?: boolean icon?: ReactNode diff --git a/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx b/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx index e9e048fd6a..396af9b11b 100644 --- a/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx +++ b/packages/ui/src/components/composites/Sortable/ItemRenderer.tsx @@ -1,10 +1,10 @@ +import { cn } from '@cherrystudio/ui/lib/utils' import type { DraggableSyntheticListeners } from '@dnd-kit/core' import type { Transform } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities' import React, { useEffect } from 'react' import styled from 'styled-components' -import { cn } from '../../../utils' import type { RenderItemType } from './types' interface ItemRendererProps { diff --git a/packages/ui/src/components/composites/ThinkingEffect/index.tsx b/packages/ui/src/components/composites/ThinkingEffect/index.tsx index 6c542bf3d4..86baad1470 100644 --- a/packages/ui/src/components/composites/ThinkingEffect/index.tsx +++ b/packages/ui/src/components/composites/ThinkingEffect/index.tsx @@ -7,12 +7,12 @@ */ // Original path: src/renderer/src/components/ThinkingEffect.tsx +import { cn } from '@cherrystudio/ui/lib/utils' import { isEqual } from 'lodash' import { ChevronRight, Lightbulb } from 'lucide-react' import { motion } from 'motion/react' import React, { useEffect, useMemo, useState } from 'react' -import { cn } from '../../../utils' import { lightbulbVariants } from './defaultVariants' interface ThinkingEffectProps { diff --git a/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx b/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx index 7a9ce03e24..e6fef89703 100644 --- a/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx +++ b/packages/ui/src/components/primitives/Avatar/EmojiAvatar.tsx @@ -1,7 +1,6 @@ +import { cn } from '@cherrystudio/ui/lib/utils' import React, { memo } from 'react' -import { cn } from '../../../utils' - interface EmojiAvatarProps { children: string size?: number diff --git a/packages/ui/src/components/primitives/Avatar/index.tsx b/packages/ui/src/components/primitives/Avatar/index.tsx index a2ad31bd73..1c5aff9658 100644 --- a/packages/ui/src/components/primitives/Avatar/index.tsx +++ b/packages/ui/src/components/primitives/Avatar/index.tsx @@ -1,7 +1,7 @@ +import { cn } from '@cherrystudio/ui/lib/utils' import type { AvatarProps as HeroUIAvatarProps } from '@heroui/react' import { Avatar as HeroUIAvatar, AvatarGroup as HeroUIAvatarGroup } from '@heroui/react' -import { cn } from '../../../utils' import EmojiAvatar from './EmojiAvatar' export interface AvatarProps extends Omit { diff --git a/packages/ui/src/components/primitives/badge.tsx b/packages/ui/src/components/primitives/badge.tsx index e63b6dde4c..5cb3c8cefe 100644 --- a/packages/ui/src/components/primitives/badge.tsx +++ b/packages/ui/src/components/primitives/badge.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/breadcrumb.tsx b/packages/ui/src/components/primitives/breadcrumb.tsx index 6f9d871409..11c3527eeb 100644 --- a/packages/ui/src/components/primitives/breadcrumb.tsx +++ b/packages/ui/src/components/primitives/breadcrumb.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { Slot } from '@radix-ui/react-slot' import { ChevronRight, MoreHorizontal } from 'lucide-react' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/button.tsx b/packages/ui/src/components/primitives/button.tsx index 092d55dd1c..8fb96c9903 100644 --- a/packages/ui/src/components/primitives/button.tsx +++ b/packages/ui/src/components/primitives/button.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { Slot } from '@radix-ui/react-slot' import { cva, type VariantProps } from 'class-variance-authority' import { Loader } from 'lucide-react' diff --git a/packages/ui/src/components/primitives/checkbox.tsx b/packages/ui/src/components/primitives/checkbox.tsx index 34f374fec4..dff1f928c2 100644 --- a/packages/ui/src/components/primitives/checkbox.tsx +++ b/packages/ui/src/components/primitives/checkbox.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as CheckboxPrimitive from '@radix-ui/react-checkbox' import { cva, type VariantProps } from 'class-variance-authority' import { CheckIcon } from 'lucide-react' diff --git a/packages/ui/src/components/primitives/combobox.tsx b/packages/ui/src/components/primitives/combobox.tsx index 15afa8c0a8..2e11809351 100644 --- a/packages/ui/src/components/primitives/combobox.tsx +++ b/packages/ui/src/components/primitives/combobox.tsx @@ -10,7 +10,7 @@ import { CommandList } from '@cherrystudio/ui/components/primitives/command' import { Popover, PopoverContent, PopoverTrigger } from '@cherrystudio/ui/components/primitives/popover' -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { cva, type VariantProps } from 'class-variance-authority' import { Check, ChevronDown, X } from 'lucide-react' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/command.tsx b/packages/ui/src/components/primitives/command.tsx index 2d0515d272..76ecf7a1c1 100644 --- a/packages/ui/src/components/primitives/command.tsx +++ b/packages/ui/src/components/primitives/command.tsx @@ -5,7 +5,7 @@ import { DialogHeader, DialogTitle } from '@cherrystudio/ui/components/primitives/dialog' -import { cn } from '@cherrystudio/ui/utils' +import { cn } from '@cherrystudio/ui/lib/utils' import { Command as CommandPrimitive } from 'cmdk' import { SearchIcon } from 'lucide-react' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/dialog.tsx b/packages/ui/src/components/primitives/dialog.tsx index 6b36644bc7..62a063eea4 100644 --- a/packages/ui/src/components/primitives/dialog.tsx +++ b/packages/ui/src/components/primitives/dialog.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as DialogPrimitive from '@radix-ui/react-dialog' import { XIcon } from 'lucide-react' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/input-group.tsx b/packages/ui/src/components/primitives/input-group.tsx index 9c27456b34..0bb9253001 100644 --- a/packages/ui/src/components/primitives/input-group.tsx +++ b/packages/ui/src/components/primitives/input-group.tsx @@ -3,7 +3,7 @@ import type { InputProps } from '@cherrystudio/ui/components/primitives/input' import { Input } from '@cherrystudio/ui/components/primitives/input' import type { TextareaInputProps } from '@cherrystudio/ui/components/primitives/textarea' import * as Textarea from '@cherrystudio/ui/components/primitives/textarea' -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { cva, type VariantProps } from 'class-variance-authority' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/input.tsx b/packages/ui/src/components/primitives/input.tsx index 5a5e29cd5a..cffad36b44 100644 --- a/packages/ui/src/components/primitives/input.tsx +++ b/packages/ui/src/components/primitives/input.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils' +import { cn } from '@cherrystudio/ui/lib/utils' import * as React from 'react' interface InputProps extends React.ComponentProps<'input'> {} diff --git a/packages/ui/src/components/primitives/kbd.tsx b/packages/ui/src/components/primitives/kbd.tsx index d1a2268e75..21c4b06b7b 100644 --- a/packages/ui/src/components/primitives/kbd.tsx +++ b/packages/ui/src/components/primitives/kbd.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) { return ( diff --git a/packages/ui/src/components/primitives/pagination.tsx b/packages/ui/src/components/primitives/pagination.tsx index eb675e8bb0..4e5a407c07 100644 --- a/packages/ui/src/components/primitives/pagination.tsx +++ b/packages/ui/src/components/primitives/pagination.tsx @@ -1,6 +1,6 @@ import type { Button } from '@cherrystudio/ui/components/primitives/button' import { buttonVariants } from '@cherrystudio/ui/components/primitives/button' -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/popover.tsx b/packages/ui/src/components/primitives/popover.tsx index b52cc7aa4a..805d952b07 100644 --- a/packages/ui/src/components/primitives/popover.tsx +++ b/packages/ui/src/components/primitives/popover.tsx @@ -1,6 +1,6 @@ 'use client' -import { cn } from '@cherrystudio/ui/utils' +import { cn } from '@cherrystudio/ui/lib/utils' import * as PopoverPrimitive from '@radix-ui/react-popover' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/radioGroup.tsx b/packages/ui/src/components/primitives/radioGroup.tsx index 0d4b95b6c9..2dd4ece391 100644 --- a/packages/ui/src/components/primitives/radioGroup.tsx +++ b/packages/ui/src/components/primitives/radioGroup.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' import { cva, type VariantProps } from 'class-variance-authority' import { CircleIcon } from 'lucide-react' diff --git a/packages/ui/src/components/primitives/select.tsx b/packages/ui/src/components/primitives/select.tsx index ec2bac4cba..9b1fa1bba5 100644 --- a/packages/ui/src/components/primitives/select.tsx +++ b/packages/ui/src/components/primitives/select.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as SelectPrimitive from '@radix-ui/react-select' import { cva, type VariantProps } from 'class-variance-authority' import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react' diff --git a/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx b/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx index 4892a94244..14ba16a6d0 100644 --- a/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx +++ b/packages/ui/src/components/primitives/shadcn-io/dropzone/index.tsx @@ -1,7 +1,7 @@ 'use client' import { Button } from '@cherrystudio/ui/components/primitives/button' -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { UploadIcon } from 'lucide-react' import type { ReactNode } from 'react' import { createContext, use } from 'react' diff --git a/packages/ui/src/components/primitives/switch.tsx b/packages/ui/src/components/primitives/switch.tsx index 7ce2ac5d3c..2e9b2c12eb 100644 --- a/packages/ui/src/components/primitives/switch.tsx +++ b/packages/ui/src/components/primitives/switch.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils' +import { cn } from '@cherrystudio/ui/lib/utils' import * as SwitchPrimitive from '@radix-ui/react-switch' import { cva } from 'class-variance-authority' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/tabs.tsx b/packages/ui/src/components/primitives/tabs.tsx index 051de1dfb2..95c8ec90e2 100644 --- a/packages/ui/src/components/primitives/tabs.tsx +++ b/packages/ui/src/components/primitives/tabs.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as TabsPrimitive from '@radix-ui/react-tabs' import { cva } from 'class-variance-authority' import * as React from 'react' diff --git a/packages/ui/src/components/primitives/textarea.tsx b/packages/ui/src/components/primitives/textarea.tsx index 5bc749d7ac..1444e8c9ed 100644 --- a/packages/ui/src/components/primitives/textarea.tsx +++ b/packages/ui/src/components/primitives/textarea.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import { composeEventHandlers } from '@radix-ui/primitive' import { useCallbackRef } from '@radix-ui/react-use-callback-ref' import { useControllableState } from '@radix-ui/react-use-controllable-state' diff --git a/packages/ui/src/components/primitives/tooltip_new.tsx b/packages/ui/src/components/primitives/tooltip_new.tsx index 430ac262f4..9b1db13e1e 100644 --- a/packages/ui/src/components/primitives/tooltip_new.tsx +++ b/packages/ui/src/components/primitives/tooltip_new.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui/utils/index' +import { cn } from '@cherrystudio/ui/lib/utils' import * as TooltipPrimitive from '@radix-ui/react-tooltip' import * as React from 'react' diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts new file mode 100644 index 0000000000..d477ffd44a --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,23 @@ +/** + * Internal utilities for UI components. + * + * This module is for INTERNAL use only and should NOT be exposed to external consumers. + * External utilities should be placed in `utils/` instead. + * + * @internal + */ + +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +/** + * Merges Tailwind CSS class names with conflict resolution. + * Combines clsx for conditional classes and tailwind-merge for deduplication. + * + * @example + * cn('px-2 py-1', 'px-4') // => 'py-1 px-4' + * cn('text-red-500', isActive && 'text-blue-500') + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 7f0275f99d..573e97be73 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1,13 +1,11 @@ -import { type ClassValue, clsx } from 'clsx' -import { twMerge } from 'tailwind-merge' - /** - * Merge class names with tailwind-merge - * This utility combines clsx and tailwind-merge for optimal class name handling + * Public utility functions for external consumers. + * + * This module is part of the PUBLIC API and can be imported via `@cherrystudio/ui/utils`. + * For internal-only utilities (e.g., Tailwind class merging), use `lib/` instead. + * + * @module utils */ -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) -} /** * Converts `null` to `undefined`, otherwise returns the input value. diff --git a/src/renderer/src/components/Avatar/ModelAvatar.tsx b/src/renderer/src/components/Avatar/ModelAvatar.tsx index 9ce6d87c46..cff23486f7 100644 --- a/src/renderer/src/components/Avatar/ModelAvatar.tsx +++ b/src/renderer/src/components/Avatar/ModelAvatar.tsx @@ -1,7 +1,8 @@ import type { AvatarProps } from '@cherrystudio/ui' -import { Avatar, cn } from '@cherrystudio/ui' +import { Avatar } from '@cherrystudio/ui' import { getModelLogo } from '@renderer/config/models' import type { Model } from '@renderer/types' +import { cn } from '@renderer/utils' import { first } from 'lodash' import type { FC } from 'react' diff --git a/src/renderer/src/components/Buttons/ActionIconButton.tsx b/src/renderer/src/components/Buttons/ActionIconButton.tsx index 221a5eeb30..ec1da45ab8 100644 --- a/src/renderer/src/components/Buttons/ActionIconButton.tsx +++ b/src/renderer/src/components/Buttons/ActionIconButton.tsx @@ -1,4 +1,5 @@ -import { Button, cn } from '@cherrystudio/ui' +import { Button } from '@cherrystudio/ui' +import { cn } from '@renderer/utils' import React, { memo } from 'react' interface ActionIconButtonProps extends Omit, 'ref'> { diff --git a/src/renderer/src/components/ProviderAvatar.tsx b/src/renderer/src/components/ProviderAvatar.tsx index 468941e3f8..dddfdce983 100644 --- a/src/renderer/src/components/ProviderAvatar.tsx +++ b/src/renderer/src/components/ProviderAvatar.tsx @@ -1,7 +1,8 @@ -import { Avatar, cn } from '@cherrystudio/ui' +import { Avatar } from '@cherrystudio/ui' import { PoeLogo } from '@renderer/components/Icons' import { getProviderLogo } from '@renderer/config/providers' import type { Provider } from '@renderer/types' +import { cn } from '@renderer/utils' import { generateColorFromChar, getFirstCharacter, getForegroundColor } from '@renderer/utils' import React from 'react' diff --git a/src/renderer/src/pages/home/Messages/NewTopicButton.tsx b/src/renderer/src/pages/home/Messages/NewTopicButton.tsx index 55696a4e95..1c654967f4 100644 --- a/src/renderer/src/pages/home/Messages/NewTopicButton.tsx +++ b/src/renderer/src/pages/home/Messages/NewTopicButton.tsx @@ -1,8 +1,9 @@ import { FormOutlined } from '@ant-design/icons' -import { Button, cn } from '@cherrystudio/ui' +import { Button } from '@cherrystudio/ui' import { useTheme } from '@renderer/context/ThemeProvider' import { EventEmitter } from '@renderer/services/EventService' import { EVENT_NAMES } from '@renderer/services/EventService' +import { cn } from '@renderer/utils' import { ThemeMode } from '@shared/data/preference/preferenceTypes' import type { FC } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx index 407fa7a271..69a89ff312 100644 --- a/src/renderer/src/pages/home/Tabs/components/AddButton.tsx +++ b/src/renderer/src/pages/home/Tabs/components/AddButton.tsx @@ -1,4 +1,5 @@ -import { Button, cn } from '@cherrystudio/ui' +import { Button } from '@cherrystudio/ui' +import { cn } from '@renderer/utils' import { PlusIcon } from 'lucide-react' const AddButton = ({ children, className, ...props }) => { diff --git a/src/renderer/src/pages/settings/index.tsx b/src/renderer/src/pages/settings/index.tsx index e5c88f83e1..9afe342548 100644 --- a/src/renderer/src/pages/settings/index.tsx +++ b/src/renderer/src/pages/settings/index.tsx @@ -1,4 +1,4 @@ -import { cn } from '@cherrystudio/ui' +import { cn } from '@renderer/utils' import type { ThemeMode } from '@shared/data/preference/preferenceTypes' import { Divider } from 'antd' import Link from 'antd/es/typography/Link' From ba107b2f6fecf02ffa63045fd55586b07a45ab69 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 29 Dec 2025 18:57:54 +0800 Subject: [PATCH 060/116] chore(deps): update React and React DOM to version 19.2.0 - Added resolutions for React and React DOM to ensure consistent usage of version 19.2.0 across the project. - Removed outdated version entries from yarn.lock to streamline dependency management. --- package.json | 2 ++ yarn.lock | 18 ------------------ 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index ff007d0cbd..6220968c5b 100644 --- a/package.json +++ b/package.json @@ -389,6 +389,8 @@ "zod": "^4.1.5" }, "resolutions": { + "react": "^19.2.0", + "react-dom": "^19.2.0", "@smithy/types": "4.7.1", "@codemirror/language": "6.11.3", "@codemirror/lint": "6.8.5", diff --git a/yarn.lock b/yarn.lock index ad0a40a700..737be7d888 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26685,17 +26685,6 @@ __metadata: languageName: node linkType: hard -"react-dom@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react-dom@npm:^19.0.0": - version: 19.2.3 - resolution: "react-dom@npm:19.2.3" - dependencies: - scheduler: "npm:^0.27.0" - peerDependencies: - react: ^19.2.3 - checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892 - languageName: node - linkType: hard - "react-dom@npm:^19.2.0": version: 19.2.0 resolution: "react-dom@npm:19.2.0" @@ -26989,13 +26978,6 @@ __metadata: languageName: node linkType: hard -"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.0.0": - version: 19.2.3 - resolution: "react@npm:19.2.3" - checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436 - languageName: node - linkType: hard - "react@npm:^19.2.0": version: 19.2.0 resolution: "react@npm:19.2.0" From b156ee68e0342ab2bc0cd9c4492e73101d622bc8 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 29 Dec 2025 23:56:27 +0800 Subject: [PATCH 061/116] feat(cache): enhance shared cache functionality and synchronization in main - Introduced type-safe access methods for shared cache in the Main process, including `getShared`, `setShared`, `hasShared`, and `deleteShared`. - Implemented `getAllShared` for initializing new Renderer windows with the complete shared cache state. - Updated IPC communication to support bidirectional synchronization of shared cache between Main and Renderer processes. - Enhanced cache management with absolute timestamps for TTL, ensuring precise expiration handling across windows. - Added ready state tracking in Renderer for improved synchronization feedback during initialization. - Refactored related documentation to reflect new features and usage patterns for shared cache. --- docs/en/references/data/cache-overview.md | 20 +-- docs/en/references/data/cache-usage.md | 62 +++++++++ packages/shared/IpcChannel.ts | 1 + packages/shared/data/cache/cacheSchemas.ts | 6 +- packages/shared/data/cache/cacheTypes.ts | 4 +- src/main/data/CacheService.ts | 147 ++++++++++++++++++++- src/preload/index.ts | 7 +- src/renderer/src/data/CacheService.ts | 131 +++++++++++++++--- src/renderer/src/data/hooks/useCache.ts | 20 +-- tests/__mocks__/renderer/CacheService.ts | 8 +- tests/__mocks__/renderer/useCache.ts | 48 +++---- 11 files changed, 379 insertions(+), 75 deletions(-) diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md index 6e28e061fb..87ab7d66cd 100644 --- a/docs/en/references/data/cache-overview.md +++ b/docs/en/references/data/cache-overview.md @@ -23,8 +23,9 @@ CacheService handles data that: - Best for: expensive computations, API response caching ### Shared Cache -- Synchronized across all windows via IPC -- Main process acts as the source of truth +- Synchronized bidirectionally between Main and all Renderer windows via IPC +- Main process maintains authoritative copy and provides initialization sync for new windows +- New windows fetch complete shared cache state from Main on startup - Best for: window layouts, shared UI state ### Persist Cache @@ -101,14 +102,17 @@ cacheService.set('temp.calculation', result, 30000) ## Main vs Renderer Responsibilities ### Main Process CacheService -- Manages shared and persist cache storage -- Handles IPC requests from renderers -- Broadcasts updates to all windows -- Manages TTL expiration for shared caches +- Manages internal cache for Main process services +- Maintains authoritative SharedCache with type-safe access (`getShared`, `setShared`, `hasShared`, `deleteShared`) +- Provides `getAllShared()` for new window initialization sync +- Handles IPC requests from renderers and broadcasts updates to all windows +- Manages TTL expiration using absolute timestamps (`expireAt`) for precise cross-window sync ### Renderer Process CacheService -- Manages local memory cache -- Proxies shared/persist operations to Main +- Manages local memory cache and SharedCache local copy +- Syncs SharedCache from Main on window initialization (async, non-blocking) +- Provides ready state tracking via `isSharedCacheReady()` and `onSharedCacheReady()` +- Broadcasts cache updates to Main for cross-window sync - Handles hook subscriptions and updates - Local TTL management for memory cache diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md index 2dfd0f8453..6e1c761362 100644 --- a/docs/en/references/data/cache-usage.md +++ b/docs/en/references/data/cache-usage.md @@ -106,6 +106,39 @@ const files = cacheService.getPersist('app.recent_files') cacheService.deletePersist('app.recent_files') ``` +## Main Process Usage + +Main process CacheService provides SharedCache for cross-window state management. + +### SharedCache in Main Process + +```typescript +import { cacheService } from '@main/data/CacheService' + +// Type-safe (schema key) - matches Renderer's type system +cacheService.setShared('window.layout', layoutConfig) +const layout = cacheService.getShared('window.layout') + +// With TTL (30 seconds) +cacheService.setShared('temp.state', state, 30000) + +// Check existence +if (cacheService.hasShared('window.layout')) { + // ... +} + +// Delete +cacheService.deleteShared('window.layout') +``` + +**Note**: Main CacheService does NOT support Casual methods (`getSharedCasual`, etc.). Only schema-based type-safe access is available in Main process. + +### Sync Strategy + +- **Renderer → Main**: When Renderer calls `setShared()`, it broadcasts to Main via IPC. Main updates its SharedCache and relays to other windows. +- **Main → Renderer**: When Main calls `setShared()`, it broadcasts to all Renderer windows. +- **New Window Initialization**: New windows fetch complete SharedCache state from Main via `getAllShared()`. Uses Main-priority override strategy for conflicts. + ## Type-Safe vs Casual Methods ### Type-Safe Methods @@ -237,6 +270,34 @@ export interface MyDataType { const [data, setData] = useCache('myFeature.data', defaultValue) ``` +## Shared Cache Ready State + +Renderer CacheService provides ready state tracking for SharedCache initialization sync. + +```typescript +import { cacheService } from '@data/CacheService' + +// Check if shared cache is ready +if (cacheService.isSharedCacheReady()) { + // SharedCache has been synced from Main +} + +// Register callback when ready +const unsubscribe = cacheService.onSharedCacheReady(() => { + // Called immediately if already ready, or when sync completes + console.log('SharedCache ready!') +}) + +// Cleanup +unsubscribe() +``` + +**Behavior notes**: +- `getShared()` returns `undefined` before ready (expected behavior) +- `setShared()` works immediately and broadcasts to Main (Main updates its cache) +- Hooks like `useSharedCache` work normally - they set initial values and update when sync completes +- Main-priority override: when sync completes, Main's values override local values + ## Best Practices 1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival @@ -244,3 +305,4 @@ const [data, setData] = useCache('myFeature.data', defaultValue) 3. **Prefer type-safe keys**: Add to schema when possible 4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed 5. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) +6. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index c6611f19a4..c6db564802 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -343,6 +343,7 @@ export enum IpcChannel { // Data: Cache Cache_Sync = 'cache:sync', Cache_SyncBatch = 'cache:sync-batch', + Cache_GetAllShared = 'cache:get-all-shared', // Data: API Channels DataApi_Request = 'data-api:request', diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 0c467d7682..16a68f2a92 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -101,11 +101,11 @@ export const DefaultUseCache: UseCacheSchema = { /** * Use shared cache schema for renderer hook */ -export type UseSharedCacheSchema = { +export type SharedCacheSchema = { 'example_scope.example_key': string } -export const DefaultUseSharedCache: UseSharedCacheSchema = { +export const DefaultSharedCache: SharedCacheSchema = { 'example_scope.example_key': 'example default value' } @@ -126,4 +126,4 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = { */ export type RendererPersistCacheKey = keyof RendererPersistCacheSchema export type UseCacheKey = keyof UseCacheSchema -export type UseSharedCacheKey = keyof UseSharedCacheSchema +export type SharedCacheKey = keyof SharedCacheSchema diff --git a/packages/shared/data/cache/cacheTypes.ts b/packages/shared/data/cache/cacheTypes.ts index 1ae71919bc..e39dd2877c 100644 --- a/packages/shared/data/cache/cacheTypes.ts +++ b/packages/shared/data/cache/cacheTypes.ts @@ -22,7 +22,7 @@ export interface CacheSyncMessage { type: 'shared' | 'persist' key: string value: any - ttl?: number + expireAt?: number // Absolute Unix timestamp for precise cross-window sync } /** @@ -33,7 +33,7 @@ export interface CacheSyncBatchMessage { entries: Array<{ key: string value: any - ttl?: number + expireAt?: number // Absolute Unix timestamp for precise cross-window sync }> } diff --git a/src/main/data/CacheService.ts b/src/main/data/CacheService.ts index 79e8104999..2d260e14d2 100644 --- a/src/main/data/CacheService.ts +++ b/src/main/data/CacheService.ts @@ -18,6 +18,7 @@ */ import { loggerService } from '@logger' +import type { SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' import { IpcChannel } from '@shared/IpcChannel' import { BrowserWindow, ipcMain } from 'electron' @@ -42,9 +43,12 @@ export class CacheService { private static instance: CacheService private initialized = false - // Main process cache + // Main process internal cache private cache = new Map() + // Shared cache (synchronized with renderer windows) + private sharedCache = new Map() + // GC timer reference and interval time (e.g., every 10 minutes) private gcInterval: NodeJS.Timeout | null = null private readonly GC_INTERVAL_MS = 10 * 60 * 1000 @@ -79,7 +83,7 @@ export class CacheService { // ============ Main Process Cache (Internal) ============ /** - * Garbage collection logic + * Garbage collection logic for both internal and shared cache */ private startGarbageCollection() { if (this.gcInterval) return @@ -88,6 +92,7 @@ export class CacheService { const now = Date.now() let removedCount = 0 + // Clean internal cache for (const [key, entry] of this.cache.entries()) { if (entry.expireAt && now > entry.expireAt) { this.cache.delete(key) @@ -95,6 +100,14 @@ export class CacheService { } } + // Clean shared cache + for (const [key, entry] of this.sharedCache.entries()) { + if (entry.expireAt && now > entry.expireAt) { + this.sharedCache.delete(key) + removedCount++ + } + } + if (removedCount > 0) { logger.debug(`Garbage collection removed ${removedCount} expired items`) } @@ -155,6 +168,110 @@ export class CacheService { return this.cache.delete(key) } + // ============ Shared Cache (Cross-window via IPC) ============ + + /** + * Get value from shared cache with TTL validation (type-safe) + * @param key - Schema-defined shared cache key + * @returns Cached value or undefined if not found or expired + */ + getShared(key: K): SharedCacheSchema[K] | undefined { + const entry = this.sharedCache.get(key) + if (!entry) return undefined + + // Check TTL (lazy cleanup) + if (entry.expireAt && Date.now() > entry.expireAt) { + this.sharedCache.delete(key) + return undefined + } + + return entry.value as SharedCacheSchema[K] + } + + /** + * Set value in shared cache with cross-window broadcast (type-safe) + * @param key - Schema-defined shared cache key + * @param value - Value to cache (type inferred from schema) + * @param ttl - Time to live in milliseconds (optional) + */ + setShared(key: K, value: SharedCacheSchema[K], ttl?: number): void { + const expireAt = ttl ? Date.now() + ttl : undefined + const entry: CacheEntry = { value, expireAt } + + this.sharedCache.set(key, entry) + + // Broadcast to all renderer windows + this.broadcastSync({ + type: 'shared', + key, + value, + expireAt + }) + + logger.verbose(`Set shared cache key "${key}"`) + } + + /** + * Check if key exists in shared cache and is not expired (type-safe) + * @param key - Schema-defined shared cache key + * @returns True if key exists and is valid, false otherwise + */ + hasShared(key: K): boolean { + const entry = this.sharedCache.get(key) + if (!entry) return false + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + this.sharedCache.delete(key) + return false + } + + return true + } + + /** + * Delete from shared cache with cross-window broadcast (type-safe) + * @param key - Schema-defined shared cache key + * @returns True if deletion succeeded + */ + deleteShared(key: K): boolean { + if (!this.sharedCache.has(key)) { + return true + } + + this.sharedCache.delete(key) + + // Broadcast deletion to all renderer windows + this.broadcastSync({ + type: 'shared', + key, + value: undefined // undefined means deletion + }) + + logger.verbose(`Deleted shared cache key "${key}"`) + return true + } + + /** + * Get all shared cache entries (for renderer initialization sync) + * @returns Record of all shared cache entries with their metadata + */ + private getAllShared(): Record { + const now = Date.now() + const result: Record = {} + + for (const [key, entry] of this.sharedCache.entries()) { + // Skip expired entries + if (entry.expireAt && now > entry.expireAt) { + this.sharedCache.delete(key) + continue + } + result[key] = entry + } + + return result + } + // ============ Persist Cache Interface (Reserved) ============ // TODO: Implement persist cache in future @@ -180,10 +297,32 @@ export class CacheService { // Handle cache sync broadcast from renderer ipcMain.on(IpcChannel.Cache_Sync, (event, message: CacheSyncMessage) => { const senderWindowId = BrowserWindow.fromWebContents(event.sender)?.id + + // Update Main's sharedCache when receiving shared type sync + if (message.type === 'shared') { + if (message.value === undefined) { + // Handle deletion + this.sharedCache.delete(message.key) + } else { + // Handle set - use expireAt directly (absolute timestamp) + const entry: CacheEntry = { + value: message.value, + expireAt: message.expireAt + } + this.sharedCache.set(message.key, entry) + } + } + + // Broadcast to other windows this.broadcastSync(message, senderWindowId) logger.verbose(`Broadcasted cache sync: ${message.type}:${message.key}`) }) + // Handle getAllShared request for renderer initialization + ipcMain.handle(IpcChannel.Cache_GetAllShared, () => { + return this.getAllShared() + }) + logger.debug('Cache sync IPC handlers registered') } @@ -197,11 +336,13 @@ export class CacheService { this.gcInterval = null } - // Clear cache + // Clear caches this.cache.clear() + this.sharedCache.clear() // Remove IPC handlers ipcMain.removeAllListeners(IpcChannel.Cache_Sync) + ipcMain.removeHandler(IpcChannel.Cache_GetAllShared) logger.debug('CacheService cleanup completed') } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a0cbfe366..f5f511b7ff 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,7 +14,7 @@ import type { WebviewKeyEvent } from '@shared/config/types' import type { MCPServerLogEntry } from '@shared/config/types' -import type { CacheSyncMessage } from '@shared/data/cache/cacheTypes' +import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' import type { PreferenceDefaultScopeType, PreferenceKeyType, @@ -580,7 +580,10 @@ const api = { const listener = (_: any, message: CacheSyncMessage) => callback(message) ipcRenderer.on(IpcChannel.Cache_Sync, listener) return () => ipcRenderer.off(IpcChannel.Cache_Sync, listener) - } + }, + + // Get all shared cache entries from Main for initialization sync + getAllShared: (): Promise> => ipcRenderer.invoke(IpcChannel.Cache_GetAllShared) }, // PreferenceService related APIs diff --git a/src/renderer/src/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index 7fe9a2d7a7..1e61ce1ab7 100644 --- a/src/renderer/src/data/CacheService.ts +++ b/src/renderer/src/data/CacheService.ts @@ -21,10 +21,10 @@ import { loggerService } from '@logger' import type { RendererPersistCacheKey, RendererPersistCacheSchema, + SharedCacheKey, + SharedCacheSchema, UseCacheKey, - UseCacheSchema, - UseSharedCacheKey, - UseSharedCacheSchema + UseCacheSchema } from '@shared/data/cache/cacheSchemas' import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes' @@ -66,6 +66,10 @@ export class CacheService { private persistSaveTimer?: NodeJS.Timeout private persistDirty = false + // Shared cache ready state for initialization sync + private sharedCacheReady = false + private sharedCacheReadyCallbacks: Array<() => void> = [] + private constructor() { this.initialize() } @@ -87,6 +91,10 @@ export class CacheService { this.loadPersistCache() this.setupIpcListeners() this.setupWindowUnloadHandler() + + // Async sync SharedCache from Main (does not block initialization) + this.syncSharedCacheFromMain() + logger.debug('CacheService initialized') } @@ -279,7 +287,7 @@ export class CacheService { * @param key - Schema-defined shared cache key * @returns True if key has TTL configured */ - hasSharedTTL(key: K): boolean { + hasSharedTTL(key: K): boolean { const entry = this.sharedCache.get(key) return entry?.expireAt !== undefined } @@ -289,7 +297,7 @@ export class CacheService { * @param key - Dynamic shared cache key * @returns True if key has TTL configured */ - hasSharedTTLCasual(key: Exclude): boolean { + hasSharedTTLCasual(key: Exclude): boolean { const entry = this.sharedCache.get(key) return entry?.expireAt !== undefined } @@ -301,7 +309,7 @@ export class CacheService { * @param key - Schema-defined shared cache key * @returns Cached value or undefined if not found or expired */ - getShared(key: K): UseSharedCacheSchema[K] | undefined { + getShared(key: K): SharedCacheSchema[K] | undefined { return this.getSharedInternal(key) } @@ -310,7 +318,7 @@ export class CacheService { * @param key - Dynamic shared cache key (e.g., `window:${id}`) * @returns Cached value or undefined if not found or expired */ - getSharedCasual(key: Exclude): T | undefined { + getSharedCasual(key: Exclude): T | undefined { return this.getSharedInternal(key) } @@ -337,7 +345,7 @@ export class CacheService { * @param value - Value to cache (type inferred from schema) * @param ttl - Time to live in milliseconds (optional) */ - setShared(key: K, value: UseSharedCacheSchema[K], ttl?: number): void { + setShared(key: K, value: SharedCacheSchema[K], ttl?: number): void { this.setSharedInternal(key, value, ttl) } @@ -347,7 +355,7 @@ export class CacheService { * @param value - Value to cache * @param ttl - Time to live in milliseconds (optional) */ - setSharedCasual(key: Exclude, value: T, ttl?: number): void { + setSharedCasual(key: Exclude, value: T, ttl?: number): void { this.setSharedInternal(key, value, ttl) } @@ -356,11 +364,11 @@ export class CacheService { */ private setSharedInternal(key: string, value: any, ttl?: number): void { const existingEntry = this.sharedCache.get(key) + const newExpireAt = ttl ? Date.now() + ttl : undefined // Value comparison optimization if (existingEntry && Object.is(existingEntry.value, value)) { // Value is same, only update TTL if needed - const newExpireAt = ttl ? Date.now() + ttl : undefined if (!Object.is(existingEntry.expireAt, newExpireAt)) { existingEntry.expireAt = newExpireAt logger.verbose(`Updated TTL for shared cache key "${key}"`) @@ -369,7 +377,7 @@ export class CacheService { type: 'shared', key, value, - ttl + expireAt: newExpireAt // Use absolute timestamp for precise sync }) } else { logger.verbose(`Skipped shared cache update for key "${key}" - value and TTL unchanged`) @@ -379,7 +387,7 @@ export class CacheService { const entry: CacheEntry = { value, - expireAt: ttl ? Date.now() + ttl : undefined + expireAt: newExpireAt } // Update local copy first @@ -391,7 +399,7 @@ export class CacheService { type: 'shared', key, value, - ttl + expireAt: newExpireAt // Use absolute timestamp for precise sync }) logger.verbose(`Updated shared cache for key "${key}"`) } @@ -401,7 +409,7 @@ export class CacheService { * @param key - Schema-defined shared cache key * @returns True if key exists and is valid, false otherwise */ - hasShared(key: K): boolean { + hasShared(key: K): boolean { return this.hasSharedInternal(key) } @@ -410,7 +418,7 @@ export class CacheService { * @param key - Dynamic shared cache key * @returns True if key exists and is valid, false otherwise */ - hasSharedCasual(key: Exclude): boolean { + hasSharedCasual(key: Exclude): boolean { return this.hasSharedInternal(key) } @@ -436,7 +444,7 @@ export class CacheService { * @param key - Schema-defined shared cache key * @returns True if deletion succeeded, false if key is protected by active hooks */ - deleteShared(key: K): boolean { + deleteShared(key: K): boolean { return this.deleteSharedInternal(key) } @@ -445,7 +453,7 @@ export class CacheService { * @param key - Dynamic shared cache key * @returns True if deletion succeeded, false if key is protected by active hooks */ - deleteSharedCasual(key: Exclude): boolean { + deleteSharedCasual(key: Exclude): boolean { return this.deleteSharedInternal(key) } @@ -557,6 +565,91 @@ export class CacheService { this.activeHooks.delete(key) } + // ============ Shared Cache Ready State Management ============ + + /** + * Check if shared cache has finished initial sync from Main + * @returns True if shared cache is ready + */ + isSharedCacheReady(): boolean { + return this.sharedCacheReady + } + + /** + * Register a callback to be called when shared cache is ready + * If already ready, callback is invoked immediately + * @param callback - Function to call when ready + * @returns Unsubscribe function + */ + onSharedCacheReady(callback: () => void): () => void { + if (this.sharedCacheReady) { + callback() + return () => {} + } + + this.sharedCacheReadyCallbacks.push(callback) + return () => { + const idx = this.sharedCacheReadyCallbacks.indexOf(callback) + if (idx >= 0) { + this.sharedCacheReadyCallbacks.splice(idx, 1) + } + } + } + + /** + * Mark shared cache as ready and notify all waiting callbacks + */ + private markSharedCacheReady(): void { + this.sharedCacheReady = true + this.sharedCacheReadyCallbacks.forEach((cb) => cb()) + this.sharedCacheReadyCallbacks = [] + } + + /** + * Sync shared cache from Main process during initialization + * Uses Main-priority override strategy for conflict resolution + */ + private async syncSharedCacheFromMain(): Promise { + if (!window.api?.cache?.getAllShared) { + logger.warn('Cache getAllShared API not available') + this.markSharedCacheReady() + return + } + + try { + const allShared = await window.api.cache.getAllShared() + let syncedCount = 0 + + for (const [key, entry] of Object.entries(allShared)) { + // Skip expired entries + if (entry.expireAt && Date.now() > entry.expireAt) { + continue + } + + const existingEntry = this.sharedCache.get(key) + + // Compare value and expireAt to determine if update is needed + const valueChanged = !existingEntry || !Object.is(existingEntry.value, entry.value) + const ttlChanged = !existingEntry || !Object.is(existingEntry.expireAt, entry.expireAt) + + if (valueChanged || ttlChanged) { + // Main-priority override: always use Main's value + this.sharedCache.set(key, entry) + this.notifySubscribers(key) // Only notify on actual change + syncedCount++ + } + } + + logger.debug( + `Synced ${syncedCount} changed shared cache entries from Main (total: ${Object.keys(allShared).length})` + ) + } catch (error) { + logger.error('Failed to sync shared cache from Main:', error as Error) + } finally { + this.markSharedCacheReady() + } + } + // ============ Subscription Management ============ /** @@ -746,10 +839,10 @@ export class CacheService { // Handle deletion this.sharedCache.delete(message.key) } else { - // Handle set + // Handle set - use expireAt directly (absolute timestamp from sender) const entry: CacheEntry = { value: message.value, - expireAt: message.ttl ? Date.now() + message.ttl : undefined + expireAt: message.expireAt } this.sharedCache.set(message.key, entry) } diff --git a/src/renderer/src/data/hooks/useCache.ts b/src/renderer/src/data/hooks/useCache.ts index 7689b62f32..00198afd2c 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -3,12 +3,12 @@ import { loggerService } from '@logger' import type { RendererPersistCacheKey, RendererPersistCacheSchema, + SharedCacheKey, + SharedCacheSchema, UseCacheKey, - UseCacheSchema, - UseSharedCacheKey, - UseSharedCacheSchema + UseCacheSchema } from '@shared/data/cache/cacheSchemas' -import { DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' +import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas' import { useCallback, useEffect, useSyncExternalStore } from 'react' const logger = loggerService.withContext('useCache') @@ -121,10 +121,10 @@ export function useCache( * setWindowCount(3) * ``` */ -export function useSharedCache( +export function useSharedCache( key: K, - initValue?: UseSharedCacheSchema[K] -): [UseSharedCacheSchema[K], (value: UseSharedCacheSchema[K]) => void] { + initValue?: SharedCacheSchema[K] +): [SharedCacheSchema[K], (value: SharedCacheSchema[K]) => void] { /** * Subscribe to shared cache changes using React's useSyncExternalStore * This ensures the component re-renders when the shared cache value changes @@ -145,7 +145,7 @@ export function useSharedCache( } if (initValue === undefined) { - cacheService.setShared(key, DefaultUseSharedCache[key]) + cacheService.setShared(key, DefaultSharedCache[key]) } else { cacheService.setShared(key, initValue) } @@ -178,13 +178,13 @@ export function useSharedCache( * @param newValue - New value to store in shared cache */ const setValue = useCallback( - (newValue: UseSharedCacheSchema[K]) => { + (newValue: SharedCacheSchema[K]) => { cacheService.setShared(key, newValue) }, [key] ) - return [value ?? initValue ?? DefaultUseSharedCache[key], setValue] + return [value ?? initValue ?? DefaultSharedCache[key], setValue] } /** diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index 653c0643d6..fbbdc10a25 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -2,9 +2,9 @@ import type { RendererPersistCacheKey, RendererPersistCacheSchema, UseCacheKey, - UseSharedCacheKey + SharedCacheKey } from '@shared/data/cache/cacheSchemas' -import { DefaultRendererPersistCache, DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' +import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' import type { CacheSubscriber } from '@shared/data/cache/cacheTypes' import { vi } from 'vitest' @@ -235,8 +235,8 @@ function getDefaultValueForKey(key: string): any { } function getDefaultSharedValueForKey(key: string): any { - if (key in DefaultUseSharedCache) { - return DefaultUseSharedCache[key as UseSharedCacheKey] + if (key in DefaultSharedCache) { + return DefaultSharedCache[key as SharedCacheKey] } return undefined } diff --git a/tests/__mocks__/renderer/useCache.ts b/tests/__mocks__/renderer/useCache.ts index 33f2f81203..77f9dd5dd1 100644 --- a/tests/__mocks__/renderer/useCache.ts +++ b/tests/__mocks__/renderer/useCache.ts @@ -3,10 +3,10 @@ import type { RendererPersistCacheSchema, UseCacheKey, UseCacheSchema, - UseSharedCacheKey, - UseSharedCacheSchema + SharedCacheKey, + SharedCacheSchema } from '@shared/data/cache/cacheSchemas' -import { DefaultRendererPersistCache, DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' +import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' import { vi } from 'vitest' /** @@ -16,7 +16,7 @@ import { vi } from 'vitest' // Mock cache state storage const mockMemoryCache = new Map() -const mockSharedCache = new Map() +const mockSharedCache = new Map() const mockPersistCache = new Map() // Initialize caches with defaults @@ -24,8 +24,8 @@ Object.entries(DefaultUseCache).forEach(([key, value]) => { mockMemoryCache.set(key as UseCacheKey, value) }) -Object.entries(DefaultUseSharedCache).forEach(([key, value]) => { - mockSharedCache.set(key as UseSharedCacheKey, value) +Object.entries(DefaultSharedCache).forEach(([key, value]) => { + mockSharedCache.set(key as SharedCacheKey, value) }) Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { @@ -34,7 +34,7 @@ Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { // Mock subscribers for cache changes const mockMemorySubscribers = new Map void>>() -const mockSharedSubscribers = new Map void>>() +const mockSharedSubscribers = new Map void>>() const mockPersistSubscribers = new Map void>>() // Helper functions to notify subscribers @@ -51,7 +51,7 @@ const notifyMemorySubscribers = (key: UseCacheKey) => { } } -const notifySharedSubscribers = (key: UseSharedCacheKey) => { +const notifySharedSubscribers = (key: SharedCacheKey) => { const subscribers = mockSharedSubscribers.get(key) if (subscribers) { subscribers.forEach((callback) => { @@ -108,21 +108,21 @@ export const mockUseCache = vi.fn( * Mock useSharedCache hook (shared cache) */ export const mockUseSharedCache = vi.fn( - ( + ( key: K, - initValue?: UseSharedCacheSchema[K] - ): [UseSharedCacheSchema[K], (value: UseSharedCacheSchema[K]) => void] => { + initValue?: SharedCacheSchema[K] + ): [SharedCacheSchema[K], (value: SharedCacheSchema[K]) => void] => { // Get current value let currentValue = mockSharedCache.get(key) if (currentValue === undefined) { - currentValue = initValue ?? DefaultUseSharedCache[key] + currentValue = initValue ?? DefaultSharedCache[key] if (currentValue !== undefined) { mockSharedCache.set(key, currentValue) } } // Mock setValue function - const setValue = vi.fn((value: UseSharedCacheSchema[K]) => { + const setValue = vi.fn((value: SharedCacheSchema[K]) => { mockSharedCache.set(key, value) notifySharedSubscribers(key) }) @@ -188,8 +188,8 @@ export const MockUseCacheUtils = { mockMemoryCache.set(key as UseCacheKey, value) }) - Object.entries(DefaultUseSharedCache).forEach(([key, value]) => { - mockSharedCache.set(key as UseSharedCacheKey, value) + Object.entries(DefaultSharedCache).forEach(([key, value]) => { + mockSharedCache.set(key as SharedCacheKey, value) }) Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { @@ -220,7 +220,7 @@ export const MockUseCacheUtils = { /** * Set shared cache value for testing */ - setSharedCacheValue: (key: K, value: UseSharedCacheSchema[K]) => { + setSharedCacheValue: (key: K, value: SharedCacheSchema[K]) => { mockSharedCache.set(key, value) notifySharedSubscribers(key) }, @@ -228,8 +228,8 @@ export const MockUseCacheUtils = { /** * Get shared cache value */ - getSharedCacheValue: (key: K): UseSharedCacheSchema[K] => { - return mockSharedCache.get(key) ?? DefaultUseSharedCache[key] + getSharedCacheValue: (key: K): SharedCacheSchema[K] => { + return mockSharedCache.get(key) ?? DefaultSharedCache[key] }, /** @@ -252,7 +252,7 @@ export const MockUseCacheUtils = { */ setMultipleCacheValues: (values: { memory?: Array<[UseCacheKey, any]> - shared?: Array<[UseSharedCacheKey, any]> + shared?: Array<[SharedCacheKey, any]> persist?: Array<[RendererPersistCacheKey, any]> }) => { values.memory?.forEach(([key, value]) => { @@ -310,10 +310,10 @@ export const MockUseCacheUtils = { /** * Mock shared cache hook to return specific value for a key */ - mockSharedCacheReturn: ( + mockSharedCacheReturn: ( key: K, - value: UseSharedCacheSchema[K], - setValue?: (value: UseSharedCacheSchema[K]) => void + value: SharedCacheSchema[K], + setValue?: (value: SharedCacheSchema[K]) => void ) => { mockUseSharedCache.mockImplementation((cacheKey, initValue) => { if (cacheKey === key) { @@ -321,7 +321,7 @@ export const MockUseCacheUtils = { } // Default behavior for other keys - const defaultValue = mockSharedCache.get(cacheKey) ?? initValue ?? DefaultUseSharedCache[cacheKey] + const defaultValue = mockSharedCache.get(cacheKey) ?? initValue ?? DefaultSharedCache[cacheKey] return [defaultValue, vi.fn()] }) }, @@ -368,7 +368,7 @@ export const MockUseCacheUtils = { /** * Add subscriber for shared cache changes */ - addSharedSubscriber: (key: UseSharedCacheKey, callback: () => void): (() => void) => { + addSharedSubscriber: (key: SharedCacheKey, callback: () => void): (() => void) => { if (!mockSharedSubscribers.has(key)) { mockSharedSubscribers.set(key, new Set()) } From ed4353b054160da3539bce5e9e78938b0b430b07 Mon Sep 17 00:00:00 2001 From: nujabse Date: Tue, 30 Dec 2025 13:33:09 +0800 Subject: [PATCH 062/116] fix: align MCP tool ids for permissions (#12127) * fix(agents): align MCP tool IDs for permissions Normalize legacy MCP allowlist entries so auto-approval matches SDK tool names. Signed-off-by: mathholic * fix: normalize mcp tool ids in sessions Signed-off-by: macmini * fix: align mcp tool ids with buildFunctionCallToolName --------- Signed-off-by: mathholic Signed-off-by: macmini --- src/main/services/agents/BaseService.ts | 76 +++++++++++++++- .../services/agents/services/AgentService.ts | 8 +- .../agents/services/SessionService.ts | 10 +- .../services/agents/tests/BaseService.test.ts | 91 +++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 src/main/services/agents/tests/BaseService.test.ts diff --git a/src/main/services/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index 461fdab96d..e30814bb6f 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -2,6 +2,7 @@ import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import type { ModelValidationError } from '@main/apiServer/utils' import { validateModelId } from '@main/apiServer/utils' +import { buildFunctionCallToolName } from '@main/utils/mcp' import type { AgentType, MCPTool, SlashCommand, Tool } from '@types' import { objectKeys } from '@types' import fs from 'fs' @@ -14,6 +15,17 @@ import { builtinSlashCommands } from './services/claudecode/commands' import { builtinTools } from './services/claudecode/tools' const logger = loggerService.withContext('BaseService') +const MCP_TOOL_ID_PREFIX = 'mcp__' +const MCP_TOOL_LEGACY_PREFIX = 'mcp_' + +const buildMcpToolId = (serverId: string, toolName: string) => `${MCP_TOOL_ID_PREFIX}${serverId}__${toolName}` +const toLegacyMcpToolId = (toolId: string) => { + if (!toolId.startsWith(MCP_TOOL_ID_PREFIX)) { + return null + } + const rawId = toolId.slice(MCP_TOOL_ID_PREFIX.length) + return `${MCP_TOOL_LEGACY_PREFIX}${rawId.replace(/__/g, '_')}` +} /** * Base service class providing shared utilities for all agent-related services. @@ -35,8 +47,12 @@ export abstract class BaseService { 'slash_commands' ] - public async listMcpTools(agentType: AgentType, ids?: string[]): Promise { + public async listMcpTools( + agentType: AgentType, + ids?: string[] + ): Promise<{ tools: Tool[]; legacyIdMap: Map }> { const tools: Tool[] = [] + const legacyIdMap = new Map() if (agentType === 'claude-code') { tools.push(...builtinTools) } @@ -46,13 +62,21 @@ export abstract class BaseService { const server = await mcpApiService.getServerInfo(id) if (server) { server.tools.forEach((tool: MCPTool) => { + const canonicalId = buildFunctionCallToolName(server.name, tool.name) + const serverIdBasedId = buildMcpToolId(id, tool.name) + const legacyId = toLegacyMcpToolId(serverIdBasedId) + tools.push({ - id: `mcp_${id}_${tool.name}`, + id: canonicalId, name: tool.name, type: 'mcp', description: tool.description || '', requirePermissions: true }) + legacyIdMap.set(serverIdBasedId, canonicalId) + if (legacyId) { + legacyIdMap.set(legacyId, canonicalId) + } }) } } catch (error) { @@ -64,7 +88,53 @@ export abstract class BaseService { } } - return tools + return { tools, legacyIdMap } + } + + /** + * Normalize MCP tool IDs in allowed_tools to the current format. + * + * Legacy formats: + * - "mcp____" (double underscore separators, server ID based) + * - "mcp__" (single underscore separators) + * Current format: "mcp____" (double underscore separators). + * + * This keeps persisted data compatible without requiring a database migration. + */ + protected normalizeAllowedTools( + allowedTools: string[] | undefined, + tools: Tool[], + legacyIdMap?: Map + ): string[] | undefined { + if (!allowedTools || allowedTools.length === 0) { + return allowedTools + } + + const resolvedLegacyIdMap = new Map() + + if (legacyIdMap) { + for (const [legacyId, canonicalId] of legacyIdMap) { + resolvedLegacyIdMap.set(legacyId, canonicalId) + } + } + + for (const tool of tools) { + if (tool.type !== 'mcp') { + continue + } + const legacyId = toLegacyMcpToolId(tool.id) + if (!legacyId) { + continue + } + resolvedLegacyIdMap.set(legacyId, tool.id) + } + + if (resolvedLegacyIdMap.size === 0) { + return allowedTools + } + + const normalized = allowedTools.map((toolId) => resolvedLegacyIdMap.get(toolId) ?? toolId) + return Array.from(new Set(normalized)) } public async listSlashCommands(agentType: AgentType): Promise { diff --git a/src/main/services/agents/services/AgentService.ts b/src/main/services/agents/services/AgentService.ts index 2faa87bb45..7542c1935b 100644 --- a/src/main/services/agents/services/AgentService.ts +++ b/src/main/services/agents/services/AgentService.ts @@ -89,7 +89,9 @@ export class AgentService extends BaseService { } const agent = this.deserializeJsonFields(result[0]) as GetAgentResponse - agent.tools = await this.listMcpTools(agent.type, agent.mcps) + const { tools, legacyIdMap } = await this.listMcpTools(agent.type, agent.mcps) + agent.tools = tools + agent.allowed_tools = this.normalizeAllowedTools(agent.allowed_tools, agent.tools, legacyIdMap) // Load installed_plugins from cache file instead of database const workdir = agent.accessible_paths?.[0] @@ -134,7 +136,9 @@ export class AgentService extends BaseService { const agents = result.map((row) => this.deserializeJsonFields(row)) as GetAgentResponse[] for (const agent of agents) { - agent.tools = await this.listMcpTools(agent.type, agent.mcps) + const { tools, legacyIdMap } = await this.listMcpTools(agent.type, agent.mcps) + agent.tools = tools + agent.allowed_tools = this.normalizeAllowedTools(agent.allowed_tools, agent.tools, legacyIdMap) } return { agents, total: totalResult[0].count } diff --git a/src/main/services/agents/services/SessionService.ts b/src/main/services/agents/services/SessionService.ts index d933ef8dd9..90b32bb31c 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -156,7 +156,9 @@ export class SessionService extends BaseService { } const session = this.deserializeJsonFields(result[0]) as GetAgentSessionResponse - session.tools = await this.listMcpTools(session.agent_type, session.mcps) + const { tools, legacyIdMap } = await this.listMcpTools(session.agent_type, session.mcps) + session.tools = tools + session.allowed_tools = this.normalizeAllowedTools(session.allowed_tools, session.tools, legacyIdMap) // If slash_commands is not in database yet (e.g., first invoke before init message), // fall back to builtin + local commands. Otherwise, use the merged commands from database. @@ -202,6 +204,12 @@ export class SessionService extends BaseService { const sessions = result.map((row) => this.deserializeJsonFields(row)) as GetAgentSessionResponse[] + for (const session of sessions) { + const { tools, legacyIdMap } = await this.listMcpTools(session.agent_type, session.mcps) + session.tools = tools + session.allowed_tools = this.normalizeAllowedTools(session.allowed_tools, session.tools, legacyIdMap) + } + return { sessions, total } } diff --git a/src/main/services/agents/tests/BaseService.test.ts b/src/main/services/agents/tests/BaseService.test.ts new file mode 100644 index 0000000000..fe2f4e103a --- /dev/null +++ b/src/main/services/agents/tests/BaseService.test.ts @@ -0,0 +1,91 @@ +import type { Tool } from '@types' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@main/apiServer/services/mcp', () => ({ + mcpApiService: { + getServerInfo: vi.fn() + } +})) + +vi.mock('@main/apiServer/utils', () => ({ + validateModelId: vi.fn() +})) + +import { BaseService } from '../BaseService' + +class TestBaseService extends BaseService { + public normalize( + allowedTools: string[] | undefined, + tools: Tool[], + legacyIdMap?: Map + ): string[] | undefined { + return this.normalizeAllowedTools(allowedTools, tools, legacyIdMap) + } +} + +const buildMcpTool = (id: string): Tool => ({ + id, + name: id, + type: 'mcp', + description: 'test tool', + requirePermissions: true +}) + +describe('BaseService.normalizeAllowedTools', () => { + const service = new TestBaseService() + + it('returns undefined or empty inputs unchanged', () => { + expect(service.normalize(undefined, [])).toBeUndefined() + expect(service.normalize([], [])).toEqual([]) + }) + + it('normalizes legacy MCP tool IDs and deduplicates entries', () => { + const tools: Tool[] = [ + buildMcpTool('mcp__server_one__tool_one'), + buildMcpTool('mcp__server_two__tool_two'), + { id: 'custom_tool', name: 'custom_tool', type: 'custom' } + ] + + const legacyIdMap = new Map([ + ['mcp__server-1__tool-one', 'mcp__server_one__tool_one'], + ['mcp_server-1_tool-one', 'mcp__server_one__tool_one'], + ['mcp__server-2__tool-two', 'mcp__server_two__tool_two'] + ]) + + const allowedTools = [ + 'mcp__server-1__tool-one', + 'mcp_server-1_tool-one', + 'mcp_server_one_tool_one', + 'mcp__server_one__tool_one', + 'custom_tool', + 'mcp__server_two__tool_two', + 'mcp_server_two_tool_two', + 'mcp__server-2__tool-two' + ] + + expect(service.normalize(allowedTools, tools, legacyIdMap)).toEqual([ + 'mcp__server_one__tool_one', + 'custom_tool', + 'mcp__server_two__tool_two' + ]) + }) + + it('keeps legacy IDs when no matching MCP tool exists', () => { + const tools: Tool[] = [buildMcpTool('mcp__server_one__tool_one')] + const legacyIdMap = new Map([['mcp__server-1__tool-one', 'mcp__server_one__tool_one']]) + + const allowedTools = ['mcp__unknown__tool', 'mcp__server_one__tool_one'] + + expect(service.normalize(allowedTools, tools, legacyIdMap)).toEqual([ + 'mcp__unknown__tool', + 'mcp__server_one__tool_one' + ]) + }) + + it('returns allowed tools unchanged when no MCP tools are available', () => { + const allowedTools = ['custom_tool', 'builtin_tool'] + const tools: Tool[] = [{ id: 'custom_tool', name: 'custom_tool', type: 'custom' }] + + expect(service.normalize(allowedTools, tools)).toEqual(allowedTools) + }) +}) From 068cf1083c75d15154548fb4edc2e0ae0d27f354 Mon Sep 17 00:00:00 2001 From: jardel Date: Tue, 30 Dec 2025 13:35:15 +0800 Subject: [PATCH 063/116] fix: use HTML content for markdown copy button (#12187) --- .../src/pages/home/Markdown/Table.tsx | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/pages/home/Markdown/Table.tsx b/src/renderer/src/pages/home/Markdown/Table.tsx index 01f325bd44..a91ce1cc91 100644 --- a/src/renderer/src/pages/home/Markdown/Table.tsx +++ b/src/renderer/src/pages/home/Markdown/Table.tsx @@ -4,6 +4,7 @@ import store from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { Tooltip } from 'antd' import { Check } from 'lucide-react' +import MarkdownIt from 'markdown-it' import React, { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -22,18 +23,26 @@ const Table: React.FC = ({ children, node, blockId }) => { const { t } = useTranslation() const [copied, setCopied] = useTemporaryValue(false, 2000) - const handleCopyTable = useCallback(() => { + const handleCopyTable = useCallback(async () => { const tableMarkdown = extractTableMarkdown(blockId ?? '', node?.position) if (!tableMarkdown) return - navigator.clipboard - .writeText(tableMarkdown) - .then(() => { - setCopied(true) - }) - .catch((error) => { - window.toast?.error(`${t('message.copy.failed')}: ${error}`) - }) + try { + const tableHtml = convertMarkdownTableToHtml(tableMarkdown) + + if (navigator.clipboard && window.ClipboardItem) { + const clipboardItem = new ClipboardItem({ + 'text/plain': new Blob([tableMarkdown], { type: 'text/plain' }), + 'text/html': new Blob([tableHtml], { type: 'text/html' }) + }) + await navigator.clipboard.write([clipboardItem]) + } else { + await navigator.clipboard.writeText(tableMarkdown) + } + setCopied(true) + } catch (error) { + window.toast?.error(`${t('message.copy.failed')}: ${error}`) + } }, [blockId, node?.position, setCopied, t]) return ( @@ -60,7 +69,6 @@ export function extractTableMarkdown(blockId: string, position: any): string { if (!position || !blockId) return '' const block = messageBlocksSelectors.selectById(store.getState(), blockId) - if (!block || !('content' in block) || typeof block.content !== 'string') return '' const { start, end } = position @@ -71,6 +79,16 @@ export function extractTableMarkdown(blockId: string, position: any): string { return tableLines.join('\n').trim() } +function convertMarkdownTableToHtml(markdownTable: string): string { + const md = new MarkdownIt({ + html: true, + breaks: false, + linkify: false + }) + + return md.render(markdownTable) +} + const TableWrapper = styled.div` position: relative; From bc9eeb9f30fba4831dd5a59864c7443b12fcfdad Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Tue, 30 Dec 2025 19:42:56 +0800 Subject: [PATCH 064/116] feat: add fuzzy search for file list with relevance scoring (#12131) * feat: add fuzzy search for file list with relevance scoring - Add fuzzy option to DirectoryListOptions (default: true) - Implement isFuzzyMatch for subsequence matching - Add getFuzzyMatchScore for relevance-based sorting - Remove searchByContent method (content-based search) - Increase maxDepth to 10 and maxEntries to 20 * perf: optimize fuzzy search with ripgrep glob pre-filtering - Add queryToGlobPattern to convert query to glob pattern - Use ripgrep --iglob for initial filtering instead of loading all files - Reduces memory footprint and improves performance for large directories * feat: add greedy substring match fallback for fuzzy search - Add isGreedySubstringMatch for flexible matching - Fallback to greedy match when glob pre-filter returns empty - Allows 'updatercontroller' to match 'updateController.ts' * fix: improve greedy substring match algorithm - Search from longest to shortest substring for better matching - Fix issue where 'updatercontroller' couldn't match 'updateController' * docs: add fuzzy search documentation (en/zh) * refactor: extract MAX_ENTRIES_PER_SEARCH constant * refactor: use logarithmic scaling for path length penalty - Replace linear penalty (0.8 * length) with logarithmic scaling - Prevents long paths from dominating the score - Add PATH_LENGTH_PENALTY_FACTOR constant with explanation * refactor: extract scoring constants with documentation - Add named constants for scoring factors (SCORE_SEGMENT_MATCH, etc.) - Update en/zh documentation with scoring strategy explanation * refactor: move PATH_LENGTH_PENALTY_FACTOR to class level constant * refactor: extract buildRipgrepBaseArgs helper method - Reduce code duplication for ripgrep argument building - Consolidate directory exclusion patterns and depth handling * refactor: rename MAX_ENTRIES_PER_SEARCH to MAX_SEARCH_RESULTS * fix: escape ! character in glob pattern for negation support * fix: avoid duplicate scoring for filename starts and contains * docs: clarify fuzzy search filtering and scoring strategies * fix: limit word boundary bonus to single match * fix: add dedicated scoring for greedy substring match - Add getGreedyMatchScore function that rewards fewer fragments and tighter matches - Add isFuzzyMatch validation before scoring in fuzzy glob path - Use greedy scoring for fallback path to properly rank longest matches first Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --------- Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/en/references/fuzzy-search.md | 129 +++++++ docs/zh/references/fuzzy-search.md | 129 +++++++ src/main/services/FileStorage.ts | 348 ++++++++++++++---- .../components/useActivityDirectoryPanel.tsx | 5 +- 4 files changed, 540 insertions(+), 71 deletions(-) create mode 100644 docs/en/references/fuzzy-search.md create mode 100644 docs/zh/references/fuzzy-search.md diff --git a/docs/en/references/fuzzy-search.md b/docs/en/references/fuzzy-search.md new file mode 100644 index 0000000000..11c2002cb9 --- /dev/null +++ b/docs/en/references/fuzzy-search.md @@ -0,0 +1,129 @@ +# Fuzzy Search for File List + +This document describes the fuzzy search implementation for file listing in Cherry Studio. + +## Overview + +The fuzzy search feature allows users to find files by typing partial or approximate file names/paths. It uses a two-tier file filtering strategy (ripgrep glob pre-filtering with greedy substring fallback) combined with subsequence-based scoring for optimal performance and flexibility. + +## Features + +- **Ripgrep Glob Pre-filtering**: Primary filtering using glob patterns for fast native-level filtering +- **Greedy Substring Matching**: Fallback file filtering strategy when ripgrep glob pre-filtering returns no results +- **Subsequence-based Segment Scoring**: During scoring, path segments gain additional weight when query characters appear in order +- **Relevance Scoring**: Results are sorted by a relevance score derived from multiple factors + +## Matching Strategies + +### 1. Ripgrep Glob Pre-filtering (Primary) + +The query is converted to a glob pattern for ripgrep to do initial filtering: + +``` +Query: "updater" +Glob: "*u*p*d*a*t*e*r*" +``` + +This leverages ripgrep's native performance for the initial file filtering. + +### 2. Greedy Substring Matching (Fallback) + +When the glob pre-filter returns no results, the system falls back to greedy substring matching. This allows more flexible matching: + +``` +Query: "updatercontroller" +File: "packages/update/src/node/updateController.ts" + +Matching process: +1. Find "update" (longest match from start) +2. Remaining "rcontroller" → find "r" then "controller" +3. All parts matched → Success +``` + +## Scoring Algorithm + +Results are ranked by a relevance score based on named constants defined in `FileStorage.ts`: + +| Constant | Value | Description | +|----------|-------|-------------| +| `SCORE_FILENAME_STARTS` | 100 | Filename starts with query (highest priority) | +| `SCORE_FILENAME_CONTAINS` | 80 | Filename contains exact query substring | +| `SCORE_SEGMENT_MATCH` | 60 | Per path segment that matches query | +| `SCORE_WORD_BOUNDARY` | 20 | Query matches start of a word | +| `SCORE_CONSECUTIVE_CHAR` | 15 | Per consecutive character match | +| `PATH_LENGTH_PENALTY_FACTOR` | 4 | Logarithmic penalty for longer paths | + +### Scoring Strategy + +The scoring prioritizes: +1. **Filename matches** (highest): Files where the query appears in the filename are most relevant +2. **Path segment matches**: Multiple matching segments indicate stronger relevance +3. **Word boundaries**: Matching at word starts (e.g., "upd" matching "update") is preferred +4. **Consecutive matches**: Longer consecutive character sequences score higher +5. **Path length**: Shorter paths are preferred (logarithmic penalty prevents long paths from dominating) + +### Example Scoring + +For query `updater`: + +| File | Score Factors | +|------|---------------| +| `RCUpdater.js` | Short path + filename contains "updater" | +| `updateController.ts` | Multiple segment matches | +| `UpdaterHelper.plist` | Long path penalty | + +## Configuration + +### DirectoryListOptions + +```typescript +interface DirectoryListOptions { + recursive?: boolean // Default: true + maxDepth?: number // Default: 10 + includeHidden?: boolean // Default: false + includeFiles?: boolean // Default: true + includeDirectories?: boolean // Default: true + maxEntries?: number // Default: 20 + searchPattern?: string // Default: '.' + fuzzy?: boolean // Default: true +} +``` + +## Usage + +```typescript +// Basic fuzzy search +const files = await window.api.file.listDirectory(dirPath, { + searchPattern: 'updater', + fuzzy: true, + maxEntries: 20 +}) + +// Disable fuzzy search (exact glob matching) +const files = await window.api.file.listDirectory(dirPath, { + searchPattern: 'update', + fuzzy: false +}) +``` + +## Performance Considerations + +1. **Ripgrep Pre-filtering**: Most queries are handled by ripgrep's native glob matching, which is extremely fast +2. **Fallback Only When Needed**: Greedy substring matching (which loads all files) only runs when glob matching returns empty results +3. **Result Limiting**: Only top 20 results are returned by default +4. **Excluded Directories**: Common large directories are automatically excluded: + - `node_modules` + - `.git` + - `dist`, `build` + - `.next`, `.nuxt` + - `coverage`, `.cache` + +## Implementation Details + +The implementation is located in `src/main/services/FileStorage.ts`: + +- `queryToGlobPattern()`: Converts query to ripgrep glob pattern +- `isFuzzyMatch()`: Subsequence matching algorithm +- `isGreedySubstringMatch()`: Greedy substring matching fallback +- `getFuzzyMatchScore()`: Calculates relevance score +- `listDirectoryWithRipgrep()`: Main search orchestration diff --git a/docs/zh/references/fuzzy-search.md b/docs/zh/references/fuzzy-search.md new file mode 100644 index 0000000000..d28d189928 --- /dev/null +++ b/docs/zh/references/fuzzy-search.md @@ -0,0 +1,129 @@ +# 文件列表模糊搜索 + +本文档描述了 Cherry Studio 中文件列表的模糊搜索实现。 + +## 概述 + +模糊搜索功能允许用户通过输入部分或近似的文件名/路径来查找文件。它使用两层文件过滤策略(ripgrep glob 预过滤 + 贪婪子串匹配回退),结合基于子序列的评分,以获得最佳性能和灵活性。 + +## 功能特性 + +- **Ripgrep Glob 预过滤**:使用 glob 模式进行快速原生级过滤的主要过滤策略 +- **贪婪子串匹配**:当 ripgrep glob 预过滤无结果时的回退文件过滤策略 +- **基于子序列的段评分**:评分时,当查询字符按顺序出现时,路径段获得额外权重 +- **相关性评分**:结果按多因素相关性分数排序 + +## 匹配策略 + +### 1. Ripgrep Glob 预过滤(主要) + +查询被转换为 glob 模式供 ripgrep 进行初始过滤: + +``` +查询: "updater" +Glob: "*u*p*d*a*t*e*r*" +``` + +这利用了 ripgrep 的原生性能进行初始文件过滤。 + +### 2. 贪婪子串匹配(回退) + +当 glob 预过滤无结果时,系统回退到贪婪子串匹配。这允许更灵活的匹配: + +``` +查询: "updatercontroller" +文件: "packages/update/src/node/updateController.ts" + +匹配过程: +1. 找到 "update"(从开头的最长匹配) +2. 剩余 "rcontroller" → 找到 "r" 然后 "controller" +3. 所有部分都匹配 → 成功 +``` + +## 评分算法 + +结果根据 `FileStorage.ts` 中定义的命名常量进行相关性分数排名: + +| 常量 | 值 | 描述 | +|------|-----|------| +| `SCORE_FILENAME_STARTS` | 100 | 文件名以查询开头(最高优先级)| +| `SCORE_FILENAME_CONTAINS` | 80 | 文件名包含精确查询子串 | +| `SCORE_SEGMENT_MATCH` | 60 | 每个匹配查询的路径段 | +| `SCORE_WORD_BOUNDARY` | 20 | 查询匹配单词开头 | +| `SCORE_CONSECUTIVE_CHAR` | 15 | 每个连续字符匹配 | +| `PATH_LENGTH_PENALTY_FACTOR` | 4 | 较长路径的对数惩罚 | + +### 评分策略 + +评分优先级: +1. **文件名匹配**(最高):查询出现在文件名中的文件最相关 +2. **路径段匹配**:多个匹配段表示更强的相关性 +3. **词边界**:在单词开头匹配(如 "upd" 匹配 "update")更优先 +4. **连续匹配**:更长的连续字符序列得分更高 +5. **路径长度**:较短路径更优先(对数惩罚防止长路径主导评分) + +### 评分示例 + +对于查询 `updater`: + +| 文件 | 评分因素 | +|------|----------| +| `RCUpdater.js` | 短路径 + 文件名包含 "updater" | +| `updateController.ts` | 多个路径段匹配 | +| `UpdaterHelper.plist` | 长路径惩罚 | + +## 配置 + +### DirectoryListOptions + +```typescript +interface DirectoryListOptions { + recursive?: boolean // 默认: true + maxDepth?: number // 默认: 10 + includeHidden?: boolean // 默认: false + includeFiles?: boolean // 默认: true + includeDirectories?: boolean // 默认: true + maxEntries?: number // 默认: 20 + searchPattern?: string // 默认: '.' + fuzzy?: boolean // 默认: true +} +``` + +## 使用方法 + +```typescript +// 基本模糊搜索 +const files = await window.api.file.listDirectory(dirPath, { + searchPattern: 'updater', + fuzzy: true, + maxEntries: 20 +}) + +// 禁用模糊搜索(精确 glob 匹配) +const files = await window.api.file.listDirectory(dirPath, { + searchPattern: 'update', + fuzzy: false +}) +``` + +## 性能考虑 + +1. **Ripgrep 预过滤**:大多数查询由 ripgrep 的原生 glob 匹配处理,速度极快 +2. **仅在需要时回退**:贪婪子串匹配(加载所有文件)仅在 glob 匹配返回空结果时运行 +3. **结果限制**:默认只返回前 20 个结果 +4. **排除目录**:自动排除常见的大型目录: + - `node_modules` + - `.git` + - `dist`、`build` + - `.next`、`.nuxt` + - `coverage`、`.cache` + +## 实现细节 + +实现位于 `src/main/services/FileStorage.ts`: + +- `queryToGlobPattern()`:将查询转换为 ripgrep glob 模式 +- `isFuzzyMatch()`:子序列匹配算法 +- `isGreedySubstringMatch()`:贪婪子串匹配回退 +- `getFuzzyMatchScore()`:计算相关性分数 +- `listDirectoryWithRipgrep()`:主搜索协调 diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index f0b7ce32b0..2d7520ca67 100644 --- a/src/main/services/FileStorage.ts +++ b/src/main/services/FileStorage.ts @@ -130,16 +130,18 @@ interface DirectoryListOptions { includeDirectories?: boolean maxEntries?: number searchPattern?: string + fuzzy?: boolean } const DEFAULT_DIRECTORY_LIST_OPTIONS: Required = { recursive: true, - maxDepth: 3, + maxDepth: 10, includeHidden: false, includeFiles: true, includeDirectories: true, - maxEntries: 10, - searchPattern: '.' + maxEntries: 20, + searchPattern: '.', + fuzzy: true } class FileStorage { @@ -1046,10 +1048,226 @@ class FileStorage { } /** - * Search files by content pattern + * Fuzzy match: checks if all characters in query appear in text in order (case-insensitive) + * Example: "updater" matches "packages/update/src/node/updateController.ts" */ - private async searchByContent(resolvedPath: string, options: Required): Promise { - const args: string[] = ['-l'] + private isFuzzyMatch(text: string, query: string): boolean { + let i = 0 // text index + let j = 0 // query index + const textLower = text.toLowerCase() + const queryLower = query.toLowerCase() + + while (i < textLower.length && j < queryLower.length) { + if (textLower[i] === queryLower[j]) { + j++ + } + i++ + } + return j === queryLower.length + } + + /** + * Scoring constants for fuzzy match relevance ranking + * Higher values = higher priority in search results + */ + private static readonly SCORE_SEGMENT_MATCH = 60 // Per path segment that matches query + private static readonly SCORE_FILENAME_CONTAINS = 80 // Filename contains exact query substring + private static readonly SCORE_FILENAME_STARTS = 100 // Filename starts with query (highest priority) + private static readonly SCORE_CONSECUTIVE_CHAR = 15 // Per consecutive character match + private static readonly SCORE_WORD_BOUNDARY = 20 // Query matches start of a word + private static readonly PATH_LENGTH_PENALTY_FACTOR = 4 // Logarithmic penalty multiplier for longer paths + + /** + * Calculate fuzzy match score (higher is better) + * Scoring factors: + * - Consecutive character matches (bonus) + * - Match at word boundaries (bonus) + * - Shorter path length (bonus) + * - Match in filename vs directory (bonus) + */ + private getFuzzyMatchScore(filePath: string, query: string): number { + const pathLower = filePath.toLowerCase() + const queryLower = query.toLowerCase() + const fileName = filePath.split('/').pop() || '' + const fileNameLower = fileName.toLowerCase() + + let score = 0 + + // Count how many times query-related words appear in path segments + const pathSegments = pathLower.split(/[/\\]/) + let segmentMatchCount = 0 + for (const segment of pathSegments) { + if (this.isFuzzyMatch(segment, queryLower)) { + segmentMatchCount++ + } + } + score += segmentMatchCount * FileStorage.SCORE_SEGMENT_MATCH + + // Bonus for filename starting with query (stronger than generic "contains") + if (fileNameLower.startsWith(queryLower)) { + score += FileStorage.SCORE_FILENAME_STARTS + } else if (fileNameLower.includes(queryLower)) { + // Bonus for exact substring match in filename (e.g., "updater" in "RCUpdater.js") + score += FileStorage.SCORE_FILENAME_CONTAINS + } + + // Calculate consecutive match bonus + let i = 0 + let j = 0 + let consecutiveCount = 0 + let maxConsecutive = 0 + + while (i < pathLower.length && j < queryLower.length) { + if (pathLower[i] === queryLower[j]) { + consecutiveCount++ + maxConsecutive = Math.max(maxConsecutive, consecutiveCount) + j++ + } else { + consecutiveCount = 0 + } + i++ + } + score += maxConsecutive * FileStorage.SCORE_CONSECUTIVE_CHAR + + // Bonus for word boundary matches (e.g., "upd" matches start of "update") + // Only count once to avoid inflating scores for paths with repeated patterns + const boundaryPrefix = queryLower.slice(0, Math.min(3, queryLower.length)) + const words = pathLower.split(/[/\\._-]/) + for (const word of words) { + if (word.startsWith(boundaryPrefix)) { + score += FileStorage.SCORE_WORD_BOUNDARY + break + } + } + + // Penalty for longer paths (prefer shorter, more specific matches) + // Use logarithmic scaling to prevent long paths from dominating the score + // A 50-char path gets ~-16 penalty, 100-char gets ~-18, 200-char gets ~-21 + score -= Math.log(filePath.length + 1) * FileStorage.PATH_LENGTH_PENALTY_FACTOR + + return score + } + + /** + * Convert query to glob pattern for ripgrep pre-filtering + * e.g., "updater" -> "*u*p*d*a*t*e*r*" + */ + private queryToGlobPattern(query: string): string { + // Escape special glob characters (including ! for negation) + const escaped = query.replace(/[[\]{}()*+?.,\\^$|#!]/g, '\\$&') + // Convert to fuzzy glob: each char separated by * + return '*' + escaped.split('').join('*') + '*' + } + + /** + * Greedy substring match: check if all characters in query can be matched + * by finding consecutive substrings in text (not necessarily single chars) + * e.g., "updatercontroller" matches "updateController" by: + * "update" + "r" (from Controller) + "controller" + */ + private isGreedySubstringMatch(text: string, query: string): boolean { + const textLower = text.toLowerCase() + const queryLower = query.toLowerCase() + + let queryIndex = 0 + let searchStart = 0 + + while (queryIndex < queryLower.length) { + // Try to find the longest matching substring starting at queryIndex + let bestMatchLen = 0 + let bestMatchPos = -1 + + for (let len = queryLower.length - queryIndex; len >= 1; len--) { + const substr = queryLower.slice(queryIndex, queryIndex + len) + const foundAt = textLower.indexOf(substr, searchStart) + if (foundAt !== -1) { + bestMatchLen = len + bestMatchPos = foundAt + break // Found longest possible match + } + } + + if (bestMatchLen === 0) { + // No substring match found, query cannot be matched + return false + } + + queryIndex += bestMatchLen + searchStart = bestMatchPos + bestMatchLen + } + + return true + } + + /** + * Calculate greedy substring match score (higher is better) + * Rewards: fewer match fragments, shorter match span, matches in filename + */ + private getGreedyMatchScore(filePath: string, query: string): number { + const textLower = filePath.toLowerCase() + const queryLower = query.toLowerCase() + const fileName = filePath.split('/').pop() || '' + const fileNameLower = fileName.toLowerCase() + + let queryIndex = 0 + let searchStart = 0 + let fragmentCount = 0 + let firstMatchPos = -1 + let lastMatchEnd = 0 + + while (queryIndex < queryLower.length) { + let bestMatchLen = 0 + let bestMatchPos = -1 + + for (let len = queryLower.length - queryIndex; len >= 1; len--) { + const substr = queryLower.slice(queryIndex, queryIndex + len) + const foundAt = textLower.indexOf(substr, searchStart) + if (foundAt !== -1) { + bestMatchLen = len + bestMatchPos = foundAt + break + } + } + + if (bestMatchLen === 0) { + return -Infinity // No match + } + + fragmentCount++ + if (firstMatchPos === -1) firstMatchPos = bestMatchPos + lastMatchEnd = bestMatchPos + bestMatchLen + queryIndex += bestMatchLen + searchStart = lastMatchEnd + } + + const matchSpan = lastMatchEnd - firstMatchPos + let score = 0 + + // Fewer fragments = better (single continuous match is best) + // Max bonus when fragmentCount=1, decreases as fragments increase + score += Math.max(0, 100 - (fragmentCount - 1) * 30) + + // Shorter span relative to query length = better (tighter match) + // Perfect match: span equals query length + const spanRatio = queryLower.length / matchSpan + score += spanRatio * 50 + + // Bonus for match in filename + if (this.isGreedySubstringMatch(fileNameLower, queryLower)) { + score += 80 + } + + // Penalty for longer paths + score -= Math.log(filePath.length + 1) * 4 + + return score + } + + /** + * Build common ripgrep arguments for file listing + */ + private buildRipgrepBaseArgs(options: Required, resolvedPath: string): string[] { + const args: string[] = ['--files'] // Handle hidden files if (!options.includeHidden) { @@ -1076,82 +1294,74 @@ class FileStorage { args.push('--max-depth', options.maxDepth.toString()) } - // Handle max count - if (options.maxEntries > 0) { - args.push('--max-count', options.maxEntries.toString()) - } - - // Add search pattern (search in content) - args.push(options.searchPattern) - - // Add the directory path args.push(resolvedPath) - const { exitCode, output } = await executeRipgrep(args) - - // Exit code 0 means files found, 1 means no files found (still success), 2+ means error - if (exitCode >= 2) { - throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) - } - - // Parse ripgrep output (already sorted by relevance) - const results = output - .split('\n') - .filter((line) => line.trim()) - .map((line) => line.replace(/\\/g, '/')) - .slice(0, options.maxEntries) - - return results + return args } private async listDirectoryWithRipgrep( resolvedPath: string, options: Required ): Promise { - const maxEntries = options.maxEntries + // Fuzzy search mode: use ripgrep glob for pre-filtering, then score in JS + if (options.fuzzy && options.searchPattern && options.searchPattern !== '.') { + const args = this.buildRipgrepBaseArgs(options, resolvedPath) - // Step 1: Search by filename first + // Insert glob pattern before the path (last element) + const globPattern = this.queryToGlobPattern(options.searchPattern) + args.splice(args.length - 1, 0, '--iglob', globPattern) + + const { exitCode, output } = await executeRipgrep(args) + + if (exitCode >= 2) { + throw new Error(`Ripgrep failed with exit code ${exitCode}: ${output}`) + } + + const filteredFiles = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + + // If fuzzy glob found results, validate fuzzy match, sort and return + if (filteredFiles.length > 0) { + return filteredFiles + .filter((file) => this.isFuzzyMatch(file, options.searchPattern)) + .map((file) => ({ file, score: this.getFuzzyMatchScore(file, options.searchPattern) })) + .sort((a, b) => b.score - a.score) + .slice(0, options.maxEntries) + .map((item) => item.file) + } + + // Fallback: if no results, try greedy substring match on all files + logger.debug('Fuzzy glob returned no results, falling back to greedy substring match') + const fallbackArgs = this.buildRipgrepBaseArgs(options, resolvedPath) + + const fallbackResult = await executeRipgrep(fallbackArgs) + + if (fallbackResult.exitCode >= 2) { + return [] + } + + const allFiles = fallbackResult.output + .split('\n') + .filter((line) => line.trim()) + .map((line) => line.replace(/\\/g, '/')) + + const greedyMatched = allFiles.filter((file) => this.isGreedySubstringMatch(file, options.searchPattern)) + + return greedyMatched + .map((file) => ({ file, score: this.getGreedyMatchScore(file, options.searchPattern) })) + .sort((a, b) => b.score - a.score) + .slice(0, options.maxEntries) + .map((item) => item.file) + } + + // Fallback: search by filename only (non-fuzzy mode) logger.debug('Searching by filename pattern', { pattern: options.searchPattern, path: resolvedPath }) const filenameResults = await this.searchByFilename(resolvedPath, options) logger.debug('Found matches by filename', { count: filenameResults.length }) - - // If we have enough filename matches, return them - if (filenameResults.length >= maxEntries) { - return filenameResults.slice(0, maxEntries) - } - - // Step 2: If filename matches are less than maxEntries, search by content to fill up - logger.debug('Filename matches insufficient, searching by content to fill up', { - filenameCount: filenameResults.length, - needed: maxEntries - filenameResults.length - }) - - // Adjust maxEntries for content search to get enough results - const contentOptions = { - ...options, - maxEntries: maxEntries - filenameResults.length + 20 // Request extra to account for duplicates - } - - const contentResults = await this.searchByContent(resolvedPath, contentOptions) - - logger.debug('Found matches by content', { count: contentResults.length }) - - // Combine results: filename matches first, then content matches (deduplicated) - const combined = [...filenameResults] - const filenameSet = new Set(filenameResults) - - for (const filePath of contentResults) { - if (!filenameSet.has(filePath)) { - combined.push(filePath) - if (combined.length >= maxEntries) { - break - } - } - } - - logger.debug('Combined results', { total: combined.length, filenameCount: filenameResults.length }) - return combined.slice(0, maxEntries) + return filenameResults.slice(0, options.maxEntries) } public validateNotesDirectory = async (_: Electron.IpcMainInvokeEvent, dirPath: string): Promise => { diff --git a/src/renderer/src/pages/home/Inputbar/tools/components/useActivityDirectoryPanel.tsx b/src/renderer/src/pages/home/Inputbar/tools/components/useActivityDirectoryPanel.tsx index b83c00c42d..e15529e66c 100644 --- a/src/renderer/src/pages/home/Inputbar/tools/components/useActivityDirectoryPanel.tsx +++ b/src/renderer/src/pages/home/Inputbar/tools/components/useActivityDirectoryPanel.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next' const logger = loggerService.withContext('useActivityDirectoryPanel') const MAX_FILE_RESULTS = 500 +const MAX_SEARCH_RESULTS = 20 const areFileListsEqual = (prev: string[], next: string[]) => { if (prev === next) return true if (prev.length !== next.length) return false @@ -193,11 +194,11 @@ export const useActivityDirectoryPanel = (params: Params, role: 'button' | 'mana try { const files = await window.api.file.listDirectory(dirPath, { recursive: true, - maxDepth: 4, + maxDepth: 10, includeHidden: false, includeFiles: true, includeDirectories: true, - maxEntries: MAX_FILE_RESULTS, + maxEntries: MAX_SEARCH_RESULTS, searchPattern: searchPattern || '.' }) From 68f9add5663d7edbbc5f5b772ec2f5a50b25fdd2 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Wed, 31 Dec 2025 21:44:15 +0800 Subject: [PATCH 065/116] refactor(message): remove deprecated properties from message types - Commented out `modelId` and `knowledgeBaseIds` properties in `BaseBlock` and `MainTextBlock` interfaces, marking them as dead code for future removal in v2. - This change helps to clean up the codebase and prepare for upcoming version updates. --- packages/shared/data/types/message.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts index be0bc3cb7e..7f85f92735 100644 --- a/packages/shared/data/types/message.ts +++ b/packages/shared/data/types/message.ts @@ -58,7 +58,7 @@ export interface BaseBlock { type: BlockType createdAt: number // timestamp updatedAt?: number - modelId?: string + // modelId?: string // v1's dead code, will be removed in v2 metadata?: Record error?: SerializedErrorData } @@ -84,7 +84,7 @@ export interface UnknownBlock extends BaseBlock { export interface MainTextBlock extends BaseBlock { type: BlockType.MAIN_TEXT content: string - knowledgeBaseIds?: string[] + //knowledgeBaseIds?: string[] // v1's dead code, will be removed in v2 citationReferences?: { citationBlockId?: string citationBlockSource?: string From 33cdcaa55854c86d23dfd55699b07c656d266bf8 Mon Sep 17 00:00:00 2001 From: Phantom Date: Wed, 31 Dec 2025 22:24:53 +0800 Subject: [PATCH 066/116] fix(ovms): add platform check to prevent errors on non-Windows systems (#12125) * fix(ovms): make ovms manager windows-only and lazy load it Add platform check in OvmsManager constructor to throw error on non-Windows platforms Lazy load ovmsManager instance and handle IPC registration only on Windows Update will-quit handler to conditionally cleanup ovms resources * feat(preload): add windows-only OVMS API and improve type safety Extract OVMS API methods into a separate windowsOnlyApi object for better organization Add explicit return type for getDeviceType method * feat(system): add system utils and refine ovms support check - Add new system utility functions for device type, hostname and CPU name - Refactor OVMS support check to require both Windows and Intel CPU - Update IPC handlers to use new system utils and provide proper OVMS fallbacks * Revert "feat(preload): add windows-only OVMS API and improve type safety" This reverts commit d7c5c2b9a45c45e79a817651f3c30bf8984bce02. * feat(ovms): add support check for ovms provider Add new IPC channel and handler to check if OVMS is supported on the current system. This replaces the previous device type and CPU name checks with a more maintainable solution. * fix(OvmsManager): improve intel cpu check for ovms manager Move isOvmsSupported check before class definition and update error message to reflect intel cpu requirement * fix: use isOvmsSupported flag for ovms cleanup check Replace platform check with feature flag to properly determine if ovms cleanup should run * fix: improve warning message for undefined ovmsManager * fix(system): handle edge cases in getCpuName function Add error handling and null checks to prevent crashes when CPU information is unavailable * feat(runtime): add ovms support check during app init Add isOvmsSupported state to runtime store and check support status during app initialization. Move ovms support check from ProviderList component to useAppInit hook for centralized management. --- packages/shared/IpcChannel.ts | 1 + src/main/index.ts | 13 +++-- src/main/ipc.ts | 50 +++++++++++++------ src/main/services/OvmsManager.ts | 12 ++++- src/main/utils/system.ts | 19 +++++++ src/preload/index.ts | 1 + src/renderer/src/hooks/useAppInit.ts | 15 +++++- .../ProviderSettings/ProviderList.tsx | 6 +-- src/renderer/src/store/runtime.ts | 8 ++- 9 files changed, 102 insertions(+), 23 deletions(-) create mode 100644 src/main/utils/system.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 2ba327db93..8361a917e5 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -364,6 +364,7 @@ export enum IpcChannel { OCR_ListProviders = 'ocr:list-providers', // OVMS + Ovms_IsSupported = 'ovms:is-supported', Ovms_AddModel = 'ovms:add-model', Ovms_StopAddModel = 'ovms:stop-addmodel', Ovms_GetModels = 'ovms:get-models', diff --git a/src/main/index.ts b/src/main/index.ts index ec16475d3f..536485a490 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -37,7 +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' +import { isOvmsSupported } from './services/OvmsManager' const logger = loggerService.withContext('MainEntry') @@ -158,7 +158,7 @@ if (!app.requestSingleInstanceLock()) { registerShortcuts(mainWindow) - registerIpc(mainWindow, app) + await registerIpc(mainWindow, app) localTransferService.startDiscovery({ resetList: true }) replaceDevtoolsFont(mainWindow) @@ -248,7 +248,14 @@ if (!app.requestSingleInstanceLock()) { app.on('will-quit', async () => { // 简单的资源清理,不阻塞退出流程 - await ovmsManager.stopOvms() + if (isOvmsSupported) { + const { ovmsManager } = await import('./services/OvmsManager') + if (ovmsManager) { + await ovmsManager.stopOvms() + } else { + logger.warn('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.') + } + } try { await mcpService.cleanup() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 8f86a93075..56932a51d6 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 { isOvmsSupported } from './services/OvmsManager' import powerMonitorService from './services/PowerMonitorService' import { proxyManager } from './services/ProxyManager' import { pythonService } from './services/PythonService' @@ -97,6 +97,7 @@ import { untildify } from './utils/file' import { updateAppDataConfig } from './utils/init' +import { getCpuName, getDeviceType, getHostname } from './utils/system' import { compress, decompress } from './utils/zip' const logger = loggerService.withContext('IPC') @@ -120,7 +121,7 @@ function extractPluginError(error: unknown): PluginError | null { return null } -export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { +export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { const appUpdater = new AppUpdater() const notificationService = new NotificationService() @@ -498,9 +499,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text)) // system - ipcMain.handle(IpcChannel.System_GetDeviceType, () => (isMac ? 'mac' : isWin ? 'windows' : 'linux')) - ipcMain.handle(IpcChannel.System_GetHostname, () => require('os').hostname()) - ipcMain.handle(IpcChannel.System_GetCpuName, () => require('os').cpus()[0].model) + ipcMain.handle(IpcChannel.System_GetDeviceType, getDeviceType) + ipcMain.handle(IpcChannel.System_GetHostname, getHostname) + ipcMain.handle(IpcChannel.System_GetCpuName, getCpuName) ipcMain.handle(IpcChannel.System_CheckGitBash, () => { if (!isWin) { return true // Non-Windows systems don't need Git Bash @@ -974,15 +975,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.OCR_ListProviders, () => ocrService.listProviderIds()) // OVMS - ipcMain.handle(IpcChannel.Ovms_AddModel, (_, modelName: string, modelId: string, modelSource: string, task: string) => - ovmsManager.addModel(modelName, modelId, modelSource, task) - ) - ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel()) - ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels()) - ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms()) - ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus()) - ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms()) - ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms()) + ipcMain.handle(IpcChannel.Ovms_IsSupported, () => isOvmsSupported) + if (isOvmsSupported) { + const { ovmsManager } = await import('./services/OvmsManager') + if (ovmsManager) { + ipcMain.handle( + IpcChannel.Ovms_AddModel, + (_, modelName: string, modelId: string, modelSource: string, task: string) => + ovmsManager.addModel(modelName, modelId, modelSource, task) + ) + ipcMain.handle(IpcChannel.Ovms_StopAddModel, () => ovmsManager.stopAddModel()) + ipcMain.handle(IpcChannel.Ovms_GetModels, () => ovmsManager.getModels()) + ipcMain.handle(IpcChannel.Ovms_IsRunning, () => ovmsManager.initializeOvms()) + ipcMain.handle(IpcChannel.Ovms_GetStatus, () => ovmsManager.getOvmsStatus()) + ipcMain.handle(IpcChannel.Ovms_RunOVMS, () => ovmsManager.runOvms()) + ipcMain.handle(IpcChannel.Ovms_StopOVMS, () => ovmsManager.stopOvms()) + } else { + logger.error('Unexpected behavior: undefined ovmsManager, but OVMS should be supported.') + } + } else { + const fallback = () => { + throw new Error('OVMS is only supported on Windows with intel CPU.') + } + ipcMain.handle(IpcChannel.Ovms_AddModel, fallback) + ipcMain.handle(IpcChannel.Ovms_StopAddModel, fallback) + ipcMain.handle(IpcChannel.Ovms_GetModels, fallback) + ipcMain.handle(IpcChannel.Ovms_IsRunning, fallback) + ipcMain.handle(IpcChannel.Ovms_GetStatus, fallback) + ipcMain.handle(IpcChannel.Ovms_RunOVMS, fallback) + ipcMain.handle(IpcChannel.Ovms_StopOVMS, fallback) + } // CherryAI ipcMain.handle(IpcChannel.Cherryai_GetSignature, (_, params) => generateSignature(params)) diff --git a/src/main/services/OvmsManager.ts b/src/main/services/OvmsManager.ts index 54e0a1bb8b..67d6d9a9df 100644 --- a/src/main/services/OvmsManager.ts +++ b/src/main/services/OvmsManager.ts @@ -3,6 +3,8 @@ import { homedir } from 'node:os' import { promisify } from 'node:util' import { loggerService } from '@logger' +import { isWin } from '@main/constant' +import { getCpuName } from '@main/utils/system' import { HOME_CHERRY_DIR } from '@shared/config/constant' import * as fs from 'fs-extra' import * as path from 'path' @@ -11,6 +13,8 @@ const logger = loggerService.withContext('OvmsManager') const execAsync = promisify(exec) +export const isOvmsSupported = isWin && getCpuName().toLowerCase().includes('intel') + interface OvmsProcess { pid: number path: string @@ -29,6 +33,12 @@ interface OvmsConfig { class OvmsManager { private ovms: OvmsProcess | null = null + constructor() { + if (!isOvmsSupported) { + throw new Error('OVMS Manager is only supported on Windows platform with Intel CPU.') + } + } + /** * Recursively terminate a process and all its child processes * @param pid Process ID to terminate @@ -563,4 +573,4 @@ class OvmsManager { } // Export singleton instance -export const ovmsManager = new OvmsManager() +export const ovmsManager = isOvmsSupported ? new OvmsManager() : undefined diff --git a/src/main/utils/system.ts b/src/main/utils/system.ts new file mode 100644 index 0000000000..2cd9e4bf22 --- /dev/null +++ b/src/main/utils/system.ts @@ -0,0 +1,19 @@ +import os from 'node:os' + +import { isMac, isWin } from '@main/constant' + +export const getDeviceType = () => (isMac ? 'mac' : isWin ? 'windows' : 'linux') + +export const getHostname = () => os.hostname() + +export const getCpuName = () => { + try { + const cpus = os.cpus() + if (!cpus || cpus.length === 0 || !cpus[0].model) { + return 'Unknown CPU' + } + return cpus[0].model + } catch { + return 'Unknown CPU' + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 424253f8e3..cb8b0f6919 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -340,6 +340,7 @@ const api = { ipcRenderer.invoke(IpcChannel.VertexAI_ClearAuthCache, projectId, clientEmail) }, ovms: { + isSupported: (): Promise => ipcRenderer.invoke(IpcChannel.Ovms_IsSupported), addModel: (modelName: string, modelId: string, modelSource: string, task: string) => ipcRenderer.invoke(IpcChannel.Ovms_AddModel, modelName, modelId, modelSource, task), stopAddModel: () => ipcRenderer.invoke(IpcChannel.Ovms_StopAddModel), diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 360f8a5e2a..9fdce88a8b 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -10,7 +10,7 @@ import { useAppDispatch } from '@renderer/store' import { useAppSelector } from '@renderer/store' import { handleSaveData } from '@renderer/store' import { selectMemoryConfig } from '@renderer/store/memory' -import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime' +import { setAvatar, setFilesPath, setIsOvmsSupported, setResourcesPath, setUpdateState } from '@renderer/store/runtime' import { type ToolPermissionRequestPayload, type ToolPermissionResultPayload, @@ -274,4 +274,17 @@ export function useAppInit() { useEffect(() => { checkDataLimit() }, []) + + useEffect(() => { + // Check once when initing + window.api.ovms + .isSupported() + .then((result) => { + dispatch(setIsOvmsSupported(result)) + }) + .catch((e) => { + logger.error('Failed to check isOvmsSupported. Fallback to false.', e as Error) + dispatch(setIsOvmsSupported(false)) + }) + }, [dispatch]) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx index 7502e4d806..9bd9e3fab1 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx @@ -8,6 +8,7 @@ import { import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { ProviderAvatar } from '@renderer/components/ProviderAvatar' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' +import { useRuntime } from '@renderer/hooks/useRuntime' import { useTimer } from '@renderer/hooks/useTimer' import ImageStorage from '@renderer/services/ImageStorage' import type { Provider, ProviderType } from '@renderer/types' @@ -30,8 +31,6 @@ import UrlSchemaInfoPopup from './UrlSchemaInfoPopup' const logger = loggerService.withContext('ProviderList') const BUTTON_WRAPPER_HEIGHT = 50 -const systemType = await window.api.system.getDeviceType() -const cpuName = await window.api.system.getCpuName() const ProviderList: FC = () => { const [searchParams, setSearchParams] = useSearchParams() @@ -44,6 +43,7 @@ const ProviderList: FC = () => { const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) const listRef = useRef(null) + const { isOvmsSupported } = useRuntime() const setSelectedProvider = useCallback((provider: Provider) => { startTransition(() => _setSelectedProvider(provider)) @@ -278,7 +278,7 @@ const ProviderList: FC = () => { } const filteredProviders = providers.filter((provider) => { - if (provider.id === 'ovms' && (systemType !== 'windows' || !cpuName.toLowerCase().includes('intel'))) { + if (provider.id === 'ovms' && !isOvmsSupported) { return false } diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 66fd161dcd..b7406a414c 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -73,6 +73,7 @@ export interface RuntimeState { export: ExportState chat: ChatState websearch: WebSearchState + isOvmsSupported: boolean | undefined } export interface ExportState { @@ -115,7 +116,8 @@ const initialState: RuntimeState = { }, websearch: { activeSearches: {} - } + }, + isOvmsSupported: undefined } const runtimeSlice = createSlice({ @@ -161,6 +163,9 @@ const runtimeSlice = createSlice({ setExportState: (state, action: PayloadAction>) => { state.export = { ...state.export, ...action.payload } }, + setIsOvmsSupported: (state, action: PayloadAction) => { + state.isOvmsSupported = action.payload + }, // Chat related actions toggleMultiSelectMode: (state, action: PayloadAction) => { state.chat.isMultiSelectMode = action.payload @@ -223,6 +228,7 @@ export const { setResourcesPath, setUpdateState, setExportState, + setIsOvmsSupported, // Chat related actions toggleMultiSelectMode, setSelectedMessageIds, From 8729aa40c7d512a5491caa6146b27ddd69b00ec0 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 1 Jan 2026 13:35:34 +0800 Subject: [PATCH 067/116] feat(message): introduce content reference types and update MainTextBlock structure - Added new enums and interfaces for content references, including `ReferenceCategory`, `CitationType`, and various citation reference types (Web, Knowledge, Memory). - Unified inline references in `MainTextBlock` with a new `references` property, replacing the deprecated `citationReferences` and `CitationBlock`. - Implemented type guards for reference validation, enhancing type safety and clarity in message handling. - Updated documentation to reflect changes and migration notes for version 2.0. --- packages/shared/data/types/message.ts | 182 +++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 4 deletions(-) diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts index 7f85f92735..6906d68e4d 100644 --- a/packages/shared/data/types/message.ts +++ b/packages/shared/data/types/message.ts @@ -32,6 +32,151 @@ export interface MessageData { //FIXME [v2] 注意,以下类型只是占位,接口未稳定,随时会变 +// ============================================================================ +// Content Reference Types +// ============================================================================ + +/** + * Reference category for content references + */ +export enum ReferenceCategory { + CITATION = 'citation', + MENTION = 'mention' +} + +/** + * Citation source type + */ +export enum CitationType { + WEB = 'web', + KNOWLEDGE = 'knowledge', + MEMORY = 'memory' +} + +/** + * Base reference structure for inline content references + */ +export interface BaseReference { + category: ReferenceCategory + /** Text marker in content, e.g., "[1]", "@user" */ + marker?: string + /** Position range in content */ + range?: { start: number; end: number } +} + +/** + * Base citation reference + */ +interface BaseCitationReference extends BaseReference { + category: ReferenceCategory.CITATION + citationType: CitationType +} + +/** + * Web search citation reference + * Data structure compatible with WebSearchResponse from renderer + */ +export interface WebCitationReference extends BaseCitationReference { + citationType: CitationType.WEB + content: { + results?: unknown // types needs to be migrated from renderer ( newMessage.ts ) + source: unknown // types needs to be migrated from renderer ( newMessage.ts ) + } +} + +/** + * Knowledge base citation reference + * Data structure compatible with KnowledgeReference[] from renderer + */ +export interface KnowledgeCitationReference extends BaseCitationReference { + citationType: CitationType.KNOWLEDGE + + // types needs to be migrated from renderer ( newMessage.ts ) + content: { + id: number + content: string + sourceUrl: string + type: string + file?: unknown + metadata?: Record + }[] +} + +/** + * Memory citation reference + * Data structure compatible with MemoryItem[] from renderer + */ +export interface MemoryCitationReference extends BaseCitationReference { + citationType: CitationType.MEMORY + // types needs to be migrated from renderer ( newMessage.ts ) + content: { + id: string + memory: string + hash?: string + createdAt?: string + updatedAt?: string + score?: number + metadata?: Record + }[] +} + +/** + * Union type of all citation references + */ +export type CitationReference = WebCitationReference | KnowledgeCitationReference | MemoryCitationReference + +/** + * Mention reference for @mentions in content + * References a Model entity + */ +export interface MentionReference extends BaseReference { + category: ReferenceCategory.MENTION + /** Model ID being mentioned */ + modelId: string //FIXME 未定接口,model的数据结构还未确定,先占位 + /** Display name for the mention */ + displayName?: string +} + +/** + * Union type of all content references + */ +export type ContentReference = CitationReference | MentionReference + +/** + * Type guard: check if reference is a citation + */ +export function isCitation(ref: ContentReference): ref is CitationReference { + return ref.category === ReferenceCategory.CITATION +} + +/** + * Type guard: check if reference is a mention + */ +export function isMention(ref: ContentReference): ref is MentionReference { + return ref.category === ReferenceCategory.MENTION +} + +/** + * Type guard: check if reference is a web citation + */ +export function isWebCitation(ref: ContentReference): ref is WebCitationReference { + return isCitation(ref) && ref.citationType === CitationType.WEB +} + +/** + * Type guard: check if reference is a knowledge citation + */ +export function isKnowledgeCitation(ref: ContentReference): ref is KnowledgeCitationReference { + return isCitation(ref) && ref.citationType === CitationType.KNOWLEDGE +} + +/** + * Type guard: check if reference is a memory citation + */ +export function isMemoryCitation(ref: ContentReference): ref is MemoryCitationReference { + return isCitation(ref) && ref.citationType === CitationType.MEMORY +} + // ============================================================================ // Message Block // ============================================================================ @@ -81,14 +226,39 @@ export interface UnknownBlock extends BaseBlock { content?: string } +/** + * Main text block containing the primary message content. + * + * ## Migration Notes (v2.0) + * + * ### Added + * - `references`: Unified inline references replacing the old citation system. + * Supports multiple reference types (citations, mentions) with position tracking. + * + * ### Removed + * - `citationReferences`: Use `references` with `ReferenceCategory.CITATION` instead. + * - `CitationBlock`: Citation data is now embedded in `MainTextBlock.references`. + * The standalone CitationBlock type is no longer used. + */ export interface MainTextBlock extends BaseBlock { type: BlockType.MAIN_TEXT content: string //knowledgeBaseIds?: string[] // v1's dead code, will be removed in v2 - citationReferences?: { - citationBlockId?: string - citationBlockSource?: string - }[] + + /** + * Inline references embedded in the content (citations, mentions, etc.) + * Replaces the old CitationBlock + citationReferences pattern. + * @since v2.0 + */ + references?: ContentReference[] + + /** + * @deprecated Use `references` with `ReferenceCategory.CITATION` instead. + */ + // citationReferences?: { + // citationBlockId?: string + // citationBlockSource?: string + // }[] } export interface ThinkingBlock extends BaseBlock { @@ -125,6 +295,10 @@ export interface ToolBlock extends BaseBlock { content?: string | object } +/** + * @deprecated Citation data is now embedded in MainTextBlock.references. + * Use ContentReference types instead. Will be removed in v3.0. + */ export interface CitationBlock extends BaseBlock { type: BlockType.CITATION responseData?: unknown From f878c8ab3ba0d27fcbd94221098282a933448300 Mon Sep 17 00:00:00 2001 From: Here_is_Daiyu <149942676+Here-is-Daiyu@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:36:52 +0800 Subject: [PATCH 068/116] Update minimax API documentation link (#12220) --- src/renderer/src/config/providers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 88aceda957..9e2831ee6e 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -1089,7 +1089,7 @@ export const PROVIDER_URLS: Record = { websites: { official: 'https://platform.minimaxi.com/', apiKey: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', - docs: 'https://platform.minimaxi.com/document/Announcement', + docs: 'https://platform.minimaxi.com/docs/api-reference/text-openai-api', models: 'https://platform.minimaxi.com/document/Models' } }, From d391e55a8a3a04d004d01171e5cdfbaf954c1cb3 Mon Sep 17 00:00:00 2001 From: Phantom Date: Thu, 1 Jan 2026 16:40:12 +0800 Subject: [PATCH 069/116] refactor(ovms): lazy-load OVMS support check with SWR (#12226) --- src/renderer/src/hooks/useAppInit.ts | 15 +-------------- .../settings/ProviderSettings/ProviderList.tsx | 16 ++++++++++++++-- src/renderer/src/store/runtime.ts | 8 +------- 3 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/hooks/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index 9fdce88a8b..360f8a5e2a 100644 --- a/src/renderer/src/hooks/useAppInit.ts +++ b/src/renderer/src/hooks/useAppInit.ts @@ -10,7 +10,7 @@ import { useAppDispatch } from '@renderer/store' import { useAppSelector } from '@renderer/store' import { handleSaveData } from '@renderer/store' import { selectMemoryConfig } from '@renderer/store/memory' -import { setAvatar, setFilesPath, setIsOvmsSupported, setResourcesPath, setUpdateState } from '@renderer/store/runtime' +import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime' import { type ToolPermissionRequestPayload, type ToolPermissionResultPayload, @@ -274,17 +274,4 @@ export function useAppInit() { useEffect(() => { checkDataLimit() }, []) - - useEffect(() => { - // Check once when initing - window.api.ovms - .isSupported() - .then((result) => { - dispatch(setIsOvmsSupported(result)) - }) - .catch((e) => { - logger.error('Failed to check isOvmsSupported. Fallback to false.', e as Error) - dispatch(setIsOvmsSupported(false)) - }) - }, [dispatch]) } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx index 9bd9e3fab1..cc19eea6a6 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx @@ -8,7 +8,6 @@ import { import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { ProviderAvatar } from '@renderer/components/ProviderAvatar' import { useAllProviders, useProviders } from '@renderer/hooks/useProvider' -import { useRuntime } from '@renderer/hooks/useRuntime' import { useTimer } from '@renderer/hooks/useTimer' import ImageStorage from '@renderer/services/ImageStorage' import type { Provider, ProviderType } from '@renderer/types' @@ -22,6 +21,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router-dom' import styled from 'styled-components' +import useSWRImmutable from 'swr/immutable' import AddProviderPopup from './AddProviderPopup' import ModelNotesPopup from './ModelNotesPopup' @@ -32,6 +32,16 @@ const logger = loggerService.withContext('ProviderList') const BUTTON_WRAPPER_HEIGHT = 50 +const getIsOvmsSupported = async (): Promise => { + try { + const result = await window.api.ovms.isSupported() + return result + } catch (e) { + logger.warn('Fetching isOvmsSupported failed. Fallback to false.', e as Error) + return false + } +} + const ProviderList: FC = () => { const [searchParams, setSearchParams] = useSearchParams() const providers = useAllProviders() @@ -43,7 +53,8 @@ const ProviderList: FC = () => { const [dragging, setDragging] = useState(false) const [providerLogos, setProviderLogos] = useState>({}) const listRef = useRef(null) - const { isOvmsSupported } = useRuntime() + + const { data: isOvmsSupported } = useSWRImmutable('ovms/isSupported', getIsOvmsSupported) const setSelectedProvider = useCallback((provider: Provider) => { startTransition(() => _setSelectedProvider(provider)) @@ -278,6 +289,7 @@ const ProviderList: FC = () => { } const filteredProviders = providers.filter((provider) => { + // don't show it when isOvmsSupported is loading if (provider.id === 'ovms' && !isOvmsSupported) { return false } diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index b7406a414c..66fd161dcd 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -73,7 +73,6 @@ export interface RuntimeState { export: ExportState chat: ChatState websearch: WebSearchState - isOvmsSupported: boolean | undefined } export interface ExportState { @@ -116,8 +115,7 @@ const initialState: RuntimeState = { }, websearch: { activeSearches: {} - }, - isOvmsSupported: undefined + } } const runtimeSlice = createSlice({ @@ -163,9 +161,6 @@ const runtimeSlice = createSlice({ setExportState: (state, action: PayloadAction>) => { state.export = { ...state.export, ...action.payload } }, - setIsOvmsSupported: (state, action: PayloadAction) => { - state.isOvmsSupported = action.payload - }, // Chat related actions toggleMultiSelectMode: (state, action: PayloadAction) => { state.chat.isMultiSelectMode = action.payload @@ -228,7 +223,6 @@ export const { setResourcesPath, setUpdateState, setExportState, - setIsOvmsSupported, // Chat related actions toggleMultiSelectMode, setSelectedMessageIds, From 77e024027c53d3e0d73f1a684c9f97a55474ea54 Mon Sep 17 00:00:00 2001 From: Northword <44738481+northword@users.noreply.github.com> Date: Thu, 1 Jan 2026 19:13:24 +0800 Subject: [PATCH 070/116] fix(miniapps): switch to new google ai studio logo (#12229) --- .../src/assets/images/apps/aistudio.png | Bin 0 -> 7398 bytes .../src/assets/images/apps/aistudio.svg | 27 ------------------ src/renderer/src/config/minapps.ts | 2 +- 3 files changed, 1 insertion(+), 28 deletions(-) create mode 100644 src/renderer/src/assets/images/apps/aistudio.png delete mode 100644 src/renderer/src/assets/images/apps/aistudio.svg diff --git a/src/renderer/src/assets/images/apps/aistudio.png b/src/renderer/src/assets/images/apps/aistudio.png new file mode 100644 index 0000000000000000000000000000000000000000..c7cb2adebe3413fa191a3714b565fcc38409bf73 GIT binary patch literal 7398 zcmVE z2bQBc5bOeBgKg}|IsSbABW=z(2lroF)$A;K9*n_k(uS$m>MTM@gNU39$Jt_VS_33U z1mi7&?Tp~qbWZ|sx(aX>Dz{L(0}F*}H#4J1t{W=d4;W~e7nakF5jtrE3L-SxC=;P0(>=eOjf;zm z{{lu7f+E6E8Mq?U<(VIU7ono+9ccGN2>6Oc^+&kL+!|kPx_&;PD+TXiz@YoPMO zz6G?S9#?C)PVO~yb(LY2y@0N5>2#I)r%kfZ5a|!O7x-~Fmpjwq+@fzFH84CezA_Q` zsSTe6diIhp*hw~_%XH$c2C>2)@{H`5hAPr~&1%v=SXtpO^+2UpC>t0anhquCPv~a) zo(sD3iJ?-jA^kU2-rO;+l$TVVycFQL1}KQo&=TJ%*a^x;XXk#L{b`cJYXCS5xP8pV zmCkOZ7V6ZfQ7OL?5pvxf)i!^IVnHYf0MT#`)%m$K+?Z(R6{Y42HQEGRwbp7|w}u5X zhd_faE-Eh7j9ffQnt52w_!StwAJ1U9AeG)kk8jmS0^HmR{6p8_!#4t3;^z z6q>Xcv<9FJUl8s@Cbnywn2)hS3z9>izKJ=rAh>!=le4HeEq}O^4egi)-xy&Ac=IRE zE!3V7p}pDE9Ke8?TQ|~(WyEPggs2iteLf>b(wwa`76!pH5qIk=5z^ZQwkZk-!F~0y5AFpd=Lvy(K(P0KsqZ|ZWS5RbDTybv%G^tS*7fc^ zbsyWE!?aWwisXxo*L{cY8BzZYSv@M>zW_vAJP^@U>%y|TLWDX?xCXS=>(EID^iONH z32hP|Vge|^+L&O4F4pvZ(5jVQG!->o3M`?C<&XK$=$FFv`M7K66Gbe=MJf}AbUqxe zjf(1)hbPwvHDEs$M+of}hY117AOg`MBMx^w%_qdEf&QEuX>s!M1>`nGpuLe8JK+&z zk5f^S`#xnc+m&UX{Z%>0r#^#z&IdxeA!G2mvtmr*eE>A=0W z6QOPOZ_=t4QxVB=0ph3t;!G-eC4H;Yhd__V051y-RD%25KJ9G{O+CC*?Xxa zHyODNcFkXEAbV{R1KDGOZA#+gjjhKNzWdKegmxa=X}kFwys*??ind6E)F4)Vnxg4< z)Pz`i4}?O&HGlh|EJ1_%8x5^9%MG$?oDr(`=BTu1dbQuWfUdlD&>B~O)*|4jq$OI1 z@asYv0jXqGD5KJpYy90VLMwJaLxnnoKdez#*$DxnYy3_Sq7~`j+TyBtbG=NoVs{V> zmx;>^8%R=uURsv8Z-GLRJ|%JAmlP$b&m@hhoO~97Lbq^Zk|?$*N?<_Z#B_QlA&3kl zu&z@VuCL$tQ;TV-!|S@C^acgJplUJ@WlKzwvWcZ~VLD2`U;|V|64wXjeU!-hsO)SX z-Y8DhBWG29?(?R1LAR`2iOLOdpWoXjL9xz%#%utRU=fo{P+b1*k)%d1`UQAm;HiOU z2A&td=#)*RoN|~+bbd_Z1F0R;^qwohi?YAoI|BFGBri=;0OK?XK@<_B5gAeb{sWie zg;JLh^lPG4)tKK2u6+D~m3OvSXauyd7svnP-2;?l$+m#uZ=Z9LRbA74{cPL1wr$(C zZQHhepKaUb;Efv7D&&5M6>DaMKp%9^Wxrn`4WMB>D%Jhfvp?38W~!+#?dxN{~D zONvSfOF0hPlTe4)d!3Nnp;FV?D`N4>U%nM;4`YOKzJ@Co`PCjSb0TsHXMr?_McsY7 z8`;KW64DHzyPV+x3C8;sMl&XoG+aZHgVnlbH3SUTBp9v|XomYlCZmRcQP&5?>k9Q{ z^j8Hbdi^>Q39{-Zx+?U?2=vB46hZcyx}@zu3|5mQ`l|t=zlJ0R_44}Y_Gi1Hu(b&p zSp?)7B(Wt!k`tt`H3=a00Xdm~Y$sbtHrp*=Yd8SWWlGrEf=H7s1WXj#;|llrvFH8; zQf`6Jj1e;G;-(5&g~?VCY)umCJ5VoAf_8nQ zMqee!`c~>k&()&}-ERB>5vXL$i)~KGMQ%7Z4OiWNP;9GjC6&ls=M2S}M>}UqrzIsJ z7piq~Bf9HF*fDWNDne&I8L>!2c%)g9fCX$tSGX!}@li;X1R*VrMw&xLrWsi2>NwTow~M#LgzbQ;}0kb0|ia21kn0#ds|swzlSr39KJ1u-Vj z)Tv%O0fz0CNEj!w+9cFmsiYf`gmwr|0lxJPAD8Vw&iKCus@y_IQ;(7*c1qY7soQ`lF96BpE5(s6~&69lZEP&j^(!sSm0XnSQ!Hbu}syk3Kb z6BwTeV0c9J9w9J1i66O^!s9+PFIdMmAYj0xI(Ka zLKB5a6|xGdFuf*ncB%@I5Hn8?6A5HiK`LyiQ$#}IDGI*h=AV$MFe}JQg;`B7w{rTb zlAO$}m1yQ1Bo-me5D+_BiU^Rn=?zp~v|<4kH$Zm_9?^&$pUk?i%oT$1#s;uA3Ts+J+~7y$X;1z}Y);(JpY{4teGq z5c3ZQu5|aiey8pD=r3>gvOoH0Q}o|=qaz4J%q$Nv-$69rJG^A$+gCp6&STqh@Uh!% z>gC?kGtGBD^P%(29}zqEIt7RXjm3z0BfUre*?q^h=jrEc^9s#7zRon<=T_z;!ZaBX zVLL(zk;yfXtRZ21m6WiGjAVR8NLGNfVrC>(iTs8)eXn`^$Hx!U1nwN{51@0~agoKUB4_U-xfe^%6`AO&$gM%?T3YnxM9J zBth1~J*L?Qmusw=o_2dSG0|#>(-^uFh?%=c> z`0Rr}v`DaYDu~&c`Gu5D@#LKUn|1u=vC2Q(^co*KId4P5H0e8j{SLNa{LZ(Hb{sb! z!YPTx?g6=gX7S{2Q+fLj|9qH5Z2Pcz;BSAY?YZ;Me~)R%T`8lk1V0d{6MwgGo^ZKfu z_nylIWG@C1y5W|PVF+Xl&x#tBoVfC%?$Mf$?jg5%_Qy7-U1I^X(M1vZ6G135AqNK# z7+s!#@f8Jv!*pkJ_G$GW=PS5yR=9i>E_X3}`;8U6=B2BL0+gE|R!(k&qV^MENb zvI~xW5@{B>Sj1kllplcpcPM;r{sBU$-22-f`OE9hgmd3^`>+35rzRI`u&|U&&u{7! zesgDMw#vW0@qME^-Sayv#>UTl@H1OZPlk*LX+FBMN{SQ=*2tPwM9TgO0lgIovw_7y zH|ARv{L7fOHv-W$Nub|!Qi&A~QR9k{y%Xx$$6xf%{iXrY_JOwRNTQV? zMaC+TqJB`>TM>}`eIRt`4jCVs%_m&v>zG?MnLRN>L>>mZPy5sCa zvy`xlRfUrqf?W9Je)U@SJ>O3F&7XMOISYNfFo#|B843kGg$;44B?+-zE@{5+C*Nf* z7vJXwe?8}X9Tp&Bo;^UAOPV6K4}pl&&tCk%I~=qqRG>_chSV3 zzUrUahJ3}f|IPdebs}O9D@(^hzaV1)iwOUG-Jf+@wjTPF4Lc=bcXfqhyHKEA(4=iS zcDr{v4JYsR4vP@G>M@eLdyhQLirD6nj9}{_uXjq0-t?1;SG1o?79ajTB&=Rpf_;#} z3KGVW#0rqc0@4U$g*c07dJQ7=^&$0ZXz(y{I!1arMZDVrPhPt#X4$ycAC1y<%31(z zLm;#S+DRWlF9;YalHmb?$Z-&XI6`;!lt=75g*+bw7nrpOT}DOu4xaVJ*<=O8uj}%pyVHp;o%5HS9*l8xs(K{c;G{5vxyI@WsljaNqPs3uw zoXDx5@{bQ?n)4nyS#Gejh+UV}jXBR+Pz8uE#YaD2ioDl(OYYr)h?w^i5DTb7iy4(e z%z3XTU5v^He{FBNdBk4z7@0rP%nZcLk~sf4R1QCty-~>H%uj={tIY~QB}HJG;H`hA z@|<(+6`?e(+(&|yL}FDo31fvEr@|;hBt{?^3nPVC3z}XNDd?hyWKa|#`VdS8Afp5> zC%PUGy$&yVufnOtw;t3f~nFy!4bN1IA=C?#`!|2 zMWc`xSK*)l4pKP8afLjLW_$^Pel-nkZERNkhk%$(!ZO+bp*?|5{^fTAFF%BJh5oD% zy&~vhgQO~*jE^PA8l75$6Q=4DuA=$;rg6q;9_LGHJkc_Qi<4&V`TSsgPa>lUYWcH^8HJ{`3L52K-l{n=Ky&+m%;lc_?3S-<5mVa$1l6b zJx62Ls_Vb~F+R40j}sBgJPgG$SA}i(S@P#Cg^0yXVtY)^KOuaMKR-ZfV9 zt5Sbo6;^X9Uwt~1Icm`y)75G86k>?zhEbLTiRd*g&knM> z`7LPr3ehUm*NdhVWuPiT(NH~hLFf+&WX&4O{z{m3jjhaj#EdR6>j*mzL=_CCy<8g# zc>+y)BBry(LTqjV-APe|bqKoS3d0Qn+D(NAug^Mgb4#Hqf`}q$Cz~^hP^lKu zZ9102qjMCYSYlm}*}2$#edf{bzxvD817a5?)nkDpej=WHZI-wNjolKo%G^o`-}=7q#6ro_d*609c57CS++jcRxc3WILE45O zm%0Ia-&!^jVIJT5@~iGx!AO?dMuDWcEwOWJ{CRQb|3Q4I>EE$1d zl%!yYqzFTWSWB8-BZKUsCy>3#mPqs^&>Lrf4#;eaMD!tPDMTX14^?P;nn0imq7l$$ z5=2*L27(}m`vW0Imlp)H7NHs=9WI!Sh|Yr$6UMn|a;%mKrq2nm-K z;Spe1pE}r_)pl$E(QQJVH`!E1P=z)&6Z9rqlow8rjsO`4t8n48BDA-SMR#8WCSq@! zJ9jQSV&M)MVQ=~THDAxgH|X=f0ef!_jrbDSfLPwMBkcVilFPpEx{IVrs(K zQd&)ru|k4zBBe28FeJeUQp8{_4HROZ$fnmMXrmVyQm;2bhCYybfkZcy5N!ui*CvRj zm(w<)7WH8nA)*V=L_-Gcqzi&B1R;C-6k)JmsEW|EYb?*DaQ>_!nff@2g;U|JE1st9Q|NJMYLw6?P~M-d{?+nUTMLi^?_Lfeo&KZJ-c zvwHx0-i9?l-y0B%%thEedsYmajnW*?vTPx!eQd^LRRFzOe6r5%!pF&0r0Q z{yq{0D@gRlr0N>WP&;eT8wfI@8>WuJK&Vv?wM|IqVhcgrC#Sn+WIId(O(!GKv;i~? zkTZ%JdY?QH2u-^fwintpH-8kkg6~InmUq*s5b&Gm21K6`|D=K=zLV zIhb*cec==FMSc&+Jy(pvnbP^DcZ6mB7$z9 zH<*pU?TV1vo7Tv1ess#O&M2g->o}$HA#?(k}Al2PEp%7nnUQG4B z(kF$kewwD2BGL3RWZlOkng$6C1kr#hT$#erRwLD^P&J}NQ+J^4A<*>+BHKYAWH$-| z4Y~{UE>!3)2BLRpw#ij{2?X8II#LAD9^;=WeC@x6#_<_NXtKElp*f+@oK&bwqS?Y! z_YHb=B(~t2Dg5V_GLnigZ4puxp*Wc!+hag>mze4EwTjL%_{R+3Cw|>>NP%g7RN+vK zX&1{lb~Tp4k^fM*-kknfyWlzse`|_}h%leJcFz={-?$6#Q(yZZ%j&J4O!Fh(Sl`Ml zLByGcEOnN`uim8=#e0e~BIgY~n8L3wSnoIDvQUIRx32JjtJo91^HvHM{HE=QOSw!n z!3%#zVfDq9My5*hidNyH8_PX%b!$1?2h=uN8N-!?)F0KUAyk71sj5#=gm>OT;r74y z;jT}RU6X-o5JB5dMd%5nwo`~kp`j41Ac@9fe^Mde*-S02dYLAw&J`h)cRakY`9 z!sa>%qarY@!qx^+g~$Hp8~57_F1X(>60_Z{qS+DUx&XN1k||(~yvoL}t#m+NuLPZ?%=Xxw-t)P4D=(mdqdAxD--Z zgxq0HQfKLW>KCu`z-u&?#m3LS^%v}tT-tFscQQWt|v7b9+30&}rM||@ger?yv z^b&iI$Ihny^R=&yul@F~9SkA5$>h{EFv3(Y;HX~oHK0qM3~hTgIRV{T|Tz8 z@ny(=JEu7TqGpU(IVx(#2*k{JGwLz3ZqmZnJ(t`o-(YU@Fitm-m$gGU6LG2#8RVte z<0~N5-saI8%~bNMIub>YQAe8I0FCTL0$sGql7S59`araGQT60#I)#`PB-7dBfvLr9 zAaSWXZjc1ojX>ze8CCfs&>@_Xu-gRc|7q?Fk{gIYAZRxKe{<)O`xNQ*6uPY?r$U1O zw5U9rtE58j$+!sT&X2YA_uDwTa zOy9pZ$lKrG8~|hz$Eg(4TYsA(LO5Aa?fJZC@Xi5DyYy96hImmlDH_b=Mz{qcAW znhfykWe{a2NG8xT3IOW^umsjX;5F%e6z~agex6Dlm;vaTNCdlSoC>o8;H$zt0gHh! zQve`J-zeu;;r9T9erBQb`_&-(IiK?t0aOM#+R>>7VYvmw3Gv?Z&3DCT?*klx@=-8x zHUKCP&+9yy( zz-kM40>WqBD2IJr9-xLj>R8exnu;Y8$`mK7hQm34v<>Dhv=Z6aXyOPv-{5 YFA`m%bPJdSK>z>%07*qoM6N<$f=w*g1^@s6 literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/images/apps/aistudio.svg b/src/renderer/src/assets/images/apps/aistudio.svg deleted file mode 100644 index 2c08015593..0000000000 --- a/src/renderer/src/assets/images/apps/aistudio.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/renderer/src/config/minapps.ts b/src/renderer/src/config/minapps.ts index 81a4a98723..eeefb218d2 100644 --- a/src/renderer/src/config/minapps.ts +++ b/src/renderer/src/config/minapps.ts @@ -1,7 +1,7 @@ import { loggerService } from '@logger' import ThreeMinTopAppLogo from '@renderer/assets/images/apps/3mintop.png?url' import AbacusLogo from '@renderer/assets/images/apps/abacus.webp?url' -import AIStudioLogo from '@renderer/assets/images/apps/aistudio.svg?url' +import AIStudioLogo from '@renderer/assets/images/apps/aistudio.png?url' import ApplicationLogo from '@renderer/assets/images/apps/application.png?url' import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png?url' import BaiduAiSearchLogo from '@renderer/assets/images/apps/baidu-ai-search.webp?url' From 4fcf047fa920f65d61c0df17254027a40782eeb0 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 1 Jan 2026 21:18:28 +0800 Subject: [PATCH 071/116] refactor(database): update message and topic table structures - Modified the `message` table to include `siblings_group_id` and `stats`, while removing `usage` and `metrics`. - Renamed `response_group_id` to `siblings_group_id` for clarity. - Enhanced the `topic` table by adding `active_node_id` and ensuring `sort_order` and `is_name_manually_edited` are defined correctly. - Updated the database schema to reflect these changes and removed obsolete migration files. --- migrations/sqlite-drizzle/0000_init.sql | 16 +- .../sqlite-drizzle/0001_faulty_ogun.sql | 3 - .../sqlite-drizzle/0002_noisy_zzzax.sql | 2 - .../sqlite-drizzle/meta/0000_snapshot.json | 906 +++++++++--------- .../sqlite-drizzle/meta/0001_snapshot.json | 605 ------------ .../sqlite-drizzle/meta/0002_snapshot.json | 623 ------------ migrations/sqlite-drizzle/meta/_journal.json | 22 +- 7 files changed, 465 insertions(+), 1712 deletions(-) delete mode 100644 migrations/sqlite-drizzle/0001_faulty_ogun.sql delete mode 100644 migrations/sqlite-drizzle/0002_noisy_zzzax.sql delete mode 100644 migrations/sqlite-drizzle/meta/0001_snapshot.json delete mode 100644 migrations/sqlite-drizzle/meta/0002_snapshot.json diff --git a/migrations/sqlite-drizzle/0000_init.sql b/migrations/sqlite-drizzle/0000_init.sql index d7d9a36095..1b49b5e7ad 100644 --- a/migrations/sqlite-drizzle/0000_init.sql +++ b/migrations/sqlite-drizzle/0000_init.sql @@ -29,20 +29,19 @@ CREATE TABLE `group` ( CREATE INDEX `group_entity_sort_idx` ON `group` (`entity_type`,`sort_order`);--> statement-breakpoint CREATE TABLE `message` ( `id` text PRIMARY KEY NOT NULL, - `topic_id` text NOT NULL, `parent_id` text, - `response_group_id` integer DEFAULT 0, + `topic_id` text NOT NULL, `role` text NOT NULL, + `data` text NOT NULL, + `searchable_text` text, `status` text NOT NULL, + `siblings_group_id` integer DEFAULT 0, `assistant_id` text, `assistant_meta` text, `model_id` text, `model_meta` text, - `data` text NOT NULL, - `usage` text, - `metrics` text, `trace_id` text, - `searchable_text` text, + `stats` text, `created_at` integer, `updated_at` integer, `deleted_at` integer, @@ -76,14 +75,15 @@ CREATE UNIQUE INDEX `tag_name_unique` ON `tag` (`name`);--> statement-breakpoint CREATE TABLE `topic` ( `id` text PRIMARY KEY NOT NULL, `name` text, + `is_name_manually_edited` integer DEFAULT false, `assistant_id` text, `assistant_meta` text, `prompt` text, + `active_node_id` text, `group_id` text, + `sort_order` integer DEFAULT 0, `is_pinned` integer DEFAULT false, `pinned_order` integer DEFAULT 0, - `sort_order` integer DEFAULT 0, - `is_name_manually_edited` integer DEFAULT false, `created_at` integer, `updated_at` integer, `deleted_at` integer, diff --git a/migrations/sqlite-drizzle/0001_faulty_ogun.sql b/migrations/sqlite-drizzle/0001_faulty_ogun.sql deleted file mode 100644 index 969e386dcb..0000000000 --- a/migrations/sqlite-drizzle/0001_faulty_ogun.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `message` ADD `stats` text;--> statement-breakpoint -ALTER TABLE `message` DROP COLUMN `usage`;--> statement-breakpoint -ALTER TABLE `message` DROP COLUMN `metrics`; \ No newline at end of file diff --git a/migrations/sqlite-drizzle/0002_noisy_zzzax.sql b/migrations/sqlite-drizzle/0002_noisy_zzzax.sql deleted file mode 100644 index b9c2b04d57..0000000000 --- a/migrations/sqlite-drizzle/0002_noisy_zzzax.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE `message` RENAME COLUMN "response_group_id" TO "siblings_group_id";--> statement-breakpoint -ALTER TABLE `topic` ADD `active_node_id` text REFERENCES message(id); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0000_snapshot.json b/migrations/sqlite-drizzle/meta/0000_snapshot.json index eb3f54f553..2fd34856f7 100644 --- a/migrations/sqlite-drizzle/meta/0000_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0000_snapshot.json @@ -1,109 +1,99 @@ { - "version": "6", + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, "dialect": "sqlite", - "id": "62a198e0-bfc2-4db1-af58-7e479fedd7b9", + "enums": {}, + "id": "2ee6f7b2-99da-4de1-b895-48866855b7c6", + "internal": { + "indexes": {} + }, "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "app_state": { - "name": "app_state", + "checkConstraints": {}, "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, "primaryKey": false, - "notNull": true, - "autoincrement": false + "type": "integer" }, "description": { + "autoincrement": false, "name": "description", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": true, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": true, + "primaryKey": false, + "type": "text" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": {}, + "indexes": {}, + "name": "app_state", + "uniqueConstraints": {} }, "entity_tag": { - "name": "entity_tag", + "checkConstraints": {}, "columns": { - "entity_type": { - "name": "entity_type", - "type": "text", + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, "primaryKey": false, - "notNull": true, - "autoincrement": false + "type": "integer" }, "entity_id": { + "autoincrement": false, "name": "entity_id", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" }, "tag_id": { + "autoincrement": false, "name": "tag_id", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "entity_tag_tag_id_idx": { - "name": "entity_tag_tag_id_idx", - "columns": ["tag_id"], - "isUnique": false - } - }, - "foreignKeys": { - "entity_tag_tag_id_tag_id_fk": { - "name": "entity_tag_tag_id_tag_id_fk", - "tableFrom": "entity_tag", - "tableTo": "tag", - "columnsFrom": ["tag_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" + "primaryKey": false, + "type": "integer" } }, "compositePrimaryKeys": { @@ -112,238 +102,87 @@ "name": "entity_tag_entity_type_entity_id_tag_id_pk" } }, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "name": "entity_tag_tag_id_tag_id_fk", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "entity_tag", + "tableTo": "tag" + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "columns": ["tag_id"], + "isUnique": false, + "name": "entity_tag_tag_id_idx" + } + }, + "name": "entity_tag", + "uniqueConstraints": {} }, "group": { - "name": "group", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" }, "entity_type": { + "autoincrement": false, "name": "entity_type", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" }, "name": { + "autoincrement": false, "name": "name", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, + "default": 0, + "name": "sort_order", "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" } }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, "indexes": { "group_entity_sort_idx": { - "name": "group_entity_sort_idx", "columns": ["entity_type", "sort_order"], - "isUnique": false + "isUnique": false, + "name": "group_entity_sort_idx" } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "group", + "uniqueConstraints": {} }, "message": { - "name": "message", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "topic_id": { - "name": "topic_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "response_group_id": { - "name": "response_group_id", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_id": { - "name": "model_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_meta": { - "name": "model_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "usage": { - "name": "usage", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "metrics": { - "name": "metrics", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trace_id": { - "name": "trace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "searchable_text": { - "name": "searchable_text", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "message_parent_id_idx": { - "name": "message_parent_id_idx", - "columns": ["parent_id"], - "isUnique": false - }, - "message_topic_created_idx": { - "name": "message_topic_created_idx", - "columns": ["topic_id", "created_at"], - "isUnique": false - }, - "message_trace_id_idx": { - "name": "message_trace_id_idx", - "columns": ["trace_id"], - "isUnique": false - } - }, - "foreignKeys": { - "message_topic_id_topic_id_fk": { - "name": "message_topic_id_topic_id_fk", - "tableFrom": "message", - "tableTo": "topic", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_parent_id_message_id_fk": { - "name": "message_parent_id_message_id_fk", - "tableFrom": "message", - "tableTo": "message", - "columnsFrom": ["parent_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, "checkConstraints": { "message_role_check": { "name": "message_role_check", @@ -353,260 +192,421 @@ "name": "message_status_check", "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" } - } - }, - "preference": { - "name": "preference", + }, "columns": { - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, + "assistant_id": { "autoincrement": false, - "default": "'default'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, + "name": "assistant_id", "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "assistant_meta": { + "autoincrement": false, + "name": "assistant_meta", + "notNull": false, + "primaryKey": false, + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "data": { + "autoincrement": false, + "name": "data", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "deleted_at": { + "autoincrement": false, + "name": "deleted_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "model_id": { + "autoincrement": false, + "name": "model_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "model_meta": { + "autoincrement": false, + "name": "model_meta", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "parent_id": { + "autoincrement": false, + "name": "parent_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "role": { + "autoincrement": false, + "name": "role", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "searchable_text": { + "autoincrement": false, + "name": "searchable_text", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "siblings_group_id": { + "autoincrement": false, + "default": 0, + "name": "siblings_group_id", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "stats": { + "autoincrement": false, + "name": "stats", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "status": { + "autoincrement": false, + "name": "status", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "topic_id": { + "autoincrement": false, + "name": "topic_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "trace_id": { + "autoincrement": false, + "name": "trace_id", + "notNull": false, + "primaryKey": false, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "message_parent_id_message_id_fk": { + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "name": "message_parent_id_message_id_fk", + "onDelete": "set null", + "onUpdate": "no action", + "tableFrom": "message", + "tableTo": "message" + }, + "message_topic_id_topic_id_fk": { + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "name": "message_topic_id_topic_id_fk", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "message", + "tableTo": "topic" + } + }, + "indexes": { + "message_parent_id_idx": { + "columns": ["parent_id"], + "isUnique": false, + "name": "message_parent_id_idx" + }, + "message_topic_created_idx": { + "columns": ["topic_id", "created_at"], + "isUnique": false, + "name": "message_topic_created_idx" + }, + "message_trace_id_idx": { + "columns": ["trace_id"], + "isUnique": false, + "name": "message_trace_id_idx" + } + }, + "name": "message", + "uniqueConstraints": {} + }, + "preference": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "scope": { + "autoincrement": false, + "default": "'default'", + "name": "scope", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": false, + "primaryKey": false, + "type": "text" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": { "preference_scope_key_pk": { "columns": ["scope", "key"], "name": "preference_scope_key_pk" } }, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": {}, + "indexes": {}, + "name": "preference", + "uniqueConstraints": {} }, "tag": { - "name": "tag", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "color": { + "autoincrement": false, "name": "color", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": true, + "primaryKey": false, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" } }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, "indexes": { "tag_name_unique": { - "name": "tag_name_unique", "columns": ["name"], - "isUnique": true + "isUnique": true, + "name": "tag_name_unique" } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "tag", + "uniqueConstraints": {} }, "topic": { - "name": "topic", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, + "active_node_id": { + "autoincrement": false, + "name": "active_node_id", "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "assistant_id": { + "autoincrement": false, "name": "assistant_id", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "assistant_meta": { + "autoincrement": false, "name": "assistant_meta", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false - }, - "prompt": { - "name": "prompt", - "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_pinned": { - "name": "is_pinned", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "pinned_order": { - "name": "pinned_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "is_name_manually_edited": { - "name": "is_name_manually_edited", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "type": "integer" }, "deleted_at": { + "autoincrement": false, "name": "deleted_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "group_id": { + "autoincrement": false, + "name": "group_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "is_name_manually_edited": { + "autoincrement": false, + "default": false, + "name": "is_name_manually_edited", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "is_pinned": { + "autoincrement": false, + "default": false, + "name": "is_pinned", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "pinned_order": { + "autoincrement": false, + "default": 0, + "name": "pinned_order", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "prompt": { + "autoincrement": false, + "name": "prompt", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "sort_order": { + "autoincrement": false, + "default": 0, + "name": "sort_order", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "name": "topic_group_id_group_id_fk", + "onDelete": "set null", + "onUpdate": "no action", + "tableFrom": "topic", + "tableTo": "group" } }, "indexes": { - "topic_group_updated_idx": { - "name": "topic_group_updated_idx", - "columns": ["group_id", "updated_at"], - "isUnique": false + "topic_assistant_id_idx": { + "columns": ["assistant_id"], + "isUnique": false, + "name": "topic_assistant_id_idx" }, "topic_group_sort_idx": { - "name": "topic_group_sort_idx", "columns": ["group_id", "sort_order"], - "isUnique": false + "isUnique": false, + "name": "topic_group_sort_idx" }, - "topic_updated_at_idx": { - "name": "topic_updated_at_idx", - "columns": ["updated_at"], - "isUnique": false + "topic_group_updated_idx": { + "columns": ["group_id", "updated_at"], + "isUnique": false, + "name": "topic_group_updated_idx" }, "topic_is_pinned_idx": { - "name": "topic_is_pinned_idx", "columns": ["is_pinned", "pinned_order"], - "isUnique": false + "isUnique": false, + "name": "topic_is_pinned_idx" }, - "topic_assistant_id_idx": { - "name": "topic_assistant_id_idx", - "columns": ["assistant_id"], - "isUnique": false + "topic_updated_at_idx": { + "columns": ["updated_at"], + "isUnique": false, + "name": "topic_updated_at_idx" } }, - "foreignKeys": { - "topic_group_id_group_id_fk": { - "name": "topic_group_id_group_id_fk", - "tableFrom": "topic", - "tableTo": "group", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "topic", + "uniqueConstraints": {} } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } + "version": "6", + "views": {} } diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json deleted file mode 100644 index 83ac3db1ac..0000000000 --- a/migrations/sqlite-drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,605 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "ae53858a-1786-4059-9ff7-9e87267911b6", - "prevId": "62a198e0-bfc2-4db1-af58-7e479fedd7b9", - "tables": { - "app_state": { - "name": "app_state", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "entity_tag": { - "name": "entity_tag", - "columns": { - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "entity_id": { - "name": "entity_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tag_id": { - "name": "tag_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "entity_tag_tag_id_idx": { - "name": "entity_tag_tag_id_idx", - "columns": ["tag_id"], - "isUnique": false - } - }, - "foreignKeys": { - "entity_tag_tag_id_tag_id_fk": { - "name": "entity_tag_tag_id_tag_id_fk", - "tableFrom": "entity_tag", - "tableTo": "tag", - "columnsFrom": ["tag_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entity_tag_entity_type_entity_id_tag_id_pk": { - "columns": ["entity_type", "entity_id", "tag_id"], - "name": "entity_tag_entity_type_entity_id_tag_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "group": { - "name": "group", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "group_entity_sort_idx": { - "name": "group_entity_sort_idx", - "columns": ["entity_type", "sort_order"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "message": { - "name": "message", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "topic_id": { - "name": "topic_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "response_group_id": { - "name": "response_group_id", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_id": { - "name": "model_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_meta": { - "name": "model_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "stats": { - "name": "stats", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trace_id": { - "name": "trace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "searchable_text": { - "name": "searchable_text", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "message_parent_id_idx": { - "name": "message_parent_id_idx", - "columns": ["parent_id"], - "isUnique": false - }, - "message_topic_created_idx": { - "name": "message_topic_created_idx", - "columns": ["topic_id", "created_at"], - "isUnique": false - }, - "message_trace_id_idx": { - "name": "message_trace_id_idx", - "columns": ["trace_id"], - "isUnique": false - } - }, - "foreignKeys": { - "message_topic_id_topic_id_fk": { - "name": "message_topic_id_topic_id_fk", - "tableFrom": "message", - "tableTo": "topic", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_parent_id_message_id_fk": { - "name": "message_parent_id_message_id_fk", - "tableFrom": "message", - "tableTo": "message", - "columnsFrom": ["parent_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "message_role_check": { - "name": "message_role_check", - "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" - }, - "message_status_check": { - "name": "message_status_check", - "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" - } - } - }, - "preference": { - "name": "preference", - "columns": { - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'default'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "preference_scope_key_pk": { - "columns": ["scope", "key"], - "name": "preference_scope_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tag": { - "name": "tag", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "tag_name_unique": { - "name": "tag_name_unique", - "columns": ["name"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "topic": { - "name": "topic", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prompt": { - "name": "prompt", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_pinned": { - "name": "is_pinned", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "pinned_order": { - "name": "pinned_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "is_name_manually_edited": { - "name": "is_name_manually_edited", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "topic_group_updated_idx": { - "name": "topic_group_updated_idx", - "columns": ["group_id", "updated_at"], - "isUnique": false - }, - "topic_group_sort_idx": { - "name": "topic_group_sort_idx", - "columns": ["group_id", "sort_order"], - "isUnique": false - }, - "topic_updated_at_idx": { - "name": "topic_updated_at_idx", - "columns": ["updated_at"], - "isUnique": false - }, - "topic_is_pinned_idx": { - "name": "topic_is_pinned_idx", - "columns": ["is_pinned", "pinned_order"], - "isUnique": false - }, - "topic_assistant_id_idx": { - "name": "topic_assistant_id_idx", - "columns": ["assistant_id"], - "isUnique": false - } - }, - "foreignKeys": { - "topic_group_id_group_id_fk": { - "name": "topic_group_id_group_id_fk", - "tableFrom": "topic", - "tableTo": "group", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/migrations/sqlite-drizzle/meta/0002_snapshot.json b/migrations/sqlite-drizzle/meta/0002_snapshot.json deleted file mode 100644 index 2a8330fec1..0000000000 --- a/migrations/sqlite-drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,623 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "b4613090-1bbb-4986-a27b-f58b638f540b", - "prevId": "ae53858a-1786-4059-9ff7-9e87267911b6", - "tables": { - "app_state": { - "name": "app_state", - "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "entity_tag": { - "name": "entity_tag", - "columns": { - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "entity_id": { - "name": "entity_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "tag_id": { - "name": "tag_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "entity_tag_tag_id_idx": { - "name": "entity_tag_tag_id_idx", - "columns": ["tag_id"], - "isUnique": false - } - }, - "foreignKeys": { - "entity_tag_tag_id_tag_id_fk": { - "name": "entity_tag_tag_id_tag_id_fk", - "tableFrom": "entity_tag", - "tableTo": "tag", - "columnsFrom": ["tag_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "entity_tag_entity_type_entity_id_tag_id_pk": { - "columns": ["entity_type", "entity_id", "tag_id"], - "name": "entity_tag_entity_type_entity_id_tag_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "group": { - "name": "group", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "entity_type": { - "name": "entity_type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "group_entity_sort_idx": { - "name": "group_entity_sort_idx", - "columns": ["entity_type", "sort_order"], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "message": { - "name": "message", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "topic_id": { - "name": "topic_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "searchable_text": { - "name": "searchable_text", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "siblings_group_id": { - "name": "siblings_group_id", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_id": { - "name": "model_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_meta": { - "name": "model_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trace_id": { - "name": "trace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "stats": { - "name": "stats", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "message_parent_id_idx": { - "name": "message_parent_id_idx", - "columns": ["parent_id"], - "isUnique": false - }, - "message_topic_created_idx": { - "name": "message_topic_created_idx", - "columns": ["topic_id", "created_at"], - "isUnique": false - }, - "message_trace_id_idx": { - "name": "message_trace_id_idx", - "columns": ["trace_id"], - "isUnique": false - } - }, - "foreignKeys": { - "message_parent_id_message_id_fk": { - "name": "message_parent_id_message_id_fk", - "tableFrom": "message", - "tableTo": "message", - "columnsFrom": ["parent_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "message_topic_id_topic_id_fk": { - "name": "message_topic_id_topic_id_fk", - "tableFrom": "message", - "tableTo": "topic", - "columnsFrom": ["topic_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": { - "message_role_check": { - "name": "message_role_check", - "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" - }, - "message_status_check": { - "name": "message_status_check", - "value": "\"message\".\"status\" IN ('success', 'error', 'paused')" - } - } - }, - "preference": { - "name": "preference", - "columns": { - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'default'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "preference_scope_key_pk": { - "columns": ["scope", "key"], - "name": "preference_scope_key_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "tag": { - "name": "tag", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "tag_name_unique": { - "name": "tag_name_unique", - "columns": ["name"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "topic": { - "name": "topic", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_name_manually_edited": { - "name": "is_name_manually_edited", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "prompt": { - "name": "prompt", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_node_id": { - "name": "active_node_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "is_pinned": { - "name": "is_pinned", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "pinned_order": { - "name": "pinned_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "topic_group_updated_idx": { - "name": "topic_group_updated_idx", - "columns": ["group_id", "updated_at"], - "isUnique": false - }, - "topic_group_sort_idx": { - "name": "topic_group_sort_idx", - "columns": ["group_id", "sort_order"], - "isUnique": false - }, - "topic_updated_at_idx": { - "name": "topic_updated_at_idx", - "columns": ["updated_at"], - "isUnique": false - }, - "topic_is_pinned_idx": { - "name": "topic_is_pinned_idx", - "columns": ["is_pinned", "pinned_order"], - "isUnique": false - }, - "topic_assistant_id_idx": { - "name": "topic_assistant_id_idx", - "columns": ["assistant_id"], - "isUnique": false - } - }, - "foreignKeys": { - "topic_active_node_id_message_id_fk": { - "name": "topic_active_node_id_message_id_fk", - "tableFrom": "topic", - "tableTo": "message", - "columnsFrom": ["active_node_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - }, - "topic_group_id_group_id_fk": { - "name": "topic_group_id_group_id_fk", - "tableFrom": "topic", - "tableTo": "group", - "columnsFrom": ["group_id"], - "columnsTo": ["id"], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": { - "\"message\".\"response_group_id\"": "\"message\".\"siblings_group_id\"" - } - }, - "internal": { - "indexes": {} - } -} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index c3aa7c7c47..cc3bfebb01 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -1,27 +1,13 @@ { - "version": "7", "dialect": "sqlite", "entries": [ { + "breakpoints": true, "idx": 0, - "version": "6", - "when": 1766588456958, "tag": "0000_init", - "breakpoints": true - }, - { - "idx": 1, "version": "6", - "when": 1766670360754, - "tag": "0001_faulty_ogun", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1766748070409, - "tag": "0002_noisy_zzzax", - "breakpoints": true + "when": 1767272575118 } - ] + ], + "version": "7" } From 4f4785396add134bb8ab59512ab1c2b4bd3bddb7 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 1 Jan 2026 23:13:43 +0800 Subject: [PATCH 072/116] feat(migration): enhance ChatMigrator for comprehensive chat data migration - Implemented detailed preparation, execution, and validation phases for migrating chat topics and messages from Dexie to SQLite. - Added robust logging and error handling to track migration progress and issues. - Introduced data transformation strategies to convert old message structures into a new tree format, ensuring data integrity and consistency. - Updated migration guide documentation to reflect changes in migrator registration and detailed comments for maintainability. --- docs/en/references/data/v2-migration-guide.md | 12 +- .../data/migration/v2/core/MigrationEngine.ts | 18 +- .../migration/v2/migrators/ChatMigrator.ts | 648 ++++++++- .../v2/migrators/README-ChatMigrator.md | 138 ++ .../v2/migrators/mappings/ChatMappings.ts | 1168 +++++++++++++++++ 5 files changed, 1921 insertions(+), 63 deletions(-) create mode 100644 src/main/data/migration/v2/migrators/README-ChatMigrator.md create mode 100644 src/main/data/migration/v2/migrators/mappings/ChatMappings.ts diff --git a/docs/en/references/data/v2-migration-guide.md b/docs/en/references/data/v2-migration-guide.md index 86d597223e..8d08dd8d3a 100644 --- a/docs/en/references/data/v2-migration-guide.md +++ b/docs/en/references/data/v2-migration-guide.md @@ -31,9 +31,10 @@ src/main/data/migration/v2/ - `execute(ctx)`: perform inserts/updates; manage your own transactions; report progress via `reportProgress` - `validate(ctx)`: verify counts and integrity; return `ValidateResult` with stats (`sourceCount`, `targetCount`, `skippedCount`) and any `errors` - Registration: list migrators (in order) in `migrators/index.ts` so the engine can sort and run them. -- Current migrators: +- Current migrators (see `migrators/README-.md` for detailed documentation): - `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`. - - `AssistantMigrator`, `KnowledgeMigrator`, `ChatMigrator` (placeholders): scaffolding and TODO notes for future tables. + - `ChatMigrator` (implemented): migrates topics and messages from Dexie to SQLite. See [`README-ChatMigrator.md`](../../../src/main/data/migration/v2/migrators/README-ChatMigrator.md). + - `AssistantMigrator`, `KnowledgeMigrator` (placeholders): scaffolding and TODO notes for future tables. - Conventions: - All logging goes through `loggerService` with a migrator-specific context. - Use `MigrationContext.sources` instead of accessing raw files/stores directly. @@ -62,3 +63,10 @@ src/main/data/migration/v2/ - [ ] Wire progress updates through `reportProgress` so UI shows per-migrator progress. - [ ] Register the migrator in `migrators/index.ts` with the correct `order`. - [ ] Add any new target tables to `MigrationEngine.verifyAndClearNewTables` once those tables exist. +- [ ] Include detailed comments for maintainability (file-level, function-level, logic blocks). +- [ ] **Create/update `migrators/README-.md`** with detailed documentation including: + - Data sources and target tables + - Key transformations + - Field mappings (source → target) + - Dropped fields and rationale + - Code quality notes diff --git a/src/main/data/migration/v2/core/MigrationEngine.ts b/src/main/data/migration/v2/core/MigrationEngine.ts index 1b004d38e7..77bc4afd92 100644 --- a/src/main/data/migration/v2/core/MigrationEngine.ts +++ b/src/main/data/migration/v2/core/MigrationEngine.ts @@ -5,7 +5,9 @@ import { dbService } from '@data/db/DbService' import { appStateTable } from '@data/db/schemas/appState' +import { messageTable } from '@data/db/schemas/message' import { preferenceTable } from '@data/db/schemas/preference' +import { topicTable } from '@data/db/schemas/topic' import { loggerService } from '@logger' import type { MigrationProgress, @@ -24,8 +26,6 @@ import { createMigrationContext } from './MigrationContext' // TODO: Import these tables when they are created in user data schema // import { assistantTable } from '../../db/schemas/assistant' -// import { topicTable } from '../../db/schemas/topic' -// import { messageTable } from '../../db/schemas/message' // import { fileTable } from '../../db/schemas/file' // import { knowledgeBaseTable } from '../../db/schemas/knowledgeBase' @@ -197,12 +197,13 @@ export class MigrationEngine { const db = dbService.getDb() // Tables to clear - add more as they are created + // Order matters: child tables must be cleared before parent tables const tables = [ + { table: messageTable, name: 'message' }, // Must clear before topic (FK reference) + { table: topicTable, name: 'topic' }, { table: preferenceTable, name: 'preference' } // TODO: Add these when tables are created // { table: assistantTable, name: 'assistant' }, - // { table: topicTable, name: 'topic' }, - // { table: messageTable, name: 'message' }, // { table: fileTable, name: 'file' }, // { table: knowledgeBaseTable, name: 'knowledge_base' } ] @@ -216,14 +217,15 @@ export class MigrationEngine { } } - // Clear tables in reverse dependency order + // Clear tables in dependency order (children before parents) + // Messages reference topics, so delete messages first + await db.delete(messageTable) + await db.delete(topicTable) + await db.delete(preferenceTable) // TODO: Add these when tables are created (in correct order) - // await db.delete(messageTable) - // await db.delete(topicTable) // await db.delete(fileTable) // await db.delete(knowledgeBaseTable) // await db.delete(assistantTable) - await db.delete(preferenceTable) logger.info('All new architecture tables cleared successfully') } diff --git a/src/main/data/migration/v2/migrators/ChatMigrator.ts b/src/main/data/migration/v2/migrators/ChatMigrator.ts index 5a9b845a00..7e622739dd 100644 --- a/src/main/data/migration/v2/migrators/ChatMigrator.ts +++ b/src/main/data/migration/v2/migrators/ChatMigrator.ts @@ -1,81 +1,623 @@ /** - * Chat migrator - migrates topics and messages from Dexie to SQLite + * Chat Migrator - Migrates topics and messages from Dexie to SQLite * - * TODO: Implement when chat tables are created - * Data source: Dexie topics table (messages are embedded in topics) - * Target tables: topic, message + * ## Overview * - * Note: This migrator handles the largest amount of data (potentially millions of messages) - * and uses streaming JSON reading with batch inserts for memory efficiency. + * This migrator handles the largest data migration task: transferring all chat topics + * and their messages from the old Dexie/IndexedDB storage to the new SQLite database. + * + * ## Data Sources + * + * | Data | Source | File/Path | + * |------|--------|-----------| + * | Topics with messages | Dexie `topics` table | `topics.json` → `{ id, messages[] }` | + * | Message blocks | Dexie `message_blocks` table | `message_blocks.json` | + * | Assistants (for meta) | Redux `assistants` slice | `ReduxStateReader.getCategory('assistants')` | + * + * ## Target Tables + * + * - `topicTable` - Stores conversation topics/threads + * - `messageTable` - Stores chat messages with tree structure + * + * ## Key Transformations + * + * 1. **Linear → Tree Structure** + * - Old: Messages stored as linear array in `topic.messages[]` + * - New: Tree via `parentId` + `siblingsGroupId` + * + * 2. **Multi-model Responses** + * - Old: `askId` links responses to user message, `foldSelected` marks active + * - New: Shared `parentId` + non-zero `siblingsGroupId` groups siblings + * + * 3. **Block Inlining** + * - Old: `message.blocks: string[]` (IDs) + separate `message_blocks` table + * - New: `message.data.blocks: MessageDataBlock[]` (inline JSON) + * + * 4. **Citation Migration** + * - Old: Separate `CitationMessageBlock` + * - New: Merged into `MainTextBlock.references` as ContentReference[] + * + * 5. **Mention Migration** + * - Old: `message.mentions: Model[]` + * - New: `MentionReference[]` in `MainTextBlock.references` + * + * ## Performance Considerations + * + * - Uses streaming JSON reader for large data sets (potentially millions of messages) + * - Processes topics in batches to control memory usage + * - Pre-loads all blocks into memory map for O(1) lookup (blocks table is smaller) + * - Uses database transactions for atomicity and performance + * + * @since v2.0.0 */ +import { messageTable } from '@data/db/schemas/message' +import { topicTable } from '@data/db/schemas/topic' import { loggerService } from '@logger' -import type { ExecuteResult, PrepareResult, ValidateResult } from '@shared/data/migration/v2/types' +import type { ExecuteResult, PrepareResult, ValidateResult, ValidationError } from '@shared/data/migration/v2/types' +import { eq, sql } from 'drizzle-orm' +import { v4 as uuidv4 } from 'uuid' +import type { MigrationContext } from '../core/MigrationContext' import { BaseMigrator } from './BaseMigrator' +import { + buildBlockLookup, + buildMessageTree, + type NewMessage, + type NewTopic, + type OldAssistant, + type OldBlock, + type OldTopic, + type OldTopicMeta, + resolveBlocks, + transformMessage, + transformTopic +} from './mappings/ChatMappings' const logger = loggerService.withContext('ChatMigrator') +/** + * Batch size for processing topics + * Chosen to balance memory usage and transaction overhead + */ +const TOPIC_BATCH_SIZE = 50 + +/** + * Batch size for inserting messages + * SQLite has limits on the number of parameters per statement + */ +const MESSAGE_INSERT_BATCH_SIZE = 100 + +/** + * Assistant data from Redux for generating AssistantMeta + */ +interface AssistantState { + assistants: OldAssistant[] +} + +/** + * Prepared data for execution phase + */ +interface PreparedTopicData { + topic: NewTopic + messages: NewMessage[] +} + export class ChatMigrator extends BaseMigrator { readonly id = 'chat' readonly name = 'ChatData' - readonly description = 'Migrate chat data' + readonly description = 'Migrate chat topics and messages' readonly order = 4 - async prepare(): Promise { - logger.info('ChatMigrator.prepare - placeholder implementation') + // Prepared data for execution + private topicCount = 0 + private messageCount = 0 + private blockLookup: Map = new Map() + private assistantLookup: Map = new Map() + // Topic metadata from Redux (name, pinned, etc.) - Dexie only has messages + private topicMetaLookup: Map = new Map() + // Topic → AssistantId mapping from Redux (Dexie topics don't store assistantId) + private topicAssistantLookup: Map = new Map() + private skippedTopics = 0 + private skippedMessages = 0 + // Track seen message IDs to handle duplicates across topics + private seenMessageIds = new Set() + // Block statistics for diagnostics + private blockStats = { requested: 0, resolved: 0, messagesWithMissingBlocks: 0, messagesWithEmptyBlocks: 0 } - // TODO: Implement when chat tables are created - // 1. Check if topics.json export file exists - // 2. Validate JSON format with sample read - // 3. Count total topics and estimate message count - // 4. Check for data integrity (e.g., messages have valid topic references) + /** + * Prepare phase - validate source data and count items + * + * Steps: + * 1. Check if topics.json and message_blocks.json exist + * 2. Load all blocks into memory for fast lookup + * 3. Load assistant data for generating meta + * 4. Count topics and estimate message count + * 5. Validate sample data for integrity + */ + async prepare(ctx: MigrationContext): Promise { + const warnings: string[] = [] - return { - success: true, - itemCount: 0, - warnings: ['ChatMigrator not yet implemented - waiting for chat tables'] - } - } + try { + // Step 1: Verify export files exist + const topicsExist = await ctx.sources.dexieExport.tableExists('topics') + if (!topicsExist) { + logger.warn('topics.json not found, skipping chat migration') + return { + success: true, + itemCount: 0, + warnings: ['topics.json not found - no chat data to migrate'] + } + } - async execute(): Promise { - logger.info('ChatMigrator.execute - placeholder implementation') + const blocksExist = await ctx.sources.dexieExport.tableExists('message_blocks') + if (!blocksExist) { + warnings.push('message_blocks.json not found - messages will have empty blocks') + } - // TODO: Implement when chat tables are created - // Use streaming JSON reader for large message files: - // - // const streamReader = _ctx.sources.dexieExport.createStreamReader('topics') - // await streamReader.readInBatches( - // BATCH_SIZE, - // async (topics, batchIndex) => { - // // 1. Insert topics - // // 2. Extract and insert messages from each topic - // // 3. Report progress - // } - // ) + // Step 2: Load all blocks into lookup map + // Blocks table is typically smaller than messages, safe to load entirely + if (blocksExist) { + logger.info('Loading message blocks into memory...') + const blocks = await ctx.sources.dexieExport.readTable('message_blocks') + this.blockLookup = buildBlockLookup(blocks) + logger.info(`Loaded ${this.blockLookup.size} blocks into lookup map`) + } - return { - success: true, - processedCount: 0 - } - } + // Step 3: Load assistant data for generating AssistantMeta + // Also extract topic metadata from assistants (Redux stores topic metadata in assistants.topics[]) + const assistantState = ctx.sources.reduxState.getCategory('assistants') + if (assistantState?.assistants) { + for (const assistant of assistantState.assistants) { + this.assistantLookup.set(assistant.id, assistant) - async validate(): Promise { - logger.info('ChatMigrator.validate - placeholder implementation') + // Extract topic metadata from this assistant's topics array + // Redux stores topic metadata (name, pinned, etc.) but with messages: [] + // Also track topic → assistantId mapping (Dexie doesn't store assistantId) + if (assistant.topics && Array.isArray(assistant.topics)) { + for (const topic of assistant.topics) { + if (topic.id) { + this.topicMetaLookup.set(topic.id, topic) + this.topicAssistantLookup.set(topic.id, assistant.id) + } + } + } + } + logger.info( + `Loaded ${this.assistantLookup.size} assistants and ${this.topicMetaLookup.size} topic metadata entries` + ) + } else { + warnings.push('No assistant data found - topics will have null assistantMeta and missing names') + } - // TODO: Implement when chat tables are created - // 1. Count validation for topics and messages - // 2. Sample validation (check a few topics have correct message counts) - // 3. Reference integrity validation + // Step 4: Count topics and estimate messages + const topicReader = ctx.sources.dexieExport.createStreamReader('topics') + this.topicCount = await topicReader.count() + logger.info(`Found ${this.topicCount} topics to migrate`) - return { - success: true, - errors: [], - stats: { - sourceCount: 0, - targetCount: 0, - skippedCount: 0 + // Estimate message count from sample + if (this.topicCount > 0) { + const sampleTopics = await topicReader.readSample(10) + const avgMessagesPerTopic = + sampleTopics.reduce((sum, t) => sum + (t.messages?.length || 0), 0) / sampleTopics.length + this.messageCount = Math.round(this.topicCount * avgMessagesPerTopic) + logger.info(`Estimated ${this.messageCount} messages based on sample`) + } + + // Step 5: Validate sample data + if (this.topicCount > 0) { + const sampleTopics = await topicReader.readSample(5) + for (const topic of sampleTopics) { + if (!topic.id) { + warnings.push(`Found topic without id - will be skipped`) + } + if (!topic.messages || !Array.isArray(topic.messages)) { + warnings.push(`Topic ${topic.id} has invalid messages array`) + } + } + } + + logger.info('Prepare phase completed', { + topics: this.topicCount, + estimatedMessages: this.messageCount, + blocks: this.blockLookup.size, + assistants: this.assistantLookup.size + }) + + return { + success: true, + itemCount: this.topicCount, + warnings: warnings.length > 0 ? warnings : undefined + } + } catch (error) { + logger.error('Prepare failed', error as Error) + return { + success: false, + itemCount: 0, + warnings: [error instanceof Error ? error.message : String(error)] } } } + + /** + * Execute phase - perform the actual data migration + * + * Processing strategy: + * 1. Stream topics in batches to control memory + * 2. For each topic batch: + * a. Transform topics and their messages + * b. Build message tree structure + * c. Insert topics in single transaction + * d. Insert messages in batched transactions + * 3. Report progress throughout + */ + async execute(ctx: MigrationContext): Promise { + if (this.topicCount === 0) { + logger.info('No topics to migrate') + return { success: true, processedCount: 0 } + } + + let processedTopics = 0 + let processedMessages = 0 + + try { + const db = ctx.db + const topicReader = ctx.sources.dexieExport.createStreamReader('topics') + + // Process topics in batches + await topicReader.readInBatches(TOPIC_BATCH_SIZE, async (topics, batchIndex) => { + logger.debug(`Processing topic batch ${batchIndex + 1}`, { count: topics.length }) + + // Transform all topics and messages in this batch + const preparedData: PreparedTopicData[] = [] + + for (const oldTopic of topics) { + try { + const prepared = this.prepareTopicData(oldTopic) + if (prepared) { + preparedData.push(prepared) + } else { + this.skippedTopics++ + } + } catch (error) { + logger.warn(`Failed to transform topic ${oldTopic.id}`, { error }) + this.skippedTopics++ + } + } + + // Insert topics in a transaction + if (preparedData.length > 0) { + await db.transaction(async (tx) => { + // Insert topics + const topicValues = preparedData.map((d) => d.topic) + await tx.insert(topicTable).values(topicValues) + + // Collect all messages, handling duplicate IDs by generating new ones + const allMessages: NewMessage[] = [] + for (const data of preparedData) { + for (const msg of data.messages) { + if (this.seenMessageIds.has(msg.id)) { + const newId = uuidv4() + logger.warn(`Duplicate message ID found: ${msg.id}, assigning new ID: ${newId}`) + msg.id = newId + } + this.seenMessageIds.add(msg.id) + allMessages.push(msg) + } + } + + // Insert messages in batches (SQLite parameter limit) + for (let i = 0; i < allMessages.length; i += MESSAGE_INSERT_BATCH_SIZE) { + const batch = allMessages.slice(i, i + MESSAGE_INSERT_BATCH_SIZE) + await tx.insert(messageTable).values(batch) + } + + processedMessages += allMessages.length + }) + + processedTopics += preparedData.length + } + + // Report progress + const progress = Math.round((processedTopics / this.topicCount) * 100) + this.reportProgress( + progress, + `已迁移 ${processedTopics}/${this.topicCount} 个对话,${processedMessages} 条消息` + ) + }) + + logger.info('Execute completed', { + processedTopics, + processedMessages, + skippedTopics: this.skippedTopics, + skippedMessages: this.skippedMessages + }) + + // Log block statistics for diagnostics + logger.info('Block migration statistics', { + blocksRequested: this.blockStats.requested, + blocksResolved: this.blockStats.resolved, + blocksMissing: this.blockStats.requested - this.blockStats.resolved, + messagesWithEmptyBlocks: this.blockStats.messagesWithEmptyBlocks, + messagesWithMissingBlocks: this.blockStats.messagesWithMissingBlocks + }) + + return { + success: true, + processedCount: processedTopics + } + } catch (error) { + logger.error('Execute failed', error as Error) + return { + success: false, + processedCount: processedTopics, + error: error instanceof Error ? error.message : String(error) + } + } + } + + /** + * Validate phase - verify migrated data integrity + * + * Validation checks: + * 1. Topic count matches source (minus skipped) + * 2. Message count is within expected range + * 3. Sample topics have correct structure + * 4. Foreign key integrity (messages belong to existing topics) + */ + async validate(ctx: MigrationContext): Promise { + const errors: ValidationError[] = [] + const db = ctx.db + + try { + // Count topics in target + const topicResult = await db.select({ count: sql`count(*)` }).from(topicTable).get() + const targetTopicCount = topicResult?.count ?? 0 + + // Count messages in target + const messageResult = await db.select({ count: sql`count(*)` }).from(messageTable).get() + const targetMessageCount = messageResult?.count ?? 0 + + logger.info('Validation counts', { + sourceTopics: this.topicCount, + targetTopics: targetTopicCount, + skippedTopics: this.skippedTopics, + targetMessages: targetMessageCount + }) + + // Validate topic count + const expectedTopics = this.topicCount - this.skippedTopics + if (targetTopicCount < expectedTopics) { + errors.push({ + key: 'topic_count', + message: `Topic count mismatch: expected ${expectedTopics}, got ${targetTopicCount}` + }) + } + + // Sample validation: check a few topics have messages + const sampleTopics = await db.select().from(topicTable).limit(5).all() + for (const topic of sampleTopics) { + const msgCount = await db + .select({ count: sql`count(*)` }) + .from(messageTable) + .where(eq(messageTable.topicId, topic.id)) + .get() + + if (msgCount?.count === 0) { + // This is a warning, not an error - some topics may legitimately have no messages + logger.warn(`Topic ${topic.id} has no messages after migration`) + } + } + + // Check for orphan messages (messages without valid topic) + // This shouldn't happen due to foreign key constraints, but verify anyway + const orphanCheck = await db + .select({ count: sql`count(*)` }) + .from(messageTable) + .where(sql`${messageTable.topicId} NOT IN (SELECT id FROM ${topicTable})`) + .get() + + if (orphanCheck && orphanCheck.count > 0) { + errors.push({ + key: 'orphan_messages', + message: `Found ${orphanCheck.count} orphan messages without valid topics` + }) + } + + return { + success: errors.length === 0, + errors, + stats: { + sourceCount: this.topicCount, + targetCount: targetTopicCount, + skippedCount: this.skippedTopics + } + } + } catch (error) { + logger.error('Validation failed', error as Error) + return { + success: false, + errors: [ + { + key: 'validation', + message: error instanceof Error ? error.message : String(error) + } + ], + stats: { + sourceCount: this.topicCount, + targetCount: 0, + skippedCount: this.skippedTopics + } + } + } + } + + /** + * Prepare a single topic and its messages for migration + * + * @param oldTopic - Source topic from Dexie (has messages, may lack metadata) + * @returns Prepared data or null if topic should be skipped + * + * ## Data Merging + * + * Topic data comes from two sources: + * - Dexie `topics` table: Has `id`, `messages[]`, `assistantId` + * - Redux `assistants[].topics[]`: Has metadata (`name`, `pinned`, `prompt`, etc.) + * + * We merge Redux metadata into the Dexie topic before transformation. + */ + private prepareTopicData(oldTopic: OldTopic): PreparedTopicData | null { + // Validate required fields + if (!oldTopic.id) { + logger.warn('Topic missing id, skipping') + return null + } + + // Merge topic metadata from Redux (name, pinned, etc.) + // Dexie topics may have stale or missing metadata; Redux is authoritative for these fields + const topicMeta = this.topicMetaLookup.get(oldTopic.id) + if (topicMeta) { + // Merge Redux metadata into Dexie topic + // Note: Redux topic.name can also be empty from ancient version migrations (see store/migrate.ts:303-305) + oldTopic.name = topicMeta.name || oldTopic.name + oldTopic.pinned = topicMeta.pinned ?? oldTopic.pinned + oldTopic.prompt = topicMeta.prompt ?? oldTopic.prompt + oldTopic.isNameManuallyEdited = topicMeta.isNameManuallyEdited ?? oldTopic.isNameManuallyEdited + // Use Redux timestamps if available and Dexie lacks them + if (topicMeta.createdAt && !oldTopic.createdAt) { + oldTopic.createdAt = topicMeta.createdAt + } + if (topicMeta.updatedAt && !oldTopic.updatedAt) { + oldTopic.updatedAt = topicMeta.updatedAt + } + } + + // Fallback: If name is still empty after merge, use a default name + // This handles cases where both Dexie and Redux have empty names (ancient version bug) + if (!oldTopic.name) { + oldTopic.name = 'Unnamed Topic' // Default fallback for topics with no name + } + + // Get assistantId from Redux mapping (Dexie topics don't store assistantId) + // Fall back to oldTopic.assistantId in case Dexie did store it (defensive) + const assistantId = this.topicAssistantLookup.get(oldTopic.id) || oldTopic.assistantId + if (assistantId && !oldTopic.assistantId) { + oldTopic.assistantId = assistantId + } + + // Get assistant for meta generation + const assistant = this.assistantLookup.get(assistantId) || null + + // Get messages array (may be empty or undefined) + const oldMessages = oldTopic.messages || [] + + // Build message tree structure + const messageTree = buildMessageTree(oldMessages) + + // === First pass: identify messages to skip (no blocks) === + const skippedMessageIds = new Set() + const messageParentMap = new Map() // messageId -> parentId + + for (const oldMsg of oldMessages) { + const blockIds = oldMsg.blocks || [] + const blocks = resolveBlocks(blockIds, this.blockLookup) + + // Track block statistics for diagnostics + this.blockStats.requested += blockIds.length + this.blockStats.resolved += blocks.length + if (blockIds.length === 0) { + this.blockStats.messagesWithEmptyBlocks++ + } else if (blocks.length < blockIds.length) { + this.blockStats.messagesWithMissingBlocks++ + if (blocks.length === 0) { + logger.warn(`Message ${oldMsg.id} has ${blockIds.length} block IDs but none found in message_blocks`) + } + } + + // Store parent info from tree + const treeInfo = messageTree.get(oldMsg.id) + messageParentMap.set(oldMsg.id, treeInfo?.parentId ?? null) + + // Mark for skipping if no blocks + if (blocks.length === 0) { + skippedMessageIds.add(oldMsg.id) + this.skippedMessages++ + } + } + + // === Helper: resolve parent through skipped messages === + // If parentId points to a skipped message, follow the chain to find a non-skipped ancestor + const resolveParentId = (parentId: string | null): string | null => { + let currentParent = parentId + const visited = new Set() // Prevent infinite loops + + while (currentParent && skippedMessageIds.has(currentParent)) { + if (visited.has(currentParent)) { + // Circular reference, break out + return null + } + visited.add(currentParent) + currentParent = messageParentMap.get(currentParent) ?? null + } + + return currentParent + } + + // === Second pass: transform messages that have blocks === + const newMessages: NewMessage[] = [] + for (const oldMsg of oldMessages) { + // Skip messages marked for skipping + if (skippedMessageIds.has(oldMsg.id)) { + continue + } + + try { + const treeInfo = messageTree.get(oldMsg.id) + if (!treeInfo) { + logger.warn(`Message ${oldMsg.id} not found in tree, using defaults`) + continue + } + + // Resolve blocks for this message (we know it has blocks from first pass) + const blockIds = oldMsg.blocks || [] + const blocks = resolveBlocks(blockIds, this.blockLookup) + + // Resolve parentId through any skipped messages + const resolvedParentId = resolveParentId(treeInfo.parentId) + + // Get assistant for this message (may differ from topic's assistant) + const msgAssistant = this.assistantLookup.get(oldMsg.assistantId) || assistant + + const newMsg = transformMessage( + oldMsg, + resolvedParentId, // Use resolved parent instead of original + treeInfo.siblingsGroupId, + blocks, + msgAssistant, + oldTopic.id + ) + + newMessages.push(newMsg) + } catch (error) { + logger.warn(`Failed to transform message ${oldMsg.id}`, { error }) + this.skippedMessages++ + } + } + + // Calculate activeNodeId based on migrated messages (not original messages) + // If no messages were migrated, set to null + let activeNodeId: string | null = null + if (newMessages.length > 0) { + // Use the last migrated message as active node + activeNodeId = newMessages[newMessages.length - 1].id + } + + // Transform topic with correct activeNodeId + const newTopic = transformTopic(oldTopic, assistant, activeNodeId) + + return { + topic: newTopic, + messages: newMessages + } + } } diff --git a/src/main/data/migration/v2/migrators/README-ChatMigrator.md b/src/main/data/migration/v2/migrators/README-ChatMigrator.md new file mode 100644 index 0000000000..a3a0640ccd --- /dev/null +++ b/src/main/data/migration/v2/migrators/README-ChatMigrator.md @@ -0,0 +1,138 @@ +# ChatMigrator + +The `ChatMigrator` handles the largest data migration task: topics and messages from Dexie/IndexedDB to SQLite. + +## Data Sources + +| Data | Source | File/Path | +|------|--------|-----------| +| Topics with messages | Dexie `topics` table | `topics.json` | +| Topic metadata (name, pinned, etc.) | Redux `assistants[].topics[]` | `ReduxStateReader.getCategory('assistants')` | +| Message blocks | Dexie `message_blocks` table | `message_blocks.json` | +| Assistants (for meta) | Redux `assistants` slice | `ReduxStateReader.getCategory('assistants')` | + +### Topic Data Split (Important!) + +The old system stores topic data in **two separate locations**: + +1. **Dexie `topics` table**: Contains only `id` and `messages[]` array (NO `assistantId`!) +2. **Redux `assistants[].topics[]`**: Contains metadata (`name`, `pinned`, `prompt`, `isNameManuallyEdited`) and implicitly the `assistantId` (from parent assistant) + +Redux deliberately clears `messages[]` to reduce storage size. The migrator merges these sources: +- Messages come from Dexie +- Metadata (name, pinned, etc.) comes from Redux +- `assistantId` comes from Redux structure (each assistant owns its topics) + +## Key Transformations + +1. **Linear → Tree Structure** + - Old: Messages stored as linear array in `topic.messages[]` + - New: Tree via `parentId` + `siblingsGroupId` + +2. **Multi-model Responses** + - Old: `askId` links responses to user message, `foldSelected` marks active + - New: Shared `parentId` + non-zero `siblingsGroupId` groups siblings + +3. **Block Inlining** + - Old: `message.blocks: string[]` (IDs) + separate `message_blocks` table + - New: `message.data.blocks: MessageDataBlock[]` (inline JSON) + +4. **Citation Migration** + - Old: Separate `CitationMessageBlock` with `response`, `knowledge`, `memories` + - New: Merged into `MainTextBlock.references` as `ContentReference[]` + +5. **Mention Migration** + - Old: `message.mentions: Model[]` + - New: `MentionReference[]` in `MainTextBlock.references` + +## Data Quality Handling + +The migrator handles potential data inconsistencies from the old system: + +| Issue | Detection | Handling | +|-------|-----------|----------| +| **Duplicate message ID** | Same ID appears in multiple topics | Generate new UUID, log warning | +| **TopicId mismatch** | `message.topicId` ≠ parent `topic.id` | Use correct parent topic.id (silent fix) | +| **Missing blocks** | Block ID not found in `message_blocks` | Skip missing block (silent) | +| **Invalid topic** | Topic missing required `id` field | Skip entire topic | +| **Missing topic metadata** | Topic not found in Redux `assistants[].topics[]` | Use Dexie values, fallback name if empty | +| **Missing assistantId** | Topic not in any `assistant.topics[]` | `assistantId` and `assistantMeta` will be null | +| **Empty topic name** | Both Dexie and Redux have empty `name` (ancient bug) | Use fallback "Unnamed Topic" | +| **Message with no blocks** | `blocks` array is empty after resolution | Skip message, re-link children to parent's parent | +| **Topic with no messages** | All messages skipped (no blocks) | Keep topic, set `activeNodeId` to null | + +## Field Mappings + +### Topic Mapping + +Topic data is merged from Dexie + Redux before transformation: + +| Source | Target (topicTable) | Notes | +|--------|---------------------|-------| +| Dexie: `id` | `id` | Direct copy | +| Redux: `name` | `name` | Merged from Redux `assistants[].topics[]` | +| Redux: `isNameManuallyEdited` | `isNameManuallyEdited` | Merged from Redux | +| Redux: (parent assistant.id) | `assistantId` | From `topicAssistantLookup` mapping | +| (from Assistant) | `assistantMeta` | Generated from assistant entity | +| Redux: `prompt` | `prompt` | Merged from Redux | +| (computed) | `activeNodeId` | Last message ID or foldSelected | +| (none) | `groupId` | null (new field) | +| (none) | `sortOrder` | 0 (new field) | +| Redux: `pinned` | `isPinned` | Merged from Redux, renamed | +| (none) | `pinnedOrder` | 0 (new field) | +| `createdAt` | `createdAt` | ISO string → timestamp | +| `updatedAt` | `updatedAt` | ISO string → timestamp | + +**Dropped fields**: `type` ('chat' | 'session') + +### Message Mapping + +| Source (OldMessage) | Target (messageTable) | Notes | +|---------------------|----------------------|-------| +| `id` | `id` | Direct copy (new UUID if duplicate) | +| (computed) | `parentId` | From tree building algorithm | +| (from parent topic) | `topicId` | Uses parent topic.id for consistency | +| `role` | `role` | Direct copy | +| `blocks` + `mentions` + citations | `data` | Complex transformation | +| (extracted) | `searchableText` | Extracted from text blocks | +| `status` | `status` | Normalized to success/error/paused | +| (computed) | `siblingsGroupId` | From multi-model detection | +| `assistantId` | `assistantId` | Direct copy | +| `modelId` | `modelId` | Direct copy | +| (from Message.model) | `modelMeta` | Generated from model entity | +| `traceId` | `traceId` | Direct copy | +| `usage` + `metrics` | `stats` | Merged into single stats object | +| `createdAt` | `createdAt` | ISO string → timestamp | +| `updatedAt` | `updatedAt` | ISO string → timestamp | + +**Dropped fields**: `type`, `useful`, `enabledMCPs`, `agentSessionId`, `providerMetadata`, `multiModelMessageStyle`, `askId` (replaced by parentId), `foldSelected` (replaced by siblingsGroupId) + +### Block Type Mapping + +| Old Type | New Type | Notes | +|----------|----------|-------| +| `main_text` | `MainTextBlock` | Direct, references added from citations/mentions | +| `thinking` | `ThinkingBlock` | `thinking_millsec` → `thinkingMs` | +| `translation` | `TranslationBlock` | Direct copy | +| `code` | `CodeBlock` | Direct copy | +| `image` | `ImageBlock` | `file.id` → `fileId` | +| `file` | `FileBlock` | `file.id` → `fileId` | +| `video` | `VideoBlock` | Direct copy | +| `tool` | `ToolBlock` | Direct copy | +| `citation` | (removed) | Converted to `MainTextBlock.references` | +| `error` | `ErrorBlock` | Direct copy | +| `compact` | `CompactBlock` | Direct copy | +| `unknown` | (skipped) | Placeholder blocks are dropped | + +## Implementation Files + +- `ChatMigrator.ts` - Main migrator class with prepare/execute/validate phases +- `mappings/ChatMappings.ts` - Pure transformation functions and type definitions + +## Code Quality + +All implementation code includes detailed comments: +- File-level comments: Describe purpose, data flow, and overview +- Function-level comments: Purpose, parameters, return values, side effects +- Logic block comments: Step-by-step explanations for complex logic +- Data transformation comments: Old field → new field mapping relationships diff --git a/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts b/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts new file mode 100644 index 0000000000..99b4023c08 --- /dev/null +++ b/src/main/data/migration/v2/migrators/mappings/ChatMappings.ts @@ -0,0 +1,1168 @@ +/** + * Chat Mappings - Topic and Message transformation functions for Dexie → SQLite migration + * + * This file contains pure transformation functions that convert old data structures + * to new SQLite-compatible formats. All functions are stateless and side-effect free. + * + * ## Data Flow Overview: + * + * ### Topics: + * - Source: Redux `assistants.topics[]` + Dexie `topics` table (for messages) + * - Target: SQLite `topicTable` + * + * ### Messages: + * - Source: Dexie `topics.messages[]` (embedded in topic) + `message_blocks` table + * - Target: SQLite `messageTable` with inline blocks in `data.blocks` + * + * ## Key Transformations: + * + * 1. **Message Order → Tree Structure** + * - Old: Linear array `topic.messages[]` with array index as order + * - New: Tree via `parentId` + `siblingsGroupId` + * + * 2. **Multi-model Responses** + * - Old: Multiple messages share same `askId`, `foldSelected` marks active + * - New: Same `parentId` + non-zero `siblingsGroupId` groups siblings + * + * 3. **Block Storage** + * - Old: `message.blocks: string[]` (IDs) + separate `message_blocks` table + * - New: `message.data.blocks: MessageDataBlock[]` (inline JSON) + * + * 4. **Citations → References** + * - Old: Separate `CitationMessageBlock` with response/knowledge/memories + * - New: Merged into `MainTextBlock.references` as typed ContentReference[] + * + * 5. **Mentions → References** + * - Old: `message.mentions: Model[]` + * - New: `MentionReference[]` in `MainTextBlock.references` + * + * @since v2.0.0 + */ + +import type { + BlockType, + CitationReference, + CitationType, + CodeBlock, + CompactBlock, + ContentReference, + ErrorBlock, + FileBlock, + ImageBlock, + MainTextBlock, + MentionReference, + MessageData, + MessageDataBlock, + MessageStats, + ReferenceCategory, + ThinkingBlock, + ToolBlock, + TranslationBlock, + VideoBlock +} from '@shared/data/types/message' +import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' + +// ============================================================================ +// Old Type Definitions (Source Data Structures) +// ============================================================================ + +/** + * Old Topic type from Redux assistants slice + * Source: src/renderer/src/types/index.ts + */ +export interface OldTopic { + id: string + type?: 'chat' | 'session' // Dropped in new schema + assistantId: string + name: string + createdAt: string + updatedAt: string + messages: OldMessage[] + pinned?: boolean + prompt?: string + isNameManuallyEdited?: boolean +} + +/** + * Old Assistant type for extracting AssistantMeta + * Note: In Redux state, assistant.topics[] contains topic metadata (but with messages: []) + */ +export interface OldAssistant { + id: string + name: string + emoji?: string + type: string + topics?: OldTopicMeta[] // Topics are nested inside assistants in Redux +} + +/** + * Old Topic metadata from Redux assistants.topics[] + * + * Redux stores topic metadata (name, pinned, etc.) but clears messages[] to reduce storage. + * Dexie stores topics with messages[] but may have stale metadata. + * Migration merges: Redux metadata + Dexie messages. + */ +export interface OldTopicMeta { + id: string + name: string + pinned?: boolean + prompt?: string + isNameManuallyEdited?: boolean + createdAt?: string + updatedAt?: string +} + +/** + * Old Model type for extracting ModelMeta + */ +export interface OldModel { + id: string + name: string + provider: string + group: string +} + +/** + * Old Message type from Dexie topics table + * Source: src/renderer/src/types/newMessage.ts + */ +export interface OldMessage { + id: string + role: 'user' | 'assistant' | 'system' + assistantId: string + topicId: string + createdAt: string + updatedAt?: string + // Old status includes more values, we normalize to success/error/paused + status: 'sending' | 'pending' | 'searching' | 'processing' | 'success' | 'paused' | 'error' + + // Model info + modelId?: string + model?: OldModel + + // Multi-model response fields + askId?: string // Links to user message ID + foldSelected?: boolean // True if this is the selected response in fold view + multiModelMessageStyle?: string // UI state, dropped + + // Content + blocks: string[] // Block IDs referencing message_blocks table + + // Metadata + usage?: OldUsage + metrics?: OldMetrics + traceId?: string + + // Fields being transformed + mentions?: OldModel[] // → MentionReference in MainTextBlock.references + + // Dropped fields + type?: 'clear' | 'text' | '@' + useful?: boolean + enabledMCPs?: unknown[] + agentSessionId?: string + providerMetadata?: unknown +} + +/** + * Old Usage type for token consumption + */ +export interface OldUsage { + prompt_tokens?: number + completion_tokens?: number + total_tokens?: number + thoughts_tokens?: number + cost?: number +} + +/** + * Old Metrics type for performance measurement + */ +export interface OldMetrics { + completion_tokens?: number + time_completion_millsec?: number + time_first_token_millsec?: number + time_thinking_millsec?: number +} + +/** + * Old MessageBlock base type + */ +export interface OldMessageBlock { + id: string + messageId: string + type: string + createdAt: string + updatedAt?: string + status: string // Dropped in new schema + model?: OldModel // Dropped in new schema + metadata?: Record + error?: unknown +} + +/** + * Old MainTextMessageBlock + */ +export interface OldMainTextBlock extends OldMessageBlock { + type: 'main_text' + content: string + knowledgeBaseIds?: string[] // Dropped (deprecated) + citationReferences?: Array<{ + citationBlockId?: string + citationBlockSource?: string + }> // Dropped (replaced by references) +} + +/** + * Old ThinkingMessageBlock + */ +export interface OldThinkingBlock extends OldMessageBlock { + type: 'thinking' + content: string + thinking_millsec: number // → thinkingMs +} + +/** + * Old TranslationMessageBlock + */ +export interface OldTranslationBlock extends OldMessageBlock { + type: 'translation' + content: string + sourceBlockId?: string + sourceLanguage?: string + targetLanguage: string +} + +/** + * Old CodeMessageBlock + */ +export interface OldCodeBlock extends OldMessageBlock { + type: 'code' + content: string + language: string +} + +/** + * Old ImageMessageBlock + */ +export interface OldImageBlock extends OldMessageBlock { + type: 'image' + url?: string + file?: { id: string; [key: string]: unknown } // file.id → fileId +} + +/** + * Old FileMessageBlock + */ +export interface OldFileBlock extends OldMessageBlock { + type: 'file' + file: { id: string; [key: string]: unknown } // file.id → fileId +} + +/** + * Old VideoMessageBlock + */ +export interface OldVideoBlock extends OldMessageBlock { + type: 'video' + url?: string + filePath?: string +} + +/** + * Old ToolMessageBlock + */ +export interface OldToolBlock extends OldMessageBlock { + type: 'tool' + toolId: string + toolName?: string + arguments?: Record + content?: string | object +} + +/** + * Old CitationMessageBlock - contains web search, knowledge, and memory references + * This is the primary source for ContentReference transformation + */ +export interface OldCitationBlock extends OldMessageBlock { + type: 'citation' + response?: { + results?: unknown + source: unknown + } + knowledge?: Array<{ + id: number + content: string + sourceUrl: string + type: string + file?: unknown + metadata?: Record + }> + memories?: Array<{ + id: string + memory: string + hash?: string + createdAt?: string + updatedAt?: string + score?: number + metadata?: Record + }> +} + +/** + * Old ErrorMessageBlock + */ +export interface OldErrorBlock extends OldMessageBlock { + type: 'error' +} + +/** + * Old CompactMessageBlock + */ +export interface OldCompactBlock extends OldMessageBlock { + type: 'compact' + content: string + compactedContent: string +} + +/** + * Union of all old block types + */ +export type OldBlock = + | OldMainTextBlock + | OldThinkingBlock + | OldTranslationBlock + | OldCodeBlock + | OldImageBlock + | OldFileBlock + | OldVideoBlock + | OldToolBlock + | OldCitationBlock + | OldErrorBlock + | OldCompactBlock + | OldMessageBlock + +// ============================================================================ +// New Type Definitions (Target Data Structures) +// ============================================================================ + +/** + * New Topic for SQLite insertion + * Matches topicTable schema + */ +export interface NewTopic { + id: string + name: string | null + isNameManuallyEdited: boolean + assistantId: string | null + assistantMeta: AssistantMeta | null + prompt: string | null + activeNodeId: string | null + groupId: string | null + sortOrder: number + isPinned: boolean + pinnedOrder: number + createdAt: number // timestamp + updatedAt: number // timestamp +} + +/** + * New Message for SQLite insertion + * Matches messageTable schema + */ +export interface NewMessage { + id: string + parentId: string | null + topicId: string + role: string + data: MessageData + searchableText: string | null + status: 'success' | 'error' | 'paused' + siblingsGroupId: number + assistantId: string | null + assistantMeta: AssistantMeta | null + modelId: string | null + modelMeta: ModelMeta | null + traceId: string | null + stats: MessageStats | null + createdAt: number // timestamp + updatedAt: number // timestamp +} + +// ============================================================================ +// Topic Transformation Functions +// ============================================================================ + +/** + * Transform old Topic to new Topic format + * + * @param oldTopic - Source topic from Redux/Dexie + * @param assistant - Assistant entity for generating AssistantMeta + * @param activeNodeId - Last message ID to set as active node + * @returns New topic ready for SQLite insertion + * + * ## Field Mapping: + * | Source | Target | Notes | + * |--------|--------|-------| + * | id | id | Direct copy | + * | name | name | Direct copy | + * | isNameManuallyEdited | isNameManuallyEdited | Direct copy | + * | assistantId | assistantId | Direct copy | + * | (from Assistant) | assistantMeta | Generated from assistant entity | + * | prompt | prompt | Direct copy | + * | (computed) | activeNodeId | Last message ID | + * | (none) | groupId | null (new field) | + * | (none) | sortOrder | 0 (new field) | + * | pinned | isPinned | Renamed | + * | (none) | pinnedOrder | 0 (new field) | + * | createdAt | createdAt | ISO string → timestamp | + * | updatedAt | updatedAt | ISO string → timestamp | + * + * ## Dropped Fields: + * - type ('chat' | 'session'): No longer needed in new schema + */ +export function transformTopic( + oldTopic: OldTopic, + assistant: OldAssistant | null, + activeNodeId: string | null +): NewTopic { + return { + id: oldTopic.id, + name: oldTopic.name || null, + isNameManuallyEdited: oldTopic.isNameManuallyEdited ?? false, + assistantId: oldTopic.assistantId || null, + assistantMeta: assistant ? extractAssistantMeta(assistant) : null, + prompt: oldTopic.prompt || null, + activeNodeId, + groupId: null, // New field, no migration source + sortOrder: 0, // New field, default value + isPinned: oldTopic.pinned ?? false, + pinnedOrder: 0, // New field, default value + createdAt: parseTimestamp(oldTopic.createdAt), + updatedAt: parseTimestamp(oldTopic.updatedAt) + } +} + +/** + * Extract AssistantMeta from old Assistant entity + * + * AssistantMeta preserves display information when the original + * assistant is deleted, ensuring messages/topics remain readable. + * + * @param assistant - Source assistant entity + * @returns AssistantMeta for storage in topic/message + */ +export function extractAssistantMeta(assistant: OldAssistant): AssistantMeta { + return { + id: assistant.id, + name: assistant.name, + emoji: assistant.emoji, + type: assistant.type + } +} + +// ============================================================================ +// Message Transformation Functions +// ============================================================================ + +/** + * Transform old Message to new Message format + * + * This is the core message transformation function. It handles: + * - Status normalization + * - Block transformation (IDs → inline data) + * - Citation merging into references + * - Mention conversion to references + * - Stats merging (usage + metrics) + * + * @param oldMessage - Source message from Dexie + * @param parentId - Computed parent message ID (from tree building) + * @param siblingsGroupId - Computed siblings group ID (from multi-model detection) + * @param blocks - Resolved block data from message_blocks table + * @param assistant - Assistant entity for generating AssistantMeta + * @param correctTopicId - The correct topic ID (from parent topic, not from message) + * @returns New message ready for SQLite insertion + * + * ## Field Mapping: + * | Source | Target | Notes | + * |--------|--------|-------| + * | id | id | Direct copy | + * | (computed) | parentId | From tree building algorithm | + * | (parameter) | topicId | From correctTopicId param (ensures consistency) | + * | role | role | Direct copy | + * | blocks + mentions + citations | data | Complex transformation | + * | (extracted) | searchableText | Extracted from text blocks | + * | status | status | Normalized to success/error/paused | + * | (computed) | siblingsGroupId | From multi-model detection | + * | assistantId | assistantId | Direct copy | + * | (from Message.model) | assistantMeta | Generated if available | + * | modelId | modelId | Direct copy | + * | (from Message.model) | modelMeta | Generated from model entity | + * | traceId | traceId | Direct copy | + * | usage + metrics | stats | Merged into single stats object | + * | createdAt | createdAt | ISO string → timestamp | + * | updatedAt | updatedAt | ISO string → timestamp | + * + * ## Dropped Fields: + * - type ('clear' | 'text' | '@') + * - useful (boolean) + * - enabledMCPs (deprecated) + * - agentSessionId (session identifier) + * - providerMetadata (raw provider data) + * - multiModelMessageStyle (UI state) + * - askId (replaced by parentId) + * - foldSelected (replaced by siblingsGroupId) + */ +export function transformMessage( + oldMessage: OldMessage, + parentId: string | null, + siblingsGroupId: number, + blocks: OldBlock[], + assistant: OldAssistant | null, + correctTopicId: string +): NewMessage { + // Transform blocks and merge citations/mentions into references + const { dataBlocks, citationReferences, searchableText } = transformBlocks(blocks) + + // Convert mentions to MentionReferences + const mentionReferences = transformMentions(oldMessage.mentions) + + // Find the MainTextBlock and add references if any exist + const allReferences = [...citationReferences, ...mentionReferences] + if (allReferences.length > 0) { + const mainTextBlock = dataBlocks.find((b) => b.type === 'main_text') as MainTextBlock | undefined + if (mainTextBlock) { + mainTextBlock.references = allReferences + } + } + + return { + id: oldMessage.id, + parentId, + topicId: correctTopicId, + role: oldMessage.role, + data: { blocks: dataBlocks }, + searchableText: searchableText || null, + status: normalizeStatus(oldMessage.status), + siblingsGroupId, + assistantId: oldMessage.assistantId || null, + assistantMeta: assistant ? extractAssistantMeta(assistant) : null, + modelId: oldMessage.modelId || null, + modelMeta: oldMessage.model ? extractModelMeta(oldMessage.model) : null, + traceId: oldMessage.traceId || null, + stats: mergeStats(oldMessage.usage, oldMessage.metrics), + createdAt: parseTimestamp(oldMessage.createdAt), + updatedAt: parseTimestamp(oldMessage.updatedAt || oldMessage.createdAt) + } +} + +/** + * Extract ModelMeta from old Model entity + * + * ModelMeta preserves model display information when the original + * model configuration is removed or unavailable. + * + * @param model - Source model entity + * @returns ModelMeta for storage in message + */ +export function extractModelMeta(model: OldModel): ModelMeta { + return { + id: model.id, + name: model.name, + provider: model.provider, + group: model.group + } +} + +/** + * Normalize old status values to new enum + * + * Old system has multiple transient states that don't apply to stored messages. + * We normalize these to the three final states in the new schema. + * + * @param oldStatus - Status from old message + * @returns Normalized status for new message + * + * ## Mapping: + * - 'success' → 'success' + * - 'error' → 'error' + * - 'paused' → 'paused' + * - 'sending', 'pending', 'searching', 'processing' → 'success' (completed states) + */ +export function normalizeStatus(oldStatus: OldMessage['status']): 'success' | 'error' | 'paused' { + switch (oldStatus) { + case 'error': + return 'error' + case 'paused': + return 'paused' + case 'success': + case 'sending': + case 'pending': + case 'searching': + case 'processing': + default: + // All transient states are treated as success for stored messages + // If a message was in a transient state during export, it completed + return 'success' + } +} + +/** + * Merge old usage and metrics into new MessageStats + * + * The old system stored token usage and performance metrics in separate objects. + * The new schema combines them into a single stats object. + * + * @param usage - Token usage data from old message + * @param metrics - Performance metrics from old message + * @returns Combined MessageStats or null if no data + * + * ## Field Mapping: + * | Source | Target | + * |--------|--------| + * | usage.prompt_tokens | promptTokens | + * | usage.completion_tokens | completionTokens | + * | usage.total_tokens | totalTokens | + * | usage.thoughts_tokens | thoughtsTokens | + * | usage.cost | cost | + * | metrics.time_first_token_millsec | timeFirstTokenMs | + * | metrics.time_completion_millsec | timeCompletionMs | + * | metrics.time_thinking_millsec | timeThinkingMs | + */ +export function mergeStats(usage?: OldUsage, metrics?: OldMetrics): MessageStats | null { + if (!usage && !metrics) return null + + const stats: MessageStats = {} + + // Token usage + if (usage) { + if (usage.prompt_tokens !== undefined) stats.promptTokens = usage.prompt_tokens + if (usage.completion_tokens !== undefined) stats.completionTokens = usage.completion_tokens + if (usage.total_tokens !== undefined) stats.totalTokens = usage.total_tokens + if (usage.thoughts_tokens !== undefined) stats.thoughtsTokens = usage.thoughts_tokens + if (usage.cost !== undefined) stats.cost = usage.cost + } + + // Performance metrics + if (metrics) { + if (metrics.time_first_token_millsec !== undefined) stats.timeFirstTokenMs = metrics.time_first_token_millsec + if (metrics.time_completion_millsec !== undefined) stats.timeCompletionMs = metrics.time_completion_millsec + if (metrics.time_thinking_millsec !== undefined) stats.timeThinkingMs = metrics.time_thinking_millsec + } + + // Return null if no data was actually added + return Object.keys(stats).length > 0 ? stats : null +} + +// ============================================================================ +// Block Transformation Functions +// ============================================================================ + +/** + * Transform old blocks to new format and extract citation references + * + * This function: + * 1. Converts each old block to new format (removing id, messageId, status) + * 2. Extracts CitationMessageBlocks and converts to ContentReference[] + * 3. Extracts searchable text from text-based blocks + * + * @param oldBlocks - Array of old blocks from message_blocks table + * @returns Object containing: + * - dataBlocks: Transformed blocks (excluding CitationBlocks) + * - citationReferences: Extracted citation references + * - searchableText: Combined searchable text + * + * ## Block Type Mapping: + * | Old Type | New Type | Notes | + * |----------|----------|-------| + * | main_text | MainTextBlock | Direct, references added later | + * | thinking | ThinkingBlock | thinking_millsec → thinkingMs | + * | translation | TranslationBlock | Direct copy | + * | code | CodeBlock | Direct copy | + * | image | ImageBlock | file.id → fileId | + * | file | FileBlock | file.id → fileId | + * | video | VideoBlock | Direct copy | + * | tool | ToolBlock | Direct copy | + * | citation | (removed) | Converted to MainTextBlock.references | + * | error | ErrorBlock | Direct copy | + * | compact | CompactBlock | Direct copy | + * | unknown | (skipped) | Placeholder blocks are dropped | + */ +export function transformBlocks(oldBlocks: OldBlock[]): { + dataBlocks: MessageDataBlock[] + citationReferences: ContentReference[] + searchableText: string +} { + const dataBlocks: MessageDataBlock[] = [] + const citationReferences: ContentReference[] = [] + const searchableTexts: string[] = [] + + for (const oldBlock of oldBlocks) { + const transformed = transformSingleBlock(oldBlock) + + if (transformed.block) { + dataBlocks.push(transformed.block) + } + + if (transformed.citations) { + citationReferences.push(...transformed.citations) + } + + if (transformed.searchableText) { + searchableTexts.push(transformed.searchableText) + } + } + + return { + dataBlocks, + citationReferences, + searchableText: searchableTexts.join('\n') + } +} + +/** + * Transform a single old block to new format + * + * @param oldBlock - Single old block + * @returns Transformed block and extracted data + */ +function transformSingleBlock(oldBlock: OldBlock): { + block: MessageDataBlock | null + citations: ContentReference[] | null + searchableText: string | null +} { + const baseFields = { + createdAt: parseTimestamp(oldBlock.createdAt), + updatedAt: oldBlock.updatedAt ? parseTimestamp(oldBlock.updatedAt) : undefined, + metadata: oldBlock.metadata, + error: oldBlock.error as MessageDataBlock['error'] + } + + switch (oldBlock.type) { + case 'main_text': { + const block = oldBlock as OldMainTextBlock + return { + block: { + type: 'main_text' as BlockType.MAIN_TEXT, + content: block.content, + ...baseFields + // knowledgeBaseIds and citationReferences are intentionally dropped + // References will be added from CitationBlocks and mentions + } as MainTextBlock, + citations: null, + searchableText: block.content + } + } + + case 'thinking': { + const block = oldBlock as OldThinkingBlock + return { + block: { + type: 'thinking' as BlockType.THINKING, + content: block.content, + thinkingMs: block.thinking_millsec, // Field rename + ...baseFields + } as ThinkingBlock, + citations: null, + searchableText: block.content + } + } + + case 'translation': { + const block = oldBlock as OldTranslationBlock + return { + block: { + type: 'translation' as BlockType.TRANSLATION, + content: block.content, + sourceBlockId: block.sourceBlockId, + sourceLanguage: block.sourceLanguage, + targetLanguage: block.targetLanguage, + ...baseFields + } as TranslationBlock, + citations: null, + searchableText: block.content + } + } + + case 'code': { + const block = oldBlock as OldCodeBlock + return { + block: { + type: 'code' as BlockType.CODE, + content: block.content, + language: block.language, + ...baseFields + } as CodeBlock, + citations: null, + searchableText: block.content + } + } + + case 'image': { + const block = oldBlock as OldImageBlock + return { + block: { + type: 'image' as BlockType.IMAGE, + url: block.url, + fileId: block.file?.id, // file.id → fileId + ...baseFields + } as ImageBlock, + citations: null, + searchableText: null + } + } + + case 'file': { + const block = oldBlock as OldFileBlock + return { + block: { + type: 'file' as BlockType.FILE, + fileId: block.file.id, // file.id → fileId + ...baseFields + } as FileBlock, + citations: null, + searchableText: null + } + } + + case 'video': { + const block = oldBlock as OldVideoBlock + return { + block: { + type: 'video' as BlockType.VIDEO, + url: block.url, + filePath: block.filePath, + ...baseFields + } as VideoBlock, + citations: null, + searchableText: null + } + } + + case 'tool': { + const block = oldBlock as OldToolBlock + return { + block: { + type: 'tool' as BlockType.TOOL, + toolId: block.toolId, + toolName: block.toolName, + arguments: block.arguments, + content: block.content, + ...baseFields + } as ToolBlock, + citations: null, + searchableText: null + } + } + + case 'citation': { + // CitationBlocks are NOT converted to blocks + // Instead, their content is extracted as ContentReferences + const block = oldBlock as OldCitationBlock + const citations = extractCitationReferences(block) + return { + block: null, // No block output + citations, + searchableText: null + } + } + + case 'error': { + return { + block: { + type: 'error' as BlockType.ERROR, + ...baseFields + } as ErrorBlock, + citations: null, + searchableText: null + } + } + + case 'compact': { + const block = oldBlock as OldCompactBlock + return { + block: { + type: 'compact' as BlockType.COMPACT, + content: block.content, + compactedContent: block.compactedContent, + ...baseFields + } as CompactBlock, + citations: null, + searchableText: block.content + } + } + + case 'unknown': + default: + // Skip unknown/placeholder blocks + return { + block: null, + citations: null, + searchableText: null + } + } +} + +/** + * Extract ContentReferences from old CitationMessageBlock + * + * Old CitationBlocks contain three types of citations: + * - response (web search results) → WebCitationReference + * - knowledge (knowledge base refs) → KnowledgeCitationReference + * - memories (memory items) → MemoryCitationReference + * + * @param citationBlock - Old CitationMessageBlock + * @returns Array of ContentReferences + */ +export function extractCitationReferences(citationBlock: OldCitationBlock): ContentReference[] { + const references: ContentReference[] = [] + + // Web search citations + if (citationBlock.response) { + references.push({ + category: 'citation' as ReferenceCategory.CITATION, + citationType: 'web' as CitationType.WEB, + content: { + results: citationBlock.response.results, + source: citationBlock.response.source + } + } as CitationReference) + } + + // Knowledge base citations + if (citationBlock.knowledge && citationBlock.knowledge.length > 0) { + references.push({ + category: 'citation' as ReferenceCategory.CITATION, + citationType: 'knowledge' as CitationType.KNOWLEDGE, + content: citationBlock.knowledge.map((k) => ({ + id: k.id, + content: k.content, + sourceUrl: k.sourceUrl, + type: k.type, + file: k.file, + metadata: k.metadata + })) + } as CitationReference) + } + + // Memory citations + if (citationBlock.memories && citationBlock.memories.length > 0) { + references.push({ + category: 'citation' as ReferenceCategory.CITATION, + citationType: 'memory' as CitationType.MEMORY, + content: citationBlock.memories.map((m) => ({ + id: m.id, + memory: m.memory, + hash: m.hash, + createdAt: m.createdAt, + updatedAt: m.updatedAt, + score: m.score, + metadata: m.metadata + })) + } as CitationReference) + } + + return references +} + +/** + * Transform old mentions to MentionReferences + * + * Old system stored @mentions as a Model[] array on the message. + * New system stores them as MentionReference[] in MainTextBlock.references. + * + * @param mentions - Array of mentioned models from old message + * @returns Array of MentionReferences + * + * ## Transformation: + * | Old Field | New Field | + * |-----------|-----------| + * | model.id | modelId | + * | model.name | displayName | + */ +export function transformMentions(mentions?: OldModel[]): MentionReference[] { + if (!mentions || mentions.length === 0) return [] + + return mentions.map((model) => ({ + category: 'mention' as ReferenceCategory.MENTION, + modelId: model.id, + displayName: model.name + })) +} + +// ============================================================================ +// Tree Building Functions +// ============================================================================ + +/** + * Build message tree structure from linear message array + * + * The old system stores messages in a linear array. The new system uses + * a tree structure with parentId for navigation. + * + * ## Algorithm: + * 1. Process messages in array order (which is the conversation order) + * 2. For each message: + * - If it's a user message or first message, parent is the previous message + * - If it's an assistant message with askId, link to that user message + * - If multiple messages share same askId, they form a siblings group + * + * @param messages - Messages in array order from old topic + * @returns Map of messageId → { parentId, siblingsGroupId } + * + * ## Example: + * ``` + * Input: [u1, a1, u2, a2, a3(askId=u2,foldSelected), a4(askId=u2), u3] + * + * Output: + * u1: { parentId: null, siblingsGroupId: 0 } + * a1: { parentId: 'u1', siblingsGroupId: 0 } + * u2: { parentId: 'a1', siblingsGroupId: 0 } + * a2: { parentId: 'u2', siblingsGroupId: 1 } // Multi-model group + * a3: { parentId: 'u2', siblingsGroupId: 1 } // Selected one + * a4: { parentId: 'u2', siblingsGroupId: 1 } + * u3: { parentId: 'a3', siblingsGroupId: 0 } // Links to foldSelected + * ``` + */ +export function buildMessageTree( + messages: OldMessage[] +): Map { + const result = new Map() + + if (messages.length === 0) return result + + // Track askId → siblingsGroupId mapping + // Each unique askId with multiple responses gets a unique siblingsGroupId + const askIdToGroupId = new Map() + const askIdCounts = new Map() + + // First pass: count messages per askId to identify multi-model responses + for (const msg of messages) { + if (msg.askId) { + askIdCounts.set(msg.askId, (askIdCounts.get(msg.askId) || 0) + 1) + } + } + + // Assign group IDs to askIds with multiple responses + let nextGroupId = 1 + for (const [askId, count] of askIdCounts) { + if (count > 1) { + askIdToGroupId.set(askId, nextGroupId++) + } + } + + // Second pass: build parent/sibling relationships + let previousMessageId: string | null = null + let lastNonGroupMessageId: string | null = null // Last message not in a group, for linking subsequent user messages + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + let parentId: string | null = null + let siblingsGroupId = 0 + + if (msg.askId && askIdToGroupId.has(msg.askId)) { + // This is part of a multi-model response group + parentId = msg.askId // Parent is the user message + siblingsGroupId = askIdToGroupId.get(msg.askId)! + + // If this is the selected response, update lastNonGroupMessageId for subsequent user messages + if (msg.foldSelected) { + lastNonGroupMessageId = msg.id + } + } else if (msg.role === 'user' && lastNonGroupMessageId) { + // User message after a multi-model group links to the selected response + parentId = lastNonGroupMessageId + lastNonGroupMessageId = null + } else { + // Normal sequential message - parent is previous message + parentId = previousMessageId + } + + result.set(msg.id, { parentId, siblingsGroupId }) + + // Update tracking for next iteration + previousMessageId = msg.id + + // Update lastNonGroupMessageId for non-group messages + if (siblingsGroupId === 0) { + lastNonGroupMessageId = msg.id + } + } + + return result +} + +/** + * Find the activeNodeId for a topic + * + * The activeNodeId should be the last message in the main conversation thread. + * For multi-model responses, it should be the foldSelected one. + * + * @param messages - Messages in array order + * @returns The ID of the last message (or foldSelected if applicable) + */ +export function findActiveNodeId(messages: OldMessage[]): string | null { + if (messages.length === 0) return null + + // Find the last message + // If it's part of a multi-model group, find the foldSelected one + const lastMsg = messages[messages.length - 1] + + if (lastMsg.askId) { + // Check if there's a foldSelected message with the same askId + const selectedMsg = messages.find((m) => m.askId === lastMsg.askId && m.foldSelected) + if (selectedMsg) return selectedMsg.id + } + + return lastMsg.id +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Parse ISO timestamp string to Unix timestamp (milliseconds) + * + * @param isoString - ISO 8601 timestamp string or undefined + * @returns Unix timestamp in milliseconds + */ +export function parseTimestamp(isoString: string | undefined): number { + if (!isoString) return Date.now() + + const parsed = new Date(isoString).getTime() + return isNaN(parsed) ? Date.now() : parsed +} + +/** + * Build block lookup map from message_blocks table + * + * Creates a Map of blockId → block for fast lookup during message transformation. + * + * @param blocks - All blocks from message_blocks table + * @returns Map for O(1) block lookup + */ +export function buildBlockLookup(blocks: OldBlock[]): Map { + const lookup = new Map() + for (const block of blocks) { + lookup.set(block.id, block) + } + return lookup +} + +/** + * Resolve block IDs to actual block data + * + * @param blockIds - Array of block IDs from message.blocks + * @param blockLookup - Map of blockId → block + * @returns Array of resolved blocks (missing blocks are skipped) + */ +export function resolveBlocks(blockIds: string[], blockLookup: Map): OldBlock[] { + const resolved: OldBlock[] = [] + for (const id of blockIds) { + const block = blockLookup.get(id) + if (block) { + resolved.push(block) + } + } + return resolved +} From 71a7b1b7ea30d698fcff0e5988edaa6a5bb8aa0e Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Thu, 1 Jan 2026 23:26:28 +0800 Subject: [PATCH 073/116] refactor(migration): improve ChatMigrator's handling of duplicate messages and active node selection - Enhanced duplicate message ID handling by updating parentId references and ensuring transaction safety. - Implemented a smart selection logic for determining activeNodeId, prioritizing original active nodes and foldSelected messages. - Updated documentation to reflect changes in duplicate handling and active node selection strategies. --- .../migration/v2/migrators/ChatMigrator.ts | 80 ++++++++++++++----- .../v2/migrators/README-ChatMigrator.md | 4 +- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/main/data/migration/v2/migrators/ChatMigrator.ts b/src/main/data/migration/v2/migrators/ChatMigrator.ts index 7e622739dd..077ad2179d 100644 --- a/src/main/data/migration/v2/migrators/ChatMigrator.ts +++ b/src/main/data/migration/v2/migrators/ChatMigrator.ts @@ -63,6 +63,7 @@ import { BaseMigrator } from './BaseMigrator' import { buildBlockLookup, buildMessageTree, + findActiveNodeId, type NewMessage, type NewTopic, type OldAssistant, @@ -287,34 +288,52 @@ export class ChatMigrator extends BaseMigrator { // Insert topics in a transaction if (preparedData.length > 0) { + // Collect all messages and handle duplicates BEFORE transaction + // This ensures parentId references are updated correctly + const allMessages: NewMessage[] = [] + const idRemapping = new Map() // oldId → newId for duplicates + const batchMessageIds = new Set() // IDs added in this batch (for transaction safety) + + for (const data of preparedData) { + for (const msg of data.messages) { + if (this.seenMessageIds.has(msg.id) || batchMessageIds.has(msg.id)) { + const newId = uuidv4() + logger.warn(`Duplicate message ID found: ${msg.id}, assigning new ID: ${newId}`) + idRemapping.set(msg.id, newId) + msg.id = newId + } + batchMessageIds.add(msg.id) + allMessages.push(msg) + } + } + + // Update parentId references for any remapped IDs + if (idRemapping.size > 0) { + for (const msg of allMessages) { + if (msg.parentId && idRemapping.has(msg.parentId)) { + msg.parentId = idRemapping.get(msg.parentId)! + } + } + } + + // Execute transaction await db.transaction(async (tx) => { // Insert topics const topicValues = preparedData.map((d) => d.topic) await tx.insert(topicTable).values(topicValues) - // Collect all messages, handling duplicate IDs by generating new ones - const allMessages: NewMessage[] = [] - for (const data of preparedData) { - for (const msg of data.messages) { - if (this.seenMessageIds.has(msg.id)) { - const newId = uuidv4() - logger.warn(`Duplicate message ID found: ${msg.id}, assigning new ID: ${newId}`) - msg.id = newId - } - this.seenMessageIds.add(msg.id) - allMessages.push(msg) - } - } - // Insert messages in batches (SQLite parameter limit) for (let i = 0; i < allMessages.length; i += MESSAGE_INSERT_BATCH_SIZE) { const batch = allMessages.slice(i, i + MESSAGE_INSERT_BATCH_SIZE) await tx.insert(messageTable).values(batch) } - - processedMessages += allMessages.length }) + // Update state ONLY after transaction succeeds (transaction safety) + for (const id of batchMessageIds) { + this.seenMessageIds.add(id) + } + processedMessages += allMessages.length processedTopics += preparedData.length } @@ -389,9 +408,12 @@ export class ChatMigrator extends BaseMigrator { const expectedTopics = this.topicCount - this.skippedTopics if (targetTopicCount < expectedTopics) { errors.push({ - key: 'topic_count', - message: `Topic count mismatch: expected ${expectedTopics}, got ${targetTopicCount}` + key: 'topic_count_low', + message: `Topic count too low: expected ${expectedTopics}, got ${targetTopicCount}` }) + } else if (targetTopicCount > expectedTopics) { + // More topics than expected could indicate duplicate insertions or data corruption + logger.warn(`Topic count higher than expected: expected ${expectedTopics}, got ${targetTopicCount}`) } // Sample validation: check a few topics have messages @@ -604,12 +626,26 @@ export class ChatMigrator extends BaseMigrator { } } - // Calculate activeNodeId based on migrated messages (not original messages) - // If no messages were migrated, set to null + // Calculate activeNodeId using smart selection logic + // Priority: 1) Original activeNode if migrated, 2) foldSelected if migrated, 3) last migrated let activeNodeId: string | null = null if (newMessages.length > 0) { - // Use the last migrated message as active node - activeNodeId = newMessages[newMessages.length - 1].id + const migratedIds = new Set(newMessages.map((m) => m.id)) + + // Try to use the original active node (handles foldSelected for multi-model) + const originalActiveId = findActiveNodeId(oldMessages) + if (originalActiveId && migratedIds.has(originalActiveId)) { + activeNodeId = originalActiveId + } else { + // Original active was skipped; find a foldSelected among migrated messages + const foldSelectedMsg = oldMessages.find((m) => m.foldSelected && migratedIds.has(m.id)) + if (foldSelectedMsg) { + activeNodeId = foldSelectedMsg.id + } else { + // Fallback to last migrated message + activeNodeId = newMessages[newMessages.length - 1].id + } + } } // Transform topic with correct activeNodeId diff --git a/src/main/data/migration/v2/migrators/README-ChatMigrator.md b/src/main/data/migration/v2/migrators/README-ChatMigrator.md index a3a0640ccd..63e2053e73 100644 --- a/src/main/data/migration/v2/migrators/README-ChatMigrator.md +++ b/src/main/data/migration/v2/migrators/README-ChatMigrator.md @@ -51,7 +51,7 @@ The migrator handles potential data inconsistencies from the old system: | Issue | Detection | Handling | |-------|-----------|----------| -| **Duplicate message ID** | Same ID appears in multiple topics | Generate new UUID, log warning | +| **Duplicate message ID** | Same ID appears in multiple topics | Generate new UUID, update parentId refs, log warning | | **TopicId mismatch** | `message.topicId` ≠ parent `topic.id` | Use correct parent topic.id (silent fix) | | **Missing blocks** | Block ID not found in `message_blocks` | Skip missing block (silent) | | **Invalid topic** | Topic missing required `id` field | Skip entire topic | @@ -75,7 +75,7 @@ Topic data is merged from Dexie + Redux before transformation: | Redux: (parent assistant.id) | `assistantId` | From `topicAssistantLookup` mapping | | (from Assistant) | `assistantMeta` | Generated from assistant entity | | Redux: `prompt` | `prompt` | Merged from Redux | -| (computed) | `activeNodeId` | Last message ID or foldSelected | +| (computed) | `activeNodeId` | Smart selection: original active → foldSelected → last migrated | | (none) | `groupId` | null (new field) | | (none) | `sortOrder` | 0 (new field) | | Redux: `pinned` | `isPinned` | Merged from Redux, renamed | From 26948b70a240bdfdf39a9df9ab180e418155b10e Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 2 Jan 2026 16:18:11 +0800 Subject: [PATCH 074/116] fix(thunk): clarify v2 file naming and comments in messageThunk.v2.ts - Updated comments to clarify that the file is part of the v1 codebase despite its v2 naming, preventing potential confusion for developers. - Removed outdated references to v2 implementations in the documentation to ensure accuracy. --- src/renderer/src/store/thunk/messageThunk.v2.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/store/thunk/messageThunk.v2.ts b/src/renderer/src/store/thunk/messageThunk.v2.ts index 587a9baf68..7d39c6a5ff 100644 --- a/src/renderer/src/store/thunk/messageThunk.v2.ts +++ b/src/renderer/src/store/thunk/messageThunk.v2.ts @@ -14,9 +14,9 @@ * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 * -------------------------------------------------------------------------- */ + /** - * V2 implementations of message thunk functions using the unified DbService - * These implementations will be gradually rolled out using feature flags + * 注意:该文件名虽然带着v2,下面注释可能也提到了v2,但该文件和v2 Data Refactor没有任何关系,该文件仍旧属于v1的旧代码,请人类和AI都要产生误判 */ import { loggerService } from '@logger' @@ -35,7 +35,6 @@ const logger = loggerService.withContext('MessageThunkV2') /** * Load messages for a topic using unified DbService - * This is the V2 implementation that will replace the original */ export const loadTopicMessagesThunkV2 = (topicId: string, forceReload: boolean = false) => From 48a582820f179a9bc650adeeff59fd6a64d144a8 Mon Sep 17 00:00:00 2001 From: SuYao Date: Fri, 2 Jan 2026 16:26:28 +0800 Subject: [PATCH 075/116] feat: update-t2i-image (#12236) * chore: comment * chore: comment 2 * fix: comment * chore: var name --- src/renderer/src/config/models/vision.ts | 57 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/config/models/vision.ts b/src/renderer/src/config/models/vision.ts index fe4bc9912c..d93c677638 100644 --- a/src/renderer/src/config/models/vision.ts +++ b/src/renderer/src/config/models/vision.ts @@ -75,12 +75,37 @@ const VISION_REGEX = new RegExp( 'i' ) -// For middleware to identify models that must use the dedicated Image API +// All dedicated image generation models (only generate images, no text chat capability) +// These models need: +// 1. Route to dedicated image generation API +// 2. Exclude from reasoning/websearch/tooluse selection const DEDICATED_IMAGE_MODELS = [ - 'grok-2-image(?:-[\\w-]+)?', + // OpenAI series 'dall-e(?:-[\\w-]+)?', - 'gpt-image-1(?:-[\\w-]+)?', - 'imagen(?:-[\\w-]+)?' + 'gpt-image(?:-[\\w-]+)?', + // xAI + 'grok-2-image(?:-[\\w-]+)?', + // Google + 'imagen(?:-[\\w-]+)?', + // Stable Diffusion series + 'flux(?:-[\\w-]+)?', + 'stable-?diffusion(?:-[\\w-]+)?', + 'stabilityai(?:-[\\w-]+)?', + 'sd-[\\w-]+', + 'sdxl(?:-[\\w-]+)?', + // zhipu + 'cogview(?:-[\\w-]+)?', + // Alibaba + 'qwen-image(?:-[\\w-]+)?', + // Others + 'janus(?:-[\\w-]+)?', + 'midjourney(?:-[\\w-]+)?', + 'mj-[\\w-]+', + 'z-image(?:-[\\w-]+)?', + 'longcat-image(?:-[\\w-]+)?', + 'hunyuanimage(?:-[\\w-]+)?', + 'seedream(?:-[\\w-]+)?', + 'kandinsky(?:-[\\w-]+)?' ] const IMAGE_ENHANCEMENT_MODELS = [ @@ -133,13 +158,23 @@ const GENERATE_IMAGE_MODELS_REGEX = new RegExp(GENERATE_IMAGE_MODELS.join('|'), const MODERN_GENERATE_IMAGE_MODELS_REGEX = new RegExp(MODERN_IMAGE_MODELS.join('|'), 'i') -export const isDedicatedImageGenerationModel = (model: Model): boolean => { +/** + * Check if the model is a dedicated image generation model + * Dedicated image generation models can only generate images, no text chat capability + * + * These models need: + * 1. Route to dedicated image generation API + * 2. Exclude from reasoning/websearch/tooluse selection + */ +export function isDedicatedImageModel(model: Model): boolean { if (!model) return false - const modelId = getLowerBaseModelName(model.id) return DEDICATED_IMAGE_MODELS_REGEX.test(modelId) } +// Backward compatible aliases +export const isDedicatedImageGenerationModel = isDedicatedImageModel + export const isAutoEnableImageGenerationModel = (model: Model): boolean => { if (!model) return false @@ -195,14 +230,8 @@ export function isPureGenerateImageModel(model: Model): boolean { return !OPENAI_TOOL_USE_IMAGE_GENERATION_MODELS.some((m) => modelId.includes(m)) } -// TODO: refine the regex -// Text to image models -const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-|dall|cogview|janus|midjourney|mj-|imagen|gpt-image/i - -export function isTextToImageModel(model: Model): boolean { - const modelId = getLowerBaseModelName(model.id) - return TEXT_TO_IMAGE_REGEX.test(modelId) -} +// Backward compatible alias - now uses unified dedicated image model detection +export const isTextToImageModel = isDedicatedImageModel /** * 判断模型是否支持图片增强(包括编辑、增强、修复等) From 078cf39313a531748a84891322df56438c0872b1 Mon Sep 17 00:00:00 2001 From: Hizome <18071447006@163.com> Date: Sat, 3 Jan 2026 16:14:12 +0800 Subject: [PATCH 076/116] fix: implement navigation in agent mode (#12238) fix: add navigation in agentm mode Co-authored-by: harry --- src/renderer/src/pages/home/Chat.tsx | 1 + .../home/Messages/AgentSessionMessages.tsx | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 01bd12377c..fb24d55d65 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -233,6 +233,7 @@ const Chat: FC = (props) => { ) : ( )} + {messageNavigation === 'buttons' && } )} diff --git a/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx b/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx index 611216919a..d32e9bdf8b 100644 --- a/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx +++ b/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx @@ -2,13 +2,17 @@ import { loggerService } from '@logger' import ContextMenu from '@renderer/components/ContextMenu' import { useSession } from '@renderer/hooks/agents/useSession' import { useTopicMessages } from '@renderer/hooks/useMessageOperations' +import useScrollPosition from '@renderer/hooks/useScrollPosition' +import { useSettings } from '@renderer/hooks/useSettings' +import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getGroupedMessages } from '@renderer/services/MessagesService' import { type Topic, TopicType } from '@renderer/types' import { buildAgentSessionTopicId } from '@renderer/utils/agentSession' import { Spin } from 'antd' -import { memo, useMemo } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef } from 'react' import styled from 'styled-components' +import MessageAnchorLine from './MessageAnchorLine' import MessageGroup from './MessageGroup' import NarrowLayout from './NarrowLayout' import PermissionModeDisplay from './PermissionModeDisplay' @@ -26,6 +30,10 @@ const AgentSessionMessages: React.FC = ({ agentId, sessionId }) => { const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId]) // Use the same hook as Messages.tsx for consistent behavior const messages = useTopicMessages(sessionTopicId) + const { messageNavigation } = useSettings() + const scrollContainerRef = useRef(null) + + const { handleScroll: handleScrollPosition } = useScrollPosition(`agent-session-${sessionId}`) const displayMessages = useMemo(() => { if (!messages || messages.length === 0) return [] @@ -60,8 +68,31 @@ const AgentSessionMessages: React.FC = ({ agentId, sessionId }) => { messageCount: messages.length }) + // Scroll to bottom function + const scrollToBottom = useCallback(() => { + if (scrollContainerRef.current) { + requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTo({ top: 0 }) + } + }) + } + }, [scrollContainerRef]) + + // Listen for send message events to auto-scroll to bottom + useEffect(() => { + const unsubscribes = [ + EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, scrollToBottom) + ] + return () => unsubscribes.forEach((unsub) => unsub()) + }, [scrollToBottom]) + return ( - + @@ -79,6 +110,7 @@ const AgentSessionMessages: React.FC = ({ agentId, sessionId }) => { + {messageNavigation === 'anchor' && } ) } From ca2b0ac28db26b4859d9138d1346e08920bb1a89 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 16:36:53 +0800 Subject: [PATCH 077/116] refactor: merge messageThunk.v2.ts into messageThunk.ts Remove the confusing V2 naming from message thunk functions to avoid conflicts with the upcoming real V2 data refactoring. Changes: - Inline V2 function implementations directly into messageThunk.ts - Replace V2 function calls with direct dbService calls - Remove messageThunk.v2.ts file - Remove misleading "V2 DATA&UI REFACTORING" header comments The V2 suffix was originally added for agent session support, not for a data layer refactoring. This cleanup clears the naming space for the actual V2 refactoring work. --- .../home/Messages/AgentSessionMessages.tsx | 4 +- .../src/pages/home/Messages/Messages.tsx | 2 +- src/renderer/src/store/thunk/messageThunk.ts | 265 +++++++++++++++--- .../src/store/thunk/messageThunk.v2.ts | 249 ---------------- 4 files changed, 232 insertions(+), 288 deletions(-) delete mode 100644 src/renderer/src/store/thunk/messageThunk.v2.ts diff --git a/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx b/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx index d32e9bdf8b..7f7900b8c5 100644 --- a/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx +++ b/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx @@ -81,9 +81,7 @@ const AgentSessionMessages: React.FC = ({ agentId, sessionId }) => { // Listen for send message events to auto-scroll to bottom useEffect(() => { - const unsubscribes = [ - EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, scrollToBottom) - ] + const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, scrollToBottom)] return () => unsubscribes.forEach((unsub) => unsub()) }, [scrollToBottom]) diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index 7e0e03a774..05ee5b8fbb 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -162,7 +162,7 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic, o const { message: clearMessage } = getUserMessage({ assistant, topic, type: 'clear' }) dispatch(newMessagesActions.addMessage({ topicId: topic.id, message: clearMessage })) - await saveMessageAndBlocksToDB(clearMessage, []) + await saveMessageAndBlocksToDB(topic.id, clearMessage, []) scrollToBottom() } finally { diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 45d7fd760a..aaa2ffc2c4 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -19,6 +19,7 @@ import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter' import { AgentApiClient } from '@renderer/api/agent' import db from '@renderer/databases' import { fetchMessagesSummary, transformMessagesAndFetch } from '@renderer/services/ApiService' +import { dbService } from '@renderer/services/db' import { DbService } from '@renderer/services/db/DbService' import FileManager from '@renderer/services/FileManager' import { BlockManager } from '@renderer/services/messageStreaming/BlockManager' @@ -57,18 +58,18 @@ import { mutate } from 'swr' import type { AppDispatch, RootState } from '../index' import { removeManyBlocks, updateOneBlock, upsertManyBlocks, upsertOneBlock } from '../messageBlock' import { newMessagesActions, selectMessagesForTopic } from '../newMessage' -import { - bulkAddBlocksV2, - clearMessagesFromDBV2, - deleteMessageFromDBV2, - deleteMessagesFromDBV2, - loadTopicMessagesThunkV2, - saveMessageAndBlocksToDBV2, - updateBlocksV2, - updateFileCountV2, - updateMessageV2, - updateSingleBlockV2 -} from './messageThunk.v2' +// import { +// bulkAddBlocksV2, +// clearMessagesFromDBV2, +// deleteMessageFromDBV2, +// deleteMessagesFromDBV2, +// loadTopicMessagesThunkV2, +// saveMessageAndBlocksToDBV2, +// updateBlocksV2, +// updateFileCountV2, +// updateMessageV2, +// updateSingleBlockV2 +// } from './messageThunk.v2' const logger = loggerService.withContext('MessageThunk') @@ -363,9 +364,9 @@ const createAgentMessageStream = async ( return createSSEReadableStream(response.body, signal) } // TODO: 后续可以将db操作移到Listener Middleware中 -export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => { - return saveMessageAndBlocksToDBV2(message.topicId, message, blocks, messageIndex) -} +// export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => { +// return saveMessageAndBlocksToDBV2(message.topicId, message, blocks, messageIndex) +// } const updateExistingMessageAndBlocksInDB = async ( updatedMessage: Partial & Pick, @@ -374,7 +375,7 @@ const updateExistingMessageAndBlocksInDB = async ( try { // Always update blocks if provided if (updatedBlocks.length > 0) { - await updateBlocksV2(updatedBlocks) + await updateBlocks(updatedBlocks) } // Check if there are message properties to update beyond id and topicId @@ -386,7 +387,7 @@ const updateExistingMessageAndBlocksInDB = async ( return acc }, {}) - await updateMessageV2(updatedMessage.topicId, updatedMessage.id, messageUpdatesPayload) + await updateMessage(updatedMessage.topicId, updatedMessage.id, messageUpdatesPayload) store.dispatch(updateTopicUpdatedAt({ topicId: updatedMessage.topicId })) } @@ -432,7 +433,7 @@ const getBlockThrottler = (id: string) => { }) blockUpdateRafs.set(id, rafId) - await updateSingleBlockV2(id, blockUpdate) + await updateSingleBlock(id, blockUpdate) }, 150) blockUpdateThrottlers.set(id, throttler) @@ -893,7 +894,7 @@ export const sendMessage = userMessage.agentSessionId = activeAgentSession.agentSessionId } - await saveMessageAndBlocksToDB(userMessage, userMessageBlocks) + await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks) dispatch(newMessagesActions.addMessage({ topicId, message: userMessage })) if (userMessageBlocks.length > 0) { dispatch(upsertManyBlocks(userMessageBlocks)) @@ -911,7 +912,7 @@ export const sendMessage = if (activeAgentSession.agentSessionId && !assistantMessage.agentSessionId) { assistantMessage.agentSessionId = activeAgentSession.agentSessionId } - await saveMessageAndBlocksToDB(assistantMessage, []) + await saveMessageAndBlocksToDB(topicId, assistantMessage, []) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) queue.add(async () => { @@ -934,7 +935,7 @@ export const sendMessage = model: assistant.model, traceId: userMessage.traceId }) - await saveMessageAndBlocksToDB(assistantMessage, []) + await saveMessageAndBlocksToDB(topicId, assistantMessage, []) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) queue.add(async () => { @@ -1000,11 +1001,11 @@ export const loadAgentSessionMessagesThunk = * Loads messages and their blocks for a specific topic from the database * and updates the Redux store. */ -export const loadTopicMessagesThunk = - (topicId: string, forceReload: boolean = false) => - async (dispatch: AppDispatch, getState: () => RootState) => { - return loadTopicMessagesThunkV2(topicId, forceReload)(dispatch, getState) - } +// export const loadTopicMessagesThunk = +// (topicId: string, forceReload: boolean = false) => +// async (dispatch: AppDispatch, getState: () => RootState) => { +// return loadTopicMessagesThunkV2(topicId, forceReload)(dispatch, getState) +// } /** * Thunk to delete a single message and its associated blocks. @@ -1023,7 +1024,7 @@ export const deleteSingleMessageThunk = try { dispatch(newMessagesActions.removeMessage({ topicId, messageId })) cleanupMultipleBlocks(dispatch, blockIdsToDelete) - await deleteMessageFromDBV2(topicId, messageId) + await deleteMessageFromDB(topicId, messageId) } catch (error) { logger.error(`[deleteSingleMessage] Failed to delete message ${messageId}:`, error as Error) } @@ -1062,7 +1063,7 @@ export const deleteMessageGroupThunk = try { dispatch(newMessagesActions.removeMessagesByAskId({ topicId, askId })) cleanupMultipleBlocks(dispatch, blockIdsToDelete) - await deleteMessagesFromDBV2(topicId, messageIdsToDelete) + await deleteMessagesFromDB(topicId, messageIdsToDelete) } catch (error) { logger.error(`[deleteMessageGroup] Failed to delete messages with askId ${askId}:`, error as Error) } @@ -1087,7 +1088,7 @@ export const clearTopicMessagesThunk = dispatch(newMessagesActions.clearTopicMessages(topicId)) cleanupMultipleBlocks(dispatch, blockIdsToDelete) - await clearMessagesFromDBV2(topicId) + await clearMessagesFromDB(topicId) } catch (error) { logger.error(`[clearTopicMessagesThunk] Failed to clear messages for topic ${topicId}:`, error as Error) } @@ -1408,7 +1409,7 @@ export const updateTranslationBlockThunk = // 更新Redux状态 dispatch(updateOneBlock({ id: blockId, changes })) - await updateSingleBlockV2(blockId, changes) + await updateSingleBlock(blockId, changes) // Logger.log(`[updateTranslationBlockThunk] Successfully updated translation block ${blockId}.`) } catch (error) { logger.error(`[updateTranslationBlockThunk] Failed to update translation block ${blockId}:`, error as Error) @@ -1479,7 +1480,7 @@ export const appendAssistantResponseThunk = const insertAtIndex = existingMessageIndex !== -1 ? existingMessageIndex + 1 : currentTopicMessageIds.length // 4. Update Database (Save the stub to the topic's message list) - await saveMessageAndBlocksToDB(newAssistantMessageStub, [], insertAtIndex) + await saveMessageAndBlocksToDB(topicId, newAssistantMessageStub, [], insertAtIndex) dispatch( newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantMessageStub, index: insertAtIndex }) @@ -1631,12 +1632,12 @@ export const cloneMessagesToNewTopicThunk = // Add the NEW blocks if (clonedBlocks.length > 0) { - await bulkAddBlocksV2(clonedBlocks) + await bulkAddBlocks(clonedBlocks) } // Update file counts const uniqueFiles = [...new Map(filesToUpdateCount.map((f) => [f.id, f])).values()] for (const file of uniqueFiles) { - await updateFileCountV2(file.id, 1, false) + await updateFileCount(file.id, 1, false) } }) @@ -1690,11 +1691,11 @@ export const updateMessageAndBlocksThunk = } // Update message properties if provided if (messageUpdates && Object.keys(messageUpdates).length > 0 && messageId) { - await updateMessageV2(topicId, messageId, messageUpdates) + await updateMessage(topicId, messageId, messageUpdates) } // Update blocks if provided if (blockUpdatesList.length > 0) { - await updateBlocksV2(blockUpdatesList) + await updateBlocks(blockUpdatesList) } dispatch(updateTopicUpdatedAt({ topicId })) @@ -1748,3 +1749,197 @@ export const removeBlocksThunk = throw error } } + +//以下内容从原 messageThunk.v2.ts 迁移过来,原文件已经删除 +//原因:v2.ts并不是v2数据重构的一部分,而相关命名对v2重构造成重大误解,故两文件合并,以消除误解 + +/** + * Load messages for a topic using unified DbService + */ +export const loadTopicMessagesThunk = + (topicId: string, forceReload: boolean = false) => + async (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState() + + dispatch(newMessagesActions.setCurrentTopicId(topicId)) + + // Skip if already cached and not forcing reload + if (!forceReload && state.messages.messageIdsByTopic[topicId]) { + return + } + + try { + dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true })) + + // Unified call - no need to check isAgentSessionTopicId + const { messages, blocks } = await dbService.fetchMessages(topicId) + + logger.silly('Loaded messages via DbService', { + topicId, + messageCount: messages.length, + blockCount: blocks.length + }) + + // Update Redux state with fetched data + if (blocks.length > 0) { + dispatch(upsertManyBlocks(blocks)) + } + dispatch(newMessagesActions.messagesReceived({ topicId, messages })) + } catch (error) { + logger.error(`Failed to load messages for topic ${topicId}:`, error as Error) + // Could dispatch an error action here if needed + } finally { + dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) + } + } + +/** + * Get raw topic data using unified DbService + * Returns topic with messages array + */ +export const getRawTopic = async (topicId: string): Promise<{ id: string; messages: Message[] } | undefined> => { + try { + const rawTopic = await dbService.getRawTopic(topicId) + logger.silly('Retrieved raw topic via DbService', { topicId, found: !!rawTopic }) + return rawTopic + } catch (error) { + logger.error('Failed to get raw topic:', { topicId, error }) + return undefined + } +} + +/** + * Update file reference count + * Only applies to Dexie data source, no-op for agent sessions + */ +export const updateFileCount = async (fileId: string, delta: number, deleteIfZero: boolean = false): Promise => { + try { + // Pass all parameters to dbService, including deleteIfZero + await dbService.updateFileCount(fileId, delta, deleteIfZero) + logger.silly('Updated file count', { fileId, delta, deleteIfZero }) + } catch (error) { + logger.error('Failed to update file count:', { fileId, delta, error }) + throw error + } +} + +/** + * Delete a single message from database + */ +export const deleteMessageFromDB = async (topicId: string, messageId: string): Promise => { + try { + await dbService.deleteMessage(topicId, messageId) + logger.silly('Deleted message via DbService', { topicId, messageId }) + } catch (error) { + logger.error('Failed to delete message:', { topicId, messageId, error }) + throw error + } +} + +/** + * Delete multiple messages from database + */ +export const deleteMessagesFromDB = async (topicId: string, messageIds: string[]): Promise => { + try { + await dbService.deleteMessages(topicId, messageIds) + logger.silly('Deleted messages via DbService', { topicId, count: messageIds.length }) + } catch (error) { + logger.error('Failed to delete messages:', { topicId, messageIds, error }) + throw error + } +} + +/** + * Clear all messages from a topic + */ +export const clearMessagesFromDB = async (topicId: string): Promise => { + try { + await dbService.clearMessages(topicId) + logger.silly('Cleared all messages via DbService', { topicId }) + } catch (error) { + logger.error('Failed to clear messages:', { topicId, error }) + throw error + } +} + +/** + * Save a message and its blocks to database + * Uses unified interface, no need for isAgentSessionTopicId check + */ +export const saveMessageAndBlocksToDB = async ( + topicId: string, + message: Message, + blocks: MessageBlock[], + messageIndex: number = -1 +): Promise => { + try { + const blockIds = blocks.map((block) => block.id) + const shouldSyncBlocks = + blockIds.length > 0 && (!message.blocks || blockIds.some((id, index) => message.blocks?.[index] !== id)) + + const messageWithBlocks = shouldSyncBlocks ? { ...message, blocks: blockIds } : message + // Direct call without conditional logic, now with messageIndex + await dbService.appendMessage(topicId, messageWithBlocks, blocks, messageIndex) + logger.silly('Saved message and blocks via DbService', { + topicId, + messageId: message.id, + blockCount: blocks.length, + messageIndex + }) + } catch (error) { + logger.error('Failed to save message and blocks:', { topicId, messageId: message.id, error }) + throw error + } +} + +/** + * Update a message in the database + */ +export const updateMessage = async (topicId: string, messageId: string, updates: Partial): Promise => { + try { + await dbService.updateMessage(topicId, messageId, updates) + logger.silly('Updated message via DbService', { topicId, messageId }) + } catch (error) { + logger.error('Failed to update message:', { topicId, messageId, error }) + throw error + } +} + +/** + * Update a single message block + */ +export const updateSingleBlock = async (blockId: string, updates: Partial): Promise => { + try { + await dbService.updateSingleBlock(blockId, updates) + logger.silly('Updated single block via DbService', { blockId }) + } catch (error) { + logger.error('Failed to update single block:', { blockId, error }) + throw error + } +} + +/** + * Bulk add message blocks (for new blocks) + */ +export const bulkAddBlocks = async (blocks: MessageBlock[]): Promise => { + try { + await dbService.bulkAddBlocks(blocks) + logger.silly('Bulk added blocks via DbService', { count: blocks.length }) + } catch (error) { + logger.error('Failed to bulk add blocks:', { count: blocks.length, error }) + throw error + } +} + +/** + * Update multiple message blocks (upsert operation) + */ +export const updateBlocks = async (blocks: MessageBlock[]): Promise => { + try { + await dbService.updateBlocks(blocks) + logger.silly('Updated blocks via DbService', { count: blocks.length }) + } catch (error) { + logger.error('Failed to update blocks:', { count: blocks.length, error }) + throw error + } +} diff --git a/src/renderer/src/store/thunk/messageThunk.v2.ts b/src/renderer/src/store/thunk/messageThunk.v2.ts deleted file mode 100644 index 587a9baf68..0000000000 --- a/src/renderer/src/store/thunk/messageThunk.v2.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * @deprecated Scheduled for removal in v2.0.0 - * -------------------------------------------------------------------------- - * ⚠️ NOTICE: V2 DATA&UI REFACTORING (by 0xfullex) - * -------------------------------------------------------------------------- - * STOP: Feature PRs affecting this file are currently BLOCKED. - * Only critical bug fixes are accepted during this migration phase. - * - * This file is being refactored to v2 standards. - * Any non-critical changes will conflict with the ongoing work. - * - * 🔗 Context & Status: - * - Contribution Hold: https://github.com/CherryHQ/cherry-studio/issues/10954 - * - v2 Refactor PR : https://github.com/CherryHQ/cherry-studio/pull/10162 - * -------------------------------------------------------------------------- - */ -/** - * V2 implementations of message thunk functions using the unified DbService - * These implementations will be gradually rolled out using feature flags - */ - -import { loggerService } from '@logger' -import { dbService } from '@renderer/services/db' -import type { Message, MessageBlock } from '@renderer/types/newMessage' - -import type { AppDispatch, RootState } from '../index' -import { upsertManyBlocks } from '../messageBlock' -import { newMessagesActions } from '../newMessage' - -const logger = loggerService.withContext('MessageThunkV2') - -// ================================================================= -// Phase 2.1 - Batch 1: Read-only operations (lowest risk) -// ================================================================= - -/** - * Load messages for a topic using unified DbService - * This is the V2 implementation that will replace the original - */ -export const loadTopicMessagesThunkV2 = - (topicId: string, forceReload: boolean = false) => - async (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState() - - dispatch(newMessagesActions.setCurrentTopicId(topicId)) - - // Skip if already cached and not forcing reload - if (!forceReload && state.messages.messageIdsByTopic[topicId]) { - return - } - - try { - dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true })) - - // Unified call - no need to check isAgentSessionTopicId - const { messages, blocks } = await dbService.fetchMessages(topicId) - - logger.silly('Loaded messages via DbService', { - topicId, - messageCount: messages.length, - blockCount: blocks.length - }) - - // Update Redux state with fetched data - if (blocks.length > 0) { - dispatch(upsertManyBlocks(blocks)) - } - dispatch(newMessagesActions.messagesReceived({ topicId, messages })) - } catch (error) { - logger.error(`Failed to load messages for topic ${topicId}:`, error as Error) - // Could dispatch an error action here if needed - } finally { - dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) - } - } - -/** - * Get raw topic data using unified DbService - * Returns topic with messages array - */ -export const getRawTopicV2 = async (topicId: string): Promise<{ id: string; messages: Message[] } | undefined> => { - try { - const rawTopic = await dbService.getRawTopic(topicId) - logger.silly('Retrieved raw topic via DbService', { topicId, found: !!rawTopic }) - return rawTopic - } catch (error) { - logger.error('Failed to get raw topic:', { topicId, error }) - return undefined - } -} - -// ================================================================= -// Phase 2.2 - Batch 2: Helper functions -// ================================================================= - -/** - * Update file reference count - * Only applies to Dexie data source, no-op for agent sessions - */ -export const updateFileCountV2 = async ( - fileId: string, - delta: number, - deleteIfZero: boolean = false -): Promise => { - try { - // Pass all parameters to dbService, including deleteIfZero - await dbService.updateFileCount(fileId, delta, deleteIfZero) - logger.silly('Updated file count', { fileId, delta, deleteIfZero }) - } catch (error) { - logger.error('Failed to update file count:', { fileId, delta, error }) - throw error - } -} - -// ================================================================= -// Phase 2.3 - Batch 3: Delete operations -// ================================================================= - -/** - * Delete a single message from database - */ -export const deleteMessageFromDBV2 = async (topicId: string, messageId: string): Promise => { - try { - await dbService.deleteMessage(topicId, messageId) - logger.silly('Deleted message via DbService', { topicId, messageId }) - } catch (error) { - logger.error('Failed to delete message:', { topicId, messageId, error }) - throw error - } -} - -/** - * Delete multiple messages from database - */ -export const deleteMessagesFromDBV2 = async (topicId: string, messageIds: string[]): Promise => { - try { - await dbService.deleteMessages(topicId, messageIds) - logger.silly('Deleted messages via DbService', { topicId, count: messageIds.length }) - } catch (error) { - logger.error('Failed to delete messages:', { topicId, messageIds, error }) - throw error - } -} - -/** - * Clear all messages from a topic - */ -export const clearMessagesFromDBV2 = async (topicId: string): Promise => { - try { - await dbService.clearMessages(topicId) - logger.silly('Cleared all messages via DbService', { topicId }) - } catch (error) { - logger.error('Failed to clear messages:', { topicId, error }) - throw error - } -} - -// ================================================================= -// Phase 2.4 - Batch 4: Complex write operations -// ================================================================= - -/** - * Save a message and its blocks to database - * Uses unified interface, no need for isAgentSessionTopicId check - */ -export const saveMessageAndBlocksToDBV2 = async ( - topicId: string, - message: Message, - blocks: MessageBlock[], - messageIndex: number = -1 -): Promise => { - try { - const blockIds = blocks.map((block) => block.id) - const shouldSyncBlocks = - blockIds.length > 0 && (!message.blocks || blockIds.some((id, index) => message.blocks?.[index] !== id)) - - const messageWithBlocks = shouldSyncBlocks ? { ...message, blocks: blockIds } : message - // Direct call without conditional logic, now with messageIndex - await dbService.appendMessage(topicId, messageWithBlocks, blocks, messageIndex) - logger.silly('Saved message and blocks via DbService', { - topicId, - messageId: message.id, - blockCount: blocks.length, - messageIndex - }) - } catch (error) { - logger.error('Failed to save message and blocks:', { topicId, messageId: message.id, error }) - throw error - } -} - -// Note: sendMessageV2 would be implemented here but it's more complex -// and would require more of the supporting code from messageThunk.ts - -// ================================================================= -// Phase 2.5 - Batch 5: Update operations -// ================================================================= - -/** - * Update a message in the database - */ -export const updateMessageV2 = async (topicId: string, messageId: string, updates: Partial): Promise => { - try { - await dbService.updateMessage(topicId, messageId, updates) - logger.silly('Updated message via DbService', { topicId, messageId }) - } catch (error) { - logger.error('Failed to update message:', { topicId, messageId, error }) - throw error - } -} - -/** - * Update a single message block - */ -export const updateSingleBlockV2 = async (blockId: string, updates: Partial): Promise => { - try { - await dbService.updateSingleBlock(blockId, updates) - logger.silly('Updated single block via DbService', { blockId }) - } catch (error) { - logger.error('Failed to update single block:', { blockId, error }) - throw error - } -} - -/** - * Bulk add message blocks (for new blocks) - */ -export const bulkAddBlocksV2 = async (blocks: MessageBlock[]): Promise => { - try { - await dbService.bulkAddBlocks(blocks) - logger.silly('Bulk added blocks via DbService', { count: blocks.length }) - } catch (error) { - logger.error('Failed to bulk add blocks:', { count: blocks.length, error }) - throw error - } -} - -/** - * Update multiple message blocks (upsert operation) - */ -export const updateBlocksV2 = async (blocks: MessageBlock[]): Promise => { - try { - await dbService.updateBlocks(blocks) - logger.silly('Updated blocks via DbService', { count: blocks.length }) - } catch (error) { - logger.error('Failed to update blocks:', { count: blocks.length, error }) - throw error - } -} From 2a3955919ef8b0234d3a904a7a2bdcc6f5f7f94c Mon Sep 17 00:00:00 2001 From: Zhaolin Liang Date: Sat, 3 Jan 2026 17:20:52 +0800 Subject: [PATCH 078/116] fix: prevent crash when switching between agent and assistant (#12252) --- src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx index 08c47311fb..ad75b75e57 100644 --- a/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx +++ b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx @@ -96,7 +96,7 @@ export const TopicManagePanel: React.FC = ({ // Topics that can be selected (non-pinned, and filtered when in search mode) const selectableTopics = useMemo(() => { const baseTopics = isSearchMode ? filteredTopics : assistant.topics - return baseTopics.filter((topic) => !topic.pinned) + return (baseTopics ?? []).filter((topic) => !topic.pinned) }, [assistant.topics, filteredTopics, isSearchMode]) // Check if all selectable topics are selected From 01d8888601b7985ac6d6d616080961047473c3db Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 18:52:11 +0800 Subject: [PATCH 079/116] feat: extend UpdateMessageDto with traceId and stats fields - Added optional fields `traceId` and `stats` to the `UpdateMessageDto` interface for enhanced message tracking and statistics. - Updated `MessageService` to handle the new fields during message updates, ensuring they are correctly processed in the database. --- packages/shared/data/api/schemas/messages.ts | 4 ++++ src/main/data/services/MessageService.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index b952156da5..a2462ca489 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -62,6 +62,10 @@ export interface UpdateMessageDto { siblingsGroupId?: number /** Update status */ status?: MessageStatus + /** Update trace ID */ + traceId?: string | null + /** Update statistics */ + stats?: MessageStats | null } /** diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 5f1c05e749..7542ffc02b 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -558,6 +558,8 @@ export class MessageService { if (dto.parentId !== undefined) updates.parentId = dto.parentId if (dto.siblingsGroupId !== undefined) updates.siblingsGroupId = dto.siblingsGroupId if (dto.status !== undefined) updates.status = dto.status + if (dto.traceId !== undefined) updates.traceId = dto.traceId + if (dto.stats !== undefined) updates.stats = dto.stats const [row] = await tx.update(messageTable).set(updates).where(eq(messageTable.id, id)).returning() From 56cf3479096940fc93b6a2ba71e77be0ed741c9f Mon Sep 17 00:00:00 2001 From: Nicolae Fericitu <118419291+NicolaeFericitu@users.noreply.github.com> Date: Sat, 3 Jan 2026 15:22:19 +0200 Subject: [PATCH 080/116] feat(i18n): add professional Romanian localization (ro-RO) (#12216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): add Romanian localization (ro-RO) Added the ro-ro.json file to provide Romanian language support for the Cherry Studio interface. This commit introduces a high-quality, professional translation for the Romanian language. The localization has been carefully reviewed to ensure linguistic accuracy and terminology consistency, so no further adjustments from other contributors are required at this stage. Thank you! * chore: move ro-ro.json to translate folder and register in index.ts Moved the Romanian translation file to the translate directory and updated the i18n index to support the automated workflow as requested. * Delete src/renderer/src/i18n/locales/ro-ro.json * chore: add ro-ro.json to translate folder Finalized the relocation of the Romanian translation file to the translate directory to support the automated i18n workflow. * chore(i18n): remove trailing comma in index.ts Biome formatter removed trailing comma for consistency. * feat(i18n): add Romanian (ro-RO) to language selector Add Romanian language option in settings with Română label and 🇷🇴 flag. * fix(i18n): add Romanian language support - Add ro-RO to LanguageVarious type - Add Romanian to language selector in settings - Add emoji picker fallback to English (no Romanian CLDR data) * feat: Add Romanian to auto-translation language map * fix: Add Romanian language support in main * fix: Add Romanian (ro-RO) locale support for AntdProvider * fix: Add Romanian language support to smooth stream segmenter * fix: Add Romanian translations for assistant preset groups --------- Co-authored-by: George·Dong Co-authored-by: icarus --- scripts/auto-translate-i18n.ts | 3 +- src/main/utils/locales.ts | 4 +- .../src/components/EmojiPicker/index.tsx | 3 + src/renderer/src/context/AntdProvider.tsx | 3 + src/renderer/src/hooks/useSmoothStream.ts | 2 +- src/renderer/src/i18n/index.ts | 4 +- src/renderer/src/i18n/translate/ro-ro.json | 5098 +++++++++++++++++ .../src/pages/settings/GeneralSettings.tsx | 3 +- .../assistantPresetGroupTranslations.ts | 103 +- src/renderer/src/types/index.ts | 1 + 10 files changed, 5185 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/i18n/translate/ro-ro.json diff --git a/scripts/auto-translate-i18n.ts b/scripts/auto-translate-i18n.ts index f57913b014..41bb14a0a1 100644 --- a/scripts/auto-translate-i18n.ts +++ b/scripts/auto-translate-i18n.ts @@ -152,7 +152,8 @@ const languageMap = { 'es-es': 'Spanish', 'fr-fr': 'French', 'pt-pt': 'Portuguese', - 'de-de': 'German' + 'de-de': 'German', + 'ro-ro': 'Romanian' } const PROMPT = ` diff --git a/src/main/utils/locales.ts b/src/main/utils/locales.ts index b41cba7c75..afaf48b20f 100644 --- a/src/main/utils/locales.ts +++ b/src/main/utils/locales.ts @@ -8,6 +8,7 @@ import esES from '../../renderer/src/i18n/translate/es-es.json' import frFR from '../../renderer/src/i18n/translate/fr-fr.json' import JaJP from '../../renderer/src/i18n/translate/ja-jp.json' import ptPT from '../../renderer/src/i18n/translate/pt-pt.json' +import roRO from '../../renderer/src/i18n/translate/ro-ro.json' import RuRu from '../../renderer/src/i18n/translate/ru-ru.json' const locales = Object.fromEntries( @@ -21,7 +22,8 @@ const locales = Object.fromEntries( ['el-GR', elGR], ['es-ES', esES], ['fr-FR', frFR], - ['pt-PT', ptPT] + ['pt-PT', ptPT], + ['ro-RO', roRO] ].map(([locale, translation]) => [locale, { translation }]) ) diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index 9a4158d469..c0a21e7c3c 100644 --- a/src/renderer/src/components/EmojiPicker/index.tsx +++ b/src/renderer/src/components/EmojiPicker/index.tsx @@ -45,6 +45,7 @@ const i18nMap: Record = { 'fr-FR': fr, 'ja-JP': ja, 'pt-PT': pt_PT, + 'ro-RO': en, // No Romanian available, fallback to English 'ru-RU': ru_RU } @@ -60,6 +61,7 @@ const dataSourceMap: Record = { 'fr-FR': dataFR, 'ja-JP': dataJA, 'pt-PT': dataPT, + 'ro-RO': dataEN, // No Romanian CLDR available, fallback to English 'ru-RU': dataRU } @@ -75,6 +77,7 @@ const localeMap: Record = { 'fr-FR': 'fr', 'ja-JP': 'ja', 'pt-PT': 'pt', + 'ro-RO': 'en', 'ru-RU': 'ru' } diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 685b3b0fbd..7fa1fbd79a 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -8,6 +8,7 @@ import esES from 'antd/locale/es_ES' import frFR from 'antd/locale/fr_FR' import jaJP from 'antd/locale/ja_JP' import ptPT from 'antd/locale/pt_PT' +import roRO from 'antd/locale/ro_RO' import ruRU from 'antd/locale/ru_RU' import zhCN from 'antd/locale/zh_CN' import zhTW from 'antd/locale/zh_TW' @@ -141,6 +142,8 @@ function getAntdLocale(language: LanguageVarious) { return frFR case 'pt-PT': return ptPT + case 'ro-RO': + return roRO default: return zhCN } diff --git a/src/renderer/src/hooks/useSmoothStream.ts b/src/renderer/src/hooks/useSmoothStream.ts index 2fffd92b8f..0c96f1b25e 100644 --- a/src/renderer/src/hooks/useSmoothStream.ts +++ b/src/renderer/src/hooks/useSmoothStream.ts @@ -7,7 +7,7 @@ interface UseSmoothStreamOptions { initialText?: string } -const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT'] +const languages = ['en-US', 'de-DE', 'es-ES', 'zh-CN', 'zh-TW', 'ja-JP', 'ru-RU', 'el-GR', 'fr-FR', 'pt-PT', 'ro-RO'] const segmenter = new Intl.Segmenter(languages) export const useSmoothStream = ({ onUpdate, streamDone, minDelay = 10, initialText = '' }: UseSmoothStreamOptions) => { diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index f2e6e7424f..5b8e1cc7ac 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -14,6 +14,7 @@ import esES from './translate/es-es.json' import frFR from './translate/fr-fr.json' import jaJP from './translate/ja-jp.json' import ptPT from './translate/pt-pt.json' +import roRO from './translate/ro-ro.json' import ruRU from './translate/ru-ru.json' const logger = loggerService.withContext('I18N') @@ -29,7 +30,8 @@ const resources = Object.fromEntries( ['el-GR', elGR], ['es-ES', esES], ['fr-FR', frFR], - ['pt-PT', ptPT] + ['pt-PT', ptPT], + ['ro-RO', roRO] ].map(([locale, translation]) => [locale, { translation }]) ) diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json new file mode 100644 index 0000000000..d74844f208 --- /dev/null +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -0,0 +1,5098 @@ +{ + "agent": { + "add": { + "description": "Gestionează sarcini complexe cu diverse instrumente", + "error": { + "failed": "Nu s-a putut adăuga un agent", + "invalid_agent": "Agent invalid" + }, + "model": { + "tooltip": "Momentan, doar modelele care acceptă endpoint-uri Anthropic sunt disponibile pentru funcția Agent." + }, + "title": "Adaugă agent", + "type": { + "placeholder": "Selectează un tip de agent" + } + }, + "delete": { + "content": "Ștergerea agentului va opri forțat și va șterge toate sesiunile asociate cu agentul. Ești sigur?", + "error": { + "failed": "Nu s-a putut șterge agentul" + }, + "title": "Șterge agentul" + }, + "edit": { + "title": "Editează agentul" + }, + "get": { + "error": { + "failed": "Nu s-a putut obține agentul.", + "null_id": "ID-ul agentului este nul." + } + }, + "gitBash": { + "autoDetected": "Se folosește Git Bash detectat automat", + "autoDiscoveredHint": "Descoperit automat", + "clear": { + "button": "Șterge calea personalizată" + }, + "customPath": "Se folosește calea personalizată: {{path}}", + "error": { + "description": "Git Bash este necesar pentru a rula agenți pe Windows. Agentul nu poate funcționa fără acesta. Te rugăm să instalezi Git pentru Windows de la", + "recheck": "Verifică din nou instalarea Git Bash", + "required": "Calea Git Bash este necesară pe Windows", + "title": "Git Bash este necesar" + }, + "found": { + "title": "Git Bash configurat" + }, + "notFound": "Git Bash nu a fost găsit. Te rugăm să-l instalezi mai întâi.", + "pick": { + "button": "Selectează calea Git Bash", + "failed": "Nu s-a putut seta calea Git Bash", + "invalidPath": "Fișierul selectat nu este un executabil Git Bash valid (bash.exe).", + "title": "Selectează executabilul Git Bash" + }, + "placeholder": "Selectează calea bash.exe", + "success": "Git Bash a fost detectat cu succes!", + "tooltip": "Git Bash este necesar pentru a rula agenți pe Windows. Instalează-l de pe git-scm.com dacă nu este disponibil." + }, + "input": { + "placeholder": "Introdu mesajul aici, trimite cu {{key}} - @ selectează calea, / selectează comanda" + }, + "list": { + "error": { + "failed": "Nu s-a putut afișa lista de agenți." + } + }, + "server": { + "error": { + "not_running": "Serverul API este activat, dar nu rulează corect." + } + }, + "session": { + "accessible_paths": { + "add": "Adaugă director", + "duplicate": "Acest director este deja inclus.", + "empty": "Selectează cel puțin un director pe care agentul îl poate accesa.", + "error": { + "at_least_one": "Te rugăm să selectezi cel puțin un director accesibil." + }, + "label": "Directoare accesibile", + "select_failed": "Nu s-a putut selecta directorul." + }, + "add": { + "title": "Adaugă o sesiune" + }, + "allowed_tools": { + "empty": "Niciun instrument disponibil pentru acest agent.", + "helper": "Instrumentele pre-aprobate rulează fără aprobare manuală. Instrumentele neselectate necesită aprobare înainte de utilizare.", + "label": "Instrumente pre-aprobate", + "placeholder": "Selectează instrumente pre-aprobate" + }, + "create": { + "error": { + "failed": "Nu s-a putut adăuga o sesiune" + } + }, + "delete": { + "content": "Ești sigur că vrei să ștergi această sesiune?", + "error": { + "failed": "Nu s-a putut șterge sesiunea", + "last": "Trebuie păstrată cel puțin o sesiune" + }, + "title": "Șterge sesiunea" + }, + "edit": { + "title": "Editează sesiunea" + }, + "get": { + "error": { + "failed": "Nu s-a putut obține sesiunea", + "null_id": "ID-ul sesiunii este nul" + } + }, + "label_one": "Sesiune", + "label_other": "Sesiuni", + "update": { + "error": { + "failed": "Nu s-a putut actualiza sesiunea" + } + } + }, + "settings": { + "advance": { + "maxTurns": { + "description": "Definește câte cicluri cerere/răspuns poate finaliza automat agentul.", + "helper": "Valorile mai mari permit rulări autonome mai lungi; valorile mai mici mențin sesiunile scurte.", + "label": "Limită de schimburi în conversație" + }, + "permissionMode": { + "description": "Controlează modul în care agentul gestionează acțiunile care necesită aprobare.", + "label": "Mod de permisiune", + "options": { + "acceptEdits": "Acceptă automat editările", + "bypassPermissions": "Omite verificările de permisiune", + "default": "Implicit (întreabă înainte de a continua)", + "plan": "Mod de planificare (necesită aprobarea planului)" + }, + "placeholder": "Alege un comportament pentru permisiuni" + }, + "title": "Setări avansate" + }, + "essential": "Setări esențiale", + "plugins": { + "available": { + "title": "Pluginuri disponibile" + }, + "confirm": { + "uninstall": "Ești sigur că vrei să dezinstalezi acest plugin?" + }, + "empty": { + "available": "Nu s-au găsit pluginuri care să corespundă filtrelor tale. Încearcă să ajustezi căutarea sau filtrele de categorie." + }, + "error": { + "install": "Nu s-a putut instala pluginul", + "load": "Nu s-au putut încărca pluginurile", + "uninstall": "Nu s-a putut dezinstala pluginul" + }, + "filter": { + "all": "Toate categoriile" + }, + "install": "Instalează", + "installed": { + "empty": "Niciun plugin instalat încă. Răsfoiește pluginurile disponibile pentru a începe.", + "title": "Pluginuri instalate" + }, + "installing": "Se instalează...", + "results": "{{count}} plugin(uri) găsit(e)", + "search": { + "placeholder": "Caută pluginuri..." + }, + "success": { + "install": "Plugin instalat cu succes", + "uninstall": "Plugin dezinstalat cu succes" + }, + "tab": "Pluginuri", + "type": { + "agent": "Agent", + "agents": "Agenți", + "all": "Toate", + "command": "Comandă", + "commands": "Comenzi", + "skills": "Abilități" + }, + "uninstall": "Dezinstalează", + "uninstalling": "Se dezinstalează..." + }, + "prompt": "Setări prompt", + "tooling": { + "mcp": { + "description": "Conectează servere MCP pentru a debloca instrumente suplimentare pe care le poți aproba mai sus.", + "empty": "Nu au fost detectate servere MCP. Adaugă unul din pagina de setări MCP.", + "manageHint": "Ai nevoie de configurare avansată? Vizitează Setări → Servere MCP.", + "toggle": "Comută {{name}}" + }, + "permissionMode": { + "acceptEdits": { + "behavior": "Pre-aprobă instrumentele de sistem de fișiere de încredere, astfel încât editările să ruleze imediat.", + "description": "Editările de fișiere și operațiunile sistemului de fișiere sunt aprobate automat.", + "title": "Acceptă automat editările de fișiere" + }, + "bypassPermissions": { + "behavior": "Fiecare instrument este pre-aprobat automat.", + "description": "Toate solicitările de permisiune sunt omise — folosește cu precauție.", + "title": "Omite verificările de permisiune", + "warning": "Folosește cu precauție — toate instrumentele vor rula fără a cere aprobare." + }, + "confirmChange": { + "description": "Schimbarea modurilor actualizează instrumentele aprobate automat.", + "title": "Schimbi modul de permisiune?" + }, + "default": { + "behavior": "Instrumentele doar-pentru-citire sunt pre-aprobate automat.", + "description": "Instrumentele doar-pentru-citire sunt pre-aprobate; orice altceva necesită încă permisiune.", + "title": "Implicit (întreabă înainte de a continua)" + }, + "helper": "Alege cum gestionează agentul aprobările pentru instrumente.", + "placeholder": "Selectează modul de permisiune", + "plan": { + "behavior": "Setările implicite doar-pentru-citire sunt pre-aprobate, în timp ce execuția rămâne dezactivată.", + "description": "Partajează setul implicit de instrumente doar-pentru-citire, dar prezintă un plan înainte de execuție.", + "title": "Mod de planificare" + }, + "title": "Mod de permisiune" + }, + "preapproved": { + "autoBadge": "Adăugat de mod", + "autoDescription": "Acest instrument este aprobat automat de modul de permisiune curent.", + "empty": "Niciun instrument nu corespunde filtrelor tale.", + "mcpBadge": "Instrument MCP", + "requiresApproval": "Necesită aprobare când este dezactivat", + "search": "Caută instrumente", + "toggle": "Comută {{name}}", + "warning": { + "description": "Activează doar instrumentele în care ai încredere. Setările implicite ale modului sunt evidențiate automat.", + "title": "Instrumentele pre-aprobate rulează fără revizuire manuală." + } + }, + "review": { + "autoTools": "Auto: {{count}}", + "customTools": "Personalizat: {{count}}", + "helper": "Modificările se salvează automat. Ajustează pașii de mai sus oricând pentru a regla fin permisiunile.", + "mcp": "MCP: {{count}}", + "mode": "Mod: {{mode}}" + }, + "steps": { + "mcp": { + "title": "Servere MCP" + }, + "permissionMode": { + "title": "Pasul 1 · Mod de permisiune" + }, + "preapproved": { + "title": "Pasul 2 · Instrumente pre-aprobate" + }, + "review": { + "title": "Pasul 3 · Revizuire" + } + }, + "tab": "Instrumente și permisiuni" + }, + "tools": { + "approved": "aprobat", + "caution": "Instrumentele pre-aprobate ocolesc revizuirea umană. Activează doar instrumente de încredere.", + "description": "Alege ce instrumente pot rula fără aprobare manuală.", + "requiresPermission": "Necesită permisiune când nu este pre-aprobat.", + "tab": "Instrumente pre-aprobate", + "title": "Instrumente pre-aprobate", + "toggle": "{{defaultValue}}" + } + }, + "toolPermission": { + "aria": { + "allowRequest": "Permite cererea instrumentului", + "denyRequest": "Refuză cererea instrumentului", + "hideDetails": "Ascunde detaliile instrumentului", + "runWithOptions": "Rulează cu opțiuni suplimentare", + "showDetails": "Arată detaliile instrumentului" + }, + "button": { + "cancel": "Anulează", + "run": "Rulează" + }, + "confirmation": "Ești sigur că vrei să rulezi acest instrument Claude?", + "defaultDenyMessage": "Utilizatorul a refuzat permisiunea pentru acest instrument.", + "defaultDescription": "Execută cod sau acțiuni de sistem în mediul tău. Asigură-te că comanda pare sigură înainte de a o rula.", + "error": { + "sendFailed": "Nu s-a putut trimite decizia ta. Te rugăm să încerci din nou." + }, + "executing": "Se execută...", + "expired": "Expirat", + "inputPreview": "Previzualizare intrare instrument", + "pending": "În așteptare ({{seconds}}s)", + "permissionExpired": "Cererea de permisiune a expirat. Se așteaptă instrucțiuni noi...", + "requiresElevatedPermissions": "Acest instrument necesită permisiuni elevate.", + "suggestion": { + "permissionUpdateMultiple": "Aprobarea poate actualiza permisiunile mai multor sesiuni dacă ai ales să permiți întotdeauna acest instrument.", + "permissionUpdateSingle": "Aprobarea poate actualiza permisiunile sesiunii tale dacă ai ales să permiți întotdeauna acest instrument." + }, + "toast": { + "denied": "Cererea instrumentului a fost refuzată.", + "timeout": "Cererea instrumentului a expirat înainte de a primi aprobare." + }, + "toolPendingFallback": "Instrument", + "waiting": "Se așteaptă decizia privind permisiunea instrumentului..." + }, + "type": { + "label": "Tip agent", + "unknown": "Tip necunoscut" + }, + "update": { + "error": { + "failed": "Nu s-a putut actualiza agentul" + } + }, + "warning": { + "enable_server": "Activează serverul API pentru a folosi agenți." + } + }, + "apiServer": { + "actions": { + "copy": "Copiază", + "regenerate": "Regenerează", + "restart": { + "button": "Repornește", + "tooltip": "Repornește serverul" + }, + "start": "Pornește", + "stop": "Oprește" + }, + "authHeader": { + "title": "Header de autorizare" + }, + "authHeaderText": "Utilizează în header-ul Authorization:", + "configuration": "Configurare", + "description": "Expune capacitățile AI ale Cherry Studio prin API-uri HTTP compatibile cu OpenAI", + "documentation": { + "title": "Documentație API" + }, + "fields": { + "apiKey": { + "copyTooltip": "Copiază cheia API", + "description": "Token de autentificare securizat pentru acces API", + "label": "Cheie API", + "placeholder": "Cheia API va fi generată automat" + }, + "port": { + "description": "Numărul portului TCP pentru serverul HTTP (1000-65535)", + "helpText": "Oprește serverul pentru a schimba portul", + "label": "Port" + }, + "url": { + "copyTooltip": "Copiază URL-ul", + "label": "URL" + } + }, + "messages": { + "apiKeyCopied": "Cheia API a fost copiată în clipboard", + "apiKeyRegenerated": "Cheia API a fost regenerată", + "notEnabled": "Serverul API nu este activat.", + "operationFailed": "Operațiunea serverului API a eșuat: ", + "restartError": "Nu s-a putut reporni serverul API: ", + "restartFailed": "Repornirea serverului API a eșuat: ", + "restartSuccess": "Serverul API a repornit cu succes", + "startError": "Nu s-a putut porni serverul API: ", + "startSuccess": "Serverul API a pornit cu succes", + "stopError": "Nu s-a putut opri serverul API: ", + "stopSuccess": "Serverul API s-a oprit cu succes", + "urlCopied": "URL-ul serverului a fost copiat în clipboard" + }, + "status": { + "running": "Rulează", + "stopped": "Oprit" + }, + "title": "Server API" + }, + "appMenu": { + "about": "Despre", + "close": "Închide fereastra", + "copy": "Copiază", + "cut": "Taie", + "delete": "Șterge", + "documentation": "Documentație", + "edit": "Editare", + "feedback": "Feedback", + "file": "Fișier", + "forceReload": "Reîncărcare forțată", + "front": "Adu toate în față", + "help": "Ajutor", + "hide": "Ascunde", + "hideOthers": "Ascunde celelalte", + "minimize": "Minimizează", + "paste": "Lipește", + "quit": "Ieșire", + "redo": "Refă", + "releases": "Lansări", + "reload": "Reîncarcă", + "resetZoom": "Dimensiune reală", + "selectAll": "Selectează tot", + "services": "Servicii", + "toggleDevTools": "Comută instrumentele pentru dezvoltatori", + "toggleFullscreen": "Comută ecranul complet", + "undo": "Anulează", + "unhide": "Arată toate", + "view": "Vizualizare", + "website": "Site web", + "window": "Fereastră", + "zoom": "Zoom", + "zoomIn": "Mărește", + "zoomOut": "Micșorează" + }, + "assistants": { + "abbr": "Asistenți", + "clear": { + "content": "Golirea subiectului va șterge toate subiectele și fișierele din asistent. Ești sigur că vrei să continui?", + "title": "Șterge subiectele" + }, + "copy": { + "title": "Copiază asistentul" + }, + "delete": { + "content": "Ștergerea unui asistent va șterge toate subiectele și fișierele din cadrul asistentului. Ești sigur că vrei să-l ștergi?", + "title": "Șterge asistentul" + }, + "edit": { + "title": "Editează asistentul" + }, + "icon": { + "type": "Pictogramă asistent" + }, + "list": { + "showByList": "Vizualizare listă", + "showByTags": "Vizualizare etichete" + }, + "presets": { + "add": { + "button": "Adaugă la asistent", + "knowledge_base": { + "label": "Bază de cunoștințe", + "placeholder": "Selectează baza de cunoștințe" + }, + "name": { + "label": "Nume", + "placeholder": "Introdu numele" + }, + "prompt": { + "label": "Prompt", + "placeholder": "Introdu promptul", + "variables": { + "tip": { + "content": "{{date}}:\tDată\n{{time}}:\tOră\n{{datetime}}:\tDată și oră\n{{system}}:\tSistem de operare\n{{arch}}:\tArhitectură CPU\n{{language}}:\tLimbă\n{{model_name}}:\tNume model\n{{username}}:\tNume utilizator", + "title": "Variabile disponibile" + } + } + }, + "title": "Creează asistent", + "unsaved_changes_warning": "Ai modificări nesalvate. Ești sigur că vrei să închizi?" + }, + "delete": { + "popup": { + "content": "Ești sigur că vrei să ștergi acest asistent?" + } + }, + "edit": { + "model": { + "select": { + "title": "Selectează modelul" + } + }, + "title": "Editează asistentul" + }, + "export": { + "agent": "Exportă asistentul" + }, + "import": { + "button": "Importă", + "error": { + "fetch_failed": "Nu s-a putut prelua de la URL", + "file_required": "Te rugăm să selectezi mai întâi un fișier", + "invalid_format": "Format asistent invalid: lipsesc câmpuri obligatorii", + "url_required": "Te rugăm să introduci un URL" + }, + "file_filter": "Fișiere JSON", + "select_file": "Selectează fișierul", + "title": "Importă din exterior", + "type": { + "file": "Fișier", + "url": "URL" + }, + "url_placeholder": "Introdu URL JSON" + }, + "manage": { + "batch_delete": { + "button": "Șterge", + "confirm": "Ești sigur că vrei să ștergi cei {{count}} asistenți selectați?" + }, + "batch_export": { + "button": "Exportă" + }, + "mode": { + "manage": "Gestionează", + "sort": "Sortează" + }, + "title": "Gestionează asistenții" + }, + "my_agents": "Asistenții mei", + "search": { + "no_results": "Niciun rezultat găsit" + }, + "settings": { + "title": "Setare asistent" + }, + "sorting": { + "title": "Sortare" + }, + "tag": { + "agent": "Asistent", + "default": "Implicit", + "new": "Nou", + "system": "Sistem" + }, + "title": "Bibliotecă de asistenți" + }, + "save": { + "success": "Salvat cu succes", + "title": "Salvează în biblioteca de asistenți" + }, + "search": "Caută asistenți...", + "settings": { + "default_model": "Model implicit", + "knowledge_base": { + "label": "Setări bază de cunoștințe", + "recognition": { + "label": "Folosește baza de cunoștințe", + "off": "Forțează căutarea", + "on": "Recunoaștere intenție", + "tip": "Asistentul va folosi capacitatea de recunoaștere a intenției modelului mare pentru a determina dacă să folosească baza de cunoștințe pentru a răspunde. Această funcție depinde de capacitățile modelului" + } + }, + "mcp": { + "description": "Servere MCP activate implicit", + "enableFirst": "Activează mai întâi acest server în setările MCP", + "label": "Servere MCP", + "noServersAvailable": "Nu există servere MCP disponibile. Adaugă servere în setări", + "title": "Setări MCP" + }, + "model": "Setări model", + "more": "Setări asistent", + "prompt": "Setări prompt", + "reasoning_effort": { + "auto": "Auto", + "auto_description": "Determină flexibil efortul de raționament", + "default": "Implicit", + "default_description": "Depinde de comportamentul implicit al modelului, fără nicio configurare.", + "high": "Ridicat", + "high_description": "Raționament de nivel ridicat", + "label": "Efort de raționament", + "low": "Scăzut", + "low_description": "Raționament de nivel scăzut", + "medium": "Mediu", + "medium_description": "Raționament de nivel mediu", + "minimal": "Minim", + "minimal_description": "Raționament minim", + "off": "Oprit", + "off_description": "Dezactivează raționamentul", + "xhigh": "Extra ridicat", + "xhigh_description": "Raționament de nivel extra ridicat" + }, + "regular_phrases": { + "add": "Adaugă expresie", + "contentLabel": "Conținut", + "contentPlaceholder": "Te rugăm să introduci conținutul expresiei; poți folosi variabile și poți apăsa Tab pentru a localiza rapid variabila de modificat. De exemplu: \nAjută-mă să planific o rută de la ${from} până la ${to} și trimite-o la ${email}.", + "delete": "Șterge expresia", + "deleteConfirm": "Ești sigur că vrei să ștergi această expresie?", + "edit": "Editează expresia", + "title": "Expresie uzuală", + "titleLabel": "Titlu", + "titlePlaceholder": "Introdu titlul" + }, + "title": "Setări asistent", + "tool_use_mode": { + "function": "Funcție", + "label": "Mod utilizare instrumente", + "prompt": "Prompt" + } + }, + "tags": { + "add": "Adaugă etichetă", + "delete": "Șterge eticheta", + "deleteConfirm": "Ești sigur că vrei să ștergi această etichetă?", + "manage": "Gestionare etichete", + "modify": "Modifică eticheta", + "none": "Fără etichete", + "settings": { + "title": "Setări etichete" + }, + "untagged": "Neetichetat" + }, + "title": "Asistenți" + }, + "auth": { + "error": "Obținerea automată a cheii API a eșuat, te rugăm să o obții manual", + "get_key": "Obține", + "get_key_success": "Cheia API a fost obținută automat cu succes", + "login": "Autentificare", + "oauth_button": "Autentificare cu {{provider}}" + }, + "backup": { + "confirm": { + "button": "Selectează locația de backup", + "label": "Ești sigur că vrei să faci backup la date?" + }, + "content": "Se face backup la toate datele, inclusiv istoricul chat-ului, setările și baza de cunoștințe. Te rugăm să reții că procesul de backup poate dura ceva timp, îți mulțumim pentru răbdare.", + "progress": { + "completed": "Backup finalizat", + "compressing": "Se comprimă fișierele...", + "copying_files": "Se copiază fișierele... {{progress}}%", + "preparing": "Se pregătește backup-ul...", + "preparing_compression": "Se pregătește compresia...", + "title": "Progres backup", + "writing_data": "Se scriu datele..." + }, + "title": "Backup date" + }, + "button": { + "add": "Adaugă", + "added": "Adăugat", + "case_sensitive": "Sensibil la majuscule", + "collapse": "Restrânge", + "download": "Descarcă", + "includes_user_questions": "Include întrebările tale", + "manage": "Gestionează", + "select_model": "Selectează modelul", + "show": { + "all": "Arată tot" + }, + "update_available": "Actualizare disponibilă", + "whole_word": "Cuvânt întreg" + }, + "chat": { + "add": { + "assistant": { + "description": "Conversații zilnice și întrebări rapide", + "title": "Adaugă asistent" + }, + "option": { + "title": "Selectează tipul" + }, + "topic": { + "title": "Subiect nou" + } + }, + "artifacts": { + "button": { + "download": "Descarcă", + "openExternal": "Deschide în browser extern", + "preview": "Previzualizare" + }, + "preview": { + "openExternal": { + "error": { + "content": "Eroare la deschiderea browserului extern." + } + } + } + }, + "assistant": { + "search": { + "placeholder": "Caută" + } + }, + "deeply_thought": "Gândit profund ({{seconds}} secunde)", + "default": { + "description": "Salut, sunt Asistentul Implicit. Poți începe să discuți cu mine imediat", + "name": "Asistent Implicit", + "topic": { + "name": "Subiect implicit" + } + }, + "history": { + "assistant_node": "Asistent", + "click_to_navigate": "Fă clic pentru a naviga la mesaj", + "coming_soon": "Diagrama fluxului de chat va fi disponibilă în curând", + "no_messages": "Nu au fost găsite mesaje", + "start_conversation": "Începe o conversație pentru a vedea diagrama fluxului de chat", + "title": "Istoric chat", + "user_node": "Utilizator", + "view_full_content": "Vezi conținutul complet" + }, + "input": { + "activity_directory": { + "description": "Selectează fișierul din directorul de activitate", + "loading": "Se încarcă fișierele...", + "no_file_found": { + "description": "Nu există fișiere disponibile în directoarele accesibile", + "label": "Nu a fost găsit niciun fișier" + }, + "title": "Director de activitate" + }, + "auto_resize": "Redimensionare automată înălțime", + "clear": { + "content": "Vrei să ștergi toate mesajele subiectului curent?", + "label": "Șterge {{Command}}", + "title": "Ștergi toate mesajele?" + }, + "collapse": "Restrânge", + "context_count": { + "tip": "Context / Context maxim" + }, + "estimated_tokens": { + "tip": "Tokeni estimați" + }, + "expand": "Extinde", + "file_error": "Eroare la procesarea fișierului", + "file_not_supported": "Modelul nu acceptă acest tip de fișier", + "file_not_supported_count": "{{count}} fișiere nu sunt acceptate", + "generate_image": "Generează imagine", + "generate_image_not_supported": "Modelul nu acceptă generarea de imagini.", + "knowledge_base": "Bază de cunoștințe", + "new": { + "context": "Șterge contextul {{Command}}" + }, + "new_session": "Sesiune nouă {{Command}}", + "new_topic": "Subiect nou {{Command}}", + "paste_text_file_confirm": "Lipești în bara de introducere?", + "pause": "Pauză", + "placeholder": "Scrie mesajul tău aici, apasă {{key}} pentru a trimite - @ pentru a selecta modelul, / pentru a include instrumente", + "placeholder_without_triggers": "Scrie mesajul tău aici, apasă {{key}} pentru a trimite", + "send": "Trimite", + "settings": "Setări", + "slash_commands": { + "description": "Comenzi slash pentru sesiunea agentului", + "title": "Comenzi slash" + }, + "thinking": { + "budget_exceeds_max": "Bugetul de gândire depășește numărul maxim de tokeni", + "label": "Gândire", + "mode": { + "custom": { + "label": "Personalizat", + "tip": "Numărul maxim de tokeni pe care modelul îi poate gândi. Trebuie să iei în considerare limita de context a modelului, altfel va fi raportată o eroare" + }, + "default": { + "label": "Implicit", + "tip": "Modelul va determina automat numărul de tokeni pentru gândire" + }, + "tokens": { + "tip": "Setează numărul de tokeni de gândire de utilizat." + } + } + }, + "tools": { + "collapse": "Restrânge", + "collapse_in": "Restrânge", + "collapse_out": "Elimină din restrângere", + "expand": "Extinde" + }, + "topics": " Subiecte ", + "translate": "Tradu în {{target_language}}", + "translating": "Se traduce...", + "upload": { + "attachment": "Încarcă atașament", + "document": "Încarcă fișier document (modelul nu acceptă imagini)", + "image_or_document": "Încarcă imagine sau fișier document", + "upload_from_local": "Încarcă fișier local..." + }, + "url_context": "Context URL", + "web_search": { + "builtin": { + "disabled_content": "Modelul curent nu acceptă căutarea web", + "enabled_content": "Folosește funcția de căutare web integrată a modelului", + "label": "Integrat în model" + }, + "button": { + "ok": "Mergi la Setări" + }, + "enable": "Activează căutarea web", + "enable_content": "Trebuie să verifici mai întâi conectivitatea căutării web în setări", + "label": "Căutare web", + "no_web_search": { + "description": "Nu activa căutarea web", + "label": "Dezactivează căutarea web" + }, + "settings": "Setări căutare web" + } + }, + "mcp": { + "error": { + "parse_tool_call": "Nu se poate converti într-un format valid de apelare a instrumentului: {{toolCall}}" + }, + "warning": { + "gemini_web_search": "Gemini nu acceptă utilizarea simultană a instrumentelor native de căutare web și a apelării funcțiilor", + "multiple_tools": "Există mai multe instrumente MCP care se potrivesc, a fost selectat {{tool}}", + "no_tool": "Nu s-a găsit niciun instrument MCP potrivit pentru {{tool}}", + "url_context": "Gemini nu acceptă utilizarea simultană a contextului URL și a apelării funcțiilor" + } + }, + "message": { + "new": { + "branch": { + "created": "Ramură nouă creată", + "label": "Ramură nouă" + }, + "context": "Context nou" + }, + "quote": "Citează", + "regenerate": { + "model": "Schimbă modelul" + }, + "useful": { + "label": "Setează ca context", + "tip": "În acest grup de mesaje, acest mesaj va fi selectat pentru a se alătura contextului" + } + }, + "multiple": { + "select": { + "empty": "Niciun mesaj selectat", + "label": "Selecție multiplă" + } + }, + "navigation": { + "bottom": "Înapoi jos", + "close": "Închide", + "first": "Deja la primul mesaj", + "history": "Istoric chat", + "last": "Deja la ultimul mesaj", + "next": "Mesajul următor", + "prev": "Mesajul anterior", + "top": "Înapoi sus" + }, + "resend": "Retrimite", + "save": { + "file": { + "title": "Salvează în fișier local" + }, + "knowledge": { + "content": { + "citation": { + "description": "Include informații de referință din căutarea web și baza de cunoștințe", + "title": "Citări" + }, + "code": { + "description": "Include blocuri de cod independente", + "title": "Blocuri de cod" + }, + "error": { + "description": "Include mesaje de eroare din timpul execuției", + "title": "Erori" + }, + "file": { + "description": "Include fișierele atașate", + "title": "Fișiere" + }, + "maintext": { + "description": "Include conținutul textului principal", + "title": "Text principal" + }, + "thinking": { + "description": "Include conținutul raționamentului modelului", + "title": "Raționament" + }, + "tool_use": { + "description": "Include parametrii apelului instrumentului și rezultatele execuției", + "title": "Utilizare instrument" + }, + "translation": { + "description": "Include conținutul traducerii", + "title": "Traduceri" + } + }, + "empty": { + "no_content": "Acest mesaj nu are conținut care poate fi salvat", + "no_knowledge_base": "Nicio bază de cunoștințe disponibilă, te rugăm să creezi una mai întâi" + }, + "error": { + "invalid_base": "Baza de cunoștințe selectată nu este configurată corect", + "no_content_selected": "Te rugăm să selectezi cel puțin un tip de conținut", + "save_failed": "Salvarea a eșuat, te rugăm să verifici configurația bazei de cunoștințe" + }, + "select": { + "base": { + "placeholder": "Te rugăm să selectezi o bază de cunoștințe", + "title": "Selectează baza de cunoștințe" + }, + "content": { + "tip": "S-au selectat {{count}} elemente, tipurile de text vor fi îmbinate și salvate ca o singură notiță", + "title": "Selectează tipurile de conținut pentru salvare" + } + }, + "title": "Salvează în Baza de cunoștințe" + }, + "label": "Salvează", + "topic": { + "knowledge": { + "content": { + "maintext": { + "description": "Include titlul subiectului și conținutul textului principal din toate mesajele" + } + }, + "empty": { + "no_content": "Acest subiect nu are conținut care poate fi salvat" + }, + "error": { + "save_failed": "Nu s-a putut salva subiectul, te rugăm să verifici configurația bazei de cunoștințe" + }, + "loading": "Se analizează conținutul subiectului...", + "select": { + "content": { + "label": "Selectează tipurile de conținut pentru salvare", + "selected_tip": "S-au selectat {{count}} elemente din {{messages}} mesaje", + "tip": "Subiectul va fi salvat în baza de cunoștințe cu contextul complet al conversației" + } + }, + "success": "Subiect salvat cu succes în baza de cunoștințe ({{count}} elemente)", + "title": "Salvează subiectul în Baza de cunoștințe" + } + } + }, + "settings": { + "code": { + "title": "Setări blocuri de cod" + }, + "code_collapsible": "Bloc de cod restrâns", + "code_editor": { + "autocompletion": "Completare automată", + "fold_gutter": "Zonă de pliere", + "highlight_active_line": "Evidențiază linia activă", + "keymap": "Mapare taste", + "title": "Editor de cod" + }, + "code_execution": { + "timeout_minutes": { + "label": "Expirare", + "tip": "Timpul de expirare (minute) al execuției codului" + }, + "tip": "Butonul de rulare va fi afișat în bara de instrumente a blocurilor de cod executabile; te rugăm să nu execuți cod periculos!", + "title": "Execuție cod" + }, + "code_fancy_block": { + "label": "Bloc de cod stilizat", + "tip": "Activează stilul sofisticat pentru blocul de cod, de ex., card html" + }, + "code_image_tools": { + "label": "Activează instrumentele de previzualizare", + "tip": "Activează instrumentele de previzualizare pentru imaginile randate din blocuri de cod, cum ar fi mermaid" + }, + "code_wrappable": "Încadrare text în blocul de cod", + "context_count": { + "label": "Context", + "tip": "Numărul de mesaje anterioare de păstrat în context." + }, + "max": "Nelimitat", + "max_tokens": { + "confirm": "Setează tokeni maximi", + "confirm_content": "Setează numărul maxim de tokeni pe care modelul îi poate genera. Trebuie să iei în considerare limita de context a modelului, altfel va fi raportată o eroare", + "label": "Setează tokeni maximi", + "tip": "Numărul maxim de tokeni pe care modelul îi poate genera. Trebuie să iei în considerare limita de context a modelului, altfel va fi raportată o eroare" + }, + "reset": "Resetează", + "set_as_default": "Aplică la asistentul implicit", + "show_line_numbers": "Arată numerele de linie în cod", + "temperature": { + "label": "Temperatură", + "tip": "Valorile mai mari fac modelul mai creativ și imprevizibil, în timp ce valorile mai mici îl fac mai determinist și precis." + }, + "thought_auto_collapse": { + "label": "Restrânge conținutul gândirii", + "tip": "Restrânge automat conținutul gândirii după ce gândirea se termină" + }, + "top_p": { + "label": "Top-P", + "tip": "Valoarea implicită este 1; cu cât valoarea este mai mică, cu atât mai puțină varietate în răspunsuri și mai ușor de înțeles; cu cât valoarea este mai mare, cu atât gama de vocabular a AI-ului este mai largă și mai diversă" + } + }, + "suggestions": { + "title": "Întrebări sugerate" + }, + "thinking": "Gândire ({{seconds}} secunde)", + "topics": { + "auto_rename": "Redenumire automată", + "clear": { + "title": "Șterge mesajele" + }, + "copy": { + "image": "Copiază ca imagine", + "md": "Copiază ca markdown", + "plain_text": "Copiază ca text simplu (elimină Markdown)", + "title": "Copiază" + }, + "delete": { + "shortcut": "Ține apăsat {{key}} pentru a șterge direct" + }, + "edit": { + "placeholder": "Introdu noul nume", + "title": "Editează numele", + "title_tip": "Sfat: Fă dublu clic pe numele subiectului pentru a-l redenumi direct" + }, + "export": { + "image": "Exportă ca imagine", + "joplin": "Exportă în Joplin", + "md": { + "label": "Exportă ca markdown", + "reason": "Exportă ca Markdown (cu raționament)" + }, + "notes": "Exportă în Note", + "notion": "Exportă în Notion", + "obsidian": "Exportă în Obsidian", + "obsidian_atributes": "Configurează atributele notiței", + "obsidian_btn": "Confirmă", + "obsidian_created": "Ora creării", + "obsidian_created_placeholder": "Te rugăm să selectezi ora creării", + "obsidian_export_failed": "Exportul a eșuat", + "obsidian_export_success": "Export reușit", + "obsidian_fetch_error": "Nu s-au putut prelua seifurile Obsidian", + "obsidian_fetch_folders_error": "Nu s-a putut prelua structura folderelor", + "obsidian_loading": "Se încarcă...", + "obsidian_no_vault_selected": "Te rugăm să selectezi mai întâi un seif", + "obsidian_no_vaults": "Nu s-au găsit seifuri Obsidian", + "obsidian_operate": "Metodă de operare", + "obsidian_operate_append": "Adaugă la sfârșit", + "obsidian_operate_new_or_overwrite": "Creează nou (Suprascrie dacă există)", + "obsidian_operate_placeholder": "Te rugăm să selectezi metoda de operare", + "obsidian_operate_prepend": "Adaugă la început", + "obsidian_path": "Cale", + "obsidian_path_placeholder": "Te rugăm să selectezi calea", + "obsidian_reasoning": "Include lanțul de raționament", + "obsidian_root_directory": "Director rădăcină", + "obsidian_select_vault_first": "Te rugăm să selectezi mai întâi un seif", + "obsidian_source": "Sursă", + "obsidian_source_placeholder": "Te rugăm să introduci sursa", + "obsidian_tags": "Etichete", + "obsidian_tags_placeholder": "Te rugăm să introduci etichete, separă etichetele multiple prin virgule", + "obsidian_title": "Titlu", + "obsidian_title_placeholder": "Te rugăm să introduci titlul", + "obsidian_title_required": "Titlul nu poate fi gol", + "obsidian_vault": "Seif", + "obsidian_vault_placeholder": "Te rugăm să selectezi numele seifului", + "siyuan": "Exportă în Siyuan Note", + "title": "Exportă", + "title_naming_failed": "Nu s-a putut genera titlul, se folosește titlul implicit", + "title_naming_success": "Titlu generat cu succes", + "wait_for_title_naming": "Se generează titlul...", + "word": "Exportă ca Word", + "yuque": "Exportă în Yuque" + }, + "list": "Listă subiecte", + "manage": { + "clear_selection": "Șterge selecția", + "delete": { + "confirm": { + "content": "Ești sigur că vrei să ștergi {{count}} subiecte selectate? Această acțiune nu poate fi anulată.", + "title": "Șterge subiecte" + }, + "success": "S-au șters {{count}} subiecte" + }, + "deselect_all": "Deselectează tot", + "error": { + "at_least_one": "Trebuie păstrat cel puțin un subiect" + }, + "move": { + "button": "Mută", + "placeholder": "Selectează asistentul țintă", + "success": "S-au mutat {{count}} subiecte" + }, + "pinned": "Subiecte fixate", + "selected_count": "{{count}} selectate", + "title": "Gestionează subiectele", + "unpinned": "Subiecte nefixate" + }, + "move_to": "Mută la", + "new": "Subiect nou", + "pin": "Fixează subiectul", + "prompt": { + "edit": { + "title": "Editează prompturile subiectului" + }, + "label": "Prompturi subiect", + "tips": "Prompturi subiect: Prompturi suplimentare furnizate pentru subiectul curent" + }, + "search": { + "placeholder": "Caută subiecte...", + "title": "Caută" + }, + "title": "Subiecte", + "unpin": "Detașează subiectul" + }, + "translate": "Tradu", + "web_search": { + "warning": { + "openai": "Efortul minim de raționament al modelului GPT-5 nu acceptă căutarea web." + } + } + }, + "code": { + "auto_update_to_latest": "Actualizează automat la cea mai recentă versiune", + "bun_required_message": "Mediul Bun este necesar pentru a rula instrumente CLI", + "cli_tool": "Instrument CLI", + "cli_tool_placeholder": "Selectează instrumentul CLI de utilizat", + "custom_path": "Cale personalizată", + "custom_path_error": "Nu s-a putut seta calea personalizată a terminalului", + "custom_path_required": "Calea personalizată este necesară pentru acest terminal", + "custom_path_set": "Calea personalizată a terminalului a fost setată cu succes", + "description": "Lansează rapid mai multe instrumente CLI de cod pentru a îmbunătăți eficiența dezvoltării", + "env_vars_help": "Introdu variabile de mediu personalizate (una pe rând, format: CHEIE=valoare)", + "environment_variables": "Variabile de mediu", + "folder_placeholder": "Selectează directorul de lucru", + "install_bun": "Instalează Bun", + "installing_bun": "Se instalează...", + "launch": { + "bun_required": "Te rugăm să instalezi mai întâi mediul Bun înainte de a lansa instrumentele CLI", + "error": "Lansarea a eșuat, te rugăm să încerci din nou", + "label": "Lansează", + "success": "Lansare reușită", + "validation_error": "Te rugăm să completezi toate câmpurile obligatorii: instrument CLI, model și director de lucru" + }, + "launching": "Se lansează...", + "model": "Model", + "model_placeholder": "Selectează modelul de utilizat", + "model_required": "Te rugăm să selectezi un model", + "select_folder": "Selectează folderul", + "set_custom_path": "Setează calea personalizată a terminalului", + "supported_providers": "Furnizori acceptați", + "terminal": "Terminal", + "terminal_placeholder": "Selectează aplicația terminal", + "title": "Instrumente de cod", + "update_options": "Opțiuni de actualizare", + "working_directory": "Director de lucru" + }, + "code_block": { + "collapse": "Restrânge", + "copy": { + "failed": "Copiere eșuată", + "label": "Copiază", + "source": "Copiază codul sursă", + "success": "Copiat" + }, + "download": { + "failed": { + "network": "Descărcarea a eșuat, te rugăm să verifici rețeaua" + }, + "label": "Descarcă", + "png": "Descarcă PNG", + "source": "Descarcă codul sursă", + "svg": "Descarcă SVG" + }, + "edit": { + "label": "Editează", + "save": { + "failed": { + "label": "Salvare eșuată", + "message_not_found": "Salvare eșuată, mesajul nu a fost găsit" + }, + "label": "Salvează modificările", + "success": "Salvat" + } + }, + "expand": "Extinde", + "more": "Mai mult", + "run": "Rulează", + "split": { + "label": "Vizualizare divizată", + "restore": "Restaurează vizualizarea divizată" + }, + "wrap": { + "off": "Nu încadra", + "on": "Încadrează" + } + }, + "common": { + "about": "Despre", + "add": "Adaugă", + "add_success": "Adăugat cu succes", + "advanced_settings": "Setări avansate", + "agent_one": "Agent", + "agent_other": "Agenți", + "and": "și", + "assistant": "Agent", + "assistant_one": "Asistent", + "assistant_other": "Asistenți", + "avatar": "Avatar", + "back": "Înapoi", + "browse": "Răsfoiește", + "cancel": "Anulează", + "chat": "Chat", + "clear": "Golește", + "close": "Închide", + "collapse": "Restrânge", + "completed": "Finalizat", + "confirm": "Confirmă", + "copied": "Copiat", + "copy": "Copiază", + "copy_failed": "Copiere eșuată", + "current": "Curent", + "cut": "Taie", + "default": "Implicit", + "delete": "Șterge", + "delete_confirm": "Ești sigur că vrei să ștergi?", + "delete_failed": "Nu s-a putut șterge", + "delete_success": "Șters cu succes", + "description": "Descriere", + "detail": "Detaliu", + "disabled": "Dezactivat", + "docs": "Documentație", + "download": "Descarcă", + "duplicate": "Duplică", + "edit": "Editează", + "enabled": "Activat", + "error": "eroare", + "errors": { + "create_message": "Nu s-a putut crea mesajul", + "validation": "Verificarea a eșuat" + }, + "expand": "Extinde", + "file": { + "not_supported": "Tip de fișier neacceptat {{type}}" + }, + "footnote": "Conținut de referință", + "footnotes": "Referințe", + "fullscreen": "S-a intrat în modul ecran complet. Apasă F11 pentru a ieși", + "go_to_settings": "Mergi la setări", + "i_know": "Am înțeles", + "ignore": "Ignoră", + "inspect": "Inspectează", + "invalid_value": "Valoare invalidă", + "knowledge_base": "Bază de cunoștințe", + "language": "Limbă", + "loading": "Se încarcă...", + "model": "Model", + "models": "Modele", + "more": "Mai mult", + "name": "Nume", + "no_results": "Niciun rezultat", + "none": "Nimic", + "off": "Oprit", + "on": "Pornit", + "open": "Deschide", + "paste": "Lipește", + "placeholders": { + "select": { + "model": "Selectează un model" + } + }, + "preview": "Previzualizare", + "prompt": "Prompt", + "provider": "Furnizor", + "reasoning_content": "Raționament profund", + "refresh": "Reîmprospătează", + "regenerate": "Regenerează", + "rename": "Redenumește", + "reset": "Resetează", + "save": "Salvează", + "saved": "Salvat", + "search": "Caută", + "select": "Selectează", + "select_all": "Selectează tot", + "selected": "Selectat", + "selectedItems": "{{count}} elemente selectate", + "selectedMessages": "{{count}} mesaje selectate", + "settings": "Setări", + "sort": { + "pinyin": { + "asc": "Sortează după Pinyin (A-Z)", + "desc": "Sortează după Pinyin (Z-A)", + "label": "Sortează după Pinyin" + } + }, + "stop": "Oprește", + "subscribe": "Abonează-te", + "success": "Succes", + "swap": "Schimbă", + "topics": "Subiecte", + "unknown": "Necunoscut", + "unnamed": "Fără nume", + "unsubscribe": "Dezabonează-te", + "update_success": "Actualizat cu succes", + "upload_files": "Încarcă fișier", + "warning": "Avertisment", + "you": "Tu" + }, + "docs": { + "title": "Documentație" + }, + "endpoint_type": { + "anthropic": "Anthropic", + "gemini": "Gemini", + "image-generation": "Generare imagini (OpenAI)", + "jina-rerank": "Jina Rerank", + "openai": "OpenAI", + "openai-response": "OpenAI-Response" + }, + "error": { + "availableProviders": "Furnizori disponibili", + "availableTools": "Instrumente disponibile", + "backup": { + "file_format": "Eroare format fișier backup" + }, + "boundary": { + "default": { + "devtools": "Deschide panoul de depanare", + "message": "Se pare că ceva nu a mers bine...", + "reload": "Reîncarcă" + }, + "details": "Detalii", + "mcp": { + "invalid": "Server MCP invalid" + } + }, + "cause": "Cauză eroare", + "chat": { + "chunk": { + "non_json": "S-a returnat un format de date invalid" + }, + "insufficient_balance": "Te rugăm să mergi la {{provider}} pentru a reîncărca.", + "no_api_key": "Nu ai configurat o cheie API. Te rugăm să mergi la {{provider}} pentru a obține o cheie API.", + "quota_exceeded": "Cota ta gratuită zilnică {{quota}} a fost epuizată. Te rugăm să mergi la {{provider}} pentru a obține o cheie API și configurează cheia API pentru a continua utilizarea.", + "response": "Ceva nu a mers bine. Te rugăm să verifici dacă ai setat cheia API în Setări > Furnizori" + }, + "content": "Conținut", + "data": "Date", + "detail": "Detalii eroare", + "details": "Detalii", + "errors": "Erori", + "finishReason": "Motiv finalizare", + "functionality": "Funcționalitate", + "http": { + "400": "Cererea a eșuat. Te rugăm să verifici dacă parametrii cererii sunt corecți. Dacă ai modificat setările modelului, te rugăm să le resetezi la valorile implicite", + "401": "Autentificarea a eșuat. Te rugăm să verifici dacă cheia API este corectă", + "403": "Acces refuzat. Te rugăm să verifici dacă contul tău este verificat sau contactează furnizorul de servicii pentru mai multe informații", + "404": "Modelul nu a fost găsit sau calea cererii este incorectă", + "429": "Prea multe cereri. Te rugăm să încerci din nou mai târziu", + "500": "Eroare de server. Te rugăm să încerci din nou mai târziu", + "502": "Eroare gateway. Te rugăm să încerci din nou mai târziu", + "503": "Serviciu indisponibil. Te rugăm să încerci din nou mai târziu", + "504": "Expirare gateway. Te rugăm să încerci din nou mai târziu" + }, + "lastError": "Ultima eroare", + "maxEmbeddingsPerCall": "Max Embeddings per apel", + "message": "Mesaj de eroare", + "missing_user_message": "Nu se poate schimba răspunsul modelului: Mesajul original al utilizatorului a fost șters. Te rugăm să trimiți un mesaj nou pentru a primi un răspuns cu acest model.", + "model": { + "exists": "Modelul există deja", + "not_exists": "Modelul nu există" + }, + "modelId": "ID model", + "modelType": "Tip model", + "name": "Nume eroare", + "no_api_key": "Cheia API nu este configurată", + "no_response": "Niciun răspuns", + "originalError": "Eroare originală", + "originalMessage": "Mesaj original", + "parameter": "Parametru", + "pause_placeholder": "În pauză", + "prompt": "Prompt", + "provider": "Furnizor", + "providerId": "ID furnizor", + "provider_disabled": "Furnizorul modelului nu este activat", + "reason": "Motiv", + "render": { + "description": "Nu s-a putut randa conținutul mesajului. Te rugăm să verifici dacă formatul conținutului mesajului este corect", + "title": "Eroare de randare" + }, + "requestBody": "Corp cerere", + "requestBodyValues": "Valori corp cerere", + "requestUrl": "URL cerere", + "response": "Răspuns", + "responseBody": "Corp răspuns", + "responseHeaders": "Header răspuns", + "responses": "Răspunsuri", + "role": "Rol", + "stack": "Stack Trace", + "status": "Cod stare", + "statusCode": "Cod stare", + "statusText": "Text stare", + "text": "Text", + "toolInput": "Intrare instrument", + "toolName": "Nume instrument", + "unknown": "Eroare necunoscută", + "usage": "Utilizare", + "user_message_not_found": "Nu se poate găsi mesajul original al utilizatorului pentru a retrimite", + "value": "Valoare", + "values": "Valori" + }, + "export": { + "assistant": "Asistent", + "attached_files": "Fișiere atașate", + "conversation_details": "Detalii conversație", + "conversation_history": "Istoric conversație", + "created": "Creat", + "last_updated": "Ultima actualizare", + "messages": "Mesaje", + "notion": { + "reasoning_truncated": "Lanțul de gândire nu poate fi fragmentat și a fost trunchiat." + }, + "user": "Utilizator" + }, + "files": { + "actions": "Acțiuni", + "all": "Toate fișierele", + "batch_delete": "Ștergere în lot", + "batch_operation": "Selectează tot", + "count": "fișiere", + "created_at": "Creat la", + "delete": { + "content": "Ștergerea unui fișier va șterge referința acestuia din toate mesajele. Ești sigur că vrei să ștergi acest fișier?", + "db_error": "Ștergerea a eșuat", + "label": "Șterge", + "paintings": { + "warning": "Imaginea conține acest fișier, ștergerea nu este posibilă" + }, + "title": "Șterge fișier" + }, + "document": "Document", + "edit": "Editează", + "error": { + "open_path": "Nu s-a putut deschide calea {{path}}" + }, + "file": "Fișier", + "image": "Imagine", + "name": "Nume", + "open": "Deschide", + "preview": { + "error": "Nu s-a putut deschide fișierul" + }, + "size": "Dimensiune", + "text": "Text", + "title": "Fișiere", + "type": "Tip" + }, + "gpustack": { + "keep_alive_time": { + "description": "Timpul în minute pentru a menține conexiunea activă; implicit este 5 minute.", + "placeholder": "Minute", + "title": "Timp menținere conexiune" + }, + "title": "GPUStack" + }, + "history": { + "continue_chat": "Continuă conversația", + "error": { + "topic_not_found": "Subiectul nu a fost găsit" + }, + "locate": { + "message": "Localizează mesajul" + }, + "search": { + "messages": "Caută în toate mesajele", + "placeholder": "Caută subiecte sau mesaje...", + "topics": { + "empty": "Nu s-au găsit subiecte, apasă Enter pentru a căuta în toate mesajele" + } + }, + "title": "Căutare subiecte" + }, + "html_artifacts": { + "capture": { + "label": "Capturează pagina", + "to_clipboard": "Copiază în clipboard", + "to_file": "Salvează ca imagine" + }, + "code": "Cod", + "empty_preview": "Niciun conținut de afișat", + "generating": "Se generează", + "preview": "Previzualizare", + "split": "Divizat" + }, + "import": { + "chatgpt": { + "assistant_name": "Import ChatGPT", + "button": "Selectează fișierul", + "description": "Importă doar textul conversației, nu include imagini și atașamente", + "error": { + "invalid_json": "Format fișier JSON invalid", + "no_conversations": "Nu s-au găsit conversații în fișier", + "no_valid_conversations": "Nu există conversații valide de importat", + "unknown": "Importul a eșuat, te rugăm să verifici formatul fișierului" + }, + "help": { + "step1": "1. Conectează-te la ChatGPT, mergi la Settings > Data controls > Export data", + "step2": "2. Așteaptă fișierul de export pe e-mail", + "step3": "3. Extrage arhiva descărcată și găsește conversations.json", + "title": "Cum export conversațiile ChatGPT?" + }, + "importing": "Se importă conversațiile...", + "selecting": "Se selectează fișierul...", + "success": "S-au importat cu succes {{topics}} conversații cu {{messages}} mesaje", + "title": "Importă conversații ChatGPT", + "untitled_conversation": "Conversație fără titlu" + }, + "confirm": { + "button": "Selectează fișierul de import", + "label": "Ești sigur că vrei să imporți date externe?" + }, + "content": "Selectează fișierul de conversație din aplicația externă pentru import; momentan acceptă doar fișiere în format JSON ChatGPT", + "title": "Importă conversații externe" + }, + "knowledge": { + "add": { + "title": "Adaugă bază de cunoștințe" + }, + "add_directory": "Adaugă director", + "add_file": "Adaugă fișier", + "add_image": "Adaugă imagine", + "add_note": "Adaugă notă", + "add_sitemap": "Hartă site", + "add_url": "Adaugă URL", + "add_video": "Adaugă video", + "cancel_index": "Anulează indexarea", + "chunk_overlap": "Suprapunere fragmente", + "chunk_overlap_placeholder": "Implicit (nu se recomandă modificarea)", + "chunk_overlap_tooltip": "Cantitatea de conținut duplicat între fragmentele adiacente, asigurând că fragmentele sunt încă legate contextual, îmbunătățind efectul general al procesării textului lung", + "chunk_size": "Dimensiune fragment", + "chunk_size_change_warning": "Modificările dimensiunii fragmentului și ale suprapunerii se aplică doar conținutului nou", + "chunk_size_placeholder": "Implicit (nu se recomandă modificarea)", + "chunk_size_too_large": "Dimensiunea fragmentului nu poate depăși limita de context a modelului ({{max_context}})", + "chunk_size_tooltip": "Împarte documentele în fragmente; dimensiunea fiecărui fragment nu trebuie să depășească limita de context a modelului", + "clear_selection": "Șterge selecția", + "delete": "Șterge", + "delete_confirm": "Ești sigur că vrei să ștergi această bază de cunoștințe?", + "dimensions": "Dimensiune embedding", + "dimensions_auto_set": "Setează automat dimensiunile embedding", + "dimensions_default": "Modelul va folosi dimensiunile implicite de embedding", + "dimensions_error_invalid": "Dimensiune embedding invalidă", + "dimensions_set_right": "⚠️ Te rugăm să te asiguri că modelul acceptă dimensiunea embedding setată", + "dimensions_size_placeholder": "Lasă gol pentru a nu transmite dimensiuni", + "dimensions_size_too_large": "Dimensiunea embedding nu poate depăși limita de context a modelului ({{max_context}}).", + "dimensions_size_tooltip": "Dimensiunea embedding; cu cât valoarea este mai mare, cu atât se vor consuma mai mulți tokeni. Lasă gol pentru a nu transmite parametrul dimensions.", + "directories": "Directoare", + "directory_placeholder": "Introdu calea directorului", + "document_count": "Fragmente de document solicitate", + "document_count_default": "Implicit", + "document_count_help": "Cu cât sunt solicitate mai multe fragmente de document, cu atât sunt incluse mai multe informații, dar se consumă mai mulți tokeni", + "drag_file": "Trage fișierul aici", + "drag_image": "Trage imaginea aici", + "edit_remark": "Editează observația", + "edit_remark_placeholder": "Te rugăm să introduci conținutul observației", + "embedding_model": "Model embedding", + "embedding_model_required": "Modelul de embedding pentru baza de cunoștințe este necesar", + "empty": "Nu a fost găsită nicio bază de cunoștințe", + "error": { + "failed_to_create": "Crearea bazei de cunoștințe a eșuat", + "failed_to_edit": "Editarea bazei de cunoștințe a eșuat", + "model_invalid": "Niciun model selectat", + "video": { + "local_file_missing": "Fișierul video nu a fost găsit", + "youtube_url_missing": "URL-ul video YouTube nu a fost găsit" + } + }, + "file_hint": "Acceptă {{file_types}}", + "image_hint": "Acceptă {{image_types}}", + "images": "Imagini", + "index_all": "Indexează tot", + "index_cancelled": "Indexare anulată", + "index_started": "Indexare pornită", + "invalid_url": "URL invalid", + "migrate": { + "button": { + "text": "Migrează" + }, + "confirm": { + "content": "S-au detectat modificări în modelul sau dimensiunea de embedding; configurarea nu poate fi salvată direct. Migrarea bazei de cunoștințe nu va șterge baza existentă, ci va crea o copie și apoi va reprocesa toate intrările, ceea ce poate consuma un număr mare de tokeni. Te rugăm să procedezi cu precauție.", + "ok": "Începe migrarea", + "title": "Migrare bază de cunoștințe" + }, + "error": { + "failed": "Migrarea a eșuat" + }, + "source_dimensions": "Dimensiuni sursă", + "source_model": "Model sursă", + "target_dimensions": "Dimensiuni țintă", + "target_model": "Model țintă" + }, + "model_info": "Informații model", + "name_required": "Numele bazei de cunoștințe este obligatoriu", + "no_bases": "Nu există baze de cunoștințe disponibile", + "no_match": "Nu s-a găsit conținut potrivit în baza de cunoștințe.", + "no_provider": "Furnizorul modelului pentru baza de cunoștințe nu este setat, baza de cunoștințe nu va mai fi acceptată; te rugăm să creezi o nouă bază de cunoștințe", + "not_set": "Nesetat", + "not_support": "Motorul bazei de date de cunoștințe a fost actualizat, baza de cunoștințe nu va mai fi acceptată; te rugăm să creezi o nouă bază de cunoștințe", + "notes": "Note", + "notes_placeholder": "Introdu informații suplimentare sau context pentru această bază de cunoștințe...", + "provider_not_found": "Furnizorul nu a fost găsit", + "quota": "Cotă rămasă {{name}}: {{quota}}", + "quota_empty": "Cota de astăzi pentru {{name}} este epuizată, te rugăm să aplici pe site-ul oficial", + "quota_infinity": "Cotă {{name}}: Nelimitat", + "rename": "Redenumește", + "search": "Caută în baza de cunoștințe", + "search_placeholder": "Introdu text pentru căutare", + "settings": { + "preprocessing": "Preprocesare", + "preprocessing_tooltip": "Preprocesează fișierele încărcate", + "title": "Setări bază de cunoștințe" + }, + "sitemap_added": "Adăugat cu succes", + "sitemap_placeholder": "Introdu URL-ul hărții site-ului", + "sitemaps": "Site-uri web", + "source": "Sursă", + "status": "Stare", + "status_completed": "Finalizat", + "status_embedding_completed": "Embedding finalizat", + "status_embedding_failed": "Embedding eșuat", + "status_failed": "Eșuat", + "status_new": "Adăugat", + "status_pending": "În așteptare", + "status_preprocess_completed": "Preprocesare finalizată", + "status_preprocess_failed": "Preprocesare eșuată", + "status_processing": "Se procesează", + "subtitle_file": "fișier subtitrare", + "threshold": "Prag de potrivire", + "threshold_placeholder": "Nesetat", + "threshold_too_large_or_small": "Pragul nu poate fi mai mare de 1 sau mai mic de 0", + "threshold_tooltip": "Folosit pentru a evalua relevanța dintre întrebarea utilizatorului și conținutul din baza de cunoștințe (0-1)", + "title": "Bază de cunoștințe", + "topN": "Număr rezultate returnate", + "topN_placeholder": "Nesetat", + "topN_too_large_or_small": "Numărul de rezultate returnate nu poate fi mai mare de 30 sau mai mic de 1.", + "topN_tooltip": "Numărul de rezultate potrivite returnate; cu cât valoarea este mai mare, cu atât mai multe rezultate, dar și mai mulți tokeni consumați.", + "url_added": "URL adăugat", + "url_placeholder": "Introdu URL, separă URL-urile multiple prin Enter", + "urls": "URL-uri", + "videos": "video", + "videos_file": "fișier video" + }, + "languages": { + "arabic": "Arabă", + "chinese": "Chineză", + "chinese-traditional": "Chineză tradițională", + "english": "Engleză", + "french": "Franceză", + "german": "Germană", + "indonesian": "Indoneziană", + "italian": "Italiană", + "japanese": "Japoneză", + "korean": "Coreeană", + "malay": "Malaieză", + "polish": "Poloneză", + "portuguese": "Portugheză", + "russian": "Rusă", + "spanish": "Spaniolă", + "thai": "Thailandeză", + "turkish": "Turcă", + "ukrainian": "Ucraineană", + "unknown": "necunoscut", + "urdu": "Urdu", + "vietnamese": "Vietnameză" + }, + "launchpad": { + "apps": "Aplicații", + "minapps": "Mini-aplicații" + }, + "lmstudio": { + "keep_alive_time": { + "description": "Timpul în minute pentru a menține conexiunea activă; implicit este 5 minute.", + "placeholder": "Minute", + "title": "Timp menținere conexiune" + }, + "title": "LM Studio" + }, + "memory": { + "actions": "Acțiuni", + "add_failed": "Nu s-a putut adăuga amintirea", + "add_first_memory": "Adaugă prima ta amintire", + "add_memory": "Adaugă amintire", + "add_new_user": "Adaugă utilizator nou", + "add_success": "Amintire adăugată cu succes", + "add_user": "Adaugă utilizator", + "add_user_failed": "Nu s-a putut adăuga utilizatorul", + "all_users": "Toți utilizatorii", + "cannot_delete_default_user": "Nu se poate șterge utilizatorul implicit", + "configure_memory_first": "Te rugăm să configurezi mai întâi setările de memorie", + "content": "Conținut", + "current_user": "Utilizator curent", + "custom": "Personalizat", + "default": "Implicit", + "default_user": "Utilizator implicit", + "delete_confirm": "Ești sigur că vrei să ștergi această amintire?", + "delete_confirm_content": "Ești sigur că vrei să ștergi {{count}} amintiri?", + "delete_confirm_single": "Ești sigur că vrei să ștergi această amintire?", + "delete_confirm_title": "Șterge amintiri", + "delete_failed": "Nu s-a putut șterge amintirea", + "delete_selected": "Șterge selectate", + "delete_success": "Amintire ștearsă cu succes", + "delete_user": "Șterge utilizator", + "delete_user_confirm_content": "Ești sigur că vrei să ștergi utilizatorul {{user}} și toate amintirile sale?", + "delete_user_confirm_title": "Șterge utilizator", + "delete_user_failed": "Nu s-a putut șterge utilizatorul", + "description": "Memoria îți permite să stochezi și să gestionezi informații despre interacțiunile tale cu asistentul. Poți adăuga, edita și șterge amintiri, precum și să le filtrezi și să cauți prin ele.", + "edit_memory": "Editează amintirea", + "embedding_dimensions": "Dimensiuni embedding", + "embedding_model": "Model embedding", + "enable_global_memory_first": "Te rugăm să activezi mai întâi memoria globală", + "end_date": "Data de sfârșit", + "global_memory": "Memorie globală", + "global_memory_description": "Pentru a folosi funcțiile de memorie, te rugăm să activezi memoria globală în setările asistentului.", + "global_memory_disabled_desc": "Pentru a folosi funcțiile de memorie, te rugăm să activezi mai întâi memoria globală în setările asistentului.", + "global_memory_disabled_title": "Memorie globală dezactivată", + "global_memory_enabled": "Memorie globală activată", + "go_to_memory_page": "Mergi la pagina Memorie", + "initial_memory_content": "Bun venit! Aceasta este prima ta amintire.", + "llm_model": "Model LLM", + "load_failed": "Nu s-au putut încărca amintirile", + "loading": "Se încarcă amintirile...", + "loading_memories": "Se încarcă amintirile...", + "memories_description": "Se afișează {{count}} din {{total}} amintiri", + "memories_reset_success": "Toate amintirile pentru {{user}} au fost resetate cu succes", + "memory": "amintire", + "memory_content": "Conținut amintire", + "memory_placeholder": "Introdu conținutul amintirii...", + "new_user_id": "ID utilizator nou", + "new_user_id_placeholder": "Introdu un ID de utilizator unic", + "no_matching_memories": "Nu s-au găsit amintiri potrivite", + "no_memories": "Încă nu există amintiri", + "no_memories_description": "Începe prin a adăuga prima ta amintire", + "not_configured_desc": "Te rugăm să configurezi modelele de embedding și LLM în setările de memorie pentru a activa funcționalitatea de memorie.", + "not_configured_title": "Memorie neconfigurată", + "pagination_total": "{{start}}-{{end}} din {{total}} elemente", + "please_enter_memory": "Te rugăm să introduci conținutul amintirii", + "please_select_embedding_model": "Te rugăm să selectezi un model de embedding", + "please_select_llm_model": "Te rugăm să selectezi un model LLM", + "reset_filters": "Resetează filtrele", + "reset_memories": "Resetează amintirile", + "reset_memories_confirm_content": "Ești sigur că vrei să ștergi definitiv toate amintirile pentru {{user}}? Această acțiune nu poate fi anulată.", + "reset_memories_confirm_title": "Resetează toate amintirile", + "reset_memories_failed": "Nu s-au putut reseta amintirile", + "reset_user_memories": "Resetează amintirile utilizatorului", + "reset_user_memories_confirm_content": "Ești sigur că vrei să resetezi toate amintirile pentru {{user}}?", + "reset_user_memories_confirm_title": "Resetează amintirile utilizatorului", + "reset_user_memories_failed": "Nu s-au putut reseta amintirile utilizatorului", + "score": "Scor", + "search": "Caută", + "search_placeholder": "Caută amintiri...", + "select_embedding_model_placeholder": "Selectează model embedding", + "select_llm_model_placeholder": "Selectează model LLM", + "select_user": "Selectează utilizator", + "settings": "Setări", + "settings_title": "Setări memorie", + "start_date": "Data de început", + "statistics": "Statistici", + "stored_memories": "Amintiri stocate", + "switch_user": "Schimbă utilizatorul", + "switch_user_confirm": "Schimbi contextul de utilizator la {{user}}?", + "time": "Timp", + "title": "Amintiri", + "total_memories": "total amintiri", + "try_different_filters": "Încearcă să ajustezi criteriile de căutare", + "update_failed": "Nu s-a putut actualiza amintirea", + "update_success": "Amintire actualizată cu succes", + "user": "Utilizator", + "user_created": "Utilizatorul {{user}} a fost creat și comutat cu succes", + "user_deleted": "Utilizatorul {{user}} a fost șters cu succes", + "user_id": "ID utilizator", + "user_id_exists": "Acest ID de utilizator există deja", + "user_id_invalid_chars": "ID-ul de utilizator poate conține doar litere, cifre, cratime și liniuțe de subliniere", + "user_id_placeholder": "Introdu ID utilizator (opțional)", + "user_id_required": "ID-ul de utilizator este obligatoriu", + "user_id_reserved": "'default-user' este rezervat, te rugăm să folosești un ID diferit", + "user_id_rules": "ID-ul de utilizator trebuie să fie unic și să conțină doar litere, cifre, cratime (-) și liniuțe de subliniere (_)", + "user_id_too_long": "ID-ul de utilizator nu poate depăși 50 de caractere", + "user_management": "Gestionare utilizatori", + "user_memories_reset": "Toate amintirile pentru {{user}} au fost resetate", + "user_switch_failed": "Nu s-a putut schimba utilizatorul", + "user_switched": "Contextul de utilizator a fost schimbat la {{user}}", + "users": "utilizatori" + }, + "message": { + "agents": { + "import": { + "error": "Import eșuat" + }, + "imported": "S-au importat cu succes {{count}} asistent/asistenți" + }, + "api": { + "check": { + "model": { + "title": "Selectează modelul de utilizat pentru detectare" + } + }, + "connection": { + "failed": "Conexiune eșuată", + "success": "Conexiune reușită" + } + }, + "assistant": { + "added": { + "content": "Asistent adăugat cu succes" + } + }, + "attachments": { + "pasted_image": "Imagine lipită", + "pasted_text": "Text lipit" + }, + "backup": { + "failed": "Backup eșuat", + "start": { + "success": "Backup început" + }, + "success": "Backup reușit" + }, + "branch": { + "error": "Crearea ramurii a eșuat" + }, + "chat": { + "completion": { + "paused": "Completarea chat-ului a fost pusă în pauză" + } + }, + "citation": "{{count}} citări", + "citations": "Referințe", + "copied": "Copiat!", + "copy": { + "failed": "Copiere eșuată", + "success": "Copiat!" + }, + "delete": { + "confirm": { + "content": "Ești sigur că vrei să ștergi cele {{count}} mesaje selectate?", + "title": "Confirmare ștergere" + }, + "failed": "Ștergere eșuată", + "success": "Ștergere reușită" + }, + "dialog": { + "failed": "Previzualizare eșuată" + }, + "download": { + "failed": "Descărcare eșuată", + "success": "Descărcare reușită" + }, + "empty_url": "Nu s-a putut descărca imaginea, posibil din cauza promptului care conține conținut sensibil sau cuvinte interzise", + "error": { + "chunk_overlap_too_large": "Suprapunerea fragmentelor nu poate fi mai mare decât dimensiunea fragmentului", + "copy": "Copiere eșuată", + "dimension_too_large": "Dimensiunea conținutului este prea mare", + "enter": { + "api": { + "host": "Te rugăm să introduci mai întâi gazda (host) API", + "label": "Te rugăm să introduci mai întâi cheia API" + }, + "model": "Te rugăm să selectezi mai întâi un model", + "name": "Te rugăm să introduci numele bazei de cunoștințe" + }, + "fetchTopicName": "Nu s-a putut numi subiectul", + "get_embedding_dimensions": "Nu s-au putut obține dimensiunile embedding", + "invalid": { + "api": { + "host": "Gazdă (Host) API invalidă", + "label": "Cheie API invalidă" + }, + "enter": { + "model": "Te rugăm să selectezi un model" + }, + "nutstore": "Setări Nutstore invalide", + "nutstore_token": "Token Nutstore invalid", + "proxy": { + "url": "URL proxy invalid" + }, + "webdav": "Setări WebDAV invalide" + }, + "joplin": { + "export": "Nu s-a putut exporta în Joplin. Te rugăm să menții Joplin rulând și să verifici starea conexiunii sau configurarea", + "no_config": "Tokenul de autorizare Joplin sau URL-ul nu sunt configurate" + }, + "markdown": { + "export": { + "preconf": "Nu s-a putut exporta fișierul Markdown în calea preconfigurată", + "specified": "Nu s-a putut exporta fișierul Markdown" + } + }, + "notes": { + "export": "Nu s-au putut exporta notele" + }, + "notion": { + "export": "Nu s-a putut exporta în Notion. Te rugăm să verifici starea conexiunii și configurarea conform documentației", + "no_api_key": "ApiKey-ul Notion sau DatabaseID-ul Notion nu sunt configurate", + "no_content": "Nu există nimic de exportat în Notion." + }, + "siyuan": { + "export": "Nu s-a putut exporta în Siyuan Note, te rugăm să verifici starea conexiunii și configurarea conform documentației", + "no_config": "Adresa API Siyuan Note sau tokenul nu sunt configurate" + }, + "unknown": "Eroare necunoscută", + "yuque": { + "export": "Nu s-a putut exporta în Yuque. Te rugăm să verifici starea conexiunii și configurarea conform documentației", + "no_config": "Tokenul Yuque sau Url-ul Yuque nu sunt configurate" + } + }, + "group": { + "delete": { + "content": "Ștergerea unui mesaj de grup va șterge întrebarea utilizatorului și toate răspunsurile asistentului", + "title": "Șterge mesajul de grup" + }, + "retry_failed": "Reîncearcă mesajele eșuate" + }, + "ignore": { + "knowledge": { + "base": "Modul căutare web este activat, se ignoră baza de cunoștințe" + } + }, + "loading": { + "notion": { + "exporting_progress": "Se exportă în Notion...", + "preparing": "Se pregătește exportul în Notion..." + } + }, + "mention": { + "title": "Schimbă răspunsul modelului" + }, + "message": { + "code_style": "Stil cod", + "compact": { + "title": "Conversație compactată" + }, + "delete": { + "content": "Ești sigur că vrei să ștergi acest mesaj?", + "title": "Șterge mesajul" + }, + "multi_model_style": { + "fold": { + "compress": "Treci la aspect compact", + "expand": "Treci la aspect extins", + "label": "Vizualizare pliată" + }, + "grid": "Aspect grilă", + "horizontal": "Alăturat", + "label": "Stil grup", + "vertical": "Vizualizare stivuită" + }, + "style": { + "bubble": "Bulă", + "label": "Stil mesaj", + "plain": "Simplu" + }, + "video": { + "error": { + "local_file_missing": "Calea fișierului video local nu a fost găsită", + "unsupported_type": "Tip video neacceptat", + "youtube_url_missing": "URL-ul video YouTube nu a fost găsit" + } + } + }, + "processing": "Se procesează...", + "regenerate": { + "confirm": "Regenerarea va înlocui mesajul curent" + }, + "reset": { + "confirm": { + "content": "Ești sigur că vrei să ștergi toate datele?" + }, + "double": { + "confirm": { + "content": "Toate datele vor fi pierdute, vrei să continui?", + "title": "DATE PIERDUTE !!!" + } + } + }, + "restore": { + "failed": "Restaurare eșuată", + "success": "Restaurat cu succes" + }, + "save": { + "success": { + "title": "Salvat cu succes" + } + }, + "searching": "Se caută...", + "success": { + "joplin": { + "export": "Exportat cu succes în Joplin" + }, + "markdown": { + "export": { + "preconf": "Fișierul Markdown a fost exportat cu succes în calea preconfigurată", + "specified": "Fișierul Markdown a fost exportat cu succes" + } + }, + "notes": { + "export": "Exportat cu succes în note" + }, + "notion": { + "export": "Exportat cu succes în Notion" + }, + "siyuan": { + "export": "Exportat cu succes în Siyuan Note" + }, + "yuque": { + "export": "Exportat cu succes în Yuque" + } + }, + "switch": { + "disabled": "Te rugăm să aștepți finalizarea răspunsului curent" + }, + "tools": { + "abort_failed": "Anularea apelului instrumentului a eșuat", + "aborted": "Apelul instrumentului a fost anulat", + "autoApproveEnabled": "Aprobare automată activată pentru acest instrument", + "cancelled": "Anulat", + "completed": "Finalizat", + "error": "A apărut o eroare", + "invoking": "Se invocă", + "pending": "În așteptare", + "preview": "Previzualizare", + "raw": "Brut" + }, + "topic": { + "added": "Subiect nou adăugat" + }, + "upgrade": { + "success": { + "button": "Repornește", + "content": "Te rugăm să repornești aplicația pentru a finaliza actualizarea", + "title": "Actualizare reușită" + } + }, + "warn": { + "export": { + "exporting": "Un alt export este în curs. Te rugăm să aștepți finalizarea exportului anterior și apoi să încerci din nou." + } + }, + "warning": { + "rate": { + "limit": "Prea multe cereri. Te rugăm să aștepți {{seconds}} secunde înainte de a încerca din nou." + } + }, + "websearch": { + "cutoff": "Se trunchiază conținutul căutării...", + "fetch_complete": "{{count}} rezultat(e) căutare", + "rag": "Se execută RAG...", + "rag_complete": "Se păstrează {{countAfter}} din {{countBefore}} rezultate...", + "rag_failed": "RAG a eșuat, se returnează rezultate goale..." + } + }, + "minapp": { + "add_to_launchpad": "Adaugă în Launchpad", + "add_to_sidebar": "Adaugă în bara laterală", + "popup": { + "close": "Închide MinApp", + "devtools": "Instrumente dezvoltator", + "goBack": "Mergi înapoi", + "goForward": "Mergi înainte", + "minimize": "Minimizează MinApp", + "openExternal": "Deschide în browser", + "open_link_external_off": "Curent: Deschide linkurile în fereastra implicită", + "open_link_external_on": "Curent: Deschide linkurile în browser", + "refresh": "Reîmprospătează", + "rightclick_copyurl": "Clic dreapta pentru a copia URL-ul" + }, + "remove_from_launchpad": "Elimină din Launchpad", + "remove_from_sidebar": "Elimină din bara laterală", + "sidebar": { + "close": { + "title": "Închide" + }, + "closeall": { + "title": "Închide tot" + }, + "hide": { + "title": "Ascunde" + }, + "remove_custom": { + "title": "Șterge aplicația personalizată" + } + }, + "title": "MinApp" + }, + "minapps": { + "ant-ling": "Ant Ling", + "baichuan": "Baichuan", + "baidu-ai-search": "Baidu AI Search", + "chatglm": "ChatGLM", + "dangbei": "Dangbei", + "doubao": "Doubao", + "hailuo": "MINIMAX", + "metaso": "Metaso", + "nami-ai": "Nami AI", + "nami-ai-search": "Nami AI Search", + "qwen": "Qwen", + "sensechat": "SenseChat", + "stepfun": "Stepfun", + "tencent-yuanbao": "Yuanbao", + "tiangong-ai": "Skywork", + "wanzhi": "Wanzhi", + "wenxin": "ERNIE", + "wps-copilot": "WPS Copilot", + "xiaoyi": "Xiaoyi", + "zhihu": "Zhihu" + }, + "miniwindow": { + "alert": { + "google_login": "Sfat: Dacă vezi un mesaj 'browser not trusted' când te conectezi la Google, te rugăm să te conectezi mai întâi prin mini-aplicația Google din lista de mini-aplicații, apoi să folosești autentificarea Google în alte mini-aplicații" + }, + "clipboard": { + "empty": "Clipboardul este gol" + }, + "feature": { + "chat": "Răspunde la această întrebare", + "explanation": "Explicație", + "summary": "Rezumat conținut", + "translate": "Traducere text" + }, + "footer": { + "backspace_clear": "Backspace pentru a șterge", + "copy_last_message": "Apasă C pentru a copia", + "esc": "ESC pentru a {{action}}", + "esc_back": "reveni", + "esc_close": "închide", + "esc_pause": "pune pauză" + }, + "input": { + "placeholder": { + "empty": "Cere ajutor de la {{model}}...", + "title": "Ce vrei să faci cu acest text?" + } + }, + "tooltip": { + "pin": "Menține fereastra deasupra" + } + }, + "models": { + "add_parameter": "Adaugă parametru", + "all": "Toate", + "custom_parameters": "Parametri personalizați", + "dimensions": "Dimensiuni {{dimensions}}", + "edit": "Editează modelul", + "embedding": "Embedding", + "embedding_dimensions": "Dimensiuni embedding", + "embedding_model": "Model embedding", + "embedding_model_tooltip": "Adaugă în Setări->Furnizor Model->Gestionează", + "enable_tool_use": "Activează utilizarea instrumentelor", + "filter": { + "by_tag": "Filtrează după etichetă", + "selected": "Etichete selectate" + }, + "function_calling": "Apelare funcții", + "invalid_model": "Model invalid", + "no_matches": "Nu există modele disponibile", + "parameter_name": "Nume parametru", + "parameter_type": { + "boolean": "Boolean", + "json": "JSON", + "number": "Număr", + "string": "Text" + }, + "pinned": "Fixat", + "price": { + "cost": "Cost", + "currency": "Monedă", + "custom": "Personalizat", + "custom_currency": "Monedă personalizată", + "custom_currency_placeholder": "Introdu moneda personalizată", + "input": "Preț intrare", + "million_tokens": "M Tokeni", + "output": "Preț ieșire", + "price": "Preț" + }, + "reasoning": "Raționament", + "rerank_model": "Reranker", + "rerank_model_not_support_provider": "Momentan, modelul reranker nu acceptă acest furnizor ({{provider}})", + "rerank_model_support_provider": "Momentan, modelul reranker acceptă doar anumiți furnizori ({{provider}})", + "rerank_model_tooltip": "Fă clic pe butonul Gestionează din Setări -> Servicii Model pentru a adăuga.", + "search": { + "placeholder": "Caută modele...", + "tooltip": "Caută modele" + }, + "stream_output": "Ieșire flux", + "type": { + "embedding": "Embedding", + "free": "Gratuit", + "function_calling": "Instrument", + "reasoning": "Raționament", + "rerank": "Reranker", + "select": "Tipuri de modele", + "text": "Text", + "vision": "Vizual", + "websearch": "Căutare Web" + } + }, + "navbar": { + "expand": "Extinde dialogul", + "hide_sidebar": "Ascunde bara laterală", + "show_sidebar": "Arată bara laterală", + "window": { + "close": "Închide", + "maximize": "Maximizează", + "minimize": "Minimizează", + "restore": "Restaurează" + } + }, + "navigate": { + "provider_settings": "Mergi la setările furnizorului" + }, + "notes": { + "auto_rename": { + "empty_note": "Notița este goală, nu se poate genera numele", + "failed": "Generarea numelui notiței a eșuat", + "label": "Generează nume notiță", + "success": "Numele notiței a fost generat cu succes" + }, + "characters": "Caractere", + "collapse": "Restrânge", + "content_placeholder": "Te rugăm să introduci conținutul notiței...", + "copyContent": "Copiază conținutul", + "crossPlatformRestoreWarning": "Configurația multi-platformă a fost restaurată, dar directorul de notițe este gol. Te rugăm să copiezi fișierele notițelor în: {{path}}", + "delete": "șterge", + "delete_confirm": "Ești sigur că vrei să ștergi acest {{type}}?", + "delete_folder_confirm": "Ești sigur că vrei să ștergi dosarul \"{{name}}\" și tot conținutul său?", + "delete_note_confirm": "Ești sigur că vrei să ștergi notița \"{{name}}\"?", + "drop_markdown_hint": "Trage fișiere sau dosare .md aici pentru a importa", + "empty": "Încă nu există notițe disponibile", + "expand": "desfășoară", + "export_failed": "Exportul în baza de cunoștințe a eșuat", + "export_knowledge": "Exportă notițele în baza de cunoștințe", + "export_success": "Exportat cu succes în baza de cunoștințe", + "folder": "dosar", + "new_folder": "Dosar nou", + "new_note": "Creează o notiță nouă", + "no_content_to_copy": "Niciun conținut de copiat", + "no_file_selected": "Te rugăm să selectezi fișierul de încărcat", + "no_valid_files": "Nu a fost încărcat niciun fișier valid", + "open_folder": "Deschide un dosar extern", + "open_outside": "Deschide din exterior", + "rename": "Redenumește", + "rename_changed": "Din cauza politicilor de securitate, numele fișierului a fost schimbat din {{original}} în {{final}}", + "save": "Salvează în Notițe", + "search": { + "both": "Nume+Conținut", + "content": "Conținut", + "found_results": "S-au găsit {{count}} rezultate (Nume: {{nameCount}}, Conținut: {{contentCount}})", + "more_matches": "mai multe potriviri", + "searching": "Se caută...", + "show_less": "Arată mai puțin" + }, + "settings": { + "data": { + "apply": "Aplică", + "apply_path_failed": "Nu s-a putut aplica calea", + "current_work_directory": "Director de lucru curent", + "invalid_directory": "Directorul selectat este invalid sau accesul este refuzat", + "path_required": "Te rugăm să selectezi un director de lucru", + "path_updated": "Directorul de lucru a fost actualizat cu succes", + "reset_failed": "Resetarea a eșuat", + "reset_to_default": "Resetează la implicit", + "select": "Selectează", + "select_directory_failed": "Nu s-a putut selecta directorul", + "title": "Setări date", + "work_directory_description": "Directorul de lucru este locul unde sunt stocate toate fișierele notițelor. Schimbarea directorului de lucru nu va muta fișierele existente; te rugăm să migrezi fișierele manual.", + "work_directory_placeholder": "Selectează directorul de lucru pentru notițe" + }, + "display": { + "compress_content": "Compresie conținut", + "compress_content_description": "Când este activat, va limita numărul de caractere pe linie, reducând conținutul afișat pe ecran, dar făcând paragrafele lungi mai ușor de citit.", + "default_font": "Font implicit", + "font_size": "Dimensiune font", + "font_size_description": "Ajustează dimensiunea fontului pentru o experiență de citire mai bună (10-30px)", + "font_size_large": "Mare", + "font_size_medium": "Mediu", + "font_size_small": "Mic", + "font_title": "Setări font", + "serif_font": "Font cu serife", + "show_table_of_contents": "Arată cuprinsul", + "show_table_of_contents_description": "Afișează o bară laterală cu cuprinsul pentru o navigare ușoară în documente", + "title": "Setări afișare" + }, + "editor": { + "edit_mode": { + "description": "În Vizualizarea Editare, modul de editare implicit pentru notițe noi", + "preview_mode": "Previzualizare live", + "source_mode": "Mod cod sursă", + "title": "Vizualizare editare implicită" + }, + "title": "Setări editor", + "view_mode": { + "description": "Mod vizualizare implicit notițe noi", + "edit_mode": "Mod editare", + "read_mode": "Mod citire", + "title": "Vizualizare implicită" + }, + "view_mode_description": "Setează modul de vizualizare implicit pentru pagina filă nouă." + }, + "title": "Notițe" + }, + "show_starred": "Arată notițele favorite", + "sort_a2z": "Nume fișier (A-Z)", + "sort_created_asc": "Ora creării (cele mai vechi întâi)", + "sort_created_desc": "Ora creării (cele mai noi întâi)", + "sort_updated_asc": "Ora actualizării (cele mai vechi întâi)", + "sort_updated_desc": "Ora actualizării (cele mai noi întâi)", + "sort_z2a": "Nume fișier (Z-A)", + "spell_check": "Verificare ortografică", + "spell_check_tooltip": "Activează/Dezactivează verificarea ortografică", + "star": "Notiță favorită", + "starred_notes": "Notițe colectate", + "title": "Notițe", + "unsaved_changes": "Ai conținut nesalvat, ești sigur că vrei să pleci?", + "unstar": "Elimină de la favorite", + "untitled_folder": "Dosar nou", + "untitled_note": "Notiță fără titlu", + "upload_failed": "Încărcarea notiței a eșuat", + "upload_files": "Încarcă fișiere", + "upload_folder": "Încarcă dosar", + "upload_success": "Notiță încărcată cu succes", + "uploading_files": "Se încarcă {{count}} fișiere..." + }, + "notification": { + "assistant": "Răspuns asistent", + "knowledge": { + "error": "{{error}}", + "success": "S-a adăugat cu succes {{type}} în baza de cunoștințe" + }, + "tip": "Dacă răspunsul este de succes, atunci doar mesajele care depășesc 30 de secunde vor declanșa un memento" + }, + "ocr": { + "builtin": { + "system": "OCR de sistem" + }, + "error": { + "provider": { + "cannot_remove_builtin": "Nu se poate șterge furnizorul integrat", + "existing": "Furnizorul există deja", + "get_providers": "Nu s-au putut obține furnizorii disponibili", + "not_found": "Furnizorul OCR nu există", + "update_failed": "Nu s-a putut actualiza configurația" + }, + "unknown": "A apărut o eroare în timpul procesului OCR" + }, + "file": { + "not_supported": "Tip de fișier neacceptat {{type}}" + }, + "processing": "Procesare OCR...", + "warning": { + "provider": { + "fallback": "S-a recurs la {{name}}, ceea ce poate cauza probleme" + } + } + }, + "ollama": { + "keep_alive_time": { + "description": "Timpul în minute pentru a menține conexiunea activă; implicit este 5 minute.", + "placeholder": "Minute", + "title": "Timp menținere conexiune" + }, + "title": "Ollama" + }, + "ovms": { + "action": { + "install": "Instalează", + "installing": "Se instalează", + "reinstall": "Reinstalează", + "run": "Rulează OVMS", + "starting": "Se pornește", + "stop": "Oprește OVMS", + "stopping": "Se oprește" + }, + "description": "

1. Descarcă modele OV.

2. Adaugă modele în 'Manager'.

Suportă doar Windows!

Cale instalare OVMS: '%USERPROFILE%\\.cherrystudio\\ovms' .

Te rugăm să consulți Ghidul Intel OVMS

", + "download": { + "button": "Descarcă", + "error": "Eroare descărcare", + "model_id": { + "label": "ID model:", + "model_id_pattern": "ID-ul modelului trebuie să înceapă cu OpenVINO/", + "placeholder": "Obligatoriu de ex. OpenVINO/Qwen3-8B-int4-ov", + "required": "Te rugăm să introduci ID-ul modelului" + }, + "model_name": { + "label": "Nume model:", + "placeholder": "Obligatoriu de ex. Qwen3-8B-int4-ov", + "required": "Te rugăm să introduci numele modelului" + }, + "model_source": "Sursă model:", + "model_task": "Sarcină model:", + "success": "Descărcare reușită", + "success_desc": "Modelul \"{{modelName}}\"-\"{{modelId}}\" descărcat cu succes, te rugăm să mergi la interfața de gestionare OVMS pentru a adăuga modelul", + "tip": "Modelul se descarcă, uneori durează ore întregi. Te rugăm să ai răbdare...", + "title": "Descarcă model Intel OpenVINO" + }, + "failed": { + "install": "Instalarea OVMS a eșuat:", + "install_code_100": "Eroare necunoscută", + "install_code_101": "Suportă doar procesoare Intel(R)", + "install_code_102": "Suportă doar Windows", + "install_code_103": "Descărcarea runtime-ului OVMS a eșuat", + "install_code_104": "Nu s-a putut instala runtime-ul OVMS", + "install_code_105": "Nu s-a putut crea ovdnd.exe", + "install_code_106": "Nu s-a putut crea run.bat", + "install_code_110": "Nu s-a putut curăța vechiul runtime OVMS", + "run": "Rularea OVMS a eșuat:", + "stop": "Oprirea OVMS a eșuat:" + }, + "status": { + "not_installed": "OVMS nu este instalat", + "not_running": "OVMS nu rulează", + "running": "OVMS rulează", + "unknown": "Stare OVMS necunoscută" + }, + "title": "Intel OVMS" + }, + "paintings": { + "aspect_ratio": "Raport de aspect", + "aspect_ratios": { + "landscape": "Peisaj", + "portrait": "Portret", + "square": "Pătrat" + }, + "auto_create_paint": "Creează automat imagine", + "auto_create_paint_tip": "După ce imaginea este generată, o nouă imagine va fi creată automat.", + "background": "Fundal", + "background_options": { + "auto": "Auto", + "opaque": "Opac", + "transparent": "Transparent" + }, + "button": { + "delete": { + "image": { + "confirm": "Ești sigur că vrei să ștergi această imagine?", + "label": "Șterge imaginea" + } + }, + "new": { + "image": "Imagine nouă" + } + }, + "custom_size": "Dimensiune personalizată", + "edit": { + "image_file": "Imagine editată", + "magic_prompt_option_tip": "Îmbunătățește inteligent prompturile de editare", + "model_tip": "Versiunile V3 și V2 acceptate", + "number_images_tip": "Numărul de rezultate editate de generat", + "rendering_speed_tip": "Controlează compromisul dintre viteza de randare și calitate, disponibil doar pentru V_3", + "seed_tip": "Controlează aleatoriul editării", + "style_type_tip": "Stil pentru imaginea editată, doar pentru V_2 și versiuni ulterioare" + }, + "generate": { + "height": "Înălțime", + "magic_prompt_option_tip": "Îmbunătățește inteligent prompturile pentru rezultate mai bune", + "model_tip": "Versiune model: V3 este cea mai recentă versiune, V2 este modelul anterior, V2A este modelul rapid, V_1 este modelul de prima generație, _TURBO este versiunea accelerată", + "negative_prompt_tip": "Descrie elementele nedorite, doar pentru V_1, V_1_TURBO, V_2 și V_2_TURBO", + "number_images_tip": "Numărul de imagini de generat", + "person_generation": "Generare persoană", + "person_generation_tip": "Permite modelului să genereze imagini cu persoane", + "rendering_speed_tip": "Controlează compromisul dintre viteza de randare și calitate, disponibil doar pentru V_3", + "safety_tolerance": "Toleranță de siguranță", + "safety_tolerance_tip": "Controlează toleranța de siguranță pentru generarea imaginilor, disponibil doar pentru FLUX.1-Kontext-pro", + "seed_tip": "Controlează aleatoriul generării imaginii pentru rezultate reproductibile", + "style_type_tip": "Stil generare imagine pentru V_2 și versiuni ulterioare", + "width": "Lățime" + }, + "generated_image": "Imagine generată", + "go_to_settings": "Mergi la Setări", + "guidance_scale": "Scară de ghidare", + "guidance_scale_tip": "Classifier Free Guidance. Cât de fidel vrei să respecte modelul promptul tău când caută o imagine similară să-ți arate", + "image": { + "size": "Dimensiune imagine" + }, + "image_file_required": "Te rugăm să încarci mai întâi o imagine", + "image_file_retry": "Te rugăm să reîncarci mai întâi o imagine", + "image_handle_required": "Te rugăm să încarci mai întâi o imagine.", + "image_placeholder": "Nicio imagine disponibilă", + "image_retry": "Reîncearcă", + "image_size_options": { + "auto": "Auto" + }, + "inference_steps": "Pași de inferență", + "inference_steps_tip": "Numărul de pași de inferență de efectuat. Mai mulți pași produc o calitate mai mare, dar durează mai mult", + "input_image": "Imagine de intrare", + "input_parameters": "Parametri de intrare", + "learn_more": "Află mai multe", + "magic_prompt_option": "Prompt magic", + "mode": { + "edit": "Editează", + "generate": "Desenează", + "merge": "Îmbină", + "remix": "Remix", + "upscale": "Upscale" + }, + "model": "Model", + "model_and_pricing": "Model și prețuri", + "moderation": "Moderare", + "moderation_options": { + "auto": "Auto", + "low": "Scăzut" + }, + "negative_prompt": "Prompt negativ", + "negative_prompt_tip": "Descrie ce nu vrei să fie inclus în imagine", + "no_image_generation_model": "Niciun model de generare imagini disponibil, te rugăm să adaugi un model și să setezi tipul endpoint-ului la {{endpoint_type}}", + "number_images": "Număr imagini", + "number_images_tip": "Numărul de imagini de generat (1-4)", + "paint_course": "tutorial", + "per_image": "pe imagine", + "per_images": "pe imagini", + "person_generation_options": { + "allow_adult": "Permite adulți", + "allow_all": "Permite tot", + "allow_none": "Nepermis" + }, + "pricing": "Prețuri", + "prompt_enhancement": "Îmbunătățire prompt", + "prompt_enhancement_tip": "Rescrie prompturile în versiuni detaliate, optimizate pentru model, când este activat", + "prompt_placeholder": "Descrie imaginea pe care vrei să o creezi, de ex. Un lac senin la apus cu munți în fundal", + "prompt_placeholder_edit": "Introdu descrierea imaginii, desenarea textului folosește \"ghilimele duble\" pentru încadrare", + "prompt_placeholder_en": "Introdu descrierea imaginii, momentan acceptă doar prompturi în engleză", + "proxy_required": "Deschide proxy-ul și activează \"Modul TUN\" pentru a vizualiza imaginile generate sau copiază-le în browser pentru deschidere. În viitor, conexiunea directă internă va fi acceptată", + "quality": "Calitate", + "quality_options": { + "auto": "Auto", + "high": "Înaltă", + "low": "Scăzută", + "medium": "Medie" + }, + "regenerate": { + "confirm": "Aceasta va înlocui imaginile generate existente. Vrei să continui?" + }, + "remix": { + "image_file": "Imagine de referință", + "image_weight": "Pondere imagine de referință", + "image_weight_tip": "Ajustează influența imaginii de referință", + "magic_prompt_option_tip": "Îmbunătățește inteligent prompturile de remix", + "model_tip": "Selectează versiunea modelului AI pentru remixare", + "negative_prompt_tip": "Descrie elementele nedorite în rezultatele remix", + "number_images_tip": "Numărul de rezultate remix de generat", + "rendering_speed_tip": "Controlează compromisul dintre viteza de randare și calitate, disponibil doar pentru V_3", + "seed_tip": "Controlează aleatoriul rezultatului combinat", + "style_type_tip": "Stil pentru imaginea remixată, doar pentru V_2 și versiuni ulterioare" + }, + "rendering_speed": "Viteză de randare", + "rendering_speeds": { + "default": "Implicit", + "quality": "Calitate", + "turbo": "Turbo" + }, + "req_error_model": "Nu s-a putut prelua modelul", + "req_error_no_balance": "Te rugăm să verifici validitatea tokenului", + "req_error_text": "Serverul este ocupat sau promptul conține termeni \"protejați prin drepturi de autor\" sau \"sensibili\". Te rugăm să încerci din nou.", + "req_error_token": "Te rugăm să verifici validitatea tokenului", + "required_field": "Câmp obligatoriu", + "seed": "Seed", + "seed_desc_tip": "Același seed și prompt pot genera imagini similare; setarea -1 va genera rezultate diferite de fiecare dată", + "seed_tip": "Același seed și prompt pot produce imagini similare", + "select_model": "Selectează modelul", + "style_type": "Stil", + "style_types": { + "3d": "3D", + "anime": "Anime", + "auto": "Auto", + "design": "Design", + "general": "General", + "realistic": "Realist" + }, + "text_desc_required": "Te rugăm să introduci mai întâi descrierea imaginii", + "title": "Imagini", + "top_up": "Reîncarcă ", + "translating": "Se traduce...", + "uploaded_input": "Intrare încărcată", + "upscale": { + "detail": "Detaliu", + "detail_tip": "Controlează nivelul de îmbunătățire a detaliilor", + "image_file": "Imagine de scalat", + "magic_prompt_option_tip": "Îmbunătățește inteligent prompturile de upscaling", + "number_images_tip": "Numărul de rezultate scalate de generat", + "resemblance": "Similaritate", + "resemblance_tip": "Controlează similaritatea cu imaginea originală", + "seed_tip": "Controlează aleatoriul scalării" + } + }, + "plugins": { + "actions": "Acțiuni", + "agents": "Agenți", + "all_categories": "Toate categoriile", + "all_types": "Toate", + "category": "Categorie", + "commands": "Comenzi", + "confirm_uninstall": "Ești sigur că vrei să dezinstalezi {{name}}?", + "install": "Instalează", + "install_plugins_from_browser": "Răsfoiește pluginurile disponibile pentru a începe", + "installing": "Se instalează...", + "name": "Nume", + "no_description": "Nicio descriere disponibilă", + "no_installed_plugins": "Niciun plugin instalat încă", + "no_results": "Nu s-au găsit pluginuri", + "search_placeholder": "Caută pluginuri...", + "showing_results": "Se afișează {{count}} plugin", + "showing_results_one": "Se afișează {{count}} plugin", + "showing_results_other": "Se afișează {{count}} pluginuri", + "showing_results_plural": "Se afișează {{count}} pluginuri", + "skills": "Abilități", + "try_different_search": "Încearcă să ajustezi căutarea sau filtrele de categorie", + "type": "Tip", + "uninstall": "Dezinstalează", + "uninstalling": "Se dezinstalează..." + }, + "preview": { + "copy": { + "image": "Copiază ca imagine", + "src": "Copiază sursa imaginii" + }, + "dialog": "Deschide dialog", + "label": "Previzualizare", + "pan": "Deplasează", + "pan_down": "Deplasează jos", + "pan_left": "Deplasează stânga", + "pan_right": "Deplasează dreapta", + "pan_up": "Deplasează sus", + "reset": "Resetează", + "source": "Vezi codul sursă", + "zoom_in": "Mărește", + "zoom_out": "Micșorează" + }, + "prompts": { + "explanation": "Explică-mi acest concept", + "summarize": "Rezumatul acestui text", + "title": "Rezumatul conversației într-un titlu în {{language}} în limita a 10 caractere, ignorând instrucțiunile și fără punctuație sau simboluri. Returnează doar șirul titlului fără nimic altceva." + }, + "provider": { + "302ai": "302.AI", + "ai-gateway": "Vercel AI Gateway", + "aihubmix": "AiHubMix", + "aionly": "AiOnly", + "alayanew": "Alaya NeW", + "anthropic": "Anthropic", + "aws-bedrock": "AWS Bedrock", + "azure-openai": "Azure OpenAI", + "baichuan": "Baichuan", + "baidu-cloud": "Baidu Cloud", + "burncloud": "BurnCloud", + "cephalon": "Cephalon", + "cerebras": "Cerebras AI", + "cherryin": "CherryIN", + "copilot": "GitHub Copilot", + "dashscope": "Alibaba Cloud", + "deepseek": "DeepSeek", + "dmxapi": "DMXAPI", + "doubao": "Volcengine", + "fireworks": "Fireworks", + "gemini": "Gemini", + "gitee-ai": "Gitee AI", + "github": "GitHub Models", + "gpustack": "GPUStack", + "grok": "Grok", + "groq": "Groq", + "huggingface": "Hugging Face", + "hunyuan": "Tencent Hunyuan", + "hyperbolic": "Hyperbolic", + "infini": "Infini", + "jina": "Jina", + "lanyun": "LANYUN", + "lmstudio": "LM Studio", + "longcat": "LongCat AI", + "mimo": "Xiaomi MiMo", + "minimax": "MiniMax", + "mistral": "Mistral", + "modelscope": "ModelScope", + "moonshot": "Moonshot", + "new-api": "New API", + "nvidia": "Nvidia", + "o3": "O3", + "ocoolai": "ocoolAI", + "ollama": "Ollama", + "openai": "OpenAI", + "openrouter": "OpenRouter", + "ovms": "Intel OVMS", + "perplexity": "Perplexity", + "ph8": "PH8", + "poe": "Poe", + "ppio": "PPIO", + "qiniu": "Qiniu AI", + "qwenlm": "QwenLM", + "silicon": "SiliconFlow", + "sophnet": "SophNet", + "stepfun": "StepFun", + "tencent-cloud-ti": "Tencent Cloud TI", + "together": "Together", + "tokenflux": "TokenFlux", + "vertexai": "Vertex AI", + "voyageai": "Voyage AI", + "xirang": "State Cloud Xirang", + "yi": "Yi", + "zhinao": "360AI", + "zhipu": "BigModel" + }, + "restore": { + "confirm": { + "button": "Selectează fișierul de backup", + "label": "Ești sigur că vrei să restaurezi datele?" + }, + "content": "Operațiunea de restaurare va suprascrie toate datele actuale ale aplicației cu datele din backup. Te rugăm să reții că procesul de restaurare poate dura ceva timp, îți mulțumim pentru răbdare.", + "progress": { + "completed": "Restaurare finalizată", + "copying_files": "Se copiază fișierele... {{progress}}%", + "extracted": "Extragere reușită", + "extracting": "Se extrage backup-ul...", + "preparing": "Se pregătește restaurarea...", + "reading_data": "Se citesc datele...", + "title": "Progres restaurare" + }, + "title": "Restaurare date" + }, + "richEditor": { + "action": { + "table": { + "deleteColumn": "Șterge coloane", + "deleteRow": "Șterge rânduri", + "insertColumnAfter": "Inserează după", + "insertColumnBefore": "Inserează înainte", + "insertRowAfter": "Inserează dedesubt", + "insertRowBefore": "Inserează deasupra" + } + }, + "commands": { + "blockMath": { + "description": "Inserează formulă matematică", + "title": "Bloc matematic" + }, + "blockquote": { + "description": "Capturează un citat", + "title": "Citat" + }, + "bold": { + "description": "Marcat cu aldine", + "title": "Aldine" + }, + "bulletList": { + "description": "Creează o listă simplă cu marcatori", + "title": "Listă cu marcatori" + }, + "calloutInfo": { + "description": "Adaugă o casetă de informații", + "title": "Casetă informații" + }, + "calloutWarning": { + "description": "Adaugă o casetă de avertizare", + "title": "Casetă avertizare" + }, + "code": { + "description": "Inserează fragment de cod", + "title": "Cod" + }, + "codeBlock": { + "description": "Capturează un fragment de cod", + "title": "Cod" + }, + "columns": { + "description": "Creează aspect pe coloane", + "title": "Coloane" + }, + "date": { + "description": "Inserează data curentă", + "title": "Dată" + }, + "divider": { + "description": "Adaugă o linie orizontală", + "title": "Divizor" + }, + "hardBreak": { + "description": "Inserează o întrerupere de linie", + "title": "Întrerupere de linie" + }, + "heading1": { + "description": "Titlu secțiune mare", + "title": "Titlu 1" + }, + "heading2": { + "description": "Titlu secțiune mediu", + "title": "Titlu 2" + }, + "heading3": { + "description": "Titlu secțiune mic", + "title": "Titlu 3" + }, + "heading4": { + "description": "Titlu secțiune mai mic", + "title": "Titlu 4" + }, + "heading5": { + "description": "Titlu secțiune și mai mic", + "title": "Titlu 5" + }, + "heading6": { + "description": "Cel mai mic titlu de secțiune", + "title": "Titlu 6" + }, + "image": { + "description": "Inserează o imagine", + "title": "Imagine" + }, + "inlineCode": { + "description": "Adaugă cod în linie", + "title": "Cod în linie" + }, + "inlineMath": { + "description": "Inserează formule matematice în linie", + "title": "Matematică în linie" + }, + "italic": { + "description": "Marcat ca italic", + "title": "Italic" + }, + "link": { + "description": "Adaugă un link", + "title": "Link" + }, + "noCommandsFound": "Nicio comandă găsită", + "orderedList": { + "description": "Creează o listă numerotată", + "title": "Listă numerotată" + }, + "paragraph": { + "description": "Începe să scrii cu text simplu", + "title": "Text" + }, + "redo": { + "description": "Refă ultima acțiune", + "title": "Refă" + }, + "strike": { + "description": "Marchează ca tăiat", + "title": "Tăiat" + }, + "table": { + "description": "Inserează un tabel", + "title": "Tabel" + }, + "taskList": { + "description": "Creează o listă de sarcini", + "title": "Listă de sarcini" + }, + "underline": { + "description": "Marchează ca subliniat", + "title": "Subliniat" + }, + "undo": { + "description": "Anulează ultima acțiune", + "title": "Anulează" + } + }, + "dragHandle": "Trage pentru a muta", + "frontMatter": { + "addProperty": "Adaugă o proprietate", + "addTag": "Adaugă etichetă", + "changeToBoolean": "Casetă de bifare", + "changeToDate": "Dată", + "changeToNumber": "Număr", + "changeToTags": "Etichete", + "changeToText": "Text", + "changeType": "Schimbă tipul", + "deleteProperty": "Șterge proprietatea", + "editValue": "Editează valoarea", + "empty": "Gol", + "moreActions": "Mai multe acțiuni", + "propertyName": "Nume proprietate" + }, + "image": { + "placeholder": "Adaugă o poză" + }, + "imageUploader": { + "embedImage": "Încorporează imagine", + "embedLink": "Încorporează link", + "embedSuccess": "Imagine încorporată cu succes", + "invalidType": "Te rugăm să selectezi un fișier imagine", + "invalidUrl": "URL imagine invalid", + "processing": "Se procesează imaginea...", + "title": "Adaugă o imagine", + "tooLarge": "Dimensiunea imaginii nu poate depăși 10MB", + "upload": "Încarcă", + "uploadError": "Încărcarea imaginii a eșuat", + "uploadFile": "Încarcă fișier", + "uploadHint": "Suportă JPG, PNG, GIF și alte formate, max 10MB", + "uploadSuccess": "Imagine încărcată cu succes", + "uploadText": "Fă clic sau trage imaginea aici pentru a încărca", + "uploading": "Se încarcă imaginea", + "urlPlaceholder": "Lipește linkul imaginii", + "urlRequired": "Te rugăm să introduci URL-ul imaginii" + }, + "link": { + "remove": "Elimină linkul", + "text": "Titlu link", + "textPlaceholder": "Te rugăm să introduci titlul linkului", + "url": "URL link" + }, + "math": { + "placeholder": "Introdu formula LaTeX" + }, + "placeholder": "Scrie '/' pentru comenzi", + "plusButton": "Fă clic pentru a adăuga dedesubt", + "toolbar": { + "blockMath": "Bloc matematic", + "blockquote": "Citat", + "bold": "Aldine", + "bulletList": "Listă cu marcatori", + "clearMarks": "Șterge formatarea", + "code": "Cod în linie", + "codeBlock": "Bloc de cod", + "heading1": "Titlu 1", + "heading2": "Titlu 2", + "heading3": "Titlu 3", + "heading4": "Titlu 4", + "heading5": "Titlu 5", + "heading6": "Titlu 6", + "image": "Imagine", + "inlineMath": "Ecuație în linie", + "italic": "Italic", + "link": "Link", + "orderedList": "Listă ordonată", + "paragraph": "Paragraf", + "redo": "Refă", + "strike": "Tăiat", + "table": "Tabel", + "taskList": "Listă de sarcini", + "underline": "Subliniat", + "undo": "Anulează" + } + }, + "selection": { + "action": { + "builtin": { + "copy": "Copiază", + "explain": "Explică", + "quote": "Citează", + "refine": "Rafinează", + "search": "Caută", + "summary": "Rezumat", + "translate": "Tradu" + }, + "translate": { + "smart_translate_tips": "Traducere inteligentă: Conținutul va fi tradus mai întâi în limba țintă; conținutul aflat deja în limba țintă va fi tradus în limba alternativă" + }, + "window": { + "c_copy": "C: Copiază", + "esc_close": "Esc: Închide", + "esc_stop": "Esc: Oprește", + "opacity": "Opacitate fereastră", + "original_copy": "Copiază originalul", + "original_hide": "Ascunde originalul", + "original_show": "Arată originalul", + "pin": "Fixează", + "pinned": "Fixat", + "r_regenerate": "R: Regenerează" + } + }, + "name": "Asistent de selecție", + "settings": { + "actions": { + "add_tooltip": { + "disabled": "Numărul maxim de acțiuni personalizate a fost atins ({{max}})", + "enabled": "Adaugă acțiune personalizată" + }, + "custom": "Acțiune personalizată", + "delete_confirm": "Ești sigur că vrei să ștergi această acțiune personalizată?", + "drag_hint": "Trage pentru a reordona. Mută deasupra pentru a activa acțiunea ({{enabled}}/{{max}})", + "reset": { + "button": "Resetează", + "confirm": "Ești sigur că vrei să resetezi la acțiunile implicite? Acțiunile personalizate nu vor fi șterse.", + "tooltip": "Resetează la acțiunile implicite. Acțiunile personalizate nu vor fi șterse." + }, + "title": "Acțiuni" + }, + "advanced": { + "filter_list": { + "description": "Funcție avansată, recomandată utilizatorilor cu experiență", + "title": "Listă de filtrare" + }, + "filter_mode": { + "blacklist": "Listă neagră", + "default": "Oprit", + "description": "Poate limita asistentul de selecție să funcționeze doar în anumite aplicații (listă albă) sau să nu funcționeze (listă neagră)", + "title": "Filtru aplicații", + "whitelist": "Listă albă" + }, + "title": "Avansat" + }, + "enable": { + "description": "Momentan acceptat doar pe Windows și macOS", + "mac_process_trust_hint": { + "button": { + "go_to_settings": "Mergi la Setări", + "open_accessibility_settings": "Deschide setările de accesibilitate" + }, + "description": { + "0": "Asistentul de selecție necesită Permisiune de accesibilitate pentru a funcționa corect.", + "1": "Te rugăm să faci clic pe \"Mergi la Setări\" și să apeși butonul \"Deschide setările de sistem\" în fereastra pop-up de solicitare a permisiunii care apare ulterior. Apoi găsește \"Cherry Studio\" în lista de aplicații și activează comutatorul de permisiune.", + "2": "După finalizarea setărilor, te rugăm să redeschizi asistentul de selecție." + }, + "title": "Permisiune de accesibilitate" + }, + "title": "Activează" + }, + "experimental": "Funcții experimentale", + "filter_modal": { + "title": "Listă filtrare aplicații", + "user_tips": { + "mac": "Te rugăm să introduci Bundle ID-ul aplicației, unul pe linie, nu este sensibil la majuscule/minuscule, poate fi potrivit aproximativ. De exemplu: com.google.Chrome, com.apple.mail etc.", + "windows": "Te rugăm să introduci numele fișierului executabil al aplicației, unul pe linie, nu este sensibil la majuscule/minuscule, poate fi potrivit aproximativ. De exemplu: chrome.exe, weixin.exe, Cherry Studio.exe etc." + } + }, + "search_modal": { + "custom": { + "name": { + "hint": "Te rugăm să introduci numele motorului de căutare", + "label": "Nume personalizat", + "max_length": "Numele nu poate depăși 16 caractere" + }, + "test": "Test", + "url": { + "hint": "Folosește {{queryString}} pentru a reprezenta termenul de căutare", + "invalid_format": "Te rugăm să introduci un URL valid care începe cu http:// sau https://", + "label": "URL căutare personalizată", + "missing_placeholder": "URL-ul trebuie să conțină substituentul {{queryString}}", + "required": "Te rugăm să introduci URL-ul de căutare" + } + }, + "engine": { + "custom": "Personalizat", + "label": "Motor de căutare" + }, + "title": "Setează motorul de căutare" + }, + "toolbar": { + "compact_mode": { + "description": "În modul compact, sunt afișate doar pictogramele, fără text", + "title": "Mod compact" + }, + "title": "Bară de instrumente", + "trigger_mode": { + "ctrlkey": "Tasta Ctrl", + "ctrlkey_note": "După selecție, ține apăsată tasta Ctrl pentru a afișa bara de instrumente", + "description": "Modul de declanșare a asistentului de selecție și de afișare a barei de instrumente", + "description_note": { + "mac": "Dacă ai remapat tasta ⌘ folosind scurtături sau instrumente de mapare a tastaturii, acest lucru poate cauza eșecul selecției textului în unele aplicații.", + "windows": "Unele aplicații nu acceptă selectarea textului cu tasta Ctrl. Dacă ai remapat tasta Ctrl folosind instrumente precum AHK, acest lucru poate cauza eșecul selecției textului în unele aplicații." + }, + "selected": "Selecție", + "selected_note": "Arată bara de instrumente imediat ce textul este selectat", + "shortcut": "Comandă rapidă", + "shortcut_link": "Mergi la Setările comenzilor rapide", + "shortcut_note": "După selecție, folosește comanda rapidă pentru a afișa bara de instrumente. Te rugăm să setezi comanda rapidă în pagina de setări și să o activezi. ", + "title": "Mod de declanșare" + } + }, + "user_modal": { + "assistant": { + "default": "Implicit", + "label": "Selectează asistentul" + }, + "icon": { + "error": "Nume pictogramă invalid, te rugăm să verifici intrarea", + "label": "Pictogramă", + "placeholder": "Introdu numele pictogramei Lucide", + "random": "Pictogramă aleatorie", + "tooltip": "Numele pictogramelor Lucide sunt cu litere mici, de ex. arrow-right", + "view_all": "Vezi toate pictogramele" + }, + "model": { + "assistant": "Folosește asistent", + "default": "Model implicit", + "label": "Model", + "tooltip": "Folosind Asistent: Va folosi atât promptul de sistem al asistentului, cât și parametrii modelului" + }, + "name": { + "hint": "Te rugăm să introduci numele acțiunii", + "label": "Nume" + }, + "prompt": { + "copy_placeholder": "Copiază substituentul", + "label": "Prompt utilizator", + "placeholder": "Folosește substituentul {{text}} pentru a reprezenta textul selectat. Dacă este gol, textul selectat va fi adăugat la acest prompt", + "placeholder_text": "Substituent", + "tooltip": "Promptul utilizatorului servește ca o completare la intrarea utilizatorului și nu va suprascrie promptul de sistem al asistentului" + }, + "title": { + "add": "Adaugă acțiune personalizată", + "edit": "Editează acțiunea personalizată" + } + }, + "window": { + "auto_close": { + "description": "Închide automat fereastra când nu este fixată și pierde focusul", + "title": "Închidere automată" + }, + "auto_pin": { + "description": "Fixează fereastra în mod implicit", + "title": "Fixare automată" + }, + "follow_toolbar": { + "description": "Poziția ferestrei va urmări bara de instrumente. Când este dezactivat, va fi întotdeauna centrată.", + "title": "Urmărește bara de instrumente" + }, + "opacity": { + "description": "Setează opacitatea implicită a ferestrei, 100% este complet opac", + "title": "Opacitate" + }, + "remember_size": { + "description": "Fereastra se va afișa la ultima dimensiune ajustată în timpul rulării aplicației", + "title": "Memorează dimensiunea" + }, + "title": "Fereastră de acțiune" + } + } + }, + "settings": { + "about": { + "checkUpdate": { + "available": "Actualizare", + "label": "Verifică actualizări" + }, + "checkingUpdate": "Se verifică actualizările...", + "contact": { + "button": "E-mail", + "title": "Contact" + }, + "debug": { + "open": "Deschide", + "title": "Depanare" + }, + "description": "Un asistent AI puternic pentru producători", + "downloading": "Se descarcă...", + "enterprise": { + "title": "Enterprise" + }, + "feedback": { + "button": "Feedback", + "title": "Feedback" + }, + "label": "Despre și feedback", + "releases": { + "button": "Lansări", + "title": "Note de lansare" + }, + "social": { + "title": "Conturi sociale" + }, + "title": "Despre", + "updateAvailable": "S-a găsit o nouă versiune {{version}}", + "updateError": "Eroare actualizare", + "updateNotAvailable": "Utilizezi cea mai recentă versiune", + "website": { + "button": "Site web", + "title": "Site oficial" + } + }, + "advanced": { + "auto_switch_to_topics": "Comutare automată la subiect", + "title": "Setări avansate" + }, + "assistant": { + "icon": { + "type": { + "emoji": "Pictogramă Emoji", + "label": "Tip pictogramă model", + "model": "Pictogramă model", + "none": "Ascunde" + } + }, + "label": "Asistent implicit", + "model_params": "Parametri model", + "title": "Asistent implicit" + }, + "data": { + "app_data": { + "copy_data_option": "Copiază datele, va reporni automat după copierea datelor din directorul original în noul director", + "copy_failed": "Copierea datelor a eșuat", + "copy_success": "Datele au fost copiate cu succes în noua locație", + "copy_time_notice": "Copierea datelor poate dura ceva timp, nu închide forțat aplicația", + "copying": "Se copiază datele în noua locație...", + "copying_warning": "Se copiază datele, nu închide forțat aplicația; aplicația va reporni după copiere", + "label": "Date aplicație", + "migration_title": "Migrare date", + "new_path": "Cale nouă", + "original_path": "Cale originală", + "path_change_failed": "Schimbarea directorului de date a eșuat", + "path_changed_without_copy": "Calea a fost schimbată cu succes", + "restart_notice": "Aplicația poate necesita repornirea de mai multe ori pentru a aplica modificările", + "select": "Modifică directorul", + "select_error": "Schimbarea directorului de date a eșuat", + "select_error_in_app_path": "Noua cale este aceeași cu calea de instalare a aplicației, te rugăm să selectezi o altă cale", + "select_error_root_path": "Noua cale nu poate fi calea rădăcină", + "select_error_same_path": "Noua cale este aceeași cu vechea cale, te rugăm să selectezi o altă cale", + "select_error_write_permission": "Noua cale nu are permisiuni de scriere", + "select_not_empty_dir": "Noua cale nu este goală", + "select_not_empty_dir_content": "Noua cale nu este goală, va suprascrie datele din noua cale și există riscul de pierdere a datelor și de eșec al copierii. Continui?", + "select_success": "Directorul de date a fost schimbat, aplicația va reporni pentru a aplica modificările", + "select_title": "Schimbă directorul de date al aplicației", + "stop_quit_app_reason": "Aplicația migrează datele momentan și nu poate fi închisă" + }, + "app_knowledge": { + "button": { + "delete": "Șterge fișierul" + }, + "label": "Fișiere bază de cunoștințe", + "remove_all": "Elimină fișierele bazei de cunoștințe", + "remove_all_confirm": "Ștergerea fișierelor bazei de cunoștințe va reduce spațiul de stocare ocupat, dar nu va șterge datele vectoriale ale bazei de cunoștințe; după ștergere, fișierul sursă nu va mai putea fi deschis. Continui?", + "remove_all_success": "Fișiere eliminate cu succes" + }, + "app_logs": { + "button": "Deschide jurnalele", + "label": "Jurnale aplicație" + }, + "backup": { + "skip_file_data_help": "Omite salvarea fișierelor de date precum imagini și baze de cunoștințe în timpul backup-ului și salvează doar înregistrările de chat și setările. Reduce ocuparea spațiului și accelerează viteza de backup.", + "skip_file_data_title": "Backup simplificat" + }, + "clear_cache": { + "button": "Golește memoria cache", + "confirm": "Golirea memoriei cache va șterge datele cache ale aplicației, inclusiv datele minapp. Această acțiune este ireversibilă, continui?", + "error": "Eroare la golirea memoriei cache", + "success": "Memoria cache a fost golită", + "title": "Golește memoria cache" + }, + "data": { + "title": "Director de date" + }, + "divider": { + "basic": "Setări date de bază", + "cloud_storage": "Setări backup în cloud", + "export_settings": "Setări export", + "import_settings": "Setări import", + "third_party": "Conexiuni terțe" + }, + "export_menu": { + "docx": "Exportă ca Word", + "image": "Exportă ca imagine", + "joplin": "Exportă în Joplin", + "markdown": "Exportă ca Markdown", + "markdown_reason": "Exportă ca Markdown (cu raționament)", + "notes": "Exportă în Notițe", + "notion": "Exportă în Notion", + "obsidian": "Exportă în Obsidian", + "plain_text": "Copiază ca text simplu", + "siyuan": "Exportă în SiYuan Note", + "title": "Setări meniu export", + "yuque": "Exportă în Yuque" + }, + "export_to_phone": { + "confirm": { + "button": "Selectează fișierul de backup" + }, + "content": "Exportă unele date, inclusiv jurnalele de chat și setările. Te rugăm să reții că procesul de backup poate dura ceva timp. Îți mulțumim pentru răbdare.", + "lan": { + "connected": "Conectat", + "connection_failed": "Conexiune eșuată", + "content": "Te rugăm să te asiguri că computerul și telefonul sunt în aceeași rețea pentru transferul LAN.", + "device_list_title": "Dispozitive în rețeaua locală", + "discovered_devices": "Dispozitive descoperite", + "error": { + "file_too_large": "Fișier prea mare, maxim 500MB acceptat", + "init_failed": "Inițializare eșuată", + "invalid_file_type": "Doar fișierele ZIP sunt acceptate", + "no_file": "Niciun fișier selectat", + "no_ip": "Nu se poate obține adresa IP", + "not_connected": "Te rugăm să finalizezi handshake-ul mai întâi", + "send_failed": "Trimiterea fișierului a eșuat" + }, + "file_transfer": { + "cancelled": "Transfer anulat", + "failed": "Transfer fișier eșuat: {{message}}", + "progress": "Se trimite... {{progress}}%", + "success": "Fișier trimis cu succes" + }, + "handshake": { + "button": "Handshake", + "failed": "Handshake eșuat: {{message}}", + "in_progress": "Se efectuează handshake...", + "success": "Handshake finalizat cu {{device}}", + "test_message_received": "Primit pong de la {{device}}", + "test_message_sent": "Trimis payload test hello world" + }, + "idle_hint": "Scanare în pauză. Începe scanarea pentru a găsi parteneri Cherry Studio în LAN.", + "ip_addresses": "Adrese IP", + "last_seen": "Văzut ultima dată la {{time}}", + "metadata": "Metadate", + "no_connection_warning": "Te rugăm să deschizi Transfer LAN pe mobil în Cherry Studio", + "no_devices": "Încă nu s-au găsit parteneri LAN", + "scan_devices": "Scanează dispozitive", + "scanning_hint": "Se scanează rețeaua locală pentru parteneri Cherry Studio...", + "send_file": "Trimite fișier", + "status": { + "completed": "Transfer finalizat", + "connected": "Conectat", + "connecting": "Se conectează...", + "disconnected": "Deconectat", + "error": "Eroare de conexiune", + "initializing": "Se inițializează conexiunea...", + "preparing": "Se pregătește transferul...", + "sending": "Se transferă {{progress}}%" + }, + "status_badge_idle": "Inactiv", + "status_badge_scanning": "Se scanează", + "stop_scan": "Oprește scanarea", + "title": "Transmisie LAN", + "transfer_progress": "Progres transfer" + }, + "title": "Exportă pe telefon" + }, + "hour_interval_one": "{{count}} oră", + "hour_interval_other": "{{count}} ore", + "import_settings": { + "button": "Importă fișier Json", + "chatgpt": "Importă din ChatGPT", + "title": "Importă date din aplicație externă" + }, + "joplin": { + "check": { + "button": "Verifică", + "empty_token": "Te rugăm să introduci tokenul de autorizare Joplin", + "empty_url": "Te rugăm să introduci URL-ul serviciului Joplin Clipper", + "fail": "Verificarea conexiunii Joplin a eșuat", + "success": "Verificarea conexiunii Joplin a reușit" + }, + "export_reasoning": { + "help": "Când este activat, conținutul exportat va include lanțul de raționament (procesul de gândire).", + "title": "Include lanțul de raționament în export" + }, + "help": "În opțiunile Joplin, activează web clipper-ul (nu este necesară extensia de browser), confirmă portul și copiază tokenul de autentificare aici.", + "title": "Configurare Joplin", + "token": "Token de autorizare Joplin", + "token_placeholder": "Token de autorizare Joplin", + "url": "URL serviciu Joplin Web Clipper", + "url_placeholder": "http://127.0.0.1:41184/" + }, + "limit": { + "appDataDiskQuota": "Avertisment spațiu pe disc", + "appDataDiskQuotaDescription": "Spațiul directorului de date este aproape plin, te rugăm să eliberezi spațiu pe disc, altfel datele se vor pierde" + }, + "local": { + "autoSync": { + "label": "Backup automat", + "off": "Oprit" + }, + "backup": { + "button": "Backup local", + "manager": { + "columns": { + "actions": "Acțiuni", + "fileName": "Nume fișier", + "modifiedTime": "Ora modificării", + "size": "Dimensiune" + }, + "delete": { + "confirm": { + "multiple": "Ești sigur că vrei să ștergi cele {{count}} fișiere de backup selectate? Această acțiune nu poate fi anulată.", + "single": "Ești sigur că vrei să ștergi fișierul de backup \"{{fileName}}\"? Această acțiune nu poate fi anulată.", + "title": "Confirmă ștergerea" + }, + "error": "Ștergerea a eșuat", + "selected": "Șterge selectate", + "success": { + "multiple": "S-au șters cu succes {{count}} fișiere de backup", + "single": "Șters cu succes" + }, + "text": "Șterge" + }, + "fetch": { + "error": "Nu s-au putut obține fișierele de backup" + }, + "refresh": "Reîmprospătează", + "restore": { + "error": "Restaurare eșuată", + "success": "Restaurare reușită, aplicația se va reîmprospăta în scurt timp", + "text": "Restaurează" + }, + "select": { + "files": { + "delete": "Te rugăm să selectezi fișierele de backup de șters" + } + }, + "title": "Manager backup local" + }, + "modal": { + "filename": { + "placeholder": "Te rugăm să introduci numele fișierului de backup" + }, + "title": "Backup în director local" + } + }, + "directory": { + "label": "Director backup local", + "placeholder": "Selectează un director pentru backup-uri locale", + "select_error_app_data_path": "Noua cale nu poate fi aceeași cu calea datelor aplicației", + "select_error_in_app_install_path": "Noua cale nu poate fi aceeași cu calea de instalare a aplicației", + "select_error_write_permission": "Noua cale nu are permisiuni de scriere", + "select_title": "Selectează directorul de backup" + }, + "hour_interval_one": "{{count}} oră", + "hour_interval_other": "{{count}} ore", + "lastSync": "Ultimul backup", + "maxBackups": { + "label": "Backup-uri maxime", + "unlimited": "Nelimitat" + }, + "minute_interval_one": "{{count}} minut", + "minute_interval_other": "{{count}} minute", + "noSync": "Se așteaptă următorul backup", + "restore": { + "button": "Restaurează din local", + "confirm": { + "content": "Restaurarea din backup-ul local va înlocui datele actuale. Vrei să continui?", + "title": "Confirmă restaurarea" + } + }, + "syncError": "Eroare backup", + "syncStatus": "Stare backup", + "title": "Backup local" + }, + "markdown_export": { + "exclude_citations": { + "help": "Exclude citările și referințele la exportul în Markdown, păstrând doar conținutul principal", + "title": "Exclude citările" + }, + "force_dollar_math": { + "help": "Când este activat, $$ va fi folosit forțat pentru a marca formulele LaTeX la exportul în Markdown. Notă: Această opțiune afectează și toate metodele de export prin Markdown, cum ar fi Notion, Yuque etc.", + "title": "Forțează $$ pentru formulele LaTeX" + }, + "help": "Dacă este furnizată, exporturile vor fi salvate automat în această cale; în caz contrar, va apărea un dialog de salvare.", + "path": "Cale export implicită", + "path_placeholder": "Cale export", + "select": "Selectează", + "show_model_name": { + "help": "Când este activat, numele modelului va fi afișat la exportul în Markdown. Notă: Această opțiune afectează și toate metodele de export prin Markdown, cum ar fi Notion, Yuque etc.", + "title": "Folosește numele modelului la export" + }, + "show_model_provider": { + "help": "Afișează furnizorul modelului (de ex., OpenAI, Gemini) la exportul în Markdown", + "title": "Arată furnizorul modelului" + }, + "standardize_citations": { + "help": "Când este activat, marcatorii de citare vor fi convertiți în format standard de notă de subsol Markdown [^1], iar listele de citare vor fi formatate.", + "title": "Standardizează formatul citării" + }, + "title": "Export Markdown" + }, + "message_title": { + "use_topic_naming": { + "help": "Când este activat, folosește modelul rapid pentru a numi titlul mesajelor exportate. Această setare afectează și toate metodele de export prin Markdown.", + "title": "Folosește modelul rapid pentru a numi titlul mesajului exportat" + } + }, + "minute_interval_one": "{{count}} minut", + "minute_interval_other": "{{count}} minute", + "notion": { + "api_key": "Cheie API Notion", + "api_key_placeholder": "Introdu cheia API Notion", + "check": { + "button": "Verifică", + "empty_api_key": "Cheia API nu este configurată", + "empty_database_id": "ID-ul bazei de date nu este configurat", + "error": "Eroare de conexiune, te rugăm să verifici configurația rețelei și cheia API și ID-ul bazei de date", + "fail": "Conexiune eșuată, te rugăm să verifici rețeaua și cheia API și ID-ul bazei de date", + "success": "Conexiune reușită" + }, + "database_id": "ID bază de date Notion", + "database_id_placeholder": "Introdu ID-ul bazei de date Notion", + "export_reasoning": { + "help": "Când este activat, conținutul exportat va include lanțul de raționament (procesul de gândire).", + "title": "Include lanțul de raționament în export" + }, + "help": "Documentație configurare Notion", + "page_name_key": "Nume câmp titlu pagină", + "page_name_key_placeholder": "Introdu numele câmpului pentru titlul paginii, implicit este Name", + "title": "Setări Notion" + }, + "nutstore": { + "backup": { + "button": "Backup în Nutstore", + "modal": { + "filename": { + "placeholder": "Introdu numele fișierului de backup" + }, + "title": "Backup în Nutstore" + } + }, + "checkConnection": { + "fail": "Conexiunea Nutstore a eșuat", + "name": "Verifică conexiunea", + "success": "Conectat la Nutstore" + }, + "isLogin": "Conectat", + "login": { + "button": "Conectare" + }, + "logout": { + "button": "Deconectare", + "content": "După deconectare, nu vei mai putea face backup în Nutstore sau restaura din Nutstore.", + "title": "Ești sigur că vrei să te deconectezi de la Nutstore?" + }, + "new_folder": { + "button": { + "cancel": "Anulează", + "confirm": "Confirmă", + "label": "Dosar nou" + } + }, + "notLogin": "Neconectat", + "path": { + "label": "Cale stocare Nutstore", + "placeholder": "Introdu calea de stocare Nutstore" + }, + "pathSelector": { + "currentPath": "Cale curentă", + "return": "Înapoi", + "title": "Cale stocare Nutstore" + }, + "restore": { + "button": "Restaurează din Nutstore", + "confirm": { + "content": "Restaurarea din Nutstore va suprascrie datele curente. Vrei să continui?", + "title": "Restaurează din Nutstore" + } + }, + "title": "Configurare Nutstore", + "username": "Nume utilizator Nutstore" + }, + "obsidian": { + "default_vault": "Seif Obsidian implicit", + "default_vault_export_failed": "Export eșuat", + "default_vault_fetch_error": "Nu s-a putut prelua seiful Obsidian", + "default_vault_loading": "Se încarcă seiful Obsidian...", + "default_vault_no_vaults": "Nu s-au găsit seifuri Obsidian", + "default_vault_placeholder": "Te rugăm să selectezi seiful Obsidian implicit", + "title": "Configurare Obsidian" + }, + "s3": { + "accessKeyId": { + "label": "ID cheie de acces", + "placeholder": "ID cheie de acces" + }, + "autoSync": { + "hour": "La fiecare {{count}} oră", + "label": "Sincronizare automată", + "minute": "La fiecare {{count}} minute", + "off": "Oprit" + }, + "backup": { + "button": "Backup acum", + "error": "Backup S3 eșuat: {{message}}", + "manager": { + "button": "Gestionează backup-uri" + }, + "modal": { + "filename": { + "placeholder": "Te rugăm să introduci numele fișierului de backup" + }, + "title": "Backup S3" + }, + "operation": "Operațiune de backup", + "success": "Backup S3 reușit" + }, + "bucket": { + "label": "Bucket", + "placeholder": "Bucket, de ex.: exemplu" + }, + "endpoint": { + "label": "Endpoint API", + "placeholder": "https://s3.example.com" + }, + "manager": { + "close": "Închide", + "columns": { + "actions": "Acțiuni", + "fileName": "Nume fișier", + "modifiedTime": "Ora modificării", + "size": "Dimensiune fișier" + }, + "config": { + "incomplete": "Te rugăm să completezi configurația S3 completă" + }, + "delete": { + "confirm": { + "multiple": "Ești sigur că vrei să ștergi cele {{count}} fișiere de backup selectate? Această acțiune nu poate fi anulată.", + "single": "Ești sigur că vrei să ștergi fișierul de backup \"{{fileName}}\"? Această acțiune nu poate fi anulată.", + "title": "Confirmă ștergerea" + }, + "error": "Nu s-a putut șterge fișierul de backup: {{message}}", + "label": "Șterge", + "selected": "Șterge selectate ({{count}})", + "success": { + "multiple": "S-au șters cu succes {{count}} fișiere de backup", + "single": "Fișier de backup șters cu succes" + } + }, + "files": { + "fetch": { + "error": "Nu s-a putut prelua lista fișierelor de backup: {{message}}" + } + }, + "refresh": "Reîmprospătează", + "restore": "Restaurează", + "select": { + "warning": "Te rugăm să selectezi fișierele de backup de șters" + }, + "title": "Manager fișiere backup S3" + }, + "maxBackups": { + "label": "Backup-uri maxime", + "unlimited": "Nelimitat" + }, + "region": { + "label": "Regiune", + "placeholder": "Regiune, de ex.: us-east-1" + }, + "restore": { + "config": { + "incomplete": "Te rugăm să completezi configurația S3 completă" + }, + "confirm": { + "cancel": "Anulează", + "content": "Restaurarea datelor va suprascrie toate datele curente. Această acțiune nu poate fi anulată. Ești sigur că vrei să continui?", + "ok": "Confirmă restaurarea", + "title": "Confirmă restaurarea datelor" + }, + "error": "Restaurarea datelor a eșuat: {{message}}", + "file": { + "required": "Te rugăm să selectezi fișierul de backup pentru restaurare" + }, + "modal": { + "select": { + "placeholder": "Te rugăm să selectezi fișierul de backup pentru restaurare" + }, + "title": "Restaurare date S3" + }, + "success": "Restaurarea datelor a reușit" + }, + "root": { + "label": "Director backup (Opțional)", + "placeholder": "de ex.: /cherry-studio" + }, + "secretAccessKey": { + "label": "Cheie secretă de acces", + "placeholder": "Cheie secretă de acces" + }, + "skipBackupFile": { + "help": "Când este activat, datele fișierelor vor fi omise în timpul backup-ului, vor fi salvate doar informațiile de configurare, reducând semnificativ dimensiunea fișierului de backup", + "label": "Backup ușor" + }, + "syncStatus": { + "error": "Eroare sincronizare: {{message}}", + "label": "Stare sincronizare", + "lastSync": "Ultima sincronizare: {{time}}", + "noSync": "Nesincronizat" + }, + "title": { + "help": "Servicii de stocare a obiectelor compatibile S3, cum ar fi AWS S3, Cloudflare R2, Aliyun OSS, Tencent COS etc.", + "label": "Stocare compatibilă S3", + "tooltip": "Documentație configurare stocare compatibilă S3" + } + }, + "siyuan": { + "api_url": "URL API SiYuan Note", + "api_url_placeholder": "de ex.: http://127.0.0.1:6806", + "box_id": "ID Box SiYuan Note", + "box_id_placeholder": "Te rugăm să introduci ID-ul Box SiYuan Note", + "check": { + "button": "Verifică", + "empty_config": "Te rugăm să completezi adresa API și tokenul", + "error": "Eroare de conexiune, te rugăm să verifici conexiunea la rețea", + "fail": "Conexiune eșuată, te rugăm să verifici adresa API și tokenul", + "success": "Conexiune reușită", + "title": "Verificare conexiune" + }, + "root_path": "Cale rădăcină SiYuan Note", + "root_path_placeholder": "de ex.: /CherryStudio", + "title": "Configurare SiYuan Note", + "token": { + "help": "Obține token SiYuan Note", + "label": "Token SiYuan Note" + }, + "token_placeholder": "Te rugăm să introduci tokenul SiYuan Note" + }, + "title": "Setări date", + "webdav": { + "autoSync": { + "label": "Backup automat", + "off": "Oprit" + }, + "backup": { + "button": "Backup în WebDAV", + "manager": { + "columns": { + "actions": "Acțiuni", + "fileName": "Nume fișier", + "modifiedTime": "Ora modificării", + "size": "Dimensiune" + }, + "delete": { + "confirm": { + "multiple": "Ești sigur că vrei să ștergi cele {{count}} fișiere de backup selectate? Această acțiune nu poate fi anulată.", + "single": "Ești sigur că vrei să ștergi fișierul de backup \"{{fileName}}\"? Această acțiune nu poate fi anulată.", + "title": "Confirmă ștergerea" + }, + "error": "Ștergerea a eșuat", + "selected": "Șterge selectate", + "success": { + "multiple": "S-au șters cu succes {{count}} fișiere de backup", + "single": "Șters cu succes" + }, + "text": "Șterge" + }, + "fetch": { + "error": "Nu s-au putut obține fișierele de backup" + }, + "refresh": "Reîmprospătează", + "restore": { + "error": "Restaurare eșuată", + "success": "Restaurare reușită, aplicația se va reîmprospăta în scurt timp", + "text": "Restaurează" + }, + "select": { + "files": { + "delete": "Te rugăm să selectezi fișierele de backup de șters" + } + }, + "title": "Gestionare date backup" + }, + "modal": { + "filename": { + "placeholder": "Te rugăm să introduci numele fișierului de backup" + }, + "title": "Backup în WebDAV" + } + }, + "disableStream": { + "help": "Când este activat, încarcă fișierul în memorie înainte de încărcare. Acest lucru poate rezolva probleme de incompatibilitate cu unele servere WebDAV care nu acceptă încărcări fragmentate, dar va crește utilizarea memoriei.", + "title": "Dezactivează încărcarea prin flux" + }, + "host": { + "label": "Gazdă WebDAV", + "placeholder": "http://localhost:8080" + }, + "hour_interval_one": "{{count}} oră", + "hour_interval_other": "{{count}} ore", + "lastSync": "Ultimul backup", + "maxBackups": "Backup-uri maxime", + "minute_interval_one": "{{count}} minut", + "minute_interval_other": "{{count}} minute", + "noSync": "Se așteaptă următorul backup", + "password": "Parolă WebDAV", + "path": { + "label": "Cale WebDAV", + "placeholder": "/backup" + }, + "restore": { + "button": "Restaurează din WebDAV", + "confirm": { + "content": "Restaurarea din WebDAV va suprascrie datele curente. Vrei să continui?", + "title": "Confirmă restaurarea" + }, + "content": "Restaurarea din WebDAV va suprascrie datele curente, continui?", + "title": "Restaurează din WebDAV" + }, + "syncError": "Eroare backup", + "syncStatus": "Stare backup", + "title": "WebDAV", + "user": "Utilizator WebDAV" + }, + "yuque": { + "check": { + "button": "Verifică", + "empty_repo_url": "Te rugăm să introduci mai întâi URL-ul bazei de cunoștințe", + "empty_token": "Te rugăm să introduci mai întâi tokenul Yuque", + "fail": "Verificarea conexiunii Yuque a eșuat", + "success": "Conexiunea Yuque a fost verificată cu succes" + }, + "help": "Obține token Yuque", + "repo_url": "URL Yuque", + "repo_url_placeholder": "https://www.yuque.com/username/xxx", + "title": "Configurare Yuque", + "token": "Token Yuque", + "token_placeholder": "Te rugăm să introduci tokenul Yuque" + } + }, + "developer": { + "enable_developer_mode": "Activează modul dezvoltator", + "help": "După activarea modului dezvoltator, poți folosi funcția de urmărire (trace) pentru a vizualiza fluxul de date în timpul invocării modelului.", + "title": "Mod dezvoltator" + }, + "display": { + "assistant": { + "title": "Setări asistent" + }, + "custom": { + "css": { + "cherrycss": "Obține de la cherrycss.com", + "label": "CSS personalizat", + "placeholder": "/* Pune CSS personalizat aici */" + } + }, + "font": { + "code": "Font cod", + "default": "Implicit", + "global": "Font global", + "select": "Selectează font", + "title": "Setări font" + }, + "navbar": { + "position": { + "label": "Poziție bară de navigare", + "left": "Stânga", + "top": "Sus" + }, + "title": "Setări bară de navigare" + }, + "sidebar": { + "chat": { + "hiddenMessage": "Asistenții sunt funcții de bază, nu se acceptă ascunderea" + }, + "disabled": "Ascunde pictograme", + "empty": "Trage funcția ascunsă din partea stângă aici", + "files": { + "icon": "Arată pictograma Fișiere" + }, + "knowledge": { + "icon": "Arată pictograma Cunoștințe" + }, + "minapp": { + "icon": "Arată pictograma MinApp" + }, + "painting": { + "icon": "Arată pictograma Pictură" + }, + "title": "Setări bară laterală", + "translate": { + "icon": "Arată pictograma Traducere" + }, + "visible": "Arată pictograme" + }, + "title": "Setări afișare", + "topic": { + "title": "Setări subiect" + }, + "zoom": { + "title": "Setări zoom" + } + }, + "font_size": { + "title": "Dimensiune font mesaj" + }, + "general": { + "auto_check_update": { + "title": "Actualizare automată" + }, + "avatar": { + "builtin": "Avatar integrat", + "reset": "Resetează avatarul" + }, + "backup": { + "button": "Backup", + "title": "Backup și recuperare date" + }, + "display": { + "title": "Setări afișare" + }, + "emoji_picker": "Selector emoji", + "image_upload": "Încărcare imagine", + "label": "Setări generale", + "reset": { + "button": "Resetează", + "title": "Resetare date" + }, + "restore": { + "button": "Restaurează" + }, + "spell_check": { + "label": "Verificare ortografică", + "languages": "Folosește verificarea ortografică pentru" + }, + "test_plan": { + "beta_version": "Versiune Beta", + "beta_version_tooltip": "Funcțiile se pot schimba oricând, mai multe bug-uri, actualizare rapidă", + "rc_version": "Versiune Previzualizare (RC)", + "rc_version_tooltip": "Aproape de versiunea stabilă, funcțiile sunt în principiu stabile, puține bug-uri", + "title": "Plan de testare", + "tooltip": "Participă la planul de testare pentru a experimenta mai rapid cele mai recente funcții, dar aduce și mai multe riscuri; te rugăm să faci backup datelor în avans", + "version_channel_not_match": "Comutarea versiunii de previzualizare și test va intra în vigoare după lansarea următoarei versiuni stabile", + "version_options": "Opțiuni versiune" + }, + "title": "Setări generale", + "user_name": { + "label": "Nume utilizator", + "placeholder": "Introdu numele tău" + }, + "view_webdav_settings": "Vezi setările WebDAV" + }, + "groq": { + "title": "Setări Groq" + }, + "hardware_acceleration": { + "confirm": { + "content": "Dezactivarea accelerării hardware necesită repornirea aplicației pentru a intra în vigoare. Vrei să repornești acum?", + "title": "Repornire necesară" + }, + "title": "Dezactivează accelerarea hardware" + }, + "input": { + "auto_translate_with_space": "Tradu rapid cu 3 spații", + "clear": { + "all": "Golește", + "knowledge_base": "Golește bazele de cunoștințe selectate", + "models": "Golește toate modelele" + }, + "show_translate_confirm": "Arată dialogul de confirmare a traducerii", + "target_language": { + "chinese": "Chineză simplificată", + "chinese-traditional": "Chineză tradițională", + "english": "Engleză", + "japanese": "Japoneză", + "label": "Limba țintă", + "russian": "Rusă" + } + }, + "launch": { + "onboot": "Pornește automat la pornirea sistemului", + "title": "Lansare", + "totray": "Minimizează în zona de notificare la pornire" + }, + "math": { + "engine": { + "label": "Motor matematic", + "none": "Niciunul" + }, + "single_dollar": { + "label": "Activează $...$", + "tip": "Randează ecuațiile matematice citate prin semne unice de dolar $...$. Implicit este activat." + }, + "title": "Setări matematice" + }, + "mcp": { + "actions": "Acțiuni", + "active": "Activ", + "addError": "Nu s-a putut adăuga serverul", + "addServer": { + "create": "Creare rapidă", + "importFrom": { + "connectionFailed": "Conexiune eșuată", + "dxt": "Importă pachet DXT", + "dxtFile": "Fișier pachet DXT", + "dxtHelp": "Selectează un fișier .dxt care conține un pachet de server MCP", + "dxtProcessFailed": "Procesarea fișierului DXT a eșuat", + "error": { + "multipleServers": "Nu se poate importa din mai multe servere" + }, + "invalid": "Intrare invalidă, te rugăm să verifici formatul JSON", + "json": "Importă din JSON", + "method": "Metodă import", + "nameExists": "Serverul există deja: {{name}}", + "noDxtFile": "Te rugăm să selectezi un fișier DXT", + "oneServer": "Doar o singură configurație de server MCP la un moment dat", + "placeholder": "Lipește configurația JSON a serverului MCP", + "selectDxtFile": "Selectează fișierul DXT", + "tooltip": "Te rugăm să copiezi JSON-ul de configurare (prioritizând configurațiile\n NPX sau UVX) din pagina de introducere a serverelor MCP și să-l lipești în caseta de intrare." + }, + "label": "Adaugă server" + }, + "addSuccess": "Server adăugat cu succes", + "advancedSettings": "Setări avansate", + "args": "Argumente", + "argsTooltip": "Fiecare argument pe o linie nouă", + "baseUrlTooltip": "URL de bază server la distanță", + "builtinServers": "Servere integrate", + "builtinServersDescriptions": { + "brave_search": "O implementare de server MCP care integrează API-ul Brave Search, oferind funcționalități de căutare web și locală. Necesită configurarea variabilei de mediu BRAVE_API_KEY", + "browser": "Controlează o fereastră Electron headless prin Protocolul Chrome DevTools. Instrumente: deschide URL, execută JS pe o singură linie, resetează sesiunea.", + "didi_mcp": "Server DiDi MCP care oferă servicii de ride-hailing, inclusiv căutare pe hartă, estimare preț, gestionare comenzi și urmărire șofer. Disponibil doar în China continentală. Necesită configurarea variabilei de mediu DIDI_API_KEY", + "dify_knowledge": "Implementarea serverului MCP Dify oferă un API simplu pentru a interacționa cu Dify. Necesită configurarea cheii Dify", + "fetch": "Server MCP pentru preluarea conținutului web de la URL", + "filesystem": "Un server Node.js care implementează Protocolul de Context Model (MCP) pentru operațiuni în sistemul de fișiere. Necesită configurarea directoarelor permise pentru acces.", + "mcp_auto_install": "Instalează automat serviciul MCP (beta)", + "memory": "Implementare de memorie persistentă bazată pe un graf de cunoștințe local. Aceasta permite modelului să rețină informații legate de utilizator între conversații diferite. Necesită configurarea variabilei de mediu MEMORY_FILE_PATH.", + "no": "Fără descriere", + "nowledge_mem": "Necesită aplicația Nowledge Mem rulând local. Păstrează chat-urile AI, instrumentele, notițele, agenții și fișierele în memoria privată de pe computerul tău. Descarcă de la https://mem.nowledge.co/", + "python": "Execută cod Python într-un mediu sandbox securizat. Rulează Python cu Pyodide, suportând majoritatea bibliotecilor standard și pachetelor de calcul științific", + "sequentialthinking": "O implementare de server MCP care oferă instrumente pentru rezolvarea dinamică și reflexivă a problemelor prin procese de gândire structurată" + }, + "command": "Comandă", + "config_description": "Configurează serverele Protocolului de Context Model", + "customRegistryPlaceholder": "Introdu URL registru privat, de ex.: https://npm.company.com", + "deleteError": "Nu s-a putut șterge serverul", + "deleteServer": "Șterge serverul", + "deleteServerConfirm": "Ești sigur că vrei să ștergi acest server?", + "deleteSuccess": "Server șters cu succes", + "dependenciesInstall": "Instalează dependențe", + "dependenciesInstalling": "Se instalează dependențele...", + "description": "Descriere", + "disable": { + "description": "Nu activa funcționalitatea serverului MCP", + "label": "Dezactivează serverul MCP" + }, + "discover": "Descoperă", + "duplicateName": "Un server cu acest nume există deja", + "editJson": "Editează JSON", + "editMcpJson": "Editează configurația MCP", + "editServer": "Editează serverul", + "env": "Variabile de mediu", + "envTooltip": "Format: CHEIE=valoare, una pe linie", + "errors": { + "32000": "Serverul MCP nu a pornit, te rugăm să verifici parametrii conform tutorialului", + "toolNotFound": "Instrumentul {{name}} nu a fost găsit" + }, + "fetch": { + "button": "Preluare servere", + "success": "Serverele MCP au fost preluate cu succes" + }, + "findMore": "Găsește mai multe MCP", + "headers": "Headere", + "headersTooltip": "Headere personalizate pentru cereri HTTP", + "inMemory": "Memorie", + "install": "Instalează", + "installError": "Instalarea dependențelor a eșuat", + "installHelp": "Obține ajutor pentru instalare", + "installSuccess": "Dependențe instalate cu succes", + "jsonFormatError": "Eroare formatare JSON", + "jsonModeHint": "Editează reprezentarea JSON a configurației serverului MCP. Te rugăm să te asiguri că formatul este corect înainte de salvare.", + "jsonSaveError": "Nu s-a putut salva configurația JSON.", + "jsonSaveSuccess": "Configurația JSON a fost salvată.", + "logoUrl": "URL logo", + "logs": "Jurnale", + "longRunning": "Mod rulare lungă", + "longRunningTooltip": "Când este activat, serverul acceptă sarcini de lungă durată. La primirea notificărilor de progres, timpul de expirare va fi resetat, iar timpul maxim de execuție va fi extins la 10 minute.", + "marketplaces": "Piețe", + "missingDependencies": "Lipsește, te rugăm să îl instalezi pentru a continua.", + "more": { + "awesome": "Listă curatoriată servere MCP", + "composio": "Instrumente dezvoltare MCP Composio", + "glama": "Director servere MCP Glama", + "higress": "Server MCP Higress", + "mcpso": "Platformă descoperire servere MCP", + "modelscope": "Server MCP comunitate ModelScope", + "official": "Colecție oficială servere MCP", + "pulsemcp": "Server MCP Pulse", + "smithery": "Instrumente MCP Smithery", + "zhipu": "MCP curatoriat, integrare rapidă" + }, + "name": "Nume", + "newServer": "Server MCP", + "noDescriptionAvailable": "Nicio descriere disponibilă", + "noLogs": "Niciun jurnal încă", + "noServers": "Niciun server configurat", + "not_support": "Model neacceptat", + "npx_list": { + "actions": "Acțiuni", + "description": "Descriere", + "no_packages": "Nu s-au găsit pachete", + "npm": "NPM", + "package_name": "Nume pachet", + "scope_placeholder": "Introdu domeniul npm (de ex. @organizatia-ta)", + "scope_required": "Te rugăm să introduci domeniul npm", + "search": "Caută", + "search_error": "Eroare căutare", + "usage": "Utilizare", + "version": "Versiune" + }, + "oauth": { + "callback": { + "message": "Poți închide această pagină și te poți întoarce la Cherry Studio", + "title": "Autentificare reușită" + } + }, + "prompts": { + "arguments": "Argumente", + "availablePrompts": "Prompturi disponibile", + "genericError": "Eroare obținere prompt", + "loadError": "Eroare obținere prompturi", + "noPromptsAvailable": "Nu există prompturi disponibile", + "requiredField": "Câmp obligatoriu" + }, + "protocolInstallWarning": { + "command": "Comandă pornire", + "message": "Acest MCP a fost instalat dintr-o sursă externă prin protocol. Rularea instrumentelor necunoscute poate dăuna computerului tău.", + "run": "Rulează", + "title": "Rulezi MCP extern?" + }, + "provider": "Furnizor", + "providerPlaceholder": "Nume furnizor", + "providerUrl": "URL furnizor", + "providers": "Furnizori", + "registry": "Registru pachete", + "registryDefault": "Implicit", + "registryTooltip": "Alege registrul pentru instalarea pachetelor pentru a rezolva problemele de rețea cu registrul implicit.", + "requiresConfig": "Necesită configurare", + "resources": { + "availableResources": "Resurse disponibile", + "blob": "Blob", + "blobInvisible": "Blob invizibil", + "genericError": "Eroare achiziție resursă", + "mimeType": "Tip MIME", + "noResourcesAvailable": "Nu există resurse disponibile", + "size": "Dimensiune", + "text": "Text", + "uri": "URI" + }, + "search": { + "placeholder": "Caută servere MCP...", + "tooltip": "Caută servere MCP" + }, + "searchNpx": "Caută MCP", + "serverPlural": "servere", + "serverSingular": "server", + "servers": "Servere MCP", + "sse": "Evenimente trimise de server (sse)", + "startError": "Pornire eșuată", + "stdio": "Intrare/Ieșire standard (stdio)", + "streamableHttp": "HTTP fluxabil (streamableHttp)", + "sync": { + "button": "Sincronizează", + "discoverMcpServers": "Descoperă servere MCP", + "discoverMcpServersDescription": "Vizitează platforma pentru a descoperi servere MCP disponibile", + "error": "Eroare sincronizare servere MCP", + "getToken": "Obține token API", + "getTokenDescription": "Obține tokenul tău personal API din contul tău", + "noServersAvailable": "Nu există servere MCP disponibile", + "selectProvider": "Selectează furnizor:", + "setToken": "Introdu tokenul tău", + "success": "Sincronizare servere MCP reușită", + "title": "Sincronizare servere", + "tokenPlaceholder": "Introdu tokenul API aici", + "tokenRequired": "Tokenul API este obligatoriu", + "unauthorized": "Sincronizare neautorizată" + }, + "system": "Sistem", + "tabs": { + "description": "Descriere", + "general": "General", + "prompts": "Prompturi", + "resources": "Resurse", + "tools": "Instrumente" + }, + "tags": "Etichete", + "tagsPlaceholder": "Introdu etichete", + "timeout": "Expirare", + "timeoutTooltip": "Timpul de expirare în secunde pentru cererile către acest server, implicit este 60 secunde", + "title": "Servere MCP", + "tools": { + "autoApprove": { + "label": "Aprobare automată", + "tooltip": { + "confirm": "Ești sigur că vrei să rulezi acest instrument MCP?", + "disabled": "Instrumentul va necesita aprobare manuală înainte de rulare", + "enabled": "Instrumentul va rula automat fără confirmare", + "howToEnable": "Activează mai întâi instrumentul pentru a folosi aprobarea automată" + } + }, + "availableTools": "Instrumente disponibile", + "enable": "Activează instrumentul", + "inputSchema": { + "enum": { + "allowedValues": "Valori permise" + }, + "label": "Schemă intrare" + }, + "loadError": "Eroare obținere instrumente", + "noToolsAvailable": "Nu există instrumente disponibile", + "run": "Rulează" + }, + "type": "Tip", + "types": { + "inMemory": "În memorie", + "sse": "SSE", + "stdio": "STDIO", + "streamableHttp": "HTTP fluxabil" + }, + "updateError": "Actualizarea serverului a eșuat", + "updateSuccess": "Server actualizat cu succes", + "url": "URL", + "user": "Utilizator" + }, + "messages": { + "divider": { + "label": "Arată divizor între mesaje", + "tooltip": "Nu se aplică mesajelor stil bulă" + }, + "grid_columns": "Coloane afișare grilă mesaje", + "grid_popover_trigger": { + "click": "Fă clic pentru a afișa", + "hover": "Plasează cursorul pentru a afișa", + "label": "Declanșator detaliu grilă" + }, + "input": { + "confirm_delete_message": "Confirmă înainte de ștergerea mesajelor", + "confirm_regenerate_message": "Confirmă înainte de regenerarea mesajelor", + "enable_quick_triggers": "Activează declanșatoarele / și @", + "paste_long_text_as_file": "Lipește text lung ca fișier", + "paste_long_text_threshold": "Lungime lipire text lung", + "send_shortcuts": "Comenzi rapide trimitere", + "show_estimated_tokens": "Arată tokeni estimați", + "title": "Setări intrare" + }, + "markdown_rendering_input_message": "Randare Markdown mesaj intrare", + "metrics": "{{time_first_token_millsec}}ms până la primul token | {{token_speed}} tok/sec", + "model": { + "title": "Setări model" + }, + "navigation": { + "anchor": "Ancoră mesaj", + "buttons": "Butoane navigare", + "label": "Bară navigare", + "none": "Niciunul" + }, + "prompt": "Arată prompt", + "show_message_outline": "Arată contur mesaj", + "title": "Setări mesaje", + "use_serif_font": "Folosește font serif" + }, + "mineru": { + "api_key": "Mineru oferă acum o cotă zilnică gratuită de 500 de pagini și nu este nevoie să introduci o cheie." + }, + "miniapps": { + "cache_change_notice": "Modificările vor intra în vigoare când numărul de mini-aplicații deschise atinge valoarea setată", + "cache_description": "Setează numărul maxim de mini-aplicații active de păstrat în memorie", + "cache_settings": "Setări cache", + "cache_title": "Limită cache mini-aplicații", + "custom": { + "conflicting_ids": "ID-uri conflictuale cu aplicațiile implicite: {{ids}}", + "duplicate_ids": "ID-uri duplicate găsite: {{ids}}", + "edit_description": "Editează configurația mini-aplicației personalizate aici. Fiecare aplicație ar trebui să includă câmpurile id, name, url și logo.", + "edit_title": "Editează mini-aplicație personalizată", + "id": "ID", + "id_error": "ID-ul este obligatoriu.", + "id_placeholder": "Introdu ID", + "logo": "Logo", + "logo_file": "Încarcă fișier logo", + "logo_upload_button": "Încarcă", + "logo_upload_error": "Încărcarea logo-ului a eșuat.", + "logo_upload_label": "Încarcă logo", + "logo_upload_success": "Logo încărcat cu succes.", + "logo_url": "URL logo", + "logo_url_label": "URL logo", + "logo_url_placeholder": "Introdu URL logo", + "name": "Nume", + "name_error": "Numele este obligatoriu.", + "name_placeholder": "Introdu nume", + "placeholder": "Introdu configurația mini-aplicației personalizate (format JSON)", + "remove_error": "Eliminarea mini-aplicației personalizate a eșuat.", + "remove_success": "Mini-aplicația personalizată a fost eliminată cu succes.", + "save": "Salvează", + "save_error": "Salvarea mini-aplicației personalizate a eșuat.", + "save_success": "Mini-aplicația personalizată a fost salvată cu succes.", + "title": "Personalizat", + "url": "URL", + "url_error": "URL-ul este obligatoriu.", + "url_placeholder": "Introdu URL" + }, + "disabled": "Mini-aplicații ascunse", + "display_title": "Setări afișare mini-aplicații", + "empty": "Trage mini-aplicațiile din stânga pentru a le ascunde", + "open_link_external": { + "title": "Deschide linkurile de fereastră nouă în browser" + }, + "reset_tooltip": "Resetează la implicit", + "sidebar_description": "Arată mini-aplicațiile active în bara laterală", + "sidebar_title": "Afișare mini-aplicații active în bara laterală", + "title": "Setări mini-aplicații", + "visible": "Mini-aplicații vizibile" + }, + "model": "Model implicit", + "models": { + "add": { + "add_model": "Adaugă model", + "batch_add_models": "Adaugă modele în lot", + "endpoint_type": { + "label": "Tip endpoint", + "placeholder": "Selectează tip endpoint", + "required": "Te rugăm să selectezi un tip de endpoint", + "tooltip": "Selectează formatul tipului de endpoint API" + }, + "group_name": { + "label": "Nume grup", + "placeholder": "Opțional de ex. ChatGPT", + "tooltip": "Opțional de ex. ChatGPT" + }, + "model_id": { + "label": "ID model", + "placeholder": "Obligatoriu de ex. gpt-3.5-turbo", + "select": { + "placeholder": "Selectează model" + }, + "tooltip": "Exemplu: gpt-3.5-turbo" + }, + "model_name": { + "label": "Nume model", + "placeholder": "Opțional de ex. GPT-4", + "tooltip": "Opțional de ex. GPT-4" + }, + "supported_text_delta": { + "label": "Suportă ieșire text incrementală", + "tooltip": "Modelul returnează text incremental, mai degrabă decât tot odată. Activat implicit, dacă modelul nu acceptă acest lucru, te rugăm să dezactivezi această opțiune" + } + }, + "api_key": "Cheie API", + "base_url": "URL de bază", + "check": { + "all": "Toate", + "all_models_passed": "Verificarea tuturor modelelor a trecut", + "button_caption": "Verificare sănătate", + "disabled": "Dezactivat", + "disclaimer": "Verificarea sănătății necesită trimiterea de cereri, te rugăm să o folosești cu precauție. Modelele care taxează pe cerere pot genera costuri suplimentare, te rugăm să îți asumi responsabilitatea.", + "enable_concurrent": "Concurent", + "enabled": "Activat", + "failed": "Eșuat", + "keys_status_count": "Reușite: {{count_passed}} chei, eșuate: {{count_failed}} chei", + "model_status_failed": "{{count}} modele complet inaccesibile", + "model_status_partial": "{{count}} modele au avut chei inaccesibile", + "model_status_passed": "{{count}} modele au trecut verificările de sănătate", + "model_status_summary": "{{provider}}: {{summary}}", + "no_api_keys": "Nu s-au găsit chei API, te rugăm să adaugi mai întâi chei API.", + "no_results": "Niciun rezultat", + "passed": "Reușit", + "select_api_key": "Selectează cheia API de utilizat:", + "single": "Singur", + "start": "Start", + "timeout": "Expirare", + "title": "Verificare sănătate model", + "use_all_keys": "Cheie(i)" + }, + "default_assistant_model": "Model asistent implicit", + "default_assistant_model_description": "Model folosit la crearea unui nou asistent; dacă asistentul nu este setat, va fi folosit acest model", + "empty": "Nu s-au găsit modele", + "manage": { + "add_listed": { + "confirm": "Ești sigur că vrei să adaugi toate modelele la listă?", + "label": "Adaugă modele la listă" + }, + "add_whole_group": "Adaugă întregul grup", + "refetch_list": "Reîmprospătează lista modelelor", + "remove_listed": "Elimină modelele din listă", + "remove_model": "Elimină modelul", + "remove_whole_group": "Elimină întregul grup" + }, + "provider_id": "ID furnizor", + "provider_key_add_confirm": "Vrei să adaugi cheia API pentru {{provider}}?", + "provider_key_add_failed_by_empty_data": "Adăugarea cheii API a furnizorului a eșuat, datele sunt goale", + "provider_key_add_failed_by_invalid_data": "Adăugarea cheii API a furnizorului a eșuat, eroare format date", + "provider_key_added": "S-a adăugat cu succes cheia API pentru {{provider}}", + "provider_key_already_exists": "{{provider}} are deja o cheie API ({{existingKey}}). Nu o adăuga din nou.", + "provider_key_confirm_title": "Adaugă cheie API furnizor", + "provider_key_no_change": "Cheia API pentru {{provider}} nu s-a schimbat", + "provider_key_overridden": "S-a actualizat cu succes cheia API pentru {{provider}}", + "provider_key_override_confirm": "{{provider}} are deja o cheie API ({{existingKey}}). Vrei să o suprascrii cu noua cheie ({{newKey}})?", + "provider_name": "Nume furnizor", + "quick_assistant_default_tag": "Implicit", + "quick_assistant_model": "Model asistent rapid", + "quick_assistant_selection": "Selectează asistent", + "quick_model": { + "description": "Model folosit pentru sarcini simple, cum ar fi numirea subiectelor și extragerea cuvintelor cheie", + "label": "Model rapid", + "setting_title": "Configurare model rapid", + "tooltip": "Se recomandă alegerea unui model ușor și nu se recomandă alegerea unui model de gândire." + }, + "topic_naming": { + "auto": "Numire automată subiect", + "label": "Numire subiect", + "prompt": "Prompt numire subiect" + }, + "translate_model": "Model traducere", + "translate_model_description": "Model folosit pentru serviciul de traducere", + "translate_model_prompt_message": "Te rugăm să introduci promptul modelului de traducere", + "translate_model_prompt_title": "Prompt model traducere", + "use_assistant": "Folosește asistent", + "use_model": "Model implicit" + }, + "moresetting": { + "check": { + "confirm": "Confirmă selecția", + "warn": "Te rugăm să fii precaut când selectezi această opțiune. Selecția incorectă poate cauza funcționarea defectuoasă a modelului!" + }, + "label": "Mai multe setări", + "warn": "Avertisment de risc" + }, + "no_provider_selected": "Furnizor neselectat", + "notification": { + "assistant": "Mesaj asistent", + "backup": "Mesaj backup", + "knowledge_embed": "Mesaj bază de cunoștințe", + "title": "Setări notificări" + }, + "openai": { + "service_tier": { + "auto": "auto", + "default": "implicit", + "flex": "flex", + "on_demand": "la cerere", + "priority": "prioritate", + "tip": "Specifică nivelul de latență de utilizat pentru procesarea cererii", + "title": "Nivel serviciu" + }, + "stream_options": { + "include_usage": { + "tip": "Dacă utilizarea tokenilor este inclusă (aplicabil doar API-ului OpenAI Chat Completions)", + "title": "Include utilizare" + } + }, + "summary_text_mode": { + "auto": "auto", + "concise": "concis", + "detailed": "detaliat", + "off": "oprit", + "tip": "Un rezumat al raționamentului efectuat de model", + "title": "Mod rezumat" + }, + "title": "Setări OpenAI", + "verbosity": { + "high": "Ridicat", + "low": "Scăzut", + "medium": "Mediu", + "tip": "Controlează nivelul de detaliu în ieșirea modelului", + "title": "Verbozitate" + } + }, + "privacy": { + "enable_privacy_mode": "Raportare anonimă a erorilor și statisticilor", + "title": "Setări confidențialitate" + }, + "provider": { + "add": { + "name": { + "label": "Nume furnizor", + "placeholder": "Exemplu: OpenAI" + }, + "title": "Adaugă furnizor", + "type": "Tip furnizor" + }, + "anthropic": { + "apikey": "Cheie API", + "auth_failed": "Autentificarea Anthropic a eșuat", + "auth_method": "Metodă de autentificare", + "auth_success": "Autentificare OAuth Anthropic reușită", + "authenticated": "Verificat", + "authenticating": "Se autentifică", + "cancel": "Anulează", + "code_error": "Cod de autorizare invalid, te rugăm să încerci din nou", + "code_placeholder": "Te rugăm să introduci codul de autorizare afișat în browser", + "code_required": "Codul de autorizare nu poate fi gol", + "description": "Autentificare OAuth", + "description_detail": "Trebuie să te abonezi la Claude Pro sau o versiune superioară pentru a folosi această metodă de autentificare", + "enter_auth_code": "Cod de autorizare", + "logout": "Deconectare", + "logout_failed": "Deconectarea a eșuat, te rugăm să încerci din nou", + "logout_success": "Te-ai deconectat cu succes de la Anthropic", + "oauth": "Web OAuth", + "start_auth": "Începe autorizarea", + "submit_code": "Finalizează conectarea" + }, + "anthropic_api_host": "Gazdă API Anthropic", + "anthropic_api_host_preview": "Previzualizare Anthropic: {{url}}", + "anthropic_api_host_tooltip": "Folosește doar când furnizorul oferă un URL de bază compatibil cu Claude.", + "api": { + "key": { + "check": { + "latency": "Latență" + }, + "error": { + "duplicate": "Cheia API există deja", + "empty": "Cheia API nu poate fi goală" + }, + "list": { + "open": "Deschide interfața de gestionare", + "title": "Gestionare chei API" + }, + "new_key": { + "placeholder": "Introdu una sau mai multe chei" + } + }, + "options": { + "array_content": { + "help": "Furnizorul acceptă ca câmpul content al mesajului să fie de tip array?", + "label": "Acceptă conținut mesaj în format array" + }, + "developer_role": { + "help": "Furnizorul acceptă mesaje cu rolul: \"developer\"?", + "label": "Suportă mesaj dezvoltator" + }, + "enable_thinking": { + "help": "Furnizorul acceptă controlul raționamentului modelelor precum Qwen3 prin parametrul enable_thinking?", + "label": "Suportă enable_thinking" + }, + "label": "Setări API", + "service_tier": { + "help": "Dacă furnizorul acceptă configurarea parametrului service_tier. Când este activat, acest parametru poate fi ajustat în setările nivelului de serviciu de pe pagina de chat. (Doar modele OpenAI)", + "label": "Suportă service_tier" + }, + "stream_options": { + "help": "Furnizorul acceptă parametrul stream_options?", + "label": "Suportă stream_options" + }, + "verbosity": { + "help": "Dacă furnizorul acceptă parametrul verbosity", + "label": "Suportă verbosity" + } + }, + "url": { + "preview": "Previzualizare: {{url}}", + "reset": "Resetează", + "tip": "Adaugă # la final pentru a dezactiva versiunea API adăugată automat." + } + }, + "api_host": "Gazdă API", + "api_host_no_valid": "Adresa API este invalidă", + "api_host_preview": "Previzualizare: {{url}}", + "api_host_tooltip": "Suprascrie doar când furnizorul tău necesită un endpoint personalizat compatibil cu OpenAI.", + "api_key": { + "label": "Cheie API", + "tip": "Folosește virgule pentru a separa mai multe chei" + }, + "api_version": "Versiune API", + "aws-bedrock": { + "access_key_id": "ID cheie acces AWS", + "access_key_id_help": "ID-ul tău de cheie de acces AWS pentru accesarea serviciilor AWS Bedrock", + "api_key": "Cheie API Bedrock", + "api_key_help": "Cheia ta API AWS Bedrock pentru autentificare", + "auth_type": "Tip autentificare", + "auth_type_api_key": "Cheie API Bedrock", + "auth_type_help": "Alege între credențiale IAM sau autentificare cu cheie API Bedrock", + "auth_type_iam": "Credențiale IAM", + "description": "AWS Bedrock este serviciul de modele de fundație complet gestionat de Amazon care acceptă diverse modele lingvistice mari avansate", + "region": "Regiune AWS", + "region_help": "Regiunea serviciului tău AWS, de ex., us-east-1", + "secret_access_key": "Cheie secretă acces AWS", + "secret_access_key_help": "Cheia ta secretă de acces AWS, te rugăm să o păstrezi în siguranță", + "title": "Configurare AWS Bedrock" + }, + "azure": { + "apiversion": { + "tip": "Versiunea API a Azure OpenAI, dacă dorești să folosești API-ul de Răspuns, te rugăm să introduci versiunea v1" + } + }, + "basic_auth": { + "label": "Autentificare HTTP", + "password": { + "label": "Parolă", + "tip": "Introdu parola" + }, + "tip": "Aplicabil instanțelor implementate la distanță (vezi documentația). Momentan, doar schema Basic (RFC 7617) este acceptată.", + "user_name": { + "label": "Nume utilizator", + "tip": "Lasă gol pentru a dezactiva" + } + }, + "bills": "Facturi taxe", + "charge": "Reîncărcare sold", + "check": "Verifică", + "check_all_keys": "Verifică toate cheile", + "check_multiple_keys": "Verifică chei API multiple", + "copilot": { + "auth_failed": "Autentificarea Github Copilot a eșuat.", + "auth_success": "Autentificarea GitHub Copilot a reușit.", + "auth_success_title": "Certificare reușită.", + "code_copied": "Codul de autorizare copiat automat în clipboard", + "code_failed": "Obținerea Codului Dispozitivului a eșuat, te rugăm să încerci din nou.", + "code_generated_desc": "Te rugăm să copiezi codul dispozitivului în linkul de browser de mai jos.", + "code_generated_title": "Obține Cod Dispozitiv", + "connect": "Conectează la Github", + "custom_headers": "Antet cerere personalizat", + "description": "Contul tău GitHub trebuie să fie abonat la Copilot.", + "description_detail": "GitHub Copilot este un asistent de cod bazat pe AI care necesită un abonament GitHub Copilot valid pentru a fi utilizat", + "expand": "Extinde", + "headers_description": "Antete cerere personalizate (format JSON)", + "invalid_json": "Eroare format JSON", + "login": "Conectare la Github", + "logout": "Ieșire GitHub", + "logout_failed": "Ieșirea a eșuat, te rugăm să încerci din nou.", + "logout_success": "Te-ai deconectat cu succes.", + "model_setting": "Setări model", + "open_verification_first": "Te rugăm să faci clic pe linkul de mai sus pentru a accesa pagina de verificare.", + "open_verification_page": "Deschide pagina de autorizare", + "rate_limit": "Limitare rată", + "start_auth": "Începe autorizarea", + "step_authorize": "Deschide pagina de autorizare", + "step_authorize_desc": "Completează autorizarea pe GitHub", + "step_authorize_detail": "Fă clic pe butonul de mai jos pentru a deschide pagina de autorizare GitHub, apoi introdu codul de autorizare copiat", + "step_connect": "Finalizează conexiunea", + "step_connect_desc": "Confirmă conexiunea la GitHub", + "step_connect_detail": "După finalizarea autorizării pe pagina GitHub, fă clic pe acest buton pentru a finaliza conexiunea", + "step_copy_code": "Copiază codul de autorizare", + "step_copy_code_desc": "Copiază codul de autorizare al dispozitivului", + "step_copy_code_detail": "Codul de autorizare a fost copiat automat, îl poți copia și manual", + "step_get_code": "Obține codul de autorizare", + "step_get_code_desc": "Generează codul de autorizare al dispozitivului" + }, + "delete": { + "content": "Ești sigur că vrei să ștergi acest furnizor?", + "title": "Șterge furnizor" + }, + "dmxapi": { + "select_platform": "Selectează platforma" + }, + "docs_check": "Verifică", + "docs_more_details": "pentru mai multe detalii", + "get_api_key": "Obține cheie API", + "misc": "Altele", + "no_models_for_check": "Nu există modele disponibile pentru verificare (de ex. modele chat)", + "not_checked": "Neverificat", + "notes": { + "markdown_editor_default_value": "Zonă previzualizare", + "placeholder": "Introdu conținut Markdown...", + "title": "Note model" + }, + "oauth": { + "button": "Conectare cu {{provider}}", + "description": "Acest serviciu este furnizat de {{provider}}", + "error": "Autentificare eșuată", + "official_website": "Site oficial" + }, + "openai": { + "alert": "Furnizorul OpenAI nu mai acceptă metodele vechi de apelare. Dacă folosești un API terț, te rugăm să creezi un furnizor de servicii nou." + }, + "remove_duplicate_keys": "Elimină cheile duplicate", + "remove_invalid_keys": "Elimină cheile invalide", + "search": "Caută furnizori...", + "search_placeholder": "Caută id sau nume model", + "title": "Furnizor model", + "vertex_ai": { + "api_host_help": "Gazda API pentru Vertex AI, nerecomandat de completat, aplicabil în general pentru reverse proxy", + "documentation": "Vezi documentația oficială pentru mai multe detalii de configurare:", + "learn_more": "Află mai multe", + "location": "Locație", + "location_help": "Locația serviciului Vertex AI, de ex., us-central1", + "project_id": "ID Proiect", + "project_id_help": "ID-ul tău de proiect Google Cloud", + "project_id_placeholder": "id-ul-tau-proiect-google-cloud", + "service_account": { + "auth_success": "Cont de serviciu autentificat cu succes", + "client_email": "E-mail client", + "client_email_help": "Câmpul client_email din fișierul cheie JSON descărcat din Google Cloud Console", + "client_email_placeholder": "Introdu e-mailul clientului Contului de Serviciu", + "description": "Folosește Contul de Serviciu pentru autentificare, potrivit pentru mediile unde ADC nu este disponibil", + "incomplete_config": "Te rugăm să finalizezi mai întâi configurarea Contului de Serviciu", + "private_key": "Cheie privată", + "private_key_help": "Câmpul private_key din fișierul cheie JSON descărcat din Google Cloud Console", + "private_key_placeholder": "Introdu cheia privată a Contului de Serviciu", + "title": "Configurare Cont de Serviciu" + } + } + }, + "proxy": { + "address": "Adresă proxy", + "bypass": "Reguli de ocolire", + "mode": { + "custom": "Proxy personalizat", + "none": "Fără proxy", + "system": "Proxy sistem", + "title": "Mod proxy" + }, + "tip": "Acceptă potrivirea cu wildcard (*.test.com, 192.168.0.0/16)" + }, + "quickAssistant": { + "click_tray_to_show": "Fă clic pe pictograma din zona de notificare pentru a începe", + "enable_quick_assistant": "Activează Asistentul rapid", + "read_clipboard_at_startup": "Citește clipboardul la pornire", + "title": "Asistent rapid", + "use_shortcut_to_show": "Clic dreapta pe pictograma din zona de notificare sau folosește comenzile rapide pentru a începe" + }, + "quickPanel": { + "back": "Înapoi", + "close": "Închide", + "confirm": "Confirmă", + "forward": "Înainte", + "multiple": "Selecție multiplă", + "noResult": "Niciun rezultat găsit", + "page": "Pagină", + "select": "Selectează", + "title": "Meniu rapid" + }, + "quickPhrase": { + "add": "Adaugă expresie", + "assistant": "Expresii asistent", + "contentLabel": "Conținut", + "contentPlaceholder": "Te rugăm să introduci conținutul expresiei, poți folosi variabile și poți apăsa Tab pentru a localiza rapid variabila de modificat. De exemplu: \nAjută-mă să planific o rută de la ${from} la ${to} și trimite-o la ${email}.", + "delete": "Șterge expresia", + "deleteConfirm": "Expresia nu poate fi recuperată după ștergere, continui?", + "edit": "Editează expresia", + "global": "Expresii globale", + "locationLabel": "Adaugă locație", + "title": "Expresii rapide", + "titleLabel": "Titlu", + "titlePlaceholder": "Te rugăm să introduci titlul expresiei" + }, + "shortcuts": { + "action": "Acțiune", + "actions": "operațiune", + "clear_shortcut": "Șterge comanda rapidă", + "clear_topic": "Șterge mesajele", + "copy_last_message": "Copiază ultimul mesaj", + "edit_last_user_message": "Editează ultimul mesaj al utilizatorului", + "enabled": "Activează", + "exit_fullscreen": "Ieși din ecran complet", + "label": "Tastă", + "mini_window": "Asistent rapid", + "new_topic": "Subiect nou", + "press_shortcut": "Apasă comanda rapidă", + "rename_topic": "Redenumește subiectul", + "reset_defaults": "Resetează la implicite", + "reset_defaults_confirm": "Ești sigur că vrei să resetezi toate comenzile rapide?", + "reset_to_default": "Resetează la implicit", + "search_message": "Caută mesaj", + "search_message_in_chat": "Caută mesaj în chat-ul curent", + "selection_assistant_select_text": "Asistent de selecție: Selectează text", + "selection_assistant_toggle": "Comută Asistentul de selecție", + "show_app": "Arată/Ascunde aplicația", + "show_settings": "Deschide setările", + "title": "Comenzi rapide de la tastatură", + "toggle_new_context": "Șterge contextul", + "toggle_show_assistants": "Comută asistenții", + "toggle_show_topics": "Comută subiectele", + "zoom_in": "Mărește", + "zoom_out": "Micșorează", + "zoom_reset": "Resetează zoom-ul" + }, + "theme": { + "color_primary": "Culoare primară", + "dark": "Întunecat", + "light": "Luminos", + "system": "Sistem", + "title": "Temă", + "window": { + "style": { + "opaque": "Fereastră opacă", + "title": "Stil fereastră", + "transparent": "Fereastră transparentă" + } + } + }, + "title": "Setări", + "tool": { + "ocr": { + "common": { + "langs": "Limbi acceptate" + }, + "error": { + "not_system": "OCR-ul de sistem acceptă doar Windows și MacOS" + }, + "image": { + "error": { + "provider_not_found": "Furnizorul nu există" + }, + "system": { + "no_need_configure": "MacOS nu necesită configurare" + }, + "title": "Imagine" + }, + "image_provider": "Furnizor serviciu OCR", + "paddleocr": { + "aistudio_access_token": "Token de acces Comunitatea AI Studio", + "aistudio_url_label": "Comunitatea AI Studio", + "api_url": "URL API", + "serving_doc_url_label": "Documentație servire PaddleOCR", + "tip": "Poți consulta documentația oficială PaddleOCR pentru a implementa un serviciu local sau poți implementa un serviciu cloud pe Comunitatea PaddlePaddle AI Studio. Pentru ultimul caz, te rugăm să furnizezi tokenul de acces al Comunității AI Studio." + }, + "system": { + "win": { + "langs_tooltip": "Dependent de Windows pentru a furniza servicii, trebuie să descarci pachete lingvistice în sistem pentru a suporta limbile relevante." + } + }, + "tesseract": { + "langs_tooltip": "Citește documentația pentru a afla ce limbi personalizate sunt acceptate" + }, + "title": "Serviciu OCR" + }, + "preprocess": { + "provider": "Furnizor procesare documente", + "provider_placeholder": "Alege un furnizor de procesare documente", + "title": "Procesare documente", + "tooltip": "În Setări -> Instrumente, setează un furnizor de servicii de procesare a documentelor. Procesarea documentelor poate îmbunătăți eficient performanța de recuperare a documentelor cu format complex și a documentelor scanate." + }, + "title": "Alte setări", + "websearch": { + "api_key_required": { + "content": "{{provider}} necesită o cheie API pentru a funcționa. Dorești să o configurezi acum?", + "ok": "Configurează", + "title": "Cheie API necesară" + }, + "api_providers": "Furnizori API", + "apikey": "Cheie API", + "blacklist": "Listă neagră", + "blacklist_description": "Rezultatele de pe următoarele site-uri web nu vor apărea în rezultatele căutării", + "blacklist_tooltip": "Te rugăm să folosești următorul format (separate prin linie nouă)\nPotrivire model: *://*.exemplu.com/*\nExpresie regulată: /exemplu\\.(net|org)/", + "check": "Verifică", + "check_failed": "Verificare eșuată", + "check_success": "Verificare reușită", + "compression": { + "cutoff": { + "limit": { + "label": "Limită trunchiere", + "placeholder": "Introdu lungimea", + "tooltip": "Limitează lungimea conținutului rezultatelor căutării, conținutul care depășește limita va fi trunchiat (de ex., 2000 caractere)" + }, + "unit": { + "char": "Caractere", + "token": "Token" + } + }, + "error": { + "rag_failed": "RAG eșuat" + }, + "info": { + "dimensions_auto_success": "Dimensiuni obținute automat cu succes, dimensiuni: {{dimensions}}" + }, + "method": { + "cutoff": "Trunchiere", + "label": "Metodă compresie", + "none": "Niciuna", + "rag": "RAG" + }, + "rag": { + "document_count": { + "label": "Număr fragmente document", + "tooltip": "Numărul așteptat de fragmente de document de extras din fiecare rezultat al căutării; numărul total real de fragmente extrase este această valoare înmulțită cu numărul de rezultate ale căutării." + } + }, + "title": "Compresie rezultate căutare" + }, + "content_limit": "Limită lungime conținut", + "content_limit_tooltip": "Limitează lungimea conținutului rezultatelor căutării; conținutul care depășește limita va fi trunchiat.", + "default_provider": "Furnizor implicit", + "free": "Gratuit", + "is_default": "Implicit", + "local_provider": { + "hint": "Conectează-te la site pentru a obține rezultate mai bune ale căutării și pentru a personaliza setările de căutare.", + "open_settings": "Deschide setările {{provider}}", + "settings": "Setări căutare locală" + }, + "local_providers": "Furnizori locali", + "no_provider_selected": "Te rugăm să selectezi un furnizor de servicii de căutare înainte de a verifica.", + "overwrite": "Suprascrie serviciul de căutare", + "overwrite_tooltip": "Forțează utilizarea serviciului de căutare în loc de LLM", + "search_max_result": { + "label": "Număr de rezultate căutare", + "tooltip": "Când compresia rezultatelor căutării este dezactivată, numărul de rezultate poate fi prea mare, ceea ce poate duce la tokeni insuficienți" + }, + "search_provider": "Furnizor serviciu căutare", + "search_provider_placeholder": "Alege un furnizor de servicii de căutare.", + "search_with_time": "Caută cu date incluse", + "set_as_default": "Setează ca implicit", + "subscribe": "Abonare listă neagră", + "subscribe_add": "Adaugă abonament", + "subscribe_add_failed": "Adăugarea sursei fluxului a eșuat", + "subscribe_add_success": "Flux de abonament adăugat cu succes!", + "subscribe_delete": "Șterge", + "subscribe_name": { + "label": "Nume alternativ", + "placeholder": "Nume alternativ folosit când fluxul de abonament descărcat nu are nume." + }, + "subscribe_update": "Actualizează", + "subscribe_update_failed": "Actualizarea sursei abonamentului a eșuat", + "subscribe_update_success": "Sursa abonamentului a fost actualizată cu succes", + "subscribe_url": "Url abonament", + "tavily": { + "api_key": { + "label": "Cheie API Tavily", + "placeholder": "Introdu cheia API Tavily" + }, + "description": "Tavily este un motor de căutare adaptat pentru agenți AI, oferind rezultate în timp real, precise, sugestii inteligente de interogare și capacități de cercetare aprofundată.", + "title": "Tavily" + }, + "title": "Căutare web", + "url_invalid": "S-a introdus un URL invalid", + "url_required": "Te rugăm să introduci un URL" + } + }, + "topic": { + "pin_to_top": "Fixează subiectele sus", + "position": { + "label": "Poziție subiect", + "left": "Stânga", + "right": "Dreapta" + }, + "show": { + "time": "Arată ora subiectului" + } + }, + "translate": { + "custom": { + "delete": { + "description": "Ești sigur că vrei să ștergi?", + "title": "Șterge limbă personalizată" + }, + "error": { + "add": "Adăugarea a eșuat", + "delete": "Ștergerea a eșuat", + "langCode": { + "builtin": "Limba are suport integrat", + "empty": "Codul limbii este gol", + "exists": "Limba există deja", + "invalid": "Cod limbă invalid" + }, + "update": "Actualizarea a eșuat", + "value": { + "empty": "Numele limbii nu poate fi gol", + "too_long": "Numele limbii este prea lung" + } + }, + "langCode": { + "help": "Format [limbă+regiune], [2-3 litere mici]-[2-3 litere mici]", + "label": "Cod limbă", + "placeholder": "en-us" + }, + "success": { + "add": "Adăugat cu succes", + "delete": "Șters cu succes", + "update": "Actualizare reușită" + }, + "table": { + "action": { + "title": "Operațiune" + } + }, + "value": { + "help": "1~32 caractere", + "label": "Nume limbă", + "placeholder": "Engleză" + } + }, + "prompt": "Prompt traducere", + "title": "Setări traducere" + }, + "tray": { + "onclose": "Minimizează în zona de notificare la închidere", + "show": "Arată pictograma în zona de notificare", + "title": "Zonă de notificare" + }, + "zoom": { + "reset": "Resetează", + "title": "Zoom pagină" + } + }, + "title": { + "apps": "Aplicații", + "code": "Cod", + "files": "Fișiere", + "home": "Acasă", + "knowledge": "Bază de cunoștințe", + "launchpad": "Launchpad", + "mcp-servers": "Servere MCP", + "memories": "Amintiri", + "notes": "Notițe", + "paintings": "Picturi", + "settings": "Setări", + "store": "Bibliotecă asistenți", + "translate": "Traducere" + }, + "trace": { + "backList": "Înapoi la listă", + "edasSupport": "Susținut de Alibaba Cloud EDAS", + "endTime": "Timp final", + "inputs": "Intrări", + "label": "Lanț de apelare", + "name": "Nume nod", + "noTraceList": "Nu s-au găsit informații de urmărire", + "outputs": "Ieșiri", + "parentId": "ID părinte", + "spanDetail": "Detalii interval", + "spendTime": "Timp petrecut", + "startTime": "Timp de început", + "tag": "Etichetă", + "tokenUsage": "Utilizare token", + "traceWindow": "Fereastră lanț de apelare" + }, + "translate": { + "alter_language": "Limbă alternativă", + "any": { + "language": "Orice limbă" + }, + "button": { + "translate": "Tradu" + }, + "close": "Închide", + "closed": "Traducere închisă", + "complete": "Traducere finalizată", + "confirm": { + "content": "Traducerea va înlocui textul original, continui?", + "title": "Confirmare traducere" + }, + "copied": "Conținutul traducerii copiat", + "custom": { + "label": "Limbă personalizată" + }, + "detect": { + "method": { + "algo": { + "label": "algoritm", + "tip": "Folosește biblioteca franc pentru detectarea limbii" + }, + "auto": { + "label": "Automat", + "tip": "Selectează automat metoda de detectare potrivită" + }, + "label": "Metodă de detectare automată", + "llm": { + "tip": "Folosirea modelului rapid pentru detectarea limbii consumă mai puțini tokeni." + }, + "placeholder": "Selectează metoda de detectare automată", + "tip": "Metoda folosită la detectarea automată a limbii de intrare" + } + }, + "detected": { + "language": "Detectare automată" + }, + "empty": "Conținutul traducerii este gol", + "error": { + "chat_qwen_mt": "Modelul Qwen MT nu poate fi folosit în chat. Te rugăm să mergi la pagina de traducere.", + "detect": { + "qwen_mt": "Modelul QwenMT nu poate fi folosit pentru detectarea limbii", + "unknown": "Limbă necunoscută detectată", + "update_setting": "Setarea a eșuat" + }, + "empty": "Rezultatul traducerii este un conținut gol", + "failed": "Traducerea a eșuat", + "invalid_source": "Limbă sursă invalidă", + "not_configured": "Modelul de traducere nu este configurat", + "not_supported": "Limbă neacceptată {{language}}", + "unknown": "A apărut o eroare necunoscută în timpul traducerii" + }, + "exchange": { + "label": "Schimbă limbile sursă și țintă" + }, + "files": { + "drag_text": "Trage aici", + "error": { + "check_type": "A apărut o eroare la verificarea tipului de fișier", + "multiple": "Încărcarea mai multor fișiere nu este permisă", + "too_large": "Fișier prea mare", + "unknown": "Citirea conținutului fișierului a eșuat" + }, + "reading": "Se citește conținutul fișierului..." + }, + "history": { + "clear": "Golește istoricul", + "clear_description": "Golirea istoricului va șterge tot istoricul traducerilor, continui?", + "delete": "Șterge istoricul traducerilor", + "empty": "Niciun istoric de traducere", + "error": { + "delete": "Ștergerea a eșuat", + "save": "Salvarea istoricului traducerilor a eșuat" + }, + "search": { + "placeholder": "Caută în istoricul traducerilor" + }, + "title": "Istoric traduceri" + }, + "info": { + "aborted": "Traducere anulată" + }, + "input": { + "placeholder": "Textul, fișierele text sau imaginile (cu suport OCR) pot fi lipite sau trase aici" + }, + "language": { + "not_pair": "Limba sursă este diferită de limba setată", + "same": "Limbile sursă și țintă sunt aceleași" + }, + "menu": { + "description": "Tradu conținutul casetei de intrare curente" + }, + "not": { + "found": "Conținutul traducerii nu a fost găsit" + }, + "output": { + "placeholder": "Traducere" + }, + "processing": "Traducere în curs...", + "settings": { + "autoCopy": "Copiază după traducere ", + "bidirectional": "Setări traducere bidirecțională", + "bidirectional_tip": "Când este activat, este acceptată doar traducerea bidirecțională între limbile sursă și țintă", + "model": "Setări model", + "model_desc": "Model folosit pentru serviciul de traducere", + "model_placeholder": "Selectează modelul de traducere", + "no_model_warning": "Niciun model de traducere selectat", + "preview": "Previzualizare Markdown", + "scroll_sync": "Setări sincronizare derulare", + "title": "Setări traducere" + }, + "success": { + "custom": { + "delete": "Șters cu succes", + "update": "Actualizare reușită" + } + }, + "target_language": "Limbă țintă", + "title": "Traducere", + "tooltip": { + "newline": "Linie nouă" + } + }, + "tray": { + "quit": "Ieșire", + "show_mini_window": "Asistent rapid", + "show_window": "Arată fereastra" + }, + "update": { + "install": "Instalează", + "later": "Mai târziu", + "message": "Noua versiune {{version}} este gata, vrei să o instalezi acum?", + "noReleaseNotes": "Nicio notă de lansare", + "saveDataError": "Salvarea datelor a eșuat, te rugăm să încerci din nou.", + "title": "Actualizare" + }, + "warning": { + "missing_provider": "Furnizorul nu există; s-a revenit la furnizorul implicit {{provider}}. Acest lucru poate cauza probleme." + }, + "words": { + "knowledgeGraph": "Grafic de cunoștințe", + "quit": "Ieșire", + "show_window": "Arată fereastra", + "visualization": "Vizualizare" + } +} diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx index f759bcbf87..9ce67dc334 100644 --- a/src/renderer/src/pages/settings/GeneralSettings.tsx +++ b/src/renderer/src/pages/settings/GeneralSettings.tsx @@ -148,7 +148,8 @@ const GeneralSettings: FC = () => { { value: 'el-GR', label: 'Ελληνικά', flag: '🇬🇷' }, { value: 'es-ES', label: 'Español', flag: '🇪🇸' }, { value: 'fr-FR', label: 'Français', flag: '🇫🇷' }, - { value: 'pt-PT', label: 'Português', flag: '🇵🇹' } + { value: 'pt-PT', label: 'Português', flag: '🇵🇹' }, + { value: 'ro-RO', label: 'Română', flag: '🇷🇴' } ] const notificationSettings = useSelector((state: RootState) => state.settings.notification) diff --git a/src/renderer/src/pages/store/assistants/presets/assistantPresetGroupTranslations.ts b/src/renderer/src/pages/store/assistants/presets/assistantPresetGroupTranslations.ts index 953871ab30..7305585be4 100644 --- a/src/renderer/src/pages/store/assistants/presets/assistantPresetGroupTranslations.ts +++ b/src/renderer/src/pages/store/assistants/presets/assistantPresetGroupTranslations.ts @@ -11,6 +11,7 @@ export type GroupTranslations = { 'ru-RU': string 'ja-JP': string 'pt-PT': string + 'ro-RO': string } } @@ -25,7 +26,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '我的', 'ru-RU': 'Мои агенты', 'ja-JP': '私のエージェント', - 'pt-PT': 'Meus Agentes' + 'pt-PT': 'Meus Agentes', + 'ro-RO': 'Mă' }, 职业: { 'el-GR': 'Επαγγελμα', @@ -37,7 +39,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '職業', 'ru-RU': 'Карьера', 'ja-JP': 'キャリア', - 'pt-PT': 'Profissional' + 'pt-PT': 'Profissional', + 'ro-RO': 'Profesional' }, 商业: { 'el-GR': 'Εμπορικός', @@ -49,7 +52,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '商業', 'ru-RU': 'Бизнес', 'ja-JP': 'ビジネス', - 'pt-PT': 'Negócio' + 'pt-PT': 'Negócio', + 'ro-RO': 'Comercial' }, 工具: { 'el-GR': 'Εργαλεία', @@ -61,7 +65,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '工具', 'ru-RU': 'Инструменты', 'ja-JP': 'ツール', - 'pt-PT': 'Ferramentas' + 'pt-PT': 'Ferramentas', + 'ro-RO': 'Utilitare' }, 语言: { 'el-GR': 'Γλώσσα', @@ -73,7 +78,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '語言', 'ru-RU': 'Язык', 'ja-JP': '言語', - 'pt-PT': 'Idioma' + 'pt-PT': 'Idioma', + 'ro-RO': 'Limba' }, 办公: { 'el-GR': 'Γραφείο', @@ -85,7 +91,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '辦公', 'ru-RU': 'Офис', 'ja-JP': 'オフィス', - 'pt-PT': 'Escritório' + 'pt-PT': 'Escritório', + 'ro-RO': 'Oficiu' }, 通用: { 'el-GR': 'Γενικά', @@ -97,7 +104,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '通用', 'ru-RU': 'Общее', 'ja-JP': '一般', - 'pt-PT': 'Geral' + 'pt-PT': 'Geral', + 'ro-RO': 'General' }, 写作: { 'el-GR': 'Γράφημα', @@ -109,7 +117,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '寫作', 'ru-RU': 'Письмо', 'ja-JP': '書き込み', - 'pt-PT': 'Escrita' + 'pt-PT': 'Escrita', + 'ro-RO': 'Scrisoare' }, 精选: { 'el-GR': 'Επιλεγμένο', @@ -121,7 +130,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '精選', 'ru-RU': 'Избранное', 'ja-JP': '特集', - 'pt-PT': 'Destaque' + 'pt-PT': 'Destaque', + 'ro-RO': 'Recomandat' }, 编程: { 'el-GR': 'Προγραμματισμός', @@ -133,7 +143,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '編程', 'ru-RU': 'Программирование', 'ja-JP': 'プログラミング', - 'pt-PT': 'Programação' + 'pt-PT': 'Programação', + 'ro-RO': 'Programare' }, 情感: { 'el-GR': 'Αίσθημα', @@ -145,7 +156,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '情感', 'ru-RU': 'Эмоции', 'ja-JP': '感情', - 'pt-PT': 'Emoção' + 'pt-PT': 'Emoção', + 'ro-RO': 'Emoție' }, 教育: { 'el-GR': 'Εκπαίδευση', @@ -157,7 +169,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '教育', 'ru-RU': 'Образование', 'ja-JP': '教育', - 'pt-PT': 'Educação' + 'pt-PT': 'Educação', + 'ro-RO': 'Educație' }, 创意: { 'el-GR': 'Κreativiteit', @@ -169,7 +182,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '創意', 'ru-RU': 'Креатив', 'ja-JP': 'クリエイティブ', - 'pt-PT': 'Criativo' + 'pt-PT': 'Criativo', + 'ro-RO': 'Creativ' }, 学术: { 'el-GR': 'Ακαδημικός', @@ -181,7 +195,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '學術', 'ru-RU': 'Академический', 'ja-JP': 'アカデミック', - 'pt-PT': 'Académico' + 'pt-PT': 'Académico', + 'ro-RO': 'Academic' }, 设计: { 'el-GR': 'Δημιουργικό', @@ -193,7 +208,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '設計', 'ru-RU': 'Дизайн', 'ja-JP': 'デザイン', - 'pt-PT': 'Design' + 'pt-PT': 'Design', + 'ro-RO': 'Design' }, 艺术: { 'el-GR': 'Τέχνη', @@ -205,7 +221,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '藝術', 'ru-RU': 'Искусство', 'ja-JP': 'アート', - 'pt-PT': 'Arte' + 'pt-PT': 'Arte', + 'ro-RO': 'Art' }, 娱乐: { 'el-GR': 'Αναψυχή', @@ -217,7 +234,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '娛樂', 'ru-RU': 'Развлечения', 'ja-JP': 'エンターテイメント', - 'pt-PT': 'Entretenimento' + 'pt-PT': 'Entretenimento', + 'ro-RO': 'Entertainment' }, 生活: { 'el-GR': 'Ζωή', @@ -229,7 +247,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '生活', 'ru-RU': 'Жизнь', 'ja-JP': '生活', - 'pt-PT': 'Vida' + 'pt-PT': 'Vida', + 'ro-RO': 'Life' }, 医疗: { 'el-GR': 'Υγεία', @@ -241,7 +260,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '醫療', 'ru-RU': 'Медицина', 'ja-JP': '医療', - 'pt-PT': 'Saúde' + 'pt-PT': 'Saúde', + 'ro-RO': 'Medical' }, 游戏: { 'el-GR': 'Παιχνίδια', @@ -253,7 +273,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '遊戲', 'ru-RU': 'Игры', 'ja-JP': 'ゲーム', - 'pt-PT': 'Jogos' + 'pt-PT': 'Jogos', + 'ro-RO': 'Games' }, 翻译: { 'el-GR': 'Γραφήματα', @@ -265,7 +286,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '翻譯', 'ru-RU': 'Перевод', 'ja-JP': '翻訳', - 'pt-PT': 'Tradução' + 'pt-PT': 'Tradução', + 'ro-RO': 'Translation' }, 音乐: { 'el-GR': 'Μουσική', @@ -277,7 +299,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '音樂', 'ru-RU': 'Музыка', 'ja-JP': '音楽', - 'pt-PT': 'Música' + 'pt-PT': 'Música', + 'ro-RO': 'Music' }, 点评: { 'el-GR': 'Αξιολόγηση', @@ -289,7 +312,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '點評', 'ru-RU': 'Обзор', 'ja-JP': 'レビュー', - 'pt-PT': 'Revisão' + 'pt-PT': 'Revisão', + 'ro-RO': 'Review' }, 文案: { 'el-GR': 'Γραφήματα', @@ -301,7 +325,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '文案', 'ru-RU': 'Копирайтинг', 'ja-JP': 'コピーライティング', - 'pt-PT': 'Escrita' + 'pt-PT': 'Escrita', + 'ro-RO': 'Copywriting' }, 百科: { 'el-GR': 'Εγκυκλοπαίδεια', @@ -313,7 +338,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '百科', 'ru-RU': 'Энциклопедия', 'ja-JP': '百科事典', - 'pt-PT': 'Enciclopédia' + 'pt-PT': 'Enciclopédia', + 'ro-RO': 'Encyclopedia' }, 健康: { 'el-GR': 'Υγεία', @@ -325,7 +351,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '健康', 'ru-RU': 'Здоровье', 'ja-JP': '健康', - 'pt-PT': 'Saúde' + 'pt-PT': 'Saúde', + 'ro-RO': 'Health' }, 营销: { 'el-GR': 'Μάρκετινγκ', @@ -337,7 +364,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '營銷', 'ru-RU': 'Маркетинг', 'ja-JP': 'マーケティング', - 'pt-PT': 'Marketing' + 'pt-PT': 'Marketing', + 'ro-RO': 'Marketing' }, 科学: { 'el-GR': 'Επιστήμη', @@ -349,7 +377,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '科學', 'ru-RU': 'Наука', 'ja-JP': '科学', - 'pt-PT': 'Ciência' + 'pt-PT': 'Ciência', + 'ro-RO': 'Science' }, 分析: { 'el-GR': 'Ανάλυση', @@ -361,7 +390,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '分析', 'ru-RU': 'Анализ', 'ja-JP': '分析', - 'pt-PT': 'Análise' + 'pt-PT': 'Análise', + 'ro-RO': 'Analysis' }, 法律: { 'el-GR': 'Νόμος', @@ -373,7 +403,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '法律', 'ru-RU': 'Право', 'ja-JP': '法律', - 'pt-PT': 'Legal' + 'pt-PT': 'Legal', + 'ro-RO': 'Legal' }, 咨询: { 'el-GR': 'Συμβουλή', @@ -385,7 +416,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '諮詢', 'ru-RU': 'Консалтинг', 'ja-JP': 'コンサルティング', - 'pt-PT': 'Consultoria' + 'pt-PT': 'Consultoria', + 'ro-RO': 'Consulting' }, 金融: { 'el-GR': 'Φορολογία', @@ -397,7 +429,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '金融', 'ru-RU': 'Финансы', 'ja-JP': '金融', - 'pt-PT': 'Finanças' + 'pt-PT': 'Finanças', + 'ro-RO': 'Finance' }, 旅游: { 'el-GR': 'Τουρισμός', @@ -409,7 +442,8 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '旅遊', 'ru-RU': 'Путешествия', 'ja-JP': '旅行', - 'pt-PT': 'Viagens' + 'pt-PT': 'Viagens', + 'ro-RO': 'Travel' }, 管理: { 'el-GR': 'Διοίκηση', @@ -421,6 +455,7 @@ export const groupTranslations: GroupTranslations = { 'zh-TW': '管理', 'ru-RU': 'Управление', 'ja-JP': '管理', - 'pt-PT': 'Gestão' + 'pt-PT': 'Gestão', + 'ro-RO': 'Management' } } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index b87271930b..388b61d248 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -473,6 +473,7 @@ export type LanguageVarious = | 'fr-FR' | 'ja-JP' | 'pt-PT' + | 'ro-RO' | 'ru-RU' export type CodeStyleVarious = 'auto' | string From f2cd361ab8a3d9173192e3ebc1a0e15de56e49d4 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 22:00:02 +0800 Subject: [PATCH 081/116] refactor: migrate v1 message streaming to v2 StreamingService for state management - Replaced Redux dispatch and state management with StreamingService in BlockManager and various callback modules. - Simplified dependencies by removing unnecessary dispatch and getState parameters. - Updated block handling logic to utilize StreamingService for immediate updates and persistence during streaming. - Enhanced architecture for better performance and maintainability as part of the v2 data refactoring initiative. --- .../services/messageStreaming/BlockManager.ts | 136 +++-- .../messageStreaming/StreamingService.ts | 572 ++++++++++++++++++ .../callbacks/baseCallbacks.ts | 154 +++-- .../callbacks/citationCallbacks.ts | 59 +- .../callbacks/compactCallbacks.ts | 93 ++- .../messageStreaming/callbacks/index.ts | 59 +- .../callbacks/textCallbacks.ts | 39 +- .../callbacks/toolCallbacks.ts | 29 +- .../src/services/messageStreaming/index.ts | 2 + .../streamCallback.integration.test.ts | 87 +-- src/renderer/src/store/thunk/messageThunk.ts | 302 ++++++--- 11 files changed, 1168 insertions(+), 364 deletions(-) create mode 100644 src/renderer/src/services/messageStreaming/StreamingService.ts diff --git a/src/renderer/src/services/messageStreaming/BlockManager.ts b/src/renderer/src/services/messageStreaming/BlockManager.ts index 9e638ebf14..31362d2215 100644 --- a/src/renderer/src/services/messageStreaming/BlockManager.ts +++ b/src/renderer/src/services/messageStreaming/BlockManager.ts @@ -1,35 +1,48 @@ +/** + * @fileoverview BlockManager - Manages block operations during message streaming + * + * This module handles the lifecycle and state management of message blocks + * during the streaming process. It provides methods for: + * - Smart block updates with throttling support + * - Block type transitions + * - Active block tracking + * + * ARCHITECTURE NOTE: + * BlockManager now uses StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + * + * Key changes from original design: + * - dispatch/getState replaced with streamingService methods + * - DB saves removed during streaming (handled by finalize) + * - Throttling logic preserved, but internal calls changed + */ + import { loggerService } from '@logger' -import type { AppDispatch, RootState } from '@renderer/store' -import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock' -import { newMessagesActions } from '@renderer/store/newMessage' import type { MessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' +import { streamingService } from './StreamingService' + const logger = loggerService.withContext('BlockManager') +/** + * Information about the currently active block during streaming + */ interface ActiveBlockInfo { id: string type: MessageBlockType } +/** + * Dependencies required by BlockManager + * + * NOTE: Simplified from original design - removed dispatch, getState, and DB save functions + * since StreamingService now handles state management and persistence. + */ interface BlockManagerDependencies { - dispatch: AppDispatch - getState: () => RootState - saveUpdatedBlockToDB: ( - blockId: string | null, - messageId: string, - topicId: string, - getState: () => RootState - ) => Promise - saveUpdatesToDB: ( - messageId: string, - topicId: string, - messageUpdates: Partial, - blocksToUpdate: MessageBlock[] - ) => Promise - assistantMsgId: string topicId: string - // 节流器管理从外部传入 + assistantMsgId: string + // Throttling is still controlled externally by messageThunk.ts throttledBlockUpdate: (id: string, blockUpdate: any) => void cancelThrottledBlockUpdate: (id: string) => void } @@ -37,9 +50,9 @@ interface BlockManagerDependencies { export class BlockManager { private deps: BlockManagerDependencies - // 简化后的状态管理 + // Simplified state management private _activeBlockInfo: ActiveBlockInfo | null = null - private _lastBlockType: MessageBlockType | null = null // 保留用于错误处理 + private _lastBlockType: MessageBlockType | null = null // Preserved for error handling constructor(dependencies: BlockManagerDependencies) { this.deps = dependencies @@ -72,7 +85,15 @@ export class BlockManager { } /** - * 智能更新策略:根据块类型连续性自动判断使用节流还是立即更新 + * Smart update strategy: automatically decides between throttled and immediate updates + * based on block type continuity. + * + * Behavior: + * - If block type changes: cancel previous throttle, immediately update via streamingService + * - If block completes: cancel throttle, immediately update via streamingService + * - Otherwise: use throttled update (throttler calls streamingService internally) + * + * NOTE: DB saves are removed - persistence happens during finalize() */ smartBlockUpdate( blockId: string, @@ -82,61 +103,60 @@ export class BlockManager { ) { const isBlockTypeChanged = this._lastBlockType !== null && this._lastBlockType !== blockType if (isBlockTypeChanged || isComplete) { - // 如果块类型改变,则取消上一个块的节流更新 + // Cancel throttled update for previous block if type changed if (isBlockTypeChanged && this._activeBlockInfo) { this.deps.cancelThrottledBlockUpdate(this._activeBlockInfo.id) } - // 如果当前块完成,则取消当前块的节流更新 + // Cancel throttled update for current block if complete if (isComplete) { this.deps.cancelThrottledBlockUpdate(blockId) - this._activeBlockInfo = null // 块完成时清空activeBlockInfo + this._activeBlockInfo = null // Clear activeBlockInfo when block completes } else { - this._activeBlockInfo = { id: blockId, type: blockType } // 更新活跃块信息 + this._activeBlockInfo = { id: blockId, type: blockType } // Update active block info } - this.deps.dispatch(updateOneBlock({ id: blockId, changes })) - this.deps.saveUpdatedBlockToDB(blockId, this.deps.assistantMsgId, this.deps.topicId, this.deps.getState) + + // Immediate update via StreamingService (replaces dispatch + DB save) + streamingService.updateBlock(blockId, changes) this._lastBlockType = blockType } else { - this._activeBlockInfo = { id: blockId, type: blockType } // 更新活跃块信息 + this._activeBlockInfo = { id: blockId, type: blockType } // Update active block info + // Throttled update (throttler internally calls streamingService.updateBlock) this.deps.throttledBlockUpdate(blockId, changes) } } /** - * 处理块转换 + * Handle block transitions (new block creation during streaming) + * + * This method: + * 1. Updates active block tracking state + * 2. Adds new block to StreamingService + * 3. Updates message block references + * + * NOTE: DB saves are removed - persistence happens during finalize() */ async handleBlockTransition(newBlock: MessageBlock, newBlockType: MessageBlockType) { logger.debug('handleBlockTransition', { newBlock, newBlockType }) this._lastBlockType = newBlockType - this._activeBlockInfo = { id: newBlock.id, type: newBlockType } // 设置新的活跃块信息 + this._activeBlockInfo = { id: newBlock.id, type: newBlockType } // Set new active block info - this.deps.dispatch( - newMessagesActions.updateMessage({ - topicId: this.deps.topicId, - messageId: this.deps.assistantMsgId, - updates: { blockInstruction: { id: newBlock.id } } - }) - ) - this.deps.dispatch(upsertOneBlock(newBlock)) - this.deps.dispatch( - newMessagesActions.upsertBlockReference({ - messageId: this.deps.assistantMsgId, - blockId: newBlock.id, - status: newBlock.status, - blockType: newBlock.type - }) - ) + // Add new block to StreamingService (replaces dispatch(upsertOneBlock)) + streamingService.addBlock(this.deps.assistantMsgId, newBlock) - const currentState = this.deps.getState() - const updatedMessage = currentState.messages.entities[this.deps.assistantMsgId] - if (updatedMessage) { - await this.deps.saveUpdatesToDB(this.deps.assistantMsgId, this.deps.topicId, { blocks: updatedMessage.blocks }, [ - newBlock - ]) - } else { - logger.error( - `[handleBlockTransition] Failed to get updated message ${this.deps.assistantMsgId} from state for DB save.` - ) - } + // Update block reference in message (replaces dispatch(upsertBlockReference)) + streamingService.addBlockReference(this.deps.assistantMsgId, newBlock.id) + + // TEMPORARY: The blockInstruction field was used for UI coordination. + // TODO: Evaluate if this is still needed with StreamingService approach + // For now, we update it in the message + streamingService.updateMessage(this.deps.assistantMsgId, { + blockInstruction: { id: newBlock.id } + } as any) // Using 'as any' since blockInstruction may not be in Message type + + logger.debug('Block transition completed', { + messageId: this.deps.assistantMsgId, + blockId: newBlock.id, + blockType: newBlockType + }) } } diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts new file mode 100644 index 0000000000..29e0a484cd --- /dev/null +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -0,0 +1,572 @@ +/** + * @fileoverview StreamingService - Manages message streaming lifecycle and state + * + * This service encapsulates the streaming state management during message generation. + * It uses CacheService (memoryCache) for temporary storage during streaming, + * and persists final data to the database via Data API or dbService. + * + * Key Design Decisions: + * - Uses messageId as primary key for sessions (supports multi-model concurrent streaming) + * - Streaming data is stored in memory only (not Redux, not Dexie during streaming) + * - On finalize, data is converted to new format and persisted via appropriate data source + * - Throttling is handled externally by messageThunk.ts (preserves existing throttle logic) + * + * Cache Key Strategy: + * - Session key: `streaming:session:${messageId}` - Internal session lifecycle management + * - Topic sessions index: `streaming:topic:${topicId}:sessions` - Track active sessions per topic + * - Message key: `streaming:message:${messageId}` - UI subscription for message-level changes + * - Block key: `streaming:block:${blockId}` - UI subscription for block content updates + */ + +import { cacheService } from '@data/CacheService' +import { dataApiService } from '@data/DataApiService' +import { loggerService } from '@logger' +import type { Message, MessageBlock } from '@renderer/types/newMessage' +import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' +import { isAgentSessionTopicId } from '@renderer/utils/agentSession' +import type { UpdateMessageDto } from '@shared/data/api/schemas/messages' +import type { MessageDataBlock, MessageStats } from '@shared/data/types/message' + +import { dbService } from '../db' + +const logger = loggerService.withContext('StreamingService') + +// Cache key generators +const getSessionKey = (messageId: string) => `streaming:session:${messageId}` +const getTopicSessionsKey = (topicId: string) => `streaming:topic:${topicId}:sessions` +const getMessageKey = (messageId: string) => `streaming:message:${messageId}` +const getBlockKey = (blockId: string) => `streaming:block:${blockId}` +const getSiblingsGroupCounterKey = (topicId: string) => `streaming:topic:${topicId}:siblings-counter` + +// Session TTL for auto-cleanup (prevents memory leaks from crashed processes) +const SESSION_TTL = 5 * 60 * 1000 // 5 minutes + +/** + * Streaming session data structure (stored in memory) + */ +interface StreamingSession { + topicId: string + messageId: string + + // Message data (legacy format, compatible with existing logic) + message: Message + blocks: Record + + // Tree structure information (v2 new fields) + parentId: string // Parent message ID (user message) + siblingsGroupId: number // Multi-model group ID (0=normal, >0=multi-model response) + + // Context for usage estimation (messages up to and including user message) + contextMessages?: Message[] + + // Metadata + startedAt: number +} + +/** + * Options for starting a streaming session + */ +interface StartSessionOptions { + parentId: string + siblingsGroupId?: number // Defaults to 0 + role: 'assistant' + model?: Message['model'] + modelId?: string + assistantId: string + askId?: string + traceId?: string + agentSessionId?: string + // Context messages for usage estimation (messages up to and including user message) + contextMessages?: Message[] +} + +/** + * StreamingService - Manages streaming message state during generation + * + * Responsibilities: + * - Session lifecycle management (start, update, finalize, clear) + * - Block operations (add, update, get) + * - Message operations (update, get) + * - Cache-based state management with automatic TTL cleanup + */ +class StreamingService { + // Internal mapping: blockId -> messageId (for efficient block updates) + private blockToMessageMap = new Map() + + // ============ Session Lifecycle ============ + + /** + * Start a streaming session for a message + * + * IMPORTANT: The message must be created via Data API POST before calling this. + * This method initializes the in-memory streaming state. + * + * @param topicId - Topic ID (used for topic sessions index) + * @param messageId - Message ID returned from Data API POST + * @param options - Session options including parentId and siblingsGroupId + */ + startSession(topicId: string, messageId: string, options: StartSessionOptions): void { + const { + parentId, + siblingsGroupId = 0, + role, + model, + modelId, + assistantId, + askId, + traceId, + agentSessionId, + contextMessages + } = options + + // Initialize message structure + const message: Message = { + id: messageId, + topicId, + role, + assistantId, + status: AssistantMessageStatus.PENDING, + createdAt: new Date().toISOString(), + blocks: [], + model, + modelId, + askId, + traceId, + agentSessionId + } + + // Create session + const session: StreamingSession = { + topicId, + messageId, + message, + blocks: {}, + parentId, + siblingsGroupId, + contextMessages, + startedAt: Date.now() + } + + // Store session with TTL + cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + + // Store message data for UI subscription + cacheService.setCasual(getMessageKey(messageId), message, SESSION_TTL) + + // Add to topic sessions index + const topicSessions = cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + if (!topicSessions.includes(messageId)) { + topicSessions.push(messageId) + cacheService.setCasual(getTopicSessionsKey(topicId), topicSessions, SESSION_TTL) + } + + logger.debug('Started streaming session', { topicId, messageId, parentId, siblingsGroupId }) + } + + /** + * Finalize a streaming session by persisting data to database + * + * This method: + * 1. Converts streaming data to the appropriate format + * 2. Routes to Data API (normal topics) or dbService (agent topics) + * 3. Cleans up all related cache keys + * + * @param messageId - Session message ID + * @param status - Final message status + */ + async finalize(messageId: string, status: AssistantMessageStatus): Promise { + const session = this.getSession(messageId) + if (!session) { + logger.warn(`finalize called for non-existent session: ${messageId}`) + return + } + + const maxRetries = 3 + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const updatePayload = this.convertToUpdatePayload(session, status) + + // TRADEOFF: Using dbService for agent messages instead of Data API + // because agent message storage refactoring is planned for later phase. + // TODO: Unify to Data API when agent message migration is complete. + if (isAgentSessionTopicId(session.topicId)) { + await dbService.updateMessageAndBlocks(session.topicId, updatePayload.messageUpdates, updatePayload.blocks) + } else { + // Normal topic → Use Data API for persistence + const dataApiPayload = this.convertToDataApiFormat(session, status) + await dataApiService.patch(`/messages/${session.messageId}`, { body: dataApiPayload }) + } + + // Success - cleanup session + this.clearSession(messageId) + logger.debug('Finalized streaming session', { messageId, status }) + return + } catch (error) { + lastError = error as Error + logger.warn(`finalize attempt ${attempt}/${maxRetries} failed:`, error as Error) + + if (attempt < maxRetries) { + // Exponential backoff + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) + } + } + } + + // All retries failed + logger.error(`finalize failed after ${maxRetries} attempts:`, lastError) + // TRADEOFF: Don't clear session to allow manual retry + // TTL will auto-clean to prevent permanent memory leak + throw lastError + } + + /** + * Clear a streaming session and all related cache keys + * + * @param messageId - Session message ID + */ + clearSession(messageId: string): void { + const session = this.getSession(messageId) + if (!session) { + return + } + + // Remove block mappings + Object.keys(session.blocks).forEach((blockId) => { + this.blockToMessageMap.delete(blockId) + cacheService.deleteCasual(getBlockKey(blockId)) + }) + + // Remove message cache + cacheService.deleteCasual(getMessageKey(messageId)) + + // Remove from topic sessions index + const topicSessions = cacheService.getCasual(getTopicSessionsKey(session.topicId)) || [] + const updatedTopicSessions = topicSessions.filter((id) => id !== messageId) + if (updatedTopicSessions.length > 0) { + cacheService.setCasual(getTopicSessionsKey(session.topicId), updatedTopicSessions, SESSION_TTL) + } else { + cacheService.deleteCasual(getTopicSessionsKey(session.topicId)) + } + + // Remove session + cacheService.deleteCasual(getSessionKey(messageId)) + + logger.debug('Cleared streaming session', { messageId, topicId: session.topicId }) + } + + // ============ Block Operations ============ + + /** + * Add a new block to a streaming session + * (Replaces dispatch(upsertOneBlock)) + * + * @param messageId - Parent message ID + * @param block - Block to add + */ + addBlock(messageId: string, block: MessageBlock): void { + const session = this.getSession(messageId) + if (!session) { + logger.warn(`addBlock called for non-existent session: ${messageId}`) + return + } + + // Register block mapping + this.blockToMessageMap.set(block.id, messageId) + + // Add to session + session.blocks[block.id] = block + + // Update message block references + if (!session.message.blocks.includes(block.id)) { + session.message.blocks = [...session.message.blocks, block.id] + } + + // Update caches + cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + cacheService.setCasual(getBlockKey(block.id), block, SESSION_TTL) + cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) + + logger.debug('Added block to session', { messageId, blockId: block.id, blockType: block.type }) + } + + /** + * Update a block in a streaming session + * (Replaces dispatch(updateOneBlock)) + * + * NOTE: This method does NOT include throttling. Throttling is controlled + * by the existing throttler in messageThunk.ts. + * + * @param blockId - Block ID to update + * @param changes - Partial block changes + */ + updateBlock(blockId: string, changes: Partial): void { + const messageId = this.blockToMessageMap.get(blockId) + if (!messageId) { + logger.warn(`updateBlock: Block ${blockId} not found in blockToMessageMap`) + return + } + + const session = this.getSession(messageId) + if (!session) { + logger.warn(`updateBlock: Session not found for message ${messageId}`) + return + } + + const existingBlock = session.blocks[blockId] + if (!existingBlock) { + logger.warn(`updateBlock: Block ${blockId} not found in session`) + return + } + + // Merge changes - use type assertion since we're updating the same block type + const updatedBlock = { ...existingBlock, ...changes } as MessageBlock + session.blocks[blockId] = updatedBlock + + // Update caches + cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + cacheService.setCasual(getBlockKey(blockId), updatedBlock, SESSION_TTL) + } + + /** + * Get a block from the streaming session + * + * @param blockId - Block ID + * @returns Block or null if not found + */ + getBlock(blockId: string): MessageBlock | null { + return cacheService.getCasual(getBlockKey(blockId)) || null + } + + // ============ Message Operations ============ + + /** + * Update message properties in the streaming session + * (Replaces dispatch(newMessagesActions.updateMessage)) + * + * @param messageId - Message ID + * @param updates - Partial message updates + */ + updateMessage(messageId: string, updates: Partial): void { + const session = this.getSession(messageId) + if (!session) { + logger.warn(`updateMessage called for non-existent session: ${messageId}`) + return + } + + // Merge updates + session.message = { ...session.message, ...updates } + + // Update caches + cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) + } + + /** + * Add a block reference to the message + * (Replaces dispatch(newMessagesActions.upsertBlockReference)) + * + * Note: In the streaming context, we just need to track the block ID in message.blocks + * The block reference details are maintained in the block itself + * + * @param messageId - Message ID + * @param blockId - Block ID to reference + */ + addBlockReference(messageId: string, blockId: string): void { + const session = this.getSession(messageId) + if (!session) { + logger.warn(`addBlockReference called for non-existent session: ${messageId}`) + return + } + + if (!session.message.blocks.includes(blockId)) { + session.message.blocks = [...session.message.blocks, blockId] + cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) + } + } + + /** + * Get a message from the streaming session + * + * @param messageId - Message ID + * @returns Message or null if not found + */ + getMessage(messageId: string): Message | null { + return cacheService.getCasual(getMessageKey(messageId)) || null + } + + // ============ Query Methods ============ + + /** + * Check if a topic has any active streaming sessions + * + * @param topicId - Topic ID + * @returns True if streaming is active + */ + isStreaming(topicId: string): boolean { + const topicSessions = cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + return topicSessions.length > 0 + } + + /** + * Check if a specific message is currently streaming + * + * @param messageId - Message ID + * @returns True if message is streaming + */ + isMessageStreaming(messageId: string): boolean { + return cacheService.hasCasual(getSessionKey(messageId)) + } + + /** + * Get the streaming session for a message + * + * @param messageId - Message ID + * @returns Session or null if not found + */ + getSession(messageId: string): StreamingSession | null { + return cacheService.getCasual(getSessionKey(messageId)) || null + } + + /** + * Get all active streaming message IDs for a topic + * + * @param topicId - Topic ID + * @returns Array of message IDs + */ + getActiveMessageIds(topicId: string): string[] { + return cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + } + + // ============ siblingsGroupId Generation ============ + + /** + * Generate the next siblingsGroupId for a topic. + * + * Used for multi-model responses where multiple assistant messages + * share the same parentId and siblingsGroupId (>0). + * + * The counter is stored in CacheService and auto-increments. + * Single-model responses should use siblingsGroupId=0 (not generated here). + * + * @param topicId - Topic ID + * @returns Next siblingsGroupId (always > 0) + */ + //FIXME [v2] 现在获取 siblingsGroupId 的方式是不正确,后续再做修改调整 + generateNextGroupId(topicId: string): number { + const counterKey = getSiblingsGroupCounterKey(topicId) + const currentCounter = cacheService.getCasual(counterKey) || 0 + const nextGroupId = currentCounter + 1 + // Store with no TTL (persistent within session, cleared on app restart) + cacheService.setCasual(counterKey, nextGroupId) + logger.debug('Generated siblingsGroupId', { topicId, siblingsGroupId: nextGroupId }) + return nextGroupId + } + + // ============ Internal Methods ============ + + /** + * Convert session data to database update payload + * + * @param session - Streaming session + * @param status - Final message status + * @returns Update payload for database + */ + private convertToUpdatePayload( + session: StreamingSession, + status: AssistantMessageStatus + ): { + messageUpdates: Partial & Pick + blocks: MessageBlock[] + } { + const blocks = Object.values(session.blocks) + + // Ensure all blocks have final status + // Use type assertion since we're only updating the status field + const finalizedBlocks: MessageBlock[] = blocks.map((block) => { + if (block.status === MessageBlockStatus.STREAMING || block.status === MessageBlockStatus.PROCESSING) { + const finalizedBlock = { + ...block, + status: status === AssistantMessageStatus.SUCCESS ? MessageBlockStatus.SUCCESS : MessageBlockStatus.ERROR + } + return finalizedBlock as typeof block + } + return block + }) + + const messageUpdates: Partial & Pick = { + id: session.messageId, + status, + blocks: session.message.blocks, + updatedAt: new Date().toISOString(), + // Include usage and metrics if available + ...(session.message.usage && { usage: session.message.usage }), + ...(session.message.metrics && { metrics: session.message.metrics }) + } + + return { + messageUpdates, + blocks: finalizedBlocks + } + } + + /** + * Convert session data to Data API UpdateMessageDto format + * + * Converts from renderer format (MessageBlock with id/status) to + * shared format (MessageDataBlock without id/status) for Data API persistence. + * + * @param session - Streaming session + * @param status - Final message status + * @returns UpdateMessageDto for Data API PATCH request + */ + private convertToDataApiFormat(session: StreamingSession, status: AssistantMessageStatus): UpdateMessageDto { + const blocks = Object.values(session.blocks) + + // Convert MessageBlock[] to MessageDataBlock[] + // Remove id, status, messageId fields as they are renderer-specific, not part of MessageDataBlock + // TRADEOFF: Using 'as unknown as' because renderer's MessageBlockType and shared's BlockType + // are structurally identical but TypeScript treats them as incompatible enums. + const dataBlocks: MessageDataBlock[] = blocks.map((block) => { + // Extract only the fields that belong to MessageDataBlock + const { + id: _id, + status: _blockStatus, + messageId: _messageId, + ...blockData + } = block as MessageBlock & { + messageId?: string + } + + return blockData as unknown as MessageDataBlock + }) + + // Build MessageStats from usage and metrics + // Note: Renderer uses 'time_first_token_millsec' while shared uses 'timeFirstTokenMs' + const stats: MessageStats | undefined = + session.message.usage || session.message.metrics + ? { + promptTokens: session.message.usage?.prompt_tokens, + completionTokens: session.message.usage?.completion_tokens, + totalTokens: session.message.usage?.total_tokens, + timeFirstTokenMs: session.message.metrics?.time_first_token_millsec, + timeCompletionMs: session.message.metrics?.time_completion_millsec + } + : undefined + + return { + data: { blocks: dataBlocks }, + status: status as 'pending' | 'success' | 'error' | 'paused', + ...(stats && { stats }) + } + } +} + +// Export singleton instance +export const streamingService = new StreamingService() + +// Also export class for testing +export { StreamingService } +export type { StartSessionOptions, StreamingSession } diff --git a/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts index ed9bdd5844..39b1955d53 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts @@ -1,12 +1,26 @@ +/** + * @fileoverview Base callbacks for streaming message processing + * + * This module provides the core callback handlers for message streaming: + * - onLLMResponseCreated: Initialize placeholder block for incoming response + * - onError: Handle streaming errors and cleanup + * - onComplete: Finalize streaming and persist to database + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + * + * Key changes: + * - dispatch/getState replaced with streamingService methods + * - saveUpdatesToDB replaced with streamingService.finalize() + */ + import { loggerService } from '@logger' import { autoRenameTopic } from '@renderer/hooks/useTopic' import i18n from '@renderer/i18n' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { NotificationService } from '@renderer/services/NotificationService' import { estimateMessagesUsage } from '@renderer/services/TokenService' -import { updateOneBlock } from '@renderer/store/messageBlock' -import { selectMessagesForTopic } from '@renderer/store/newMessage' -import { newMessagesActions } from '@renderer/store/newMessage' import type { Assistant } from '@renderer/types' import type { PlaceholderMessageBlock, Response } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' @@ -19,47 +33,58 @@ import type { AISDKError } from 'ai' import { NoOutputGeneratedError } from 'ai' import type { BlockManager } from '../BlockManager' +import { streamingService } from '../StreamingService' const logger = loggerService.withContext('BaseCallbacks') + +/** + * Dependencies required for base callbacks + * + * NOTE: Simplified from original design - removed dispatch, getState, and saveUpdatesToDB + * since StreamingService now handles state management and persistence. + */ interface BaseCallbacksDependencies { blockManager: BlockManager - dispatch: any - getState: any topicId: string assistantMsgId: string - saveUpdatesToDB: any assistant: Assistant } export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { - const { blockManager, dispatch, getState, topicId, assistantMsgId, saveUpdatesToDB, assistant } = deps + const { blockManager, topicId, assistantMsgId, assistant } = deps const startTime = Date.now() const notificationService = NotificationService.getInstance() - // 通用的 block 查找函数 - const findBlockIdForCompletion = (message?: any) => { - // 优先使用 BlockManager 中的 activeBlockInfo + /** + * Find the block ID that should receive completion updates. + * Priority: active block > latest block in message > initial placeholder + */ + const findBlockIdForCompletion = () => { + // Priority 1: Use active block from BlockManager const activeBlockInfo = blockManager.activeBlockInfo - if (activeBlockInfo) { return activeBlockInfo.id } - // 如果没有活跃的block,从message中查找最新的block作为备选 - const targetMessage = message || getState().messages.entities[assistantMsgId] - if (targetMessage) { - const allBlocks = findAllBlocks(targetMessage) + // Priority 2: Find latest block from StreamingService message + const message = streamingService.getMessage(assistantMsgId) + if (message) { + const allBlocks = findAllBlocks(message) if (allBlocks.length > 0) { - return allBlocks[allBlocks.length - 1].id // 返回最新的block + return allBlocks[allBlocks.length - 1].id } } - // 最后的备选方案:从 blockManager 获取占位符块ID + // Priority 3: Initial placeholder block return blockManager.initialPlaceholderBlockId } return { + /** + * Called when LLM response stream is created. + * Creates an initial placeholder block to receive streaming content. + */ onLLMResponseCreated: async () => { const baseBlock = createBaseMessageBlock(assistantMsgId, MessageBlockType.UNKNOWN, { status: MessageBlockStatus.PROCESSING @@ -67,6 +92,10 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { await blockManager.handleBlockTransition(baseBlock as PlaceholderMessageBlock, MessageBlockType.UNKNOWN) }, + /** + * Called when an error occurs during streaming. + * Updates block and message status, creates error block, and finalizes session. + */ onError: async (error: AISDKError) => { logger.debug('onError', error) if (NoOutputGeneratedError.isInstance(error)) { @@ -79,7 +108,8 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { } const duration = Date.now() - startTime - // 发送错误通知(除了中止错误) + + // Send error notification (except for abort errors) if (!isErrorTypeAbort) { const timeOut = duration > 30 * 1000 if ((!isOnHomePage() && timeOut) || (!isFocused() && timeOut)) { @@ -98,45 +128,35 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { const possibleBlockId = findBlockIdForCompletion() if (possibleBlockId) { - // 更改上一个block的状态为ERROR + // Update previous block status to ERROR/PAUSED const changes = { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR } blockManager.smartBlockUpdate(possibleBlockId, changes, blockManager.lastBlockType!, true) } - // Fix: 更新所有仍处于 STREAMING 状态的 blocks 为 PAUSED/ERROR - // 这修复了停止回复时思考计时器继续运行的问题 - const currentMessage = getState().messages.entities[assistantMsgId] + // Fix: Update all blocks still in STREAMING status to PAUSED/ERROR + // This fixes the thinking timer continuing when response is stopped + const currentMessage = streamingService.getMessage(assistantMsgId) if (currentMessage) { const allBlockRefs = findAllBlocks(currentMessage) - const blockState = getState().messageBlocks for (const blockRef of allBlockRefs) { - const block = blockState.entities[blockRef.id] + const block = streamingService.getBlock(blockRef.id) if (block && block.status === MessageBlockStatus.STREAMING && block.id !== possibleBlockId) { - dispatch( - updateOneBlock({ - id: block.id, - changes: { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR } - }) - ) + streamingService.updateBlock(block.id, { + status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR + }) } } } + // Create error block const errorBlock = createErrorBlock(assistantMsgId, serializableError, { status: MessageBlockStatus.SUCCESS }) await blockManager.handleBlockTransition(errorBlock, MessageBlockType.ERROR) - const messageErrorUpdate = { - status: isErrorTypeAbort ? AssistantMessageStatus.SUCCESS : AssistantMessageStatus.ERROR - } - dispatch( - newMessagesActions.updateMessage({ - topicId, - messageId: assistantMsgId, - updates: messageErrorUpdate - }) - ) - await saveUpdatesToDB(assistantMsgId, topicId, messageErrorUpdate, []) + + // Finalize session with error/success status + const finalStatus = isErrorTypeAbort ? AssistantMessageStatus.SUCCESS : AssistantMessageStatus.ERROR + await streamingService.finalize(assistantMsgId, finalStatus) EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, @@ -146,18 +166,15 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { }) }, + /** + * Called when streaming completes successfully. + * Updates block status, processes usage stats, and finalizes session. + */ onComplete: async (status: AssistantMessageStatus, response?: Response) => { - const finalStateOnComplete = getState() - const finalAssistantMsg = finalStateOnComplete.messages.entities[assistantMsgId] + const finalAssistantMsg = streamingService.getMessage(assistantMsgId) if (status === 'success' && finalAssistantMsg) { - const userMsgId = finalAssistantMsg.askId - const orderedMsgs = selectMessagesForTopic(finalStateOnComplete, topicId) - const userMsgIndex = orderedMsgs.findIndex((m) => m.id === userMsgId) - const contextForUsage = userMsgIndex !== -1 ? orderedMsgs.slice(0, userMsgIndex + 1) : [] - const finalContextWithAssistant = [...contextForUsage, finalAssistantMsg] - - const possibleBlockId = findBlockIdForCompletion(finalAssistantMsg) + const possibleBlockId = findBlockIdForCompletion() if (possibleBlockId) { const changes = { @@ -170,7 +187,7 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { const content = getMainTextContent(finalAssistantMsg) const timeOut = duration > 30 * 1000 - // 发送长时间运行消息的成功通知 + // Send success notification for long-running messages if ((!isOnHomePage() && timeOut) || (!isFocused() && timeOut)) { await notificationService.send({ id: uuid(), @@ -184,10 +201,10 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { }) } - // 更新topic的name + // Rename topic if needed autoRenameTopic(assistant, topicId) - // 处理usage估算 + // Process usage estimation // For OpenRouter, always use the accurate usage data from API, don't estimate const isOpenRouter = assistant.model?.provider === 'openrouter' if ( @@ -197,11 +214,20 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { response?.usage?.prompt_tokens === 0 || response?.usage?.completion_tokens === 0) ) { - const usage = await estimateMessagesUsage({ assistant, messages: finalContextWithAssistant }) - response.usage = usage + // Use context from session for usage estimation + const session = streamingService.getSession(assistantMsgId) + if (session?.contextMessages && session.contextMessages.length > 0) { + // Include the final assistant message in context for accurate estimation + const finalContextWithAssistant = [...session.contextMessages, finalAssistantMsg] + const usage = await estimateMessagesUsage({ assistant, messages: finalContextWithAssistant }) + response.usage = usage + } else { + logger.debug('Skipping usage estimation - contextMessages not available in session') + } } } + // Handle metrics completion_tokens fallback if (response && response.metrics) { if (response.metrics.completion_tokens === 0 && response.usage?.completion_tokens) { response = { @@ -214,15 +240,17 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { } } - const messageUpdates = { status, metrics: response?.metrics, usage: response?.usage } - dispatch( - newMessagesActions.updateMessage({ - topicId, - messageId: assistantMsgId, - updates: messageUpdates + // Update message with final stats before finalize + if (response) { + streamingService.updateMessage(assistantMsgId, { + metrics: response.metrics, + usage: response.usage }) - ) - await saveUpdatesToDB(assistantMsgId, topicId, messageUpdates, []) + } + + // Finalize session and persist to database + await streamingService.finalize(assistantMsgId, status) + EventEmitter.emit(EVENT_NAMES.MESSAGE_COMPLETE, { id: assistantMsgId, topicId, status }) logger.debug('onComplete finished') } diff --git a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts index 3245493636..72406bec53 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/citationCallbacks.ts @@ -1,3 +1,15 @@ +/** + * @fileoverview Citation callbacks for handling web search and knowledge references + * + * This module provides callbacks for processing citation data during streaming: + * - External tool citations (web search, knowledge) + * - LLM-integrated web search citations + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + */ + import { loggerService } from '@logger' import type { ExternalToolResult } from '@renderer/types' import type { CitationMessageBlock } from '@renderer/types/newMessage' @@ -6,17 +18,22 @@ import { createCitationBlock } from '@renderer/utils/messageUtils/create' import { findMainTextBlocks } from '@renderer/utils/messageUtils/find' import type { BlockManager } from '../BlockManager' +import { streamingService } from '../StreamingService' const logger = loggerService.withContext('CitationCallbacks') +/** + * Dependencies required for citation callbacks + * + * NOTE: Simplified - removed getState since StreamingService handles state. + */ interface CitationCallbacksDependencies { blockManager: BlockManager assistantMsgId: string - getState: any } export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => { - const { blockManager, assistantMsgId, getState } = deps + const { blockManager, assistantMsgId } = deps // 内部维护的状态 let citationBlockId: string | null = null @@ -80,15 +97,18 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => } blockManager.smartBlockUpdate(blockId, changes, MessageBlockType.CITATION, true) - const state = getState() - const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) - if (existingMainTextBlocks.length > 0) { - const existingMainTextBlock = existingMainTextBlocks[0] - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] + // Get message from StreamingService + const message = streamingService.getMessage(assistantMsgId) + if (message) { + const existingMainTextBlocks = findMainTextBlocks(message) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { blockId, citationBlockSource: llmWebSearchResult.source }] + } + blockManager.smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT, true) } - blockManager.smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT, true) } if (blockManager.hasInitialPlaceholder) { @@ -106,15 +126,18 @@ export const createCitationCallbacks = (deps: CitationCallbacksDependencies) => ) citationBlockId = citationBlock.id - const state = getState() - const existingMainTextBlocks = findMainTextBlocks(state.messages.entities[assistantMsgId]) - if (existingMainTextBlocks.length > 0) { - const existingMainTextBlock = existingMainTextBlocks[0] - const currentRefs = existingMainTextBlock.citationReferences || [] - const mainTextChanges = { - citationReferences: [...currentRefs, { citationBlockId, citationBlockSource: llmWebSearchResult.source }] + // Get message from StreamingService + const message = streamingService.getMessage(assistantMsgId) + if (message) { + const existingMainTextBlocks = findMainTextBlocks(message) + if (existingMainTextBlocks.length > 0) { + const existingMainTextBlock = existingMainTextBlocks[0] + const currentRefs = existingMainTextBlock.citationReferences || [] + const mainTextChanges = { + citationReferences: [...currentRefs, { citationBlockId, citationBlockSource: llmWebSearchResult.source }] + } + blockManager.smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT, true) } - blockManager.smartBlockUpdate(existingMainTextBlock.id, mainTextChanges, MessageBlockType.MAIN_TEXT, true) } await blockManager.handleBlockTransition(citationBlock, MessageBlockType.CITATION) } diff --git a/src/renderer/src/services/messageStreaming/callbacks/compactCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/compactCallbacks.ts index 8b36aa7089..acfc85177d 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/compactCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/compactCallbacks.ts @@ -1,22 +1,39 @@ +/** + * @fileoverview Compact callbacks for handling /compact command responses + * + * This module provides callbacks for processing compact command responses + * from Claude Code. It detects compact_boundary messages and creates + * compact blocks that contain both summary and compacted content. + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + * + * Key changes: + * - dispatch/getState replaced with streamingService methods + * - saveUpdatesToDB removed (handled by finalize) + */ + import { loggerService } from '@logger' -import type { AppDispatch, RootState } from '@renderer/store' -import { updateOneBlock } from '@renderer/store/messageBlock' -import { newMessagesActions } from '@renderer/store/newMessage' import type { MainTextMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import type { ClaudeCodeRawValue } from '@shared/agents/claudecode/types' import type { BlockManager } from '../BlockManager' +import { streamingService } from '../StreamingService' const logger = loggerService.withContext('CompactCallbacks') +/** + * Dependencies required for compact callbacks + * + * NOTE: Simplified from original design - removed dispatch, getState, and saveUpdatesToDB + * since StreamingService now handles state management and persistence. + */ interface CompactCallbacksDeps { blockManager: BlockManager assistantMsgId: string - dispatch: AppDispatch - getState: () => RootState topicId: string - saveUpdatesToDB: any } interface CompactState { @@ -27,7 +44,7 @@ interface CompactState { } export const createCompactCallbacks = (deps: CompactCallbacksDeps) => { - const { blockManager, assistantMsgId, dispatch, getState, topicId, saveUpdatesToDB } = deps + const { blockManager, assistantMsgId } = deps // State to track compact command processing const compactState: CompactState = { @@ -78,9 +95,8 @@ export const createCompactCallbacks = (deps: CompactCallbacksDeps) => { return false } - // Get the current main text block to check its full content - const state = getState() - const currentBlock = state.messageBlocks.entities[currentMainTextBlockId] as MainTextMessageBlock | undefined + // Get the current main text block from StreamingService + const currentBlock = streamingService.getBlock(currentMainTextBlockId) as MainTextMessageBlock | null if (!currentBlock) { return false @@ -99,14 +115,9 @@ export const createCompactCallbacks = (deps: CompactCallbacksDeps) => { // Hide this block by marking it as a placeholder temporarily // We'll convert it to compact block when we get the second block - dispatch( - updateOneBlock({ - id: currentMainTextBlockId, - changes: { - status: MessageBlockStatus.PROCESSING - } - }) - ) + streamingService.updateBlock(currentMainTextBlockId, { + status: MessageBlockStatus.PROCESSING + }) return true // Prevent normal text block completion } @@ -125,53 +136,25 @@ export const createCompactCallbacks = (deps: CompactCallbacksDeps) => { }) // Update the summary block to compact type - dispatch( - updateOneBlock({ - id: summaryBlockId, - changes: { - type: MessageBlockType.COMPACT, - content: compactState.summaryText, - compactedContent: compactedContent, - status: MessageBlockStatus.SUCCESS - } - }) - ) - - // Update block reference - dispatch( - newMessagesActions.upsertBlockReference({ - messageId: assistantMsgId, - blockId: summaryBlockId, - status: MessageBlockStatus.SUCCESS, - blockType: MessageBlockType.COMPACT - }) - ) + streamingService.updateBlock(summaryBlockId, { + type: MessageBlockType.COMPACT, + content: compactState.summaryText, + compactedContent: compactedContent, + status: MessageBlockStatus.SUCCESS + } as any) // Using 'as any' for compactedContent which is specific to CompactMessageBlock // Clear active block info and update lastBlockType since the compact block is now complete blockManager.activeBlockInfo = null blockManager.lastBlockType = MessageBlockType.COMPACT // Remove the current block (the one with XML tags) from message.blocks - const currentState = getState() - const currentMessage = currentState.messages.entities[assistantMsgId] + const currentMessage = streamingService.getMessage(assistantMsgId) if (currentMessage && currentMessage.blocks) { const updatedBlocks = currentMessage.blocks.filter((id) => id !== currentMainTextBlockId) - dispatch( - newMessagesActions.updateMessage({ - topicId, - messageId: assistantMsgId, - updates: { blocks: updatedBlocks } - }) - ) + streamingService.updateMessage(assistantMsgId, { blocks: updatedBlocks }) } - // Save to DB - const updatedState = getState() - const updatedMessage = updatedState.messages.entities[assistantMsgId] - const updatedBlock = updatedState.messageBlocks.entities[summaryBlockId] - if (updatedMessage && updatedBlock) { - await saveUpdatesToDB(assistantMsgId, topicId, { blocks: updatedMessage.blocks }, [updatedBlock]) - } + // NOTE: DB save is removed - will be handled by finalize() // Reset compact state compactState.compactBoundaryDetected = false diff --git a/src/renderer/src/services/messageStreaming/callbacks/index.ts b/src/renderer/src/services/messageStreaming/callbacks/index.ts index 2bb1d158bb..054ad1e6b2 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/index.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/index.ts @@ -1,3 +1,22 @@ +/** + * @fileoverview Callbacks factory for streaming message processing + * + * This module creates and composes all callback handlers used during + * message streaming. Each callback type handles specific aspects: + * - Base: session lifecycle, error handling, completion + * - Text: main text block processing + * - Thinking: thinking/reasoning block processing + * - Tool: tool call/result processing + * - Image: image generation processing + * - Citation: web search/knowledge citations + * - Video: video content processing + * - Compact: /compact command handling + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + */ + import type { Assistant } from '@renderer/types' import type { BlockManager } from '../BlockManager' @@ -10,31 +29,31 @@ import { createThinkingCallbacks } from './thinkingCallbacks' import { createToolCallbacks } from './toolCallbacks' import { createVideoCallbacks } from './videoCallbacks' +/** + * Dependencies required for creating all callbacks + * + * NOTE: Simplified from original design - removed dispatch, getState, and saveUpdatesToDB + * since StreamingService now handles state management and persistence. + */ interface CallbacksDependencies { blockManager: BlockManager - dispatch: any - getState: any topicId: string assistantMsgId: string - saveUpdatesToDB: any assistant: Assistant } export const createCallbacks = (deps: CallbacksDependencies) => { - const { blockManager, dispatch, getState, topicId, assistantMsgId, saveUpdatesToDB, assistant } = deps + const { blockManager, topicId, assistantMsgId, assistant } = deps - // 创建基础回调 + // Create base callbacks (lifecycle, error, complete) const baseCallbacks = createBaseCallbacks({ blockManager, - dispatch, - getState, topicId, assistantMsgId, - saveUpdatesToDB, assistant }) - // 创建各类回调 + // Create specialized callbacks for each block type const thinkingCallbacks = createThinkingCallbacks({ blockManager, assistantMsgId @@ -42,8 +61,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => { const toolCallbacks = createToolCallbacks({ blockManager, - assistantMsgId, - dispatch + assistantMsgId }) const imageCallbacks = createImageCallbacks({ @@ -53,8 +71,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => { const citationCallbacks = createCitationCallbacks({ blockManager, - assistantMsgId, - getState + assistantMsgId }) const videoCallbacks = createVideoCallbacks({ blockManager, assistantMsgId }) @@ -62,23 +79,19 @@ export const createCallbacks = (deps: CallbacksDependencies) => { const compactCallbacks = createCompactCallbacks({ blockManager, assistantMsgId, - dispatch, - getState, - topicId, - saveUpdatesToDB + topicId }) - // 创建textCallbacks时传入citationCallbacks的getCitationBlockId方法和compactCallbacks的handleTextComplete方法 + // Create textCallbacks with citation and compact handlers const textCallbacks = createTextCallbacks({ blockManager, - getState, assistantMsgId, getCitationBlockId: citationCallbacks.getCitationBlockId, getCitationBlockIdFromTool: toolCallbacks.getCitationBlockId, handleCompactTextComplete: compactCallbacks.handleTextComplete }) - // 组合所有回调 + // Compose all callbacks return { ...baseCallbacks, ...textCallbacks, @@ -88,10 +101,10 @@ export const createCallbacks = (deps: CallbacksDependencies) => { ...citationCallbacks, ...videoCallbacks, ...compactCallbacks, - // 清理资源的方法 + // Cleanup method (throttling is managed by messageThunk) cleanup: () => { - // 清理由 messageThunk 中的节流函数管理,这里不需要特别处理 - // 如果需要,可以调用 blockManager 的相关清理方法 + // Cleanup is managed by messageThunk throttle functions + // Add any additional cleanup here if needed } } } diff --git a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts index 1abaab938a..48e6af76b1 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/textCallbacks.ts @@ -1,3 +1,16 @@ +/** + * @fileoverview Text callbacks for handling main text block streaming + * + * This module provides callbacks for processing text content during streaming: + * - Text start: initialize or transform placeholder to main text block + * - Text chunk: update content during streaming + * - Text complete: finalize the block + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + */ + import { loggerService } from '@logger' import { WebSearchSource } from '@renderer/types' import type { CitationMessageBlock, MessageBlock } from '@renderer/types/newMessage' @@ -5,12 +18,17 @@ import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage import { createMainTextBlock } from '@renderer/utils/messageUtils/create' import type { BlockManager } from '../BlockManager' +import { streamingService } from '../StreamingService' const logger = loggerService.withContext('TextCallbacks') +/** + * Dependencies required for text callbacks + * + * NOTE: Simplified - removed getState since StreamingService handles state. + */ interface TextCallbacksDependencies { blockManager: BlockManager - getState: any assistantMsgId: string getCitationBlockId: () => string | null getCitationBlockIdFromTool: () => string | null @@ -18,14 +36,8 @@ interface TextCallbacksDependencies { } export const createTextCallbacks = (deps: TextCallbacksDependencies) => { - const { - blockManager, - getState, - assistantMsgId, - getCitationBlockId, - getCitationBlockIdFromTool, - handleCompactTextComplete - } = deps + const { blockManager, assistantMsgId, getCitationBlockId, getCitationBlockIdFromTool, handleCompactTextComplete } = + deps // 内部维护的状态 let mainTextBlockId: string | null = null @@ -52,9 +64,12 @@ export const createTextCallbacks = (deps: TextCallbacksDependencies) => { onTextChunk: async (text: string) => { const citationBlockId = getCitationBlockId() || getCitationBlockIdFromTool() - const citationBlockSource = citationBlockId - ? (getState().messageBlocks.entities[citationBlockId] as CitationMessageBlock).response?.source - : WebSearchSource.WEBSEARCH + // Get citation block from StreamingService to determine source + const citationBlock = citationBlockId + ? (streamingService.getBlock(citationBlockId) as CitationMessageBlock | null) + : null + const citationBlockSource = citationBlock?.response?.source ?? WebSearchSource.WEBSEARCH + if (text) { const blockChanges: Partial = { content: text, diff --git a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts index 74d854d665..2dcce81d44 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/toolCallbacks.ts @@ -1,5 +1,20 @@ +/** + * @fileoverview Tool callbacks for handling MCP tool calls during streaming + * + * This module provides callbacks for processing tool calls: + * - Tool call pending: create tool block when tool is called + * - Tool call complete: update with result or error + * + * ARCHITECTURE NOTE: + * These callbacks now use StreamingService for state management instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + * + * NOTE: toolPermissionsActions dispatch is still required for permission management + * as this is outside the scope of streaming state management. + */ + import { loggerService } from '@logger' -import type { AppDispatch } from '@renderer/store' +import store from '@renderer/store' import { toolPermissionsActions } from '@renderer/store/toolPermissions' import type { MCPToolResponse } from '@renderer/types' import { WebSearchSource } from '@renderer/types' @@ -11,14 +26,19 @@ import type { BlockManager } from '../BlockManager' const logger = loggerService.withContext('ToolCallbacks') +/** + * Dependencies required for tool callbacks + * + * NOTE: dispatch removed - toolPermissions uses store.dispatch directly + * since it's outside streaming state scope. + */ interface ToolCallbacksDependencies { blockManager: BlockManager assistantMsgId: string - dispatch: AppDispatch } export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { - const { blockManager, assistantMsgId, dispatch } = deps + const { blockManager, assistantMsgId } = deps // 内部维护的状态 const toolCallIdToBlockIdMap = new Map() @@ -57,7 +77,8 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => { onToolCallComplete: (toolResponse: MCPToolResponse) => { if (toolResponse?.id) { - dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id })) + // Use store.dispatch for permission cleanup (outside streaming state scope) + store.dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id })) } const existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id) toolCallIdToBlockIdMap.delete(toolResponse.id) diff --git a/src/renderer/src/services/messageStreaming/index.ts b/src/renderer/src/services/messageStreaming/index.ts index 6cdda5e3ee..13b45bdd2e 100644 --- a/src/renderer/src/services/messageStreaming/index.ts +++ b/src/renderer/src/services/messageStreaming/index.ts @@ -1,3 +1,5 @@ export { BlockManager } from './BlockManager' export type { createCallbacks as CreateCallbacksFunction } from './callbacks' export { createCallbacks } from './callbacks' +export type { StartSessionOptions, StreamingSession } from './StreamingService' +export { StreamingService, streamingService } from './StreamingService' diff --git a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts index 49c71aea56..4aef0a2603 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -1,8 +1,8 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit' import { BlockManager } from '@renderer/services/messageStreaming/BlockManager' import { createCallbacks } from '@renderer/services/messageStreaming/callbacks' +import { streamingService } from '@renderer/services/messageStreaming/StreamingService' import { createStreamProcessor } from '@renderer/services/StreamProcessingService' -import type { AppDispatch } from '@renderer/store' import { messageBlocksSlice } from '@renderer/store/messageBlock' import { messagesSlice } from '@renderer/store/newMessage' import type { Assistant, ExternalToolResult, MCPTool, Model } from '@renderer/types' @@ -12,33 +12,41 @@ import { ChunkType } from '@renderer/types/chunk' import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RootState } from '../../index' - +/** + * Create mock callbacks for testing. + * + * NOTE: Updated to use simplified dependencies after StreamingService refactoring. + * Now we need to initialize StreamingService session before creating callbacks. + */ const createMockCallbacks = ( mockAssistantMsgId: string, mockTopicId: string, - mockAssistant: Assistant, - dispatch: AppDispatch, - getState: () => ReturnType & RootState -) => - createCallbacks({ + mockAssistant: Assistant + // dispatch and getState are no longer needed after StreamingService refactoring +) => { + // Initialize streaming session for tests + streamingService.startSession(mockTopicId, mockAssistantMsgId, { + parentId: 'test-user-msg-id', + role: 'assistant', + assistantId: mockAssistant.id, + model: mockAssistant.model + }) + + return createCallbacks({ blockManager: new BlockManager({ - dispatch, - getState, - saveUpdatedBlockToDB: vi.fn(), - saveUpdatesToDB: vi.fn(), assistantMsgId: mockAssistantMsgId, topicId: mockTopicId, - throttledBlockUpdate: vi.fn(), + throttledBlockUpdate: vi.fn((blockId, changes) => { + // In tests, immediately update the block + streamingService.updateBlock(blockId, changes) + }), cancelThrottledBlockUpdate: vi.fn() }), - dispatch, - getState, topicId: mockTopicId, assistantMsgId: mockAssistantMsgId, - saveUpdatesToDB: vi.fn(), assistant: mockAssistant }) +} // Mock external dependencies vi.mock('@renderer/config/models', () => ({ @@ -311,8 +319,7 @@ const processChunks = async (chunks: Chunk[], callbacks: ReturnType { let store: ReturnType - let dispatch: AppDispatch - let getState: () => ReturnType & RootState + // dispatch and getState are no longer needed after StreamingService refactoring const mockTopicId = 'test-topic-id' const mockAssistantMsgId = 'test-assistant-msg-id' @@ -334,10 +341,8 @@ describe('streamCallback Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() store = createMockStore() - dispatch = store.dispatch - getState = store.getState as () => ReturnType & RootState - // 为测试消息添加初始状态 + // Add initial message state for tests store.dispatch( messagesSlice.actions.addMessage({ topicId: mockTopicId, @@ -360,7 +365,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle complete text streaming flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -387,7 +392,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) expect(blocks.length).toBeGreaterThan(0) @@ -403,7 +408,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle thinking flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -418,7 +423,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const thinkingBlock = blocks.find((block) => block.type === MessageBlockType.THINKING) @@ -432,7 +437,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle tool call flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockTool: MCPTool = { id: 'tool-1', @@ -492,7 +497,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const toolBlock = blocks.find((block) => block.type === MessageBlockType.TOOL) @@ -503,7 +508,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle image generation flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -532,7 +537,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const imageBlock = blocks.find((block) => block.type === MessageBlockType.IMAGE) expect(imageBlock).toBeDefined() @@ -543,7 +548,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle web search flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockWebSearchResult = { source: WebSearchSource.WEBSEARCH, @@ -560,7 +565,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const citationBlock = blocks.find((block) => block.type === MessageBlockType.CITATION) expect(citationBlock).toBeDefined() @@ -569,7 +574,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle mixed content flow (thinking + tool + text)', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockCalculatorTool: MCPTool = { id: 'tool-1', @@ -652,7 +657,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) expect(blocks.length).toBeGreaterThan(2) // 至少有思考块、工具块、文本块 @@ -671,7 +676,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle error flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockError = new Error('Test error') @@ -685,7 +690,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) expect(blocks.length).toBeGreaterThan(0) @@ -701,7 +706,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle external tool flow', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockExternalToolResult: ExternalToolResult = { webSearch: { @@ -728,7 +733,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const citationBlock = blocks.find((block) => block.type === MessageBlockType.CITATION) @@ -739,7 +744,7 @@ describe('streamCallback Integration Tests', () => { }) it('should handle abort error correctly', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) // 创建一个模拟的 abort 错误 const abortError = new Error('Request aborted') @@ -755,7 +760,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) expect(blocks.length).toBeGreaterThan(0) @@ -770,7 +775,7 @@ describe('streamCallback Integration Tests', () => { }) it('should maintain block reference integrity during streaming', async () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -784,7 +789,7 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) // 验证 Redux 状态 - const state = getState() + const state = store.getState() const blocks = Object.values(state.messageBlocks.entities) const message = state.messages.entities[mockAssistantMsgId] diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index b210c532ce..7bafb27cdb 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -15,6 +15,7 @@ * -------------------------------------------------------------------------- */ import { cacheService } from '@data/CacheService' +import { dataApiService } from '@data/DataApiService' import { loggerService } from '@logger' import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter' import { AgentApiClient } from '@renderer/api/agent' @@ -25,6 +26,7 @@ import { DbService } from '@renderer/services/db/DbService' import FileManager from '@renderer/services/FileManager' import { BlockManager } from '@renderer/services/messageStreaming/BlockManager' import { createCallbacks } from '@renderer/services/messageStreaming/callbacks' +import { streamingService } from '@renderer/services/messageStreaming/StreamingService' import { endSpan } from '@renderer/services/SpanManagerService' import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService' import store from '@renderer/store' @@ -48,6 +50,8 @@ import { } from '@renderer/utils/messageUtils/create' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue' +import type { CreateMessageDto } from '@shared/data/api/schemas/messages' +import type { Message as SharedMessage } from '@shared/data/types/message' import { IpcChannel } from '@shared/IpcChannel' import { defaultAppHeaders } from '@shared/utils' import type { TextStreamPart } from 'ai' @@ -74,6 +78,35 @@ import { newMessagesActions, selectMessagesForTopic } from '../newMessage' const logger = loggerService.withContext('MessageThunk') +/** + * Convert shared Message format (from Data API) to renderer Message format + * + * The Data API returns messages with `data: { blocks: MessageDataBlock[] }` format, + * but the renderer expects `blocks: string[]` format. + * + * For newly created pending messages, blocks are empty, so conversion is straightforward. + * For messages with content, this would need to store blocks separately and return IDs. + * + * @param shared - Message from Data API response + * @param model - Optional Model object to include + * @returns Renderer-format Message + */ +const convertSharedToRendererMessage = (shared: SharedMessage, assistantId: string, model?: Model): Message => { + return { + id: shared.id, + topicId: shared.topicId, + role: shared.role, + assistantId, + status: shared.status as AssistantMessageStatus, + blocks: [], // For new pending messages, blocks are empty + createdAt: shared.createdAt, + askId: shared.parentId ?? undefined, + modelId: shared.modelId ?? undefined, + traceId: shared.traceId ?? undefined, + model + } +} + const finishTopicLoading = async (topicId: string) => { await waitForTopicQueue(topicId) store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) @@ -418,23 +451,34 @@ const blockUpdateRafs = new LRUCache({ }) /** - * 获取或创建消息块专用的节流函数。 + * Get or create a dedicated throttle function for a message block. + * + * ARCHITECTURE NOTE: + * Updated to use StreamingService.updateBlock instead of Redux dispatch. + * This is part of the v2 data refactoring to use CacheService + Data API. + * + * The throttler now: + * 1. Uses RAF for visual consistency + * 2. Updates StreamingService (memory cache) for immediate reactivity + * 3. Removes the DB update (moved to finalize) */ const getBlockThrottler = (id: string) => { if (!blockUpdateThrottlers.has(id)) { - const throttler = throttle(async (blockUpdate: any) => { + const throttler = throttle((blockUpdate: any) => { const existingRAF = blockUpdateRafs.get(id) if (existingRAF) { cancelAnimationFrame(existingRAF) } const rafId = requestAnimationFrame(() => { - store.dispatch(updateOneBlock({ id, changes: blockUpdate })) + // Update StreamingService instead of Redux store + streamingService.updateBlock(id, blockUpdate) blockUpdateRafs.delete(id) }) blockUpdateRafs.set(id, rafId) - await updateSingleBlock(id, blockUpdate) + // NOTE: DB update removed - persistence happens during finalize() + // await updateSingleBlock(id, blockUpdate) }, 150) blockUpdateThrottlers.set(id, throttler) @@ -516,25 +560,26 @@ const saveUpdatesToDB = async ( } } -// 新增: 辅助函数,用于获取并保存单个更新后的 Block 到数据库 -const saveUpdatedBlockToDB = async ( - blockId: string | null, - messageId: string, - topicId: string, - getState: () => RootState -) => { - if (!blockId) { - logger.warn('[DB Save Single Block] Received null/undefined blockId. Skipping save.') - return - } - const state = getState() - const blockToSave = state.messageBlocks.entities[blockId] - if (blockToSave) { - await saveUpdatesToDB(messageId, topicId, {}, [blockToSave]) // Pass messageId, topicId, empty message updates, and the block - } else { - logger.warn(`[DB Save Single Block] Block ${blockId} not found in state. Cannot save.`) - } -} +// NOTE: saveUpdatedBlockToDB was removed as part of StreamingService refactoring. +// Block persistence is now handled by StreamingService.finalize(). +// const saveUpdatedBlockToDB = async ( +// blockId: string | null, +// messageId: string, +// topicId: string, +// getState: () => RootState +// ) => { +// if (!blockId) { +// logger.warn('[DB Save Single Block] Received null/undefined blockId. Skipping save.') +// return +// } +// const state = getState() +// const blockToSave = state.messageBlocks.entities[blockId] +// if (blockToSave) { +// await saveUpdatesToDB(messageId, topicId, {}, [blockToSave]) // Pass messageId, topicId, empty message updates, and the block +// } else { +// logger.warn(`[DB Save Single Block] Block ${blockId} not found in state. Cannot save.`) +// } +// } interface AgentStreamParams { topicId: string @@ -553,24 +598,32 @@ const fetchAndProcessAgentResponseImpl = async ( try { dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true })) + // Initialize streaming session in StreamingService + streamingService.startSession(topicId, assistantMessage.id, { + parentId: userMessageId, + siblingsGroupId: 0, + role: 'assistant', + model: assistant.model, + modelId: assistant.model?.id, + assistantId: assistant.id, + askId: userMessageId, + traceId: assistantMessage.traceId, + agentSessionId: agentSession.agentSessionId + }) + + // Create BlockManager with simplified dependencies (no dispatch/getState/saveUpdatesToDB) const blockManager = new BlockManager({ - dispatch, - getState, - saveUpdatedBlockToDB, - saveUpdatesToDB, assistantMsgId: assistantMessage.id, topicId, throttledBlockUpdate, cancelThrottledBlockUpdate }) + // Create callbacks with simplified dependencies callbacks = createCallbacks({ blockManager, - dispatch, - getState, topicId, assistantMsgId: assistantMessage.id, - saveUpdatesToDB, assistant }) @@ -718,74 +771,85 @@ const dispatchMultiModelResponses = async ( mentionedModels: Model[] ) => { const assistantMessageStubs: Message[] = [] - const tasksToQueue: { assistantConfig: Assistant; messageStub: Message }[] = [] + const tasksToQueue: { assistantConfig: Assistant; messageStub: Message; siblingsGroupId: number }[] = [] + + // Generate siblingsGroupId for multi-model responses (all share the same group ID) + const siblingsGroupId = mentionedModels.length > 1 ? streamingService.generateNextGroupId(topicId) : 0 for (const mentionedModel of mentionedModels) { const assistantForThisMention = { ...assistant, model: mentionedModel } - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: triggeringMessage.id, - model: mentionedModel, + + // Create message via Data API instead of local creation + const createDto: CreateMessageDto = { + parentId: triggeringMessage.id, + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId, + assistantId: assistant.id, modelId: mentionedModel.id, - traceId: triggeringMessage.traceId - }) + traceId: triggeringMessage.traceId ?? undefined + } + + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, mentionedModel) + dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) assistantMessageStubs.push(assistantMessage) - tasksToQueue.push({ assistantConfig: assistantForThisMention, messageStub: assistantMessage }) + tasksToQueue.push({ assistantConfig: assistantForThisMention, messageStub: assistantMessage, siblingsGroupId }) } - const topicFromDB = await db.topics.get(topicId) - if (topicFromDB) { - const currentTopicMessageIds = getState().messages.messageIdsByTopic[topicId] || [] - const currentEntities = getState().messages.entities - const messagesToSaveInDB = currentTopicMessageIds.map((id) => currentEntities[id]).filter((m): m is Message => !!m) - await db.topics.update(topicId, { messages: messagesToSaveInDB }) - } else { - logger.error(`[dispatchMultiModelResponses] Topic ${topicId} not found in DB during multi-model save.`) - throw new Error(`Topic ${topicId} not found in DB.`) - } + // Note: Dexie save removed - messages are now persisted via Data API POST above + // const topicFromDB = await db.topics.get(topicId) + // if (topicFromDB) { + // const currentTopicMessageIds = getState().messages.messageIdsByTopic[topicId] || [] + // const currentEntities = getState().messages.entities + // const messagesToSaveInDB = currentTopicMessageIds.map((id) => currentEntities[id]).filter((m): m is Message => !!m) + // await db.topics.update(topicId, { messages: messagesToSaveInDB }) + // } else { + // logger.error(`[dispatchMultiModelResponses] Topic ${topicId} not found in DB during multi-model save.`) + // throw new Error(`Topic ${topicId} not found in DB.`) + // } const queue = getTopicQueue(topicId) for (const task of tasksToQueue) { queue.add(async () => { - await fetchAndProcessAssistantResponseImpl(dispatch, getState, topicId, task.assistantConfig, task.messageStub) + await fetchAndProcessAssistantResponseImpl( + dispatch, + getState, + topicId, + task.assistantConfig, + task.messageStub, + task.siblingsGroupId + ) }) } } // --- End Helper Function --- -// 发送和处理助手响应的实现函数,话题提示词在此拼接 +// Send and process assistant response implementation - topic prompts are concatenated here const fetchAndProcessAssistantResponseImpl = async ( dispatch: AppDispatch, getState: () => RootState, topicId: string, origAssistant: Assistant, - assistantMessage: Message // Pass the prepared assistant message (new or reset) + assistantMessage: Message, // Pass the prepared assistant message (new or reset) + siblingsGroupId: number = 0 // Multi-model group ID (0=normal, >0=multi-model response) ) => { const topic = origAssistant.topics.find((t) => t.id === topicId) const assistant = topic?.prompt ? { ...origAssistant, prompt: `${origAssistant.prompt}\n${topic.prompt}` } : origAssistant const assistantMsgId = assistantMessage.id + const userMessageId = assistantMessage.askId let callbacks: StreamProcessorCallbacks = {} try { dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true })) - // 创建 BlockManager 实例 - const blockManager = new BlockManager({ - dispatch, - getState, - saveUpdatedBlockToDB, - saveUpdatesToDB, - assistantMsgId, - topicId, - throttledBlockUpdate, - cancelThrottledBlockUpdate - }) - + // Build context messages first (needed for startSession) const allMessagesForTopic = selectMessagesForTopic(getState(), topicId) let messagesForContext: Message[] = [] - const userMessageId = assistantMessage.askId const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessageId) if (userMessageIndex === -1) { @@ -812,13 +876,32 @@ const fetchAndProcessAssistantResponseImpl = async ( } } + // Initialize streaming session in StreamingService (includes context for usage estimation) + streamingService.startSession(topicId, assistantMsgId, { + parentId: userMessageId!, + siblingsGroupId, + role: 'assistant', + model: assistant.model, + modelId: assistant.model?.id, + assistantId: assistant.id, + askId: userMessageId, + traceId: assistantMessage.traceId, + contextMessages: messagesForContext + }) + + // Create BlockManager with simplified dependencies (no dispatch/getState/saveUpdatesToDB) + const blockManager = new BlockManager({ + assistantMsgId, + topicId, + throttledBlockUpdate, + cancelThrottledBlockUpdate + }) + + // Create callbacks with simplified dependencies callbacks = createCallbacks({ blockManager, - dispatch, - getState, topicId, assistantMsgId, - saveUpdatesToDB, assistant }) const streamProcessorCallbacks = createStreamProcessor(callbacks) @@ -931,12 +1014,21 @@ export const sendMessage = if (mentionedModels && mentionedModels.length > 0) { await dispatchMultiModelResponses(dispatch, getState, topicId, userMessage, assistant, mentionedModels) } else { - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessage.id, - model: assistant.model, - traceId: userMessage.traceId - }) - await saveMessageAndBlocksToDB(topicId, assistantMessage, []) + // Create message via Data API for normal topics + const createDto: CreateMessageDto = { + parentId: userMessage.id, + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId: 0, + assistantId: assistant.id, + modelId: assistant.model?.id, + traceId: userMessage.traceId ?? undefined + } + + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, assistant.model) + dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) queue.add(async () => { @@ -1126,12 +1218,21 @@ export const resendMessageThunk = if (assistantMessagesToReset.length === 0 && !userMessageToResend?.mentions?.length) { // 没有相关的助手消息且没有提及模型时,使用助手模型创建一条消息 + // Create message via Data API + const createDto: CreateMessageDto = { + parentId: userMessageToResend.id, + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId: 0, + assistantId: assistant.id, + modelId: assistant.model?.id, + traceId: userMessageToResend.traceId ?? undefined + } + + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, assistant.model) - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessageToResend.id, - model: assistant.model - }) - assistantMessage.traceId = userMessageToResend.traceId resetDataList.push(assistantMessage) resetDataList.forEach((message) => { @@ -1166,11 +1267,21 @@ export const resendMessageThunk = const mentionedModelSet = new Set(userMessageToResend.mentions ?? []) const newModelSet = new Set([...mentionedModelSet].filter((m) => !originModelSet.has(m))) for (const model of newModelSet) { - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessageToResend.id, - model: model, - modelId: model.id - }) + // Create message via Data API for new mentioned models + const createDto: CreateMessageDto = { + parentId: userMessageToResend.id, + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId: 0, + assistantId: assistant.id, + modelId: model.id, + traceId: userMessageToResend.traceId ?? undefined + } + + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, model) + resetDataList.push(assistantMessage) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) } @@ -1178,10 +1289,14 @@ export const resendMessageThunk = messagesToUpdateInRedux.forEach((update) => dispatch(newMessagesActions.updateMessage(update))) cleanupMultipleBlocks(dispatch, allBlockIdsToDelete) + // Note: Block deletion still uses Dexie for now + // TODO: Migrate block deletion to Data API when block endpoints are available try { if (allBlockIdsToDelete.length > 0) { await db.message_blocks.bulkDelete(allBlockIdsToDelete) } + // Note: Dexie topic update removed for new messages - they are created via Data API + // However, existing message updates still need Dexie sync for now const finalMessagesToSave = selectMessagesForTopic(getState(), topicId) await db.topics.update(topicId, { messages: finalMessagesToSave }) } catch (dbError) { @@ -1467,21 +1582,28 @@ export const appendAssistantResponseThunk = return } - // 2. Create the new assistant message stub - const newAssistantMessageStub = createAssistantMessage(assistant.id, topicId, { - askId: askId, // Crucial: Use the original askId - model: newModel, + // 2. Create the new assistant message via Data API + const createDto: CreateMessageDto = { + parentId: askId, // Crucial: Use the original askId + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId: 0, + assistantId: assistant.id, modelId: newModel.id, - traceId: traceId - }) + traceId: traceId ?? undefined + } + + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + const newAssistantMessageStub = convertSharedToRendererMessage(sharedMessage, assistant.id, newModel) // 3. Update Redux Store const currentTopicMessageIds = getState().messages.messageIdsByTopic[topicId] || [] const existingMessageIndex = currentTopicMessageIds.findIndex((id) => id === existingAssistantMessageId) const insertAtIndex = existingMessageIndex !== -1 ? existingMessageIndex + 1 : currentTopicMessageIds.length - // 4. Update Database (Save the stub to the topic's message list) - await saveMessageAndBlocksToDB(topicId, newAssistantMessageStub, [], insertAtIndex) + // 4. Message already saved via Data API POST above + // await saveMessageAndBlocksToDB(topicId, newAssistantMessageStub, [], insertAtIndex) dispatch( newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantMessageStub, index: insertAtIndex }) From e3d1996254fcc14c48d4824c91bfed4f729bf5f5 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Sat, 3 Jan 2026 22:07:53 +0800 Subject: [PATCH 082/116] fix: prevent zoom reset during in-page navigation (#12257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes an Electron bug where zoom factor resets during route changes by listening to the 'did-navigate-in-page' event and reapplying the configured zoom factor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.5 --- src/main/ipc.ts | 10 ++++++++-- src/main/services/WindowService.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 56932a51d6..ccaa664ab8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1061,12 +1061,18 @@ export async function registerIpc(mainWindow: BrowserWindow, app: Electron.App) } catch (error) { const pluginError = extractPluginError(error) if (pluginError) { - logger.error('Failed to list installed plugins', { agentId, error: pluginError }) + logger.error('Failed to list installed plugins', { + agentId, + error: pluginError + }) return { success: false, error: pluginError } } const err = normalizeError(error) - logger.error('Failed to list installed plugins', { agentId, error: err }) + logger.error('Failed to list installed plugins', { + agentId, + error: err + }) return { success: false, error: { diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 3f96497e63..1bee04c1e7 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -255,6 +255,12 @@ export class WindowService { } private setupWebContentsHandlers(mainWindow: BrowserWindow) { + // Fix for Electron bug where zoom resets during in-page navigation (route changes) + // This complements the resize-based workaround by catching navigation events + mainWindow.webContents.on('did-navigate-in-page', () => { + mainWindow.webContents.setZoomFactor(configManager.getZoomFactor()) + }) + mainWindow.webContents.on('will-navigate', (event, url) => { if (url.includes('localhost:517')) { return @@ -516,7 +522,9 @@ export class WindowService { miniWindowState.manage(this.miniWindow) //miniWindow should show in current desktop - this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) + this.miniWindow?.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true + }) //make miniWindow always on top of fullscreen apps with level set //[mac] level higher than 'floating' will cover the pinyin input method this.miniWindow.setAlwaysOnTop(true, 'floating') From f1b9ab4250be481b526c3afff2734db57aa5a706 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 22:47:44 +0800 Subject: [PATCH 083/116] feat: enhance CreateMessageDto and MessageService for improved parentId handling - Updated CreateMessageDto to include detailed behavior for the parentId field, allowing for auto-resolution based on topic state, explicit root creation, or attachment to a specified parent message. - Refactored MessageService to implement the new parentId logic, ensuring proper validation and error handling for message creation based on the topic's current state and existing messages. - Enhanced transaction safety and clarity in the message insertion process by resolving parentId before inserting new messages. --- packages/shared/data/api/schemas/messages.ts | 16 ++++- src/main/data/services/MessageService.ts | 73 ++++++++++++++++++-- 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/shared/data/api/schemas/messages.ts b/packages/shared/data/api/schemas/messages.ts index a2462ca489..59c0e15c6a 100644 --- a/packages/shared/data/api/schemas/messages.ts +++ b/packages/shared/data/api/schemas/messages.ts @@ -24,8 +24,20 @@ import type { AssistantMeta, ModelMeta } from '@shared/data/types/meta' * DTO for creating a new message */ export interface CreateMessageDto { - /** Parent message ID (null for root) */ - parentId: string | null + /** + * Parent message ID for positioning this message in the conversation tree. + * + * Behavior: + * - `undefined` (omitted): Auto-resolve parent based on topic state: + * - If topic has no messages: create as root (parentId = null) + * - If topic has messages and activeNodeId is set: attach to activeNodeId + * - If topic has messages but no activeNodeId: throw INVALID_OPERATION error + * - `null` (explicit): Create as root message. Throws INVALID_OPERATION if + * topic already has a root message (only one root allowed per topic). + * - `string` (message ID): Attach to specified parent. Throws NOT_FOUND if + * parent doesn't exist, or INVALID_OPERATION if parent belongs to different topic. + */ + parentId?: string | null /** Message role */ role: MessageRole /** Message content */ diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 7542ffc02b..17c184ae2a 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -27,7 +27,7 @@ import type { TreeNode, TreeResponse } from '@shared/data/types/message' -import { and, eq, inArray, or, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' const logger = loggerService.withContext('MessageService') @@ -468,27 +468,90 @@ export class MessageService { const db = dbService.getDb() return await db.transaction(async (tx) => { - // Verify topic exists + // Step 1: Verify topic exists and fetch its current state. + // We need the topic to check activeNodeId for parentId auto-resolution. const [topic] = await tx.select().from(topicTable).where(eq(topicTable.id, topicId)).limit(1) if (!topic) { throw DataApiErrorFactory.notFound('Topic', topicId) } - // Verify parent exists if specified - if (dto.parentId) { + // Step 2: Resolve parentId based on the three possible input states: + // - undefined: auto-resolve based on topic state + // - null: explicitly create as root (must validate uniqueness) + // - string: use provided ID (must validate existence and ownership) + let resolvedParentId: string | null + + if (dto.parentId === undefined) { + // Auto-resolution mode: Determine parentId based on topic's current state. + // This provides convenience for callers who want to "append" to the conversation + // without needing to know the tree structure. + + // Check if topic has any existing messages by querying for at least one. + const [existingMessage] = await tx + .select({ id: messageTable.id }) + .from(messageTable) + .where(eq(messageTable.topicId, topicId)) + .limit(1) + + if (!existingMessage) { + // Topic is empty: This will be the first message, so it becomes the root. + // Root messages have parentId = null. + resolvedParentId = null + } else if (topic.activeNodeId) { + // Topic has messages and an active node: Attach new message as child of activeNodeId. + // This is the typical case for continuing a conversation. + resolvedParentId = topic.activeNodeId + } else { + // Topic has messages but no activeNodeId: This is an ambiguous state. + // We cannot auto-resolve because we don't know where in the tree to attach. + // Require explicit parentId from caller to resolve the ambiguity. + throw DataApiErrorFactory.invalidOperation( + 'create message', + 'Topic has messages but no activeNodeId. Please specify parentId explicitly.' + ) + } + } else if (dto.parentId === null) { + // Explicit root creation: Caller wants to create a root message. + // Each topic can only have one root message (parentId = null). + // Check if a root already exists to enforce this constraint. + + const [existingRoot] = await tx + .select({ id: messageTable.id }) + .from(messageTable) + .where(and(eq(messageTable.topicId, topicId), isNull(messageTable.parentId))) + .limit(1) + + if (existingRoot) { + // Root already exists: Cannot create another root message. + // This enforces the single-root tree structure constraint. + throw DataApiErrorFactory.invalidOperation('create root message', 'Topic already has a root message') + } + resolvedParentId = null + } else { + // Explicit parent ID provided: Validate the parent exists and belongs to this topic. + // This ensures referential integrity within the message tree. + const [parent] = await tx.select().from(messageTable).where(eq(messageTable.id, dto.parentId)).limit(1) if (!parent) { + // Parent message not found: Cannot attach to non-existent message. throw DataApiErrorFactory.notFound('Message', dto.parentId) } + if (parent.topicId !== topicId) { + // Parent belongs to different topic: Cross-topic references are not allowed. + // Each topic's message tree must be self-contained. + throw DataApiErrorFactory.invalidOperation('create message', 'Parent message does not belong to this topic') + } + resolvedParentId = dto.parentId } + // Step 3: Insert the message using the resolved parentId. const [row] = await tx .insert(messageTable) .values({ topicId, - parentId: dto.parentId, + parentId: resolvedParentId, role: dto.role, data: dto.data, status: dto.status ?? 'pending', From e6f85ba9fc19c62b65b01deb51c48c3cfdb9f0bf Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 23:55:56 +0800 Subject: [PATCH 084/116] =?UTF-8?q?fix(migration):=20add=20=E2=80=98pendin?= =?UTF-8?q?g'=20to=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced a new SQL migration script to create the `__new_message` table with updated schema, including foreign key constraints and check constraints for `role` and `status`. - Migrated existing data from the old `message` table to the new structure and renamed the table to `message`. - Added indexes for improved query performance on `parent_id`, `topic_id`, and `trace_id`. - Updated metadata to reflect the new migration version and breakpoints for debugging. --- .../0001_futuristic_human_fly.sql | 32 + .../sqlite-drizzle/meta/0001_snapshot.json | 662 ++++++++++++++++++ migrations/sqlite-drizzle/meta/_journal.json | 9 +- src/main/data/db/schemas/message.ts | 2 +- 4 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 migrations/sqlite-drizzle/0001_futuristic_human_fly.sql create mode 100644 migrations/sqlite-drizzle/meta/0001_snapshot.json diff --git a/migrations/sqlite-drizzle/0001_futuristic_human_fly.sql b/migrations/sqlite-drizzle/0001_futuristic_human_fly.sql new file mode 100644 index 0000000000..e4683658be --- /dev/null +++ b/migrations/sqlite-drizzle/0001_futuristic_human_fly.sql @@ -0,0 +1,32 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_message` ( + `id` text PRIMARY KEY NOT NULL, + `parent_id` text, + `topic_id` text NOT NULL, + `role` text NOT NULL, + `data` text NOT NULL, + `searchable_text` text, + `status` text NOT NULL, + `siblings_group_id` integer DEFAULT 0, + `assistant_id` text, + `assistant_meta` text, + `model_id` text, + `model_meta` text, + `trace_id` text, + `stats` text, + `created_at` integer, + `updated_at` integer, + `deleted_at` integer, + FOREIGN KEY (`topic_id`) REFERENCES `topic`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`parent_id`) REFERENCES `message`(`id`) ON UPDATE no action ON DELETE set null, + CONSTRAINT "message_role_check" CHECK("__new_message"."role" IN ('user', 'assistant', 'system')), + CONSTRAINT "message_status_check" CHECK("__new_message"."status" IN ('pending', 'success', 'error', 'paused')) +); +--> statement-breakpoint +INSERT INTO `__new_message`("id", "parent_id", "topic_id", "role", "data", "searchable_text", "status", "siblings_group_id", "assistant_id", "assistant_meta", "model_id", "model_meta", "trace_id", "stats", "created_at", "updated_at", "deleted_at") SELECT "id", "parent_id", "topic_id", "role", "data", "searchable_text", "status", "siblings_group_id", "assistant_id", "assistant_meta", "model_id", "model_meta", "trace_id", "stats", "created_at", "updated_at", "deleted_at" FROM `message`;--> statement-breakpoint +DROP TABLE `message`;--> statement-breakpoint +ALTER TABLE `__new_message` RENAME TO `message`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `message_parent_id_idx` ON `message` (`parent_id`);--> statement-breakpoint +CREATE INDEX `message_topic_created_idx` ON `message` (`topic_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `message_trace_id_idx` ON `message` (`trace_id`); \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..6c263b18df --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0001_snapshot.json @@ -0,0 +1,662 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a433b120-0ab8-4f3f-9d1d-766b48c216c8", + "prevId": "2ee6f7b2-99da-4de1-b895-48866855b7c6", + "tables": { + "app_state": { + "name": "app_state", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_tag": { + "name": "entity_tag", + "columns": { + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "name": "entity_tag_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "name": "entity_tag_tag_id_tag_id_fk", + "tableFrom": "entity_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_tag_entity_type_entity_id_tag_id_pk": { + "columns": [ + "entity_type", + "entity_id", + "tag_id" + ], + "name": "entity_tag_entity_type_entity_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "group": { + "name": "group", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "group_entity_sort_idx": { + "name": "group_entity_sort_idx", + "columns": [ + "entity_type", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message": { + "name": "message", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "searchable_text": { + "name": "searchable_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "siblings_group_id": { + "name": "siblings_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_meta": { + "name": "model_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stats": { + "name": "stats", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "message_parent_id_idx": { + "name": "message_parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "message_topic_created_idx": { + "name": "message_topic_created_idx", + "columns": [ + "topic_id", + "created_at" + ], + "isUnique": false + }, + "message_trace_id_idx": { + "name": "message_trace_id_idx", + "columns": [ + "trace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "message_topic_id_topic_id_fk": { + "name": "message_topic_id_topic_id_fk", + "tableFrom": "message", + "tableTo": "topic", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_parent_id_message_id_fk": { + "name": "message_parent_id_message_id_fk", + "tableFrom": "message", + "tableTo": "message", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "message_role_check": { + "name": "message_role_check", + "value": "\"message\".\"role\" IN ('user', 'assistant', 'system')" + }, + "message_status_check": { + "name": "message_status_check", + "value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')" + } + } + }, + "preference": { + "name": "preference", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": [ + "scope", + "key" + ], + "name": "preference_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tag_name_unique": { + "name": "tag_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "topic": { + "name": "topic", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_name_manually_edited": { + "name": "is_name_manually_edited", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "assistant_id": { + "name": "assistant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assistant_meta": { + "name": "assistant_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_node_id": { + "name": "active_node_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "pinned_order": { + "name": "pinned_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "topic_group_updated_idx": { + "name": "topic_group_updated_idx", + "columns": [ + "group_id", + "updated_at" + ], + "isUnique": false + }, + "topic_group_sort_idx": { + "name": "topic_group_sort_idx", + "columns": [ + "group_id", + "sort_order" + ], + "isUnique": false + }, + "topic_updated_at_idx": { + "name": "topic_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "topic_is_pinned_idx": { + "name": "topic_is_pinned_idx", + "columns": [ + "is_pinned", + "pinned_order" + ], + "isUnique": false + }, + "topic_assistant_id_idx": { + "name": "topic_assistant_id_idx", + "columns": [ + "assistant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "name": "topic_group_id_group_id_fk", + "tableFrom": "topic", + "tableTo": "group", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index cc3bfebb01..09f883d363 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -7,7 +7,14 @@ "tag": "0000_init", "version": "6", "when": 1767272575118 + }, + { + "idx": 1, + "version": "6", + "when": 1767455592181, + "tag": "0001_futuristic_human_fly", + "breakpoints": true } ], "version": "7" -} +} \ No newline at end of file diff --git a/src/main/data/db/schemas/message.ts b/src/main/data/db/schemas/message.ts index bbb07277e7..3118433f6c 100644 --- a/src/main/data/db/schemas/message.ts +++ b/src/main/data/db/schemas/message.ts @@ -60,6 +60,6 @@ export const messageTable = sqliteTable( index('message_trace_id_idx').on(t.traceId), // Check constraints for enum fields check('message_role_check', sql`${t.role} IN ('user', 'assistant', 'system')`), - check('message_status_check', sql`${t.status} IN ('success', 'error', 'paused')`) + check('message_status_check', sql`${t.status} IN ('pending', 'success', 'error', 'paused')`) ] ) From b1de7283dc7947677b503d6e47d70104e049cf30 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sat, 3 Jan 2026 23:58:48 +0800 Subject: [PATCH 085/116] feat: implement user message creation via Data API in StreamingService - Added a new method `createUserMessage` in StreamingService to handle user message creation through the Data API, generating server-side message IDs while preserving client-generated block IDs. - Updated `sendMessage` thunk to utilize the new method for normal topics, ensuring proper message handling based on the active agent session. - Refactored block conversion logic to streamline the process of preparing message data for the API. --- .../messageStreaming/StreamingService.ts | 70 ++++++++++++++++--- src/renderer/src/store/thunk/messageThunk.ts | 26 ++++--- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 29e0a484cd..7e3a7bd28f 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -24,7 +24,7 @@ import { loggerService } from '@logger' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' import { isAgentSessionTopicId } from '@renderer/utils/agentSession' -import type { UpdateMessageDto } from '@shared/data/api/schemas/messages' +import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages' import type { MessageDataBlock, MessageStats } from '@shared/data/types/message' import { dbService } from '../db' @@ -465,8 +465,64 @@ class StreamingService { return nextGroupId } + // ============ User Message Creation ============ + + /** + * Create a user message via Data API + * + * The message ID is generated by the server, not locally. + * Block IDs remain client-generated for Redux store use. + * + * TRADEOFF: Not passing parentId - Data API will use topic.activeNodeId as parent. + * In multi-window/multi-branch scenarios, this may cause incorrect associations + * if activeNodeId was changed by another window. + * TODO: In the future, parentId should come from the full message tree + * maintained in the topic UI, not from topic.activeNodeId. + * + * @param topicId - Topic ID + * @param message - Renderer format message (message.id will be ignored, server generates ID) + * @param blocks - Renderer format blocks (block IDs preserved for Redux) + * @returns Message with server-generated ID and original block IDs + */ + async createUserMessage(topicId: string, message: Message, blocks: MessageBlock[]): Promise { + // Convert blocks to MessageDataBlock format (remove id, status, messageId) + const dataBlocks = this.convertBlocksToDataFormat(blocks) + + // Build CreateMessageDto (parentId omitted - API uses topic.activeNodeId) + const createDto: CreateMessageDto = { + role: 'user', + data: { blocks: dataBlocks }, + status: 'success', + traceId: message.traceId ?? undefined + } + + // POST to Data API - server generates message ID + const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) + + logger.debug('Created user message via Data API', { topicId, messageId: sharedMessage.id }) + + // Return message with server ID, preserving other fields from original message + return { + ...message, + id: sharedMessage.id, // Use server-generated ID + blocks: blocks.map((b) => b.id) // Preserve client-generated block IDs + } + } + // ============ Internal Methods ============ + /** + * Convert renderer MessageBlock[] to shared MessageDataBlock[] + * Removes renderer-specific fields: id, status, messageId + */ + private convertBlocksToDataFormat(blocks: MessageBlock[]): MessageDataBlock[] { + return blocks.map((block) => { + // oxlint-disable-next-line @typescript-eslint/no-unused-vars + const { id, status, messageId, ...blockData } = block as MessageBlock & { messageId?: string } + return blockData as unknown as MessageDataBlock + }) + } + /** * Convert session data to database update payload * @@ -530,16 +586,8 @@ class StreamingService { // TRADEOFF: Using 'as unknown as' because renderer's MessageBlockType and shared's BlockType // are structurally identical but TypeScript treats them as incompatible enums. const dataBlocks: MessageDataBlock[] = blocks.map((block) => { - // Extract only the fields that belong to MessageDataBlock - const { - id: _id, - status: _blockStatus, - messageId: _messageId, - ...blockData - } = block as MessageBlock & { - messageId?: string - } - + // oxlint-disable-next-line @typescript-eslint/no-unused-vars + const { id, status, messageId, ...blockData } = block as MessageBlock & { messageId?: string } return blockData as unknown as MessageDataBlock }) diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index 7bafb27cdb..de8f9e26d3 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -978,8 +978,18 @@ export const sendMessage = userMessage.agentSessionId = activeAgentSession.agentSessionId } - await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks) - dispatch(newMessagesActions.addMessage({ topicId, message: userMessage })) + let finalUserMessage: Message + + if (activeAgentSession) { + // Agent session: keep existing Dexie logic + await saveMessageAndBlocksToDB(topicId, userMessage, userMessageBlocks) + finalUserMessage = userMessage + } else { + // Normal topic: use Data API, get server-generated message ID + finalUserMessage = await streamingService.createUserMessage(topicId, userMessage, userMessageBlocks) + } + + dispatch(newMessagesActions.addMessage({ topicId, message: finalUserMessage })) if (userMessageBlocks.length > 0) { dispatch(upsertManyBlocks(userMessageBlocks)) } @@ -989,7 +999,7 @@ export const sendMessage = if (activeAgentSession) { const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessage.id, + askId: finalUserMessage.id, model: assistant.model, traceId: userMessage.traceId }) @@ -1005,25 +1015,25 @@ export const sendMessage = assistant, assistantMessage, agentSession: activeAgentSession, - userMessageId: userMessage.id + userMessageId: finalUserMessage.id }) }) } else { - const mentionedModels = userMessage.mentions + const mentionedModels = finalUserMessage.mentions if (mentionedModels && mentionedModels.length > 0) { - await dispatchMultiModelResponses(dispatch, getState, topicId, userMessage, assistant, mentionedModels) + await dispatchMultiModelResponses(dispatch, getState, topicId, finalUserMessage, assistant, mentionedModels) } else { // Create message via Data API for normal topics const createDto: CreateMessageDto = { - parentId: userMessage.id, + parentId: finalUserMessage.id, role: 'assistant', data: { blocks: [] }, status: 'pending', siblingsGroupId: 0, assistantId: assistant.id, modelId: assistant.model?.id, - traceId: userMessage.traceId ?? undefined + traceId: finalUserMessage.traceId ?? undefined } const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) From 3dfd5c7c2b62a4dff6f9897e4e5dc6ed6d5baa2d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 01:07:04 +0800 Subject: [PATCH 086/116] feat: add custom SQL handling for triggers and virtual tables - Introduced a new method `runCustomMigrations` in `DbService` to execute custom SQL statements that Drizzle cannot manage, such as triggers and virtual tables. - Updated `database-patterns.md` and `README.md` to document the handling of custom SQL and its importance in maintaining database integrity during migrations. - Refactored `messageFts.ts` to define FTS5 virtual table and associated triggers as idempotent SQL statements for better migration management. --- docs/en/references/data/database-patterns.md | 8 + .../sqlite-drizzle/meta/0001_snapshot.json | 968 +++++++++--------- migrations/sqlite-drizzle/meta/_journal.json | 8 +- src/main/data/db/DbService.ts | 25 + src/main/data/db/README.md | 6 + src/main/data/db/customSql.ts | 25 + src/main/data/db/schemas/messageFts.ts | 88 +- 7 files changed, 567 insertions(+), 561 deletions(-) create mode 100644 src/main/data/db/customSql.ts diff --git a/docs/en/references/data/database-patterns.md b/docs/en/references/data/database-patterns.md index 10d3a44593..c4745832b9 100644 --- a/docs/en/references/data/database-patterns.md +++ b/docs/en/references/data/database-patterns.md @@ -197,3 +197,11 @@ return this.getById(id) The schema supports soft delete via `deletedAt` field (see `createUpdateDeleteTimestamps`). Business logic can choose to use soft delete or hard delete based on requirements. + +## Custom SQL + +Drizzle cannot manage triggers and virtual tables (e.g., FTS5). These are defined in `customSql.ts` and run automatically after every migration. + +**Why**: SQLite's `DROP TABLE` removes associated triggers. When Drizzle modifies a table schema, it drops and recreates the table, losing triggers in the process. + +**Adding new custom SQL**: Define statements as `string[]` in the relevant schema file, then spread into `CUSTOM_SQL_STATEMENTS` in `customSql.ts`. All statements must use `IF NOT EXISTS` to be idempotent. diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json index 6c263b18df..7560d37a6c 100644 --- a/migrations/sqlite-drizzle/meta/0001_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0001_snapshot.json @@ -1,370 +1,188 @@ { - "version": "6", + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, "dialect": "sqlite", + "enums": {}, "id": "a433b120-0ab8-4f3f-9d1d-766b48c216c8", + "internal": { + "indexes": {} + }, "prevId": "2ee6f7b2-99da-4de1-b895-48866855b7c6", "tables": { "app_state": { - "name": "app_state", + "checkConstraints": {}, "columns": { - "key": { - "name": "key", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, "primaryKey": false, - "notNull": true, - "autoincrement": false + "type": "integer" }, "description": { + "autoincrement": false, "name": "description", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": true, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": true, + "primaryKey": false, + "type": "text" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": {}, + "indexes": {}, + "name": "app_state", + "uniqueConstraints": {} }, "entity_tag": { - "name": "entity_tag", + "checkConstraints": {}, "columns": { - "entity_type": { - "name": "entity_type", - "type": "text", + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, "primaryKey": false, - "notNull": true, - "autoincrement": false + "type": "integer" }, "entity_id": { + "autoincrement": false, "name": "entity_id", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" }, "tag_id": { + "autoincrement": false, "name": "tag_id", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "entity_tag_tag_id_idx": { - "name": "entity_tag_tag_id_idx", - "columns": [ - "tag_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "entity_tag_tag_id_tag_id_fk": { - "name": "entity_tag_tag_id_tag_id_fk", - "tableFrom": "entity_tag", - "tableTo": "tag", - "columnsFrom": [ - "tag_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "primaryKey": false, + "type": "integer" } }, "compositePrimaryKeys": { "entity_tag_entity_type_entity_id_tag_id_pk": { - "columns": [ - "entity_type", - "entity_id", - "tag_id" - ], + "columns": ["entity_type", "entity_id", "tag_id"], "name": "entity_tag_entity_type_entity_id_tag_id_pk" } }, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": { + "entity_tag_tag_id_tag_id_fk": { + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "name": "entity_tag_tag_id_tag_id_fk", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "entity_tag", + "tableTo": "tag" + } + }, + "indexes": { + "entity_tag_tag_id_idx": { + "columns": ["tag_id"], + "isUnique": false, + "name": "entity_tag_tag_id_idx" + } + }, + "name": "entity_tag", + "uniqueConstraints": {} }, "group": { - "name": "group", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" }, "entity_type": { + "autoincrement": false, "name": "entity_type", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" }, "name": { + "autoincrement": false, "name": "name", - "type": "text", - "primaryKey": false, "notNull": true, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, + "default": 0, + "name": "sort_order", "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" } }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, "indexes": { "group_entity_sort_idx": { - "name": "group_entity_sort_idx", - "columns": [ - "entity_type", - "sort_order" - ], - "isUnique": false + "columns": ["entity_type", "sort_order"], + "isUnique": false, + "name": "group_entity_sort_idx" } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "group", + "uniqueConstraints": {} }, "message": { - "name": "message", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "parent_id": { - "name": "parent_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "topic_id": { - "name": "topic_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "data": { - "name": "data", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "searchable_text": { - "name": "searchable_text", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "siblings_group_id": { - "name": "siblings_group_id", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "assistant_id": { - "name": "assistant_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "assistant_meta": { - "name": "assistant_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_id": { - "name": "model_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "model_meta": { - "name": "model_meta", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "trace_id": { - "name": "trace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "stats": { - "name": "stats", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "message_parent_id_idx": { - "name": "message_parent_id_idx", - "columns": [ - "parent_id" - ], - "isUnique": false - }, - "message_topic_created_idx": { - "name": "message_topic_created_idx", - "columns": [ - "topic_id", - "created_at" - ], - "isUnique": false - }, - "message_trace_id_idx": { - "name": "message_trace_id_idx", - "columns": [ - "trace_id" - ], - "isUnique": false - } - }, - "foreignKeys": { - "message_topic_id_topic_id_fk": { - "name": "message_topic_id_topic_id_fk", - "tableFrom": "message", - "tableTo": "topic", - "columnsFrom": [ - "topic_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "message_parent_id_message_id_fk": { - "name": "message_parent_id_message_id_fk", - "tableFrom": "message", - "tableTo": "message", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, "checkConstraints": { "message_role_check": { "name": "message_role_check", @@ -374,289 +192,421 @@ "name": "message_status_check", "value": "\"message\".\"status\" IN ('pending', 'success', 'error', 'paused')" } - } - }, - "preference": { - "name": "preference", + }, "columns": { - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": true, + "assistant_id": { "autoincrement": false, - "default": "'default'" - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, + "name": "assistant_id", "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" + }, + "assistant_meta": { + "autoincrement": false, + "name": "assistant_meta", + "notNull": false, + "primaryKey": false, + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "data": { + "autoincrement": false, + "name": "data", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "deleted_at": { + "autoincrement": false, + "name": "deleted_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "model_id": { + "autoincrement": false, + "name": "model_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "model_meta": { + "autoincrement": false, + "name": "model_meta", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "parent_id": { + "autoincrement": false, + "name": "parent_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "role": { + "autoincrement": false, + "name": "role", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "searchable_text": { + "autoincrement": false, + "name": "searchable_text", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "siblings_group_id": { + "autoincrement": false, + "default": 0, + "name": "siblings_group_id", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "stats": { + "autoincrement": false, + "name": "stats", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "status": { + "autoincrement": false, + "name": "status", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "topic_id": { + "autoincrement": false, + "name": "topic_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "trace_id": { + "autoincrement": false, + "name": "trace_id", + "notNull": false, + "primaryKey": false, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "message_parent_id_message_id_fk": { + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "name": "message_parent_id_message_id_fk", + "onDelete": "set null", + "onUpdate": "no action", + "tableFrom": "message", + "tableTo": "message" + }, + "message_topic_id_topic_id_fk": { + "columnsFrom": ["topic_id"], + "columnsTo": ["id"], + "name": "message_topic_id_topic_id_fk", + "onDelete": "cascade", + "onUpdate": "no action", + "tableFrom": "message", + "tableTo": "topic" + } + }, + "indexes": { + "message_parent_id_idx": { + "columns": ["parent_id"], + "isUnique": false, + "name": "message_parent_id_idx" + }, + "message_topic_created_idx": { + "columns": ["topic_id", "created_at"], + "isUnique": false, + "name": "message_topic_created_idx" + }, + "message_trace_id_idx": { + "columns": ["trace_id"], + "isUnique": false, + "name": "message_trace_id_idx" + } + }, + "name": "message", + "uniqueConstraints": {} + }, + "preference": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "scope": { + "autoincrement": false, + "default": "'default'", + "name": "scope", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": false, + "primaryKey": false, + "type": "text" } }, - "indexes": {}, - "foreignKeys": {}, "compositePrimaryKeys": { "preference_scope_key_pk": { - "columns": [ - "scope", - "key" - ], + "columns": ["scope", "key"], "name": "preference_scope_key_pk" } }, - "uniqueConstraints": {}, - "checkConstraints": {} + "foreignKeys": {}, + "indexes": {}, + "name": "preference", + "uniqueConstraints": {} }, "tag": { - "name": "tag", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, "color": { + "autoincrement": false, "name": "color", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": true, + "primaryKey": false, + "type": "text" }, "updated_at": { + "autoincrement": false, "name": "updated_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" } }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, "indexes": { "tag_name_unique": { - "name": "tag_name_unique", - "columns": [ - "name" - ], - "isUnique": true + "columns": ["name"], + "isUnique": true, + "name": "tag_name_unique" } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "tag", + "uniqueConstraints": {} }, "topic": { - "name": "topic", + "checkConstraints": {}, "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_name_manually_edited": { - "name": "is_name_manually_edited", - "type": "integer", - "primaryKey": false, - "notNull": false, + "active_node_id": { "autoincrement": false, - "default": false + "name": "active_node_id", + "notNull": false, + "primaryKey": false, + "type": "text" }, "assistant_id": { + "autoincrement": false, "name": "assistant_id", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "text" }, "assistant_meta": { + "autoincrement": false, "name": "assistant_meta", - "type": "text", - "primaryKey": false, "notNull": false, - "autoincrement": false - }, - "prompt": { - "name": "prompt", - "type": "text", "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_node_id": { - "name": "active_node_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "group_id": { - "name": "group_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "sort_order": { - "name": "sort_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "is_pinned": { - "name": "is_pinned", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": false - }, - "pinned_order": { - "name": "pinned_order", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 + "type": "text" }, "created_at": { + "autoincrement": false, "name": "created_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", "primaryKey": false, - "notNull": false, - "autoincrement": false + "type": "integer" }, "deleted_at": { + "autoincrement": false, "name": "deleted_at", - "type": "integer", - "primaryKey": false, "notNull": false, - "autoincrement": false + "primaryKey": false, + "type": "integer" + }, + "group_id": { + "autoincrement": false, + "name": "group_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "is_name_manually_edited": { + "autoincrement": false, + "default": false, + "name": "is_name_manually_edited", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "is_pinned": { + "autoincrement": false, + "default": false, + "name": "is_pinned", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "pinned_order": { + "autoincrement": false, + "default": 0, + "name": "pinned_order", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "prompt": { + "autoincrement": false, + "name": "prompt", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "sort_order": { + "autoincrement": false, + "default": 0, + "name": "sort_order", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": { + "topic_group_id_group_id_fk": { + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "name": "topic_group_id_group_id_fk", + "onDelete": "set null", + "onUpdate": "no action", + "tableFrom": "topic", + "tableTo": "group" } }, "indexes": { - "topic_group_updated_idx": { - "name": "topic_group_updated_idx", - "columns": [ - "group_id", - "updated_at" - ], - "isUnique": false + "topic_assistant_id_idx": { + "columns": ["assistant_id"], + "isUnique": false, + "name": "topic_assistant_id_idx" }, "topic_group_sort_idx": { - "name": "topic_group_sort_idx", - "columns": [ - "group_id", - "sort_order" - ], - "isUnique": false + "columns": ["group_id", "sort_order"], + "isUnique": false, + "name": "topic_group_sort_idx" }, - "topic_updated_at_idx": { - "name": "topic_updated_at_idx", - "columns": [ - "updated_at" - ], - "isUnique": false + "topic_group_updated_idx": { + "columns": ["group_id", "updated_at"], + "isUnique": false, + "name": "topic_group_updated_idx" }, "topic_is_pinned_idx": { - "name": "topic_is_pinned_idx", - "columns": [ - "is_pinned", - "pinned_order" - ], - "isUnique": false + "columns": ["is_pinned", "pinned_order"], + "isUnique": false, + "name": "topic_is_pinned_idx" }, - "topic_assistant_id_idx": { - "name": "topic_assistant_id_idx", - "columns": [ - "assistant_id" - ], - "isUnique": false + "topic_updated_at_idx": { + "columns": ["updated_at"], + "isUnique": false, + "name": "topic_updated_at_idx" } }, - "foreignKeys": { - "topic_group_id_group_id_fk": { - "name": "topic_group_id_group_id_fk", - "tableFrom": "topic", - "tableTo": "group", - "columnsFrom": [ - "group_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "set null", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} + "name": "topic", + "uniqueConstraints": {} } }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file + "version": "6", + "views": {} +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index 09f883d363..c2bac3b325 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -9,12 +9,12 @@ "when": 1767272575118 }, { + "breakpoints": true, "idx": 1, - "version": "6", - "when": 1767455592181, "tag": "0001_futuristic_human_fly", - "breakpoints": true + "version": "6", + "when": 1767455592181 } ], "version": "7" -} \ No newline at end of file +} diff --git a/src/main/data/db/DbService.ts b/src/main/data/db/DbService.ts index 8a7edb6f33..de72be03dd 100644 --- a/src/main/data/db/DbService.ts +++ b/src/main/data/db/DbService.ts @@ -6,6 +6,7 @@ import { app } from 'electron' import path from 'path' import { pathToFileURL } from 'url' +import { CUSTOM_SQL_STATEMENTS } from './customSql' import Seeding from './seeding' import type { DbType } from './types' @@ -120,6 +121,9 @@ class DbService { const migrationsFolder = this.getMigrationsFolder() await migrate(this.db, { migrationsFolder }) + // Run custom SQL that Drizzle cannot manage (triggers, virtual tables, etc.) + await this.runCustomMigrations() + logger.info('Database migration completed successfully') } catch (error) { logger.error('Database migration failed', error as Error) @@ -127,6 +131,27 @@ class DbService { } } + /** + * Run custom SQL statements that Drizzle cannot manage + * + * This includes triggers, virtual tables, and other SQL objects. + * Called after every migration because: + * 1. Drizzle doesn't track these in schema + * 2. DROP TABLE removes associated triggers + * 3. All statements use IF NOT EXISTS, so they're idempotent + */ + private async runCustomMigrations(): Promise { + try { + for (const statement of CUSTOM_SQL_STATEMENTS) { + await this.db.run(sql.raw(statement)) + } + logger.debug('Custom migrations completed', { count: CUSTOM_SQL_STATEMENTS.length }) + } catch (error) { + logger.error('Custom migrations failed', error as Error) + throw error + } + } + /** * Get the database instance * @throws {Error} If database is not initialized diff --git a/src/main/data/db/README.md b/src/main/data/db/README.md index 0e43e760eb..2a07bd5d43 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -14,8 +14,10 @@ src/main/data/db/ │ ├── columnHelpers.ts # Reusable column definitions │ ├── topic.ts # Topic table │ ├── message.ts # Message table +│ ├── messageFts.ts # FTS5 virtual table & triggers │ └── ... # Other tables ├── seeding/ # Database initialization +├── customSql.ts # Custom SQL (triggers, virtual tables, etc.) └── DbService.ts # Database connection management ``` @@ -33,6 +35,10 @@ src/main/data/db/ yarn db:migrations:generate ``` +### Custom SQL (Triggers, Virtual Tables) + +Drizzle cannot manage triggers and virtual tables. See `customSql.ts` for how these are handled. + ### Column Helpers ```typescript diff --git a/src/main/data/db/customSql.ts b/src/main/data/db/customSql.ts new file mode 100644 index 0000000000..eaeea28db2 --- /dev/null +++ b/src/main/data/db/customSql.ts @@ -0,0 +1,25 @@ +/** + * Custom SQL statements that Drizzle cannot manage + * + * Drizzle ORM doesn't track: + * - Virtual tables (FTS5) + * - Triggers + * - Custom indexes with expressions + * + * These are executed after every migration via DbService.runCustomMigrations() + * All statements must be idempotent (use IF NOT EXISTS, etc.) + * + * To add new custom SQL: + * 1. Create statements in the relevant schema file (e.g., messageFts.ts) + * 2. Import and spread them into CUSTOM_SQL_STATEMENTS below + */ + +import { MESSAGE_FTS_STATEMENTS } from './schemas/messageFts' + +/** + * All custom SQL statements to run after migrations + */ +export const CUSTOM_SQL_STATEMENTS: string[] = [ + ...MESSAGE_FTS_STATEMENTS + // Add more custom SQL arrays here as needed +] diff --git a/src/main/data/db/schemas/messageFts.ts b/src/main/data/db/schemas/messageFts.ts index e87bcf0010..ccffbb5eaf 100644 --- a/src/main/data/db/schemas/messageFts.ts +++ b/src/main/data/db/schemas/messageFts.ts @@ -24,58 +24,50 @@ export const SEARCHABLE_TEXT_EXPRESSION = ` ` /** - * Migration SQL - Copy these statements to migration file + * Custom SQL statements that Drizzle cannot manage + * These are executed after every migration via DbService.runCustomMigrations() + * + * All statements should use IF NOT EXISTS to be idempotent. */ -export const MESSAGE_FTS_MIGRATION_SQL = ` ---> statement-breakpoint --- ============================================================ --- FTS5 Virtual Table and Triggers for Message Full-Text Search --- ============================================================ +export const MESSAGE_FTS_STATEMENTS: string[] = [ + // FTS5 virtual table, Links to message table's searchable_text column + `CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( + searchable_text, + content='message', + content_rowid='rowid', + tokenize='trigram' + )`, --- 1. Create FTS5 virtual table with external content --- Links to message table's searchable_text column -CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5( - searchable_text, - content='message', - content_rowid='rowid', - tokenize='trigram' -);--> statement-breakpoint + // Trigger: populate searchable_text and sync FTS on INSERT + `CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; + END`, --- 2. Trigger: populate searchable_text and sync FTS on INSERT -CREATE TRIGGER IF NOT EXISTS message_ai AFTER INSERT ON message BEGIN - -- Extract searchable text from data.blocks - UPDATE message SET searchable_text = ( - SELECT group_concat(json_extract(value, '$.content'), ' ') - FROM json_each(json_extract(NEW.data, '$.blocks')) - WHERE json_extract(value, '$.type') = 'main_text' - ) WHERE id = NEW.id; - -- Sync to FTS5 - INSERT INTO message_fts(rowid, searchable_text) - SELECT rowid, searchable_text FROM message WHERE id = NEW.id; -END;--> statement-breakpoint + // Trigger: sync FTS on DELETE + `CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); + END`, --- 3. Trigger: sync FTS on DELETE -CREATE TRIGGER IF NOT EXISTS message_ad AFTER DELETE ON message BEGIN - INSERT INTO message_fts(message_fts, rowid, searchable_text) - VALUES ('delete', OLD.rowid, OLD.searchable_text); -END;--> statement-breakpoint - --- 4. Trigger: update searchable_text and sync FTS on UPDATE OF data -CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN - -- Remove old FTS entry - INSERT INTO message_fts(message_fts, rowid, searchable_text) - VALUES ('delete', OLD.rowid, OLD.searchable_text); - -- Update searchable_text - UPDATE message SET searchable_text = ( - SELECT group_concat(json_extract(value, '$.content'), ' ') - FROM json_each(json_extract(NEW.data, '$.blocks')) - WHERE json_extract(value, '$.type') = 'main_text' - ) WHERE id = NEW.id; - -- Add new FTS entry - INSERT INTO message_fts(rowid, searchable_text) - SELECT rowid, searchable_text FROM message WHERE id = NEW.id; -END; -` + // Trigger: update searchable_text and sync FTS on UPDATE OF data + `CREATE TRIGGER IF NOT EXISTS message_au AFTER UPDATE OF data ON message BEGIN + INSERT INTO message_fts(message_fts, rowid, searchable_text) + VALUES ('delete', OLD.rowid, OLD.searchable_text); + UPDATE message SET searchable_text = ( + SELECT group_concat(json_extract(value, '$.content'), ' ') + FROM json_each(json_extract(NEW.data, '$.blocks')) + WHERE json_extract(value, '$.type') = 'main_text' + ) WHERE id = NEW.id; + INSERT INTO message_fts(rowid, searchable_text) + SELECT rowid, searchable_text FROM message WHERE id = NEW.id; + END` +] /** * Rebuild FTS index (run manually if needed) From acd1ecc09c1118ead8c37e5013c7b7f94302d059 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 08:49:16 +0800 Subject: [PATCH 087/116] ci(deps): bump peter-evans/create-pull-request from 6 to 8 (#12224) Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 8. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-i18n.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 2ca56c0837..7537c4d4a3 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -73,7 +73,7 @@ jobs: - name: 🚀 Create Pull Request if changes exist if: steps.git_status.outputs.has_changes == 'true' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions commit-message: "feat(bot): Weekly automated script run" From 7e8cc430a89c2a426006a325d14c14925bf9d632 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 09:10:14 +0800 Subject: [PATCH 088/116] refactor: update logger context for DataApi components - Changed logger context strings in ApiServer, MiddlewareEngine, IpcAdapter, MessageService, TestService, and TopicService to follow a consistent naming convention with the prefix 'DataApi:'. - This refactor enhances clarity and organization in logging across the API services. --- src/main/data/api/core/ApiServer.ts | 2 +- src/main/data/api/core/MiddlewareEngine.ts | 2 +- src/main/data/api/core/adapters/IpcAdapter.ts | 2 +- src/main/data/services/MessageService.ts | 2 +- src/main/data/services/TestService.ts | 2 +- src/main/data/services/TopicService.ts | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/data/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index df92c783b9..50ee6cd02e 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -9,7 +9,7 @@ import { MiddlewareEngine } from './MiddlewareEngine' // Handler function type type HandlerFunction = (params: { params?: Record; query?: any; body?: any }) => Promise -const logger = loggerService.withContext('DataApiServer') +const logger = loggerService.withContext('DataApi:Server') /** * Core API Server - Transport agnostic request processor diff --git a/src/main/data/api/core/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts index d8af7bd3ef..f1bf3c90b7 100644 --- a/src/main/data/api/core/MiddlewareEngine.ts +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -2,7 +2,7 @@ import { loggerService } from '@logger' import { toDataApiError } from '@shared/data/api/apiErrors' import type { DataRequest, DataResponse, Middleware, RequestContext } from '@shared/data/api/apiTypes' -const logger = loggerService.withContext('MiddlewareEngine') +const logger = loggerService.withContext('DataApi:MiddlewareEngine') /** * Middleware engine for executing middleware chains diff --git a/src/main/data/api/core/adapters/IpcAdapter.ts b/src/main/data/api/core/adapters/IpcAdapter.ts index b4d8e56bfe..d7d08fa6e1 100644 --- a/src/main/data/api/core/adapters/IpcAdapter.ts +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -6,7 +6,7 @@ import { ipcMain } from 'electron' import type { ApiServer } from '../ApiServer' -const logger = loggerService.withContext('DataApiIpcAdapter') +const logger = loggerService.withContext('DataApi:IpcAdapter') /** * IPC Adapter for Electron environment diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts index 17c184ae2a..85e02ec8e2 100644 --- a/src/main/data/services/MessageService.ts +++ b/src/main/data/services/MessageService.ts @@ -29,7 +29,7 @@ import type { } from '@shared/data/types/message' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' -const logger = loggerService.withContext('MessageService') +const logger = loggerService.withContext('DataApi:MessageService') /** * Preview length for tree nodes diff --git a/src/main/data/services/TestService.ts b/src/main/data/services/TestService.ts index 1af016cf44..7e7b810eaf 100644 --- a/src/main/data/services/TestService.ts +++ b/src/main/data/services/TestService.ts @@ -1,6 +1,6 @@ import { loggerService } from '@logger' -const logger = loggerService.withContext('TestService') +const logger = loggerService.withContext('DataApi:TestService') /** * Test service for API testing scenarios diff --git a/src/main/data/services/TopicService.ts b/src/main/data/services/TopicService.ts index b660f6f09a..d30215d283 100644 --- a/src/main/data/services/TopicService.ts +++ b/src/main/data/services/TopicService.ts @@ -18,7 +18,7 @@ import { eq } from 'drizzle-orm' import { messageService } from './MessageService' -const logger = loggerService.withContext('TopicService') +const logger = loggerService.withContext('DataApi:TopicService') /** * Convert database row to Topic entity From 542702ad56f862b89eb9ccbdef20c9c773d32d36 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 09:49:32 +0800 Subject: [PATCH 089/116] feat: implement assistant message creation via StreamingService - Added a new method `createAssistantMessage` in StreamingService to facilitate the creation of assistant messages through the Data API, ensuring server-generated message IDs while maintaining client-side data integrity. - Updated `messageThunk` to utilize the new method for creating assistant messages, replacing previous direct API calls and enhancing the overall message handling process. - Introduced a conversion method to transform shared message formats from the Data API into the renderer's expected format, streamlining message processing and improving code organization. --- .../messageStreaming/StreamingService.ts | 76 ++++++++++++- src/renderer/src/store/thunk/messageThunk.ts | 107 ++++-------------- 2 files changed, 100 insertions(+), 83 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 7e3a7bd28f..bafcb2dbb1 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -21,11 +21,12 @@ import { cacheService } from '@data/CacheService' import { dataApiService } from '@data/DataApiService' import { loggerService } from '@logger' +import type { Model } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' import { isAgentSessionTopicId } from '@renderer/utils/agentSession' import type { CreateMessageDto, UpdateMessageDto } from '@shared/data/api/schemas/messages' -import type { MessageDataBlock, MessageStats } from '@shared/data/types/message' +import type { Message as SharedMessage, MessageDataBlock, MessageStats } from '@shared/data/types/message' import { dbService } from '../db' @@ -80,6 +81,18 @@ interface StartSessionOptions { contextMessages?: Message[] } +/** + * Options for creating an assistant message + */ +interface CreateAssistantMessageOptions { + parentId: string // askId (user message id) + assistantId: string + modelId?: string + model?: Model + siblingsGroupId?: number + traceId?: string +} + /** * StreamingService - Manages streaming message state during generation * @@ -509,8 +522,69 @@ class StreamingService { } } + // ============ Assistant Message Creation ============ + + /** + * Create an assistant message via Data API + * + * The message ID is generated by the server, not locally. + * This method is used for normal topics only (not agent sessions). + * + * @param topicId - Topic ID + * @param options - Creation options including parentId, assistantId, modelId + * @returns Message with server-generated ID in renderer format + */ + async createAssistantMessage(topicId: string, options: CreateAssistantMessageOptions): Promise { + const { parentId, assistantId, modelId, model, siblingsGroupId = 0, traceId } = options + + const createDto: CreateMessageDto = { + parentId, + role: 'assistant', + data: { blocks: [] }, + status: 'pending', + siblingsGroupId, + assistantId, + modelId, + traceId + } + + const sharedMessage = (await dataApiService.post(`/topics/${topicId}/messages`, { + body: createDto + })) as SharedMessage + + logger.debug('Created assistant message via Data API', { topicId, messageId: sharedMessage.id }) + + return this.convertSharedToRendererMessage(sharedMessage, assistantId, model) + } + // ============ Internal Methods ============ + /** + * Convert shared Message format (from Data API) to renderer Message format + * + * For newly created pending messages, blocks are empty. + * + * @param shared - Message from Data API response + * @param assistantId - Assistant ID to include + * @param model - Optional Model object to include + * @returns Renderer-format Message + */ + private convertSharedToRendererMessage(shared: SharedMessage, assistantId: string, model?: Model): Message { + return { + id: shared.id, + topicId: shared.topicId, + role: shared.role, + assistantId, + status: shared.status as AssistantMessageStatus, + blocks: [], // For new pending messages, blocks are empty + createdAt: shared.createdAt, + askId: shared.parentId ?? undefined, + modelId: shared.modelId ?? undefined, + traceId: shared.traceId ?? undefined, + model + } + } + /** * Convert renderer MessageBlock[] to shared MessageDataBlock[] * Removes renderer-specific fields: id, status, messageId diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index de8f9e26d3..cbc7d8b82f 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -15,7 +15,6 @@ * -------------------------------------------------------------------------- */ import { cacheService } from '@data/CacheService' -import { dataApiService } from '@data/DataApiService' import { loggerService } from '@logger' import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter' import { AgentApiClient } from '@renderer/api/agent' @@ -50,8 +49,6 @@ import { } from '@renderer/utils/messageUtils/create' import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue' -import type { CreateMessageDto } from '@shared/data/api/schemas/messages' -import type { Message as SharedMessage } from '@shared/data/types/message' import { IpcChannel } from '@shared/IpcChannel' import { defaultAppHeaders } from '@shared/utils' import type { TextStreamPart } from 'ai' @@ -78,35 +75,6 @@ import { newMessagesActions, selectMessagesForTopic } from '../newMessage' const logger = loggerService.withContext('MessageThunk') -/** - * Convert shared Message format (from Data API) to renderer Message format - * - * The Data API returns messages with `data: { blocks: MessageDataBlock[] }` format, - * but the renderer expects `blocks: string[]` format. - * - * For newly created pending messages, blocks are empty, so conversion is straightforward. - * For messages with content, this would need to store blocks separately and return IDs. - * - * @param shared - Message from Data API response - * @param model - Optional Model object to include - * @returns Renderer-format Message - */ -const convertSharedToRendererMessage = (shared: SharedMessage, assistantId: string, model?: Model): Message => { - return { - id: shared.id, - topicId: shared.topicId, - role: shared.role, - assistantId, - status: shared.status as AssistantMessageStatus, - blocks: [], // For new pending messages, blocks are empty - createdAt: shared.createdAt, - askId: shared.parentId ?? undefined, - modelId: shared.modelId ?? undefined, - traceId: shared.traceId ?? undefined, - model - } -} - const finishTopicLoading = async (topicId: string) => { await waitForTopicQueue(topicId) store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false })) @@ -779,20 +747,15 @@ const dispatchMultiModelResponses = async ( for (const mentionedModel of mentionedModels) { const assistantForThisMention = { ...assistant, model: mentionedModel } - // Create message via Data API instead of local creation - const createDto: CreateMessageDto = { + // Create message via StreamingService + const assistantMessage = await streamingService.createAssistantMessage(topicId, { parentId: triggeringMessage.id, - role: 'assistant', - data: { blocks: [] }, - status: 'pending', - siblingsGroupId, assistantId: assistant.id, modelId: mentionedModel.id, + model: mentionedModel, + siblingsGroupId, traceId: triggeringMessage.traceId ?? undefined - } - - const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) - const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, mentionedModel) + }) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) assistantMessageStubs.push(assistantMessage) @@ -1024,20 +987,15 @@ export const sendMessage = if (mentionedModels && mentionedModels.length > 0) { await dispatchMultiModelResponses(dispatch, getState, topicId, finalUserMessage, assistant, mentionedModels) } else { - // Create message via Data API for normal topics - const createDto: CreateMessageDto = { + // Create message via StreamingService for normal topics + const assistantMessage = await streamingService.createAssistantMessage(topicId, { parentId: finalUserMessage.id, - role: 'assistant', - data: { blocks: [] }, - status: 'pending', - siblingsGroupId: 0, assistantId: assistant.id, modelId: assistant.model?.id, + model: assistant.model, + siblingsGroupId: 0, traceId: finalUserMessage.traceId ?? undefined - } - - const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) - const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, assistant.model) + }) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) @@ -1228,20 +1186,15 @@ export const resendMessageThunk = if (assistantMessagesToReset.length === 0 && !userMessageToResend?.mentions?.length) { // 没有相关的助手消息且没有提及模型时,使用助手模型创建一条消息 - // Create message via Data API - const createDto: CreateMessageDto = { + // Create message via StreamingService + const assistantMessage = await streamingService.createAssistantMessage(topicId, { parentId: userMessageToResend.id, - role: 'assistant', - data: { blocks: [] }, - status: 'pending', - siblingsGroupId: 0, assistantId: assistant.id, modelId: assistant.model?.id, + model: assistant.model, + siblingsGroupId: 0, traceId: userMessageToResend.traceId ?? undefined - } - - const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) - const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, assistant.model) + }) resetDataList.push(assistantMessage) @@ -1277,20 +1230,15 @@ export const resendMessageThunk = const mentionedModelSet = new Set(userMessageToResend.mentions ?? []) const newModelSet = new Set([...mentionedModelSet].filter((m) => !originModelSet.has(m))) for (const model of newModelSet) { - // Create message via Data API for new mentioned models - const createDto: CreateMessageDto = { + // Create message via StreamingService for new mentioned models + const assistantMessage = await streamingService.createAssistantMessage(topicId, { parentId: userMessageToResend.id, - role: 'assistant', - data: { blocks: [] }, - status: 'pending', - siblingsGroupId: 0, assistantId: assistant.id, modelId: model.id, + model, + siblingsGroupId: 0, traceId: userMessageToResend.traceId ?? undefined - } - - const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) - const assistantMessage = convertSharedToRendererMessage(sharedMessage, assistant.id, model) + }) resetDataList.push(assistantMessage) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) @@ -1592,20 +1540,15 @@ export const appendAssistantResponseThunk = return } - // 2. Create the new assistant message via Data API - const createDto: CreateMessageDto = { + // 2. Create the new assistant message via StreamingService + const newAssistantMessageStub = await streamingService.createAssistantMessage(topicId, { parentId: askId, // Crucial: Use the original askId - role: 'assistant', - data: { blocks: [] }, - status: 'pending', - siblingsGroupId: 0, assistantId: assistant.id, modelId: newModel.id, + model: newModel, + siblingsGroupId: 0, traceId: traceId ?? undefined - } - - const sharedMessage = await dataApiService.post(`/topics/${topicId}/messages`, { body: createDto }) - const newAssistantMessageStub = convertSharedToRendererMessage(sharedMessage, assistant.id, newModel) + }) // 3. Update Redux Store const currentTopicMessageIds = getState().messages.messageIdsByTopic[topicId] || [] From 773e9eac32e26eeaf247ceb4656fd217a367e6c3 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 10:24:35 +0800 Subject: [PATCH 090/116] feat: enhance mock services for improved testing capabilities - Updated `CacheService` mock to include TTL support and type-safe memory and shared cache methods, enhancing the accuracy of cache behavior during tests. - Refactored `DataApiService` mock to implement realistic HTTP methods and subscription handling, including retry configuration and request management, improving the fidelity of API interactions in tests. - Enhanced `useDataApi` mocks to align with actual hook signatures, providing a more accurate simulation of data fetching and mutation behaviors, including loading and error states. - Introduced utility functions for managing mock state and triggering subscription callbacks, streamlining the testing process for components relying on these services. --- tests/__mocks__/renderer/CacheService.ts | 584 ++++++++++++++------- tests/__mocks__/renderer/DataApiService.ts | 307 ++++++++--- tests/__mocks__/renderer/useDataApi.ts | 442 +++++++++------- 3 files changed, 895 insertions(+), 438 deletions(-) diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index fbbdc10a25..a66d8ddba6 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -2,15 +2,18 @@ import type { RendererPersistCacheKey, RendererPersistCacheSchema, UseCacheKey, - SharedCacheKey + UseCacheSchema, + SharedCacheKey, + SharedCacheSchema } from '@shared/data/cache/cacheSchemas' import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' -import type { CacheSubscriber } from '@shared/data/cache/cacheTypes' +import type { CacheEntry, CacheSubscriber } from '@shared/data/cache/cacheTypes' import { vi } from 'vitest' /** * Mock CacheService for testing * Provides a comprehensive mock of the three-layer cache system + * Matches the actual CacheService interface from src/renderer/src/data/CacheService.ts */ /** @@ -18,19 +21,34 @@ import { vi } from 'vitest' */ export const createMockCacheService = ( options: { - initialMemoryCache?: Map - initialSharedCache?: Map + initialMemoryCache?: Map + initialSharedCache?: Map initialPersistCache?: Map } = {} ) => { - // Mock cache storage - const memoryCache = new Map(options.initialMemoryCache || []) - const sharedCache = new Map(options.initialSharedCache || []) + // Mock cache storage with CacheEntry structure (includes TTL support) + const memoryCache = new Map(options.initialMemoryCache || []) + const sharedCache = new Map(options.initialSharedCache || []) const persistCache = new Map(options.initialPersistCache || []) + // Active hooks tracking + const activeHooks = new Set() + // Mock subscribers const subscribers = new Map>() + // Shared cache ready state + let sharedCacheReady = true + const sharedCacheReadyCallbacks: Array<() => void> = [] + + // Helper function to check TTL expiration + const isExpired = (entry: CacheEntry): boolean => { + if (entry.expireAt && Date.now() > entry.expireAt) { + return true + } + return false + } + // Helper function to notify subscribers const notifySubscribers = (key: string) => { const keySubscribers = subscribers.get(key) @@ -46,80 +64,228 @@ export const createMockCacheService = ( } const mockCacheService = { - // Memory cache methods - get: vi.fn((key: string): T | null => { - if (memoryCache.has(key)) { - return memoryCache.get(key) as T - } - // Return default values for known cache keys - const defaultValue = getDefaultValueForKey(key) - return defaultValue !== undefined ? defaultValue : null - }), + // ============ Memory Cache (Type-safe) ============ - set: vi.fn((key: string, value: T): void => { - const oldValue = memoryCache.get(key) - memoryCache.set(key, value) - if (oldValue !== value) { + get: vi.fn((key: K): UseCacheSchema[K] => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return DefaultUseCache[key] + } + if (isExpired(entry)) { + memoryCache.delete(key) notifySubscribers(key) + return DefaultUseCache[key] } + return entry.value }), - delete: vi.fn((key: string): boolean => { + set: vi.fn((key: K, value: UseCacheSchema[K], ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + memoryCache.set(key, entry) + notifySubscribers(key) + }), + + has: vi.fn((key: K): boolean => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + delete: vi.fn((key: K): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useCache hook`) + return false + } const existed = memoryCache.has(key) memoryCache.delete(key) if (existed) { notifySubscribers(key) } - return existed + return true }), - clear: vi.fn((): void => { - const keys = Array.from(memoryCache.keys()) - memoryCache.clear() - keys.forEach((key) => notifySubscribers(key)) + hasTTL: vi.fn((key: K): boolean => { + const entry = memoryCache.get(key) + return entry?.expireAt !== undefined }), - has: vi.fn((key: string): boolean => { - return memoryCache.has(key) - }), + // ============ Memory Cache (Casual - Dynamic Keys) ============ - size: vi.fn((): number => { - return memoryCache.size - }), - - // Shared cache methods - getShared: vi.fn((key: string): T | null => { - if (sharedCache.has(key)) { - return sharedCache.get(key) as T + getCasual: vi.fn((key: string): T | undefined => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return undefined } - const defaultValue = getDefaultSharedValueForKey(key) - return defaultValue !== undefined ? defaultValue : null - }), - - setShared: vi.fn((key: string, value: T): void => { - const oldValue = sharedCache.get(key) - sharedCache.set(key, value) - if (oldValue !== value) { - notifySubscribers(`shared:${key}`) + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return undefined } + return entry.value as T }), - deleteShared: vi.fn((key: string): boolean => { + setCasual: vi.fn((key: string, value: T, ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + memoryCache.set(key, entry) + notifySubscribers(key) + }), + + hasCasual: vi.fn((key: string): boolean => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + memoryCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteCasual: vi.fn((key: string): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useCache hook`) + return false + } + const existed = memoryCache.has(key) + memoryCache.delete(key) + if (existed) { + notifySubscribers(key) + } + return true + }), + + hasTTLCasual: vi.fn((key: string): boolean => { + const entry = memoryCache.get(key) + return entry?.expireAt !== undefined + }), + + // ============ Shared Cache (Type-safe) ============ + + getShared: vi.fn((key: K): SharedCacheSchema[K] | undefined => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return DefaultSharedCache[key] + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return DefaultSharedCache[key] + } + return entry.value + }), + + setShared: vi.fn((key: K, value: SharedCacheSchema[K], ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + sharedCache.set(key, entry) + notifySubscribers(key) + }), + + hasShared: vi.fn((key: K): boolean => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteShared: vi.fn((key: K): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`) + return false + } const existed = sharedCache.has(key) sharedCache.delete(key) if (existed) { - notifySubscribers(`shared:${key}`) + notifySubscribers(key) } - return existed + return true }), - clearShared: vi.fn((): void => { - const keys = Array.from(sharedCache.keys()) - sharedCache.clear() - keys.forEach((key) => notifySubscribers(`shared:${key}`)) + hasSharedTTL: vi.fn((key: K): boolean => { + const entry = sharedCache.get(key) + return entry?.expireAt !== undefined }), - // Persist cache methods + // ============ Shared Cache (Casual - Dynamic Keys) ============ + + getSharedCasual: vi.fn((key: string): T | undefined => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return undefined + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return undefined + } + return entry.value as T + }), + + setSharedCasual: vi.fn((key: string, value: T, ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + sharedCache.set(key, entry) + notifySubscribers(key) + }), + + hasSharedCasual: vi.fn((key: string): boolean => { + const entry = sharedCache.get(key) + if (entry === undefined) { + return false + } + if (isExpired(entry)) { + sharedCache.delete(key) + notifySubscribers(key) + return false + } + return true + }), + + deleteSharedCasual: vi.fn((key: string): boolean => { + if (activeHooks.has(key)) { + console.error(`Cannot delete key "${key}" as it's being used by useSharedCache hook`) + return false + } + const existed = sharedCache.has(key) + sharedCache.delete(key) + if (existed) { + notifySubscribers(key) + } + return true + }), + + hasSharedTTLCasual: vi.fn((key: string): boolean => { + const entry = sharedCache.get(key) + return entry?.expireAt !== undefined + }), + + // ============ Persist Cache ============ + getPersist: vi.fn((key: K): RendererPersistCacheSchema[K] => { if (persistCache.has(key)) { return persistCache.get(key) as RendererPersistCacheSchema[K] @@ -128,29 +294,46 @@ export const createMockCacheService = ( }), setPersist: vi.fn((key: K, value: RendererPersistCacheSchema[K]): void => { - const oldValue = persistCache.get(key) persistCache.set(key, value) - if (oldValue !== value) { - notifySubscribers(`persist:${key}`) + notifySubscribers(key) + }), + + hasPersist: vi.fn((key: RendererPersistCacheKey): boolean => { + return persistCache.has(key) + }), + + // ============ Hook Reference Management ============ + + registerHook: vi.fn((key: string): void => { + activeHooks.add(key) + }), + + unregisterHook: vi.fn((key: string): void => { + activeHooks.delete(key) + }), + + // ============ Shared Cache Ready State ============ + + isSharedCacheReady: vi.fn((): boolean => { + return sharedCacheReady + }), + + onSharedCacheReady: vi.fn((callback: () => void): (() => void) => { + if (sharedCacheReady) { + callback() + return () => {} + } + sharedCacheReadyCallbacks.push(callback) + return () => { + const idx = sharedCacheReadyCallbacks.indexOf(callback) + if (idx >= 0) { + sharedCacheReadyCallbacks.splice(idx, 1) + } } }), - deletePersist: vi.fn((key: K): boolean => { - const existed = persistCache.has(key) - persistCache.delete(key) - if (existed) { - notifySubscribers(`persist:${key}`) - } - return existed - }), + // ============ Subscription Management ============ - clearPersist: vi.fn((): void => { - const keys = Array.from(persistCache.keys()) as RendererPersistCacheKey[] - persistCache.clear() - keys.forEach((key) => notifySubscribers(`persist:${key}`)) - }), - - // Subscription methods subscribe: vi.fn((key: string, callback: CacheSubscriber): (() => void) => { if (!subscribers.has(key)) { subscribers.set(key, new Set()) @@ -169,78 +352,52 @@ export const createMockCacheService = ( } }), - unsubscribe: vi.fn((key: string, callback?: CacheSubscriber): void => { - if (callback) { - const keySubscribers = subscribers.get(key) - if (keySubscribers) { - keySubscribers.delete(callback) - if (keySubscribers.size === 0) { - subscribers.delete(key) - } - } - } else { - subscribers.delete(key) - } + notifySubscribers: vi.fn((key: string): void => { + notifySubscribers(key) }), - // Hook reference tracking (for advanced cache management) - addHookReference: vi.fn((): void => { - // Mock implementation - in real service this prevents cache cleanup + // ============ Lifecycle ============ + + cleanup: vi.fn((): void => { + memoryCache.clear() + sharedCache.clear() + persistCache.clear() + activeHooks.clear() + subscribers.clear() }), - removeHookReference: vi.fn((): void => { - // Mock implementation - }), + // ============ Internal State Access for Testing ============ - // Utility methods - getAllKeys: vi.fn((): string[] => { - return Array.from(memoryCache.keys()) - }), - - getStats: vi.fn(() => ({ - memorySize: memoryCache.size, - sharedSize: sharedCache.size, - persistSize: persistCache.size, - subscriberCount: subscribers.size - })), - - // Internal state access for testing _getMockState: () => ({ memoryCache: new Map(memoryCache), sharedCache: new Map(sharedCache), persistCache: new Map(persistCache), - subscribers: new Map(subscribers) + activeHooks: new Set(activeHooks), + subscribers: new Map(subscribers), + sharedCacheReady }), _resetMockState: () => { memoryCache.clear() sharedCache.clear() persistCache.clear() + activeHooks.clear() subscribers.clear() + sharedCacheReady = true + }, + + _setSharedCacheReady: (ready: boolean) => { + sharedCacheReady = ready + if (ready) { + sharedCacheReadyCallbacks.forEach((cb) => cb()) + sharedCacheReadyCallbacks.length = 0 + } } } return mockCacheService } -/** - * Get default value for cache keys based on schema - */ -function getDefaultValueForKey(key: string): any { - // Try to match against known cache schemas - if (key in DefaultUseCache) { - return DefaultUseCache[key as UseCacheKey] - } - return undefined -} - -function getDefaultSharedValueForKey(key: string): any { - if (key in DefaultSharedCache) { - return DefaultSharedCache[key as SharedCacheKey] - } - return undefined -} - // Default mock instance export const mockCacheService = createMockCacheService() @@ -251,47 +408,91 @@ export const MockCacheService = { return mockCacheService } - // Delegate all methods to the mock - get(key: string): T | null { - return mockCacheService.get(key) as T | null + // ============ Memory Cache (Type-safe) ============ + get(key: K): UseCacheSchema[K] { + return mockCacheService.get(key) } - set(key: string, value: T): void { - return mockCacheService.set(key, value) + set(key: K, value: UseCacheSchema[K], ttl?: number): void { + return mockCacheService.set(key, value, ttl) } - delete(key: string): boolean { - return mockCacheService.delete(key) - } - - clear(): void { - return mockCacheService.clear() - } - - has(key: string): boolean { + has(key: K): boolean { return mockCacheService.has(key) } - size(): number { - return mockCacheService.size() + delete(key: K): boolean { + return mockCacheService.delete(key) } - getShared(key: string): T | null { - return mockCacheService.getShared(key) as T | null + hasTTL(key: K): boolean { + return mockCacheService.hasTTL(key) } - setShared(key: string, value: T): void { - return mockCacheService.setShared(key, value) + // ============ Memory Cache (Casual) ============ + getCasual(key: string): T | undefined { + return mockCacheService.getCasual(key) as T | undefined } - deleteShared(key: string): boolean { + setCasual(key: string, value: T, ttl?: number): void { + return mockCacheService.setCasual(key, value, ttl) + } + + hasCasual(key: string): boolean { + return mockCacheService.hasCasual(key) + } + + deleteCasual(key: string): boolean { + return mockCacheService.deleteCasual(key) + } + + hasTTLCasual(key: string): boolean { + return mockCacheService.hasTTLCasual(key) + } + + // ============ Shared Cache (Type-safe) ============ + getShared(key: K): SharedCacheSchema[K] | undefined { + return mockCacheService.getShared(key) + } + + setShared(key: K, value: SharedCacheSchema[K], ttl?: number): void { + return mockCacheService.setShared(key, value, ttl) + } + + hasShared(key: K): boolean { + return mockCacheService.hasShared(key) + } + + deleteShared(key: K): boolean { return mockCacheService.deleteShared(key) } - clearShared(): void { - return mockCacheService.clearShared() + hasSharedTTL(key: K): boolean { + return mockCacheService.hasSharedTTL(key) } + // ============ Shared Cache (Casual) ============ + getSharedCasual(key: string): T | undefined { + return mockCacheService.getSharedCasual(key) as T | undefined + } + + setSharedCasual(key: string, value: T, ttl?: number): void { + return mockCacheService.setSharedCasual(key, value, ttl) + } + + hasSharedCasual(key: string): boolean { + return mockCacheService.hasSharedCasual(key) + } + + deleteSharedCasual(key: string): boolean { + return mockCacheService.deleteSharedCasual(key) + } + + hasSharedTTLCasual(key: string): boolean { + return mockCacheService.hasSharedTTLCasual(key) + } + + // ============ Persist Cache ============ getPersist(key: K): RendererPersistCacheSchema[K] { return mockCacheService.getPersist(key) } @@ -300,36 +501,40 @@ export const MockCacheService = { return mockCacheService.setPersist(key, value) } - deletePersist(key: K): boolean { - return mockCacheService.deletePersist(key) + hasPersist(key: RendererPersistCacheKey): boolean { + return mockCacheService.hasPersist(key) } - clearPersist(): void { - return mockCacheService.clearPersist() + // ============ Hook Reference Management ============ + registerHook(key: string): void { + return mockCacheService.registerHook(key) } + unregisterHook(key: string): void { + return mockCacheService.unregisterHook(key) + } + + // ============ Ready State ============ + isSharedCacheReady(): boolean { + return mockCacheService.isSharedCacheReady() + } + + onSharedCacheReady(callback: () => void): () => void { + return mockCacheService.onSharedCacheReady(callback) + } + + // ============ Subscription ============ subscribe(key: string, callback: CacheSubscriber): () => void { return mockCacheService.subscribe(key, callback) } - unsubscribe(key: string, callback?: CacheSubscriber): void { - return mockCacheService.unsubscribe(key, callback) + notifySubscribers(key: string): void { + return mockCacheService.notifySubscribers(key) } - addHookReference(): void { - return mockCacheService.addHookReference() - } - - removeHookReference(): void { - return mockCacheService.removeHookReference() - } - - getAllKeys(): string[] { - return mockCacheService.getAllKeys() - } - - getStats() { - return mockCacheService.getStats() + // ============ Lifecycle ============ + cleanup(): void { + return mockCacheService.cleanup() } }, cacheService: mockCacheService @@ -349,7 +554,7 @@ export const MockCacheUtils = { } }) if ('_resetMockState' in mockCacheService) { - ;(mockCacheService as any)._resetMockState() + mockCacheService._resetMockState() } }, @@ -357,33 +562,52 @@ export const MockCacheUtils = { * Set initial cache state for testing */ setInitialState: (state: { - memory?: Array<[string, any]> - shared?: Array<[string, any]> + memory?: Array<[string, any, number?]> // [key, value, ttl?] + shared?: Array<[string, any, number?]> persist?: Array<[RendererPersistCacheKey, any]> }) => { - if ('_resetMockState' in mockCacheService) { - ;(mockCacheService as any)._resetMockState() - } + mockCacheService._resetMockState() - state.memory?.forEach(([key, value]) => mockCacheService.set(key, value)) - state.shared?.forEach(([key, value]) => mockCacheService.setShared(key, value)) - state.persist?.forEach(([key, value]) => mockCacheService.setPersist(key, value)) + state.memory?.forEach(([key, value, ttl]) => { + mockCacheService.setCasual(key, value, ttl) + }) + state.shared?.forEach(([key, value, ttl]) => { + mockCacheService.setSharedCasual(key, value, ttl) + }) + state.persist?.forEach(([key, value]) => { + mockCacheService.setPersist(key, value) + }) }, /** * Get current mock state for inspection */ getCurrentState: () => { - if ('_getMockState' in mockCacheService) { - return (mockCacheService as any)._getMockState() - } - return null + return mockCacheService._getMockState() }, /** * Simulate cache events for testing subscribers */ - triggerCacheChange: (key: string, value: any) => { - mockCacheService.set(key, value) + triggerCacheChange: (key: string, value: any, ttl?: number) => { + mockCacheService.setCasual(key, value, ttl) + }, + + /** + * Set shared cache ready state for testing + */ + setSharedCacheReady: (ready: boolean) => { + mockCacheService._setSharedCacheReady(ready) + }, + + /** + * Simulate TTL expiration by manipulating cache entries + */ + simulateTTLExpiration: (key: string) => { + const state = mockCacheService._getMockState() + const entry = state.memoryCache.get(key) || state.sharedCache.get(key) + if (entry) { + entry.expireAt = Date.now() - 1000 // Set to expired + } } } diff --git a/tests/__mocks__/renderer/DataApiService.ts b/tests/__mocks__/renderer/DataApiService.ts index e1bc58ed36..da1c04b415 100644 --- a/tests/__mocks__/renderer/DataApiService.ts +++ b/tests/__mocks__/renderer/DataApiService.ts @@ -1,63 +1,21 @@ -import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { DataResponse } from '@shared/data/api/apiTypes' +import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' +import type { SubscriptionCallback, SubscriptionOptions } from '@shared/data/api/apiTypes' +import { SubscriptionEvent } from '@shared/data/api/apiTypes' import { vi } from 'vitest' /** * Mock DataApiService for testing * Provides a comprehensive mock of the DataApiService with realistic behavior + * Matches the actual DataApiService interface from src/renderer/src/data/DataApiService.ts */ -// Mock response utilities -const createMockResponse = (data: T, success = true): DataResponse => ({ - id: 'mock-id', - status: success ? 200 : 500, - data, - ...(success ? {} : { error: { code: 'MOCK_ERROR', message: 'Mock error', details: {}, status: 500 } }) -}) - -const createMockError = (message: string): DataResponse => ({ - id: 'mock-error-id', - status: 500, - error: { - code: 'MOCK_ERROR', - message, - details: {}, - status: 500 - } -}) - /** - * Mock implementation of DataApiService + * Retry options interface (matches actual) */ -export const createMockDataApiService = (customBehavior: Partial = {}): ApiClient => { - const mockService: ApiClient = { - // HTTP Methods - get: vi.fn(async (path: ConcreteApiPaths) => { - // Default mock behavior - return raw data based on path - return getMockDataForPath(path, 'GET') as any - }), - - post: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'POST') as any - }), - - put: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'PUT') as any - }), - - patch: vi.fn(async (path: ConcreteApiPaths) => { - return getMockDataForPath(path, 'PATCH') as any - }), - - delete: vi.fn(async () => { - return { deleted: true } as any - }), - - // Apply custom behavior overrides - ...customBehavior - } - - return mockService +interface RetryOptions { + maxRetries: number + retryDelay: number + backoffMultiplier: number } /** @@ -136,6 +94,157 @@ function getMockDataForPath(path: ConcreteApiPaths, method: string): any { } } +/** + * Create a mock DataApiService with realistic behavior + */ +export const createMockDataApiService = (customBehavior: Partial> = {}) => { + // Track subscriptions + const subscriptions = new Map< + string, + { + callback: SubscriptionCallback + options: SubscriptionOptions + } + >() + + // Retry configuration + let retryOptions: RetryOptions = { + maxRetries: 2, + retryDelay: 1000, + backoffMultiplier: 2 + } + + const mockService = { + // ============ HTTP Methods ============ + + get: vi.fn( + async ( + path: TPath, + _options?: { query?: any; headers?: Record } + ) => { + return getMockDataForPath(path, 'GET') + } + ), + + post: vi.fn( + async ( + path: TPath, + _options: { body?: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'POST') + } + ), + + put: vi.fn( + async ( + path: TPath, + _options: { body: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'PUT') + } + ), + + patch: vi.fn( + async ( + path: TPath, + _options: { body?: any; query?: Record; headers?: Record } + ) => { + return getMockDataForPath(path, 'PATCH') + } + ), + + delete: vi.fn( + async ( + _path: TPath, + _options?: { query?: Record; headers?: Record } + ) => { + return { deleted: true } + } + ), + + // ============ Subscription ============ + + subscribe: vi.fn((options: SubscriptionOptions, callback: SubscriptionCallback): (() => void) => { + const subscriptionId = `sub_${Date.now()}_${Math.random()}` + + subscriptions.set(subscriptionId, { + callback: callback as SubscriptionCallback, + options + }) + + // Return unsubscribe function + return () => { + subscriptions.delete(subscriptionId) + } + }), + + // ============ Retry Configuration ============ + + configureRetry: vi.fn((options: Partial): void => { + retryOptions = { + ...retryOptions, + ...options + } + }), + + getRetryConfig: vi.fn((): RetryOptions => { + return { ...retryOptions } + }), + + // ============ Request Management (Deprecated) ============ + + /** + * @deprecated This method has no effect with direct IPC + */ + cancelRequest: vi.fn((_requestId: string): void => { + // No-op - direct IPC requests cannot be cancelled + }), + + /** + * @deprecated This method has no effect with direct IPC + */ + cancelAllRequests: vi.fn((): void => { + // No-op - direct IPC requests cannot be cancelled + }), + + // ============ Statistics ============ + + getRequestStats: vi.fn(() => ({ + pendingRequests: 0, + activeSubscriptions: subscriptions.size + })), + + // ============ Internal State Access for Testing ============ + + _getMockState: () => ({ + subscriptions: new Map(subscriptions), + retryOptions: { ...retryOptions } + }), + + _resetMockState: () => { + subscriptions.clear() + retryOptions = { + maxRetries: 2, + retryDelay: 1000, + backoffMultiplier: 2 + } + }, + + _triggerSubscription: (path: string, data: any, event: SubscriptionEvent) => { + subscriptions.forEach(({ callback, options }) => { + if (options.path === path) { + callback(data, event) + } + }) + }, + + // Apply custom behavior overrides + ...customBehavior + } + + return mockService +} + // Default mock instance export const mockDataApiService = createMockDataApiService() @@ -146,26 +255,69 @@ export const MockDataApiService = { return mockDataApiService } - // Instance methods delegate to the mock - async get(path: ConcreteApiPaths, options?: any) { + // ============ HTTP Methods ============ + async get( + path: TPath, + options?: { query?: any; headers?: Record } + ) { return mockDataApiService.get(path, options) } - async post(path: ConcreteApiPaths, options?: any) { + async post( + path: TPath, + options: { body?: any; query?: Record; headers?: Record } + ) { return mockDataApiService.post(path, options) } - async put(path: ConcreteApiPaths, options?: any) { + async put( + path: TPath, + options: { body: any; query?: Record; headers?: Record } + ) { return mockDataApiService.put(path, options) } - async patch(path: ConcreteApiPaths, options?: any) { + async patch( + path: TPath, + options: { body?: any; query?: Record; headers?: Record } + ) { return mockDataApiService.patch(path, options) } - async delete(path: ConcreteApiPaths, options?: any) { + async delete( + path: TPath, + options?: { query?: Record; headers?: Record } + ) { return mockDataApiService.delete(path, options) } + + // ============ Subscription ============ + subscribe(options: SubscriptionOptions, callback: SubscriptionCallback): () => void { + return mockDataApiService.subscribe(options, callback) + } + + // ============ Retry Configuration ============ + configureRetry(options: Partial): void { + return mockDataApiService.configureRetry(options) + } + + getRetryConfig(): RetryOptions { + return mockDataApiService.getRetryConfig() + } + + // ============ Request Management ============ + cancelRequest(requestId: string): void { + return mockDataApiService.cancelRequest(requestId) + } + + cancelAllRequests(): void { + return mockDataApiService.cancelAllRequests() + } + + // ============ Statistics ============ + getRequestStats() { + return mockDataApiService.getRequestStats() + } }, dataApiService: mockDataApiService } @@ -183,20 +335,20 @@ export const MockDataApiUtils = { method.mockClear() } }) + mockDataApiService._resetMockState() }, /** * Set custom response for a specific path and method */ - setCustomResponse: (path: ConcreteApiPaths, method: string, response: any) => { - const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + setCustomResponse: (path: ConcreteApiPaths, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', response: any) => { + const methodFn = mockDataApiService[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'] if (vi.isMockFunction(methodFn)) { methodFn.mockImplementation(async (requestPath: string) => { if (requestPath === path) { - return createMockResponse(response) + return response } - // Fall back to default behavior - return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + return getMockDataForPath(requestPath as ConcreteApiPaths, method) }) } }, @@ -204,15 +356,14 @@ export const MockDataApiUtils = { /** * Set error response for a specific path and method */ - setErrorResponse: (path: ConcreteApiPaths, method: string, errorMessage: string) => { - const methodFn = mockDataApiService[method.toLowerCase() as keyof ApiClient] as any + setErrorResponse: (path: ConcreteApiPaths, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', error: Error) => { + const methodFn = mockDataApiService[method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'] if (vi.isMockFunction(methodFn)) { methodFn.mockImplementation(async (requestPath: string) => { if (requestPath === path) { - return createMockError(errorMessage) + throw error } - // Fall back to default behavior - return createMockResponse(getMockDataForPath(requestPath as ConcreteApiPaths, method)) + return getMockDataForPath(requestPath as ConcreteApiPaths, method) }) } }, @@ -220,16 +371,30 @@ export const MockDataApiUtils = { /** * Get call count for a specific method */ - getCallCount: (method: keyof ApiClient): number => { - const methodFn = mockDataApiService[method] as any + getCallCount: (method: 'get' | 'post' | 'put' | 'patch' | 'delete'): number => { + const methodFn = mockDataApiService[method] return vi.isMockFunction(methodFn) ? methodFn.mock.calls.length : 0 }, /** * Get calls for a specific method */ - getCalls: (method: keyof ApiClient): any[] => { - const methodFn = mockDataApiService[method] as any + getCalls: (method: 'get' | 'post' | 'put' | 'patch' | 'delete'): any[] => { + const methodFn = mockDataApiService[method] return vi.isMockFunction(methodFn) ? methodFn.mock.calls : [] + }, + + /** + * Trigger a subscription callback for testing + */ + triggerSubscription: (path: string, data: any, event: SubscriptionEvent = SubscriptionEvent.UPDATED) => { + mockDataApiService._triggerSubscription(path, data, event) + }, + + /** + * Get current mock state + */ + getCurrentState: () => { + return mockDataApiService._getMockState() } } diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 53048738c8..a1af44d41c 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -1,38 +1,14 @@ -import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' +import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' +import type { ConcreteApiPaths, PaginatedResponse } from '@shared/data/api/apiTypes' +import type { KeyedMutator } from 'swr' import { vi } from 'vitest' /** * Mock useDataApi hooks for testing * Provides comprehensive mocks for all data API hooks with realistic SWR-like behavior + * Matches the actual interface from src/renderer/src/data/hooks/useDataApi.ts */ -// Mock SWR response interface -interface MockSWRResponse { - data?: T - error?: Error - isLoading: boolean - isValidating: boolean - mutate: (data?: T | Promise | ((data: T) => T)) => Promise -} - -// Mock mutation response interface -interface MockMutationResponse { - data?: T - error?: Error - isMutating: boolean - trigger: (...args: any[]) => Promise - reset: () => void -} - -// Mock paginated response interface -interface MockPaginatedResponse extends MockSWRResponse> { - loadMore: () => void - isLoadingMore: boolean - hasMore: boolean - items: T[] -} - /** * Create mock data based on API path */ @@ -70,98 +46,121 @@ function createMockDataForPath(path: ConcreteApiPaths): any { /** * Mock useQuery hook + * Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate } */ export const mockUseQuery = vi.fn( - (path: TPath | null, _query?: any, options?: any): MockSWRResponse => { - const isLoading = options?.initialLoading ?? false - const hasError = options?.shouldError ?? false - - if (hasError) { + ( + path: TPath, + options?: { + query?: QueryParamsForPath + enabled?: boolean + swrOptions?: any + } + ): { + data?: ResponseForPath + loading: boolean + error?: Error + refetch: () => void + mutate: KeyedMutator> + } => { + // Check if query is disabled + if (options?.enabled === false) { return { data: undefined, - error: new Error(`Mock error for ${path}`), - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator> } } - const mockData = path ? createMockDataForPath(path) : undefined + const mockData = createMockDataForPath(path) return { - data: mockData, + data: mockData as ResponseForPath, + loading: false, error: undefined, - isLoading, - isValidating: false, - mutate: vi.fn().mockResolvedValue(mockData) + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator> } } ) /** * Mock useMutation hook + * Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error } */ export const mockUseMutation = vi.fn( ( - path: TPath, method: TMethod, - options?: any - ): MockMutationResponse => { - const isMutating = options?.initialMutating ?? false - const hasError = options?.shouldError ?? false - - const mockTrigger = vi.fn(async (...args: any[]) => { - if (hasError) { - throw new Error(`Mock mutation error for ${method} ${path}`) + _path: TPath, + _options?: { + onSuccess?: (data: ResponseForPath) => void + onError?: (error: Error) => void + revalidate?: boolean | string[] + optimistic?: boolean + optimisticData?: ResponseForPath + } + ): { + mutate: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + loading: boolean + error: Error | undefined + } => { + const mockMutate = vi.fn( + async (_data?: { body?: BodyForPath; query?: QueryParamsForPath }) => { + // Simulate different responses based on method + switch (method) { + case 'POST': + return { id: 'new_item', created: true } as ResponseForPath + case 'PUT': + case 'PATCH': + return { id: 'updated_item', updated: true } as ResponseForPath + case 'DELETE': + return { deleted: true } as ResponseForPath + default: + return { success: true } as ResponseForPath + } } - - // Simulate different responses based on method - switch (method) { - case 'POST': - return { id: 'new_item', created: true, ...args[0] } - case 'PUT': - case 'PATCH': - return { id: 'updated_item', updated: true, ...args[0] } - case 'DELETE': - return { deleted: true } - default: - return { success: true } - } - }) + ) return { - data: undefined, - error: undefined, - isMutating, - trigger: mockTrigger, - reset: vi.fn() + mutate: mockMutate, + loading: false, + error: undefined } } ) /** * Mock usePaginatedQuery hook + * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset } */ export const mockUsePaginatedQuery = vi.fn( - (path: TPath | null, _query?: any, options?: any): MockPaginatedResponse => { - const isLoading = options?.initialLoading ?? false - const isLoadingMore = options?.initialLoadingMore ?? false - const hasError = options?.shouldError ?? false - - if (hasError) { - return { - data: undefined, - error: new Error(`Mock paginated error for ${path}`), - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined), - loadMore: vi.fn(), - isLoadingMore: false, - hasMore: false, - items: [] - } + ( + path: TPath, + _options?: { + query?: Omit, 'page' | 'limit'> + limit?: number + swrOptions?: any } - + ): ResponseForPath extends PaginatedResponse + ? { + items: T[] + total: number + page: number + loading: boolean + error?: Error + hasMore: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void + } + : never => { const mockItems = path ? [ { id: 'item1', name: 'Mock Item 1' }, @@ -170,52 +169,61 @@ export const mockUsePaginatedQuery = vi.fn( ] : [] - const mockData: PaginatedResponse = { + return { items: mockItems, total: mockItems.length, page: 1, - pageCount: 1, - hasNext: false, - hasPrev: false - } - - return { - data: mockData, + loading: false, error: undefined, - isLoading, - isValidating: false, - mutate: vi.fn().mockResolvedValue(mockData), - loadMore: vi.fn(), - isLoadingMore, - hasMore: mockData.hasNext, - items: mockItems - } + hasMore: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } as unknown as ResponseForPath extends PaginatedResponse + ? { + items: T[] + total: number + page: number + loading: boolean + error?: Error + hasMore: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void + } + : never } ) /** * Mock useInvalidateCache hook + * Matches actual signature: useInvalidateCache() => (keys?) => Promise */ -export const mockUseInvalidateCache = vi.fn(() => { - return { - invalidate: vi.fn(async () => { - // Mock cache invalidation - return Promise.resolve() - }), - invalidateAll: vi.fn(async () => { - // Mock invalidate all caches - return Promise.resolve() - }) - } +export const mockUseInvalidateCache = vi.fn((): ((keys?: string | string[] | boolean) => Promise) => { + const invalidate = vi.fn(async (_keys?: string | string[] | boolean) => { + return Promise.resolve() + }) + return invalidate }) /** * Mock prefetch function + * Matches actual signature: prefetch(path, options?) => Promise> */ -export const mockPrefetch = vi.fn(async (_path: TPath): Promise => { - // Mock prefetch - return mock data - return createMockDataForPath(_path) -}) +export const mockPrefetch = vi.fn( + async ( + path: TPath, + _options?: { + query?: QueryParamsForPath + } + ): Promise> => { + return createMockDataForPath(path) as ResponseForPath + } +) /** * Export all mocks as a unified module @@ -246,27 +254,26 @@ export const MockUseDataApiUtils = { /** * Set up useQuery to return specific data */ - mockQueryData: (path: ConcreteApiPaths, data: T) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockQueryData: (path: TPath, data: ResponseForPath) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data, + loading: false, error: undefined, - isLoading: false, - isValidating: false, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(data) } } // Default behavior for other paths - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -274,25 +281,24 @@ export const MockUseDataApiUtils = { * Set up useQuery to return loading state */ mockQueryLoading: (path: ConcreteApiPaths) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, + loading: true, error: undefined, - isLoading: true, - isValidating: true, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) } } - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -300,77 +306,139 @@ export const MockUseDataApiUtils = { * Set up useQuery to return error state */ mockQueryError: (path: ConcreteApiPaths, error: Error) => { - mockUseQuery.mockImplementation((queryPath, query, options) => { + mockUseQuery.mockImplementation((queryPath, _options) => { if (queryPath === path) { return { data: undefined, + loading: false, error, - isLoading: false, - isValidating: false, + refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) } } - return ( - mockUseQuery.getMockImplementation()?.(queryPath, query, options) || { - data: undefined, - error: undefined, - isLoading: false, - isValidating: false, - mutate: vi.fn().mockResolvedValue(undefined) - } - ) + const defaultData = createMockDataForPath(queryPath) + return { + data: defaultData, + loading: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, /** - * Set up useMutation to simulate success + * Set up useMutation to simulate success with specific result */ - mockMutationSuccess: (path: ConcreteApiPaths, method: string, result: T) => { - mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + mockMutationSuccess: ( + method: TMethod, + path: TPath, + result: ResponseForPath + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue(result), - reset: vi.fn() + mutate: vi.fn().mockResolvedValue(result), + loading: false, + error: undefined } } - return ( - mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue({}), - reset: vi.fn() - } - ) + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } }) }, /** * Set up useMutation to simulate error */ - mockMutationError: (path: ConcreteApiPaths, method: string, error: Error) => { - mockUseMutation.mockImplementation((mutationPath, mutationMethod, options) => { + mockMutationError: ( + method: TMethod, + path: ConcreteApiPaths, + error: Error + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - data: undefined, + mutate: vi.fn().mockRejectedValue(error), + loading: false, + error: undefined + } + } + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } + }) + }, + + /** + * Set up useMutation to be in loading state + */ + mockMutationLoading: ( + method: TMethod, + path: ConcreteApiPaths + ) => { + mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { + if (mutationPath === path && mutationMethod === method) { + return { + mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + loading: true, + error: undefined + } + } + // Default behavior + return { + mutate: vi.fn().mockResolvedValue({ success: true }), + loading: false, + error: undefined + } + }) + }, + + /** + * Set up usePaginatedQuery to return specific items + */ + mockPaginatedData: ( + path: TPath, + items: any[], + options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean } + ) => { + mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { + if (queryPath === path) { + return { + items, + total: options?.total ?? items.length, + page: options?.page ?? 1, + loading: false, error: undefined, - isMutating: false, - trigger: vi.fn().mockRejectedValue(error), + hasMore: options?.hasMore ?? false, + hasPrev: options?.hasPrev ?? false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), reset: vi.fn() } } - return ( - mockUseMutation.getMockImplementation()?.(mutationPath, mutationMethod, options) || { - data: undefined, - error: undefined, - isMutating: false, - trigger: vi.fn().mockResolvedValue({}), - reset: vi.fn() - } - ) + // Default behavior + return { + items: [], + total: 0, + page: 1, + loading: false, + error: undefined, + hasMore: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } }) } } From f36ad573f5e152ac60bd2561a8fd689d0ede4ca8 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 10:33:01 +0800 Subject: [PATCH 091/116] feat: add shared cache functionality to MockMainCacheService - Introduced methods for managing a shared cache, including `getShared`, `setShared`, `hasShared`, and `deleteShared`, enhancing testing capabilities for shared data scenarios. - Implemented utility functions for shared cache operations, such as `setSharedCacheValue`, `getSharedCacheValue`, and `getAllSharedCacheEntries`, to facilitate easier testing and state management. - Updated cache statistics to include shared cache entries, improving visibility into cache usage during tests. --- tests/__mocks__/main/CacheService.ts | 125 ++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/tests/__mocks__/main/CacheService.ts b/tests/__mocks__/main/CacheService.ts index 3f453853e8..fa0f9567b3 100644 --- a/tests/__mocks__/main/CacheService.ts +++ b/tests/__mocks__/main/CacheService.ts @@ -1,3 +1,4 @@ +import type { SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' import { vi } from 'vitest' @@ -9,6 +10,9 @@ import { vi } from 'vitest' // Mock cache storage const mockMainCache = new Map() +// Mock shared cache storage +const mockSharedCache = new Map() + // Mock broadcast tracking const mockBroadcastCalls: Array<{ message: CacheSyncMessage; senderWindowId?: number }> = [] @@ -72,9 +76,75 @@ export class MockMainCacheService { return mockMainCache.delete(key) }) + // ============ Shared Cache Methods ============ + + public getShared = vi.fn((key: K): SharedCacheSchema[K] | undefined => { + const entry = mockSharedCache.get(key) + if (!entry) return undefined + + // Check TTL (lazy cleanup) + if (entry.expireAt && Date.now() > entry.expireAt) { + mockSharedCache.delete(key) + return undefined + } + + return entry.value as SharedCacheSchema[K] + }) + + public setShared = vi.fn((key: K, value: SharedCacheSchema[K], ttl?: number): void => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + mockSharedCache.set(key, entry) + + // Track broadcast for testing + mockBroadcastCalls.push({ + message: { + type: 'shared', + key, + value, + expireAt: entry.expireAt + } + }) + }) + + public hasShared = vi.fn((key: K): boolean => { + const entry = mockSharedCache.get(key) + if (!entry) return false + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + mockSharedCache.delete(key) + return false + } + + return true + }) + + public deleteShared = vi.fn((key: K): boolean => { + if (!mockSharedCache.has(key)) { + return true + } + + mockSharedCache.delete(key) + + // Track broadcast for testing + mockBroadcastCalls.push({ + message: { + type: 'shared', + key, + value: undefined + } + }) + + return true + }) + // Mock cleanup public cleanup = vi.fn((): void => { mockMainCache.clear() + mockSharedCache.clear() mockBroadcastCalls.length = 0 }) @@ -110,6 +180,7 @@ export const MockMainCacheServiceUtils = { // Reset cache state mockMainCache.clear() + mockSharedCache.clear() mockBroadcastCalls.length = 0 // Reset initialized state @@ -164,6 +235,52 @@ export const MockMainCacheServiceUtils = { return new Map(mockMainCache) }, + // ============ Shared Cache Utilities ============ + + /** + * Set shared cache value for testing + */ + setSharedCacheValue: (key: K, value: SharedCacheSchema[K], ttl?: number) => { + const entry: CacheEntry = { + value, + expireAt: ttl ? Date.now() + ttl : undefined + } + mockSharedCache.set(key, entry) + }, + + /** + * Get shared cache value for testing + */ + getSharedCacheValue: (key: K): SharedCacheSchema[K] | undefined => { + const entry = mockSharedCache.get(key) + if (!entry) return undefined + + // Check TTL + if (entry.expireAt && Date.now() > entry.expireAt) { + mockSharedCache.delete(key) + return undefined + } + + return entry.value as SharedCacheSchema[K] + }, + + /** + * Get all shared cache entries for testing + */ + getAllSharedCacheEntries: (): Map => { + return new Map(mockSharedCache) + }, + + /** + * Simulate shared cache expiration for testing + */ + simulateSharedCacheExpiration: (key: string) => { + const entry = mockSharedCache.get(key) + if (entry) { + entry.expireAt = Date.now() - 1000 // Set to expired + } + }, + /** * Get broadcast call history for testing */ @@ -206,8 +323,10 @@ export const MockMainCacheServiceUtils = { */ getCacheStats: () => ({ totalEntries: mockMainCache.size, + sharedEntries: mockSharedCache.size, broadcastCalls: mockBroadcastCalls.length, - keys: Array.from(mockMainCache.keys()) + keys: Array.from(mockMainCache.keys()), + sharedKeys: Array.from(mockSharedCache.keys()) }), /** @@ -226,6 +345,10 @@ export const MockMainCacheServiceUtils = { set: mockInstance.set.mock.calls.length, has: mockInstance.has.mock.calls.length, delete: mockInstance.delete.mock.calls.length, + getShared: mockInstance.getShared.mock.calls.length, + setShared: mockInstance.setShared.mock.calls.length, + hasShared: mockInstance.hasShared.mock.calls.length, + deleteShared: mockInstance.deleteShared.mock.calls.length, cleanup: mockInstance.cleanup.mock.calls.length }) } From 37eac8c7fdef376a7020130bc6dc076daf8a58a3 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 10:44:38 +0800 Subject: [PATCH 092/116] feat: update documentation and enhance test mocks for data management - Added a new section in the data management documentation for testing, including a link to unified test mocks for Cache, Preference, and DataApi. - Refined the README for test mocks, providing a clearer overview and detailed descriptions of available mocks and their organization. - Improved the structure and clarity of mock usage examples, ensuring better guidance for developers on utilizing the testing utilities effectively. --- docs/en/references/data/README.md | 4 + tests/__mocks__/README.md | 757 ++++++++++-------------------- 2 files changed, 240 insertions(+), 521 deletions(-) diff --git a/docs/en/references/data/README.md b/docs/en/references/data/README.md index cd247160c5..abccd93508 100644 --- a/docs/en/references/data/README.md +++ b/docs/en/references/data/README.md @@ -21,6 +21,9 @@ This is the main entry point for Cherry Studio's data management documentation. - [API Types](./api-types.md) - API type system, schemas, error handling - [V2 Migration Guide](./v2-migration-guide.md) - Migration system +### Testing +- [Test Mocks](../../../../tests/__mocks__/README.md) - Unified mocks for Cache, Preference, and DataApi + --- ## Choosing the Right System @@ -191,3 +194,4 @@ const { data: files } = useQuery('/files') - `src/renderer/src/data/CacheService.ts` - Cache service - `src/renderer/src/data/PreferenceService.ts` - Preference service - `src/renderer/src/data/hooks/` - React hooks + diff --git a/tests/__mocks__/README.md b/tests/__mocks__/README.md index 89ac4d348a..789cbf0cea 100644 --- a/tests/__mocks__/README.md +++ b/tests/__mocks__/README.md @@ -1,608 +1,323 @@ # Test Mocks -这个目录包含了项目中使用的统一测试模拟(mocks)。这些模拟按照进程类型组织,避免重名冲突,并在相应的测试设置文件中全局配置。 +Unified test mocks for the project, organized by process type and globally configured in test setup files. -## 🎯 统一模拟概述 +## Overview -### 已实现的统一模拟 +### Available Mocks -#### Renderer Process Mocks -- ✅ **PreferenceService** - 渲染进程偏好设置服务模拟 -- ✅ **DataApiService** - 渲染进程数据API服务模拟 -- ✅ **CacheService** - 渲染进程三层缓存服务模拟 -- ✅ **useDataApi hooks** - 数据API钩子模拟 (useQuery, useMutation, usePaginatedQuery, etc.) -- ✅ **usePreference hooks** - 偏好设置钩子模拟 (usePreference, useMultiplePreferences) -- ✅ **useCache hooks** - 缓存钩子模拟 (useCache, useSharedCache, usePersistCache) +| Process | Mock | Description | +|---------|------|-------------| +| Renderer | `CacheService` | Three-tier cache (memory/shared/persist) | +| Renderer | `DataApiService` | HTTP client for Data API | +| Renderer | `PreferenceService` | User preferences | +| Renderer | `useDataApi` | Data API hooks (useQuery, useMutation, etc.) | +| Renderer | `usePreference` | Preference hooks | +| Renderer | `useCache` | Cache hooks | +| Main | `CacheService` | Internal + shared cache | +| Main | `DataApiService` | API coordinator | +| Main | `PreferenceService` | Preference service | -#### Main Process Mocks -- ✅ **PreferenceService** - 主进程偏好设置服务模拟 -- ✅ **DataApiService** - 主进程数据API服务模拟 -- ✅ **CacheService** - 主进程缓存服务模拟 - -### 🌟 核心优势 - -- **进程分离**: 按照renderer/main分开组织,避免重名冲突 -- **自动应用**: 无需在每个测试文件中单独模拟 -- **完整API覆盖**: 实现了所有服务和钩子的完整API -- **类型安全**: 完全支持 TypeScript,保持与真实服务的类型兼容性 -- **现实行为**: 模拟提供现实的默认值和行为模式 -- **高度可定制**: 支持为特定测试定制行为 -- **测试工具**: 内置丰富的测试工具函数 - -### 📁 文件结构 +### File Structure ``` tests/__mocks__/ -├── README.md # 本文档 -├── renderer/ # 渲染进程模拟 -│ ├── PreferenceService.ts # 渲染进程偏好设置服务模拟 -│ ├── DataApiService.ts # 渲染进程数据API服务模拟 -│ ├── CacheService.ts # 渲染进程缓存服务模拟 -│ ├── useDataApi.ts # 数据API钩子模拟 -│ ├── usePreference.ts # 偏好设置钩子模拟 -│ └── useCache.ts # 缓存钩子模拟 -├── main/ # 主进程模拟 -│ ├── PreferenceService.ts # 主进程偏好设置服务模拟 -│ ├── DataApiService.ts # 主进程数据API服务模拟 -│ └── CacheService.ts # 主进程缓存服务模拟 -├── RendererLoggerService.ts # 渲染进程日志服务模拟 -└── MainLoggerService.ts # 主进程日志服务模拟 +├── renderer/ +│ ├── CacheService.ts +│ ├── DataApiService.ts +│ ├── PreferenceService.ts +│ ├── useDataApi.ts +│ ├── usePreference.ts +│ └── useCache.ts +├── main/ +│ ├── CacheService.ts +│ ├── DataApiService.ts +│ └── PreferenceService.ts +├── RendererLoggerService.ts +└── MainLoggerService.ts ``` -### 🔧 测试设置 - -#### Renderer Process Tests -在 `tests/renderer.setup.ts` 中配置了所有渲染进程模拟: - -```typescript -// 自动加载 renderer/ 目录下的模拟 -vi.mock('@data/PreferenceService', async () => { - const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService') - return MockPreferenceService -}) -// ... 其他渲染进程模拟 -``` - -#### Main Process Tests -在 `tests/main.setup.ts` 中配置了所有主进程模拟: - -```typescript -// 自动加载 main/ 目录下的模拟 -vi.mock('@main/data/PreferenceService', async () => { - const { MockMainPreferenceServiceExport } = await import('./__mocks__/main/PreferenceService') - return MockMainPreferenceServiceExport -}) -// ... 其他主进程模拟 -``` - -## PreferenceService Mock - -### 简介 - -`PreferenceService.ts` 提供了 PreferenceService 的统一模拟实现,用于所有渲染进程测试。这个模拟: - -- ✅ **自动应用**:在 `renderer.setup.ts` 中全局配置,无需在每个测试文件中单独模拟 -- ✅ **完整API**:实现了 PreferenceService 的所有方法(get, getMultiple, set, etc.) -- ✅ **合理默认值**:提供了常用偏好设置的默认值 -- ✅ **可定制**:支持为特定测试定制默认值 -- ✅ **类型安全**:完全支持 TypeScript 类型检查 - -### 默认值 - -模拟提供了以下默认偏好设置: - -```typescript -// 导出偏好设置 -'data.export.markdown.force_dollar_math': false -'data.export.markdown.exclude_citations': false -'data.export.markdown.standardize_citations': true -'data.export.markdown.show_model_name': false -'data.export.markdown.show_model_provider': false - -// UI偏好设置 -'ui.language': 'en' -'ui.theme': 'light' -'ui.font_size': 14 - -// AI偏好设置 -'ai.default_model': 'gpt-4' -'ai.temperature': 0.7 -'ai.max_tokens': 2000 - -// 功能开关 -'feature.web_search': true -'feature.reasoning': false -'feature.tool_calling': true -``` - -### 基本使用 - -由于模拟已经全局配置,大多数测试可以直接使用 PreferenceService,无需额外设置: - -```typescript -import { preferenceService } from '@data/PreferenceService' - -describe('MyComponent', () => { - it('should use preference values', async () => { - // PreferenceService 已经被自动模拟 - const value = await preferenceService.get('ui.theme') - expect(value).toBe('light') // 使用默认值 - }) -}) -``` - -### 高级使用 - -#### 1. 修改单个测试的偏好值 - -```typescript -import { preferenceService } from '@data/PreferenceService' -import { vi } from 'vitest' - -describe('Custom preferences', () => { - it('should work with custom preference values', async () => { - // 为这个测试修改特定值 - ;(preferenceService.get as any).mockImplementation((key: string) => { - if (key === 'ui.theme') return Promise.resolve('dark') - // 其他键使用默认模拟行为 - return vi.fn().mockResolvedValue(null)() - }) - - const theme = await preferenceService.get('ui.theme') - expect(theme).toBe('dark') - }) -}) -``` - -#### 2. 重置模拟状态 - -```typescript -import { preferenceService } from '@data/PreferenceService' - -describe('Mock state management', () => { - beforeEach(() => { - // 重置模拟到初始状态 - if ('_resetMockState' in preferenceService) { - ;(preferenceService as any)._resetMockState() - } - }) -}) -``` - -#### 3. 检查模拟内部状态 - -```typescript -import { preferenceService } from '@data/PreferenceService' - -describe('Mock inspection', () => { - it('should allow inspecting mock state', () => { - // 查看当前模拟状态 - if ('_getMockState' in preferenceService) { - const state = (preferenceService as any)._getMockState() - console.log('Current mock state:', state) - } - }) -}) -``` - -#### 4. 为整个测试套件定制默认值 - -如果需要为特定的测试文件定制默认值,可以在该文件中重新模拟: - -```typescript -import { vi } from 'vitest' - -// 重写全局模拟,添加自定义默认值 -vi.mock('@data/PreferenceService', async () => { - const { createMockPreferenceService } = await import('tests/__mocks__/PreferenceService') - - // 定制默认值 - const customDefaults = { - 'my.custom.setting': 'custom_value', - 'ui.theme': 'dark' // 覆盖默认值 - } - - return { - preferenceService: createMockPreferenceService(customDefaults) - } -}) -``` - -### 测试验证 - -可以验证 PreferenceService 方法是否被正确调用: - -```typescript -import { preferenceService } from '@data/PreferenceService' -import { vi } from 'vitest' - -describe('Preference service calls', () => { - it('should call preference service methods', async () => { - await preferenceService.get('ui.theme') - - // 验证方法调用 - expect(preferenceService.get).toHaveBeenCalledWith('ui.theme') - expect(preferenceService.get).toHaveBeenCalledTimes(1) - }) -}) -``` - -### 添加新的默认值 - -当项目中添加新的偏好设置时,请在 `PreferenceService.ts` 的 `mockPreferenceDefaults` 中添加相应的默认值: - -```typescript -export const mockPreferenceDefaults: Record = { - // 现有默认值... - - // 新增默认值 - 'new.feature.enabled': true, - 'new.feature.config': { option: 'value' } -} -``` - -这样可以确保所有测试都能使用合理的默认值,减少测试失败的可能性。 - -## DataApiService Mock - -### 简介 - -`DataApiService.ts` 提供了数据API服务的统一模拟,支持所有HTTP方法和高级功能。 - -### 功能特性 - -- **完整HTTP支持**: GET, POST, PUT, PATCH, DELETE -- **订阅系统**: subscribe/unsubscribe 模拟 -- **连接管理**: connect/disconnect/ping 方法 -- **智能模拟数据**: 基于路径自动生成合理的响应 - -### 基本使用 - -```typescript -import { dataApiService } from '@data/DataApiService' - -describe('API Integration', () => { - it('should fetch topics', async () => { - // 自动模拟,返回预设的主题列表 - const response = await dataApiService.get('/api/topics') - expect(response.success).toBe(true) - expect(response.data.topics).toHaveLength(2) - }) -}) -``` - -### 高级使用 - -```typescript -import { MockDataApiUtils } from 'tests/__mocks__/DataApiService' - -describe('Custom API behavior', () => { - beforeEach(() => { - MockDataApiUtils.resetMocks() - }) - - it('should handle custom responses', async () => { - // 设置特定路径的自定义响应 - MockDataApiUtils.setCustomResponse('/api/topics', 'GET', { - topics: [{ id: 'custom', name: 'Custom Topic' }] - }) - - const response = await dataApiService.get('/api/topics') - expect(response.data.topics[0].name).toBe('Custom Topic') - }) - - it('should simulate errors', async () => { - // 模拟错误响应 - MockDataApiUtils.setErrorResponse('/api/topics', 'GET', 'Network error') - - const response = await dataApiService.get('/api/topics') - expect(response.success).toBe(false) - expect(response.error?.message).toBe('Network error') - }) -}) -``` - -## CacheService Mock - -### 简介 - -`CacheService.ts` 提供了三层缓存系统的完整模拟:内存缓存、共享缓存和持久化缓存。 - -### 功能特性 - -- **三层架构**: 内存、共享、持久化缓存 -- **订阅系统**: 支持缓存变更订阅 -- **TTL支持**: 模拟缓存过期(简化版) -- **Hook引用跟踪**: 模拟生产环境的引用管理 -- **默认值**: 基于缓存schema的智能默认值 - -### 基本使用 +### Test Setup + +Mocks are globally configured in setup files: +- **Renderer**: `tests/renderer.setup.ts` +- **Main**: `tests/main.setup.ts` + +--- + +## Renderer Mocks + +### CacheService + +Three-tier cache system with type-safe and casual (dynamic key) methods. + +#### Methods + +| Category | Method | Signature | +|----------|--------|-----------| +| Memory (typed) | `get` | `(key: K) => UseCacheSchema[K]` | +| Memory (typed) | `set` | `(key: K, value, ttl?) => void` | +| Memory (typed) | `has` | `(key: K) => boolean` | +| Memory (typed) | `delete` | `(key: K) => boolean` | +| Memory (typed) | `hasTTL` | `(key: K) => boolean` | +| Memory (casual) | `getCasual` | `(key: string) => T \| undefined` | +| Memory (casual) | `setCasual` | `(key, value, ttl?) => void` | +| Memory (casual) | `hasCasual` | `(key: string) => boolean` | +| Memory (casual) | `deleteCasual` | `(key: string) => boolean` | +| Memory (casual) | `hasTTLCasual` | `(key: string) => boolean` | +| Shared (typed) | `getShared` | `(key: K) => SharedCacheSchema[K]` | +| Shared (typed) | `setShared` | `(key: K, value, ttl?) => void` | +| Shared (typed) | `hasShared` | `(key: K) => boolean` | +| Shared (typed) | `deleteShared` | `(key: K) => boolean` | +| Shared (typed) | `hasSharedTTL` | `(key: K) => boolean` | +| Shared (casual) | `getSharedCasual` | `(key: string) => T \| undefined` | +| Shared (casual) | `setSharedCasual` | `(key, value, ttl?) => void` | +| Shared (casual) | `hasSharedCasual` | `(key: string) => boolean` | +| Shared (casual) | `deleteSharedCasual` | `(key: string) => boolean` | +| Shared (casual) | `hasSharedTTLCasual` | `(key: string) => boolean` | +| Persist | `getPersist` | `(key: K) => RendererPersistCacheSchema[K]` | +| Persist | `setPersist` | `(key: K, value) => void` | +| Persist | `hasPersist` | `(key) => boolean` | +| Hook mgmt | `registerHook` | `(key: string) => void` | +| Hook mgmt | `unregisterHook` | `(key: string) => void` | +| Ready state | `isSharedCacheReady` | `() => boolean` | +| Ready state | `onSharedCacheReady` | `(callback) => () => void` | +| Lifecycle | `subscribe` | `(key, callback) => () => void` | +| Lifecycle | `cleanup` | `() => void` | + +#### Usage ```typescript import { cacheService } from '@data/CacheService' +import { MockCacheUtils } from 'tests/__mocks__/renderer/CacheService' -describe('Cache Operations', () => { - it('should store and retrieve cache values', () => { - // 设置缓存值 - cacheService.set('user.preferences', { theme: 'dark' }) +describe('Cache', () => { + beforeEach(() => MockCacheUtils.resetMocks()) - // 获取缓存值 - const preferences = cacheService.get('user.preferences') - expect(preferences.theme).toBe('dark') + it('basic usage', () => { + cacheService.setCasual('key', { data: 'value' }, 5000) + expect(cacheService.getCasual('key')).toEqual({ data: 'value' }) }) - it('should work with persist cache', () => { - // 持久化缓存操作 - cacheService.setPersist('app.last_opened_topic', 'topic123') - const lastTopic = cacheService.getPersist('app.last_opened_topic') - expect(lastTopic).toBe('topic123') + it('with test utilities', () => { + MockCacheUtils.setInitialState({ + memory: [['key', 'value']], + shared: [['shared.key', 'shared']], + persist: [['persist.key', 'persist']] + }) }) }) ``` -### 高级测试工具 +--- + +### DataApiService + +HTTP client with subscriptions and retry configuration. + +#### Methods + +| Method | Signature | +|--------|-----------| +| `get` | `(path, options?) => Promise` | +| `post` | `(path, options) => Promise` | +| `put` | `(path, options) => Promise` | +| `patch` | `(path, options) => Promise` | +| `delete` | `(path, options?) => Promise` | +| `subscribe` | `(options, callback) => () => void` | +| `configureRetry` | `(options) => void` | +| `getRetryConfig` | `() => RetryOptions` | +| `getRequestStats` | `() => { pendingRequests, activeSubscriptions }` | + +#### Usage ```typescript -import { MockCacheUtils } from 'tests/__mocks__/CacheService' +import { dataApiService } from '@data/DataApiService' +import { MockDataApiUtils } from 'tests/__mocks__/renderer/DataApiService' -describe('Advanced cache testing', () => { - beforeEach(() => { - MockCacheUtils.resetMocks() +describe('API', () => { + beforeEach(() => MockDataApiUtils.resetMocks()) + + it('basic request', async () => { + const response = await dataApiService.get('/topics') + expect(response.topics).toBeDefined() }) - it('should set initial cache state', () => { - // 设置初始缓存状态 - MockCacheUtils.setInitialState({ - memory: [['theme', 'dark'], ['language', 'en']], - persist: [['app.version', '1.0.0']] - }) - - expect(cacheService.get('theme')).toBe('dark') - expect(cacheService.getPersist('app.version')).toBe('1.0.0') + it('custom response', async () => { + MockDataApiUtils.setCustomResponse('/topics', 'GET', { custom: true }) + const response = await dataApiService.get('/topics') + expect(response.custom).toBe(true) }) - it('should simulate cache changes', () => { - let changeCount = 0 - cacheService.subscribe('theme', () => changeCount++) - - MockCacheUtils.triggerCacheChange('theme', 'light') - expect(changeCount).toBe(1) + it('error simulation', async () => { + MockDataApiUtils.setErrorResponse('/topics', 'GET', new Error('Failed')) + await expect(dataApiService.get('/topics')).rejects.toThrow('Failed') }) }) ``` -## useDataApi Hooks Mock +--- -### 简介 +### useDataApi Hooks -`useDataApi.ts` 提供了所有数据API钩子的统一模拟,包括查询、变更和分页功能。 +React hooks for data operations. -### 支持的钩子 +#### Hooks -- `useQuery` - 数据查询钩子 -- `useMutation` - 数据变更钩子 -- `usePaginatedQuery` - 分页查询钩子 -- `useInvalidateCache` - 缓存失效钩子 -- `prefetch` - 预取函数 +| Hook | Signature | Returns | +|------|-----------|---------| +| `useQuery` | `(path, options?)` | `{ data, loading, error, refetch, mutate }` | +| `useMutation` | `(method, path, options?)` | `{ mutate, loading, error }` | +| `usePaginatedQuery` | `(path, options?)` | `{ items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset }` | +| `useInvalidateCache` | `()` | `(keys?) => Promise` | -### 基本使用 +#### Usage ```typescript import { useQuery, useMutation } from '@data/hooks/useDataApi' +import { MockUseDataApiUtils } from 'tests/__mocks__/renderer/useDataApi' -describe('Data API Hooks', () => { - it('should work with useQuery', () => { - const { data, isLoading, error } = useQuery('/api/topics') +describe('Hooks', () => { + beforeEach(() => MockUseDataApiUtils.resetMocks()) - // 默认返回模拟数据 + it('useQuery', () => { + const { data, loading } = useQuery('/topics') + expect(loading).toBe(false) expect(data).toBeDefined() - expect(data.topics).toHaveLength(2) - expect(isLoading).toBe(false) - expect(error).toBeUndefined() }) - it('should work with useMutation', async () => { - const { trigger, isMutating } = useMutation('/api/topics', 'POST') - - const result = await trigger({ name: 'New Topic' }) + it('useMutation', async () => { + const { mutate } = useMutation('POST', '/topics') + const result = await mutate({ body: { name: 'New' } }) expect(result.created).toBe(true) - expect(result.name).toBe('New Topic') + }) + + it('custom data', () => { + MockUseDataApiUtils.mockQueryData('/topics', { custom: true }) + const { data } = useQuery('/topics') + expect(data.custom).toBe(true) }) }) ``` -### 自定义测试行为 +--- + +### useCache Hooks + +React hooks for cache operations. + +| Hook | Signature | Returns | +|------|-----------|---------| +| `useCache` | `(key, initValue?)` | `[value, setValue]` | +| `useSharedCache` | `(key, initValue?)` | `[value, setValue]` | +| `usePersistCache` | `(key)` | `[value, setValue]` | ```typescript -import { MockUseDataApiUtils } from 'tests/__mocks__/useDataApi' +import { useCache } from '@data/hooks/useCache' -describe('Custom hook behavior', () => { - beforeEach(() => { - MockUseDataApiUtils.resetMocks() - }) - - it('should mock loading state', () => { - MockUseDataApiUtils.mockQueryLoading('/api/topics') - - const { data, isLoading } = useQuery('/api/topics') - expect(isLoading).toBe(true) - expect(data).toBeUndefined() - }) - - it('should mock error state', () => { - const error = new Error('API Error') - MockUseDataApiUtils.mockQueryError('/api/topics', error) - - const { data, error: queryError } = useQuery('/api/topics') - expect(queryError).toBe(error) - expect(data).toBeUndefined() - }) -}) +const [value, setValue] = useCache('key', 'default') +setValue('new value') ``` -## usePreference Hooks Mock +--- -### 简介 +### usePreference Hooks -`usePreference.ts` 提供了偏好设置钩子的统一模拟,支持单个和批量偏好管理。 +React hooks for preferences. -### 支持的钩子 - -- `usePreference` - 单个偏好设置钩子 -- `useMultiplePreferences` - 多个偏好设置钩子 - -### 基本使用 +| Hook | Signature | Returns | +|------|-----------|---------| +| `usePreference` | `(key)` | `[value, setValue]` | +| `useMultiplePreferences` | `(keyMap)` | `[values, setValues]` | ```typescript -import { usePreference, useMultiplePreferences } from '@data/hooks/usePreference' +import { usePreference } from '@data/hooks/usePreference' -describe('Preference Hooks', () => { - it('should work with usePreference', async () => { - const [theme, setTheme] = usePreference('ui.theme') - - expect(theme).toBe('light') // 默认值 - - await setTheme('dark') - // 在测试中,可以通过工具函数验证值是否更新 - }) - - it('should work with multiple preferences', async () => { - const [prefs, setPrefs] = useMultiplePreferences({ - theme: 'ui.theme', - lang: 'ui.language' - }) - - expect(prefs.theme).toBe('light') - expect(prefs.lang).toBe('en') - - await setPrefs({ theme: 'dark' }) - }) -}) +const [theme, setTheme] = usePreference('ui.theme') +await setTheme('dark') ``` -### 高级测试 +--- + +## Main Process Mocks + +### Main CacheService + +Internal cache and cross-window shared cache. + +#### Methods + +| Category | Method | Signature | +|----------|--------|-----------| +| Lifecycle | `initialize` | `() => Promise` | +| Lifecycle | `cleanup` | `() => void` | +| Internal | `get` | `(key: string) => T \| undefined` | +| Internal | `set` | `(key, value, ttl?) => void` | +| Internal | `has` | `(key: string) => boolean` | +| Internal | `delete` | `(key: string) => boolean` | +| Shared | `getShared` | `(key: K) => SharedCacheSchema[K] \| undefined` | +| Shared | `setShared` | `(key: K, value, ttl?) => void` | +| Shared | `hasShared` | `(key: K) => boolean` | +| Shared | `deleteShared` | `(key: K) => boolean` | ```typescript -import { MockUsePreferenceUtils } from 'tests/__mocks__/usePreference' +import { MockMainCacheServiceUtils } from 'tests/__mocks__/main/CacheService' -describe('Advanced preference testing', () => { - beforeEach(() => { - MockUsePreferenceUtils.resetMocks() - }) +beforeEach(() => MockMainCacheServiceUtils.resetMocks()) - it('should simulate preference changes', () => { - MockUsePreferenceUtils.setPreferenceValue('ui.theme', 'dark') - - const [theme] = usePreference('ui.theme') - expect(theme).toBe('dark') - }) - - it('should simulate external changes', () => { - let callCount = 0 - MockUsePreferenceUtils.addSubscriber('ui.theme', () => callCount++) - - MockUsePreferenceUtils.simulateExternalPreferenceChange('ui.theme', 'dark') - expect(callCount).toBe(1) - }) -}) +MockMainCacheServiceUtils.setCacheValue('key', 'value') +MockMainCacheServiceUtils.setSharedCacheValue('shared.key', 'shared') ``` -## useCache Hooks Mock +--- -### 简介 +### Main DataApiService -`useCache.ts` 提供了缓存钩子的统一模拟,支持三种缓存层级。 +API coordinator managing ApiServer and IpcAdapter. -### 支持的钩子 - -- `useCache` - 内存缓存钩子 -- `useSharedCache` - 共享缓存钩子 -- `usePersistCache` - 持久化缓存钩子 - -### 基本使用 +| Method | Signature | +|--------|-----------| +| `initialize` | `() => Promise` | +| `shutdown` | `() => Promise` | +| `getSystemStatus` | `() => object` | +| `getApiServer` | `() => ApiServer` | ```typescript -import { useCache, useSharedCache, usePersistCache } from '@data/hooks/useCache' +import { MockMainDataApiServiceUtils } from 'tests/__mocks__/main/DataApiService' -describe('Cache Hooks', () => { - it('should work with useCache', () => { - const [theme, setTheme] = useCache('ui.theme', 'light') +beforeEach(() => MockMainDataApiServiceUtils.resetMocks()) - expect(theme).toBe('light') - setTheme('dark') - // 值立即更新 - }) - - it('should work with different cache types', () => { - const [shared, setShared] = useSharedCache('app.window_count', 1) - const [persist, setPersist] = usePersistCache('app.last_version', '1.0.0') - - expect(shared).toBe(1) - expect(persist).toBe('1.0.0') - }) -}) +MockMainDataApiServiceUtils.simulateInitializationError(new Error('Failed')) ``` -### 测试工具 +--- -```typescript -import { MockUseCacheUtils } from 'tests/__mocks__/useCache' +## Utility Functions -describe('Cache hook testing', () => { - beforeEach(() => { - MockUseCacheUtils.resetMocks() - }) +Each mock exports a `MockXxxUtils` object with testing utilities: - it('should set initial cache state', () => { - MockUseCacheUtils.setMultipleCacheValues({ - memory: [['ui.theme', 'dark']], - shared: [['app.mode', 'development']], - persist: [['user.id', 'user123']] - }) +| Utility | Description | +|---------|-------------| +| `resetMocks()` | Reset all mock state and call counts | +| `setXxxValue()` | Set specific values for testing | +| `getXxxValue()` | Get current mock values | +| `simulateXxx()` | Simulate specific scenarios (errors, expiration, etc.) | +| `getMockCallCounts()` | Get call counts for debugging | - const [theme] = useCache('ui.theme') - const [mode] = useSharedCache('app.mode') - const [userId] = usePersistCache('user.id') +--- - expect(theme).toBe('dark') - expect(mode).toBe('development') - expect(userId).toBe('user123') - }) -}) -``` +## Best Practices -## LoggerService Mock +1. **Use global mocks** - Don't re-mock in individual tests unless necessary +2. **Reset in beforeEach** - Call `MockXxxUtils.resetMocks()` to ensure test isolation +3. **Use utility functions** - Prefer `MockXxxUtils` over direct mock manipulation +4. **Type safety** - Mocks match actual service interfaces -### 简介 +## Troubleshooting -项目还包含了 LoggerService 的模拟: -- `RendererLoggerService.ts` - 渲染进程日志服务模拟 -- `MainLoggerService.ts` - 主进程日志服务模拟 - -这些模拟同样在相应的测试设置文件中全局配置。 - -## 最佳实践 - -1. **优先使用全局模拟**:大多数情况下应该直接使用全局配置的模拟,而不是在每个测试中单独模拟 -2. **合理的默认值**:确保模拟的默认值反映实际应用的常见配置 -3. **文档更新**:当添加新的模拟或修改现有模拟时,请更新相关文档 -4. **类型安全**:保持模拟与实际服务的类型兼容性 -5. **测试隔离**:如果需要修改模拟行为,确保在测试后恢复或在 beforeEach 中重置 - -## 故障排除 - -### 模拟未生效 - -如果发现 PreferenceService 模拟未生效: - -1. 确认测试运行在渲染进程环境中(`vitest.config.ts` 中的 `renderer` 项目) -2. 检查 `tests/renderer.setup.ts` 是否正确配置 -3. 确认导入路径使用的是 `@data/PreferenceService` 而非相对路径 - -### 类型错误 - -如果遇到 TypeScript 类型错误: - -1. 确认模拟实现与实际 PreferenceService 接口匹配 -2. 在测试中使用类型断言:`(preferenceService as any)._getMockState()` -3. 检查是否需要更新模拟的类型定义 \ No newline at end of file +| Issue | Solution | +|-------|----------| +| Mock not applied | Check test runs in correct process (renderer/main in vitest.config.ts) | +| Type errors | Ensure mock matches actual interface, use type assertions if needed | +| State pollution | Call `resetMocks()` in `beforeEach` | +| Import issues | Use path aliases (`@data/CacheService`) not relative paths | From 61dddad22f40002ea3a76cb9ae57b79cf36827d3 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 10:47:42 +0800 Subject: [PATCH 093/116] refactor: streamline block management and update StreamingService integration - Consolidated block addition logic in BlockManager to enhance clarity and maintainability, ensuring that new blocks also update message references internally. - Updated StreamingService to improve documentation and clarify the relationship between parentId and askId for backward compatibility. - Removed redundant block reference handling, simplifying the message structure and improving performance during streaming sessions. - Enhanced comments and documentation throughout to provide clearer guidance on the updated functionality and its implications for message processing. --- .../services/messageStreaming/BlockManager.ts | 8 +-- .../messageStreaming/StreamingService.ts | 71 +++++++++---------- src/renderer/src/store/thunk/messageThunk.ts | 4 +- 3 files changed, 38 insertions(+), 45 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/BlockManager.ts b/src/renderer/src/services/messageStreaming/BlockManager.ts index 31362d2215..8061574e1d 100644 --- a/src/renderer/src/services/messageStreaming/BlockManager.ts +++ b/src/renderer/src/services/messageStreaming/BlockManager.ts @@ -130,8 +130,7 @@ export class BlockManager { * * This method: * 1. Updates active block tracking state - * 2. Adds new block to StreamingService - * 3. Updates message block references + * 2. Adds new block to StreamingService (which also updates message.blocks references) * * NOTE: DB saves are removed - persistence happens during finalize() */ @@ -140,12 +139,9 @@ export class BlockManager { this._lastBlockType = newBlockType this._activeBlockInfo = { id: newBlock.id, type: newBlockType } // Set new active block info - // Add new block to StreamingService (replaces dispatch(upsertOneBlock)) + // Add new block to StreamingService (also updates message.blocks references internally) streamingService.addBlock(this.deps.assistantMsgId, newBlock) - // Update block reference in message (replaces dispatch(upsertBlockReference)) - streamingService.addBlockReference(this.deps.assistantMsgId, newBlock.id) - // TEMPORARY: The blockInstruction field was used for UI coordination. // TODO: Evaluate if this is still needed with StreamingService approach // For now, we update it in the message diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index bafcb2dbb1..9dd4bb3835 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -55,7 +55,9 @@ interface StreamingSession { // Tree structure information (v2 new fields) parentId: string // Parent message ID (user message) - siblingsGroupId: number // Multi-model group ID (0=normal, >0=multi-model response) + // siblingsGroupId: 0 = single model response, >0 = multi-model response group + // Messages with the same parentId and siblingsGroupId (>0) are displayed together for comparison + siblingsGroupId: number // Context for usage estimation (messages up to and including user message) contextMessages?: Message[] @@ -66,15 +68,18 @@ interface StreamingSession { /** * Options for starting a streaming session + * + * NOTE: Internal naming uses v2 convention (parentId). + * The renderer Message format uses 'askId' for backward compatibility, + * which is set from parentId during message creation. */ interface StartSessionOptions { parentId: string - siblingsGroupId?: number // Defaults to 0 + siblingsGroupId?: number // Defaults to 0 (single model), >0 for multi-model response groups role: 'assistant' model?: Message['model'] modelId?: string assistantId: string - askId?: string traceId?: string agentSessionId?: string // Context messages for usage estimation (messages up to and including user message) @@ -126,13 +131,13 @@ class StreamingService { model, modelId, assistantId, - askId, traceId, agentSessionId, contextMessages } = options // Initialize message structure + // NOTE: askId is set from parentId for renderer format compatibility (v1 uses askId, v2 uses parentId) const message: Message = { id: messageId, topicId, @@ -143,7 +148,7 @@ class StreamingService { blocks: [], model, modelId, - askId, + askId: parentId, // Map v2 parentId to v1 renderer format askId traceId, agentSessionId } @@ -181,9 +186,16 @@ class StreamingService { * * This method: * 1. Converts streaming data to the appropriate format - * 2. Routes to Data API (normal topics) or dbService (agent topics) + * 2. Routes to the appropriate data source based on topic type * 3. Cleans up all related cache keys * + * ## Persistence Paths + * + * - **Normal topics** → Data API (target architecture for v2) + * - **Agent sessions** → dbService (TEMPORARY: This is a transitional approach. + * Agent message storage will be migrated to Data API in a later phase. + * Once migration is complete, all paths will use Data API uniformly.) + * * @param messageId - Session message ID * @param status - Final message status */ @@ -201,13 +213,12 @@ class StreamingService { try { const updatePayload = this.convertToUpdatePayload(session, status) - // TRADEOFF: Using dbService for agent messages instead of Data API - // because agent message storage refactoring is planned for later phase. - // TODO: Unify to Data API when agent message migration is complete. + // Route to appropriate data source based on topic type + // TEMPORARY: Agent sessions use dbService until migration to Data API is complete if (isAgentSessionTopicId(session.topicId)) { await dbService.updateMessageAndBlocks(session.topicId, updatePayload.messageUpdates, updatePayload.blocks) } else { - // Normal topic → Use Data API for persistence + // Normal topic → Use Data API for persistence (v2 target architecture) const dataApiPayload = this.convertToDataApiFormat(session, status) await dataApiService.patch(`/messages/${session.messageId}`, { body: dataApiPayload }) } @@ -376,30 +387,6 @@ class StreamingService { cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) } - /** - * Add a block reference to the message - * (Replaces dispatch(newMessagesActions.upsertBlockReference)) - * - * Note: In the streaming context, we just need to track the block ID in message.blocks - * The block reference details are maintained in the block itself - * - * @param messageId - Message ID - * @param blockId - Block ID to reference - */ - addBlockReference(messageId: string, blockId: string): void { - const session = this.getSession(messageId) - if (!session) { - logger.warn(`addBlockReference called for non-existent session: ${messageId}`) - return - } - - if (!session.message.blocks.includes(blockId)) { - session.message.blocks = [...session.message.blocks, blockId] - cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) - cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) - } - } - /** * Get a message from the streaming session * @@ -458,14 +445,20 @@ class StreamingService { /** * Generate the next siblingsGroupId for a topic. * - * Used for multi-model responses where multiple assistant messages + * ## siblingsGroupId Semantics + * + * - **0** = Single-model response (one assistant message per user message) + * - **>0** = Multi-model response group (multiple assistant messages sharing + * the same parentId belong to the same sibling group for parallel comparison) + * + * This method is used for multi-model responses where multiple assistant messages * share the same parentId and siblingsGroupId (>0). * - * The counter is stored in CacheService and auto-increments. + * The counter is stored in CacheService and auto-increments per topic. * Single-model responses should use siblingsGroupId=0 (not generated here). * * @param topicId - Topic ID - * @returns Next siblingsGroupId (always > 0) + * @returns Next siblingsGroupId (always > 0 for multi-model groups) */ //FIXME [v2] 现在获取 siblingsGroupId 的方式是不正确,后续再做修改调整 generateNextGroupId(topicId: string): number { @@ -564,6 +557,9 @@ class StreamingService { * * For newly created pending messages, blocks are empty. * + * NOTE: Field mapping for backward compatibility: + * - shared.parentId (v2 Data API) → askId (v1 renderer format) + * * @param shared - Message from Data API response * @param assistantId - Assistant ID to include * @param model - Optional Model object to include @@ -578,6 +574,7 @@ class StreamingService { status: shared.status as AssistantMessageStatus, blocks: [], // For new pending messages, blocks are empty createdAt: shared.createdAt, + // v2 Data API uses 'parentId'; renderer format uses 'askId' for backward compatibility askId: shared.parentId ?? undefined, modelId: shared.modelId ?? undefined, traceId: shared.traceId ?? undefined, diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts index cbc7d8b82f..58af404677 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -567,6 +567,7 @@ const fetchAndProcessAgentResponseImpl = async ( dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true })) // Initialize streaming session in StreamingService + // NOTE: parentId is used internally; askId in renderer format is derived from parentId streamingService.startSession(topicId, assistantMessage.id, { parentId: userMessageId, siblingsGroupId: 0, @@ -574,7 +575,6 @@ const fetchAndProcessAgentResponseImpl = async ( model: assistant.model, modelId: assistant.model?.id, assistantId: assistant.id, - askId: userMessageId, traceId: assistantMessage.traceId, agentSessionId: agentSession.agentSessionId }) @@ -840,6 +840,7 @@ const fetchAndProcessAssistantResponseImpl = async ( } // Initialize streaming session in StreamingService (includes context for usage estimation) + // NOTE: parentId is used internally; askId in renderer format is derived from parentId streamingService.startSession(topicId, assistantMsgId, { parentId: userMessageId!, siblingsGroupId, @@ -847,7 +848,6 @@ const fetchAndProcessAssistantResponseImpl = async ( model: assistant.model, modelId: assistant.model?.id, assistantId: assistant.id, - askId: userMessageId, traceId: assistantMessage.traceId, contextMessages: messagesForContext }) From 11843e21d59c5e52db416878d57219715a8ba4ed Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 11:06:03 +0800 Subject: [PATCH 094/116] feat: add test mocks path alias for improved import management - Introduced a new path alias '@test-mocks' in TypeScript configuration files to simplify the import of mock utilities across the project. - Updated relevant files to utilize the new alias, enhancing code readability and maintainability. - Enhanced the README for test mocks to document the new import path, providing clearer guidance for developers on using mock utilities. --- electron.vite.config.ts | 6 ++++-- .../services/__tests__/AppUpdater.test.ts | 4 ++-- tests/__mocks__/README.md | 19 ++++++++++++++----- tsconfig.node.json | 3 ++- tsconfig.web.json | 4 ++-- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index c0af436f0d..ccb020ce7d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -26,7 +26,8 @@ export default defineConfig({ '@shared': resolve('packages/shared'), '@logger': resolve('src/main/services/LoggerService'), '@mcp-trace/trace-core': resolve('packages/mcp-trace/trace-core'), - '@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node') + '@mcp-trace/trace-node': resolve('packages/mcp-trace/trace-node'), + '@test-mocks': resolve('tests/__mocks__') } }, build: { @@ -112,7 +113,8 @@ export default defineConfig({ '@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'), '@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'), '@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'), - '@cherrystudio/ui': resolve('packages/ui/src') + '@cherrystudio/ui': resolve('packages/ui/src'), + '@test-mocks': resolve('tests/__mocks__') } }, optimizeDeps: { diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index babc76ca81..7d18b0e4c7 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -14,7 +14,7 @@ vi.mock('@logger', () => ({ // Mock PreferenceService using the existing mock vi.mock('@data/PreferenceService', async () => { - const { MockMainPreferenceServiceExport } = await import('../../../../tests/__mocks__/main/PreferenceService') + const { MockMainPreferenceServiceExport } = await import('@test-mocks/main/PreferenceService') return MockMainPreferenceServiceExport }) @@ -86,7 +86,7 @@ import { preferenceService } from '@data/PreferenceService' import { UpdateMirror } from '@shared/config/constant' import { app, net } from 'electron' -import { MockMainPreferenceServiceUtils } from '../../../../tests/__mocks__/main/PreferenceService' +import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' import AppUpdater from '../AppUpdater' // Mock clientId for ConfigManager since it's not migrated yet diff --git a/tests/__mocks__/README.md b/tests/__mocks__/README.md index 789cbf0cea..38ba6286ec 100644 --- a/tests/__mocks__/README.md +++ b/tests/__mocks__/README.md @@ -43,6 +43,15 @@ Mocks are globally configured in setup files: - **Renderer**: `tests/renderer.setup.ts` - **Main**: `tests/main.setup.ts` +### Import Path Alias + +Use `@test-mocks/*` to import mock utilities: + +```typescript +import { MockCacheUtils } from '@test-mocks/renderer/CacheService' +import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService' +``` + --- ## Renderer Mocks @@ -89,7 +98,7 @@ Three-tier cache system with type-safe and casual (dynamic key) methods. ```typescript import { cacheService } from '@data/CacheService' -import { MockCacheUtils } from 'tests/__mocks__/renderer/CacheService' +import { MockCacheUtils } from '@test-mocks/renderer/CacheService' describe('Cache', () => { beforeEach(() => MockCacheUtils.resetMocks()) @@ -133,7 +142,7 @@ HTTP client with subscriptions and retry configuration. ```typescript import { dataApiService } from '@data/DataApiService' -import { MockDataApiUtils } from 'tests/__mocks__/renderer/DataApiService' +import { MockDataApiUtils } from '@test-mocks/renderer/DataApiService' describe('API', () => { beforeEach(() => MockDataApiUtils.resetMocks()) @@ -175,7 +184,7 @@ React hooks for data operations. ```typescript import { useQuery, useMutation } from '@data/hooks/useDataApi' -import { MockUseDataApiUtils } from 'tests/__mocks__/renderer/useDataApi' +import { MockUseDataApiUtils } from '@test-mocks/renderer/useDataApi' describe('Hooks', () => { beforeEach(() => MockUseDataApiUtils.resetMocks()) @@ -261,7 +270,7 @@ Internal cache and cross-window shared cache. | Shared | `deleteShared` | `(key: K) => boolean` | ```typescript -import { MockMainCacheServiceUtils } from 'tests/__mocks__/main/CacheService' +import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService' beforeEach(() => MockMainCacheServiceUtils.resetMocks()) @@ -283,7 +292,7 @@ API coordinator managing ApiServer and IpcAdapter. | `getApiServer` | `() => ApiServer` | ```typescript -import { MockMainDataApiServiceUtils } from 'tests/__mocks__/main/DataApiService' +import { MockMainDataApiServiceUtils } from '@test-mocks/main/DataApiService' beforeEach(() => MockMainDataApiServiceUtils.resetMocks()) diff --git a/tsconfig.node.json b/tsconfig.node.json index 5c5c8fb97a..c6255b3df0 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -28,7 +28,8 @@ "@types": ["./src/renderer/src/types/index.ts"], "@shared/*": ["./packages/shared/*"], "@mcp-trace/*": ["./packages/mcp-trace/*"], - "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"] + "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"], + "@test-mocks/*": ["./tests/__mocks__/*"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index 8ca81be30b..7427c512b4 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -34,8 +34,8 @@ "@cherrystudio/ai-sdk-provider": ["./packages/ai-sdk-provider/src/index.ts"], "@cherrystudio/ui/icons": ["./packages/ui/src/components/icons/index.ts"], "@cherrystudio/ui": ["./packages/ui/src/index.ts"], - "@cherrystudio/ui/*": ["./packages/ui/src/*"] - + "@cherrystudio/ui/*": ["./packages/ui/src/*"], + "@test-mocks/*": ["./tests/__mocks__/*"] }, "experimentalDecorators": true, "emitDecoratorMetadata": true, From 60204e21664a0046ff4aad0241ef140295443038 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 11:12:41 +0800 Subject: [PATCH 095/116] fix: `streamCallback.integration.test.ts` test logic - Refined `streamCallback.integration.test.ts` to streamline mock setups and enhance clarity in testing logic, including the addition of utility functions for handling persisted data. - Improved the organization of mock services and their integration into tests, ensuring better maintainability and readability of test cases. - Enhanced comments and documentation within tests to provide clearer guidance on the purpose and functionality of various mock utilities. --- .../services/__tests__/AppUpdater.test.ts | 2 +- .../streamCallback.integration.test.ts | 299 +++++++++--------- 2 files changed, 159 insertions(+), 142 deletions(-) diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index 7d18b0e4c7..7774738028 100644 --- a/src/main/services/__tests__/AppUpdater.test.ts +++ b/src/main/services/__tests__/AppUpdater.test.ts @@ -84,9 +84,9 @@ vi.mock('electron-updater', () => ({ // Import after mocks import { preferenceService } from '@data/PreferenceService' import { UpdateMirror } from '@shared/config/constant' +import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' import { app, net } from 'electron' -import { MockMainPreferenceServiceUtils } from '@test-mocks/main/PreferenceService' import AppUpdater from '../AppUpdater' // Mock clientId for ConfigManager since it's not migrated yet diff --git a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts index 4aef0a2603..9cba03512b 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -9,7 +9,9 @@ import type { Assistant, ExternalToolResult, MCPTool, Model } from '@renderer/ty import { WebSearchSource } from '@renderer/types' import type { Chunk } from '@renderer/types/chunk' import { ChunkType } from '@renderer/types/chunk' -import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { AssistantMessageStatus } from '@renderer/types/newMessage' +import { MockCacheUtils } from '@test-mocks/renderer/CacheService' +import { MockDataApiUtils } from '@test-mocks/renderer/DataApiService' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' /** @@ -49,67 +51,33 @@ const createMockCallbacks = ( } // Mock external dependencies -vi.mock('@renderer/config/models', () => ({ - SYSTEM_MODELS: { - defaultModel: [{}, {}, {}], - silicon: [], - aihubmix: [], - ocoolai: [], - deepseek: [], - ppio: [], - alayanew: [], - qiniu: [], - dmxapi: [], - burncloud: [], - tokenflux: [], - '302ai': [], - cephalon: [], - lanyun: [], - ph8: [], - openrouter: [], - ollama: [], - 'new-api': [], - lmstudio: [], - anthropic: [], - openai: [], - 'azure-openai': [], - gemini: [], - vertexai: [], - github: [], - copilot: [], - zhipu: [], - yi: [], - moonshot: [], - baichuan: [], - dashscope: [], - stepfun: [], - doubao: [], - infini: [], - minimax: [], - groq: [], - together: [], - fireworks: [], - nvidia: [], - grok: [], - hyperbolic: [], - mistral: [], - jina: [], - perplexity: [], - modelscope: [], - xirang: [], - hunyuan: [], - 'tencent-cloud-ti': [], - 'baidu-cloud': [], - gpustack: [], - voyageai: [] - }, - getModelLogo: vi.fn(), - isVisionModel: vi.fn(() => false), - isFunctionCallingModel: vi.fn(() => false), - isEmbeddingModel: vi.fn(() => false), - isReasoningModel: vi.fn(() => false) - // ... 其他需要用到的函数也可以在这里 mock -})) +// NOTE: CacheService and DataApiService are globally mocked in tests/renderer.setup.ts +// Use MockCacheUtils and MockDataApiUtils for testing utilities + +/** + * Helper function to get persisted data from mock DataApiService calls + * Finds the PATCH call for a specific message path and returns the body + */ +const getPersistedDataForMessage = (messageId: string) => { + const patchCalls = MockDataApiUtils.getCalls('patch') + // Find the last call for this message (most recent state) + const matchingCalls = patchCalls.filter(([path]: [string]) => path === `/messages/${messageId}`) + if (matchingCalls.length === 0) return undefined + const lastCall = matchingCalls[matchingCalls.length - 1] + return lastCall[1]?.body +} + +vi.mock('@renderer/config/models', async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + // Override functions that need mocking for tests + isVisionModel: vi.fn(() => false), + isFunctionCallingModel: vi.fn(() => false), + isEmbeddingModel: vi.fn(() => false), + isReasoningModel: vi.fn(() => false) + } +}) vi.mock('@renderer/databases', () => ({ default: { @@ -167,12 +135,41 @@ vi.mock('@renderer/services/NotificationService', () => ({ } })) +vi.mock('@renderer/services/db/DbService', () => ({ + DbService: { + getInstance: vi.fn(() => ({ + createMessage: vi.fn(), + updateMessage: vi.fn(), + deleteMessage: vi.fn(), + createBlock: vi.fn(), + updateBlock: vi.fn(), + deleteBlock: vi.fn(), + createBlocks: vi.fn(), + getMessageById: vi.fn(), + getBlocksByMessageId: vi.fn() + })) + }, + dbService: { + createMessage: vi.fn(), + updateMessage: vi.fn(), + deleteMessage: vi.fn(), + createBlock: vi.fn(), + updateBlock: vi.fn(), + deleteBlock: vi.fn(), + createBlocks: vi.fn(), + getMessageById: vi.fn(), + getBlocksByMessageId: vi.fn() + } +})) + vi.mock('@renderer/services/EventService', () => ({ EventEmitter: { - emit: vi.fn() + emit: vi.fn(), + on: vi.fn() }, EVENT_NAMES: { - MESSAGE_COMPLETE: 'MESSAGE_COMPLETE' + MESSAGE_COMPLETE: 'MESSAGE_COMPLETE', + SEND_MESSAGE: 'SEND_MESSAGE' } })) @@ -340,6 +337,8 @@ describe('streamCallback Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() + MockCacheUtils.resetMocks() + MockDataApiUtils.resetMocks() store = createMockStore() // Add initial message state for tests @@ -391,20 +390,25 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 (v2架构通过DataApiService持久化) + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + status?: string + stats?: { totalTokens?: number } + data?: { blocks?: Array<{ type: string; content?: string }> } + } + expect(persistedData).toBeDefined() + + // 验证blocks (data.blocks 格式) + const blocks = persistedData?.data?.blocks || [] expect(blocks.length).toBeGreaterThan(0) - const textBlock = blocks.find((block) => block.type === MessageBlockType.MAIN_TEXT) + const textBlock = blocks.find((block) => block.type === 'main_text') expect(textBlock).toBeDefined() expect(textBlock?.content).toBe('Hello world!') - expect(textBlock?.status).toBe(MessageBlockStatus.SUCCESS) // 验证消息状态更新 - const message = state.messages.entities[mockAssistantMsgId] - expect(message?.status).toBe(AssistantMessageStatus.SUCCESS) - expect(message?.usage?.total_tokens).toBe(150) + expect(persistedData?.status).toBe('success') + expect(persistedData?.stats?.totalTokens).toBe(150) }) it('should handle thinking flow', async () => { @@ -422,18 +426,20 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 (v2架构通过DataApiService持久化) + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; content?: string; thinking_millsec?: number }> } + } + expect(persistedData).toBeDefined() - const thinkingBlock = blocks.find((block) => block.type === MessageBlockType.THINKING) + const blocks = persistedData?.data?.blocks || [] + const thinkingBlock = blocks.find((block) => block.type === 'thinking') expect(thinkingBlock).toBeDefined() expect(thinkingBlock?.content).toBe('Final thoughts') - expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS) // thinking_millsec 现在是本地计算的,只验证它存在且是一个合理的数字 - expect((thinkingBlock as any)?.thinking_millsec).toBeDefined() - expect(typeof (thinkingBlock as any)?.thinking_millsec).toBe('number') - expect((thinkingBlock as any)?.thinking_millsec).toBeGreaterThanOrEqual(0) + expect(thinkingBlock?.thinking_millsec).toBeDefined() + expect(typeof thinkingBlock?.thinking_millsec).toBe('number') + expect(thinkingBlock?.thinking_millsec).toBeGreaterThanOrEqual(0) }) it('should handle tool call flow', async () => { @@ -496,15 +502,17 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; content?: string; toolName?: string }> } + } + expect(persistedData).toBeDefined() - const toolBlock = blocks.find((block) => block.type === MessageBlockType.TOOL) + const blocks = persistedData?.data?.blocks || [] + const toolBlock = blocks.find((block) => block.type === 'tool') expect(toolBlock).toBeDefined() expect(toolBlock?.content).toBe('Tool result') - expect(toolBlock?.status).toBe(MessageBlockStatus.SUCCESS) - expect((toolBlock as any)?.toolName).toBe('test-tool') + expect(toolBlock?.toolName).toBe('test-tool') }) it('should handle image generation flow', async () => { @@ -536,15 +544,18 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) - const imageBlock = blocks.find((block) => block.type === MessageBlockType.IMAGE) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; url?: string }> } + } + expect(persistedData).toBeDefined() + + const blocks = persistedData?.data?.blocks || [] + const imageBlock = blocks.find((block) => block.type === 'image') expect(imageBlock).toBeDefined() expect(imageBlock?.url).toBe( 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAQABADASIAAhEBAxEB/8QAFwAAAwEAAAAAAAAAAAAAAAAAAQMEB//EACMQAAIBAwMEAwAAAAAAAAAAAAECAwAEEQUSIQYxQVExUYH/xAAVAQEBAAAAAAAAAAAAAAAAAAAAAf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/AM/8A//Z' ) - expect(imageBlock?.status).toBe(MessageBlockStatus.SUCCESS) }) it('should handle web search flow', async () => { @@ -564,13 +575,16 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) - const citationBlock = blocks.find((block) => block.type === MessageBlockType.CITATION) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; response?: { source?: string } }> } + } + expect(persistedData).toBeDefined() + + const blocks = persistedData?.data?.blocks || [] + const citationBlock = blocks.find((block) => block.type === 'citation') expect(citationBlock).toBeDefined() expect(citationBlock?.response?.source).toEqual(mockWebSearchResult.source) - expect(citationBlock?.status).toBe(MessageBlockStatus.SUCCESS) }) it('should handle mixed content flow (thinking + tool + text)', async () => { @@ -656,23 +670,23 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; content?: string }> } + } + expect(persistedData).toBeDefined() + const blocks = persistedData?.data?.blocks || [] expect(blocks.length).toBeGreaterThan(2) // 至少有思考块、工具块、文本块 - const thinkingBlock = blocks.find((block) => block.type === MessageBlockType.THINKING) + const thinkingBlock = blocks.find((block) => block.type === 'thinking') expect(thinkingBlock?.content).toBe('Let me calculate this..., I need to use a calculator') - expect(thinkingBlock?.status).toBe(MessageBlockStatus.SUCCESS) - const toolBlock = blocks.find((block) => block.type === MessageBlockType.TOOL) + const toolBlock = blocks.find((block) => block.type === 'tool') expect(toolBlock?.content).toBe('42') - expect(toolBlock?.status).toBe(MessageBlockStatus.SUCCESS) - const textBlock = blocks.find((block) => block.type === MessageBlockType.MAIN_TEXT) + const textBlock = blocks.find((block) => block.type === 'main_text') expect(textBlock?.content).toBe('The answer is 42') - expect(textBlock?.status).toBe(MessageBlockStatus.SUCCESS) }) it('should handle error flow', async () => { @@ -689,20 +703,22 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + status?: string + data?: { blocks?: Array<{ type: string; error?: { message: string } }> } + } + expect(persistedData).toBeDefined() + const blocks = persistedData?.data?.blocks || [] expect(blocks.length).toBeGreaterThan(0) - const errorBlock = blocks.find((block) => block.type === MessageBlockType.ERROR) + const errorBlock = blocks.find((block) => block.type === 'error') expect(errorBlock).toBeDefined() - expect(errorBlock?.status).toBe(MessageBlockStatus.SUCCESS) - expect((errorBlock as any)?.error?.message).toBe('Test error') + expect(errorBlock?.error?.message).toBe('Test error') // 验证消息状态更新 - const message = state.messages.entities[mockAssistantMsgId] - expect(message?.status).toBe(AssistantMessageStatus.ERROR) + expect(persistedData?.status).toBe('error') }) it('should handle external tool flow', async () => { @@ -732,15 +748,17 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; response?: unknown; knowledge?: unknown }> } + } + expect(persistedData).toBeDefined() - const citationBlock = blocks.find((block) => block.type === MessageBlockType.CITATION) + const blocks = persistedData?.data?.blocks || [] + const citationBlock = blocks.find((block) => block.type === 'citation') expect(citationBlock).toBeDefined() - expect((citationBlock as any)?.response).toEqual(mockExternalToolResult.webSearch) - expect((citationBlock as any)?.knowledge).toEqual(mockExternalToolResult.knowledge) - expect(citationBlock?.status).toBe(MessageBlockStatus.SUCCESS) + expect(citationBlock?.response).toEqual(mockExternalToolResult.webSearch) + expect(citationBlock?.knowledge).toEqual(mockExternalToolResult.knowledge) }) it('should handle abort error correctly', async () => { @@ -759,19 +777,21 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + status?: string + data?: { blocks?: Array<{ type: string }> } + } + expect(persistedData).toBeDefined() + const blocks = persistedData?.data?.blocks || [] expect(blocks.length).toBeGreaterThan(0) - const errorBlock = blocks.find((block) => block.type === MessageBlockType.ERROR) + const errorBlock = blocks.find((block) => block.type === 'error') expect(errorBlock).toBeDefined() - expect(errorBlock?.status).toBe(MessageBlockStatus.SUCCESS) // 验证消息状态更新为成功(因为是暂停,不是真正的错误) - const message = state.messages.entities[mockAssistantMsgId] - expect(message?.status).toBe(AssistantMessageStatus.SUCCESS) + expect(persistedData?.status).toBe('success') }) it('should maintain block reference integrity during streaming', async () => { @@ -788,23 +808,20 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = store.getState() - const blocks = Object.values(state.messageBlocks.entities) - const message = state.messages.entities[mockAssistantMsgId] + // 验证持久化数据 + const persistedData = getPersistedDataForMessage(mockAssistantMsgId) as { + data?: { blocks?: Array<{ type: string; content?: string }> } + } + expect(persistedData).toBeDefined() - // 验证消息的 blocks 数组包含正确的块ID - expect(message?.blocks).toBeDefined() - expect(message?.blocks?.length).toBeGreaterThan(0) - - // 验证所有块都存在于 messageBlocks 状态中 - message?.blocks?.forEach((blockId) => { - const block = state.messageBlocks.entities[blockId] - expect(block).toBeDefined() - expect(block?.messageId).toBe(mockAssistantMsgId) - }) + const blocks = persistedData?.data?.blocks || [] // 验证blocks包含正确的内容 expect(blocks.length).toBeGreaterThan(0) + + // 验证有main_text block + const textBlock = blocks.find((block) => block.type === 'main_text') + expect(textBlock).toBeDefined() + expect(textBlock?.content).toBe('First chunkSecond chunk') }) }) From 24288cecf9689c3643c5f818250e39e50b4e5c25 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 11:33:50 +0800 Subject: [PATCH 096/116] refactor: simplify error handling and session finalization in StreamingService - Removed retry logic for finalizing streaming sessions, streamlining the process to handle success and error cases more clearly. - Enhanced error logging to provide immediate feedback on failures without retaining session data, preventing potential memory leaks. - Improved code readability by consolidating the success and error handling paths, ensuring a more straightforward flow in the session finalization logic. --- .../messageStreaming/StreamingService.ts | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 9dd4bb3835..1dc31fee35 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -206,43 +206,25 @@ class StreamingService { return } - const maxRetries = 3 - let lastError: Error | null = null - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { + try { + // Route to appropriate data source based on topic type + // TEMPORARY: Agent sessions use dbService until migration to Data API is complete + if (isAgentSessionTopicId(session.topicId)) { const updatePayload = this.convertToUpdatePayload(session, status) - - // Route to appropriate data source based on topic type - // TEMPORARY: Agent sessions use dbService until migration to Data API is complete - if (isAgentSessionTopicId(session.topicId)) { - await dbService.updateMessageAndBlocks(session.topicId, updatePayload.messageUpdates, updatePayload.blocks) - } else { - // Normal topic → Use Data API for persistence (v2 target architecture) - const dataApiPayload = this.convertToDataApiFormat(session, status) - await dataApiService.patch(`/messages/${session.messageId}`, { body: dataApiPayload }) - } - - // Success - cleanup session - this.clearSession(messageId) - logger.debug('Finalized streaming session', { messageId, status }) - return - } catch (error) { - lastError = error as Error - logger.warn(`finalize attempt ${attempt}/${maxRetries} failed:`, error as Error) - - if (attempt < maxRetries) { - // Exponential backoff - await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) - } + await dbService.updateMessageAndBlocks(session.topicId, updatePayload.messageUpdates, updatePayload.blocks) + } else { + // Normal topic → Use Data API for persistence (has built-in retry) + const dataApiPayload = this.convertToDataApiFormat(session, status) + await dataApiService.patch(`/messages/${session.messageId}`, { body: dataApiPayload }) } - } - // All retries failed - logger.error(`finalize failed after ${maxRetries} attempts:`, lastError) - // TRADEOFF: Don't clear session to allow manual retry - // TTL will auto-clean to prevent permanent memory leak - throw lastError + this.clearSession(messageId) + logger.debug('Finalized streaming session', { messageId, status }) + } catch (error) { + logger.error('finalize failed:', error as Error) + // Don't clear session on error - TTL will auto-clean to prevent memory leak + throw error + } } /** From 680bda3993ced26b028cdafbb61fadc5a4a70822 Mon Sep 17 00:00:00 2001 From: Phantom Date: Sun, 4 Jan 2026 13:25:08 +0800 Subject: [PATCH 097/116] fix(translate): Fix ActionTranslate duplicate execution and getLanguageByLangcode logic (#12241) * refactor(translate): remove default temperature setting * refactor(translate): Remove temperature setting from language detection prompt * refactor: Set default translate languages from user settings Initialize target language from user's language setting, falling back to zh-CN if unknown. Set alter language to en-US by default. Remove redundant logic that was duplicated in the effect. * fix(translate): translate action won't trigger twice * fix(translate): Fix getLanguageByLangcode to return built-in language when not loaded Previously, the function would always return UNKNOWN if the languages were not loaded, even for built-in language codes. This change ensures built-in languages are returned if found, regardless of the load state. * fix: Expose translation languages loaded state and fix initialization Add `isLoaded` flag to `useTranslate` hook to track when translation languages are available. Use this flag to prevent premature initialization in `ActionTranslate` component, ensuring language lookups succeed before creating assistants and topics. Add error logging for failed custom language loading and update fallback warning messages for better debugging. * fix: set initialized when finished --- src/renderer/src/hooks/useTranslate.ts | 11 +- src/renderer/src/services/AssistantService.ts | 1 - src/renderer/src/utils/translate.ts | 5 +- .../action/components/ActionTranslate.tsx | 112 ++++++++++++------ 4 files changed, 81 insertions(+), 48 deletions(-) diff --git a/src/renderer/src/hooks/useTranslate.ts b/src/renderer/src/hooks/useTranslate.ts index 6191560d3d..8b4f7147bb 100644 --- a/src/renderer/src/hooks/useTranslate.ts +++ b/src/renderer/src/hooks/useTranslate.ts @@ -36,18 +36,16 @@ export default function useTranslate() { const getLanguageByLangcode = useCallback( (langCode: string) => { - if (!isLoaded) { - logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.') - return UNKNOWN - } - const result = translateLanguages.find((item) => item.langCode === langCode) + if (result) { return result + } else if (!isLoaded) { + logger.verbose('Translate languages are not loaded yet. Return UNKNOWN.') } else { logger.warn(`Unknown language ${langCode}`) - return UNKNOWN } + return UNKNOWN }, [isLoaded, translateLanguages] ) @@ -63,6 +61,7 @@ export default function useTranslate() { prompt, settings, translateLanguages, + isLoaded, getLanguageByLangcode, updateSettings: handleUpdateSettings } diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index 6f4ec188da..97f2a6f179 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -116,7 +116,6 @@ export function getDefaultTranslateAssistant( // disable reasoning if it could be disabled, otherwise no configuration const reasoningEffort = supportedOptions?.includes('none') ? 'none' : 'default' const settings = { - temperature: 0.7, reasoning_effort: reasoningEffort, ..._settings } satisfies Partial diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index 4e01649369..d724bdb35e 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -83,9 +83,7 @@ const detectLanguageByLLM = async (inputText: string): Promise void = (chunk: Chunk) => { @@ -257,6 +255,7 @@ export const getTranslateOptions = async () => { })) return [...builtinLanguages, ...transformedCustomLangs] } catch (e) { + logger.error('[getTranslateOptions] Failed to get custom languages. Fallback to builtinLanguages', e as Error) return builtinLanguages } } diff --git a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx index 5e83071add..b5e0fea689 100644 --- a/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx +++ b/src/renderer/src/windows/selection/action/components/ActionTranslate.tsx @@ -11,7 +11,6 @@ import MessageContent from '@renderer/pages/home/Messages/MessageContent' import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService' import type { Assistant, Topic, TranslateLanguage, TranslateLanguageCode } from '@renderer/types' import type { ActionItem } from '@renderer/types/selectionTypes' -import { runAsyncFunction } from '@renderer/utils' import { abortCompletion } from '@renderer/utils/abortController' import { detectLanguage } from '@renderer/utils/translate' import { Tooltip } from 'antd' @@ -32,70 +31,102 @@ const logger = loggerService.withContext('ActionTranslate') const ActionTranslate: FC = ({ action, scrollToBottom }) => { const { t } = useTranslation() - const { translateModelPrompt, language } = useSettings() + const { language } = useSettings() + const { getLanguageByLangcode, isLoaded: isLanguagesLoaded } = useTranslate() - const [targetLanguage, setTargetLanguage] = useState(LanguagesEnum.enUS) - const [alterLanguage, setAlterLanguage] = useState(LanguagesEnum.zhCN) + const [targetLanguage, setTargetLanguage] = useState(() => { + const lang = getLanguageByLangcode(language) + if (lang !== UNKNOWN) { + return lang + } else { + logger.warn('[initialize targetLanguage] Unexpected UNKNOWN. Fallback to zh-CN') + return LanguagesEnum.zhCN + } + }) + + const [alterLanguage, setAlterLanguage] = useState(LanguagesEnum.enUS) const [error, setError] = useState('') const [showOriginal, setShowOriginal] = useState(false) const [isContented, setIsContented] = useState(false) const [isLoading, setIsLoading] = useState(true) const [contentToCopy, setContentToCopy] = useState('') - const { getLanguageByLangcode } = useTranslate() // Use useRef for values that shouldn't trigger re-renders const initialized = useRef(false) const assistantRef = useRef(null) const topicRef = useRef(null) const askId = useRef('') + const targetLangRef = useRef(targetLanguage) - useEffect(() => { - runAsyncFunction(async () => { - const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' }) + // It's called only in initialization. + // It will change target/alter language, so fetchResult will be triggered. Be careful! + const updateLanguagePair = useCallback(async () => { + // Only called is when languages loaded. + // It ensure we could get right language from getLanguageByLangcode. + if (!isLanguagesLoaded) { + logger.silly('[updateLanguagePair] Languages are not loaded. Skip.') + return + } - let targetLang: TranslateLanguage - let alterLang: TranslateLanguage - - if (!biDirectionLangPair || !biDirectionLangPair.value[0]) { - const lang = getLanguageByLangcode(language) - if (lang !== UNKNOWN) { - targetLang = lang - } else { - logger.warn('Fallback to zh-CN') - targetLang = LanguagesEnum.zhCN - } - } else { - targetLang = getLanguageByLangcode(biDirectionLangPair.value[0]) - } - - if (!biDirectionLangPair || !biDirectionLangPair.value[1]) { - alterLang = LanguagesEnum.enUS - } else { - alterLang = getLanguageByLangcode(biDirectionLangPair.value[1]) - } + const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' }) + if (biDirectionLangPair && biDirectionLangPair.value[0]) { + const targetLang = getLanguageByLangcode(biDirectionLangPair.value[0]) setTargetLanguage(targetLang) - setAlterLanguage(alterLang) - }) - }, [getLanguageByLangcode, language]) + targetLangRef.current = targetLang + } - // Initialize values only once when action changes - useEffect(() => { - if (initialized.current || !action.selectedText) return - initialized.current = true + if (biDirectionLangPair && biDirectionLangPair.value[1]) { + const alterLang = getLanguageByLangcode(biDirectionLangPair.value[1]) + setAlterLanguage(alterLang) + } + }, [getLanguageByLangcode, isLanguagesLoaded]) + + // Initialize values only once + const initialize = useCallback(async () => { + if (initialized.current) { + logger.silly('[initialize] Already initialized.') + return + } + + // Only try to initialize when languages loaded, so updateLanguagePair would not fail. + if (!isLanguagesLoaded) { + logger.silly('[initialize] Languages not loaded. Skip initialization.') + return + } + + // Edge case + if (action.selectedText === undefined) { + logger.error('[initialize] No selected text.') + return + } + logger.silly('[initialize] Start initialization.') + + // Initialize language pair. + // It will update targetLangRef, so we could get latest target language in the following code + await updateLanguagePair() // Initialize assistant - const currentAssistant = getDefaultTranslateAssistant(targetLanguage, action.selectedText) + const currentAssistant = getDefaultTranslateAssistant(targetLangRef.current, action.selectedText) assistantRef.current = currentAssistant // Initialize topic topicRef.current = getDefaultTopic(currentAssistant.id) - }, [action, targetLanguage, translateModelPrompt]) + initialized.current = true + }, [action.selectedText, isLanguagesLoaded, updateLanguagePair]) + + // Try to initialize when: + // 1. action.selectedText change (generally will not) + // 2. isLanguagesLoaded change (only initialize when languages loaded) + // 3. updateLanguagePair change (depend on translateLanguages and isLanguagesLoaded) + useEffect(() => { + initialize() + }, [initialize]) const fetchResult = useCallback(async () => { - if (!assistantRef.current || !topicRef.current || !action.selectedText) return + if (!assistantRef.current || !topicRef.current || !action.selectedText || !initialized.current) return const setAskId = (id: string) => { askId.current = id @@ -141,6 +172,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const assistant = getDefaultTranslateAssistant(translateLang, action.selectedText) assistantRef.current = assistant + logger.debug('process once') processMessages(assistant, topicRef.current, assistant.content, setAskId, onStream, onFinish, onError) }, [action, targetLanguage, alterLanguage, scrollToBottom]) @@ -157,7 +189,11 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { }, [allMessages]) const handleChangeLanguage = (targetLanguage: TranslateLanguage, alterLanguage: TranslateLanguage) => { + if (!initialized.current) { + return + } setTargetLanguage(targetLanguage) + targetLangRef.current = targetLanguage setAlterLanguage(alterLanguage) db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] }) From 86adb2e11c4a02c8d7cc28e0d5c50560ac7f62b2 Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:28:48 +0800 Subject: [PATCH 098/116] feat(browser): add user data persistence and multi-tab support (#12082) - Add session-based user data persistence using Electron partitions - Implement multi-tab support with tab management operations - Add new tools: create_tab, list_tabs, close_tab, switch_tab - Update existing tools (open, execute, fetch, reset) to support tabId parameter - Refactor controller to manage sessions with multiple tabs - Add comprehensive documentation in README.md - Add TypeScript interfaces for SessionInfo and TabInfo BREAKING CHANGE: Controller now manages sessions with tabs instead of single windows per session --- src/main/mcpServers/__tests__/browser.test.ts | 306 +++++- src/main/mcpServers/browser/README.md | 177 ++++ src/main/mcpServers/browser/constants.ts | 3 + src/main/mcpServers/browser/controller.ts | 884 +++++++++++++++--- src/main/mcpServers/browser/tabbar-html.ts | 567 +++++++++++ src/main/mcpServers/browser/tools/execute.ts | 32 +- src/main/mcpServers/browser/tools/fetch.ts | 49 - src/main/mcpServers/browser/tools/index.ts | 7 +- src/main/mcpServers/browser/tools/open.ts | 70 +- src/main/mcpServers/browser/tools/reset.ts | 37 +- src/main/mcpServers/browser/tools/utils.ts | 5 +- src/main/mcpServers/browser/types.ts | 22 +- 12 files changed, 1881 insertions(+), 278 deletions(-) create mode 100644 src/main/mcpServers/browser/README.md create mode 100644 src/main/mcpServers/browser/constants.ts create mode 100644 src/main/mcpServers/browser/tabbar-html.ts delete mode 100644 src/main/mcpServers/browser/tools/fetch.ts diff --git a/src/main/mcpServers/__tests__/browser.test.ts b/src/main/mcpServers/__tests__/browser.test.ts index 712eaf94ea..800d03d7c5 100644 --- a/src/main/mcpServers/__tests__/browser.test.ts +++ b/src/main/mcpServers/__tests__/browser.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from 'vitest' +vi.mock('node:fs', () => ({ + default: { + existsSync: vi.fn(() => false), + mkdirSync: vi.fn() + }, + existsSync: vi.fn(() => false), + mkdirSync: vi.fn() +})) + vi.mock('electron', () => { const sendCommand = vi.fn(async (command: string, params?: { expression?: string }) => { if (command === 'Runtime.evaluate') { @@ -21,24 +30,31 @@ vi.mock('electron', () => { sendCommand } - const webContents = { + const createWebContents = () => ({ debugger: debuggerObj, setUserAgent: vi.fn(), getURL: vi.fn(() => 'https://example.com/'), getTitle: vi.fn(async () => 'Example Title'), + loadURL: vi.fn(async () => {}), once: vi.fn(), removeListener: vi.fn(), - on: vi.fn() - } - - const loadURL = vi.fn(async () => {}) + on: vi.fn(), + isDestroyed: vi.fn(() => false), + canGoBack: vi.fn(() => false), + canGoForward: vi.fn(() => false), + goBack: vi.fn(), + goForward: vi.fn(), + reload: vi.fn(), + executeJavaScript: vi.fn(async () => null), + setWindowOpenHandler: vi.fn() + }) const windows: any[] = [] + const views: any[] = [] class MockBrowserWindow { private destroyed = false - public webContents = webContents - public loadURL = loadURL + public webContents = createWebContents() public isDestroyed = vi.fn(() => this.destroyed) public close = vi.fn(() => { this.destroyed = true @@ -47,31 +63,58 @@ vi.mock('electron', () => { this.destroyed = true }) public on = vi.fn() + public setBrowserView = vi.fn() + public addBrowserView = vi.fn() + public removeBrowserView = vi.fn() + public getContentSize = vi.fn(() => [1200, 800]) + public show = vi.fn() constructor() { windows.push(this) } } + class MockBrowserView { + public webContents = createWebContents() + public setBounds = vi.fn() + public setAutoResize = vi.fn() + public destroy = vi.fn() + + constructor() { + views.push(this) + } + } + const app = { isReady: vi.fn(() => true), whenReady: vi.fn(async () => {}), - on: vi.fn() + on: vi.fn(), + getPath: vi.fn((key: string) => { + if (key === 'userData') return '/mock/userData' + if (key === 'temp') return '/tmp' + return '/mock/unknown' + }), + getAppPath: vi.fn(() => '/mock/app'), + setPath: vi.fn() + } + + const nativeTheme = { + on: vi.fn(), + shouldUseDarkColors: false } return { BrowserWindow: MockBrowserWindow as any, + BrowserView: MockBrowserView as any, app, + nativeTheme, __mockDebugger: debuggerObj, __mockSendCommand: sendCommand, - __mockLoadURL: loadURL, - __mockWindows: windows + __mockWindows: windows, + __mockViews: views } }) -import * as electron from 'electron' -const { __mockWindows } = electron as typeof electron & { __mockWindows: any[] } - import { CdpBrowserController } from '../browser' describe('CdpBrowserController', () => { @@ -81,54 +124,249 @@ describe('CdpBrowserController', () => { expect(result).toBe('ok') }) - it('opens a URL (hidden) and returns current page info', async () => { + it('opens a URL in normal mode and returns current page info', async () => { const controller = new CdpBrowserController() const result = await controller.open('https://foo.bar/', 5000, false) expect(result.currentUrl).toBe('https://example.com/') expect(result.title).toBe('Example Title') }) - it('opens a URL (visible) when show=true', async () => { + it('opens a URL in private mode', async () => { const controller = new CdpBrowserController() - const result = await controller.open('https://foo.bar/', 5000, true, 'session-a') + const result = await controller.open('https://foo.bar/', 5000, true) expect(result.currentUrl).toBe('https://example.com/') expect(result.title).toBe('Example Title') }) it('reuses session for execute and supports multiline', async () => { const controller = new CdpBrowserController() - await controller.open('https://foo.bar/', 5000, false, 'session-b') - const result = await controller.execute('const a=1; const b=2; a+b;', 5000, 'session-b') + await controller.open('https://foo.bar/', 5000, false) + const result = await controller.execute('const a=1; const b=2; a+b;', 5000, false) expect(result).toBe('ok') }) - it('evicts least recently used session when exceeding maxSessions', async () => { - const controller = new CdpBrowserController({ maxSessions: 2, idleTimeoutMs: 1000 * 60 }) - await controller.open('https://foo.bar/', 5000, false, 's1') - await controller.open('https://foo.bar/', 5000, false, 's2') - await controller.open('https://foo.bar/', 5000, false, 's3') - const destroyedCount = __mockWindows.filter( - (w: any) => w.destroy.mock.calls.length > 0 || w.close.mock.calls.length > 0 - ).length - expect(destroyedCount).toBeGreaterThanOrEqual(1) + it('normal and private modes are isolated', async () => { + const controller = new CdpBrowserController() + await controller.open('https://foo.bar/', 5000, false) + await controller.open('https://foo.bar/', 5000, true) + const normalResult = await controller.execute('1+1', 5000, false) + const privateResult = await controller.execute('1+1', 5000, true) + expect(normalResult).toBe('ok') + expect(privateResult).toBe('ok') }) - it('fetches URL and returns html format', async () => { + it('fetches URL and returns html format with tabId', async () => { const controller = new CdpBrowserController() const result = await controller.fetch('https://example.com/', 'html') - expect(result).toBe('

Test

Content

') + expect(result.tabId).toBeDefined() + expect(result.content).toBe('

Test

Content

') }) - it('fetches URL and returns txt format', async () => { + it('fetches URL and returns txt format with tabId', async () => { const controller = new CdpBrowserController() const result = await controller.fetch('https://example.com/', 'txt') - expect(result).toBe('Test\nContent') + expect(result.tabId).toBeDefined() + expect(result.content).toBe('Test\nContent') }) - it('fetches URL and returns markdown format (default)', async () => { + it('fetches URL and returns markdown format (default) with tabId', async () => { const controller = new CdpBrowserController() const result = await controller.fetch('https://example.com/') - expect(typeof result).toBe('string') - expect(result).toContain('Test') + expect(result.tabId).toBeDefined() + expect(typeof result.content).toBe('string') + expect(result.content).toContain('Test') + }) + + it('fetches URL in private mode with tabId', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'html', 10000, true) + expect(result.tabId).toBeDefined() + expect(result.content).toBe('

Test

Content

') + }) + + describe('Multi-tab support', () => { + it('creates new tab with newTab parameter', async () => { + const controller = new CdpBrowserController() + const result1 = await controller.open('https://site1.com/', 5000, false, true) + const result2 = await controller.open('https://site2.com/', 5000, false, true) + + expect(result1.tabId).toBeDefined() + expect(result2.tabId).toBeDefined() + expect(result1.tabId).not.toBe(result2.tabId) + }) + + it('reuses same tab without newTab parameter', async () => { + const controller = new CdpBrowserController() + const result1 = await controller.open('https://site1.com/', 5000, false) + const result2 = await controller.open('https://site2.com/', 5000, false) + + expect(result1.tabId).toBe(result2.tabId) + }) + + it('fetches in new tab with newTab parameter', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + const tabs = await controller.listTabs(false) + const initialTabCount = tabs.length + + await controller.fetch('https://other.com/', 'html', 10000, false, true) + const tabsAfter = await controller.listTabs(false) + + expect(tabsAfter.length).toBe(initialTabCount + 1) + }) + }) + + describe('Tab management', () => { + it('lists tabs in a window', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + + const tabs = await controller.listTabs(false) + expect(tabs.length).toBeGreaterThan(0) + expect(tabs[0].tabId).toBeDefined() + }) + + it('lists tabs separately for normal and private modes', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + await controller.open('https://example.com/', 5000, true) + + const normalTabs = await controller.listTabs(false) + const privateTabs = await controller.listTabs(true) + + expect(normalTabs.length).toBe(1) + expect(privateTabs.length).toBe(1) + expect(normalTabs[0].tabId).not.toBe(privateTabs[0].tabId) + }) + + it('closes specific tab', async () => { + const controller = new CdpBrowserController() + const result1 = await controller.open('https://site1.com/', 5000, false, true) + await controller.open('https://site2.com/', 5000, false, true) + + const tabsBefore = await controller.listTabs(false) + expect(tabsBefore.length).toBe(2) + + await controller.closeTab(false, result1.tabId) + + const tabsAfter = await controller.listTabs(false) + expect(tabsAfter.length).toBe(1) + expect(tabsAfter.find((t) => t.tabId === result1.tabId)).toBeUndefined() + }) + + it('switches active tab', async () => { + const controller = new CdpBrowserController() + const result1 = await controller.open('https://site1.com/', 5000, false, true) + const result2 = await controller.open('https://site2.com/', 5000, false, true) + + await controller.switchTab(false, result1.tabId) + await controller.switchTab(false, result2.tabId) + }) + + it('throws error when switching to non-existent tab', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + + await expect(controller.switchTab(false, 'non-existent-tab')).rejects.toThrow('Tab non-existent-tab not found') + }) + }) + + describe('Reset behavior', () => { + it('resets specific tab only', async () => { + const controller = new CdpBrowserController() + const result1 = await controller.open('https://site1.com/', 5000, false, true) + await controller.open('https://site2.com/', 5000, false, true) + + await controller.reset(false, result1.tabId) + + const tabs = await controller.listTabs(false) + expect(tabs.length).toBe(1) + }) + + it('resets specific window only', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + await controller.open('https://example.com/', 5000, true) + + await controller.reset(false) + + const normalTabs = await controller.listTabs(false) + const privateTabs = await controller.listTabs(true) + + expect(normalTabs.length).toBe(0) + expect(privateTabs.length).toBe(1) + }) + + it('resets all windows', async () => { + const controller = new CdpBrowserController() + await controller.open('https://example.com/', 5000, false) + await controller.open('https://example.com/', 5000, true) + + await controller.reset() + + const normalTabs = await controller.listTabs(false) + const privateTabs = await controller.listTabs(true) + + expect(normalTabs.length).toBe(0) + expect(privateTabs.length).toBe(0) + }) + }) + + describe('showWindow parameter', () => { + it('passes showWindow parameter through open', async () => { + const controller = new CdpBrowserController() + const result = await controller.open('https://example.com/', 5000, false, false, true) + expect(result.currentUrl).toBe('https://example.com/') + expect(result.tabId).toBeDefined() + }) + + it('passes showWindow parameter through fetch', async () => { + const controller = new CdpBrowserController() + const result = await controller.fetch('https://example.com/', 'html', 10000, false, false, true) + expect(result.tabId).toBeDefined() + expect(result.content).toBe('

Test

Content

') + }) + + it('passes showWindow parameter through createTab', async () => { + const controller = new CdpBrowserController() + const { tabId, view } = await controller.createTab(false, true) + expect(tabId).toBeDefined() + expect(view).toBeDefined() + }) + + it('shows existing window when showWindow=true on subsequent calls', async () => { + const controller = new CdpBrowserController() + // First call creates window + await controller.open('https://example.com/', 5000, false, false, false) + // Second call with showWindow=true should show existing window + const result = await controller.open('https://example.com/', 5000, false, false, true) + expect(result.currentUrl).toBe('https://example.com/') + }) + }) + + describe('Window limits and eviction', () => { + it('respects maxWindows limit', async () => { + const controller = new CdpBrowserController({ maxWindows: 1 }) + await controller.open('https://example.com/', 5000, false) + await controller.open('https://example.com/', 5000, true) + + const normalTabs = await controller.listTabs(false) + const privateTabs = await controller.listTabs(true) + + expect(privateTabs.length).toBe(1) + expect(normalTabs.length).toBe(0) + }) + + it('cleans up idle windows on next access', async () => { + const controller = new CdpBrowserController({ idleTimeoutMs: 1 }) + await controller.open('https://example.com/', 5000, false) + + await new Promise((r) => setTimeout(r, 10)) + + await controller.open('https://example.com/', 5000, true) + + const normalTabs = await controller.listTabs(false) + expect(normalTabs.length).toBe(0) + }) }) }) diff --git a/src/main/mcpServers/browser/README.md b/src/main/mcpServers/browser/README.md new file mode 100644 index 0000000000..27d1307782 --- /dev/null +++ b/src/main/mcpServers/browser/README.md @@ -0,0 +1,177 @@ +# Browser MCP Server + +A Model Context Protocol (MCP) server for controlling browser windows via Chrome DevTools Protocol (CDP). + +## Features + +### ✨ User Data Persistence +- **Normal mode (default)**: Cookies, localStorage, and sessionStorage persist across browser restarts +- **Private mode**: Ephemeral browsing - no data persists (like incognito mode) + +### 🔄 Window Management +- Two browsing modes: normal (persistent) and private (ephemeral) +- Lazy idle timeout cleanup (cleaned on next window access) +- Maximum window limits to prevent resource exhaustion + +> **Note**: Normal mode uses a global `persist:default` partition shared by all clients. This means login sessions and stored data are accessible to any code using the MCP server. + +## Architecture + +### How It Works +``` +Normal Mode (BrowserWindow) +├─ Persistent Storage (partition: persist:default) ← Global, shared across all clients +└─ Tabs (BrowserView) ← created via newTab or automatically + +Private Mode (BrowserWindow) +├─ Ephemeral Storage (partition: private) ← No disk persistence +└─ Tabs (BrowserView) ← created via newTab or automatically +``` + +- **One Window Per Mode**: Normal and private modes each have their own window +- **Multi-Tab Support**: Use `newTab: true` for parallel URL requests +- **Storage Isolation**: Normal and private modes have completely separate storage + +## Available Tools + +### `open` +Open a URL in a browser window. Optionally return page content. +```json +{ + "url": "https://example.com", + "format": "markdown", + "timeout": 10000, + "privateMode": false, + "newTab": false, + "showWindow": false +} +``` +- `format`: If set (`html`, `txt`, `markdown`, `json`), returns page content in that format along with tabId. If not set, just opens the page and returns navigation info. +- `newTab`: Set to `true` to open in a new tab (required for parallel requests) +- `showWindow`: Set to `true` to display the browser window (useful for debugging) +- Returns (without format): `{ currentUrl, title, tabId }` +- Returns (with format): `{ tabId, content }` where content is in the specified format + +### `execute` +Execute JavaScript code in the page context. +```json +{ + "code": "document.title", + "timeout": 5000, + "privateMode": false, + "tabId": "optional-tab-id" +} +``` +- `tabId`: Target a specific tab (from `open` response) + +### `reset` +Reset browser windows and tabs. +```json +{ + "privateMode": false, + "tabId": "optional-tab-id" +} +``` +- Omit all parameters to close all windows +- Set `privateMode` to close a specific window +- Set both `privateMode` and `tabId` to close a specific tab only + +## Usage Examples + +### Basic Navigation +```typescript +// Open a URL in normal mode (data persists) +await controller.open('https://example.com') +``` + +### Fetch Page Content +```typescript +// Open URL and get content as markdown +await open({ url: 'https://example.com', format: 'markdown' }) + +// Open URL and get raw HTML +await open({ url: 'https://example.com', format: 'html' }) +``` + +### Multi-Tab / Parallel Requests +```typescript +// Open multiple URLs in parallel using newTab +const [page1, page2] = await Promise.all([ + controller.open('https://site1.com', 10000, false, true), // newTab: true + controller.open('https://site2.com', 10000, false, true) // newTab: true +]) + +// Execute on specific tab +await controller.execute('document.title', 5000, false, page1.tabId) + +// Close specific tab when done +await controller.reset(false, page1.tabId) +``` + +### Private Browsing +```typescript +// Open a URL in private mode (no data persistence) +await controller.open('https://example.com', 10000, true) + +// Cookies and localStorage won't persist after reset +``` + +### Data Persistence (Normal Mode) +```typescript +// Set data +await controller.open('https://example.com', 10000, false) +await controller.execute('localStorage.setItem("key", "value")', 5000, false) + +// Close window +await controller.reset(false) + +// Reopen - data persists! +await controller.open('https://example.com', 10000, false) +const value = await controller.execute('localStorage.getItem("key")', 5000, false) +// Returns: "value" +``` + +### No Persistence (Private Mode) +```typescript +// Set data in private mode +await controller.open('https://example.com', 10000, true) +await controller.execute('localStorage.setItem("key", "value")', 5000, true) + +// Close private window +await controller.reset(true) + +// Reopen - data is gone! +await controller.open('https://example.com', 10000, true) +const value = await controller.execute('localStorage.getItem("key")', 5000, true) +// Returns: null +``` + +## Configuration + +```typescript +const controller = new CdpBrowserController({ + maxWindows: 5, // Maximum concurrent windows + idleTimeoutMs: 5 * 60 * 1000 // 5 minutes idle timeout (lazy cleanup) +}) +``` + +> **Note on Idle Timeout**: Idle windows are cleaned up lazily when the next window is created or accessed, not on a background timer. + +## Best Practices + +1. **Use Normal Mode for Authentication**: When you need to stay logged in across sessions +2. **Use Private Mode for Sensitive Operations**: When you don't want data to persist +3. **Use `newTab: true` for Parallel Requests**: Avoid race conditions when fetching multiple URLs +4. **Resource Cleanup**: Call `reset()` when done, or `reset(privateMode, tabId)` to close specific tabs +5. **Error Handling**: All tool handlers return error responses on failure +6. **Timeout Configuration**: Adjust timeouts based on page complexity + +## Technical Details + +- **CDP Version**: 1.3 +- **User Agent**: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0 +- **Storage**: + - Normal mode: `persist:default` (disk-persisted, global) + - Private mode: `private` (memory only) +- **Window Size**: 1200x800 (default) +- **Visibility**: Windows hidden by default (use `showWindow: true` to display) diff --git a/src/main/mcpServers/browser/constants.ts b/src/main/mcpServers/browser/constants.ts new file mode 100644 index 0000000000..2b10943f8e --- /dev/null +++ b/src/main/mcpServers/browser/constants.ts @@ -0,0 +1,3 @@ +export const TAB_BAR_HEIGHT = 92 // Height for Chrome-style tab bar (42px) + address bar (50px) +export const SESSION_KEY_DEFAULT = 'default' +export const SESSION_KEY_PRIVATE = 'private' diff --git a/src/main/mcpServers/browser/controller.ts b/src/main/mcpServers/browser/controller.ts index 6246da45d2..9e0f5220ca 100644 --- a/src/main/mcpServers/browser/controller.ts +++ b/src/main/mcpServers/browser/controller.ts @@ -1,20 +1,49 @@ -import { app, BrowserWindow } from 'electron' +import { titleBarOverlayDark, titleBarOverlayLight } from '@main/config' +import { isMac } from '@main/constant' +import { randomUUID } from 'crypto' +import { app, BrowserView, BrowserWindow, nativeTheme } from 'electron' import TurndownService from 'turndown' -import { logger, userAgent } from './types' +import { SESSION_KEY_DEFAULT, SESSION_KEY_PRIVATE, TAB_BAR_HEIGHT } from './constants' +import { TAB_BAR_HTML } from './tabbar-html' +import { logger, type TabInfo, userAgent, type WindowInfo } from './types' /** * Controller for managing browser windows via Chrome DevTools Protocol (CDP). - * Supports multiple sessions with LRU eviction and idle timeout cleanup. + * Supports two modes: normal (persistent) and private (ephemeral). + * Normal mode persists user data (cookies, localStorage, etc.) globally across all clients. + * Private mode is ephemeral - data is cleared when the window closes. */ export class CdpBrowserController { - private windows: Map = new Map() - private readonly maxSessions: number + private windows: Map = new Map() + private readonly maxWindows: number private readonly idleTimeoutMs: number + private readonly turndownService: TurndownService - constructor(options?: { maxSessions?: number; idleTimeoutMs?: number }) { - this.maxSessions = options?.maxSessions ?? 5 + constructor(options?: { maxWindows?: number; idleTimeoutMs?: number }) { + this.maxWindows = options?.maxWindows ?? 5 this.idleTimeoutMs = options?.idleTimeoutMs ?? 5 * 60 * 1000 + this.turndownService = new TurndownService() + + // Listen for theme changes and update all tab bars + nativeTheme.on('updated', () => { + const isDark = nativeTheme.shouldUseDarkColors + for (const windowInfo of this.windows.values()) { + if (windowInfo.tabBarView && !windowInfo.tabBarView.webContents.isDestroyed()) { + windowInfo.tabBarView.webContents.executeJavaScript(`window.setTheme(${isDark})`).catch(() => { + // Ignore errors if tab bar is not ready + }) + } + } + }) + } + + private getWindowKey(privateMode: boolean): string { + return privateMode ? SESSION_KEY_PRIVATE : SESSION_KEY_DEFAULT + } + + private getPartition(privateMode: boolean): string { + return privateMode ? SESSION_KEY_PRIVATE : `persist:${SESSION_KEY_DEFAULT}` } private async ensureAppReady() { @@ -23,28 +52,50 @@ export class CdpBrowserController { } } - private touch(sessionId: string) { - const entry = this.windows.get(sessionId) - if (entry) entry.lastActive = Date.now() + private touchWindow(windowKey: string) { + const windowInfo = this.windows.get(windowKey) + if (windowInfo) windowInfo.lastActive = Date.now() } - private closeWindow(win: BrowserWindow, sessionId: string) { - try { - if (!win.isDestroyed()) { - if (win.webContents.debugger.isAttached()) { - win.webContents.debugger.detach() - } - win.close() - } - } catch (error) { - logger.warn('Error closing window', { error, sessionId }) + private touchTab(windowKey: string, tabId: string) { + const windowInfo = this.windows.get(windowKey) + if (windowInfo) { + const tab = windowInfo.tabs.get(tabId) + if (tab) tab.lastActive = Date.now() + windowInfo.lastActive = Date.now() } } - private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionId: string) { + private closeTabInternal(windowInfo: WindowInfo, tabId: string) { + try { + const tab = windowInfo.tabs.get(tabId) + if (!tab) return + + if (!tab.view.webContents.isDestroyed()) { + if (tab.view.webContents.debugger.isAttached()) { + tab.view.webContents.debugger.detach() + } + } + + // Remove view from window + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.removeBrowserView(tab.view) + } + + // Destroy the view using safe cast + const viewWithDestroy = tab.view as BrowserView & { destroy?: () => void } + if (viewWithDestroy.destroy) { + viewWithDestroy.destroy() + } + } catch (error) { + logger.warn('Error closing tab', { error, windowKey: windowInfo.windowKey, tabId }) + } + } + + private async ensureDebuggerAttached(dbg: Electron.Debugger, sessionKey: string) { if (!dbg.isAttached()) { try { - logger.info('Attaching debugger', { sessionId }) + logger.info('Attaching debugger', { sessionKey }) dbg.attach('1.3') await dbg.sendCommand('Page.enable') await dbg.sendCommand('Runtime.enable') @@ -58,110 +109,514 @@ export class CdpBrowserController { private sweepIdle() { const now = Date.now() - for (const [id, entry] of this.windows.entries()) { - if (now - entry.lastActive > this.idleTimeoutMs) { - this.closeWindow(entry.win, id) - this.windows.delete(id) + const windowKeys = Array.from(this.windows.keys()) + for (const windowKey of windowKeys) { + const windowInfo = this.windows.get(windowKey) + if (!windowInfo) continue + if (now - windowInfo.lastActive > this.idleTimeoutMs) { + const tabIds = Array.from(windowInfo.tabs.keys()) + for (const tabId of tabIds) { + this.closeTabInternal(windowInfo, tabId) + } + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } + this.windows.delete(windowKey) } } } - private evictIfNeeded(newSessionId: string) { - if (this.windows.size < this.maxSessions) return - let lruId: string | null = null + private evictIfNeeded(newWindowKey: string) { + if (this.windows.size < this.maxWindows) return + let lruKey: string | null = null let lruTime = Number.POSITIVE_INFINITY - for (const [id, entry] of this.windows.entries()) { - if (id === newSessionId) continue - if (entry.lastActive < lruTime) { - lruTime = entry.lastActive - lruId = id + for (const [key, windowInfo] of this.windows.entries()) { + if (key === newWindowKey) continue + if (windowInfo.lastActive < lruTime) { + lruTime = windowInfo.lastActive + lruKey = key } } - if (lruId) { - const entry = this.windows.get(lruId) - if (entry) { - this.closeWindow(entry.win, lruId) + if (lruKey) { + const windowInfo = this.windows.get(lruKey) + if (windowInfo) { + for (const [tabId] of windowInfo.tabs.entries()) { + this.closeTabInternal(windowInfo, tabId) + } + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } } - this.windows.delete(lruId) - logger.info('Evicted session to respect maxSessions', { evicted: lruId }) + this.windows.delete(lruKey) + logger.info('Evicted window to respect maxWindows', { evicted: lruKey }) } } - private async getWindow(sessionId = 'default', forceNew = false, show = false): Promise { + private sendTabBarUpdate(windowInfo: WindowInfo) { + if (!windowInfo.tabBarView || !windowInfo.tabBarView.webContents || windowInfo.tabBarView.webContents.isDestroyed()) + return + + const tabs = Array.from(windowInfo.tabs.values()).map((tab) => ({ + id: tab.id, + title: tab.title || 'New Tab', + url: tab.url, + isActive: tab.id === windowInfo.activeTabId + })) + + let activeUrl = '' + let canGoBack = false + let canGoForward = false + + if (windowInfo.activeTabId) { + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (activeTab && !activeTab.view.webContents.isDestroyed()) { + activeUrl = activeTab.view.webContents.getURL() + canGoBack = activeTab.view.webContents.canGoBack() + canGoForward = activeTab.view.webContents.canGoForward() + } + } + + const script = `window.updateTabs(${JSON.stringify(tabs)}, ${JSON.stringify(activeUrl)}, ${canGoBack}, ${canGoForward})` + windowInfo.tabBarView.webContents.executeJavaScript(script).catch((error) => { + logger.debug('Tab bar update failed', { error, windowKey: windowInfo.windowKey }) + }) + } + + private handleNavigateAction(windowInfo: WindowInfo, url: string) { + if (!windowInfo.activeTabId) return + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (!activeTab || activeTab.view.webContents.isDestroyed()) return + + let finalUrl = url.trim() + if (!/^https?:\/\//i.test(finalUrl)) { + if (/^[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}/.test(finalUrl) || finalUrl.includes('.')) { + finalUrl = 'https://' + finalUrl + } else { + finalUrl = 'https://www.google.com/search?q=' + encodeURIComponent(finalUrl) + } + } + + activeTab.view.webContents.loadURL(finalUrl).catch((error) => { + logger.warn('Navigation failed in tab bar', { error, url: finalUrl, tabId: windowInfo.activeTabId }) + }) + } + + private handleBackAction(windowInfo: WindowInfo) { + if (!windowInfo.activeTabId) return + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (!activeTab || activeTab.view.webContents.isDestroyed()) return + + if (activeTab.view.webContents.canGoBack()) { + activeTab.view.webContents.goBack() + } + } + + private handleForwardAction(windowInfo: WindowInfo) { + if (!windowInfo.activeTabId) return + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (!activeTab || activeTab.view.webContents.isDestroyed()) return + + if (activeTab.view.webContents.canGoForward()) { + activeTab.view.webContents.goForward() + } + } + + private handleRefreshAction(windowInfo: WindowInfo) { + if (!windowInfo.activeTabId) return + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (!activeTab || activeTab.view.webContents.isDestroyed()) return + + activeTab.view.webContents.reload() + } + + private setupTabBarMessageHandler(windowInfo: WindowInfo) { + if (!windowInfo.tabBarView) return + + windowInfo.tabBarView.webContents.on('console-message', (_event, _level, message) => { + try { + const parsed = JSON.parse(message) + if (parsed?.channel === 'tabbar-action' && parsed?.payload) { + this.handleTabBarAction(windowInfo, parsed.payload) + } + } catch { + // Not a JSON message, ignore + } + }) + + windowInfo.tabBarView.webContents + .executeJavaScript(` + (function() { + window.addEventListener('message', function(e) { + if (e.data && e.data.channel === 'tabbar-action') { + console.log(JSON.stringify(e.data)); + } + }); + })(); + `) + .catch((error) => { + logger.debug('Tab bar message handler setup failed', { error, windowKey: windowInfo.windowKey }) + }) + } + + private handleTabBarAction(windowInfo: WindowInfo, action: { type: string; tabId?: string; url?: string }) { + if (action.type === 'switch' && action.tabId) { + this.switchTab(windowInfo.privateMode, action.tabId).catch((error) => { + logger.warn('Tab switch failed', { error, tabId: action.tabId, windowKey: windowInfo.windowKey }) + }) + } else if (action.type === 'close' && action.tabId) { + this.closeTab(windowInfo.privateMode, action.tabId).catch((error) => { + logger.warn('Tab close failed', { error, tabId: action.tabId, windowKey: windowInfo.windowKey }) + }) + } else if (action.type === 'new') { + this.createTab(windowInfo.privateMode, true) + .then(({ tabId }) => this.switchTab(windowInfo.privateMode, tabId)) + .catch((error) => { + logger.warn('New tab creation failed', { error, windowKey: windowInfo.windowKey }) + }) + } else if (action.type === 'navigate' && action.url) { + this.handleNavigateAction(windowInfo, action.url) + } else if (action.type === 'back') { + this.handleBackAction(windowInfo) + } else if (action.type === 'forward') { + this.handleForwardAction(windowInfo) + } else if (action.type === 'refresh') { + this.handleRefreshAction(windowInfo) + } else if (action.type === 'window-minimize') { + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.minimize() + } + } else if (action.type === 'window-maximize') { + if (!windowInfo.window.isDestroyed()) { + if (windowInfo.window.isMaximized()) { + windowInfo.window.unmaximize() + } else { + windowInfo.window.maximize() + } + } + } else if (action.type === 'window-close') { + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } + } + } + + private createTabBarView(windowInfo: WindowInfo): BrowserView { + const tabBarView = new BrowserView({ + webPreferences: { + contextIsolation: false, + sandbox: false, + nodeIntegration: false + } + }) + + windowInfo.window.addBrowserView(tabBarView) + const [width] = windowInfo.window.getContentSize() + tabBarView.setBounds({ x: 0, y: 0, width, height: TAB_BAR_HEIGHT }) + tabBarView.setAutoResize({ width: true, height: false }) + tabBarView.webContents.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(TAB_BAR_HTML)}`) + + tabBarView.webContents.on('did-finish-load', () => { + // Initialize platform for proper styling + const platform = isMac ? 'mac' : process.platform === 'win32' ? 'win' : 'linux' + tabBarView.webContents.executeJavaScript(`window.initPlatform('${platform}')`).catch((error) => { + logger.debug('Platform init failed', { error, windowKey: windowInfo.windowKey }) + }) + // Initialize theme + const isDark = nativeTheme.shouldUseDarkColors + tabBarView.webContents.executeJavaScript(`window.setTheme(${isDark})`).catch((error) => { + logger.debug('Theme init failed', { error, windowKey: windowInfo.windowKey }) + }) + this.setupTabBarMessageHandler(windowInfo) + this.sendTabBarUpdate(windowInfo) + }) + + return tabBarView + } + + private async createBrowserWindow( + windowKey: string, + privateMode: boolean, + showWindow = false + ): Promise { await this.ensureAppReady() - this.sweepIdle() - - const existing = this.windows.get(sessionId) - if (existing && !existing.win.isDestroyed() && !forceNew) { - this.touch(sessionId) - return existing.win - } - - if (existing && !existing.win.isDestroyed() && forceNew) { - try { - if (existing.win.webContents.debugger.isAttached()) { - existing.win.webContents.debugger.detach() - } - } catch (error) { - logger.warn('Error detaching debugger before recreate', { error, sessionId }) - } - existing.win.destroy() - this.windows.delete(sessionId) - } - - this.evictIfNeeded(sessionId) + const partition = this.getPartition(privateMode) const win = new BrowserWindow({ - show, + show: showWindow, + width: 1200, + height: 800, + ...(isMac + ? { + titleBarStyle: 'hidden', + titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight, + trafficLightPosition: { x: 8, y: 13 } + } + : { + frame: false // Frameless window for Windows and Linux + }), webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false, - devTools: true + devTools: true, + partition } }) - // Use a standard Chrome UA to avoid some anti-bot blocks - win.webContents.setUserAgent(userAgent) - - // Log navigation lifecycle to help diagnose slow loads - win.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { sessionId })) - win.webContents.on('dom-ready', () => logger.info(`dom-ready`, { sessionId })) - win.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { sessionId })) - win.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc })) - win.on('closed', () => { - this.windows.delete(sessionId) + const windowInfo = this.windows.get(windowKey) + if (windowInfo) { + const tabIds = Array.from(windowInfo.tabs.keys()) + for (const tabId of tabIds) { + this.closeTabInternal(windowInfo, tabId) + } + this.windows.delete(windowKey) + } }) - this.windows.set(sessionId, { win, lastActive: Date.now() }) return win } + private async getOrCreateWindow(privateMode: boolean, showWindow = false): Promise { + await this.ensureAppReady() + this.sweepIdle() + + const windowKey = this.getWindowKey(privateMode) + + let windowInfo = this.windows.get(windowKey) + if (!windowInfo) { + this.evictIfNeeded(windowKey) + const window = await this.createBrowserWindow(windowKey, privateMode, showWindow) + windowInfo = { + windowKey, + privateMode, + window, + tabs: new Map(), + activeTabId: null, + lastActive: Date.now(), + tabBarView: undefined + } + this.windows.set(windowKey, windowInfo) + const tabBarView = this.createTabBarView(windowInfo) + windowInfo.tabBarView = tabBarView + + // Register resize listener once per window (not per tab) + // Capture windowKey to look up fresh windowInfo on each resize + windowInfo.window.on('resize', () => { + const info = this.windows.get(windowKey) + if (info) this.updateViewBounds(info) + }) + + logger.info('Created new window', { windowKey, privateMode }) + } else if (showWindow && !windowInfo.window.isDestroyed()) { + windowInfo.window.show() + } + + this.touchWindow(windowKey) + return windowInfo + } + + private updateViewBounds(windowInfo: WindowInfo) { + if (windowInfo.window.isDestroyed()) return + + const [width, height] = windowInfo.window.getContentSize() + + // Update tab bar bounds + if (windowInfo.tabBarView && !windowInfo.tabBarView.webContents.isDestroyed()) { + windowInfo.tabBarView.setBounds({ x: 0, y: 0, width, height: TAB_BAR_HEIGHT }) + } + + // Update active tab view bounds + if (windowInfo.activeTabId) { + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (activeTab && !activeTab.view.webContents.isDestroyed()) { + activeTab.view.setBounds({ + x: 0, + y: TAB_BAR_HEIGHT, + width, + height: Math.max(0, height - TAB_BAR_HEIGHT) + }) + } + } + } + + /** + * Creates a new tab in the window + * @param privateMode - If true, uses private browsing mode (default: false) + * @param showWindow - If true, shows the browser window (default: false) + * @returns Tab ID and view + */ + public async createTab(privateMode = false, showWindow = false): Promise<{ tabId: string; view: BrowserView }> { + const windowInfo = await this.getOrCreateWindow(privateMode, showWindow) + const tabId = randomUUID() + const partition = this.getPartition(privateMode) + + const view = new BrowserView({ + webPreferences: { + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + partition + } + }) + + view.webContents.setUserAgent(userAgent) + + const windowKey = windowInfo.windowKey + view.webContents.on('did-start-loading', () => logger.info(`did-start-loading`, { windowKey, tabId })) + view.webContents.on('dom-ready', () => logger.info(`dom-ready`, { windowKey, tabId })) + view.webContents.on('did-finish-load', () => logger.info(`did-finish-load`, { windowKey, tabId })) + view.webContents.on('did-fail-load', (_e, code, desc) => logger.warn('Navigation failed', { code, desc })) + + view.webContents.on('destroyed', () => { + windowInfo.tabs.delete(tabId) + if (windowInfo.activeTabId === tabId) { + windowInfo.activeTabId = windowInfo.tabs.keys().next().value ?? null + if (windowInfo.activeTabId) { + const newActiveTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (newActiveTab && !windowInfo.window.isDestroyed()) { + windowInfo.window.addBrowserView(newActiveTab.view) + this.updateViewBounds(windowInfo) + } + } + } + this.sendTabBarUpdate(windowInfo) + }) + + view.webContents.on('page-title-updated', (_event, title) => { + tabInfo.title = title + this.sendTabBarUpdate(windowInfo) + }) + + view.webContents.on('did-navigate', (_event, url) => { + tabInfo.url = url + this.sendTabBarUpdate(windowInfo) + }) + + view.webContents.on('did-navigate-in-page', (_event, url) => { + tabInfo.url = url + this.sendTabBarUpdate(windowInfo) + }) + + // Handle new window requests (e.g., target="_blank" links) - open in new tab instead + view.webContents.setWindowOpenHandler(({ url }) => { + // Create a new tab and navigate to the URL + this.createTab(privateMode, true) + .then(({ tabId: newTabId }) => { + return this.switchTab(privateMode, newTabId).then(() => { + const newTab = windowInfo.tabs.get(newTabId) + if (newTab && !newTab.view.webContents.isDestroyed()) { + newTab.view.webContents.loadURL(url) + } + }) + }) + .catch((error) => { + logger.warn('Failed to open link in new tab', { error, url }) + }) + return { action: 'deny' } + }) + + const tabInfo: TabInfo = { + id: tabId, + view, + url: '', + title: '', + lastActive: Date.now() + } + + windowInfo.tabs.set(tabId, tabInfo) + + // Set as active tab and add to window + if (!windowInfo.activeTabId || windowInfo.tabs.size === 1) { + windowInfo.activeTabId = tabId + windowInfo.window.addBrowserView(view) + this.updateViewBounds(windowInfo) + } + + this.sendTabBarUpdate(windowInfo) + logger.info('Created new tab', { windowKey, tabId, privateMode }) + return { tabId, view } + } + + /** + * Gets an existing tab or creates a new one + * @param privateMode - Whether to use private browsing mode + * @param tabId - Optional specific tab ID to use + * @param newTab - If true, always create a new tab (useful for parallel requests) + * @param showWindow - If true, shows the browser window (default: false) + */ + private async getTab( + privateMode: boolean, + tabId?: string, + newTab?: boolean, + showWindow = false + ): Promise<{ tabId: string; tab: TabInfo }> { + const windowInfo = await this.getOrCreateWindow(privateMode, showWindow) + + // If newTab is requested, create a fresh tab + if (newTab) { + const { tabId: freshTabId } = await this.createTab(privateMode, showWindow) + const tab = windowInfo.tabs.get(freshTabId) + if (!tab) { + throw new Error(`Tab ${freshTabId} was created but not found - it may have been closed`) + } + return { tabId: freshTabId, tab } + } + + if (tabId) { + const tab = windowInfo.tabs.get(tabId) + if (tab && !tab.view.webContents.isDestroyed()) { + this.touchTab(windowInfo.windowKey, tabId) + return { tabId, tab } + } + } + + // Use active tab or create new one + if (windowInfo.activeTabId) { + const activeTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (activeTab && !activeTab.view.webContents.isDestroyed()) { + this.touchTab(windowInfo.windowKey, windowInfo.activeTabId) + return { tabId: windowInfo.activeTabId, tab: activeTab } + } + } + + // Create new tab + const { tabId: newTabId } = await this.createTab(privateMode, showWindow) + const tab = windowInfo.tabs.get(newTabId) + if (!tab) { + throw new Error(`Tab ${newTabId} was created but not found - it may have been closed`) + } + return { tabId: newTabId, tab } + } + /** * Opens a URL in a browser window and waits for navigation to complete. * @param url - The URL to navigate to * @param timeout - Navigation timeout in milliseconds (default: 10000) - * @param show - Whether to show the browser window (default: false) - * @param sessionId - Session identifier for window reuse (default: 'default') - * @returns Object containing the current URL and page title after navigation + * @param privateMode - If true, uses private browsing mode (default: false) + * @param newTab - If true, always creates a new tab (useful for parallel requests) + * @param showWindow - If true, shows the browser window (default: false) + * @returns Object containing the current URL, page title, and tab ID after navigation */ - public async open(url: string, timeout = 10000, show = false, sessionId = 'default') { - const win = await this.getWindow(sessionId, true, show) - logger.info('Loading URL', { url, sessionId }) - const { webContents } = win - this.touch(sessionId) + public async open(url: string, timeout = 10000, privateMode = false, newTab = false, showWindow = false) { + const { tabId: actualTabId, tab } = await this.getTab(privateMode, undefined, newTab, showWindow) + const view = tab.view + const windowKey = this.getWindowKey(privateMode) + + logger.info('Loading URL', { url, windowKey, tabId: actualTabId, privateMode }) + const { webContents } = view + this.touchTab(windowKey, actualTabId) - // Track resolution state to prevent multiple handlers from firing let resolved = false + let timeoutHandle: ReturnType | undefined let onFinish: () => void let onDomReady: () => void let onFail: (_event: Electron.Event, code: number, desc: string) => void - // Define cleanup outside Promise to ensure it's callable in finally block, - // preventing memory leaks when timeout occurs before navigation completes const cleanup = () => { + if (timeoutHandle) clearTimeout(timeoutHandle) webContents.removeListener('did-finish-load', onFinish) webContents.removeListener('did-fail-load', onFail) webContents.removeListener('dom-ready', onDomReady) @@ -192,67 +647,134 @@ export class CdpBrowserController { }) const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Navigation timed out')), timeout) + timeoutHandle = setTimeout(() => reject(new Error('Navigation timed out')), timeout) }) try { - await Promise.race([win.loadURL(url), loadPromise, timeoutPromise]) + await Promise.race([view.webContents.loadURL(url), loadPromise, timeoutPromise]) } finally { - // Always cleanup listeners to prevent memory leaks on timeout cleanup() } const currentUrl = webContents.getURL() const title = await webContents.getTitle() - return { currentUrl, title } + + // Update tab info + tab.url = currentUrl + tab.title = title + + return { currentUrl, title, tabId: actualTabId } } - public async execute(code: string, timeout = 5000, sessionId = 'default') { - const win = await this.getWindow(sessionId) - this.touch(sessionId) - const dbg = win.webContents.debugger + /** + * Executes JavaScript code in the page context using Chrome DevTools Protocol. + * @param code - JavaScript code to evaluate in the page + * @param timeout - Execution timeout in milliseconds (default: 5000) + * @param privateMode - If true, targets the private browsing window (default: false) + * @param tabId - Optional specific tab ID to target; if omitted, uses the active tab + * @returns The result value from the evaluated code, or null if no value returned + */ + public async execute(code: string, timeout = 5000, privateMode = false, tabId?: string) { + const { tabId: actualTabId, tab } = await this.getTab(privateMode, tabId) + const windowKey = this.getWindowKey(privateMode) + this.touchTab(windowKey, actualTabId) + const dbg = tab.view.webContents.debugger - await this.ensureDebuggerAttached(dbg, sessionId) + await this.ensureDebuggerAttached(dbg, windowKey) + let timeoutHandle: ReturnType | undefined const evalPromise = dbg.sendCommand('Runtime.evaluate', { expression: code, awaitPromise: true, returnByValue: true }) - const result = await Promise.race([ - evalPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Execution timed out')), timeout)) - ]) + try { + const result = await Promise.race([ + evalPromise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('Execution timed out')), timeout) + }) + ]) - const evalResult = result as any + const evalResult = result as any - if (evalResult?.exceptionDetails) { - const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error' - logger.warn('Runtime.evaluate raised exception', { message }) - throw new Error(message) + if (evalResult?.exceptionDetails) { + const message = evalResult.exceptionDetails.exception?.description || 'Unknown script error' + logger.warn('Runtime.evaluate raised exception', { message }) + throw new Error(message) + } + + const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null + return value + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) } - - const value = evalResult?.result?.value ?? evalResult?.result?.description ?? null - return value } - public async reset(sessionId?: string) { - if (sessionId) { - const entry = this.windows.get(sessionId) - if (entry) { - this.closeWindow(entry.win, sessionId) + public async reset(privateMode?: boolean, tabId?: string) { + if (privateMode !== undefined && tabId) { + const windowKey = this.getWindowKey(privateMode) + const windowInfo = this.windows.get(windowKey) + if (windowInfo) { + this.closeTabInternal(windowInfo, tabId) + windowInfo.tabs.delete(tabId) + + // If no tabs left, close the window + if (windowInfo.tabs.size === 0) { + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } + this.windows.delete(windowKey) + logger.info('Browser CDP window closed (last tab closed)', { windowKey, tabId }) + return + } + + if (windowInfo.activeTabId === tabId) { + windowInfo.activeTabId = windowInfo.tabs.keys().next().value ?? null + if (windowInfo.activeTabId) { + const newActiveTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (newActiveTab && !windowInfo.window.isDestroyed()) { + windowInfo.window.addBrowserView(newActiveTab.view) + this.updateViewBounds(windowInfo) + } + } + } + this.sendTabBarUpdate(windowInfo) } - this.windows.delete(sessionId) - logger.info('Browser CDP context reset', { sessionId }) + logger.info('Browser CDP tab reset', { windowKey, tabId }) return } - for (const [id, entry] of this.windows.entries()) { - this.closeWindow(entry.win, id) - this.windows.delete(id) + if (privateMode !== undefined) { + const windowKey = this.getWindowKey(privateMode) + const windowInfo = this.windows.get(windowKey) + if (windowInfo) { + const tabIds = Array.from(windowInfo.tabs.keys()) + for (const tid of tabIds) { + this.closeTabInternal(windowInfo, tid) + } + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } + } + this.windows.delete(windowKey) + logger.info('Browser CDP window reset', { windowKey, privateMode }) + return } - logger.info('Browser CDP context reset (all sessions)') + + const allWindowInfos = Array.from(this.windows.values()) + for (const windowInfo of allWindowInfos) { + const tabIds = Array.from(windowInfo.tabs.keys()) + for (const tid of tabIds) { + this.closeTabInternal(windowInfo, tid) + } + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.close() + } + } + this.windows.clear() + logger.info('Browser CDP context reset (all windows)') } /** @@ -260,21 +782,26 @@ export class CdpBrowserController { * @param url - The URL to fetch * @param format - Output format: 'html', 'txt', 'markdown', or 'json' (default: 'markdown') * @param timeout - Navigation timeout in milliseconds (default: 10000) - * @param sessionId - Session identifier (default: 'default') - * @returns Content in the requested format. For 'json', returns parsed object or { data: rawContent } if parsing fails + * @param privateMode - If true, uses private browsing mode (default: false) + * @param newTab - If true, always creates a new tab (useful for parallel requests) + * @param showWindow - If true, shows the browser window (default: false) + * @returns Object with tabId and content in the requested format. For 'json', content is parsed object or { data: rawContent } if parsing fails */ public async fetch( url: string, format: 'html' | 'txt' | 'markdown' | 'json' = 'markdown', timeout = 10000, - sessionId = 'default' - ) { - await this.open(url, timeout, false, sessionId) + privateMode = false, + newTab = false, + showWindow = false + ): Promise<{ tabId: string; content: string | object }> { + const { tabId } = await this.open(url, timeout, privateMode, newTab, showWindow) - const win = await this.getWindow(sessionId) - const dbg = win.webContents.debugger + const { tab } = await this.getTab(privateMode, tabId, false, showWindow) + const dbg = tab.view.webContents.debugger + const windowKey = this.getWindowKey(privateMode) - await this.ensureDebuggerAttached(dbg, sessionId) + await this.ensureDebuggerAttached(dbg, windowKey) let expression: string if (format === 'json' || format === 'txt') { @@ -283,25 +810,100 @@ export class CdpBrowserController { expression = 'document.documentElement.outerHTML' } - const result = (await dbg.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true - })) as { result?: { value?: string } } + let timeoutHandle: ReturnType | undefined + try { + const result = (await Promise.race([ + dbg.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true + }), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('Fetch content timed out')), timeout) + }) + ])) as { result?: { value?: string } } - const content = result?.result?.value ?? '' + const rawContent = result?.result?.value ?? '' - if (format === 'markdown') { - const turndownService = new TurndownService() - return turndownService.turndown(content) + let content: string | object + if (format === 'markdown') { + content = this.turndownService.turndown(rawContent) + } else if (format === 'json') { + try { + content = JSON.parse(rawContent) + } catch (parseError) { + logger.warn('JSON parse failed, returning raw content', { + url, + contentLength: rawContent.length, + error: parseError + }) + content = { data: rawContent } + } + } else { + content = rawContent + } + + return { tabId, content } + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) } - if (format === 'json') { - // Attempt to parse as JSON; if content is not valid JSON, wrap it in a data object - try { - return JSON.parse(content) - } catch { - return { data: content } + } + + /** + * Lists all tabs in a window + * @param privateMode - If true, lists tabs from private window (default: false) + */ + public async listTabs(privateMode = false): Promise> { + const windowKey = this.getWindowKey(privateMode) + const windowInfo = this.windows.get(windowKey) + if (!windowInfo) return [] + + return Array.from(windowInfo.tabs.values()).map((tab) => ({ + tabId: tab.id, + url: tab.url, + title: tab.title + })) + } + + /** + * Closes a specific tab + * @param privateMode - If true, closes tab from private window (default: false) + * @param tabId - Tab identifier to close + */ + public async closeTab(privateMode: boolean, tabId: string) { + await this.reset(privateMode, tabId) + } + + /** + * Switches the active tab + * @param privateMode - If true, switches tab in private window (default: false) + * @param tabId - Tab identifier to switch to + */ + public async switchTab(privateMode: boolean, tabId: string) { + const windowKey = this.getWindowKey(privateMode) + const windowInfo = this.windows.get(windowKey) + if (!windowInfo) throw new Error(`Window not found for ${privateMode ? 'private' : 'normal'} mode`) + + const tab = windowInfo.tabs.get(tabId) + if (!tab) throw new Error(`Tab ${tabId} not found`) + + // Remove previous active tab view (but NOT the tabBarView) + if (windowInfo.activeTabId && windowInfo.activeTabId !== tabId) { + const prevTab = windowInfo.tabs.get(windowInfo.activeTabId) + if (prevTab && !windowInfo.window.isDestroyed()) { + windowInfo.window.removeBrowserView(prevTab.view) } } - return content + + windowInfo.activeTabId = tabId + + // Add the new active tab view + if (!windowInfo.window.isDestroyed()) { + windowInfo.window.addBrowserView(tab.view) + this.updateViewBounds(windowInfo) + } + + this.touchTab(windowKey, tabId) + this.sendTabBarUpdate(windowInfo) + logger.info('Switched active tab', { windowKey, tabId, privateMode }) } } diff --git a/src/main/mcpServers/browser/tabbar-html.ts b/src/main/mcpServers/browser/tabbar-html.ts new file mode 100644 index 0000000000..4a1bec0e0d --- /dev/null +++ b/src/main/mcpServers/browser/tabbar-html.ts @@ -0,0 +1,567 @@ +export const TAB_BAR_HTML = ` + + + + + + +
+
+
+ +
+
+ +
+ + + +
+
+
+ + + +
+ +
+
+ + +` diff --git a/src/main/mcpServers/browser/tools/execute.ts b/src/main/mcpServers/browser/tools/execute.ts index 1585a467a8..09cd79f2d1 100644 --- a/src/main/mcpServers/browser/tools/execute.ts +++ b/src/main/mcpServers/browser/tools/execute.ts @@ -1,36 +1,39 @@ import * as z from 'zod' import type { CdpBrowserController } from '../controller' +import { logger } from '../types' import { errorResponse, successResponse } from './utils' export const ExecuteSchema = z.object({ - code: z - .string() - .describe( - 'JavaScript evaluated via Chrome DevTools Runtime.evaluate. Keep it short; prefer one-line with semicolons for multiple statements.' - ), - timeout: z.number().default(5000).describe('Timeout in milliseconds for code execution (default: 5000ms)'), - sessionId: z.string().optional().describe('Session identifier to target a specific page (default: default)') + code: z.string().describe('JavaScript code to run in page context'), + timeout: z.number().default(5000).describe('Execution timeout in ms (default: 5000)'), + privateMode: z.boolean().optional().describe('Target private session (default: false)'), + tabId: z.string().optional().describe('Target specific tab by ID') }) export const executeToolDefinition = { name: 'execute', description: - 'Run JavaScript in the current page via Runtime.evaluate. Prefer short, single-line snippets; use semicolons for multiple statements.', + 'Run JavaScript in the currently open page. Use after open to: click elements, fill forms, extract content (document.body.innerText), or interact with the page. The page must be opened first with open or fetch.', inputSchema: { type: 'object', properties: { code: { type: 'string', - description: 'One-line JS to evaluate in page context' + description: + 'JavaScript to evaluate. Examples: document.body.innerText (get text), document.querySelector("button").click() (click), document.title (get title)' }, timeout: { type: 'number', - description: 'Timeout in milliseconds (default 5000)' + description: 'Execution timeout in ms (default: 5000)' }, - sessionId: { + privateMode: { + type: 'boolean', + description: 'Target private session (default: false)' + }, + tabId: { type: 'string', - description: 'Session identifier; targets a specific page (default: default)' + description: 'Target specific tab by ID (from open response)' } }, required: ['code'] @@ -38,11 +41,12 @@ export const executeToolDefinition = { } export async function handleExecute(controller: CdpBrowserController, args: unknown) { - const { code, timeout, sessionId } = ExecuteSchema.parse(args) + const { code, timeout, privateMode, tabId } = ExecuteSchema.parse(args) try { - const value = await controller.execute(code, timeout, sessionId ?? 'default') + const value = await controller.execute(code, timeout, privateMode ?? false, tabId) return successResponse(typeof value === 'string' ? value : JSON.stringify(value)) } catch (error) { + logger.error('Execute failed', { error, code: code.slice(0, 100), privateMode, tabId }) return errorResponse(error as Error) } } diff --git a/src/main/mcpServers/browser/tools/fetch.ts b/src/main/mcpServers/browser/tools/fetch.ts deleted file mode 100644 index b749aaff93..0000000000 --- a/src/main/mcpServers/browser/tools/fetch.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as z from 'zod' - -import type { CdpBrowserController } from '../controller' -import { errorResponse, successResponse } from './utils' - -export const FetchSchema = z.object({ - url: z.url().describe('URL to fetch'), - format: z.enum(['html', 'txt', 'markdown', 'json']).default('markdown').describe('Output format (default: markdown)'), - timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), - sessionId: z.string().optional().describe('Session identifier (default: default)') -}) - -export const fetchToolDefinition = { - name: 'fetch', - description: 'Fetch a URL using the browser and return content in specified format (html, txt, markdown, json)', - inputSchema: { - type: 'object', - properties: { - url: { - type: 'string', - description: 'URL to fetch' - }, - format: { - type: 'string', - enum: ['html', 'txt', 'markdown', 'json'], - description: 'Output format (default: markdown)' - }, - timeout: { - type: 'number', - description: 'Navigation timeout in milliseconds (default: 10000)' - }, - sessionId: { - type: 'string', - description: 'Session identifier (default: default)' - } - }, - required: ['url'] - } -} - -export async function handleFetch(controller: CdpBrowserController, args: unknown) { - const { url, format, timeout, sessionId } = FetchSchema.parse(args) - try { - const content = await controller.fetch(url, format, timeout ?? 10000, sessionId ?? 'default') - return successResponse(typeof content === 'string' ? content : JSON.stringify(content)) - } catch (error) { - return errorResponse(error as Error) - } -} diff --git a/src/main/mcpServers/browser/tools/index.ts b/src/main/mcpServers/browser/tools/index.ts index 19f1ee4163..5ba6fcae6d 100644 --- a/src/main/mcpServers/browser/tools/index.ts +++ b/src/main/mcpServers/browser/tools/index.ts @@ -1,15 +1,13 @@ export { ExecuteSchema, executeToolDefinition, handleExecute } from './execute' -export { FetchSchema, fetchToolDefinition, handleFetch } from './fetch' export { handleOpen, OpenSchema, openToolDefinition } from './open' export { handleReset, resetToolDefinition } from './reset' import type { CdpBrowserController } from '../controller' import { executeToolDefinition, handleExecute } from './execute' -import { fetchToolDefinition, handleFetch } from './fetch' import { handleOpen, openToolDefinition } from './open' import { handleReset, resetToolDefinition } from './reset' -export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition, fetchToolDefinition] +export const toolDefinitions = [openToolDefinition, executeToolDefinition, resetToolDefinition] export const toolHandlers: Record< string, @@ -20,6 +18,5 @@ export const toolHandlers: Record< > = { open: handleOpen, execute: handleExecute, - reset: handleReset, - fetch: handleFetch + reset: handleReset } diff --git a/src/main/mcpServers/browser/tools/open.ts b/src/main/mcpServers/browser/tools/open.ts index 9739b3bcae..6ea9ec9e48 100644 --- a/src/main/mcpServers/browser/tools/open.ts +++ b/src/main/mcpServers/browser/tools/open.ts @@ -1,39 +1,52 @@ import * as z from 'zod' import type { CdpBrowserController } from '../controller' -import { successResponse } from './utils' +import { logger } from '../types' +import { errorResponse, successResponse } from './utils' export const OpenSchema = z.object({ - url: z.url().describe('URL to open in the controlled Electron window'), - timeout: z.number().optional().describe('Timeout in milliseconds for navigation (default: 10000)'), - show: z.boolean().optional().describe('Whether to show the browser window (default: false)'), - sessionId: z - .string() + url: z.url().describe('URL to navigate to'), + format: z + .enum(['html', 'txt', 'markdown', 'json']) .optional() - .describe('Session identifier; separate sessions keep separate pages (default: default)') + .describe('If set, return page content in this format. If not set, just open the page and return tabId.'), + timeout: z.number().optional().describe('Navigation timeout in ms (default: 10000)'), + privateMode: z.boolean().optional().describe('Use incognito mode, no data persisted (default: false)'), + newTab: z.boolean().optional().describe('Open in new tab, required for parallel requests (default: false)'), + showWindow: z.boolean().optional().default(true).describe('Show browser window (default: true)') }) export const openToolDefinition = { name: 'open', - description: 'Open a URL in a hidden Electron window controlled via Chrome DevTools Protocol', + description: + 'Navigate to a URL in a browser window. If format is specified, returns { tabId, content } with page content in that format. Otherwise, returns { currentUrl, title, tabId } for subsequent operations with execute tool. Set newTab=true when opening multiple URLs in parallel.', inputSchema: { type: 'object', properties: { url: { type: 'string', - description: 'URL to load' + description: 'URL to navigate to' + }, + format: { + type: 'string', + enum: ['html', 'txt', 'markdown', 'json'], + description: 'If set, return page content in this format. If not set, just open the page and return tabId.' }, timeout: { type: 'number', - description: 'Navigation timeout in milliseconds (default 10000)' + description: 'Navigation timeout in ms (default: 10000)' }, - show: { + privateMode: { type: 'boolean', - description: 'Whether to show the browser window (default false)' + description: 'Use incognito mode, no data persisted (default: false)' }, - sessionId: { - type: 'string', - description: 'Session identifier; separate sessions keep separate pages (default: default)' + newTab: { + type: 'boolean', + description: 'Open in new tab, required for parallel requests (default: false)' + }, + showWindow: { + type: 'boolean', + description: 'Show browser window (default: true)' } }, required: ['url'] @@ -41,7 +54,28 @@ export const openToolDefinition = { } export async function handleOpen(controller: CdpBrowserController, args: unknown) { - const { url, timeout, show, sessionId } = OpenSchema.parse(args) - const res = await controller.open(url, timeout ?? 10000, show ?? false, sessionId ?? 'default') - return successResponse(JSON.stringify(res)) + try { + const { url, format, timeout, privateMode, newTab, showWindow } = OpenSchema.parse(args) + + if (format) { + const { tabId, content } = await controller.fetch( + url, + format, + timeout ?? 10000, + privateMode ?? false, + newTab ?? false, + showWindow + ) + return successResponse(JSON.stringify({ tabId, content })) + } else { + const res = await controller.open(url, timeout ?? 10000, privateMode ?? false, newTab ?? false, showWindow) + return successResponse(JSON.stringify(res)) + } + } catch (error) { + logger.error('Open failed', { + error, + url: args && typeof args === 'object' && 'url' in args ? args.url : undefined + }) + return errorResponse(error instanceof Error ? error : String(error)) + } } diff --git a/src/main/mcpServers/browser/tools/reset.ts b/src/main/mcpServers/browser/tools/reset.ts index d09d251119..fe67b74b1d 100644 --- a/src/main/mcpServers/browser/tools/reset.ts +++ b/src/main/mcpServers/browser/tools/reset.ts @@ -1,34 +1,43 @@ import * as z from 'zod' import type { CdpBrowserController } from '../controller' -import { successResponse } from './utils' +import { logger } from '../types' +import { errorResponse, successResponse } from './utils' -/** Zod schema for validating reset tool arguments */ export const ResetSchema = z.object({ - sessionId: z.string().optional().describe('Session identifier to reset; omit to reset all sessions') + privateMode: z.boolean().optional().describe('true=private window, false=normal window, omit=all windows'), + tabId: z.string().optional().describe('Close specific tab only (requires privateMode)') }) -/** MCP tool definition for the reset tool */ export const resetToolDefinition = { name: 'reset', - description: 'Reset the controlled window and detach debugger', + description: + 'Close browser windows and clear state. Call when done browsing to free resources. Omit all parameters to close everything.', inputSchema: { type: 'object', properties: { - sessionId: { + privateMode: { + type: 'boolean', + description: 'true=reset private window only, false=reset normal window only, omit=reset all' + }, + tabId: { type: 'string', - description: 'Session identifier to reset; omit to reset all sessions' + description: 'Close specific tab only (requires privateMode to be set)' } } } } -/** - * Handler for the reset MCP tool. - * Closes browser window(s) and detaches debugger for the specified session or all sessions. - */ export async function handleReset(controller: CdpBrowserController, args: unknown) { - const { sessionId } = ResetSchema.parse(args) - await controller.reset(sessionId) - return successResponse('reset') + try { + const { privateMode, tabId } = ResetSchema.parse(args) + await controller.reset(privateMode, tabId) + return successResponse('reset') + } catch (error) { + logger.error('Reset failed', { + error, + privateMode: args && typeof args === 'object' && 'privateMode' in args ? args.privateMode : undefined + }) + return errorResponse(error instanceof Error ? error : String(error)) + } } diff --git a/src/main/mcpServers/browser/tools/utils.ts b/src/main/mcpServers/browser/tools/utils.ts index 2c5ecc0f1d..f5272ac81c 100644 --- a/src/main/mcpServers/browser/tools/utils.ts +++ b/src/main/mcpServers/browser/tools/utils.ts @@ -5,9 +5,10 @@ export function successResponse(text: string) { } } -export function errorResponse(error: Error) { +export function errorResponse(error: Error | string) { + const message = error instanceof Error ? error.message : error return { - content: [{ type: 'text', text: error.message }], + content: [{ type: 'text', text: message }], isError: true } } diff --git a/src/main/mcpServers/browser/types.ts b/src/main/mcpServers/browser/types.ts index 2cc934e6ce..a59fe59665 100644 --- a/src/main/mcpServers/browser/types.ts +++ b/src/main/mcpServers/browser/types.ts @@ -1,4 +1,24 @@ import { loggerService } from '@logger' +import type { BrowserView, BrowserWindow } from 'electron' export const logger = loggerService.withContext('MCPBrowserCDP') -export const userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:145.0) Gecko/20100101 Firefox/145.0' +export const userAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36' + +export interface TabInfo { + id: string + view: BrowserView + url: string + title: string + lastActive: number +} + +export interface WindowInfo { + windowKey: string + privateMode: boolean + window: BrowserWindow + tabs: Map + activeTabId: string | null + lastActive: number + tabBarView?: BrowserView +} From 2012378341cb5960a12a8cd74893fe827482ba82 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:47:59 +0800 Subject: [PATCH 099/116] =?UTF-8?q?=F0=9F=A4=96=20Weekly=20Auto=20I18N=20S?= =?UTF-8?q?ync:=20Jan=2004,=202026=20(#12262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(bot): Weekly automated script run Co-authored-by: EurFelux <59059173+EurFelux@users.noreply.github.com> --- src/renderer/src/i18n/translate/ro-ro.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index d74844f208..6002365814 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -420,6 +420,9 @@ }, "delete": { "content": "Ștergerea unui asistent va șterge toate subiectele și fișierele din cadrul asistentului. Ești sigur că vrei să-l ștergi?", + "error": { + "remain_one": "Nu este permisă ștergerea ultimului" + }, "title": "Șterge asistentul" }, "edit": { From f8519f0bf0a9ea78d557c0b97cddb5460a5d69db Mon Sep 17 00:00:00 2001 From: Zhaolin Liang Date: Sun, 4 Jan 2026 13:49:40 +0800 Subject: [PATCH 100/116] fix: HTML preview tab controls not working in fullscreen (#12152) --- src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 8346eee120..a51b718ecf 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -222,6 +222,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht afterClose={onClose} centered={!isFullscreen} destroyOnHidden + forceRender={isFullscreen} mask={!isFullscreen} maskClosable={false} width={isFullscreen ? '100vw' : '90vw'} From 2383fd06db84cf64f2900b927b813a08dc73a207 Mon Sep 17 00:00:00 2001 From: Tsingv <34980893+Tsingv@users.noreply.github.com> Date: Sun, 4 Jan 2026 14:29:00 +0800 Subject: [PATCH 101/116] fix: resolve unexpected miniwindow loop closure on Mac (#12106) fix: resolve unexpected miniwindow loop closure on MacOS 26+ --- src/main/services/WindowService.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 1bee04c1e7..cda99cc37a 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -643,6 +643,11 @@ export class WindowService { return } else if (isMac) { this.miniWindow.hide() + const majorVersion = parseInt(process.getSystemVersion().split('.')[0], 10) + if (majorVersion >= 26) { + // on macOS 26+, the popup of the mimiWindow would not change the focus to previous application. + return + } if (!this.wasMainWindowFocused) { app.hide() } From b01113aae68f0568ffa8ded833929980a174375c Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 15:52:18 +0800 Subject: [PATCH 102/116] feat: enhance pagination support in API types and hooks - Introduced new pagination types and interfaces, including `PaginationMode`, `BasePaginatedResponse`, `OffsetPaginatedResponse`, and `CursorPaginatedResponse`, to standardize pagination handling. - Updated `useDataApi` hook to support both offset and cursor-based pagination, improving data fetching capabilities. - Added `useInfiniteQuery` and `usePaginatedQuery` hooks for better management of paginated data, including loading states and pagination controls. - Refactored existing pagination logic to improve clarity and maintainability, ensuring consistent handling of pagination across the application. --- packages/shared/data/api/apiTypes.ts | 56 ++++- src/renderer/src/data/hooks/useDataApi.ts | 288 ++++++++++++++++++++-- 2 files changed, 316 insertions(+), 28 deletions(-) diff --git a/packages/shared/data/api/apiTypes.ts b/packages/shared/data/api/apiTypes.ts index 207703e46a..8f17294fd5 100644 --- a/packages/shared/data/api/apiTypes.ts +++ b/packages/shared/data/api/apiTypes.ts @@ -134,15 +134,24 @@ import type { SerializedDataApiError } from './apiErrors' // Re-export for backwards compatibility in DataResponse export type { SerializedDataApiError } from './apiErrors' +// ============================================================================ +// Pagination Types +// ============================================================================ + +/** + * Pagination mode + */ +export type PaginationMode = 'offset' | 'cursor' + /** * Pagination parameters for list operations */ export interface PaginationParams { - /** Page number (1-based) */ - page?: number /** Items per page */ limit?: number - /** Cursor for cursor-based pagination */ + /** Page number (offset mode, 1-based) */ + page?: number + /** Cursor (cursor mode) */ cursor?: string /** Sort field and direction */ sort?: { @@ -152,14 +161,20 @@ export interface PaginationParams { } /** - * Paginated response wrapper + * Base paginated response (shared fields) */ -export interface PaginatedResponse { +export interface BasePaginatedResponse { /** Items for current page */ items: T[] /** Total number of items */ total: number - /** Current page number */ +} + +/** + * Offset-based paginated response + */ +export interface OffsetPaginatedResponse extends BasePaginatedResponse { + /** Current page number (1-based) */ page: number /** Total number of pages */ pageCount: number @@ -167,12 +182,37 @@ export interface PaginatedResponse { hasNext: boolean /** Whether there are previous pages */ hasPrev: boolean - /** Next cursor for cursor-based pagination */ +} + +/** + * Cursor-based paginated response + */ +export interface CursorPaginatedResponse extends BasePaginatedResponse { + /** Next cursor (undefined means no more data) */ nextCursor?: string - /** Previous cursor for cursor-based pagination */ + /** Previous cursor */ prevCursor?: string } +/** + * Unified paginated response (union type) + */ +export type PaginatedResponse = OffsetPaginatedResponse | CursorPaginatedResponse + +/** + * Type guard: check if response is offset-based + */ +export function isOffsetPaginatedResponse(response: PaginatedResponse): response is OffsetPaginatedResponse { + return 'page' in response && 'pageCount' in response +} + +/** + * Type guard: check if response is cursor-based + */ +export function isCursorPaginatedResponse(response: PaginatedResponse): response is CursorPaginatedResponse { + return 'nextCursor' in response || !('page' in response) +} + /** * Subscription options for real-time data updates */ diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 599a7aee42..96d3ec15ff 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,9 +1,14 @@ import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' -import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' -import { useState } from 'react' +import type { ConcreteApiPaths, PaginationMode } from '@shared/data/api/apiTypes' +import { + isCursorPaginatedResponse, + type OffsetPaginatedResponse, + type PaginatedResponse +} from '@shared/data/api/apiTypes' +import { useCallback, useMemo, useState } from 'react' import type { KeyedMutator } from 'swr' import useSWR, { useSWRConfig } from 'swr' +import useSWRInfinite from 'swr/infinite' import useSWRMutation from 'swr/mutation' import { dataApiService } from '../DataApiService' @@ -146,8 +151,10 @@ export function useQuery( ): { /** The fetched data */ data?: ResponseForPath - /** Loading state */ - loading: boolean + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean /** Error if request failed */ error?: Error /** Function to manually refetch data */ @@ -173,7 +180,8 @@ export function useQuery( return { data, - loading: isLoading || isValidating, + isLoading, + isRefreshing: isValidating, error: error as Error | undefined, refetch, mutate @@ -275,7 +283,7 @@ export function useMutation }) => Promise> /** True while the mutation is in progress */ - loading: boolean + isLoading: boolean /** Error object if the mutation failed */ error: Error | undefined } { @@ -367,7 +375,7 @@ export function useMutation( return apiFetcher(path, { query: options?.query as Record }) } +// ============================================================================ +// Infinite Query Hook +// ============================================================================ + +/** + * Options for useInfiniteQuery hook + * SWR-related options are consolidated in swrOptions + */ +export interface UseInfiniteQueryOptions { + /** Additional query parameters (excluding pagination params) */ + query?: Omit, 'page' | 'limit' | 'cursor'> + /** Items per page (default: 10) */ + limit?: number + /** Pagination mode (default: 'cursor') */ + mode?: PaginationMode + /** Whether to enable the query (default: true) */ + enabled?: boolean + /** SWR options (including initialSize, revalidateAll, etc.) */ + swrOptions?: Parameters[2] +} + +/** + * React hook for infinite scrolling data fetching + * Uses useSWRInfinite internally for efficient page management + * + * @template TPath - The concrete API path type + * @param path - API endpoint path that returns paginated data + * @param options - Configuration options for infinite query + * @returns Object containing accumulated items, loading states, and controls + * + * @example + * ```typescript + * // Basic usage with cursor mode (default) + * const { items, hasNext, loadMore, isLoadingMore } = useInfiniteQuery('/test/items', { + * limit: 20, + * query: { search: 'hello' } + * }) + * + * // Offset mode + * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { + * mode: 'offset', + * limit: 20 + * }) + * + * // Custom SWR options + * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { + * limit: 20, + * swrOptions: { + * initialSize: 2, // Load 2 pages initially + * revalidateFirstPage: false // Don't auto-refresh first page + * } + * }) + * + * // With InfiniteScroll component + * } + * > + * {items.map(item => )} + * + * ``` + */ +export function useInfiniteQuery( + path: TPath, + options?: UseInfiniteQueryOptions +): ResponseForPath extends PaginatedResponse + ? { + /** Accumulated items from all loaded pages */ + items: T[] + /** Raw page data array */ + pages: PaginatedResponse[] + /** Total number of items */ + total: number + /** Number of pages loaded */ + size: number + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean + /** Error if request failed */ + error?: Error + /** Whether there are more pages to load */ + hasNext: boolean + /** Load the next page */ + loadNext: () => void + /** Set number of pages to load */ + setSize: (size: number | ((size: number) => number)) => void + /** Refresh all loaded pages */ + refresh: () => void + /** Reset to first page only */ + reset: () => void + /** SWR mutate function */ + mutate: KeyedMutator[]> + } + : never { + const limit = options?.limit ?? 10 + const mode = options?.mode ?? 'cursor' // Default: cursor mode + const enabled = options?.enabled !== false + + // getKey: Generate SWR key for each page + const getKey = useCallback( + (pageIndex: number, previousPageData: PaginatedResponse | null) => { + if (!enabled) return null + + // Check if we've reached the end + if (previousPageData) { + if (mode === 'cursor') { + if (!isCursorPaginatedResponse(previousPageData) || !previousPageData.nextCursor) { + return null + } + } else { + // offset mode + if (isCursorPaginatedResponse(previousPageData)) { + // Response doesn't match expected mode + return null + } + if (!previousPageData.hasNext) { + return null + } + } + } + + // Build pagination query + const paginationQuery: Record = { + ...(options?.query as Record), + limit + } + + if (mode === 'cursor' && previousPageData && isCursorPaginatedResponse(previousPageData)) { + paginationQuery.cursor = previousPageData.nextCursor + } else if (mode === 'offset') { + paginationQuery.page = pageIndex + 1 + } + + return [path, paginationQuery] as [TPath, Record] + }, + [path, options?.query, limit, mode, enabled] + ) + + // Fetcher for infinite query - wraps getFetcher with proper types + const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { + return getFetcher(key) as Promise> + } + + const swrResult = useSWRInfinite(getKey, infiniteFetcher, { + // Default configuration + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000, + initialSize: 1, + revalidateAll: false, + revalidateFirstPage: true, + parallel: false, + // User overrides + ...options?.swrOptions + }) + + const { error, isLoading, isValidating, mutate, size, setSize } = swrResult + const data = swrResult.data as PaginatedResponse[] | undefined + + // Compute derived state + const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) + + const hasNext = useMemo(() => { + if (!data?.length) return false + const last = data[data.length - 1] + if (mode === 'cursor') { + return isCursorPaginatedResponse(last) && !!last.nextCursor + } + return !isCursorPaginatedResponse(last) && (last as OffsetPaginatedResponse).hasNext + }, [data, mode]) + + // Action methods + const loadNext = useCallback(() => { + if (!hasNext || isValidating) return + setSize((s) => s + 1) + }, [hasNext, isValidating, setSize]) + + const refresh = useCallback(() => mutate(), [mutate]) + const reset = useCallback(() => setSize(1), [setSize]) + + return { + items, + pages: data ?? [], + total: data?.[0]?.total ?? 0, + size, + isLoading, + isRefreshing: isValidating, + error: error as Error | undefined, + hasNext, + loadNext, + setSize, + refresh, + reset, + mutate + } as unknown as ResponseForPath extends PaginatedResponse + ? { + items: T[] + pages: PaginatedResponse[] + total: number + size: number + isLoading: boolean + isRefreshing: boolean + error?: Error + hasNext: boolean + loadNext: () => void + setSize: (size: number | ((size: number) => number)) => void + refresh: () => void + reset: () => void + mutate: KeyedMutator[]> + } + : never +} + +// ============================================================================ +// Paginated Query Hook +// ============================================================================ + +/** + * Options for usePaginatedQuery hook + */ +export interface UsePaginatedQueryOptions { + /** Additional query parameters (excluding pagination params) */ + query?: Omit, 'page' | 'limit'> + /** Items per page (default: 10) */ + limit?: number + /** Whether to enable the query (default: true) */ + enabled?: boolean + /** SWR options */ + swrOptions?: Parameters[2] +} + /** * React hook for paginated data fetching with type safety * Automatically manages pagination state and provides navigation controls @@ -482,7 +726,7 @@ export function prefetch( * loading, * total, * page, - * hasMore, + * hasNext, * nextPage, * prevPage * } = usePaginatedQuery('/test/items', { @@ -508,7 +752,7 @@ export function prefetch( * Previous * * Page {page} of {Math.ceil(total / 20)} - * *
@@ -537,12 +781,14 @@ export function usePaginatedQuery( total: number /** Current page number (1-based) */ page: number - /** Loading state */ - loading: boolean + /** True during initial load (no data yet) */ + isLoading: boolean + /** True during any request (including background refresh) */ + isRefreshing: boolean /** Error if request failed */ error?: Error /** Whether there are more pages available */ - hasMore: boolean + hasNext: boolean /** Whether there are previous pages available */ hasPrev: boolean /** Navigate to previous page */ @@ -565,7 +811,7 @@ export function usePaginatedQuery( limit } as Record - const { data, loading, error, refetch } = useQuery(path, { + const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { query: queryWithPagination as QueryParamsForPath, swrOptions: options?.swrOptions }) @@ -576,11 +822,11 @@ export function usePaginatedQuery( const total = paginatedData?.total || 0 const totalPages = Math.ceil(total / limit) - const hasMore = currentPage < totalPages + const hasNext = currentPage < totalPages const hasPrev = currentPage > 1 const nextPage = () => { - if (hasMore) { + if (hasNext) { setCurrentPage((prev) => prev + 1) } } @@ -599,9 +845,10 @@ export function usePaginatedQuery( items, total, page: currentPage, - loading, + isLoading, + isRefreshing, error, - hasMore, + hasNext, hasPrev, prevPage, nextPage, @@ -612,9 +859,10 @@ export function usePaginatedQuery( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void From 4c67e5b43aed72a38d422019471aee57e4aedfe7 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 4 Jan 2026 16:07:56 +0800 Subject: [PATCH 103/116] fix: update links in README and AboutSettings for correct documentation paths --- README.md | 14 +++++++------- docs/zh/README.md | 4 ++-- src/renderer/src/pages/settings/AboutSettings.tsx | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f790c10cbd..781e9299e5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ -

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

+

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

@@ -242,12 +242,12 @@ The Enterprise Edition addresses core challenges in team collaboration by centra ## Version Comparison -| Feature | Community Edition | Enterprise Edition | -| :---------------- | :----------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| **Open Source** | ✅ Yes | ⭕️ Partially released to customers | +| Feature | Community Edition | Enterprise Edition | +| :---------------- | :----------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| **Open Source** | ✅ Yes | ⭕️ Partially released to customers | | **Cost** | [AGPL-3.0 License](https://github.com/CherryHQ/cherry-studio?tab=AGPL-3.0-1-ov-file) | Buyout / Subscription Fee | -| **Admin Backend** | — | ● Centralized **Model** Access
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **Data** Backup | -| **Server** | — | ✅ Dedicated Private Deployment | +| **Admin Backend** | — | ● Centralized **Model** Access
● **Employee** Management
● Shared **Knowledge Base**
● **Access** Control
● **Data** Backup | +| **Server** | — | ✅ Dedicated Private Deployment | ## Get the Enterprise Edition @@ -275,7 +275,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine # 📊 GitHub Stats -![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image') +![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image") # ⭐️ Star History diff --git a/docs/zh/README.md b/docs/zh/README.md index f8a1f1ab8c..c4adeb4901 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -34,7 +34,7 @@

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

@@ -281,7 +281,7 @@ https://docs.cherry-ai.com # 📊 GitHub 统计 -![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg 'Repobeats analytics image') +![Stats](https://repobeats.axiom.co/api/embed/a693f2e5f773eed620f70031e974552156c7f397.svg "Repobeats analytics image") # ⭐️ Star 记录 diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 3bf02a518d..b7309a0a0a 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -170,7 +170,7 @@ const AboutSettings: FC = () => { const onOpenDocs = () => { const isChinese = i18n.language.startsWith('zh') window.api.openWebsite( - isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/cherry-studio-wen-dang/en-us' + isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/docs/en-us' ) } From 68a75dc4e3fc5944b9f0ac9a56a41293556cbdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Sun, 4 Jan 2026 18:07:49 +0800 Subject: [PATCH 104/116] feat(code-tools): add 302.AI as Claude Code provider (#12254) * feat(code-tools): add 302.AI as Claude Code provider * feat(agent): add 302.AI anthropicApiHost to enable Agent support 302.AI now supports Claude Code (Agent) functionality by configuring the anthropicApiHost endpoint. Users can use 302.AI's Claude models (claude-sonnet-4-20250514, claude-opus-4-20250514) with Agent. * feat(migrate): add migration 192 to set 302ai API host --- src/renderer/src/config/providers.ts | 1 + src/renderer/src/pages/code/index.ts | 6 ++++++ src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 9e2831ee6e..f49794aaa7 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -212,6 +212,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record = type: 'openai', apiKey: '', apiHost: 'https://api.302.ai', + anthropicApiHost: 'https://api.302.ai', models: SYSTEM_MODELS['302ai'], isSystem: true, enabled: false diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index 78347cd2c7..81f5ddddc3 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -43,6 +43,7 @@ export const CLAUDE_SUPPORTED_PROVIDERS = [ 'dmxapi', 'new-api', 'cherryin', + '302ai', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS ] export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin'] @@ -96,6 +97,11 @@ export const getCodeToolsApiBaseUrl = (model: Model, type: EndpointType) => { anthropic: { api_base_url: 'https://api.minimaxi.com/anthropic' } + }, + '302ai': { + anthropic: { + api_base_url: 'https://api.302.ai' + } } } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 3d21d12cc7..4727bdc1e7 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -83,7 +83,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 191, + version: 192, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 9375dc3b75..272d9fbaa4 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3144,6 +3144,20 @@ const migrateConfig = { logger.error('migrate 191 error', error as Error) return state } + }, + '192': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === '302ai') { + provider.anthropicApiHost = 'https://api.302.ai' + } + }) + logger.info('migrate 192 success') + return state + } catch (error) { + logger.error('migrate 192 error', error as Error) + return state + } } } From 952e7c350b9057638774367b6d4f4c6cada05083 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 18:08:26 +0800 Subject: [PATCH 105/116] feat: enhance useDataApi hook with type-safe query and mutation results - Introduced new TypeScript interfaces for query and mutation results, improving type safety and clarity in the useDataApi hook. - Refactored existing functions to utilize these new types, ensuring consistent handling of loading states, errors, and data structures. - Updated documentation and examples to reflect the new type-safe approach, enhancing developer experience and reducing potential runtime errors. --- src/renderer/src/data/hooks/useDataApi.ts | 575 +++++----------------- 1 file changed, 115 insertions(+), 460 deletions(-) diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 96d3ec15ff..2ce7e34333 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -13,11 +13,79 @@ import useSWRMutation from 'swr/mutation' import { dataApiService } from '../DataApiService' -// buildPath function removed - users now pass concrete paths directly +// ============================================================================ +// Hook Result Types +// ============================================================================ + +/** Infer item type from paginated response path */ +type InferPaginatedItem = ResponseForPath extends PaginatedResponse< + infer T +> + ? T + : unknown + +/** useQuery result type */ +export interface UseQueryResult { + data?: ResponseForPath + isLoading: boolean + isRefreshing: boolean + error?: Error + refetch: () => void + mutate: KeyedMutator> +} + +/** useMutation result type */ +export interface UseMutationResult< + TPath extends ConcreteApiPaths, + TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH' +> { + mutate: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + isLoading: boolean + error: Error | undefined +} + +/** useInfiniteQuery result type */ +export interface UseInfiniteQueryResult { + items: T[] + pages: PaginatedResponse[] + total: number + size: number + isLoading: boolean + isRefreshing: boolean + error?: Error + hasNext: boolean + loadNext: () => void + setSize: (size: number | ((size: number) => number)) => void + refresh: () => void + reset: () => void + mutate: KeyedMutator[]> +} + +/** usePaginatedQuery result type */ +export interface UsePaginatedQueryResult { + items: T[] + total: number + page: number + isLoading: boolean + isRefreshing: boolean + error?: Error + hasNext: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void +} + +// ============================================================================ +// Utilities +// ============================================================================ /** - * Unified fetcher utility for API requests - * Provides type-safe method dispatching to reduce code duplication + * Unified API fetcher with type-safe method dispatching */ function createApiFetcher( method: TMethod @@ -47,18 +115,7 @@ function createApiFetcher( path: TPath, @@ -73,8 +130,6 @@ function buildSWRKey( /** * GET request fetcher for SWR - * @param args - Tuple containing [path, query] parameters - * @returns Promise resolving to the fetched data */ function getFetcher([path, query]: [TPath, Record?]): Promise< ResponseForPath @@ -84,59 +139,10 @@ function getFetcher([path, query]: [TPath, Recor } /** - * React hook for data fetching with SWR - * Provides type-safe API calls with caching, revalidation, and error handling - * - * @template TPath - The concrete API path type - * @param path - The concrete API endpoint path (e.g., '/test/items/123') - * @param options - Configuration options - * @param options.query - Query parameters for filtering, pagination, etc. - * @param options.enabled - Whether the request should be executed (default: true) - * @param options.swrOptions - Additional SWR configuration options - * @returns Object containing data, loading state, error state, and control functions + * Data fetching hook with SWR caching and revalidation * * @example - * ```typescript - * // Basic usage with type-safe concrete path - * const { data, loading, error } = useQuery('/test/items') - * // data is automatically typed as PaginatedResponse - * - * // With dynamic ID - full type safety - * const { data, loading, error } = useQuery(`/test/items/${itemId}`, { - * enabled: !!itemId - * }) - * // data is automatically typed as the specific item type - * - * // With type-safe query parameters - * const { data, loading, error } = useQuery('/test/items', { - * query: { - * page: 1, - * limit: 20, - * search: 'hello', // TypeScript validates these fields - * status: 'active' - * } - * }) - * - * // With custom SWR options - * const { data, loading, error, refetch } = useQuery('/test/items', { - * swrOptions: { - * refreshInterval: 5000, // Auto-refresh every 5 seconds - * revalidateOnFocus: true - * } - * }) - * - * // Optimistic updates with mutate (bound to current key, no key needed) - * const { data, mutate } = useQuery(`/topics/${id}`) - * - * // Update cache immediately without revalidation - * await mutate({ ...data, title: 'New Title' }, false) - * - * // Update cache with function and revalidate - * await mutate(prev => ({ ...prev, count: prev.count + 1 })) - * - * // Just revalidate (refetch from server) - * await mutate() - * ``` + * const { data, isLoading, error } = useQuery('/items', { query: { page: 1 } }) */ export function useQuery( path: TPath, @@ -148,20 +154,7 @@ export function useQuery( /** Custom SWR options */ swrOptions?: Parameters[2] } -): { - /** The fetched data */ - data?: ResponseForPath - /** True during initial load (no data yet) */ - isLoading: boolean - /** True during any request (including background refresh) */ - isRefreshing: boolean - /** Error if request failed */ - error?: Error - /** Function to manually refetch data */ - refetch: () => void - /** SWR mutate function for optimistic updates */ - mutate: KeyedMutator> -} { +): UseQueryResult { // Internal type conversion for SWR compatibility const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null @@ -189,78 +182,15 @@ export function useQuery( } /** - * React hook for mutation operations (POST, PUT, DELETE, PATCH) - * Provides optimized handling of side-effect operations with automatic cache invalidation - * - * @template TPath - The concrete API path type - * @param method - HTTP method for the mutation - * @param path - The concrete API endpoint path (e.g., '/test/items/123') - * @param options - Configuration options - * @param options.onSuccess - Callback executed on successful mutation - * @param options.onError - Callback executed on mutation error - * @param options.revalidate - Cache invalidation strategy (true = invalidate all, string[] = specific paths) - * @returns Object containing mutate function, loading state, and error state + * Mutation hook for POST, PUT, DELETE, PATCH operations * * @example - * ```typescript - * // Create a new item with full type safety - * const createItem = useMutation('POST', '/test/items', { - * onSuccess: (data) => { - * console.log('Item created:', data) // data is properly typed - * }, - * onError: (error) => { - * console.error('Failed to create item:', error) - * }, - * revalidate: ['/test/items'] // Refresh items list after creation + * const { mutate, isLoading } = useMutation('POST', '/items', { + * onSuccess: (data) => console.log(data), + * revalidate: ['/items'] * }) - * - * // Update existing item with optimistic updates - * const updateItem = useMutation('PUT', `/test/items/${itemId}`, { - * optimistic: true, - * optimisticData: { id: itemId, title: 'Updated Item' }, // Type-safe - * revalidate: true // Refresh all cached data - * }) - * - * // Delete item - * const deleteItem = useMutation('DELETE', `/test/items/${itemId}`) - * - * // Usage in component with type-safe parameters - * const handleCreate = async () => { - * try { - * const result = await createItem.mutate({ - * body: { - * title: 'New Item', - * description: 'Item description', - * // TypeScript validates all fields against ApiSchemas - * tags: ['tag1', 'tag2'] - * } - * }) - * console.log('Created:', result) - * } catch (error) { - * console.error('Creation failed:', error) - * } - * } - * - * const handleUpdate = async () => { - * try { - * const result = await updateItem.mutate({ - * body: { title: 'Updated Item' } // Type-safe, only valid fields allowed - * }) - * } catch (error) { - * console.error('Update failed:', error) - * } - * } - * - * const handleDelete = async () => { - * try { - * await deleteItem.mutate() - * } catch (error) { - * console.error('Delete failed:', error) - * } - * } - * ``` + * await mutate({ body: { title: 'New Item' } }) */ - export function useMutation( method: TMethod, path: TPath, @@ -276,17 +206,7 @@ export function useMutation } -): { - /** Function to execute the mutation */ - mutate: (data?: { - body?: BodyForPath - query?: QueryParamsForPath - }) => Promise> - /** True while the mutation is in progress */ - isLoading: boolean - /** Error object if the mutation failed */ - error: Error | undefined -} { +): UseMutationResult { const { mutate: globalMutate } = useSWRConfig() const apiFetcher = createApiFetcher(method) @@ -327,29 +247,20 @@ export function useMutation }): Promise> => { if (options?.optimistic && options?.optimisticData) { - // Apply optimistic update await globalMutate(path, options.optimisticData, false) } try { - // Convert user's strongly-typed query to Record for internal use - const convertedData = data - ? { - body: data.body, - query: data.query as Record - } - : undefined + const convertedData = data ? { body: data.body, query: data.query as Record } : undefined const result = await trigger(convertedData) - // Revalidate with real data after successful mutation if (options?.optimistic) { await globalMutate(path) } return result } catch (err) { - // Revert optimistic update on error if (options?.optimistic && options?.optimisticData) { await globalMutate(path) } @@ -357,18 +268,11 @@ export function useMutation query?: QueryParamsForPath }): Promise> => { - // Convert user's strongly-typed query to Record for internal use - const convertedData = data - ? { - body: data.body, - query: data.query as Record - } - : undefined + const convertedData = data ? { body: data.body, query: data.query as Record } : undefined return trigger(convertedData) } @@ -381,30 +285,13 @@ export function useMutation { - * // Invalidate specific cache key - * await invalidate('/test/items') - * - * // Invalidate multiple keys - * await invalidate(['/test/items', '/test/stats']) - * - * // Invalidate all cache entries - * await invalidate(true) - * } - * - * return - * } - * ``` + * const invalidate = useInvalidateCache() + * await invalidate('/items') // specific key + * await invalidate(['/a', '/b']) // multiple keys + * await invalidate(true) // all keys */ export function useInvalidateCache() { const { mutate } = useSWRConfig() @@ -424,40 +311,10 @@ export function useInvalidateCache() { } /** - * Prefetch data for a given path without caching - * Useful for warming up data before user interactions or pre-loading critical resources - * - * @template TPath - The concrete API path type - * @param path - The concrete API endpoint path (e.g., '/test/items/123') - * @param options - Configuration options for the prefetch request - * @param options.query - Query parameters for filtering, pagination, etc. - * @returns Promise resolving to the prefetched data + * Prefetch data for warming up before user interactions * * @example - * ```typescript - * // Prefetch items list on component mount - * useEffect(() => { - * prefetch('/test/items', { - * query: { page: 1, limit: 20 } - * }) - * }, []) - * - * // Prefetch specific item on hover - * const handleItemHover = (itemId: string) => { - * prefetch(`/test/items/${itemId}`) - * } - * - * // Prefetch with search parameters - * const preloadSearchResults = async (searchTerm: string) => { - * const results = await prefetch('/test/search', { - * query: { - * query: searchTerm, - * limit: 10 - * } - * }) - * console.log('Preloaded:', results) - * } - * ``` + * prefetch('/items', { query: { page: 1 } }) */ export function prefetch( path: TPath, @@ -474,117 +331,44 @@ export function prefetch( // ============================================================================ /** - * Options for useInfiniteQuery hook - * SWR-related options are consolidated in swrOptions - */ -export interface UseInfiniteQueryOptions { - /** Additional query parameters (excluding pagination params) */ - query?: Omit, 'page' | 'limit' | 'cursor'> - /** Items per page (default: 10) */ - limit?: number - /** Pagination mode (default: 'cursor') */ - mode?: PaginationMode - /** Whether to enable the query (default: true) */ - enabled?: boolean - /** SWR options (including initialSize, revalidateAll, etc.) */ - swrOptions?: Parameters[2] -} - -/** - * React hook for infinite scrolling data fetching - * Uses useSWRInfinite internally for efficient page management - * - * @template TPath - The concrete API path type - * @param path - API endpoint path that returns paginated data - * @param options - Configuration options for infinite query - * @returns Object containing accumulated items, loading states, and controls + * Infinite scrolling hook with cursor/offset pagination * * @example - * ```typescript - * // Basic usage with cursor mode (default) - * const { items, hasNext, loadMore, isLoadingMore } = useInfiniteQuery('/test/items', { + * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', { * limit: 20, - * query: { search: 'hello' } + * mode: 'cursor' // or 'offset' * }) - * - * // Offset mode - * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { - * mode: 'offset', - * limit: 20 - * }) - * - * // Custom SWR options - * const { items, hasNext, loadMore } = useInfiniteQuery('/test/items', { - * limit: 20, - * swrOptions: { - * initialSize: 2, // Load 2 pages initially - * revalidateFirstPage: false // Don't auto-refresh first page - * } - * }) - * - * // With InfiniteScroll component - * } - * > - * {items.map(item => )} - * - * ``` */ export function useInfiniteQuery( path: TPath, - options?: UseInfiniteQueryOptions -): ResponseForPath extends PaginatedResponse - ? { - /** Accumulated items from all loaded pages */ - items: T[] - /** Raw page data array */ - pages: PaginatedResponse[] - /** Total number of items */ - total: number - /** Number of pages loaded */ - size: number - /** True during initial load (no data yet) */ - isLoading: boolean - /** True during any request (including background refresh) */ - isRefreshing: boolean - /** Error if request failed */ - error?: Error - /** Whether there are more pages to load */ - hasNext: boolean - /** Load the next page */ - loadNext: () => void - /** Set number of pages to load */ - setSize: (size: number | ((size: number) => number)) => void - /** Refresh all loaded pages */ - refresh: () => void - /** Reset to first page only */ - reset: () => void - /** SWR mutate function */ - mutate: KeyedMutator[]> - } - : never { + options?: { + /** Additional query parameters (excluding pagination params) */ + query?: Omit, 'page' | 'limit' | 'cursor'> + /** Items per page (default: 10) */ + limit?: number + /** Pagination mode (default: 'cursor') */ + mode?: PaginationMode + /** Whether to enable the query (default: true) */ + enabled?: boolean + /** SWR options (including initialSize, revalidateAll, etc.) */ + swrOptions?: Parameters[2] + } +): UseInfiniteQueryResult> { const limit = options?.limit ?? 10 const mode = options?.mode ?? 'cursor' // Default: cursor mode const enabled = options?.enabled !== false - // getKey: Generate SWR key for each page const getKey = useCallback( (pageIndex: number, previousPageData: PaginatedResponse | null) => { if (!enabled) return null - // Check if we've reached the end if (previousPageData) { if (mode === 'cursor') { if (!isCursorPaginatedResponse(previousPageData) || !previousPageData.nextCursor) { return null } } else { - // offset mode if (isCursorPaginatedResponse(previousPageData)) { - // Response doesn't match expected mode return null } if (!previousPageData.hasNext) { @@ -593,7 +377,6 @@ export function useInfiniteQuery( } } - // Build pagination query const paginationQuery: Record = { ...(options?.query as Record), limit @@ -610,13 +393,11 @@ export function useInfiniteQuery( [path, options?.query, limit, mode, enabled] ) - // Fetcher for infinite query - wraps getFetcher with proper types const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { return getFetcher(key) as Promise> } const swrResult = useSWRInfinite(getKey, infiniteFetcher, { - // Default configuration revalidateOnFocus: false, revalidateOnReconnect: true, dedupingInterval: 5000, @@ -626,14 +407,12 @@ export function useInfiniteQuery( revalidateAll: false, revalidateFirstPage: true, parallel: false, - // User overrides ...options?.swrOptions }) const { error, isLoading, isValidating, mutate, size, setSize } = swrResult const data = swrResult.data as PaginatedResponse[] | undefined - // Compute derived state const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) const hasNext = useMemo(() => { @@ -645,7 +424,6 @@ export function useInfiniteQuery( return !isCursorPaginatedResponse(last) && (last as OffsetPaginatedResponse).hasNext }, [data, mode]) - // Action methods const loadNext = useCallback(() => { if (!hasNext || isValidating) return setSize((s) => s + 1) @@ -668,23 +446,7 @@ export function useInfiniteQuery( refresh, reset, mutate - } as unknown as ResponseForPath extends PaginatedResponse - ? { - items: T[] - pages: PaginatedResponse[] - total: number - size: number - isLoading: boolean - isRefreshing: boolean - error?: Error - hasNext: boolean - loadNext: () => void - setSize: (size: number | ((size: number) => number)) => void - refresh: () => void - reset: () => void - mutate: KeyedMutator[]> - } - : never + } as UseInfiniteQueryResult> } // ============================================================================ @@ -692,119 +454,28 @@ export function useInfiniteQuery( // ============================================================================ /** - * Options for usePaginatedQuery hook - */ -export interface UsePaginatedQueryOptions { - /** Additional query parameters (excluding pagination params) */ - query?: Omit, 'page' | 'limit'> - /** Items per page (default: 10) */ - limit?: number - /** Whether to enable the query (default: true) */ - enabled?: boolean - /** SWR options */ - swrOptions?: Parameters[2] -} - -/** - * React hook for paginated data fetching with type safety - * Automatically manages pagination state and provides navigation controls - * Works with API endpoints that return PaginatedResponse - * - * @template TPath - The concrete API path type - * @param path - API endpoint path that returns paginated data (e.g., '/test/items') - * @param options - Configuration options for pagination and filtering - * @param options.query - Additional query parameters (excluding page/limit) - * @param options.limit - Items per page (default: 10) - * @param options.swrOptions - Additional SWR configuration options - * @returns Object containing paginated data, navigation controls, and state + * Paginated data fetching hook with navigation controls * * @example - * ```typescript - * // Basic paginated list - * const { - * items, - * loading, - * total, - * page, - * hasNext, - * nextPage, - * prevPage - * } = usePaginatedQuery('/test/items', { - * limit: 20 + * const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', { + * limit: 20, + * query: { search: 'hello' } * }) - * - * // With search and filtering - * const paginatedItems = usePaginatedQuery('/test/items', { - * query: { - * search: searchTerm, - * status: 'active', - * type: 'premium' - * }, - * limit: 25, - * swrOptions: { - * refreshInterval: 30000 // Refresh every 30 seconds - * } - * }) - * - * // Navigation controls usage - *
- * - * Page {page} of {Math.ceil(total / 20)} - * - *
- * - * // Reset pagination when search changes - * useEffect(() => { - * reset() // Go back to first page - * }, [searchTerm]) - * ``` */ export function usePaginatedQuery( path: TPath, options?: { - /** Additional query parameters (excluding pagination) */ + /** Additional query parameters (excluding pagination params) */ query?: Omit, 'page' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Custom SWR options */ + /** SWR options */ swrOptions?: Parameters[2] } -): ResponseForPath extends PaginatedResponse - ? { - /** Array of items for current page */ - items: T[] - /** Total number of items across all pages */ - total: number - /** Current page number (1-based) */ - page: number - /** True during initial load (no data yet) */ - isLoading: boolean - /** True during any request (including background refresh) */ - isRefreshing: boolean - /** Error if request failed */ - error?: Error - /** Whether there are more pages available */ - hasNext: boolean - /** Whether there are previous pages available */ - hasPrev: boolean - /** Navigate to previous page */ - prevPage: () => void - /** Navigate to next page */ - nextPage: () => void - /** Refresh current page data */ - refresh: () => void - /** Reset to first page */ - reset: () => void - } - : never { +): UsePaginatedQueryResult> { const [currentPage, setCurrentPage] = useState(1) const limit = options?.limit || 10 - // Convert user's strongly-typed query with pagination for internal use const queryWithPagination = { ...options?.query, page: currentPage, @@ -816,7 +487,6 @@ export function usePaginatedQuery( swrOptions: options?.swrOptions }) - // Extract paginated response data with type safety const paginatedData = data as PaginatedResponse const items = paginatedData?.items || [] const total = paginatedData?.total || 0 @@ -854,20 +524,5 @@ export function usePaginatedQuery( nextPage, refresh: refetch, reset - } as ResponseForPath extends PaginatedResponse - ? { - items: T[] - total: number - page: number - isLoading: boolean - isRefreshing: boolean - error?: Error - hasNext: boolean - hasPrev: boolean - prevPage: () => void - nextPage: () => void - refresh: () => void - reset: () => void - } - : never + } as UsePaginatedQueryResult> } From 6a6f114946adf22f82a9fa641797db62d9c6ad74 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Sun, 4 Jan 2026 18:28:51 +0800 Subject: [PATCH 106/116] feat: enhance useDataApi hook with default SWR options and improved refetch logic - Introduced a centralized `DEFAULT_SWR_OPTIONS` object to standardize SWR configuration across the useDataApi hook, improving consistency and maintainability. - Refactored refetch logic to utilize useCallback for better performance and to avoid stale closures. - Updated mutation handling to use a ref for options, ensuring the latest options are always applied during API calls. - Added a useEffect to reset pagination state when query parameters change, enhancing the hook's responsiveness to dynamic queries. --- src/renderer/src/data/hooks/useDataApi.ts | 76 ++++++++++++++--------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 2ce7e34333..a4a73230e4 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -5,7 +5,7 @@ import { type OffsetPaginatedResponse, type PaginatedResponse } from '@shared/data/api/apiTypes' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { KeyedMutator } from 'swr' import useSWR, { useSWRConfig } from 'swr' import useSWRInfinite from 'swr/infinite' @@ -138,6 +138,17 @@ function getFetcher([path, query]: [TPath, Recor return apiFetcher(path, { query }) } +/** + * Default SWR configuration options shared across hooks + */ +const DEFAULT_SWR_OPTIONS = { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000 +} as const + /** * Data fetching hook with SWR caching and revalidation * @@ -159,17 +170,11 @@ export function useQuery( const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: true, - dedupingInterval: 5000, - errorRetryCount: 3, - errorRetryInterval: 1000, + ...DEFAULT_SWR_OPTIONS, ...options?.swrOptions }) - const refetch = () => { - mutate() - } + const refetch = useCallback(() => mutate(), [mutate]) return { data, @@ -209,6 +214,12 @@ export function useMutation { const { mutate: globalMutate } = useSWRConfig() + // Use ref to avoid stale closure issues with callbacks + const optionsRef = useRef(options) + useEffect(() => { + optionsRef.current = options + }, [options]) + const apiFetcher = createApiFetcher(method) const fetcher = async ( @@ -229,25 +240,24 @@ export function useMutation { - options?.onSuccess?.(data) + optionsRef.current?.onSuccess?.(data) - if (options?.revalidate === true) { + if (optionsRef.current?.revalidate === true) { await globalMutate(() => true) - } else if (Array.isArray(options?.revalidate)) { - for (const path of options.revalidate) { - await globalMutate(path) - } + } else if (Array.isArray(optionsRef.current?.revalidate)) { + await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key))) } }, - onError: options?.onError + onError: (error) => optionsRef.current?.onError?.(error) }) const optimisticMutate = async (data?: { body?: BodyForPath query?: QueryParamsForPath }): Promise> => { - if (options?.optimistic && options?.optimisticData) { - await globalMutate(path, options.optimisticData, false) + const opts = optionsRef.current + if (opts?.optimistic && opts?.optimisticData) { + await globalMutate(path, opts.optimisticData, false) } try { @@ -255,13 +265,13 @@ export function useMutation => { + const invalidate = async (keys?: string | string[] | boolean): Promise => { if (keys === true || keys === undefined) { - return mutate(() => true) + await mutate(() => true) } else if (typeof keys === 'string') { - return mutate(keys) + await mutate(keys) } else if (Array.isArray(keys)) { - return Promise.all(keys.map((key) => mutate(key))) + await Promise.all(keys.map((key) => mutate(key))) } - return Promise.resolve() } return invalidate @@ -398,11 +407,7 @@ export function useInfiniteQuery( } const swrResult = useSWRInfinite(getKey, infiniteFetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: true, - dedupingInterval: 5000, - errorRetryCount: 3, - errorRetryInterval: 1000, + ...DEFAULT_SWR_OPTIONS, initialSize: 1, revalidateAll: false, revalidateFirstPage: true, @@ -469,6 +474,8 @@ export function usePaginatedQuery( query?: Omit, 'page' | 'limit'> /** Items per page (default: 10) */ limit?: number + /** Whether to enable the query (default: true) */ + enabled?: boolean /** SWR options */ swrOptions?: Parameters[2] } @@ -476,6 +483,12 @@ export function usePaginatedQuery( const [currentPage, setCurrentPage] = useState(1) const limit = options?.limit || 10 + // Reset page to 1 when query parameters change + const queryKey = JSON.stringify(options?.query) + useEffect(() => { + setCurrentPage(1) + }, [queryKey]) + const queryWithPagination = { ...options?.query, page: currentPage, @@ -484,6 +497,7 @@ export function usePaginatedQuery( const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { query: queryWithPagination as QueryParamsForPath, + enabled: options?.enabled, swrOptions: options?.swrOptions }) From a2639053ef5906d0faa48d110d678d1abf400d33 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 4 Jan 2026 18:36:03 +0800 Subject: [PATCH 107/116] chore(release): v1.7.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- electron-builder.yml | 58 +++++++++++++++++-------------- package.json | 2 +- src/renderer/src/store/migrate.ts | 1 + 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/electron-builder.yml b/electron-builder.yml index 8af4642f05..bf7b7b4e91 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -134,38 +134,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.8 - Bug Fixes & Performance Improvements + Cherry Studio 1.7.9 - New Features & Bug Fixes - This release focuses on bug fixes and performance optimizations. - - ⚡ Performance - - [ModelList] Improve model list loading performance + ✨ New Features + - [Agent] Add 302.AI provider support + - [Browser] Browser data now persists and supports multiple tabs + - [Language] Add Romanian language support + - [Search] Add fuzzy search for file list + - [Models] Add latest Zhipu models + - [Image] Improve text-to-image functionality 🐛 Bug Fixes - - [Ollama] Fix new users unable to use Ollama models - - [Ollama] Improve reasoningEffort handling - - [Assistants] Prevent deleting last assistant and add error message - - [Shortcut] Fix shortcut icons sorting disorder - - [Memory] Fix global memory settings submit failure - - [Windows] Fix remember size not working for SelectionAction window - - [Anthropic] Fix API base URL handling - - [Files] Allow more file extensions + - [Mac] Fix mini window unexpected closing issue + - [Preview] Fix HTML preview controls not working in fullscreen + - [Translate] Fix translation duplicate execution issue + - [Zoom] Fix page zoom reset issue during navigation + - [Agent] Fix crash when switching between agent and assistant + - [Agent] Fix navigation in agent mode + - [Copy] Fix markdown copy button issue + - [Windows] Fix compatibility issues on non-Windows systems - Cherry Studio 1.7.8 - 问题修复与性能优化 + Cherry Studio 1.7.9 - 新功能与问题修复 - 本次更新专注于问题修复和性能优化。 - - ⚡ 性能优化 - - [模型列表] 提升模型列表加载性能 + ✨ 新功能 + - [Agent] 新增 302.AI 服务商支持 + - [浏览器] 浏览器数据现在可以保存,支持多标签页 + - [语言] 新增罗马尼亚语支持 + - [搜索] 文件列表新增模糊搜索功能 + - [模型] 新增最新智谱模型 + - [图片] 优化文生图功能 🐛 问题修复 - - [Ollama] 修复新用户无法使用 Ollama 模型的问题 - - [Ollama] 改进推理参数处理 - - [助手] 防止删除最后一个助手并添加错误提示 - - [快捷方式] 修复快捷方式图标排序混乱 - - [记忆] 修复全局记忆设置提交失败 - - [窗口] 修复 SelectionAction 窗口记住尺寸不生效 - - [Anthropic] 修复 API 地址处理 - - [文件] 允许更多文件扩展名 + - [Mac] 修复迷你窗口意外关闭的问题 + - [预览] 修复全屏模式下 HTML 预览控件无法使用的问题 + - [翻译] 修复翻译重复执行的问题 + - [缩放] 修复页面导航时缩放被重置的问题 + - [智能体] 修复在智能体和助手间切换时崩溃的问题 + - [智能体] 修复智能体模式下的导航问题 + - [复制] 修复 Markdown 复制按钮问题 + - [兼容性] 修复非 Windows 系统的兼容性问题 diff --git a/package.json b/package.json index 250abf1b9a..6dddf4fd4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CherryStudio", - "version": "1.7.8", + "version": "1.7.9", "private": true, "description": "A powerful AI assistant for producer.", "main": "./out/main/index.js", diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 272d9fbaa4..8719cdb7c1 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -3152,6 +3152,7 @@ const migrateConfig = { provider.anthropicApiHost = 'https://api.302.ai' } }) + state.settings.readClipboardAtStartup = false logger.info('migrate 192 success') return state } catch (error) { From d27d750bc5fec5270469a5d27370e73fd9298c70 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Sun, 4 Jan 2026 19:17:54 +0800 Subject: [PATCH 108/116] feat(i18n): add "open" label for app data directory in multiple languages --- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + src/renderer/src/i18n/translate/de-de.json | 1 + src/renderer/src/i18n/translate/el-gr.json | 1 + src/renderer/src/i18n/translate/es-es.json | 1 + src/renderer/src/i18n/translate/fr-fr.json | 1 + src/renderer/src/i18n/translate/ja-jp.json | 1 + src/renderer/src/i18n/translate/pt-pt.json | 1 + src/renderer/src/i18n/translate/ro-ro.json | 1 + src/renderer/src/i18n/translate/ru-ru.json | 1 + .../src/pages/settings/AboutSettings.tsx | 4 +-- .../settings/DataSettings/DataSettings.tsx | 32 ++++++------------- 13 files changed, 21 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 9e60f31f00..41d5933311 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -3165,6 +3165,7 @@ "label": "App Data", "migration_title": "Data Migration", "new_path": "New Path", + "open": "Open Directory", "original_path": "Original Path", "path_change_failed": "Failed to change data directory", "path_changed_without_copy": "Path changed successfully", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index b9b07a596c..252758d6e5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -3165,6 +3165,7 @@ "label": "应用数据", "migration_title": "数据迁移", "new_path": "新路径", + "open": "打开目录", "original_path": "原始路径", "path_change_failed": "数据目录更改失败", "path_changed_without_copy": "路径已更改成功", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 3d613f00f4..e1bc20d092 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -3165,6 +3165,7 @@ "label": "應用程式資料", "migration_title": "資料移轉", "new_path": "新路徑", + "open": "開啟目錄", "original_path": "原始路徑", "path_change_failed": "資料目錄變更失敗", "path_changed_without_copy": "路徑已變更成功", diff --git a/src/renderer/src/i18n/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index 402437f1e8..d5445961e5 100644 --- a/src/renderer/src/i18n/translate/de-de.json +++ b/src/renderer/src/i18n/translate/de-de.json @@ -3165,6 +3165,7 @@ "label": "Anwendungsdaten", "migration_title": "Datenmigration", "new_path": "Neuer Pfad", + "open": "Offenes Verzeichnis", "original_path": "Ursprünglicher Pfad", "path_change_failed": "Datenverzeichnisänderung fehlgeschlagen", "path_changed_without_copy": "Pfad erfolgreich geändert", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index 1fb0b08abb..d0cfe0579c 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -3165,6 +3165,7 @@ "label": "Δεδομένα εφαρμογής", "migration_title": "Μεταφορά δεδομένων", "new_path": "Νέα διαδρομή", + "open": "Ανοιχτός Κατάλογος", "original_path": "Αρχική διαδρομή", "path_change_failed": "Η αλλαγή του καταλόγου δεδομένων απέτυχε", "path_changed_without_copy": "Η διαδρομή άλλαξε επιτυχώς", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 1aa78e82dd..039a289e7a 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -3165,6 +3165,7 @@ "label": "Datos de la aplicación", "migration_title": "Migración de datos", "new_path": "Nueva ruta", + "open": "Directorio abierto", "original_path": "Ruta original", "path_change_failed": "Error al cambiar el directorio de datos", "path_changed_without_copy": "La ruta se ha cambiado correctamente", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index 4906109228..352678c4ad 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -3165,6 +3165,7 @@ "label": "Données de l'application", "migration_title": "Migration des données", "new_path": "Nouveau chemin", + "open": "Répertoire ouvert", "original_path": "Chemin d'origine", "path_change_failed": "Échec de la modification du répertoire de données", "path_changed_without_copy": "Le chemin a été modifié avec succès", diff --git a/src/renderer/src/i18n/translate/ja-jp.json b/src/renderer/src/i18n/translate/ja-jp.json index 950fef7130..b58fe588f6 100644 --- a/src/renderer/src/i18n/translate/ja-jp.json +++ b/src/renderer/src/i18n/translate/ja-jp.json @@ -3165,6 +3165,7 @@ "label": "アプリデータ", "migration_title": "データ移行", "new_path": "新しいパス", + "open": "オープンディレクトリ", "original_path": "元のパス", "path_change_failed": "データディレクトリの変更に失敗しました", "path_changed_without_copy": "パスが変更されました。", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index 73c8e28e4d..24a38261ca 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -3165,6 +3165,7 @@ "label": "Dados do aplicativo", "migration_title": "Migração de Dados", "new_path": "Novo Caminho", + "open": "Diretório Aberto", "original_path": "Caminho Original", "path_change_failed": "Falha ao alterar o diretório de dados", "path_changed_without_copy": "O caminho foi alterado com sucesso", diff --git a/src/renderer/src/i18n/translate/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json index 6002365814..d18b952baf 100644 --- a/src/renderer/src/i18n/translate/ro-ro.json +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -3165,6 +3165,7 @@ "label": "Date aplicație", "migration_title": "Migrare date", "new_path": "Cale nouă", + "open": "Director Deschis", "original_path": "Cale originală", "path_change_failed": "Schimbarea directorului de date a eșuat", "path_changed_without_copy": "Calea a fost schimbată cu succes", diff --git a/src/renderer/src/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index 200b03e6c1..74ce3df5fb 100644 --- a/src/renderer/src/i18n/translate/ru-ru.json +++ b/src/renderer/src/i18n/translate/ru-ru.json @@ -3165,6 +3165,7 @@ "label": "Данные приложения", "migration_title": "Миграция данных", "new_path": "Новый путь", + "open": "Открыть каталог", "original_path": "Исходный путь", "path_change_failed": "Сбой изменения каталога данных", "path_changed_without_copy": "Путь изменен успешно", diff --git a/src/renderer/src/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index b7309a0a0a..1765f1fbda 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -169,9 +169,7 @@ const AboutSettings: FC = () => { const onOpenDocs = () => { const isChinese = i18n.language.startsWith('zh') - window.api.openWebsite( - isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/docs/en-us' - ) + window.api.openWebsite(isChinese ? 'https://docs.cherry-ai.com/' : 'https://docs.cherry-ai.com/docs/en-us') } return ( diff --git a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 6c111fcdaf..20441bd12c 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -1,11 +1,4 @@ -import { - CloudServerOutlined, - CloudSyncOutlined, - FileSearchOutlined, - LoadingOutlined, - WifiOutlined, - YuqueOutlined -} from '@ant-design/icons' +import { CloudServerOutlined, CloudSyncOutlined, LoadingOutlined, WifiOutlined, YuqueOutlined } from '@ant-design/icons' import DividerWithText from '@renderer/components/DividerWithText' import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' import { HStack } from '@renderer/components/Layout' @@ -23,8 +16,8 @@ import { setSkipBackupFile as _setSkipBackupFile } from '@renderer/store/setting import type { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { occupiedDirs } from '@shared/config/constant' -import { Button, Progress, Switch, Typography } from 'antd' -import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon } from 'lucide-react' +import { Button, Progress, Switch, Tooltip, Typography } from 'antd' +import { FileText, FolderCog, FolderInput, FolderOpen, FolderOutput, SaveIcon } from 'lucide-react' import type { FC } from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -646,9 +639,13 @@ const DataSettings: FC = () => { onClick={() => handleOpenPath(appInfo?.appDataPath)}> {appInfo?.appDataPath} - handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> + + + - + @@ -659,7 +656,6 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.logsPath)}> {appInfo?.logsPath} - handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} /> ) @@ -200,26 +225,16 @@ function CreateTopicForm() { ```typescript function TopicItem({ topic }: { topic: Topic }) { - const { trigger: updateTopic } = useMutation(`/topics/${topic.id}`, 'PATCH') - const { mutate } = useQuery('/topics') + // Use optimisticData for automatic optimistic updates with rollback + const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, { + optimisticData: { ...topic, starred: !topic.starred } + }) const handleToggleStar = async () => { - // Optimistically update the cache - await mutate( - current => ({ - ...current, - items: current.items.map(t => - t.id === topic.id ? { ...t, starred: !t.starred } : t - ) - }), - { revalidate: false } - ) - try { await updateTopic({ body: { starred: !topic.starred } }) } catch (error) { - // Revert on failure - await mutate() + // Rollback happens automatically when optimisticData is set toast.error('Failed to update') } } @@ -279,7 +294,7 @@ The API is fully typed based on schema definitions: const { data } = useQuery('/topics') // data is typed as PaginatedResponse -const { trigger } = useMutation('/topics', 'POST') +const { trigger } = useMutation('POST', '/topics') // trigger expects { body: CreateTopicDto } // returns Topic @@ -291,8 +306,9 @@ const { data: topic } = useQuery('/topics/abc123') ## Best Practices 1. **Use hooks for components**: `useQuery` and `useMutation` handle loading/error states -2. **Handle loading states**: Always show feedback while data is loading -3. **Handle errors gracefully**: Provide meaningful error messages to users -4. **Revalidate after mutations**: Keep the UI in sync with the database -5. **Use conditional fetching**: Pass `null` to skip queries when dependencies aren't ready -6. **Batch related operations**: Consider using transactions for multiple updates +2. **Choose the right pagination hook**: Use `useInfiniteQuery` for infinite scroll, `usePaginatedQuery` for page navigation +3. **Handle loading states**: Always show feedback while data is loading +4. **Handle errors gracefully**: Provide meaningful error messages to users +5. **Revalidate after mutations**: Use `refresh` option to keep the UI in sync +6. **Use conditional fetching**: Set `enabled: false` to skip queries when dependencies aren't ready +7. **Batch related operations**: Consider using transactions for multiple updates diff --git a/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index 4af746d9cf..41aa80a016 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,17 +1,62 @@ +/** + * @fileoverview React hooks for data fetching with SWR integration. + * + * This module provides type-safe hooks for interacting with the DataApi: + * + * - {@link useQuery} - Fetch data with automatic caching and revalidation + * - {@link useMutation} - Perform POST/PUT/PATCH/DELETE operations + * - {@link useInfiniteQuery} - Cursor-based infinite scrolling + * - {@link usePaginatedQuery} - Offset-based pagination with navigation + * - {@link useInvalidateCache} - Manual cache invalidation + * - {@link prefetch} - Warm up cache before user interactions + * + * All hooks use SWR under the hood for caching, deduplication, and revalidation. + * + * @example + * // Basic data fetching + * const { data, isLoading } = useQuery('/topics') + * + * @example + * // Create with auto-refresh + * const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'] }) + * await trigger({ body: { name: 'New Topic' } }) + * + * @see {@link https://swr.vercel.app SWR Documentation} + */ + +import { dataApiService } from '@data/DataApiService' import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' import { - isCursorPaginationResponse, + type CursorPaginationResponse, type OffsetPaginationResponse, type PaginationResponse } from '@shared/data/api/apiTypes' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { KeyedMutator } from 'swr' -import useSWR, { useSWRConfig } from 'swr' +import type { KeyedMutator, SWRConfiguration } from 'swr' +import useSWR, { preload, useSWRConfig } from 'swr' +import type { SWRInfiniteConfiguration } from 'swr/infinite' import useSWRInfinite from 'swr/infinite' +import type { SWRMutationConfiguration } from 'swr/mutation' import useSWRMutation from 'swr/mutation' -import { dataApiService } from '../DataApiService' +/** + * Default SWR configuration shared across all hooks. + * + * @remarks + * - `revalidateOnFocus: false` - Prevents refetch when window regains focus + * - `revalidateOnReconnect: true` - Refetch when network reconnects + * - `dedupingInterval: 5000` - Dedupe requests within 5 seconds + * - `errorRetryCount: 3` - Retry failed requests up to 3 times + * - `errorRetryInterval: 1000` - Wait 1 second between retries + */ +const DEFAULT_SWR_OPTIONS = { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 5000, + errorRetryCount: 3, + errorRetryInterval: 1000 +} as const // ============================================================================ // Hook Result Types @@ -24,7 +69,15 @@ type InferPaginatedItem = ResponseForPath { data?: ResponseForPath isLoading: boolean @@ -34,12 +87,17 @@ export interface UseQueryResult { mutate: KeyedMutator> } -/** useMutation result type */ +/** + * useMutation result type + * @property trigger - Execute the mutation with optional body and query params + * @property isLoading - True while the mutation is in progress + * @property error - Error object if the last mutation failed + */ export interface UseMutationResult< TPath extends ConcreteApiPaths, TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH' > { - mutate: (data?: { + trigger: (data?: { body?: BodyForPath query?: QueryParamsForPath }) => Promise> @@ -47,24 +105,45 @@ export interface UseMutationResult< error: Error | undefined } -/** useInfiniteQuery result type */ +/** + * useInfiniteQuery result type (cursor-based pagination) + * @property items - All loaded items flattened from all pages + * @property isLoading - True during initial load + * @property isRefreshing - True during background revalidation + * @property error - Error object if the request failed + * @property hasNext - True if more pages are available (nextCursor exists) + * @property loadNext - Load the next page of items + * @property refresh - Revalidate all loaded pages from the server + * @property reset - Reset to first page only + * @property mutate - SWR mutator for advanced cache control + */ export interface UseInfiniteQueryResult { items: T[] - pages: PaginationResponse[] - total: number - size: number isLoading: boolean isRefreshing: boolean error?: Error hasNext: boolean loadNext: () => void - setSize: (size: number | ((size: number) => number)) => void refresh: () => void reset: () => void - mutate: KeyedMutator[]> + mutate: KeyedMutator[]> } -/** usePaginatedQuery result type */ +/** + * usePaginatedQuery result type (offset-based pagination) + * @property items - Items on the current page + * @property total - Total number of items across all pages + * @property page - Current page number (1-indexed) + * @property isLoading - True during initial load + * @property isRefreshing - True during background revalidation + * @property error - Error object if the request failed + * @property hasNext - True if next page exists + * @property hasPrev - True if previous page exists (page > 1) + * @property prevPage - Navigate to previous page + * @property nextPage - Navigate to next page + * @property refresh - Revalidate current page from the server + * @property reset - Reset to page 1 + */ export interface UsePaginatedQueryResult { items: T[] total: number @@ -80,94 +159,50 @@ export interface UsePaginatedQueryResult { reset: () => void } -// ============================================================================ -// Utilities -// ============================================================================ - /** - * Unified API fetcher with type-safe method dispatching - */ -function createApiFetcher( - method: TMethod -) { - return async ( - path: TPath, - options?: { - body?: BodyForPath - query?: Record - } - ): Promise> => { - switch (method) { - case 'GET': - return dataApiService.get(path, { query: options?.query }) - case 'POST': - return dataApiService.post(path, { body: options?.body, query: options?.query }) - case 'PUT': - return dataApiService.put(path, { body: options?.body || {}, query: options?.query }) - case 'DELETE': - return dataApiService.delete(path, { query: options?.query }) - case 'PATCH': - return dataApiService.patch(path, { body: options?.body, query: options?.query }) - default: - throw new Error(`Unsupported method: ${method}`) - } - } -} - -/** - * Build SWR cache key from path and query - */ -function buildSWRKey( - path: TPath, - query?: Record -): [TPath, Record?] | null { - if (query && Object.keys(query).length > 0) { - return [path, query] - } - - return [path] -} - -/** - * GET request fetcher for SWR - */ -function getFetcher([path, query]: [TPath, Record?]): Promise< - ResponseForPath -> { - const apiFetcher = createApiFetcher('GET') - return apiFetcher(path, { query }) -} - -/** - * Default SWR configuration options shared across hooks - */ -const DEFAULT_SWR_OPTIONS = { - revalidateOnFocus: false, - revalidateOnReconnect: true, - dedupingInterval: 5000, - errorRetryCount: 3, - errorRetryInterval: 1000 -} as const - -/** - * Data fetching hook with SWR caching and revalidation + * Data fetching hook with SWR caching and revalidation. + * + * Features: + * - Automatic caching and deduplication + * - Background revalidation on focus/reconnect + * - Error retry with exponential backoff + * + * @param path - API endpoint path (e.g., '/topics', '/messages') + * @param options - Query options + * @param options.query - Query parameters for filtering, pagination, etc. + * @param options.enabled - Set to false to disable the request (default: true) + * @param options.swrOptions - Override default SWR configuration + * @returns Query result with data, loading states, and cache controls * * @example - * const { data, isLoading, error } = useQuery('/items', { query: { page: 1 } }) + * // Basic usage + * const { data, isLoading, error } = useQuery('/topics') + * + * @example + * // With query parameters + * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } }) + * + * @example + * // Conditional fetching + * const { data } = useQuery('/topics', { enabled: !!userId }) + * + * @example + * // Manual cache update + * const { data, mutate } = useQuery('/topics') + * mutate({ ...data, name: 'Updated' }, { revalidate: false }) */ export function useQuery( path: TPath, options?: { /** Query parameters for filtering, pagination, etc. */ query?: QueryParamsForPath - /** Disable the request */ + /** Disable the request (default: true) */ enabled?: boolean - /** Custom SWR options */ - swrOptions?: Parameters[2] + /** Override default SWR configuration */ + swrOptions?: SWRConfiguration } ): UseQueryResult { - // Internal type conversion for SWR compatibility - const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null + const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : null const { data, error, isLoading, isValidating, mutate } = useSWR(key, getFetcher, { ...DEFAULT_SWR_OPTIONS, @@ -187,29 +222,59 @@ export function useQuery( } /** - * Mutation hook for POST, PUT, DELETE, PATCH operations + * Mutation hook for POST, PUT, DELETE, PATCH operations. + * + * Features: + * - Automatic cache invalidation via refresh option + * - Optimistic updates with automatic rollback on error + * - Success/error callbacks + * + * @param method - HTTP method ('POST' | 'PUT' | 'DELETE' | 'PATCH') + * @param path - API endpoint path + * @param options - Mutation options + * @param options.onSuccess - Callback when mutation succeeds + * @param options.onError - Callback when mutation fails + * @param options.refresh - API paths to revalidate on success + * @param options.optimisticData - If provided, updates cache immediately before request completes + * @param options.swrOptions - Override SWR mutation configuration + * @returns Mutation result with trigger function and loading state * * @example - * const { mutate, isLoading } = useMutation('POST', '/items', { - * onSuccess: (data) => console.log(data), - * revalidate: ['/items'] + * // Basic POST + * const { trigger, isLoading } = useMutation('POST', '/topics') + * await trigger({ body: { name: 'New Topic' } }) + * + * @example + * // With auto-refresh and callbacks + * const { trigger } = useMutation('POST', '/topics', { + * refresh: ['/topics'], + * onSuccess: (data) => toast.success('Created!'), + * onError: (error) => toast.error(error.message) + * }) + * + * @example + * // Optimistic update (UI updates immediately, rolls back on error) + * const { trigger } = useMutation('PATCH', '/topics/abc', { + * optimisticData: { ...topic, starred: true } * }) - * await mutate({ body: { title: 'New Item' } }) */ export function useMutation( method: TMethod, path: TPath, options?: { - /** Called when mutation succeeds */ + /** Callback when mutation succeeds */ onSuccess?: (data: ResponseForPath) => void - /** Called when mutation fails */ + /** Callback when mutation fails */ onError?: (error: Error) => void - /** Automatically revalidate these SWR keys on success */ - revalidate?: boolean | string[] - /** Enable optimistic updates */ - optimistic?: boolean - /** Optimistic data to use for updates */ + /** API paths to revalidate on success */ + refresh?: ConcreteApiPaths[] + /** If provided, updates cache immediately (with auto-rollback on error) */ optimisticData?: ResponseForPath + /** Override SWR mutation configuration (fetcher, onSuccess, onError are handled internally) */ + swrOptions?: Omit< + SWRMutationConfiguration, Error>, + 'fetcher' | 'onSuccess' | 'onError' + > } ): UseMutationResult { const { mutate: globalMutate } = useSWRConfig() @@ -220,7 +285,7 @@ export function useMutation(method) const fetcher = async ( _key: string, @@ -229,79 +294,87 @@ export function useMutation - query?: Record + query?: QueryParamsForPath } } ): Promise> => { return apiFetcher(path, { body: arg?.body, query: arg?.query }) } - const { trigger, isMutating, error } = useSWRMutation(path as string, fetcher, { + const { + trigger: swrTrigger, + isMutating, + error + } = useSWRMutation(path as string, fetcher, { populateCache: false, revalidate: false, onSuccess: async (data) => { optionsRef.current?.onSuccess?.(data) - if (optionsRef.current?.revalidate === true) { - await globalMutate(() => true) - } else if (Array.isArray(optionsRef.current?.revalidate)) { - await Promise.all(optionsRef.current.revalidate.map((key) => globalMutate(key))) + // Refresh specified keys on success + if (optionsRef.current?.refresh?.length) { + await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key))) } }, - onError: (error) => optionsRef.current?.onError?.(error) + onError: (error) => optionsRef.current?.onError?.(error), + ...options?.swrOptions }) - const optimisticMutate = async (data?: { + const trigger = async (data?: { body?: BodyForPath query?: QueryParamsForPath }): Promise> => { const opts = optionsRef.current - if (opts?.optimistic && opts?.optimisticData) { - await globalMutate(path, opts.optimisticData, false) + const hasOptimisticData = opts?.optimisticData !== undefined + + // Apply optimistic update if optimisticData is provided + if (hasOptimisticData) { + await globalMutate(path, opts!.optimisticData, false) } try { - const convertedData = data ? { body: data.body, query: data.query as Record } : undefined + const result = await swrTrigger(data) - const result = await trigger(convertedData) - - if (opts?.optimistic) { + // Revalidate after optimistic update completes + if (hasOptimisticData) { await globalMutate(path) } return result } catch (err) { - if (opts?.optimistic && opts?.optimisticData) { + // Rollback optimistic update on error + if (hasOptimisticData) { await globalMutate(path) } throw err } } - const normalMutate = async (data?: { - body?: BodyForPath - query?: QueryParamsForPath - }): Promise> => { - const convertedData = data ? { body: data.body, query: data.query as Record } : undefined - - return trigger(convertedData) - } - return { - mutate: optionsRef.current?.optimistic ? optimisticMutate : normalMutate, + trigger, isLoading: isMutating, error } } /** - * Hook to invalidate SWR cache entries + * Hook to invalidate SWR cache entries and trigger revalidation. + * + * Use this to manually clear cached data and force a fresh fetch. + * + * @returns Invalidate function that accepts keys to invalidate * * @example * const invalidate = useInvalidateCache() - * await invalidate('/items') // specific key - * await invalidate(['/a', '/b']) // multiple keys - * await invalidate(true) // all keys + * + * // Invalidate specific path + * await invalidate('/topics') + * + * // Invalidate multiple paths + * await invalidate(['/topics', '/messages']) + * + * // Invalidate all cached data + * await invalidate(true) */ export function useInvalidateCache() { const { mutate } = useSWRConfig() @@ -320,10 +393,25 @@ export function useInvalidateCache() { } /** - * Prefetch data for warming up before user interactions + * Prefetch data to warm up the cache before user interactions. + * + * Uses SWR preload to fetch and cache data. Subsequent useQuery calls + * with the same path and query will use the cached data immediately. + * + * @param path - API endpoint path to prefetch + * @param options - Prefetch options + * @param options.query - Query parameters (must match useQuery call) + * @returns Promise resolving to the fetched data * * @example - * prefetch('/items', { query: { page: 1 } }) + * // Prefetch on hover + * onMouseEnter={() => prefetch('/topics/abc')} + * + * @example + * // Prefetch with query params + * await prefetch('/messages', { query: { topicId: 'abc', limit: 20 } }) + * // Later, this will be instant: + * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } }) */ export function prefetch( path: TPath, @@ -331,8 +419,8 @@ export function prefetch( query?: QueryParamsForPath } ): Promise> { - const apiFetcher = createApiFetcher('GET') - return apiFetcher(path, { query: options?.query as Record }) + const key = buildSWRKey(path, options?.query) + return preload(key, getFetcher) } // ============================================================================ @@ -340,100 +428,94 @@ export function prefetch( // ============================================================================ /** - * Infinite scrolling hook with cursor/offset pagination + * Infinite scrolling hook with cursor-based pagination. + * + * Automatically loads pages using cursor tokens. Items from all loaded pages + * are flattened into a single array for easy rendering. + * + * @param path - API endpoint path (must return CursorPaginationResponse) + * @param options - Infinite query options + * @param options.query - Additional query parameters (cursor/limit are managed internally) + * @param options.limit - Items per page (default: 10) + * @param options.enabled - Set to false to disable fetching (default: true) + * @param options.swrOptions - Override SWR infinite configuration + * @returns Infinite query result with items, pagination controls, and loading states * * @example - * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/items', { - * limit: 20, - * mode: 'cursor' // or 'offset' + * // Basic infinite scroll + * const { items, hasNext, loadNext, isLoading } = useInfiniteQuery('/messages') + * + * return ( + *
+ * {items.map(item => )} + * {hasNext && } + *
+ * ) + * + * @example + * // With filters and custom limit + * const { items, loadNext } = useInfiniteQuery('/messages', { + * query: { topicId: 'abc' }, + * limit: 50 * }) */ export function useInfiniteQuery( path: TPath, options?: { - /** Additional query parameters (excluding pagination params) */ - query?: Omit, 'page' | 'limit' | 'cursor'> + /** Additional query parameters (cursor/limit are managed internally) */ + query?: Omit, 'cursor' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Pagination mode (default: 'cursor') */ - mode?: 'offset' | 'cursor' - /** Whether to enable the query (default: true) */ + /** Set to false to disable fetching (default: true) */ enabled?: boolean - /** SWR options (including initialSize, revalidateAll, etc.) */ - swrOptions?: Parameters[2] + /** Override SWR infinite configuration */ + swrOptions?: SWRInfiniteConfiguration } ): UseInfiniteQueryResult> { const limit = options?.limit ?? 10 - const mode = options?.mode ?? 'cursor' // Default: cursor mode const enabled = options?.enabled !== false const getKey = useCallback( - (pageIndex: number, previousPageData: PaginationResponse | null) => { + (_pageIndex: number, previousPageData: CursorPaginationResponse | null) => { if (!enabled) return null - if (previousPageData) { - if (mode === 'cursor') { - if (!isCursorPaginationResponse(previousPageData) || !previousPageData.nextCursor) { - return null - } - } else { - // Offset mode: check if we've reached the end - if (isCursorPaginationResponse(previousPageData)) { - return null - } - const offsetData = previousPageData as OffsetPaginationResponse - // No more pages if items returned is less than limit or we've fetched all - if (offsetData.items.length < limit || pageIndex * limit >= offsetData.total) { - return null - } - } + // Stop if previous page has no nextCursor + if (previousPageData && !previousPageData.nextCursor) { + return null } - const paginationQuery: Record = { - ...(options?.query as Record), - limit + const paginationQuery = { + ...options?.query, + limit, + ...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {}) } - if (mode === 'cursor' && previousPageData && isCursorPaginationResponse(previousPageData)) { - paginationQuery.cursor = previousPageData.nextCursor - } else if (mode === 'offset') { - paginationQuery.page = pageIndex + 1 - } - - return [path, paginationQuery] as [TPath, Record] + return [path, paginationQuery] as [TPath, typeof paginationQuery] }, - [path, options?.query, limit, mode, enabled] + [path, options?.query, limit, enabled] ) - const infiniteFetcher = (key: [ConcreteApiPaths, Record?]) => { - return getFetcher(key) as Promise> + const infiniteFetcher = (key: [TPath, Record]) => { + return getFetcher(key as unknown as [TPath, QueryParamsForPath?]) as Promise< + CursorPaginationResponse> + > } const swrResult = useSWRInfinite(getKey, infiniteFetcher, { ...DEFAULT_SWR_OPTIONS, - initialSize: 1, - revalidateAll: false, - revalidateFirstPage: true, - parallel: false, ...options?.swrOptions }) - const { error, isLoading, isValidating, mutate, size, setSize } = swrResult - const data = swrResult.data as PaginationResponse[] | undefined + const { error, isLoading, isValidating, mutate, setSize } = swrResult + const data = swrResult.data as CursorPaginationResponse>[] | undefined const items = useMemo(() => data?.flatMap((p) => p.items) ?? [], [data]) const hasNext = useMemo(() => { if (!data?.length) return false const last = data[data.length - 1] - if (mode === 'cursor') { - return isCursorPaginationResponse(last) && !!last.nextCursor - } - // Offset mode: check if there are more items - if (isCursorPaginationResponse(last)) return false - const offsetData = last as OffsetPaginationResponse - return offsetData.page * limit < offsetData.total - }, [data, mode, limit]) + return !!last.nextCursor + }, [data]) const loadNext = useCallback(() => { if (!hasNext || isValidating) return @@ -443,29 +525,17 @@ export function useInfiniteQuery( const refresh = useCallback(() => mutate(), [mutate]) const reset = useCallback(() => setSize(1), [setSize]) - // Total is only available in offset mode - const total = useMemo(() => { - if (!data?.length) return 0 - const first = data[0] - if (isCursorPaginationResponse(first)) return 0 - return (first as OffsetPaginationResponse).total - }, [data]) - return { items, - pages: data ?? [], - total, - size, isLoading, isRefreshing: isValidating, error: error as Error | undefined, hasNext, loadNext, - setSize, refresh, reset, mutate - } as UseInfiniteQueryResult> + } } // ============================================================================ @@ -473,25 +543,50 @@ export function useInfiniteQuery( // ============================================================================ /** - * Paginated data fetching hook with navigation controls + * Paginated data fetching hook with offset-based navigation. + * + * Provides page-by-page navigation with previous/next controls. + * Automatically resets to page 1 when query parameters change. + * + * @param path - API endpoint path (must return OffsetPaginationResponse) + * @param options - Pagination options + * @param options.query - Additional query parameters (page/limit are managed internally) + * @param options.limit - Items per page (default: 10) + * @param options.enabled - Set to false to disable fetching (default: true) + * @param options.swrOptions - Override SWR configuration + * @returns Paginated query result with items, page info, and navigation controls * * @example - * const { items, page, hasNext, nextPage, prevPage } = usePaginatedQuery('/items', { - * limit: 20, - * query: { search: 'hello' } + * // Basic pagination + * const { items, page, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics') + * + * return ( + *
+ * {items.map(item => )} + * + * Page {page} + * + *
+ * ) + * + * @example + * // With search filter + * const { items, total } = usePaginatedQuery('/topics', { + * query: { search: searchTerm }, + * limit: 20 * }) */ export function usePaginatedQuery( path: TPath, options?: { - /** Additional query parameters (excluding pagination params) */ + /** Additional query parameters (page/limit are managed internally) */ query?: Omit, 'page' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Whether to enable the query (default: true) */ + /** Set to false to disable fetching (default: true) */ enabled?: boolean - /** SWR options */ - swrOptions?: Parameters[2] + /** Override SWR configuration */ + swrOptions?: SWRConfiguration } ): UsePaginatedQueryResult> { const [currentPage, setCurrentPage] = useState(1) @@ -503,13 +598,15 @@ export function usePaginatedQuery( setCurrentPage(1) }, [queryKey]) + // Build query with pagination params const queryWithPagination = { ...options?.query, page: currentPage, limit - } as Record + } const { data, isLoading, isRefreshing, error, refetch } = useQuery(path, { + // Type assertion needed: we're adding pagination params to a partial query type query: queryWithPagination as QueryParamsForPath, enabled: options?.enabled, swrOptions: options?.swrOptions @@ -555,3 +652,80 @@ export function usePaginatedQuery( reset } as UsePaginatedQueryResult> } + +// ============================================================================ +// Internal Utilities +// ============================================================================ + +/** + * Create a type-safe API fetcher for the specified HTTP method. + * + * @internal + * @param method - HTTP method to use + * @returns Async function that makes the API request + * + * @remarks + * Type assertion at dataApiService boundary is intentional since dataApiService + * accepts 'any' for maximum flexibility. + */ +function createApiFetcher( + method: TMethod +) { + return async ( + path: TPath, + options?: { + body?: BodyForPath + query?: QueryParamsForPath + } + ): Promise> => { + // Internal type assertion for dataApiService boundary (accepts any) + const query = options?.query as Record | undefined + switch (method) { + case 'GET': + return dataApiService.get(path, { query }) + case 'POST': + return dataApiService.post(path, { body: options?.body, query }) + case 'PUT': + return dataApiService.put(path, { body: options?.body || {}, query }) + case 'DELETE': + return dataApiService.delete(path, { query }) + case 'PATCH': + return dataApiService.patch(path, { body: options?.body, query }) + default: + throw new Error(`Unsupported method: ${method}`) + } + } +} + +/** + * Build SWR cache key from path and optional query parameters. + * + * @internal + * @param path - API endpoint path + * @param query - Optional query parameters + * @returns Tuple of [path] or [path, query] for SWR cache key + */ +function buildSWRKey>( + path: TPath, + query?: TQuery +): [TPath] | [TPath, TQuery] { + if (query && Object.keys(query).length > 0) { + return [path, query] + } + + return [path] +} + +/** + * SWR fetcher function for GET requests. + * + * @internal + * @param key - SWR cache key tuple [path, query?] + * @returns Promise resolving to the API response + */ +function getFetcher([path, query]: [TPath, QueryParamsForPath?]): Promise< + ResponseForPath +> { + const apiFetcher = createApiFetcher('GET') + return apiFetcher(path, { query }) +} diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 760a134583..03d9b71cc0 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -46,7 +46,7 @@ function createMockDataForPath(path: ConcreteApiPaths): any { /** * Mock useQuery hook - * Matches actual signature: useQuery(path, options?) => { data, loading, error, refetch, mutate } + * Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, error, refetch, mutate } */ export const mockUseQuery = vi.fn( ( @@ -58,7 +58,8 @@ export const mockUseQuery = vi.fn( } ): { data?: ResponseForPath - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error refetch: () => void mutate: KeyedMutator> @@ -67,7 +68,8 @@ export const mockUseQuery = vi.fn( if (options?.enabled === false) { return { data: undefined, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) as unknown as KeyedMutator> @@ -78,7 +80,8 @@ export const mockUseQuery = vi.fn( return { data: mockData as ResponseForPath, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(mockData) as unknown as KeyedMutator> @@ -88,7 +91,7 @@ export const mockUseQuery = vi.fn( /** * Mock useMutation hook - * Matches actual signature: useMutation(method, path, options?) => { mutate, loading, error } + * Matches actual signature: useMutation(method, path, options?) => { trigger, isLoading, error } */ export const mockUseMutation = vi.fn( ( @@ -97,19 +100,19 @@ export const mockUseMutation = vi.fn( _options?: { onSuccess?: (data: ResponseForPath) => void onError?: (error: Error) => void - revalidate?: boolean | string[] - optimistic?: boolean + refresh?: ConcreteApiPaths[] optimisticData?: ResponseForPath + swrOptions?: any } ): { - mutate: (data?: { + trigger: (data?: { body?: BodyForPath query?: QueryParamsForPath }) => Promise> - loading: boolean + isLoading: boolean error: Error | undefined } => { - const mockMutate = vi.fn( + const mockTrigger = vi.fn( async (_data?: { body?: BodyForPath; query?: QueryParamsForPath }) => { // Simulate different responses based on method switch (method) { @@ -127,8 +130,8 @@ export const mockUseMutation = vi.fn( ) return { - mutate: mockMutate, - loading: false, + trigger: mockTrigger, + isLoading: false, error: undefined } } @@ -136,7 +139,7 @@ export const mockUseMutation = vi.fn( /** * Mock usePaginatedQuery hook - * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, loading, error, hasMore, hasPrev, prevPage, nextPage, refresh, reset } + * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, hasPrev, prevPage, nextPage, refresh, reset } */ export const mockUsePaginatedQuery = vi.fn( ( @@ -151,9 +154,10 @@ export const mockUsePaginatedQuery = vi.fn( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void @@ -173,9 +177,10 @@ export const mockUsePaginatedQuery = vi.fn( items: mockItems, total: mockItems.length, page: 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: false, + hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(), @@ -186,9 +191,10 @@ export const mockUsePaginatedQuery = vi.fn( items: T[] total: number page: number - loading: boolean + isLoading: boolean + isRefreshing: boolean error?: Error - hasMore: boolean + hasNext: boolean hasPrev: boolean prevPage: () => void nextPage: () => void @@ -259,7 +265,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(data) @@ -269,7 +276,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -285,7 +293,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data: undefined, - loading: true, + isLoading: true, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) @@ -294,7 +303,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -310,7 +320,8 @@ export const MockUseDataApiUtils = { if (queryPath === path) { return { data: undefined, - loading: false, + isLoading: false, + isRefreshing: false, error, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(undefined) @@ -319,7 +330,8 @@ export const MockUseDataApiUtils = { const defaultData = createMockDataForPath(queryPath) return { data: defaultData, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, refetch: vi.fn(), mutate: vi.fn().mockResolvedValue(defaultData) @@ -338,15 +350,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockResolvedValue(result), - loading: false, + trigger: vi.fn().mockResolvedValue(result), + isLoading: false, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -363,15 +375,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockRejectedValue(error), - loading: false, + trigger: vi.fn().mockRejectedValue(error), + isLoading: false, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -387,15 +399,15 @@ export const MockUseDataApiUtils = { mockUseMutation.mockImplementation((mutationMethod, mutationPath, _options) => { if (mutationPath === path && mutationMethod === method) { return { - mutate: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves - loading: true, + trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + isLoading: true, error: undefined } } // Default behavior return { - mutate: vi.fn().mockResolvedValue({ success: true }), - loading: false, + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, error: undefined } }) @@ -407,7 +419,7 @@ export const MockUseDataApiUtils = { mockPaginatedData: ( path: TPath, items: any[], - options?: { total?: number; page?: number; hasMore?: boolean; hasPrev?: boolean } + options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean } ) => { mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { if (queryPath === path) { @@ -415,9 +427,10 @@ export const MockUseDataApiUtils = { items, total: options?.total ?? items.length, page: options?.page ?? 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: options?.hasMore ?? false, + hasNext: options?.hasNext ?? false, hasPrev: options?.hasPrev ?? false, prevPage: vi.fn(), nextPage: vi.fn(), @@ -430,9 +443,10 @@ export const MockUseDataApiUtils = { items: [], total: 0, page: 1, - loading: false, + isLoading: false, + isRefreshing: false, error: undefined, - hasMore: false, + hasNext: false, hasPrev: false, prevPage: vi.fn(), nextPage: vi.fn(), From b6a1240bd8da6553e3b045ab68b4432aaaf26aea Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 10:15:42 +0800 Subject: [PATCH 113/116] chore: update CLAUDE.md for v2 migration details - Clarified the Electron structure by removing Redux from the Renderer Process description. - Enhanced the Data Layer section to specify the removal of Redux and Dexie, and the adoption of Cache/Preference/DataApi architecture. - Updated the UI Layer section to reflect the removal of antd and styled-components, and the adoption of `@cherrystudio/ui` with Tailwind CSS and Shadcn UI. - Introduced a file naming convention for ongoing migration with `*.v2.ts` suffix to indicate work-in-progress files. - Added a new section for v2 refactoring to outline the major changes in the project structure and dependencies. --- CLAUDE.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ada5fe5040..e448c2b487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ When creating a Pull Request, you MUST: ### Electron Structure - **Main Process** (`src/main/`): Node.js backend with services (MCP, Knowledge, Storage, etc.) -- **Renderer Process** (`src/renderer/`): React UI with Redux state management +- **Renderer Process** (`src/renderer/`): React UI - **Preload Scripts** (`src/preload/`): Secure IPC bridge ### Key Architectural Components @@ -52,11 +52,11 @@ When creating a Pull Request, you MUST: **MUST READ**: [docs/en/references/data/README.md](docs/en/references/data/README.md) for system selection, architecture, and patterns. -| System | Use Case | APIs | -|--------|----------|------| -| Cache | Temp data (can lose) | `useCache`, `useSharedCache`, `usePersistCache` | -| Preference | User settings | `usePreference` | -| DataApi | Business data (**critical**) | `useQuery`, `useMutation` | +| System | Use Case | APIs | +| ---------- | ---------------------------- | ----------------------------------------------- | +| Cache | Temp data (can lose) | `useCache`, `useSharedCache`, `usePersistCache` | +| Preference | User settings | `usePreference` | +| DataApi | Business data (**critical**) | `useQuery`, `useMutation` | Database: SQLite + Drizzle ORM, schemas in `src/main/data/db/schemas/`, migrations via `yarn db:migrations:generate` @@ -83,17 +83,33 @@ Database: SQLite + Drizzle ORM, schemas in `src/main/data/db/schemas/`, migratio - **Multi-language Support**: i18n with dynamic loading - **Theme System**: Light/dark themes with custom CSS variables -### UI Design +## v2 Refactoring (In Progress) -The project is in the process of migrating from antd & styled-components to Tailwind CSS and Shadcn UI. Please use components from `@packages/ui` to build UI components. The use of antd and styled-components is prohibited. +The v2 branch is undergoing a major refactoring effort: -UI Library: `@packages/ui` +### Data Layer + +- **Removing**: Redux, Dexie +- **Adopting**: Cache / Preference / DataApi architecture (see [Data Management](#data-management)) + +### UI Layer + +- **Removing**: antd, HeroUI, styled-components +- **Adopting**: `@cherrystudio/ui` (located in `packages/ui`, Tailwind CSS + Shadcn UI) +- **Prohibited**: antd, HeroUI, styled-components + +### File Naming Convention + +During migration, use `*.v2.ts` suffix for files not yet fully migrated: + +- Indicates work-in-progress refactoring +- Avoids conflicts with existing code +- **Post-completion**: These files will be renamed or merged into their final locations ## Logging Standards ### Usage - ```typescript import { loggerService } from "@logger"; const logger = loggerService.withContext("moduleName"); From fb51df99d00681f9c4e67cc22aeb0c590cd67c76 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 12:23:19 +0800 Subject: [PATCH 114/116] feat(cache): add template key support for useCache with type inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add type utilities for template key matching (IsTemplateKey, ExpandTemplateKey, ProcessKey) - Add InferUseCacheValue for automatic value type inference from template patterns - Update useCache hook to support template keys with default value fallback - Extend ESLint rule to validate template key syntax (e.g., 'scroll.position:${id}') - Update CacheService.get() docs: clarify | undefined return is intentional (developers need to know when value doesn't exist after deletion/TTL expiry) - Update cache documentation with template key usage examples BREAKING CHANGE: CacheService.get() now explicitly returns T | undefined (was implicit before). Callers should use ?? defaultValue for fallback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/en/references/data/cache-overview.md | 29 ++- docs/en/references/data/cache-usage.md | 134 +++++++++++-- eslint.config.mjs | 84 +++++++- packages/shared/data/cache/cacheSchemas.ts | 180 +++++++++++++++++- src/renderer/src/data/CacheService.ts | 131 +++++++++++-- src/renderer/src/data/hooks/useCache.ts | 166 ++++++++++++++-- src/renderer/src/services/WebSearchService.ts | 3 +- 7 files changed, 669 insertions(+), 58 deletions(-) diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md index 87ab7d66cd..159609388b 100644 --- a/docs/en/references/data/cache-overview.md +++ b/docs/en/references/data/cache-overview.md @@ -51,8 +51,9 @@ cacheService.set('temp.calculation', result, 30000) - Main process resolves conflicts ### Type Safety -- Schema-based keys for compile-time checking -- Casual methods for dynamic keys with manual typing +- **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`) +- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position:${id}'` matches `'scroll.position:topic-123'`) +- **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys) ## Data Categories @@ -120,10 +121,20 @@ cacheService.set('temp.calculation', result, 30000) For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md). -| Method | Tier | Type Safety | -|--------|------|-------------| -| `useCache` / `get` / `set` | Memory | Schema-based keys | -| `getCasual` / `setCasual` | Memory | Dynamic keys (manual typing) | -| `useSharedCache` / `getShared` / `setShared` | Shared | Schema-based keys | -| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys (manual typing) | -| `usePersistCache` / `getPersist` / `setPersist` | Persist | Schema-based keys only | +### Key Types + +| Type | Example Schema | Example Usage | Type Inference | +|------|----------------|---------------|----------------| +| Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic | +| Template key | `'scroll.position:${id}': number` | `get('scroll.position:topic-123')` | Automatic | +| Casual key | N/A | `getCasual('my.key')` | Manual | + +### API Reference + +| Method | Tier | Key Type | +|--------|------|----------| +| `useCache` / `get` / `set` | Memory | Fixed + Template keys | +| `getCasual` / `setCasual` | Memory | Dynamic keys only (schema keys blocked) | +| `useSharedCache` / `getShared` / `setShared` | Shared | Fixed keys only | +| `getSharedCasual` / `setSharedCasual` | Shared | Dynamic keys only (schema keys blocked) | +| `usePersistCache` / `getPersist` / `setPersist` | Persist | Fixed keys only | diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md index 6e1c761362..2b30d12471 100644 --- a/docs/en/references/data/cache-usage.md +++ b/docs/en/references/data/cache-usage.md @@ -155,19 +155,75 @@ const [counter, setCounter] = useCache('ui.counter', 0) - Use dynamically constructed keys - Require manual type specification via generics - No compile-time key validation +- **Cannot use keys that match schema patterns** (including template keys) ```typescript // Dynamic key, must specify type -const topic = cacheService.getCasual(`topic:${id}`) +const topic = cacheService.getCasual(`my.custom.key`) + +// Compile error: cannot use schema keys with Casual methods +cacheService.getCasual('app.user.avatar') // Error: matches fixed key +cacheService.getCasual('scroll.position:topic-123') // Error: matches template key ``` +### Template Keys + +Template keys provide type-safe caching for dynamic key patterns. Define a template in the schema using `${variable}` syntax, and TypeScript will automatically match and infer types for concrete keys. + +#### Defining Template Keys + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export type UseCacheSchema = { + // Fixed key + 'app.user.avatar': string + + // Template keys - use ${variable} for dynamic segments + 'scroll.position:${topicId}': number + 'entity.cache:${type}:${id}': EntityData +} + +// Default values for templates (shared by all instances) +export const DefaultUseCache: UseCacheSchema = { + 'app.user.avatar': '', + 'scroll.position:${topicId}': 0, + 'entity.cache:${type}:${id}': { loaded: false } +} +``` + +#### Using Template Keys + +```typescript +// TypeScript infers the value type from schema +const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') +// scrollPos is inferred as `number` + +const [entity, setEntity] = useCache('entity.cache:user:456') +// entity is inferred as `EntityData` + +// Direct CacheService usage +cacheService.set('scroll.position:my-topic', 150) // OK: value must be number +cacheService.set('scroll.position:my-topic', 'hi') // Error: type mismatch +``` + +#### Template Key Benefits + +| Feature | Fixed Keys | Template Keys | Casual Methods | +|---------|-----------|---------------|----------------| +| Type inference | ✅ Automatic | ✅ Automatic | ❌ Manual | +| Auto-completion | ✅ Full | ✅ Partial (prefix) | ❌ None | +| Compile-time validation | ✅ Yes | ✅ Yes | ❌ No | +| Dynamic IDs | ❌ No | ✅ Yes | ✅ Yes | +| Default values | ✅ Yes | ✅ Shared per template | ❌ No | + ### When to Use Which | Scenario | Method | Example | |----------|--------|---------| | Fixed cache keys | Type-safe | `useCache('ui.counter')` | -| Entity caching by ID | Casual | `getCasual(\`topic:${id}\`)` | -| Session-based keys | Casual | `setCasual(\`session:${sessionId}\`)` | +| Dynamic keys with known pattern | Template key | `useCache('scroll.position:topic-123')` | +| Entity caching by ID | Template key | `get('entity.cache:user:456')` | +| Completely dynamic keys | Casual | `getCasual(\`unknown.pattern:${x}\`)` | | UI state | Type-safe | `useSharedCache('window.layout')` | ## Common Patterns @@ -243,17 +299,24 @@ function useCachedWithExpiry(key: string, fetcher: () => Promise, maxAge: ## Adding New Cache Keys -### 1. Add to Cache Schema +### Adding Fixed Keys + +#### 1. Add to Cache Schema ```typescript // packages/shared/data/cache/cacheSchemas.ts -export interface CacheSchema { +export type UseCacheSchema = { // Existing keys... 'myFeature.data': MyDataType } + +export const DefaultUseCache: UseCacheSchema = { + // Existing defaults... + 'myFeature.data': { items: [], lastUpdated: 0 } +} ``` -### 2. Define Value Type (if complex) +#### 2. Define Value Type (if complex) ```typescript // packages/shared/data/cache/cacheValueTypes.ts @@ -263,13 +326,60 @@ export interface MyDataType { } ``` -### 3. Use in Code +#### 3. Use in Code ```typescript // Now type-safe -const [data, setData] = useCache('myFeature.data', defaultValue) +const [data, setData] = useCache('myFeature.data') ``` +### Adding Template Keys + +#### 1. Add Template to Schema + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +export type UseCacheSchema = { + // Existing keys... + // Template key with dynamic segment + 'scroll.position:${topicId}': number +} + +export const DefaultUseCache: UseCacheSchema = { + // Existing defaults... + // Default shared by all instances of this template + 'scroll.position:${topicId}': 0 +} +``` + +#### 2. Use in Code + +```typescript +// TypeScript infers number from template pattern +const [scrollPos, setScrollPos] = useCache(`scroll.position:${topicId}`) + +// Works with any string in the dynamic segment +const [pos1, setPos1] = useCache('scroll.position:topic-123') +const [pos2, setPos2] = useCache('scroll.position:conversation-abc') +``` + +### Key Naming Convention + +All keys (fixed and template) must follow the naming convention: + +- **Format**: `namespace.sub.key_name` or `namespace.key:${variable}` +- **Rules**: + - Start with lowercase letter + - Use lowercase letters, numbers, and underscores + - Separate segments with dots (`.`) + - Use colons (`:`) before template placeholders +- **Examples**: + - ✅ `app.user.avatar` + - ✅ `scroll.position:${id}` + - ✅ `cache.entity:${type}:${id}` + - ❌ `UserAvatar` (no dots) + - ❌ `App.User` (uppercase) + ## Shared Cache Ready State Renderer CacheService provides ready state tracking for SharedCache initialization sync. @@ -303,6 +413,8 @@ unsubscribe() 1. **Choose the right tier**: Memory for temp, Shared for cross-window, Persist for survival 2. **Use TTL for stale data**: Prevent serving outdated cached values 3. **Prefer type-safe keys**: Add to schema when possible -4. **Clean up dynamic keys**: Remove casual cache entries when no longer needed -5. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) -6. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync +4. **Use template keys for patterns**: When you have a recurring pattern (e.g., caching by ID), define a template key instead of using casual methods +5. **Reserve casual for truly dynamic keys**: Only use casual methods when the key pattern is completely unknown at development time +6. **Clean up dynamic keys**: Remove casual cache entries when no longer needed +7. **Consider data size**: Persist cache uses localStorage (limited to ~5MB) +8. **Use absolute timestamps for sync**: CacheSyncMessage uses `expireAt` (absolute Unix timestamp) for precise cross-window TTL sync diff --git a/eslint.config.mjs b/eslint.config.mjs index 928aa51008..8a77021cbc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -172,6 +172,9 @@ export default defineConfig([ } }, // Schema key naming convention (cache & preferences) + // Supports both fixed keys and template keys: + // - Fixed: 'app.user.avatar', 'chat.multi_select_mode' + // - Template: 'scroll.position:${topicId}', 'cache:${type}:${id}' { files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'], plugins: { @@ -181,25 +184,91 @@ export default defineConfig([ meta: { type: 'problem', docs: { - description: 'Enforce schema key naming convention: namespace.sub.key_name', + description: + 'Enforce schema key naming convention: namespace.sub.key_name or namespace.key:${variable}', recommended: true }, messages: { invalidKey: - 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar).' + 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar) or with template: namespace.key:${variable} (e.g., scroll.position:${id}).', + invalidTemplateVar: + 'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).' } }, create(context) { - const VALID_KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + /** + * Validates a schema key for correct naming convention. + * + * Supports two formats: + * 1. Fixed keys: lowercase segments separated by dots + * Example: 'app.user.avatar', 'chat.multi_select_mode' + * + * 2. Template keys: fixed prefix + template placeholders + * Example: 'scroll.position:${id}', 'cache:${type}:${id}' + * + * Template placeholder rules: + * - Must use ${variableName} syntax + * - Variable name must be valid identifier (start with letter, alphanumeric + underscore) + * - Empty placeholders like ${} are invalid + * + * @param {string} key - The schema key to validate + * @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }} + */ + function validateKey(key) { + // Check if key contains template placeholders + const hasTemplate = key.includes('${') + + if (hasTemplate) { + // Template key validation + // Must have at least one dot-separated segment before any template or colon + // Example valid: 'scroll.position:${id}', 'cache:${type}:${id}' + // Example invalid: '${id}', ':${id}' + + // Extract and validate all template variables + const templateVarPattern = /\$\{([^}]*)\}/g + let match + while ((match = templateVarPattern.exec(key)) !== null) { + const varName = match[1] + // Variable must be a valid identifier: start with letter, contain only alphanumeric and underscore + if (!varName || !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(varName)) { + return { valid: false, error: 'invalidTemplateVar' } + } + } + + // Replace template placeholders with a marker to validate the structure + const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, '__TEMPLATE__') + + // Template key structure: + // - Must start with a valid segment (lowercase letters, numbers, underscores) + // - Segments separated by dots or colons + // - Must have at least one dot-separated segment + // - Can end with template placeholder + const templateKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*(:[a-z0-9_]*|:__TEMPLATE__)*$/ + + if (!templateKeyPattern.test(keyWithoutTemplates)) { + return { valid: false, error: 'invalidKey' } + } + + return { valid: true } + } else { + // Fixed key validation: standard dot-separated format + const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + if (!fixedKeyPattern.test(key)) { + return { valid: false, error: 'invalidKey' } + } + return { valid: true } + } + } return { TSPropertySignature(node) { if (node.key.type === 'Literal' && typeof node.key.value === 'string') { const key = node.key.value - if (!VALID_KEY_PATTERN.test(key)) { + const result = validateKey(key) + if (!result.valid) { context.report({ node: node.key, - messageId: 'invalidKey', + messageId: result.error, data: { key } }) } @@ -208,10 +277,11 @@ export default defineConfig([ Property(node) { if (node.key.type === 'Literal' && typeof node.key.value === 'string') { const key = node.key.value - if (!VALID_KEY_PATTERN.test(key)) { + const result = validateKey(key) + if (!result.valid) { context.report({ node: node.key, - messageId: 'invalidKey', + messageId: result.error, data: { key } }) } diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 16a68f2a92..8ec2bdaff0 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -19,9 +19,87 @@ import type * as CacheValueTypes from './cacheValueTypes' * - 'userAvatar' (invalid - missing dot separator) * - 'App.user' (invalid - uppercase not allowed) * + * ## Template Key Support + * + * Template keys allow type-safe dynamic keys using template literal syntax. + * Define in schema with `${variable}` placeholder, use with actual values. + * + * Examples: + * - Schema: `'scroll.position:${topicId}': number` + * - Usage: `useCache('scroll.position:topic-123')` -> infers `number` type + * + * Multiple placeholders are supported: + * - Schema: `'cache:${type}:${id}': CacheData` + * - Usage: `useCache('cache:user:456')` -> infers `CacheData` type + * * This convention is enforced by ESLint rule: data-schema-key/valid-key */ +// ============================================================================ +// Template Key Type Utilities +// ============================================================================ + +/** + * Detects whether a key string contains template placeholder syntax. + * + * Template keys use `${variable}` syntax to define dynamic segments. + * This type returns `true` if the key contains at least one `${...}` placeholder. + * + * @template K - The key string to check + * @returns `true` if K contains `${...}`, `false` otherwise + * + * @example + * ```typescript + * type Test1 = IsTemplateKey<'scroll:${id}'> // true + * type Test2 = IsTemplateKey<'cache:${a}:${b}'> // true + * type Test3 = IsTemplateKey<'app.user.avatar'> // false + * ``` + */ +export type IsTemplateKey = K extends `${string}\${${string}}${string}` ? true : false + +/** + * Expands a template key pattern into a matching literal type. + * + * Replaces each `${variable}` placeholder with `string`, allowing + * TypeScript to match concrete keys against the template pattern. + * Recursively processes multiple placeholders. + * + * @template T - The template key pattern to expand + * @returns A template literal type that matches all valid concrete keys + * + * @example + * ```typescript + * type Test1 = ExpandTemplateKey<'scroll:${id}'> + * // Result: `scroll:${string}` (matches 'scroll:123', 'scroll:abc', etc.) + * + * type Test2 = ExpandTemplateKey<'cache:${type}:${id}'> + * // Result: `cache:${string}:${string}` (matches 'cache:user:123', etc.) + * + * type Test3 = ExpandTemplateKey<'app.user.avatar'> + * // Result: 'app.user.avatar' (unchanged for non-template keys) + * ``` + */ +export type ExpandTemplateKey = T extends `${infer Prefix}\${${string}}${infer Suffix}` + ? `${Prefix}${string}${ExpandTemplateKey}` + : T + +/** + * Processes a cache key, expanding template patterns if present. + * + * For template keys (containing `${...}`), returns the expanded pattern. + * For fixed keys, returns the key unchanged. + * + * @template K - The key to process + * @returns The processed key type (expanded if template, unchanged if fixed) + * + * @example + * ```typescript + * type Test1 = ProcessKey<'scroll:${id}'> // `scroll:${string}` + * type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar' + * ``` + */ +export type ProcessKey = IsTemplateKey extends true ? ExpandTemplateKey : K + /** * Use cache schema for renderer hook */ @@ -121,9 +199,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = { 'example_scope.example_key': 'example default value' } +// ============================================================================ +// Cache Key Types +// ============================================================================ + /** - * Type-safe cache key + * Key type for renderer persist cache (fixed keys only) */ export type RendererPersistCacheKey = keyof RendererPersistCacheSchema -export type UseCacheKey = keyof UseCacheSchema + +/** + * Key type for shared cache (fixed keys only) + */ export type SharedCacheKey = keyof SharedCacheSchema + +/** + * Key type for memory cache (supports both fixed and template keys). + * + * This type expands all schema keys using ProcessKey, which: + * - Keeps fixed keys unchanged (e.g., 'app.user.avatar') + * - Expands template keys to match patterns (e.g., 'scroll:${id}' -> `scroll:${string}`) + * + * The resulting union type allows TypeScript to accept any concrete key + * that matches either a fixed key or an expanded template pattern. + * + * @example + * ```typescript + * // Given schema: + * // 'app.user.avatar': string + * // 'scroll.position:${topicId}': number + * + * // UseCacheKey becomes: 'app.user.avatar' | `scroll.position:${string}` + * + * // Valid keys: + * const k1: UseCacheKey = 'app.user.avatar' // fixed key + * const k2: UseCacheKey = 'scroll.position:123' // matches template + * const k3: UseCacheKey = 'scroll.position:abc' // matches template + * + * // Invalid keys: + * const k4: UseCacheKey = 'unknown.key' // error: not in schema + * ``` + */ +export type UseCacheKey = { + [K in keyof UseCacheSchema]: ProcessKey +}[keyof UseCacheSchema] + +// ============================================================================ +// UseCache Specialized Types +// ============================================================================ + +/** + * Infers the value type for a given cache key from UseCacheSchema. + * + * Works with both fixed keys and template keys: + * - For fixed keys, returns the exact value type from schema + * - For template keys, matches the key against expanded patterns and returns the value type + * + * If the key doesn't match any schema entry, returns `never`. + * + * @template K - The cache key to infer value type for + * @returns The value type associated with the key, or `never` if not found + * + * @example + * ```typescript + * // Given schema: + * // 'app.user.avatar': string + * // 'scroll.position:${topicId}': number + * + * type T1 = InferUseCacheValue<'app.user.avatar'> // string + * type T2 = InferUseCacheValue<'scroll.position:123'> // number + * type T3 = InferUseCacheValue<'scroll.position:abc'> // number + * type T4 = InferUseCacheValue<'unknown.key'> // never + * ``` + */ +export type InferUseCacheValue = { + [S in keyof UseCacheSchema]: K extends ProcessKey ? UseCacheSchema[S] : never +}[keyof UseCacheSchema] + +/** + * Type guard for casual cache keys that blocks schema-defined keys. + * + * Used to ensure casual API methods (getCasual, setCasual, etc.) cannot + * be called with keys that are defined in the schema (including template patterns). + * This enforces proper API usage: use type-safe methods for schema keys, + * use casual methods only for truly dynamic/unknown keys. + * + * @template K - The key to check + * @returns `K` if the key doesn't match any schema pattern, `never` if it does + * + * @example + * ```typescript + * // Given schema: + * // 'app.user.avatar': string + * // 'scroll.position:${topicId}': number + * + * // These cause compile-time errors (key matches schema): + * getCasual('app.user.avatar') // Error: never + * getCasual('scroll.position:123') // Error: never (matches template) + * + * // These are allowed (key doesn't match any schema pattern): + * getCasual('my.custom.key') // OK + * getCasual('dynamic:xyz:456') // OK + * ``` + */ +export type UseCacheCasualKey = K extends UseCacheKey ? never : K diff --git a/src/renderer/src/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index 1e61ce1ab7..f2f15697f4 100644 --- a/src/renderer/src/data/CacheService.ts +++ b/src/renderer/src/data/CacheService.ts @@ -19,12 +19,12 @@ import { loggerService } from '@logger' import type { + InferUseCacheValue, RendererPersistCacheKey, RendererPersistCacheSchema, SharedCacheKey, SharedCacheSchema, - UseCacheKey, - UseCacheSchema + UseCacheKey } from '@shared/data/cache/cacheSchemas' import { DefaultRendererPersistCache } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSubscriber, CacheSyncMessage } from '@shared/data/cache/cacheTypes' @@ -102,17 +102,57 @@ export class CacheService { /** * Get value from memory cache with TTL validation (type-safe) - * @param key - Schema-defined cache key + * + * Supports both fixed keys and template keys: + * - Fixed keys: `get('app.user.avatar')` + * - Template keys: `get('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * + * DESIGN NOTE: Returns `undefined` when cache miss or TTL expired. + * This is intentional - developers need to know when a value doesn't exist + * (e.g., after explicit deletion) and handle it appropriately. + * For UI components that always need a value, use `useCache` hook instead, + * which provides automatic default value fallback. + * + * @template K - The cache key type (inferred from UseCacheKey, supports template patterns) + * @param key - Schema-defined cache key (fixed or matching template pattern) * @returns Cached value or undefined if not found or expired + * + * @example + * ```typescript + * // Fixed key - handle undefined explicitly + * const avatar = cacheService.get('app.user.avatar') ?? '' + * + * // Template key (schema: 'scroll.position:${id}': number) + * const scrollPos = cacheService.get('scroll.position:topic-123') ?? 0 + * ``` */ - get(key: K): UseCacheSchema[K] { + get(key: K): InferUseCacheValue | undefined { return this.getInternal(key) } /** * Get value from memory cache with TTL validation (casual, dynamic key) - * @param key - Dynamic cache key (e.g., `topic:${id}`) + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `get()` instead. + * + * Note: Due to TypeScript limitations with template literal types, compile-time + * blocking of schema keys works best with literal string arguments. Variable + * keys are accepted but may not trigger compile errors. + * + * @template T - The expected value type (must be specified manually) + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns Cached value or undefined if not found or expired + * + * @example + * ```typescript + * // Dynamic key with manual type specification + * const data = cacheService.getCasual('custom.dynamic.key') + * + * // Schema keys should use type-safe methods: + * // Use: cacheService.get('app.user.avatar') + * // Instead of: cacheService.getCasual('app.user.avatar') + * ``` */ getCasual(key: Exclude): T | undefined { return this.getInternal(key) @@ -138,19 +178,54 @@ export class CacheService { /** * Set value in memory cache with optional TTL (type-safe) - * @param key - Schema-defined cache key - * @param value - Value to cache (type inferred from schema) + * + * Supports both fixed keys and template keys: + * - Fixed keys: `set('app.user.avatar', 'url')` + * - Template keys: `set('scroll.position:topic-123', 100)` + * + * @template K - The cache key type (inferred from UseCacheKey, supports template patterns) + * @param key - Schema-defined cache key (fixed or matching template pattern) + * @param value - Value to cache (type inferred from schema via template matching) * @param ttl - Time to live in milliseconds (optional) + * + * @example + * ```typescript + * // Fixed key + * cacheService.set('app.user.avatar', 'https://example.com/avatar.png') + * + * // Template key (schema: 'scroll.position:${id}': number) + * cacheService.set('scroll.position:topic-123', 150) + * + * // With TTL (expires after 30 seconds) + * cacheService.set('chat.generating', true, 30000) + * ``` */ - set(key: K, value: UseCacheSchema[K], ttl?: number): void { + set(key: K, value: InferUseCacheValue, ttl?: number): void { this.setInternal(key, value, ttl) } /** * Set value in memory cache with optional TTL (casual, dynamic key) - * @param key - Dynamic cache key (e.g., `topic:${id}`) + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `set()` instead. + * + * @template T - The value type to cache + * @param key - Dynamic cache key that doesn't match any schema pattern * @param value - Value to cache * @param ttl - Time to live in milliseconds (optional) + * + * @example + * ```typescript + * // Dynamic key usage + * cacheService.setCasual('my.custom.key', { data: 'value' }) + * + * // With TTL (expires after 60 seconds) + * cacheService.setCasual('temp.data', result, 60000) + * + * // Schema keys should use type-safe methods: + * // Use: cacheService.set('app.user.avatar', 'url') + * ``` */ setCasual(key: Exclude, value: T, ttl?: number): void { this.setInternal(key, value, ttl) @@ -196,8 +271,19 @@ export class CacheService { /** * Check if key exists in memory cache and is not expired (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `has()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if key exists and is valid, false otherwise + * + * @example + * ```typescript + * if (cacheService.hasCasual('my.custom.key')) { + * const data = cacheService.getCasual('my.custom.key') + * } + * ``` */ hasCasual(key: Exclude): boolean { return this.hasInternal(key) @@ -233,8 +319,18 @@ export class CacheService { /** * Delete from memory cache with hook protection (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `delete()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if deletion succeeded, false if key is protected by active hooks + * + * @example + * ```typescript + * // Delete dynamic cache entry + * cacheService.deleteCasual('my.custom.key') + * ``` */ deleteCasual(key: Exclude): boolean { return this.deleteInternal(key) @@ -274,8 +370,19 @@ export class CacheService { /** * Check if a key has TTL set in memory cache (casual, dynamic key) - * @param key - Dynamic cache key + * + * Use this for fully dynamic keys that don't match any schema pattern. + * For keys matching schema patterns (including templates), use `hasTTL()` instead. + * + * @param key - Dynamic cache key that doesn't match any schema pattern * @returns True if key has TTL configured + * + * @example + * ```typescript + * if (cacheService.hasTTLCasual('my.custom.key')) { + * console.log('This cache entry will expire') + * } + * ``` */ hasTTLCasual(key: Exclude): boolean { const entry = this.memoryCache.get(key) diff --git a/src/renderer/src/data/hooks/useCache.ts b/src/renderer/src/data/hooks/useCache.ts index 00198afd2c..9ebfa4e687 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -1,6 +1,7 @@ import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import type { + InferUseCacheValue, RendererPersistCacheKey, RendererPersistCacheSchema, SharedCacheKey, @@ -10,59 +11,192 @@ import type { } from '@shared/data/cache/cacheSchemas' import { DefaultSharedCache, DefaultUseCache } from '@shared/data/cache/cacheSchemas' import { useCallback, useEffect, useSyncExternalStore } from 'react' + const logger = loggerService.withContext('useCache') +// ============================================================================ +// Template Matching Utilities +// ============================================================================ + +/** + * Checks if a schema key is a template key (contains ${...} placeholder). + * + * @param key - The schema key to check + * @returns true if the key contains template placeholder syntax + * + * @example + * ```typescript + * isTemplateKey('scroll.position:${id}') // true + * isTemplateKey('app.user.avatar') // false + * ``` + */ +function isTemplateKey(key: string): boolean { + return key.includes('${') && key.includes('}') +} + +/** + * Converts a template key pattern into a RegExp for matching concrete keys. + * + * Each `${variable}` placeholder is replaced with a pattern that matches + * any non-empty string of word characters, dots, and hyphens. + * + * @param template - The template key pattern (e.g., 'scroll.position:${id}') + * @returns A RegExp that matches concrete keys for this template + * + * @example + * ```typescript + * const regex = templateToRegex('scroll.position:${id}') + * regex.test('scroll.position:topic-123') // true + * regex.test('scroll.position:') // false + * regex.test('other.key:123') // false + * ``` + */ +function templateToRegex(template: string): RegExp { + // Escape special regex characters except for ${...} placeholders + const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => { + // Don't escape the ${...} syntax, we'll handle it specially + if (match === '$' || match === '{' || match === '}') { + return match + } + return '\\' + match + }) + + // Replace ${...} placeholders with a pattern matching non-empty strings + // Allows: word chars, dots, hyphens, underscores, colons + const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w.\\-_:]+)') + + return new RegExp(`^${pattern}$`) +} + +/** + * Finds the schema key that matches a given concrete key. + * + * First checks for exact match (fixed keys), then checks template patterns. + * This is used to look up default values for template keys. + * + * @param key - The concrete key to find a match for + * @returns The matching schema key, or undefined if no match found + * + * @example + * ```typescript + * // Given schema has 'app.user.avatar' and 'scroll.position:${id}' + * + * findMatchingUseCacheSchemaKey('app.user.avatar') // 'app.user.avatar' + * findMatchingUseCacheSchemaKey('scroll.position:123') // 'scroll.position:${id}' + * findMatchingUseCacheSchemaKey('unknown.key') // undefined + * ``` + */ +function findMatchingUseCacheSchemaKey(key: string): keyof UseCacheSchema | undefined { + // First, check for exact match (fixed keys) + if (key in DefaultUseCache) { + return key as keyof UseCacheSchema + } + + // Then, check template patterns + const schemaKeys = Object.keys(DefaultUseCache) as Array + for (const schemaKey of schemaKeys) { + if (isTemplateKey(schemaKey as string)) { + const regex = templateToRegex(schemaKey as string) + if (regex.test(key)) { + return schemaKey + } + } + } + + return undefined +} + +/** + * Gets the default value for a cache key from the schema. + * + * Works with both fixed keys (direct lookup) and concrete keys that + * match template patterns (finds template, returns its default). + * + * @param key - The cache key (fixed or concrete template instance) + * @returns The default value from schema, or undefined if not found + * + * @example + * ```typescript + * // Given schema: + * // 'app.user.avatar': '' (default) + * // 'scroll.position:${id}': 0 (default) + * + * getUseCacheDefaultValue('app.user.avatar') // '' + * getUseCacheDefaultValue('scroll.position:123') // 0 + * getUseCacheDefaultValue('unknown.key') // undefined + * ``` + */ +function getUseCacheDefaultValue(key: K): InferUseCacheValue | undefined { + const schemaKey = findMatchingUseCacheSchemaKey(key) + if (schemaKey) { + return DefaultUseCache[schemaKey] as InferUseCacheValue + } + return undefined +} + /** * React hook for component-level memory cache * * Use this for data that needs to be shared between components in the same window. * Data is lost when the app restarts. * - * @param key - Cache key from the predefined schema + * Supports both fixed keys and template keys: + * - Fixed keys: `useCache('app.user.avatar')` + * - Template keys: `useCache('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * + * @template K - The cache key type (inferred from UseCacheKey) + * @param key - Cache key from the predefined schema (fixed or matching template pattern) * @param initValue - Initial value (optional, uses schema default if not provided) * @returns [value, setValue] - Similar to useState but shared across components * * @example * ```typescript - * // Basic usage - * const [theme, setTheme] = useCache('ui.theme') + * // Fixed key usage + * const [avatar, setAvatar] = useCache('app.user.avatar') + * + * // Template key usage (schema: 'scroll.position:${id}': number) + * const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') + * // TypeScript infers scrollPos as number * * // With custom initial value - * const [count, setCount] = useCache('counter', 0) + * const [generating, setGenerating] = useCache('chat.generating', true) * * // Update the value - * setTheme('dark') + * setAvatar('new-avatar-url') * ``` */ export function useCache( key: K, - initValue?: UseCacheSchema[K] -): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] { + initValue?: InferUseCacheValue +): [InferUseCacheValue, (value: InferUseCacheValue) => void] { + // Get the default value for this key (works with both fixed and template keys) + const defaultValue = getUseCacheDefaultValue(key) + /** * Subscribe to cache changes using React's useSyncExternalStore * This ensures the component re-renders when the cache value changes */ const value = useSyncExternalStore( useCallback((callback) => cacheService.subscribe(key, callback), [key]), - useCallback(() => cacheService.get(key), [key]), - useCallback(() => cacheService.get(key), [key]) // SSR snapshot + useCallback(() => cacheService.get(key) as InferUseCacheValue | undefined, [key]), + useCallback(() => cacheService.get(key) as InferUseCacheValue | undefined, [key]) // SSR snapshot ) /** * Initialize cache with default value if it doesn't exist - * Priority: existing cache value > custom initValue > schema default + * Priority: existing cache value > custom initValue > schema default (via template matching) */ useEffect(() => { if (cacheService.has(key)) { return } - if (initValue === undefined) { - cacheService.set(key, DefaultUseCache[key]) - } else { + if (initValue !== undefined) { cacheService.set(key, initValue) + } else if (defaultValue !== undefined) { + cacheService.set(key, defaultValue) } - }, [key, initValue]) + }, [key, initValue, defaultValue]) /** * Register this hook as actively using the cache key @@ -90,13 +224,13 @@ export function useCache( * @param newValue - New value to store in cache */ const setValue = useCallback( - (newValue: UseCacheSchema[K]) => { + (newValue: InferUseCacheValue) => { cacheService.set(key, newValue) }, [key] ) - return [value ?? initValue ?? DefaultUseCache[key], setValue] + return [value ?? initValue ?? defaultValue!, setValue] } /** diff --git a/src/renderer/src/services/WebSearchService.ts b/src/renderer/src/services/WebSearchService.ts index b20b0ede5d..e2ce737bfe 100644 --- a/src/renderer/src/services/WebSearchService.ts +++ b/src/renderer/src/services/WebSearchService.ts @@ -191,7 +191,8 @@ class WebSearchService { * 设置网络搜索状态 */ private async setWebSearchStatus(requestId: string, status: WebSearchStatus, delayMs?: number) { - const activeSearches = cacheService.get('chat.websearch.active_searches') + // Use ?? {} to handle cache miss (cacheService.get returns undefined when not cached) + const activeSearches = cacheService.get('chat.websearch.active_searches') ?? {} activeSearches[requestId] = status cacheService.set('chat.websearch.active_searches', activeSearches) From 2093452e691a197ad5ae144759c561be2b09e42d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 13:17:58 +0800 Subject: [PATCH 115/116] fix(cache): enforce dot-separated naming for template keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update template key pattern to use dots instead of colons (e.g., 'scroll.position.${id}' not 'scroll.position:${id}') - Template keys follow same naming convention as fixed keys - Add example template keys to schema for testing - Add comprehensive type tests for template key inference - Update mock files to support template key types - Update documentation with correct template key examples 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/en/references/data/cache-overview.md | 8 +- docs/en/references/data/cache-usage.md | 48 +++--- eslint.config.mjs | 49 +++--- packages/shared/data/cache/cacheSchemas.ts | 71 +++++---- src/renderer/src/data/CacheService.ts | 17 +- .../hooks/__tests__/useCache.types.test.ts | 149 ++++++++++++++++++ src/renderer/src/data/hooks/useCache.ts | 42 +++-- tests/__mocks__/renderer/CacheService.ts | 22 +-- tests/__mocks__/renderer/useCache.ts | 94 ++++++++--- 9 files changed, 362 insertions(+), 138 deletions(-) create mode 100644 src/renderer/src/data/hooks/__tests__/useCache.types.test.ts diff --git a/docs/en/references/data/cache-overview.md b/docs/en/references/data/cache-overview.md index 159609388b..ab0f366b27 100644 --- a/docs/en/references/data/cache-overview.md +++ b/docs/en/references/data/cache-overview.md @@ -52,9 +52,11 @@ cacheService.set('temp.calculation', result, 30000) ### Type Safety - **Fixed keys**: Schema-based keys for compile-time checking (e.g., `'app.user.avatar'`) -- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position:${id}'` matches `'scroll.position:topic-123'`) +- **Template keys**: Dynamic patterns with automatic type inference (e.g., `'scroll.position.${id}'` matches `'scroll.position.topic123'`) - **Casual methods**: For completely dynamic keys with manual typing (blocked from using schema-defined keys) +Note: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www` + ## Data Categories ### Performance Cache (Memory tier) @@ -126,8 +128,8 @@ For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage. | Type | Example Schema | Example Usage | Type Inference | |------|----------------|---------------|----------------| | Fixed key | `'app.user.avatar': string` | `get('app.user.avatar')` | Automatic | -| Template key | `'scroll.position:${id}': number` | `get('scroll.position:topic-123')` | Automatic | -| Casual key | N/A | `getCasual('my.key')` | Manual | +| Template key | `'scroll.position.${id}': number` | `get('scroll.position.topic123')` | Automatic | +| Casual key | N/A | `getCasual('my.custom.key')` | Manual | ### API Reference diff --git a/docs/en/references/data/cache-usage.md b/docs/en/references/data/cache-usage.md index 2b30d12471..54066e700f 100644 --- a/docs/en/references/data/cache-usage.md +++ b/docs/en/references/data/cache-usage.md @@ -163,13 +163,15 @@ const topic = cacheService.getCasual(`my.custom.key`) // Compile error: cannot use schema keys with Casual methods cacheService.getCasual('app.user.avatar') // Error: matches fixed key -cacheService.getCasual('scroll.position:topic-123') // Error: matches template key +cacheService.getCasual('scroll.position.topic123') // Error: matches template key ``` ### Template Keys Template keys provide type-safe caching for dynamic key patterns. Define a template in the schema using `${variable}` syntax, and TypeScript will automatically match and infer types for concrete keys. +**Important**: Template keys follow the same dot-separated naming pattern as fixed keys. When `${xxx}` is treated as a literal string, the key must match the format: `xxx.yyy.zzz_www` + #### Defining Template Keys ```typescript @@ -179,15 +181,16 @@ export type UseCacheSchema = { 'app.user.avatar': string // Template keys - use ${variable} for dynamic segments - 'scroll.position:${topicId}': number - 'entity.cache:${type}:${id}': EntityData + // Must follow dot-separated pattern like fixed keys + 'scroll.position.${topicId}': number + 'entity.cache.${type}_${id}': EntityData } // Default values for templates (shared by all instances) export const DefaultUseCache: UseCacheSchema = { 'app.user.avatar': '', - 'scroll.position:${topicId}': 0, - 'entity.cache:${type}:${id}': { loaded: false } + 'scroll.position.${topicId}': 0, + 'entity.cache.${type}_${id}': { loaded: false } } ``` @@ -195,15 +198,15 @@ export const DefaultUseCache: UseCacheSchema = { ```typescript // TypeScript infers the value type from schema -const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') +const [scrollPos, setScrollPos] = useCache('scroll.position.topic123') // scrollPos is inferred as `number` -const [entity, setEntity] = useCache('entity.cache:user:456') +const [entity, setEntity] = useCache('entity.cache.user_456') // entity is inferred as `EntityData` // Direct CacheService usage -cacheService.set('scroll.position:my-topic', 150) // OK: value must be number -cacheService.set('scroll.position:my-topic', 'hi') // Error: type mismatch +cacheService.set('scroll.position.mytopic', 150) // OK: value must be number +cacheService.set('scroll.position.mytopic', 'hi') // Error: type mismatch ``` #### Template Key Benefits @@ -221,9 +224,9 @@ cacheService.set('scroll.position:my-topic', 'hi') // Error: type mismatch | Scenario | Method | Example | |----------|--------|---------| | Fixed cache keys | Type-safe | `useCache('ui.counter')` | -| Dynamic keys with known pattern | Template key | `useCache('scroll.position:topic-123')` | -| Entity caching by ID | Template key | `get('entity.cache:user:456')` | -| Completely dynamic keys | Casual | `getCasual(\`unknown.pattern:${x}\`)` | +| Dynamic keys with known pattern | Template key | `useCache('scroll.position.topic123')` | +| Entity caching by ID | Template key | `get('entity.cache.user_456')` | +| Completely dynamic keys | Casual | `getCasual(\`custom.dynamic.${x}\`)` | | UI state | Type-safe | `useSharedCache('window.layout')` | ## Common Patterns @@ -342,13 +345,13 @@ const [data, setData] = useCache('myFeature.data') export type UseCacheSchema = { // Existing keys... // Template key with dynamic segment - 'scroll.position:${topicId}': number + 'scroll.position.${topicId}': number } export const DefaultUseCache: UseCacheSchema = { // Existing defaults... // Default shared by all instances of this template - 'scroll.position:${topicId}': 0 + 'scroll.position.${topicId}': 0 } ``` @@ -356,27 +359,28 @@ export const DefaultUseCache: UseCacheSchema = { ```typescript // TypeScript infers number from template pattern -const [scrollPos, setScrollPos] = useCache(`scroll.position:${topicId}`) +const [scrollPos, setScrollPos] = useCache(`scroll.position.${topicId}`) // Works with any string in the dynamic segment -const [pos1, setPos1] = useCache('scroll.position:topic-123') -const [pos2, setPos2] = useCache('scroll.position:conversation-abc') +const [pos1, setPos1] = useCache('scroll.position.topic123') +const [pos2, setPos2] = useCache('scroll.position.conversationabc') ``` ### Key Naming Convention -All keys (fixed and template) must follow the naming convention: +All keys (fixed and template) must follow the same naming convention: -- **Format**: `namespace.sub.key_name` or `namespace.key:${variable}` +- **Format**: `namespace.sub.key_name` (template `${xxx}` treated as a literal string segment) - **Rules**: - Start with lowercase letter - Use lowercase letters, numbers, and underscores - Separate segments with dots (`.`) - - Use colons (`:`) before template placeholders + - Template placeholders `${xxx}` are treated as literal string segments - **Examples**: - ✅ `app.user.avatar` - - ✅ `scroll.position:${id}` - - ✅ `cache.entity:${type}:${id}` + - ✅ `scroll.position.${id}` + - ✅ `entity.cache.${type}_${id}` + - ❌ `scroll.position:${id}` (colon not allowed) - ❌ `UserAvatar` (no dots) - ❌ `App.User` (uppercase) diff --git a/eslint.config.mjs b/eslint.config.mjs index 8a77021cbc..2e6a2cc311 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -174,7 +174,9 @@ export default defineConfig([ // Schema key naming convention (cache & preferences) // Supports both fixed keys and template keys: // - Fixed: 'app.user.avatar', 'chat.multi_select_mode' - // - Template: 'scroll.position:${topicId}', 'cache:${type}:${id}' + // - Template: 'scroll.position.${topicId}', 'entity.cache.${type}_${id}' + // Template keys must follow the same dot-separated pattern as fixed keys. + // When ${xxx} placeholders are treated as literal strings, the key must match: xxx.yyy.zzz_www { files: ['packages/shared/data/cache/cacheSchemas.ts', 'packages/shared/data/preference/preferenceSchemas.ts'], plugins: { @@ -185,12 +187,12 @@ export default defineConfig([ type: 'problem', docs: { description: - 'Enforce schema key naming convention: namespace.sub.key_name or namespace.key:${variable}', + 'Enforce schema key naming convention: namespace.sub.key_name (template placeholders treated as literal strings)', recommended: true }, messages: { invalidKey: - 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar) or with template: namespace.key:${variable} (e.g., scroll.position:${id}).', + 'Schema key "{{key}}" must follow format: namespace.sub.key_name (e.g., app.user.avatar, scroll.position.${id}). Template ${xxx} is treated as a literal string segment.', invalidTemplateVar: 'Template variable in "{{key}}" must be a valid identifier (e.g., ${id}, ${topicId}).' } @@ -199,17 +201,14 @@ export default defineConfig([ /** * Validates a schema key for correct naming convention. * - * Supports two formats: - * 1. Fixed keys: lowercase segments separated by dots - * Example: 'app.user.avatar', 'chat.multi_select_mode' + * Both fixed keys and template keys must follow the same pattern: + * - Lowercase segments separated by dots + * - Each segment: starts with letter, contains letters/numbers/underscores + * - At least two segments (must have at least one dot) * - * 2. Template keys: fixed prefix + template placeholders - * Example: 'scroll.position:${id}', 'cache:${type}:${id}' - * - * Template placeholder rules: - * - Must use ${variableName} syntax - * - Variable name must be valid identifier (start with letter, alphanumeric + underscore) - * - Empty placeholders like ${} are invalid + * Template keys: ${xxx} placeholders are treated as literal string segments. + * Example valid: 'scroll.position.${id}', 'entity.cache.${type}_${id}' + * Example invalid: 'cache:${type}' (colon not allowed), '${id}' (no dot) * * @param {string} key - The schema key to validate * @returns {{ valid: boolean, error?: 'invalidKey' | 'invalidTemplateVar' }} @@ -219,12 +218,7 @@ export default defineConfig([ const hasTemplate = key.includes('${') if (hasTemplate) { - // Template key validation - // Must have at least one dot-separated segment before any template or colon - // Example valid: 'scroll.position:${id}', 'cache:${type}:${id}' - // Example invalid: '${id}', ':${id}' - - // Extract and validate all template variables + // Validate template variable names first const templateVarPattern = /\$\{([^}]*)\}/g let match while ((match = templateVarPattern.exec(key)) !== null) { @@ -235,17 +229,14 @@ export default defineConfig([ } } - // Replace template placeholders with a marker to validate the structure - const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, '__TEMPLATE__') + // Replace template placeholders with a valid segment marker + // Use 'x' as placeholder since it's a valid segment character + const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x') - // Template key structure: - // - Must start with a valid segment (lowercase letters, numbers, underscores) - // - Segments separated by dots or colons - // - Must have at least one dot-separated segment - // - Can end with template placeholder - const templateKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*(:[a-z0-9_]*|:__TEMPLATE__)*$/ - - if (!templateKeyPattern.test(keyWithoutTemplates)) { + // Template key must follow the same pattern as fixed keys + // when ${xxx} is treated as a literal string + const fixedKeyPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + if (!fixedKeyPattern.test(keyWithoutTemplates)) { return { valid: false, error: 'invalidKey' } } diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 8ec2bdaff0..892009ea0a 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -5,32 +5,35 @@ import type * as CacheValueTypes from './cacheValueTypes' * * ## Key Naming Convention * - * All cache keys MUST follow the format: `namespace.sub.key_name` + * All cache keys (fixed and template) MUST follow the format: `namespace.sub.key_name` * * Rules: * - At least 2 segments separated by dots (.) * - Each segment uses lowercase letters, numbers, and underscores only * - Pattern: /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + * - Template placeholders `${xxx}` are treated as literal string segments * * Examples: * - 'app.user.avatar' (valid) * - 'chat.multi_select_mode' (valid) - * - 'minapp.opened_keep_alive' (valid) + * - 'scroll.position.${topicId}' (valid template key) * - 'userAvatar' (invalid - missing dot separator) * - 'App.user' (invalid - uppercase not allowed) + * - 'scroll.position:${id}' (invalid - colon not allowed) * * ## Template Key Support * * Template keys allow type-safe dynamic keys using template literal syntax. * Define in schema with `${variable}` placeholder, use with actual values. + * Template keys follow the same dot-separated pattern as fixed keys. * * Examples: - * - Schema: `'scroll.position:${topicId}': number` - * - Usage: `useCache('scroll.position:topic-123')` -> infers `number` type + * - Schema: `'scroll.position.${topicId}': number` + * - Usage: `useCache('scroll.position.topic123')` -> infers `number` type * * Multiple placeholders are supported: - * - Schema: `'cache:${type}:${id}': CacheData` - * - Usage: `useCache('cache:user:456')` -> infers `CacheData` type + * - Schema: `'entity.cache.${type}_${id}': CacheData` + * - Usage: `useCache('entity.cache.user_456')` -> infers `CacheData` type * * This convention is enforced by ESLint rule: data-schema-key/valid-key */ @@ -50,9 +53,9 @@ import type * as CacheValueTypes from './cacheValueTypes' * * @example * ```typescript - * type Test1 = IsTemplateKey<'scroll:${id}'> // true - * type Test2 = IsTemplateKey<'cache:${a}:${b}'> // true - * type Test3 = IsTemplateKey<'app.user.avatar'> // false + * type Test1 = IsTemplateKey<'scroll.position.${id}'> // true + * type Test2 = IsTemplateKey<'entity.cache.${a}_${b}'> // true + * type Test3 = IsTemplateKey<'app.user.avatar'> // false * ``` */ export type IsTemplateKey = K extends `${string}\${${string}}${string}` ? true : false @@ -69,11 +72,11 @@ export type IsTemplateKey = K extends `${string}\${${string}}$ * * @example * ```typescript - * type Test1 = ExpandTemplateKey<'scroll:${id}'> - * // Result: `scroll:${string}` (matches 'scroll:123', 'scroll:abc', etc.) + * type Test1 = ExpandTemplateKey<'scroll.position.${id}'> + * // Result: `scroll.position.${string}` (matches 'scroll.position.123', etc.) * - * type Test2 = ExpandTemplateKey<'cache:${type}:${id}'> - * // Result: `cache:${string}:${string}` (matches 'cache:user:123', etc.) + * type Test2 = ExpandTemplateKey<'entity.cache.${type}_${id}'> + * // Result: `entity.cache.${string}_${string}` (matches 'entity.cache.user_123', etc.) * * type Test3 = ExpandTemplateKey<'app.user.avatar'> * // Result: 'app.user.avatar' (unchanged for non-template keys) @@ -94,8 +97,8 @@ export type ExpandTemplateKey = T extends `${infer Prefix}\${$ * * @example * ```typescript - * type Test1 = ProcessKey<'scroll:${id}'> // `scroll:${string}` - * type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar' + * type Test1 = ProcessKey<'scroll.position.${id}'> // `scroll.position.${string}` + * type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar' * ``` */ export type ProcessKey = IsTemplateKey extends true ? ExpandTemplateKey : K @@ -135,6 +138,10 @@ export type UseCacheSchema = { 'agent.active_id': string | null 'agent.session.active_id_map': Record 'agent.session.waiting_id_map': Record + + // Template key examples (for testing and demonstration) + 'scroll.position.${topicId}': number + 'entity.cache.${type}_${id}': { loaded: boolean; data: unknown } } export const DefaultUseCache: UseCacheSchema = { @@ -173,7 +180,11 @@ export const DefaultUseCache: UseCacheSchema = { // Agent management 'agent.active_id': null, 'agent.session.active_id_map': {}, - 'agent.session.waiting_id_map': {} + 'agent.session.waiting_id_map': {}, + + // Template key examples (for testing and demonstration) + 'scroll.position.${topicId}': 0, + 'entity.cache.${type}_${id}': { loaded: false, data: null } } /** @@ -218,7 +229,7 @@ export type SharedCacheKey = keyof SharedCacheSchema * * This type expands all schema keys using ProcessKey, which: * - Keeps fixed keys unchanged (e.g., 'app.user.avatar') - * - Expands template keys to match patterns (e.g., 'scroll:${id}' -> `scroll:${string}`) + * - Expands template keys to match patterns (e.g., 'scroll.position.${id}' -> `scroll.position.${string}`) * * The resulting union type allows TypeScript to accept any concrete key * that matches either a fixed key or an expanded template pattern. @@ -227,17 +238,17 @@ export type SharedCacheKey = keyof SharedCacheSchema * ```typescript * // Given schema: * // 'app.user.avatar': string - * // 'scroll.position:${topicId}': number + * // 'scroll.position.${topicId}': number * - * // UseCacheKey becomes: 'app.user.avatar' | `scroll.position:${string}` + * // UseCacheKey becomes: 'app.user.avatar' | `scroll.position.${string}` * * // Valid keys: - * const k1: UseCacheKey = 'app.user.avatar' // fixed key - * const k2: UseCacheKey = 'scroll.position:123' // matches template - * const k3: UseCacheKey = 'scroll.position:abc' // matches template + * const k1: UseCacheKey = 'app.user.avatar' // fixed key + * const k2: UseCacheKey = 'scroll.position.123' // matches template + * const k3: UseCacheKey = 'scroll.position.abc' // matches template * * // Invalid keys: - * const k4: UseCacheKey = 'unknown.key' // error: not in schema + * const k4: UseCacheKey = 'unknown.key' // error: not in schema * ``` */ export type UseCacheKey = { @@ -264,12 +275,12 @@ export type UseCacheKey = { * ```typescript * // Given schema: * // 'app.user.avatar': string - * // 'scroll.position:${topicId}': number + * // 'scroll.position.${topicId}': number * * type T1 = InferUseCacheValue<'app.user.avatar'> // string - * type T2 = InferUseCacheValue<'scroll.position:123'> // number - * type T3 = InferUseCacheValue<'scroll.position:abc'> // number - * type T4 = InferUseCacheValue<'unknown.key'> // never + * type T2 = InferUseCacheValue<'scroll.position.123'> // number + * type T3 = InferUseCacheValue<'scroll.position.abc'> // number + * type T4 = InferUseCacheValue<'unknown.key'> // never * ``` */ export type InferUseCacheValue = { @@ -291,15 +302,15 @@ export type InferUseCacheValue = { * ```typescript * // Given schema: * // 'app.user.avatar': string - * // 'scroll.position:${topicId}': number + * // 'scroll.position.${topicId}': number * * // These cause compile-time errors (key matches schema): * getCasual('app.user.avatar') // Error: never - * getCasual('scroll.position:123') // Error: never (matches template) + * getCasual('scroll.position.123') // Error: never (matches template) * * // These are allowed (key doesn't match any schema pattern): * getCasual('my.custom.key') // OK - * getCasual('dynamic:xyz:456') // OK + * getCasual('other.dynamic.key') // OK * ``` */ export type UseCacheCasualKey = K extends UseCacheKey ? never : K diff --git a/src/renderer/src/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index f2f15697f4..f88432ee10 100644 --- a/src/renderer/src/data/CacheService.ts +++ b/src/renderer/src/data/CacheService.ts @@ -105,7 +105,10 @@ export class CacheService { * * Supports both fixed keys and template keys: * - Fixed keys: `get('app.user.avatar')` - * - Template keys: `get('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * - Template keys: `get('scroll.position.topic123')` (matches schema `'scroll.position.${id}'`) + * + * Template keys follow the same dot-separated pattern as fixed keys. + * When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www * * DESIGN NOTE: Returns `undefined` when cache miss or TTL expired. * This is intentional - developers need to know when a value doesn't exist @@ -122,8 +125,8 @@ export class CacheService { * // Fixed key - handle undefined explicitly * const avatar = cacheService.get('app.user.avatar') ?? '' * - * // Template key (schema: 'scroll.position:${id}': number) - * const scrollPos = cacheService.get('scroll.position:topic-123') ?? 0 + * // Template key (schema: 'scroll.position.${id}': number) + * const scrollPos = cacheService.get('scroll.position.topic123') ?? 0 * ``` */ get(key: K): InferUseCacheValue | undefined { @@ -181,7 +184,9 @@ export class CacheService { * * Supports both fixed keys and template keys: * - Fixed keys: `set('app.user.avatar', 'url')` - * - Template keys: `set('scroll.position:topic-123', 100)` + * - Template keys: `set('scroll.position.topic123', 100)` + * + * Template keys follow the same dot-separated pattern as fixed keys. * * @template K - The cache key type (inferred from UseCacheKey, supports template patterns) * @param key - Schema-defined cache key (fixed or matching template pattern) @@ -193,8 +198,8 @@ export class CacheService { * // Fixed key * cacheService.set('app.user.avatar', 'https://example.com/avatar.png') * - * // Template key (schema: 'scroll.position:${id}': number) - * cacheService.set('scroll.position:topic-123', 150) + * // Template key (schema: 'scroll.position.${id}': number) + * cacheService.set('scroll.position.topic123', 150) * * // With TTL (expires after 30 seconds) * cacheService.set('chat.generating', true, 30000) diff --git a/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts b/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts new file mode 100644 index 0000000000..fee022e2a6 --- /dev/null +++ b/src/renderer/src/data/hooks/__tests__/useCache.types.test.ts @@ -0,0 +1,149 @@ +/** + * Type-level tests for template key type inference + * + * These tests verify compile-time type behavior of the cache system: + * 1. Template key type inference works correctly + * 2. Casual API blocks schema keys (including template patterns) + * 3. Value types are correctly inferred from schema + */ + +import type { + ExpandTemplateKey, + InferUseCacheValue, + IsTemplateKey, + ProcessKey, + UseCacheCasualKey, + UseCacheKey +} from '@shared/data/cache/cacheSchemas' +import { describe, expect, expectTypeOf, it } from 'vitest' + +describe('Template Key Type Utilities', () => { + describe('IsTemplateKey', () => { + it('should detect template keys as true', () => { + // Using expectTypeOf for type-level assertions + const templateResult1: IsTemplateKey<'scroll.position.${id}'> = true + const templateResult2: IsTemplateKey<'entity.cache.${type}_${id}'> = true + expect(templateResult1).toBe(true) + expect(templateResult2).toBe(true) + }) + + it('should detect fixed keys as false', () => { + const fixedResult1: IsTemplateKey<'app.user.avatar'> = false + const fixedResult2: IsTemplateKey<'chat.generating'> = false + expect(fixedResult1).toBe(false) + expect(fixedResult2).toBe(false) + }) + }) + + describe('ExpandTemplateKey', () => { + it('should expand single placeholder', () => { + // Type assertion: 'scroll.position.topic123' should extend the expanded type + type Expanded = ExpandTemplateKey<'scroll.position.${id}'> + const key1: Expanded = 'scroll.position.topic123' + const key2: Expanded = 'scroll.position.abc' + expect(key1).toBe('scroll.position.topic123') + expect(key2).toBe('scroll.position.abc') + }) + + it('should expand multiple placeholders', () => { + type Expanded = ExpandTemplateKey<'entity.cache.${type}_${id}'> + const key1: Expanded = 'entity.cache.user_123' + const key2: Expanded = 'entity.cache.post_456' + expect(key1).toBe('entity.cache.user_123') + expect(key2).toBe('entity.cache.post_456') + }) + + it('should leave fixed keys unchanged', () => { + type Expanded = ExpandTemplateKey<'app.user.avatar'> + const key: Expanded = 'app.user.avatar' + expect(key).toBe('app.user.avatar') + }) + }) + + describe('ProcessKey', () => { + it('should expand template keys', () => { + type Processed = ProcessKey<'scroll.position.${topicId}'> + const key: Processed = 'scroll.position.topic123' + expect(key).toBe('scroll.position.topic123') + }) + + it('should keep fixed keys unchanged', () => { + type Processed = ProcessKey<'app.user.avatar'> + const key: Processed = 'app.user.avatar' + expect(key).toBe('app.user.avatar') + }) + }) + + describe('UseCacheKey', () => { + it('should include fixed keys', () => { + const key1: UseCacheKey = 'app.user.avatar' + const key2: UseCacheKey = 'chat.generating' + expect(key1).toBe('app.user.avatar') + expect(key2).toBe('chat.generating') + }) + + it('should match template patterns', () => { + const key1: UseCacheKey = 'scroll.position.topic123' + const key2: UseCacheKey = 'scroll.position.abc-def' + const key3: UseCacheKey = 'entity.cache.user_456' + expect(key1).toBe('scroll.position.topic123') + expect(key2).toBe('scroll.position.abc-def') + expect(key3).toBe('entity.cache.user_456') + }) + }) + + describe('InferUseCacheValue', () => { + it('should infer value type for fixed keys', () => { + // These type assertions verify the type system works + const avatarType: InferUseCacheValue<'app.user.avatar'> = 'test' + const generatingType: InferUseCacheValue<'chat.generating'> = true + expectTypeOf(avatarType).toBeString() + expectTypeOf(generatingType).toBeBoolean() + }) + + it('should infer value type for template key instances', () => { + const scrollType: InferUseCacheValue<'scroll.position.topic123'> = 100 + const entityType: InferUseCacheValue<'entity.cache.user_456'> = { loaded: true, data: null } + expectTypeOf(scrollType).toBeNumber() + expectTypeOf(entityType).toMatchTypeOf<{ loaded: boolean; data: unknown }>() + }) + + it('should return never for unknown keys', () => { + // Unknown key should infer to never + type UnknownValue = InferUseCacheValue<'unknown.key.here'> + expectTypeOf().toBeNever() + }) + }) + + describe('UseCacheCasualKey', () => { + it('should block fixed schema keys', () => { + // Fixed keys should resolve to never + type BlockedFixed = UseCacheCasualKey<'app.user.avatar'> + expectTypeOf().toBeNever() + }) + + it('should block template pattern matches', () => { + // Keys matching template patterns should resolve to never + type BlockedTemplate = UseCacheCasualKey<'scroll.position.topic123'> + expectTypeOf().toBeNever() + }) + + it('should allow non-schema keys', () => { + // Non-schema keys should pass through + type AllowedKey = UseCacheCasualKey<'my.custom.key'> + const key: AllowedKey = 'my.custom.key' + expect(key).toBe('my.custom.key') + }) + }) + + describe('Runtime template key detection', () => { + it('should correctly detect template keys', () => { + const isTemplate = (key: string) => key.includes('${') && key.includes('}') + + expect(isTemplate('scroll.position.${id}')).toBe(true) + expect(isTemplate('entity.cache.${type}_${id}')).toBe(true) + expect(isTemplate('app.user.avatar')).toBe(false) + expect(isTemplate('chat.generating')).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/data/hooks/useCache.ts b/src/renderer/src/data/hooks/useCache.ts index 9ebfa4e687..d13196bc11 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -26,8 +26,8 @@ const logger = loggerService.withContext('useCache') * * @example * ```typescript - * isTemplateKey('scroll.position:${id}') // true - * isTemplateKey('app.user.avatar') // false + * isTemplateKey('scroll.position.${id}') // true + * isTemplateKey('app.user.avatar') // false * ``` */ function isTemplateKey(key: string): boolean { @@ -38,17 +38,21 @@ function isTemplateKey(key: string): boolean { * Converts a template key pattern into a RegExp for matching concrete keys. * * Each `${variable}` placeholder is replaced with a pattern that matches - * any non-empty string of word characters, dots, and hyphens. + * any non-empty string of word characters (letters, numbers, underscores, hyphens). * - * @param template - The template key pattern (e.g., 'scroll.position:${id}') + * Template keys follow the same dot-separated pattern as fixed keys. + * When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www + * + * @param template - The template key pattern (e.g., 'scroll.position.${id}') * @returns A RegExp that matches concrete keys for this template * * @example * ```typescript - * const regex = templateToRegex('scroll.position:${id}') - * regex.test('scroll.position:topic-123') // true - * regex.test('scroll.position:') // false - * regex.test('other.key:123') // false + * const regex = templateToRegex('scroll.position.${id}') + * regex.test('scroll.position.topic123') // true + * regex.test('scroll.position.topic-123') // true + * regex.test('scroll.position.') // false + * regex.test('other.key.123') // false * ``` */ function templateToRegex(template: string): RegExp { @@ -62,8 +66,9 @@ function templateToRegex(template: string): RegExp { }) // Replace ${...} placeholders with a pattern matching non-empty strings - // Allows: word chars, dots, hyphens, underscores, colons - const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w.\\-_:]+)') + // Allows: word chars (letters, numbers, underscores) and hyphens + // Does NOT allow dots or colons since those are structural separators + const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w\\-]+)') return new RegExp(`^${pattern}$`) } @@ -79,10 +84,10 @@ function templateToRegex(template: string): RegExp { * * @example * ```typescript - * // Given schema has 'app.user.avatar' and 'scroll.position:${id}' + * // Given schema has 'app.user.avatar' and 'scroll.position.${id}' * * findMatchingUseCacheSchemaKey('app.user.avatar') // 'app.user.avatar' - * findMatchingUseCacheSchemaKey('scroll.position:123') // 'scroll.position:${id}' + * findMatchingUseCacheSchemaKey('scroll.position.123') // 'scroll.position.${id}' * findMatchingUseCacheSchemaKey('unknown.key') // undefined * ``` */ @@ -119,10 +124,10 @@ function findMatchingUseCacheSchemaKey(key: string): keyof UseCacheSchema | unde * ```typescript * // Given schema: * // 'app.user.avatar': '' (default) - * // 'scroll.position:${id}': 0 (default) + * // 'scroll.position.${id}': 0 (default) * * getUseCacheDefaultValue('app.user.avatar') // '' - * getUseCacheDefaultValue('scroll.position:123') // 0 + * getUseCacheDefaultValue('scroll.position.123') // 0 * getUseCacheDefaultValue('unknown.key') // undefined * ``` */ @@ -142,7 +147,10 @@ function getUseCacheDefaultValue(key: K): InferUseCacheVa * * Supports both fixed keys and template keys: * - Fixed keys: `useCache('app.user.avatar')` - * - Template keys: `useCache('scroll.position:topic-123')` (matches schema `'scroll.position:${id}'`) + * - Template keys: `useCache('scroll.position.topic123')` (matches schema `'scroll.position.${id}'`) + * + * Template keys follow the same dot-separated pattern as fixed keys. + * When ${xxx} is treated as a literal string, the key matches: xxx.yyy.zzz_www * * @template K - The cache key type (inferred from UseCacheKey) * @param key - Cache key from the predefined schema (fixed or matching template pattern) @@ -154,8 +162,8 @@ function getUseCacheDefaultValue(key: K): InferUseCacheVa * // Fixed key usage * const [avatar, setAvatar] = useCache('app.user.avatar') * - * // Template key usage (schema: 'scroll.position:${id}': number) - * const [scrollPos, setScrollPos] = useCache('scroll.position:topic-123') + * // Template key usage (schema: 'scroll.position.${id}': number) + * const [scrollPos, setScrollPos] = useCache('scroll.position.topic123') * // TypeScript infers scrollPos as number * * // With custom initial value diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index a66d8ddba6..69f41f8689 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -2,11 +2,11 @@ import type { RendererPersistCacheKey, RendererPersistCacheSchema, UseCacheKey, - UseCacheSchema, + InferUseCacheValue, SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas' -import { DefaultRendererPersistCache, DefaultUseCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' +import { DefaultRendererPersistCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' import type { CacheEntry, CacheSubscriber } from '@shared/data/cache/cacheTypes' import { vi } from 'vitest' @@ -66,20 +66,20 @@ export const createMockCacheService = ( const mockCacheService = { // ============ Memory Cache (Type-safe) ============ - get: vi.fn((key: K): UseCacheSchema[K] => { + get: vi.fn((key: K): InferUseCacheValue | undefined => { const entry = memoryCache.get(key) if (entry === undefined) { - return DefaultUseCache[key] + return undefined } if (isExpired(entry)) { memoryCache.delete(key) notifySubscribers(key) - return DefaultUseCache[key] + return undefined } - return entry.value + return entry.value as InferUseCacheValue }), - set: vi.fn((key: K, value: UseCacheSchema[K], ttl?: number): void => { + set: vi.fn((key: K, value: InferUseCacheValue, ttl?: number): void => { const entry: CacheEntry = { value, expireAt: ttl ? Date.now() + ttl : undefined @@ -409,12 +409,12 @@ export const MockCacheService = { } // ============ Memory Cache (Type-safe) ============ - get(key: K): UseCacheSchema[K] { - return mockCacheService.get(key) + get(key: K): InferUseCacheValue | undefined { + return mockCacheService.get(key) as unknown as InferUseCacheValue | undefined } - set(key: K, value: UseCacheSchema[K], ttl?: number): void { - return mockCacheService.set(key, value, ttl) + set(key: K, value: InferUseCacheValue, ttl?: number): void { + mockCacheService.set(key, value as unknown as InferUseCacheValue, ttl) } has(key: K): boolean { diff --git a/tests/__mocks__/renderer/useCache.ts b/tests/__mocks__/renderer/useCache.ts index 77f9dd5dd1..4f323d31d6 100644 --- a/tests/__mocks__/renderer/useCache.ts +++ b/tests/__mocks__/renderer/useCache.ts @@ -3,6 +3,7 @@ import type { RendererPersistCacheSchema, UseCacheKey, UseCacheSchema, + InferUseCacheValue, SharedCacheKey, SharedCacheSchema } from '@shared/data/cache/cacheSchemas' @@ -14,14 +15,14 @@ import { vi } from 'vitest' * Provides comprehensive mocks for all cache management hooks */ -// Mock cache state storage -const mockMemoryCache = new Map() +// Mock cache state storage (using string for memory cache to support template keys) +const mockMemoryCache = new Map() const mockSharedCache = new Map() const mockPersistCache = new Map() // Initialize caches with defaults Object.entries(DefaultUseCache).forEach(([key, value]) => { - mockMemoryCache.set(key as UseCacheKey, value) + mockMemoryCache.set(key, value) }) Object.entries(DefaultSharedCache).forEach(([key, value]) => { @@ -32,13 +33,13 @@ Object.entries(DefaultRendererPersistCache).forEach(([key, value]) => { mockPersistCache.set(key as RendererPersistCacheKey, value) }) -// Mock subscribers for cache changes -const mockMemorySubscribers = new Map void>>() +// Mock subscribers for cache changes (using string for memory to support template keys) +const mockMemorySubscribers = new Map void>>() const mockSharedSubscribers = new Map void>>() const mockPersistSubscribers = new Map void>>() // Helper functions to notify subscribers -const notifyMemorySubscribers = (key: UseCacheKey) => { +const notifyMemorySubscribers = (key: string) => { const subscribers = mockMemorySubscribers.get(key) if (subscribers) { subscribers.forEach((callback) => { @@ -77,25 +78,78 @@ const notifyPersistSubscribers = (key: RendererPersistCacheKey) => { } } +// ============ Template Key Utilities ============ + +/** + * Checks if a schema key is a template key (contains ${...} placeholder). + */ +const isTemplateKey = (key: string): boolean => { + return key.includes('${') && key.includes('}') +} + +/** + * Converts a template key pattern into a RegExp for matching concrete keys. + */ +const templateToRegex = (template: string): RegExp => { + const escaped = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => { + if (match === '$' || match === '{' || match === '}') { + return match + } + return '\\' + match + }) + const pattern = escaped.replace(/\$\{[^}]+\}/g, '([\\w\\-]+)') + return new RegExp(`^${pattern}$`) +} + +/** + * Finds the schema key that matches a given concrete key. + */ +const findMatchingSchemaKey = (key: string): keyof UseCacheSchema | undefined => { + if (key in DefaultUseCache) { + return key as keyof UseCacheSchema + } + const schemaKeys = Object.keys(DefaultUseCache) as Array + for (const schemaKey of schemaKeys) { + if (isTemplateKey(schemaKey as string)) { + const regex = templateToRegex(schemaKey as string) + if (regex.test(key)) { + return schemaKey + } + } + } + return undefined +} + +/** + * Gets the default value for a cache key from the schema. + */ +const getDefaultValue = (key: K): InferUseCacheValue | undefined => { + const schemaKey = findMatchingSchemaKey(key) + if (schemaKey) { + return DefaultUseCache[schemaKey] as InferUseCacheValue + } + return undefined +} + /** * Mock useCache hook (memory cache) */ export const mockUseCache = vi.fn( ( key: K, - initValue?: UseCacheSchema[K] - ): [UseCacheSchema[K], (value: UseCacheSchema[K]) => void] => { + initValue?: InferUseCacheValue + ): [InferUseCacheValue, (value: InferUseCacheValue) => void] => { // Get current value let currentValue = mockMemoryCache.get(key) if (currentValue === undefined) { - currentValue = initValue ?? DefaultUseCache[key] + currentValue = initValue ?? getDefaultValue(key) if (currentValue !== undefined) { mockMemoryCache.set(key, currentValue) } } // Mock setValue function - const setValue = vi.fn((value: UseCacheSchema[K]) => { + const setValue = vi.fn((value: InferUseCacheValue) => { mockMemoryCache.set(key, value) notifyMemorySubscribers(key) }) @@ -185,7 +239,7 @@ export const MockUseCacheUtils = { mockPersistCache.clear() Object.entries(DefaultUseCache).forEach(([key, value]) => { - mockMemoryCache.set(key as UseCacheKey, value) + mockMemoryCache.set(key, value) }) Object.entries(DefaultSharedCache).forEach(([key, value]) => { @@ -205,7 +259,7 @@ export const MockUseCacheUtils = { /** * Set cache value for testing (memory cache) */ - setCacheValue: (key: K, value: UseCacheSchema[K]) => { + setCacheValue: (key: K, value: InferUseCacheValue) => { mockMemoryCache.set(key, value) notifyMemorySubscribers(key) }, @@ -213,8 +267,8 @@ export const MockUseCacheUtils = { /** * Get cache value (memory cache) */ - getCacheValue: (key: K): UseCacheSchema[K] => { - return mockMemoryCache.get(key) ?? DefaultUseCache[key] + getCacheValue: (key: K): InferUseCacheValue | undefined => { + return mockMemoryCache.get(key) ?? getDefaultValue(key) }, /** @@ -283,7 +337,7 @@ export const MockUseCacheUtils = { /** * Simulate cache change from external source */ - simulateExternalCacheChange: (key: K, value: UseCacheSchema[K]) => { + simulateExternalCacheChange: (key: K, value: InferUseCacheValue) => { mockMemoryCache.set(key, value) notifyMemorySubscribers(key) }, @@ -293,17 +347,17 @@ export const MockUseCacheUtils = { */ mockCacheReturn: ( key: K, - value: UseCacheSchema[K], - setValue?: (value: UseCacheSchema[K]) => void + value: InferUseCacheValue, + setValue?: (value: InferUseCacheValue) => void ) => { mockUseCache.mockImplementation((cacheKey, initValue) => { if (cacheKey === key) { - return [value, setValue || vi.fn()] + return [value, setValue || vi.fn()] as any } // Default behavior for other keys - const defaultValue = mockMemoryCache.get(cacheKey) ?? initValue ?? DefaultUseCache[cacheKey] - return [defaultValue, vi.fn()] + const defaultValue = mockMemoryCache.get(cacheKey) ?? initValue ?? getDefaultValue(cacheKey) + return [defaultValue, vi.fn()] as any }) }, From d50149dccb3706dfc27b4e11a39cb04f362ebcf4 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 5 Jan 2026 13:33:41 +0800 Subject: [PATCH 116/116] refactor(cache): migrate StreamingService to schema-defined cache keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5 template keys in cacheSchemas.ts for streaming service: - message.streaming.session.${messageId} - message.streaming.topic_sessions.${topicId} - message.streaming.content.${messageId} - message.streaming.block.${blockId} - message.streaming.siblings_counter.${topicId} - Replace xxxCasual methods with type-safe get/set/has/delete - Update key naming to follow dot-separated convention - Use `any` types temporarily (TODO for v2 type migration) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/shared/data/cache/cacheSchemas.ts | 24 +++++- .../messageStreaming/StreamingService.ts | 73 ++++++++++--------- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/packages/shared/data/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 892009ea0a..dc182a9c7e 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -142,6 +142,21 @@ export type UseCacheSchema = { // Template key examples (for testing and demonstration) 'scroll.position.${topicId}': number 'entity.cache.${type}_${id}': { loaded: boolean; data: unknown } + + // ============================================================================ + // Message Streaming Cache (Temporary) + // ============================================================================ + // TODO [v2]: Replace `any` with proper types after newMessage.ts types are + // migrated to packages/shared/data/types/message.ts + // Current types: + // - StreamingSession: defined locally in StreamingService.ts + // - Message: src/renderer/src/types/newMessage.ts (renderer format, not shared/Message) + // - MessageBlock: src/renderer/src/types/newMessage.ts + 'message.streaming.session.${messageId}': any // StreamingSession + 'message.streaming.topic_sessions.${topicId}': string[] + 'message.streaming.content.${messageId}': any // Message (renderer format) + 'message.streaming.block.${blockId}': any // MessageBlock + 'message.streaming.siblings_counter.${topicId}': number } export const DefaultUseCache: UseCacheSchema = { @@ -184,7 +199,14 @@ export const DefaultUseCache: UseCacheSchema = { // Template key examples (for testing and demonstration) 'scroll.position.${topicId}': 0, - 'entity.cache.${type}_${id}': { loaded: false, data: null } + 'entity.cache.${type}_${id}': { loaded: false, data: null }, + + // Message Streaming Cache + 'message.streaming.session.${messageId}': null, + 'message.streaming.topic_sessions.${topicId}': [], + 'message.streaming.content.${messageId}': null, + 'message.streaming.block.${blockId}': null, + 'message.streaming.siblings_counter.${topicId}': 0 } /** diff --git a/src/renderer/src/services/messageStreaming/StreamingService.ts b/src/renderer/src/services/messageStreaming/StreamingService.ts index 1dc31fee35..8707b18a0c 100644 --- a/src/renderer/src/services/messageStreaming/StreamingService.ts +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -11,11 +11,12 @@ * - On finalize, data is converted to new format and persisted via appropriate data source * - Throttling is handled externally by messageThunk.ts (preserves existing throttle logic) * - * Cache Key Strategy: - * - Session key: `streaming:session:${messageId}` - Internal session lifecycle management - * - Topic sessions index: `streaming:topic:${topicId}:sessions` - Track active sessions per topic - * - Message key: `streaming:message:${messageId}` - UI subscription for message-level changes - * - Block key: `streaming:block:${blockId}` - UI subscription for block content updates + * Cache Key Strategy (uses schema-defined template keys from cacheSchemas.ts): + * - Session key: `message.streaming.session.${messageId}` - Internal session lifecycle management + * - Topic sessions index: `message.streaming.topic_sessions.${topicId}` - Track active sessions per topic + * - Message key: `message.streaming.content.${messageId}` - UI subscription for message-level changes + * - Block key: `message.streaming.block.${blockId}` - UI subscription for block content updates + * - Siblings counter: `message.streaming.siblings_counter.${topicId}` - Multi-model response group counter */ import { cacheService } from '@data/CacheService' @@ -32,12 +33,12 @@ import { dbService } from '../db' const logger = loggerService.withContext('StreamingService') -// Cache key generators -const getSessionKey = (messageId: string) => `streaming:session:${messageId}` -const getTopicSessionsKey = (topicId: string) => `streaming:topic:${topicId}:sessions` -const getMessageKey = (messageId: string) => `streaming:message:${messageId}` -const getBlockKey = (blockId: string) => `streaming:block:${blockId}` -const getSiblingsGroupCounterKey = (topicId: string) => `streaming:topic:${topicId}:siblings-counter` +// Cache key generators (matches template keys in cacheSchemas.ts) +const getSessionKey = (messageId: string) => `message.streaming.session.${messageId}` as const +const getTopicSessionsKey = (topicId: string) => `message.streaming.topic_sessions.${topicId}` as const +const getMessageKey = (messageId: string) => `message.streaming.content.${messageId}` as const +const getBlockKey = (blockId: string) => `message.streaming.block.${blockId}` as const +const getSiblingsGroupCounterKey = (topicId: string) => `message.streaming.siblings_counter.${topicId}` as const // Session TTL for auto-cleanup (prevents memory leaks from crashed processes) const SESSION_TTL = 5 * 60 * 1000 // 5 minutes @@ -166,16 +167,16 @@ class StreamingService { } // Store session with TTL - cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(getSessionKey(messageId), session, SESSION_TTL) // Store message data for UI subscription - cacheService.setCasual(getMessageKey(messageId), message, SESSION_TTL) + cacheService.set(getMessageKey(messageId), message, SESSION_TTL) // Add to topic sessions index - const topicSessions = cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + const topicSessions = cacheService.get(getTopicSessionsKey(topicId)) || [] if (!topicSessions.includes(messageId)) { topicSessions.push(messageId) - cacheService.setCasual(getTopicSessionsKey(topicId), topicSessions, SESSION_TTL) + cacheService.set(getTopicSessionsKey(topicId), topicSessions, SESSION_TTL) } logger.debug('Started streaming session', { topicId, messageId, parentId, siblingsGroupId }) @@ -241,23 +242,23 @@ class StreamingService { // Remove block mappings Object.keys(session.blocks).forEach((blockId) => { this.blockToMessageMap.delete(blockId) - cacheService.deleteCasual(getBlockKey(blockId)) + cacheService.delete(getBlockKey(blockId)) }) // Remove message cache - cacheService.deleteCasual(getMessageKey(messageId)) + cacheService.delete(getMessageKey(messageId)) // Remove from topic sessions index - const topicSessions = cacheService.getCasual(getTopicSessionsKey(session.topicId)) || [] + const topicSessions = cacheService.get(getTopicSessionsKey(session.topicId)) || [] const updatedTopicSessions = topicSessions.filter((id) => id !== messageId) if (updatedTopicSessions.length > 0) { - cacheService.setCasual(getTopicSessionsKey(session.topicId), updatedTopicSessions, SESSION_TTL) + cacheService.set(getTopicSessionsKey(session.topicId), updatedTopicSessions, SESSION_TTL) } else { - cacheService.deleteCasual(getTopicSessionsKey(session.topicId)) + cacheService.delete(getTopicSessionsKey(session.topicId)) } // Remove session - cacheService.deleteCasual(getSessionKey(messageId)) + cacheService.delete(getSessionKey(messageId)) logger.debug('Cleared streaming session', { messageId, topicId: session.topicId }) } @@ -290,9 +291,9 @@ class StreamingService { } // Update caches - cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) - cacheService.setCasual(getBlockKey(block.id), block, SESSION_TTL) - cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) + cacheService.set(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(getBlockKey(block.id), block, SESSION_TTL) + cacheService.set(getMessageKey(messageId), session.message, SESSION_TTL) logger.debug('Added block to session', { messageId, blockId: block.id, blockType: block.type }) } @@ -331,8 +332,8 @@ class StreamingService { session.blocks[blockId] = updatedBlock // Update caches - cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) - cacheService.setCasual(getBlockKey(blockId), updatedBlock, SESSION_TTL) + cacheService.set(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(getBlockKey(blockId), updatedBlock, SESSION_TTL) } /** @@ -342,7 +343,7 @@ class StreamingService { * @returns Block or null if not found */ getBlock(blockId: string): MessageBlock | null { - return cacheService.getCasual(getBlockKey(blockId)) || null + return cacheService.get(getBlockKey(blockId)) || null } // ============ Message Operations ============ @@ -365,8 +366,8 @@ class StreamingService { session.message = { ...session.message, ...updates } // Update caches - cacheService.setCasual(getSessionKey(messageId), session, SESSION_TTL) - cacheService.setCasual(getMessageKey(messageId), session.message, SESSION_TTL) + cacheService.set(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(getMessageKey(messageId), session.message, SESSION_TTL) } /** @@ -376,7 +377,7 @@ class StreamingService { * @returns Message or null if not found */ getMessage(messageId: string): Message | null { - return cacheService.getCasual(getMessageKey(messageId)) || null + return cacheService.get(getMessageKey(messageId)) || null } // ============ Query Methods ============ @@ -388,7 +389,7 @@ class StreamingService { * @returns True if streaming is active */ isStreaming(topicId: string): boolean { - const topicSessions = cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + const topicSessions = cacheService.get(getTopicSessionsKey(topicId)) || [] return topicSessions.length > 0 } @@ -399,7 +400,7 @@ class StreamingService { * @returns True if message is streaming */ isMessageStreaming(messageId: string): boolean { - return cacheService.hasCasual(getSessionKey(messageId)) + return cacheService.has(getSessionKey(messageId)) } /** @@ -409,7 +410,7 @@ class StreamingService { * @returns Session or null if not found */ getSession(messageId: string): StreamingSession | null { - return cacheService.getCasual(getSessionKey(messageId)) || null + return cacheService.get(getSessionKey(messageId)) || null } /** @@ -419,7 +420,7 @@ class StreamingService { * @returns Array of message IDs */ getActiveMessageIds(topicId: string): string[] { - return cacheService.getCasual(getTopicSessionsKey(topicId)) || [] + return cacheService.get(getTopicSessionsKey(topicId)) || [] } // ============ siblingsGroupId Generation ============ @@ -445,10 +446,10 @@ class StreamingService { //FIXME [v2] 现在获取 siblingsGroupId 的方式是不正确,后续再做修改调整 generateNextGroupId(topicId: string): number { const counterKey = getSiblingsGroupCounterKey(topicId) - const currentCounter = cacheService.getCasual(counterKey) || 0 + const currentCounter = cacheService.get(counterKey) || 0 const nextGroupId = currentCounter + 1 // Store with no TTL (persistent within session, cleared on app restart) - cacheService.setCasual(counterKey, nextGroupId) + cacheService.set(counterKey, nextGroupId) logger.debug('Generated siblingsGroupId', { topicId, siblingsGroupId: nextGroupId }) return nextGroupId }