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')} + , + , + + ]}> + + + ) +} 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 ( + +
+ 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')} + + + + + + + + {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<{ setLocalPair([localPair[0], value])} - options={translateLanguageOptions().map((lang) => ({ - value: lang.value, + value={localPair[1].langCode} + onChange={(value) => setLocalPair([localPair[0], getLanguageByLangcode(value)])} + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} -
{lang.label}
+
{lang.label()}
) }))} @@ -274,7 +275,6 @@ const TranslateSettings: FC<{ const TranslatePage: FC = () => { const { t } = useTranslation() const { shikiMarkdownIt } = useCodeStyle() - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const [text, setText] = useState(_text) const [renderedMarkdown, setRenderedMarkdown] = useState('') const { translateModel, setTranslateModel } = useDefaultModel() @@ -283,10 +283,14 @@ const TranslatePage: FC = () => { const [isScrollSyncEnabled, setIsScrollSyncEnabled] = useState(false) const [isBidirectional, setIsBidirectional] = useState(false) const [enableMarkdown, setEnableMarkdown] = useState(false) - const [bidirectionalPair, setBidirectionalPair] = useState<[string, string]>(['english', 'chinese']) + const [bidirectionalPair, setBidirectionalPair] = useState<[Language, Language]>([ + LanguagesEnum.enUS, + LanguagesEnum.zhCN + ]) const [settingsVisible, setSettingsVisible] = useState(false) - const [detectedLanguage, setDetectedLanguage] = useState(null) - const [sourceLanguage, setSourceLanguage] = useState('auto') + const [detectedLanguage, setDetectedLanguage] = useState(null) + const [sourceLanguage, setSourceLanguage] = useState('auto') + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const contentContainerRef = useRef(null) const textAreaRef = useRef(null) const outputTextRef = useRef(null) @@ -344,7 +348,7 @@ const TranslatePage: FC = () => { setTranslating(true) try { // 确定源语言:如果用户选择了特定语言,使用用户选择的;如果选择'auto',则自动检测 - let actualSourceLanguage: string + let actualSourceLanguage: Language if (sourceLanguage === 'auto') { actualSourceLanguage = await detectLanguage(text) setDetectedLanguage(actualSourceLanguage) @@ -369,14 +373,14 @@ const TranslatePage: FC = () => { return } - const actualTargetLanguage = result.language as string + const actualTargetLanguage = result.language as Language if (isBidirectional) { setTargetLanguage(actualTargetLanguage) } const assistant = getDefaultTranslateAssistant(actualTargetLanguage, text) - await translate(text, assistant, actualSourceLanguage, actualTargetLanguage) + await translate(text, assistant, actualSourceLanguage.langCode, actualTargetLanguage.langCode) } catch (error) { console.error('Translation error:', error) window.message.error({ @@ -402,7 +406,7 @@ const TranslatePage: FC = () => { const onHistoryItemClick = (history: TranslateHistory) => { setText(history.sourceText) setTranslatedContent(history.targetText) - setTargetLanguage(history.targetLanguage) + setTargetLanguage(getLanguageByLangcode(history.targetLanguage)) } useEffect(() => { @@ -430,20 +434,32 @@ const TranslatePage: FC = () => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) const sourceLang = await db.settings.get({ id: 'translate:source:language' }) - sourceLang && setSourceLanguage(sourceLang.value) + sourceLang && + setSourceLanguage(sourceLang.value === 'auto' ? sourceLang.value : getLanguageByLangcode(sourceLang.value)) const bidirectionalPairSetting = await db.settings.get({ id: 'translate:bidirectional:pair' }) if (bidirectionalPairSetting) { const langPair = bidirectionalPairSetting.value + let source: undefined | Language + let target: undefined | Language + if (Array.isArray(langPair) && langPair.length === 2 && langPair[0] !== langPair[1]) { - setBidirectionalPair(langPair as [string, string]) + source = getLanguageByLangcode(langPair[0]) + target = getLanguageByLangcode(langPair[1]) + } + + if (source && target) { + setBidirectionalPair([source, target]) } else { - const defaultPair: [string, string] = ['english', 'chinese'] + const defaultPair: [Language, Language] = [LanguagesEnum.enUS, LanguagesEnum.zhCN] setBidirectionalPair(defaultPair) - db.settings.put({ id: 'translate:bidirectional:pair', value: defaultPair }) + db.settings.put({ + id: 'translate:bidirectional:pair', + value: [defaultPair[0].langCode, defaultPair[1].langCode] + }) } } @@ -459,7 +475,7 @@ const TranslatePage: FC = () => { }, []) const onKeyDown = (e: React.KeyboardEvent) => { - const isEnterPressed = e.keyCode == 13 + const isEnterPressed = e.key === 'Enter' if (isEnterPressed && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault() onTranslate() @@ -471,32 +487,37 @@ const TranslatePage: FC = () => { // 获取当前语言状态显示 const getLanguageDisplay = () => { - if (isBidirectional) { - return ( - - - {`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`} - - - ) + try { + if (isBidirectional) { + return ( + + + {`${bidirectionalPair[0].label()} ⇆ ${bidirectionalPair[1].label()}`} + + + ) + } + } catch (error) { + console.error('Error getting language display:', error) + setBidirectionalPair([LanguagesEnum.enUS, LanguagesEnum.zhCN]) } return ( { - setSourceLanguage(value) + onChange={(value: LanguageCode | 'auto') => { + if (value !== 'auto') setSourceLanguage(getLanguageByLangcode(value)) + else setSourceLanguage('auto') db.settings.put({ id: 'translate:source:language', value }) }} options={[ { value: 'auto', label: detectedLanguage - ? `${t('translate.detected.language')} (${t(`languages.${detectedLanguage.toLowerCase()}`)})` + ? `${t('translate.detected.language')} (${detectedLanguage.label()})` : t('translate.detected.language') }, - ...translateLanguageOptions().map((lang) => ({ - value: lang.value, + ...translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) })) diff --git a/src/renderer/src/services/AssistantService.ts b/src/renderer/src/services/AssistantService.ts index e8ec416b1e..0d216aa3aa 100644 --- a/src/renderer/src/services/AssistantService.ts +++ b/src/renderer/src/services/AssistantService.ts @@ -2,7 +2,7 @@ import { DEFAULT_CONTEXTCOUNT, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '@ import i18n from '@renderer/i18n' import store from '@renderer/store' import { addAssistant } from '@renderer/store/assistants' -import type { Agent, Assistant, AssistantSettings, Model, Provider, Topic } from '@renderer/types' +import type { Agent, Assistant, AssistantSettings, Language, Model, Provider, Topic } from '@renderer/types' import { uuid } from '@renderer/utils' export function getDefaultAssistant(): Assistant { @@ -28,7 +28,7 @@ export function getDefaultAssistant(): Assistant { } } -export function getDefaultTranslateAssistant(targetLanguage: string, text: string): Assistant { +export function getDefaultTranslateAssistant(targetLanguage: Language, text: string): Assistant { const translateModel = getTranslateModel() const assistant: Assistant = getDefaultAssistant() assistant.model = translateModel @@ -39,7 +39,7 @@ export function getDefaultTranslateAssistant(targetLanguage: string, text: strin assistant.prompt = store .getState() - .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage) + .settings.translateModelPrompt.replaceAll('{{target_language}}', targetLanguage.value) .replaceAll('{{text}}', text) return assistant } diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 3d78b2752a..4bb92f38b0 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -1,14 +1,66 @@ import Logger from '@renderer/config/logger' import db from '@renderer/databases' -import { upgradeToV7 } from '@renderer/databases/upgrades' +import { upgradeToV7, upgradeToV8 } from '@renderer/databases/upgrades' import i18n from '@renderer/i18n' import store from '@renderer/store' import { setWebDAVSyncState } from '@renderer/store/backup' +import { setS3SyncState } from '@renderer/store/backup' +import { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' import dayjs from 'dayjs' import { NotificationService } from './NotificationService' +// 重试删除S3文件的辅助函数 +async function deleteS3FileWithRetry(fileName: string, s3Config: S3Config, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteS3File(fileName, s3Config) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + +// 重试删除WebDAV文件的辅助函数 +async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: WebDavConfig, maxRetries = 3) { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await window.api.backup.deleteWebdavFile(fileName, webdavConfig) + Logger.log(`[Backup] Successfully deleted old backup file: ${fileName} (attempt ${attempt})`) + return true + } catch (error: any) { + lastError = error + Logger.warn(`[Backup] Delete attempt ${attempt}/${maxRetries} failed for ${fileName}:`, error.message) + + // 如果不是最后一次尝试,等待一段时间再重试 + if (attempt < maxRetries) { + const delay = attempt * 1000 + Math.random() * 1000 // 1-2秒的随机延迟 + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + Logger.error(`[Backup] Failed to delete old backup file after ${maxRetries} attempts: ${fileName}`, lastError) + return false +} + export async function backup(skipBackupFile: boolean) { const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` const fileContnet = await getBackupData() @@ -161,17 +213,21 @@ export async function backupToWebdav({ // 文件已按修改时间降序排序,所以最旧的文件在末尾 const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups) - for (const file of filesToDelete) { - try { - await window.api.backup.deleteWebdavFile(file.fileName, { - webdavHost, - webdavUser, - webdavPass, - webdavPath - }) - Logger.log(`[Backup] Deleted old backup file: ${file.fileName}`) - } catch (error) { - Logger.error(`[Backup] Failed to delete old backup file: ${file.fileName}`, error) + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + // 串行删除文件,避免并发请求导致的问题 + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteWebdavFileWithRetry(file.fileName, { + webdavHost, + webdavUser, + webdavPass, + webdavPath + }) + + // 在删除操作之间添加短暂延迟,避免请求过于频繁 + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) } } } @@ -242,6 +298,160 @@ export async function restoreFromWebdav(fileName?: string) { } } +export async function backupToS3({ + showMessage = false, + customFileName = '', + autoBackupProcess = false +}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) { + const notificationService = NotificationService.getInstance() + if (isManualBackupRunning) { + Logger.log('[Backup] Manual backup already in progress') + return + } + + if (autoBackupProcess) { + showMessage = false + } + + isManualBackupRunning = true + + store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null })) + + const s3Config = store.getState().settings.s3 + let deviceType = 'unknown' + let hostname = 'unknown' + try { + deviceType = (await window.api.system.getDeviceType()) || 'unknown' + hostname = (await window.api.system.getHostname()) || 'unknown' + } catch (error) { + Logger.error('[Backup] Failed to get device type or hostname:', error) + } + const timestamp = dayjs().format('YYYYMMDDHHmmss') + const backupFileName = customFileName || `cherry-studio.${timestamp}.${hostname}.${deviceType}.zip` + const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip` + const backupData = await getBackupData() + + try { + const success = await window.api.backup.backupToS3(backupData, { + ...s3Config, + fileName: finalFileName + }) + + if (success) { + store.dispatch( + setS3SyncState({ + lastSyncError: null, + syncing: false, + lastSyncTime: Date.now() + }) + ) + notificationService.send({ + id: uuid(), + type: 'success', + title: i18n.t('common.success'), + message: i18n.t('message.backup.success'), + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) + + // 清理旧备份文件 + if (s3Config.maxBackups > 0) { + try { + // 获取所有备份文件 + const files = await window.api.backup.listS3Files(s3Config) + + // 筛选当前设备的备份文件 + const currentDeviceFiles = files.filter((file) => { + return file.fileName.includes(deviceType) && file.fileName.includes(hostname) + }) + + // 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件 + if (currentDeviceFiles.length > s3Config.maxBackups) { + const filesToDelete = currentDeviceFiles.slice(s3Config.maxBackups) + + Logger.log(`[Backup] Cleaning up ${filesToDelete.length} old backup files`) + + for (let i = 0; i < filesToDelete.length; i++) { + const file = filesToDelete[i] + await deleteS3FileWithRetry(file.fileName, s3Config) + + if (i < filesToDelete.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + } + } catch (error) { + Logger.error('[Backup] Failed to clean up old backup files:', error) + } + } + } else { + if (autoBackupProcess) { + throw new Error(i18n.t('message.backup.failed')) + } + + store.dispatch(setS3SyncState({ lastSyncError: 'Backup failed' })) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + } + } catch (error: any) { + if (autoBackupProcess) { + throw error + } + notificationService.send({ + id: uuid(), + type: 'error', + title: i18n.t('message.backup.failed'), + message: error.message, + silent: false, + timestamp: Date.now(), + source: 'backup' + }) + store.dispatch(setS3SyncState({ lastSyncError: error.message })) + console.error('[Backup] backupToS3: Error uploading file to S3:', error) + showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) + throw error + } finally { + if (!autoBackupProcess) { + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false + }) + ) + } + isManualBackupRunning = false + } +} + +// 从 S3 恢复 +export async function restoreFromS3(fileName?: string) { + const s3Config = store.getState().settings.s3 + + if (!fileName) { + const files = await window.api.backup.listS3Files(s3Config) + if (files.length > 0) { + fileName = files[0].fileName + } + } + + if (fileName) { + const restoreData = await window.api.backup.restoreFromS3({ + ...s3Config, + fileName + }) + const data = JSON.parse(restoreData) + await handleData(data) + store.dispatch( + setS3SyncState({ + lastSyncTime: Date.now(), + syncing: false, + lastSyncError: null + }) + ) + } +} + let autoSyncStarted = false let syncTimeout: NodeJS.Timeout | null = null let isAutoBackupRunning = false @@ -252,9 +462,18 @@ export function startAutoSync(immediate = false) { return } - const { webdavAutoSync, webdavHost } = store.getState().settings + const settings = store.getState().settings + const { webdavAutoSync, webdavHost } = settings + const s3Settings = settings.s3 - if (!webdavAutoSync || !webdavHost) { + const s3AutoSync = s3Settings?.autoSync + const s3Endpoint = s3Settings?.endpoint + + // 检查WebDAV或S3自动同步配置 + const hasWebdavConfig = webdavAutoSync && webdavHost + const hasS3Config = s3AutoSync && s3Endpoint + + if (!hasWebdavConfig && !hasS3Config) { Logger.log('[AutoSync] Invalid sync settings, auto sync disabled') return } @@ -277,22 +496,28 @@ export function startAutoSync(immediate = false) { syncTimeout = null } - const { webdavSyncInterval } = store.getState().settings - const { webdavSync } = store.getState().backup + const settings = store.getState().settings + const _webdavSyncInterval = settings.webdavSyncInterval + const _s3SyncInterval = settings.s3?.syncInterval + const { webdavSync, s3Sync } = store.getState().backup - if (webdavSyncInterval <= 0) { + // 使用当前激活的同步配置 + const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval + const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime + + if (!syncInterval || syncInterval <= 0) { Logger.log('[AutoSync] Invalid sync interval, auto sync disabled') stopAutoSync() return } // 用户指定的自动备份时间间隔(毫秒) - const requiredInterval = webdavSyncInterval * 60 * 1000 + const requiredInterval = syncInterval * 60 * 1000 let timeUntilNextSync = 1000 //also immediate switch (type) { - case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间 - timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now()) + case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间 + timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now()) break case 'fromNow': timeUntilNextSync = requiredInterval @@ -301,8 +526,9 @@ export function startAutoSync(immediate = false) { syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' Logger.log( - `[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( + `[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor( (timeUntilNextSync / 1000) % 60 )} seconds` ) @@ -321,17 +547,28 @@ export function startAutoSync(immediate = false) { while (retryCount < maxRetries) { try { - Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`) - await backupToWebdav({ autoBackupProcess: true }) - - store.dispatch( - setWebDAVSyncState({ - lastSyncError: null, - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + await backupToWebdav({ autoBackupProcess: true }) + store.dispatch( + setWebDAVSyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + await backupToS3({ autoBackupProcess: true }) + store.dispatch( + setS3SyncState({ + lastSyncError: null, + lastSyncTime: Date.now(), + syncing: false + }) + ) + } isAutoBackupRunning = false scheduleNextBackup() @@ -340,20 +577,31 @@ export function startAutoSync(immediate = false) { } catch (error: any) { retryCount++ if (retryCount === maxRetries) { - Logger.error('[AutoSync] Auto backup failed after all retries:', error) + const backupType = hasWebdavConfig ? 'WebDAV' : 'S3' + Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error) - store.dispatch( - setWebDAVSyncState({ - lastSyncError: 'Auto backup failed', - lastSyncTime: Date.now(), - syncing: false - }) - ) + if (hasWebdavConfig) { + store.dispatch( + setWebDAVSyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } else if (hasS3Config) { + store.dispatch( + setS3SyncState({ + lastSyncError: 'Auto backup failed', + lastSyncTime: Date.now(), + syncing: false + }) + ) + } //only show 1 time error modal, and autoback stopped until user click ok await window.modal.error({ title: i18n.t('message.backup.failed'), - content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message + content: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message }) scheduleNextBackup('fromNow') @@ -389,7 +637,7 @@ export function stopAutoSync() { export async function getBackupData() { return JSON.stringify({ time: new Date().getTime(), - version: 4, + version: 5, localStorage, indexedDB: await backupDatabase() }) @@ -426,6 +674,12 @@ export async function handleData(data: Record) { }) } + if (data.version === 4) { + await db.transaction('rw', db.tables, async (tx) => { + await upgradeToV8(tx) + }) + } + window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) setTimeout(() => window.api.reload(), 1000) return diff --git a/src/renderer/src/services/NutstoreService.ts b/src/renderer/src/services/NutstoreService.ts index c52e6b8030..6eb727abc1 100644 --- a/src/renderer/src/services/NutstoreService.ts +++ b/src/renderer/src/services/NutstoreService.ts @@ -48,7 +48,7 @@ export async function checkConnection() { return false } - const isSuccess = await window.api.backup.checkConnection({ + const isSuccess = await window.api.backup.checkWebdavConnection({ ...config, webdavPath: '/' }) diff --git a/src/renderer/src/services/TranslateService.ts b/src/renderer/src/services/TranslateService.ts index 513fa3ef36..1fb25d1a86 100644 --- a/src/renderer/src/services/TranslateService.ts +++ b/src/renderer/src/services/TranslateService.ts @@ -1,12 +1,13 @@ import i18n from '@renderer/i18n' import store from '@renderer/store' +import { Language } from '@renderer/types' import { fetchTranslate } from './ApiService' import { getDefaultTranslateAssistant } from './AssistantService' export const translateText = async ( text: string, - targetLanguage: string, + targetLanguage: Language, onResponse?: (text: string, isComplete: boolean) => void ) => { const translateModel = store.getState().llm.translateModel diff --git a/src/renderer/src/store/backup.ts b/src/renderer/src/store/backup.ts index a8b7d342c5..0418e5ab96 100644 --- a/src/renderer/src/store/backup.ts +++ b/src/renderer/src/store/backup.ts @@ -1,13 +1,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -export interface WebDAVSyncState { +export interface RemoteSyncState { lastSyncTime: number | null syncing: boolean lastSyncError: string | null } export interface BackupState { - webdavSync: WebDAVSyncState + webdavSync: RemoteSyncState + s3Sync: RemoteSyncState } const initialState: BackupState = { @@ -15,6 +16,11 @@ const initialState: BackupState = { lastSyncTime: null, syncing: false, lastSyncError: null + }, + s3Sync: { + lastSyncTime: null, + syncing: false, + lastSyncError: null } } @@ -22,11 +28,14 @@ const backupSlice = createSlice({ name: 'backup', initialState, reducers: { - setWebDAVSyncState: (state, action: PayloadAction>) => { + setWebDAVSyncState: (state, action: PayloadAction>) => { state.webdavSync = { ...state.webdavSync, ...action.payload } + }, + setS3SyncState: (state, action: PayloadAction>) => { + state.s3Sync = { ...state.s3Sync, ...action.payload } } } }) -export const { setWebDAVSyncState } = backupSlice.actions +export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions export default backupSlice.reducer diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f78a5cadd2..2b05576d99 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -56,7 +56,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 119, + version: 120, blacklist: ['runtime', 'messages', 'messageBlocks'], migrate }, diff --git a/src/renderer/src/store/mcp.ts b/src/renderer/src/store/mcp.ts index 05caf291c4..f267c546e9 100644 --- a/src/renderer/src/store/mcp.ts +++ b/src/renderer/src/store/mcp.ts @@ -124,6 +124,7 @@ export const builtinMCPServers: MCPServer[] = [ name: '@cherry/filesystem', type: 'inMemory', description: '实现文件系统操作的模型上下文协议(MCP)的 Node.js 服务器', + args: ['/Users/username/Desktop', '/path/to/other/allowed/dir'], isActive: false, provider: 'CherryAI' }, diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 1e29aebd69..53c28a84ba 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -5,7 +5,7 @@ import { SYSTEM_MODELS } from '@renderer/config/models' import { TRANSLATE_PROMPT } from '@renderer/config/prompts' import db from '@renderer/databases' import i18n from '@renderer/i18n' -import { Assistant, Provider, WebSearchProvider } from '@renderer/types' +import { Assistant, LanguageCode, Provider, WebSearchProvider } from '@renderer/types' import { getDefaultGroupName, getLeadingEmoji, runAsyncFunction, uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' import { isEmpty } from 'lodash' @@ -897,6 +897,7 @@ const migrateConfig = { }, '65': (state: RootState) => { try { + // @ts-ignore expect error state.settings.targetLanguage = 'english' return state } catch (error) { @@ -1726,6 +1727,35 @@ const migrateConfig = { } catch (error) { return state } + }, + '120': (state: RootState) => { + try { + if (!state.settings.s3) { + state.settings.s3 = settingsInitialState.s3 + } + return state + } catch (error) { + return state + } + }, + '120': (state: RootState) => { + try { + const langMap: Record = { + english: 'en-us', + chinese: 'zh-cn', + 'chinese-traditional': 'zh-tw', + japanese: 'ja-jp', + russian: 'ru-ru' + } + + const origin = state.settings.targetLanguage + const newLang = langMap[origin] + if (newLang) state.settings.targetLanguage = newLang + else state.settings.targetLanguage = 'en-us' + return state + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/nutstore.ts b/src/renderer/src/store/nutstore.ts index 354a93bd39..cd4721b6df 100644 --- a/src/renderer/src/store/nutstore.ts +++ b/src/renderer/src/store/nutstore.ts @@ -1,8 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' -export interface NutstoreSyncState extends WebDAVSyncState {} +export interface NutstoreSyncState extends RemoteSyncState {} export interface NutstoreState { nutstoreToken: string | null @@ -42,7 +42,7 @@ const nutstoreSlice = createSlice({ setNutstoreSyncInterval: (state, action: PayloadAction) => { state.nutstoreSyncInterval = action.payload }, - setNutstoreSyncState: (state, action: PayloadAction>) => { + setNutstoreSyncState: (state, action: PayloadAction>) => { state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload } }, setNutstoreSkipBackupFile: (state, action: PayloadAction) => { diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 778837e388..71b99b9463 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -8,13 +8,14 @@ import { OpenAIServiceTier, OpenAISummaryText, PaintingProvider, + S3Config, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { uuid } from '@renderer/utils' import { UpgradeChannel } from '@shared/config/constant' -import { WebDAVSyncState } from './backup' +import { RemoteSyncState } from './backup' export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' | 'Alt+Enter' @@ -30,7 +31,7 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'files' ] -export interface NutstoreSyncRuntime extends WebDAVSyncState {} +export interface NutstoreSyncRuntime extends RemoteSyncState {} export type AssistantIconType = 'model' | 'emoji' | 'none' @@ -189,6 +190,7 @@ export interface SettingsState { knowledge: boolean } defaultPaintingProvider: PaintingProvider + s3: S3Config } export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid' @@ -199,7 +201,7 @@ export const initialState: SettingsState = { assistantsTabSortType: 'list', sendMessageShortcut: 'Enter', language: navigator.language as LanguageVarious, - targetLanguage: 'english' as TranslateLanguageVarious, + targetLanguage: 'en-us', proxyMode: 'system', proxyUrl: undefined, userName: '', @@ -336,7 +338,19 @@ export const initialState: SettingsState = { backup: false, knowledge: false }, - defaultPaintingProvider: 'aihubmix' + defaultPaintingProvider: 'aihubmix', + s3: { + endpoint: '', + region: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + root: '', + autoSync: false, + syncInterval: 0, + maxBackups: 0, + skipBackupFile: false + } } const settingsSlice = createSlice({ @@ -703,6 +717,12 @@ const settingsSlice = createSlice({ }, setDefaultPaintingProvider: (state, action: PayloadAction) => { state.defaultPaintingProvider = action.payload + }, + setS3: (state, action: PayloadAction) => { + state.s3 = action.payload + }, + setS3Partial: (state, action: PayloadAction>) => { + state.s3 = { ...state.s3, ...action.payload } } } }) @@ -812,7 +832,9 @@ export const { setOpenAISummaryText, setOpenAIServiceTier, setNotificationSettings, - setDefaultPaintingProvider + setDefaultPaintingProvider, + setS3, + setS3Partial } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 084cc130e2..b90e41b79b 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -340,16 +340,7 @@ export enum ThemeMode { export type LanguageVarious = 'zh-CN' | 'zh-TW' | 'el-GR' | 'en-US' | 'es-ES' | 'fr-FR' | 'ja-JP' | 'pt-PT' | 'ru-RU' -export type TranslateLanguageVarious = - | 'chinese' - | 'chinese-traditional' - | 'greek' - | 'english' - | 'spanish' - | 'french' - | 'japanese' - | 'portuguese' - | 'russian' +export type TranslateLanguageVarious = LanguageCode export type CodeStyleVarious = 'auto' | string @@ -489,12 +480,41 @@ export type GenerateImageResponse = { images: string[] } +export type LanguageCode = + | 'en-us' + | 'zh-cn' + | 'zh-tw' + | 'ja-jp' + | 'ko-kr' + | 'fr-fr' + | 'de-de' + | 'it-it' + | 'es-es' + | 'pt-pt' + | 'ru-ru' + | 'pl-pl' + | 'ar-ar' + | 'tr-tr' + | 'th-th' + | 'vi-vn' + | 'id-id' + | 'ur-pk' + | 'ms-my' + +// langCode应当能够唯一确认一种语言 +export type Language = { + value: string + langCode: LanguageCode + label: () => string + emoji: string +} + export interface TranslateHistory { id: string sourceText: string targetText: string - sourceLanguage: string - targetLanguage: string + sourceLanguage: LanguageCode + targetLanguage: LanguageCode createdAt: string } @@ -749,4 +769,19 @@ export interface StoreSyncAction { export type OpenAISummaryText = 'auto' | 'concise' | 'detailed' | 'off' export type OpenAIServiceTier = 'auto' | 'default' | 'flex' + +export type S3Config = { + endpoint: string + region: string + bucket: string + accessKeyId: string + secretAccessKey: string + root?: string + fileName?: string + skipBackupFile: boolean + autoSync: boolean + syncInterval: number + maxBackups: number +} + export type { Message } from './newMessage' diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index ee52739b7c..25ba33aabf 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -68,6 +68,9 @@ export const captureScrollableDiv = async (divRef: React.RefObject { +export const detectLanguageByUnicode = (text: string): Language => { const counts = { zh: 0, ja: 0, @@ -40,8 +42,8 @@ export const detectLanguageByUnicode = (text: string): string => { } } - if (totalChars === 0) return 'en' - let maxLang = 'en' + if (totalChars === 0) return LanguagesEnum.enUS + let maxLang = '' let maxCount = 0 for (const [lang, count] of Object.entries(counts)) { @@ -52,73 +54,68 @@ export const detectLanguageByUnicode = (text: string): string => { } if (maxCount / totalChars < 0.3) { - return 'en' + return LanguagesEnum.enUS + } + + switch (maxLang) { + case 'zh': + return LanguagesEnum.zhCN + case 'ja': + return LanguagesEnum.jaJP + case 'ko': + return LanguagesEnum.koKR + case 'ru': + return LanguagesEnum.ruRU + case 'ar': + return LanguagesEnum.arAR + case 'en': + return LanguagesEnum.enUS + default: + console.error(`Unknown language: ${maxLang}`) + return LanguagesEnum.enUS } - return maxLang } /** * 检测输入文本的语言 - * @param {string} inputText 需要检测语言的文本 - * @returns {Promise} 检测到的语言代码 + * @param inputText 需要检测语言的文本 + * @returns 检测到的语言 */ -export const detectLanguage = async (inputText: string): Promise => { +export const detectLanguage = async (inputText: string): Promise => { const text = inputText.trim() - if (!text) return 'any' - let code: string + if (!text) return LanguagesEnum.zhCN + let lang: Language // 如果文本长度小于20个字符,使用Unicode范围检测 if (text.length < 20) { - code = detectLanguageByUnicode(text) + lang = detectLanguageByUnicode(text) } else { // franc 返回 ISO 639-3 代码 const iso3 = franc(text) - const isoMap: Record = { - cmn: 'zh', - jpn: 'ja', - kor: 'ko', - rus: 'ru', - ara: 'ar', - spa: 'es', - fra: 'fr', - deu: 'de', - ita: 'it', - por: 'pt', - eng: 'en', - pol: 'pl', - tur: 'tr', - tha: 'th', - vie: 'vi', - ind: 'id', - urd: 'ur', - zsm: 'ms' + const isoMap: Record = { + cmn: LanguagesEnum.zhCN, + jpn: LanguagesEnum.jaJP, + kor: LanguagesEnum.koKR, + rus: LanguagesEnum.ruRU, + ara: LanguagesEnum.arAR, + spa: LanguagesEnum.esES, + fra: LanguagesEnum.frFR, + deu: LanguagesEnum.deDE, + ita: LanguagesEnum.itIT, + por: LanguagesEnum.ptPT, + eng: LanguagesEnum.enUS, + pol: LanguagesEnum.plPL, + tur: LanguagesEnum.trTR, + tha: LanguagesEnum.thTH, + vie: LanguagesEnum.viVN, + ind: LanguagesEnum.idID, + urd: LanguagesEnum.urPK, + zsm: LanguagesEnum.msMY } - code = isoMap[iso3] || 'en' + lang = isoMap[iso3] || LanguagesEnum.enUS } - // 映射到应用使用的语言键 - const languageMap: Record = { - zh: 'chinese', - ja: 'japanese', - ko: 'korean', - ru: 'russian', - es: 'spanish', - fr: 'french', - de: 'german', - it: 'italian', - pt: 'portuguese', - ar: 'arabic', - en: 'english', - pl: 'polish', - tr: 'turkish', - th: 'thai', - vi: 'vietnamese', - id: 'indonesian', - ur: 'urdu', - ms: 'malay' - } - - return languageMap[code] || 'english' + return lang } /** @@ -127,10 +124,13 @@ export const detectLanguage = async (inputText: string): Promise => { * @param languagePair 配置的语言对 * @returns 目标语言 */ -export const getTargetLanguageForBidirectional = (sourceLanguage: string, languagePair: [string, string]): string => { - if (sourceLanguage === languagePair[0]) { +export const getTargetLanguageForBidirectional = ( + sourceLanguage: Language, + languagePair: [Language, Language] +): Language => { + if (sourceLanguage.langCode === languagePair[0].langCode) { return languagePair[1] - } else if (sourceLanguage === languagePair[1]) { + } else if (sourceLanguage.langCode === languagePair[1].langCode) { return languagePair[0] } return languagePair[0] !== sourceLanguage ? languagePair[0] : languagePair[1] @@ -142,8 +142,8 @@ export const getTargetLanguageForBidirectional = (sourceLanguage: string, langua * @param languagePair 配置的语言对 * @returns 是否在语言对中 */ -export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, string]): boolean => { - return [languagePair[0], languagePair[1]].includes(sourceLanguage) +export const isLanguageInPair = (sourceLanguage: Language, languagePair: [Language, Language]): boolean => { + return [languagePair[0].langCode, languagePair[1].langCode].includes(sourceLanguage.langCode) } /** @@ -155,11 +155,11 @@ export const isLanguageInPair = (sourceLanguage: string, languagePair: [string, * @returns 处理结果对象 */ export const determineTargetLanguage = ( - sourceLanguage: string, - targetLanguage: string, + sourceLanguage: Language, + targetLanguage: Language, isBidirectional: boolean, - bidirectionalPair: [string, string] -): { success: boolean; language?: string; errorType?: 'same_language' | 'not_in_pair' } => { + bidirectionalPair: [Language, Language] +): { success: boolean; language?: Language; errorType?: 'same_language' | 'not_in_pair' } => { if (isBidirectional) { if (!isLanguageInPair(sourceLanguage, bidirectionalPair)) { return { success: false, errorType: 'not_in_pair' } @@ -169,7 +169,7 @@ export const determineTargetLanguage = ( language: getTargetLanguageForBidirectional(sourceLanguage, bidirectionalPair) } } else { - if (sourceLanguage === targetLanguage) { + if (sourceLanguage.langCode === targetLanguage.langCode) { return { success: false, errorType: 'same_language' } } return { success: true, language: targetLanguage } @@ -228,3 +228,21 @@ export const createOutputScrollHandler = ( handleScrollSync(e.currentTarget, inputEl, isProgrammaticScrollRef) } } + +/** + * 根据语言代码获取对应的语言对象 + * @param langcode - 语言代码 + * @returns 返回对应的语言对象,如果找不到则返回英语(enUS) + * @example + * ```typescript + * const language = getLanguageByLangcode('zh-cn') // 返回中文语言对象 + * ``` + */ +export const getLanguageByLangcode = (langcode: LanguageCode): Language => { + const result = Object.values(LanguagesEnum).find((item) => item.langCode === langcode) + if (!result) { + console.error(`Language not found for langcode: ${langcode}`) + return LanguagesEnum.enUS + } + return result +} diff --git a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx index 26e83fcf17..9cfde74bd3 100644 --- a/src/renderer/src/windows/mini/translate/TranslateWindow.tsx +++ b/src/renderer/src/windows/mini/translate/TranslateWindow.tsx @@ -1,13 +1,14 @@ import { SwapOutlined } from '@ant-design/icons' import Scrollbar from '@renderer/components/Scrollbar' -import { TranslateLanguageOptions } from '@renderer/config/translate' +import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate' import db from '@renderer/databases' import { useDefaultModel } from '@renderer/hooks/useAssistant' import { fetchTranslate } from '@renderer/services/ApiService' import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService' -import { Assistant } from '@renderer/types' +import { Assistant, Language } from '@renderer/types' import { runAsyncFunction } from '@renderer/utils' -import { Select, Space } from 'antd' +import { getLanguageByLangcode } from '@renderer/utils/translate' +import { Select } from 'antd' import { isEmpty } from 'lodash' import { FC, useCallback, useEffect, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -18,11 +19,11 @@ interface Props { text: string } -let _targetLanguage = 'chinese' +let _targetLanguage = (await db.settings.get({ id: 'translate:target:language' }))?.value || LanguagesEnum.zhCN const Translate: FC = ({ text }) => { const [result, setResult] = useState('') - const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) + const [targetLanguage, setTargetLanguage] = useState(_targetLanguage) const { translateModel } = useDefaultModel() const { t } = useTranslation() const translatingRef = useRef(false) @@ -37,8 +38,7 @@ const Translate: FC = ({ text }) => { try { translatingRef.current = true - const targetLang = await db.settings.get({ id: 'translate:target:language' }) - const assistant: Assistant = getDefaultTranslateAssistant(targetLang?.value || targetLanguage, text) + const assistant: Assistant = getDefaultTranslateAssistant(targetLanguage, text) // const message: Message = { // id: uuid(), // role: 'user', @@ -64,7 +64,7 @@ const Translate: FC = ({ text }) => { useEffect(() => { runAsyncFunction(async () => { const targetLang = await db.settings.get({ id: 'translate:target:language' }) - targetLang && setTargetLanguage(targetLang.value) + targetLang && setTargetLanguage(getLanguageByLangcode(targetLang.value)) }) }, []) @@ -91,22 +91,17 @@ const Translate: FC = ({ text }) => { ({ - value: lang.value, + options={translateLanguageOptions.map((lang) => ({ + value: lang.langCode, label: ( {lang.emoji} - {lang.label} + {lang.label()} ) }))} - onChange={(value) => handleChangeLanguage(value, alterLanguage)} + onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)} disabled={isLoading} />