From 8de304accfe8c8de59045b9a4ce87fbfcac621ae Mon Sep 17 00:00:00 2001 From: SuYao Date: Sun, 6 Jul 2025 19:50:18 +0800 Subject: [PATCH 01/13] fix: model recognize (#7887) * fix(image generation): model recognize * fix(grok): disable off option --- src/renderer/src/config/models.ts | 2 -- src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index bebc6cc0c7..2b4d9e959a 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2320,8 +2320,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [ ] export const SUPPORTED_DISABLE_GENERATION_MODELS = [ - 'gemini-2.0-flash-exp-image-generation', - 'gemini-2.0-flash-preview-image-generation', 'gemini-2.0-flash-exp', 'gpt-4o', 'gpt-4o-mini', diff --git a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx index 5bf57d9c9c..1338b03fcc 100644 --- a/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/ThinkingButton.tsx @@ -36,7 +36,7 @@ interface Props { // 模型类型到支持选项的映射表 const MODEL_SUPPORTED_OPTIONS: Record = { default: ['off', 'low', 'medium', 'high'], - grok: ['off', 'low', 'high'], + grok: ['low', 'high'], gemini: ['off', 'low', 'medium', 'high', 'auto'], gemini_pro: ['low', 'medium', 'high', 'auto'], qwen: ['off', 'low', 'medium', 'high'], From 84b4ae06345806932574b22b2165e7af7d33b66c Mon Sep 17 00:00:00 2001 From: one Date: Sun, 6 Jul 2025 19:50:47 +0800 Subject: [PATCH 02/13] chore: update readme badges (#7888) --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee33521ede..3594915f34 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@
[![][github-release-shield]][github-release-link] +[![][github-nightly-shield]][github-nightly-link] [![][github-contributors-shield]][github-contributors-link] [![][license-shield]][license-link] [![][commercial-shield]][commercial-link] @@ -287,7 +288,7 @@ We believe the Enterprise Edition will become your team's AI productivity engine -[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC +[deepwiki-shield]: https://img.shields.io/badge/Deepwiki-CherryHQ-0088CC?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNy45MyAzMiI+PHBhdGggZD0iTTE5LjMzIDE0LjEyYy42Ny0uMzkgMS41LS4zOSAyLjE4IDBsMS43NCAxYy4wNi4wMy4xMS4wNi4xOC4wN2guMDRjLjA2LjAzLjEyLjAzLjE4LjAzaC4wMmMuMDYgMCAuMTEgMCAuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNS4xNy0uMDhoLjAybDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWOC40YS44MS44MSAwIDAgMC0uNC0uN2wtMy40OC0yLjAxYS44My44MyAwIDAgMC0uODEgMEwxOS43NyA3LjdoLS4wMWwtLjE1LjEyLS4wMi4wMnMtLjA3LjA5LS4xLjE0VjhhLjQuNCAwIDAgMC0uMDguMTd2LjA0Yy0uMDMuMDYtLjAzLjEyLS4wMy4xOXYyLjAxYzAgLjc4LS40MSAxLjQ5LTEuMDkgMS44OC0uNjcuMzktMS41LjM5LTIuMTggMGwtMS43NC0xYS42LjYgMCAwIDAtLjIxLS4wOGMtLjA2LS4wMS0uMTItLjAyLS4xOC0uMDJoLS4wM2MtLjA2IDAtLjExLjAxLS4xNy4wMmgtLjAzYy0uMDYuMDItLjEyLjA0LS4xNy4wN2gtLjAybC0zLjQ3IDIuMDFjLS4yNS4xNC0uNC40MS0uNC43VjE4YzAgLjI5LjE1LjU1LjQuN2wzLjQ4IDIuMDFoLjAyYy4wNi4wNC4xMS4wNi4xNy4wOGguMDNjLjA1LjAyLjExLjAzLjE3LjAzaC4wMmMuMDYgMCAuMTIgMCAuMTgtLjAyaC4wNGMuMDYtLjAzLjEyLS4wNS4xOC0uMDhsMS43NC0xYy42Ny0uMzkgMS41LS4zOSAyLjE3IDBzMS4wOSAxLjExIDEuMDkgMS44OHYyLjAxYzAgLjA3IDAgLjEzLjAyLjE5di4wNGMuMDMuMDYuMDUuMTIuMDguMTd2LjAycy4wOC4wOS4xMi4xM2wuMDIuMDJzLjA5LjA4LjE1LjExYzAgMCAuMDEgMCAuMDEuMDFsMy40OCAyLjAxYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjd2LTQuMDFhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ4LTIuMDFoLS4wMmMtLjA1LS4wNC0uMTEtLjA2LS4xNy0uMDhoLS4wM2EuNS41IDAgMCAwLS4xNy0uMDNoLS4wM2MtLjA2IDAtLjEyIDAtLjE4LjAyLS4wNy4wMi0uMTUuMDUtLjIxLjA4bC0xLjc0IDFjLS42Ny4zOS0xLjUuMzktMi4xNyAwYTIuMTkgMi4xOSAwIDAgMS0xLjA5LTEuODhjMC0uNzguNDItMS40OSAxLjA5LTEuODhaIiBzdHlsZT0iZmlsbDojNWRiZjlkIi8+PHBhdGggZD0ibS40IDEzLjExIDMuNDcgMi4wMWMuMjUuMTQuNTYuMTQuOCAwbDMuNDctMi4wMWguMDFsLjE1LS4xMi4wMi0uMDJzLjA3LS4wOS4xLS4xNGwuMDItLjAyYy4wMy0uMDUuMDUtLjExLjA3LS4xN3YtLjA0Yy4wMy0uMDYuMDMtLjEyLjAzLS4xOVYxMC40YzAtLjc4LjQyLTEuNDkgMS4wOS0xLjg4czEuNS0uMzkgMi4xOCAwbDEuNzQgMWMuMDcuMDQuMTQuMDcuMjEuMDguMDYuMDEuMTIuMDIuMTguMDJoLjAzYy4wNiAwIC4xMS0uMDEuMTctLjAyaC4wM2MuMDYtLjAyLjEyLS4wNC4xNy0uMDdoLjAybDMuNDctMi4wMmMuMjUtLjE0LjQtLjQxLjQtLjd2LTRhLjgxLjgxIDAgMCAwLS40LS43bC0zLjQ2LTJhLjgzLjgzIDAgMCAwLS44MSAwbC0zLjQ4IDIuMDFoLS4wMWwtLjE1LjEyLS4wMi4wMi0uMS4xMy0uMDIuMDJjLS4wMy4wNS0uMDUuMTEtLjA3LjE3di4wNGMtLjAzLjA2LS4wMy4xMi0uMDMuMTl2Mi4wMWMwIC43OC0uNDIgMS40OS0xLjA5IDEuODhzLTEuNS4zOS0yLjE4IDBsLTEuNzQtMWEuNi42IDAgMCAwLS4yMS0uMDhjLS4wNi0uMDEtLjEyLS4wMi0uMTgtLjAyaC0uMDNjLS4wNiAwLS4xMS4wMS0uMTcuMDJoLS4wM2MtLjA2LjAyLS4xMi4wNS0uMTcuMDhoLS4wMkwuNCA3LjcxYy0uMjUuMTQtLjQuNDEtLjQuNjl2NC4wMWMwIC4yOS4xNS41Ni40LjciIHN0eWxlPSJmaWxsOiM0NDY4YzQiLz48cGF0aCBkPSJtMTcuODQgMjQuNDgtMy40OC0yLjAxaC0uMDJjLS4wNS0uMDQtLjExLS4wNi0uMTctLjA4aC0uMDNhLjUuNSAwIDAgMC0uMTctLjAzaC0uMDNjLS4wNiAwLS4xMiAwLS4xOC4wMmgtLjA0Yy0uMDYuMDMtLjEyLjA1LS4xOC4wOGwtMS43NCAxYy0uNjcuMzktMS41LjM5LTIuMTggMGEyLjE5IDIuMTkgMCAwIDEtMS4wOS0xLjg4di0yLjAxYzAtLjA2IDAtLjEzLS4wMi0uMTl2LS4wNGMtLjAzLS4wNi0uMDUtLjExLS4wOC0uMTdsLS4wMi0uMDJzLS4wNi0uMDktLjEtLjEzTDguMjkgMTlzLS4wOS0uMDgtLjE1LS4xMWgtLjAxbC0zLjQ3LTIuMDJhLjgzLjgzIDAgMCAwLS44MSAwTC4zNyAxOC44OGEuODcuODcgMCAwIDAtLjM3LjcxdjQuMDFjMCAuMjkuMTUuNTUuNC43bDMuNDcgMi4wMWguMDJjLjA1LjA0LjExLjA2LjE3LjA4aC4wM2MuMDUuMDIuMTEuMDMuMTYuMDNoLjAzYy4wNiAwIC4xMiAwIC4xOC0uMDJoLjA0Yy4wNi0uMDMuMTItLjA1LjE4LS4wOGwxLjc0LTFjLjY3LS4zOSAxLjUtLjM5IDIuMTcgMHMxLjA5IDEuMTEgMS4wOSAxLjg4djIuMDFjMCAuMDcgMCAuMTMuMDIuMTl2LjA0Yy4wMy4wNi4wNS4xMS4wOC4xN2wuMDIuMDJzLjA2LjA5LjEuMTRsLjAyLjAycy4wOS4wOC4xNS4xMWguMDFsMy40OCAyLjAyYy4yNS4xNC41Ni4xNC44MSAwbDMuNDgtMi4wMWMuMjUtLjE0LjQtLjQxLjQtLjdWMjUuMmEuODEuODEgMCAwIDAtLjQtLjdaIiBzdHlsZT0iZmlsbDojNDI5M2Q5Ii8+PC9zdmc+ [deepwiki-link]: https://deepwiki.com/CherryHQ/cherry-studio [twitter-shield]: https://img.shields.io/badge/Twitter-CherryStudioApp-0088CC?logo=x [twitter-link]: https://twitter.com/CherryStudioHQ @@ -298,9 +299,11 @@ We believe the Enterprise Edition will become your team's AI productivity engine -[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio +[github-release-shield]: https://img.shields.io/github/v/release/CherryHQ/cherry-studio?logo=github [github-release-link]: https://github.com/CherryHQ/cherry-studio/releases -[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio +[github-nightly-shield]: https://img.shields.io/github/actions/workflow/status/CherryHQ/cherry-studio/nightly-build.yml?label=nightly%20build&logo=github +[github-nightly-link]: https://github.com/CherryHQ/cherry-studio/actions/workflows/nightly-build.yml +[github-contributors-shield]: https://img.shields.io/github/contributors/CherryHQ/cherry-studio?logo=github [github-contributors-link]: https://github.com/CherryHQ/cherry-studio/graphs/contributors From 8ab4682519c80a2bd97eec4f99944afcbf0d7579 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 6 Jul 2025 19:51:59 +0800 Subject: [PATCH 03/13] fix: hide scrollbars on capturing (#7867) --- src/renderer/src/assets/styles/scrollbar.scss | 8 ++++++++ src/renderer/src/utils/image.ts | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/src/renderer/src/assets/styles/scrollbar.scss b/src/renderer/src/assets/styles/scrollbar.scss index c5df842f78..21039de9c2 100644 --- a/src/renderer/src/assets/styles/scrollbar.scss +++ b/src/renderer/src/assets/styles/scrollbar.scss @@ -49,3 +49,11 @@ pre:not(.shiki)::-webkit-scrollbar-thumb { --color-scrollbar-thumb: var(--color-scrollbar-thumb-light); --color-scrollbar-thumb-hover: var(--color-scrollbar-thumb-light-hover); } + +/* 用于截图时隐藏滚动条 + * FIXME: 临时方案,因为 html-to-image 没有正确处理伪元素。 + */ +.hide-scrollbar, +.hide-scrollbar * { + scrollbar-width: none !important; +} diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index ee52739b7c..25ba33aabf 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -68,6 +68,9 @@ export const captureScrollableDiv = async (divRef: React.RefObject Date: Sun, 6 Jul 2025 20:31:08 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E4=BD=BF=E8=87=AA=E5=8A=A8=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=89=88=E6=9C=AC=E5=8F=B7=E6=9B=B4=E5=81=A5=E5=A3=AE?= =?UTF-8?q?=20(#7864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dispatch-docs-update.yml | 27 ++++++++++++++++ .github/workflows/release.yml | 37 +--------------------- 2 files changed, 28 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/dispatch-docs-update.yml diff --git a/.github/workflows/dispatch-docs-update.yml b/.github/workflows/dispatch-docs-update.yml new file mode 100644 index 0000000000..b9457faec6 --- /dev/null +++ b/.github/workflows/dispatch-docs-update.yml @@ -0,0 +1,27 @@ +name: Dispatch Docs Update on Release + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + dispatch-docs-update: + runs-on: ubuntu-latest + steps: + - name: Get Release Tag from Event + id: get-event-tag + shell: bash + run: | + # 从当前 Release 事件中获取 tag_name + echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + + - name: Dispatch update-download-version workflow to cherry-studio-docs + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.REPO_DISPATCH_TOKEN }} + repository: CherryHQ/cherry-studio-docs + event-type: update-download-version + client-payload: '{"version": "${{ steps.get-event-tag.outputs.tag }}"}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d60f3e75c..88513e7e50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,39 +117,4 @@ jobs: makeLatest: false tag: ${{ steps.get-tag.outputs.tag }} artifacts: 'dist/*.exe,dist/*.zip,dist/*.dmg,dist/*.AppImage,dist/*.snap,dist/*.deb,dist/*.rpm,dist/*.tar.gz,dist/latest*.yml,dist/rc*.yml,dist/*.blockmap' - token: ${{ secrets.GITHUB_TOKEN }} - - dispatch-docs-update: - needs: release - if: success() && github.repository == 'CherryHQ/cherry-studio' # 确保所有构建成功且在主仓库中运行 - runs-on: ubuntu-latest - steps: - - name: Get release tag - id: get-tag - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT - else - echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - fi - - - name: Check if tag is pre-release - id: check-tag - shell: bash - run: | - TAG="${{ steps.get-tag.outputs.tag }}" - if [[ "$TAG" == *"rc"* || "$TAG" == *"pre-release"* ]]; then - echo "is_pre_release=true" >> $GITHUB_OUTPUT - else - echo "is_pre_release=false" >> $GITHUB_OUTPUT - fi - - - name: Dispatch update-download-version workflow to cherry-studio-docs - if: steps.check-tag.outputs.is_pre_release == 'false' - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.REPO_DISPATCH_TOKEN }} - repository: CherryHQ/cherry-studio-docs - event-type: update-download-version - client-payload: '{"version": "${{ steps.get-tag.outputs.tag }}"}' + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 7f8ad88c0680fe9f3bf83334d6bb0e21601fe1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=87=AA=E7=94=B1=E7=9A=84=E4=B8=96=E7=95=8C=E4=BA=BA?= <3196812536@qq.com> Date: Sun, 6 Jul 2025 21:37:17 +0800 Subject: [PATCH 05/13] feat: add show/hide toggle for API keys in settings (#7883) * feat: add show/hide toggle for API keys in settings Introduces an eye icon button to toggle visibility of API keys and tokens in Joplin, Notion, Siyuan, and Yuque settings pages. Refactors input fields to allow users to view or hide sensitive credentials, improving usability and security. Also updates translation keys in AgentsSubscribeUrlSettings for consistency. * refactor: settings pages to use Input.Password for tokens Replaced custom password visibility toggles and related styled-components with Ant Design's Input.Password component in Joplin, Notion, Siyuan, and Yuque settings pages. This simplifies the codebase and improves consistency in handling sensitive input fields. * fix: Improve layout of token input fields in settings Wrapped token/password input and check button pairs in Joplin, Notion, and Siyuan settings with Ant Design's Space.Compact for better alignment and consistent UI. * fix: trigger token change handler on blur in settings Added onBlur event handlers to the Joplin and Siyuan token input fields to ensure token changes are handled when the input loses focus, improving reliability of token updates. --- .../settings/DataSettings/AgentsSubscribeUrlSettings.tsx | 6 +++--- .../src/pages/settings/DataSettings/JoplinSettings.tsx | 7 ++++--- .../src/pages/settings/DataSettings/NotionSettings.tsx | 6 +++--- .../src/pages/settings/DataSettings/SiyuanSettings.tsx | 7 ++++--- .../src/pages/settings/DataSettings/YuqueSettings.tsx | 7 ++++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx index f4e76fadd9..0e40f26501 100755 --- a/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/AgentsSubscribeUrlSettings.tsx @@ -24,18 +24,18 @@ const AgentsSubscribeUrlSettings: FC = () => { {t('agents.tag.agent')} - {t('settings.websearch.subscribe_add')} + {t('settings.tool.websearch.subscribe_add')} - {t('settings.websearch.subscribe_url')} + {t('settings.tool.websearch.subscribe_url')} diff --git a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx index 32b3148541..664b65a60c 100644 --- a/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/JoplinSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setJoplinExportReasoning, setJoplinToken, setJoplinUrl } from '@renderer/store/settings' import { Button, Space, Switch, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -107,11 +107,12 @@ const JoplinSettings: FC = () => { - diff --git a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx index 26e8d0872a..3b97b5cbcc 100644 --- a/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/NotionSettings.tsx @@ -11,7 +11,7 @@ import { setNotionPageNameKey } from '@renderer/store/settings' import { Button, Space, Switch, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -122,12 +122,12 @@ const NotionSettings: FC = () => { {t('settings.data.notion.api_key')} - diff --git a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx index 2681f13053..7a51edc70f 100644 --- a/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/SiyuanSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings' import { Button, Space, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -109,11 +109,12 @@ const SiyuanSettings: FC = () => { - diff --git a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx index 60a8d6ef7c..1f013130c1 100644 --- a/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx +++ b/src/renderer/src/pages/settings/DataSettings/YuqueSettings.tsx @@ -5,7 +5,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup' import { RootState, useAppDispatch } from '@renderer/store' import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings' import { Button, Space, Tooltip } from 'antd' -import Input from 'antd/es/input/Input' +import { Input } from 'antd' import { FC } from 'react' import { useTranslation } from 'react-i18next' import { useSelector } from 'react-redux' @@ -101,11 +101,12 @@ const YuqueSettings: FC = () => { - From 40519b48c55d508ebba579a3af691f2c1ff46f1d Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Sun, 6 Jul 2025 23:41:20 +0800 Subject: [PATCH 06/13] fix(SelectionAssistant): overall bug fix from v1.4.8 (#7834) * feat(SelectionService): enable toolbar visibility on all workspaces * feat: update selection-hook to v1.0.5 * fix: show toolbar over fullscreen apps * fix(SelectionService): adjust macOS window type handling for fullscreen apps --- package.json | 2 +- src/main/services/SelectionService.ts | 136 ++++++++++++++++---------- yarn.lock | 10 +- 3 files changed, 90 insertions(+), 58 deletions(-) diff --git a/package.json b/package.json index d5cbc6e707..ee48a5733f 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "notion-helper": "^1.3.22", "os-proxy-config": "^1.1.2", "pdfjs-dist": "4.10.38", - "selection-hook": "^1.0.4", + "selection-hook": "^1.0.5", "turndown": "7.2.0" }, "devDependencies": { diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 23578b75e0..3be2d5a95a 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -141,7 +141,7 @@ export class SelectionService { * Initialize zoom factor from config and subscribe to changes * Ensures UI elements scale properly with system DPI settings */ - private initZoomFactor() { + private initZoomFactor(): void { const zoomFactor = configManager.getZoomFactor() if (zoomFactor) { this.setZoomFactor(zoomFactor) @@ -154,7 +154,7 @@ export class SelectionService { this.zoomFactor = zoomFactor } - private initConfig() { + private initConfig(): void { this.triggerMode = configManager.getSelectionAssistantTriggerMode() as TriggerMode this.isFollowToolbar = configManager.getSelectionAssistantFollowToolbar() this.isRemeberWinSize = configManager.getSelectionAssistantRemeberWinSize() @@ -207,7 +207,7 @@ export class SelectionService { * @param mode - The mode to set, either 'default', 'whitelist', or 'blacklist' * @param list - An array of strings representing the list of items to include or exclude */ - private setHookGlobalFilterMode(mode: string, list: string[]) { + private setHookGlobalFilterMode(mode: string, list: string[]): void { if (!this.selectionHook) return const modeMap = { @@ -245,7 +245,7 @@ export class SelectionService { } } - private setHookFineTunedList() { + private setHookFineTunedList(): void { if (!this.selectionHook) return const excludeClipboardCursorDetectList = isWin @@ -271,6 +271,11 @@ export class SelectionService { * @returns {boolean} Success status of service start */ public start(): boolean { + if (!isSupportedOS) { + this.logError(new Error('SelectionService start(): not supported on this OS')) + return false + } + if (!this.selectionHook) { this.logError(new Error('SelectionService start(): instance is null')) return false @@ -373,7 +378,7 @@ export class SelectionService { * Toggle the enabled state of the selection service * Will sync the new enabled store to all renderer windows */ - public toggleEnabled(enabled: boolean | undefined = undefined) { + public toggleEnabled(enabled: boolean | undefined = undefined): void { if (!this.selectionHook) return const newEnabled = enabled === undefined ? !configManager.getSelectionAssistantEnabled() : enabled @@ -389,7 +394,7 @@ export class SelectionService { * Sets up window properties, event handlers, and loads the toolbar UI * @param readyCallback Optional callback when window is ready to show */ - private createToolbarWindow(readyCallback?: () => void) { + private createToolbarWindow(readyCallback?: () => void): void { if (this.isToolbarAlive()) return const { toolbarWidth, toolbarHeight } = this.getToolbarRealSize() @@ -414,9 +419,11 @@ export class SelectionService { backgroundMaterial: 'none', // Platform specific settings - // [macOS] DO NOT set type to 'panel', it will not work because it conflicts with other settings // [macOS] DO NOT set focusable to false, it will make other windows bring to front together - ...(isWin ? { type: 'toolbar', focusable: false } : {}), + // [macOS] `panel` conflicts with other settings , + // and log will show `NSWindow does not support nonactivating panel styleMask 0x80` + // but it seems still work on fullscreen apps, so we set this anyway + ...(isWin ? { type: 'toolbar', focusable: false } : { type: 'panel' }), hiddenInMissionControl: true, // [macOS only] acceptFirstMouse: true, // [macOS only] @@ -447,13 +454,6 @@ export class SelectionService { // Add show/hide event listeners this.toolbarWindow.on('show', () => { this.toolbarWindow?.webContents.send(IpcChannel.Selection_ToolbarVisibilityChange, true) - - // [macOS] force the toolbar window to be visible on current desktop - // but it will make docker icon flash. And we found that it's not necessary now. - // will remove after testing - // if (isMac) { - // this.toolbarWindow!.setVisibleOnAllWorkspaces(false) - // } }) this.toolbarWindow.on('hide', () => { @@ -485,10 +485,10 @@ export class SelectionService { * @param point Reference point for positioning, logical coordinates * @param orientation Preferred position relative to reference point */ - private showToolbarAtPosition(point: Point, orientation: RelativeOrientation) { + private showToolbarAtPosition(point: Point, orientation: RelativeOrientation, programName: string): void { if (!this.isToolbarAlive()) { this.createToolbarWindow(() => { - this.showToolbarAtPosition(point, orientation) + this.showToolbarAtPosition(point, orientation, programName) }) return } @@ -509,16 +509,45 @@ export class SelectionService { //should set every time the window is shown this.toolbarWindow!.setAlwaysOnTop(true, 'screen-saver') - // [macOS] force the toolbar window to be visible on current desktop - // but it will make docker icon flash. And we found that it's not necessary now. - // will remove after testing - // if (isMac) { - // this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) - // } + // [macOS] a series of hacky ways only for macOS + if (isMac) { + // [macOS] a hacky way + // when set `skipTransformProcessType: true`, if the selection is in self app, it will make the selection canceled after toolbar showing + // so we just don't set `skipTransformProcessType: true` when in self app + const isSelf = ['com.github.Electron', 'com.kangfenmao.CherryStudio'].includes(programName) - // [macOS] MUST use `showInactive()` to prevent other windows bring to front together - // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` - this.toolbarWindow!.showInactive() + if (!isSelf) { + // [macOS] an ugly hacky way + // `focusable: true` will make mainWindow disappeared when `setVisibleOnAllWorkspaces` + // so we set `focusable: true` before showing, and then set false after showing + this.toolbarWindow!.setFocusable(false) + + // [macOS] + // force `setVisibleOnAllWorkspaces: true` to let toolbar show in all workspaces. And we MUST not set it to false again + // set `skipTransformProcessType: true` to avoid dock icon spinning when `setVisibleOnAllWorkspaces` + this.toolbarWindow!.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + skipTransformProcessType: true + }) + } + + // [macOS] MUST use `showInactive()` to prevent other windows bring to front together + // [Windows] is OK for both `show()` and `showInactive()` because of `focusable: false` + this.toolbarWindow!.showInactive() + + // [macOS] restore the focusable status + this.toolbarWindow!.setFocusable(true) + + this.startHideByMouseKeyListener() + + return + } + + /** + * The following is for Windows + */ + + this.toolbarWindow!.show() /** * [Windows] @@ -588,8 +617,8 @@ export class SelectionService { * Check if toolbar window exists and is not destroyed * @returns {boolean} Toolbar window status */ - private isToolbarAlive() { - return this.toolbarWindow && !this.toolbarWindow.isDestroyed() + private isToolbarAlive(): boolean { + return !!(this.toolbarWindow && !this.toolbarWindow.isDestroyed()) } /** @@ -598,7 +627,7 @@ export class SelectionService { * @param width New toolbar width * @param height New toolbar height */ - public determineToolbarSize(width: number, height: number) { + public determineToolbarSize(width: number, height: number): void { const toolbarWidth = Math.ceil(width) // only update toolbar width if it's changed @@ -611,7 +640,7 @@ export class SelectionService { * Get actual toolbar dimensions accounting for zoom factor * @returns Object containing toolbar width and height */ - private getToolbarRealSize() { + private getToolbarRealSize(): { toolbarWidth: number; toolbarHeight: number } { return { toolbarWidth: this.TOOLBAR_WIDTH * this.zoomFactor, toolbarHeight: this.TOOLBAR_HEIGHT * this.zoomFactor @@ -882,8 +911,8 @@ export class SelectionService { refPoint = { x: Math.round(refPoint.x), y: Math.round(refPoint.y) } } - this.showToolbarAtPosition(refPoint, refOrientation) - this.toolbarWindow?.webContents.send(IpcChannel.Selection_TextSelected, selectionData) + this.showToolbarAtPosition(refPoint, refOrientation, selectionData.programName) + this.toolbarWindow!.webContents.send(IpcChannel.Selection_TextSelected, selectionData) } /** @@ -891,7 +920,7 @@ export class SelectionService { */ // Start monitoring global mouse clicks - private startHideByMouseKeyListener() { + private startHideByMouseKeyListener(): void { try { // Register event handlers this.selectionHook!.on('mouse-down', this.handleMouseDownHide) @@ -904,7 +933,7 @@ export class SelectionService { } // Stop monitoring global mouse clicks - private stopHideByMouseKeyListener() { + private stopHideByMouseKeyListener(): void { if (!this.isHideByMouseKeyListenerActive) return try { @@ -1098,7 +1127,7 @@ export class SelectionService { * Initialize preloaded action windows * Creates a pool of windows at startup for faster response */ - private async initPreloadedActionWindows() { + private async initPreloadedActionWindows(): Promise { try { // Create initial pool of preloaded windows for (let i = 0; i < this.PRELOAD_ACTION_WINDOW_COUNT; i++) { @@ -1112,7 +1141,7 @@ export class SelectionService { /** * Close all preloaded action windows */ - private closePreloadedActionWindows() { + private closePreloadedActionWindows(): void { for (const actionWindow of this.preloadedActionWindows) { if (!actionWindow.isDestroyed()) { actionWindow.destroy() @@ -1124,7 +1153,7 @@ export class SelectionService { * Preload a new action window asynchronously * This method is called after popping a window to ensure we always have windows ready */ - private async pushNewActionWindow() { + private async pushNewActionWindow(): Promise { try { const actionWindow = this.createPreloadedActionWindow() this.preloadedActionWindows.push(actionWindow) @@ -1138,7 +1167,7 @@ export class SelectionService { * Immediately returns a window and asynchronously creates a new one * @returns {BrowserWindow} The action window */ - private popActionWindow() { + private popActionWindow(): BrowserWindow { // Get a window from the preloaded queue or create a new one if empty const actionWindow = this.preloadedActionWindows.pop() || this.createPreloadedActionWindow() @@ -1202,7 +1231,7 @@ export class SelectionService { * Ensures window stays within screen boundaries * @param actionWindow Window to position and show */ - private showActionWindow(actionWindow: BrowserWindow) { + private showActionWindow(actionWindow: BrowserWindow): void { let actionWindowWidth = this.ACTION_WINDOW_WIDTH let actionWindowHeight = this.ACTION_WINDOW_HEIGHT @@ -1228,6 +1257,7 @@ export class SelectionService { }) actionWindow.show() + return } @@ -1292,38 +1322,40 @@ export class SelectionService { * Switches between selection-based and alt-key based triggering * Manages appropriate event listeners for each mode */ - private processTriggerMode() { + private processTriggerMode(): void { + if (!this.selectionHook) return + switch (this.triggerMode) { case TriggerMode.Selected: if (this.isCtrlkeyListenerActive) { - this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = false } - this.selectionHook!.setSelectionPassiveMode(false) + this.selectionHook.setSelectionPassiveMode(false) break case TriggerMode.Ctrlkey: if (!this.isCtrlkeyListenerActive) { - this.selectionHook!.on('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.on('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.on('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.on('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = true } - this.selectionHook!.setSelectionPassiveMode(true) + this.selectionHook.setSelectionPassiveMode(true) break case TriggerMode.Shortcut: //remove the ctrlkey listener, don't need any key listener for shortcut mode if (this.isCtrlkeyListenerActive) { - this.selectionHook!.off('key-down', this.handleKeyDownCtrlkeyMode) - this.selectionHook!.off('key-up', this.handleKeyUpCtrlkeyMode) + this.selectionHook.off('key-down', this.handleKeyDownCtrlkeyMode) + this.selectionHook.off('key-up', this.handleKeyUpCtrlkeyMode) this.isCtrlkeyListenerActive = false } - this.selectionHook!.setSelectionPassiveMode(true) + this.selectionHook.setSelectionPassiveMode(true) break } } @@ -1404,13 +1436,13 @@ export class SelectionService { this.isIpcHandlerRegistered = true } - private logInfo(message: string, forceShow: boolean = false) { + private logInfo(message: string, forceShow: boolean = false): void { if (isDev || forceShow) { Logger.info('[SelectionService] Info: ', message) } } - private logError(...args: [...string[], Error]) { + private logError(...args: [...string[], Error]): void { Logger.error('[SelectionService] Error: ', ...args) } } @@ -1423,7 +1455,7 @@ export class SelectionService { export function initSelectionService(): boolean { if (!isSupportedOS) return false - configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean) => { + configManager.subscribe(ConfigKeys.SelectionAssistantEnabled, (enabled: boolean): void => { //avoid closure const ss = SelectionService.getInstance() if (!ss) { diff --git a/yarn.lock b/yarn.lock index 0711dc386e..5496f84dee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5960,7 +5960,7 @@ __metadata: remove-markdown: "npm:^0.6.2" rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" - selection-hook: "npm:^1.0.4" + selection-hook: "npm:^1.0.5" shiki: "npm:^3.7.0" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -16928,14 +16928,14 @@ __metadata: languageName: node linkType: hard -"selection-hook@npm:^1.0.4": - version: 1.0.4 - resolution: "selection-hook@npm:1.0.4" +"selection-hook@npm:^1.0.5": + version: 1.0.5 + resolution: "selection-hook@npm:1.0.5" dependencies: node-addon-api: "npm:^8.4.0" node-gyp: "npm:latest" node-gyp-build: "npm:^4.8.4" - checksum: 10c0/8c694cf7bb82159ec8aa2079e9fe74149d8cf5679720e67203b7b56a591f67d9d25e43bb5ed4242c5d6fa8be61ba64863d3b81f6a2fc5dbad7861a5273f65061 + checksum: 10c0/d188e2bafa6d820779e57a721bd2480dc1fde3f9daa2e3f92f1b69712637079e5fd9443575bc8624c98a057608f867d82fb2abf2d0796777db1f18ea50ea0028 languageName: node linkType: hard From 2c5bb5b69941f82d9b42693ec3ed17efd2eade64 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 7 Jul 2025 01:46:11 +0800 Subject: [PATCH 07/13] chore: remove useless classnames (#7795) * chore: remove useless classnames * fix: respect filterIncludeUser --- src/renderer/src/pages/home/Chat.tsx | 31 +++++++------------ .../src/pages/home/Messages/Message.tsx | 8 +---- 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 2639a06387..ac8483fc73 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -57,28 +57,19 @@ const Chat: FC = (props) => { const contentSearchFilter: NodeFilter = { acceptNode(node) { - if (node.parentNode) { - let parentNode: HTMLElement | null = node.parentNode as HTMLElement - while (parentNode?.parentNode) { - if (parentNode.classList.contains('MessageFooter')) { - return NodeFilter.FILTER_REJECT - } + const container = node.parentElement?.closest('.message-content-container') + if (!container) return NodeFilter.FILTER_REJECT - if (filterIncludeUser) { - if (parentNode?.classList.contains('message-content-container')) { - return NodeFilter.FILTER_ACCEPT - } - } else { - if (parentNode?.classList.contains('message-content-container-assistant')) { - return NodeFilter.FILTER_ACCEPT - } - } - parentNode = parentNode.parentNode as HTMLElement - } - return NodeFilter.FILTER_REJECT - } else { - return NodeFilter.FILTER_REJECT + const message = container.closest('.message') + if (!message) return NodeFilter.FILTER_REJECT + + if (filterIncludeUser) { + return NodeFilter.FILTER_ACCEPT } + if (message.classList.contains('message-assistant')) { + return NodeFilter.FILTER_ACCEPT + } + return NodeFilter.FILTER_REJECT } } diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index f5de966f0d..b171e65bf7 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -156,13 +156,7 @@ const MessageItem: FC = ({ {!isEditing && ( <> Date: Mon, 7 Jul 2025 07:29:15 +0800 Subject: [PATCH 08/13] chore: move ocr and preprocess into knowledge folder (#7896) chore: move ocr and preprocess into knowledge file --- src/main/{ => knowledage}/ocr/BaseOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/DefaultOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/MacSysOcrProvider.ts | 0 src/main/{ => knowledage}/ocr/OcrProvider.ts | 0 src/main/{ => knowledage}/ocr/OcrProviderFactory.ts | 0 .../{ => knowledage}/preprocess/BasePreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/DefaultPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/Doc2xPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/MineruPreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/MistralPreprocessProvider.ts | 0 src/main/{ => knowledage}/preprocess/PreprocessProvider.ts | 0 .../{ => knowledage}/preprocess/PreprocessProviderFactory.ts | 0 src/main/services/KnowledgeService.ts | 4 ++-- 13 files changed, 2 insertions(+), 2 deletions(-) rename src/main/{ => knowledage}/ocr/BaseOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/DefaultOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/MacSysOcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/OcrProvider.ts (100%) rename src/main/{ => knowledage}/ocr/OcrProviderFactory.ts (100%) rename src/main/{ => knowledage}/preprocess/BasePreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/DefaultPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/Doc2xPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/MineruPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/MistralPreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/PreprocessProvider.ts (100%) rename src/main/{ => knowledage}/preprocess/PreprocessProviderFactory.ts (100%) diff --git a/src/main/ocr/BaseOcrProvider.ts b/src/main/knowledage/ocr/BaseOcrProvider.ts similarity index 100% rename from src/main/ocr/BaseOcrProvider.ts rename to src/main/knowledage/ocr/BaseOcrProvider.ts diff --git a/src/main/ocr/DefaultOcrProvider.ts b/src/main/knowledage/ocr/DefaultOcrProvider.ts similarity index 100% rename from src/main/ocr/DefaultOcrProvider.ts rename to src/main/knowledage/ocr/DefaultOcrProvider.ts diff --git a/src/main/ocr/MacSysOcrProvider.ts b/src/main/knowledage/ocr/MacSysOcrProvider.ts similarity index 100% rename from src/main/ocr/MacSysOcrProvider.ts rename to src/main/knowledage/ocr/MacSysOcrProvider.ts diff --git a/src/main/ocr/OcrProvider.ts b/src/main/knowledage/ocr/OcrProvider.ts similarity index 100% rename from src/main/ocr/OcrProvider.ts rename to src/main/knowledage/ocr/OcrProvider.ts diff --git a/src/main/ocr/OcrProviderFactory.ts b/src/main/knowledage/ocr/OcrProviderFactory.ts similarity index 100% rename from src/main/ocr/OcrProviderFactory.ts rename to src/main/knowledage/ocr/OcrProviderFactory.ts diff --git a/src/main/preprocess/BasePreprocessProvider.ts b/src/main/knowledage/preprocess/BasePreprocessProvider.ts similarity index 100% rename from src/main/preprocess/BasePreprocessProvider.ts rename to src/main/knowledage/preprocess/BasePreprocessProvider.ts diff --git a/src/main/preprocess/DefaultPreprocessProvider.ts b/src/main/knowledage/preprocess/DefaultPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/DefaultPreprocessProvider.ts rename to src/main/knowledage/preprocess/DefaultPreprocessProvider.ts diff --git a/src/main/preprocess/Doc2xPreprocessProvider.ts b/src/main/knowledage/preprocess/Doc2xPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/Doc2xPreprocessProvider.ts rename to src/main/knowledage/preprocess/Doc2xPreprocessProvider.ts diff --git a/src/main/preprocess/MineruPreprocessProvider.ts b/src/main/knowledage/preprocess/MineruPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/MineruPreprocessProvider.ts rename to src/main/knowledage/preprocess/MineruPreprocessProvider.ts diff --git a/src/main/preprocess/MistralPreprocessProvider.ts b/src/main/knowledage/preprocess/MistralPreprocessProvider.ts similarity index 100% rename from src/main/preprocess/MistralPreprocessProvider.ts rename to src/main/knowledage/preprocess/MistralPreprocessProvider.ts diff --git a/src/main/preprocess/PreprocessProvider.ts b/src/main/knowledage/preprocess/PreprocessProvider.ts similarity index 100% rename from src/main/preprocess/PreprocessProvider.ts rename to src/main/knowledage/preprocess/PreprocessProvider.ts diff --git a/src/main/preprocess/PreprocessProviderFactory.ts b/src/main/knowledage/preprocess/PreprocessProviderFactory.ts similarity index 100% rename from src/main/preprocess/PreprocessProviderFactory.ts rename to src/main/knowledage/preprocess/PreprocessProviderFactory.ts diff --git a/src/main/services/KnowledgeService.ts b/src/main/services/KnowledgeService.ts index c57c0eb104..2e5f3a44d0 100644 --- a/src/main/services/KnowledgeService.ts +++ b/src/main/services/KnowledgeService.ts @@ -24,9 +24,9 @@ import { WebLoader } from '@cherrystudio/embedjs-loader-web' import Embeddings from '@main/knowledage/embeddings/Embeddings' import { addFileLoader } from '@main/knowledage/loader' import { NoteLoader } from '@main/knowledage/loader/noteLoader' +import OcrProvider from '@main/knowledage/ocr/OcrProvider' +import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider' import Reranker from '@main/knowledage/reranker/Reranker' -import OcrProvider from '@main/ocr/OcrProvider' -import PreprocessProvider from '@main/preprocess/PreprocessProvider' import { windowService } from '@main/services/WindowService' import { getDataPath } from '@main/utils' import { getAllFiles } from '@main/utils/file' From 9fd2583fd5229da007cb662f533a1079f16517d2 Mon Sep 17 00:00:00 2001 From: one Date: Mon, 7 Jul 2025 10:51:56 +0800 Subject: [PATCH 09/13] refactor(CodeEditor): add blur extension, move some extensions to hooks (#7882) --- .../CodeEditor/{hook.ts => hooks.ts} | 57 ++++++++++++++++++- .../src/components/CodeEditor/index.tsx | 28 ++++----- 2 files changed, 65 insertions(+), 20 deletions(-) rename src/renderer/src/components/CodeEditor/{hook.ts => hooks.ts} (56%) diff --git a/src/renderer/src/components/CodeEditor/hook.ts b/src/renderer/src/components/CodeEditor/hooks.ts similarity index 56% rename from src/renderer/src/components/CodeEditor/hook.ts rename to src/renderer/src/components/CodeEditor/hooks.ts index 7e3bd28327..71d74ca3a5 100644 --- a/src/renderer/src/components/CodeEditor/hook.ts +++ b/src/renderer/src/components/CodeEditor/hooks.ts @@ -1,7 +1,8 @@ import { linter } from '@codemirror/lint' // statically imported by @uiw/codemirror-extensions-basic-setup +import { EditorView } from '@codemirror/view' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' -import { Extension } from '@uiw/react-codemirror' -import { useEffect, useState } from 'react' +import { Extension, keymap } from '@uiw/react-codemirror' +import { useEffect, useMemo, useState } from 'react' // 语言对应的 linter 加载器 const linterLoaders: Record Promise> = { @@ -53,3 +54,55 @@ export const useLanguageExtensions = (language: string, lint?: boolean) => { return extensions } + +interface UseSaveKeymapProps { + onSave?: (content: string) => void + enabled?: boolean +} + +/** + * CodeMirror 扩展,用于处理保存快捷键 (Cmd/Ctrl + S) + * @param onSave 保存时触发的回调函数 + * @param enabled 是否启用此快捷键 + * @returns 扩展或空数组 + */ +export function useSaveKeymap({ onSave, enabled = true }: UseSaveKeymapProps) { + return useMemo(() => { + if (!enabled || !onSave) { + return [] + } + + return keymap.of([ + { + key: 'Mod-s', + run: (view: EditorView) => { + onSave(view.state.doc.toString()) + return true + }, + preventDefault: true + } + ]) + }, [onSave, enabled]) +} + +interface UseBlurHandlerProps { + onBlur?: (content: string) => void +} + +/** + * CodeMirror 扩展,用于处理编辑器的 blur 事件 + * @param onBlur blur 事件触发时的回调函数 + * @returns 扩展或空数组 + */ +export function useBlurHandler({ onBlur }: UseBlurHandlerProps) { + return useMemo(() => { + if (!onBlur) { + return [] + } + return EditorView.domEventHandlers({ + blur: (_event, view) => { + onBlur(view.state.doc.toString()) + } + }) + }, [onBlur]) +} diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx index db699fa030..db7dd5f1ba 100644 --- a/src/renderer/src/components/CodeEditor/index.tsx +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -1,7 +1,7 @@ import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useSettings } from '@renderer/hooks/useSettings' -import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension, keymap } from '@uiw/react-codemirror' +import CodeMirror, { Annotation, BasicSetupOptions, EditorView, Extension } from '@uiw/react-codemirror' import diff from 'fast-diff' import { ChevronsDownUp, @@ -14,7 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useLanguageExtensions } from './hook' +import { useBlurHandler, useLanguageExtensions, useSaveKeymap } from './hooks' // 标记非用户编辑的变更 const External = Annotation.define() @@ -25,6 +25,7 @@ interface Props { language: string onSave?: (newContent: string) => void onChange?: (newContent: string) => void + onBlur?: (newContent: string) => void setTools?: (value: React.SetStateAction) => void height?: string minHeight?: string @@ -54,6 +55,7 @@ const CodeEditor = ({ language, onSave, onChange, + onBlur, setTools, height, minHeight, @@ -166,28 +168,18 @@ const CodeEditor = ({ setIsUnwrapped(!wrappable) }, [wrappable]) - // 保存功能的快捷键 - const saveKeymap = useMemo(() => { - return keymap.of([ - { - key: 'Mod-s', - run: () => { - handleSave() - return true - }, - preventDefault: true - } - ]) - }, [handleSave]) + const saveKeymapExtension = useSaveKeymap({ onSave, enabled: enableKeymap }) + const blurExtension = useBlurHandler({ onBlur }) const customExtensions = useMemo(() => { return [ ...(extensions ?? []), ...langExtensions, ...(isUnwrapped ? [] : [EditorView.lineWrapping]), - ...(enableKeymap ? [saveKeymap] : []) - ] - }, [extensions, langExtensions, isUnwrapped, enableKeymap, saveKeymap]) + saveKeymapExtension, + blurExtension + ].flat() + }, [extensions, langExtensions, isUnwrapped, saveKeymapExtension, blurExtension]) return ( Date: Mon, 7 Jul 2025 10:53:19 +0800 Subject: [PATCH 10/13] fix(MessageMenubar): use classNames function to handle className (#7903) --- src/renderer/src/pages/home/Messages/MessageMenubar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index ea5e043c33..dcd86288e1 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -15,7 +15,7 @@ import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' import type { Assistant, Model, Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' -import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils' +import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils' import { copyMessageAsPlainText } from '@renderer/utils/copy' import { exportMarkdownToJoplin, @@ -399,7 +399,7 @@ const MessageMenubar: FC = (props) => { const softHoverBg = isBubbleStyle && !isLastMessage return ( - + {message.role === 'user' && ( Date: Mon, 7 Jul 2025 11:05:47 +0800 Subject: [PATCH 11/13] feat(mcp): Add default args for built-in file system MCP server (#7865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(mcp): 为内置文件系统MCP服务器添加允许目录参数 --- src/renderer/src/store/mcp.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 05caf291c4..f267c546e9 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -124,6 +124,7 @@ export const builtinMCPServers: MCPServer[] = [ name: '@cherry/filesystem', type: 'inMemory', description: '实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器', + args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'], isActive: false, provider: 'CherryAI' }, From 278fd931fb65e70ae19c09c7d869f215573805c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?George=C2=B7Dong?= <98630204+GeorgeDong32@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:00:51 +0800 Subject: [PATCH 12/13] feat: object storage backup (#7791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: import opendal * feat: 添加S3备份支持及相关设置界面 - 在IpcChannel中新增S3备份相关IPC事件,支持备份、恢复、 列表、删除文件及连接检测 - 在ipc主进程注册对应的S3备份处理函数,集成backupManager - 新增S3设置页面,支持配置Endpoint、Region、Bucket、AccessKey等 参数,并提供同步和备份策略的UI控制 - 删除未使用的RemoteStorage.ts,简化代码库 提升备份功能的灵活性,支持S3作为远程存储目标 * feat(S3 Backup): 完善S3备份功能 - 支持自动备份 - 优化设置前端 - 优化备份恢复代码 * feat(i18n): add S3 storage translations * feat(settings): 优化数据设置页面和S3设置页面UI * feat(settings): optimize S3 settings state structure and update usage * refactor: simplify S3 backup and restore modal logic * feat(s3 backup): improve S3 settings defaults and modal props * fix(i18n): optimize S3 access key translations * feat(backup): optimize logging and progress reporting * fix(settings): set S3 maxBackups as unlimited by default * chore(package): restore opendal dependency in package.json * feat(backup): migrate S3 Backup dependency from opendal to aws-sdk * refactor(backup): simplify S3 config handling and partial updates * refactor(backup): update Nutstore sync state to use RemoteSyncState * feat(store): add migration 120 to initialize missing s3 settings * feat(settings): add tooltip and help link for S3 storage * fix(s3settings): disable backup button until all fields are set --------- Co-authored-by: suyao --- package.json | 1 + packages/shared/IpcChannel.ts | 5 + src/main/ipc.ts | 5 + src/main/services/BackupManager.ts | 193 ++- src/main/services/RemoteStorage.ts | 57 - src/main/services/S3Storage.ts | 183 +++ src/preload/index.ts | 18 +- .../src/components/S3BackupManager.tsx | 295 ++++ src/renderer/src/components/S3Modals.tsx | 265 ++++ src/renderer/src/i18n/locales/en-us.json | 65 + src/renderer/src/i18n/locales/ja-jp.json | 65 + src/renderer/src/i18n/locales/ru-ru.json | 65 + src/renderer/src/i18n/locales/zh-cn.json | 67 +- src/renderer/src/i18n/locales/zh-tw.json | 67 +- src/renderer/src/init.ts | 4 +- .../settings/DataSettings/DataSettings.tsx | 10 +- .../settings/DataSettings/S3Settings.tsx | 292 ++++ src/renderer/src/services/BackupService.ts | 326 ++++- src/renderer/src/services/NutstoreService.ts | 2 +- src/renderer/src/store/backup.ts | 17 +- src/renderer/src/store/migrate.ts | 10 + src/renderer/src/store/nutstore.ts | 6 +- src/renderer/src/store/settings.ts | 30 +- src/renderer/src/types/index.ts | 15 + yarn.lock | 1272 ++++++++++++++++- 25 files changed, 3193 insertions(+), 142 deletions(-) delete mode 100644 src/main/services/RemoteStorage.ts create mode 100644 src/main/services/S3Storage.ts create mode 100644 src/renderer/src/components/S3BackupManager.tsx create mode 100644 src/renderer/src/components/S3Modals.tsx create mode 100644 src/renderer/src/pages/settings/DataSettings/S3Settings.tsx diff --git a/package.json b/package.json index ee48a5733f..06e656cbc1 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "prepare": "husky" }, "dependencies": { + "@aws-sdk/client-s3": "^3.840.0", "@cherrystudio/pdf-to-img-napi": "^0.0.1", "@libsql/client": "0.14.0", "@libsql/win32-x64-msvc": "^0.4.7", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 38c6c2b516..66475c50fa 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -165,6 +165,11 @@ export enum IpcChannel { Backup_CheckConnection = 'backup:checkConnection', Backup_CreateDirectory = 'backup:createDirectory', Backup_DeleteWebdavFile = 'backup:deleteWebdavFile', + Backup_BackupToS3 = 'backup:backupToS3', + Backup_RestoreFromS3 = 'backup:restoreFromS3', + Backup_ListS3Files = 'backup:listS3Files', + Backup_DeleteS3File = 'backup:deleteS3File', + Backup_CheckS3Connection = 'backup:checkS3Connection', // zip Zip_Compress = 'zip:compress', diff --git a/src/main/ipc.ts b/src/main/ipc.ts index f97cb60ed9..4a5433f67f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -368,6 +368,11 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Backup_CheckConnection, backupManager.checkConnection) ipcMain.handle(IpcChannel.Backup_CreateDirectory, backupManager.createDirectory) ipcMain.handle(IpcChannel.Backup_DeleteWebdavFile, backupManager.deleteWebdavFile) + ipcMain.handle(IpcChannel.Backup_BackupToS3, backupManager.backupToS3) + ipcMain.handle(IpcChannel.Backup_RestoreFromS3, backupManager.restoreFromS3) + ipcMain.handle(IpcChannel.Backup_ListS3Files, backupManager.listS3Files) + ipcMain.handle(IpcChannel.Backup_DeleteS3File, backupManager.deleteS3File) + ipcMain.handle(IpcChannel.Backup_CheckS3Connection, backupManager.checkS3Connection) // file ipcMain.handle(IpcChannel.File_Open, fileManager.open) diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index e994e90bed..576f004188 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -1,5 +1,6 @@ import { IpcChannel } from '@shared/IpcChannel' import { WebDavConfig } from '@types' +import { S3Config } from '@types' import archiver from 'archiver' import { exec } from 'child_process' import { app } from 'electron' @@ -10,6 +11,7 @@ import * as path from 'path' import { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' +import S3Storage from './S3Storage' import WebDav from './WebDav' import { windowService } from './WindowService' @@ -25,6 +27,11 @@ class BackupManager { this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.listWebdavFiles = this.listWebdavFiles.bind(this) this.deleteWebdavFile = this.deleteWebdavFile.bind(this) + this.backupToS3 = this.backupToS3.bind(this) + this.restoreFromS3 = this.restoreFromS3.bind(this) + this.listS3Files = this.listS3Files.bind(this) + this.deleteS3File = this.deleteS3File.bind(this) + this.checkS3Connection = this.checkS3Connection.bind(this) } private async setWritableRecursive(dirPath: string): Promise { @@ -85,7 +92,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) - Logger.log('[BackupManager] backup progress', processData) + // 只在关键阶段记录日志:开始、结束和主要阶段转换点 + const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] backup progress', processData) + } } try { @@ -147,18 +158,23 @@ class BackupManager { let totalBytes = 0 let processedBytes = 0 - // 首先计算总文件数和总大小 + // 首先计算总文件数和总大小,但不记录详细日志 const calculateTotals = async (dirPath: string) => { - const items = await fs.readdir(dirPath, { withFileTypes: true }) - for (const item of items) { - const fullPath = path.join(dirPath, item.name) - if (item.isDirectory()) { - await calculateTotals(fullPath) - } else { - totalEntries++ - const stats = await fs.stat(fullPath) - totalBytes += stats.size + try { + const items = await fs.readdir(dirPath, { withFileTypes: true }) + for (const item of items) { + const fullPath = path.join(dirPath, item.name) + if (item.isDirectory()) { + await calculateTotals(fullPath) + } else { + totalEntries++ + const stats = await fs.stat(fullPath) + totalBytes += stats.size + } } + } catch (error) { + // 仅在出错时记录日志 + Logger.error('[BackupManager] Error calculating totals:', error) } } @@ -230,7 +246,11 @@ class BackupManager { const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) - Logger.log('[BackupManager] restore progress', processData) + // 只在关键阶段记录日志 + const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] + if (logStages.includes(processData.stage) || processData.progress === 100) { + Logger.log('[BackupManager] restore progress', processData) + } } try { @@ -382,21 +402,54 @@ class BackupManager { destination: string, onProgress: (size: number) => void ): Promise { - const items = await fs.readdir(source, { withFileTypes: true }) + // 先统计总文件数 + let totalFiles = 0 + let processedFiles = 0 + let lastProgressReported = 0 - for (const item of items) { - const sourcePath = path.join(source, item.name) - const destPath = path.join(destination, item.name) + // 计算总文件数 + const countFiles = async (dir: string): Promise => { + let count = 0 + const items = await fs.readdir(dir, { withFileTypes: true }) + for (const item of items) { + if (item.isDirectory()) { + count += await countFiles(path.join(dir, item.name)) + } else { + count++ + } + } + return count + } - if (item.isDirectory()) { - await fs.ensureDir(destPath) - await this.copyDirWithProgress(sourcePath, destPath, onProgress) - } else { - const stats = await fs.stat(sourcePath) - await fs.copy(sourcePath, destPath) - onProgress(stats.size) + totalFiles = await countFiles(source) + + // 复制文件并更新进度 + const copyDir = async (src: string, dest: string): Promise => { + const items = await fs.readdir(src, { withFileTypes: true }) + + for (const item of items) { + const sourcePath = path.join(src, item.name) + const destPath = path.join(dest, item.name) + + if (item.isDirectory()) { + await fs.ensureDir(destPath) + await copyDir(sourcePath, destPath) + } else { + const stats = await fs.stat(sourcePath) + await fs.copy(sourcePath, destPath) + processedFiles++ + + // 只在进度变化超过5%时报告进度 + const currentProgress = Math.floor((processedFiles / totalFiles) * 100) + if (currentProgress - lastProgressReported >= 5 || processedFiles === totalFiles) { + lastProgressReported = currentProgress + onProgress(stats.size) + } + } } } + + await copyDir(source, destination) } async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { @@ -423,6 +476,100 @@ class BackupManager { throw new Error(error.message || 'Failed to delete backup file') } } + + async backupToS3(_: Electron.IpcMainInvokeEvent, data: string, s3Config: S3Config) { + const os = require('os') + const deviceName = os.hostname ? os.hostname() : 'device' + const timestamp = new Date() + .toISOString() + .replace(/[-:T.Z]/g, '') + .slice(0, 14) + const filename = s3Config.fileName || `cherry-studio.backup.${deviceName}.${timestamp}.zip` + + Logger.log(`[BackupManager] Starting S3 backup to ${filename}`) + + const backupedFilePath = await this.backup(_, filename, data, undefined, s3Config.skipBackupFile) + const s3Client = new S3Storage(s3Config) + try { + const fileBuffer = await fs.promises.readFile(backupedFilePath) + const result = await s3Client.putFileContents(filename, fileBuffer) + await fs.remove(backupedFilePath) + + Logger.log(`[BackupManager] S3 backup completed successfully: ${filename}`) + return result + } catch (error) { + Logger.error(`[BackupManager] S3 backup failed:`, error) + await fs.remove(backupedFilePath) + throw error + } + } + + async restoreFromS3(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const filename = s3Config.fileName || 'cherry-studio.backup.zip' + + Logger.log(`[BackupManager] Starting restore from S3: ${filename}`) + + const s3Client = new S3Storage(s3Config) + try { + const retrievedFile = await s3Client.getFileContents(filename) + const backupedFilePath = path.join(this.backupDir, filename) + if (!fs.existsSync(this.backupDir)) { + fs.mkdirSync(this.backupDir, { recursive: true }) + } + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(backupedFilePath) + writeStream.write(retrievedFile as Buffer) + writeStream.end() + writeStream.on('finish', () => resolve()) + writeStream.on('error', (error) => reject(error)) + }) + + Logger.log(`[BackupManager] S3 restore file downloaded successfully: ${filename}`) + return await this.restore(_, backupedFilePath) + } catch (error: any) { + Logger.error('[BackupManager] Failed to restore from S3:', error) + throw new Error(error.message || 'Failed to restore backup file') + } + } + + listS3Files = async (_: Electron.IpcMainInvokeEvent, s3Config: S3Config) => { + try { + const s3Client = new S3Storage(s3Config) + + const objects = await s3Client.listFiles() + const files = objects + .filter((obj) => obj.key.endsWith('.zip')) + .map((obj) => { + const segments = obj.key.split('/') + const fileName = segments[segments.length - 1] + return { + fileName, + modifiedTime: obj.lastModified || '', + size: obj.size + } + }) + + return files.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime()) + } catch (error: any) { + Logger.error('Failed to list S3 files:', error) + throw new Error(error.message || 'Failed to list backup files') + } + } + + async deleteS3File(_: Electron.IpcMainInvokeEvent, fileName: string, s3Config: S3Config) { + try { + const s3Client = new S3Storage(s3Config) + return await s3Client.deleteFile(fileName) + } catch (error: any) { + Logger.error('Failed to delete S3 file:', error) + throw new Error(error.message || 'Failed to delete backup file') + } + } + + async checkS3Connection(_: Electron.IpcMainInvokeEvent, s3Config: S3Config) { + const s3Client = new S3Storage(s3Config) + return await s3Client.checkConnection() + } } export default BackupManager diff --git a/src/main/services/RemoteStorage.ts b/src/main/services/RemoteStorage.ts deleted file mode 100644 index b62489bbbe..0000000000 --- a/src/main/services/RemoteStorage.ts +++ /dev/null @@ -1,57 +0,0 @@ -// import Logger from 'electron-log' -// import { Operator } from 'opendal' - -// export default class RemoteStorage { -// public instance: Operator | undefined - -// /** -// * -// * @param scheme is the scheme for opendal services. Available value includes "azblob", "azdls", "cos", "gcs", "obs", "oss", "s3", "webdav", "webhdfs", "aliyun-drive", "alluxio", "azfile", "dropbox", "gdrive", "onedrive", "postgresql", "mysql", "redis", "swift", "mongodb", "alluxio", "b2", "seafile", "upyun", "koofr", "yandex-disk" -// * @param options is the options for given opendal services. Valid options depend on the scheme. Checkout https://docs.rs/opendal/latest/opendal/services/index.html for all valid options. -// * -// * For example, use minio as remote storage: -// * -// * ```typescript -// * const storage = new RemoteStorage('s3', { -// * endpoint: 'http://localhost:9000', -// * region: 'us-east-1', -// * bucket: 'testbucket', -// * access_key_id: 'user', -// * secret_access_key: 'password', -// * root: '/path/to/basepath', -// * }) -// * ``` -// */ -// constructor(scheme: string, options?: Record | undefined | null) { -// this.instance = new Operator(scheme, options) - -// this.putFileContents = this.putFileContents.bind(this) -// this.getFileContents = this.getFileContents.bind(this) -// } - -// public putFileContents = async (filename: string, data: string | Buffer) => { -// if (!this.instance) { -// return new Error('RemoteStorage client not initialized') -// } - -// try { -// return await this.instance.write(filename, data) -// } catch (error) { -// Logger.error('[RemoteStorage] Error putting file contents:', error) -// throw error -// } -// } - -// public getFileContents = async (filename: string) => { -// if (!this.instance) { -// throw new Error('RemoteStorage client not initialized') -// } - -// try { -// return await this.instance.read(filename) -// } catch (error) { -// Logger.error('[RemoteStorage] Error getting file contents:', error) -// throw error -// } -// } -// } diff --git a/src/main/services/S3Storage.ts b/src/main/services/S3Storage.ts new file mode 100644 index 0000000000..0b45bb0387 --- /dev/null +++ b/src/main/services/S3Storage.ts @@ -0,0 +1,183 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client +} from '@aws-sdk/client-s3' +import type { S3Config } from '@types' +import Logger from 'electron-log' +import * as net from 'net' +import { Readable } from 'stream' + +/** + * 将可读流转换为 Buffer + */ +function streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + stream.on('error', reject) + stream.on('end', () => resolve(Buffer.concat(chunks))) + }) +} + +// 需要使用 Virtual Host-Style 的服务商域名后缀白名单 +const VIRTUAL_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com'] + +/** + * 使用 AWS SDK v3 的简单 S3 封装,兼容之前 RemoteStorage 的最常用接口。 + */ +export default class S3Storage { + private client: S3Client + private bucket: string + private root: string + + constructor(config: S3Config) { + const { endpoint, region, accessKeyId, secretAccessKey, bucket, root } = config + + const usePathStyle = (() => { + if (!endpoint) return false + + try { + const { hostname } = new URL(endpoint) + + if (hostname === 'localhost' || net.isIP(hostname) !== 0) { + return true + } + + const isInWhiteList = VIRTUAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)) + return !isInWhiteList + } catch (e) { + Logger.warn('[S3Storage] Failed to parse endpoint, fallback to Path-Style:', endpoint, e) + return true + } + })() + + this.client = new S3Client({ + region, + endpoint: endpoint || undefined, + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey + }, + forcePathStyle: usePathStyle + }) + + this.bucket = bucket + this.root = root?.replace(/^\/+/g, '').replace(/\/+$/g, '') || '' + + this.putFileContents = this.putFileContents.bind(this) + this.getFileContents = this.getFileContents.bind(this) + this.deleteFile = this.deleteFile.bind(this) + this.listFiles = this.listFiles.bind(this) + this.checkConnection = this.checkConnection.bind(this) + } + + /** + * 内部辅助方法,用来拼接带 root 的对象 key + */ + private buildKey(key: string): string { + if (!this.root) return key + return key.startsWith(`${this.root}/`) ? key : `${this.root}/${key}` + } + + async putFileContents(key: string, data: Buffer | string) { + try { + const contentType = key.endsWith('.zip') ? 'application/zip' : 'application/octet-stream' + + return await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: this.buildKey(key), + Body: data, + ContentType: contentType + }) + ) + } catch (error) { + Logger.error('[S3Storage] Error putting object:', error) + throw error + } + } + + async getFileContents(key: string): Promise { + try { + const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: this.buildKey(key) })) + if (!res.Body || !(res.Body instanceof Readable)) { + throw new Error('Empty body received from S3') + } + return await streamToBuffer(res.Body as Readable) + } catch (error) { + Logger.error('[S3Storage] Error getting object:', error) + throw error + } + } + + async deleteFile(key: string) { + try { + const keyWithRoot = this.buildKey(key) + const variations = new Set([keyWithRoot, key.replace(/^\//, '')]) + for (const k of variations) { + try { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: k })) + } catch { + // 忽略删除失败 + } + } + } catch (error) { + Logger.error('[S3Storage] Error deleting object:', error) + throw error + } + } + + /** + * 列举指定前缀下的对象,默认列举全部。 + */ + async listFiles(prefix = ''): Promise> { + const files: Array<{ key: string; lastModified?: string; size: number }> = [] + let continuationToken: string | undefined + const fullPrefix = this.buildKey(prefix) + + try { + do { + const res = await this.client.send( + new ListObjectsV2Command({ + Bucket: this.bucket, + Prefix: fullPrefix === '' ? undefined : fullPrefix, + ContinuationToken: continuationToken + }) + ) + + res.Contents?.forEach((obj) => { + if (!obj.Key) return + files.push({ + key: obj.Key, + lastModified: obj.LastModified?.toISOString(), + size: obj.Size ?? 0 + }) + }) + + continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined + } while (continuationToken) + + return files + } catch (error) { + Logger.error('[S3Storage] Error listing objects:', error) + throw error + } + } + + /** + * 尝试调用 HeadBucket 判断凭证/网络是否可用 + */ + async checkConnection() { + try { + await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })) + return true + } catch (error) { + Logger.error('[S3Storage] Error checking connection:', error) + throw error + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 533263512d..ea081645b2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { KnowledgeItem, MCPServer, Provider, + S3Config, Shortcut, ThemeMode, WebDavConfig @@ -72,9 +73,9 @@ const api = { decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) }, backup: { - backup: (fileName: string, data: string, destinationPath?: string, skipBackupFile?: boolean) => - ipcRenderer.invoke(IpcChannel.Backup_Backup, fileName, data, destinationPath, skipBackupFile), - restore: (backupPath: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, backupPath), + backup: (filename: string, content: string, path: string, skipBackupFile: boolean) => + ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile), + restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig), restoreFromWebdav: (webdavConfig: WebDavConfig) => @@ -86,7 +87,16 @@ const api = { createDirectory: (webdavConfig: WebDavConfig, path: string, options?: CreateDirectoryOptions) => ipcRenderer.invoke(IpcChannel.Backup_CreateDirectory, webdavConfig, path, options), deleteWebdavFile: (fileName: string, webdavConfig: WebDavConfig) => - ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig) + ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig), + checkWebdavConnection: (webdavConfig: WebDavConfig) => + ipcRenderer.invoke(IpcChannel.Backup_CheckConnection, webdavConfig), + + backupToS3: (data: string, s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_BackupToS3, data, s3Config), + restoreFromS3: (s3Config: S3Config) => ipcRenderer.invoke(IpcChannel.Backup_RestoreFromS3, s3Config), + 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) }, file: { select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options), diff --git a/src/renderer/src/components/S3BackupManager.tsx b/src/renderer/src/components/S3BackupManager.tsx new file mode 100644 index 0000000000..f644d2dce6 --- /dev/null +++ b/src/renderer/src/components/S3BackupManager.tsx @@ -0,0 +1,295 @@ +import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { restoreFromS3 } from '@renderer/services/BackupService' +import type { S3Config } from '@renderer/types' +import { formatFileSize } from '@renderer/utils' +import { Button, Modal, Table, Tooltip } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +interface S3BackupManagerProps { + visible: boolean + onClose: () => void + s3Config: Partial + restoreMethod?: (fileName: string) => Promise +} + +export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) { + const [backupFiles, setBackupFiles] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + const [deleting, setDeleting] = useState(false) + const [restoring, setRestoring] = useState(false) + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 5, + total: 0 + }) + const { t } = useTranslation() + + const { endpoint, region, bucket, accessKeyId, secretAccessKey } = s3Config + + const fetchBackupFiles = useCallback(async () => { + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + setLoading(true) + try { + const files = await window.api.backup.listS3Files({ + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + setBackupFiles(files) + setPagination((prev) => ({ + ...prev, + total: files.length + })) + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.files.fetch.error', { message: error.message })) + } finally { + setLoading(false) + } + }, [endpoint, region, bucket, accessKeyId, secretAccessKey, t, s3Config]) + + useEffect(() => { + if (visible) { + fetchBackupFiles() + setSelectedRowKeys([]) + setPagination((prev) => ({ + ...prev, + current: 1 + })) + } + }, [visible, fetchBackupFiles]) + + const handleTableChange = (pagination: any) => { + setPagination(pagination) + } + + const handleDeleteSelected = async () => { + if (selectedRowKeys.length === 0) { + window.message.warning(t('settings.data.s3.manager.select.warning')) + return + } + + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.multiple', { count: selectedRowKeys.length }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + // 依次删除选中的文件 + for (const key of selectedRowKeys) { + await window.api.backup.deleteS3File(key.toString(), { + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + } + window.message.success( + t('settings.data.s3.manager.delete.success.multiple', { count: selectedRowKeys.length }) + ) + setSelectedRowKeys([]) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleDeleteSingle = async (fileName: string) => { + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.manager.delete.confirm.title'), + icon: , + content: t('settings.data.s3.manager.delete.confirm.single', { fileName }), + okText: t('settings.data.s3.manager.delete.confirm.title'), + cancelText: t('common.cancel'), + centered: true, + onOk: async () => { + setDeleting(true) + try { + await window.api.backup.deleteS3File(fileName, { + ...s3Config, + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + skipBackupFile: false, + autoSync: false, + syncInterval: 0, + maxBackups: 0 + }) + window.message.success(t('settings.data.s3.manager.delete.success.single')) + await fetchBackupFiles() + } catch (error: any) { + window.message.error(t('settings.data.s3.manager.delete.error', { message: error.message })) + } finally { + setDeleting(false) + } + } + }) + } + + const handleRestore = async (fileName: string) => { + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error(t('settings.data.s3.manager.config.incomplete')) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + icon: , + content: t('settings.data.s3.restore.confirm.content'), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await (restoreMethod || restoreFromS3)(fileName) + window.message.success(t('settings.data.s3.restore.success')) + onClose() // 关闭模态框 + } catch (error: any) { + window.message.error(t('settings.data.s3.restore.error', { message: error.message })) + } finally { + setRestoring(false) + } + } + }) + } + + const columns = [ + { + title: t('settings.data.s3.manager.columns.fileName'), + dataIndex: 'fileName', + key: 'fileName', + ellipsis: { + showTitle: false + }, + render: (fileName: string) => ( + + {fileName} + + ) + }, + { + title: t('settings.data.s3.manager.columns.modifiedTime'), + dataIndex: 'modifiedTime', + key: 'modifiedTime', + width: 180, + render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: t('settings.data.s3.manager.columns.size'), + dataIndex: 'size', + key: 'size', + width: 120, + render: (size: number) => formatFileSize(size) + }, + { + title: t('settings.data.s3.manager.columns.actions'), + key: 'action', + width: 160, + render: (_: any, record: BackupFile) => ( + <> + + + + ) + } + ] + + const rowSelection = { + selectedRowKeys, + onChange: (selectedRowKeys: React.Key[]) => { + setSelectedRowKeys(selectedRowKeys) + } + } + + return ( + } onClick={fetchBackupFiles} disabled={loading}> + {t('settings.data.s3.manager.refresh')} + , + , + + ]}> + + + ) +} diff --git a/src/renderer/src/components/S3Modals.tsx b/src/renderer/src/components/S3Modals.tsx new file mode 100644 index 0000000000..75c8b31b3a --- /dev/null +++ b/src/renderer/src/components/S3Modals.tsx @@ -0,0 +1,265 @@ +import { backupToS3 } from '@renderer/services/BackupService' +import { formatFileSize } from '@renderer/utils' +import { Input, Modal, Select, Spin } from 'antd' +import dayjs from 'dayjs' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface BackupFile { + fileName: string + modifiedTime: string + size: number +} + +export function useS3BackupModal() { + const [customFileName, setCustomFileName] = useState('') + const [isModalVisible, setIsModalVisible] = useState(false) + const [backuping, setBackuping] = useState(false) + + const handleBackup = async () => { + setBackuping(true) + try { + await backupToS3({ customFileName, showMessage: true }) + } finally { + setBackuping(false) + setIsModalVisible(false) + } + } + + const handleCancel = () => { + setIsModalVisible(false) + } + + const showBackupModal = useCallback(async () => { + // 获取默认文件名 + const deviceType = await window.api.system.getDeviceType() + const hostname = await window.api.system.getHostname() + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const defaultFileName = `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + setCustomFileName(defaultFileName) + setIsModalVisible(true) + }, []) + + return { + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName, + showBackupModal + } +} + +type S3BackupModalProps = { + isModalVisible: boolean + handleBackup: () => Promise + handleCancel: () => void + backuping: boolean + customFileName: string + setCustomFileName: (value: string) => void +} + +export function S3BackupModal({ + isModalVisible, + handleBackup, + handleCancel, + backuping, + customFileName, + setCustomFileName +}: S3BackupModalProps) { + const { t } = useTranslation() + + return ( + + setCustomFileName(e.target.value)} + placeholder={t('settings.data.s3.backup.modal.filename.placeholder')} + /> + + ) +} + +interface UseS3RestoreModalProps { + endpoint: string | undefined + region: string | undefined + bucket: string | undefined + accessKeyId: string | undefined + secretAccessKey: string | undefined + root?: string | undefined +} + +export function useS3RestoreModal({ + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + root +}: UseS3RestoreModalProps) { + const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false) + const [restoring, setRestoring] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) + const [loadingFiles, setLoadingFiles] = useState(false) + const [backupFiles, setBackupFiles] = useState([]) + const { t } = useTranslation() + + const showRestoreModal = useCallback(async () => { + if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error({ content: t('settings.data.s3.manager.config.incomplete'), key: 's3-error' }) + return + } + + setIsRestoreModalVisible(true) + setLoadingFiles(true) + try { + const files = await window.api.backup.listS3Files({ + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + root, + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + }) + setBackupFiles(files) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.manager.files.fetch.error', { message: error.message }), + key: 'list-files-error' + }) + } finally { + setLoadingFiles(false) + } + }, [endpoint, region, bucket, accessKeyId, secretAccessKey, root, t]) + + const handleRestore = useCallback(async () => { + if (!selectedFile || !endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) { + window.message.error({ + content: !selectedFile + ? t('settings.data.s3.restore.file.required') + : t('settings.data.s3.restore.config.incomplete'), + key: 'restore-error' + }) + return + } + + window.modal.confirm({ + title: t('settings.data.s3.restore.confirm.title'), + content: t('settings.data.s3.restore.confirm.content', { fileName: selectedFile }), + okText: t('settings.data.s3.restore.confirm.ok'), + cancelText: t('settings.data.s3.restore.confirm.cancel'), + centered: true, + onOk: async () => { + setRestoring(true) + try { + await window.api.backup.restoreFromS3({ + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + root, + fileName: selectedFile, + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + }) + window.message.success({ content: t('message.restore.success'), key: 's3-restore' }) + setIsRestoreModalVisible(false) + } catch (error: any) { + window.message.error({ + content: t('settings.data.s3.restore.error', { message: error.message }), + key: 'restore-error' + }) + } finally { + setRestoring(false) + } + } + }) + }, [selectedFile, endpoint, region, bucket, accessKeyId, secretAccessKey, root, t]) + + const handleCancel = () => { + setIsRestoreModalVisible(false) + } + + return { + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles, + showRestoreModal + } +} + +type S3RestoreModalProps = ReturnType + +export function S3RestoreModal({ + isRestoreModalVisible, + handleRestore, + handleCancel, + restoring, + selectedFile, + setSelectedFile, + loadingFiles, + backupFiles +}: S3RestoreModalProps) { + const { t } = useTranslation() + + return ( + +
+ setEndpoint(e.target.value)} + style={{ width: 250 }} + type="url" + onBlur={() => dispatch(setS3Partial({ endpoint: endpoint || '' }))} + /> + + + + {t('settings.data.s3.region')} + setRegion(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ region: region || '' }))} + /> + + + + {t('settings.data.s3.bucket')} + setBucket(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ bucket: bucket || '' }))} + /> + + + + {t('settings.data.s3.accessKeyId')} + setAccessKeyId(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ accessKeyId: accessKeyId || '' }))} + /> + + + + {t('settings.data.s3.secretAccessKey')} + setSecretAccessKey(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ secretAccessKey: secretAccessKey || '' }))} + /> + + + + {t('settings.data.s3.root')} + setRoot(e.target.value)} + style={{ width: 250 }} + onBlur={() => dispatch(setS3Partial({ root: root || '' }))} + /> + + + + {t('settings.data.s3.backup.operation')} + + + + + + + + {t('settings.data.s3.autoSync')} + + + + + {t('settings.data.s3.maxBackups')} + + + + + {t('settings.data.s3.skipBackupFile')} + + + + {t('settings.data.s3.skipBackupFile.help')} + + {syncInterval > 0 && ( + <> + + + {t('settings.data.s3.syncStatus')} + {renderSyncStatus()} + + + )} + <> + + + + + + ) +} + +export default S3Settings diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 3d78b2752a..00a09acf54 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -4,11 +4,63 @@ import { upgradeToV7 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' +import { setS3SyncState } from '@renderer/store/backup' +import { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// 重试删除S3文件的辅助函数 +async function deleteS3FileWithRetry(fileName: string, s3Config: S3Config, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteS3File(fileName, s3Config) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + +// 重试删除WebDAV文件的辅助函数 +async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: WebDavConfig, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteWebdavFile(fileName, webdavConfig) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + export async function backup(skipBackupFile: boolean) { const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` const fileContnet = await getBackupData() @@ -161,17 +213,21 @@ export async function backupToWebdav({ // 文件已按修改时间降序排序,所以最旧的文件在末尾 const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups) - for (const file of filesToDelete) { - try { - await window.api.backup.deleteWebdavFile(file.fileName, { - webdavHost, - webdavUser, - webdavPass, - webdavPath - }) - Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`) - } catch (error) { - Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error) + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteWebdavFileWithRetry(file.fileName, { + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) } } } @@ -242,6 +298,160 @@ export async function restoreFromWebdav(fileName?: string) { } } +export async function backupToS3({ + showMessage = false, + customFileName = '', + autoBackupProcess = false +}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { + const notificationService = NotificationService.getInstance() + if (isManualBackupRunning) { + Logger.log('[Backup] Manual backup already in progress') + return + } + + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null })) + + const s3Config = store.getState().settings.s3 + let deviceType = 'unknown' + let hostname = 'unknown' + try { + deviceType = (await window.api.system.getDeviceType()) || 'unknown' + hostname = (await window.api.system.getHostname()) || 'unknown' + } catch (error) { + Logger.error('[Backup] Failed to get device type or hostname:', error) + } + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + const backupData = await getBackupData() + + try { + const success = await window.api.backup.backupToS3(backupData, { + ...s3Config, + fileName: finalFileName + }) + + if (success) { + store.dispatch( + setS3SyncState({ + lastSyncError: null, + syncing: false, + lastSyncTime: Date.now() + }) + ) + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + + // 清理旧备份文件 + if (s3Config.maxBackups > 0) { + try { + // 获取所有备份文件 + const files = await window.api.backup.listS3Files(s3Config) + + // 筛选当前设备的备份文件 + const currentDeviceFiles = files.filter((file) => { + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + // 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件 + if (currentDeviceFiles.length > s3Config.maxBackups) { + const filesToDelete = currentDeviceFiles.slice(s3Config.maxBackups) + + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteS3FileWithRetry(file.fileName, s3Config) + + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + } + } catch (error) { + Logger.error('[Backup] Failed to clean up old backup files:', error) + } + } + } else { + if (autoBackupProcess) { + throw new Error(i18n.t('message.backup.failed')) + } + + store.dispatch(setS3SyncState({ lastSyncError: 'Backup failed' })) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + } + } catch (error: any) { + if (autoBackupProcess) { + throw error + } + notificationService.send({ + id: uuid(), + type: 'error', + title: i18n.t('message.backup.failed'), + message: error.message, + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + store.dispatch(setS3SyncState({ lastSyncError: error.message })) + console.error('[Backup] backupToS3: Error uploading file to S3:', error) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +// 从 S3 恢复 +export async function restoreFromS3(fileName?: string) { + const s3Config = store.getState().settings.s3 + + if (!fileName) { + const files = await window.api.backup.listS3Files(s3Config) + if (files.length > 0) { + fileName = files[0].fileName + } + } + + if (fileName) { + const restoreData = await window.api.backup.restoreFromS3({ + ...s3Config, + fileName + }) + const data = JSON.parse(restoreData) + await handleData(data) + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false, + lastSyncError: null + }) + ) + } +} + let autoSyncStarted = false let syncTimeout: NodeJS.Timeout | null = null let isAutoBackupRunning = false @@ -252,9 +462,18 @@ export function startAutoSync(immediate = false) { return } - const { webdavAutoSync, webdavHost } = store.getState().settings + const settings = store.getState().settings + const { webdavAutoSync, webdavHost } = settings + const s3Settings = settings.s3 - if (!webdavAutoSync || !webdavHost) { + const s3AutoSync = s3Settings?.autoSync + const s3Endpoint = s3Settings?.endpoint + + // 检查WebDAV或S3自动同步配置 + const hasWebdavConfig = webdavAutoSync && webdavHost + const hasS3Config = s3AutoSync && s3Endpoint + + if (!hasWebdavConfig && !hasS3Config) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -277,22 +496,28 @@ export function startAutoSync(immediate = false) { syncTimeout = null } - const { webdavSyncInterval } = store.getState().settings - const { webdavSync } = store.getState().backup + const settings = store.getState().settings + const _webdavSyncInterval = settings.webdavSyncInterval + const _s3SyncInterval = settings.s3?.syncInterval + const { webdavSync, s3Sync } = store.getState().backup - if (webdavSyncInterval <= 0) { + // 使用当前激活的同步配置 + const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval + const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + + if (!syncInterval || syncInterval <= 0) { Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') stopAutoSync() return } // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = webdavSyncInterval * 60 * 1000 + const requiredInterval = syncInterval * 60 * 1000 let timeUntilNextSync = 1000 //also immediate switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间 - timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': timeUntilNextSync = requiredInterval @@ -301,8 +526,9 @@ export function startAutoSync(immediate = false) { syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) @@ -321,17 +547,28 @@ export function startAutoSync(immediate = false) { while (retryCount < maxRetries) { try { - Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) - await backupToWebdav({ autoBackupProcess: true }) - - store.dispatch( - setWebDAVSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + await backupToWebdav({ autoBackupProcess: true }) + store.dispatch( + setWebDAVSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + await backupToS3({ autoBackupProcess: true }) + store.dispatch( + setS3SyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } isAutoBackupRunning = false scheduleNextBackup() @@ -340,20 +577,31 @@ export function startAutoSync(immediate = false) { } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - Logger.error('[AutoSync] Auto backup failed after all retries:', error) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) - store.dispatch( - setWebDAVSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + store.dispatch( + setWebDAVSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + store.dispatch( + setS3SyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message }) scheduleNextBackup('fromNow') diff --git a/src/renderer/src/services/NutstoreService.ts b/src/renderer/src/services/NutstoreService.ts index c52e6b8030..6eb727abc1 100644 --- a/src/renderer/src/services/NutstoreService.ts +++ b/src/renderer/src/services/NutstoreService.ts @@ -48,7 +48,7 @@ export async function checkConnection() { return false } - const isSuccess = await window.api.backup.checkConnection({ + const isSuccess = await window.api.backup.checkWebdavConnection({ ...config, webdavPath: '/' }) diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index a8b7d342c5..0418e5ab96 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -1,13 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -export interface WebDAVSyncState { +export interface RemoteSyncState { lastSyncTime: number | null syncing: boolean lastSyncError: string | null } export interface BackupState { - webdavSync: WebDAVSyncState + webdavSync: RemoteSyncState + s3Sync: RemoteSyncState } const initialState: BackupState = { @@ -15,6 +16,11 @@ const initialState: BackupState = { lastSyncTime: null, syncing: false, lastSyncError: null + }, + s3Sync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null } } @@ -22,11 +28,14 @@ const backupSlice = createSlice({ name: 'backup', initialState, reducers: { - setWebDAVSyncState: (state, action: PayloadAction>) => { + setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } + }, + setS3SyncState: (state, action: PayloadAction>) => { + state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState } = backupSlice.actions +export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 1e29aebd69..a305d03735 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1726,6 +1726,16 @@ const migrateConfig = { } catch (error) { return state } + }, + '120': (state: RootState) => { + try { + if (!state.settings.s3) { + state.settings.s3 = settingsInitialState.s3 + } + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/nutstore.ts b/src/renderer/src/store/nutstore.ts index 354a93bd39..cd4721b6df 100644 --- a/src/renderer/src/store/nutstore.ts +++ b/src/renderer/src/store/nutstore.ts @@ -1,8 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' -export interface NutstoreSyncState extends WebDAVSyncState {} +export interface NutstoreSyncState extends RemoteSyncState {} export interface NutstoreState { nutstoreToken: string | null @@ -42,7 +42,7 @@ const nutstoreSlice = createSlice({ setNutstoreSyncInterval: (state, action: PayloadAction) => { state.nutstoreSyncInterval = action.payload }, - setNutstoreSyncState: (state, action: PayloadAction>) => { + setNutstoreSyncState: (state, action: PayloadAction>) => { state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload } }, setNutstoreSkipBackupFile: (state, action: PayloadAction) => { diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 778837e388..771e7cf349 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -8,13 +8,14 @@ import { OpenAIServiceTier, OpenAISummaryText, PaintingProvider, + S3Config, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter' @@ -30,7 +31,7 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'files' ] -export interface NutstoreSyncRuntime extends WebDAVSyncState {} +export interface NutstoreSyncRuntime extends RemoteSyncState {} export type AssistantIconType = 'model' | 'emoji' | 'none' @@ -189,6 +190,7 @@ export interface SettingsState { knowledge: boolean } defaultPaintingProvider: PaintingProvider + s3: S3Config } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -336,7 +338,19 @@ export const initialState: SettingsState = { backup: false, knowledge: false }, - defaultPaintingProvider: 'aihubmix' + defaultPaintingProvider: 'aihubmix', + s3: { + endpoint: '', + region: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + root: '', + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + } } const settingsSlice = createSlice({ @@ -703,6 +717,12 @@ const settingsSlice = createSlice({ }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload + }, + setS3: (state, action: PayloadAction) => { + state.s3 = action.payload + }, + setS3Partial: (state, action: PayloadAction>) => { + state.s3 = { ...state.s3, ...action.payload } } } }) @@ -812,7 +832,9 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, - setDefaultPaintingProvider + setDefaultPaintingProvider, + setS3, + setS3Partial } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 084cc130e2..21eb4bbc99 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -749,4 +749,19 @@ export interface StoreSyncAction { export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off' export type OpenAIServiceTier = 'auto' | 'default' | 'flex' + +export type S3Config = { + endpoint: string + region: string + bucket: string + accessKeyId: string + secretAccessKey: string + root?: string + fileName?: string + skipBackupFile: boolean + autoSync: boolean + syncInterval: number + maxBackups: number +} + export type { Message } from './newMessage' diff --git a/yarn.lock b/yarn.lock index 5496f84dee..7afa0defc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -228,6 +228,650 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/crc32@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/eab9581d3363af5ea498ae0e72de792f54d8890360e14a9d8261b7b5c55ebe080279fb2556e07994d785341cdaa99ab0b1ccf137832b53b5904cd6928f2b094b + languageName: node + linkType: hard + +"@aws-crypto/crc32c@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/crc32c@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223efac396cdebaf5645568fa9a38cd0c322c960ae1f4276bedfe2e1031d0112e49d7d39225d386354680ecefae29f39af469a84b2ddfa77cb6692036188af77 + languageName: node + linkType: hard + +"@aws-crypto/sha1-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha1-browser@npm:5.2.0" + dependencies: + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/51fed0bf078c10322d910af179871b7d299dde5b5897873ffbeeb036f427e5d11d23db9794439226544b73901920fd19f4d86bbc103ed73cc0cfdea47a83c6ac + languageName: node + linkType: hard + +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/05f6d256794df800fe9aef5f52f2ac7415f7f3117d461f85a6aecaa4e29e91527b6fd503681a17136fa89e9dd3d916e9c7e4cfb5eba222875cb6c077bdc1d00d + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6c48701f8336341bb104dfde3d0050c89c288051f6b5e9bdfeb8091cf3ffc86efcd5c9e6ff2a4a134406b019c07aca9db608128f8d9267c952578a3108db9fd1 + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/4d2118e29d68ca3f5947f1e37ce1fbb3239a0c569cc938cdc8ab8390d595609b5caf51a07c9e0535105b17bf5c52ea256fed705a07e9681118120ab64ee73af2 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:5.2.0, @aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/0362d4c197b1fd64b423966945130207d1fe23e1bb2878a18e361f7743c8d339dad3f8729895a29aa34fff6a86c65f281cf5167c4bf253f21627ae80b6dd2951 + languageName: node + linkType: hard + +"@aws-sdk/client-s3@npm:^3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/client-s3@npm:3.840.0" + dependencies: + "@aws-crypto/sha1-browser": "npm:5.2.0" + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/credential-provider-node": "npm:3.840.0" + "@aws-sdk/middleware-bucket-endpoint": "npm:3.840.0" + "@aws-sdk/middleware-expect-continue": "npm:3.840.0" + "@aws-sdk/middleware-flexible-checksums": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-location-constraint": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-sdk-s3": "npm:3.840.0" + "@aws-sdk/middleware-ssec": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/signature-v4-multi-region": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@aws-sdk/xml-builder": "npm:3.821.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/eventstream-serde-browser": "npm:^4.0.4" + "@smithy/eventstream-serde-config-resolver": "npm:^4.1.2" + "@smithy/eventstream-serde-node": "npm:^4.0.4" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-blob-browser": "npm:^4.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/hash-stream-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/md5-js": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + "@smithy/util-waiter": "npm:^4.0.6" + "@types/uuid": "npm:^9.0.1" + tslib: "npm:^2.6.2" + uuid: "npm:^9.0.1" + checksum: 10c0/c923c8a0b6743f81478758641190b7c1da8306e7f6bf81d7f9df722be183f7ad506ad47e1b9de0807961fffec6b36074385d4c611c0c2fb08c8e5b1d47948a48 + languageName: node + linkType: hard + +"@aws-sdk/client-sso@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/client-sso@npm:3.840.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/6d83d3dfefaab731818eade68f08f563906e9bee37c0836da262f47b15be8b1885e813a67927dd2549b1a043dffb551a2ec39a963ef335b9df54e8b9faf534e5 + languageName: node + linkType: hard + +"@aws-sdk/core@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/core@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/xml-builder": "npm:3.821.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-utf8": "npm:^4.0.0" + fast-xml-parser: "npm:4.4.1" + tslib: "npm:^2.6.2" + checksum: 10c0/6bd10d86a85c2f52d1a6ca3fe4e45fb8b8ba43abb0f52d2cd14b8d3fb9908f2e1ec0cd9dcf7980df847cfb3dbcd329679a6fe7d029fbc57840d716d1120bc445 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-env@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/ed12ee47f67980b2a434a168de12401312d995428f33e487ea64420a670fdfec59324318eb02e630ef779336723499ca13533cec2b64f1f9d9f48fe9c7e138ef + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-http@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-stream": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10c0/21892b9252b4f7692f9a3e9999a5991e476a8ef7541674c230e94d6a5a1fa7381e643e69d1f7e77dd3bbcee952fa9f4bf45793abf8e5a9c60c0ecb407f10ad4f + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-ini@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/credential-provider-env": "npm:3.840.0" + "@aws-sdk/credential-provider-http": "npm:3.840.0" + "@aws-sdk/credential-provider-process": "npm:3.840.0" + "@aws-sdk/credential-provider-sso": "npm:3.840.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/963c9a675b327f70c7123c392ce0e96ee9e451e118b3af7ba1ea65921965718f96896c29992448c4d5f7739c499e66007aed03be28e094fab0728b8b2bb19731 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-node@npm:3.840.0" + dependencies: + "@aws-sdk/credential-provider-env": "npm:3.840.0" + "@aws-sdk/credential-provider-http": "npm:3.840.0" + "@aws-sdk/credential-provider-ini": "npm:3.840.0" + "@aws-sdk/credential-provider-process": "npm:3.840.0" + "@aws-sdk/credential-provider-sso": "npm:3.840.0" + "@aws-sdk/credential-provider-web-identity": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/cef45e1d12aee1e05aae0498a03eafe6b0f18aa612cb7b49965dcb535bb7bc91339f33de299afb235d20e557a9a2ce16ab1ff2ddf9babec3860cc217437106b7 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-process@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/c4278d64dd3a4c3072b30483fb723c6fabf811989f4f434f6573c729fed94e6851ff339275fe207e6aeab83a672d57dca70b1385c8c2dca731cae87fcec59319 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-sso@npm:3.840.0" + dependencies: + "@aws-sdk/client-sso": "npm:3.840.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/token-providers": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4b0398be1d148bcab6e228016fead4c14d0fa6c6d0a7bc59b1b3e937534070f9a99c2147a897a24e83de4601e406d47d8a1a5b19fa59a5d35beb2474b1b41087 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a68d4b09d9c1869383372c105ed78c5b2c5442e783f8a2fa5f8ca3e9f84e4041d7eaf854a74f867b9f4bfa9f7288093b71e2789494e77ae04e8f77ef280ffdab + languageName: node + linkType: hard + +"@aws-sdk/middleware-bucket-endpoint@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-arn-parser": "npm:3.804.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/371f6e30b16821e1a9c17efcbe6436616eb2bcbfe1757d5f70c56d5eca8452d8dddd42f26f53635b87f927b4da541dc36156e4d3529bb0eb0705969365dce8fc + languageName: node + linkType: hard + +"@aws-sdk/middleware-expect-continue@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-expect-continue@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/73099d06d044f5d82cf172398939c8776c966bf88466288270d80a4e93f451c9e620c92252b0b5c8086b22429f6a69137a21d81bbac66e573c36241859f0739b + languageName: node + linkType: hard + +"@aws-sdk/middleware-flexible-checksums@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-flexible-checksums@npm:3.840.0" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@aws-crypto/crc32c": "npm:5.2.0" + "@aws-crypto/util": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/is-array-buffer": "npm:^4.0.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/55f31563a9811cc0b49c00d3c24e719416f51be31ac3d2af87425850d1c4ea2abb9a2dfc2f853ca6c3e10b837640e189c5cd37369476951dd0eab286e5abacbf + languageName: node + linkType: hard + +"@aws-sdk/middleware-host-header@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-host-header@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/aae5964c39118815293f3f1d42c6b5131ff44862d33af9c8d44eb98fb5b8db0e6191cceba59c487a2b89b70b2e7ad710b174a14506bc6d99d333af42fd6b3d07 + languageName: node + linkType: hard + +"@aws-sdk/middleware-location-constraint@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-location-constraint@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4520274c5b350881df39e28b1732b482ee8023801e8cc6fe1da4b11856ea9660af5036dc6144cefce20338ed0cf5622cc03d10dddf67f95354447d3d0448d987 + languageName: node + linkType: hard + +"@aws-sdk/middleware-logger@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-logger@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5cc4eec656ec9811b64e504a96812f05f1b57e3542ea1dae6710505f81f8dfb36119709538b736a55792f02565818ab71f803e91b00bc4f0652ab198fce153fd + languageName: node + linkType: hard + +"@aws-sdk/middleware-recursion-detection@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-recursion-detection@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/88b1dfbf487d86b2aa26761b08e3de2fd1edd8d09abffd88f5d31b77215fd0852c74deba38802a15cc7015a716d990c2925523af88577890311958f53ef739e7 + languageName: node + linkType: hard + +"@aws-sdk/middleware-sdk-s3@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-sdk-s3@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-arn-parser": "npm:3.804.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/8ef8413028e710a5cee96af80b545d578c3c385dbcb87d2e2b61772b81813f700d7ca503305305af9819462c354e131e8aef692f58eeb08164279701ca1e67ef + languageName: node + linkType: hard + +"@aws-sdk/middleware-ssec@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-ssec@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/22cdded72582d15adb266e5f65b5756c129b7104535765ff5c67eedc24609bface9eebb1fa3b74ed41e7b8fade57940195810bbbe2e44b8283104849894ec658 + languageName: node + linkType: hard + +"@aws-sdk/middleware-user-agent@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/middleware-user-agent@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@smithy/core": "npm:^3.6.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/68822bc24d1311ba47a1e3b2ff194376f3923b39379aa29e6be658ee7e1b809bfea5ea07335c696ca581b42665f30899e25bbe8d9b3216003f602622b4326140 + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/nested-clients@npm:3.840.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/middleware-host-header": "npm:3.840.0" + "@aws-sdk/middleware-logger": "npm:3.840.0" + "@aws-sdk/middleware-recursion-detection": "npm:3.840.0" + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/region-config-resolver": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@aws-sdk/util-endpoints": "npm:3.840.0" + "@aws-sdk/util-user-agent-browser": "npm:3.840.0" + "@aws-sdk/util-user-agent-node": "npm:3.840.0" + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/core": "npm:^3.6.0" + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/hash-node": "npm:^4.0.4" + "@smithy/invalid-dependency": "npm:^4.0.4" + "@smithy/middleware-content-length": "npm:^4.0.4" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-retry": "npm:^4.1.14" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-body-length-node": "npm:^4.0.0" + "@smithy/util-defaults-mode-browser": "npm:^4.0.21" + "@smithy/util-defaults-mode-node": "npm:^4.0.21" + "@smithy/util-endpoints": "npm:^3.0.6" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/1b9ee866f37f433723e472ed194629155de2b1fb7d464bf772727c5140bcb6ad5fbc5d4ae911a19b319f55614239bb1935304fa3ec5a881038a577c32a96b238 + languageName: node + linkType: hard + +"@aws-sdk/region-config-resolver@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/region-config-resolver@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/27d72bb9657efd79637a4c4aa895004d29c66eefce083fa84050f092f68bcba8cb9bf0e4c16c11c132a5fa01f1841e878fa903bc837c4e1e6904d1b2d2c3dd37 + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.840.0" + dependencies: + "@aws-sdk/middleware-sdk-s3": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/signature-v4": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/224e17e624925ba5972f698d92e92289912f9e1ca1fd0525bbc62e6965a9e0585abb309fdb6b7e304fddeb4301e5c832d4370b324c55cbfd42922e73c1abc70c + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/token-providers@npm:3.840.0" + dependencies: + "@aws-sdk/core": "npm:3.840.0" + "@aws-sdk/nested-clients": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a172666169fd8164ce48a3a0ea242405d8437119c8fbcf259223badf8ad04cf68a1ebba54c09c22cbee5c16775e885733788978aa99c9a27241036e967ea2fa5 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:3.840.0, @aws-sdk/types@npm:^3.222.0": + version: 3.840.0 + resolution: "@aws-sdk/types@npm:3.840.0" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/292d38f5087c3aa925addd890f8ae2bf650282c2cf4997d971a341dc0249dfca7ce02d69a4af09da2562b78a4232232d2a3b88105f34f66aee608d52aac238d1 + languageName: node + linkType: hard + +"@aws-sdk/util-arn-parser@npm:3.804.0": + version: 3.804.0 + resolution: "@aws-sdk/util-arn-parser@npm:3.804.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/b6d4c883ec2949fa40552fe8573c9c32af07c92c1bd94a27d978aa14d37b005be95392069d6b882ba977484f4dd0371792296fb2516f5d7601be5102888ee9ee + languageName: node + linkType: hard + +"@aws-sdk/util-endpoints@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-endpoints@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-endpoints": "npm:^3.0.6" + tslib: "npm:^2.6.2" + checksum: 10c0/822fe59c003b433c955756daf47736a17c42c25f449b9ca96c2c2bb79964866ee0a0a657824da6289588d689e76712a7058d70e42c3fad2b78bfb23f905643d9 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.804.0 + resolution: "@aws-sdk/util-locate-window@npm:3.804.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/a0ceaf6531f188751fea7e829b730650689fa2196e0b3f870dde3888bcb840fe0852e10488699d4d9683db0765cd7f7060ca8ac216348991996b6d794f9957ab + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-browser@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-user-agent-browser@npm:3.840.0" + dependencies: + "@aws-sdk/types": "npm:3.840.0" + "@smithy/types": "npm:^4.3.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/873d5e3218958aa935127b05dad5a1d8cf26c9b7726584eb424a5958e7e205786dd99e4fa053b65f3b956261a7f8a3746e48e9b7dc47c3149792ff525da97631 + languageName: node + linkType: hard + +"@aws-sdk/util-user-agent-node@npm:3.840.0": + version: 3.840.0 + resolution: "@aws-sdk/util-user-agent-node@npm:3.840.0" + dependencies: + "@aws-sdk/middleware-user-agent": "npm:3.840.0" + "@aws-sdk/types": "npm:3.840.0" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + peerDependencies: + aws-crt: ">=1.0.0" + peerDependenciesMeta: + aws-crt: + optional: true + checksum: 10c0/862fc435d8a25f3e299e5c92c5ba51ef287a75f18cb0a529797a42a72de1481e3c92458a5569eeeab09fddfb5a75db1c59aa766d95b0e832c32c6c1bd7745644 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:3.821.0": + version: 3.821.0 + resolution: "@aws-sdk/xml-builder@npm:3.821.0" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/316e0eb04bcec0bb0897f67718629deab29adb9664ce78743ad854df772472c02332ab12627d74b96ebe2205adc51b1cb7fb01fcb4251e80a7af405e56cfa135 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.10.4": version: 7.27.1 resolution: "@babel/code-frame@npm:7.27.1" @@ -3993,6 +4637,604 @@ __metadata: languageName: node linkType: hard +"@smithy/abort-controller@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/abort-controller@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/eb172b002fb92406c69b83460f949ace73247e6abd85d0d3714de2765c5db7b98070b9abfb630e2c591dd7b2ff770cc24f7737c1c207581f716c402b16bf46f9 + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader-native@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/chunked-blob-reader-native@npm:4.0.0" + dependencies: + "@smithy/util-base64": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4387f4e8841f20c1c4e689078141de7e6f239e7883be3a02810a023aa30939b15576ee00227b991972d2c5a2f3b6152bcaeca0975c9fa8d3669354c647bd532a + languageName: node + linkType: hard + +"@smithy/chunked-blob-reader@npm:^5.0.0": + version: 5.0.0 + resolution: "@smithy/chunked-blob-reader@npm:5.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/55ba0fe366ddaa3f93e1faf8a70df0b67efedbd0008922295efe215df09b68df0ba3043293e65b17e7d1be71448d074c2bfc54e5eb6bd18f59b425822c2b9e9a + languageName: node + linkType: hard + +"@smithy/config-resolver@npm:^4.1.4": + version: 4.1.4 + resolution: "@smithy/config-resolver@npm:4.1.4" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-config-provider": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/41832a42f8da7143732c71098b410f4ddcb096066126f7e8f45bae8d9aeb95681bd0d0d54886f46244c945c63829ca5d23373d4de31a038487aa07159722ef4e + languageName: node + linkType: hard + +"@smithy/core@npm:^3.6.0": + version: 3.6.0 + resolution: "@smithy/core@npm:3.6.0" + dependencies: + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-stream": "npm:^4.2.2" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/015874e1c44815b6e50594f2983a1a88e3c4f777760d062b2e31b402e8d145ce5c64b33065eaa97fd37867ef6c95493ddc62f3775cd7102e6fd41c9808be219a + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/credential-provider-imds@npm:4.0.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/b1f3157d0a7b9f9155ac80aeac70d7db896d23d0322a6b38f0e848f1e53864ba1bca6d3dc5dd9af86446c371ebc5bffe01f0712ad562e7635e7d13e532622aa4 + languageName: node + linkType: hard + +"@smithy/eventstream-codec@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-codec@npm:4.0.4" + dependencies: + "@aws-crypto/crc32": "npm:5.2.0" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-hex-encoding": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/89b76826d4d3bf97317e3539ece105b9a03552144ad816a687b0b2cbca60e2b3513062c04b6cfacaffb270d616ffc8ac8bf549afc4aa676a6d7465df5a3215ba + languageName: node + linkType: hard + +"@smithy/eventstream-serde-browser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-browser@npm:4.0.4" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b2444538555c54ac96d4049b0be3a65d959914bcd5198a8059edc838c7ffac5a1db225290194f85ea8805c47c1edc95484dfeb415cb2004ec3e572880f4fc8c5 + languageName: node + linkType: hard + +"@smithy/eventstream-serde-config-resolver@npm:^4.1.2": + version: 4.1.2 + resolution: "@smithy/eventstream-serde-config-resolver@npm:4.1.2" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/54184a29d1e42f1b972292efc3a5cbbe3ca237cd9ab76132bad40e8426fa62d0b7f6fdac01f23e3a9cac69919107ddfd9d2f2873f83ae1f65470d3052c67cefc + languageName: node + linkType: hard + +"@smithy/eventstream-serde-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-node@npm:4.0.4" + dependencies: + "@smithy/eventstream-serde-universal": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/e6d0765a73332c79b69531ed20c27e49475173da09ce21e4c011a64d8a61d7c5c328c9bc1cab991e145fc969b16071ffd6a33ab11291c0fa2a46e8dae28da23b + languageName: node + linkType: hard + +"@smithy/eventstream-serde-universal@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/eventstream-serde-universal@npm:4.0.4" + dependencies: + "@smithy/eventstream-codec": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/f0c18efa6cafa111ed20c8c53b4a7b6a0f8e25ccb0d2cafdf83282ebc6f96e47f26daf24b5b810ea83a02e03c994c35419d94fad76871f2cc6cb01d2aed277d9 + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.0.4": + version: 5.0.4 + resolution: "@smithy/fetch-http-handler@npm:5.0.4" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/querystring-builder": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ce57acfcd40a6ff3965c5f14b432c5ab87f0b0766766960224d4af79af85e37d61da2db6dc5cfa16bf4b8f2d8966a2838d2ee6eef8d5cd5a837aacbc01517851 + languageName: node + linkType: hard + +"@smithy/hash-blob-browser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-blob-browser@npm:4.0.4" + dependencies: + "@smithy/chunked-blob-reader": "npm:^5.0.0" + "@smithy/chunked-blob-reader-native": "npm:^4.0.0" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/f970058c2e04e86427e1474355199027fc84dc1d96d9a2278ed37904458d37b020472541390558bd3fb071bbd177b2850b18ceb1beb39d387fead06a2912f974 + languageName: node + linkType: hard + +"@smithy/hash-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-node@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/07beb38643990f6c055457765d65af2aedd5944d819025df90d1f2f59596d1a1394cd8c9035ac6d343bc55e3afeb186b51b0ac91938024da8687120fc0b436dc + languageName: node + linkType: hard + +"@smithy/hash-stream-node@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/hash-stream-node@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/4899132433f520e45972bbacb6a999da8d7ccf4c813f2fb28b1af65eaf268ba549b2c37dd54a586cd7bcd82f6e4cec914651a6446b3fb3e1f226ca1864051535 + languageName: node + linkType: hard + +"@smithy/invalid-dependency@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/invalid-dependency@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5e5a6282c17a7310f8e866c7e34fa07479d42c650cf3c1875bdb0ec38d5280eeac82a269605a3521b8fa455b92673d8fd5e97eb997acf81a80da82d6f501d651 + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/2f2523cd8cc4538131e408eb31664983fecb0c8724956788b015aaf3ab85a0c976b50f4f09b176f1ed7bbe79f3edf80743be7a80a11f22cd9ce1285d77161aaf + 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/md5-js@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/md5-js@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/7c66405dca5d7df6694367dbb4a3d9f13fdfe2589abc81f85d5fb7bf876e1382578d9c477d2256d4b5bc59951c3c534e51eb65c53c2fb3251080f16d1d7ea82c + languageName: node + linkType: hard + +"@smithy/middleware-content-length@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/middleware-content-length@npm:4.0.4" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/fde43ff13f0830c4608b83cf6e2bd3ae142aa6eb3df6f6c190c2564dd00c2c98f4f95da9146c69bc09115ad87ffc9dc24935d1a3d6d3b2383a9c8558d9177dd6 + languageName: node + linkType: hard + +"@smithy/middleware-endpoint@npm:^4.1.13": + version: 4.1.13 + resolution: "@smithy/middleware-endpoint@npm:4.1.13" + dependencies: + "@smithy/core": "npm:^3.6.0" + "@smithy/middleware-serde": "npm:^4.0.8" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + "@smithy/url-parser": "npm:^4.0.4" + "@smithy/util-middleware": "npm:^4.0.4" + tslib: "npm:^2.6.2" + checksum: 10c0/a4f605ba95d59e5afbad326ed0a1417fb33cb1c6085a9c13f765520d3732e223ab501033457eab72ed223d41ce0a079d6895ebb3954935b2a6d25b223c4ef72c + languageName: node + linkType: hard + +"@smithy/middleware-retry@npm:^4.1.14": + version: 4.1.14 + resolution: "@smithy/middleware-retry@npm:4.1.14" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/service-error-classification": "npm:^4.0.6" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-retry": "npm:^4.0.6" + tslib: "npm:^2.6.2" + uuid: "npm:^9.0.1" + checksum: 10c0/a720f366f3c8b5ea9d35bf38718d3885492fe896288623f9e5b3c293bfea14bc530b9107da100abdac3ff45bebbe1335f6da928c005fc78dbdefab2d65f1269d + languageName: node + linkType: hard + +"@smithy/middleware-serde@npm:^4.0.8": + version: 4.0.8 + resolution: "@smithy/middleware-serde@npm:4.0.8" + dependencies: + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/11414e584780716b2b0487fe748da9927943d4d810b5b0161e73df6ab24a4d17f675773287f95868c57a71013385f7b027eb2afbab1eed3dbaafef754b482b27 + languageName: node + linkType: hard + +"@smithy/middleware-stack@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/middleware-stack@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b29b6430e31f11683f0ce0e06d21a4bfe6cb791ce1eb5686533559baa81698f617bfbfdac06f569e13f077ce177cb70e55f4db20701906b3e344d9294817f382 + languageName: node + linkType: hard + +"@smithy/node-config-provider@npm:^4.1.3": + version: 4.1.3 + resolution: "@smithy/node-config-provider@npm:4.1.3" + dependencies: + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/shared-ini-file-loader": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/bea20b3f92290fbefa32d30c4ac7632f94d4e89b5432dfe5a2d0c6261bfd90e882d62dd02e0a4e65f3bc89f815b19e44d7bb103a78b6c77941cc186450ad79f1 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/node-http-handler@npm:4.0.6" + dependencies: + "@smithy/abort-controller": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/querystring-builder": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/bde23701b6166b76958cbc194d551a139e3dcc1d05a6c7de3d5b14f54934ca5a49a28d13d8ec4b012716aae816cd0c8c4735c959d5ef697a7a1932fbcfc5d7f2 + languageName: node + linkType: hard + +"@smithy/property-provider@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/property-provider@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/c370efbb43ab01fb6050fbf4c231bbe2fb7d660256adeee40c0c4c14b7af1b9b75c36f6924aeacdd2885fad1aaf0655047cafe5f0d22f5e371cbd25ff2f04b27 + languageName: node + linkType: hard + +"@smithy/protocol-http@npm:^5.1.2": + version: 5.1.2 + resolution: "@smithy/protocol-http@npm:5.1.2" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/50fb026efa321e65a77f9747312eeb428ff2196095c15ed5937efe807a4734c47746759ccf2dbc84a45719effcbc81221662289be6d4d5ec122afb0e3cd66fd9 + languageName: node + linkType: hard + +"@smithy/querystring-builder@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/querystring-builder@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + "@smithy/util-uri-escape": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/30ec0301fbc2212101391841000a3117ab6c3ae2b6b2a1db230cc1dfcf97738f527b23f859f0a5e843f2a983793b58cdcd21a0ce11ef93fcdf5d8a1ee0d70fbc + languageName: node + linkType: hard + +"@smithy/querystring-parser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/querystring-parser@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/36bc93732a1628be5dd53748f6f36237bad26de2da810195213541dd35b20eee0b0264160a0de734b9333ca747e0229253d6729d1a8ddc26d176c0b1cce309e0 + languageName: node + linkType: hard + +"@smithy/service-error-classification@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/service-error-classification@npm:4.0.6" + dependencies: + "@smithy/types": "npm:^4.3.1" + checksum: 10c0/b67f5ef633fa803f6b9f81f53dcc361253f33e01ffefbcb1beaf29c578834b1381e5f979e25b38985d351142e1ab4ee638cf132a2ba9f6f7a0a806a35da76d86 + languageName: node + linkType: hard + +"@smithy/shared-ini-file-loader@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/shared-ini-file-loader@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/a3ecabadda13ff6fca99585e7e0086a04c4d2350b8c783b3a23493c2ae0a599f397d3cb80a7e171b7123889340995cada866d320726fa6a03f3063d60d5d0207 + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.1.2": + version: 5.1.2 + resolution: "@smithy/signature-v4@npm:5.1.2" + dependencies: + "@smithy/is-array-buffer": "npm:^4.0.0" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-hex-encoding": "npm:^4.0.0" + "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-uri-escape": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/83d3870668a6c080c1d0cbecf2e7d1a86c0298cc3a3df9fba21bd942e2a9bcae81eb50960c66bba00c6f9820ef9e5ab3e5ddba67b2d7914a09a82c7887621c0c + languageName: node + linkType: hard + +"@smithy/smithy-client@npm:^4.4.5": + version: 4.4.5 + resolution: "@smithy/smithy-client@npm:4.4.5" + dependencies: + "@smithy/core": "npm:^3.6.0" + "@smithy/middleware-endpoint": "npm:^4.1.13" + "@smithy/middleware-stack": "npm:^4.0.4" + "@smithy/protocol-http": "npm:^5.1.2" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-stream": "npm:^4.2.2" + tslib: "npm:^2.6.2" + checksum: 10c0/180115cf186a0984db9110b3763db2f84451b65c353ae8e908cc6b6941ad4ad13de690192e7ee50281c83694ab09a7f282bcf4c81a2d839497f515c951d86b38 + languageName: node + linkType: hard + +"@smithy/types@npm:^4.3.1": + version: 4.3.1 + resolution: "@smithy/types@npm:4.3.1" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/8b350562b9ed4ff97465025b4ae77a34bb07b9d47fb6f9781755aac9401b0355a63c2fef307393e2dae3fa0277149dd7d83f5bc2a63d4ad3519ea32fd56b5cda + languageName: node + linkType: hard + +"@smithy/url-parser@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/url-parser@npm:4.0.4" + dependencies: + "@smithy/querystring-parser": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/5f4649d9ff618c683e339fa826b1d722419bf8e20d72726fc5fe3cd479ec8c161d4b09b6e24e49b0143a6fb4f9a950d35410db1834e143c28e377b9c529a3657 + languageName: node + linkType: hard + +"@smithy/util-base64@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-base64@npm:4.0.0" + dependencies: + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/ad18ec66cc357c189eef358d96876b114faf7086b13e47e009b265d0ff80cec046052500489c183957b3a036768409acdd1a373e01074cc002ca6983f780cffc + languageName: node + linkType: hard + +"@smithy/util-body-length-browser@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-body-length-browser@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/574a10934024a86556e9dcde1a9776170284326c3dfcc034afa128cc5a33c1c8179fca9cfb622ef8be5f2004316cc3f427badccceb943e829105536ec26306d9 + languageName: node + linkType: hard + +"@smithy/util-body-length-node@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-body-length-node@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/e91fd3816767606c5f786166ada26440457fceb60f96653b3d624dcf762a8c650e513c275ff3f647cb081c63c283cc178853a7ed9aa224abc8ece4eeeef7a1dd + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/223d6a508b52ff236eea01cddc062b7652d859dd01d457a4e50365af3de1e24a05f756e19433f6ccf1538544076b4215469e21a4ea83dc1d58d829725b0dbc5a + 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-config-provider@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-config-provider@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/cd9498d5f77a73aadd575084bcb22d2bb5945bac4605d605d36f2efe3f165f2b60f4dc88b7a62c2ed082ffa4b2c2f19621d0859f18399edbc2b5988d92e4649f + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-browser@npm:^4.0.21": + version: 4.0.21 + resolution: "@smithy/util-defaults-mode-browser@npm:4.0.21" + dependencies: + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10c0/401d5f83aa0c054755e18742a6f35de50268174d93ad05bd95123fe176870153da3bfc2344ebad23a2a159bd0668f2c0c758a94e3d5696dd59990d5e881c4c1b + languageName: node + linkType: hard + +"@smithy/util-defaults-mode-node@npm:^4.0.21": + version: 4.0.21 + resolution: "@smithy/util-defaults-mode-node@npm:4.0.21" + dependencies: + "@smithy/config-resolver": "npm:^4.1.4" + "@smithy/credential-provider-imds": "npm:^4.0.6" + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/property-provider": "npm:^4.0.4" + "@smithy/smithy-client": "npm:^4.4.5" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/936399758fdecf68b14f7adfcb6a9dbc50b62edeabc6c146affe5f7dc40ccfc42df0c6af882748a8ccc32a54834bcf1d22fd42ec8589242dcabe5b983b67e40c + languageName: node + linkType: hard + +"@smithy/util-endpoints@npm:^3.0.6": + version: 3.0.6 + resolution: "@smithy/util-endpoints@npm:3.0.6" + dependencies: + "@smithy/node-config-provider": "npm:^4.1.3" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/d7d583c73a0c1ce38188569616cd4d7c95c36c0393516117043962b932f8c743e8cd672d2edd23ea8a9da0e30b84ee0f0ced0709cc8024b70ea8e5f17f505811 + 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-middleware@npm:^4.0.4": + version: 4.0.4 + resolution: "@smithy/util-middleware@npm:4.0.4" + dependencies: + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/39530add63ec13dac555846c30e98128316136f7f57bfd8fe876a8c15a7677cb64d0a33fd1f08b671096d769ab3f025d4d8c785a9d7a7cdf42fd0188236b0f32 + languageName: node + linkType: hard + +"@smithy/util-retry@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/util-retry@npm:4.0.6" + dependencies: + "@smithy/service-error-classification": "npm:^4.0.6" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/b1d3a5875769300bb74d63243868eba8a8f3567a9b22776cfb11700cfdd10bf10b2ed96bffdc9527d9130daf1be2482ea9217e1865a94efed01fe66e688768f4 + languageName: node + linkType: hard + +"@smithy/util-stream@npm:^4.2.2": + version: 4.2.2 + resolution: "@smithy/util-stream@npm:4.2.2" + dependencies: + "@smithy/fetch-http-handler": "npm:^5.0.4" + "@smithy/node-http-handler": "npm:^4.0.6" + "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-buffer-from": "npm:^4.0.0" + "@smithy/util-hex-encoding": "npm:^4.0.0" + "@smithy/util-utf8": "npm:^4.0.0" + tslib: "npm:^2.6.2" + checksum: 10c0/5e4ef783e41185d291a72e8503d02fd5a5f7bd23f3d30198f3d738c0f27dd6d7ea131fe6fbe36a6ac69b8bd4207f7dfc75a15329764e6aa52f62c45bc5442619 + languageName: node + linkType: hard + +"@smithy/util-uri-escape@npm:^4.0.0": + version: 4.0.0 + resolution: "@smithy/util-uri-escape@npm:4.0.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10c0/23984624060756adba8aa4ab1693fe6b387ee5064d8ec4dfd39bb5908c4ee8b9c3f2dc755da9b07505d8e3ce1338c1867abfa74158931e4728bf3cfcf2c05c3d + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10c0/e18840c58cc507ca57fdd624302aefd13337ee982754c9aa688463ffcae598c08461e8620e9852a424d662ffa948fc64919e852508028d09e89ced459bd506ab + languageName: node + linkType: hard + +"@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-waiter@npm:^4.0.6": + version: 4.0.6 + resolution: "@smithy/util-waiter@npm:4.0.6" + dependencies: + "@smithy/abort-controller": "npm:^4.0.4" + "@smithy/types": "npm:^4.3.1" + tslib: "npm:^2.6.2" + checksum: 10c0/4027aed03515dfb627c09e0d490f001b912def1142865d0ec8de1fc0422e7c71e96df3efc7b92c7fdfff9030105b2b4213120506d064ad346cc79124708c1b17 + languageName: node + linkType: hard + "@strongtz/win32-arm64-msvc@npm:^0.4.7": version: 0.4.7 resolution: "@strongtz/win32-arm64-msvc@npm:0.4.7" @@ -4930,6 +6172,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.1": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: 10c0/b411b93054cb1d4361919579ef3508a1f12bf15b5fdd97337d3d351bece6c921b52b6daeef89b62340fd73fd60da407878432a1af777f40648cbe53a01723489 + languageName: node + linkType: hard + "@types/verror@npm:^1.10.3": version: 1.10.11 resolution: "@types/verror@npm:1.10.11" @@ -5811,6 +7060,7 @@ __metadata: "@agentic/tavily": "npm:^7.3.3" "@ant-design/v5-patch-for-react-19": "npm:^1.0.3" "@anthropic-ai/sdk": "npm:^0.41.0" + "@aws-sdk/client-s3": "npm:^3.840.0" "@cherrystudio/embedjs": "npm:^0.1.31" "@cherrystudio/embedjs-libsql": "npm:^0.1.31" "@cherrystudio/embedjs-loader-csv": "npm:^0.1.31" @@ -6691,6 +7941,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.11.0 + resolution: "bowser@npm:2.11.0" + checksum: 10c0/04efeecc7927a9ec33c667fa0965dea19f4ac60b3fea60793c2e6cf06c1dcd2f7ae1dbc656f450c5f50783b1c75cf9dc173ba6f3b7db2feee01f8c4b793e1bd3 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -9955,6 +11212,17 @@ __metadata: languageName: node linkType: hard +"fast-xml-parser@npm:4.4.1": + version: 4.4.1 + resolution: "fast-xml-parser@npm:4.4.1" + dependencies: + strnum: "npm:^1.0.5" + bin: + fxparser: src/cli/cli.js + checksum: 10c0/7f334841fe41bfb0bf5d920904ccad09cefc4b5e61eaf4c225bf1e1bb69ee77ef2147d8942f783ee8249e154d1ca8a858e10bda78a5d78b8bed3f48dcee9bf33 + languageName: node + linkType: hard + "fast-xml-parser@npm:^4.5.0, fast-xml-parser@npm:^4.5.1": version: 4.5.3 resolution: "fast-xml-parser@npm:4.5.3" @@ -17573,7 +18841,7 @@ __metadata: languageName: node linkType: hard -"strnum@npm:^1.1.1": +"strnum@npm:^1.0.5, strnum@npm:^1.1.1": version: 1.1.2 resolution: "strnum@npm:1.1.2" checksum: 10c0/a0fce2498fa3c64ce64a40dada41beb91cabe3caefa910e467dc0518ef2ebd7e4d10f8c2202a6104f1410254cae245066c0e94e2521fb4061a5cb41831952392 @@ -18143,7 +19411,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From a314a43f0f08b7f9dac4dc7314fcd49a66d241d1 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Mon, 7 Jul 2025 22:08:56 +0800 Subject: [PATCH 13/13] refactor(translate): Language Type (#7727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(translate): 重构翻译功能使用语言枚举类型 统一翻译功能中的语言表示方式,使用枚举类型替代字符串 更新相关组件和服务以适配新的语言类型定义 添加数据库迁移脚本处理语言类型变更 添加store迁移处理语言类型变更 * refactor(translate): 移除调试用的console.log语句 * refactor(translate): 移除冗余的类型检查逻辑 * fix(db): 添加对TranslateHistory的db迁移 * fix(databases): 捕获数据库升级时的语言映射错误 添加错误处理以防止语言映射失败时中断升级过程 * fix(翻译组件): 修复语言比较和选择逻辑错误 修复语言比较时直接比较对象而非langCode的问题 更新Select组件使用langCode作为值并正确处理语言切换 * refactor(translate): 将saveTranslateHistory参数类型从Language改为LanguageCode * refactor(hooks): 更新useMessageOperations中的语言代码类型 将targetLanguage和sourceLanguage参数类型从string更新为LanguageCode,提高类型安全性 * docs(translate): 更新JSDoc注释以使用TypeScript类型语法 * feat(备份服务): 升级数据库版本至v8并添加迁移逻辑 添加从v7到v8的数据库迁移支持 更新翻译历史记录中的语言代码映射 优化迁移过程中的日志记录和错误处理 * fix(store): 修复目标语言迁移时的默认值处理 确保在迁移配置时将旧版语言代码正确映射到新版格式,无法映射时使用默认英语 * refactor(translate): 将语言标签从字符串改为函数以支持动态翻译 * refactor(translate): 优化翻译窗口语言选择逻辑 重构翻译窗口的目标语言选择逻辑,使用语言代码获取完整语言信息 移除冗余的Space组件,简化Select选项渲染方式 * docs(技术文档): 新增数据库设置字段文档 添加数据库设置字段的说明文档,包含翻译相关字段的类型和用途 * refactor(translate): 修改db中biDirectionLangPair存储类型 将语言代码处理统一改为存储langCode而非Language对象 修改相关代码以使用getLanguageByLangcode进行转换 更新数据库升级逻辑以兼容新格式 * docs(translate): 为getLanguageByLangcode函数添加注释说明 * fix(数据库升级): 修复升级到V8时可能出现的空值访问问题 * refactor(databases): 优化语言映射错误处理逻辑 将不必要的try-catch块替换为if条件判断 * docs(technical): 修正数据库设置文档中的类型描述 * refactor: 优化语言代码处理和变量命名 * fix(ActionTranslate): 使用langCode存储双向翻译语言对 * fix(migrate): 修复错误的迁移过程 * refactor(translate): 重构语言选项从硬编码改为动态生成 将translateLanguageOptions从硬编码的数组改为通过LanguagesEnum动态生成,提高可维护性 * fix(store): 更新持久化存储版本并修复语言映射迁移问题 将持久化存储版本从119升级到120,并修复语言代码映射迁移问题。迁移过程中将旧的语言标识转换为新的标准语言代码格式。 --- docs/technical/db.settings.md | 11 + .../src/components/Popups/TextEditPopup.tsx | 3 +- .../src/components/TranslateButton.tsx | 5 +- src/renderer/src/config/translate.ts | 285 ++++++++++-------- src/renderer/src/databases/index.ts | 15 +- src/renderer/src/databases/upgrades.ts | 81 ++++- .../src/hooks/useMessageOperations.ts | 6 +- .../src/pages/home/Inputbar/Inputbar.tsx | 3 +- .../pages/home/Messages/MessageMenubar.tsx | 16 +- .../src/pages/home/Tabs/SettingsTab.tsx | 22 +- .../src/pages/paintings/AihubmixPage.tsx | 3 +- .../src/pages/paintings/SiliconPage.tsx | 3 +- .../src/pages/paintings/TokenFluxPage.tsx | 3 +- .../src/pages/translate/TranslatePage.tsx | 130 ++++---- src/renderer/src/services/AssistantService.ts | 6 +- src/renderer/src/services/BackupService.ts | 10 +- src/renderer/src/services/TranslateService.ts | 3 +- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 22 +- src/renderer/src/store/settings.ts | 2 +- src/renderer/src/types/index.ts | 44 ++- src/renderer/src/utils/translate.ts | 150 +++++---- .../mini/translate/TranslateWindow.tsx | 33 +- .../action/components/ActionTranslate.tsx | 56 ++-- 24 files changed, 557 insertions(+), 357 deletions(-) create mode 100644 docs/technical/db.settings.md diff --git a/docs/technical/db.settings.md b/docs/technical/db.settings.md new file mode 100644 index 0000000000..1d63098851 --- /dev/null +++ b/docs/technical/db.settings.md @@ -0,0 +1,11 @@ +# 数据库设置字段 + +此文档包含部分字段的数据类型说明。 + +## 字段 + +| 字段名 | 类型 | 说明 | +| ------------------------------ | ------------------------------ | ------------ | +| `translate:target:language` | `LanguageCode` | 翻译目标语言 | +| `translate:source:language` | `LanguageCode` | 翻译源语言 | +| `translate:bidirectional:pair` | `[LanguageCode, LanguageCode]` | 双向翻译对 | diff --git a/src/renderer/src/components/Popups/TextEditPopup.tsx b/src/renderer/src/components/Popups/TextEditPopup.tsx index 46bca109fc..ab7bf40cb6 100644 --- a/src/renderer/src/components/Popups/TextEditPopup.tsx +++ b/src/renderer/src/components/Popups/TextEditPopup.tsx @@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { Modal, ModalProps } from 'antd' import TextArea from 'antd/es/input/TextArea' import { TextAreaProps } from 'antd/lib/input' @@ -111,7 +112,7 @@ const PopupContainer: React.FC = ({ } try { - const assistant = getDefaultTranslateAssistant(targetLanguage, textValue) + const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), textValue) const translatedText = await fetchTranslate({ content: textValue, assistant }) if (isMounted.current) { setTextValue(translatedText) diff --git a/src/renderer/src/components/TranslateButton.tsx b/src/renderer/src/components/TranslateButton.tsx index 78dd1b7d34..d52448b488 100644 --- a/src/renderer/src/components/TranslateButton.tsx +++ b/src/renderer/src/components/TranslateButton.tsx @@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { Button, Tooltip } from 'antd' import { Languages } from 'lucide-react' import { FC, useEffect, useState } from 'react' @@ -54,7 +55,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa setIsTranslating(true) try { - const assistant = getDefaultTranslateAssistant(targetLanguage, text) + const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), text) const translatedText = await fetchTranslate({ content: text, assistant }) onTranslated(translatedText) } catch (error) { @@ -75,7 +76,7 @@ const TranslateButton: FC = ({ text, onTranslated, disabled, style, isLoa return ( {isTranslating ? : } diff --git a/src/renderer/src/config/translate.ts b/src/renderer/src/config/translate.ts index 9a85b68ecc..cbac95aafa 100644 --- a/src/renderer/src/config/translate.ts +++ b/src/renderer/src/config/translate.ts @@ -1,136 +1,159 @@ import i18n from '@renderer/i18n' +import { Language } from '@renderer/types' -export interface TranslateLanguageOption { - value: string - langCode?: string - label: string - emoji: string +export const ENGLISH: Language = { + value: 'English', + langCode: 'en-us', + label: () => i18n.t('languages.english'), + emoji: '🇬🇧' } -export const TranslateLanguageOptions: TranslateLanguageOption[] = [ - { - value: 'English', - langCode: 'en-us', - label: i18n.t('languages.english'), - emoji: '🇬🇧' - }, - { - value: 'Chinese (Simplified)', - langCode: 'zh-cn', - label: i18n.t('languages.chinese'), - emoji: '🇨🇳' - }, - { - value: 'Chinese (Traditional)', - langCode: 'zh-tw', - label: i18n.t('languages.chinese-traditional'), - emoji: '🇭🇰' - }, - { - value: 'Japanese', - langCode: 'ja-jp', - label: i18n.t('languages.japanese'), - emoji: '🇯🇵' - }, - { - value: 'Korean', - langCode: 'ko-kr', - label: i18n.t('languages.korean'), - emoji: '🇰🇷' - }, - - { - value: 'French', - langCode: 'fr-fr', - label: i18n.t('languages.french'), - emoji: '🇫🇷' - }, - { - value: 'German', - langCode: 'de-de', - label: i18n.t('languages.german'), - emoji: '🇩🇪' - }, - { - value: 'Italian', - langCode: 'it-it', - label: i18n.t('languages.italian'), - emoji: '🇮🇹' - }, - { - value: 'Spanish', - langCode: 'es-es', - label: i18n.t('languages.spanish'), - emoji: '🇪🇸' - }, - { - value: 'Portuguese', - langCode: 'pt-pt', - label: i18n.t('languages.portuguese'), - emoji: '🇵🇹' - }, - { - value: 'Russian', - langCode: 'ru-ru', - label: i18n.t('languages.russian'), - emoji: '🇷🇺' - }, - { - value: 'Polish', - langCode: 'pl-pl', - label: i18n.t('languages.polish'), - emoji: '🇵🇱' - }, - { - value: 'Arabic', - langCode: 'ar-ar', - label: i18n.t('languages.arabic'), - emoji: '🇸🇦' - }, - { - value: 'Turkish', - langCode: 'tr-tr', - label: i18n.t('languages.turkish'), - emoji: '🇹🇷' - }, - { - value: 'Thai', - langCode: 'th-th', - label: i18n.t('languages.thai'), - emoji: '🇹🇭' - }, - { - value: 'Vietnamese', - langCode: 'vi-vn', - label: i18n.t('languages.vietnamese'), - emoji: '🇻🇳' - }, - { - value: 'Indonesian', - langCode: 'id-id', - label: i18n.t('languages.indonesian'), - emoji: '🇮🇩' - }, - { - value: 'Urdu', - langCode: 'ur-pk', - label: i18n.t('languages.urdu'), - emoji: '🇵🇰' - }, - { - value: 'Malay', - langCode: 'ms-my', - label: i18n.t('languages.malay'), - emoji: '🇲🇾' - } -] - -export const translateLanguageOptions = (): typeof TranslateLanguageOptions => { - return TranslateLanguageOptions.map((option) => { - return { - value: option.value, - label: option.label, - emoji: option.emoji - } - }) +export const CHINESE_SIMPLIFIED: Language = { + value: 'Chinese (Simplified)', + langCode: 'zh-cn', + label: () => i18n.t('languages.chinese'), + emoji: '🇨🇳' } + +export const CHINESE_TRADITIONAL: Language = { + value: 'Chinese (Traditional)', + langCode: 'zh-tw', + label: () => i18n.t('languages.chinese-traditional'), + emoji: '🇭🇰' +} + +export const JAPANESE: Language = { + value: 'Japanese', + langCode: 'ja-jp', + label: () => i18n.t('languages.japanese'), + emoji: '🇯🇵' +} + +export const KOREAN: Language = { + value: 'Korean', + langCode: 'ko-kr', + label: () => i18n.t('languages.korean'), + emoji: '🇰🇷' +} + +export const FRENCH: Language = { + value: 'French', + langCode: 'fr-fr', + label: () => i18n.t('languages.french'), + emoji: '🇫🇷' +} + +export const GERMAN: Language = { + value: 'German', + langCode: 'de-de', + label: () => i18n.t('languages.german'), + emoji: '🇩🇪' +} + +export const ITALIAN: Language = { + value: 'Italian', + langCode: 'it-it', + label: () => i18n.t('languages.italian'), + emoji: '🇮🇹' +} + +export const SPANISH: Language = { + value: 'Spanish', + langCode: 'es-es', + label: () => i18n.t('languages.spanish'), + emoji: '🇪🇸' +} + +export const PORTUGUESE: Language = { + value: 'Portuguese', + langCode: 'pt-pt', + label: () => i18n.t('languages.portuguese'), + emoji: '🇵🇹' +} + +export const RUSSIAN: Language = { + value: 'Russian', + langCode: 'ru-ru', + label: () => i18n.t('languages.russian'), + emoji: '🇷🇺' +} + +export const POLISH: Language = { + value: 'Polish', + langCode: 'pl-pl', + label: () => i18n.t('languages.polish'), + emoji: '🇵🇱' +} + +export const ARABIC: Language = { + value: 'Arabic', + langCode: 'ar-ar', + label: () => i18n.t('languages.arabic'), + emoji: '🇸🇦' +} + +export const TURKISH: Language = { + value: 'Turkish', + langCode: 'tr-tr', + label: () => i18n.t('languages.turkish'), + emoji: '🇹🇷' +} + +export const THAI: Language = { + value: 'Thai', + langCode: 'th-th', + label: () => i18n.t('languages.thai'), + emoji: '🇹🇭' +} + +export const VIETNAMESE: Language = { + value: 'Vietnamese', + langCode: 'vi-vn', + label: () => i18n.t('languages.vietnamese'), + emoji: '🇻🇳' +} + +export const INDONESIAN: Language = { + value: 'Indonesian', + langCode: 'id-id', + label: () => i18n.t('languages.indonesian'), + emoji: '🇮🇩' +} + +export const URDU: Language = { + value: 'Urdu', + langCode: 'ur-pk', + label: () => i18n.t('languages.urdu'), + emoji: '🇵🇰' +} + +export const MALAY: Language = { + value: 'Malay', + langCode: 'ms-my', + label: () => i18n.t('languages.malay'), + emoji: '🇲🇾' +} + +export const LanguagesEnum = { + enUS: ENGLISH, + zhCN: CHINESE_SIMPLIFIED, + zhTW: CHINESE_TRADITIONAL, + jaJP: JAPANESE, + koKR: KOREAN, + frFR: FRENCH, + deDE: GERMAN, + itIT: ITALIAN, + esES: SPANISH, + ptPT: PORTUGUESE, + ruRU: RUSSIAN, + plPL: POLISH, + arAR: ARABIC, + trTR: TURKISH, + thTH: THAI, + viVN: VIETNAMESE, + idID: INDONESIAN, + urPK: URDU, + msMY: MALAY +} as const + +export const translateLanguageOptions: Language[] = Object.values(LanguagesEnum) diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index aa765db05b..6c23a115a5 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -3,7 +3,7 @@ import { FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@ren import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage' import { Dexie, type EntityTable } from 'dexie' -import { upgradeToV5, upgradeToV7 } from './upgrades' +import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades' // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { @@ -74,4 +74,17 @@ db.version(7) }) .upgrade((tx) => upgradeToV7(tx)) +db.version(8) + .stores({ + // Re-declare all tables for the new version + files: 'id, name, origin_name, path, size, ext, type, created_at, count', + topics: '&id', // Correct index for topics + settings: '&id, value', + knowledge_notes: '&id, baseId, type, content, created_at, updated_at', + translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', + quick_phrases: 'id', + message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator + }) + .upgrade((tx) => upgradeToV8(tx)) + export default db diff --git a/src/renderer/src/databases/upgrades.ts b/src/renderer/src/databases/upgrades.ts index cb1e770db0..5543dde4ab 100644 --- a/src/renderer/src/databases/upgrades.ts +++ b/src/renderer/src/databases/upgrades.ts @@ -1,7 +1,7 @@ import Logger from '@renderer/config/logger' -import type { LegacyMessage as OldMessage, Topic } from '@renderer/types' -import { FileTypes } from '@renderer/types' // Import FileTypes enum -import { WebSearchSource } from '@renderer/types' +import { LanguagesEnum } from '@renderer/config/translate' +import type { LanguageCode, LegacyMessage as OldMessage, Topic } from '@renderer/types' +import { FileTypes, WebSearchSource } from '@renderer/types' // Import FileTypes enum import type { BaseMessageBlock, CitationMessageBlock, @@ -308,3 +308,78 @@ export async function upgradeToV7(tx: Transaction): Promise { Logger.log('DB migration to version 7 finished successfully.') } + +export async function upgradeToV8(tx: Transaction): Promise { + Logger.log('DB migration to version 8 started') + + const langMap: Record = { + english: 'en-us', + chinese: 'zh-cn', + 'chinese-traditional': 'zh-tw', + japanese: 'ja-jp', + korean: 'ko-kr', + french: 'fr-fr', + german: 'de-de', + italian: 'it-it', + spanish: 'es-es', + portuguese: 'pt-pt', + russian: 'ru-ru', + polish: 'pl-pl', + arabic: 'ar-ar', + turkish: 'tr-tr', + thai: 'th-th', + vietnamese: 'vi-vn', + indonesian: 'id-id', + urdu: 'ur-pk', + malay: 'ms-my' + } + + const settingsTable = tx.table('settings') + const defaultPair: [LanguageCode, LanguageCode] = [LanguagesEnum.enUS.langCode, LanguagesEnum.zhCN.langCode] + const originSource = (await settingsTable.get('translate:source:language'))?.value + const originTarget = (await settingsTable.get('translate:target:language'))?.value + const originPair = (await settingsTable.get('translate:bidirectional:pair'))?.value + let newSource, newTarget, newPair + Logger.log('originSource: %o', originSource) + if (originSource === 'auto') { + newSource = 'auto' + } else { + newSource = langMap[originSource] + if (!newSource) { + newSource = LanguagesEnum.enUS.langCode + } + } + + Logger.log('originTarget: %o', originTarget) + newTarget = langMap[originTarget] + if (!newTarget) { + newTarget = LanguagesEnum.zhCN.langCode + } + + Logger.log('originPair: %o', originPair) + newPair = [langMap[originPair[0]], langMap[originPair[1]]] + if (!newPair[0] || !newPair[1]) { + newPair = defaultPair + } + + Logger.log('DB migration to version 8: %o', { newSource, newTarget, newPair }) + + await settingsTable.put({ id: 'translate:bidirectional:pair', value: newPair }) + await settingsTable.put({ id: 'translate:source:language', value: newSource }) + await settingsTable.put({ id: 'translate:target:language', value: newTarget }) + + const histories = tx.table('translate_history') + + for (const history of await histories.toArray()) { + try { + await tx.table('translate_history').put({ + ...history, + sourceLanguage: langMap[history.sourceLanguage], + targetLanguage: langMap[history.targetLanguage] + }) + } catch (error) { + console.error('Error upgrading history:', error) + } + } + Logger.log('DB migration to version 8 finished.') +} diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 559b4ad879..d8ac8aac60 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -19,7 +19,7 @@ import { updateMessageAndBlocksThunk, updateTranslationBlockThunk } from '@renderer/store/thunk/messageThunk' -import type { Assistant, Model, Topic } from '@renderer/types' +import type { Assistant, LanguageCode, Model, Topic } from '@renderer/types' import type { Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' import { abortCompletion } from '@renderer/utils/abortController' @@ -195,9 +195,9 @@ export function useMessageOperations(topic: Topic) { const getTranslationUpdater = useCallback( async ( messageId: string, - targetLanguage: string, + targetLanguage: LanguageCode, sourceBlockId?: string, - sourceLanguage?: string + sourceLanguage?: LanguageCode ): Promise<((accumulatedText: string, isComplete?: boolean) => void) | null> => { if (!topic.id) return null diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index fe550510a0..98095a81e1 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -37,6 +37,7 @@ import type { MessageInputBaseParams } from '@renderer/types/newMessage' import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { formatQuotedText } from '@renderer/utils/formats' import { getFilesFromDropEvent, getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input' +import { getLanguageByLangcode } from '@renderer/utils/translate' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' import { Button, Tooltip } from 'antd' @@ -253,7 +254,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = try { setIsTranslating(true) - const translatedText = await translateText(text, targetLanguage) + const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage)) translatedText && setText(translatedText) setTimeout(() => resizeTextArea(), 0) } catch (error) { diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index dcd86288e1..a8d55bbe80 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -2,7 +2,7 @@ import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } fro import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup' import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup' import { isVisionModel } from '@renderer/config/models' -import { TranslateLanguageOptions } from '@renderer/config/translate' +import { translateLanguageOptions } from '@renderer/config/translate' import { useMessageEditing } from '@renderer/context/MessageEditingContext' import { useChatContext } from '@renderer/hooks/useChatContext' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' @@ -13,7 +13,7 @@ import { translateText } from '@renderer/services/TranslateService' import store, { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { selectMessagesForTopic } from '@renderer/store/newMessage' -import type { Assistant, Model, Topic } from '@renderer/types' +import type { Assistant, Language, Model, Topic } from '@renderer/types' import { type Message, MessageBlockType } from '@renderer/types/newMessage' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils' import { copyMessageAsPlainText } from '@renderer/utils/copy' @@ -153,12 +153,12 @@ const MessageMenubar: FC = (props) => { }, [message.id, startEditing]) const handleTranslate = useCallback( - async (language: string) => { + async (language: Language) => { if (isTranslating) return setIsTranslating(true) const messageId = message.id - const translationUpdater = await getTranslationUpdater(messageId, language) + const translationUpdater = await getTranslationUpdater(messageId, language.langCode) if (!translationUpdater) return try { await translateText(mainTextContent, language, translationUpdater) @@ -457,10 +457,10 @@ const MessageMenubar: FC = (props) => { backgroundClip: 'border-box' }, items: [ - ...TranslateLanguageOptions.map((item) => ({ - label: item.emoji + ' ' + item.label, - key: item.value, - onClick: () => handleTranslate(item.value) + ...translateLanguageOptions.map((item) => ({ + label: item.emoji + ' ' + item.label(), + key: item.langCode, + onClick: () => handleTranslate(item) })), ...(hasTranslationBlocks ? [ diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 67c3ba7b97..cc56f72c05 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -8,6 +8,7 @@ import { isSupportedFlexServiceTier, isSupportedReasoningEffortOpenAIModel } from '@renderer/config/models' +import { translateLanguageOptions } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import { useTheme } from '@renderer/context/ThemeProvider' import { useAssistant } from '@renderer/hooks/useAssistant' @@ -44,14 +45,7 @@ import { setShowTranslateConfirm, setThoughtAutoCollapse } from '@renderer/store/settings' -import { - Assistant, - AssistantSettings, - CodeStyleVarious, - MathEngine, - ThemeMode, - TranslateLanguageVarious -} from '@renderer/types' +import { Assistant, AssistantSettings, CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types' import { modalConfirm } from '@renderer/utils' import { getSendMessageShortcutLabel } from '@renderer/utils/input' import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd' @@ -625,14 +619,10 @@ const SettingsTab: FC = (props) => { {t('settings.input.target_language')} setTargetLanguage(value as TranslateLanguageVarious)} - options={[ - { value: 'chinese', label: t('settings.input.target_language.chinese') }, - { value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') }, - { value: 'english', label: t('settings.input.target_language.english') }, - { value: 'japanese', label: t('settings.input.target_language.japanese') }, - { value: 'russian', label: t('settings.input.target_language.russian') } - ]} + onChange={(value) => setTargetLanguage(value)} + options={translateLanguageOptions.map((item) => { + return { value: item.langCode, label: item.emoji + ' ' + item.label() } + })} /> diff --git a/src/renderer/src/pages/paintings/AihubmixPage.tsx b/src/renderer/src/pages/paintings/AihubmixPage.tsx index 60d152a353..af57c21f47 100644 --- a/src/renderer/src/pages/paintings/AihubmixPage.tsx +++ b/src/renderer/src/pages/paintings/AihubmixPage.tsx @@ -7,6 +7,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' @@ -543,7 +544,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/paintings/SiliconPage.tsx b/src/renderer/src/pages/paintings/SiliconPage.tsx index 51edb4244a..f595ef76de 100644 --- a/src/renderer/src/pages/paintings/SiliconPage.tsx +++ b/src/renderer/src/pages/paintings/SiliconPage.tsx @@ -12,6 +12,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models' +import { LanguagesEnum } from '@renderer/config/translate' import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' @@ -302,7 +303,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/paintings/TokenFluxPage.tsx b/src/renderer/src/pages/paintings/TokenFluxPage.tsx index 85a39df438..40e1c2f2c0 100644 --- a/src/renderer/src/pages/paintings/TokenFluxPage.tsx +++ b/src/renderer/src/pages/paintings/TokenFluxPage.tsx @@ -4,6 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar' import TranslateButton from '@renderer/components/TranslateButton' import { isMac } from '@renderer/config/constant' import { getProviderLogo } from '@renderer/config/providers' +import { LanguagesEnum } from '@renderer/config/translate' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -255,7 +256,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => { try { setIsTranslating(true) - const translatedText = await translateText(painting.prompt, 'english') + const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS) updatePaintingState({ prompt: translatedText }) } catch (error) { console.error('Translation failed:', error) diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx index 8d4e7116b6..ae1ee408a3 100644 --- a/src/renderer/src/pages/translate/TranslatePage.tsx +++ b/src/renderer/src/pages/translate/TranslatePage.tsx @@ -4,7 +4,7 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon' import { HStack } from '@renderer/components/Layout' import { isEmbeddingModel } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' -import { translateLanguageOptions } from '@renderer/config/translate' +import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate' import { useCodeStyle } from '@renderer/context/CodeStyleProvider' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' @@ -15,13 +15,14 @@ import { getDefaultTranslateAssistant } from '@renderer/services/AssistantServic import { getModelUniqId, hasModel } from '@renderer/services/ModelService' import { useAppDispatch } from '@renderer/store' import { setTranslateModelPrompt } from '@renderer/store/settings' -import type { Model, TranslateHistory } from '@renderer/types' +import type { Language, LanguageCode, Model, TranslateHistory } from '@renderer/types' import { runAsyncFunction, uuid } from '@renderer/utils' import { createInputScrollHandler, createOutputScrollHandler, detectLanguage, - determineTargetLanguage + determineTargetLanguage, + getLanguageByLangcode } from '@renderer/utils/translate' import { Button, Dropdown, Empty, Flex, Modal, Popconfirm, Select, Space, Switch, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' @@ -35,7 +36,7 @@ import styled from 'styled-components' let _text = '' let _result = '' -let _targetLanguage = 'english' +let _targetLanguage = LanguagesEnum.enUS const TranslateSettings: FC<{ visible: boolean @@ -46,8 +47,8 @@ const TranslateSettings: FC<{ setIsBidirectional: (value: boolean) => void enableMarkdown: boolean setEnableMarkdown: (value: boolean) => void - bidirectionalPair: [string, string] - setBidirectionalPair: (value: [string, string]) => void + bidirectionalPair: [Language, Language] + setBidirectionalPair: (value: [Language, Language]) => void translateModel: Model | undefined onModelChange: (model: Model) => void allModels: Model[] @@ -71,7 +72,7 @@ const TranslateSettings: FC<{ const { t } = useTranslation() const { translateModelPrompt } = useSettings() const dispatch = useAppDispatch() - const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair) + const [localPair, setLocalPair] = useState<[Language, Language]>(bidirectionalPair) const [showPrompt, setShowPrompt] = useState(false) const [localPrompt, setLocalPrompt] = useState(translateModelPrompt) @@ -94,7 +95,7 @@ const TranslateSettings: FC<{ return } setBidirectionalPair(localPair) - db.settings.put({ id: 'translate:bidirectional:pair', value: localPair }) + db.settings.put({ id: 'translate:bidirectional:pair', value: [localPair[0].langCode, localPair[1].langCode] }) db.settings.put({ id: 'translate:scroll:sync', value: isScrollSyncEnabled }) db.settings.put({ id: 'translate:markdown:enabled', value: enableMarkdown }) db.settings.put({ id: 'translate:model:prompt', value: localPrompt }) @@ -189,16 +190,16 @@ const TranslateSettings: FC<{ setLocalPair([localPair[0], value])} - options={translateLanguageOptions().map((lang) => ({ - value: lang.value, + value={localPair[1].langCode} + onChange={(value) => setLocalPair([localPair[0], getLanguageByLangcode(value)])} + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} -
{lang.label}
+
{lang.label()}
) }))} @@ -275,7 +276,6 @@ const TranslateSettings: FC<{ const TranslatePage: FC = () => { const { t } = useTranslation() const { shikiMarkdownIt } = useCodeStyle() - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [text, setText] = useState(_text) const [result, setResult] = useState(_result) const [renderedMarkdown, setRenderedMarkdown] = useState('') @@ -286,10 +286,14 @@ const TranslatePage: FC = () => { const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) const [isBidirectional, setIsBidirectional] = useState(false) const [enableMarkdown, setEnableMarkdown] = useState(false) - const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) + const [bidirectionalPair, setBidirectionalPair] = useState<[Language, Language]>([ + LanguagesEnum.enUS, + LanguagesEnum.zhCN + ]) const [settingsVisible, setSettingsVisible] = useState(false) - const [detectedLanguage, setDetectedLanguage] = useState(null) - const [sourceLanguage, setSourceLanguage] = useState('auto') + const [detectedLanguage, setDetectedLanguage] = useState(null) + const [sourceLanguage, setSourceLanguage] = useState('auto') + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) @@ -329,8 +333,8 @@ const TranslatePage: FC = () => { const saveTranslateHistory = async ( sourceText: string, targetText: string, - sourceLanguage: string, - targetLanguage: string + sourceLanguage: LanguageCode, + targetLanguage: LanguageCode ) => { const history: TranslateHistory = { id: uuid(), @@ -364,7 +368,7 @@ const TranslatePage: FC = () => { setLoading(true) try { // 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测 - let actualSourceLanguage: string + let actualSourceLanguage: Language if (sourceLanguage === 'auto') { actualSourceLanguage = await detectLanguage(text) setDetectedLanguage(actualSourceLanguage) @@ -389,7 +393,7 @@ const TranslatePage: FC = () => { return } - const actualTargetLanguage = result.language as string + const actualTargetLanguage = result.language as Language if (isBidirectional) { setTargetLanguage(actualTargetLanguage) } @@ -405,7 +409,7 @@ const TranslatePage: FC = () => { } }) - await saveTranslateHistory(text, translatedText, actualSourceLanguage, actualTargetLanguage) + await saveTranslateHistory(text, translatedText, actualSourceLanguage.langCode, actualTargetLanguage.langCode) setLoading(false) } catch (error) { console.error('Translation error:', error) @@ -432,7 +436,7 @@ const TranslatePage: FC = () => { const onHistoryItemClick = (history: TranslateHistory) => { setText(history.sourceText) setResult(history.targetText) - setTargetLanguage(history.targetLanguage) + setTargetLanguage(getLanguageByLangcode(history.targetLanguage)) } useEffect(() => { @@ -460,20 +464,32 @@ const TranslatePage: FC = () => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) const sourceLang = await db.settings.get({ id: 'translate:source:language' }) - sourceLang && setSourceLanguage(sourceLang.value) + sourceLang && + setSourceLanguage(sourceLang.value === 'auto' ? sourceLang.value : getLanguageByLangcode(sourceLang.value)) const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) if (bidirectionalPairSetting) { const langPair = bidirectionalPairSetting.value + let source: undefined | Language + let target: undefined | Language + if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) { - setBidirectionalPair(langPair as [string, string]) + source = getLanguageByLangcode(langPair[0]) + target = getLanguageByLangcode(langPair[1]) + } + + if (source && target) { + setBidirectionalPair([source, target]) } else { - const defaultPair: [string, string] = ['english', 'chinese'] + const defaultPair: [Language, Language] = [LanguagesEnum.enUS, LanguagesEnum.zhCN] setBidirectionalPair(defaultPair) - db.settings.put({ id: 'translate:bidirectional:pair', value: defaultPair }) + db.settings.put({ + id: 'translate:bidirectional:pair', + value: [defaultPair[0].langCode, defaultPair[1].langCode] + }) } } @@ -489,7 +505,7 @@ const TranslatePage: FC = () => { }, []) const onKeyDown = (e: React.KeyboardEvent) => { - const isEnterPressed = e.keyCode == 13 + const isEnterPressed = e.key === 'Enter' if (isEnterPressed && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault() onTranslate() @@ -501,32 +517,37 @@ const TranslatePage: FC = () => { // 获取当前语言状态显示 const getLanguageDisplay = () => { - if (isBidirectional) { - return ( - - - {`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`} - - - ) + try { + if (isBidirectional) { + return ( + + + {`${bidirectionalPair[0].label()} ⇆ ${bidirectionalPair[1].label()}`} + + + ) + } + } catch (error) { + console.error('Error getting language display:', error) + setBidirectionalPair([LanguagesEnum.enUS, LanguagesEnum.zhCN]) } return ( { - setSourceLanguage(value) + onChange={(value: LanguageCode | 'auto') => { + if (value !== 'auto') setSourceLanguage(getLanguageByLangcode(value)) + else setSourceLanguage('auto') db.settings.put({ id: 'translate:source:language', value }) }} options={[ { value: 'auto', label: detectedLanguage - ? `${t('translate.detected.language')} (${t(`languages.${detectedLanguage.toLowerCase()}`)})` + ? `${t('translate.detected.language')} (${detectedLanguage.label()})` : t('translate.detected.language') }, - ...translateLanguageOptions().map((lang) => ({ - value: lang.value, + ...translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) })) diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index e8ec416b1e..0d216aa3aa 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -2,7 +2,7 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@ import i18n from '@renderer/i18n' import store from '@renderer/store' import { addAssistant } from '@renderer/store/assistants' -import type { Agent, Assistant, AssistantSettings, Model, Provider, Topic } from '@renderer/types' +import type { Agent, Assistant, AssistantSettings, Language, Model, Provider, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' export function getDefaultAssistant(): Assistant { @@ -28,7 +28,7 @@ export function getDefaultAssistant(): Assistant { } } -export function getDefaultTranslateAssistant(targetLanguage: string, text: string): Assistant { +export function getDefaultTranslateAssistant(targetLanguage: Language, text: string): Assistant { const translateModel = getTranslateModel() const assistant: Assistant = getDefaultAssistant() assistant.model = translateModel @@ -39,7 +39,7 @@ export function getDefaultTranslateAssistant(targetLanguage: string, text: strin assistant.prompt = store .getState() - .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage) + .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value) .replaceAll('{{text}}', text) return assistant } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 00a09acf54..4bb92f38b0 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -1,6 +1,6 @@ import Logger from '@renderer/config/logger' import db from '@renderer/databases' -import { upgradeToV7 } from '@renderer/databases/upgrades' +import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' @@ -637,7 +637,7 @@ export function stopAutoSync() { export async function getBackupData() { return JSON.stringify({ time: new Date().getTime(), - version: 4, + version: 5, localStorage, indexedDB: await backupDatabase() }) @@ -674,6 +674,12 @@ export async function handleData(data: Record) { }) } + if (data.version === 4) { + await db.transaction('rw', db.tables, async (tx) => { + await upgradeToV8(tx) + }) + } + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) setTimeout(() => window.api.reload(), 1000) return diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 513fa3ef36..1fb25d1a86 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -1,12 +1,13 @@ import i18n from '@renderer/i18n' import store from '@renderer/store' +import { Language } from '@renderer/types' import { fetchTranslate } from './ApiService' import { getDefaultTranslateAssistant } from './AssistantService' export const translateText = async ( text: string, - targetLanguage: string, + targetLanguage: Language, onResponse?: (text: string, isComplete: boolean) => void ) => { const translateModel = store.getState().llm.translateModel diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f0c0cb2680..5f1d84bfae 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -54,7 +54,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 119, + version: 120, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index a305d03735..53c28a84ba 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -5,7 +5,7 @@ import { SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import db from '@renderer/databases' import i18n from '@renderer/i18n' -import { Assistant, Provider, WebSearchProvider } from '@renderer/types' +import { Assistant, LanguageCode, Provider, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { isEmpty } from 'lodash' @@ -897,6 +897,7 @@ const migrateConfig = { }, '65': (state: RootState) => { try { + // @ts-ignore expect error state.settings.targetLanguage = 'english' return state } catch (error) { @@ -1736,6 +1737,25 @@ const migrateConfig = { } catch (error) { return state } + }, + '120': (state: RootState) => { + try { + const langMap: Record = { + english: 'en-us', + chinese: 'zh-cn', + 'chinese-traditional': 'zh-tw', + japanese: 'ja-jp', + russian: 'ru-ru' + } + + const origin = state.settings.targetLanguage + const newLang = langMap[origin] + if (newLang) state.settings.targetLanguage = newLang + else state.settings.targetLanguage = 'en-us' + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 771e7cf349..71b99b9463 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -201,7 +201,7 @@ export const initialState: SettingsState = { assistantsTabSortType: 'list', sendMessageShortcut: 'Enter', language: navigator.language as LanguageVarious, - targetLanguage: 'english' as TranslateLanguageVarious, + targetLanguage: 'en-us', proxyMode: 'system', proxyUrl: undefined, userName: '', diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 21eb4bbc99..b90e41b79b 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -340,16 +340,7 @@ export enum ThemeMode { export type LanguageVarious = 'zh-CN' | 'zh-TW' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' | 'ru-RU' -export type TranslateLanguageVarious = - | 'chinese' - | 'chinese-traditional' - | 'greek' - | 'english' - | 'spanish' - | 'french' - | 'japanese' - | 'portuguese' - | 'russian' +export type TranslateLanguageVarious = LanguageCode export type CodeStyleVarious = 'auto' | string @@ -489,12 +480,41 @@ export type GenerateImageResponse = { images: string[] } +export type LanguageCode = + | 'en-us' + | 'zh-cn' + | 'zh-tw' + | 'ja-jp' + | 'ko-kr' + | 'fr-fr' + | 'de-de' + | 'it-it' + | 'es-es' + | 'pt-pt' + | 'ru-ru' + | 'pl-pl' + | 'ar-ar' + | 'tr-tr' + | 'th-th' + | 'vi-vn' + | 'id-id' + | 'ur-pk' + | 'ms-my' + +// langCode应当能够唯一确认一种语言 +export type Language = { + value: string + langCode: LanguageCode + label: () => string + emoji: string +} + export interface TranslateHistory { id: string sourceText: string targetText: string - sourceLanguage: string - targetLanguage: string + sourceLanguage: LanguageCode + targetLanguage: LanguageCode createdAt: string } diff --git a/src/renderer/src/utils/translate.ts b/src/renderer/src/utils/translate.ts index bd50733482..77a88928b9 100644 --- a/src/renderer/src/utils/translate.ts +++ b/src/renderer/src/utils/translate.ts @@ -1,13 +1,15 @@ +import { LanguagesEnum } from '@renderer/config/translate' +import { Language, LanguageCode } from '@renderer/types' import { franc } from 'franc-min' import React, { MutableRefObject } from 'react' /** * 使用Unicode字符范围检测语言 * 适用于较短文本的语言检测 - * @param {string} text 需要检测语言的文本 - * @returns {string} 检测到的语言代码 + * @param text 需要检测语言的文本 + * @returns 检测到的语言 */ -export const detectLanguageByUnicode = (text: string): string => { +export const detectLanguageByUnicode = (text: string): Language => { const counts = { zh: 0, ja: 0, @@ -40,8 +42,8 @@ export const detectLanguageByUnicode = (text: string): string => { } } - if (totalChars === 0) return 'en' - let maxLang = 'en' + if (totalChars === 0) return LanguagesEnum.enUS + let maxLang = '' let maxCount = 0 for (const [lang, count] of Object.entries(counts)) { @@ -52,73 +54,68 @@ export const detectLanguageByUnicode = (text: string): string => { } if (maxCount / totalChars < 0.3) { - return 'en' + return LanguagesEnum.enUS + } + + switch (maxLang) { + case 'zh': + return LanguagesEnum.zhCN + case 'ja': + return LanguagesEnum.jaJP + case 'ko': + return LanguagesEnum.koKR + case 'ru': + return LanguagesEnum.ruRU + case 'ar': + return LanguagesEnum.arAR + case 'en': + return LanguagesEnum.enUS + default: + console.error(`Unknown language: ${maxLang}`) + return LanguagesEnum.enUS } - return maxLang } /** * 检测输入文本的语言 - * @param {string} inputText 需要检测语言的文本 - * @returns {Promise} 检测到的语言代码 + * @param inputText 需要检测语言的文本 + * @returns 检测到的语言 */ -export const detectLanguage = async (inputText: string): Promise => { +export const detectLanguage = async (inputText: string): Promise => { const text = inputText.trim() - if (!text) return 'any' - let code: string + if (!text) return LanguagesEnum.zhCN + let lang: Language // 如果文本长度小于20个字符,使用Unicode范围检测 if (text.length < 20) { - code = detectLanguageByUnicode(text) + lang = detectLanguageByUnicode(text) } else { // franc 返回 ISO 639-3 代码 const iso3 = franc(text) - const isoMap: Record = { - cmn: 'zh', - jpn: 'ja', - kor: 'ko', - rus: 'ru', - ara: 'ar', - spa: 'es', - fra: 'fr', - deu: 'de', - ita: 'it', - por: 'pt', - eng: 'en', - pol: 'pl', - tur: 'tr', - tha: 'th', - vie: 'vi', - ind: 'id', - urd: 'ur', - zsm: 'ms' + const isoMap: Record = { + cmn: LanguagesEnum.zhCN, + jpn: LanguagesEnum.jaJP, + kor: LanguagesEnum.koKR, + rus: LanguagesEnum.ruRU, + ara: LanguagesEnum.arAR, + spa: LanguagesEnum.esES, + fra: LanguagesEnum.frFR, + deu: LanguagesEnum.deDE, + ita: LanguagesEnum.itIT, + por: LanguagesEnum.ptPT, + eng: LanguagesEnum.enUS, + pol: LanguagesEnum.plPL, + tur: LanguagesEnum.trTR, + tha: LanguagesEnum.thTH, + vie: LanguagesEnum.viVN, + ind: LanguagesEnum.idID, + urd: LanguagesEnum.urPK, + zsm: LanguagesEnum.msMY } - code = isoMap[iso3] || 'en' + lang = isoMap[iso3] || LanguagesEnum.enUS } - // 映射到应用使用的语言键 - const languageMap: Record = { - zh: 'chinese', - ja: 'japanese', - ko: 'korean', - ru: 'russian', - es: 'spanish', - fr: 'french', - de: 'german', - it: 'italian', - pt: 'portuguese', - ar: 'arabic', - en: 'english', - pl: 'polish', - tr: 'turkish', - th: 'thai', - vi: 'vietnamese', - id: 'indonesian', - ur: 'urdu', - ms: 'malay' - } - - return languageMap[code] || 'english' + return lang } /** @@ -127,10 +124,13 @@ export const detectLanguage = async (inputText: string): Promise => { * @param languagePair 配置的语言对 * @returns 目标语言 */ -export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => { - if (sourceLanguage === languagePair[0]) { +export const getTargetLanguageForBidirectional = ( + sourceLanguage: Language, + languagePair: [Language, Language] +): Language => { + if (sourceLanguage.langCode === languagePair[0].langCode) { return languagePair[1] - } else if (sourceLanguage === languagePair[1]) { + } else if (sourceLanguage.langCode === languagePair[1].langCode) { return languagePair[0] } return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1] @@ -142,8 +142,8 @@ export const getTargetLanguageForBidirectional = (sourceLanguage: string, langua * @param languagePair 配置的语言对 * @returns 是否在语言对中 */ -export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => { - return [languagePair[0], languagePair[1]].includes(sourceLanguage) +export const isLanguageInPair = (sourceLanguage: Language, languagePair: [Language, Language]): boolean => { + return [languagePair[0].langCode, languagePair[1].langCode].includes(sourceLanguage.langCode) } /** @@ -155,11 +155,11 @@ export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, * @returns 处理结果对象 */ export const determineTargetLanguage = ( - sourceLanguage: string, - targetLanguage: string, + sourceLanguage: Language, + targetLanguage: Language, isBidirectional: boolean, - bidirectionalPair: [string, string] -): { success: boolean; language?: string; errorType?: 'same_language' | 'not_in_pair' } => { + bidirectionalPair: [Language, Language] +): { success: boolean; language?: Language; errorType?: 'same_language' | 'not_in_pair' } => { if (isBidirectional) { if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) { return { success: false, errorType: 'not_in_pair' } @@ -169,7 +169,7 @@ export const determineTargetLanguage = ( language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair) } } else { - if (sourceLanguage === targetLanguage) { + if (sourceLanguage.langCode === targetLanguage.langCode) { return { success: false, errorType: 'same_language' } } return { success: true, language: targetLanguage } @@ -228,3 +228,21 @@ export const createOutputScrollHandler = ( handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef) } } + +/** + * 根据语言代码获取对应的语言对象 + * @param langcode - 语言代码 + * @returns 返回对应的语言对象,如果找不到则返回英语(enUS) + * @example + * ```typescript + * const language = getLanguageByLangcode('zh-cn') // 返回中文语言对象 + * ``` + */ +export const getLanguageByLangcode = (langcode: LanguageCode): Language => { + const result = Object.values(LanguagesEnum).find((item) => item.langCode === langcode) + if (!result) { + console.error(`Language not found for langcode: ${langcode}`) + return LanguagesEnum.enUS + } + return result +} diff --git a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx index 26e83fcf17..9cfde74bd3 100644 --- a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx +++ b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx @@ -1,13 +1,14 @@ import { SwapOutlined } from '@ant-design/icons' import Scrollbar from '@renderer/components/Scrollbar' -import { TranslateLanguageOptions } from '@renderer/config/translate' +import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' -import { Assistant } from '@renderer/types' +import { Assistant, Language } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' -import { Select, Space } from 'antd' +import { getLanguageByLangcode } from '@renderer/utils/translate' +import { Select } from 'antd' import { isEmpty } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -18,11 +19,11 @@ interface Props { text: string } -let _targetLanguage = 'chinese' +let _targetLanguage = (await db.settings.get({ id: 'translate:target:language' }))?.value || LanguagesEnum.zhCN const Translate: FC = ({ text }) => { const [result, setResult] = useState('') - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const { translateModel } = useDefaultModel() const { t } = useTranslation() const translatingRef = useRef(false) @@ -37,8 +38,7 @@ const Translate: FC = ({ text }) => { try { translatingRef.current = true - const targetLang = await db.settings.get({ id: 'translate:target:language' }) - const assistant: Assistant = getDefaultTranslateAssistant(targetLang?.value || targetLanguage, text) + const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text) // const message: Message = { // id: uuid(), // role: 'user', @@ -64,7 +64,7 @@ const Translate: FC = ({ text }) => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) }) }, []) @@ -91,22 +91,17 @@ const Translate: FC = ({ text }) => { ({ - value: lang.value, + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) }))} - onChange={(value) => handleChangeLanguage(value, alterLanguage)} + onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} disabled={isLoading} />