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
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
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/package.json b/package.json
index d5cbc6e707..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",
@@ -68,7 +69,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/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/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/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/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'
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/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/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/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/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 (
= ({
}
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/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')}
+ ,
+ }
+ onClick={handleDeleteSelected}
+ disabled={selectedRowKeys.length === 0 || deleting}
+ loading={deleting}>
+ {t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
+ ,
+
+ ]}>
+
+
+ )
+}
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 (
+
+
+
+
+ )
+}
+
+function formatFileOption(file: BackupFile) {
+ const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
+ const size = formatFileSize(file.size)
+ return {
+ label: `${file.fileName} (${date}, ${size})`,
+ value: file.fileName
+ }
+}
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/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/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/hooks/useTranslate.ts b/src/renderer/src/hooks/useTranslate.ts
index f3b6b9634a..5938b99667 100644
--- a/src/renderer/src/hooks/useTranslate.ts
+++ b/src/renderer/src/hooks/useTranslate.ts
@@ -5,7 +5,7 @@ import {
setTranslatedContent as _setTranslatedContent,
setTranslating as _setTranslating
} from '@renderer/store/translate'
-import { Assistant, TranslateHistory } from '@renderer/types'
+import { Assistant, LanguageCode, TranslateHistory } from '@renderer/types'
import { uuid } from '@renderer/utils'
import { t } from 'i18next'
@@ -26,8 +26,8 @@ export default function useTranslate() {
const translate = async (
text: string,
assistant: Assistant,
- actualSourceLanguage: string,
- actualTargetLanguage: string
+ actualSourceLanguage: LanguageCode,
+ actualTargetLanguage: LanguageCode
) => {
setTranslating(true)
await fetchTranslate({
@@ -53,8 +53,8 @@ export default function useTranslate() {
const saveTranslateHistory = async (
sourceText: string,
targetText: string,
- sourceLanguage: string,
- targetLanguage: string
+ sourceLanguage: LanguageCode,
+ targetLanguage: LanguageCode
) => {
const history: TranslateHistory = {
id: uuid(),
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index 2ed00d77cc..dfce0e100d 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -1268,6 +1268,71 @@
"maxBackups": "Maximum Backups",
"maxBackups.unlimited": "Unlimited"
},
+ "s3": {
+ "title": "S3 Compatible Storage",
+ "title.help": "S3 compatible object storage services, such as AWS S3, Cloudflare R2, Aliyun OSS, Tencent COS, etc.",
+ "title.tooltip": "S3 Compatible Storage Configuration Document",
+ "endpoint": "API Endpoint",
+ "endpoint.placeholder": "https://s3.example.com",
+ "region": "Region",
+ "region.placeholder": "Region, e.g: us-east-1",
+ "bucket": "Bucket",
+ "bucket.placeholder": "Bucket, e.g: example",
+ "accessKeyId": "Access Key ID",
+ "accessKeyId.placeholder": "Access Key ID",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKey.placeholder": "Secret Access Key",
+ "root": "Backup Directory (Optional)",
+ "root.placeholder": "e.g: /cherry-studio",
+ "backup.operation": "Backup Operation",
+ "backup.button": "Backup Now",
+ "backup.manager.button": "Manage Backups",
+ "backup.modal.title": "S3 Backup",
+ "backup.modal.filename.placeholder": "Please enter backup filename",
+ "backup.success": "S3 backup successful",
+ "backup.error": "S3 backup failed: {{message}}",
+ "autoSync": "Auto Sync",
+ "autoSync.off": "Off",
+ "autoSync.minute": "Every {{count}} minute",
+ "autoSync.hour": "Every {{count}} hour",
+ "maxBackups": "Maximum Backups",
+ "maxBackups.unlimited": "Unlimited",
+ "skipBackupFile": "Lightweight Backup",
+ "skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
+ "syncStatus": "Sync Status",
+ "syncStatus.noSync": "Not synced",
+ "syncStatus.error": "Sync error: {{message}}",
+ "syncStatus.lastSync": "Last sync: {{time}}",
+ "manager.title": "S3 Backup File Manager",
+ "manager.refresh": "Refresh",
+ "manager.delete.selected": "Delete Selected ({{count}})",
+ "manager.close": "Close",
+ "manager.columns.fileName": "File Name",
+ "manager.columns.modifiedTime": "Modified Time",
+ "manager.columns.size": "File Size",
+ "manager.columns.actions": "Actions",
+ "manager.restore": "Restore",
+ "manager.delete": "Delete",
+ "manager.config.incomplete": "Please fill in complete S3 configuration",
+ "manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
+ "manager.delete.confirm.title": "Confirm Delete",
+ "manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
+ "manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
+ "manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
+ "manager.delete.success.single": "Backup file deleted successfully",
+ "manager.delete.error": "Failed to delete backup file: {{message}}",
+ "manager.select.warning": "Please select backup files to delete",
+ "restore.modal.title": "S3 Data Restore",
+ "restore.modal.select.placeholder": "Please select backup file to restore",
+ "restore.confirm.title": "Confirm Restore Data",
+ "restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
+ "restore.confirm.ok": "Confirm Restore",
+ "restore.confirm.cancel": "Cancel",
+ "restore.success": "Data restore successful",
+ "restore.error": "Data restore failed: {{message}}",
+ "restore.config.incomplete": "Please fill in complete S3 configuration",
+ "restore.file.required": "Please select backup file to restore"
+ },
"yuque": {
"check": {
"button": "Check",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index b008b019f7..63db909952 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -1248,6 +1248,71 @@
"maxBackups": "最大バックアップ数",
"maxBackups.unlimited": "無制限"
},
+ "s3": {
+ "title": "S3互換ストレージ",
+ "title.tooltip": "S3互換ストレージ設定ガイド",
+ "title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービス(例:AWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど)",
+ "endpoint": "APIエンドポイント",
+ "endpoint.placeholder": "https://s3.example.com",
+ "region": "リージョン",
+ "region.placeholder": "Region、例: us-east-1",
+ "bucket": "バケット",
+ "bucket.placeholder": "Bucket、例: example",
+ "accessKeyId": "Access Key ID",
+ "accessKeyId.placeholder": "Access Key ID",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKey.placeholder": "Secret Access Key",
+ "root": "バックアップディレクトリ(オプション)",
+ "root.placeholder": "例:/cherry-studio",
+ "backup.operation": "バックアップ操作",
+ "backup.button": "今すぐバックアップ",
+ "backup.manager.button": "バックアップ管理",
+ "backup.modal.title": "S3バックアップ",
+ "backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
+ "backup.success": "S3バックアップ成功",
+ "backup.error": "S3バックアップ失敗: {{message}}",
+ "autoSync": "自動同期",
+ "autoSync.off": "オフ",
+ "autoSync.minute": "{{count}}分毎",
+ "autoSync.hour": "{{count}}時間毎",
+ "maxBackups": "最大バックアップ数",
+ "maxBackups.unlimited": "無制限",
+ "skipBackupFile": "軽量バックアップ",
+ "skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
+ "syncStatus": "同期ステータス",
+ "syncStatus.noSync": "未同期",
+ "syncStatus.error": "同期エラー: {{message}}",
+ "syncStatus.lastSync": "最終同期: {{time}}",
+ "manager.title": "S3バックアップファイルマネージャー",
+ "manager.refresh": "更新",
+ "manager.delete.selected": "選択項目を削除 ({{count}})",
+ "manager.close": "閉じる",
+ "manager.columns.fileName": "ファイル名",
+ "manager.columns.modifiedTime": "変更日時",
+ "manager.columns.size": "ファイルサイズ",
+ "manager.columns.actions": "操作",
+ "manager.restore": "復元",
+ "manager.delete": "削除",
+ "manager.config.incomplete": "完全なS3設定情報を入力してください",
+ "manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
+ "manager.delete.confirm.title": "削除の確認",
+ "manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
+ "manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
+ "manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
+ "manager.delete.success.single": "バックアップファイルの削除に成功しました",
+ "manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
+ "manager.select.warning": "削除するバックアップファイルを選択してください",
+ "restore.modal.title": "S3データ復元",
+ "restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
+ "restore.confirm.title": "データ復元の確認",
+ "restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
+ "restore.confirm.ok": "復元を確認",
+ "restore.confirm.cancel": "キャンセル",
+ "restore.success": "データの復元に成功しました",
+ "restore.error": "データの復元に失敗しました: {{message}}",
+ "restore.config.incomplete": "完全なS3設定情報を入力してください",
+ "restore.file.required": "復元するバックアップファイルを選択してください"
+ },
"yuque": {
"check": {
"button": "接続確認",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index d78f6a3a26..783fdf823c 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -1266,6 +1266,71 @@
"maxBackups": "Максимальное количество резервных копий",
"maxBackups.unlimited": "Без ограничений"
},
+ "s3": {
+ "title": "S3-совместимое хранилище",
+ "title.tooltip": "Руководство по настройке S3-совместимого хранилища",
+ "title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
+ "endpoint": "Конечная точка API",
+ "endpoint.placeholder": "https://s3.example.com",
+ "region": "Регион",
+ "region.placeholder": "Регион, например: us-east-1",
+ "bucket": "Корзина",
+ "bucket.placeholder": "Корзина, например: example",
+ "accessKeyId": "Access Key ID",
+ "accessKeyId.placeholder": "Access Key ID",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKey.placeholder": "Secret Access Key",
+ "root": "Каталог резервных копий (необязательно)",
+ "root.placeholder": "например: /cherry-studio",
+ "backup.operation": "Операция резервного копирования",
+ "backup.button": "Создать резервную копию сейчас",
+ "backup.manager.button": "Управление резервными копиями",
+ "backup.modal.title": "Резервное копирование S3",
+ "backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
+ "backup.success": "Резервное копирование S3 успешно",
+ "backup.error": "Ошибка резервного копирования S3: {{message}}",
+ "autoSync": "Автосинхронизация",
+ "autoSync.off": "Выкл.",
+ "autoSync.minute": "Каждые {{count}} мин.",
+ "autoSync.hour": "Каждые {{count}} ч.",
+ "maxBackups": "Макс. резервных копий",
+ "maxBackups.unlimited": "Неограниченно",
+ "skipBackupFile": "Облегченное резервное копирование",
+ "skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
+ "syncStatus": "Статус синхронизации",
+ "syncStatus.noSync": "Не синхронизировано",
+ "syncStatus.error": "Ошибка синхронизации: {{message}}",
+ "syncStatus.lastSync": "Последняя синхронизация: {{time}}",
+ "manager.title": "Менеджер файлов резервных копий S3",
+ "manager.refresh": "Обновить",
+ "manager.delete.selected": "Удалить выбранные ({{count}})",
+ "manager.close": "Закрыть",
+ "manager.columns.fileName": "Имя файла",
+ "manager.columns.modifiedTime": "Время изменения",
+ "manager.columns.size": "Размер файла",
+ "manager.columns.actions": "Действия",
+ "manager.restore": "Восстановить",
+ "manager.delete": "Удалить",
+ "manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
+ "manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
+ "manager.delete.confirm.title": "Подтвердить удаление",
+ "manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
+ "manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
+ "manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
+ "manager.delete.success.single": "Файл резервной копии успешно удален",
+ "manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
+ "manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
+ "restore.modal.title": "Восстановление данных S3",
+ "restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
+ "restore.confirm.title": "Подтвердить восстановление данных",
+ "restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
+ "restore.confirm.ok": "Подтвердить восстановление",
+ "restore.confirm.cancel": "Отмена",
+ "restore.success": "Восстановление данных успешно",
+ "restore.error": "Ошибка восстановления данных: {{message}}",
+ "restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
+ "restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
+ },
"yuque": {
"check": {
"button": "Проверить",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 9b03ea98fa..71ae772021 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -1268,7 +1268,72 @@
"title": "WebDAV",
"user": "WebDAV 用户名",
"maxBackups": "最大备份数",
- "maxBackups.unlimited": "无限制"
+ "maxBackups.unlimited": "不限"
+ },
+ "s3": {
+ "title": "S3 兼容存储",
+ "title.tooltip": "S3 兼容存储配置文档",
+ "title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等",
+ "endpoint": "API 地址",
+ "endpoint.placeholder": "https://s3.example.com",
+ "region": "区域",
+ "region.placeholder": "Region, 例如: us-east-1",
+ "bucket": "存储桶",
+ "bucket.placeholder": "Bucket, 例如: example",
+ "accessKeyId": "Access Key ID",
+ "accessKeyId.placeholder": "Access Key ID",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKey.placeholder": "Secret Access Key",
+ "root": "备份目录(可选)",
+ "root.placeholder": "例如:/cherry-studio",
+ "backup.operation": "备份操作",
+ "backup.button": "立即备份",
+ "backup.manager.button": "管理备份",
+ "backup.modal.title": "S3 备份",
+ "backup.modal.filename.placeholder": "请输入备份文件名",
+ "backup.success": "S3 备份成功",
+ "backup.error": "S3 备份失败: {{message}}",
+ "autoSync": "自动同步",
+ "autoSync.off": "关闭",
+ "autoSync.minute": "每 {{count}} 分钟",
+ "autoSync.hour": "每 {{count}} 小时",
+ "maxBackups": "最大备份数",
+ "maxBackups.unlimited": "不限",
+ "skipBackupFile": "精简备份",
+ "skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
+ "syncStatus": "同步状态",
+ "syncStatus.noSync": "未同步",
+ "syncStatus.error": "同步错误: {{message}}",
+ "syncStatus.lastSync": "上次同步: {{time}}",
+ "manager.title": "S3 备份文件管理",
+ "manager.refresh": "刷新",
+ "manager.delete.selected": "删除选中 ({{count}})",
+ "manager.close": "关闭",
+ "manager.columns.fileName": "文件名",
+ "manager.columns.modifiedTime": "修改时间",
+ "manager.columns.size": "文件大小",
+ "manager.columns.actions": "操作",
+ "manager.restore": "恢复",
+ "manager.delete": "删除",
+ "manager.config.incomplete": "请填写完整的 S3 配置信息",
+ "manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
+ "manager.delete.confirm.title": "确认删除",
+ "manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
+ "manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
+ "manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
+ "manager.delete.success.single": "删除备份文件成功",
+ "manager.delete.error": "删除备份文件失败: {{message}}",
+ "manager.select.warning": "请选择要删除的备份文件",
+ "restore.modal.title": "S3 数据恢复",
+ "restore.modal.select.placeholder": "请选择要恢复的备份文件",
+ "restore.confirm.title": "确认恢复数据",
+ "restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
+ "restore.confirm.ok": "确认恢复",
+ "restore.confirm.cancel": "取消",
+ "restore.success": "数据恢复成功",
+ "restore.error": "数据恢复失败: {{message}}",
+ "restore.config.incomplete": "请填写完整的 S3 配置信息",
+ "restore.file.required": "请选择要恢复的备份文件"
},
"yuque": {
"check": {
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 14801e944c..dcbdd27b70 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -1266,7 +1266,72 @@
"title": "WebDAV",
"user": "WebDAV 使用者名稱",
"maxBackups": "最大備份數量",
- "maxBackups.unlimited": "無限制"
+ "maxBackups.unlimited": "不限"
+ },
+ "s3": {
+ "title": "S3 相容儲存",
+ "title.tooltip": "S3 相容儲存設定指南",
+ "title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等",
+ "endpoint": "API 位址",
+ "endpoint.placeholder": "https://s3.example.com",
+ "region": "區域",
+ "region.placeholder": "Region,例如: us-east-1",
+ "bucket": "儲存桶",
+ "bucket.placeholder": "Bucket,例如: example",
+ "accessKeyId": "Access Key ID",
+ "accessKeyId.placeholder": "Access Key ID",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKey.placeholder": "Secret Access Key",
+ "root": "備份目錄(可選)",
+ "root.placeholder": "例如:/cherry-studio",
+ "backup.operation": "備份操作",
+ "backup.button": "立即備份",
+ "backup.manager.button": "管理備份",
+ "backup.modal.title": "S3 備份",
+ "backup.modal.filename.placeholder": "請輸入備份檔案名稱",
+ "backup.success": "S3 備份成功",
+ "backup.error": "S3 備份失敗: {{message}}",
+ "autoSync": "自動同步",
+ "autoSync.off": "關閉",
+ "autoSync.minute": "每 {{count}} 分鐘",
+ "autoSync.hour": "每 {{count}} 小時",
+ "maxBackups": "最大備份數",
+ "maxBackups.unlimited": "不限",
+ "skipBackupFile": "精簡備份",
+ "skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
+ "syncStatus": "同步狀態",
+ "syncStatus.noSync": "未同步",
+ "syncStatus.error": "同步錯誤: {{message}}",
+ "syncStatus.lastSync": "上次同步: {{time}}",
+ "manager.title": "S3 備份檔案管理",
+ "manager.refresh": "重新整理",
+ "manager.delete.selected": "刪除選中 ({{count}})",
+ "manager.close": "關閉",
+ "manager.columns.fileName": "檔案名稱",
+ "manager.columns.modifiedTime": "修改時間",
+ "manager.columns.size": "檔案大小",
+ "manager.columns.actions": "操作",
+ "manager.restore": "恢復",
+ "manager.delete": "刪除",
+ "manager.config.incomplete": "請填寫完整的 S3 設定資訊",
+ "manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
+ "manager.delete.confirm.title": "確認刪除",
+ "manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
+ "manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
+ "manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
+ "manager.delete.success.single": "刪除備份檔案成功",
+ "manager.delete.error": "刪除備份檔案失敗: {{message}}",
+ "manager.select.warning": "請選擇要刪除的備份檔案",
+ "restore.modal.title": "S3 資料恢復",
+ "restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
+ "restore.confirm.title": "確認恢復資料",
+ "restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
+ "restore.confirm.ok": "確認恢復",
+ "restore.confirm.cancel": "取消",
+ "restore.success": "資料恢復成功",
+ "restore.error": "資料恢復失敗: {{message}}",
+ "restore.config.incomplete": "請填寫完整的 S3 設定資訊",
+ "restore.file.required": "請選擇要恢復的備份檔案"
},
"yuque": {
"check": {
diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts
index 096ec7a6f0..c5d969b6fd 100644
--- a/src/renderer/src/init.ts
+++ b/src/renderer/src/init.ts
@@ -12,9 +12,9 @@ function initKeyv() {
function initAutoSync() {
setTimeout(() => {
- const { webdavAutoSync } = store.getState().settings
+ const { webdavAutoSync, s3 } = store.getState().settings
const { nutstoreAutoSync } = store.getState().nutstore
- if (webdavAutoSync) {
+ if (webdavAutoSync || (s3 && s3.autoSync)) {
startAutoSync()
}
if (nutstoreAutoSync) {
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/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/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'],
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 && (
<>
= (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)
@@ -399,7 +399,7 @@ const MessageMenubar: FC = (props) => {
const softHoverBg = isBubbleStyle && !isLastMessage
return (
-
+
{message.role === 'user' && (
= (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/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/DataSettings.tsx b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
index fece64a16a..1ef101157c 100644
--- a/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
+++ b/src/renderer/src/pages/settings/DataSettings/DataSettings.tsx
@@ -1,4 +1,5 @@
import {
+ CloudServerOutlined,
CloudSyncOutlined,
FileSearchOutlined,
FolderOpenOutlined,
@@ -42,6 +43,7 @@ import MarkdownExportSettings from './MarkdownExportSettings'
import NotionSettings from './NotionSettings'
import NutstoreSettings from './NutstoreSettings'
import ObsidianSettings from './ObsidianSettings'
+import S3Settings from './S3Settings'
import SiyuanSettings from './SiyuanSettings'
import WebDavSettings from './WebDavSettings'
import YuqueSettings from './YuqueSettings'
@@ -88,6 +90,7 @@ const DataSettings: FC = () => {
{ key: 'divider_1', isDivider: true, text: t('settings.data.divider.cloud_storage') },
{ key: 'webdav', title: 'settings.data.webdav.title', icon: },
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: },
+ { key: 's3', title: 'settings.data.s3.title', icon: },
{ key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
{
key: 'export_menu',
@@ -653,6 +656,7 @@ const DataSettings: FC = () => {
)}
{menu === 'webdav' && }
{menu === 'nutstore' && }
+ {menu === 's3' && }
{menu === 'export_menu' && }
{menu === 'markdown_export' && }
{menu === 'notion' && }
@@ -686,8 +690,12 @@ const MenuList = styled.div`
gap: 5px;
width: var(--settings-width);
padding: 12px;
+ padding-bottom: 48px;
border-right: 0.5px solid var(--color-border);
- height: 100%;
+ height: 100vh;
+ overflow: auto;
+ box-sizing: border-box;
+ min-height: 0;
.iconfont {
color: var(--color-text-2);
line-height: 16px;
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/S3Settings.tsx b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
new file mode 100644
index 0000000000..c261c5b736
--- /dev/null
+++ b/src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
@@ -0,0 +1,292 @@
+import { FolderOpenOutlined, InfoCircleOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
+import { HStack } from '@renderer/components/Layout'
+import { S3BackupManager } from '@renderer/components/S3BackupManager'
+import { S3BackupModal, useS3BackupModal } from '@renderer/components/S3Modals'
+import { useTheme } from '@renderer/context/ThemeProvider'
+import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
+import { useSettings } from '@renderer/hooks/useSettings'
+import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
+import { useAppDispatch, useAppSelector } from '@renderer/store'
+import { setS3Partial } from '@renderer/store/settings'
+import { S3Config } from '@renderer/types'
+import { Button, Input, Select, Switch, Tooltip } from 'antd'
+import dayjs from 'dayjs'
+import { FC, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+
+import { SettingDivider, SettingGroup, SettingHelpText, SettingRow, SettingRowTitle, SettingTitle } from '..'
+
+const S3Settings: FC = () => {
+ const { s3 = {} as S3Config } = useSettings()
+
+ const {
+ endpoint: s3EndpointInit = '',
+ region: s3RegionInit = '',
+ bucket: s3BucketInit = '',
+ accessKeyId: s3AccessKeyIdInit = '',
+ secretAccessKey: s3SecretAccessKeyInit = '',
+ root: s3RootInit = '',
+ syncInterval: s3SyncIntervalInit = 0,
+ maxBackups: s3MaxBackupsInit = 5,
+ skipBackupFile: s3SkipBackupFileInit = false
+ } = s3
+
+ const [endpoint, setEndpoint] = useState(s3EndpointInit)
+ const [region, setRegion] = useState(s3RegionInit)
+ const [bucket, setBucket] = useState(s3BucketInit)
+ const [accessKeyId, setAccessKeyId] = useState(s3AccessKeyIdInit)
+ const [secretAccessKey, setSecretAccessKey] = useState(s3SecretAccessKeyInit)
+ const [root, setRoot] = useState(s3RootInit)
+ const [skipBackupFile, setSkipBackupFile] = useState(s3SkipBackupFileInit)
+ const [backupManagerVisible, setBackupManagerVisible] = useState(false)
+
+ const [syncInterval, setSyncInterval] = useState(s3SyncIntervalInit)
+ const [maxBackups, setMaxBackups] = useState(s3MaxBackupsInit)
+
+ const dispatch = useAppDispatch()
+ const { theme } = useTheme()
+ const { t } = useTranslation()
+ const { openMinapp } = useMinappPopup()
+
+ const { s3Sync } = useAppSelector((state) => state.backup)
+
+ const onSyncIntervalChange = (value: number) => {
+ setSyncInterval(value)
+ dispatch(setS3Partial({ syncInterval: value, autoSync: value !== 0 }))
+ if (value === 0) {
+ stopAutoSync()
+ } else {
+ startAutoSync()
+ }
+ }
+
+ const handleTitleClick = () => {
+ openMinapp({
+ id: 's3-help',
+ name: 'S3 Compatible Storage Help',
+ url: 'https://docs.cherry-ai.com/data-settings/s3-compatible'
+ })
+ }
+
+ const onMaxBackupsChange = (value: number) => {
+ setMaxBackups(value)
+ dispatch(setS3Partial({ maxBackups: value }))
+ }
+
+ const onSkipBackupFilesChange = (value: boolean) => {
+ setSkipBackupFile(value)
+ dispatch(setS3Partial({ skipBackupFile: value }))
+ }
+
+ const renderSyncStatus = () => {
+ if (!endpoint) return null
+
+ if (!s3Sync?.lastSyncTime && !s3Sync?.syncing && !s3Sync?.lastSyncError) {
+ return {t('settings.data.s3.syncStatus.noSync')}
+ }
+
+ return (
+
+ {s3Sync?.syncing && }
+ {!s3Sync?.syncing && s3Sync?.lastSyncError && (
+
+
+
+ )}
+ {s3Sync?.lastSyncTime && (
+
+ {t('settings.data.s3.syncStatus.lastSync', { time: dayjs(s3Sync.lastSyncTime).format('HH:mm:ss') })}
+
+ )}
+
+ )
+ }
+
+ const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
+ useS3BackupModal()
+
+ const showBackupManager = () => {
+ setBackupManagerVisible(true)
+ }
+
+ const closeBackupManager = () => {
+ setBackupManagerVisible(false)
+ }
+
+ return (
+
+
+ {t('settings.data.s3.title')}
+
+
+
+
+ {t('settings.data.s3.title.help')}
+
+
+ {t('settings.data.s3.endpoint')}
+ 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')}
+
+ }
+ loading={backuping}
+ disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
+ {t('settings.data.s3.backup.button')}
+
+ }
+ disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
+ {t('settings.data.s3.backup.manager.button')}
+
+
+
+
+
+ {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/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 = () => {
-
diff --git a/src/renderer/src/pages/translate/TranslatePage.tsx b/src/renderer/src/pages/translate/TranslatePage.tsx
index be486145da..d24a7eca78 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 } 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'
@@ -34,7 +35,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
let _text = ''
-let _targetLanguage = 'english'
+let _targetLanguage = LanguagesEnum.enUS
const TranslateSettings: FC<{
visible: boolean
@@ -45,8 +46,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[]
@@ -70,7 +71,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)
@@ -93,7 +94,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 })
@@ -188,16 +189,16 @@ const TranslateSettings: FC<{