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/.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" 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/.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/CLAUDE.md b/CLAUDE.md index 5c942b83d4..e448c2b487 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,39 +43,22 @@ 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 - -#### 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 @@ -100,61 +83,33 @@ When creating a Pull Request, you MUST: - **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 -### Database Architecture +- **Removing**: Redux, Dexie +- **Adopting**: Cache / Preference / DataApi architecture (see [Data Management](#data-management)) -- **Database**: SQLite (`cherrystudio.sqlite`) + libsql driver -- **ORM**: Drizzle ORM with comprehensive migration system -- **Schemas**: Located in `src/main/data/db/schemas/` directory +### UI Layer -#### Database Standards +- **Removing**: antd, HeroUI, styled-components +- **Adopting**: `@cherrystudio/ui` (located in `packages/ui`, Tailwind CSS + Shadcn UI) +- **Prohibited**: antd, HeroUI, styled-components -- **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 migrations:generate` +### File Naming Convention -## Data Access Patterns +During migration, use `*.v2.ts` suffix for files not yet fully migrated: -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) +- 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"); 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/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/en/references/data/README.md b/docs/en/references/data/README.md new file mode 100644 index 0000000000..abccd93508 --- /dev/null +++ b/docs/en/references/data/README.md @@ -0,0 +1,197 @@ +# 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 + +### Testing +- [Test Mocks](../../../../tests/__mocks__/README.md) - Unified mocks for Cache, Preference, and DataApi + +--- + +## 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/docs/en/references/data/api-design-guidelines.md b/docs/en/references/data/api-design-guidelines.md new file mode 100644 index 0000000000..6c6da6eba0 --- /dev/null +++ b/docs/en/references/data/api-design-guidelines.md @@ -0,0 +1,250 @@ +# 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 +} +``` + +## 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: + +```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 | +| 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 | +| 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 `SerializedDataApiError` structure (transmitted via IPC): + +```typescript +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 +} +``` + +**Examples:** + +```typescript +// 404 Not Found +{ + code: 'NOT_FOUND', + message: "Topic with id 'abc123' not found", + status: 404, + details: { resource: 'Topic', id: 'abc123' }, + requestContext: { requestId: 'req_123', path: '/topics/abc123', method: 'GET' } +} + +// 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'] + } + } +} + +// 504 Timeout +{ + code: 'TIMEOUT', + message: 'Request timeout: fetch topics (3000ms)', + 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: + +```typescript +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') +throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') + +// Check if error is retryable +if (error instanceof DataApiError && error.isRetryable) { + await retry(operation) +} +``` + +## 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` | diff --git a/docs/en/references/data/api-types.md b/docs/en/references/data/api-types.md new file mode 100644 index 0000000000..a475b4d0c4 --- /dev/null +++ b/docs/en/references/data/api-types.md @@ -0,0 +1,338 @@ +# 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, + // Pagination types + OffsetPaginationParams, + OffsetPaginationResponse, + CursorPaginationParams, + CursorPaginationResponse, + PaginationResponse, + // Query parameter types + SortParams, + SearchParams +} from '@shared/data/api' + +import { + ErrorCode, + DataApiError, + DataApiErrorFactory, + isDataApiError, + toDataApiError, + // Pagination type guards + isOffsetPaginationResponse, + isCursorPaginationResponse +} 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' +``` + +## Pagination Types + +The API system supports two pagination modes with composable query parameters. + +### Request Parameters + +| Type | Fields | Use Case | +|------|--------|----------| +| `OffsetPaginationParams` | `page?`, `limit?` | Traditional page-based navigation | +| `CursorPaginationParams` | `cursor?`, `limit?` | Infinite scroll, real-time feeds | +| `SortParams` | `sortBy?`, `sortOrder?` | Sorting (combine as needed) | +| `SearchParams` | `search?` | Text search (combine as needed) | + +### Cursor Semantics + +The `cursor` in `CursorPaginationParams` marks an **exclusive boundary** - the cursor item itself is never included in the response. + +**Common patterns:** + +| Pattern | Use Case | Behavior | +|---------|----------|----------| +| "after cursor" | Forward pagination, new items | Returns items AFTER cursor | +| "before cursor" | Backward/historical loading | Returns items BEFORE cursor | + +The specific semantic depends on the API endpoint. For example: +- `GET /topics/:id/messages` uses "before cursor" for loading historical messages +- Other endpoints may use "after cursor" for forward pagination + +**Example: Loading historical messages** + +```typescript +// First request - get most recent messages +const res1 = await api.get('/topics/123/messages', { query: { limit: 20 } }) +// res1: { items: [msg80...msg99], nextCursor: 'msg80-id', activeNodeId: '...' } + +// Load more - get older messages before the cursor +const res2 = await api.get('/topics/123/messages', { + query: { cursor: res1.nextCursor, limit: 20 } +}) +// res2: { items: [msg60...msg79], nextCursor: 'msg60-id', activeNodeId: '...' } +// Note: msg80 is NOT in res2 (cursor is exclusive) +``` + +### Response Types + +| Type | Fields | Description | +|------|--------|-------------| +| `OffsetPaginationResponse` | `items`, `total`, `page` | Page-based results | +| `CursorPaginationResponse` | `items`, `nextCursor?` | Cursor-based results | +| `PaginationResponse` | Union of both | When either mode is acceptable | + +### Usage Examples + +```typescript +// Offset pagination with sort and search +query?: OffsetPaginationParams & SortParams & SearchParams & { + type?: string +} +response: OffsetPaginationResponse + +// Cursor pagination for infinite scroll +query?: CursorPaginationParams & { + userId: string +} +response: CursorPaginationResponse +``` + +### Client-side Calculations + +For `OffsetPaginationResponse`, clients can calculate: +```typescript +const pageCount = Math.ceil(total / limit) +const hasNext = page * limit < total +const hasPrev = page > 1 +``` + +For `CursorPaginationResponse`: +```typescript +const hasNext = nextCursor !== undefined +``` + +## Adding a New Domain Schema + +1. Create the schema file (e.g., `schemas/topic.ts`): + +```typescript +import type { + OffsetPaginationParams, + OffsetPaginationResponse, + SearchParams, + SortParams +} 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: { + query?: OffsetPaginationParams & SortParams & SearchParams + response: OffsetPaginationResponse // 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, limit: 20, search: 'hello' } +}) // Returns OffsetPaginationResponse +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..ab0f366b27 --- /dev/null +++ b/docs/en/references/data/cache-overview.md @@ -0,0 +1,142 @@ +# 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 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 +- 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 +- **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.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) +- 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 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 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 + +## Usage Summary + +For detailed code examples and API usage, see [Cache Usage Guide](./cache-usage.md). + +### 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.topic123')` | Automatic | +| Casual key | N/A | `getCasual('my.custom.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 new file mode 100644 index 0000000000..54066e700f --- /dev/null +++ b/docs/en/references/data/cache-usage.md @@ -0,0 +1,424 @@ +# 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') +``` + +## 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 +- 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 +- **Cannot use keys that match schema patterns** (including template keys) + +```typescript +// Dynamic key, must specify type +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.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 +// packages/shared/data/cache/cacheSchemas.ts +export type UseCacheSchema = { + // Fixed key + 'app.user.avatar': string + + // Template keys - use ${variable} for dynamic segments + // 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 } +} +``` + +#### Using Template Keys + +```typescript +// TypeScript infers the value type from schema +const [scrollPos, setScrollPos] = useCache('scroll.position.topic123') +// 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.mytopic', 150) // OK: value must be number +cacheService.set('scroll.position.mytopic', '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')` | +| 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 + +### 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 + +### Adding Fixed Keys + +#### 1. Add to Cache Schema + +```typescript +// packages/shared/data/cache/cacheSchemas.ts +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) + +```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') +``` + +### 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.topic123') +const [pos2, setPos2] = useCache('scroll.position.conversationabc') +``` + +### Key Naming Convention + +All keys (fixed and template) must follow the same naming convention: + +- **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 (`.`) + - Template placeholders `${xxx}` are treated as literal string segments +- **Examples**: + - ✅ `app.user.avatar` + - ✅ `scroll.position.${id}` + - ✅ `entity.cache.${type}_${id}` + - ❌ `scroll.position:${id}` (colon not allowed) + - ❌ `UserAvatar` (no dots) + - ❌ `App.User` (uppercase) + +## 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 +2. **Use TTL for stale data**: Prevent serving outdated cached values +3. **Prefer type-safe keys**: Add to schema when possible +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/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..8d4b9f07f6 --- /dev/null +++ b/docs/en/references/data/data-api-in-renderer.md @@ -0,0 +1,314 @@ +# 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, isLoading, 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('/topics', { enabled: !!topicId }) + +// With refresh callback +const { data, mutate, refetch } = useQuery('/topics') +// Refresh data +refetch() // or 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, isLoading } = useMutation('POST', '/topics') +const newTopic = await createTopic({ body: { name: 'New Topic' } }) + +// Update (PUT - full replacement) +const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123') +await replaceTopic({ body: { name: 'Updated Name', description: '...' } }) + +// Partial Update (PATCH) +const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123') +await updateTopic({ body: { name: 'New Name' } }) + +// Delete +const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123') +await deleteTopic() + +// With auto-refresh of other queries +const { trigger } = useMutation('POST', '/topics', { + refresh: ['/topics'], // Refresh these keys on success + onSuccess: (data) => console.log('Created:', data) +}) +``` + +### useInfiniteQuery (Cursor-based Infinite Scroll) + +For infinite scroll UIs with "Load More" pattern. + +```typescript +import { useInfiniteQuery } from '@data/hooks/useDataApi' + +const { items, isLoading, hasNext, loadNext } = useInfiniteQuery('/messages', { + query: { topicId: 'abc123' }, + limit: 20 +}) + +// items: all loaded items flattened +// loadNext(): load next page +// hasNext: true if more pages available +``` + +### usePaginatedQuery (Offset-based Pagination) + +For page-by-page navigation with previous/next controls. + +```typescript +import { usePaginatedQuery } from '@data/hooks/useDataApi' + +const { items, page, total, hasNext, hasPrev, nextPage, prevPage } = + usePaginatedQuery('/topics', { limit: 10 }) + +// items: current page items +// page/total: current page number and total count +// nextPage()/prevPage(): navigate between pages +``` + +### Choosing Pagination Hooks + +| Use Case | Hook | +|----------|------| +| Infinite scroll, chat, feeds | `useInfiniteQuery` | +| Page navigation, tables | `usePaginatedQuery` | +| Manual control | `useQuery` | + +## 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, isLoading, error } = useQuery('/topics') + + if (isLoading) 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 + +### Create Form + +```typescript +function CreateTopicForm() { + // Use refresh option to auto-refresh /topics after creation + const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', { + refresh: ['/topics'] + }) + + const handleSubmit = async (data: CreateTopicDto) => { + try { + await createTopic({ body: data }) + toast.success('Topic created') + } catch (error) { + toast.error('Failed to create topic') + } + } + + return ( +
+ {/* form fields */} + +
+ ) +} +``` + +### Optimistic Updates + +```typescript +function TopicItem({ topic }: { topic: Topic }) { + // Use optimisticData for automatic optimistic updates with rollback + const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, { + optimisticData: { ...topic, starred: !topic.starred } + }) + + const handleToggleStar = async () => { + try { + await updateTopic({ body: { starred: !topic.starred } }) + } catch (error) { + // Rollback happens automatically when optimisticData is set + 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('POST', '/topics') +// 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. **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/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..c4745832b9 --- /dev/null +++ b/docs/en/references/data/database-patterns.md @@ -0,0 +1,207 @@ +# 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. + +## 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/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..8d08dd8d3a --- /dev/null +++ b/docs/en/references/data/v2-migration-guide.md @@ -0,0 +1,72 @@ +# 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 (see `migrators/README-.md` for detailed documentation): + - `PreferencesMigrator` (implemented): maps ElectronStore + Redux settings to the `preference` table using `mappings/PreferencesMappings.ts`. + - `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. + - 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. +- [ ] 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/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/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/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 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/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/electron-builder.yml b/electron-builder.yml index b56f32220a..bb5a4f3954 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -135,38 +135,44 @@ artifactBuildCompleted: scripts/artifact-build-completed.js releaseInfo: releaseNotes: | - Cherry Studio 1.7.6 - New Models & MCP Enhancements - - This release adds support for new AI models and includes a new MCP server for memory management. + Cherry Studio 1.7.9 - New Features & Bug Fixes ✨ 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 + - [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 - - [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 + - [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.6 - 新模型与 MCP 增强 - - 本次更新添加了多个新 AI 模型支持,并新增记忆管理 MCP 服务器。 + Cherry Studio 1.7.9 - 新功能与问题修复 ✨ 新功能 - - [模型] 添加小米 MiMo 模型支持 - - [模型] 添加 Gemini 3 Flash 和 Pro 模型检测支持 - - [模型] 添加火山引擎 Doubao-Seed-1.8 模型支持 - - [MCP] 新增 Nowledge Mem 内置 MCP 服务器,用于记忆管理 - - [设置] 添加默认推理强度选项,解决 undefined 和 none 之间的混淆 + - [Agent] 新增 302.AI 服务商支持 + - [浏览器] 浏览器数据现在可以保存,支持多标签页 + - [语言] 新增罗马尼亚语支持 + - [搜索] 文件列表新增模糊搜索功能 + - [模型] 新增最新智谱模型 + - [图片] 优化文生图功能 🐛 问题修复 - - [Azure] 修复非 v1 apiVersion 的部署 URL 问题 - - [翻译] 禁用翻译时的推理模式以提高效率 - - [图像] 更新 OpenAIBaseClient 中图像生成请求的 API 路径 - - [Windows] 自动发现并保存 Windows scoop 用户的 Git Bash 路径 + - [Mac] 修复迷你窗口意外关闭的问题 + - [预览] 修复全屏模式下 HTML 预览控件无法使用的问题 + - [翻译] 修复翻译重复执行的问题 + - [缩放] 修复页面导航时缩放被重置的问题 + - [智能体] 修复在智能体和助手间切换时崩溃的问题 + - [智能体] 修复智能体模式下的导航问题 + - [复制] 修复 Markdown 复制按钮问题 + - [兼容性] 修复非 Windows 系统的兼容性问题 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 444d3c533b..6da235db4e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,7 +1,7 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite' 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' @@ -18,7 +18,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'), @@ -27,7 +27,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: { @@ -53,8 +54,7 @@ export default defineConfig({ plugins: [ react({ tsDecorators: true - }), - externalizeDepsPlugin() + }) ], resolve: { alias: { @@ -120,7 +120,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/eslint.config.mjs b/eslint.config.mjs index 94357f9126..cde667d167 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -173,6 +173,11 @@ 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}', '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: { @@ -182,25 +187,80 @@ 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 (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).' + '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}).' } }, create(context) { - const VALID_KEY_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/ + /** + * Validates a schema key for correct naming convention. + * + * 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) + * + * 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' }} + */ + function validateKey(key) { + // Check if key contains template placeholders + const hasTemplate = key.includes('${') + + if (hasTemplate) { + // Validate template variable names first + 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 valid segment marker + // Use 'x' as placeholder since it's a valid segment character + const keyWithoutTemplates = key.replace(/\$\{[^}]+\}/g, 'x') + + // 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' } + } + + 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 } }) } @@ -209,10 +269,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/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/0000_init.sql b/migrations/sqlite-drizzle/0000_init.sql new file mode 100644 index 0000000000..1b49b5e7ad --- /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, + `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("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, + `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, + `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/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/0000_snapshot.json b/migrations/sqlite-drizzle/meta/0000_snapshot.json index 51c5ed6cba..2fd34856f7 100644 --- a/migrations/sqlite-drizzle/meta/0000_snapshot.json +++ b/migrations/sqlite-drizzle/meta/0000_snapshot.json @@ -6,7 +6,7 @@ }, "dialect": "sqlite", "enums": {}, - "id": "de8009d7-95b9-4f99-99fa-4b8795708f21", + "id": "2ee6f7b2-99da-4de1-b895-48866855b7c6", "internal": { "indexes": {} }, @@ -57,6 +57,305 @@ "name": "app_state", "uniqueConstraints": {} }, + "entity_tag": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "entity_id": { + "autoincrement": false, + "name": "entity_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "tag_id": { + "autoincrement": false, + "name": "tag_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, + "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" + } + }, + "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": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": true, + "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": {}, + "indexes": { + "group_entity_sort_idx": { + "columns": ["entity_type", "sort_order"], + "isUnique": false, + "name": "group_entity_sort_idx" + } + }, + "name": "group", + "uniqueConstraints": {} + }, + "message": { + "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')" + } + }, + "columns": { + "assistant_id": { + "autoincrement": false, + "name": "assistant_id", + "notNull": 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", + "notNull": 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", + "notNull": 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": { @@ -76,6 +375,7 @@ }, "scope": { "autoincrement": false, + "default": "'default'", "name": "scope", "notNull": true, "primaryKey": false, @@ -96,16 +396,214 @@ "type": "text" } }, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": ["scope", "key"], + "name": "preference_scope_key_pk" + } + }, + "foreignKeys": {}, + "indexes": {}, + "name": "preference", + "uniqueConstraints": {} + }, + "tag": { + "checkConstraints": {}, + "columns": { + "color": { + "autoincrement": false, + "name": "color", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": 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", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, "compositePrimaryKeys": {}, "foreignKeys": {}, "indexes": { - "scope_name_idx": { - "columns": ["scope", "key"], - "isUnique": false, - "name": "scope_name_idx" + "tag_name_unique": { + "columns": ["name"], + "isUnique": true, + "name": "tag_name_unique" } }, - "name": "preference", + "name": "tag", + "uniqueConstraints": {} + }, + "topic": { + "checkConstraints": {}, + "columns": { + "active_node_id": { + "autoincrement": false, + "name": "active_node_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "assistant_id": { + "autoincrement": false, + "name": "assistant_id", + "notNull": 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", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "deleted_at": { + "autoincrement": false, + "name": "deleted_at", + "notNull": 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_assistant_id_idx": { + "columns": ["assistant_id"], + "isUnique": false, + "name": "topic_assistant_id_idx" + }, + "topic_group_sort_idx": { + "columns": ["group_id", "sort_order"], + "isUnique": false, + "name": "topic_group_sort_idx" + }, + "topic_group_updated_idx": { + "columns": ["group_id", "updated_at"], + "isUnique": false, + "name": "topic_group_updated_idx" + }, + "topic_is_pinned_idx": { + "columns": ["is_pinned", "pinned_order"], + "isUnique": false, + "name": "topic_is_pinned_idx" + }, + "topic_updated_at_idx": { + "columns": ["updated_at"], + "isUnique": false, + "name": "topic_updated_at_idx" + } + }, + "name": "topic", "uniqueConstraints": {} } }, diff --git a/migrations/sqlite-drizzle/meta/0001_snapshot.json b/migrations/sqlite-drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000000..7560d37a6c --- /dev/null +++ b/migrations/sqlite-drizzle/meta/0001_snapshot.json @@ -0,0 +1,612 @@ +{ + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "dialect": "sqlite", + "enums": {}, + "id": "a433b120-0ab8-4f3f-9d1d-766b48c216c8", + "internal": { + "indexes": {} + }, + "prevId": "2ee6f7b2-99da-4de1-b895-48866855b7c6", + "tables": { + "app_state": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "description": { + "autoincrement": false, + "name": "description", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "key": { + "autoincrement": false, + "name": "key", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "value": { + "autoincrement": false, + "name": "value", + "notNull": true, + "primaryKey": false, + "type": "text" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": {}, + "name": "app_state", + "uniqueConstraints": {} + }, + "entity_tag": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "entity_id": { + "autoincrement": false, + "name": "entity_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "tag_id": { + "autoincrement": false, + "name": "tag_id", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "updated_at": { + "autoincrement": false, + "name": "updated_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, + "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" + } + }, + "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": { + "checkConstraints": {}, + "columns": { + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "entity_type": { + "autoincrement": false, + "name": "entity_type", + "notNull": true, + "primaryKey": false, + "type": "text" + }, + "id": { + "autoincrement": false, + "name": "id", + "notNull": true, + "primaryKey": true, + "type": "text" + }, + "name": { + "autoincrement": false, + "name": "name", + "notNull": true, + "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": {}, + "indexes": { + "group_entity_sort_idx": { + "columns": ["entity_type", "sort_order"], + "isUnique": false, + "name": "group_entity_sort_idx" + } + }, + "name": "group", + "uniqueConstraints": {} + }, + "message": { + "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')" + } + }, + "columns": { + "assistant_id": { + "autoincrement": false, + "name": "assistant_id", + "notNull": 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", + "notNull": 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", + "notNull": 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" + } + }, + "compositePrimaryKeys": { + "preference_scope_key_pk": { + "columns": ["scope", "key"], + "name": "preference_scope_key_pk" + } + }, + "foreignKeys": {}, + "indexes": {}, + "name": "preference", + "uniqueConstraints": {} + }, + "tag": { + "checkConstraints": {}, + "columns": { + "color": { + "autoincrement": false, + "name": "color", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "created_at": { + "autoincrement": false, + "name": "created_at", + "notNull": 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", + "notNull": false, + "primaryKey": false, + "type": "integer" + } + }, + "compositePrimaryKeys": {}, + "foreignKeys": {}, + "indexes": { + "tag_name_unique": { + "columns": ["name"], + "isUnique": true, + "name": "tag_name_unique" + } + }, + "name": "tag", + "uniqueConstraints": {} + }, + "topic": { + "checkConstraints": {}, + "columns": { + "active_node_id": { + "autoincrement": false, + "name": "active_node_id", + "notNull": false, + "primaryKey": false, + "type": "text" + }, + "assistant_id": { + "autoincrement": false, + "name": "assistant_id", + "notNull": 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", + "notNull": false, + "primaryKey": false, + "type": "integer" + }, + "deleted_at": { + "autoincrement": false, + "name": "deleted_at", + "notNull": 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_assistant_id_idx": { + "columns": ["assistant_id"], + "isUnique": false, + "name": "topic_assistant_id_idx" + }, + "topic_group_sort_idx": { + "columns": ["group_id", "sort_order"], + "isUnique": false, + "name": "topic_group_sort_idx" + }, + "topic_group_updated_idx": { + "columns": ["group_id", "updated_at"], + "isUnique": false, + "name": "topic_group_updated_idx" + }, + "topic_is_pinned_idx": { + "columns": ["is_pinned", "pinned_order"], + "isUnique": false, + "name": "topic_is_pinned_idx" + }, + "topic_updated_at_idx": { + "columns": ["updated_at"], + "isUnique": false, + "name": "topic_updated_at_idx" + } + }, + "name": "topic", + "uniqueConstraints": {} + } + }, + "version": "6", + "views": {} +} diff --git a/migrations/sqlite-drizzle/meta/_journal.json b/migrations/sqlite-drizzle/meta/_journal.json index db2791fd7f..c2bac3b325 100644 --- a/migrations/sqlite-drizzle/meta/_journal.json +++ b/migrations/sqlite-drizzle/meta/_journal.json @@ -4,9 +4,16 @@ { "breakpoints": true, "idx": 0, - "tag": "0000_solid_lord_hawal", + "tag": "0000_init", "version": "6", - "when": 1754745234572 + "when": 1767272575118 + }, + { + "breakpoints": true, + "idx": 1, + "tag": "0001_futuristic_human_fly", + "version": "6", + "when": 1767455592181 } ], "version": "7" diff --git a/package.json b/package.json index 23fc3eb68e..ee872cdcc6 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", @@ -75,7 +76,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", @@ -88,6 +89,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", @@ -98,10 +100,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", "stream-json": "^1.9.1", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", @@ -280,7 +280,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", @@ -375,7 +375,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", @@ -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", @@ -405,7 +407,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", @@ -421,7 +423,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/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/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index ed54fd760d..c611cd3151 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', // data migration DataMigrate_CheckNeeded = 'data-migrate:check-needed', @@ -327,6 +329,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', // Data: Preference Preference_Get = 'preference:get', @@ -340,11 +343,10 @@ 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', - 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', @@ -393,6 +395,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', @@ -413,10 +416,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/packages/shared/data/README.md b/packages/shared/data/README.md index b65af18e33..30d30ff54d 100644 --- a/packages/shared/data/README.md +++ b/packages/shared/data/README.md @@ -1,106 +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. -## 📁 Directory Structure +## 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 -│ ├── 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 +│ ├── 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 -└── 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 three main data management systems: +### Import Conventions -### 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 - -### 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 -// Import API types -import type { DataRequest, DataResponse, ApiSchemas } from '@shared/data/api' -``` +// API infrastructure types (from barrel) +import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api' +import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api' -### Cache Types -```typescript -// Import cache types +// Domain DTOs (from schema files) +import type { Topic, CreateTopicDto } from '@shared/data/api/schemas/topic' + +// 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 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. Define business entities in `api/apiModels.ts` -2. Add endpoint definitions to `api/apiSchemas.ts` -3. Export types from `api/index.ts` - -### Best Practices -- Use `import type` for type-only imports -- Follow existing naming conventions -- Document complex types with JSDoc -- Maintain type safety across all imports - -## 🔗 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 - -### 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 diff --git a/packages/shared/data/api/README.md b/packages/shared/data/api/README.md new file mode 100644 index 0000000000..eb06824d87 --- /dev/null +++ b/packages/shared/data/api/README.md @@ -0,0 +1,42 @@ +# Data API Type System + +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 exports +├── apiTypes.ts # Core request/response types +├── apiPaths.ts # Path template utilities +├── apiErrors.ts # Error handling +└── schemas/ + ├── index.ts # Schema composition + └── *.ts # Domain-specific schemas +``` + +## Quick Reference + +### Import Conventions + +```typescript +// Infrastructure types (via barrel) +import type { DataRequest, DataResponse, ApiClient } from '@shared/data/api' +import { ErrorCode, DataApiError, DataApiErrorFactory } from '@shared/data/api' + +// 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' +``` + +### 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/` diff --git a/packages/shared/data/api/apiErrors.ts b/packages/shared/data/api/apiErrors.ts new file mode 100644 index 0000000000..4819ac32e2 --- /dev/null +++ b/packages/shared/data/api/apiErrors.ts @@ -0,0 +1,817 @@ +/** + * @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', + + /** + * 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 + // ───────────────────────────────────────────────────────────────── + + /** 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, + [ErrorCode.INVALID_OPERATION]: 400, + + // 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.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', + [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 INVALID_OPERATION - what operation was invalid. + */ +export interface InvalidOperationErrorDetails { + operation: string + reason?: 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.INVALID_OPERATION]: InvalidOperationErrorDetails + [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 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 + * @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/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..4cdfc08761 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 } } @@ -46,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 */ @@ -60,146 +127,131 @@ 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' + +// Re-export for backwards compatibility in DataResponse +export type { SerializedDataApiError } from './apiErrors' + +// ============================================================================ +// Pagination Types +// ============================================================================ + +// ----- Request Parameters ----- /** - * Standard error codes for Data API + * Offset-based pagination parameters (page + limit) */ -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' -} - -/** - * 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 - */ -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 - */ -export interface PaginationParams { +export interface OffsetPaginationParams { /** Page number (1-based) */ page?: number /** Items per page */ limit?: number - /** Cursor for cursor-based pagination */ - cursor?: string - /** Sort field and direction */ - sort?: { - field: string - order: 'asc' | 'desc' - } } /** - * Paginated response wrapper + * Cursor-based pagination parameters (cursor + limit) + * + * The cursor is typically an opaque reference to a record in the dataset. + * The cursor itself is NEVER included in the response - it marks an exclusive boundary. + * + * Common semantics: + * - "after cursor": Returns items AFTER the cursor (forward pagination) + * - "before cursor": Returns items BEFORE the cursor (backward/historical pagination) + * + * The specific semantic depends on the API endpoint. Check endpoint documentation. */ -export interface PaginatedResponse { +export interface CursorPaginationParams { + /** Cursor for pagination boundary (exclusive - cursor item not included in response) */ + cursor?: string + /** Items per page */ + limit?: number +} + +/** + * Sort parameters (independent, combine as needed) + */ +export interface SortParams { + /** Field to sort by */ + sortBy?: string + /** Sort direction */ + sortOrder?: 'asc' | 'desc' +} + +/** + * Search parameters (independent, combine as needed) + */ +export interface SearchParams { + /** Search query string */ + search?: string +} + +// ----- Response Types ----- + +/** + * Offset-based pagination response + */ +export interface OffsetPaginationResponse { /** Items for current page */ items: T[] /** Total number of items */ total: number - /** Current page number */ + /** Current page number (1-based) */ page: number - /** Total number of pages */ - pageCount: number - /** Whether there are more pages */ - hasNext: boolean - /** Whether there are previous pages */ - hasPrev: boolean - /** Next cursor for cursor-based pagination */ +} + +/** + * Cursor-based pagination response + */ +export interface CursorPaginationResponse { + /** Items for current page */ + items: T[] + /** Next cursor (undefined means no more data) */ nextCursor?: string - /** Previous cursor for cursor-based pagination */ - prevCursor?: string +} + +// ----- Type Utilities ----- + +/** + * Infer pagination mode from response type + */ +export type InferPaginationMode = R extends OffsetPaginationResponse + ? 'offset' + : R extends CursorPaginationResponse + ? 'cursor' + : never + +/** + * Infer item type from pagination response + */ +export type InferPaginationItem = R extends OffsetPaginationResponse + ? T + : R extends CursorPaginationResponse + ? T + : never + +/** + * Union type for both pagination responses + */ +export type PaginationResponse = OffsetPaginationResponse | CursorPaginationResponse + +/** + * Type guard: check if response is offset-based + */ +export function isOffsetPaginationResponse( + response: PaginationResponse +): response is OffsetPaginationResponse { + return 'page' in response && 'total' in response +} + +/** + * Type guard: check if response is cursor-based + */ +export function isCursorPaginationResponse( + response: PaginationResponse +): response is CursorPaginationResponse { + return !('page' in response) } /** @@ -274,16 +326,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/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 3b00e37473..a90c4e2c51 100644 --- a/packages/shared/data/api/index.ts +++ b/packages/shared/data/api/index.ts @@ -1,121 +1,110 @@ /** * 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, DataApiError } 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, + CursorPaginationParams, + CursorPaginationResponse, DataRequest, DataResponse, HttpMethod, - Middleware, - PaginatedResponse, - PaginationParams, - RequestContext, - ServiceOptions, - ServiceResult, - SubscriptionCallback, - SubscriptionOptions, - TransactionRequest + OffsetPaginationParams, + OffsetPaginationResponse, + PaginationResponse, + SearchParams, + SortParams } from './apiTypes' -export { ErrorCode, SubscriptionEvent } from './apiTypes' +export { isCursorPaginationResponse, isOffsetPaginationResponse } 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 (from apiErrors.ts) +// ============================================================================ + +// Error code enum and mappings export { - ErrorCode as DataApiErrorCode, - DataApiErrorFactory, ERROR_MESSAGES, ERROR_STATUS_MAP, - isDataApiError, - toDataApiError -} from './errorCodes' - -/** - * Re-export commonly used type combinations for convenience - */ - -// 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 -} from './apiTypes' -import type { DataApiErrorFactory } from './errorCodes' + isRetryableErrorCode, + RETRYABLE_ERROR_CODES +} from './apiErrors' -/** All test item-related types */ -export type TestItemTypes = { - TestItem: TestItem - CreateTestItemDto: CreateTestItemDto - UpdateTestItemDto: UpdateTestItemDto -} +// DataApiError class and factory +export { + DataApiError, + DataApiErrorFactory, + isDataApiError, + isSerializedDataApiError, + toDataApiError +} from './apiErrors' -/** All error-related types and utilities */ -export type ErrorTypes = { - DataApiError: DataApiError - ErrorCode: ErrorCode - ErrorFactory: typeof DataApiErrorFactory -} +// Error-related types +export type { + ConcurrentModificationErrorDetails, + DatabaseErrorDetails, + DataInconsistentErrorDetails, + DetailsForCode, + ErrorDetailsMap, + InternalErrorDetails, + InvalidOperationErrorDetails, + NotFoundErrorDetails, + PermissionDeniedErrorDetails, + RequestContext, + ResourceLockedErrorDetails, + SerializedDataApiError, + TimeoutErrorDetails, + ValidationErrorDetails +} from './apiErrors' -/** All request/response types */ -export type RequestTypes = { - DataRequest: DataRequest - DataResponse: DataResponse - BatchRequest: BatchRequest - BatchResponse: BatchResponse - TransactionRequest: TransactionRequest -} +// ============================================================================ +// Subscription & Middleware (for advanced usage) +// ============================================================================ -/** All pagination-related types */ -export type PaginationTypes = { - PaginationParams: PaginationParams - PaginatedResponse: PaginatedResponse -} +export type { Middleware, ServiceOptions, SubscriptionCallback, SubscriptionOptions } from './apiTypes' +export { SubscriptionEvent } from './apiTypes' diff --git a/packages/shared/data/api/schemas/index.ts b/packages/shared/data/api/schemas/index.ts new file mode 100644 index 0000000000..703b92ff24 --- /dev/null +++ b/packages/shared/data/api/schemas/index.ts @@ -0,0 +1,39 @@ +/** + * 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/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 + * + * 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 + */ +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..4cc0a54998 --- /dev/null +++ b/packages/shared/data/api/schemas/messages.ts @@ -0,0 +1,213 @@ +/** + * 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 { CursorPaginationParams } from '@shared/data/api/apiTypes' +import type { + BranchMessagesResponse, + Message, + MessageData, + MessageRole, + MessageStats, + MessageStatus, + 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 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 */ + data: MessageData + /** Message status */ + status?: MessageStatus + /** 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 + /** Set this message as the active node in the topic (default: true) */ + setAsActive?: boolean +} + +/** + * 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?: MessageStatus + /** Update trace ID */ + traceId?: string | null + /** Update statistics */ + stats?: MessageStats | null +} + +/** + * Strategy for updating activeNodeId when the active message is deleted + */ +export type ActiveNodeStrategy = 'parent' | 'clear' + +/** + * Response for delete operation + */ +export interface DeleteMessageResponse { + /** IDs of deleted messages */ + 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 +} + +// ============================================================================ +// 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 + * + * Uses "before cursor" semantics for loading historical messages: + * - First request (no cursor): Returns the most recent `limit` messages + * - Subsequent requests: Pass `nextCursor` from previous response as `cursor` + * to load older messages towards root + * - The cursor message itself is NOT included in the response + */ +export interface BranchMessagesQueryParams extends CursorPaginationParams { + /** End node ID (defaults to topic.activeNodeId) */ + nodeId?: string + /** 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 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 + activeNodeStrategy?: ActiveNodeStrategy + } + response: DeleteMessageResponse + } + } +} diff --git a/packages/shared/data/api/schemas/test.ts b/packages/shared/data/api/schemas/test.ts new file mode 100644 index 0000000000..b1627c1ed8 --- /dev/null +++ b/packages/shared/data/api/schemas/test.ts @@ -0,0 +1,318 @@ +/** + * 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 { OffsetPaginationParams, OffsetPaginationResponse, SearchParams, SortParams } 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?: OffsetPaginationParams & + SortParams & + SearchParams & { + /** Filter by item type */ + type?: string + /** Filter by status */ + status?: string + } + response: OffsetPaginationResponse + } + /** 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: OffsetPaginationParams & { + /** Search query string */ + query: string + /** Additional filters */ + type?: string + status?: string + } + response: OffsetPaginationResponse + } + } + + /** + * 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/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/cache/cacheSchemas.ts b/packages/shared/data/cache/cacheSchemas.ts index 32aabb2ff9..eae585b121 100644 --- a/packages/shared/data/cache/cacheSchemas.ts +++ b/packages/shared/data/cache/cacheSchemas.ts @@ -6,23 +6,104 @@ import type { TabsState } 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.topic123')` -> infers `number` type + * + * Multiple placeholders are supported: + * - 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 */ +// ============================================================================ +// 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.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 + +/** + * 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.position.${id}'> + * // Result: `scroll.position.${string}` (matches 'scroll.position.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) + * ``` + */ +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.position.${id}'> // `scroll.position.${string}` + * type Test2 = ProcessKey<'app.user.avatar'> // 'app.user.avatar' + * ``` + */ +export type ProcessKey = IsTemplateKey extends true ? ExpandTemplateKey : K + /** * Use cache schema for renderer hook */ @@ -58,6 +139,25 @@ 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 } + + // ============================================================================ + // 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 = { @@ -96,17 +196,28 @@ 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 }, + + // 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 } /** * 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' } @@ -122,9 +233,107 @@ export const DefaultRendererPersistCache: RendererPersistCacheSchema = { 'ui.tab.state': { tabs: [], activeTabId: '' } } +// ============================================================================ +// 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 -export type UseSharedCacheKey = keyof UseSharedCacheSchema + +/** + * 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.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. + * + * @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('other.dynamic.key') // OK + * ``` + */ +export type UseCacheCasualKey = K extends UseCacheKey ? never : K 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/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts index 1937266c47..0b6a7cc27f 100644 --- a/packages/shared/data/preference/preferenceTypes.ts +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -55,14 +55,15 @@ export enum ThemeMode { export type LanguageVarious = | 'zh-CN' | 'zh-TW' + | 'de-DE' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' + | 'ro-RO' | 'ru-RU' - | 'de-DE' export type WindowStyle = 'transparent' | 'opaque' diff --git a/packages/shared/data/types/message.ts b/packages/shared/data/types/message.ts new file mode 100644 index 0000000000..3542c30a57 --- /dev/null +++ b/packages/shared/data/types/message.ts @@ -0,0 +1,481 @@ +import type { CursorPaginationResponse } from '@shared/data/api/apiTypes' +/** + * 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] 注意,以下类型只是占位,接口未稳定,随时会变 + +// ============================================================================ +// 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 +// ============================================================================ + +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 // v1's dead code, will be removed in v2 + 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 +} + +/** + * 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 + + /** + * 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 { + 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 +} + +/** + * @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 + 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 + +// ============================================================================ +// Message Entity Types +// ============================================================================ + +import type { AssistantMeta, ModelMeta } from './meta' + +/** + * Message role - user, assistant, or system + */ +export type MessageRole = 'user' | 'assistant' | 'system' + +/** + * Message status + * - pending: Placeholder created, streaming in progress + * - success: Completed successfully + * - error: Failed with error + * - paused: User stopped generation + */ +export type MessageStatus = 'pending' | '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 extends CursorPaginationResponse { + /** Current active node ID */ + activeNodeId: string | null +} 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/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/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/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/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/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/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/main/data/README.md b/src/main/data/README.md index 7efff10113..e596b87434 100644 --- a/src/main/data/README.md +++ b/src/main/data/README.md @@ -1,386 +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 # Thin handlers: param extraction, DTO conversion -│ └── 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 -│ │ └── 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 (production handlers pending) -- **Type Safety**: Must implement all endpoints defined in `@shared/data/api` - -### 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 - -### Current Tables -- `preference`: User configuration storage -- `appState`: Application state persistence - -## 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. 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 -### Adding Database Tables -1. Create schema in `db/schemas/{tableName}.ts` -2. Generate migration: `yarn run 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 '../../services/ExampleService' - -export const exampleHandlers = { - 'POST /examples': 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 = { - 'GET /items/:id': 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/api/core/ApiServer.ts b/src/main/data/api/core/ApiServer.ts index 038f7cc1b8..50ee6cd02e 100644 --- a/src/main/data/api/core/ApiServer.ts +++ b/src/main/data/api/core/ApiServer.ts @@ -1,14 +1,15 @@ import { loggerService } from '@logger' -import type { ApiImplementation } from '@shared/data/api/apiSchemas' +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' // 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 @@ -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() @@ -105,37 +115,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/MiddlewareEngine.ts b/src/main/data/api/core/MiddlewareEngine.ts index 1f6bf1915d..f1bf3c90b7 100644 --- a/src/main/data/api/core/MiddlewareEngine.ts +++ b/src/main/data/api/core/MiddlewareEngine.ts @@ -1,8 +1,8 @@ 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') +const logger = loggerService.withContext('DataApi:MiddlewareEngine') /** * Middleware engine for executing middleware chains @@ -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 7d17264388..d7d08fa6e1 100644 --- a/src/main/data/api/core/adapters/IpcAdapter.ts +++ b/src/main/data/api/core/adapters/IpcAdapter.ts @@ -1,12 +1,12 @@ 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' import type { ApiServer } from '../ApiServer' -const logger = loggerService.withContext('DataApiIpcAdapter') +const logger = loggerService.withContext('DataApi:IpcAdapter') /** * IPC Adapter for Electron environment @@ -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() @@ -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/index.ts b/src/main/data/api/handlers/index.ts index 817a882be8..87072fdfc0 100644 --- a/src/main/data/api/handlers/index.ts +++ b/src/main/data/api/handlers/index.ts @@ -1,210 +1,30 @@ /** - * 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 + * - topics.ts - Topic API handlers + * - messages.ts - Message API handlers */ -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 { messageHandlers } from './messages' +import { testHandlers } from './test' +import { topicHandlers } from './topics' /** * 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, + ...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..d89457ab5e --- /dev/null +++ b/src/main/data/api/handlers/messages.ts @@ -0,0 +1,75 @@ +/** + * 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 { + ActiveNodeStrategy, + 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, + cursor: q.cursor, + 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; activeNodeStrategy?: ActiveNodeStrategy } + const cascade = q.cascade ?? false + const activeNodeStrategy = q.activeNodeStrategy ?? 'parent' + return await messageService.delete(params.id, cascade, activeNodeStrategy) + } + } +} 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/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/api/index.ts b/src/main/data/api/index.ts index 1bd4d3b7a5..d2cf988727 100644 --- a/src/main/data/api/index.ts +++ b/src/main/data/api/index.ts @@ -20,13 +20,18 @@ 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 { + CursorPaginationParams, + CursorPaginationResponse, DataRequest, DataResponse, Middleware, - PaginatedResponse, - PaginationParams, + OffsetPaginationParams, + OffsetPaginationResponse, + PaginationResponse, RequestContext, - ServiceOptions + SearchParams, + ServiceOptions, + SortParams } from '@shared/data/api/apiTypes' +export type { CreateTestItemDto, TestItem, UpdateTestItemDto } from '@shared/data/api/schemas/test' 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 8bc38b01c4..2a07bd5d43 100644 --- a/src/main/data/db/README.md +++ b/src/main/data/db/README.md @@ -1,2 +1,52 @@ -- All the database table names use **singular** form, snake_casing -- Export table names use `xxxxTable` +# Database Layer + +This directory contains database schemas and configuration. + +## Documentation + +- **Database Patterns**: [docs/en/references/data/database-patterns.md](../../../../docs/en/references/data/database-patterns.md) + +## Directory Structure + +``` +src/main/data/db/ +├── schemas/ # Drizzle table definitions +│ ├── 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 +``` + +## Quick Reference + +### Naming Conventions + +- **Table names**: Singular snake_case (`topic`, `message`, `app_state`) +- **Export names**: `xxxTable` pattern (`topicTable`, `messageTable`) + +### Common Commands + +```bash +# Generate migrations after schema changes +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 +import { uuidPrimaryKey, createUpdateTimestamps } from './columnHelpers' + +export const myTable = sqliteTable('my_table', { + id: uuidPrimaryKey(), + name: text(), + ...createUpdateTimestamps +}) +``` 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/columnHelpers.ts b/src/main/data/db/schemas/columnHelpers.ts index 7623afd0ed..61a596602d 100644 --- a/src/main/data/db/schemas/columnHelpers.ts +++ b/src/main/data/db/schemas/columnHelpers.ts @@ -1,4 +1,32 @@ -import { integer } from 'drizzle-orm/sqlite-core' +/** + * 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' + +/** + * 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/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..6ef06c522f --- /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, uuidPrimaryKey } 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: uuidPrimaryKey(), + // 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..3118433f6c --- /dev/null +++ b/src/main/data/db/schemas/message.ts @@ -0,0 +1,65 @@ +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, foreignKey, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateDeleteTimestamps, uuidPrimaryKeyOrdered } 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: uuidPrimaryKeyOrdered(), + // Adjacency list parent reference for tree structure + parentId: text(), + // FK to topic - CASCADE: delete messages when topic is deleted + topicId: text() + .notNull() + .references(() => topicTable.id, { onDelete: 'cascade' }), + // 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' }).$type(), + // Model identifier + modelId: text(), + // Preserved model info (provider, name) + modelMeta: text({ mode: 'json' }).$type(), + // Trace ID for tracking + + traceId: text(), + // Statistics: token usage, performance metrics, etc. + stats: text({ mode: 'json' }).$type(), + + ...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), + 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 ('pending', '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..ccffbb5eaf --- /dev/null +++ b/src/main/data/db/schemas/messageFts.ts @@ -0,0 +1,86 @@ +/** + * 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') +` + +/** + * 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_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' + )`, + + // 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`, + + // 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`, + + // 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) + */ +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..87820fadf9 --- /dev/null +++ b/src/main/data/db/schemas/tag.ts @@ -0,0 +1,18 @@ +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateTimestamps, uuidPrimaryKey } 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: uuidPrimaryKey(), + // 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..68078d8f86 --- /dev/null +++ b/src/main/data/db/schemas/topic.ts @@ -0,0 +1,47 @@ +import type { AssistantMeta } from '@shared/data/types/meta' +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' + +import { createUpdateDeleteTimestamps, uuidPrimaryKey } 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: uuidPrimaryKey(), + 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' }).$type(), + // Topic-specific prompt override + prompt: text(), + // Active node ID in the message tree + activeNodeId: text(), + + // 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), + + ...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) + ] +) 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/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..077ad2179d 100644 --- a/src/main/data/migration/v2/migrators/ChatMigrator.ts +++ b/src/main/data/migration/v2/migrators/ChatMigrator.ts @@ -1,81 +1,659 @@ /** - * 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, + findActiveNodeId, + 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) { + // 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) + + // 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) + } + }) + + // Update state ONLY after transaction succeeds (transaction safety) + for (const id of batchMessageIds) { + this.seenMessageIds.add(id) + } + 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_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 + 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 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) { + 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 + 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..63e2053e73 --- /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, 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 | +| **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` | Smart selection: original active → foldSelected → last migrated | +| (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 +} diff --git a/src/main/data/services/MessageService.ts b/src/main/data/services/MessageService.ts new file mode 100644 index 0000000000..1dfb7f378e --- /dev/null +++ b/src/main/data/services/MessageService.ts @@ -0,0 +1,815 @@ +/** + * 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 { + ActiveNodeStrategy, + CreateMessageDto, + DeleteMessageResponse, + 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, or, sql } from 'drizzle-orm' + +const logger = loggerService.withContext('DataApi: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 + * + * 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, + 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 + + // 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 (!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 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)) { + childrenMap.set(parentId, []) + } + childrenMap.get(parentId)!.push(message.id) + } + + // 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 && 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 + collectNodes(rootId, 0, activePath.has(rootId)) + + return { + nodes: resultNodes, + siblingsGroups, + activeNodeId + } + } + + /** + * 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. + * + * Uses "before cursor" pagination semantics: + * - cursor: Message ID marking the pagination boundary (exclusive) + * - Returns messages BEFORE the cursor (towards root) + * - The cursor message itself is NOT included + * - nextCursor points to the oldest message in current batch + * + * Example flow: + * 1. First request (no cursor) → returns msg80-99, nextCursor=msg80.id + * 2. Second request (cursor=msg80.id) → returns msg60-79, nextCursor=msg60.id + */ + async getBranchMessages( + topicId: string, + options: { nodeId?: string; cursor?: string; limit?: number; includeSiblings?: boolean } = {} + ): Promise { + const db = dbService.getDb() + const { cursor, 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) + } + + const nodeId = options.nodeId || topic.activeNodeId + + // Return empty if no active node + if (!nodeId) { + return { items: [], nextCursor: undefined, activeNodeId: null } + } + + // 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) + } + + // Reverse to get root->nodeId order + const fullPath = pathMessages.reverse() + + // Apply pagination + let startIndex = 0 + let endIndex = fullPath.length + + if (cursor) { + const cursorIndex = fullPath.findIndex((m) => m.id === cursor) + if (cursorIndex === -1) { + throw DataApiErrorFactory.notFound('Message (cursor)', cursor) + } + startIndex = Math.max(0, cursorIndex - limit) + endIndex = cursorIndex + } else { + startIndex = Math.max(0, fullPath.length - limit) + } + + const paginatedPath = fullPath.slice(startIndex, endIndex) + + // Calculate nextCursor: if there are more historical messages + const nextCursor = startIndex > 0 ? fullPath[startIndex].id : undefined + + // Build result with optional siblings + const result: BranchMessage[] = [] + + if (includeSiblings) { + // Collect unique (parentId, siblingsGroupId) pairs that need siblings + const uniqueGroups = new Set() + const groupsToQuery: Array<{ parentId: string; siblingsGroupId: number }> = [] + + 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 }) + } + } + } + + // 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 { + items: result, + nextCursor, + 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(eq(messageTable.id, id)).limit(1) + + if (!row) { + throw DataApiErrorFactory.notFound('Message', id) + } + + return rowToMessage(row) + } + + /** + * 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() + + return await db.transaction(async (tx) => { + // 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) + } + + // 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: resolvedParentId, + 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') + } + } + + 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 (!existingRow) { + throw DataApiErrorFactory.notFound('Message', id) + } + + const existing = rowToMessage(existingRow) + + // 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) + + 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 + 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() + + logger.info('Updated message', { id, changes: Object.keys(dto) }) + + return rowToMessage(row) + }) + } + + /** + * 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, + 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 + + if (isRoot && !cascade) { + throw DataApiErrorFactory.invalidOperation('delete root message', 'cascade=true required') + } + + // Get all descendant IDs before transaction (for cascade delete) + let descendantIds: string[] = [] + if (cascade) { + descendantIds = await this.getDescendantIds(id) + } + + // 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 + + if (cascade) { + deletedIds = [id, ...descendantIds] + + // Check if activeNodeId is affected + if (topic.activeNodeId && deletedIds.includes(topic.activeNodeId)) { + newActiveNodeId = activeNodeStrategy === 'clear' ? null : message.parentId + } + + // Hard delete all + await tx.delete(messageTable).where(inArray(messageTable.id, deletedIds)) + + 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 }) + } + + // Update topic.activeNodeId if needed + if (newActiveNodeId !== undefined) { + await tx.update(topicTable).set({ activeNodeId: newActiveNodeId }).where(eq(topicTable.id, message.topicId)) + + logger.info('Updated topic activeNodeId after message deletion', { + topicId: message.topicId, + oldActiveNodeId: topic.activeNodeId, + newActiveNodeId + }) + } + + return { + deletedIds, + reparentedIds: reparentedIds?.length ? reparentedIds : undefined, + newActiveNodeId + } + }) + } + + /** + * 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} + UNION ALL + SELECT m.id FROM message m + INNER JOIN descendants d ON m.parent_id = d.id + ) + SELECT id FROM descendants + `) + + return result.map((r) => r.id) + } + + /** + * 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 db = dbService.getDb() + + // 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) + } + + // Result is from nodeId to root, reverse to get root to nodeId + return result.reverse().map(rowToMessage) + } +} + +export const messageService = MessageService.getInstance() 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 new file mode 100644 index 0000000000..d30215d283 --- /dev/null +++ b/src/main/data/services/TopicService.ts @@ -0,0 +1,233 @@ +/** + * 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 { eq } from 'drizzle-orm' + +import { messageService } from './MessageService' + +const logger = loggerService.withContext('DataApi: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(eq(topicTable.id, id)).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() + + // If forking from existing node, copy the path + 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 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() + + 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 newParentId = message.parentId ? idMapping.get(message.parentId) || null : null + + 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() + + idMapping.set(message.id, messageRow.id) + activeNodeId = messageRow.id + } + + // Update topic with active node + await db.update(topicTable).set({ activeNodeId }).where(eq(topicTable.id, topicId)) + + logger.info('Created topic by forking', { + id: topicId, + sourceNodeId: dto.sourceNodeId, + messageCount: path.length + }) + + 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() + + logger.info('Created empty topic', { id: row.id }) + + return rowToTopic(row) + } + } + + /** + * 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 = {} + + 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 + + const [row] = await db.update(topicTable).set(updates).where(eq(topicTable.id, id)).returning() + + logger.info('Updated topic', { id, changes: Object.keys(dto) }) + + return rowToTopic(row) + } + + /** + * Delete a topic and all its messages (hard delete) + */ + async delete(id: string): Promise { + const db = dbService.getDb() + + // Verify topic exists + await this.getById(id) + + // Hard delete all messages first (due to foreign key) + await db.delete(messageTable).where(eq(messageTable.topicId, id)) + + // Hard delete topic + await db.delete(topicTable).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(eq(messageTable.id, nodeId)).limit(1) + + if (!message || message.topicId !== topicId) { + throw DataApiErrorFactory.notFound('Message', nodeId) + } + + // Update active node + await db.update(topicTable).set({ activeNodeId: nodeId }).where(eq(topicTable.id, topicId)) + + logger.info('Set active node', { topicId, nodeId }) + + return { activeNodeId: nodeId } + } +} + +export const topicService = TopicService.getInstance() diff --git a/src/main/data/services/base/IBaseService.ts b/src/main/data/services/base/IBaseService.ts index 446de55716..d9b3a4b0be 100644 --- a/src/main/data/services/base/IBaseService.ts +++ b/src/main/data/services/base/IBaseService.ts @@ -1,4 +1,9 @@ -import type { PaginationParams, ServiceOptions } from '@shared/data/api/apiTypes' +import type { CursorPaginationParams, OffsetPaginationParams, ServiceOptions } from '@shared/data/api/apiTypes' + +/** + * Base pagination params for service layer (supports both modes) + */ +type BasePaginationParams = (OffsetPaginationParams | CursorPaginationParams) & Record /** * Standard service interface for data operations @@ -14,12 +19,12 @@ export interface IBaseService { * Find multiple entities with pagination */ findMany( - params: PaginationParams & Record, + params: BasePaginationParams, options?: ServiceOptions ): Promise<{ items: T[] - total: number - hasNext?: boolean + total?: number + page?: number nextCursor?: string }> @@ -68,12 +73,12 @@ export interface ISearchableService exten */ search( query: string, - params?: PaginationParams, + params?: BasePaginationParams, options?: ServiceOptions ): Promise<{ items: T[] - total: number - hasNext?: boolean + total?: number + page?: number nextCursor?: string }> } @@ -87,12 +92,12 @@ export interface IHierarchicalService diff --git a/src/main/index.ts b/src/main/index.ts index 0456797abd..6c692ce532 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,8 +20,10 @@ import { registerIpc } from './ipc' import { agentService } from './services/agents' import { apiServerService } from './services/ApiServerService' import { appMenuService } from './services/AppMenuService' -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, @@ -45,6 +47,7 @@ import { dataApiService } from '@data/DataApiService' import { cacheService } from '@data/CacheService' import { initWebviewHotkeys } from './services/WebviewService' import { runAsyncFunction } from './utils' +import { isOvmsSupported } from './services/OvmsManager' const logger = loggerService.withContext('MainEntry') @@ -132,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 @@ -241,7 +259,8 @@ if (!app.requestSingleInstanceLock()) { }) registerShortcuts(mainWindow) - registerIpc(mainWindow, app) + await registerIpc(mainWindow, app) + localTransferService.startDiscovery({ resetList: true }) replaceDevtoolsFont(mainWindow) @@ -323,16 +342,29 @@ if (!app.requestSingleInstanceLock()) { if (selectionService) { selectionService.quit() } + + lanTransferClientService.dispose() + localTransferService.dispose() }) app.on('will-quit', async () => { // 简单的资源清理,不阻塞退出流程 + 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() 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 1b9562c80c..7560d67460 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -19,6 +19,7 @@ import { import { handleZoomFactor } from '@main/utils/zoom' import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' +import type { LocalTransferConnectPayload } from '@shared/config/types' import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' import type { @@ -50,6 +51,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' @@ -57,7 +60,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' @@ -80,7 +83,6 @@ import { } from './services/SpanCacheService' import storeSyncService from './services/StoreSyncService' 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' @@ -95,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') @@ -105,7 +108,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 { @@ -119,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 +500,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 @@ -584,6 +586,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)) @@ -683,36 +687,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) => { @@ -872,8 +859,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) @@ -989,15 +976,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)) @@ -1054,12 +1062,18 @@ export 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: { @@ -1115,12 +1129,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/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 +} diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index f331254fdf..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' @@ -767,6 +783,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/ConfigManager.ts b/src/main/services/ConfigManager.ts index 5c7f342035..6ee96580e4 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 { ZOOM_SHORTCUTS } from '@shared/config/constant' import type { Shortcut } from '@types' import Store from 'electron-store' diff --git a/src/main/services/FileStorage.ts b/src/main/services/FileStorage.ts index 78bffa6692..2d7520ca67 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' @@ -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 { @@ -185,7 +187,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 +206,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 +216,7 @@ class FileStorage { created_at: storedStats.birthtime.toISOString(), size: storedStats.size, ext, - type: getFileType(ext), + type, count: 2 } } @@ -222,6 +226,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 +252,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 +318,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 +343,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 +352,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 +700,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 +750,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) { @@ -1038,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) { @@ -1068,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 => { @@ -1317,7 +1535,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 +1822,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) { 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/MCPService.ts b/src/main/services/MCPService.ts index c773ee5f28..eec1f04b02 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/services/OvmsManager.ts b/src/main/services/OvmsManager.ts index 3a32d74ecf..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 @@ -102,32 +112,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 +572,5 @@ class OvmsManager { } } -export default OvmsManager +// Export singleton instance +export const ovmsManager = isOvmsSupported ? new OvmsManager() : undefined diff --git a/src/main/services/ReduxService.ts b/src/main/services/ReduxService.ts index 2df15bf3eb..8880691a24 100644 --- a/src/main/services/ReduxService.ts +++ b/src/main/services/ReduxService.ts @@ -1,7 +1,19 @@ /** - * @deprecated this file will be removed after v2 refactor + * @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/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/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index aeafce5be5..0adf50c6d1 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -1440,6 +1440,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 } + } } /** diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 536c164a0d..af57a41b7b 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 { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { handleZoomFactor } from '@main/utils/zoom' 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/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/WindowService.ts b/src/main/services/WindowService.ts index 2210582fee..0be118b0ad 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(preferenceService.get('app.zoom_factor')) + }) + 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') @@ -635,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() } diff --git a/src/main/services/__tests__/AppUpdater.test.ts b/src/main/services/__tests__/AppUpdater.test.ts index babc76ca81..7774738028 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 }) @@ -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 '../../../../tests/__mocks__/main/PreferenceService' import AppUpdater from '../AppUpdater' // Mock clientId for ConfigManager since it's not migrated yet 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..a371ed0a3c --- /dev/null +++ b/src/main/services/__tests__/BackupManager.deleteTempBackup.test.ts @@ -0,0 +1,279 @@ +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 * as path from 'path' + +import BackupManager from '../BackupManager' + +// Helper to construct platform-independent paths for assertions +// The implementation uses path.normalize() which converts to platform separators +const normalizePath = (p: string): string => path.normalize(p) + +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(normalizePath(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(normalizePath(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(normalizePath(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/agents/BaseService.ts b/src/main/services/agents/BaseService.ts index a4c1a23240..0b0340c82b 100644 --- a/src/main/services/agents/BaseService.ts +++ b/src/main/services/agents/BaseService.ts @@ -1,6 +1,7 @@ import { loggerService } from '@logger' import { mcpApiService } from '@main/apiServer/services/mcp' import { type ModelValidationError, 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' @@ -12,6 +13,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. @@ -33,8 +45,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) } @@ -44,13 +60,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) { @@ -62,7 +86,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/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/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 5ba4721646..8f08160060 100644 --- a/src/main/services/agents/services/SessionService.ts +++ b/src/main/services/agents/services/SessionService.ts @@ -157,7 +157,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. @@ -203,6 +205,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/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index 7ec6927a35..1a18b6aba9 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 '../..' @@ -116,6 +117,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 @@ -124,7 +132,7 @@ class ClaudeCodeService implements AgentServiceInterface { // ANTHROPIC_BASE_URL: `http://${apiConfig['feature.csaas.host']}:${apiConfig['feature.csaas.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/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) + }) +}) 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/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/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/language.ts b/src/main/utils/language.ts index a0f2c8dc9b..a5dcb1bbf3 100644 --- a/src/main/utils/language.ts +++ b/src/main/utils/language.ts @@ -13,6 +13,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' export const locales = Object.fromEntries( @@ -26,7 +27,8 @@ export 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/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 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 7bfb8e4aff..a6f68fe07b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,9 +4,17 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import type { SpanContext } from '@opentelemetry/api' import type { GitBashPathInfo, TerminalConfig } 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 type { CacheSyncMessage } from '@shared/data/cache/cacheTypes' +import type { CacheEntry, CacheSyncMessage } from '@shared/data/cache/cacheTypes' import type { PreferenceDefaultScopeType, PreferenceKeyType, @@ -178,7 +186,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 => @@ -304,7 +316,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) => @@ -333,6 +346,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), @@ -435,7 +449,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) }, @@ -567,7 +581,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 @@ -592,8 +609,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) @@ -631,12 +646,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/aiCore/chunk/AiSdkToChunkAdapter.ts b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts index 9f9542a92a..00e0e4e9ce 100644 --- a/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts +++ b/src/renderer/src/aiCore/chunk/AiSdkToChunkAdapter.ts @@ -119,6 +119,21 @@ export class AiSdkToChunkAdapter { } } + /** + * 如果有累积的思考内容,发送 THINKING_COMPLETE chunk 并清空 + * @param final 包含 reasoningContent 的状态对象 + * @returns 是否发送了 THINKING_COMPLETE chunk + */ + private emitThinkingCompleteIfNeeded(final: { reasoningContent: string; [key: string]: any }) { + if (final.reasoningContent) { + this.onChunk({ + type: ChunkType.THINKING_COMPLETE, + text: final.reasoningContent + }) + final.reasoningContent = '' + } + } + /** * 转换 AI SDK chunk 为 Cherry Studio chunk 并调用回调 * @param chunk AI SDK 的 chunk 数据 @@ -144,6 +159,9 @@ export class AiSdkToChunkAdapter { } // === 文本相关事件 === case 'text-start': + // 如果有未完成的思考内容,先生成 THINKING_COMPLETE + // 这处理了某些提供商不发送 reasoning-end 事件的情况 + this.emitThinkingCompleteIfNeeded(final) this.onChunk({ type: ChunkType.TEXT_START }) @@ -214,11 +232,7 @@ export class AiSdkToChunkAdapter { }) break case 'reasoning-end': - this.onChunk({ - type: ChunkType.THINKING_COMPLETE, - text: final.reasoningContent || '' - }) - final.reasoningContent = '' + this.emitThinkingCompleteIfNeeded(final) break // === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) === 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/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/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, '') +} diff --git a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts index 2be36057bf..3ee4c6a15c 100644 --- a/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/ovms/OVMSClient.ts @@ -2,7 +2,8 @@ import type OpenAI from '@cherrystudio/openai' import { loggerService } from '@logger' import { isSupportedModel } from '@renderer/config/models' import { objectKeys, type Provider } 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/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts index 2c77649634..d31632de57 100644 --- a/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/zhipu/ZhipuAPIClient.ts @@ -65,6 +65,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/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/aiCore/tools/MemorySearchTool.ts b/src/renderer/src/aiCore/tools/MemorySearchTool.ts index bf2bbd286a..f185f79841 100644 --- a/src/renderer/src/aiCore/tools/MemorySearchTool.ts +++ b/src/renderer/src/aiCore/tools/MemorySearchTool.ts @@ -25,7 +25,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/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..8dc7a10af9 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, @@ -396,10 +397,12 @@ function buildOpenAIProviderOptions( } } + // TODO: 支持配置是否在服务端持久化 providerOptions = { ...providerOptions, serviceTier, - textVerbosity + textVerbosity, + store: false } return { @@ -577,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) } @@ -601,7 +606,7 @@ function buildGenericProviderOptions( enableGenerateImage: boolean } ): Record { - const { enableWebSearch } = capabilities + const { enableWebSearch, enableReasoning } = capabilities let providerOptions: Record = {} const reasoningParams = getReasoningEffort(assistant, model) @@ -609,6 +614,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/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/assets/images/apps/aistudio.png b/src/renderer/src/assets/images/apps/aistudio.png new file mode 100644 index 0000000000..c7cb2adebe Binary files /dev/null and b/src/renderer/src/assets/images/apps/aistudio.png differ 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/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/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/CodeBlockView/HtmlArtifactsPopup.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx index 36bfc559da..05c3f230e1 100644 --- a/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifactsPopup.tsx @@ -224,6 +224,7 @@ const HtmlArtifactsPopup: React.FC = ({ open, title, ht afterClose={onClose} centered={!isFullscreen} destroyOnHidden + forceRender={isFullscreen} mask={!isFullscreen} maskClosable={false} width={isFullscreen ? '100vw' : '90vw'} diff --git a/src/renderer/src/components/EmojiPicker/index.tsx b/src/renderer/src/components/EmojiPicker/index.tsx index 16561ea9be..245b997704 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/components/Icons/SVGIcon.tsx b/src/renderer/src/components/Icons/SVGIcon.tsx index e4858f8e2f..7b2d3549f7 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 ( 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/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/components/Tab/TabContainer.tsx b/src/renderer/src/components/Tab/TabContainer.tsx index 986c6ec782..2e2a959e7e 100644 --- a/src/renderer/src/components/Tab/TabContainer.tsx +++ b/src/renderer/src/components/Tab/TabContainer.tsx @@ -22,7 +22,6 @@ import type { LRUCache } from 'lru-cache' import { FileSearch, Folder, - Hammer, Home, Languages, LayoutGrid, @@ -99,8 +98,6 @@ const getTabIcon = ( return case 'knowledge': return - case 'mcp': - return case 'files': return case 'settings': 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' 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/__tests__/reasoning.test.ts b/src/renderer/src/config/models/__tests__/reasoning.test.ts index 783cb39993..56f9cd0b60 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, @@ -679,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') }) }) @@ -1762,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', @@ -2157,3 +2178,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/default.ts b/src/renderer/src/config/models/default.ts index 37854c5749..1223d0c92c 100644 --- a/src/renderer/src/config/models/default.ts +++ b/src/renderer/src/config/models/default.ts @@ -617,6 +617,30 @@ 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', + name: 'GLM-4.7', + group: 'GLM-4.7' + }, { id: 'glm-4.5', provider: 'zhipu', @@ -921,6 +945,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/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/config/models/reasoning.ts b/src/renderer/src/config/models/reasoning.ts index 144afc52a7..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)) { @@ -571,7 +576,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 +637,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 { @@ -738,3 +743,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/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 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 /** * 判断模型是否支持图片增强(包括编辑、增强、修复等) diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 1adeb58ad0..f49794aaa7 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 @@ -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 @@ -211,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 @@ -289,7 +291,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, @@ -1088,7 +1090,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' } }, diff --git a/src/renderer/src/context/AntdProvider.tsx b/src/renderer/src/context/AntdProvider.tsx index 3650492606..398967c122 100644 --- a/src/renderer/src/context/AntdProvider.tsx +++ b/src/renderer/src/context/AntdProvider.tsx @@ -9,6 +9,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' @@ -140,6 +141,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/data/CacheService.ts b/src/renderer/src/data/CacheService.ts index 7fe9a2d7a7..f88432ee10 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, - UseCacheKey, - UseCacheSchema, - UseSharedCacheKey, - UseSharedCacheSchema + SharedCacheKey, + SharedCacheSchema, + UseCacheKey } 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') } @@ -94,17 +102,60 @@ 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.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 + * (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.topic123') ?? 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) @@ -130,19 +181,56 @@ 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.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) + * @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.topic123', 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) @@ -188,8 +276,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) @@ -225,8 +324,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) @@ -266,8 +375,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) @@ -279,7 +399,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 +409,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 +421,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 +430,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 +457,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 +467,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 +476,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 +489,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 +499,7 @@ export class CacheService { const entry: CacheEntry = { value, - expireAt: ttl ? Date.now() + ttl : undefined + expireAt: newExpireAt } // Update local copy first @@ -391,7 +511,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 +521,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 +530,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 +556,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 +565,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 +677,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 +951,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/DataApiService.ts b/src/renderer/src/data/DataApiService.ts index 2d3791139a..8b2b8a7f62 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: @@ -31,30 +30,30 @@ */ import { loggerService } from '@logger' -import type { ApiClient, ConcreteApiPaths } from '@shared/data/api/apiSchemas' +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 { - BatchRequest, - BatchResponse, DataRequest, - DataResponse, HttpMethod, SubscriptionCallback, SubscriptionEvent, - SubscriptionOptions, - TransactionRequest + 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 } /** @@ -76,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() { @@ -136,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 { @@ -149,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}`, { @@ -163,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 @@ -184,7 +187,7 @@ export class DataApiService implements ApiClient { return this.sendRequest(retryRequest, retryCount + 1) } - throw error + throw apiError } } @@ -311,30 +314,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..4cafe2284e 100644 --- a/src/renderer/src/data/README.md +++ b/src/renderer/src/data/README.md @@ -1,436 +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 -- Batch operations and transactions -- Real-time subscriptions -- Request cancellation and 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' } -}) - -// Batch operations -const responses = await dataApiService.batch([ - { method: 'GET', path: '/topics' }, - { method: 'GET', path: '/messages' } -]) -``` - -### 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 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 7689b62f32..d13196bc11 100644 --- a/src/renderer/src/data/hooks/useCache.ts +++ b/src/renderer/src/data/hooks/useCache.ts @@ -1,68 +1,210 @@ import { cacheService } from '@data/CacheService' import { loggerService } from '@logger' import type { + InferUseCacheValue, 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') +// ============================================================================ +// 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 (letters, numbers, underscores, hyphens). + * + * 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.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 { + // 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 (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}$`) +} + +/** + * 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.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) * @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.topic123') + * // 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 +232,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] } /** @@ -121,10 +263,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 +287,7 @@ export function useSharedCache( } if (initValue === undefined) { - cacheService.setShared(key, DefaultUseSharedCache[key]) + cacheService.setShared(key, DefaultSharedCache[key]) } else { cacheService.setShared(key, initValue) } @@ -178,13 +320,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/src/renderer/src/data/hooks/useDataApi.ts b/src/renderer/src/data/hooks/useDataApi.ts index de30473ab0..41aa80a016 100644 --- a/src/renderer/src/data/hooks/useDataApi.ts +++ b/src/renderer/src/data/hooks/useDataApi.ts @@ -1,179 +1,220 @@ +/** + * @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/apiSchemas' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' -import { useState } from 'react' -import type { KeyedMutator } from 'swr' -import useSWR, { useSWRConfig } from 'swr' +import type { ConcreteApiPaths } from '@shared/data/api/apiTypes' +import { + type CursorPaginationResponse, + type OffsetPaginationResponse, + type PaginationResponse +} from '@shared/data/api/apiTypes' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +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 -// buildPath function removed - users now pass concrete paths directly +// ============================================================================ +// Hook Result Types +// ============================================================================ + +/** Infer item type from paginated response path */ +type InferPaginatedItem = ResponseForPath extends PaginationResponse< + infer T +> + ? T + : unknown /** - * Unified fetcher utility for API requests - * Provides type-safe method dispatching to reduce code duplication + * useQuery result type + * @property data - The fetched data, undefined while loading or on error + * @property isLoading - True during initial load (no cached data) + * @property isRefreshing - True during background revalidation (has cached data) + * @property error - Error object if the request failed + * @property refetch - Trigger a revalidation from the server + * @property mutate - SWR mutator for advanced cache control (optimistic updates, manual cache manipulation) */ -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}`) - } - } +export interface UseQueryResult { + data?: ResponseForPath + isLoading: boolean + isRefreshing: boolean + error?: Error + refetch: () => void + mutate: KeyedMutator> } /** - * Build SWR cache key for request identification and caching - * Creates a unique key based on path and query parameters - * - * @param path - The concrete API path - * @param query - Query parameters - * @returns SWR key tuple or null if disabled - * - * @example - * ```typescript - * buildSWRKey('/topics', { page: 1 }) // Returns ['/topics', { page: 1 }] - * buildSWRKey('/topics/123') // Returns ['/topics/123'] - * ``` + * 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 */ -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 - * @param args - Tuple containing [path, query] parameters - * @returns Promise resolving to the fetched data - */ -function getFetcher([path, query]: [TPath, Record?]): Promise< - ResponseForPath +export interface UseMutationResult< + TPath extends ConcreteApiPaths, + TMethod extends 'POST' | 'PUT' | 'DELETE' | 'PATCH' > { - const apiFetcher = createApiFetcher('GET') - return apiFetcher(path, { query }) + trigger: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + isLoading: boolean + error: Error | undefined } /** - * React hook for data fetching with SWR - * Provides type-safe API calls with caching, revalidation, and error handling + * 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[] + isLoading: boolean + isRefreshing: boolean + error?: Error + hasNext: boolean + loadNext: () => void + refresh: () => void + reset: () => void + mutate: KeyedMutator[]> +} + +/** + * 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 + page: number + isLoading: boolean + isRefreshing: boolean + error?: Error + hasNext: boolean + hasPrev: boolean + prevPage: () => void + nextPage: () => void + refresh: () => void + reset: () => void +} + +/** + * Data fetching hook with SWR caching and revalidation. * - * @template TPath - The concrete API path type - * @param path - The concrete API endpoint path (e.g., '/test/items/123') - * @param options - Configuration options + * 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 - 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 + * @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 - * ```typescript - * // Basic usage with type-safe concrete path - * const { data, loading, error } = useQuery('/test/items') - * // data is automatically typed as PaginatedResponse + * // Basic usage + * const { data, isLoading, error } = useQuery('/topics') * - * // 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 + * @example + * // With query parameters + * const { data } = useQuery('/messages', { query: { topicId: 'abc', limit: 20 } }) * - * // 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' - * } - * }) + * @example + * // Conditional fetching + * const { data } = useQuery('/topics', { enabled: !!userId }) * - * // 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() - * ``` + * @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 } -): { - /** The fetched data */ - data?: ResponseForPath - /** Loading state */ - loading: boolean - /** Error if request failed */ - error?: Error - /** Function to manually refetch data */ - refetch: () => void - /** SWR mutate function for optimistic updates */ - mutate: KeyedMutator> -} { - // Internal type conversion for SWR compatibility - const key = options?.enabled !== false ? buildSWRKey(path, options?.query as Record) : null +): UseQueryResult { + const key = options?.enabled !== false ? buildSWRKey(path, options?.query) : 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, - loading: isLoading || isValidating, + isLoading, + isRefreshing: isValidating, error: error as Error | undefined, refetch, mutate @@ -181,107 +222,70 @@ export function useQuery( } /** - * React hook for mutation operations (POST, PUT, DELETE, PATCH) - * Provides optimized handling of side-effect operations with automatic cache invalidation + * Mutation hook for POST, PUT, DELETE, PATCH operations. * - * @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 + * 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 - * ```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 + * // 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) * }) * - * // 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 + * @example + * // Optimistic update (UI updates immediately, rolls back on error) + * const { trigger } = useMutation('PATCH', '/topics/abc', { + * optimisticData: { ...topic, starred: true } * }) - * - * // 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) - * } - * } - * ``` */ - 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' + > } -): { - /** Function to execute the mutation */ - mutate: (data?: { - body?: BodyForPath - query?: QueryParamsForPath - }) => Promise> - /** True while the mutation is in progress */ - loading: boolean - /** Error object if the mutation failed */ - error: Error | undefined -} { +): UseMutationResult { const { mutate: globalMutate } = useSWRConfig() - const apiFetcher = createApiFetcher(method) + // 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 ( _key: string, @@ -290,166 +294,124 @@ 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) => { - options?.onSuccess?.(data) + optionsRef.current?.onSuccess?.(data) - if (options?.revalidate === true) { - await globalMutate(() => true) - } else if (Array.isArray(options?.revalidate)) { - for (const path of options.revalidate) { - await globalMutate(path) - } + // Refresh specified keys on success + if (optionsRef.current?.refresh?.length) { + await Promise.all(optionsRef.current.refresh.map((key) => globalMutate(key))) } }, - onError: options?.onError + onError: (error) => optionsRef.current?.onError?.(error), + ...options?.swrOptions }) - const optimisticMutate = async (data?: { + const trigger = async (data?: { body?: BodyForPath query?: QueryParamsForPath }): Promise> => { - if (options?.optimistic && options?.optimisticData) { - // Apply optimistic update - await globalMutate(path, options.optimisticData, false) + const opts = optionsRef.current + const hasOptimisticData = opts?.optimisticData !== undefined + + // Apply optimistic update if optimisticData is provided + if (hasOptimisticData) { + await globalMutate(path, opts!.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 result = await swrTrigger(data) - const result = await trigger(convertedData) - - // Revalidate with real data after successful mutation - if (options?.optimistic) { + // Revalidate after optimistic update completes + if (hasOptimisticData) { await globalMutate(path) } return result } catch (err) { - // Revert optimistic update on error - if (options?.optimistic && options?.optimisticData) { + // Rollback optimistic update on error + if (hasOptimisticData) { await globalMutate(path) } throw err } } - // Wrapper for non-optimistic mutations to handle type conversion - const normalMutate = async (data?: { - body?: BodyForPath - 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 - - return trigger(convertedData) - } - return { - mutate: options?.optimistic ? optimisticMutate : normalMutate, - loading: isMutating, + trigger, + isLoading: isMutating, error } } /** - * Hook for invalidating SWR cache entries - * Must be used inside a React component or hook + * Hook to invalidate SWR cache entries and trigger revalidation. * - * @returns Function to invalidate cache entries + * Use this to manually clear cached data and force a fresh fetch. + * + * @returns Invalidate function that accepts keys to invalidate * * @example - * ```typescript - * function MyComponent() { - * const invalidate = useInvalidateCache() + * const invalidate = useInvalidateCache() * - * const handleInvalidate = async () => { - * // Invalidate specific cache key - * await invalidate('/test/items') + * // Invalidate specific path + * await invalidate('/topics') * - * // Invalidate multiple keys - * await invalidate(['/test/items', '/test/stats']) + * // Invalidate multiple paths + * await invalidate(['/topics', '/messages']) * - * // Invalidate all cache entries - * await invalidate(true) - * } - * - * return - * } - * ``` + * // Invalidate all cached data + * await invalidate(true) */ export function useInvalidateCache() { const { mutate } = useSWRConfig() - const invalidate = (keys?: string | string[] | boolean): Promise => { + 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 } /** - * Prefetch data for a given path without caching - * Useful for warming up data before user interactions or pre-loading critical resources + * Prefetch data to warm up the cache before user interactions. * - * @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 + * 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 - * ```typescript - * // Prefetch items list on component mount - * useEffect(() => { - * prefetch('/test/items', { - * query: { page: 1, limit: 20 } - * }) - * }, []) + * // Prefetch on hover + * onMouseEnter={() => prefetch('/topics/abc')} * - * // 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) - * } - * ``` + * @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, @@ -457,130 +419,210 @@ 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) } +// ============================================================================ +// Infinite Query Hook +// ============================================================================ + /** - * React hook for paginated data fetching with type safety - * Automatically manages pagination state and provides navigation controls - * Works with API endpoints that return PaginatedResponse + * Infinite scrolling hook with cursor-based pagination. * - * @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) + * 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.swrOptions - Additional SWR configuration options - * @returns Object containing paginated data, navigation controls, and state + * @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 - * ```typescript - * // Basic paginated list - * const { - * items, - * loading, - * total, - * page, - * hasMore, - * nextPage, - * prevPage - * } = usePaginatedQuery('/test/items', { + * // 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 (cursor/limit are managed internally) */ + query?: Omit, 'cursor' | 'limit'> + /** Items per page (default: 10) */ + limit?: number + /** Set to false to disable fetching (default: true) */ + enabled?: boolean + /** Override SWR infinite configuration */ + swrOptions?: SWRInfiniteConfiguration + } +): UseInfiniteQueryResult> { + const limit = options?.limit ?? 10 + const enabled = options?.enabled !== false + + const getKey = useCallback( + (_pageIndex: number, previousPageData: CursorPaginationResponse | null) => { + if (!enabled) return null + + // Stop if previous page has no nextCursor + if (previousPageData && !previousPageData.nextCursor) { + return null + } + + const paginationQuery = { + ...options?.query, + limit, + ...(previousPageData?.nextCursor ? { cursor: previousPageData.nextCursor } : {}) + } + + return [path, paginationQuery] as [TPath, typeof paginationQuery] + }, + [path, options?.query, limit, enabled] + ) + + const infiniteFetcher = (key: [TPath, Record]) => { + return getFetcher(key as unknown as [TPath, QueryParamsForPath?]) as Promise< + CursorPaginationResponse> + > + } + + const swrResult = useSWRInfinite(getKey, infiniteFetcher, { + ...DEFAULT_SWR_OPTIONS, + ...options?.swrOptions + }) + + 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] + return !!last.nextCursor + }, [data]) + + 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, + isLoading, + isRefreshing: isValidating, + error: error as Error | undefined, + hasNext, + loadNext, + refresh, + reset, + mutate + } +} + +// ============================================================================ +// Paginated Query Hook +// ============================================================================ + +/** + * 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 + * // 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 * }) - * - * // 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 (page/limit are managed internally) */ query?: Omit, 'page' | 'limit'> /** Items per page (default: 10) */ limit?: number - /** Custom SWR options */ - swrOptions?: Parameters[2] + /** Set to false to disable fetching (default: true) */ + enabled?: boolean + /** Override SWR configuration */ + swrOptions?: SWRConfiguration } -): 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 - /** Loading state */ - loading: boolean - /** Error if request failed */ - error?: Error - /** Whether there are more pages available */ - hasMore: 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 + // Reset page to 1 when query parameters change + const queryKey = JSON.stringify(options?.query) + useEffect(() => { + setCurrentPage(1) + }, [queryKey]) + + // Build query with pagination params const queryWithPagination = { ...options?.query, page: currentPage, limit - } as Record + } - const { data, loading, error, refetch } = useQuery(path, { + 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 }) - // Extract paginated response data with type safety - const paginatedData = data as PaginatedResponse + // usePaginatedQuery is only for offset pagination + const paginatedData = data as OffsetPaginationResponse | undefined const items = paginatedData?.items || [] 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,27 +641,91 @@ export function usePaginatedQuery( items, total, page: currentPage, - loading, + isLoading, + isRefreshing, error, - hasMore, + hasNext, hasPrev, prevPage, nextPage, refresh: refetch, reset - } 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 + } 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/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/useAppInit.ts b/src/renderer/src/hooks/useAppInit.ts index e30dd1b60b..7d7d6730d7 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/hooks/useSettings.ts b/src/renderer/src/hooks/useSettings.ts index 84ccb3fa16..9a687c0b7a 100644 --- a/src/renderer/src/hooks/useSettings.ts +++ b/src/renderer/src/hooks/useSettings.ts @@ -1,8 +1,19 @@ /** - * Data Refactor, notes by fullex - * //TODO @deprecated this file will be removed + * @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 { usePreference } from '@data/hooks/usePreference' import { useAppSelector } from '@renderer/store' import store from '@renderer/store' 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/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/hooks/useStore.ts b/src/renderer/src/hooks/useStore.ts index d7d96bfdaa..a770e83ace 100644 --- a/src/renderer/src/hooks/useStore.ts +++ b/src/renderer/src/hooks/useStore.ts @@ -1,4 +1,20 @@ -//FIXME @deprecated this file will be removed after data refactor +/** + * @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 { usePreference } from '@data/hooks/usePreference' import { CHERRYAI_PROVIDER } from '@renderer/config/providers' import store from '@renderer/store' diff --git a/src/renderer/src/hooks/useTranslate.ts b/src/renderer/src/hooks/useTranslate.ts index a3eb4bff56..1ace13fde8 100644 --- a/src/renderer/src/hooks/useTranslate.ts +++ b/src/renderer/src/hooks/useTranslate.ts @@ -37,18 +37,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] ) @@ -64,6 +62,7 @@ export default function useTranslate() { prompt, settings, translateLanguages, + isLoaded, getLanguageByLangcode, updateSettings: handleUpdateSettings } diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index 8064bfdd1e..59ffa82769 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -15,6 +15,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') @@ -30,7 +31,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/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index d1ad38159f..426c58565f 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": { @@ -3162,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", @@ -3232,24 +3236,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 +3281,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" }, @@ -4101,7 +4126,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", @@ -4735,6 +4760,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", @@ -4776,7 +4807,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", @@ -4787,6 +4826,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 309568f320..609f2bced9 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": { @@ -3162,6 +3165,7 @@ "label": "应用数据", "migration_title": "数据迁移", "new_path": "新路径", + "open": "打开目录", "original_path": "原始路径", "path_change_failed": "数据目录更改失败", "path_changed_without_copy": "路径已更改成功", @@ -3232,24 +3236,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 +3281,11 @@ "error": "连接出错", "initializing": "正在初始化连接...", "preparing": "准备传输中...", - "sending": "传输中 {{progress}}%", - "waiting_qr_scan": "请扫描二维码连接" + "sending": "传输中 {{progress}}%" }, + "status_badge_idle": "空闲", + "status_badge_scanning": "扫描中", + "stop_scan": "停止扫描", "title": "局域网传输", "transfer_progress": "传输进度" }, @@ -4101,7 +4126,7 @@ "tagsPlaceholder": "输入标签", "timeout": "超时", "timeoutTooltip": "对该服务器请求的超时时间(秒),默认为 60 秒", - "title": "MCP", + "title": "MCP 服务器", "tools": { "autoApprove": { "label": "自动批准", @@ -4735,6 +4760,12 @@ }, "title": "其他设置", "websearch": { + "api_key_required": { + "content": "{{provider}} 需要 API 密钥才能使用。是否现在去配置?", + "ok": "去配置", + "title": "需要 API 密钥" + }, + "api_providers": "API 服务商", "apikey": "API 密钥", "blacklist": "黑名单", "blacklist_description": "在搜索结果中不会出现以下网站的结果", @@ -4776,7 +4807,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": "强制使用搜索服务商而不是大语言模型进行搜索", @@ -4787,6 +4826,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 7b42cf4556..3f0a9d9d18 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": { @@ -2643,7 +2646,7 @@ "lanyun": "藍耘", "lmstudio": "LM Studio", "longcat": "龍貓", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "小米 MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope 魔搭", @@ -3162,6 +3165,7 @@ "label": "應用程式資料", "migration_title": "資料移轉", "new_path": "新路徑", + "open": "開啟目錄", "original_path": "原始路徑", "path_change_failed": "資料目錄變更失敗", "path_changed_without_copy": "路徑已變更成功", @@ -3232,24 +3236,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 +3281,11 @@ "error": "連線錯誤", "initializing": "正在初始化連線...", "preparing": "正在準備傳輸...", - "sending": "傳輸中 {{progress}}%", - "waiting_qr_scan": "請掃描 QR 碼以連線" + "sending": "傳輸中 {{progress}}%" }, + "status_badge_idle": "閒置", + "status_badge_scanning": "掃描中", + "stop_scan": "停止掃描", "title": "區域網路傳輸", "transfer_progress": "傳輸進度" }, @@ -4101,7 +4126,7 @@ "tagsPlaceholder": "輸入標籤", "timeout": "逾時", "timeoutTooltip": "對該伺服器請求的逾時時間(秒),預設為 60 秒", - "title": "MCP", + "title": "MCP 伺服器", "tools": { "autoApprove": { "label": "自動核准", @@ -4735,6 +4760,12 @@ }, "title": "其他設定", "websearch": { + "api_key_required": { + "content": "{{provider}} 需要 API 金鑰才能運作。您現在要設定嗎?", + "ok": "設定", + "title": "需要 API 金鑰" + }, + "api_providers": "API 服務商", "apikey": "API 金鑰", "blacklist": "黑名單", "blacklist_description": "以下網站不會出現在搜尋結果中", @@ -4776,7 +4807,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": "強制使用搜尋服務而不是 LLM", @@ -4787,6 +4826,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/translate/de-de.json b/src/renderer/src/i18n/translate/de-de.json index e8520252f0..b12015139a 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" @@ -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": { @@ -2198,7 +2201,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 +2646,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", @@ -3162,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", @@ -3232,24 +3236,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": "Lokale Netzwerkgeräte", + "discovered_devices": "Entdeckte Geräte", "error": { + "file_too_large": "Datei zu groß, maximal 500 MB unterstützt", "init_failed": "Initialisierung fehlgeschlagen", + "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": "Bitte vervollständigen Sie zuerst das Handshake.", "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": "Überweisung storniert", + "failed": "Dateiübertragung fehlgeschlagen: {{message}}", + "progress": "Senden... {{progress}}%", + "success": "Datei erfolgreich gesendet" + }, + "handshake": { + "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": "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", @@ -3258,9 +3281,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": "Leerlauf", + "status_badge_scanning": "Scannen", + "stop_scan": "Scanvorgang stoppen", "title": "LAN-Übertragung", "transfer_progress": "Übertragungsfortschritt" }, @@ -4101,7 +4126,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", @@ -4735,6 +4760,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", @@ -4776,7 +4807,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": "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", + "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", @@ -4787,6 +4826,7 @@ "search_provider": "Suchanbieter", "search_provider_placeholder": "Einen Suchanbieter auswählen", "search_with_time": "Suche mit Datum", + "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 eb324977b6..5fcae03612 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}} - @ επιλέξτε διαδρομή, / επιλέξτε εντολή" @@ -420,6 +420,9 @@ }, "delete": { "content": "Η διαγραφή του βοηθού θα διαγράψει όλα τα θέματα και τα αρχεία που είναι συνδεδεμένα με αυτόν. Είστε σίγουροι πως θέλετε να συνεχίσετε;", + "error": { + "remain_one": "Δεν επιτρέπεται η διαγραφή του τελευταίου βοηθού" + }, "title": "Διαγραφή βοηθού" }, "edit": { @@ -2198,7 +2201,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 +2646,7 @@ "lanyun": "Λανιούν Τεχνολογία", "lmstudio": "LM Studio", "longcat": "Τσίρο", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope Magpie", @@ -3162,6 +3165,7 @@ "label": "Δεδομένα εφαρμογής", "migration_title": "Μεταφορά δεδομένων", "new_path": "Νέα διαδρομή", + "open": "Ανοιχτός Κατάλογος", "original_path": "Αρχική διαδρομή", "path_change_failed": "Η αλλαγή του καταλόγου δεδομένων απέτυχε", "path_changed_without_copy": "Η διαδρομή άλλαξε επιτυχώς", @@ -3232,24 +3236,43 @@ }, "content": "Εξαγωγή μέρους των δεδομένων, συμπεριλαμβανομένων των ιστορικών συνομιλιών και των ρυθμίσεων. Σημειώστε ότι η διαδικασία δημιουργίας αντιγράφων ασφαλείας ενδέχεται να διαρκέσει κάποιο χρονικό διάστημα, ευχαριστούμε για την υπομονή σας.", "lan": { - "auto_close_tip": "Αυτόματο κλείσιμο σε {{seconds}} δευτερόλεπτα...", - "confirm_close_message": "Η μεταφορά αρχείων είναι σε εξέλιξη. Το κλείσιμο θα διακόψει τη μεταφορά. Είστε σίγουροι ότι θέλετε να κλείσετε βίαια;", - "confirm_close_title": "Επιβεβαίωση Κλεισίματος", "connected": "Συνδεδεμένος", "connection_failed": "Η σύνδεση απέτυχε", "content": "Βεβαιωθείτε ότι ο υπολογιστής και το κινητό βρίσκονται στο ίδιο δίκτυο για να χρησιμοποιήσετε τη μεταφορά LAN. Ανοίξτε την εφαρμογή Cherry Studio και σαρώστε αυτόν τον κωδικό QR.", + "device_list_title": "Τοπικές συσκευές δικτύου", + "discovered_devices": "Ανακαλυφθείσες συσκευές", "error": { + "file_too_large": "Το αρχείο είναι πολύ μεγάλο, υποστηρίζεται μέγιστο μέγεθος 500 MB", "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": "Λήφθηκε pong από {{device}}", + "test_message_sent": "Στάλθηκε δοκιμαστικό φορτίο hello world" + }, + "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": "Συνδεδεμένος", @@ -3258,9 +3281,11 @@ "error": "Σφάλμα σύνδεσης", "initializing": "Αρχικοποίηση σύνδεσης...", "preparing": "Προετοιμασία μεταφοράς...", - "sending": "Μεταφορά {{progress}}%", - "waiting_qr_scan": "Παρακαλώ σαρώστε τον κωδικό QR για σύνδεση" + "sending": "Μεταφορά {{progress}}%" }, + "status_badge_idle": "Αδρανής", + "status_badge_scanning": "Σάρωση", + "stop_scan": "Διακοπή σάρωσης", "title": "Μεταφορά τοπικού δικτύου", "transfer_progress": "Πρόοδος μεταφοράς" }, @@ -3940,7 +3965,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 που υλοποιείται, παρέχοντας εργαλεία για δυναμική και αναστοχαστική επίλυση προβλημάτων μέσω δομημένων διαδικασιών σκέψης" }, @@ -4735,6 +4760,12 @@ }, "title": "Ρυθμίσεις Εργαλείων", "websearch": { + "api_key_required": { + "content": "Ο {{provider}} απαιτεί κλειδί API για να λειτουργήσει. Θα θέλατε να το διαμορφώσετε τώρα;", + "ok": "Ρυθμίστε", + "title": "Απαιτείται κλειδί API" + }, + "api_providers": "Πάροχοι API", "apikey": "Κλειδί API", "blacklist": "Μαύρη Λίστα", "blacklist_description": "Τα αποτελέσματα από τους παρακάτω ιστότοπους δεν θα εμφανίζονται στα αποτελέσματα αναζήτησης", @@ -4776,7 +4807,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": "Εξαναγκάζει τη χρήση του παρόχου αναζήτησης αντί για μοντέλο μεγάλης γλώσσας για αναζήτηση", @@ -4787,6 +4826,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/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index 87215cb27d..f8bc54e1d3 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" @@ -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": { @@ -2198,7 +2201,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 +2646,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", @@ -3162,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", @@ -3232,24 +3236,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": "Dispositivos de red local", + "discovered_devices": "Dispositivos descubiertos", "error": { + "file_too_large": "Archivo demasiado grande, se admite un máximo de 500 MB", "init_failed": "Falló la inicialización", + "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": "Por favor, completa primero el apretón de manos.", "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": "Transferencia cancelada", + "failed": "Error en la transferencia del archivo: {{message}}", + "progress": "Enviando... {{progress}}%", + "success": "Archivo enviado con éxito" + }, + "handshake": { + "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": "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", @@ -3258,9 +3281,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": "Ocioso", + "status_badge_scanning": "Escaneando", + "stop_scan": "Detener escaneo", "title": "Transferencia de red local", "transfer_progress": "Progreso de transferencia" }, @@ -3940,7 +3965,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" }, @@ -4101,7 +4126,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", @@ -4735,6 +4760,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", @@ -4776,7 +4807,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": "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}}", + "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", @@ -4787,6 +4826,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": "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 f0e38cdbc4..57f644f581 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" @@ -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": { @@ -2198,7 +2201,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 +2646,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", @@ -3162,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", @@ -3232,24 +3236,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": "Périphériques réseau locaux", + "discovered_devices": "Appareils découverts", "error": { + "file_too_large": "Fichier trop volumineux, taille maximale supportée : 500 Mo", "init_failed": "Échec de l'initialisation", + "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": "Veuillez d'abord terminer la poignée de main", "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": "Transfert annulé", + "failed": "Le transfert de fichier a échoué : {{message}}", + "progress": "Envoi en cours... {{progress}}%", + "success": "Fichier envoyé avec succès" + }, + "handshake": { + "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": "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é", @@ -3258,9 +3281,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": "Inactif", + "status_badge_scanning": "Numérisation", + "stop_scan": "Arrêter le scan", "title": "Transmission en réseau local", "transfer_progress": "Progression du transfert" }, @@ -3940,7 +3965,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é" }, @@ -4101,7 +4126,7 @@ "tagsPlaceholder": "Введите теги", "timeout": "Таймаут", "timeoutTooltip": "Таймаут запроса к серверу (в секундах), по умолчанию 60 секунд", - "title": "Paramètres MCP", + "title": "Serveurs MCP", "tools": { "autoApprove": { "label": "Approbation automatique", @@ -4735,6 +4760,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", @@ -4776,7 +4807,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": "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}}", + "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", @@ -4787,6 +4826,7 @@ "search_provider": "Fournisseur de recherche", "search_provider_placeholder": "Sélectionnez un fournisseur de recherche", "search_with_time": "Rechercher avec date", + "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 363f7d4585..fd9667b2dd 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}}で送信 - @でパスを選択、/でコマンドを選択" @@ -420,6 +420,9 @@ }, "delete": { "content": "アシスタントを削除すると、そのアシスタントのすべてのトピックとファイルが削除されます。削除しますか?", + "error": { + "remain_one": "最後の1人のアシスタントは削除できません" + }, "title": "アシスタントを削除" }, "edit": { @@ -2198,7 +2201,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 +2646,7 @@ "lanyun": "LANYUN", "lmstudio": "LM Studio", "longcat": "トトロ", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "シャオミ・ミモ", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope", @@ -3162,6 +3165,7 @@ "label": "アプリデータ", "migration_title": "データ移行", "new_path": "新しいパス", + "open": "オープンディレクトリ", "original_path": "元のパス", "path_change_failed": "データディレクトリの変更に失敗しました", "path_changed_without_copy": "パスが変更されました。", @@ -3232,24 +3236,43 @@ }, "content": "一部のデータ、チャット履歴や設定をエクスポートします。バックアップには時間がかかる場合がありますので、しばらくお待ちください。", "lan": { - "auto_close_tip": "{{seconds}}秒後に自動的に閉じます...", - "confirm_close_message": "ファイル転送が進行中です。閉じると転送が中断されます。強制終了してもよろしいですか?", - "confirm_close_title": "閉じることを確認", "connected": "接続済み", "connection_failed": "接続に失敗しました", "content": "コンピューターとスマートフォンが同じネットワークに接続されていることを確認し、ローカルエリアネットワーク転送を使用してください。Cherry Studioアプリを開き、このQRコードをスキャンしてください。", + "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": "スキャンが一時停止されました。スキャンを開始して、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": "接続済み", @@ -3258,9 +3281,11 @@ "error": "接続エラー", "initializing": "接続を初期化中...", "preparing": "転送準備中...", - "sending": "転送中 {{progress}}%", - "waiting_qr_scan": "QRコードをスキャンして接続してください" + "sending": "転送中 {{progress}}%" }, + "status_badge_idle": "アイドル", + "status_badge_scanning": "スキャン", + "stop_scan": "スキャンを停止", "title": "LAN転送", "transfer_progress": "転送進行" }, @@ -4735,6 +4760,12 @@ }, "title": "その他の設定", "websearch": { + "api_key_required": { + "content": "{{provider}}はAPIキーが必要です。今すぐ設定しますか?", + "ok": "設定", + "title": "APIキーが必要" + }, + "api_providers": "APIプロバイダー", "apikey": "APIキー", "blacklist": "ブラックリスト", "blacklist_description": "以下のウェブサイトの結果は検索結果に表示されません", @@ -4776,7 +4807,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": "LLMの代わりに検索サービスを強制的に使用する", @@ -4787,6 +4826,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/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index a2b73e3b7c..803334d832 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" }, @@ -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,9 +53,9 @@ "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": "[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" @@ -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": { @@ -2198,7 +2201,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?", @@ -2643,7 +2646,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á", @@ -3162,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", @@ -3232,24 +3236,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": "Dispositivos de rede local", + "discovered_devices": "Dispositivos descobertos", "error": { + "file_too_large": "Arquivo muito grande, máximo de 500MB suportado", "init_failed": "Falha na inicialização", + "invalid_file_type": "Apenas arquivos ZIP são suportados", "no_file": "Nenhum arquivo selecionado", "no_ip": "Incapaz de obter endereço IP", + "not_connected": "Por favor, complete primeiro o handshake", "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": "Transferência cancelada", + "failed": "Falha na transferência de arquivo: {{message}}", + "progress": "Enviando... {{progress}}%", + "success": "Arquivo enviado com sucesso" + }, + "handshake": { + "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": "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", @@ -3258,9 +3281,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": "Ocioso", + "status_badge_scanning": "Digitalização", + "stop_scan": "Parar digitalização", "title": "transmissão de rede local", "transfer_progress": "Progresso da transferência" }, @@ -4735,6 +4760,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", @@ -4776,7 +4807,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": "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}}", + "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", @@ -4787,6 +4826,7 @@ "search_provider": "Provedor de pesquisa", "search_provider_placeholder": "Selecione um provedor de pesquisa", "search_with_time": "Pesquisar com data", + "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/ro-ro.json b/src/renderer/src/i18n/translate/ro-ro.json new file mode 100644 index 0000000000..d18b952baf --- /dev/null +++ b/src/renderer/src/i18n/translate/ro-ro.json @@ -0,0 +1,5102 @@ +{ + "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?", + "error": { + "remain_one": "Nu este permisă ștergerea ultimului" + }, + "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ă", + "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", + "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/i18n/translate/ru-ru.json b/src/renderer/src/i18n/translate/ru-ru.json index bafe20358c..66651e7cb7 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}} — @ выбрать путь, / выбрать команду" @@ -420,6 +420,9 @@ }, "delete": { "content": "Удаление ассистента удалит все топики и файлы под ассистентом. Вы уверены, что хотите удалить его?", + "error": { + "remain_one": "Нельзя удалить последнего помощника" + }, "title": "Удалить ассистента" }, "edit": { @@ -2198,7 +2201,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 +2646,7 @@ "lanyun": "LANYUN", "lmstudio": "LM Studio", "longcat": "Тоторо", - "mimo": "[to be translated]:Xiaomi MiMo", + "mimo": "Xiaomi MiMo", "minimax": "MiniMax", "mistral": "Mistral", "modelscope": "ModelScope", @@ -3162,6 +3165,7 @@ "label": "Данные приложения", "migration_title": "Миграция данных", "new_path": "Новый путь", + "open": "Открыть каталог", "original_path": "Исходный путь", "path_change_failed": "Сбой изменения каталога данных", "path_changed_without_copy": "Путь изменен успешно", @@ -3232,24 +3236,43 @@ }, "content": "Экспорт части данных, включая историю чатов и настройки. Обратите внимание, процесс резервного копирования может занять некоторое время, благодарим за ваше терпение.", "lan": { - "auto_close_tip": "Автоматическое закрытие через {{seconds}} секунд...", - "confirm_close_message": "Передача файла в процессе. Закрытие прервет передачу. Вы уверены, что хотите принудительно закрыть?", - "confirm_close_title": "Подтвердить закрытие", "connected": "Подключено", "connection_failed": "Соединение не удалось", "content": "Убедитесь, что компьютер и телефон подключены к одной сети, чтобы использовать локальную передачу. Откройте приложение Cherry Studio и отсканируйте этот QR-код.", + "device_list_title": "Устройства локальной сети", + "discovered_devices": "Обнаруженные устройства", "error": { + "file_too_large": "Файл слишком большой, поддерживается максимум 500 МБ.", "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}}", + "test_message_sent": "Отправлен тестовый полезный груз \"hello world\"" + }, + "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": "Подключено", @@ -3258,9 +3281,11 @@ "error": "Ошибка подключения", "initializing": "Инициализация соединения...", "preparing": "Подготовка передачи...", - "sending": "Передача {{progress}}%", - "waiting_qr_scan": "Пожалуйста, отсканируйте QR-код для подключения" + "sending": "Передача {{progress}}%" }, + "status_badge_idle": "Бездействие", + "status_badge_scanning": "Сканирование", + "stop_scan": "Остановить сканирование", "title": "Передача по локальной сети", "transfer_progress": "Прогресс передачи" }, @@ -4735,6 +4760,12 @@ }, "title": "Другие настройки", "websearch": { + "api_key_required": { + "content": "{{provider}} требует API-ключ для работы. Хотите настроить его сейчас?", + "ok": "Настроить", + "title": "Требуется ключ API" + }, + "api_providers": "Поставщики API", "apikey": "API ключ", "blacklist": "Черный список", "blacklist_description": "Результаты из следующих веб-сайтов не будут отображаться в результатах поиска", @@ -4776,7 +4807,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": "Принудительно использовать поисковый сервис вместо LLM", @@ -4787,6 +4826,7 @@ "search_provider": "поиск сервисного провайдера", "search_provider_placeholder": "Выберите поставщика поисковых услуг", "search_with_time": "Поиск, содержащий дату", + "set_as_default": "Установить по умолчанию", "subscribe": "Подписка на черный список", "subscribe_add": "Добавить подписку", "subscribe_add_failed": "Не удалось добавить источник подписки", diff --git a/src/renderer/src/pages/code/index.ts b/src/renderer/src/pages/code/index.ts index dcc9f43534..81f5ddddc3 100644 --- a/src/renderer/src/pages/code/index.ts +++ b/src/renderer/src/pages/code/index.ts @@ -34,13 +34,16 @@ export const CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS = [ 'minimax', 'longcat', SystemProviderIds.qiniu, - SystemProviderIds.silicon + SystemProviderIds.silicon, + SystemProviderIds.mimo, + SystemProviderIds.openrouter ] export const CLAUDE_SUPPORTED_PROVIDERS = [ 'aihubmix', 'dmxapi', 'new-api', 'cherryin', + '302ai', ...CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS ] export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin'] @@ -94,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/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()} diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index d76e7c9192..3b5eb80fdf 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -237,6 +237,7 @@ const Chat: FC = (props) => { ) : ( )} + {messageNavigation === 'buttons' && } )} diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index ef96dc74ed..2a79626aa2 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -262,9 +262,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' 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 || '.' }) diff --git a/src/renderer/src/pages/home/Markdown/Table.tsx b/src/renderer/src/pages/home/Markdown/Table.tsx index 5c596e7eb9..6b1c344142 100644 --- a/src/renderer/src/pages/home/Markdown/Table.tsx +++ b/src/renderer/src/pages/home/Markdown/Table.tsx @@ -4,6 +4,7 @@ import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue' import store from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' 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; diff --git a/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx b/src/renderer/src/pages/home/Messages/AgentSessionMessages.tsx index 611216919a..7f7900b8c5 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,29 @@ 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 +108,7 @@ const AgentSessionMessages: React.FC = ({ agentId, sessionId }) => { + {messageNavigation === 'anchor' && } ) } diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index f37e829a2a..12e3e04988 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -163,7 +163,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/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/AssistantsTab.tsx b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx index 471d6921a0..9c1a950113 100644 --- a/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/AssistantsTab.tsx @@ -10,6 +10,7 @@ import type { Assistant, Topic } from '@renderer/types' import type { AssistantTabSortType } from '@shared/data/preference/preferenceTypes' 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 containerRef = useRef(null) const { apiServerConfig } = useApiServer() const apiServerEnabled = apiServerConfig.enabled + 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( 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/home/Tabs/components/TopicManageMode.tsx b/src/renderer/src/pages/home/Tabs/components/TopicManageMode.tsx index 92f213312d..46c09bd294 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 diff --git a/src/renderer/src/pages/notes/HeaderNavbar.tsx b/src/renderer/src/pages/notes/HeaderNavbar.tsx index a05cbc7b40..f853f021bf 100644 --- a/src/renderer/src/pages/notes/HeaderNavbar.tsx +++ b/src/renderer/src/pages/notes/HeaderNavbar.tsx @@ -1,6 +1,7 @@ import { RowFlex } from '@cherrystudio/ui' import { loggerService } from '@logger' import { NavbarCenter, NavbarHeader, NavbarRight } from '@renderer/components/app/Navbar' +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 70d8f92a69..d8b01672e6 100644 --- a/src/renderer/src/pages/settings/NotesSettings.tsx +++ b/src/renderer/src/pages/notes/NotesSettings.tsx @@ -1,17 +1,8 @@ -import { Switch } from '@cherrystudio/ui' -import { Button } from '@cherrystudio/ui' +import { Button, Switch } from '@cherrystudio/ui' 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 { Input, Slider } 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, @@ -20,7 +11,14 @@ import { SettingRow, SettingRowTitle, SettingTitle -} from '.' +} from '@renderer/pages/settings' +import type { EditorView } from '@renderer/types' +import { Input, Slider } 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') @@ -94,7 +92,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 ddde3cd52a..0ba11a28f1 100644 --- a/src/renderer/src/pages/notes/NotesSidebar.tsx +++ b/src/renderer/src/pages/notes/NotesSidebar.tsx @@ -411,7 +411,7 @@ const NotesSidebar: FC = ({ {!isShowStarred && !isShowSearch && ( -
+
= ({ 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 } } @@ -149,7 +152,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { model, priceModel, image_size, - modelGroups + modelGroups, + extend_params } } @@ -158,7 +162,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, @@ -169,6 +173,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { modelGroups, priceModel, image_size, + extend_params, ...params } } @@ -186,7 +191,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 + }) } } @@ -289,7 +299,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) @@ -305,9 +315,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 }) } } @@ -351,7 +362,8 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const params = { prompt, model: painting.model, - n: painting.n + n: painting.n, + ...painting?.extend_params } const headerExpand = { @@ -393,7 +405,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/pages/settings/AboutSettings.tsx b/src/renderer/src/pages/settings/AboutSettings.tsx index 4a6b3cbe60..783cddc6d9 100644 --- a/src/renderer/src/pages/settings/AboutSettings.tsx +++ b/src/renderer/src/pages/settings/AboutSettings.tsx @@ -170,9 +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' - ) + 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/AssistantSettings/AssistantMemorySettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx index b323b6dfcd..5354555ade 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantMemorySettings.tsx @@ -1,7 +1,7 @@ import { Box, Button, InfoTooltip, Switch, Tooltip } from '@cherrystudio/ui' import { loggerService } from '@logger' import { usePreference } from '@renderer/data/hooks/usePreference' -import MemoriesSettingsModal from '@renderer/pages/memory/settings-modal' +import MemoriesSettingsModal from '@renderer/pages/settings/MemorySettings/MemorySettingsModal' import MemoryService from '@renderer/services/MemoryService' import { 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 ( @@ -133,16 +133,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/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 1a981251f3..74c7deb0b2 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -40,7 +40,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) @@ -167,7 +167,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/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/DataSettings/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx index 5b98bda4fb..1346ed96e4 100644 --- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx @@ -1,18 +1,11 @@ -import { - CloudServerOutlined, - CloudSyncOutlined, - FileSearchOutlined, - LoadingOutlined, - WifiOutlined, - YuqueOutlined -} from '@ant-design/icons' +import { CloudServerOutlined, CloudSyncOutlined, LoadingOutlined, WifiOutlined, YuqueOutlined } from '@ant-design/icons' import { Button, RowFlex, Switch } from '@cherrystudio/ui' import { usePreference } from '@data/hooks/usePreference' import DividerWithText from '@renderer/components/DividerWithText' import { NutstoreIcon } from '@renderer/components/Icons/NutstoreIcons' 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' @@ -22,8 +15,8 @@ import { reset } from '@renderer/services/BackupService' import type { AppInfo } from '@renderer/types' import { formatFileSize } from '@renderer/utils' import { occupiedDirs } from '@shared/config/constant' -import { Progress, Typography } from 'antd' -import { FileText, FolderCog, FolderInput, FolderOpen, SaveIcon } from 'lucide-react' +import { Progress, 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' @@ -625,12 +618,13 @@ const DataSettings: FC = () => { {t('settings.data.export_to_phone.title')} - + {t('settings.data.data.title')} @@ -643,10 +637,12 @@ const DataSettings: FC = () => { onClick={() => handleOpenPath(appInfo?.appDataPath)}> {appInfo?.appDataPath} - handleOpenPath(appInfo?.appDataPath)} style={{ flexShrink: 0 }} /> + + + - @@ -658,7 +654,6 @@ const DataSettings: FC = () => { handleOpenPath(appInfo?.logsPath)}> {appInfo?.logsPath} - handleOpenPath(appInfo?.logsPath)} style={{ flexShrink: 0 }} /> , - - ]}> + }}>
{ } 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) { cacheService.setCasual('memory.wait.settings', true) return setSettingsModalVisible(true) } @@ -796,7 +790,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 65% rename from src/renderer/src/pages/memory/settings-modal.tsx rename to src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx index 1917765e55..03bb302af1 100644 --- a/src/renderer/src/pages/memory/settings-modal.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/MemorySettingsModal.tsx @@ -1,7 +1,6 @@ import { Flex } from '@cherrystudio/ui' import { InfoTooltip } from '@cherrystudio/ui' import { loggerService } from '@logger' -import AiProvider from '@renderer/aiCore' import InputEmbeddingDimension from '@renderer/components/InputEmbeddingDimension' import ModelSelector from '@renderer/components/ModelSelector' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' @@ -13,12 +12,12 @@ import type { Model } from '@renderer/types' import { 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 @@ -27,78 +26,61 @@ 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) { + // 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) - 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 } @@ -151,7 +133,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') + // 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 ( = ({ visible, onSubm } - name="embedderDimensions" + name="embeddingDimensions" rules={[ { validator(_, value) { @@ -184,7 +168,7 @@ const MemoriesSettingsModal: FC = ({ visible, onSubm } } ]}> - + ) }} @@ -200,4 +184,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 6515beec3b..999d94d415 100644 --- a/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx +++ b/src/renderer/src/pages/settings/MemorySettings/UserSelector.tsx @@ -1,7 +1,7 @@ -import { Avatar, Button, Flex, RowFlex, Tooltip } from '@cherrystudio/ui' +import { Button, Flex, Tooltip } from '@cherrystudio/ui' import { Select } 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,37 +16,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/pages/settings/ModelSettings/DefaultAssistantSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx index adbf96dcaa..7e0a77e4a7 100644 --- a/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/DefaultAssistantSettings.tsx @@ -4,9 +4,10 @@ import EmojiPicker from '@renderer/components/EmojiPicker' import { ResetIcon } from '@renderer/components/Icons' 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 { Col, Divider, Input, InputNumber, Modal, Popover, Row, Slider } 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/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/DownloadOVMSModelPopup.tsx index 5af1080ec1..9f94cd1683 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', diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ModelList.tsx index b2f352dd84..39ef624269 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) @@ -136,9 +144,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 e90661a0f6..2bbd68aa10 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} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx index 220613f703..44f1355031 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderList.tsx @@ -22,6 +22,7 @@ import type { FC } from 'react' import { startTransition, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import useSWRImmutable from 'swr/immutable' import AddProviderPopup from './AddProviderPopup' import ModelNotesPopup from './ModelNotesPopup' @@ -31,8 +32,16 @@ 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 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 search = useSearch({ strict: false }) as Record @@ -48,6 +57,8 @@ const ProviderList: FC = () => { const [providerLogos, setProviderLogos] = useState>({}) const listRef = useRef(null) + const { data: isOvmsSupported } = useSWRImmutable('ovms/isSupported', getIsOvmsSupported) + const setSelectedProvider = useCallback((provider: Provider) => { startTransition(() => _setSelectedProvider(provider)) }, []) @@ -288,7 +299,8 @@ const ProviderList: FC = () => { } const filteredProviders = providers.filter((provider) => { - if (provider.id === 'ovms' && (systemType !== 'windows' || !cpuName.toLowerCase().includes('intel'))) { + // don't show it when isOvmsSupported is loading + if (provider.id === 'ovms' && !isOvmsSupported) { return false } diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 546378fb81..412d99db14 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -81,7 +81,9 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [ SystemProviderIds.silicon, SystemProviderIds.qiniu, SystemProviderIds.dmxapi, - SystemProviderIds.mimo + SystemProviderIds.mimo, + SystemProviderIds.openrouter, + SystemProviderIds.tokenflux ] as const type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number] diff --git a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx index 57aa40b97e..cfdc467bc7 100644 --- a/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx +++ b/src/renderer/src/pages/settings/SelectionAssistantSettings/SelectionAssistantSettings.tsx @@ -1,13 +1,12 @@ 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 { Link } from '@tanstack/react-router' -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' && ( diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index 57bbcd6d2e..21764a1a76 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -1,5 +1,5 @@ -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 { Link, Outlet, useLocation } from '@tanstack/react-router' import { Divider as AntDivider } from 'antd' @@ -8,13 +8,12 @@ import { Cloud, Command, FileCode, - Hammer, HardDrive, Info, MonitorCog, - NotebookPen, Package, PictureInPicture2, + Search, Server, Settings2, TextCursorInput, @@ -72,19 +71,13 @@ const SettingsPage: FC = () => { - + {t('settings.mcp.title')} - - - - {t('notes.settings.title')} - - - + {t('settings.tool.websearch.title')} diff --git a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx index aa4a4f6590..166c2db77d 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/BasicSettings.tsx @@ -1,23 +1,138 @@ -import { Switch } from '@cherrystudio/ui' -import { InfoTooltip } from '@cherrystudio/ui' +import { InfoTooltip, Switch } from '@cherrystudio/ui' +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 } from 'antd' -import { t } from 'i18next' 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')} @@ -50,4 +165,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 d81d3c20ae..40c2f2ee2e 100644 --- a/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx +++ b/src/renderer/src/pages/settings/WebSearchSettings/WebSearchProviderSetting.tsx @@ -1,15 +1,18 @@ import { CheckOutlined, ExportOutlined, LoadingOutlined } from '@ant-design/icons' import { Button, Flex, InfoTooltip, RowFlex, Tooltip } from '@cherrystudio/ui' 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' 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 || '') @@ -148,26 +152,80 @@ 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')} @@ -231,10 +289,11 @@ const WebSearchProviderSetting: FC = ({ providerId }) => { )} - {hasObjectKey(provider, 'basicAuthUsername') && ( + {!isLocalProvider && hasObjectKey(provider, 'basicAuthUsername') && ( <> - + {t('settings.provider.basic_auth.label')} { + 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 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' 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/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 33afe2aa14..74f0f257ba 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -31,8 +31,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, @@ -184,7 +183,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 @@ -205,11 +204,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, setTimeoutTimer, setTranslatedContent, setTranslating, t, translating] @@ -269,7 +268,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) @@ -429,7 +428,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'))) } } @@ -500,7 +499,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 { @@ -532,11 +531,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() @@ -580,7 +579,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/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index d32d539394..1628d398d3 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -28,21 +28,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', @@ -57,6 +87,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 async function getDefaultTranslateAssistant( targetLanguage: TranslateLanguage, text: string, @@ -79,7 +117,6 @@ export async 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 @@ -109,6 +146,17 @@ export async 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 } @@ -168,6 +216,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 = () => { @@ -184,16 +244,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 } } diff --git a/src/renderer/src/services/MemoryProcessor.ts b/src/renderer/src/services/MemoryProcessor.ts index f6b7a26151..577aad53d0 100644 --- a/src/renderer/src/services/MemoryProcessor.ts +++ b/src/renderer/src/services/MemoryProcessor.ts @@ -41,7 +41,7 @@ export class MemoryProcessor { try { const { memoryConfig } = config - if (!memoryConfig.llmApiClient) { + if (!memoryConfig.llmModel) { throw new Error('No LLM model configured for memory processing') } @@ -54,8 +54,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 [] } @@ -101,9 +102,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 = cacheService.getCasual(`memory-search-${lastMessageId}`) || [] const existingMemories = existingMemoriesResult.map((memory) => ({ @@ -124,7 +126,7 @@ export class MemoryProcessor { const responseContent = await fetchGenerate({ prompt: MEMORY_UPDATE_SYSTEM_PROMPT, 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/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index c1f1d808c4..d030068190 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/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) 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/services/messageStreaming/BlockManager.ts b/src/renderer/src/services/messageStreaming/BlockManager.ts index 9e638ebf14..8061574e1d 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,56 @@ 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 (which also updates message.blocks 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 (also updates message.blocks references internally) + 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.` - ) - } + // 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..8707b18a0c --- /dev/null +++ b/src/renderer/src/services/messageStreaming/StreamingService.ts @@ -0,0 +1,674 @@ +/** + * @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 (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' +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 { Message as SharedMessage, MessageDataBlock, MessageStats } from '@shared/data/types/message' + +import { dbService } from '../db' + +const logger = loggerService.withContext('StreamingService') + +// 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 + +/** + * 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: 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[] + + // Metadata + startedAt: number +} + +/** + * 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 (single model), >0 for multi-model response groups + role: 'assistant' + model?: Message['model'] + modelId?: string + assistantId: string + traceId?: string + agentSessionId?: string + // Context messages for usage estimation (messages up to and including user message) + 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 + * + * 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, + 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, + role, + assistantId, + status: AssistantMessageStatus.PENDING, + createdAt: new Date().toISOString(), + blocks: [], + model, + modelId, + askId: parentId, // Map v2 parentId to v1 renderer format askId + traceId, + agentSessionId + } + + // Create session + const session: StreamingSession = { + topicId, + messageId, + message, + blocks: {}, + parentId, + siblingsGroupId, + contextMessages, + startedAt: Date.now() + } + + // Store session with TTL + cacheService.set(getSessionKey(messageId), session, SESSION_TTL) + + // Store message data for UI subscription + cacheService.set(getMessageKey(messageId), message, SESSION_TTL) + + // Add to topic sessions index + const topicSessions = cacheService.get(getTopicSessionsKey(topicId)) || [] + if (!topicSessions.includes(messageId)) { + topicSessions.push(messageId) + cacheService.set(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 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 + */ + async finalize(messageId: string, status: AssistantMessageStatus): Promise { + const session = this.getSession(messageId) + if (!session) { + logger.warn(`finalize called for non-existent session: ${messageId}`) + return + } + + 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) + 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 }) + } + + 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 + } + } + + /** + * 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.delete(getBlockKey(blockId)) + }) + + // Remove message cache + cacheService.delete(getMessageKey(messageId)) + + // Remove from topic sessions index + const topicSessions = cacheService.get(getTopicSessionsKey(session.topicId)) || [] + const updatedTopicSessions = topicSessions.filter((id) => id !== messageId) + if (updatedTopicSessions.length > 0) { + cacheService.set(getTopicSessionsKey(session.topicId), updatedTopicSessions, SESSION_TTL) + } else { + cacheService.delete(getTopicSessionsKey(session.topicId)) + } + + // Remove session + cacheService.delete(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.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 }) + } + + /** + * 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.set(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(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.get(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.set(getSessionKey(messageId), session, SESSION_TTL) + cacheService.set(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.get(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.get(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.has(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.get(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.get(getTopicSessionsKey(topicId)) || [] + } + + // ============ siblingsGroupId Generation ============ + + /** + * Generate the next siblingsGroupId for a topic. + * + * ## 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 per topic. + * Single-model responses should use siblingsGroupId=0 (not generated here). + * + * @param topicId - Topic ID + * @returns Next siblingsGroupId (always > 0 for multi-model groups) + */ + //FIXME [v2] 现在获取 siblingsGroupId 的方式是不正确,后续再做修改调整 + generateNextGroupId(topicId: string): number { + const counterKey = getSiblingsGroupCounterKey(topicId) + const currentCounter = cacheService.get(counterKey) || 0 + const nextGroupId = currentCounter + 1 + // Store with no TTL (persistent within session, cleared on app restart) + cacheService.set(counterKey, nextGroupId) + logger.debug('Generated siblingsGroupId', { topicId, siblingsGroupId: nextGroupId }) + 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 + } + } + + // ============ 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. + * + * 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 + * @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, + // v2 Data API uses 'parentId'; renderer format uses 'askId' for backward compatibility + 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 + */ + 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 + * + * @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) => { + // 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 + }) + + // 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..e8193041db 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/baseCallbacks.ts @@ -1,14 +1,28 @@ +/** + * @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 type { PlaceholderMessageBlock, Response, ThinkingMessageBlock } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { uuid } from '@renderer/utils' import { isAbortError, serializeError } from '@renderer/utils/error' @@ -19,47 +33,59 @@ 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 + getCurrentThinkingInfo?: () => { blockId: string | null; millsec: number } } export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { - const { blockManager, dispatch, getState, topicId, assistantMsgId, saveUpdatesToDB, assistant } = deps + const { blockManager, topicId, assistantMsgId, assistant, getCurrentThinkingInfo } = 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 +93,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 +109,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 +129,55 @@ export const createBaseCallbacks = (deps: BaseCallbacksDependencies) => { const possibleBlockId = findBlockIdForCompletion() if (possibleBlockId) { - // 更改上一个block的状态为ERROR - const changes = { + // Update previous block status to ERROR/PAUSED/PAUSED + const changes: Partial = { status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR } + // 如果是 thinking block,保留实际思考时间 + if (blockManager.lastBlockType === MessageBlockType.THINKING) { + const thinkingInfo = getCurrentThinkingInfo?.() + if (thinkingInfo?.blockId === possibleBlockId && thinkingInfo?.millsec && thinkingInfo.millsec > 0) { + changes.thinking_millsec = thinkingInfo.millsec + } + } 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 + // 获取当前思考信息(如果有),用于保留实际思考时间 + const thinkingInfo = getCurrentThinkingInfo?.() 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 } - }) - ) + // 构建更新对象 + const changes: Partial = { + status: isErrorTypeAbort ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR + } + // 如果是 thinking block 且有思考时间信息,保留实际思考时间 + if ( + block.type === MessageBlockType.THINKING && + thinkingInfo?.blockId === block.id && + thinkingInfo?.millsec && + thinkingInfo.millsec > 0 + ) { + changes.thinking_millsec = thinkingInfo.millsec + } + streamingService.updateBlock(block.id, changes) } } } + // 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 +187,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 +208,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 +222,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 +235,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 +261,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..d5f82e1032 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,40 +29,40 @@ 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 - // 创建基础回调 - const baseCallbacks = createBaseCallbacks({ - blockManager, - dispatch, - getState, - topicId, - assistantMsgId, - saveUpdatesToDB, - assistant - }) - - // 创建各类回调 + // 首先创建 thinkingCallbacks ,以便传递 getCurrentThinkingInfo 给 baseCallbacks const thinkingCallbacks = createThinkingCallbacks({ blockManager, assistantMsgId }) + // Create base callbacks (lifecycle, error, complete) + const baseCallbacks = createBaseCallbacks({ + blockManager, + topicId, + assistantMsgId, + assistant, + getCurrentThinkingInfo: thinkingCallbacks.getCurrentThinkingInfo + }) + const toolCallbacks = createToolCallbacks({ blockManager, - assistantMsgId, - dispatch + assistantMsgId }) const imageCallbacks = createImageCallbacks({ @@ -53,8 +72,7 @@ export const createCallbacks = (deps: CallbacksDependencies) => { const citationCallbacks = createCitationCallbacks({ blockManager, - assistantMsgId, - getState + assistantMsgId }) const videoCallbacks = createVideoCallbacks({ blockManager, assistantMsgId }) @@ -62,23 +80,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 +102,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/thinkingCallbacks.ts b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts index aeb160fd02..3c718c4a67 100644 --- a/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts +++ b/src/renderer/src/services/messageStreaming/callbacks/thinkingCallbacks.ts @@ -19,6 +19,12 @@ export const createThinkingCallbacks = (deps: ThinkingCallbacksDependencies) => let thinking_millsec_now: number = 0 return { + // 获取当前思考时间(用于停止回复时保留思考时间) + getCurrentThinkingInfo: () => ({ + blockId: thinkingBlockId, + millsec: thinking_millsec_now > 0 ? performance.now() - thinking_millsec_now : 0 + }), + onThinkingStart: async () => { if (blockManager.hasInitialPlaceholder) { const changes: Partial = { 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/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 fc398b62ca..4727bdc1e7 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -1,6 +1,18 @@ /** - * Data Refactor, notes by fullex - * //TODO @deprecated this file will be removed + * @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' @@ -71,7 +83,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 187, + version: 192, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'], migrate }, 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 2a02546053..745aa9d05d 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 { MEMORY_FACT_EXTRACTION_PROMPT, MEMORY_UPDATE_SYSTEM_PROMPT } from '@shared/config/prompts' import type { MemoryConfig } from '@types' @@ -17,7 +33,7 @@ export interface MemoryState { // Default memory configuration to avoid undefined errors const defaultMemoryConfig: MemoryConfig = { - embedderDimensions: 1536, + embeddingDimensions: undefined, isAutoDimensions: true, customFactExtractionPrompt: MEMORY_FACT_EXTRACTION_PROMPT, customUpdateMemoryPrompt: MEMORY_UPDATE_SYSTEM_PROMPT 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 e922fd0d36..5ff038cded 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1,5 +1,18 @@ /** - * @deprecated this file will be removed after data refactor + * @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' @@ -20,6 +33,7 @@ import { BUILTIN_OCR_PROVIDERS, BUILTIN_OCR_PROVIDERS_MAP, DEFAULT_OCR_PROVIDER 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' @@ -3058,6 +3072,95 @@ 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 + } + }, + // 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 + } + }, + // 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 + } + }, + '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 + } + }, + '192': (state: RootState) => { + try { + state.llm.providers.forEach((provider) => { + if (provider.id === '302ai') { + provider.anthropicApiHost = 'https://api.302.ai' + } + }) + state.settings.readClipboardAtStartup = false + logger.info('migrate 192 success') + return state + } catch (error) { + logger.error('migrate 192 error', error as Error) + return state + } } } 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 125761f641..88b795c295 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -1,8 +1,20 @@ /** - * Data Refactor, notes by fullex - * //TODO @deprecated this file will be removed + * @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 { Topic, WebSearchStatus } from '@renderer/types' diff --git a/src/renderer/src/store/selectionStore.ts b/src/renderer/src/store/selectionStore.ts index 5acdbca28d..1bb6d28ce5 100644 --- a/src/renderer/src/store/selectionStore.ts +++ b/src/renderer/src/store/selectionStore.ts @@ -1,5 +1,18 @@ /** - * @deprecated The whole file will be removed after data refactoring + * @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/settings.ts b/src/renderer/src/store/settings.ts index ae4bd55b55..894f2d92ce 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -1,5 +1,18 @@ /** - * //TODO @deprecated this file will be removed after data refactor + * @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/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/__tests__/streamCallback.integration.test.ts b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts index 49c71aea56..9cba03512b 100644 --- a/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts +++ b/src/renderer/src/store/thunk/__tests__/streamCallback.integration.test.ts @@ -1,107 +1,83 @@ 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' 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' -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({ - blockManager: new BlockManager({ - dispatch, - getState, - saveUpdatedBlockToDB: vi.fn(), - saveUpdatesToDB: vi.fn(), - assistantMsgId: mockAssistantMsgId, - topicId: mockTopicId, - throttledBlockUpdate: vi.fn(), - cancelThrottledBlockUpdate: vi.fn() - }), - dispatch, - getState, - topicId: mockTopicId, - assistantMsgId: mockAssistantMsgId, - saveUpdatesToDB: vi.fn(), - assistant: mockAssistant + 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({ + assistantMsgId: mockAssistantMsgId, + topicId: mockTopicId, + throttledBlockUpdate: vi.fn((blockId, changes) => { + // In tests, immediately update the block + streamingService.updateBlock(blockId, changes) + }), + cancelThrottledBlockUpdate: vi.fn() + }), + topicId: mockTopicId, + assistantMsgId: mockAssistantMsgId, + assistant: mockAssistant + }) +} + // 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: { @@ -159,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' } })) @@ -311,8 +316,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' @@ -333,11 +337,11 @@ describe('streamCallback Integration Tests', () => { beforeEach(() => { vi.clearAllMocks() + MockCacheUtils.resetMocks() + MockDataApiUtils.resetMocks() 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 +364,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 }, @@ -386,24 +390,29 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -417,22 +426,24 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockTool: MCPTool = { id: 'tool-1', @@ -491,19 +502,21 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -531,19 +544,22 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockWebSearchResult = { source: WebSearchSource.WEBSEARCH, @@ -559,17 +575,20 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockCalculatorTool: MCPTool = { id: 'tool-1', @@ -651,27 +670,27 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockError = new Error('Test error') @@ -684,24 +703,26 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const mockExternalToolResult: ExternalToolResult = { webSearch: { @@ -727,19 +748,21 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) // 创建一个模拟的 abort 错误 const abortError = new Error('Request aborted') @@ -754,23 +777,25 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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 () => { - const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant, dispatch, getState) + const callbacks = createMockCallbacks(mockAssistantMsgId, mockTopicId, mockAssistant) const chunks: Chunk[] = [ { type: ChunkType.LLM_RESPONSE_CREATED }, @@ -783,23 +808,20 @@ describe('streamCallback Integration Tests', () => { await processChunks(chunks, callbacks) - // 验证 Redux 状态 - const state = 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') }) }) 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 51bfaca614..58af404677 100644 --- a/src/renderer/src/store/thunk/messageThunk.ts +++ b/src/renderer/src/store/thunk/messageThunk.ts @@ -1,13 +1,31 @@ +/** + * @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 { cacheService } from '@data/CacheService' import { loggerService } from '@logger' 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' 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' @@ -42,18 +60,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') @@ -348,9 +366,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, @@ -359,7 +377,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 @@ -371,7 +389,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 })) } @@ -401,23 +419,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 updateSingleBlockV2(id, blockUpdate) + // NOTE: DB update removed - persistence happens during finalize() + // await updateSingleBlock(id, blockUpdate) }, 150) blockUpdateThrottlers.set(id, throttler) @@ -499,25 +528,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 @@ -536,24 +566,32 @@ const fetchAndProcessAgentResponseImpl = async ( try { 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, + role: 'assistant', + model: assistant.model, + modelId: assistant.model?.id, + assistantId: assistant.id, + 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 }) @@ -701,74 +739,80 @@ 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 StreamingService + const assistantMessage = await streamingService.createAssistantMessage(topicId, { + parentId: triggeringMessage.id, + assistantId: assistant.id, modelId: mentionedModel.id, - traceId: triggeringMessage.traceId + model: mentionedModel, + siblingsGroupId, + traceId: triggeringMessage.traceId ?? undefined }) + 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) { @@ -795,13 +839,32 @@ 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, + role: 'assistant', + model: assistant.model, + modelId: assistant.model?.id, + assistantId: assistant.id, + 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) @@ -878,8 +941,18 @@ export const sendMessage = userMessage.agentSessionId = activeAgentSession.agentSessionId } - await saveMessageAndBlocksToDB(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)) } @@ -889,14 +962,14 @@ export const sendMessage = if (activeAgentSession) { const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessage.id, + askId: finalUserMessage.id, model: assistant.model, traceId: userMessage.traceId }) 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 () => { @@ -905,21 +978,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 { - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessage.id, + // Create message via StreamingService for normal topics + const assistantMessage = await streamingService.createAssistantMessage(topicId, { + parentId: finalUserMessage.id, + assistantId: assistant.id, + modelId: assistant.model?.id, model: assistant.model, - traceId: userMessage.traceId + siblingsGroupId: 0, + traceId: finalUserMessage.traceId ?? undefined }) - await saveMessageAndBlocksToDB(assistantMessage, []) + dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) queue.add(async () => { @@ -985,11 +1062,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. @@ -1008,7 +1085,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) } @@ -1047,7 +1124,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) } @@ -1072,7 +1149,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) } @@ -1109,12 +1186,16 @@ export const resendMessageThunk = if (assistantMessagesToReset.length === 0 && !userMessageToResend?.mentions?.length) { // 没有相关的助手消息且没有提及模型时,使用助手模型创建一条消息 - - const assistantMessage = createAssistantMessage(assistant.id, topicId, { - askId: userMessageToResend.id, - model: assistant.model + // Create message via StreamingService + const assistantMessage = await streamingService.createAssistantMessage(topicId, { + parentId: userMessageToResend.id, + assistantId: assistant.id, + modelId: assistant.model?.id, + model: assistant.model, + siblingsGroupId: 0, + traceId: userMessageToResend.traceId ?? undefined }) - assistantMessage.traceId = userMessageToResend.traceId + resetDataList.push(assistantMessage) resetDataList.forEach((message) => { @@ -1149,11 +1230,16 @@ 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 StreamingService for new mentioned models + const assistantMessage = await streamingService.createAssistantMessage(topicId, { + parentId: userMessageToResend.id, + assistantId: assistant.id, + modelId: model.id, + model, + siblingsGroupId: 0, + traceId: userMessageToResend.traceId ?? undefined }) + resetDataList.push(assistantMessage) dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) } @@ -1161,10 +1247,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) { @@ -1393,7 +1483,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) @@ -1450,12 +1540,14 @@ 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 StreamingService + const newAssistantMessageStub = await streamingService.createAssistantMessage(topicId, { + parentId: askId, // Crucial: Use the original askId + assistantId: assistant.id, modelId: newModel.id, - traceId: traceId + model: newModel, + siblingsGroupId: 0, + traceId: traceId ?? undefined }) // 3. Update Redux Store @@ -1463,8 +1555,8 @@ export const appendAssistantResponseThunk = 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(newAssistantMessageStub, [], insertAtIndex) + // 4. Message already saved via Data API POST above + // await saveMessageAndBlocksToDB(topicId, newAssistantMessageStub, [], insertAtIndex) dispatch( newMessagesActions.insertMessageAtIndex({ topicId, message: newAssistantMessageStub, index: insertAtIndex }) @@ -1616,12 +1708,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) } }) @@ -1675,11 +1767,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 })) @@ -1733,3 +1825,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 ec0aed947b..0000000000 --- a/src/renderer/src/store/thunk/messageThunk.v2.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * 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 - } -} 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' diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 727a609133..ee45586ca0 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', @@ -395,6 +396,7 @@ export interface DmxapiPainting extends PaintingParams { autoCreate?: boolean generationMode?: generationModeType priceModel?: string + extend_params?: Record } export interface TokenFluxPainting extends PaintingParams { @@ -455,7 +457,18 @@ export type MinAppType = { } /** 有限的UI语言 */ -// export type LanguageVarious = 'zh-CN' | 'zh-TW' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' | 'ru-RU' +// export type LanguageVarious = +// | 'zh-CN' +// | 'zh-TW' +// | 'de-DE' +// | 'el-GR' +// | 'en-US' +// | 'es-ES' +// | 'fr-FR' +// | 'ja-JP' +// | 'pt-PT' +// | 'ro-RO' +// | 'ru-RU' export type CodeStyleVarious = 'auto' | string @@ -900,17 +913,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 */ 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' 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, '') -} diff --git a/src/renderer/src/utils/error.ts b/src/renderer/src/utils/error.ts index e75c482c55..97545610c2 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' @@ -19,7 +20,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 @@ -64,11 +65,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 { diff --git a/src/renderer/src/utils/prompt.ts b/src/renderer/src/utils/prompt.ts index 6419fa1e0e..09158dfadc 100644 --- a/src/renderer/src/utils/prompt.ts +++ b/src/renderer/src/utils/prompt.ts @@ -1,3 +1,4 @@ +import { DEFAULT_SYSTEM_PROMPT } from '@cherrystudio/ai-core/built-in/plugins' import { loggerService } from '@logger' import { preferenceService } from '@renderer/data/PreferenceService' import store from '@renderer/store' @@ -6,61 +7,7 @@ import { defaultLanguage } from '@shared/config/constant' 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 }}` @@ -260,7 +207,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)) } diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index a5e5cbfaa6..c355519669 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 d46dbebd0a..c80034fb4c 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 useTranslate from '@renderer/hooks/useTranslate' 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 { runAsyncFunction } from '@renderer/utils' import { abortCompletion } from '@renderer/utils/abortController' import { detectLanguage } from '@renderer/utils/translate' import { defaultLanguage } from '@shared/config/constant' @@ -35,72 +34,101 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const { t } = useTranslation() const [language] = usePreference('app.language') - const [translateModelPrompt] = usePreference('feature.translate.model_prompt') + 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 || navigator.language || defaultLanguage) + 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 || navigator.language || defaultLanguage) - 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) + targetLangRef.current = targetLang + } + + if (biDirectionLangPair && biDirectionLangPair.value[1]) { + const alterLang = getLanguageByLangcode(biDirectionLangPair.value[1]) setAlterLanguage(alterLang) - }) - }, [getLanguageByLangcode, language]) + } + }, [getLanguageByLangcode, isLanguagesLoaded]) - // Initialize values only once when action changes - useEffect(() => { - if (initialized.current || !action.selectedText) return + // 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 = await getDefaultTranslateAssistant(targetLangRef.current, action.selectedText) + + assistantRef.current = currentAssistant + + // Initialize topic + topicRef.current = getDefaultTopic(currentAssistant.id) initialized.current = true + }, [action.selectedText, isLanguagesLoaded, updateLanguagePair]) - runAsyncFunction(async () => { - // Initialize assistant - const currentAssistant = await getDefaultTranslateAssistant(targetLanguage, action.selectedText!) - - assistantRef.current = currentAssistant - - // Initialize topic - topicRef.current = getDefaultTopic(currentAssistant.id) - }) - }, [action, targetLanguage, translateModelPrompt]) + // 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 @@ -146,6 +174,7 @@ const ActionTranslate: FC = ({ action, scrollToBottom }) => { const assistant = await 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]) @@ -162,7 +191,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] }) diff --git a/tests/__mocks__/README.md b/tests/__mocks__/README.md index 2ef64b7230..38ba6286ec 100644 --- a/tests/__mocks__/README.md +++ b/tests/__mocks__/README.md @@ -1,609 +1,332 @@ # 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 ``` -### 🔧 测试设置 +### Test Setup -#### Renderer Process Tests -在 `tests/renderer.setup.ts` 中配置了所有渲染进程模拟: +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 -// 自动加载 renderer/ 目录下的模拟 -vi.mock('@data/PreferenceService', async () => { - const { MockPreferenceService } = await import('./__mocks__/renderer/PreferenceService') - return MockPreferenceService -}) -// ... 其他渲染进程模拟 +import { MockCacheUtils } from '@test-mocks/renderer/CacheService' +import { MockMainCacheServiceUtils } from '@test-mocks/main/CacheService' ``` -#### Main Process Tests -在 `tests/main.setup.ts` 中配置了所有主进程模拟: +--- -```typescript -// 自动加载 main/ 目录下的模拟 -vi.mock('@main/data/PreferenceService', async () => { - const { MockMainPreferenceServiceExport } = await import('./__mocks__/main/PreferenceService') - return MockMainPreferenceServiceExport -}) -// ... 其他主进程模拟 -``` +## Renderer Mocks -## PreferenceService Mock +### CacheService -### 简介 +Three-tier cache system with type-safe and casual (dynamic key) methods. -`PreferenceService.ts` 提供了 PreferenceService 的统一模拟实现,用于所有渲染进程测试。这个模拟: +#### Methods -- ✅ **自动应用**:在 `renderer.setup.ts` 中全局配置,无需在每个测试文件中单独模拟 -- ✅ **完整API**:实现了 PreferenceService 的所有方法(get, getMultiple, set, etc.) -- ✅ **合理默认值**:提供了常用偏好设置的默认值 -- ✅ **可定制**:支持为特定测试定制默认值 -- ✅ **类型安全**:完全支持 TypeScript 类型检查 +| 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` | -### 默认值 - -模拟提供了以下默认偏好设置: - -```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 -- **批量操作**: batch() 和 transaction() 支持 -- **订阅系统**: 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的智能默认值 - -### 基本使用 +#### Usage ```typescript import { cacheService } from '@data/CacheService' +import { MockCacheUtils } from '@test-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 '@test-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 '@test-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 '@test-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 '@test-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 | 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 }) } diff --git a/tests/__mocks__/renderer/CacheService.ts b/tests/__mocks__/renderer/CacheService.ts index 653c0643d6..69f41f8689 100644 --- a/tests/__mocks__/renderer/CacheService.ts +++ b/tests/__mocks__/renderer/CacheService.ts @@ -2,15 +2,18 @@ import type { RendererPersistCacheKey, RendererPersistCacheSchema, UseCacheKey, - UseSharedCacheKey + InferUseCacheValue, + SharedCacheKey, + SharedCacheSchema } from '@shared/data/cache/cacheSchemas' -import { DefaultRendererPersistCache, DefaultUseCache, DefaultUseSharedCache } from '@shared/data/cache/cacheSchemas' -import type { CacheSubscriber } from '@shared/data/cache/cacheTypes' +import { DefaultRendererPersistCache, DefaultSharedCache } from '@shared/data/cache/cacheSchemas' +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): InferUseCacheValue | undefined => { + const entry = memoryCache.get(key) + if (entry === undefined) { + return undefined + } + if (isExpired(entry)) { + memoryCache.delete(key) notifySubscribers(key) + return undefined } + return entry.value as InferUseCacheValue }), - delete: vi.fn((key: string): boolean => { + set: vi.fn((key: K, value: InferUseCacheValue, 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 DefaultUseSharedCache) { - return DefaultUseSharedCache[key as UseSharedCacheKey] - } - 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): InferUseCacheValue | undefined { + return mockCacheService.get(key) as unknown as InferUseCacheValue | undefined } - set(key: string, value: T): void { - return mockCacheService.set(key, value) + set(key: K, value: InferUseCacheValue, ttl?: number): void { + mockCacheService.set(key, value as unknown as InferUseCacheValue, 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 122a2c03d0..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/apiSchemas' -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/useCache.ts b/tests/__mocks__/renderer/useCache.ts index 33f2f81203..4f323d31d6 100644 --- a/tests/__mocks__/renderer/useCache.ts +++ b/tests/__mocks__/renderer/useCache.ts @@ -3,10 +3,11 @@ import type { RendererPersistCacheSchema, UseCacheKey, UseCacheSchema, - UseSharedCacheKey, - UseSharedCacheSchema + InferUseCacheValue, + 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' /** @@ -14,31 +15,31 @@ import { vi } from 'vitest' * Provides comprehensive mocks for all cache management hooks */ -// Mock cache state storage -const mockMemoryCache = new Map() -const mockSharedCache = 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(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]) => { mockPersistCache.set(key as RendererPersistCacheKey, value) }) -// Mock subscribers for cache changes -const mockMemorySubscribers = new Map void>>() -const mockSharedSubscribers = 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) => { @@ -51,7 +52,7 @@ const notifyMemorySubscribers = (key: UseCacheKey) => { } } -const notifySharedSubscribers = (key: UseSharedCacheKey) => { +const notifySharedSubscribers = (key: SharedCacheKey) => { const subscribers = mockSharedSubscribers.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) }) @@ -108,21 +162,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) }) @@ -185,11 +239,11 @@ export const MockUseCacheUtils = { mockPersistCache.clear() Object.entries(DefaultUseCache).forEach(([key, value]) => { - mockMemoryCache.set(key as UseCacheKey, value) + mockMemoryCache.set(key, 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]) => { @@ -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,14 +267,14 @@ 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) }, /** * 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 +282,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 +306,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]) => { @@ -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,27 +347,27 @@ 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 }) }, /** * 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 +375,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 +422,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()) } diff --git a/tests/__mocks__/renderer/useDataApi.ts b/tests/__mocks__/renderer/useDataApi.ts index 9b85d25550..03d9b71cc0 100644 --- a/tests/__mocks__/renderer/useDataApi.ts +++ b/tests/__mocks__/renderer/useDataApi.ts @@ -1,38 +1,14 @@ -import type { ConcreteApiPaths } from '@shared/data/api/apiSchemas' -import type { PaginatedResponse } from '@shared/data/api/apiTypes' +import type { BodyForPath, QueryParamsForPath, ResponseForPath } from '@shared/data/api/apiPaths' +import type { ConcreteApiPaths, PaginationResponse } 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,125 @@ function createMockDataForPath(path: ConcreteApiPaths): any { /** * Mock useQuery hook + * Matches actual signature: useQuery(path, options?) => { data, isLoading, isRefreshing, 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 + isLoading: boolean + isRefreshing: 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) + isRefreshing: 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, + isLoading: false, + isRefreshing: 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?) => { trigger, isLoading, 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 + refresh?: ConcreteApiPaths[] + optimisticData?: ResponseForPath + swrOptions?: any + } + ): { + trigger: (data?: { + body?: BodyForPath + query?: QueryParamsForPath + }) => Promise> + isLoading: boolean + error: Error | undefined + } => { + const mockTrigger = 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() + isLoading: false, + error: undefined } } ) /** * Mock usePaginatedQuery hook + * Matches actual signature: usePaginatedQuery(path, options?) => { items, total, page, isLoading, isRefreshing, error, hasNext, 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 PaginationResponse + ? { + 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 => { const mockItems = path ? [ { id: 'item1', name: 'Mock Item 1' }, @@ -170,52 +173,63 @@ 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, + isLoading: false, + isRefreshing: false, error: undefined, - isLoading, - isValidating: false, - mutate: vi.fn().mockResolvedValue(mockData), - loadMore: vi.fn(), - isLoadingMore, - hasMore: mockData.hasNext, - items: mockItems - } + hasNext: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } as unknown as ResponseForPath extends PaginationResponse + ? { + 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 } ) /** * 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 +260,28 @@ 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, - error: undefined, isLoading: false, - isValidating: false, + isRefreshing: false, + error: undefined, + 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, + isLoading: false, + isRefreshing: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -274,25 +289,26 @@ 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, - error: undefined, isLoading: true, - isValidating: true, + isRefreshing: false, + error: undefined, + 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, + isLoading: false, + isRefreshing: false, + error: undefined, + refetch: vi.fn(), + mutate: vi.fn().mockResolvedValue(defaultData) + } }) }, @@ -300,77 +316,143 @@ 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, - error, isLoading: false, - isValidating: false, + isRefreshing: false, + error, + 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, + isLoading: false, + isRefreshing: 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() + isLoading: 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 { + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: 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, - error: undefined, - isMutating: false, trigger: vi.fn().mockRejectedValue(error), + isLoading: false, + error: undefined + } + } + // Default behavior + return { + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: 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 { + trigger: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + isLoading: true, + error: undefined + } + } + // Default behavior + return { + trigger: vi.fn().mockResolvedValue({ success: true }), + isLoading: false, + error: undefined + } + }) + }, + + /** + * Set up usePaginatedQuery to return specific items + */ + mockPaginatedData: ( + path: TPath, + items: any[], + options?: { total?: number; page?: number; hasNext?: boolean; hasPrev?: boolean } + ) => { + mockUsePaginatedQuery.mockImplementation((queryPath, _queryOptions) => { + if (queryPath === path) { + return { + items, + total: options?.total ?? items.length, + page: options?.page ?? 1, + isLoading: false, + isRefreshing: false, + error: undefined, + hasNext: options?.hasNext ?? 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, + isLoading: false, + isRefreshing: false, + error: undefined, + hasNext: false, + hasPrev: false, + prevPage: vi.fn(), + nextPage: vi.fn(), + refresh: vi.fn(), + reset: vi.fn() + } }) } } 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, diff --git a/yarn.lock b/yarn.lock index 3ddb60f3f1..9a3935426a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,19 +242,7 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/openai-compatible@npm:1.0.27": - 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.19, @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 @@ -403,7 +391,16 @@ __metadata: languageName: node linkType: hard -"@ant-design/colors@npm:^7.0.0, @ant-design/colors@npm:^7.2.1": +"@ant-design/colors@npm:^7.0.0": + version: 7.2.0 + resolution: "@ant-design/colors@npm:7.2.0" + dependencies: + "@ant-design/fast-color": "npm:^2.0.6" + checksum: 10c0/3c495e2380aa2acc2a1c5e12aa8427f71f146fddb93548129b0aaaa4e06b8b1a8c03e3d394519070092f782ed1b29655b055cb6efbba014f348de4a9176e10ca + languageName: node + linkType: hard + +"@ant-design/colors@npm:^7.2.1": version: 7.2.1 resolution: "@ant-design/colors@npm:7.2.1" dependencies: @@ -1527,7 +1524,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.7, @babel/core@npm:^7.27.4": +"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.7, @babel/core@npm:^7.27.4, @babel/core@npm:^7.27.7, @babel/core@npm:^7.28.0, @babel/core@npm:^7.28.4": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" dependencies: @@ -1550,29 +1547,6 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.27.7, @babel/core@npm:^7.28.0": - version: 7.28.4 - resolution: "@babel/core@npm:7.28.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.4" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.4" - "@babel/types": "npm:^7.28.4" - "@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/ef5a6c3c6bf40d3589b5593f8118cfe2602ce737412629fb6e26d595be2fcbaae0807b43027a5c42ec4fba5b895ff65891f2503b5918c8a3ea3542ab44d4c278 - languageName: node - linkType: hard - "@babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.5": version: 7.28.5 resolution: "@babel/generator@npm:7.28.5" @@ -1586,16 +1560,16 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.28.0, @babel/generator@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/generator@npm:7.28.3" +"@babel/generator@npm:^7.28.0": + version: 7.28.0 + resolution: "@babel/generator@npm:7.28.0" dependencies: - "@babel/parser": "npm:^7.28.3" - "@babel/types": "npm:^7.28.2" + "@babel/parser": "npm:^7.28.0" + "@babel/types": "npm:^7.28.0" "@jridgewell/gen-mapping": "npm:^0.3.12" "@jridgewell/trace-mapping": "npm:^0.3.28" jsesc: "npm:^3.0.2" - checksum: 10c0/0ff58bcf04f8803dcc29479b547b43b9b0b828ec1ee0668e92d79f9e90f388c28589056637c5ff2fd7bcf8d153c990d29c448d449d852bf9d1bc64753ca462bc + checksum: 10c0/1b3d122268ea3df50fde707ad864d9a55c72621357d5cebb972db3dd76859c45810c56e16ad23123f18f80cc2692f5a015d2858361300f0f224a05dc43d36a92 languageName: node linkType: hard @@ -1755,7 +1729,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.6, @babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -1766,7 +1740,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.28.0": +"@babel/parser@npm:^7.25.4, @babel/parser@npm:^7.27.2, @babel/parser@npm:^7.27.5, @babel/parser@npm:^7.27.7, @babel/parser@npm:^7.28.0": version: 7.28.0 resolution: "@babel/parser@npm:7.28.0" dependencies: @@ -1852,7 +1826,14 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.26.7": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0": + version: 7.27.4 + resolution: "@babel/runtime@npm:7.27.4" + checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.26.7": version: 7.28.3 resolution: "@babel/runtime@npm:7.28.3" checksum: 10c0/b360f82c2c5114f2a062d4d143d7b4ec690094764853937110585a9497977aed66c102166d0e404766c274e02a50ffb8f6d77fef7251ecf3f607f0e03e6397bc @@ -1877,7 +1858,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.23.7, @babel/traverse@npm:^7.27.7, @babel/traverse@npm:^7.28.5": +"@babel/traverse@npm:^7.23.7, @babel/traverse@npm:^7.27.7, @babel/traverse@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: @@ -1892,32 +1873,22 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/traverse@npm:7.28.4" +"@babel/traverse@npm:^7.27.1": + version: 7.28.0 + resolution: "@babel/traverse@npm:7.28.0" dependencies: "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" + "@babel/generator": "npm:^7.28.0" "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.4" + "@babel/parser": "npm:^7.28.0" "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.4" + "@babel/types": "npm:^7.28.0" debug: "npm:^4.3.1" - checksum: 10c0/ee678fdd49c9f54a32e07e8455242390d43ce44887cea6567b233fe13907b89240c377e7633478a32c6cf1be0e17c2f7f3b0c59f0666e39c5074cc47b968489c + checksum: 10c0/32794402457827ac558173bcebdcc0e3a18fa339b7c41ca35621f9f645f044534d91bb923ff385f5f960f2e495f56ce18d6c7b0d064d2f0ccb55b285fa6bc7b9 languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.28.4": - version: 7.28.4 - resolution: "@babel/types@npm:7.28.4" - dependencies: - "@babel/helper-string-parser": "npm:^7.27.1" - "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10c0/ac6f909d6191319e08c80efbfac7bd9a25f80cc83b43cd6d82e7233f7a6b9d6e7b90236f3af7400a3f83b576895bcab9188a22b584eb0f224e80e6d4e95f4517 - languageName: node - linkType: hard - -"@babel/types@npm:^7.21.3, @babel/types@npm:^7.23.6, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.28.5": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.6, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" dependencies: @@ -2085,7 +2056,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" @@ -2105,7 +2076,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" @@ -3152,22 +3123,50 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.4.5, @emnapi/core@npm:^1.5.0": - version: 1.6.0 - resolution: "@emnapi/core@npm:1.6.0" +"@emnapi/core@npm:^1.4.3, @emnapi/core@npm:^1.4.5": + version: 1.4.5 + resolution: "@emnapi/core@npm:1.4.5" dependencies: - "@emnapi/wasi-threads": "npm:1.1.0" + "@emnapi/wasi-threads": "npm:1.0.4" tslib: "npm:^2.4.0" - checksum: 10c0/40e384f39104a9f8260e671c0110f8618961afc564afb2e626af79175717a8b5e2d8b2ae3d30194d318a71247e0fc833601666233adfeb244c46cadc06c58a51 + checksum: 10c0/da4a57f65f325d720d0e0d1a9c6618b90c4c43a5027834a110476984e1d47c95ebaed4d316b5dddb9c0ed9a493ffeb97d1934f9677035f336d8a36c1f3b2818f languageName: node linkType: hard -"@emnapi/runtime@npm:^1.4.3, @emnapi/runtime@npm:^1.4.4, @emnapi/runtime@npm:^1.4.5, @emnapi/runtime@npm:^1.5.0": - version: 1.6.0 - resolution: "@emnapi/runtime@npm:1.6.0" +"@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" dependencies: tslib: "npm:^2.4.0" - checksum: 10c0/e3d2452a8fb83bb59fe60dfcf4cff99f9680c13c07dff8ad28639ccc8790151841ef626a67014bde132939bad73dfacc440ade8c3db2ab12693ea9c8ba4d37fb + checksum: 10c0/37a0278be5ac81e918efe36f1449875cbafba947039c53c65a1f8fc238001b866446fc66041513b286baaff5d6f9bec667f5164b3ca481373a8d9cb65bfc984b + 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" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/2c91a53e62f875800baf035c4d42c9c0d18e5afd9a31ca2aac8b435aeaeaeaac386b5b3d0d0e70aa7a5a9852bbe05106b1f680cd82cce03145c703b423d41313 languageName: node linkType: hard @@ -3435,7 +3434,18 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0": +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": + version: 4.6.0 + resolution: "@eslint-community/eslint-utils@npm:4.6.0" + dependencies: + eslint-visitor-keys: "npm:^3.4.3" + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + checksum: 10c0/a64131c1b43021e3a84267f6011fd678a936718097c9be169c37a40ada2c7016bec7d6685ecc88112737d57733f36837bb90d9425ad48d2e2aa351d999d32443 + languageName: node + linkType: hard + +"@eslint-community/eslint-utils@npm:^4.7.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" dependencies: @@ -3648,7 +3658,17 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.6.13, @floating-ui/dom@npm:^1.7.4": +"@floating-ui/dom@npm:^1.0.0, @floating-ui/dom@npm:^1.6.13": + version: 1.7.3 + resolution: "@floating-ui/dom@npm:1.7.3" + dependencies: + "@floating-ui/core": "npm:^1.7.3" + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10c0/cba30e9af1a52fb7cb443ae516d7aec032b33da2fa50914dcb18fc834dc31c71922f5c7653431e70d493f347018b2ce6435c98b3f154d92082345689b4458e59 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.7.4": version: 1.7.4 resolution: "@floating-ui/dom@npm:1.7.4" dependencies: @@ -3677,15 +3697,15 @@ __metadata: languageName: node linkType: hard -"@formatjs/ecma402-abstract@npm:2.3.4": - version: 2.3.4 - resolution: "@formatjs/ecma402-abstract@npm:2.3.4" +"@formatjs/ecma402-abstract@npm:2.3.6": + version: 2.3.6 + resolution: "@formatjs/ecma402-abstract@npm:2.3.6" dependencies: "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/intl-localematcher": "npm:0.6.1" + "@formatjs/intl-localematcher": "npm:0.6.2" decimal.js: "npm:^10.4.3" tslib: "npm:^2.8.0" - checksum: 10c0/2644bc618a34dc610ef9691281eeb45ae6175e6982cf19f1bd140672fc95c748747ce3c85b934649ea7e4a304f7ae0060625fd53d5df76f92ca3acf743e1eb0a + checksum: 10c0/63be2a73d3168bf45ab5d50db58376e852db5652d89511ae6e44f1fa03ad96ebbfe9b06a1dfaa743db06e40eb7f33bd77530b9388289855cca79a0e3fc29eacf languageName: node linkType: hard @@ -3698,33 +3718,33 @@ __metadata: languageName: node linkType: hard -"@formatjs/icu-messageformat-parser@npm:2.11.2": - version: 2.11.2 - resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2" +"@formatjs/icu-messageformat-parser@npm:2.11.4": + version: 2.11.4 + resolution: "@formatjs/icu-messageformat-parser@npm:2.11.4" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" - "@formatjs/icu-skeleton-parser": "npm:1.8.14" + "@formatjs/ecma402-abstract": "npm:2.3.6" + "@formatjs/icu-skeleton-parser": "npm:1.8.16" tslib: "npm:^2.8.0" - checksum: 10c0/a121f2d2c6b36a1632ffd64c3545e2500c8ee0f7fee5db090318c035d635c430ab123faedb5d000f18d9423a7b55fbf670b84e2e2dd72cc307a38aed61d3b2e0 + checksum: 10c0/3ea9e9dae18282881d19a5f88107b6013f514ec8675684ed2c04bee2a174032377858937243e3bd9c9263a470988a3773a53bf8d208a34a78e7843ce66f87f3b languageName: node linkType: hard -"@formatjs/icu-skeleton-parser@npm:1.8.14": - version: 1.8.14 - resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14" +"@formatjs/icu-skeleton-parser@npm:1.8.16": + version: 1.8.16 + resolution: "@formatjs/icu-skeleton-parser@npm:1.8.16" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/ecma402-abstract": "npm:2.3.6" tslib: "npm:^2.8.0" - checksum: 10c0/a1807ed6e90b8a2e8d0e5b5125e6f9a2c057d3cff377fb031d2333af7cfaa6de4ed3a15c23da7294d4c3557f8b28b2163246434a19720f26b5db0497d97e9b58 + checksum: 10c0/6fa1586dc11c925cd8d17e927cc635d238c969a6b7e97282a924376f78622fc25336c407589d19796fb6f8124a0e7765f99ecdb1aac014edcfbe852e7c3d87f3 languageName: node linkType: hard -"@formatjs/intl-localematcher@npm:0.6.1": - version: 0.6.1 - resolution: "@formatjs/intl-localematcher@npm:0.6.1" +"@formatjs/intl-localematcher@npm:0.6.2": + version: 0.6.2 + resolution: "@formatjs/intl-localematcher@npm:0.6.2" dependencies: tslib: "npm:^2.8.0" - checksum: 10c0/bacbedd508519c1bb5ca2620e89dc38f12101be59439aa14aa472b222915b462cb7d679726640f6dcf52a05dd218b5aa27ccd60f2e5010bb96f1d4929848cde0 + checksum: 10c0/22a17a4c67160b6c9f52667914acfb7b79cd6d80630d4ac6d4599ce447cb89d2a64f7d58fa35c3145ddb37fef893f0a45b9a55e663a4eb1f2ae8b10a89fac235 languageName: node linkType: hard @@ -3772,347 +3792,347 @@ __metadata: languageName: node linkType: hard -"@heroui/accordion@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/accordion@npm:2.2.24" +"@heroui/accordion@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/accordion@npm:2.2.26" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/divider": "npm:2.2.20" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/divider": "npm:2.2.21" "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-accordion": "npm:2.2.18" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-stately/tree": "npm:3.9.3" + "@heroui/use-aria-accordion": "npm:2.2.19" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-stately/tree": "npm:3.9.4" "@react-types/accordion": "npm:3.0.0-alpha.26" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/30630eb28368bd347ee7621fa164a004b5d19eb95a0723571c66714d54b3d4df060072878b0c48af7d97f51b402d9d1a8969a26e875698269d16c952b3d36e39 + checksum: 10c0/2952b85326f6cf2c82b70dff2b2c73c0ab3cdd1852d025dcbda02e0d3e2ac2f5b86a27c57f14dc3de06ebc336bc1f04e5905c790a4be1ee65697ca13d1e4bf8c languageName: node linkType: hard -"@heroui/alert@npm:2.2.27": - version: 2.2.27 - resolution: "@heroui/alert@npm:2.2.27" +"@heroui/alert@npm:2.2.29": + version: 2.2.29 + resolution: "@heroui/alert@npm:2.2.29" dependencies: - "@heroui/button": "npm:2.2.27" + "@heroui/button": "npm:2.2.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@react-stately/utils": "npm:3.10.8" + "@react-stately/utils": "npm:3.11.0" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.19" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/9661de6290b694ec5fd0951cfb17913f1308b1c42a439659f4fed1a5e6fad17d549e45712045ea44c15cd7d1b87a22e70e80dd54e9bf53a1b30c949e3df98976 + checksum: 10c0/070af535d23ba3bfbf48a7080872c2c370e884a8d07aff8c8b04e9fef6a84cedce6f4f84777bcd089ac0a1336a80868f60ddcf6692032689bc64150db6d7fba2 languageName: node linkType: hard -"@heroui/aria-utils@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/aria-utils@npm:2.2.24" +"@heroui/aria-utils@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/aria-utils@npm:2.2.26" dependencies: - "@heroui/system": "npm:2.4.23" - "@react-aria/utils": "npm:3.31.0" + "@heroui/system": "npm:2.4.25" + "@react-aria/utils": "npm:3.32.0" "@react-stately/collections": "npm:3.12.8" "@react-types/overlays": "npm:3.9.2" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/77acac4a3f2eeb1f195e1f34e969958367eaa056dbe18c9031960cc8fbae8f2521071eab8711006edcd60ffdf68b1c51bba229a57d6f72c1f700a48717f90d05 + checksum: 10c0/0b45c5bfd00072bef122a3528927e17c82a33eea0e297ecac4169eecd3ccef70aae8e4b157525a2b071a7c04f3276dca1fe610f39cb1f73bb5e52a322a63952c languageName: node linkType: hard -"@heroui/autocomplete@npm:2.3.29": - version: 2.3.29 - resolution: "@heroui/autocomplete@npm:2.3.29" +"@heroui/autocomplete@npm:2.3.31": + version: 2.3.31 + resolution: "@heroui/autocomplete@npm:2.3.31" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/button": "npm:2.2.27" - "@heroui/form": "npm:2.1.27" - "@heroui/input": "npm:2.4.28" - "@heroui/listbox": "npm:2.3.26" - "@heroui/popover": "npm:2.3.27" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/button": "npm:2.2.29" + "@heroui/form": "npm:2.1.29" + "@heroui/input": "npm:2.4.30" + "@heroui/listbox": "npm:2.3.28" + "@heroui/popover": "npm:2.3.29" "@heroui/react-utils": "npm:2.1.14" - "@heroui/scroll-shadow": "npm:2.3.18" + "@heroui/scroll-shadow": "npm:2.3.19" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/combobox": "npm:3.14.0" - "@react-aria/i18n": "npm:3.12.13" - "@react-stately/combobox": "npm:3.12.0" - "@react-types/combobox": "npm:3.13.9" + "@react-aria/combobox": "npm:3.14.1" + "@react-aria/i18n": "npm:3.12.14" + "@react-stately/combobox": "npm:3.12.1" + "@react-types/combobox": "npm:3.13.10" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/242fe1d9ae9260b1f169e1a0ddacd08e42c6f92b9cedc7a0b0f4deda6c260b975795c2c754ed082a57dbc443d50e7998a0df19d35e63a13a8855bdc289266b48 + checksum: 10c0/e7ead8d81d7885d6940ab374f77ef1e460ea9367f6237642857cbaeeafbfe9a5ae86620c264d5b555c4186882065f603daef0509c850f2533a1613471846bdeb languageName: node linkType: hard -"@heroui/avatar@npm:2.2.22": - version: 2.2.22 - resolution: "@heroui/avatar@npm:2.2.22" +"@heroui/avatar@npm:2.2.24": + version: 2.2.24 + resolution: "@heroui/avatar@npm:2.2.24" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-image": "npm:2.1.13" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/bfed66cd9c31991eb4879f2a3bcee1e18149b5bf21b18bca2aae942d8c857fe15f3239928451254f3693527ac89b55317d779d7ef961e9cedced88e571b40e6b + checksum: 10c0/b9bcbfcfd955e5bdc8f6a0594b44a97df71289e6c8607ceae184e35d2b5de5629ae03441a3c371cf57c1ac59c464cf598537f69e151503eb10f94aa8fc0fcf72 languageName: node linkType: hard -"@heroui/badge@npm:2.2.17": - version: 2.2.17 - resolution: "@heroui/badge@npm:2.2.17" +"@heroui/badge@npm:2.2.18": + version: 2.2.18 + resolution: "@heroui/badge@npm:2.2.18" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/ed020878f2ec9b14abdd29473fe0d95b52bf87e759d984a5e8bd2a7c9b8a3deae78f1f2249ce18fcec5a82ee907209dc5b2bd0fbe338d877e805d47d5411d77f + checksum: 10c0/2083ff49aa4ed44aee0492cd39dd5f514cef7c1d0cb9d80bb0d00364ff98c8b122802f59823209216cd7d88d93f823cd2ab0c01aea026dcb76ce9ae4092deeeb languageName: node linkType: hard -"@heroui/breadcrumbs@npm:2.2.22": - version: 2.2.22 - resolution: "@heroui/breadcrumbs@npm:2.2.22" +"@heroui/breadcrumbs@npm:2.2.24": + version: 2.2.24 + resolution: "@heroui/breadcrumbs@npm:2.2.24" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/breadcrumbs": "npm:3.5.29" - "@react-aria/focus": "npm:3.21.2" + "@react-aria/breadcrumbs": "npm:3.5.30" + "@react-aria/focus": "npm:3.21.3" "@react-types/breadcrumbs": "npm:3.7.17" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b14672be475315c559427f27657bf049c4af8655038d935c1aa2951a9ec128afb3d4a225f3a44f40e9ce667d229964960dd5e35564a205c976bdfc63be1a0a0f + checksum: 10c0/3180fc84f2dae551f8cfed4390fa377937639a7e708ba827bc69b062af9eef6f2dd55caac21b87771896fee216c453f2186aa8dae3caa3a049c1d9371c77b8cb languageName: node linkType: hard -"@heroui/button@npm:2.2.27": - version: 2.2.27 - resolution: "@heroui/button@npm:2.2.27" +"@heroui/button@npm:2.2.29": + version: 2.2.29 + resolution: "@heroui/button@npm:2.2.29" dependencies: "@heroui/react-utils": "npm:2.1.14" - "@heroui/ripple": "npm:2.2.20" + "@heroui/ripple": "npm:2.2.21" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/spinner": "npm:2.2.24" - "@heroui/use-aria-button": "npm:2.2.20" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" + "@heroui/spinner": "npm:2.2.26" + "@heroui/use-aria-button": "npm:2.2.21" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/f4b20d336374c55d01eaec5e28db289c35534a230fd78f472aeceafcbf351487857df8198c72b18f19fdf7d13130a137d9f86b62acd1839c8eb9b65ee774d7d9 + checksum: 10c0/bd62e948105e2e92171f3a819e07964616c92cd0ded8b5374cf5a7ec604fe3e6faa585257a3dbcca62a27f1ed69c083587a205d020d598bffb106121c17579b4 languageName: node linkType: hard -"@heroui/calendar@npm:2.2.27": - version: 2.2.27 - resolution: "@heroui/calendar@npm:2.2.27" +"@heroui/calendar@npm:2.2.29": + version: 2.2.29 + resolution: "@heroui/calendar@npm:2.2.29" dependencies: - "@heroui/button": "npm:2.2.27" + "@heroui/button": "npm:2.2.29" "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-button": "npm:2.2.20" - "@internationalized/date": "npm:3.10.0" - "@react-aria/calendar": "npm:3.9.2" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/visually-hidden": "npm:3.8.28" - "@react-stately/calendar": "npm:3.9.0" - "@react-stately/utils": "npm:3.10.8" + "@heroui/use-aria-button": "npm:2.2.21" + "@internationalized/date": "npm:3.10.1" + "@react-aria/calendar": "npm:3.9.3" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/visually-hidden": "npm:3.8.29" + "@react-stately/calendar": "npm:3.9.1" + "@react-stately/utils": "npm:3.11.0" "@react-types/button": "npm:3.14.1" - "@react-types/calendar": "npm:3.8.0" + "@react-types/calendar": "npm:3.8.1" "@react-types/shared": "npm:3.32.1" scroll-into-view-if-needed: "npm:3.0.10" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/2a317a053289315a73b8111f5de51bd96b13786bbf1b2ea28a5daaa38358e03b37374c869caef9269ab577db57922e06451ced33f720fa887b0d3f0ca5e21ea9 + checksum: 10c0/d748f9bea9d46dc863c397a1c70d0b4b9382b1c432dad329fefb68f734ffe06b12dde39f0f2bebaf8dfa7e78e5c412f734c67de369d8b7fc9e193c28847e9434 languageName: node linkType: hard -"@heroui/card@npm:2.2.25": - version: 2.2.25 - resolution: "@heroui/card@npm:2.2.25" +"@heroui/card@npm:2.2.27": + version: 2.2.27 + resolution: "@heroui/card@npm:2.2.27" dependencies: "@heroui/react-utils": "npm:2.1.14" - "@heroui/ripple": "npm:2.2.20" + "@heroui/ripple": "npm:2.2.21" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-button": "npm:2.2.20" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" + "@heroui/use-aria-button": "npm:2.2.21" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/0e5e6e36d2e002c33a5b2991e2d3126e52712848a0b93198281e635c7dec12b028e60b74461cca110b26b24f0f65d35ea2974d25dce545c64c3dc652324f4a18 + checksum: 10c0/617f8afe1b3391b505cbdae8fbd0b9d599ffa024654983e43811c0dd72a384ad079b269bf910dd2ef269df4cc21e8106524142d9f078e43904a1628277e6453d languageName: node linkType: hard -"@heroui/checkbox@npm:2.3.27": - version: 2.3.27 - resolution: "@heroui/checkbox@npm:2.3.27" +"@heroui/checkbox@npm:2.3.29": + version: 2.3.29 + resolution: "@heroui/checkbox@npm:2.3.29" dependencies: - "@heroui/form": "npm:2.1.27" + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-callback-ref": "npm:2.1.8" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/checkbox": "npm:3.16.2" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-stately/checkbox": "npm:3.7.2" - "@react-stately/toggle": "npm:3.9.2" + "@react-aria/checkbox": "npm:3.16.3" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-stately/checkbox": "npm:3.7.3" + "@react-stately/toggle": "npm:3.9.3" "@react-types/checkbox": "npm:3.10.2" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/2180a262c4c71e11677b22bf5763db36cf218faa1cc52af1824a4cdf477ab3de47de12d678ed5b7bf300157874485e80b443229a7927863f5bc5bf6050933be0 + checksum: 10c0/70e583293f09d69b9d4f20460bd178bdf3ccaa15cea39ebf3de2dc5f158d98defb6e2994523c8fa75f282dcf05b16dfbe8a4eed8deab0f34b62e683695c83e95 languageName: node linkType: hard -"@heroui/chip@npm:2.2.22": +"@heroui/chip@npm:2.2.24": + version: 2.2.24 + resolution: "@heroui/chip@npm:2.2.24" + dependencies: + "@heroui/react-utils": "npm:2.1.14" + "@heroui/shared-icons": "npm:2.1.10" + "@heroui/shared-utils": "npm:2.1.12" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + peerDependencies: + "@heroui/system": ">=2.4.18" + "@heroui/theme": ">=2.4.24" + react: ">=18 || >=19.0.0-rc.0" + react-dom: ">=18 || >=19.0.0-rc.0" + checksum: 10c0/8122ca62a440beede9123734c31b411b8cb0ddf74e04e37fa656b70c7b79ba51193f6497124797c98e92104de3fdb278fedfd2ec3e89eea495b8a1b2cb5c8a82 + languageName: node + linkType: hard + +"@heroui/code@npm:2.2.22": version: 2.2.22 - resolution: "@heroui/chip@npm:2.2.22" + resolution: "@heroui/code@npm:2.2.22" dependencies: "@heroui/react-utils": "npm:2.1.14" - "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" + "@heroui/system-rsc": "npm:2.3.21" peerDependencies: - "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/586880c7fe5af6ff1cf2b73e57fb2399ed8ac0081555ad644a4811fccdd89c8405bed05c6de38f11f5342e077aff2f0d1c6aa30035caff658d7bde473e63bb5f + checksum: 10c0/86267101c6dacd27230db7369cea0d53a35b19a539f029a6c58b0cbe04cc53ec0aa588bc9b42b9d4f72f8c5ef71764d451a22fe34f0d88c01f228ec3a22897ec languageName: node linkType: hard -"@heroui/code@npm:2.2.21": - version: 2.2.21 - resolution: "@heroui/code@npm:2.2.21" +"@heroui/date-input@npm:2.3.29": + version: 2.3.29 + resolution: "@heroui/date-input@npm:2.3.29" dependencies: + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/system-rsc": "npm:2.3.20" - peerDependencies: - "@heroui/theme": ">=2.4.17" - react: ">=18 || >=19.0.0-rc.0" - react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/6de6e7547fd24bb8bdb909fa09c92dd54b77e1849c5d462f14e2cb07f3e131ebd982d13590b781f2a2b49b008e3815983c64b1dd3568b28ed15e26f5449f0cef - languageName: node - linkType: hard - -"@heroui/date-input@npm:2.3.27": - version: 2.3.27 - resolution: "@heroui/date-input@npm:2.3.27" - dependencies: - "@heroui/form": "npm:2.1.27" - "@heroui/react-utils": "npm:2.1.14" - "@heroui/shared-utils": "npm:2.1.12" - "@internationalized/date": "npm:3.10.0" - "@react-aria/datepicker": "npm:3.15.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-stately/datepicker": "npm:3.15.2" - "@react-types/datepicker": "npm:3.13.2" + "@internationalized/date": "npm:3.10.1" + "@react-aria/datepicker": "npm:3.15.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-stately/datepicker": "npm:3.15.3" + "@react-types/datepicker": "npm:3.13.3" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/1a55d59a844c88a3f3876d9a98502c0fa77216e817e29a11e9c8c0d330d7d31164d7046952f9e30550ebc430c5b5b192b2b5be86eee32a957b5eee72fb3d977c + checksum: 10c0/658697de744a9750de7bff438d5eab7e74c00ab573121f6432c56fb81929b2e1c4ad373d71bd4a98965e71ce1be8fb24da8f119aec7873ca8bf820343c85dfaa languageName: node linkType: hard -"@heroui/date-picker@npm:2.3.28": - version: 2.3.28 - resolution: "@heroui/date-picker@npm:2.3.28" +"@heroui/date-picker@npm:2.3.30": + version: 2.3.30 + resolution: "@heroui/date-picker@npm:2.3.30" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/button": "npm:2.2.27" - "@heroui/calendar": "npm:2.2.27" - "@heroui/date-input": "npm:2.3.27" - "@heroui/form": "npm:2.1.27" - "@heroui/popover": "npm:2.3.27" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/button": "npm:2.2.29" + "@heroui/calendar": "npm:2.2.29" + "@heroui/date-input": "npm:2.3.29" + "@heroui/form": "npm:2.1.29" + "@heroui/popover": "npm:2.3.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@internationalized/date": "npm:3.10.0" - "@react-aria/datepicker": "npm:3.15.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-stately/datepicker": "npm:3.15.2" - "@react-stately/utils": "npm:3.10.8" - "@react-types/datepicker": "npm:3.13.2" + "@internationalized/date": "npm:3.10.1" + "@react-aria/datepicker": "npm:3.15.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-stately/datepicker": "npm:3.15.3" + "@react-stately/utils": "npm:3.11.0" + "@react-types/datepicker": "npm:3.13.3" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/c3b588a1a5b57237344edb64e02aa05db220df1621bc111d989c990a66403a0f474f96e298d88e6d973888b741cd59a40a5ec68ae59eed3a78394a7575a4dd11 + checksum: 10c0/e455282cf2280340b4e982d492c21f8452f2a54124b8b329d3c616ab82b84b3e5494d14d19ff73e236b40e128c7361cae777c2e5b8e0aedc75ffc72cff477aac languageName: node linkType: hard -"@heroui/divider@npm:2.2.20": - version: 2.2.20 - resolution: "@heroui/divider@npm:2.2.20" +"@heroui/divider@npm:2.2.21": + version: 2.2.21 + resolution: "@heroui/divider@npm:2.2.21" dependencies: "@heroui/react-rsc-utils": "npm:2.1.9" - "@heroui/system-rsc": "npm:2.3.20" + "@heroui/system-rsc": "npm:2.3.21" "@react-types/shared": "npm:3.32.1" peerDependencies: - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/dda0810063b5553a6db9062ba8377cbafaeebc5a87bf73e059f8e37836cf8e2f5c9aac451885e9767e02158498cf843ee1e9832dfe2addd6e5e805532f2275d2 + checksum: 10c0/9293a4d4af5bfe3e4b16ab3b302463cc64a2403f0171b60eb7fa8d479d748c0b88d1b93133be0abdd2e81ce009647a7b9022c6d3f7f13f6766fb8455f8c387ab languageName: node linkType: hard @@ -4125,395 +4145,395 @@ __metadata: languageName: node linkType: hard -"@heroui/drawer@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/drawer@npm:2.2.24" +"@heroui/drawer@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/drawer@npm:2.2.26" dependencies: - "@heroui/framer-utils": "npm:2.1.23" - "@heroui/modal": "npm:2.2.24" + "@heroui/framer-utils": "npm:2.1.25" + "@heroui/modal": "npm:2.2.26" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/cedf6a4f8d1a875fe2b3541e46098eeb6f1151affe054c14b48c42caf1a7184b8ed65e214755d874097349dfd4943a4c6d9b1b86d538ff63b5f8040799a3949b + checksum: 10c0/bd66b16204941eeac7ad4b087a6f8dec869fc78010f55746eef04e449a99291d27bcc4d4aac7698adb453aa17d4a307d5b46e37784a2b6e4dfb91d57db263dd3 languageName: node linkType: hard -"@heroui/dropdown@npm:2.3.27": - version: 2.3.27 - resolution: "@heroui/dropdown@npm:2.3.27" +"@heroui/dropdown@npm:2.3.29": + version: 2.3.29 + resolution: "@heroui/dropdown@npm:2.3.29" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/menu": "npm:2.2.26" - "@heroui/popover": "npm:2.3.27" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/menu": "npm:2.2.28" + "@heroui/popover": "npm:2.3.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/menu": "npm:3.19.3" - "@react-stately/menu": "npm:3.9.8" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/menu": "npm:3.19.4" + "@react-stately/menu": "npm:3.9.9" "@react-types/menu": "npm:3.10.5" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b9e669b915a0edefa2e71753e52c387afb0edcd57a6cffa9327136e9dc90d8ad53896368ac317b68fb5a28ff437936b18b814f1d807cb9a7df18ca1616f2c877 + checksum: 10c0/2c1f805b940f95fa6c55774c61f8f385ac388cc84f245e86e942abac1cd822ee4d6db4b718ce4b5169798724525f83bd0756593a25c4c054802d73845075dfd8 languageName: node linkType: hard -"@heroui/form@npm:2.1.27": - version: 2.1.27 - resolution: "@heroui/form@npm:2.1.27" +"@heroui/form@npm:2.1.29": + version: 2.1.29 + resolution: "@heroui/form@npm:2.1.29" dependencies: "@heroui/shared-utils": "npm:2.1.12" - "@heroui/system": "npm:2.4.23" - "@heroui/theme": "npm:2.4.23" + "@heroui/system": "npm:2.4.25" + "@heroui/theme": "npm:2.4.25" "@react-stately/form": "npm:3.2.2" "@react-types/form": "npm:3.7.16" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18" react-dom: ">=18" - checksum: 10c0/805e3b665ba2761a1d24a28b6fb6107cdd51a41dc719b3f19e50ef9a5e3a459d3cadb41c01e6f5387d8abb66aa8585b51198352c4b6cd561918a360c118f03a0 + checksum: 10c0/f31330d465841ec85ec73b74295bd198e3e2363d39b56a09baaece5c70bc0d453c2439f4d6dddccb8deff5aa4f4e446756685d7b23cd9f66d2dfac05468f4d7e languageName: node linkType: hard -"@heroui/framer-utils@npm:2.1.23": - version: 2.1.23 - resolution: "@heroui/framer-utils@npm:2.1.23" +"@heroui/framer-utils@npm:2.1.25": + version: 2.1.25 + resolution: "@heroui/framer-utils@npm:2.1.25" dependencies: - "@heroui/system": "npm:2.4.23" + "@heroui/system": "npm:2.4.25" "@heroui/use-measure": "npm:2.1.8" peerDependencies: framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/38ad4f87a345576e7b3d0ac53789b194c8e6057fe3ddfae4846c58dcd36b4932836db4a61b89d024285401b15f76ba1ac9035a03d47f80f7fabd1085e1d0247d + checksum: 10c0/012ba06f34b42ec8b69468f40c7e788bc82d208361d46a86ada6b42f005b271d3acd9b8317cd13c771b7e37dec78cc01d9be5d02b6370b8375de78b6cb5030a5 languageName: node linkType: hard -"@heroui/image@npm:2.2.17": - version: 2.2.17 - resolution: "@heroui/image@npm:2.2.17" +"@heroui/image@npm:2.2.18": + version: 2.2.18 + resolution: "@heroui/image@npm:2.2.18" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-image": "npm:2.1.13" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/59e16470743d067189fea71fdfc5068bcd6952cd5e2cefe05e0ab7e45701927e6e591a3325c5dcc5783ae95ef32b5112987bde4131ef5f915b2d5a0e8252ef23 + checksum: 10c0/e4bfaa72e8f42d910d80bb5152e0e816746aec67dbe7201007e0b4c21bedb1c6265a10463ea59d07bbe06a060b1491c74f27f929ab7b8d30e28e507e999868e8 languageName: node linkType: hard -"@heroui/input-otp@npm:2.1.27": - version: 2.1.27 - resolution: "@heroui/input-otp@npm:2.1.27" +"@heroui/input-otp@npm:2.1.29": + version: 2.1.29 + resolution: "@heroui/input-otp@npm:2.1.29" dependencies: - "@heroui/form": "npm:2.1.27" + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-form-reset": "npm:2.0.1" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/form": "npm:3.1.2" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/form": "npm:3.1.3" "@react-stately/form": "npm:3.2.2" - "@react-stately/utils": "npm:3.10.8" + "@react-stately/utils": "npm:3.11.0" "@react-types/textfield": "npm:3.12.6" input-otp: "npm:1.4.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18" react-dom: ">=18" - checksum: 10c0/1376e40450105f6a3e3b0593396ec10fcde0adeadcf33fae6f6af96ae6ddc88c2f651852ee2510ac9116469d65f7124aac564eaf0d3edfc343e9981686ac0feb + checksum: 10c0/bfc8102a90f4b423570953b40543225224d94d5f90a685b2a133d54d163211bb998fedd6b7e289124b561463af7331f82ef96a8b9eb8904f96207fed02be8529 languageName: node linkType: hard -"@heroui/input@npm:2.4.28": - version: 2.4.28 - resolution: "@heroui/input@npm:2.4.28" +"@heroui/input@npm:2.4.30": + version: 2.4.30 + resolution: "@heroui/input@npm:2.4.30" dependencies: - "@heroui/form": "npm:2.1.27" + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/textfield": "npm:3.18.2" - "@react-stately/utils": "npm:3.10.8" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/textfield": "npm:3.18.3" + "@react-stately/utils": "npm:3.11.0" "@react-types/shared": "npm:3.32.1" "@react-types/textfield": "npm:3.12.6" react-textarea-autosize: "npm:^8.5.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.19" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b7334e39f3aed61808c9df9b9ddc217768f7b1689ccbfe514bb2ceb3e0e547c23de816bdab8ea88963e3708f68624617c972b330386ebd9409557c082aa291a4 + checksum: 10c0/845fced954fe51ed23084bfeb1476d4b7e50a37ef949b84401e1a9da5e70b903c53a45e5762f20121de48bfa3695ff7d851f18413419a5e1681916bfbd7eed43 languageName: node linkType: hard -"@heroui/kbd@npm:2.2.22": - version: 2.2.22 - resolution: "@heroui/kbd@npm:2.2.22" +"@heroui/kbd@npm:2.2.23": + version: 2.2.23 + resolution: "@heroui/kbd@npm:2.2.23" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/system-rsc": "npm:2.3.20" + "@heroui/system-rsc": "npm:2.3.21" peerDependencies: - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/17680cb4528a163f752b9d0af5e0a6c21a07362a2ce2ffaa9e84b6447514d936c9ebfdb501a761904546abf07df14ac719eb8f8a57677a4b842ea574ea4808ca + checksum: 10c0/9c7643547f6b794318e97e0eec7ceaab39debbc6fdf56fc00676c40a442a936e6a914ea340dbae042d9b8ebb5e4fceedcaeeed996f6526c1f1223726f2b68ecf languageName: node linkType: hard -"@heroui/link@npm:2.2.23": - version: 2.2.23 - resolution: "@heroui/link@npm:2.2.23" +"@heroui/link@npm:2.2.25": + version: 2.2.25 + resolution: "@heroui/link@npm:2.2.25" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-link": "npm:2.2.21" - "@react-aria/focus": "npm:3.21.2" + "@heroui/use-aria-link": "npm:2.2.22" + "@react-aria/focus": "npm:3.21.3" "@react-types/link": "npm:3.6.5" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/3ed8f45b4894e9b7401a440fdb72b921e14e1a130beeba47e1e02cf59a7efe204bb4c85c9875003731a8f4b020c7f21e5033ae193922879487ac2f17b21f2646 + checksum: 10c0/6bfa2daf1f08e80a455ecad348c0a967216db0b78200d63e1bd49bb045a28069473d56879e5627618e5d12203ba02e8b78c6c44057f23f1441b906951a71e78e languageName: node linkType: hard -"@heroui/listbox@npm:2.3.26": - version: 2.3.26 - resolution: "@heroui/listbox@npm:2.3.26" +"@heroui/listbox@npm:2.3.28": + version: 2.3.28 + resolution: "@heroui/listbox@npm:2.3.28" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/divider": "npm:2.2.20" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/divider": "npm:2.2.21" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-is-mobile": "npm:2.2.12" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/listbox": "npm:3.15.0" - "@react-stately/list": "npm:3.13.1" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/listbox": "npm:3.15.1" + "@react-stately/list": "npm:3.13.2" "@react-types/shared": "npm:3.32.1" "@tanstack/react-virtual": "npm:3.11.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/083b8acc99778f4e40251fdebd25ba0ac27d948c9f5959cc55165fcd0ce8a47e28917220d4f67ec87674644c2ca794b0ce6a2208c6c06a82d98588ae76f68886 + checksum: 10c0/38f1d03627ca52e6e4b565d006721f0e3291b1d3be76a086400655c68e57f8380ed0929695eb806ac3f7517f15d83c76280c4bf9a9b4693998ee016e0ee60032 languageName: node linkType: hard -"@heroui/menu@npm:2.2.26": - version: 2.2.26 - resolution: "@heroui/menu@npm:2.2.26" +"@heroui/menu@npm:2.2.28": + version: 2.2.28 + resolution: "@heroui/menu@npm:2.2.28" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/divider": "npm:2.2.20" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/divider": "npm:2.2.21" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-is-mobile": "npm:2.2.12" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/menu": "npm:3.19.3" - "@react-stately/tree": "npm:3.9.3" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/menu": "npm:3.19.4" + "@react-stately/tree": "npm:3.9.4" "@react-types/menu": "npm:3.10.5" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b07eac25fc82b40274d764ae63c4b4d10b108aa6a2449b538fb4dbc11b837e7aa6e632e5d55d35728ed86f330c13919fcd1565632af7e0b04bd46b1800a55fb4 + checksum: 10c0/c262a2149b0b8d1514688223b8d7e83f14931aa3de497d14f4a37b5b49448ddf9061651a8075bbdb67e29d606a21c7335fd01dd30618ae3e05e319e2fb07eaf1 languageName: node linkType: hard -"@heroui/modal@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/modal@npm:2.2.24" +"@heroui/modal@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/modal@npm:2.2.26" dependencies: "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-button": "npm:2.2.20" - "@heroui/use-aria-modal-overlay": "npm:2.2.19" - "@heroui/use-disclosure": "npm:2.2.17" - "@heroui/use-draggable": "npm:2.1.18" + "@heroui/use-aria-button": "npm:2.2.21" + "@heroui/use-aria-modal-overlay": "npm:2.2.20" + "@heroui/use-disclosure": "npm:2.2.18" + "@heroui/use-draggable": "npm:2.1.19" "@heroui/use-viewport-size": "npm:2.0.1" - "@react-aria/dialog": "npm:3.5.31" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/overlays": "npm:3.30.0" - "@react-stately/overlays": "npm:3.6.20" + "@react-aria/dialog": "npm:3.5.32" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/overlays": "npm:3.31.0" + "@react-stately/overlays": "npm:3.6.21" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/10e8d24a8354fde35db9a1c636686377b1c3cb42f184429a55f6c00f2c6bbed52291236ead4258899cabb2dde431e51cd1ac4a1e6339b7ece9cbf1724a8b7de4 + checksum: 10c0/8f4c24f1a286e7b3fa375700363713a47fe0bcf0827955308bcc2157e8854164001ef3d5d38a09b56e1e2da85b6dc65687ba002f2335c18a2f319c7a09e44ac0 languageName: node linkType: hard -"@heroui/navbar@npm:2.2.25": - version: 2.2.25 - resolution: "@heroui/navbar@npm:2.2.25" +"@heroui/navbar@npm:2.2.27": + version: 2.2.27 + resolution: "@heroui/navbar@npm:2.2.27" dependencies: "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-resize": "npm:2.1.8" "@heroui/use-scroll-position": "npm:2.1.8" - "@react-aria/button": "npm:3.14.2" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/overlays": "npm:3.30.0" - "@react-stately/toggle": "npm:3.9.2" - "@react-stately/utils": "npm:3.10.8" + "@react-aria/button": "npm:3.14.3" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/overlays": "npm:3.31.0" + "@react-stately/toggle": "npm:3.9.3" + "@react-stately/utils": "npm:3.11.0" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/dd40240dac1e91b446a5609601b2a2946f430ed04f7f3a09f98a462b017a75e969503640b194a88f1b43a746d44940dd4c62cdace7dcc9b52c66355d49dce9e1 + checksum: 10c0/26f53942d3ebb680d4a5df819addf7295b14176232f26a27f45e039589404a8e471ffc43e7952ae3e9fc206c4c9d6832a5394b489c0aa185fdea52775bb38f3d languageName: node linkType: hard -"@heroui/number-input@npm:2.0.18": - version: 2.0.18 - resolution: "@heroui/number-input@npm:2.0.18" +"@heroui/number-input@npm:2.0.20": + version: 2.0.20 + resolution: "@heroui/number-input@npm:2.0.20" dependencies: - "@heroui/button": "npm:2.2.27" - "@heroui/form": "npm:2.1.27" + "@heroui/button": "npm:2.2.29" + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/numberfield": "npm:3.12.2" - "@react-stately/numberfield": "npm:3.10.2" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/numberfield": "npm:3.12.3" + "@react-stately/numberfield": "npm:3.10.3" "@react-types/button": "npm:3.14.1" - "@react-types/numberfield": "npm:3.8.15" + "@react-types/numberfield": "npm:3.8.16" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.19" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/f0bbd05d13d5abe7b33ee7281633381302a8b5ec6b7a4dfa375326fee0deced487036e75e7ddcda6b7d990ee63e2cfbb91901f79879c06ff1cd0066ed9d12f45 + checksum: 10c0/dcce15410ca4cf8ba93ea281889f0d6bfa2e7da1828718efd0e42915f62b79bea51b4796367928632fbeeee0537cbc3992bed17b9c07337606bf6726017126f2 languageName: node linkType: hard -"@heroui/pagination@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/pagination@npm:2.2.24" +"@heroui/pagination@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/pagination@npm:2.2.26" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-intersection-observer": "npm:2.2.14" - "@heroui/use-pagination": "npm:2.2.18" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/utils": "npm:3.31.0" + "@heroui/use-pagination": "npm:2.2.19" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/utils": "npm:3.32.0" scroll-into-view-if-needed: "npm:3.0.10" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/9e4766fb1a062548b0c184b203a0846a540f34901eed540c22df7085bbb96ce124635590f09aaa6fffabeee18ec5bec9492615cc36904cc40683abd765cd4c28 + checksum: 10c0/47fc1fba686301f2af13d0228c90f46ae947c50637a72da1dd2bee2e73398d1a2ebcffd047dbb2916014821c44421a87b2a8a4213cebbb5b7be2d8fd140a655b languageName: node linkType: hard -"@heroui/popover@npm:2.3.27": - version: 2.3.27 - resolution: "@heroui/popover@npm:2.3.27" +"@heroui/popover@npm:2.3.29": + version: 2.3.29 + resolution: "@heroui/popover@npm:2.3.29" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/button": "npm:2.2.27" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/button": "npm:2.2.29" "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-button": "npm:2.2.20" - "@heroui/use-aria-overlay": "npm:2.0.4" + "@heroui/use-aria-button": "npm:2.2.21" + "@heroui/use-aria-overlay": "npm:2.0.5" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/dialog": "npm:3.5.31" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/overlays": "npm:3.30.0" - "@react-stately/overlays": "npm:3.6.20" + "@react-aria/dialog": "npm:3.5.32" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/overlays": "npm:3.31.0" + "@react-stately/overlays": "npm:3.6.21" "@react-types/overlays": "npm:3.9.2" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/20337f9e5307207240a3f1df517ccb93a5f2bddee8d5e2b3f0a564febcf28ac87505893d12390c7f98d165401a488520ed4245911bd4fda8a8e087c286f7d6c9 + checksum: 10c0/3e935772800782b9936de7a278f29329a6d20b8333bb5a906551f817d057689674fccf9933e52b0a04c37338fcd29330b0ec34649f103a526dc4fce60b2fb55a languageName: node linkType: hard -"@heroui/progress@npm:2.2.22": - version: 2.2.22 - resolution: "@heroui/progress@npm:2.2.22" +"@heroui/progress@npm:2.2.24": + version: 2.2.24 + resolution: "@heroui/progress@npm:2.2.24" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-is-mounted": "npm:2.1.8" - "@react-aria/progress": "npm:3.4.27" + "@react-aria/progress": "npm:3.4.28" "@react-types/progress": "npm:3.5.16" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/def8bd09e3d966372910c77c2933c642f0c2ebdebcbb12c655fb1680fc8b17070f8b52f12917e2a5944614df7765650860dfe104c575e4a3bbb543fd5de7888f + checksum: 10c0/c98369606ef88ae6c444a8a90d2af69e5dd86c9be43326ff1210bef77aaeae7159164232b722d57d9bfecbebc7088ad89f48b60e391baca3daa38a8103d826c7 languageName: node linkType: hard -"@heroui/radio@npm:2.3.27": - version: 2.3.27 - resolution: "@heroui/radio@npm:2.3.27" +"@heroui/radio@npm:2.3.29": + version: 2.3.29 + resolution: "@heroui/radio@npm:2.3.29" dependencies: - "@heroui/form": "npm:2.1.27" + "@heroui/form": "npm:2.1.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/radio": "npm:3.12.2" - "@react-aria/visually-hidden": "npm:3.8.28" - "@react-stately/radio": "npm:3.11.2" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/radio": "npm:3.12.3" + "@react-aria/visually-hidden": "npm:3.8.29" + "@react-stately/radio": "npm:3.11.3" "@react-types/radio": "npm:3.9.2" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/e20c6ad2495edde9699728c53a627b25d5ab6a6917b17d31bccc8cd1241188e45d07592c8385a5d432abd872fff126bc9c7c72d77f0b85a21c6d2da4c0578225 + checksum: 10c0/d7cf5ab165d6ae1ff1e2abb8e20b8d981574cc70594fa424cfac8c53fbc507e76e810e799cc354727fa2eceaa57dd76a7bc32b1a0972841f5c823d04423a21de languageName: node linkType: hard @@ -4539,129 +4559,129 @@ __metadata: linkType: hard "@heroui/react@npm:^2.8.4": - version: 2.8.5 - resolution: "@heroui/react@npm:2.8.5" + version: 2.8.7 + resolution: "@heroui/react@npm:2.8.7" dependencies: - "@heroui/accordion": "npm:2.2.24" - "@heroui/alert": "npm:2.2.27" - "@heroui/autocomplete": "npm:2.3.29" - "@heroui/avatar": "npm:2.2.22" - "@heroui/badge": "npm:2.2.17" - "@heroui/breadcrumbs": "npm:2.2.22" - "@heroui/button": "npm:2.2.27" - "@heroui/calendar": "npm:2.2.27" - "@heroui/card": "npm:2.2.25" - "@heroui/checkbox": "npm:2.3.27" - "@heroui/chip": "npm:2.2.22" - "@heroui/code": "npm:2.2.21" - "@heroui/date-input": "npm:2.3.27" - "@heroui/date-picker": "npm:2.3.28" - "@heroui/divider": "npm:2.2.20" - "@heroui/drawer": "npm:2.2.24" - "@heroui/dropdown": "npm:2.3.27" - "@heroui/form": "npm:2.1.27" - "@heroui/framer-utils": "npm:2.1.23" - "@heroui/image": "npm:2.2.17" - "@heroui/input": "npm:2.4.28" - "@heroui/input-otp": "npm:2.1.27" - "@heroui/kbd": "npm:2.2.22" - "@heroui/link": "npm:2.2.23" - "@heroui/listbox": "npm:2.3.26" - "@heroui/menu": "npm:2.2.26" - "@heroui/modal": "npm:2.2.24" - "@heroui/navbar": "npm:2.2.25" - "@heroui/number-input": "npm:2.0.18" - "@heroui/pagination": "npm:2.2.24" - "@heroui/popover": "npm:2.3.27" - "@heroui/progress": "npm:2.2.22" - "@heroui/radio": "npm:2.3.27" - "@heroui/ripple": "npm:2.2.20" - "@heroui/scroll-shadow": "npm:2.3.18" - "@heroui/select": "npm:2.4.28" - "@heroui/skeleton": "npm:2.2.17" - "@heroui/slider": "npm:2.4.24" - "@heroui/snippet": "npm:2.2.28" - "@heroui/spacer": "npm:2.2.21" - "@heroui/spinner": "npm:2.2.24" - "@heroui/switch": "npm:2.2.24" - "@heroui/system": "npm:2.4.23" - "@heroui/table": "npm:2.2.27" - "@heroui/tabs": "npm:2.2.24" - "@heroui/theme": "npm:2.4.23" - "@heroui/toast": "npm:2.0.17" - "@heroui/tooltip": "npm:2.2.24" - "@heroui/user": "npm:2.2.22" - "@react-aria/visually-hidden": "npm:3.8.28" + "@heroui/accordion": "npm:2.2.26" + "@heroui/alert": "npm:2.2.29" + "@heroui/autocomplete": "npm:2.3.31" + "@heroui/avatar": "npm:2.2.24" + "@heroui/badge": "npm:2.2.18" + "@heroui/breadcrumbs": "npm:2.2.24" + "@heroui/button": "npm:2.2.29" + "@heroui/calendar": "npm:2.2.29" + "@heroui/card": "npm:2.2.27" + "@heroui/checkbox": "npm:2.3.29" + "@heroui/chip": "npm:2.2.24" + "@heroui/code": "npm:2.2.22" + "@heroui/date-input": "npm:2.3.29" + "@heroui/date-picker": "npm:2.3.30" + "@heroui/divider": "npm:2.2.21" + "@heroui/drawer": "npm:2.2.26" + "@heroui/dropdown": "npm:2.3.29" + "@heroui/form": "npm:2.1.29" + "@heroui/framer-utils": "npm:2.1.25" + "@heroui/image": "npm:2.2.18" + "@heroui/input": "npm:2.4.30" + "@heroui/input-otp": "npm:2.1.29" + "@heroui/kbd": "npm:2.2.23" + "@heroui/link": "npm:2.2.25" + "@heroui/listbox": "npm:2.3.28" + "@heroui/menu": "npm:2.2.28" + "@heroui/modal": "npm:2.2.26" + "@heroui/navbar": "npm:2.2.27" + "@heroui/number-input": "npm:2.0.20" + "@heroui/pagination": "npm:2.2.26" + "@heroui/popover": "npm:2.3.29" + "@heroui/progress": "npm:2.2.24" + "@heroui/radio": "npm:2.3.29" + "@heroui/ripple": "npm:2.2.21" + "@heroui/scroll-shadow": "npm:2.3.19" + "@heroui/select": "npm:2.4.30" + "@heroui/skeleton": "npm:2.2.18" + "@heroui/slider": "npm:2.4.26" + "@heroui/snippet": "npm:2.2.30" + "@heroui/spacer": "npm:2.2.22" + "@heroui/spinner": "npm:2.2.26" + "@heroui/switch": "npm:2.2.26" + "@heroui/system": "npm:2.4.25" + "@heroui/table": "npm:2.2.29" + "@heroui/tabs": "npm:2.2.26" + "@heroui/theme": "npm:2.4.25" + "@heroui/toast": "npm:2.0.19" + "@heroui/tooltip": "npm:2.2.26" + "@heroui/user": "npm:2.2.24" + "@react-aria/visually-hidden": "npm:3.8.29" peerDependencies: framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/9117b3d2e45346bec8777bf658a0e28a4c1873a7bea189d208d7f68005174fe074e6526862f2547189916bf9628b9292accb2325a0991bc80796b94c611f93c6 + checksum: 10c0/74ab92ae7cc3d3183ac14d36722481714a8efbc822a23cd31075d78b0c493915d24d4aaa52206452be16747a9bbf11489ea1e99db0e178a6f749bc4b7957f4e7 languageName: node linkType: hard -"@heroui/ripple@npm:2.2.20": - version: 2.2.20 - resolution: "@heroui/ripple@npm:2.2.20" +"@heroui/ripple@npm:2.2.21": + version: 2.2.21 + resolution: "@heroui/ripple@npm:2.2.21" dependencies: "@heroui/dom-animation": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/baba2bf79f71acf0294036e85b44db0ac320d41b93d819b88275356854a7b7ddfedc12fed9e47f4deb4a8279c9fb8016240c9ee6a5ba1e252890c3c5314f279e + checksum: 10c0/493dde0e76517d3c1f5c98afe47d721c950bd952ac06edd384cf3e99c4ef892be8442642e60bb078b7c8db8acbe1ff035f2834bfeebbf02b3c9c535890d15377 languageName: node linkType: hard -"@heroui/scroll-shadow@npm:2.3.18": - version: 2.3.18 - resolution: "@heroui/scroll-shadow@npm:2.3.18" +"@heroui/scroll-shadow@npm:2.3.19": + version: 2.3.19 + resolution: "@heroui/scroll-shadow@npm:2.3.19" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-data-scroll-overflow": "npm:2.2.13" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/ed2962a19be425e858ad2a0939a24f5d3aa8dfb985c85a7a2829ed0290b9dc5c5ed922c9c80bf6917ccf46be2498743403b102d5eb093185cc80ef415bd49db5 + checksum: 10c0/a03d6ed449fa903514f843ec227b290eb977cafe0815da2150fdcdca9d449417c3e3835f8de5132ff6740d54adc97263fdb7b57a29cefad18ad0419b1bf9b281 languageName: node linkType: hard -"@heroui/select@npm:2.4.28": - version: 2.4.28 - resolution: "@heroui/select@npm:2.4.28" +"@heroui/select@npm:2.4.30": + version: 2.4.30 + resolution: "@heroui/select@npm:2.4.30" dependencies: - "@heroui/aria-utils": "npm:2.2.24" - "@heroui/form": "npm:2.1.27" - "@heroui/listbox": "npm:2.3.26" - "@heroui/popover": "npm:2.3.27" + "@heroui/aria-utils": "npm:2.2.26" + "@heroui/form": "npm:2.1.29" + "@heroui/listbox": "npm:2.3.28" + "@heroui/popover": "npm:2.3.29" "@heroui/react-utils": "npm:2.1.14" - "@heroui/scroll-shadow": "npm:2.3.18" + "@heroui/scroll-shadow": "npm:2.3.19" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/spinner": "npm:2.2.24" - "@heroui/use-aria-button": "npm:2.2.20" - "@heroui/use-aria-multiselect": "npm:2.4.19" + "@heroui/spinner": "npm:2.2.26" + "@heroui/use-aria-button": "npm:2.2.21" + "@heroui/use-aria-multiselect": "npm:2.4.20" "@heroui/use-form-reset": "npm:2.0.1" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/form": "npm:3.1.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/overlays": "npm:3.30.0" - "@react-aria/visually-hidden": "npm:3.8.28" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/form": "npm:3.1.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/overlays": "npm:3.31.0" + "@react-aria/visually-hidden": "npm:3.8.29" "@react-types/shared": "npm:3.32.1" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/4b8e9674acd09ab820e09d97091ffee38db03c5276f668eb827fdedc7f95efab813803163714ad0856c6af1f5ef126a905a8f599e89bb75065b93a07176c1350 + checksum: 10c0/9e3f27a1aa8ef39d66c60d62e9003bb7c6a368f0491c370caf51c9521a7b1f25abdef1bd64c994a45725ca0edc138bdf6aa0f432aa1d5375f26733a8ee5c77d8 languageName: node linkType: hard @@ -4681,358 +4701,355 @@ __metadata: languageName: node linkType: hard -"@heroui/skeleton@npm:2.2.17": - version: 2.2.17 - resolution: "@heroui/skeleton@npm:2.2.17" +"@heroui/skeleton@npm:2.2.18": + version: 2.2.18 + resolution: "@heroui/skeleton@npm:2.2.18" dependencies: "@heroui/shared-utils": "npm:2.1.12" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b60df05261246ee62da9d892aec1a170ddb34a1ea915b062fcfffcd8b12c98881af60261b6452cbf69a2248d87b21a7e73aad8da6f146e5f0cc608b4968e1302 + checksum: 10c0/6c34e52b169449ac1953656596a849a2d77734c0e03222361e2aae872337ab945e57fb942928f54a9cc363c1f13f0add3e55ee826940fad2591f07dd9b461f04 languageName: node linkType: hard -"@heroui/slider@npm:2.4.24": - version: 2.4.24 - resolution: "@heroui/slider@npm:2.4.24" +"@heroui/slider@npm:2.4.26": + version: 2.4.26 + resolution: "@heroui/slider@npm:2.4.26" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/tooltip": "npm:2.2.24" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/slider": "npm:3.8.2" - "@react-aria/visually-hidden": "npm:3.8.28" - "@react-stately/slider": "npm:3.7.2" + "@heroui/tooltip": "npm:2.2.26" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/slider": "npm:3.8.3" + "@react-aria/visually-hidden": "npm:3.8.29" + "@react-stately/slider": "npm:3.7.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.19" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/e0f31b91ba2ecdd195cb8e68b555d60fa7c918ce05659c7cceb778632433fc2522e6b4b6f7a001f2dcfebee237694465c94c89ef4cb9ae0c18cecf08a62bf455 + checksum: 10c0/8d4e74446537dfac16a736697f532ecf75ef3c916a128a2e56423e7b9fcad014223de1e038043584c31d5daf2c307b5c80d9a87804de8a665a4e9f42bde7b155 languageName: node linkType: hard -"@heroui/snippet@npm:2.2.28": - version: 2.2.28 - resolution: "@heroui/snippet@npm:2.2.28" +"@heroui/snippet@npm:2.2.30": + version: 2.2.30 + resolution: "@heroui/snippet@npm:2.2.30" dependencies: - "@heroui/button": "npm:2.2.27" + "@heroui/button": "npm:2.2.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/tooltip": "npm:2.2.24" + "@heroui/tooltip": "npm:2.2.26" "@heroui/use-clipboard": "npm:2.1.9" - "@react-aria/focus": "npm:3.21.2" + "@react-aria/focus": "npm:3.21.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/cf210d39241edb8d8f8946d53c8ce84dc00cf611613f4654e3f653b56496fd656c27e5264240dadef3cdf412fbebe0ada607f0b4d6ffd4ba876fb77464b192e5 + checksum: 10c0/543a970bc69acd42f26a699abd6b902b13e7d0d2f76cf7b1a0caa739532c2b67cfa3138305fdd77025952a8aed24acf6dbc4e1b48eecd85d5543bd60c32cb9a9 languageName: node linkType: hard -"@heroui/spacer@npm:2.2.21": - version: 2.2.21 - resolution: "@heroui/spacer@npm:2.2.21" +"@heroui/spacer@npm:2.2.22": + version: 2.2.22 + resolution: "@heroui/spacer@npm:2.2.22" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/system-rsc": "npm:2.3.20" + "@heroui/system-rsc": "npm:2.3.21" peerDependencies: - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/0c829fe2bed673a6dcfe28077acf9157152285a286e43f1adc2e915bb2927d475667b3c9e3d28bfbc2aaf6f71d96394b2c87e9ed3a2e86fdbdc2b80541906228 + checksum: 10c0/720f7e506e0547069d20c82f63619915413b9f19fba3d52df56c3f40efc978dbfabd05992ad79569dee7f8d638cc5cca1bc301a8a55e7c2b43b08ab2770a0f97 languageName: node linkType: hard -"@heroui/spinner@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/spinner@npm:2.2.24" +"@heroui/spinner@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/spinner@npm:2.2.26" dependencies: "@heroui/shared-utils": "npm:2.1.12" - "@heroui/system": "npm:2.4.23" - "@heroui/system-rsc": "npm:2.3.20" + "@heroui/system": "npm:2.4.25" + "@heroui/system-rsc": "npm:2.3.21" peerDependencies: - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/61f16fa48356d12790eea009b05347ea5f68b5e2cc353345c0a63a17085437f745b5441e0ea17ac7bffd85c3b9aa9feeb4f22fa33c2ea4651eb55a1eebbdc163 + checksum: 10c0/8b7aaa6db164ef2bf00f322591b2a16bd7df2266228c6d808017e5e4c409772307818c2db4b53e7ac6ff87c37a1030919dddb7db07b023eded12caac9411022e languageName: node linkType: hard -"@heroui/switch@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/switch@npm:2.2.24" +"@heroui/switch@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/switch@npm:2.2.26" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/switch": "npm:3.7.8" - "@react-aria/visually-hidden": "npm:3.8.28" - "@react-stately/toggle": "npm:3.9.2" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/switch": "npm:3.7.9" + "@react-aria/visually-hidden": "npm:3.8.29" + "@react-stately/toggle": "npm:3.9.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/bdaa941fc08ebf5f7d1644806ebe8c4c531da022b6632ca38a83a5a2d93d595955babcb67bcd5bf1e57848720b53cc6da9e0bfc84ce05bca92ece4daa65e32c7 + checksum: 10c0/858ca313a5c750bbd7e7ccde93eb27efb2c8aca413352f70202feabf4a9502292ae8a823bb8426a13e15f0fde5120ade8a654d6d851e69f9649518aa133649ba languageName: node linkType: hard -"@heroui/system-rsc@npm:2.3.20": - version: 2.3.20 - resolution: "@heroui/system-rsc@npm:2.3.20" +"@heroui/system-rsc@npm:2.3.21": + version: 2.3.21 + resolution: "@heroui/system-rsc@npm:2.3.21" dependencies: "@react-types/shared": "npm:3.32.1" - clsx: "npm:^1.2.1" peerDependencies: - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.23" react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/51fe248aba76c0eac626661e0a09f50096f49365d1c2831ad8472649d2c7a03cf33cba817fcefb75e2294f808774c01b0b9eb9abf687eff380f2c6549a96d085 + checksum: 10c0/5d076522a504a9e175633d140dc2c485a3049f47e21943c8896465c64836feff763abeddc8555ca328e6dcc14923f4ff83c66107310936c23d35f875acebc56a languageName: node linkType: hard -"@heroui/system@npm:2.4.23": - version: 2.4.23 - resolution: "@heroui/system@npm:2.4.23" +"@heroui/system@npm:2.4.25": + version: 2.4.25 + resolution: "@heroui/system@npm:2.4.25" dependencies: "@heroui/react-utils": "npm:2.1.14" - "@heroui/system-rsc": "npm:2.3.20" - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/overlays": "npm:3.30.0" - "@react-aria/utils": "npm:3.31.0" + "@heroui/system-rsc": "npm:2.3.21" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/overlays": "npm:3.31.0" + "@react-aria/utils": "npm:3.32.0" peerDependencies: framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/325bc381fdf3ed89ef45fc0bf30f287175660c7bcc481e458866aec082a33a545ce14247dc48b32954a926b3caf47afad088e7c77b1a0ea96ebb259b400a4805 + checksum: 10c0/dc4f512e4a6f8a975c3d434e8a9ccde4e3cf97059a7c60fc95d4cdee35d15eadad89e16b1df149186478e48ccbd93eb90a6e75d300ea2b4a78c663167cabd8ae languageName: node linkType: hard -"@heroui/table@npm:2.2.27": - version: 2.2.27 - resolution: "@heroui/table@npm:2.2.27" +"@heroui/table@npm:2.2.29": + version: 2.2.29 + resolution: "@heroui/table@npm:2.2.29" dependencies: - "@heroui/checkbox": "npm:2.3.27" + "@heroui/checkbox": "npm:2.3.29" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/spacer": "npm:2.2.21" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/table": "npm:3.17.8" - "@react-aria/visually-hidden": "npm:3.8.28" - "@react-stately/table": "npm:3.15.1" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/table": "npm:3.17.9" + "@react-aria/visually-hidden": "npm:3.8.29" + "@react-stately/table": "npm:3.15.2" "@react-stately/virtualizer": "npm:4.4.4" "@react-types/grid": "npm:3.3.6" "@react-types/table": "npm:3.13.4" "@tanstack/react-virtual": "npm:3.11.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/ffc8278afb383a249d8ab66ba11ddd2db528b36a3149b495ec92000da398e9157f7b959029600b3bd48a95ef963eb05a317373bf845c366938439df8135cbd78 + checksum: 10c0/9d2d43b44f953ad50b5e67c612b69d07d9da4ae9ec6dc59ab48efa7c2ffc00fa2ffa9f97239befde7ce00f946a3e2aacb6102a86ca574bc57fe7a83f363c610a languageName: node linkType: hard -"@heroui/tabs@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/tabs@npm:2.2.24" +"@heroui/tabs@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/tabs@npm:2.2.26" dependencies: - "@heroui/aria-utils": "npm:2.2.24" + "@heroui/aria-utils": "npm:2.2.26" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" "@heroui/use-is-mounted": "npm:2.1.8" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/tabs": "npm:3.10.8" - "@react-stately/tabs": "npm:3.8.6" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/tabs": "npm:3.10.9" + "@react-stately/tabs": "npm:3.8.7" "@react-types/shared": "npm:3.32.1" scroll-into-view-if-needed: "npm:3.0.10" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.22" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/2c42b27092388fe2042f0e9a5d072a13f9db73f46892ea571c5cb38f6c0024a5869048058b9027701dc148197d71f5da45e2d7caa5ba627703772e474b1b6cc2 + checksum: 10c0/108eacb568d6b643ad68e685131d8b40ccdebcb0868a21a92ac2bfdbb225a1949a93f75fc7b451875863ec65d83f26b52829ccbb9a6b872c84241c9a38170207 languageName: node linkType: hard -"@heroui/theme@npm:2.4.23": - version: 2.4.23 - resolution: "@heroui/theme@npm:2.4.23" +"@heroui/theme@npm:2.4.25": + version: 2.4.25 + resolution: "@heroui/theme@npm:2.4.25" dependencies: "@heroui/shared-utils": "npm:2.1.12" - clsx: "npm:^1.2.1" color: "npm:^4.2.3" color2k: "npm:^2.0.3" deepmerge: "npm:4.3.1" flat: "npm:^5.0.2" - tailwind-merge: "npm:3.3.1" - tailwind-variants: "npm:3.1.1" + tailwind-merge: "npm:3.4.0" + tailwind-variants: "npm:3.2.2" peerDependencies: tailwindcss: ">=4.0.0" - checksum: 10c0/bed7d85c7ae97d02a551ed977dc74f5d683ece4b9add3758c57093d7527c660be3d65c3a283c8bdc825fa36ba858df068a2b6c6de96d1aa49f925f40a8c409b0 + checksum: 10c0/3ee57d52a9a089b113aa92a43549adf5d9a35c69ca79675ed139963d8a770a7f91ebfabaaedcc0e3c9ff53d19be4eb509d81fd73a264402130c9ef8c301823e7 languageName: node linkType: hard -"@heroui/toast@npm:2.0.17": - version: 2.0.17 - resolution: "@heroui/toast@npm:2.0.17" +"@heroui/toast@npm:2.0.19": + version: 2.0.19 + resolution: "@heroui/toast@npm:2.0.19" dependencies: "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-icons": "npm:2.1.10" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/spinner": "npm:2.2.24" + "@heroui/spinner": "npm:2.2.26" "@heroui/use-is-mobile": "npm:2.2.12" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/toast": "npm:3.0.8" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/toast": "npm:3.0.9" "@react-stately/toast": "npm:3.1.2" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/63677c4deaf5c49d6237f77f27b7e9437dd96fdc1a9bd9c6cc91c0bde5bd15a47856cb24ddcf8e450520608edb939dffbb14a00fde10d5ea4a878f3393328ea9 + checksum: 10c0/c7f130ff73bc4e7749c0219488cf3e901cd6f7b2951a7681d4e4c552e5753e8d0c8614bbca8fe64c0506006d03436f71cb0b379f9794dd3cb6a8b4323ad5ef78 languageName: node linkType: hard -"@heroui/tooltip@npm:2.2.24": - version: 2.2.24 - resolution: "@heroui/tooltip@npm:2.2.24" +"@heroui/tooltip@npm:2.2.26": + version: 2.2.26 + resolution: "@heroui/tooltip@npm:2.2.26" dependencies: - "@heroui/aria-utils": "npm:2.2.24" + "@heroui/aria-utils": "npm:2.2.26" "@heroui/dom-animation": "npm:2.1.10" - "@heroui/framer-utils": "npm:2.1.23" + "@heroui/framer-utils": "npm:2.1.25" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@heroui/use-aria-overlay": "npm:2.0.4" + "@heroui/use-aria-overlay": "npm:2.0.5" "@heroui/use-safe-layout-effect": "npm:2.1.8" - "@react-aria/overlays": "npm:3.30.0" - "@react-aria/tooltip": "npm:3.8.8" - "@react-stately/tooltip": "npm:3.5.8" + "@react-aria/overlays": "npm:3.31.0" + "@react-aria/tooltip": "npm:3.9.0" + "@react-stately/tooltip": "npm:3.5.9" "@react-types/overlays": "npm:3.9.2" - "@react-types/tooltip": "npm:3.4.21" + "@react-types/tooltip": "npm:3.5.0" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" framer-motion: ">=11.5.6 || >=12.0.0-alpha.1" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/b1e96bae5a2995d78d953804652a9f37c79b2b87f85f36b2eb6cabe7f7a4c5343e1f557e456bda901a5e744a8d13dda922a14ae31f9f45dd527d4ef25fe72eb0 + checksum: 10c0/ecf1e0e75cbf0846178f976dd3f6afe1e442dd7d1113fe35f2179b1dbc8787cc1785a0ae8251df9d8ea640ae129e94cd6ef3d6b2341f5d75e16eda064a9da4a5 languageName: node linkType: hard -"@heroui/use-aria-accordion@npm:2.2.18": - version: 2.2.18 - resolution: "@heroui/use-aria-accordion@npm:2.2.18" +"@heroui/use-aria-accordion@npm:2.2.19": + version: 2.2.19 + resolution: "@heroui/use-aria-accordion@npm:2.2.19" dependencies: - "@react-aria/button": "npm:3.14.2" - "@react-aria/focus": "npm:3.21.2" - "@react-aria/selection": "npm:3.26.0" - "@react-stately/tree": "npm:3.9.3" + "@react-aria/button": "npm:3.14.3" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/selection": "npm:3.27.0" + "@react-stately/tree": "npm:3.9.4" "@react-types/accordion": "npm:3.0.0-alpha.26" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/1dd9cf6a5995319cf34cbcf02afee1ca9daf5e8f907fd1b31a7ae5c73d055470f586b3c0ed42f86825a209fa7eb1ae8ca2d10add0b5f4d035767734db9d37690 + checksum: 10c0/e913b81bead9a8fc57ea4fc4f21069a1230e751acf36fc60ff7e44276c4cc354809361d6540dd9a479d516f1be3d94cd3980e6f4efbffabd93670026d7b01104 languageName: node linkType: hard -"@heroui/use-aria-button@npm:2.2.20": - version: 2.2.20 - resolution: "@heroui/use-aria-button@npm:2.2.20" +"@heroui/use-aria-button@npm:2.2.21": + version: 2.2.21 + resolution: "@heroui/use-aria-button@npm:2.2.21" dependencies: - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/utils": "npm:3.31.0" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/utils": "npm:3.32.0" "@react-types/button": "npm:3.14.1" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/dd578b3877158e3e60d00056d0339b2e64556d91610f37b41fce2f392d3865413440fbeb11525d757e7996f76db1b0081c3088a7cadaf8a4d608630a708939e0 + checksum: 10c0/ee2e2115f04f5be3e7814c3820952700652c79a0214b1104c3e17c37616b902cd4b71b0225c3dfeb2b2ddfd4014fb890204dd1740db7edc2fbcd286e66422fe6 languageName: node linkType: hard -"@heroui/use-aria-link@npm:2.2.21": - version: 2.2.21 - resolution: "@heroui/use-aria-link@npm:2.2.21" +"@heroui/use-aria-link@npm:2.2.22": + version: 2.2.22 + resolution: "@heroui/use-aria-link@npm:2.2.22" dependencies: - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/utils": "npm:3.31.0" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/utils": "npm:3.32.0" "@react-types/link": "npm:3.6.5" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/67674f7e7f9dc3c6899f9de0014ad1e6395e39fcd196b182c0f49940f93187bcc1f1ad6f461c2d75d9a9ad2b3830895ee68d7d4044335dac78ea449c03086ce4 + checksum: 10c0/bc6f8f94394f7cb85940835607a406cbc9e726f15bc800a987ecc3f86ef7d4e911261eeb80dd1f9849a46c458b38400b968ba3421a04e2ae636c4325f9f30a94 languageName: node linkType: hard -"@heroui/use-aria-modal-overlay@npm:2.2.19": - version: 2.2.19 - resolution: "@heroui/use-aria-modal-overlay@npm:2.2.19" +"@heroui/use-aria-modal-overlay@npm:2.2.20": + version: 2.2.20 + resolution: "@heroui/use-aria-modal-overlay@npm:2.2.20" dependencies: - "@heroui/use-aria-overlay": "npm:2.0.4" - "@react-aria/overlays": "npm:3.30.0" - "@react-aria/utils": "npm:3.31.0" - "@react-stately/overlays": "npm:3.6.20" + "@heroui/use-aria-overlay": "npm:2.0.5" + "@react-aria/overlays": "npm:3.31.0" + "@react-aria/utils": "npm:3.32.0" + "@react-stately/overlays": "npm:3.6.21" peerDependencies: react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/7893293569bb7ba8c004c1bda21db3fdc7ab187e3fbe9879e90235eea5da49ec22e3109a44ef2741ada4c26a9f425c690cfde9de6f688e8fdd85de3a428d0b40 + checksum: 10c0/5a2052260c31b6d9380d94302e0bfd351b3a37ab8fcdd236cc0b6e1e88b31ab0a3c81184ecd8c27291004fbea8848c57d78b576f2f6848abf625f26917cdf49f languageName: node linkType: hard -"@heroui/use-aria-multiselect@npm:2.4.19": - version: 2.4.19 - resolution: "@heroui/use-aria-multiselect@npm:2.4.19" +"@heroui/use-aria-multiselect@npm:2.4.20": + version: 2.4.20 + resolution: "@heroui/use-aria-multiselect@npm:2.4.20" dependencies: - "@react-aria/i18n": "npm:3.12.13" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/label": "npm:3.7.22" - "@react-aria/listbox": "npm:3.15.0" - "@react-aria/menu": "npm:3.19.3" - "@react-aria/selection": "npm:3.26.0" - "@react-aria/utils": "npm:3.31.0" + "@react-aria/i18n": "npm:3.12.14" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/label": "npm:3.7.23" + "@react-aria/listbox": "npm:3.15.1" + "@react-aria/menu": "npm:3.19.4" + "@react-aria/selection": "npm:3.27.0" + "@react-aria/utils": "npm:3.32.0" "@react-stately/form": "npm:3.2.2" - "@react-stately/list": "npm:3.13.1" - "@react-stately/menu": "npm:3.9.8" + "@react-stately/list": "npm:3.13.2" + "@react-stately/menu": "npm:3.9.9" "@react-types/button": "npm:3.14.1" "@react-types/overlays": "npm:3.9.2" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/2b4e6dabf394096f0f32dbcc18c7b5ebbf3adf65a3e1cb1ae9848cc6df75b334f73425ef46021e77020dd64a28eed9fb843de5cbebf159c2c678f0712ccc34e1 + checksum: 10c0/24503478e5bf394b4574e954cfaeeab3bdcca31f1d02d70214e966341b624e8e32095d751a8065a642dcfdf859b37a6e19a0504c4c4c94502c3de217cab13081 languageName: node linkType: hard -"@heroui/use-aria-overlay@npm:2.0.4": - version: 2.0.4 - resolution: "@heroui/use-aria-overlay@npm:2.0.4" +"@heroui/use-aria-overlay@npm:2.0.5": + version: 2.0.5 + resolution: "@heroui/use-aria-overlay@npm:2.0.5" dependencies: - "@react-aria/focus": "npm:3.21.2" - "@react-aria/interactions": "npm:3.25.6" - "@react-aria/overlays": "npm:3.30.0" + "@react-aria/focus": "npm:3.21.3" + "@react-aria/interactions": "npm:3.26.0" + "@react-aria/overlays": "npm:3.31.0" "@react-types/shared": "npm:3.32.1" peerDependencies: react: ">=18" react-dom: ">=18" - checksum: 10c0/c768f147ebee3a9064eb44327062677f88211ea6c99f2bddd8f9433eb9c0009a419026314d8ae56f8cf48e184f685b1e4ca0482ebd39d44f04c3748951dbaa29 + checksum: 10c0/8258acf50f0472eba9a57dac7de4129ccd419dc1a9976b95eb2808c97fdbc943b036e3d54199ff29afeb2dfddbb158cd36194af3612f09711f602d10d0531a51 languageName: node linkType: hard @@ -5067,27 +5084,27 @@ __metadata: languageName: node linkType: hard -"@heroui/use-disclosure@npm:2.2.17": - version: 2.2.17 - resolution: "@heroui/use-disclosure@npm:2.2.17" +"@heroui/use-disclosure@npm:2.2.18": + version: 2.2.18 + resolution: "@heroui/use-disclosure@npm:2.2.18" dependencies: "@heroui/use-callback-ref": "npm:2.1.8" - "@react-aria/utils": "npm:3.31.0" - "@react-stately/utils": "npm:3.10.8" + "@react-aria/utils": "npm:3.32.0" + "@react-stately/utils": "npm:3.11.0" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/33b94ca0635827cc4ec2bb1eaeff40cde64c2de7c1f29f9659f13e4680a6f0a26588fde52d68995dd1839ce73ea1ef2c1572fbb12dee100ddad25e18ba494ba5 + checksum: 10c0/da4dcd460dacdfe4ff4a5861cda808fb76c9b7d386aa911123267b4a0c32a09d85ae05c7b3b3074765a7cfec31729fe172af10101f5bc1cce546ab0255f2672d languageName: node linkType: hard -"@heroui/use-draggable@npm:2.1.18": - version: 2.1.18 - resolution: "@heroui/use-draggable@npm:2.1.18" +"@heroui/use-draggable@npm:2.1.19": + version: 2.1.19 + resolution: "@heroui/use-draggable@npm:2.1.19" dependencies: - "@react-aria/interactions": "npm:3.25.6" + "@react-aria/interactions": "npm:3.26.0" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/fb5c6696860d5cb287321eeaeeec02821c285f783f6625ca40c323630d7f5b02d40b7df959a1e826e8da8615aee1ab23e8d814ff9bdcc8b5cf89a5bec0d9bf58 + checksum: 10c0/77b06ae6a892d4daab4e258e7b26b9be13083a4f03186ffc1cd0965dea975c7501a53a8a67ad513aa03486e6b13756021ba4adeb075ccd42dc9fb9e492afe48c languageName: node linkType: hard @@ -5150,15 +5167,15 @@ __metadata: languageName: node linkType: hard -"@heroui/use-pagination@npm:2.2.18": - version: 2.2.18 - resolution: "@heroui/use-pagination@npm:2.2.18" +"@heroui/use-pagination@npm:2.2.19": + version: 2.2.19 + resolution: "@heroui/use-pagination@npm:2.2.19" dependencies: "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/i18n": "npm:3.12.13" + "@react-aria/i18n": "npm:3.12.14" peerDependencies: react: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/3b17772c76402b7845704c4f5d3f621094f2a67a215f54781342bee33d424d5db3171ae30748f7889e769027341acaa18c735c66fdfbe7facc564a629c1f6306 + checksum: 10c0/cdf04d6b054ab0e66eb9cffc6c678d63f022b888a4417d9ee14f9577e5398f34a2046fd17e15d8a4aa9a6ca3f6a37275ae9b637f61d4574c0428b37881bbf120 languageName: node linkType: hard @@ -5198,20 +5215,20 @@ __metadata: languageName: node linkType: hard -"@heroui/user@npm:2.2.22": - version: 2.2.22 - resolution: "@heroui/user@npm:2.2.22" +"@heroui/user@npm:2.2.24": + version: 2.2.24 + resolution: "@heroui/user@npm:2.2.24" dependencies: - "@heroui/avatar": "npm:2.2.22" + "@heroui/avatar": "npm:2.2.24" "@heroui/react-utils": "npm:2.1.14" "@heroui/shared-utils": "npm:2.1.12" - "@react-aria/focus": "npm:3.21.2" + "@react-aria/focus": "npm:3.21.3" peerDependencies: "@heroui/system": ">=2.4.18" - "@heroui/theme": ">=2.4.17" + "@heroui/theme": ">=2.4.24" react: ">=18 || >=19.0.0-rc.0" react-dom: ">=18 || >=19.0.0-rc.0" - checksum: 10c0/f13e09c0d18a2141a0af920a1e2d965f7ae5b2aab5003b9f4404987d1dbd3d47962dac1509f398f08faaee80ac5d89dcc0d64062f5705f151f35844aa533ec24 + checksum: 10c0/bad4d97224796bd8af5e3930228a4ddc140ef78ba41ad72a8f64f5a2fd6943c739fc6f4910a081814a9be02b05ea3a001d4777f055983002568013484d5a8e1f languageName: node linkType: hard @@ -5577,12 +5594,12 @@ __metadata: languageName: node linkType: hard -"@internationalized/date@npm:3.10.0, @internationalized/date@npm:^3.10.0": - version: 3.10.0 - resolution: "@internationalized/date@npm:3.10.0" +"@internationalized/date@npm:3.10.1, @internationalized/date@npm:^3.10.1": + version: 3.10.1 + resolution: "@internationalized/date@npm:3.10.1" dependencies: "@swc/helpers": "npm:^0.5.0" - checksum: 10c0/29634148f0d9232e65402a5c6a4194ecf7c375e89e687f71dd084d30315c9d544e2202de2ec26e199432c620da41a15cc473479f80897e08566e274e402f898e + checksum: 10c0/2b7a8144a97baf0c8bd9f3ef28fe86238e2cfde3b837c943aa03bd07354a04753bab3fd7162e5865c284f5b2616e832c9eee395dec92c0fed4eff57615d9d940 languageName: node linkType: hard @@ -5660,12 +5677,11 @@ __metadata: languageName: node linkType: hard -"@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.1": - version: 0.6.1 - resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.1" +"@joshwooding/vite-plugin-react-docgen-typescript@npm:^0.6.3": + version: 0.6.3 + resolution: "@joshwooding/vite-plugin-react-docgen-typescript@npm:0.6.3" dependencies: - glob: "npm:^10.0.0" - magic-string: "npm:^0.30.0" + glob: "npm:^11.1.0" react-docgen-typescript: "npm:^2.2.2" peerDependencies: typescript: ">= 4.3.x" @@ -5673,7 +5689,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/0bcc2adbb49158018102bd9d84cd8572c770daee3d46733157933ef0330953bd5b9e102c26f2338ee7dfb8f21a7bb937134d23f8a7935d5dc88525a253557467 + checksum: 10c0/e68d2884235b8290673c17a13bc303a088feba6ce0a275ab0778b50e90b967f5dffdcf71ed3197e9cdf07607594a9cb2a86e3ea6e4eb8962b50d61078107bac3 languageName: node linkType: hard @@ -5704,7 +5720,14 @@ __metadata: languageName: node linkType: hard -"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" + checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.5.5": version: 1.5.5 resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 @@ -6241,6 +6264,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" @@ -6782,14 +6812,14 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.0.3, @napi-rs/wasm-runtime@npm:^1.0.7": - version: 1.0.7 - resolution: "@napi-rs/wasm-runtime@npm:1.0.7" +"@napi-rs/wasm-runtime@npm:^1.0.7, @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.5.0" - "@emnapi/runtime": "npm:^1.5.0" + "@emnapi/core": "npm:^1.7.1" + "@emnapi/runtime": "npm:^1.7.1" "@tybys/wasm-util": "npm:^0.10.1" - checksum: 10c0/2d8635498136abb49d6dbf7395b78c63422292240963bf055f307b77aeafbde57ae2c0ceaaef215601531b36d6eb92a2cdd6f5ba90ed2aa8127c27aff9c4ae55 + checksum: 10c0/ee351052123bfc635c4cef03ac273a686522394ccd513b1e5b7b3823cecd6abb4a31f23a3a962933192b87eb7b7c3eb3def7748bd410edc66f932d90cf44e9ab languageName: node linkType: hard @@ -7124,6 +7154,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" @@ -7131,13 +7168,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" @@ -7145,10 +7175,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 @@ -7384,12 +7414,21 @@ __metadata: languageName: node linkType: hard -"@quansync/fs@npm:^0.1.5": - version: 0.1.5 - resolution: "@quansync/fs@npm:0.1.5" +"@quansync/fs@npm:^0.1.1": + version: 0.1.3 + resolution: "@quansync/fs@npm:0.1.3" dependencies: - quansync: "npm:^0.2.11" - checksum: 10c0/c7f8f654499240be450b23c308a484de87bebcd0a0c8291c1afda8908a4aafafe7bc1b50e43bed0ac82ec53712505be2fa71db60e992d9353fd8ac6e664bc157 + quansync: "npm:^0.2.10" + checksum: 10c0/15d9914328d296df6626b6b2d5e9f455f618d5c8ffff09270ca3ce42c1bd21e4a91b53d6c1d857fbcae3be8c07b33ab82a83532870f2c5bf74904fe0ac60a3d1 + languageName: node + linkType: hard + +"@quansync/fs@npm:^1.0.0": + version: 1.0.0 + resolution: "@quansync/fs@npm:1.0.0" + dependencies: + quansync: "npm:^1.0.0" + checksum: 10c0/41a7e145d4fc349eaeac20ee7ffe0c876a7c26b2268d5704b462b3e7379091221336e315b2b346d5b07a531502a41cad15c9f374800cc60b6339d074ef99aa16 languageName: node linkType: hard @@ -7778,7 +7817,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-primitive@npm:2.1.3, @radix-ui/react-primitive@npm:^2.0.2": +"@radix-ui/react-primitive@npm:2.1.3": version: 2.1.3 resolution: "@radix-ui/react-primitive@npm:2.1.3" dependencies: @@ -7797,6 +7836,25 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-primitive@npm:^2.0.2": + version: 2.1.4 + resolution: "@radix-ui/react-primitive@npm:2.1.4" + dependencies: + "@radix-ui/react-slot": "npm:1.2.4" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/90d687b222a25975371ed1f9f08648d75237214b8dec4cbaf09ec9ac951339b17421278f1aff2fb7c5672ba8bd03774a94904efdba73805dd5cc947ce5be8c4a + languageName: node + linkType: hard + "@radix-ui/react-radio-group@npm:^1.3.8": version: 1.3.8 resolution: "@radix-ui/react-radio-group@npm:1.3.8" @@ -7906,7 +7964,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-slot@npm:^1.2.4": +"@radix-ui/react-slot@npm:1.2.4, @radix-ui/react-slot@npm:^1.2.4": version: 1.2.4 resolution: "@radix-ui/react-slot@npm:1.2.4" dependencies: @@ -8217,16 +8275,29 @@ __metadata: languageName: node linkType: hard -"@rc-component/qrcode@npm:~1.0.0, @rc-component/qrcode@npm:~1.0.1": - version: 1.0.1 - resolution: "@rc-component/qrcode@npm:1.0.1" +"@rc-component/qrcode@npm:~1.0.0": + version: 1.0.0 + resolution: "@rc-component/qrcode@npm:1.0.0" dependencies: "@babel/runtime": "npm:^7.24.7" classnames: "npm:^2.3.2" + rc-util: "npm:^5.38.0" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/9d1f8fdf10c1c6774fd8500ccab4c386c21ef9f2f8ce40c5eef59c7c4ff6e296df9b4ebdf6949c2d7a734ac58a4407e7a434fd844f0f951e2000c44c3326828a + checksum: 10c0/406dbe13e3b24ca20ef729d5456a329711ac9ca50f20604ff1e1fdbcb3a716408ad453cc083ec87d541096c85e2f512175f0b357075b40f71bea38e2a4f59cbd + languageName: node + linkType: hard + +"@rc-component/qrcode@npm:~1.1.0": + version: 1.1.1 + resolution: "@rc-component/qrcode@npm:1.1.1" + dependencies: + "@babel/runtime": "npm:^7.24.7" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/8b7f38b2f1b319322a7b1b75ce2766789035cdf61d31ee45d0ca9e3570ee7345760c9fc1d51bb9ff9c65d8d7a823d90abd31b1ff4f207480c54ed04479fb8b25 languageName: node linkType: hard @@ -8246,7 +8317,24 @@ __metadata: languageName: node linkType: hard -"@rc-component/trigger@npm:^2.0.0, @rc-component/trigger@npm:^2.1.1, @rc-component/trigger@npm:^2.3.0": +"@rc-component/trigger@npm:^2.0.0, @rc-component/trigger@npm:^2.1.1": + version: 2.2.6 + resolution: "@rc-component/trigger@npm:2.2.6" + dependencies: + "@babel/runtime": "npm:^7.23.2" + "@rc-component/portal": "npm:^1.1.0" + classnames: "npm:^2.3.2" + rc-motion: "npm:^2.0.0" + rc-resize-observer: "npm:^1.3.1" + rc-util: "npm:^5.44.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/e7ef14099fac74a58301ccf65a003ddaefb6f2a410c950c8354e0d63fd13e21e3a1f32dd4e73a11c7c0c6199e66629f7f3e31c09d887198b974d35805c4de8e1 + languageName: node + linkType: hard + +"@rc-component/trigger@npm:^2.3.0": version: 2.3.0 resolution: "@rc-component/trigger@npm:2.3.0" dependencies: @@ -8263,202 +8351,202 @@ __metadata: languageName: node linkType: hard -"@react-aria/breadcrumbs@npm:3.5.29": - version: 3.5.29 - resolution: "@react-aria/breadcrumbs@npm:3.5.29" +"@react-aria/breadcrumbs@npm:3.5.30": + version: 3.5.30 + resolution: "@react-aria/breadcrumbs@npm:3.5.30" dependencies: - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/link": "npm:^3.8.6" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/link": "npm:^3.8.7" + "@react-aria/utils": "npm:^3.32.0" "@react-types/breadcrumbs": "npm:^3.7.17" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5a7c1ed3c165ed72364187c4a5b866126499e364ca73587f11a5031364c11cefe0a2cfbeb059a496e48c9d299b8b5326ca2473b42268a6d466c9d78cc8134f19 + checksum: 10c0/b33793650f99f331866ccf92947c8d52fe065d61a945a671c1e2e40503ca936bfdabbcb4571138002afc66316fc364acdabc57e3c6d4cfc257eab92ce55ad00c languageName: node linkType: hard -"@react-aria/button@npm:3.14.2": - version: 3.14.2 - resolution: "@react-aria/button@npm:3.14.2" +"@react-aria/button@npm:3.14.3": + version: 3.14.3 + resolution: "@react-aria/button@npm:3.14.3" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/toolbar": "npm:3.0.0-beta.21" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/toggle": "npm:^3.9.2" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/toolbar": "npm:3.0.0-beta.22" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/toggle": "npm:^3.9.3" "@react-types/button": "npm:^3.14.1" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7e171054a2f81ded1255ea4e31806cd3e71a8fbc50e13dcad682908d2681af818b7276579d95d9e506552246c4f2bfc4884c77a4d6eb657523bc751607623300 + checksum: 10c0/072c2e3b298eb8eccbb334933e70ad4248180c09c8a628ed94cba62cfcb90060ea99ca83bffcf88877ce4a35590614f686051a81f95ff4aac7bb5cef1c5f2086 languageName: node linkType: hard -"@react-aria/calendar@npm:3.9.2": - version: 3.9.2 - resolution: "@react-aria/calendar@npm:3.9.2" +"@react-aria/calendar@npm:3.9.3": + version: 3.9.3 + resolution: "@react-aria/calendar@npm:3.9.3" dependencies: - "@internationalized/date": "npm:^3.10.0" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" + "@internationalized/date": "npm:^3.10.1" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/calendar": "npm:^3.9.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/calendar": "npm:^3.9.1" "@react-types/button": "npm:^3.14.1" - "@react-types/calendar": "npm:^3.8.0" + "@react-types/calendar": "npm:^3.8.1" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cc17787b17cd0a9a47dac812c28ec2be49d1d4f6043ce6e3028a351db94cd405b136f562b2f22d5e0e285403c6926c110e55393e167c6ab3476960fcae080596 + checksum: 10c0/15eb86b6da788690ac664f2372d8c7c224250f0fd86836caed25e58011561fb5c038d2d12e023c0a2e73549c3db7a30b79c8d1c988b0eb40a5dc0dd2ca8e6bd0 languageName: node linkType: hard -"@react-aria/checkbox@npm:3.16.2": - version: 3.16.2 - resolution: "@react-aria/checkbox@npm:3.16.2" +"@react-aria/checkbox@npm:3.16.3": + version: 3.16.3 + resolution: "@react-aria/checkbox@npm:3.16.3" dependencies: - "@react-aria/form": "npm:^3.1.2" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/toggle": "npm:^3.12.2" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/checkbox": "npm:^3.7.2" + "@react-aria/form": "npm:^3.1.3" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/toggle": "npm:^3.12.3" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/checkbox": "npm:^3.7.3" "@react-stately/form": "npm:^3.2.2" - "@react-stately/toggle": "npm:^3.9.2" + "@react-stately/toggle": "npm:^3.9.3" "@react-types/checkbox": "npm:^3.10.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/76ae85e93969c252d689db214787636cd651a4726aa14068bd1cc95af3179f5cedf51e968a85041088687cf670b7d3800f4b90b09361631ff7f902ab09dc6daf + checksum: 10c0/b83406f233bc47df718e4304dcea2137678f45af00eb306f67217055ea636b2319715059ae2818b4b872b9263cb892d6e1f0a72a87284ef51050603c38562e1a languageName: node linkType: hard -"@react-aria/combobox@npm:3.14.0": - version: 3.14.0 - resolution: "@react-aria/combobox@npm:3.14.0" +"@react-aria/combobox@npm:3.14.1": + version: 3.14.1 + resolution: "@react-aria/combobox@npm:3.14.1" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/listbox": "npm:^3.15.0" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/listbox": "npm:^3.15.1" "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/menu": "npm:^3.19.3" - "@react-aria/overlays": "npm:^3.30.0" - "@react-aria/selection": "npm:^3.26.0" - "@react-aria/textfield": "npm:^3.18.2" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/menu": "npm:^3.19.4" + "@react-aria/overlays": "npm:^3.31.0" + "@react-aria/selection": "npm:^3.27.0" + "@react-aria/textfield": "npm:^3.18.3" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/collections": "npm:^3.12.8" - "@react-stately/combobox": "npm:^3.12.0" + "@react-stately/combobox": "npm:^3.12.1" "@react-stately/form": "npm:^3.2.2" "@react-types/button": "npm:^3.14.1" - "@react-types/combobox": "npm:^3.13.9" + "@react-types/combobox": "npm:^3.13.10" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5b4868ecda985994bba8d7885e5f772d72a2fce7ace4cbf967fe10aac7079521daa9f3cded47d36e17114e60aafea577473d2783dd60da9b2d5afeaee9de3296 + checksum: 10c0/425fd484faaf52625263583de5bad0799a75f3f4db09bad3315c1580a245069e7247751390927789f1683540f4d3a720a6dfb7587f7bbd4c18528bbf5f93badf languageName: node linkType: hard -"@react-aria/datepicker@npm:3.15.2": - version: 3.15.2 - resolution: "@react-aria/datepicker@npm:3.15.2" +"@react-aria/datepicker@npm:3.15.3": + version: 3.15.3 + resolution: "@react-aria/datepicker@npm:3.15.3" dependencies: - "@internationalized/date": "npm:^3.10.0" + "@internationalized/date": "npm:^3.10.1" "@internationalized/number": "npm:^3.6.5" "@internationalized/string": "npm:^3.2.7" - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/form": "npm:^3.1.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/spinbutton": "npm:^3.6.19" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/datepicker": "npm:^3.15.2" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/form": "npm:^3.1.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/spinbutton": "npm:^3.7.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/datepicker": "npm:^3.15.3" "@react-stately/form": "npm:^3.2.2" "@react-types/button": "npm:^3.14.1" - "@react-types/calendar": "npm:^3.8.0" - "@react-types/datepicker": "npm:^3.13.2" + "@react-types/calendar": "npm:^3.8.1" + "@react-types/datepicker": "npm:^3.13.3" "@react-types/dialog": "npm:^3.5.22" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/585f3323e58153e7e707c2629eeb98cb7c278c67de67434f7126e740b7fcb94079a120cd496bc401a803ecfb61c2b6253812dd5d6ef49207e57ea28ac897cd21 + checksum: 10c0/ce18df7cd40241718628839c7995b955951577578ec276ce961ed86e35d66f224aae4465958cc812bea919edee52aefba3db7dccd1e6c21ade5b6bad31651c9e languageName: node linkType: hard -"@react-aria/dialog@npm:3.5.31": - version: 3.5.31 - resolution: "@react-aria/dialog@npm:3.5.31" +"@react-aria/dialog@npm:3.5.32": + version: 3.5.32 + resolution: "@react-aria/dialog@npm:3.5.32" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/overlays": "npm:^3.30.0" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/overlays": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/dialog": "npm:^3.5.22" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8a9e6498a15bd8a95b2f426436d25183b41ed1a522a35384da69976d118d3471580b7aab0a90e9995dc566641897c41f9f7937a79aeeb666ba692ffacbec2a8c + checksum: 10c0/c5472dd70d8079993b84eb30c42c076dab6eb14adf21db6f63f1b94fbd4cf7c4b90391c5e842484a180e3123f4bacb000fcaf8434247ad5be9870f106eb87ba1 languageName: node linkType: hard -"@react-aria/focus@npm:3.21.2, @react-aria/focus@npm:^3.21.2": - version: 3.21.2 - resolution: "@react-aria/focus@npm:3.21.2" +"@react-aria/focus@npm:3.21.3, @react-aria/focus@npm:^3.21.3": + version: 3.21.3 + resolution: "@react-aria/focus@npm:3.21.3" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/bfcdbb8d47bf038c035b025df6b9c292eeea9a2af7c77ec2ac27c302cb64dc481cfe80bb6575b399301ad1516feba134dec01e3c112ca2cf912ca13b47965917 + checksum: 10c0/c1169f2047908dd2641439ed49b51d1482df00514f5adc569d73727bc6375150198dd1b6e345a79fc31f3571d7d09549743ba2e6b3168ed8d6a554708d48fa9b languageName: node linkType: hard -"@react-aria/form@npm:3.1.2, @react-aria/form@npm:^3.1.2": - version: 3.1.2 - resolution: "@react-aria/form@npm:3.1.2" +"@react-aria/form@npm:3.1.3, @react-aria/form@npm:^3.1.3": + version: 3.1.3 + resolution: "@react-aria/form@npm:3.1.3" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/form": "npm:^3.2.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d706e08545765c18c47e6b1cf64d09fc0e22af73e0a938ffdea209afa3be6036a019f9d92f22f4239acb5300f757da46a33c0b55a13be54c4ab2ad1c7f2a2e84 + checksum: 10c0/8469b0ee653deedae1ef0c0ab64773a204e337f4919d3ed9caf67c6df54f5b2dd33935a484eb49d79621976ef1a2fde69cc0ca6af2f9361ab32c3b909f41431b languageName: node linkType: hard -"@react-aria/grid@npm:^3.14.5": - version: 3.14.5 - resolution: "@react-aria/grid@npm:3.14.5" +"@react-aria/grid@npm:^3.14.6": + version: 3.14.6 + resolution: "@react-aria/grid@npm:3.14.6" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/selection": "npm:^3.26.0" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/selection": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/collections": "npm:^3.12.8" - "@react-stately/grid": "npm:^3.11.6" - "@react-stately/selection": "npm:^3.20.6" + "@react-stately/grid": "npm:^3.11.7" + "@react-stately/selection": "npm:^3.20.7" "@react-types/checkbox": "npm:^3.10.2" "@react-types/grid": "npm:^3.3.6" "@react-types/shared": "npm:^3.32.1" @@ -8466,107 +8554,107 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1e2ce96c55b31fd6ee4e3d4b190e9a5ab6987332dead3d3bf24c5f53dc0c375fb46fa1784a4a65325857812ceed18ea2cd5b79e06867629e301e536864b6d8dd + checksum: 10c0/012544af3ef3192de3fd5e4e01e03abb7f3ff0e3266e25bf52137fdc79c4214aaa936d9142975b8a95dd608cecf711704887bd499312e041fa20a6048d7e3f3e languageName: node linkType: hard -"@react-aria/i18n@npm:3.12.13, @react-aria/i18n@npm:^3.12.13": - version: 3.12.13 - resolution: "@react-aria/i18n@npm:3.12.13" +"@react-aria/i18n@npm:3.12.14, @react-aria/i18n@npm:^3.12.14": + version: 3.12.14 + resolution: "@react-aria/i18n@npm:3.12.14" dependencies: - "@internationalized/date": "npm:^3.10.0" + "@internationalized/date": "npm:^3.10.1" "@internationalized/message": "npm:^3.1.8" "@internationalized/number": "npm:^3.6.5" "@internationalized/string": "npm:^3.2.7" "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0c79fa6ffb171cde2fc7fc7150042d6f7d5911a0df275286e5e5f5ad0edb35d51092335ed922fd83a1370e22a8467055082c4e851392a7e9827c78cf3e6f591b + checksum: 10c0/c25095a268b30b715713a7f2af8e4023cb9b6993118f824ceafcaa7af65200ccaa4ff8b100a670f58821a007cb57f2571a7a6823b492a116b38a43ca880ebd8b languageName: node linkType: hard -"@react-aria/interactions@npm:3.25.6, @react-aria/interactions@npm:^3.25.6": - version: 3.25.6 - resolution: "@react-aria/interactions@npm:3.25.6" +"@react-aria/interactions@npm:3.26.0, @react-aria/interactions@npm:^3.26.0": + version: 3.26.0 + resolution: "@react-aria/interactions@npm:3.26.0" dependencies: "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/flags": "npm:^3.1.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/000300ee3cfab724228c89f7261e94e1357f91f746256c352466a014ab6e1e907a3e6c6a2c0e73a6dd7efc97c1a608c96462de5b41a3eebda22cbc97550a797d + checksum: 10c0/542044d08c02aec337ceda1ed55e5b01f6fa3e76c930b0063bc4a2146102d39659df81570912b7bef4782e268c08bbfdca82a44df413ec8ce8f1bdf930e97051 languageName: node linkType: hard -"@react-aria/label@npm:3.7.22, @react-aria/label@npm:^3.7.22": - version: 3.7.22 - resolution: "@react-aria/label@npm:3.7.22" +"@react-aria/label@npm:3.7.23, @react-aria/label@npm:^3.7.23": + version: 3.7.23 + resolution: "@react-aria/label@npm:3.7.23" dependencies: - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/608c7e4e4d8b3b1599b5e79a32836094812c3d9c5a30ee9eff5dfe8508b76abb084b300e28ee9cdab01b99d8fd748135827534ab26e384657e6347348caf28e1 + checksum: 10c0/202871efd3c04435219ad58cd05fdc80829d95d848ac50f22eedd7e04e04ae1bb45ff1d9de0721f6f4ce84f5df135f8248942ae740ff27dc8511f9dcfbbaff7f languageName: node linkType: hard -"@react-aria/landmark@npm:^3.0.7": - version: 3.0.7 - resolution: "@react-aria/landmark@npm:3.0.7" +"@react-aria/landmark@npm:^3.0.8": + version: 3.0.8 + resolution: "@react-aria/landmark@npm:3.0.8" dependencies: - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" use-sync-external-store: "npm:^1.4.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/581692703e20d351431d99025aca1e5ce06c8fb4b034dc283cf04d9cb86063780c0022f8e2a7948845a4bd90c891251f7549146bb07e845a5c287b739ad46f7d + checksum: 10c0/13a81bbc37121eb1a1019608a20e97c72e5d7fa338550011052ba2230310002bf6d87aec31ae74e44ac622b473b8830108571787ca1edffd4f0df84f17b002ca languageName: node linkType: hard -"@react-aria/link@npm:^3.8.6": - version: 3.8.6 - resolution: "@react-aria/link@npm:3.8.6" +"@react-aria/link@npm:^3.8.7": + version: 3.8.7 + resolution: "@react-aria/link@npm:3.8.7" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/link": "npm:^3.6.5" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/ed5a37b7446957d860cc48b5720cd65f4dc84c001c7ac69541d4320c2ac584a7a5a4066630e90bc6dfed3f7bf324a60bcf3039f090cb521439fd01325e589a5e + checksum: 10c0/0fb7e17fae5f07bfea3b26a507453d6987f9bfc7009330778b242099a045cb3a4f910621cab22e41e40193ace21db67acc73a64ef6b0bf942154b2cd630674db languageName: node linkType: hard -"@react-aria/listbox@npm:3.15.0, @react-aria/listbox@npm:^3.15.0": - version: 3.15.0 - resolution: "@react-aria/listbox@npm:3.15.0" +"@react-aria/listbox@npm:3.15.1, @react-aria/listbox@npm:^3.15.1": + version: 3.15.1 + resolution: "@react-aria/listbox@npm:3.15.1" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/selection": "npm:^3.26.0" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/selection": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/collections": "npm:^3.12.8" - "@react-stately/list": "npm:^3.13.1" + "@react-stately/list": "npm:^3.13.2" "@react-types/listbox": "npm:^3.7.4" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e3c0c3e0331ffd5c14fbfdd8f916941c629e6d37399c893d80cc7459c2c583d4c3cbc747a3029d4816dd9fc77926ae25910552da101d54248530bae936a0ae13 + checksum: 10c0/2499e19d780ae09563bb4dd1d00fc36f2dd7bc110e573a2727d6290c4c61d2604939e36697e9edf92c3c3587db49c2680ec06e0932fcddf8ad59059d56bb2d65 languageName: node linkType: hard @@ -8579,20 +8667,20 @@ __metadata: languageName: node linkType: hard -"@react-aria/menu@npm:3.19.3, @react-aria/menu@npm:^3.19.3": - version: 3.19.3 - resolution: "@react-aria/menu@npm:3.19.3" +"@react-aria/menu@npm:3.19.4, @react-aria/menu@npm:^3.19.4": + version: 3.19.4 + resolution: "@react-aria/menu@npm:3.19.4" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/overlays": "npm:^3.30.0" - "@react-aria/selection": "npm:^3.26.0" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/overlays": "npm:^3.31.0" + "@react-aria/selection": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/collections": "npm:^3.12.8" - "@react-stately/menu": "npm:^3.9.8" - "@react-stately/selection": "npm:^3.20.6" - "@react-stately/tree": "npm:^3.9.3" + "@react-stately/menu": "npm:^3.9.9" + "@react-stately/selection": "npm:^3.20.7" + "@react-stately/tree": "npm:^3.9.4" "@react-types/button": "npm:^3.14.1" "@react-types/menu": "npm:^3.10.5" "@react-types/shared": "npm:^3.32.1" @@ -8600,43 +8688,43 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9a2582d8d7eff18d814ddcd3d4a56023c47aecf84bcd52afcc47fedf8bce65b2d81fdd33a5e9b584ef54cbffa6ab7f6377cc8ab783712e5142a5e47b97003423 + checksum: 10c0/c16e4a283ef4275c21e795617046d2d3ac5106bf61fce62244691e7edb64f2ddd3c407e0e1481283484a9e007d42b70449ce53297e30b5547a00d375b6f2a81b languageName: node linkType: hard -"@react-aria/numberfield@npm:3.12.2": - version: 3.12.2 - resolution: "@react-aria/numberfield@npm:3.12.2" +"@react-aria/numberfield@npm:3.12.3": + version: 3.12.3 + resolution: "@react-aria/numberfield@npm:3.12.3" dependencies: - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/spinbutton": "npm:^3.6.19" - "@react-aria/textfield": "npm:^3.18.2" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/spinbutton": "npm:^3.7.0" + "@react-aria/textfield": "npm:^3.18.3" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/form": "npm:^3.2.2" - "@react-stately/numberfield": "npm:^3.10.2" + "@react-stately/numberfield": "npm:^3.10.3" "@react-types/button": "npm:^3.14.1" - "@react-types/numberfield": "npm:^3.8.15" + "@react-types/numberfield": "npm:^3.8.16" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2d88daabf391c78f3464f71994596cf38376c1ab66dc703ac5b6f4d6a7a8da55f6d521133016378ef1c93188632f5aa594fec654b0a432959fb032d421a7f7a1 + checksum: 10c0/4f53d5696eb77eb739d9083c2eba6bbd4d780879170c29b92877b4ee1e0780160b900a1914f6affe3623b53ec90f2f661be5055ad4ea00bfc4d3f5a330d4f80b languageName: node linkType: hard -"@react-aria/overlays@npm:3.30.0, @react-aria/overlays@npm:^3.30.0": - version: 3.30.0 - resolution: "@react-aria/overlays@npm:3.30.0" +"@react-aria/overlays@npm:3.31.0, @react-aria/overlays@npm:^3.31.0": + version: 3.31.0 + resolution: "@react-aria/overlays@npm:3.31.0" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" "@react-aria/ssr": "npm:^3.9.10" - "@react-aria/utils": "npm:^3.31.0" - "@react-aria/visually-hidden": "npm:^3.8.28" - "@react-stately/overlays": "npm:^3.6.20" + "@react-aria/utils": "npm:^3.32.0" + "@react-aria/visually-hidden": "npm:^3.8.29" + "@react-stately/overlays": "npm:^3.6.21" "@react-types/button": "npm:^3.14.1" "@react-types/overlays": "npm:^3.9.2" "@react-types/shared": "npm:^3.32.1" @@ -8644,99 +8732,99 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/239a8e70c33ad61142df213cadf71c5c273f4503054bdf27e72f5a2e1867203bc6e5752ab79afd0e9b8d50038c9adf2e38503b68a903c07c2bca1977c889c4dc + checksum: 10c0/6cdc30d669e42c1a69af33c5c6da37fecae6139be923b4bddb17ac79392a40d62c3f3d67a0437201bddd1cc3b07b2e6d4ffeb5fb285db319427a5552ac4383f0 languageName: node linkType: hard -"@react-aria/progress@npm:3.4.27": - version: 3.4.27 - resolution: "@react-aria/progress@npm:3.4.27" +"@react-aria/progress@npm:3.4.28": + version: 3.4.28 + resolution: "@react-aria/progress@npm:3.4.28" dependencies: - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/utils": "npm:^3.32.0" "@react-types/progress": "npm:^3.5.16" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2269fc7dc55d6b13a380d651b504ed5ba567afc80306193bf96fcf89def88a01853d4601dffb872b0839e8af4674087066b4b6b14c0ab6243b8f82ad09916221 + checksum: 10c0/e70e65d204036d379f21aaac760cf9b820d672655b722b4863b2e6bfca4a6431b3bf8a602433448b4dc738e202d2378fc46efeb06585d3e2e97ea229ba54f013 languageName: node linkType: hard -"@react-aria/radio@npm:3.12.2": - version: 3.12.2 - resolution: "@react-aria/radio@npm:3.12.2" +"@react-aria/radio@npm:3.12.3": + version: 3.12.3 + resolution: "@react-aria/radio@npm:3.12.3" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/form": "npm:^3.1.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/radio": "npm:^3.11.2" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/form": "npm:^3.1.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/radio": "npm:^3.11.3" "@react-types/radio": "npm:^3.9.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7d0e0121dd546e41f34312a7e7d7dcc6b73920bb23d57b17544654aca8b45a00ee78925ac6ba98027dc66b2050af7524a8b275f26a52a3fece6058b88dbf55ba + checksum: 10c0/bf45c98d034e551b0611b1688e12b47655b5f6806f69d948e1cf523761395a41c51c49f01a7b0af74e0c3d86fc3c66f189cd0fdd3f1c0c670193a50eab2f128d languageName: node linkType: hard -"@react-aria/selection@npm:3.26.0, @react-aria/selection@npm:^3.26.0": - version: 3.26.0 - resolution: "@react-aria/selection@npm:3.26.0" +"@react-aria/selection@npm:3.27.0, @react-aria/selection@npm:^3.27.0": + version: 3.27.0 + resolution: "@react-aria/selection@npm:3.27.0" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/selection": "npm:^3.20.6" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/selection": "npm:^3.20.7" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/77e26f4c3f9944b919e36aa6421364ae6c7738fb0d0d0eef1a658c86bf7a0a5d2f69914909064d354b8dd915291ab9b380dce500a886ce549e7b13159b8c20d2 + checksum: 10c0/287713832368162f49217aefaa9a292cfc194d556e37d6212df58087aebbdf56be661c89d98eb3a3a4604e057a263c3cdcff343afa57115b751fdd6f787fc4d8 languageName: node linkType: hard -"@react-aria/slider@npm:3.8.2": - version: 3.8.2 - resolution: "@react-aria/slider@npm:3.8.2" +"@react-aria/slider@npm:3.8.3": + version: 3.8.3 + resolution: "@react-aria/slider@npm:3.8.3" dependencies: - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/slider": "npm:^3.7.2" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/slider": "npm:^3.7.3" "@react-types/shared": "npm:^3.32.1" "@react-types/slider": "npm:^3.8.2" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/5b21bef90d1efe648305d177d033608b116265724327be626d5ed365b2dc8379f0a861a949a38f05a5f6d9abd8838649a644f222ba0607bb1d2ac221d7fc10ed + checksum: 10c0/8e0b1e8d5307d9c874ddd6391d2ddc79e4895addf7dd86d971fd375502bd5e866fb9183202b13929bea927995270e7d766cb6f8d2558a6192325a62efa63e4fd languageName: node linkType: hard -"@react-aria/spinbutton@npm:^3.6.19": - version: 3.6.19 - resolution: "@react-aria/spinbutton@npm:3.6.19" +"@react-aria/spinbutton@npm:^3.7.0": + version: 3.7.0 + resolution: "@react-aria/spinbutton@npm:3.7.0" dependencies: - "@react-aria/i18n": "npm:^3.12.13" + "@react-aria/i18n": "npm:^3.12.14" "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/button": "npm:^3.14.1" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c53ba7aafd27eb2ef3be75e87d0906f26385d15006efa980753ac69d5085665603cf6fee0968ee1e20876813851206064ccd91ae9f8fe511d307e9d5f9817760 + checksum: 10c0/1247ac3610468aa81f2ed862624904412254485623ea0140200e2ef89f8bbaf4fea94b997ee5b7095058b44dea5e2d72c1c94208b02ad62783d6cc4bf9135da8 languageName: node linkType: hard @@ -8751,36 +8839,36 @@ __metadata: languageName: node linkType: hard -"@react-aria/switch@npm:3.7.8": - version: 3.7.8 - resolution: "@react-aria/switch@npm:3.7.8" +"@react-aria/switch@npm:3.7.9": + version: 3.7.9 + resolution: "@react-aria/switch@npm:3.7.9" dependencies: - "@react-aria/toggle": "npm:^3.12.2" - "@react-stately/toggle": "npm:^3.9.2" + "@react-aria/toggle": "npm:^3.12.3" + "@react-stately/toggle": "npm:^3.9.3" "@react-types/shared": "npm:^3.32.1" "@react-types/switch": "npm:^3.5.15" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/dcaf663b046df2db2e4e5bcb55ebe80301319a9ae6191cc83a6e09f53a7975a5da3f6343b3a0a738ad1871f07204d4fee96c1bebf84856d128f1b7191a0b21dc + checksum: 10c0/6475862dea50ef7246940bc0dd28846971061e5d1c580335a43db075fa2c4e0fda98836b2c6689524f8b108f225b704a722224d7605f1fe68b40561a17dcd46d languageName: node linkType: hard -"@react-aria/table@npm:3.17.8": - version: 3.17.8 - resolution: "@react-aria/table@npm:3.17.8" +"@react-aria/table@npm:3.17.9": + version: 3.17.9 + resolution: "@react-aria/table@npm:3.17.9" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/grid": "npm:^3.14.5" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/grid": "npm:^3.14.6" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" "@react-aria/live-announcer": "npm:^3.4.4" - "@react-aria/utils": "npm:^3.31.0" - "@react-aria/visually-hidden": "npm:^3.8.28" + "@react-aria/utils": "npm:^3.32.0" + "@react-aria/visually-hidden": "npm:^3.8.29" "@react-stately/collections": "npm:^3.12.8" "@react-stately/flags": "npm:^3.1.2" - "@react-stately/table": "npm:^3.15.1" + "@react-stately/table": "npm:^3.15.2" "@react-types/checkbox": "npm:^3.10.2" "@react-types/grid": "npm:^3.3.6" "@react-types/shared": "npm:^3.32.1" @@ -8789,57 +8877,57 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d7724dab248984e065b1cf77250382eda8ed0141de7352d8f1c5222119107b6535e66bbd11ec535629efc5987acd8a2037459dff78a0a92a3df007a27896edee + checksum: 10c0/7101b289cd17813f8091da4a064166407c99d5960a0188284b098da4a28f59d02bf51afdc5fdcd4377ac99fd4e9ed10ada59de883a7d465f7f2358e8b8985981 languageName: node linkType: hard -"@react-aria/tabs@npm:3.10.8": - version: 3.10.8 - resolution: "@react-aria/tabs@npm:3.10.8" +"@react-aria/tabs@npm:3.10.9": + version: 3.10.9 + resolution: "@react-aria/tabs@npm:3.10.9" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/selection": "npm:^3.26.0" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/tabs": "npm:^3.8.6" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/selection": "npm:^3.27.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/tabs": "npm:^3.8.7" "@react-types/shared": "npm:^3.32.1" - "@react-types/tabs": "npm:^3.3.19" + "@react-types/tabs": "npm:^3.3.20" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/dfa665a93591a9ffd5a748156377317238706b5ce3ad5ac1837b5ee566ae898f708996ddd072da1fddf70aa2392b2d80ada6dd087fbfe2cda6886efa2c991597 + checksum: 10c0/ad71d9bcb06a150f0686956535aeecc7478d00ef7dd199960750933f8dfb9dc3b9de663f20e535b872839a8a375b91a2633cfb927f5bc8b225f97fecd2c1fb1c languageName: node linkType: hard -"@react-aria/textfield@npm:3.18.2, @react-aria/textfield@npm:^3.18.2": - version: 3.18.2 - resolution: "@react-aria/textfield@npm:3.18.2" +"@react-aria/textfield@npm:3.18.3, @react-aria/textfield@npm:^3.18.3": + version: 3.18.3 + resolution: "@react-aria/textfield@npm:3.18.3" dependencies: - "@react-aria/form": "npm:^3.1.2" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/label": "npm:^3.7.22" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/form": "npm:^3.1.3" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/label": "npm:^3.7.23" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/form": "npm:^3.2.2" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@react-types/textfield": "npm:^3.12.6" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/9233e34eff752f6b2976309138234269805497a5fcccd2511a612e3f508f7f10946efb85783f89dd8a932e617de308e3610d08139d8c83b1b1a714ad0ae6dc71 + checksum: 10c0/a6096702af45e005870ac615d9c02a45114a84449ff9475c0e1735b2ba8e1e33e2ce3c99aa7c0764f2b3ba7eb694fbcf6999339a28e135b57aeeb4dea5ce4627 languageName: node linkType: hard -"@react-aria/toast@npm:3.0.8": - version: 3.0.8 - resolution: "@react-aria/toast@npm:3.0.8" +"@react-aria/toast@npm:3.0.9": + version: 3.0.9 + resolution: "@react-aria/toast@npm:3.0.9" dependencies: - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/landmark": "npm:^3.0.7" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/landmark": "npm:^3.0.8" + "@react-aria/utils": "npm:^3.32.0" "@react-stately/toast": "npm:^3.1.2" "@react-types/button": "npm:^3.14.1" "@react-types/shared": "npm:^3.32.1" @@ -8847,119 +8935,119 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d47c08e62ebff123c056b333acc60325c78afdba0fcf9a4808c30f192196d7650fa601e0a0b4160f70308d0a782b874773f85ca34f6dd4fd235147724aec2684 + checksum: 10c0/56a276f012f2deaac5193bffba9e8293f989953475610361b5b718786783dd9950e14eb9b18248af98358526d5ae32fdbb40e7bd499715613bee7c74ff44e1e2 languageName: node linkType: hard -"@react-aria/toggle@npm:^3.12.2": - version: 3.12.2 - resolution: "@react-aria/toggle@npm:3.12.2" +"@react-aria/toggle@npm:^3.12.3": + version: 3.12.3 + resolution: "@react-aria/toggle@npm:3.12.3" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/toggle": "npm:^3.9.2" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/toggle": "npm:^3.9.3" "@react-types/checkbox": "npm:^3.10.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/523437544cfd8b529f0ac48940412dfa250df55815c745f3257d1801a0f2b402c94ff072e61a89ee5d7b12639874f9613a70b593a07f4c02193b00a9a4b287cf + checksum: 10c0/ec2f6670120f9f1585fa035db67215d4fde948a58f83ef03153798304da152cab2fbffd7a722f153d2cb60411fc5ba5d01527b12c967767d7a2e0bc64c0739ee languageName: node linkType: hard -"@react-aria/toolbar@npm:3.0.0-beta.21": - version: 3.0.0-beta.21 - resolution: "@react-aria/toolbar@npm:3.0.0-beta.21" +"@react-aria/toolbar@npm:3.0.0-beta.22": + version: 3.0.0-beta.22 + resolution: "@react-aria/toolbar@npm:3.0.0-beta.22" dependencies: - "@react-aria/focus": "npm:^3.21.2" - "@react-aria/i18n": "npm:^3.12.13" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/focus": "npm:^3.21.3" + "@react-aria/i18n": "npm:^3.12.14" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/4a1287463b9f17a9ae1262c7ebb96dab6869f528e53e29d46020f0e11376506dc90fcb69329a455f481ce22715c3691b4197fb292ba299420050f41c68730dcf + checksum: 10c0/aebc2e72f2ed9853f81468a8695e4d9688b6b021a940a2af552fee30515cbd261fc1d200c75e8b4ad92a02aa613b26705f6882952b4380fe779449a8fe93811b languageName: node linkType: hard -"@react-aria/tooltip@npm:3.8.8": - version: 3.8.8 - resolution: "@react-aria/tooltip@npm:3.8.8" +"@react-aria/tooltip@npm:3.9.0": + version: 3.9.0 + resolution: "@react-aria/tooltip@npm:3.9.0" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" - "@react-stately/tooltip": "npm:^3.5.8" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" + "@react-stately/tooltip": "npm:^3.5.9" "@react-types/shared": "npm:^3.32.1" - "@react-types/tooltip": "npm:^3.4.21" + "@react-types/tooltip": "npm:^3.5.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/bb9950edaede37b7b480d964a15162e3ddeeb977f87eefb4d542b41e8dde01365ddc00c5f36d0ef0dca13382745e690ce8e4518321d6c754b2528878a8401f43 + checksum: 10c0/374a09a97cd81d75e1326517ce94cb289524b8d6f2734ce9012c13c5506786ad62d31cb2b15d0a2d41928797f813882c5dca382971783c062cd4652903f1e318 languageName: node linkType: hard -"@react-aria/utils@npm:3.31.0, @react-aria/utils@npm:^3.31.0": - version: 3.31.0 - resolution: "@react-aria/utils@npm:3.31.0" +"@react-aria/utils@npm:3.32.0, @react-aria/utils@npm:^3.32.0": + version: 3.32.0 + resolution: "@react-aria/utils@npm:3.32.0" dependencies: "@react-aria/ssr": "npm:^3.9.10" "@react-stately/flags": "npm:^3.1.2" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" clsx: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a6b5c6b85a51fa9ca204f045f70d36a55e16b56b85141d556eaacb7b74c4c0915189f6d2baea06df59bdd2926dcca08c2313c98478dbb50ed8e59f9b6754735c + checksum: 10c0/10fd9b162f8c752bf70070f5e091eaf3bd2c163b0a86e1f29c306c766b6b1acbbefa85c1ed6c28973b858afeafd638faa783361440c679890698c3d78bb50121 languageName: node linkType: hard -"@react-aria/visually-hidden@npm:3.8.28, @react-aria/visually-hidden@npm:^3.8.28": - version: 3.8.28 - resolution: "@react-aria/visually-hidden@npm:3.8.28" +"@react-aria/visually-hidden@npm:3.8.29, @react-aria/visually-hidden@npm:^3.8.29": + version: 3.8.29 + resolution: "@react-aria/visually-hidden@npm:3.8.29" dependencies: - "@react-aria/interactions": "npm:^3.25.6" - "@react-aria/utils": "npm:^3.31.0" + "@react-aria/interactions": "npm:^3.26.0" + "@react-aria/utils": "npm:^3.32.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cda852956ea4dceaced291e8f0c36d2eb99e5ace4eabaf2c0821aaf893cc0219a28a0881c246f7e40a2a716cc868d34bb4b4350e1dff2c7b58c8775ae478f83b + checksum: 10c0/6a5a6cf115bdf2e96cd54210b2fc28884ee70875982ebfc3ff8423e8d887968a1f5d6cf66af27799b3727f9e49c85fb9eb84ec0f81c8ed530eb2b5e5ac617776 languageName: node linkType: hard -"@react-stately/calendar@npm:3.9.0, @react-stately/calendar@npm:^3.9.0": - version: 3.9.0 - resolution: "@react-stately/calendar@npm:3.9.0" +"@react-stately/calendar@npm:3.9.1, @react-stately/calendar@npm:^3.9.1": + version: 3.9.1 + resolution: "@react-stately/calendar@npm:3.9.1" dependencies: - "@internationalized/date": "npm:^3.10.0" - "@react-stately/utils": "npm:^3.10.8" - "@react-types/calendar": "npm:^3.8.0" + "@internationalized/date": "npm:^3.10.1" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/calendar": "npm:^3.8.1" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2139d419ddbc217aed4f0f578b2198b574071e53483e000698acb85bf04a2eef954ba42cd6bafa913edc916df004561d1291e09105e82ddc7ada0b95a0924ca8 + checksum: 10c0/5dde2f2643ca239d356bdffc5a63fd0c3a1b52c9a3eeff9079d4351bbef04b5e199a58df32f4257a21de3645a5036903bdfe9641abe9c0e7bbed78223aaf9367 languageName: node linkType: hard -"@react-stately/checkbox@npm:3.7.2, @react-stately/checkbox@npm:^3.7.2": - version: 3.7.2 - resolution: "@react-stately/checkbox@npm:3.7.2" +"@react-stately/checkbox@npm:3.7.3, @react-stately/checkbox@npm:^3.7.3": + version: 3.7.3 + resolution: "@react-stately/checkbox@npm:3.7.3" dependencies: "@react-stately/form": "npm:^3.2.2" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/checkbox": "npm:^3.10.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7f7d90aec96412d922384d0fc217b643e30c1d456310e4a2d3aa9936f9ac9e09091e5106223faa50119ef7c18bdaf0296c0b601ba096c963d4c1ecd908d6d651 + checksum: 10c0/20b510f50a0be5c70dd1e291498c48b5de10062ee01dc06860003dac378d0445bf3f73cd332591cec57b86e1370355ec1018ec04d00a82f90a6248d44e92c594 languageName: node linkType: hard @@ -8975,39 +9063,39 @@ __metadata: languageName: node linkType: hard -"@react-stately/combobox@npm:3.12.0, @react-stately/combobox@npm:^3.12.0": - version: 3.12.0 - resolution: "@react-stately/combobox@npm:3.12.0" +"@react-stately/combobox@npm:3.12.1, @react-stately/combobox@npm:^3.12.1": + version: 3.12.1 + resolution: "@react-stately/combobox@npm:3.12.1" dependencies: "@react-stately/collections": "npm:^3.12.8" "@react-stately/form": "npm:^3.2.2" - "@react-stately/list": "npm:^3.13.1" - "@react-stately/overlays": "npm:^3.6.20" - "@react-stately/utils": "npm:^3.10.8" - "@react-types/combobox": "npm:^3.13.9" + "@react-stately/list": "npm:^3.13.2" + "@react-stately/overlays": "npm:^3.6.21" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/combobox": "npm:^3.13.10" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/6e1728186020ccd363cb371c77182e4fe5e35513dfb911419cdcad13031b0081d7e5709f63e7362b7c27785c534fad30695b4fff501b069f6c6aa1ecdb7a7605 + checksum: 10c0/abdb03341614614218a121c24bb4dcf44594c482ac54e589833ac0b94e2178f3689aa36a188fa1467b1fbe1e1e23a993bb373088fa3f7eb01638eed15588f850 languageName: node linkType: hard -"@react-stately/datepicker@npm:3.15.2, @react-stately/datepicker@npm:^3.15.2": - version: 3.15.2 - resolution: "@react-stately/datepicker@npm:3.15.2" +"@react-stately/datepicker@npm:3.15.3, @react-stately/datepicker@npm:^3.15.3": + version: 3.15.3 + resolution: "@react-stately/datepicker@npm:3.15.3" dependencies: - "@internationalized/date": "npm:^3.10.0" + "@internationalized/date": "npm:^3.10.1" "@internationalized/string": "npm:^3.2.7" "@react-stately/form": "npm:^3.2.2" - "@react-stately/overlays": "npm:^3.6.20" - "@react-stately/utils": "npm:^3.10.8" - "@react-types/datepicker": "npm:^3.13.2" + "@react-stately/overlays": "npm:^3.6.21" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/datepicker": "npm:^3.13.3" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/3f35c1a4747003e22a6859a4f2c9201a813e5e9ccd9b0723201eb2f270296e1a86cc5c1b3fd30e41f8fbb46462f14ba616717fa5eb4e9cc7597dd75c3bf5cf15 + checksum: 10c0/41a23ec44259fd3e1ba2848532301d0f6420f7fb4635da0e76a55f2a7075ec70b10665ff10bfa9bd3ad6a9c70d085ce0cd65a5d81d3457bdc4e1062282148b38 languageName: node linkType: hard @@ -9032,151 +9120,151 @@ __metadata: languageName: node linkType: hard -"@react-stately/grid@npm:^3.11.6": - version: 3.11.6 - resolution: "@react-stately/grid@npm:3.11.6" +"@react-stately/grid@npm:^3.11.7": + version: 3.11.7 + resolution: "@react-stately/grid@npm:3.11.7" dependencies: "@react-stately/collections": "npm:^3.12.8" - "@react-stately/selection": "npm:^3.20.6" + "@react-stately/selection": "npm:^3.20.7" "@react-types/grid": "npm:^3.3.6" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/b92594035e0b0c98efa1ab4c775ab6b07d6093b41abd1f1313290d24adcdb33f0d079f0836c0ce4456068cab8868b5d727171acba4c4f0bdad651ffd5f3af5e0 + checksum: 10c0/b00bc984a52cdbf077473780fac0c711d99c4c308ac8500ff35b7e7bbd20f20a995af4b810756868e305e35dac53513f0d908cb0637699c33cc7c932953b69ff languageName: node linkType: hard -"@react-stately/list@npm:3.13.1, @react-stately/list@npm:^3.13.1": - version: 3.13.1 - resolution: "@react-stately/list@npm:3.13.1" +"@react-stately/list@npm:3.13.2, @react-stately/list@npm:^3.13.2": + version: 3.13.2 + resolution: "@react-stately/list@npm:3.13.2" dependencies: "@react-stately/collections": "npm:^3.12.8" - "@react-stately/selection": "npm:^3.20.6" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/selection": "npm:^3.20.7" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/7fe61fb69f100930c7e490ca728d85c923f314f2f145415fc6884f39485e56c2ec7582135b691ec278c255b76d216617b4d86dd5bca44c48a9a365605060f0a2 + checksum: 10c0/1611c786cc722af12e6a70dc24bb07c2c09e112a9423c3377aafac63e01f23532c74072cd246793148d858dc36d3a7402c15231aac2ff7616021e7e031fb8e1a languageName: node linkType: hard -"@react-stately/menu@npm:3.9.8, @react-stately/menu@npm:^3.9.8": - version: 3.9.8 - resolution: "@react-stately/menu@npm:3.9.8" +"@react-stately/menu@npm:3.9.9, @react-stately/menu@npm:^3.9.9": + version: 3.9.9 + resolution: "@react-stately/menu@npm:3.9.9" dependencies: - "@react-stately/overlays": "npm:^3.6.20" + "@react-stately/overlays": "npm:^3.6.21" "@react-types/menu": "npm:^3.10.5" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/c30ac98fc8f5bf88287b664e23c1081faf84d46e9f515db5837f06695b152417351fe1c8bcdc9ec3f7e0c42f20a181f65eff5956ab827c2407cacb275fffc1ed + checksum: 10c0/a9a6e9f13849a2ce7192103b9d4c46fb2ef69d6767a0e0e29ac9adcba9dc061aa233e429425c1499fdf60ab7eb378bf24719c3f5b7ff76a8094af7bc77f5baab languageName: node linkType: hard -"@react-stately/numberfield@npm:3.10.2, @react-stately/numberfield@npm:^3.10.2": - version: 3.10.2 - resolution: "@react-stately/numberfield@npm:3.10.2" +"@react-stately/numberfield@npm:3.10.3, @react-stately/numberfield@npm:^3.10.3": + version: 3.10.3 + resolution: "@react-stately/numberfield@npm:3.10.3" dependencies: "@internationalized/number": "npm:^3.6.5" "@react-stately/form": "npm:^3.2.2" - "@react-stately/utils": "npm:^3.10.8" - "@react-types/numberfield": "npm:^3.8.15" + "@react-stately/utils": "npm:^3.11.0" + "@react-types/numberfield": "npm:^3.8.16" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/e51f55fdeea0b58a763edbac5dab816e3774882ff9608f87b1ec3ae3d302d1c644ac325751860ecc4e724e0e24e1e7d690b5775e8ba7b3a067ff99c2f3a06760 + checksum: 10c0/da8f14a3938eaa8025a2a56279fdbe23151cb1e3b0de2d1c817ab50882d3492ab3c056bda9ad806a4b8f2461be0359f183a12944c57b6fe3becf70399b0e454a languageName: node linkType: hard -"@react-stately/overlays@npm:3.6.20, @react-stately/overlays@npm:^3.6.20": - version: 3.6.20 - resolution: "@react-stately/overlays@npm:3.6.20" +"@react-stately/overlays@npm:3.6.21, @react-stately/overlays@npm:^3.6.21": + version: 3.6.21 + resolution: "@react-stately/overlays@npm:3.6.21" dependencies: - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/overlays": "npm:^3.9.2" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/2f65e7bae0fbdd265937be00a3331f37f4b2f6ba0d0c0dfad5fb1f020cdc6fc86f498a6821e90f5d538b1c601860075819ab00f751e2eb6d05259fbdfaa6faa9 + checksum: 10c0/1f52664e8a21f2841b3818e35588188ac86f03b783c8caa47290abc7582a778ff06416071fd504429e6c808ea13fdf004681f5d2e001955da57c5310daf959bc languageName: node linkType: hard -"@react-stately/radio@npm:3.11.2, @react-stately/radio@npm:^3.11.2": - version: 3.11.2 - resolution: "@react-stately/radio@npm:3.11.2" +"@react-stately/radio@npm:3.11.3, @react-stately/radio@npm:^3.11.3": + version: 3.11.3 + resolution: "@react-stately/radio@npm:3.11.3" dependencies: "@react-stately/form": "npm:^3.2.2" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/radio": "npm:^3.9.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/00898d2b221f2312881c0c5297328049025ea0682257e81c1f0b084b64756411bba5016758907efc92bb4b3c695671040252558ca407538bf0559460afa4bad9 + checksum: 10c0/77d8acf82b07f778c6c6655168543945ea842d365c99734f1c60ddf968169fa06ddbcc932a7733c336a679a53a175ac3a23620175f087a4619a6028bd543a91c languageName: node linkType: hard -"@react-stately/selection@npm:^3.20.6": - version: 3.20.6 - resolution: "@react-stately/selection@npm:3.20.6" +"@react-stately/selection@npm:^3.20.7": + version: 3.20.7 + resolution: "@react-stately/selection@npm:3.20.7" dependencies: "@react-stately/collections": "npm:^3.12.8" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/506f6f1668ca381f23b449197dabc7d27fc0d621fd432ce5b46c419c7699259ba9f0a8f815b2f70724e8d6f35b94a04ef7b7968eb3b3754a23059c0035d67601 + checksum: 10c0/5cee2680b35632868d8049b9a80440c0367795d6a6bcd183fe10e2e889185c1eaadf1538014eff8a1f39a8553c6c43198940c66a5a357a3e074f550346cf1f82 languageName: node linkType: hard -"@react-stately/slider@npm:3.7.2, @react-stately/slider@npm:^3.7.2": - version: 3.7.2 - resolution: "@react-stately/slider@npm:3.7.2" +"@react-stately/slider@npm:3.7.3, @react-stately/slider@npm:^3.7.3": + version: 3.7.3 + resolution: "@react-stately/slider@npm:3.7.3" dependencies: - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@react-types/slider": "npm:^3.8.2" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/f1b0a2d36790f1609b268b561ccfa3873ac589df50e85efb6afef953b61e3e66f89729e10d14f2b267e03d66885560c401301d576891bc457c063bbb99da66cb + checksum: 10c0/725d7ff07b48086b0c0d1dddb61c6ea3a7f4effd6845a13a4e45c81126e368825f4d1586d067b82f0a85b4bbb5aa9d0aaf20622721d1dd69802361c221f6f572 languageName: node linkType: hard -"@react-stately/table@npm:3.15.1, @react-stately/table@npm:^3.15.1": - version: 3.15.1 - resolution: "@react-stately/table@npm:3.15.1" +"@react-stately/table@npm:3.15.2, @react-stately/table@npm:^3.15.2": + version: 3.15.2 + resolution: "@react-stately/table@npm:3.15.2" dependencies: "@react-stately/collections": "npm:^3.12.8" "@react-stately/flags": "npm:^3.1.2" - "@react-stately/grid": "npm:^3.11.6" - "@react-stately/selection": "npm:^3.20.6" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/grid": "npm:^3.11.7" + "@react-stately/selection": "npm:^3.20.7" + "@react-stately/utils": "npm:^3.11.0" "@react-types/grid": "npm:^3.3.6" "@react-types/shared": "npm:^3.32.1" "@react-types/table": "npm:^3.13.4" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/d2371234a2e13607e7819680ed6ee3b4e697e231620532b84c048aee11b34db58d035ba1f098cf5c52c7a05970d7c315851d2ada5677e8c551c8992757a9796a + checksum: 10c0/e7ae6e2d9606cf45c7c1c870e12fe13ac66e0321aa905adf1b1fba2e10caccbc9b93cd70890ba54363a602fcc38a2e429f8464b86e9ad3de17448a55c51e6dd3 languageName: node linkType: hard -"@react-stately/tabs@npm:3.8.6, @react-stately/tabs@npm:^3.8.6": - version: 3.8.6 - resolution: "@react-stately/tabs@npm:3.8.6" +"@react-stately/tabs@npm:3.8.7, @react-stately/tabs@npm:^3.8.7": + version: 3.8.7 + resolution: "@react-stately/tabs@npm:3.8.7" dependencies: - "@react-stately/list": "npm:^3.13.1" + "@react-stately/list": "npm:^3.13.2" "@react-types/shared": "npm:^3.32.1" - "@react-types/tabs": "npm:^3.3.19" + "@react-types/tabs": "npm:^3.3.20" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/704af4cc8befc11f6d37387a3d6a9c9d08e8fb00b2b035be79076ac98a3112613b0409a806211db43a92a1df66ae27a8ac781b6c62270b45a9a4c470fd9b3526 + checksum: 10c0/6cf902c193d0557909a107ecc1283841c64f56519f98ff0c20b83bcc94c19d53ecbb00a5132346988f950fe5afef4ac0ae14d63d9f272ed1c330a47c31990fbc languageName: node linkType: hard @@ -9192,56 +9280,56 @@ __metadata: languageName: node linkType: hard -"@react-stately/toggle@npm:3.9.2, @react-stately/toggle@npm:^3.9.2": - version: 3.9.2 - resolution: "@react-stately/toggle@npm:3.9.2" +"@react-stately/toggle@npm:3.9.3, @react-stately/toggle@npm:^3.9.3": + version: 3.9.3 + resolution: "@react-stately/toggle@npm:3.9.3" dependencies: - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/utils": "npm:^3.11.0" "@react-types/checkbox": "npm:^3.10.2" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/cfa2fa77a3c77d5da0fa947570eab37f4bf78d5d5694efb39f4ded6c08fb7a6b57c3869584538b0a38b2902256b66b7e8113a7a9299da6dad2d1b8db2c37f856 + checksum: 10c0/d7b7b4ec1ade79d5c600e62454aefc552a74013197ab4fa236296d426b93fcdaecccebb1b6ea7658c6ffc08448e5787f9d25e9c40f971a4644eb8e142fd830c2 languageName: node linkType: hard -"@react-stately/tooltip@npm:3.5.8, @react-stately/tooltip@npm:^3.5.8": - version: 3.5.8 - resolution: "@react-stately/tooltip@npm:3.5.8" +"@react-stately/tooltip@npm:3.5.9, @react-stately/tooltip@npm:^3.5.9": + version: 3.5.9 + resolution: "@react-stately/tooltip@npm:3.5.9" dependencies: - "@react-stately/overlays": "npm:^3.6.20" - "@react-types/tooltip": "npm:^3.4.21" + "@react-stately/overlays": "npm:^3.6.21" + "@react-types/tooltip": "npm:^3.5.0" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/80ccff781082e6efe7035cb92a0b7c2365f911c78a0339182732a5446100a259e21343feb487d1c6163038430036bb856b2c6312f4c8fdcfaef81ee0a7eca022 + checksum: 10c0/64dbf61d673bd6d9c27a8b1e01844ceeb0979ca6a4408a8361f6de538c2b8b0d5398786ae20b6426a59bacca23d5c6d451c7f9b05bf78cf516b9cc14686c1aa0 languageName: node linkType: hard -"@react-stately/tree@npm:3.9.3, @react-stately/tree@npm:^3.9.3": - version: 3.9.3 - resolution: "@react-stately/tree@npm:3.9.3" +"@react-stately/tree@npm:3.9.4, @react-stately/tree@npm:^3.9.4": + version: 3.9.4 + resolution: "@react-stately/tree@npm:3.9.4" dependencies: "@react-stately/collections": "npm:^3.12.8" - "@react-stately/selection": "npm:^3.20.6" - "@react-stately/utils": "npm:^3.10.8" + "@react-stately/selection": "npm:^3.20.7" + "@react-stately/utils": "npm:^3.11.0" "@react-types/shared": "npm:^3.32.1" "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/6147d20c389f1ccc48135cc96a1780555153b557482e53c7b64451e304a8916dc2b7d94515768c8e8cc2dcf742560d8d20fb069fd49e229c84414a79418d41ac + checksum: 10c0/0c5e82caedf2d9d9e9d096e7f2650ceace40daf114b15e3ed491e76d3651dc7f742cff6dcb5599578ab4b88a077aac94a1bba06f6c0a32394e3cf9fe9848b7fa languageName: node linkType: hard -"@react-stately/utils@npm:3.10.8, @react-stately/utils@npm:^3.10.8": - version: 3.10.8 - resolution: "@react-stately/utils@npm:3.10.8" +"@react-stately/utils@npm:3.11.0, @react-stately/utils@npm:^3.11.0": + version: 3.11.0 + resolution: "@react-stately/utils@npm:3.11.0" dependencies: "@swc/helpers": "npm:^0.5.0" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/a97cc292986e3eeb2ceb1626671ce60e8342a3ff35ab92bcfcb94bd6b28729836cc592e3fe4df2fba603e5fdd26291be77b7f60441920298c282bb93f424feba + checksum: 10c0/09b38438df19fd8ff14d3147b2f9e5d42869b3ee637b0e33d6f2ab5cba93612e640c6de339b766b8c825d7bef828851fd551d5a197a037eb1331913546b8516c languageName: node linkType: hard @@ -9292,15 +9380,15 @@ __metadata: languageName: node linkType: hard -"@react-types/calendar@npm:3.8.0, @react-types/calendar@npm:^3.8.0": - version: 3.8.0 - resolution: "@react-types/calendar@npm:3.8.0" +"@react-types/calendar@npm:3.8.1, @react-types/calendar@npm:^3.8.1": + version: 3.8.1 + resolution: "@react-types/calendar@npm:3.8.1" dependencies: - "@internationalized/date": "npm:^3.10.0" + "@internationalized/date": "npm:^3.10.1" "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/8119bdf2225c3afd49df3bf27250f996fded894952bea3989f8578e78ae567da6b1f02895fed5e13276a13686f8fed64aa40632d89a888c97f27d17565df37c6 + checksum: 10c0/083f29679360bef8ab4ae2ddb7ac0ca828fe84f23938ba901f3b9560c20bff1294ae79e5222b842869c303e556156ab1a72639ee2ed345f696c0e16c7ac78987 languageName: node linkType: hard @@ -9315,28 +9403,28 @@ __metadata: languageName: node linkType: hard -"@react-types/combobox@npm:3.13.9, @react-types/combobox@npm:^3.13.9": - version: 3.13.9 - resolution: "@react-types/combobox@npm:3.13.9" +"@react-types/combobox@npm:3.13.10, @react-types/combobox@npm:^3.13.10": + version: 3.13.10 + resolution: "@react-types/combobox@npm:3.13.10" dependencies: "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/0ad5a0502f8baa423fea9f68cbb88761aaf38d71e43318b5471340facc9e415ba1e48e5795d177d56aff0984ea44ed116d5e10a8e51951a48f0b68b9bda232ac + checksum: 10c0/106eaeabcb62f1dcc6d55a8d4cdd03876b677699a79e7b33e07c45222d43909de79ef81b7746d2f616a958253b8ad5bb5dc8f9c581e1fed628449f63321d556c languageName: node linkType: hard -"@react-types/datepicker@npm:3.13.2, @react-types/datepicker@npm:^3.13.2": - version: 3.13.2 - resolution: "@react-types/datepicker@npm:3.13.2" +"@react-types/datepicker@npm:3.13.3, @react-types/datepicker@npm:^3.13.3": + version: 3.13.3 + resolution: "@react-types/datepicker@npm:3.13.3" dependencies: - "@internationalized/date": "npm:^3.10.0" - "@react-types/calendar": "npm:^3.8.0" + "@internationalized/date": "npm:^3.10.1" + "@react-types/calendar": "npm:^3.8.1" "@react-types/overlays": "npm:^3.9.2" "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1117cf22f5c60947cc8524ba1fa99948734aa2e050a0c03f55d9dfa8c57da70ae78dda28a885c7b825373f58b93c8c5923b884874c61a58ea10ff41724cd16bd + checksum: 10c0/2a8c429f0f4e04049ce875b3784b25ada8a9a6f52e67b3f300d08cff7fefbbcd7bdb5e87142a1038320c8da9dfe704e2d43f64bce03fd5c96d4bbcd2f81d202b languageName: node linkType: hard @@ -9408,14 +9496,14 @@ __metadata: languageName: node linkType: hard -"@react-types/numberfield@npm:3.8.15, @react-types/numberfield@npm:^3.8.15": - version: 3.8.15 - resolution: "@react-types/numberfield@npm:3.8.15" +"@react-types/numberfield@npm:3.8.16, @react-types/numberfield@npm:^3.8.16": + version: 3.8.16 + resolution: "@react-types/numberfield@npm:3.8.16" dependencies: "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/1a21e20f16d0b7e584f6202cf1e5a9739be3a55ab65f55a2278861adaa5b36276480b080b439cb815dc69dd42a407855260ae3171ef28284657a0d60fdfcf11a + checksum: 10c0/ad117da17113fe720535e228427a4a002c4ac6c13aac5c8ffaecb6da58951ce60386fa68828e6ef56dfc1be1fb6f488daaad966c478b43489373c7503cb5bd61 languageName: node linkType: hard @@ -9495,14 +9583,14 @@ __metadata: languageName: node linkType: hard -"@react-types/tabs@npm:^3.3.19": - version: 3.3.19 - resolution: "@react-types/tabs@npm:3.3.19" +"@react-types/tabs@npm:^3.3.20": + version: 3.3.20 + resolution: "@react-types/tabs@npm:3.3.20" dependencies: "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/fe5d635a2475b9e7c1be3e4e6b80a5c2cba8dade042f9381413d99533bc2e508a0ffa022e0326af009d6d127f22d64352c6ab445ec7de08867b008e9de03e677 + checksum: 10c0/3f8e45d96923456c74b3224552f49481954ceb4d38e3bdd9d3073da7e7e04d6223891355d046688a88e32e1dce148555f0225cfeb377af6fcaf685a9f4697b4d languageName: node linkType: hard @@ -9517,15 +9605,15 @@ __metadata: languageName: node linkType: hard -"@react-types/tooltip@npm:3.4.21, @react-types/tooltip@npm:^3.4.21": - version: 3.4.21 - resolution: "@react-types/tooltip@npm:3.4.21" +"@react-types/tooltip@npm:3.5.0, @react-types/tooltip@npm:^3.5.0": + version: 3.5.0 + resolution: "@react-types/tooltip@npm:3.5.0" dependencies: "@react-types/overlays": "npm:^3.9.2" "@react-types/shared": "npm:^3.32.1" peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - checksum: 10c0/db48d727657c6b654f4de40584adffcaf5f887bfb383d4c0cdfbf7b766a53d7bebf131cbf3622a2233b85ab4a15021230d254dab3a76cb9f1701e8a82cfa1ec2 + checksum: 10c0/2b24b4eee393e6d329f1bdd9cbd6c0b1aadff2f68310bbed14dcd1e785afd0abeb248c3fd1cd93f647dbcc3a3d61db95d2cb1c1b6a9696603e269e6edf647b69 languageName: node linkType: hard @@ -9601,30 +9689,30 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.45" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-android-arm64@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-android-arm64@npm:1.0.0-beta.44" +"@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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.45" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-darwin-arm64@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-beta.44" +"@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 @@ -9636,16 +9724,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.45" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-darwin-x64@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-beta.44" +"@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 @@ -9657,16 +9745,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.45" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-freebsd-x64@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-beta.44" +"@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 @@ -9678,16 +9766,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.45" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-beta.44" +"@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 @@ -9699,16 +9787,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.45" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-beta.44" +"@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 @@ -9720,16 +9808,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.45" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-beta.44" +"@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 @@ -9741,16 +9829,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.45" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-beta.44" +"@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 @@ -9762,16 +9850,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.45" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-beta.44" +"@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 @@ -9783,34 +9871,34 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.45" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-beta.44" +"@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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.45" dependencies: - "@napi-rs/wasm-runtime": "npm:^1.0.3" + "@napi-rs/wasm-runtime": "npm:^1.0.7" conditions: cpu=wasm32 languageName: node linkType: hard -"@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-beta.44" +"@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.7" + "@napi-rs/wasm-runtime": "npm:^1.1.0" conditions: cpu=wasm32 languageName: node linkType: hard @@ -9824,16 +9912,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.45" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-beta.44" +"@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 @@ -9845,16 +9933,9 @@ __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.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.44" +"@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-win32-ia32-msvc@npm:1.0.0-beta.45" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -9866,16 +9947,16 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.45" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-beta.44" +"@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 @@ -9894,17 +9975,17 @@ __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.45": + version: 1.0.0-beta.45 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.45" + checksum: 10c0/54f067b07beca4f416dcc4a126718138f0da2efb29c4cf20f688d18eadfc66e69d859a7f551ecde2c58abbb9a03eafc675d5f1a1430ae2f67c61ecbfe59d1d7c languageName: node linkType: hard -"@rolldown/pluginutils@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "@rolldown/pluginutils@npm:1.0.0-beta.44" - checksum: 10c0/945edb7883cc2a2ae2d139b9cb94093b318ec92757a3f7056b343f1cbfd4a76a5ba75a7a1043e9cb579eaeff362b20df2282c8112517580811f94385b2fffcf9 +"@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 @@ -10125,7 +10206,19 @@ __metadata: languageName: node linkType: hard -"@smithy/eventstream-codec@npm:^4.0.1, @smithy/eventstream-codec@npm:^4.2.2": +"@smithy/eventstream-codec@npm:^4.0.1": + version: 4.0.5 + resolution: "@smithy/eventstream-codec@npm:4.0.5" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@smithy/types": "npm:^4.3.2" + "@smithy/util-hex-encoding": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/d94928e22468cb6e6d09bdc8a6ee04f05947c141c0b040aa90e95b6edc123ba03a562ff3994b5827c57295981183325ed8e8f6c60448a4eec392227735e86d62 + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^4.2.2": version: 4.2.2 resolution: "@smithy/eventstream-codec@npm:4.2.2" dependencies: @@ -10247,6 +10340,15 @@ __metadata: languageName: node linkType: hard +"@smithy/is-array-buffer@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/is-array-buffer@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/ae393fbd5944d710443cd5dd225d1178ef7fb5d6259c14f3e1316ec75e401bda6cf86f7eb98bfd38e5ed76e664b810426a5756b916702cbd418f0933e15e7a3b + languageName: node + linkType: hard + "@smithy/is-array-buffer@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/is-array-buffer@npm:4.2.0" @@ -10507,6 +10609,16 @@ __metadata: languageName: node linkType: hard +"@smithy/util-buffer-from@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-buffer-from@npm:4.0.0" + dependencies: + "@smithy/is-array-buffer": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/be7cd33b6cb91503982b297716251e67cdca02819a15797632091cadab2dc0b4a147fff0709a0aa9bbc0b82a2644a7ed7c8afdd2194d5093cee2e9605b3a9f6f + languageName: node + linkType: hard + "@smithy/util-buffer-from@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/util-buffer-from@npm:4.2.0" @@ -10564,6 +10676,15 @@ __metadata: languageName: node linkType: hard +"@smithy/util-hex-encoding@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-hex-encoding@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/70dbb3aa1a79aff3329d07a66411ff26398df338bdd8a6d077b438231afe3dc86d9a7022204baddecd8bc633f059d5c841fa916d81dd7447ea79b64148f386d2 + languageName: node + linkType: hard + "@smithy/util-hex-encoding@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/util-hex-encoding@npm:4.2.0" @@ -10629,7 +10750,17 @@ __metadata: languageName: node linkType: hard -"@smithy/util-utf8@npm:^4.0.0, @smithy/util-utf8@npm:^4.2.0": +"@smithy/util-utf8@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-utf8@npm:4.0.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/28a5a5372cbf0b3d2e32dd16f79b04c2aec6f704cf13789db922e9686fde38dde0171491cfa4c2c201595d54752a319faaeeed3c325329610887694431e28c98 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^4.2.0": version: 4.2.0 resolution: "@smithy/util-utf8@npm:4.2.0" dependencies: @@ -10659,13 +10790,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" @@ -10674,55 +10798,56 @@ __metadata: linkType: hard "@storybook/addon-docs@npm:^10.0.5": - version: 10.0.5 - resolution: "@storybook/addon-docs@npm:10.0.5" + version: 10.1.10 + resolution: "@storybook/addon-docs@npm:10.1.10" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/csf-plugin": "npm:10.0.5" - "@storybook/icons": "npm:^1.6.0" - "@storybook/react-dom-shim": "npm:10.0.5" + "@storybook/csf-plugin": "npm:10.1.10" + "@storybook/icons": "npm:^2.0.0" + "@storybook/react-dom-shim": "npm:10.1.10" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^10.0.5 - checksum: 10c0/fcaebcc8e67ca7c3b9c3a643e67aee0adbe397fbd400e5fc2287f226a7fab7bcd9c255394d2068b8c396de927d6d6b062653bddfd01fc0a02eb86d00ddec5700 + storybook: ^10.1.10 + checksum: 10c0/b6560b9f61c0fe7257325ae72ac88b9b001f909b25b18378a622b8cba799bfa1b887ec48a46d71e76da7e4425fe4c9d9b5fa10f3405c72e7141c053de44fe6ca languageName: node linkType: hard "@storybook/addon-themes@npm:^10.0.5": - version: 10.0.5 - resolution: "@storybook/addon-themes@npm:10.0.5" + version: 10.1.10 + resolution: "@storybook/addon-themes@npm:10.1.10" dependencies: ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^10.0.5 - checksum: 10c0/4296a9275882232db303e0c9af3475deb1192df22797d1c350a02117c4b20a952b721b1e176451238fc4acc640767234648ea0dc5221d5ceb9085b63808a2ab2 + storybook: ^10.1.10 + checksum: 10c0/93a9ce28e4d5b003587176202c31183a2a99a209bd3353c2a4b4dc36a835300bf9b2478c68adb2a711ab42df40caa647792e9d74331b33bcc4cf80a57f4b9b55 languageName: node linkType: hard -"@storybook/builder-vite@npm:10.0.5": - version: 10.0.5 - resolution: "@storybook/builder-vite@npm:10.0.5" +"@storybook/builder-vite@npm:10.1.10": + version: 10.1.10 + resolution: "@storybook/builder-vite@npm:10.1.10" dependencies: - "@storybook/csf-plugin": "npm:10.0.5" + "@storybook/csf-plugin": "npm:10.1.10" + "@vitest/mocker": "npm:3.2.4" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^10.0.5 + storybook: ^10.1.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/13791b246dcbdd55dcf837b2b63e28820a828015dc2872ae4c539e045bd86c924315e9ce6981622ff076a1819aeadc18c25ae234c3b1e1a5994453a0d74364b3 + checksum: 10c0/06f939eceaea47517db5facba3ece4887988c09b87bae788c6802526660946122a89d7ce316b52047c1062026d7a62c66ce9b2ad20338ffbb37df57337566422 languageName: node linkType: hard -"@storybook/csf-plugin@npm:10.0.5": - version: 10.0.5 - resolution: "@storybook/csf-plugin@npm:10.0.5" +"@storybook/csf-plugin@npm:10.1.10": + version: 10.1.10 + resolution: "@storybook/csf-plugin@npm:10.1.10" dependencies: unplugin: "npm:^2.3.5" peerDependencies: esbuild: "*" rollup: "*" - storybook: ^10.0.5 + storybook: ^10.1.10 vite: "*" webpack: "*" peerDependenciesMeta: @@ -10734,7 +10859,7 @@ __metadata: optional: true webpack: optional: true - checksum: 10c0/e862f0b82433bd60278733656bc9675c00faf6c27cfa43d890c555e3880b83e7e94775a16c7ed7d6aaf891af54829dd36780eb6e4a2d0dddbdd14fe186e1276b + checksum: 10c0/0580f6a485cf1e48da903d7809c6e6de9758530d97b11073fbc98ca96e0d73060803d074a6b8fa4267e6710a0d65818acc8ecca159cd645d481470ab8fc4c41f languageName: node linkType: hard @@ -10745,35 +10870,35 @@ __metadata: languageName: node linkType: hard -"@storybook/icons@npm:^1.6.0": - version: 1.6.0 - resolution: "@storybook/icons@npm:1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - checksum: 10c0/bbec9201a78a730195f9cf377b15856dc414a54d04e30d16c379d062425cc617bfd0d6586ba1716012cfbdab461f0c9693a6a52920f9bd09c7b4291fb116f59c - languageName: node - linkType: hard - -"@storybook/react-dom-shim@npm:10.0.5": - version: 10.0.5 - resolution: "@storybook/react-dom-shim@npm:10.0.5" +"@storybook/icons@npm:^2.0.0": + version: 2.0.1 + resolution: "@storybook/icons@npm:2.0.1" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.5 - checksum: 10c0/85994c02df2f0d95076ad4c5c7ce53db6d9c1ce7c28afeb9f9afad155150097dda0e28c0bc4ebe4f7099fec06d1f1697610dfb0dd3435b94e2ff24900055b657 + checksum: 10c0/df2bbf1a5b50f12ab1bf78cae6de4dbf7c49df0e3a5f845553b51b20adbe8386a09fd172ea60342379f9284bb528cba2d0e2659cae6eb8d015cf92c8b32f1222 + languageName: node + linkType: hard + +"@storybook/react-dom-shim@npm:10.1.10": + version: 10.1.10 + resolution: "@storybook/react-dom-shim@npm:10.1.10" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + storybook: ^10.1.10 + checksum: 10c0/78bc51e99f538b6bff76c2753733b84e18c8234dd0fad90da9ce280cdb68a505181da813f8a364814113e9c0857bee412bad4d1d3690c4d5005ab9b243aa8f11 languageName: node linkType: hard "@storybook/react-vite@npm:^10.0.5": - version: 10.0.5 - resolution: "@storybook/react-vite@npm:10.0.5" + version: 10.1.10 + resolution: "@storybook/react-vite@npm:10.1.10" dependencies: - "@joshwooding/vite-plugin-react-docgen-typescript": "npm:0.6.1" + "@joshwooding/vite-plugin-react-docgen-typescript": "npm:^0.6.3" "@rollup/pluginutils": "npm:^5.0.2" - "@storybook/builder-vite": "npm:10.0.5" - "@storybook/react": "npm:10.0.5" + "@storybook/builder-vite": "npm:10.1.10" + "@storybook/react": "npm:10.1.10" empathic: "npm:^2.0.0" magic-string: "npm:^0.30.0" react-docgen: "npm:^8.0.0" @@ -10782,27 +10907,28 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.5 + storybook: ^10.1.10 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 - checksum: 10c0/810d8448c8fcb8d03951d246ce10f93dbafa6d45287e22849e9b377f1bd8d23638d7a39961a5e03b15f350029888fbe2b60473efaa8a851a1853c60a17ccd231 + checksum: 10c0/7f228603176fab1bba0ca1a5d69905908060a4b60dc81a3bf2c26d24840c248d61fa7a82c36a50ca2a6cdccc524041fcfeadee10d2bcafad11c7cbda159b3a08 languageName: node linkType: hard -"@storybook/react@npm:10.0.5": - version: 10.0.5 - resolution: "@storybook/react@npm:10.0.5" +"@storybook/react@npm:10.1.10": + version: 10.1.10 + resolution: "@storybook/react@npm:10.1.10" dependencies: "@storybook/global": "npm:^5.0.0" - "@storybook/react-dom-shim": "npm:10.0.5" + "@storybook/react-dom-shim": "npm:10.1.10" + react-docgen: "npm:^8.0.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.0.5 + storybook: ^10.1.10 typescript: ">= 4.9.x" peerDependenciesMeta: typescript: optional: true - checksum: 10c0/b251a5c6f9ca781fc795ed4d5bd398d9f4d7b6eb840fa84c3c27afe2493e51d535e1b4033c755c272651fbb1cf34c8f3aba357df62e69188abf3d68c35b35152 + checksum: 10c0/c39d47a7c43f6e8592e5b90831a104b921e90a0c0a78e4e264979a8319c57164b49af1423a593823698648d1157f4143aa10dc3761c40e67ed9e0f163cbe0532 languageName: node linkType: hard @@ -11097,11 +11223,11 @@ __metadata: linkType: hard "@swc/helpers@npm:^0.5.0": - version: 0.5.17 - resolution: "@swc/helpers@npm:0.5.17" + version: 0.5.18 + resolution: "@swc/helpers@npm:0.5.18" dependencies: tslib: "npm:^2.8.0" - checksum: 10c0/fe1f33ebb968558c5a0c595e54f2e479e4609bff844f9ca9a2d1ffd8dd8504c26f862a11b031f48f75c95b0381c2966c3dd156e25942f90089badd24341e7dbb + checksum: 10c0/cb32d72e32f775c30287bffbcf61c89ea3a963608cb3a4a675a3f9af545b8b3ab0bc9930432a5520a7307daaa87538158e253584ae1cf39f3e7e6e83408a2d51 languageName: node linkType: hard @@ -12068,7 +12194,16 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.0, @tybys/wasm-util@npm:^0.10.1": +"@tybys/wasm-util@npm:^0.10.0": + version: 0.10.0 + resolution: "@tybys/wasm-util@npm:0.10.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/044feba55c1e2af703aa4946139969badb183ce1a659a75ed60bc195a90e73a3f3fc53bcd643497c9954597763ddb051fec62f80962b2ca6fc716ba897dc696e + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.1": version: 0.10.1 resolution: "@tybys/wasm-util@npm:0.10.1" dependencies: @@ -12181,7 +12316,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: @@ -12754,7 +12889,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*, @types/node@npm:>=13.7.0": +"@types/node@npm:*, @types/node@npm:>=13.7.0, @types/node@npm:^22.7.5, @types/node@npm:^22.7.7": version: 22.15.29 resolution: "@types/node@npm:22.15.29" dependencies: @@ -12763,15 +12898,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" @@ -12781,7 +12907,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.17.1, @types/node@npm:^22.7.5, @types/node@npm:^22.7.7": +"@types/node@npm:^22.17.1": version: 22.17.2 resolution: "@types/node@npm:22.17.2" dependencies: @@ -12946,13 +13072,13 @@ __metadata: linkType: hard "@types/styled-components@npm:^5.1.34": - version: 5.1.34 - resolution: "@types/styled-components@npm:5.1.34" + version: 5.1.36 + resolution: "@types/styled-components@npm:5.1.36" dependencies: "@types/hoist-non-react-statics": "npm:*" "@types/react": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10c0/5bce93ea2c6161fc45daaf863eefdc20672e839ae486597c40b95e7978e249c160c1bc9706f56cb5152a7ef63cf485d15a9502889169ef945281f511e4b2d5a0 + csstype: "npm:^3.2.2" + checksum: 10c0/bbbf65e22a852a5bc3e1839aa591372febe084e33b8d4f44712f790e0675df9f5a327c6c6a262bbf5fd08032afe2ac9b265de92c72efca0bfa5dea09d7127c28 languageName: node linkType: hard @@ -13107,20 +13233,20 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/project-service@npm:8.46.2" +"@typescript-eslint/project-service@npm:8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/project-service@npm:8.50.1" dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.46.2" - "@typescript-eslint/types": "npm:^8.46.2" + "@typescript-eslint/tsconfig-utils": "npm:^8.50.1" + "@typescript-eslint/types": "npm:^8.50.1" debug: "npm:^4.3.4" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/03e87bcbca6af3f95bf54d4047a8b4d12434126c27d7312e804499a9459e1c847fe045f83fe8e3b22c3dc3925baad0aa2a1a5476d0d51f73a493dc5909a53dbf + checksum: 10c0/50fee0882188c2d704deddfb39f5283618adf7e5f72418143e9f69a8f3771233d55a3e0fc2673fa09c62e230ec53e500f95c0f1ed331ffac5f6a7f8e7b7a2e8c languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.30.1": +"@typescript-eslint/scope-manager@npm:8.30.1, @typescript-eslint/scope-manager@npm:^8.30.1": version: 8.30.1 resolution: "@typescript-eslint/scope-manager@npm:8.30.1" dependencies: @@ -13130,22 +13256,22 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.46.2, @typescript-eslint/scope-manager@npm:^8.30.1": - version: 8.46.2 - resolution: "@typescript-eslint/scope-manager@npm:8.46.2" +"@typescript-eslint/scope-manager@npm:8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/scope-manager@npm:8.50.1" dependencies: - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" - checksum: 10c0/42f52ee621a3a0ef2233e7d3384d9dbd76218f5c906a9cce3152a1f55c060a3d3614c7b8fff5270bdf48e8fcc003e732d3f003f283ea6fb204d64a2f6bb3ea9c + "@typescript-eslint/types": "npm:8.50.1" + "@typescript-eslint/visitor-keys": "npm:8.50.1" + checksum: 10c0/ef0df092745f5d4e3684a3d770dc47735ab3195456de4ac5825931aeed1857a7e8d7cec14cc9c78c5ed049b3d83b0f8ac43b9463c5032ba548558a06bebb5539 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.46.2, @typescript-eslint/tsconfig-utils@npm:^8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.46.2" +"@typescript-eslint/tsconfig-utils@npm:8.50.1, @typescript-eslint/tsconfig-utils@npm:^8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.50.1" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/23e34ad296347417e42234945138022fb045d180fde69941483884a38e85fa55d5449420d2a660c0ebf1794a445add2f13e171c8dd64e4e83f594e2c4e35bf4d + checksum: 10c0/6a1ffb0cd2d9e820ed0c7555a43ebb21438ca80f26c9632e0753bd09e764d9b8e9a352215e4ae60f6d570ab1e77751c9460a00515648b9a2f13f56c56a068a94 languageName: node linkType: hard @@ -13164,21 +13290,21 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.30.1": +"@typescript-eslint/types@npm:8.30.1, @typescript-eslint/types@npm:^8.30.1": version: 8.30.1 resolution: "@typescript-eslint/types@npm:8.30.1" checksum: 10c0/461e800bf911c24d9b61bdbeed897921454acc0c24b4e8a79f943c14234241828c13a31dce31dcce77511185f806a2fb94769075e122e3182ba5a32dd55573eb languageName: node linkType: hard -"@typescript-eslint/types@npm:8.46.2, @typescript-eslint/types@npm:^8.30.1, @typescript-eslint/types@npm:^8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/types@npm:8.46.2" - checksum: 10c0/611716bae2369a1b8001c7f6cc03c5ecadfb956643cbbe27269defd28a61d43fe52eda008d7a09568b0be50c502e8292bf767b246366004283476e9a971b6fbc +"@typescript-eslint/types@npm:8.50.1, @typescript-eslint/types@npm:^8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/types@npm:8.50.1" + checksum: 10c0/04e3c296d81293e370578762be6736fccd1581476f9d534938d42fe93968571fcaf26d7d8c3de52ed63a5af2c0b2da922b8ee2011fa5fb9fb401fc7f0916367a languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.30.1": +"@typescript-eslint/typescript-estree@npm:8.30.1, @typescript-eslint/typescript-estree@npm:^8.30.1": version: 8.30.1 resolution: "@typescript-eslint/typescript-estree@npm:8.30.1" dependencies: @@ -13196,27 +13322,26 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.46.2, @typescript-eslint/typescript-estree@npm:^8.30.1": - version: 8.46.2 - resolution: "@typescript-eslint/typescript-estree@npm:8.46.2" +"@typescript-eslint/typescript-estree@npm:8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.50.1" dependencies: - "@typescript-eslint/project-service": "npm:8.46.2" - "@typescript-eslint/tsconfig-utils": "npm:8.46.2" - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/visitor-keys": "npm:8.46.2" + "@typescript-eslint/project-service": "npm:8.50.1" + "@typescript-eslint/tsconfig-utils": "npm:8.50.1" + "@typescript-eslint/types": "npm:8.50.1" + "@typescript-eslint/visitor-keys": "npm:8.50.1" debug: "npm:^4.3.4" - fast-glob: "npm:^3.3.2" - is-glob: "npm:^4.0.3" minimatch: "npm:^9.0.4" semver: "npm:^7.6.0" + tinyglobby: "npm:^0.2.15" ts-api-utils: "npm:^2.1.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/ad7dbf352982bc6e16473ef19fc7d209fffeb147a732db8a2464e0ec33e7fbbc24ce3f23d01bdf99d503626c582a476debf4c90c527d755eeb99b863476d9f5f + checksum: 10c0/697b53fd3355619271a7bf543c5880731670b96567da63f554a3c3cd4d746feb8153628ec912c8a2df95e3123472e9a77df43c32fad72946b69ace89c2cf8b7e languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.30.1": +"@typescript-eslint/utils@npm:8.30.1, @typescript-eslint/utils@npm:^8.30.1": version: 8.30.1 resolution: "@typescript-eslint/utils@npm:8.30.1" dependencies: @@ -13231,18 +13356,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:^8.30.1, @typescript-eslint/utils@npm:^8.8.1": - version: 8.46.2 - resolution: "@typescript-eslint/utils@npm:8.46.2" +"@typescript-eslint/utils@npm:^8.8.1": + version: 8.50.1 + resolution: "@typescript-eslint/utils@npm:8.50.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.46.2" - "@typescript-eslint/types": "npm:8.46.2" - "@typescript-eslint/typescript-estree": "npm:8.46.2" + "@typescript-eslint/scope-manager": "npm:8.50.1" + "@typescript-eslint/types": "npm:8.50.1" + "@typescript-eslint/typescript-estree": "npm:8.50.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 10c0/600b70730077ed85a6e278e06771f3933cdafce242f979e4af1c1b41290bf1efb14d20823c25c38a3a792def69b18eb9410af28bb228fe86027ad7859753c62d + checksum: 10c0/66b19a9c8981b0b601af3a477fdcabdd110b0805591f28eefa11b32bbb88518d80b928e49eaa4c40d42ea8d71605bf5cd2ee5e39802022d1daec2800f1b198df languageName: node linkType: hard @@ -13256,13 +13381,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.46.2": - version: 8.46.2 - resolution: "@typescript-eslint/visitor-keys@npm:8.46.2" +"@typescript-eslint/visitor-keys@npm:8.50.1": + version: 8.50.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.50.1" dependencies: - "@typescript-eslint/types": "npm:8.46.2" + "@typescript-eslint/types": "npm:8.50.1" eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/2067cd9a3c90b3817242cc49b5fa77428e1b92b28e16a12f45c2b399acbba7bd17e503553e5e68924e40078477a5c247dfa12e7709c24fe11c0b17a0c8486c33 + checksum: 10c0/b23839d04b2e5e7964a4006317d75cdc3cf76e56f4c5fde1e0bcd23f3bb78dca910e3dcadca80606f76a09ff9e44b3363ee1e1d6394e3f7479da74a641a8870f languageName: node linkType: hard @@ -14254,6 +14379,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" @@ -14281,7 +14407,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" @@ -14342,7 +14468,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" @@ -14372,7 +14497,6 @@ __metadata: selection-hook: "npm:^1.0.12" sharp: "npm:^0.34.3" shiki: "npm:^3.12.0" - socket.io: "npm:^4.8.1" stream-json: "npm:^1.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" @@ -14395,7 +14519,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" @@ -14436,16 +14560,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" @@ -14455,7 +14569,16 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.14.0, acorn@npm:^8.15.0, acorn@npm:^8.8.0": +"acorn@npm:^8.14.0": + version: 8.14.1 + resolution: "acorn@npm:8.14.1" + bin: + acorn: bin/acorn + checksum: 10c0/dbd36c1ed1d2fa3550140000371fcf721578095b18777b85a79df231ca093b08edc6858d75d6e48c73e431c174dcf9214edbd7e6fa5911b93bd8abfa54e47123 + languageName: node + linkType: hard + +"acorn@npm:^8.15.0, acorn@npm:^8.8.0": version: 8.15.0 resolution: "acorn@npm:8.15.0" bin: @@ -14615,7 +14738,14 @@ __metadata: languageName: node linkType: hard -"ansis@npm:^4.0.0, ansis@npm:^4.1.0, ansis@npm:^4.2.0": +"ansis@npm:^4.0.0, ansis@npm:^4.1.0": + version: 4.1.0 + resolution: "ansis@npm:4.1.0" + checksum: 10c0/df62d017a7791babdaf45b93f930d2cfd6d1dab5568b610735c11434c9a5ef8f513740e7cfd80bcbc3530fc8bd892b88f8476f26621efc251230e53cbd1a2c24 + languageName: node + linkType: hard + +"ansis@npm:^4.2.0": version: 4.2.0 resolution: "ansis@npm:4.2.0" checksum: 10c0/cd6a7a681ecd36e72e0d79c1e34f1f3bcb1b15bcbb6f0f8969b4228062d3bfebbef468e09771b00d93b2294370b34f707599d4a113542a876de26823b795b5d2 @@ -14683,8 +14813,8 @@ __metadata: linkType: hard "antd@npm:^5.22.5": - version: 5.27.6 - resolution: "antd@npm:5.27.6" + version: 5.29.3 + resolution: "antd@npm:5.29.3" dependencies: "@ant-design/colors": "npm:^7.2.1" "@ant-design/cssinjs": "npm:^1.23.0" @@ -14695,7 +14825,7 @@ __metadata: "@babel/runtime": "npm:^7.26.0" "@rc-component/color-picker": "npm:~2.0.1" "@rc-component/mutate-observer": "npm:^1.1.0" - "@rc-component/qrcode": "npm:~1.0.1" + "@rc-component/qrcode": "npm:~1.1.0" "@rc-component/tour": "npm:~1.15.1" "@rc-component/trigger": "npm:^2.3.0" classnames: "npm:^2.5.1" @@ -14707,7 +14837,7 @@ __metadata: rc-dialog: "npm:~9.6.0" rc-drawer: "npm:~7.3.0" rc-dropdown: "npm:~4.2.1" - rc-field-form: "npm:~2.7.0" + rc-field-form: "npm:~2.7.1" rc-image: "npm:~7.12.0" rc-input: "npm:~1.8.0" rc-input-number: "npm:~9.5.0" @@ -14731,14 +14861,14 @@ __metadata: rc-tooltip: "npm:~6.4.0" rc-tree: "npm:~5.13.1" rc-tree-select: "npm:~5.27.0" - rc-upload: "npm:~4.9.2" + rc-upload: "npm:~4.11.0" rc-util: "npm:^5.44.4" scroll-into-view-if-needed: "npm:^3.1.0" throttle-debounce: "npm:^5.0.2" peerDependencies: react: ">=16.9.0" react-dom: ">=16.9.0" - checksum: 10c0/2b3b30ac9c39c7fd374a9a81de64bb032c6e6cda1b9ac26ce5a099775f8e5851ea97cee429ff03eeb41ad1e9a0e1947fa969c90b9d8c73713ec57168c5b4a3d9 + checksum: 10c0/e2e488dfd51f0a7c1e5ef2aa51ab6809b0015151d338dcdd27a7bef6f7ac9f7644e73693b37d0213089f6aa02718090ceba957966c76c387b258c789b3ca28fd languageName: node linkType: hard @@ -14973,13 +15103,23 @@ __metadata: languageName: node linkType: hard -"ast-kit@npm:^2.1.1, ast-kit@npm:^2.1.3": - version: 2.1.3 - resolution: "ast-kit@npm:2.1.3" +"ast-kit@npm:^2.1.1": + version: 2.1.1 + resolution: "ast-kit@npm:2.1.1" dependencies: - "@babel/parser": "npm:^7.28.4" + "@babel/parser": "npm:^7.27.7" pathe: "npm:^2.0.3" - checksum: 10c0/33cc530bfbff610fff720df031e5fcd8342ee14a0a9380635cf2f57a4dbbe3d69408a8b990c424323144a51c0cfb8da5a65dc0cc0826b1c66a7a8eb426e21945 + checksum: 10c0/2afbf21d88cbe74a6a1d2571e257a684231f0d27be6512a08ad2bd2e410fb1c946dfac9ad8ad736015bcc83328c9c32e169ee47d2bd1aadb6cc548f0450d9e62 + languageName: node + linkType: hard + +"ast-kit@npm:^2.2.0": + version: 2.2.0 + resolution: "ast-kit@npm:2.2.0" + dependencies: + "@babel/parser": "npm:^7.28.5" + pathe: "npm:^2.0.3" + checksum: 10c0/d885f3a4e9837e730451a667d26936eef34773d6e5ecacd771a3e9d1f82fdc45d38958ab35e18880e0cf667896604a599497c5186f2578cf73c0d802ed7fc697 languageName: node linkType: hard @@ -15163,13 +15303,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" @@ -15226,10 +15359,17 @@ __metadata: languageName: node linkType: hard -"birpc@npm:^2.5.0, birpc@npm:^2.6.1": - version: 2.6.1 - resolution: "birpc@npm:2.6.1" - checksum: 10c0/eda4a9fbf95ac7ac2112d7fc10db588dc9145d9d50ad91bb714f3f36d64dd1a5c5206ac17b5bc5c0d6fbdb48c63e110946b240ad1bb51eeca37ce161efdf2d06 +"birpc@npm:^2.5.0": + version: 2.5.0 + resolution: "birpc@npm:2.5.0" + checksum: 10c0/8caed5ad86b71e0b4af6a1c5e8ed006f451d3b378ce52c2fa613fe68f15bb3df1357ad69f7fb0251e4261f39b2926995e34307ac06397f993665b16ba569dc54 + languageName: node + linkType: hard + +"birpc@npm:^2.8.0": + version: 2.9.0 + resolution: "birpc@npm:2.9.0" + checksum: 10c0/2462d0d67061f95bae213b0b9b323a6643ff749f7457a25242897c99e31355f1bd522c17f83ecf57506351e3e28b4e38c12a39b8beddee2dd0cbf78f9b9876ce languageName: node linkType: hard @@ -15285,6 +15425,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" @@ -15464,6 +15614,15 @@ __metadata: languageName: node linkType: hard +"bundle-name@npm:^4.1.0": + version: 4.1.0 + resolution: "bundle-name@npm:4.1.0" + dependencies: + run-applescript: "npm:^7.0.0" + checksum: 10c0/8e575981e79c2bcf14d8b1c027a3775c095d362d1382312f444a7c861b0e21513c0bd8db5bd2b16e50ba0709fa622d4eab6b53192d222120305e68359daece29 + languageName: node + linkType: hard + "byte-length@npm:^1.0.2": version: 1.0.2 resolution: "byte-length@npm:1.0.2" @@ -16009,13 +16168,6 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.2.1": - version: 1.2.1 - resolution: "clsx@npm:1.2.1" - checksum: 10c0/34dead8bee24f5e96f6e7937d711978380647e936a22e76380290e35486afd8634966ce300fc4b74a32f3762c7d4c0303f442c3e259f4ce02374eb0c82834f27 - languageName: node - linkType: hard - "clsx@npm:^2.0.0, clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" @@ -16441,7 +16593,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 @@ -16478,7 +16630,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: @@ -17173,15 +17325,15 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": - version: 4.4.3 - resolution: "debug@npm:4.4.3" +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0, debug@npm:^4.4.1": + version: 4.4.1 + resolution: "debug@npm:4.4.1" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + checksum: 10c0/d2b44bc1afd912b49bb7ebb0d50a860dc93a4dd7d946e8de94abc957bb63726b7dd5aa48c18c2386c379ec024c46692e15ed3ed97d481729f929201e671fcd55 languageName: node linkType: hard @@ -17194,15 +17346,15 @@ __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" +"debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 languageName: node linkType: hard @@ -17357,6 +17509,23 @@ __metadata: languageName: node linkType: hard +"default-browser-id@npm:^5.0.0": + version: 5.0.1 + resolution: "default-browser-id@npm:5.0.1" + checksum: 10c0/5288b3094c740ef3a86df9b999b04ff5ba4dee6b64e7b355c0fff5217752c8c86908d67f32f6cba9bb4f9b7b61a1b640c0a4f9e34c57e0ff3493559a625245ee + languageName: node + linkType: hard + +"default-browser@npm:^5.2.1": + version: 5.4.0 + resolution: "default-browser@npm:5.4.0" + dependencies: + bundle-name: "npm:^4.1.0" + default-browser-id: "npm:^5.0.0" + checksum: 10c0/a49ddd0c7b1a319163f64a5fc68ebb45a98548ea23a3155e04518f026173d85cfa2f451b646366c36c8f70b01e4cb773e23d1d22d2c61d8b84e5fbf151b4b609 + languageName: node + linkType: hard + "defaults@npm:^1.0.3": version: 1.0.4 resolution: "defaults@npm:1.0.4" @@ -17391,6 +17560,13 @@ __metadata: languageName: node linkType: hard +"define-lazy-prop@npm:^3.0.0": + version: 3.0.0 + resolution: "define-lazy-prop@npm:3.0.0" + checksum: 10c0/5ab0b2bf3fa58b3a443140bbd4cd3db1f91b985cc8a246d330b9ac3fc0b6a325a6d82bddc0b055123d745b3f9931afeea74a5ec545439a1630b9c8512b0eeb49 + languageName: node + linkType: hard + "define-properties@npm:^1.2.1": version: 1.2.1 resolution: "define-properties@npm:1.2.1" @@ -17601,6 +17777,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, doctrine@npm:^3.0.0": version: 3.0.0 resolution: "doctrine@npm:3.0.0" @@ -17870,15 +18055,27 @@ __metadata: languageName: node linkType: hard -"dts-resolver@npm:^2.1.1, dts-resolver@npm:^2.1.2": - version: 2.1.2 - resolution: "dts-resolver@npm:2.1.2" +"dts-resolver@npm:^2.1.1": + version: 2.1.1 + resolution: "dts-resolver@npm:2.1.1" peerDependencies: oxc-resolver: ">=11.0.0" peerDependenciesMeta: oxc-resolver: optional: true - checksum: 10c0/521986fc9a7922e972c5d603bc2a2e1e2a0d7aa4902533947e2d63362d3ac6ac5b6ca22a75e82ee1ff7a3de9480eb050b1a584f3d2c653b3fe8091413f99f69f + checksum: 10c0/bc36d71822d39f23cfe274b6781fae4b1729bd8b0a07e4a011fe243a73c5dbbb30ea067fb0d6248fdfedc29cf4dfc0ff19f0dd38950158444409d109c1c55b7e + languageName: node + linkType: hard + +"dts-resolver@npm:^2.1.3": + version: 2.1.3 + resolution: "dts-resolver@npm:2.1.3" + peerDependencies: + oxc-resolver: ">=11.0.0" + peerDependenciesMeta: + oxc-resolver: + optional: true + checksum: 10c0/bf589ba9bfacdb23ff9c075948175f5a21ae0bccb2ca36f8315bff2729358902256ee7aca972f5b259641f08a4b5973034e082a730113d5af76e64062e45fe3a languageName: node linkType: hard @@ -18043,15 +18240,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 @@ -18061,7 +18258,7 @@ __metadata: optional: true bin: electron-vite: bin/electron-vite.js - checksum: 10c0/4e81ac4e4ede6060ffec56ba9b1d5ff95bb263496e62527345e8c79542924c54c54def39de9b466a81ed250b68774792c2106b93274c790b4cd8e7be448f6af8 + checksum: 10c0/e7797910b23f23f39c12ded92d07d7164c5c6adab294aa13278c1b49ada3b12868b13ace8546d2656db4dbab89978cf8368a659d1ce6a2fb9f1aeddb1c8de557 languageName: node linkType: hard @@ -18179,30 +18376,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" @@ -18732,7 +18905,14 @@ __metadata: languageName: node linkType: hard -"eslint-visitor-keys@npm:^4.2.0, eslint-visitor-keys@npm:^4.2.1": +"eslint-visitor-keys@npm:^4.2.0": + version: 4.2.0 + resolution: "eslint-visitor-keys@npm:4.2.0" + checksum: 10c0/2ed81c663b147ca6f578312919483eb040295bbab759e5a371953456c636c5b49a559883e2677112453728d66293c0a4c90ab11cab3428cf02a0236d2e738269 + languageName: node + linkType: hard + +"eslint-visitor-keys@npm:^4.2.1": version: 4.2.1 resolution: "eslint-visitor-keys@npm:4.2.1" checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 @@ -18918,9 +19098,9 @@ __metadata: linkType: hard "eventsource-parser@npm:^3.0.0": - version: 3.0.5 - resolution: "eventsource-parser@npm:3.0.5" - checksum: 10c0/5cb75e3f84ff1cfa1cee6199d4fd430c4544855ab03e953ddbe5927e7b31bc2af3933ab8aba6440ba160ed2c48972b6c317f27b8a1d0764c7b12e34e249de631 + version: 3.0.3 + resolution: "eventsource-parser@npm:3.0.3" + checksum: 10c0/2594011630efba56cafafc8ed6bd9a50db8f6d5dd62089b0950346e7961828c16efe07a588bdea3ba79e568fd9246c8163824a2ffaade767e1fdb2270c1fae0b languageName: node linkType: hard @@ -19505,7 +19685,7 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:^3.1.0": +"foreground-child@npm:^3.1.0, foreground-child@npm:^3.3.1": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" dependencies: @@ -19568,7 +19748,29 @@ __metadata: languageName: node linkType: hard -"framer-motion@npm:^12.10.5, framer-motion@npm:^12.23.12": +"framer-motion@npm:^12.10.5": + version: 12.10.5 + resolution: "framer-motion@npm:12.10.5" + dependencies: + motion-dom: "npm:^12.10.5" + motion-utils: "npm:^12.9.4" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/a24a44b7a1b21e347f93f9ec3c1218b9ebf2b2bc2883c26ab9951e19a62fdc2e03f80a57d0c78eaf408d098ed6f0fbcae48207313921c1f5462eb04296adf55b + languageName: node + linkType: hard + +"framer-motion@npm:^12.23.12": version: 12.23.12 resolution: "framer-motion@npm:12.23.12" dependencies: @@ -19889,12 +20091,12 @@ __metadata: languageName: node linkType: hard -"get-tsconfig@npm:^4.12.0": - version: 4.12.0 - resolution: "get-tsconfig@npm:4.12.0" +"get-tsconfig@npm:^4.13.0": + version: 4.13.0 + resolution: "get-tsconfig@npm:4.13.0" dependencies: resolve-pkg-maps: "npm:^1.0.0" - checksum: 10c0/3438106bd46bfc6595fce6117190f1ac0998de2e6916b40ec23b20c784b0b47e79ea2b920895b9ed26029b1f80b8867626fb24795d5f45abbdab716a4ba1ef92 + checksum: 10c0/2c49ef8d3907047a107f229fd610386fe3b7fe9e42dfd6b42e7406499493cdda8c62e83e57e8d7a98125610774b9f604d3a0ff308d7f9de5c7ac6d1b07cb6036 languageName: node linkType: hard @@ -19964,6 +20166,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^11.1.0": + version: 11.1.0 + resolution: "glob@npm:11.1.0" + dependencies: + foreground-child: "npm:^3.3.1" + jackspeak: "npm:^4.1.1" + minimatch: "npm:^10.1.1" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10c0/1ceae07f23e316a6fa74581d9a74be6e8c2e590d2f7205034dd5c0435c53f5f7b712c2be00c3b65bf0a49294a1c6f4b98cd84c7637e29453b5aa13b79f1763a2 + languageName: node + linkType: hard + "glob@npm:^7.1.3, glob@npm:^7.1.6": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -20830,14 +21048,14 @@ __metadata: linkType: hard "intl-messageformat@npm:^10.1.0": - version: 10.7.16 - resolution: "intl-messageformat@npm:10.7.16" + version: 10.7.18 + resolution: "intl-messageformat@npm:10.7.18" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/ecma402-abstract": "npm:2.3.6" "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/icu-messageformat-parser": "npm:2.11.2" + "@formatjs/icu-messageformat-parser": "npm:2.11.4" tslib: "npm:^2.8.0" - checksum: 10c0/537735bf6439f0560f132895d117df6839957ac04cdd58d861f6da86803d40bfc19059e3d341ddb8de87214b73a6329b57f9acdb512bb0f745dcf08729507b9b + checksum: 10c0/d54da9987335cb2bca26246304cea2ca6b1cb44ca416d6b28f3aa62b11477c72f7ce0bf3f11f5d236ceb1842bdc3378a926e606496d146fde18783ec92c314e1 languageName: node linkType: hard @@ -20968,6 +21186,15 @@ __metadata: languageName: node linkType: hard +"is-docker@npm:^3.0.0": + version: 3.0.0 + resolution: "is-docker@npm:3.0.0" + bin: + is-docker: cli.js + checksum: 10c0/d2c4f8e6d3e34df75a5defd44991b6068afad4835bb783b902fa12d13ebdb8f41b2a199dcb0b5ed2cb78bfee9e4c0bbdb69c2d9646f4106464674d3e697a5856 + languageName: node + linkType: hard + "is-extendable@npm:^0.1.0": version: 0.1.1 resolution: "is-extendable@npm:0.1.1" @@ -21058,6 +21285,17 @@ __metadata: languageName: node linkType: hard +"is-inside-container@npm:^1.0.0": + version: 1.0.0 + resolution: "is-inside-container@npm:1.0.0" + dependencies: + is-docker: "npm:^3.0.0" + bin: + is-inside-container: cli.js + checksum: 10c0/a8efb0e84f6197e6ff5c64c52890fa9acb49b7b74fed4da7c95383965da6f0fa592b4dbd5e38a79f87fc108196937acdbcd758fcefc9b140e479b39ce1fcd1cd + languageName: node + linkType: hard + "is-interactive@npm:^1.0.0": version: 1.0.0 resolution: "is-interactive@npm:1.0.0" @@ -21165,6 +21403,15 @@ __metadata: languageName: node linkType: hard +"is-wsl@npm:^3.1.0": + version: 3.1.0 + resolution: "is-wsl@npm:3.1.0" + dependencies: + is-inside-container: "npm:^1.0.0" + checksum: 10c0/d3317c11995690a32c362100225e22ba793678fe8732660c6de511ae71a0ff05b06980cf21f98a6bf40d7be0e9e9506f859abe00a1118287d63e53d0a3d06947 + languageName: node + linkType: hard + "isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -21266,6 +21513,15 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^4.1.1": + version: 4.1.1 + resolution: "jackspeak@npm:4.1.1" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + checksum: 10c0/84ec4f8e21d6514db24737d9caf65361511f75e5e424980eebca4199f400874f45e562ac20fa8aeb1dd20ca2f3f81f0788b6e9c3e64d216a5794fd6f30e0e042 + languageName: node + linkType: hard + "jaison@npm:^2.0.2": version: 2.0.2 resolution: "jaison@npm:2.0.2" @@ -21316,6 +21572,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.6.1": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b + languageName: node + linkType: hard + "js-base64@npm:^3.7.5": version: 3.7.7 resolution: "js-base64@npm:3.7.7" @@ -21735,7 +22000,27 @@ __metadata: languageName: node linkType: hard -"langsmith@npm:>=0.2.8 <0.4.0, langsmith@npm:^0.3.64": +"langsmith@npm:>=0.2.8 <0.4.0": + version: 0.3.16 + resolution: "langsmith@npm:0.3.16" + dependencies: + "@types/uuid": "npm:^10.0.0" + chalk: "npm:^4.1.2" + console-table-printer: "npm:^2.12.1" + p-queue: "npm:^6.6.2" + p-retry: "npm:4" + semver: "npm:^7.6.3" + uuid: "npm:^10.0.0" + peerDependencies: + openai: "*" + peerDependenciesMeta: + openai: + optional: true + checksum: 10c0/7ab1d82b525a0916f950a2575bd44941785e699522cc251e03fcad699f198b3b99a3f67ec7dd0ec721eb661d060240ce7dd8326442b4cb227098389b88a1cb82 + languageName: node + linkType: hard + +"langsmith@npm:^0.3.64": version: 0.3.76 resolution: "langsmith@npm:0.3.76" dependencies: @@ -21925,6 +22210,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" @@ -21932,6 +22224,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" @@ -21939,6 +22238,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" @@ -21946,6 +22252,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" @@ -21953,6 +22266,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" @@ -21960,6 +22280,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" @@ -21967,6 +22294,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" @@ -21974,6 +22308,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" @@ -21981,6 +22322,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" @@ -21988,6 +22336,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" @@ -21995,7 +22350,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: @@ -22035,6 +22397,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" @@ -22057,9 +22462,9 @@ __metadata: linkType: hard "linguist-languages@npm:^9.0.0": - version: 9.0.0 - resolution: "linguist-languages@npm:9.0.0" - checksum: 10c0/c3fe0f158d0e4a68b925324ea3f69f229daf7438d7206b0775b818e6e84317770bc9742c6ce68711e9ca772f996a897f8f22c8a775f07dbdb7fb4b28d0c8c509 + version: 9.1.11 + resolution: "linguist-languages@npm:9.1.11" + checksum: 10c0/349d2c6f7c881ded6eef30928a8dba514d82bed8411993da7449ca8477c7ecc3f66870f3e42ec7815da012ae1500deaa6d8a69fb396a1d99a28e7ec742eda751 languageName: node linkType: hard @@ -22295,7 +22700,14 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.4": +"loupe@npm:^3.1.0": + version: 3.1.3 + resolution: "loupe@npm:3.1.3" + checksum: 10c0/f5dab4144254677de83a35285be1b8aba58b3861439ce4ba65875d0d5f3445a4a496daef63100ccf02b2dbc25bf58c6db84c9cb0b96d6435331e9d0a33b48541 + languageName: node + linkType: hard + +"loupe@npm:^3.1.4": version: 3.2.0 resolution: "loupe@npm:3.2.0" checksum: 10c0/f572fd9e38db8d36ae9eede305480686e310d69bc40394b6842838ebc6c3860a0e35ab30182f33606ab2d8a685d9ff6436649269f8218a1c3385ca329973cb2c @@ -22332,6 +22744,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.2.4 + resolution: "lru-cache@npm:11.2.4" + checksum: 10c0/4a24f9b17537619f9144d7b8e42cd5a225efdfd7076ebe7b5e7dc02b860a818455201e67fbf000765233fe7e339d3c8229fc815e9b58ee6ede511e07608c19b2 + languageName: node + linkType: hard + "lru-cache@npm:^11.1.0": version: 11.1.0 resolution: "lru-cache@npm:11.1.0" @@ -22405,12 +22824,12 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0, magic-string@npm:^0.30.19": - version: 0.30.19 - resolution: "magic-string@npm:0.30.19" +"magic-string@npm:^0.30.0, magic-string@npm:^0.30.19, magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" dependencies: "@jridgewell/sourcemap-codec": "npm:^1.5.5" - checksum: 10c0/db23fd2e2ee98a1aeb88a4cdb2353137fcf05819b883c856dd79e4c7dfb25151e2a5a4d5dbd88add5e30ed8ae5c51bcf4accbc6becb75249d924ec7b4fbcae27 + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a languageName: node linkType: hard @@ -23542,7 +23961,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: @@ -23648,7 +24067,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.0.3": +"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1": version: 10.1.1 resolution: "minimatch@npm:10.1.1" dependencies: @@ -23846,6 +24265,15 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.10.5": + version: 12.10.5 + resolution: "motion-dom@npm:12.10.5" + dependencies: + motion-utils: "npm:^12.9.4" + checksum: 10c0/2c362eb94c941bbbc42288a6738b8c7a11933687b3b20aa6c9f2c3dedc69e5c7995c7348499b535f8abe5ed9ea81d88f9eb2f98b69f5012bcd80b8f7a64a1c2c + languageName: node + linkType: hard + "motion-dom@npm:^12.23.12": version: 12.23.12 resolution: "motion-dom@npm:12.23.12" @@ -23862,6 +24290,13 @@ __metadata: languageName: node linkType: hard +"motion-utils@npm:^12.9.4": + version: 12.9.4 + resolution: "motion-utils@npm:12.9.4" + checksum: 10c0/b6783babfd1282ad320585f7cdac9fe7a1f97b39e07d12a500d3709534441bd9d49b556fa1cd838d1bde188570d4ab6b4c5aa9d297f7f5aa9dc16d600c17afdc + languageName: node + linkType: hard + "motion@npm:^12.10.5": version: 12.10.5 resolution: "motion@npm:12.10.5" @@ -23930,6 +24365,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" @@ -24013,13 +24460,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" @@ -24346,6 +24786,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.0.0": + version: 2.1.1 + resolution: "obug@npm:2.1.1" + checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78 + languageName: node + linkType: hard + "office-text-extractor@npm:^3.0.3": version: 3.0.3 resolution: "office-text-extractor@npm:3.0.3" @@ -24392,13 +24839,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 @@ -24483,6 +24930,18 @@ __metadata: languageName: node linkType: hard +"open@npm:^10.2.0": + version: 10.2.0 + resolution: "open@npm:10.2.0" + dependencies: + default-browser: "npm:^5.2.1" + define-lazy-prop: "npm:^3.0.0" + is-inside-container: "npm:^1.0.0" + wsl-utils: "npm:^0.1.0" + checksum: 10c0/5a36d0c1fd2f74ce553beb427ca8b8494b623fc22c6132d0c1688f246a375e24584ea0b44c67133d9ab774fa69be8e12fbe1ff12504b1142bd960fb09671948f + languageName: node + linkType: hard + "open@npm:^8.4.0": version: 8.4.2 resolution: "open@npm:8.4.2" @@ -24908,7 +25367,16 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0, parse5@npm:^7.2.1, parse5@npm:^7.3.0": +"parse5@npm:^7.0.0, parse5@npm:^7.2.1": + version: 7.2.1 + resolution: "parse5@npm:7.2.1" + dependencies: + entities: "npm:^4.5.0" + checksum: 10c0/829d37a0c709215a887e410a7118d754f8e1afd7edb529db95bc7bbf8045fb0266a7b67801331d8e8d9d073ea75793624ec27ce9ff3b96862c3b9008f4d68e80 + languageName: node + linkType: hard + +"parse5@npm:^7.3.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" dependencies: @@ -25007,6 +25475,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^2.0.0": + version: 2.0.1 + resolution: "path-scurry@npm:2.0.1" + dependencies: + lru-cache: "npm:^11.0.0" + minipass: "npm:^7.1.2" + checksum: 10c0/2a16ed0e81fbc43513e245aa5763354e25e787dab0d539581a6c3f0f967461a159ed6236b2559de23aa5b88e7dc32b469b6c47568833dd142a4b24b4f5cd2620 + languageName: node + linkType: hard + "path-to-regexp@npm:^6.3.0": version: 6.3.0 resolution: "path-to-regexp@npm:6.3.0" @@ -25123,7 +25601,14 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3": +"picomatch@npm:^4.0.2": + version: 4.0.2 + resolution: "picomatch@npm:4.0.2" + checksum: 10c0/7c51f3ad2bb42c776f49ebf964c644958158be30d0a510efd5a395e8d49cb5acfed5b82c0c5b365523ce18e6ab85013c9ebe574f60305892ec3fa8eee8304ccc + languageName: node + linkType: hard + +"picomatch@npm:^4.0.3": version: 4.0.3 resolution: "picomatch@npm:4.0.3" checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 @@ -25747,15 +26232,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" @@ -25765,10 +26241,17 @@ __metadata: languageName: node linkType: hard -"quansync@npm:^0.2.11, quansync@npm:^0.2.8": - version: 0.2.11 - resolution: "quansync@npm:0.2.11" - checksum: 10c0/cb9a1f8ebce074069f2f6a78578873ffedd9de9f6aa212039b44c0870955c04a71c3b1311b5d97f8ac2f2ec476de202d0a5c01160cb12bc0a11b7ef36d22ef56 +"quansync@npm:^0.2.10, quansync@npm:^0.2.8": + version: 0.2.10 + resolution: "quansync@npm:0.2.10" + checksum: 10c0/f86f1d644f812a3a7c42de79eb401c47a5a67af82a9adff8a8afb159325e03e00f77cebbf42af6340a0bd47bd0c1fbe999e7caf7e1bbb30d7acb00c8729b7530 + languageName: node + linkType: hard + +"quansync@npm:^1.0.0": + version: 1.0.0 + resolution: "quansync@npm:1.0.0" + checksum: 10c0/076542634399a0cc46078baab6b31acee7a88c5a435234345f645aedaa42bc6a63836e655fb39b1b21a83c98ec86a4b73ec5e2b2d6f3fdc22711eeeff9463253 languageName: node linkType: hard @@ -25925,6 +26408,20 @@ __metadata: languageName: node linkType: hard +"rc-field-form@npm:~2.7.1": + version: 2.7.1 + resolution: "rc-field-form@npm:2.7.1" + dependencies: + "@babel/runtime": "npm:^7.18.0" + "@rc-component/async-validator": "npm:^5.0.3" + rc-util: "npm:^5.32.2" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/2493cb9f26e69e17d55f32ad689da103a325e613d9222bfb332c2dcbdc96d44ce7dc4c8642a9b89a932ad2c2573508c997a4685e7fe6de2e951a027d2837403a + languageName: node + linkType: hard + "rc-image@npm:~7.12.0": version: 7.12.0 resolution: "rc-image@npm:7.12.0" @@ -26153,7 +26650,25 @@ __metadata: languageName: node linkType: hard -"rc-select@npm:~14.16.2, rc-select@npm:~14.16.8": +"rc-select@npm:~14.16.2": + version: 14.16.6 + resolution: "rc-select@npm:14.16.6" + dependencies: + "@babel/runtime": "npm:^7.10.1" + "@rc-component/trigger": "npm:^2.1.1" + classnames: "npm:2.x" + rc-motion: "npm:^2.0.1" + rc-overflow: "npm:^1.3.1" + rc-util: "npm:^5.16.1" + rc-virtual-list: "npm:^3.5.2" + peerDependencies: + react: "*" + react-dom: "*" + checksum: 10c0/a0aa16e611bfe48bc26612a95189a33e7bed38f12a1c41f34b74778b5d83437d74f7b0b304ce27eda2f5797b330f35fc73f1ddc3e85efff56a3089da22a0a3bf + languageName: node + linkType: hard + +"rc-select@npm:~14.16.8": version: 14.16.8 resolution: "rc-select@npm:14.16.8" dependencies: @@ -26171,7 +26686,21 @@ __metadata: languageName: node linkType: hard -"rc-slider@npm:~11.1.8, rc-slider@npm:~11.1.9": +"rc-slider@npm:~11.1.8": + version: 11.1.8 + resolution: "rc-slider@npm:11.1.8" + dependencies: + "@babel/runtime": "npm:^7.10.1" + classnames: "npm:^2.2.5" + rc-util: "npm:^5.36.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/b202599abf85e21234c2cababe9c6f908aa7fcdde9eca413ef96b209838f3b1a33292d1a1bbe571b84bf46f8a5d28d5c1a070f331bddc0504101e9e2a75cf422 + languageName: node + linkType: hard + +"rc-slider@npm:~11.1.9": version: 11.1.9 resolution: "rc-slider@npm:11.1.9" dependencies: @@ -26265,7 +26794,23 @@ __metadata: languageName: node linkType: hard -"rc-textarea@npm:~1.10.0, rc-textarea@npm:~1.10.2": +"rc-textarea@npm:~1.10.0": + version: 1.10.0 + resolution: "rc-textarea@npm:1.10.0" + dependencies: + "@babel/runtime": "npm:^7.10.1" + classnames: "npm:^2.2.1" + rc-input: "npm:~1.8.0" + rc-resize-observer: "npm:^1.0.0" + rc-util: "npm:^5.27.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/aef90816078afa4bae54f152ca8a06834bb86d700e22a30f65979dc45fa5fbb10fe9894ecf2acb10102a3183c5a03b3518134db0df3ba3a32a79fe6de398fde0 + languageName: node + linkType: hard + +"rc-textarea@npm:~1.10.2": version: 1.10.2 resolution: "rc-textarea@npm:1.10.2" dependencies: @@ -26328,6 +26873,20 @@ __metadata: languageName: node linkType: hard +"rc-upload@npm:~4.11.0": + version: 4.11.0 + resolution: "rc-upload@npm:4.11.0" + dependencies: + "@babel/runtime": "npm:^7.18.3" + classnames: "npm:^2.2.5" + rc-util: "npm:^5.2.0" + peerDependencies: + react: ">=16.9.0" + react-dom: ">=16.9.0" + checksum: 10c0/9ce59e22e0f9839e482fd37e63d8489e1a9d418113bfe39cd4d2b1e88f59236d4f7c60a3bb5928d2d85781d6f4cf8c30b6085863c802de605ef339fa415903d3 + languageName: node + linkType: hard + "rc-upload@npm:~4.9.2": version: 4.9.2 resolution: "rc-upload@npm:4.9.2" @@ -26405,7 +26964,7 @@ __metadata: languageName: node linkType: hard -"react-docgen@npm:^8.0.0": +"react-docgen@npm:^8.0.0, react-docgen@npm:^8.0.2": version: 8.0.2 resolution: "react-docgen@npm:8.0.2" dependencies: @@ -26423,7 +26982,7 @@ __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, react-dom@npm:^19.2.0": +"react-dom@npm:^19.2.0": version: 19.2.0 resolution: "react-dom@npm:19.2.0" dependencies: @@ -26692,7 +27251,7 @@ __metadata: languageName: node linkType: hard -"react@npm:^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0, react@npm:^19.0.0, react@npm:^19.2.0": +"react@npm:^19.2.0": version: 19.2.0 resolution: "react@npm:19.2.0" checksum: 10c0/1b6d64eacb9324725bfe1e7860cb7a6b8a34bc89a482920765ebff5c10578eb487e6b46b2f0df263bd27a25edbdae2c45e5ea5d81ae61404301c1a7192c38330 @@ -27337,23 +27896,23 @@ __metadata: languageName: node linkType: hard -"rolldown-plugin-dts@npm:^0.16.12": - version: 0.16.12 - resolution: "rolldown-plugin-dts@npm:0.16.12" +"rolldown-plugin-dts@npm:^0.17.2": + version: 0.17.8 + resolution: "rolldown-plugin-dts@npm:0.17.8" dependencies: - "@babel/generator": "npm:^7.28.3" - "@babel/parser": "npm:^7.28.4" - "@babel/types": "npm:^7.28.4" - ast-kit: "npm:^2.1.3" - birpc: "npm:^2.6.1" - debug: "npm:^4.4.3" - dts-resolver: "npm:^2.1.2" - get-tsconfig: "npm:^4.12.0" - magic-string: "npm:^0.30.19" + "@babel/generator": "npm:^7.28.5" + "@babel/parser": "npm:^7.28.5" + "@babel/types": "npm:^7.28.5" + ast-kit: "npm:^2.2.0" + birpc: "npm:^2.8.0" + dts-resolver: "npm:^2.1.3" + get-tsconfig: "npm:^4.13.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.0.0" peerDependencies: "@ts-macro/tsc": ^0.3.6 "@typescript/native-preview": ">=7.0.0-dev.20250601.1" - rolldown: ^1.0.0-beta.9 + rolldown: ^1.0.0-beta.44 typescript: ^5.0.0 vue-tsc: ~3.1.0 peerDependenciesMeta: @@ -27365,87 +27924,30 @@ __metadata: optional: true vue-tsc: optional: true - checksum: 10c0/5b138ea608e0e1165ef987c36df332af5d074bc58000ceb500460abf298e9316a0bf38c3209a046348cc81f7f64f8c047e6135d131c3ce32dc02b1114a1d910a + checksum: 10c0/ec94c4d724522dcb66b59b4c9af24598d0fd4a2f86cd461da23eb7a04549939bc70d2ce7a391adcfed4d40100e62f122d5bf4a593273e0815a697ebce29852fa 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" - 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" - dependenciesMeta: - "@rolldown/binding-android-arm64": - optional: true - "@rolldown/binding-darwin-arm64": - optional: true - "@rolldown/binding-darwin-x64": - optional: true - "@rolldown/binding-freebsd-x64": - optional: true - "@rolldown/binding-linux-arm-gnueabihf": - optional: true - "@rolldown/binding-linux-arm64-gnu": - optional: true - "@rolldown/binding-linux-arm64-musl": - optional: true - "@rolldown/binding-linux-x64-gnu": - optional: true - "@rolldown/binding-linux-x64-musl": - optional: true - "@rolldown/binding-openharmony-arm64": - optional: true - "@rolldown/binding-wasm32-wasi": - 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 - languageName: node - linkType: hard - -"rolldown@npm:1.0.0-beta.44": - version: 1.0.0-beta.44 - resolution: "rolldown@npm:1.0.0-beta.44" +"rolldown@npm:1.0.0-beta.45": + version: 1.0.0-beta.45 + resolution: "rolldown@npm:1.0.0-beta.45" dependencies: "@oxc-project/types": "npm:=0.95.0" - "@rolldown/binding-android-arm64": "npm:1.0.0-beta.44" - "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.44" - "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.44" - "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.44" - "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.44" - "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.44" - "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.44" - "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.44" - "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.44" - "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.44" - "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.44" - "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.44" - "@rolldown/binding-win32-ia32-msvc": "npm:1.0.0-beta.44" - "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.44" - "@rolldown/pluginutils": "npm:1.0.0-beta.44" + "@rolldown/binding-android-arm64": "npm:1.0.0-beta.45" + "@rolldown/binding-darwin-arm64": "npm:1.0.0-beta.45" + "@rolldown/binding-darwin-x64": "npm:1.0.0-beta.45" + "@rolldown/binding-freebsd-x64": "npm:1.0.0-beta.45" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-beta.45" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-beta.45" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-beta.45" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-beta.45" + "@rolldown/binding-linux-x64-musl": "npm:1.0.0-beta.45" + "@rolldown/binding-openharmony-arm64": "npm:1.0.0-beta.45" + "@rolldown/binding-wasm32-wasi": "npm:1.0.0-beta.45" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-beta.45" + "@rolldown/binding-win32-ia32-msvc": "npm:1.0.0-beta.45" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-beta.45" + "@rolldown/pluginutils": "npm:1.0.0-beta.45" dependenciesMeta: "@rolldown/binding-android-arm64": optional: true @@ -27477,7 +27979,59 @@ __metadata: optional: true bin: rolldown: bin/cli.mjs - checksum: 10c0/e8a8e50856cbde6333d6ec813955dd40c0b7b146066cc5c50db8c5b094fcc6a7db206b47289f382aceabb08b9966a439ff1e5cfbfa068e90e50a8dd43f179312 + checksum: 10c0/6b3f7dd9e6680ea2f880a76386f8bf0f7d11c7097dbef11efa38b5b60ef13fc743ba69c372da51e4ed2a67675ed889dc332709d4487256cb47eeed0be8fe7ab4 + languageName: node + linkType: hard + +"rolldown@npm:1.0.0-beta.53": + version: 1.0.0-beta.53 + resolution: "rolldown@npm:1.0.0-beta.53" + dependencies: + "@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 + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: bin/cli.mjs + checksum: 10c0/363109aa38b31254e682e69aa9f199074d98b823b437faac6d05fd1b4a2b73168b9434043a060fecfc25d3e1d441e2d3b757e92621bc1e843a3e916e2b0d3f58 languageName: node linkType: hard @@ -27593,6 +28147,13 @@ __metadata: languageName: node linkType: hard +"run-applescript@npm:^7.0.0": + version: 7.1.0 + resolution: "run-applescript@npm:7.1.0" + checksum: 10c0/ab826c57c20f244b2ee807704b1ef4ba7f566aa766481ae5922aac785e2570809e297c69afcccc3593095b538a8a77d26f2b2e9a1d9dffee24e0e039502d1a03 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -28201,41 +28762,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" @@ -28381,19 +28907,20 @@ __metadata: linkType: hard "storybook@npm:^10.0.5": - version: 10.0.5 - resolution: "storybook@npm:10.0.5" + version: 10.1.10 + resolution: "storybook@npm:10.1.10" dependencies: "@storybook/global": "npm:^5.0.0" - "@storybook/icons": "npm:^1.6.0" + "@storybook/icons": "npm:^2.0.0" "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/user-event": "npm:^14.6.1" "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" "@vitest/spy": "npm:3.2.4" - esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0" + esbuild: "npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + open: "npm:^10.2.0" recast: "npm:^0.23.5" semver: "npm:^7.6.2" + use-sync-external-store: "npm:^1.5.0" ws: "npm:^8.18.0" peerDependencies: prettier: ^2 || ^3 @@ -28402,7 +28929,7 @@ __metadata: optional: true bin: storybook: ./dist/bin/dispatcher.js - checksum: 10c0/ea4bcdbc8d793f53970fe2e72de805bfd5b0872d3640f7526bdf42fbe0114f225c09f3683ab011ac08b5240450fd7726f17c5210d929c6f261dadc851ee09eec + checksum: 10c0/beff5472ee86a995cbde2789b2aabd941f823e31ca6957bb4434cb8ee3d3703cf1248e44f4b4d402416a52bfee94677e74f233cc906487901e831e8ab610defa languageName: node linkType: hard @@ -28732,7 +29259,27 @@ __metadata: languageName: node linkType: hard -"styled-components@npm:^6.1.11, styled-components@npm:^6.1.15": +"styled-components@npm:^6.1.11": + version: 6.1.17 + resolution: "styled-components@npm:6.1.17" + dependencies: + "@emotion/is-prop-valid": "npm:1.2.2" + "@emotion/unitless": "npm:0.8.1" + "@types/stylis": "npm:4.2.5" + css-to-react-native: "npm:3.2.0" + csstype: "npm:3.1.3" + postcss: "npm:8.4.49" + shallowequal: "npm:1.1.0" + stylis: "npm:4.3.2" + tslib: "npm:2.6.2" + peerDependencies: + react: ">= 16.8.0" + react-dom: ">= 16.8.0" + checksum: 10c0/87f35173c5fc2291ddba7ed8224d19fe6872d056a577f55fe130248f5ea23e5c48c012e881fa1ad93df60b56a12c1c2d553f628e204f090189221734927e50b0 + languageName: node + linkType: hard + +"styled-components@npm:^6.1.15": version: 6.1.19 resolution: "styled-components@npm:6.1.19" dependencies: @@ -28895,10 +29442,10 @@ __metadata: languageName: node linkType: hard -"tailwind-merge@npm:3.3.1, tailwind-merge@npm:^3.3.1": - version: 3.3.1 - resolution: "tailwind-merge@npm:3.3.1" - checksum: 10c0/b84c6a78d4669fa12bf5ab8f0cdc4400a3ce0a7c006511af4af4be70bb664a27466dbe13ee9e3b31f50ddf6c51d380e8192ce0ec9effce23ca729d71a9f63818 +"tailwind-merge@npm:3.4.0": + version: 3.4.0 + resolution: "tailwind-merge@npm:3.4.0" + checksum: 10c0/eaf17bb695c51c7bb7a90366a9c62be295473ee97fcfd1da54287714d4a5788a88ff4ad1ab9e0128638257fda777d6c9ea88682e36195e31a7fa2cf43f45e310 languageName: node linkType: hard @@ -28909,16 +29456,23 @@ __metadata: languageName: node linkType: hard -"tailwind-variants@npm:3.1.1": - version: 3.1.1 - resolution: "tailwind-variants@npm:3.1.1" +"tailwind-merge@npm:^3.3.1": + version: 3.3.1 + resolution: "tailwind-merge@npm:3.3.1" + checksum: 10c0/b84c6a78d4669fa12bf5ab8f0cdc4400a3ce0a7c006511af4af4be70bb664a27466dbe13ee9e3b31f50ddf6c51d380e8192ce0ec9effce23ca729d71a9f63818 + languageName: node + linkType: hard + +"tailwind-variants@npm:3.2.2": + version: 3.2.2 + resolution: "tailwind-variants@npm:3.2.2" peerDependencies: tailwind-merge: ">=3.0.0" tailwindcss: "*" peerDependenciesMeta: tailwind-merge: optional: true - checksum: 10c0/58b4d50ac3d4abd67a8cb26cbae9ec83f3e4a48234aeedc0c9a0a23f7111495caad55c1641d4314dea242cd9aa71db2f0e6ceb2f914c393d570075d7f12a01ac + checksum: 10c0/715a35b66c374f3bb234cd1e6737588cb7c0213c80a69bd62239d752d20c44377bec1d028c3ea7c28882d81384507fbae407813d348c142cb991c5a0ad063d48 languageName: node linkType: hard @@ -29130,6 +29684,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" @@ -29195,13 +29756,13 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" +"tinyglobby@npm:^0.2.12": + version: 0.2.13 + resolution: "tinyglobby@npm:0.2.13" dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10c0/ef07dfaa7b26936601d3f6d999f7928a4d1c6234c5eb36896bb88681947c0d459b7ebe797022400e555fe4b894db06e922b95d0ce60cb05fd827a0a66326b18c languageName: node linkType: hard @@ -29215,6 +29776,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" @@ -29442,7 +30013,7 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.0.0, ts-api-utils@npm:^2.0.1, ts-api-utils@npm:^2.1.0": +"ts-api-utils@npm:^2.0.0, ts-api-utils@npm:^2.0.1": version: 2.1.0 resolution: "ts-api-utils@npm:2.1.0" peerDependencies: @@ -29451,6 +30022,15 @@ __metadata: languageName: node linkType: hard +"ts-api-utils@npm:^2.1.0": + version: 2.2.0 + resolution: "ts-api-utils@npm:2.2.0" + peerDependencies: + typescript: ">=4.8.4" + checksum: 10c0/533861e49558964e2934ba618245205cb8e22def86ca5e758b6c8920d6f940392380c13bdfa41e2e18fc556943df392e37f42672f9b4130d50334eae84c573a5 + languageName: node + linkType: hard + "ts-declaration-location@npm:^1.0.4": version: 1.0.7 resolution: "ts-declaration-location@npm:1.0.7" @@ -29569,8 +30149,8 @@ __metadata: linkType: hard "tsdown@npm:^0.15.5": - version: 0.15.9 - resolution: "tsdown@npm:0.15.9" + version: 0.15.12 + resolution: "tsdown@npm:0.15.12" dependencies: ansis: "npm:^4.2.0" cac: "npm:^6.7.14" @@ -29579,8 +30159,8 @@ __metadata: diff: "npm:^8.0.2" empathic: "npm:^2.0.0" hookable: "npm:^5.5.3" - rolldown: "npm:1.0.0-beta.44" - rolldown-plugin-dts: "npm:^0.16.12" + rolldown: "npm:1.0.0-beta.45" + rolldown-plugin-dts: "npm:^0.17.2" semver: "npm:^7.7.3" tinyexec: "npm:^1.0.1" tinyglobby: "npm:^0.2.15" @@ -29592,6 +30172,7 @@ __metadata: typescript: ^5.0.0 unplugin-lightningcss: ^0.4.0 unplugin-unused: ^0.5.0 + unrun: ^0.2.1 peerDependenciesMeta: "@arethetypeswrong/core": optional: true @@ -29603,9 +30184,11 @@ __metadata: optional: true unplugin-unused: optional: true + unrun: + optional: true bin: tsdown: dist/run.mjs - checksum: 10c0/faa19fe6aa51f370dccfb56f127e14b4648bfa4f64ed08d36ce5ee6c81e19459185be431bd090140a1efff1cdfa05f18f4607aa4cb99b33ce99c75ec61075453 + checksum: 10c0/01e394848192bb89b69c508bcc956d231f3f2696f383c6c9bf2700d99e23765242fa0286ae0b27f39617f79979cc35beea529367e2db47eee2b9068ba462b774 languageName: node linkType: hard @@ -29630,9 +30213,25 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.19.2, tsx@npm:^4.20.3, tsx@npm:^4.20.6": - version: 4.20.6 - resolution: "tsx@npm:4.20.6" +"tsx@npm:^4.19.2, tsx@npm:^4.20.6": + version: 4.21.0 + resolution: "tsx@npm:4.21.0" + dependencies: + esbuild: "npm:~0.27.0" + fsevents: "npm:~2.3.3" + get-tsconfig: "npm:^4.7.5" + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 10c0/f5072923cd8459a1f9a26df87823a2ab5754641739d69df2a20b415f61814322b751fa6be85db7c6ec73cf68ba8fac2fd1cfc76bdb0aa86ded984d84d5d2126b + languageName: node + linkType: hard + +"tsx@npm:^4.20.3": + version: 4.20.3 + resolution: "tsx@npm:4.20.3" dependencies: esbuild: "npm:~0.25.0" fsevents: "npm:~2.3.3" @@ -29642,7 +30241,7 @@ __metadata: optional: true bin: tsx: dist/cli.mjs - checksum: 10c0/07757a9bf62c271e0a00869b2008c5f2d6e648766536e4faf27d9d8027b7cde1ac8e4871f4bb570c99388bcee0018e6869dad98c07df809b8052f9c549cd216f + checksum: 10c0/6ff0d91ed046ec743fac7ed60a07f3c025e5b71a5aaf58f3d2a6b45e4db114c83e59ebbb078c8e079e48d3730b944a02bc0de87695088aef4ec8bbc705dc791b languageName: node linkType: hard @@ -29747,7 +30346,17 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.0.0, typescript@npm:^5.6.2, typescript@npm:^5.8.2": +"typescript@npm:^5.0.0": + version: 5.9.2 + resolution: "typescript@npm:5.9.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/cd635d50f02d6cf98ed42de2f76289701c1ec587a363369255f01ed15aaf22be0813226bff3c53e99d971f9b540e0b3cc7583dbe05faded49b1b0bed2f638a18 + languageName: node + linkType: hard + +"typescript@npm:^5.6.2, typescript@npm:^5.8.2": version: 5.9.3 resolution: "typescript@npm:5.9.3" bin: @@ -29767,7 +30376,17 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin, typescript@patch:typescript@npm%3A^5.6.2#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.0.0#optional!builtin": + version: 5.9.2 + resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/34d2a8e23eb8e0d1875072064d5e1d9c102e0bdce56a10a25c0b917b8aa9001a9cf5c225df12497e99da107dc379360bc138163c66b55b95f5b105b50578067e + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.6.2#optional!builtin, typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: @@ -29838,15 +30457,38 @@ __metadata: languageName: node linkType: hard -"unconfig@npm:^7.3.2, unconfig@npm:^7.3.3": - version: 7.3.3 - resolution: "unconfig@npm:7.3.3" +"unconfig-core@npm:7.4.2": + version: 7.4.2 + resolution: "unconfig-core@npm:7.4.2" dependencies: - "@quansync/fs": "npm:^0.1.5" + "@quansync/fs": "npm:^1.0.0" + quansync: "npm:^1.0.0" + checksum: 10c0/12aa85c78114a505fb49adc5fd6c2ce58b1661c517caea7f8943b9f56912634bfd085443a84d7675a0b245598e6220daaff0a69e3dd712819e0d05195fcfb75b + languageName: node + linkType: hard + +"unconfig@npm:^7.3.2": + version: 7.3.2 + resolution: "unconfig@npm:7.3.2" + dependencies: + "@quansync/fs": "npm:^0.1.1" defu: "npm:^6.1.4" - jiti: "npm:^2.5.1" - quansync: "npm:^0.2.11" - checksum: 10c0/7c1b0688ce7ba36a92cfeb36f248a61b86e27807b25a4504acc3e0fbf19a217fc74ba80fe45e3205def7648666de51d2b28551e61c86d1c54dcb8e129a011e58 + jiti: "npm:^2.4.2" + quansync: "npm:^0.2.8" + checksum: 10c0/245a0add92413b9a04a0bad879c7ee4d6904e58c9d091dbb1ea89fb7491d22d0f2ad17bd561329e006cb1954b5ece00f4cd9f9300a72af5013a927dc7fd5d27b + languageName: node + linkType: hard + +"unconfig@npm:^7.3.3": + version: 7.4.2 + resolution: "unconfig@npm:7.4.2" + dependencies: + "@quansync/fs": "npm:^1.0.0" + defu: "npm:^6.1.4" + jiti: "npm:^2.6.1" + quansync: "npm:^1.0.0" + unconfig-core: "npm:7.4.2" + checksum: 10c0/8423963303297e1e4213323b87bfa265fb893806d10bc8f7b38d40dab17fcb5f6173683b8400b275687a2c78839d61215679fb41895f5465713f700205464687 languageName: node linkType: hard @@ -29871,13 +30513,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" @@ -30056,7 +30691,7 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^2.1.2": +"unplugin@npm:^2.1.2, unplugin@npm:^2.3.5": version: 2.3.11 resolution: "unplugin@npm:2.3.11" dependencies: @@ -30068,18 +30703,6 @@ __metadata: languageName: node linkType: hard -"unplugin@npm:^2.3.5": - version: 2.3.10 - resolution: "unplugin@npm:2.3.10" - dependencies: - "@jridgewell/remapping": "npm:^2.3.5" - acorn: "npm:^8.15.0" - picomatch: "npm:^4.0.3" - webpack-virtual-modules: "npm:^0.6.2" - checksum: 10c0/29dcd738772aeff91c6f0154f156c95c58a37a4674fcb7cc34d6868af763834f0f447a1c3af074818c0c5602baead49bd3b9399a13f0425d69a00a527e58ddda - languageName: node - linkType: hard - "until-async@npm:^3.0.2": version: 3.0.2 resolution: "until-async@npm:3.0.2" @@ -30216,7 +30839,7 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.6.0": +"use-sync-external-store@npm:^1.5.0, use-sync-external-store@npm:^1.6.0": version: 1.6.0 resolution: "use-sync-external-store@npm:1.6.0" peerDependencies: @@ -30399,20 +31022,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 @@ -30450,7 +31074,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/55f6648a8700345700382adac4877208eedcfff5757debba74851227dbc50eae3cc7ccea86bcfda689a9855fbbd2c7e7dd020ffc0c01bfb815dbc6bf65991cbd + checksum: 10c0/7098ba9be029e6530baf6a08e786859910e502e14f18a6fdda856b149fe676ff81d5cb069b8b42f3e88e791fff17f77f9f067c26159fb85a7aab4e4b8692bbb2 languageName: node linkType: hard @@ -30915,7 +31539,22 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.13.0, ws@npm:^8.18.0, ws@npm:^8.18.2": +"ws@npm:^8.13.0, ws@npm:^8.18.0": + version: 8.18.1 + resolution: "ws@npm:8.18.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/e498965d6938c63058c4310ffb6967f07d4fa06789d3364829028af380d299fe05762961742971c764973dce3d1f6a2633fe8b2d9410c9b52e534b4b882a99fa + languageName: node + linkType: hard + +"ws@npm:^8.18.2": version: 8.18.3 resolution: "ws@npm:8.18.3" peerDependencies: @@ -30930,18 +31569,12 @@ __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 +"wsl-utils@npm:^0.1.0": + version: 0.1.0 + resolution: "wsl-utils@npm:0.1.0" + dependencies: + is-wsl: "npm:^3.1.0" + checksum: 10c0/44318f3585eb97be994fc21a20ddab2649feaf1fbe893f1f866d936eea3d5f8c743bec6dc02e49fbdd3c0e69e9b36f449d90a0b165a4f47dd089747af4cf2377 languageName: node linkType: hard @@ -31087,7 +31720,16 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.2.1, yaml@npm:^2.7.0, yaml@npm:^2.8.1": +"yaml@npm:^2.2.1, yaml@npm:^2.7.0": + version: 2.7.1 + resolution: "yaml@npm:2.7.1" + bin: + yaml: bin.mjs + checksum: 10c0/ee2126398ab7d1fdde566b4013b68e36930b9e6d8e68b6db356875c99614c10d678b6f45597a145ff6d63814961221fc305bf9242af8bf7450177f8a68537590 + languageName: node + linkType: hard + +"yaml@npm:^2.8.1": version: 2.8.1 resolution: "yaml@npm:2.8.1" bin: