mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-01 09:49:03 +08:00
Merge branch 'main' into feat/async-translate
This commit is contained in:
commit
f1d8efd0e3
27
.github/workflows/dispatch-docs-update.yml
vendored
Normal file
27
.github/workflows/dispatch-docs-update.yml
vendored
Normal file
@ -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 }}"}'
|
||||
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
@ -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 }}
|
||||
@ -47,6 +47,7 @@
|
||||
<div align="center">
|
||||
|
||||
[![][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
|
||||
|
||||
<!-- Links & Images -->
|
||||
|
||||
[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
|
||||
|
||||
<!-- Links & Images -->
|
||||
|
||||
[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
|
||||
|
||||
<!-- Links & Images -->
|
||||
|
||||
11
docs/technical/db.settings.md
Normal file
11
docs/technical/db.settings.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 数据库设置字段
|
||||
|
||||
此文档包含部分字段的数据类型说明。
|
||||
|
||||
## 字段
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
| ------------------------------ | ------------------------------ | ------------ |
|
||||
| `translate:target:language` | `LanguageCode` | 翻译目标语言 |
|
||||
| `translate:source:language` | `LanguageCode` | 翻译源语言 |
|
||||
| `translate:bidirectional:pair` | `[LanguageCode, LanguageCode]` | 双向翻译对 |
|
||||
@ -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": {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<void> {
|
||||
@ -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<void> {
|
||||
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<number> => {
|
||||
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<void> => {
|
||||
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<void>((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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<string, string> | 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
183
src/main/services/S3Storage.ts
Normal file
183
src/main/services/S3Storage.ts
Normal file
@ -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<Buffer> {
|
||||
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<Buffer> {
|
||||
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<Array<{ key: string; lastModified?: string; size: number }>> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<string, () => Promise<any>> = {
|
||||
@ -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])
|
||||
}
|
||||
@ -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<boolean>()
|
||||
@ -25,6 +25,7 @@ interface Props {
|
||||
language: string
|
||||
onSave?: (newContent: string) => void
|
||||
onChange?: (newContent: string) => void
|
||||
onBlur?: (newContent: string) => void
|
||||
setTools?: (value: React.SetStateAction<CodeTool[]>) => 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 (
|
||||
<CodeMirror
|
||||
|
||||
@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { getLanguageByLangcode } from '@renderer/utils/translate'
|
||||
import { Modal, ModalProps } from 'antd'
|
||||
import TextArea from 'antd/es/input/TextArea'
|
||||
import { TextAreaProps } from 'antd/lib/input'
|
||||
@ -111,7 +112,7 @@ const PopupContainer: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const assistant = getDefaultTranslateAssistant(targetLanguage, textValue)
|
||||
const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), textValue)
|
||||
const translatedText = await fetchTranslate({ content: textValue, assistant })
|
||||
if (isMounted.current) {
|
||||
setTextValue(translatedText)
|
||||
|
||||
295
src/renderer/src/components/S3BackupManager.tsx
Normal file
295
src/renderer/src/components/S3BackupManager.tsx
Normal file
@ -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<S3Config>
|
||||
restoreMethod?: (fileName: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function S3BackupManager({ visible, onClose, s3Config, restoreMethod }: S3BackupManagerProps) {
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
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: <ExclamationCircleOutlined />,
|
||||
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: <ExclamationCircleOutlined />,
|
||||
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: <ExclamationCircleOutlined />,
|
||||
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) => (
|
||||
<Tooltip placement="topLeft" title={fileName}>
|
||||
{fileName}
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<>
|
||||
<Button type="link" onClick={() => handleRestore(record.fileName)} disabled={restoring || deleting}>
|
||||
{t('settings.data.s3.manager.restore')}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => handleDeleteSingle(record.fileName)}
|
||||
disabled={deleting || restoring}>
|
||||
{t('settings.data.s3.manager.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (selectedRowKeys: React.Key[]) => {
|
||||
setSelectedRowKeys(selectedRowKeys)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.manager.title')}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
centered
|
||||
transitionName="animation-move-down"
|
||||
footer={[
|
||||
<Button key="refresh" icon={<ReloadOutlined />} onClick={fetchBackupFiles} disabled={loading}>
|
||||
{t('settings.data.s3.manager.refresh')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedRowKeys.length === 0 || deleting}
|
||||
loading={deleting}>
|
||||
{t('settings.data.s3.manager.delete.selected', { count: selectedRowKeys.length })}
|
||||
</Button>,
|
||||
<Button key="close" onClick={onClose}>
|
||||
{t('settings.data.s3.manager.close')}
|
||||
</Button>
|
||||
]}>
|
||||
<Table
|
||||
rowKey="fileName"
|
||||
columns={columns}
|
||||
dataSource={backupFiles}
|
||||
rowSelection={rowSelection}
|
||||
pagination={pagination}
|
||||
loading={loading}
|
||||
onChange={handleTableChange}
|
||||
size="middle"
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
265
src/renderer/src/components/S3Modals.tsx
Normal file
265
src/renderer/src/components/S3Modals.tsx
Normal file
@ -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<void>
|
||||
handleCancel: () => void
|
||||
backuping: boolean
|
||||
customFileName: string
|
||||
setCustomFileName: (value: string) => void
|
||||
}
|
||||
|
||||
export function S3BackupModal({
|
||||
isModalVisible,
|
||||
handleBackup,
|
||||
handleCancel,
|
||||
backuping,
|
||||
customFileName,
|
||||
setCustomFileName
|
||||
}: S3BackupModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.backup.modal.title')}
|
||||
open={isModalVisible}
|
||||
onOk={handleBackup}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: backuping }}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<Input
|
||||
value={customFileName}
|
||||
onChange={(e) => setCustomFileName(e.target.value)}
|
||||
placeholder={t('settings.data.s3.backup.modal.filename.placeholder')}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = useState(false)
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
|
||||
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<typeof useS3RestoreModal>
|
||||
|
||||
export function S3RestoreModal({
|
||||
isRestoreModalVisible,
|
||||
handleRestore,
|
||||
handleCancel,
|
||||
restoring,
|
||||
selectedFile,
|
||||
setSelectedFile,
|
||||
loadingFiles,
|
||||
backupFiles
|
||||
}: S3RestoreModalProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('settings.data.s3.restore.modal.title')}
|
||||
open={isRestoreModalVisible}
|
||||
onOk={handleRestore}
|
||||
onCancel={handleCancel}
|
||||
okButtonProps={{ loading: restoring }}
|
||||
width={600}
|
||||
transitionName="animation-move-down"
|
||||
centered>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('settings.data.s3.restore.modal.select.placeholder')}
|
||||
value={selectedFile}
|
||||
onChange={setSelectedFile}
|
||||
options={backupFiles.map(formatFileOption)}
|
||||
loading={loadingFiles}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
typeof option?.label === 'string' ? option.label.toLowerCase().includes(input.toLowerCase()) : false
|
||||
}
|
||||
/>
|
||||
{loadingFiles && (
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileOption(file: BackupFile) {
|
||||
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
|
||||
const size = formatFileSize(file.size)
|
||||
return {
|
||||
label: `${file.fileName} (${date}, ${size})`,
|
||||
value: file.fileName
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
import { getLanguageByLangcode } from '@renderer/utils/translate'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { Languages } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
@ -54,7 +55,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
|
||||
setIsTranslating(true)
|
||||
try {
|
||||
const assistant = getDefaultTranslateAssistant(targetLanguage, text)
|
||||
const assistant = getDefaultTranslateAssistant(getLanguageByLangcode(targetLanguage), text)
|
||||
const translatedText = await fetchTranslate({ content: text, assistant })
|
||||
onTranslated(translatedText)
|
||||
} catch (error) {
|
||||
@ -75,7 +76,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoa
|
||||
return (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={t('chat.input.translate', { target_language: t(`languages.${targetLanguage.toString()}`) })}
|
||||
title={t('chat.input.translate', { target_language: getLanguageByLangcode(targetLanguage).label() })}
|
||||
arrow>
|
||||
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
|
||||
{isTranslating ? <LoadingOutlined spin /> : <Languages size={18} />}
|
||||
|
||||
@ -2320,8 +2320,6 @@ export const TEXT_TO_IMAGES_MODELS_SUPPORT_IMAGE_ENHANCEMENT = [
|
||||
]
|
||||
|
||||
export const SUPPORTED_DISABLE_GENERATION_MODELS = [
|
||||
'gemini-2.0-flash-exp-image-generation',
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
'gemini-2.0-flash-exp',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
|
||||
@ -1,136 +1,159 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import { Language } from '@renderer/types'
|
||||
|
||||
export interface TranslateLanguageOption {
|
||||
value: string
|
||||
langCode?: string
|
||||
label: string
|
||||
emoji: string
|
||||
export const ENGLISH: Language = {
|
||||
value: 'English',
|
||||
langCode: 'en-us',
|
||||
label: () => i18n.t('languages.english'),
|
||||
emoji: '🇬🇧'
|
||||
}
|
||||
|
||||
export const TranslateLanguageOptions: TranslateLanguageOption[] = [
|
||||
{
|
||||
value: 'English',
|
||||
langCode: 'en-us',
|
||||
label: i18n.t('languages.english'),
|
||||
emoji: '🇬🇧'
|
||||
},
|
||||
{
|
||||
value: 'Chinese (Simplified)',
|
||||
langCode: 'zh-cn',
|
||||
label: i18n.t('languages.chinese'),
|
||||
emoji: '🇨🇳'
|
||||
},
|
||||
{
|
||||
value: 'Chinese (Traditional)',
|
||||
langCode: 'zh-tw',
|
||||
label: i18n.t('languages.chinese-traditional'),
|
||||
emoji: '🇭🇰'
|
||||
},
|
||||
{
|
||||
value: 'Japanese',
|
||||
langCode: 'ja-jp',
|
||||
label: i18n.t('languages.japanese'),
|
||||
emoji: '🇯🇵'
|
||||
},
|
||||
{
|
||||
value: 'Korean',
|
||||
langCode: 'ko-kr',
|
||||
label: i18n.t('languages.korean'),
|
||||
emoji: '🇰🇷'
|
||||
},
|
||||
|
||||
{
|
||||
value: 'French',
|
||||
langCode: 'fr-fr',
|
||||
label: i18n.t('languages.french'),
|
||||
emoji: '🇫🇷'
|
||||
},
|
||||
{
|
||||
value: 'German',
|
||||
langCode: 'de-de',
|
||||
label: i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
},
|
||||
{
|
||||
value: 'Italian',
|
||||
langCode: 'it-it',
|
||||
label: i18n.t('languages.italian'),
|
||||
emoji: '🇮🇹'
|
||||
},
|
||||
{
|
||||
value: 'Spanish',
|
||||
langCode: 'es-es',
|
||||
label: i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
},
|
||||
{
|
||||
value: 'Portuguese',
|
||||
langCode: 'pt-pt',
|
||||
label: i18n.t('languages.portuguese'),
|
||||
emoji: '🇵🇹'
|
||||
},
|
||||
{
|
||||
value: 'Russian',
|
||||
langCode: 'ru-ru',
|
||||
label: i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
},
|
||||
{
|
||||
value: 'Polish',
|
||||
langCode: 'pl-pl',
|
||||
label: i18n.t('languages.polish'),
|
||||
emoji: '🇵🇱'
|
||||
},
|
||||
{
|
||||
value: 'Arabic',
|
||||
langCode: 'ar-ar',
|
||||
label: i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
},
|
||||
{
|
||||
value: 'Turkish',
|
||||
langCode: 'tr-tr',
|
||||
label: i18n.t('languages.turkish'),
|
||||
emoji: '🇹🇷'
|
||||
},
|
||||
{
|
||||
value: 'Thai',
|
||||
langCode: 'th-th',
|
||||
label: i18n.t('languages.thai'),
|
||||
emoji: '🇹🇭'
|
||||
},
|
||||
{
|
||||
value: 'Vietnamese',
|
||||
langCode: 'vi-vn',
|
||||
label: i18n.t('languages.vietnamese'),
|
||||
emoji: '🇻🇳'
|
||||
},
|
||||
{
|
||||
value: 'Indonesian',
|
||||
langCode: 'id-id',
|
||||
label: i18n.t('languages.indonesian'),
|
||||
emoji: '🇮🇩'
|
||||
},
|
||||
{
|
||||
value: 'Urdu',
|
||||
langCode: 'ur-pk',
|
||||
label: i18n.t('languages.urdu'),
|
||||
emoji: '🇵🇰'
|
||||
},
|
||||
{
|
||||
value: 'Malay',
|
||||
langCode: 'ms-my',
|
||||
label: i18n.t('languages.malay'),
|
||||
emoji: '🇲🇾'
|
||||
}
|
||||
]
|
||||
|
||||
export const translateLanguageOptions = (): typeof TranslateLanguageOptions => {
|
||||
return TranslateLanguageOptions.map((option) => {
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
emoji: option.emoji
|
||||
}
|
||||
})
|
||||
export const CHINESE_SIMPLIFIED: Language = {
|
||||
value: 'Chinese (Simplified)',
|
||||
langCode: 'zh-cn',
|
||||
label: () => i18n.t('languages.chinese'),
|
||||
emoji: '🇨🇳'
|
||||
}
|
||||
|
||||
export const CHINESE_TRADITIONAL: Language = {
|
||||
value: 'Chinese (Traditional)',
|
||||
langCode: 'zh-tw',
|
||||
label: () => i18n.t('languages.chinese-traditional'),
|
||||
emoji: '🇭🇰'
|
||||
}
|
||||
|
||||
export const JAPANESE: Language = {
|
||||
value: 'Japanese',
|
||||
langCode: 'ja-jp',
|
||||
label: () => i18n.t('languages.japanese'),
|
||||
emoji: '🇯🇵'
|
||||
}
|
||||
|
||||
export const KOREAN: Language = {
|
||||
value: 'Korean',
|
||||
langCode: 'ko-kr',
|
||||
label: () => i18n.t('languages.korean'),
|
||||
emoji: '🇰🇷'
|
||||
}
|
||||
|
||||
export const FRENCH: Language = {
|
||||
value: 'French',
|
||||
langCode: 'fr-fr',
|
||||
label: () => i18n.t('languages.french'),
|
||||
emoji: '🇫🇷'
|
||||
}
|
||||
|
||||
export const GERMAN: Language = {
|
||||
value: 'German',
|
||||
langCode: 'de-de',
|
||||
label: () => i18n.t('languages.german'),
|
||||
emoji: '🇩🇪'
|
||||
}
|
||||
|
||||
export const ITALIAN: Language = {
|
||||
value: 'Italian',
|
||||
langCode: 'it-it',
|
||||
label: () => i18n.t('languages.italian'),
|
||||
emoji: '🇮🇹'
|
||||
}
|
||||
|
||||
export const SPANISH: Language = {
|
||||
value: 'Spanish',
|
||||
langCode: 'es-es',
|
||||
label: () => i18n.t('languages.spanish'),
|
||||
emoji: '🇪🇸'
|
||||
}
|
||||
|
||||
export const PORTUGUESE: Language = {
|
||||
value: 'Portuguese',
|
||||
langCode: 'pt-pt',
|
||||
label: () => i18n.t('languages.portuguese'),
|
||||
emoji: '🇵🇹'
|
||||
}
|
||||
|
||||
export const RUSSIAN: Language = {
|
||||
value: 'Russian',
|
||||
langCode: 'ru-ru',
|
||||
label: () => i18n.t('languages.russian'),
|
||||
emoji: '🇷🇺'
|
||||
}
|
||||
|
||||
export const POLISH: Language = {
|
||||
value: 'Polish',
|
||||
langCode: 'pl-pl',
|
||||
label: () => i18n.t('languages.polish'),
|
||||
emoji: '🇵🇱'
|
||||
}
|
||||
|
||||
export const ARABIC: Language = {
|
||||
value: 'Arabic',
|
||||
langCode: 'ar-ar',
|
||||
label: () => i18n.t('languages.arabic'),
|
||||
emoji: '🇸🇦'
|
||||
}
|
||||
|
||||
export const TURKISH: Language = {
|
||||
value: 'Turkish',
|
||||
langCode: 'tr-tr',
|
||||
label: () => i18n.t('languages.turkish'),
|
||||
emoji: '🇹🇷'
|
||||
}
|
||||
|
||||
export const THAI: Language = {
|
||||
value: 'Thai',
|
||||
langCode: 'th-th',
|
||||
label: () => i18n.t('languages.thai'),
|
||||
emoji: '🇹🇭'
|
||||
}
|
||||
|
||||
export const VIETNAMESE: Language = {
|
||||
value: 'Vietnamese',
|
||||
langCode: 'vi-vn',
|
||||
label: () => i18n.t('languages.vietnamese'),
|
||||
emoji: '🇻🇳'
|
||||
}
|
||||
|
||||
export const INDONESIAN: Language = {
|
||||
value: 'Indonesian',
|
||||
langCode: 'id-id',
|
||||
label: () => i18n.t('languages.indonesian'),
|
||||
emoji: '🇮🇩'
|
||||
}
|
||||
|
||||
export const URDU: Language = {
|
||||
value: 'Urdu',
|
||||
langCode: 'ur-pk',
|
||||
label: () => i18n.t('languages.urdu'),
|
||||
emoji: '🇵🇰'
|
||||
}
|
||||
|
||||
export const MALAY: Language = {
|
||||
value: 'Malay',
|
||||
langCode: 'ms-my',
|
||||
label: () => i18n.t('languages.malay'),
|
||||
emoji: '🇲🇾'
|
||||
}
|
||||
|
||||
export const LanguagesEnum = {
|
||||
enUS: ENGLISH,
|
||||
zhCN: CHINESE_SIMPLIFIED,
|
||||
zhTW: CHINESE_TRADITIONAL,
|
||||
jaJP: JAPANESE,
|
||||
koKR: KOREAN,
|
||||
frFR: FRENCH,
|
||||
deDE: GERMAN,
|
||||
itIT: ITALIAN,
|
||||
esES: SPANISH,
|
||||
ptPT: PORTUGUESE,
|
||||
ruRU: RUSSIAN,
|
||||
plPL: POLISH,
|
||||
arAR: ARABIC,
|
||||
trTR: TURKISH,
|
||||
thTH: THAI,
|
||||
viVN: VIETNAMESE,
|
||||
idID: INDONESIAN,
|
||||
urPK: URDU,
|
||||
msMY: MALAY
|
||||
} as const
|
||||
|
||||
export const translateLanguageOptions: Language[] = Object.values(LanguagesEnum)
|
||||
|
||||
@ -3,7 +3,7 @@ import { FileMetadata, KnowledgeItem, QuickPhrase, TranslateHistory } from '@ren
|
||||
import type { Message as NewMessage, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { Dexie, type EntityTable } from 'dexie'
|
||||
|
||||
import { upgradeToV5, upgradeToV7 } from './upgrades'
|
||||
import { upgradeToV5, upgradeToV7, upgradeToV8 } from './upgrades'
|
||||
|
||||
// Database declaration (move this to its own module also)
|
||||
export const db = new Dexie('CherryStudio') as Dexie & {
|
||||
@ -74,4 +74,17 @@ db.version(7)
|
||||
})
|
||||
.upgrade((tx) => upgradeToV7(tx))
|
||||
|
||||
db.version(8)
|
||||
.stores({
|
||||
// Re-declare all tables for the new version
|
||||
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
|
||||
topics: '&id', // Correct index for topics
|
||||
settings: '&id, value',
|
||||
knowledge_notes: '&id, baseId, type, content, created_at, updated_at',
|
||||
translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt',
|
||||
quick_phrases: 'id',
|
||||
message_blocks: 'id, messageId, file.id' // Correct syntax with comma separator
|
||||
})
|
||||
.upgrade((tx) => upgradeToV8(tx))
|
||||
|
||||
export default db
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import type { LegacyMessage as OldMessage, Topic } from '@renderer/types'
|
||||
import { FileTypes } from '@renderer/types' // Import FileTypes enum
|
||||
import { WebSearchSource } from '@renderer/types'
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import type { LanguageCode, LegacyMessage as OldMessage, Topic } from '@renderer/types'
|
||||
import { FileTypes, WebSearchSource } from '@renderer/types' // Import FileTypes enum
|
||||
import type {
|
||||
BaseMessageBlock,
|
||||
CitationMessageBlock,
|
||||
@ -308,3 +308,78 @@ export async function upgradeToV7(tx: Transaction): Promise<void> {
|
||||
|
||||
Logger.log('DB migration to version 7 finished successfully.')
|
||||
}
|
||||
|
||||
export async function upgradeToV8(tx: Transaction): Promise<void> {
|
||||
Logger.log('DB migration to version 8 started')
|
||||
|
||||
const langMap: Record<string, LanguageCode> = {
|
||||
english: 'en-us',
|
||||
chinese: 'zh-cn',
|
||||
'chinese-traditional': 'zh-tw',
|
||||
japanese: 'ja-jp',
|
||||
korean: 'ko-kr',
|
||||
french: 'fr-fr',
|
||||
german: 'de-de',
|
||||
italian: 'it-it',
|
||||
spanish: 'es-es',
|
||||
portuguese: 'pt-pt',
|
||||
russian: 'ru-ru',
|
||||
polish: 'pl-pl',
|
||||
arabic: 'ar-ar',
|
||||
turkish: 'tr-tr',
|
||||
thai: 'th-th',
|
||||
vietnamese: 'vi-vn',
|
||||
indonesian: 'id-id',
|
||||
urdu: 'ur-pk',
|
||||
malay: 'ms-my'
|
||||
}
|
||||
|
||||
const settingsTable = tx.table('settings')
|
||||
const defaultPair: [LanguageCode, LanguageCode] = [LanguagesEnum.enUS.langCode, LanguagesEnum.zhCN.langCode]
|
||||
const originSource = (await settingsTable.get('translate:source:language'))?.value
|
||||
const originTarget = (await settingsTable.get('translate:target:language'))?.value
|
||||
const originPair = (await settingsTable.get('translate:bidirectional:pair'))?.value
|
||||
let newSource, newTarget, newPair
|
||||
Logger.log('originSource: %o', originSource)
|
||||
if (originSource === 'auto') {
|
||||
newSource = 'auto'
|
||||
} else {
|
||||
newSource = langMap[originSource]
|
||||
if (!newSource) {
|
||||
newSource = LanguagesEnum.enUS.langCode
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log('originTarget: %o', originTarget)
|
||||
newTarget = langMap[originTarget]
|
||||
if (!newTarget) {
|
||||
newTarget = LanguagesEnum.zhCN.langCode
|
||||
}
|
||||
|
||||
Logger.log('originPair: %o', originPair)
|
||||
newPair = [langMap[originPair[0]], langMap[originPair[1]]]
|
||||
if (!newPair[0] || !newPair[1]) {
|
||||
newPair = defaultPair
|
||||
}
|
||||
|
||||
Logger.log('DB migration to version 8: %o', { newSource, newTarget, newPair })
|
||||
|
||||
await settingsTable.put({ id: 'translate:bidirectional:pair', value: newPair })
|
||||
await settingsTable.put({ id: 'translate:source:language', value: newSource })
|
||||
await settingsTable.put({ id: 'translate:target:language', value: newTarget })
|
||||
|
||||
const histories = tx.table('translate_history')
|
||||
|
||||
for (const history of await histories.toArray()) {
|
||||
try {
|
||||
await tx.table('translate_history').put({
|
||||
...history,
|
||||
sourceLanguage: langMap[history.sourceLanguage],
|
||||
targetLanguage: langMap[history.targetLanguage]
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error upgrading history:', error)
|
||||
}
|
||||
}
|
||||
Logger.log('DB migration to version 8 finished.')
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
updateMessageAndBlocksThunk,
|
||||
updateTranslationBlockThunk
|
||||
} from '@renderer/store/thunk/messageThunk'
|
||||
import type { Assistant, Model, Topic } from '@renderer/types'
|
||||
import type { Assistant, LanguageCode, Model, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
@ -195,9 +195,9 @@ export function useMessageOperations(topic: Topic) {
|
||||
const getTranslationUpdater = useCallback(
|
||||
async (
|
||||
messageId: string,
|
||||
targetLanguage: string,
|
||||
targetLanguage: LanguageCode,
|
||||
sourceBlockId?: string,
|
||||
sourceLanguage?: string
|
||||
sourceLanguage?: LanguageCode
|
||||
): Promise<((accumulatedText: string, isComplete?: boolean) => void) | null> => {
|
||||
if (!topic.id) return null
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
setTranslatedContent as _setTranslatedContent,
|
||||
setTranslating as _setTranslating
|
||||
} from '@renderer/store/translate'
|
||||
import { Assistant, TranslateHistory } from '@renderer/types'
|
||||
import { Assistant, LanguageCode, TranslateHistory } from '@renderer/types'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { t } from 'i18next'
|
||||
|
||||
@ -26,8 +26,8 @@ export default function useTranslate() {
|
||||
const translate = async (
|
||||
text: string,
|
||||
assistant: Assistant,
|
||||
actualSourceLanguage: string,
|
||||
actualTargetLanguage: string
|
||||
actualSourceLanguage: LanguageCode,
|
||||
actualTargetLanguage: LanguageCode
|
||||
) => {
|
||||
setTranslating(true)
|
||||
await fetchTranslate({
|
||||
@ -53,8 +53,8 @@ export default function useTranslate() {
|
||||
const saveTranslateHistory = async (
|
||||
sourceText: string,
|
||||
targetText: string,
|
||||
sourceLanguage: string,
|
||||
targetLanguage: string
|
||||
sourceLanguage: LanguageCode,
|
||||
targetLanguage: LanguageCode
|
||||
) => {
|
||||
const history: TranslateHistory = {
|
||||
id: uuid(),
|
||||
|
||||
@ -1268,6 +1268,71 @@
|
||||
"maxBackups": "Maximum Backups",
|
||||
"maxBackups.unlimited": "Unlimited"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 Compatible Storage",
|
||||
"title.help": "S3 compatible object storage services, such as AWS S3, Cloudflare R2, Aliyun OSS, Tencent COS, etc.",
|
||||
"title.tooltip": "S3 Compatible Storage Configuration Document",
|
||||
"endpoint": "API Endpoint",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "Region",
|
||||
"region.placeholder": "Region, e.g: us-east-1",
|
||||
"bucket": "Bucket",
|
||||
"bucket.placeholder": "Bucket, e.g: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "Backup Directory (Optional)",
|
||||
"root.placeholder": "e.g: /cherry-studio",
|
||||
"backup.operation": "Backup Operation",
|
||||
"backup.button": "Backup Now",
|
||||
"backup.manager.button": "Manage Backups",
|
||||
"backup.modal.title": "S3 Backup",
|
||||
"backup.modal.filename.placeholder": "Please enter backup filename",
|
||||
"backup.success": "S3 backup successful",
|
||||
"backup.error": "S3 backup failed: {{message}}",
|
||||
"autoSync": "Auto Sync",
|
||||
"autoSync.off": "Off",
|
||||
"autoSync.minute": "Every {{count}} minute",
|
||||
"autoSync.hour": "Every {{count}} hour",
|
||||
"maxBackups": "Maximum Backups",
|
||||
"maxBackups.unlimited": "Unlimited",
|
||||
"skipBackupFile": "Lightweight Backup",
|
||||
"skipBackupFile.help": "When enabled, file data will be skipped during backup, only configuration information will be backed up, significantly reducing backup file size",
|
||||
"syncStatus": "Sync Status",
|
||||
"syncStatus.noSync": "Not synced",
|
||||
"syncStatus.error": "Sync error: {{message}}",
|
||||
"syncStatus.lastSync": "Last sync: {{time}}",
|
||||
"manager.title": "S3 Backup File Manager",
|
||||
"manager.refresh": "Refresh",
|
||||
"manager.delete.selected": "Delete Selected ({{count}})",
|
||||
"manager.close": "Close",
|
||||
"manager.columns.fileName": "File Name",
|
||||
"manager.columns.modifiedTime": "Modified Time",
|
||||
"manager.columns.size": "File Size",
|
||||
"manager.columns.actions": "Actions",
|
||||
"manager.restore": "Restore",
|
||||
"manager.delete": "Delete",
|
||||
"manager.config.incomplete": "Please fill in complete S3 configuration",
|
||||
"manager.files.fetch.error": "Failed to fetch backup file list: {{message}}",
|
||||
"manager.delete.confirm.title": "Confirm Delete",
|
||||
"manager.delete.confirm.multiple": "Are you sure you want to delete {{count}} selected backup files? This action cannot be undone.",
|
||||
"manager.delete.confirm.single": "Are you sure you want to delete backup file \"{{fileName}}\"? This action cannot be undone.",
|
||||
"manager.delete.success.multiple": "Successfully deleted {{count}} backup files",
|
||||
"manager.delete.success.single": "Backup file deleted successfully",
|
||||
"manager.delete.error": "Failed to delete backup file: {{message}}",
|
||||
"manager.select.warning": "Please select backup files to delete",
|
||||
"restore.modal.title": "S3 Data Restore",
|
||||
"restore.modal.select.placeholder": "Please select backup file to restore",
|
||||
"restore.confirm.title": "Confirm Restore Data",
|
||||
"restore.confirm.content": "Restoring data will overwrite all current data. This action cannot be undone. Are you sure you want to continue?",
|
||||
"restore.confirm.ok": "Confirm Restore",
|
||||
"restore.confirm.cancel": "Cancel",
|
||||
"restore.success": "Data restore successful",
|
||||
"restore.error": "Data restore failed: {{message}}",
|
||||
"restore.config.incomplete": "Please fill in complete S3 configuration",
|
||||
"restore.file.required": "Please select backup file to restore"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "Check",
|
||||
|
||||
@ -1248,6 +1248,71 @@
|
||||
"maxBackups": "最大バックアップ数",
|
||||
"maxBackups.unlimited": "無制限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3互換ストレージ",
|
||||
"title.tooltip": "S3互換ストレージ設定ガイド",
|
||||
"title.help": "AWS S3 APIと互換性のあるオブジェクトストレージサービス(例:AWS S3、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COSなど)",
|
||||
"endpoint": "APIエンドポイント",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "リージョン",
|
||||
"region.placeholder": "Region、例: us-east-1",
|
||||
"bucket": "バケット",
|
||||
"bucket.placeholder": "Bucket、例: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "バックアップディレクトリ(オプション)",
|
||||
"root.placeholder": "例:/cherry-studio",
|
||||
"backup.operation": "バックアップ操作",
|
||||
"backup.button": "今すぐバックアップ",
|
||||
"backup.manager.button": "バックアップ管理",
|
||||
"backup.modal.title": "S3バックアップ",
|
||||
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
|
||||
"backup.success": "S3バックアップ成功",
|
||||
"backup.error": "S3バックアップ失敗: {{message}}",
|
||||
"autoSync": "自動同期",
|
||||
"autoSync.off": "オフ",
|
||||
"autoSync.minute": "{{count}}分毎",
|
||||
"autoSync.hour": "{{count}}時間毎",
|
||||
"maxBackups": "最大バックアップ数",
|
||||
"maxBackups.unlimited": "無制限",
|
||||
"skipBackupFile": "軽量バックアップ",
|
||||
"skipBackupFile.help": "有効にすると、バックアップ時にファイルデータがスキップされ、設定情報のみがバックアップされ、バックアップファイルのサイズが大幅に削減されます。",
|
||||
"syncStatus": "同期ステータス",
|
||||
"syncStatus.noSync": "未同期",
|
||||
"syncStatus.error": "同期エラー: {{message}}",
|
||||
"syncStatus.lastSync": "最終同期: {{time}}",
|
||||
"manager.title": "S3バックアップファイルマネージャー",
|
||||
"manager.refresh": "更新",
|
||||
"manager.delete.selected": "選択項目を削除 ({{count}})",
|
||||
"manager.close": "閉じる",
|
||||
"manager.columns.fileName": "ファイル名",
|
||||
"manager.columns.modifiedTime": "変更日時",
|
||||
"manager.columns.size": "ファイルサイズ",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "復元",
|
||||
"manager.delete": "削除",
|
||||
"manager.config.incomplete": "完全なS3設定情報を入力してください",
|
||||
"manager.files.fetch.error": "バックアップファイルリストの取得に失敗しました: {{message}}",
|
||||
"manager.delete.confirm.title": "削除の確認",
|
||||
"manager.delete.confirm.multiple": "選択した{{count}}個のバックアップファイルを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"manager.delete.confirm.single": "バックアップファイル「{{fileName}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"manager.delete.success.multiple": "{{count}}個のバックアップファイルを正常に削除しました",
|
||||
"manager.delete.success.single": "バックアップファイルの削除に成功しました",
|
||||
"manager.delete.error": "バックアップファイルの削除に失敗しました: {{message}}",
|
||||
"manager.select.warning": "削除するバックアップファイルを選択してください",
|
||||
"restore.modal.title": "S3データ復元",
|
||||
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
|
||||
"restore.confirm.title": "データ復元の確認",
|
||||
"restore.confirm.content": "データを復元すると、現在のすべてのデータが上書きされます。この操作は元に戻せません。続行してもよろしいですか?",
|
||||
"restore.confirm.ok": "復元を確認",
|
||||
"restore.confirm.cancel": "キャンセル",
|
||||
"restore.success": "データの復元に成功しました",
|
||||
"restore.error": "データの復元に失敗しました: {{message}}",
|
||||
"restore.config.incomplete": "完全なS3設定情報を入力してください",
|
||||
"restore.file.required": "復元するバックアップファイルを選択してください"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "接続確認",
|
||||
|
||||
@ -1266,6 +1266,71 @@
|
||||
"maxBackups": "Максимальное количество резервных копий",
|
||||
"maxBackups.unlimited": "Без ограничений"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3-совместимое хранилище",
|
||||
"title.tooltip": "Руководство по настройке S3-совместимого хранилища",
|
||||
"title.help": "Сервисы объектного хранения, совместимые с AWS S3 API, такие как AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS и т.д.",
|
||||
"endpoint": "Конечная точка API",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "Регион",
|
||||
"region.placeholder": "Регион, например: us-east-1",
|
||||
"bucket": "Корзина",
|
||||
"bucket.placeholder": "Корзина, например: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "Каталог резервных копий (необязательно)",
|
||||
"root.placeholder": "например: /cherry-studio",
|
||||
"backup.operation": "Операция резервного копирования",
|
||||
"backup.button": "Создать резервную копию сейчас",
|
||||
"backup.manager.button": "Управление резервными копиями",
|
||||
"backup.modal.title": "Резервное копирование S3",
|
||||
"backup.modal.filename.placeholder": "Пожалуйста, введите имя файла резервной копии",
|
||||
"backup.success": "Резервное копирование S3 успешно",
|
||||
"backup.error": "Ошибка резервного копирования S3: {{message}}",
|
||||
"autoSync": "Автосинхронизация",
|
||||
"autoSync.off": "Выкл.",
|
||||
"autoSync.minute": "Каждые {{count}} мин.",
|
||||
"autoSync.hour": "Каждые {{count}} ч.",
|
||||
"maxBackups": "Макс. резервных копий",
|
||||
"maxBackups.unlimited": "Неограниченно",
|
||||
"skipBackupFile": "Облегченное резервное копирование",
|
||||
"skipBackupFile.help": "Если включено, данные файлов будут пропущены во время резервного копирования, будет скопирована только информация о конфигурации, что значительно уменьшит размер файла резервной копии.",
|
||||
"syncStatus": "Статус синхронизации",
|
||||
"syncStatus.noSync": "Не синхронизировано",
|
||||
"syncStatus.error": "Ошибка синхронизации: {{message}}",
|
||||
"syncStatus.lastSync": "Последняя синхронизация: {{time}}",
|
||||
"manager.title": "Менеджер файлов резервных копий S3",
|
||||
"manager.refresh": "Обновить",
|
||||
"manager.delete.selected": "Удалить выбранные ({{count}})",
|
||||
"manager.close": "Закрыть",
|
||||
"manager.columns.fileName": "Имя файла",
|
||||
"manager.columns.modifiedTime": "Время изменения",
|
||||
"manager.columns.size": "Размер файла",
|
||||
"manager.columns.actions": "Действия",
|
||||
"manager.restore": "Восстановить",
|
||||
"manager.delete": "Удалить",
|
||||
"manager.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||
"manager.files.fetch.error": "Не удалось получить список файлов резервных копий: {{message}}",
|
||||
"manager.delete.confirm.title": "Подтвердить удаление",
|
||||
"manager.delete.confirm.multiple": "Вы уверены, что хотите удалить {{count}} выбранных файлов резервных копий? Это действие нельзя отменить.",
|
||||
"manager.delete.confirm.single": "Вы уверены, что хотите удалить файл резервной копии \"{{fileName}}\"? Это действие нельзя отменить.",
|
||||
"manager.delete.success.multiple": "Успешно удалено {{count}} файлов резервных копий",
|
||||
"manager.delete.success.single": "Файл резервной копии успешно удален",
|
||||
"manager.delete.error": "Не удалось удалить файл резервной копии: {{message}}",
|
||||
"manager.select.warning": "Пожалуйста, выберите файлы резервных копий для удаления",
|
||||
"restore.modal.title": "Восстановление данных S3",
|
||||
"restore.modal.select.placeholder": "Пожалуйста, выберите файл резервной копии для восстановления",
|
||||
"restore.confirm.title": "Подтвердить восстановление данных",
|
||||
"restore.confirm.content": "Восстановление данных перезапишет все текущие данные. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
|
||||
"restore.confirm.ok": "Подтвердить восстановление",
|
||||
"restore.confirm.cancel": "Отмена",
|
||||
"restore.success": "Восстановление данных успешно",
|
||||
"restore.error": "Ошибка восстановления данных: {{message}}",
|
||||
"restore.config.incomplete": "Пожалуйста, заполните полную конфигурацию S3",
|
||||
"restore.file.required": "Пожалуйста, выберите файл резервной копии для восстановления"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
"button": "Проверить",
|
||||
|
||||
@ -1268,7 +1268,72 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 用户名",
|
||||
"maxBackups": "最大备份数",
|
||||
"maxBackups.unlimited": "无限制"
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 兼容存储",
|
||||
"title.tooltip": "S3 兼容存储配置文档",
|
||||
"title.help": "与AWS S3 API兼容的对象存储服务, 例如AWS S3, Cloudflare R2, 阿里云OSS, 腾讯云COS等",
|
||||
"endpoint": "API 地址",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "区域",
|
||||
"region.placeholder": "Region, 例如: us-east-1",
|
||||
"bucket": "存储桶",
|
||||
"bucket.placeholder": "Bucket, 例如: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "备份目录(可选)",
|
||||
"root.placeholder": "例如:/cherry-studio",
|
||||
"backup.operation": "备份操作",
|
||||
"backup.button": "立即备份",
|
||||
"backup.manager.button": "管理备份",
|
||||
"backup.modal.title": "S3 备份",
|
||||
"backup.modal.filename.placeholder": "请输入备份文件名",
|
||||
"backup.success": "S3 备份成功",
|
||||
"backup.error": "S3 备份失败: {{message}}",
|
||||
"autoSync": "自动同步",
|
||||
"autoSync.off": "关闭",
|
||||
"autoSync.minute": "每 {{count}} 分钟",
|
||||
"autoSync.hour": "每 {{count}} 小时",
|
||||
"maxBackups": "最大备份数",
|
||||
"maxBackups.unlimited": "不限",
|
||||
"skipBackupFile": "精简备份",
|
||||
"skipBackupFile.help": "开启后备份时将跳过文件数据,仅备份配置信息,显著减小备份文件体积",
|
||||
"syncStatus": "同步状态",
|
||||
"syncStatus.noSync": "未同步",
|
||||
"syncStatus.error": "同步错误: {{message}}",
|
||||
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||
"manager.title": "S3 备份文件管理",
|
||||
"manager.refresh": "刷新",
|
||||
"manager.delete.selected": "删除选中 ({{count}})",
|
||||
"manager.close": "关闭",
|
||||
"manager.columns.fileName": "文件名",
|
||||
"manager.columns.modifiedTime": "修改时间",
|
||||
"manager.columns.size": "文件大小",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "恢复",
|
||||
"manager.delete": "删除",
|
||||
"manager.config.incomplete": "请填写完整的 S3 配置信息",
|
||||
"manager.files.fetch.error": "获取备份文件列表失败: {{message}}",
|
||||
"manager.delete.confirm.title": "确认删除",
|
||||
"manager.delete.confirm.multiple": "确定要删除选中的 {{count}} 个备份文件吗?此操作不可撤销。",
|
||||
"manager.delete.confirm.single": "确定要删除备份文件 \"{{fileName}}\" 吗?此操作不可撤销。",
|
||||
"manager.delete.success.multiple": "成功删除 {{count}} 个备份文件",
|
||||
"manager.delete.success.single": "删除备份文件成功",
|
||||
"manager.delete.error": "删除备份文件失败: {{message}}",
|
||||
"manager.select.warning": "请选择要删除的备份文件",
|
||||
"restore.modal.title": "S3 数据恢复",
|
||||
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
|
||||
"restore.confirm.title": "确认恢复数据",
|
||||
"restore.confirm.content": "恢复数据将覆盖当前所有数据,此操作不可撤销。确定要继续吗?",
|
||||
"restore.confirm.ok": "确认恢复",
|
||||
"restore.confirm.cancel": "取消",
|
||||
"restore.success": "数据恢复成功",
|
||||
"restore.error": "数据恢复失败: {{message}}",
|
||||
"restore.config.incomplete": "请填写完整的 S3 配置信息",
|
||||
"restore.file.required": "请选择要恢复的备份文件"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
|
||||
@ -1266,7 +1266,72 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 使用者名稱",
|
||||
"maxBackups": "最大備份數量",
|
||||
"maxBackups.unlimited": "無限制"
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 相容儲存",
|
||||
"title.tooltip": "S3 相容儲存設定指南",
|
||||
"title.help": "與AWS S3 API相容的物件儲存服務,例如AWS S3、Cloudflare R2、阿里雲OSS、騰訊雲COS等",
|
||||
"endpoint": "API 位址",
|
||||
"endpoint.placeholder": "https://s3.example.com",
|
||||
"region": "區域",
|
||||
"region.placeholder": "Region,例如: us-east-1",
|
||||
"bucket": "儲存桶",
|
||||
"bucket.placeholder": "Bucket,例如: example",
|
||||
"accessKeyId": "Access Key ID",
|
||||
"accessKeyId.placeholder": "Access Key ID",
|
||||
"secretAccessKey": "Secret Access Key",
|
||||
"secretAccessKey.placeholder": "Secret Access Key",
|
||||
"root": "備份目錄(可選)",
|
||||
"root.placeholder": "例如:/cherry-studio",
|
||||
"backup.operation": "備份操作",
|
||||
"backup.button": "立即備份",
|
||||
"backup.manager.button": "管理備份",
|
||||
"backup.modal.title": "S3 備份",
|
||||
"backup.modal.filename.placeholder": "請輸入備份檔案名稱",
|
||||
"backup.success": "S3 備份成功",
|
||||
"backup.error": "S3 備份失敗: {{message}}",
|
||||
"autoSync": "自動同步",
|
||||
"autoSync.off": "關閉",
|
||||
"autoSync.minute": "每 {{count}} 分鐘",
|
||||
"autoSync.hour": "每 {{count}} 小時",
|
||||
"maxBackups": "最大備份數",
|
||||
"maxBackups.unlimited": "不限",
|
||||
"skipBackupFile": "精簡備份",
|
||||
"skipBackupFile.help": "開啟後備份時將跳過檔案資料,僅備份設定資訊,顯著減小備份檔案體積",
|
||||
"syncStatus": "同步狀態",
|
||||
"syncStatus.noSync": "未同步",
|
||||
"syncStatus.error": "同步錯誤: {{message}}",
|
||||
"syncStatus.lastSync": "上次同步: {{time}}",
|
||||
"manager.title": "S3 備份檔案管理",
|
||||
"manager.refresh": "重新整理",
|
||||
"manager.delete.selected": "刪除選中 ({{count}})",
|
||||
"manager.close": "關閉",
|
||||
"manager.columns.fileName": "檔案名稱",
|
||||
"manager.columns.modifiedTime": "修改時間",
|
||||
"manager.columns.size": "檔案大小",
|
||||
"manager.columns.actions": "操作",
|
||||
"manager.restore": "恢復",
|
||||
"manager.delete": "刪除",
|
||||
"manager.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||
"manager.files.fetch.error": "取得備份檔案清單失敗: {{message}}",
|
||||
"manager.delete.confirm.title": "確認刪除",
|
||||
"manager.delete.confirm.multiple": "確定要刪除選中的 {{count}} 個備份檔案嗎?此操作不可撤銷。",
|
||||
"manager.delete.confirm.single": "確定要刪除備份檔案 \"{{fileName}}\" 嗎?此操作不可撤銷。",
|
||||
"manager.delete.success.multiple": "成功刪除 {{count}} 個備份檔案",
|
||||
"manager.delete.success.single": "刪除備份檔案成功",
|
||||
"manager.delete.error": "刪除備份檔案失敗: {{message}}",
|
||||
"manager.select.warning": "請選擇要刪除的備份檔案",
|
||||
"restore.modal.title": "S3 資料恢復",
|
||||
"restore.modal.select.placeholder": "請選擇要恢復的備份檔案",
|
||||
"restore.confirm.title": "確認恢復資料",
|
||||
"restore.confirm.content": "恢復資料將覆寫當前所有資料,此操作不可撤銷。確定要繼續嗎?",
|
||||
"restore.confirm.ok": "確認恢復",
|
||||
"restore.confirm.cancel": "取消",
|
||||
"restore.success": "資料恢復成功",
|
||||
"restore.error": "資料恢復失敗: {{message}}",
|
||||
"restore.config.incomplete": "請填寫完整的 S3 設定資訊",
|
||||
"restore.file.required": "請選擇要恢復的備份檔案"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
|
||||
@ -12,9 +12,9 @@ function initKeyv() {
|
||||
|
||||
function initAutoSync() {
|
||||
setTimeout(() => {
|
||||
const { webdavAutoSync } = store.getState().settings
|
||||
const { webdavAutoSync, s3 } = store.getState().settings
|
||||
const { nutstoreAutoSync } = store.getState().nutstore
|
||||
if (webdavAutoSync) {
|
||||
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||
startAutoSync()
|
||||
}
|
||||
if (nutstoreAutoSync) {
|
||||
|
||||
@ -57,28 +57,19 @@ const Chat: FC<Props> = (props) => {
|
||||
|
||||
const contentSearchFilter: NodeFilter = {
|
||||
acceptNode(node) {
|
||||
if (node.parentNode) {
|
||||
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
||||
while (parentNode?.parentNode) {
|
||||
if (parentNode.classList.contains('MessageFooter')) {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
const container = node.parentElement?.closest('.message-content-container')
|
||||
if (!container) return NodeFilter.FILTER_REJECT
|
||||
|
||||
if (filterIncludeUser) {
|
||||
if (parentNode?.classList.contains('message-content-container')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
} else {
|
||||
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
}
|
||||
parentNode = parentNode.parentNode as HTMLElement
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT
|
||||
} else {
|
||||
return NodeFilter.FILTER_REJECT
|
||||
const message = container.closest('.message')
|
||||
if (!message) return NodeFilter.FILTER_REJECT
|
||||
|
||||
if (filterIncludeUser) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
if (message.classList.contains('message-assistant')) {
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ import type { MessageInputBaseParams } from '@renderer/types/newMessage'
|
||||
import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
|
||||
import { formatQuotedText } from '@renderer/utils/formats'
|
||||
import { getFilesFromDropEvent, getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
|
||||
import { getLanguageByLangcode } from '@renderer/utils/translate'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@ -253,7 +254,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(text, targetLanguage)
|
||||
const translatedText = await translateText(text, getLanguageByLangcode(targetLanguage))
|
||||
translatedText && setText(translatedText)
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
} catch (error) {
|
||||
|
||||
@ -36,7 +36,7 @@ interface Props {
|
||||
// 模型类型到支持选项的映射表
|
||||
const MODEL_SUPPORTED_OPTIONS: Record<string, ThinkingOption[]> = {
|
||||
default: ['off', 'low', 'medium', 'high'],
|
||||
grok: ['off', 'low', 'high'],
|
||||
grok: ['low', 'high'],
|
||||
gemini: ['off', 'low', 'medium', 'high', 'auto'],
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'],
|
||||
qwen: ['off', 'low', 'medium', 'high'],
|
||||
|
||||
@ -156,13 +156,7 @@ const MessageItem: FC<Props> = ({
|
||||
{!isEditing && (
|
||||
<>
|
||||
<MessageContentContainer
|
||||
className={
|
||||
message.role === 'user'
|
||||
? 'message-content-container message-content-container-user'
|
||||
: message.role === 'assistant'
|
||||
? 'message-content-container message-content-container-assistant'
|
||||
: 'message-content-container'
|
||||
}
|
||||
className="message-content-container"
|
||||
style={{
|
||||
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
|
||||
fontSize,
|
||||
|
||||
@ -2,7 +2,7 @@ import { CheckOutlined, EditOutlined, QuestionCircleOutlined, SyncOutlined } fro
|
||||
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
|
||||
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
|
||||
import { isVisionModel } from '@renderer/config/models'
|
||||
import { TranslateLanguageOptions } from '@renderer/config/translate'
|
||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
@ -13,9 +13,9 @@ import { translateText } from '@renderer/services/TranslateService'
|
||||
import store, { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import type { Assistant, Model, Topic } from '@renderer/types'
|
||||
import type { Assistant, Language, Model, Topic } from '@renderer/types'
|
||||
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils'
|
||||
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils'
|
||||
import { copyMessageAsPlainText } from '@renderer/utils/copy'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
@ -153,12 +153,12 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
}, [message.id, startEditing])
|
||||
|
||||
const handleTranslate = useCallback(
|
||||
async (language: string) => {
|
||||
async (language: Language) => {
|
||||
if (isTranslating) return
|
||||
|
||||
setIsTranslating(true)
|
||||
const messageId = message.id
|
||||
const translationUpdater = await getTranslationUpdater(messageId, language)
|
||||
const translationUpdater = await getTranslationUpdater(messageId, language.langCode)
|
||||
if (!translationUpdater) return
|
||||
try {
|
||||
await translateText(mainTextContent, language, translationUpdater)
|
||||
@ -399,7 +399,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const softHoverBg = isBubbleStyle && !isLastMessage
|
||||
|
||||
return (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
<MenusBar className={classNames({ menubar: true, show: isLastMessage })}>
|
||||
{message.role === 'user' && (
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
@ -457,10 +457,10 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
backgroundClip: 'border-box'
|
||||
},
|
||||
items: [
|
||||
...TranslateLanguageOptions.map((item) => ({
|
||||
label: item.emoji + ' ' + item.label,
|
||||
key: item.value,
|
||||
onClick: () => handleTranslate(item.value)
|
||||
...translateLanguageOptions.map((item) => ({
|
||||
label: item.emoji + ' ' + item.label(),
|
||||
key: item.langCode,
|
||||
onClick: () => handleTranslate(item)
|
||||
})),
|
||||
...(hasTranslationBlocks
|
||||
? [
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
isSupportedFlexServiceTier,
|
||||
isSupportedReasoningEffortOpenAIModel
|
||||
} from '@renderer/config/models'
|
||||
import { translateLanguageOptions } from '@renderer/config/translate'
|
||||
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
@ -44,14 +45,7 @@ import {
|
||||
setShowTranslateConfirm,
|
||||
setThoughtAutoCollapse
|
||||
} from '@renderer/store/settings'
|
||||
import {
|
||||
Assistant,
|
||||
AssistantSettings,
|
||||
CodeStyleVarious,
|
||||
MathEngine,
|
||||
ThemeMode,
|
||||
TranslateLanguageVarious
|
||||
} from '@renderer/types'
|
||||
import { Assistant, AssistantSettings, CodeStyleVarious, MathEngine, ThemeMode } from '@renderer/types'
|
||||
import { modalConfirm } from '@renderer/utils'
|
||||
import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { Button, Col, InputNumber, Row, Slider, Switch, Tooltip } from 'antd'
|
||||
@ -625,14 +619,10 @@ const SettingsTab: FC<Props> = (props) => {
|
||||
<SettingRowTitleSmall>{t('settings.input.target_language')}</SettingRowTitleSmall>
|
||||
<Selector
|
||||
value={targetLanguage}
|
||||
onChange={(value) => setTargetLanguage(value as TranslateLanguageVarious)}
|
||||
options={[
|
||||
{ value: 'chinese', label: t('settings.input.target_language.chinese') },
|
||||
{ value: 'chinese-traditional', label: t('settings.input.target_language.chinese-traditional') },
|
||||
{ value: 'english', label: t('settings.input.target_language.english') },
|
||||
{ value: 'japanese', label: t('settings.input.target_language.japanese') },
|
||||
{ value: 'russian', label: t('settings.input.target_language.russian') }
|
||||
]}
|
||||
onChange={(value) => setTargetLanguage(value)}
|
||||
options={translateLanguageOptions.map((item) => {
|
||||
return { value: item.langCode, label: item.emoji + ' ' + item.label() }
|
||||
})}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
|
||||
@ -7,6 +7,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
@ -543,7 +544,7 @@ const AihubmixPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(painting.prompt, 'english')
|
||||
const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS)
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
|
||||
@ -12,6 +12,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
@ -302,7 +303,7 @@ const SiliconPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(painting.prompt, 'english')
|
||||
const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS)
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
|
||||
@ -4,6 +4,7 @@ import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import TranslateButton from '@renderer/components/TranslateButton'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import { usePaintings } from '@renderer/hooks/usePaintings'
|
||||
import { useAllProviders } from '@renderer/hooks/useProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
@ -255,7 +256,7 @@ const TokenFluxPage: FC<{ Options: string[] }> = ({ Options }) => {
|
||||
|
||||
try {
|
||||
setIsTranslating(true)
|
||||
const translatedText = await translateText(painting.prompt, 'english')
|
||||
const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS)
|
||||
updatePaintingState({ prompt: translatedText })
|
||||
} catch (error) {
|
||||
console.error('Translation failed:', error)
|
||||
|
||||
@ -24,18 +24,18 @@ const AgentsSubscribeUrlSettings: FC = () => {
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>
|
||||
{t('agents.tag.agent')}
|
||||
{t('settings.websearch.subscribe_add')}
|
||||
{t('settings.tool.websearch.subscribe_add')}
|
||||
</SettingTitle>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.websearch.subscribe_url')}</SettingRowTitle>
|
||||
<SettingRowTitle>{t('settings.tool.websearch.subscribe_url')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={agentssubscribeUrl || ''}
|
||||
onChange={handleAgentChange}
|
||||
style={{ width: 315 }}
|
||||
placeholder={t('settings.websearch.subscribe_name.placeholder')}
|
||||
placeholder={t('settings.tool.websearch.subscribe_name.placeholder')}
|
||||
/>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
|
||||
@ -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: <CloudSyncOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'nutstore', title: 'settings.data.nutstore.title', icon: <NutstoreIcon /> },
|
||||
{ key: 's3', title: 'settings.data.s3.title', icon: <CloudServerOutlined style={{ fontSize: 16 }} /> },
|
||||
{ key: 'divider_2', isDivider: true, text: t('settings.data.divider.export_settings') },
|
||||
{
|
||||
key: 'export_menu',
|
||||
@ -653,6 +656,7 @@ const DataSettings: FC = () => {
|
||||
)}
|
||||
{menu === 'webdav' && <WebDavSettings />}
|
||||
{menu === 'nutstore' && <NutstoreSettings />}
|
||||
{menu === 's3' && <S3Settings />}
|
||||
{menu === 'export_menu' && <ExportMenuOptions />}
|
||||
{menu === 'markdown_export' && <MarkdownExportSettings />}
|
||||
{menu === 'notion' && <NotionSettings />}
|
||||
@ -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;
|
||||
|
||||
@ -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 = () => {
|
||||
</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type="password"
|
||||
<Input.Password
|
||||
value={joplinToken || ''}
|
||||
onChange={handleJoplinTokenChange}
|
||||
onBlur={handleJoplinTokenChange}
|
||||
placeholder={t('settings.data.joplin.token_placeholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={handleJoplinConnectionCheck}>{t('settings.data.joplin.check.button')}</Button>
|
||||
</Space.Compact>
|
||||
|
||||
@ -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 = () => {
|
||||
<SettingRowTitle>{t('settings.data.notion.api_key')}</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type="password"
|
||||
<Input.Password
|
||||
value={notionApiKey || ''}
|
||||
onChange={handleNotionTokenChange}
|
||||
onBlur={handleNotionTokenChange}
|
||||
placeholder={t('settings.data.notion.api_key_placeholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={handleNotionConnectionCheck}>{t('settings.data.notion.check.button')}</Button>
|
||||
</Space.Compact>
|
||||
|
||||
292
src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
Normal file
292
src/renderer/src/pages/settings/DataSettings/S3Settings.tsx
Normal file
@ -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<string | undefined>(s3EndpointInit)
|
||||
const [region, setRegion] = useState<string | undefined>(s3RegionInit)
|
||||
const [bucket, setBucket] = useState<string | undefined>(s3BucketInit)
|
||||
const [accessKeyId, setAccessKeyId] = useState<string | undefined>(s3AccessKeyIdInit)
|
||||
const [secretAccessKey, setSecretAccessKey] = useState<string | undefined>(s3SecretAccessKeyInit)
|
||||
const [root, setRoot] = useState<string | undefined>(s3RootInit)
|
||||
const [skipBackupFile, setSkipBackupFile] = useState<boolean>(s3SkipBackupFileInit)
|
||||
const [backupManagerVisible, setBackupManagerVisible] = useState(false)
|
||||
|
||||
const [syncInterval, setSyncInterval] = useState<number>(s3SyncIntervalInit)
|
||||
const [maxBackups, setMaxBackups] = useState<number>(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 <span style={{ color: 'var(--text-secondary)' }}>{t('settings.data.s3.syncStatus.noSync')}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack gap="5px" alignItems="center">
|
||||
{s3Sync?.syncing && <SyncOutlined spin />}
|
||||
{!s3Sync?.syncing && s3Sync?.lastSyncError && (
|
||||
<Tooltip title={t('settings.data.s3.syncStatus.error', { message: s3Sync.lastSyncError })}>
|
||||
<WarningOutlined style={{ color: 'red' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{s3Sync?.lastSyncTime && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.s3.syncStatus.lastSync', { time: dayjs(s3Sync.lastSyncTime).format('HH:mm:ss') })}
|
||||
</span>
|
||||
)}
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const { isModalVisible, handleBackup, handleCancel, backuping, customFileName, setCustomFileName, showBackupModal } =
|
||||
useS3BackupModal()
|
||||
|
||||
const showBackupManager = () => {
|
||||
setBackupManagerVisible(true)
|
||||
}
|
||||
|
||||
const closeBackupManager = () => {
|
||||
setBackupManagerVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle style={{ justifyContent: 'flex-start', gap: 10 }}>
|
||||
{t('settings.data.s3.title')}
|
||||
<Tooltip title={t('settings.data.s3.title.tooltip')} placement="right">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-2)', cursor: 'pointer' }} onClick={handleTitleClick} />
|
||||
</Tooltip>
|
||||
</SettingTitle>
|
||||
<SettingHelpText>{t('settings.data.s3.title.help')}</SettingHelpText>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.endpoint')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.data.s3.endpoint.placeholder')}
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
type="url"
|
||||
onBlur={() => dispatch(setS3Partial({ endpoint: endpoint || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.region')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.data.s3.region.placeholder')}
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(setS3Partial({ region: region || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.bucket')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.data.s3.bucket.placeholder')}
|
||||
value={bucket}
|
||||
onChange={(e) => setBucket(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(setS3Partial({ bucket: bucket || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.accessKeyId')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.data.s3.accessKeyId.placeholder')}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(setS3Partial({ accessKeyId: accessKeyId || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.secretAccessKey')}</SettingRowTitle>
|
||||
<Input.Password
|
||||
placeholder={t('settings.data.s3.secretAccessKey.placeholder')}
|
||||
value={secretAccessKey}
|
||||
onChange={(e) => setSecretAccessKey(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(setS3Partial({ secretAccessKey: secretAccessKey || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.root')}</SettingRowTitle>
|
||||
<Input
|
||||
placeholder={t('settings.data.s3.root.placeholder')}
|
||||
value={root}
|
||||
onChange={(e) => setRoot(e.target.value)}
|
||||
style={{ width: 250 }}
|
||||
onBlur={() => dispatch(setS3Partial({ root: root || '' }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.backup.operation')}</SettingRowTitle>
|
||||
<HStack gap="5px" justifyContent="space-between">
|
||||
<Button
|
||||
onClick={showBackupModal}
|
||||
icon={<SaveOutlined />}
|
||||
loading={backuping}
|
||||
disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
|
||||
{t('settings.data.s3.backup.button')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={showBackupManager}
|
||||
icon={<FolderOpenOutlined />}
|
||||
disabled={!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey}>
|
||||
{t('settings.data.s3.backup.manager.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.autoSync')}</SettingRowTitle>
|
||||
<Select
|
||||
value={syncInterval}
|
||||
onChange={onSyncIntervalChange}
|
||||
disabled={!endpoint || !accessKeyId || !secretAccessKey}
|
||||
style={{ width: 120 }}>
|
||||
<Select.Option value={0}>{t('settings.data.s3.autoSync.off')}</Select.Option>
|
||||
<Select.Option value={1}>{t('settings.data.s3.autoSync.minute', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={5}>{t('settings.data.s3.autoSync.minute', { count: 5 })}</Select.Option>
|
||||
<Select.Option value={15}>{t('settings.data.s3.autoSync.minute', { count: 15 })}</Select.Option>
|
||||
<Select.Option value={30}>{t('settings.data.s3.autoSync.minute', { count: 30 })}</Select.Option>
|
||||
<Select.Option value={60}>{t('settings.data.s3.autoSync.hour', { count: 1 })}</Select.Option>
|
||||
<Select.Option value={120}>{t('settings.data.s3.autoSync.hour', { count: 2 })}</Select.Option>
|
||||
<Select.Option value={360}>{t('settings.data.s3.autoSync.hour', { count: 6 })}</Select.Option>
|
||||
<Select.Option value={720}>{t('settings.data.s3.autoSync.hour', { count: 12 })}</Select.Option>
|
||||
<Select.Option value={1440}>{t('settings.data.s3.autoSync.hour', { count: 24 })}</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.maxBackups')}</SettingRowTitle>
|
||||
<Select
|
||||
value={maxBackups}
|
||||
onChange={onMaxBackupsChange}
|
||||
disabled={!endpoint || !accessKeyId || !secretAccessKey}
|
||||
style={{ width: 120 }}>
|
||||
<Select.Option value={0}>{t('settings.data.s3.maxBackups.unlimited')}</Select.Option>
|
||||
<Select.Option value={1}>1</Select.Option>
|
||||
<Select.Option value={3}>3</Select.Option>
|
||||
<Select.Option value={5}>5</Select.Option>
|
||||
<Select.Option value={10}>10</Select.Option>
|
||||
<Select.Option value={20}>20</Select.Option>
|
||||
<Select.Option value={50}>50</Select.Option>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.skipBackupFile')}</SettingRowTitle>
|
||||
<Switch checked={skipBackupFile} onChange={onSkipBackupFilesChange} />
|
||||
</SettingRow>
|
||||
<SettingRow>
|
||||
<SettingHelpText>{t('settings.data.s3.skipBackupFile.help')}</SettingHelpText>
|
||||
</SettingRow>
|
||||
{syncInterval > 0 && (
|
||||
<>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.data.s3.syncStatus')}</SettingRowTitle>
|
||||
{renderSyncStatus()}
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<S3BackupModal
|
||||
isModalVisible={isModalVisible}
|
||||
handleBackup={handleBackup}
|
||||
handleCancel={handleCancel}
|
||||
backuping={backuping}
|
||||
customFileName={customFileName}
|
||||
setCustomFileName={setCustomFileName}
|
||||
/>
|
||||
|
||||
<S3BackupManager
|
||||
visible={backupManagerVisible}
|
||||
onClose={closeBackupManager}
|
||||
s3Config={{
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
root
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default S3Settings
|
||||
@ -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 = () => {
|
||||
</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type="password"
|
||||
<Input.Password
|
||||
value={siyuanToken || ''}
|
||||
onChange={handleTokenChange}
|
||||
onBlur={handleTokenChange}
|
||||
placeholder={t('settings.data.siyuan.token_placeholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={handleCheckConnection}>{t('settings.data.siyuan.check.button')}</Button>
|
||||
</Space.Compact>
|
||||
|
||||
@ -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 = () => {
|
||||
</SettingRowTitle>
|
||||
<HStack alignItems="center" gap="5px" style={{ width: 315 }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type="password"
|
||||
<Input.Password
|
||||
value={yuqueToken || ''}
|
||||
onChange={handleYuqueTokenChange}
|
||||
onBlur={handleYuqueTokenChange}
|
||||
placeholder={t('settings.data.yuque.token_placeholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button onClick={handleYuqueConnectionCheck}>{t('settings.data.yuque.check.button')}</Button>
|
||||
</Space.Compact>
|
||||
|
||||
@ -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<{
|
||||
<Flex align="center" justify="space-between" gap={10}>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={localPair[0]}
|
||||
onChange={(value) => setLocalPair([value, localPair[1]])}
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
value={localPair[0].langCode}
|
||||
onChange={(value) => setLocalPair([getLanguageByLangcode(value), localPair[1]])}
|
||||
options={translateLanguageOptions.map((lang) => ({
|
||||
value: lang.langCode,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
<Space.Compact block>{lang.label()}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
@ -205,16 +206,16 @@ const TranslateSettings: FC<{
|
||||
<span>⇆</span>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
value={localPair[1]}
|
||||
onChange={(value) => 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: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>{lang.label}</div>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>{lang.label()}</div>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
@ -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<string>('')
|
||||
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<string | null>(null)
|
||||
const [sourceLanguage, setSourceLanguage] = useState<string>('auto')
|
||||
const [detectedLanguage, setDetectedLanguage] = useState<Language | null>(null)
|
||||
const [sourceLanguage, setSourceLanguage] = useState<Language | 'auto'>('auto')
|
||||
const [targetLanguage, setTargetLanguage] = useState<Language>(_targetLanguage)
|
||||
const contentContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textAreaRef = useRef<TextAreaRef>(null)
|
||||
const outputTextRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<Flex align="center" style={{ width: 160 }}>
|
||||
<BidirectionalLanguageDisplay>
|
||||
{`${t(`languages.${bidirectionalPair[0]}`)} ⇆ ${t(`languages.${bidirectionalPair[1]}`)}`}
|
||||
</BidirectionalLanguageDisplay>
|
||||
</Flex>
|
||||
)
|
||||
try {
|
||||
if (isBidirectional) {
|
||||
return (
|
||||
<Flex align="center" style={{ width: 160 }}>
|
||||
<BidirectionalLanguageDisplay>
|
||||
{`${bidirectionalPair[0].label()} ⇆ ${bidirectionalPair[1].label()}`}
|
||||
</BidirectionalLanguageDisplay>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting language display:', error)
|
||||
setBidirectionalPair([LanguagesEnum.enUS, LanguagesEnum.zhCN])
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
style={{ width: 160 }}
|
||||
value={targetLanguage}
|
||||
value={targetLanguage.langCode}
|
||||
onChange={(value) => {
|
||||
setTargetLanguage(value)
|
||||
setTargetLanguage(getLanguageByLangcode(value))
|
||||
db.settings.put({ id: 'translate:target:language', value })
|
||||
}}
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
options={translateLanguageOptions.map((lang) => ({
|
||||
value: lang.langCode,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
<Space.Compact block>{lang.label()}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
@ -573,28 +594,29 @@ const TranslatePage: FC = () => {
|
||||
<Flex align="center" gap={20}>
|
||||
<Select
|
||||
showSearch
|
||||
value={sourceLanguage}
|
||||
value={sourceLanguage !== 'auto' ? sourceLanguage.langCode : 'auto'}
|
||||
style={{ width: 180 }}
|
||||
optionFilterProp="label"
|
||||
onChange={(value) => {
|
||||
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: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
<Space.Compact block>{lang.label()}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<string, any>) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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: '/'
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<Partial<WebDAVSyncState>>) => {
|
||||
setWebDAVSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
||||
},
|
||||
setS3SyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||
state.s3Sync = { ...state.s3Sync, ...action.payload }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setWebDAVSyncState } = backupSlice.actions
|
||||
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
|
||||
export default backupSlice.reducer
|
||||
|
||||
@ -56,7 +56,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 119,
|
||||
version: 120,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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'
|
||||
},
|
||||
|
||||
@ -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<string, LanguageCode> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<number>) => {
|
||||
state.nutstoreSyncInterval = action.payload
|
||||
},
|
||||
setNutstoreSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
||||
setNutstoreSyncState: (state, action: PayloadAction<Partial<RemoteSyncState>>) => {
|
||||
state.nutstoreSyncState = { ...state.nutstoreSyncState, ...action.payload }
|
||||
},
|
||||
setNutstoreSkipBackupFile: (state, action: PayloadAction<boolean>) => {
|
||||
|
||||
@ -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<PaintingProvider>) => {
|
||||
state.defaultPaintingProvider = action.payload
|
||||
},
|
||||
setS3: (state, action: PayloadAction<S3Config>) => {
|
||||
state.s3 = action.payload
|
||||
},
|
||||
setS3Partial: (state, action: PayloadAction<Partial<S3Config>>) => {
|
||||
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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -68,6 +68,9 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
|
||||
const originalScrollTop = div.scrollTop
|
||||
|
||||
// Hide scrollbars during capture
|
||||
div.classList.add('hide-scrollbar')
|
||||
|
||||
// Modify styles to show full content
|
||||
div.style.height = 'auto'
|
||||
div.style.maxHeight = 'none'
|
||||
@ -134,6 +137,9 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
} catch (error) {
|
||||
console.error('Error capturing scrollable div:', error)
|
||||
throw error
|
||||
} finally {
|
||||
// Remove scrollbar hiding class
|
||||
divRef.current?.classList.remove('hide-scrollbar')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { LanguagesEnum } from '@renderer/config/translate'
|
||||
import { Language, LanguageCode } from '@renderer/types'
|
||||
import { franc } from 'franc-min'
|
||||
import React, { MutableRefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 使用Unicode字符范围检测语言
|
||||
* 适用于较短文本的语言检测
|
||||
* @param {string} text 需要检测语言的文本
|
||||
* @returns {string} 检测到的语言代码
|
||||
* @param text 需要检测语言的文本
|
||||
* @returns 检测到的语言
|
||||
*/
|
||||
export const detectLanguageByUnicode = (text: string): string => {
|
||||
export const detectLanguageByUnicode = (text: string): Language => {
|
||||
const counts = {
|
||||
zh: 0,
|
||||
ja: 0,
|
||||
@ -40,8 +42,8 @@ export const detectLanguageByUnicode = (text: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalChars === 0) return 'en'
|
||||
let maxLang = 'en'
|
||||
if (totalChars === 0) return LanguagesEnum.enUS
|
||||
let maxLang = ''
|
||||
let maxCount = 0
|
||||
|
||||
for (const [lang, count] of Object.entries(counts)) {
|
||||
@ -52,73 +54,68 @@ export const detectLanguageByUnicode = (text: string): string => {
|
||||
}
|
||||
|
||||
if (maxCount / totalChars < 0.3) {
|
||||
return 'en'
|
||||
return LanguagesEnum.enUS
|
||||
}
|
||||
|
||||
switch (maxLang) {
|
||||
case 'zh':
|
||||
return LanguagesEnum.zhCN
|
||||
case 'ja':
|
||||
return LanguagesEnum.jaJP
|
||||
case 'ko':
|
||||
return LanguagesEnum.koKR
|
||||
case 'ru':
|
||||
return LanguagesEnum.ruRU
|
||||
case 'ar':
|
||||
return LanguagesEnum.arAR
|
||||
case 'en':
|
||||
return LanguagesEnum.enUS
|
||||
default:
|
||||
console.error(`Unknown language: ${maxLang}`)
|
||||
return LanguagesEnum.enUS
|
||||
}
|
||||
return maxLang
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测输入文本的语言
|
||||
* @param {string} inputText 需要检测语言的文本
|
||||
* @returns {Promise<string>} 检测到的语言代码
|
||||
* @param inputText 需要检测语言的文本
|
||||
* @returns 检测到的语言
|
||||
*/
|
||||
export const detectLanguage = async (inputText: string): Promise<string> => {
|
||||
export const detectLanguage = async (inputText: string): Promise<Language> => {
|
||||
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<string, string> = {
|
||||
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<string, Language> = {
|
||||
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<string, string> = {
|
||||
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<string> => {
|
||||
* @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
|
||||
}
|
||||
|
||||
@ -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<Props> = ({ text }) => {
|
||||
const [result, setResult] = useState('')
|
||||
const [targetLanguage, setTargetLanguage] = useState(_targetLanguage)
|
||||
const [targetLanguage, setTargetLanguage] = useState<Language>(_targetLanguage)
|
||||
const { translateModel } = useDefaultModel()
|
||||
const { t } = useTranslation()
|
||||
const translatingRef = useRef(false)
|
||||
@ -37,8 +38,7 @@ const Translate: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ text }) => {
|
||||
<SwapOutlined />
|
||||
<Select
|
||||
showSearch
|
||||
value={targetLanguage}
|
||||
value={targetLanguage.langCode}
|
||||
style={{ maxWidth: 200, minWidth: 130, flex: 1 }}
|
||||
optionFilterProp="label"
|
||||
options={TranslateLanguageOptions}
|
||||
options={translateLanguageOptions.map((option) => ({
|
||||
value: option.langCode,
|
||||
label: option.emoji + ' ' + option.label()
|
||||
}))}
|
||||
onChange={async (value) => {
|
||||
await db.settings.put({ id: 'translate:target:language', value })
|
||||
setTargetLanguage(value)
|
||||
setTargetLanguage(getLanguageByLangcode(value))
|
||||
}}
|
||||
optionRender={(option) => (
|
||||
<Space>
|
||||
<span role="img" aria-label={option.data.label}>
|
||||
{option.data.emoji}
|
||||
</span>
|
||||
{option.label}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</MenuContainer>
|
||||
<Main>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons'
|
||||
import CopyButton from '@renderer/components/CopyButton'
|
||||
import { TranslateLanguageOptions, translateLanguageOptions } from '@renderer/config/translate'
|
||||
import { LanguagesEnum, translateLanguageOptions } from '@renderer/config/translate'
|
||||
import db from '@renderer/databases'
|
||||
import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
@ -11,11 +11,11 @@ import {
|
||||
getDefaultTopic,
|
||||
getTranslateModel
|
||||
} from '@renderer/services/AssistantService'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Assistant, Language, Topic } from '@renderer/types'
|
||||
import type { ActionItem } from '@renderer/types/selectionTypes'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { detectLanguage } from '@renderer/utils/translate'
|
||||
import { detectLanguage, getLanguageByLangcode } from '@renderer/utils/translate'
|
||||
import { Select, Space, Tooltip } from 'antd'
|
||||
import { ArrowRightFromLine, ArrowRightToLine, ChevronDown, CircleHelp, Globe } from 'lucide-react'
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@ -33,8 +33,8 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
const { t } = useTranslation()
|
||||
const { translateModelPrompt, language } = useSettings()
|
||||
|
||||
const [targetLanguage, setTargetLanguage] = useState('')
|
||||
const [alterLanguage, setAlterLanguage] = useState('')
|
||||
const [targetLanguage, setTargetLanguage] = useState<Language>(LanguagesEnum.enUS)
|
||||
const [alterLanguage, setAlterLanguage] = useState<Language>(LanguagesEnum.zhCN)
|
||||
|
||||
const [error, setError] = useState('')
|
||||
const [showOriginal, setShowOriginal] = useState(false)
|
||||
@ -52,24 +52,24 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
runAsyncFunction(async () => {
|
||||
const biDirectionLangPair = await db.settings.get({ id: 'translate:bidirectional:pair' })
|
||||
|
||||
let targetLang = ''
|
||||
let alterLang = ''
|
||||
let targetLang: Language
|
||||
let alterLang: Language
|
||||
|
||||
if (!biDirectionLangPair || !biDirectionLangPair.value[0]) {
|
||||
const lang = TranslateLanguageOptions.find((lang) => lang.langCode?.toLowerCase() === language.toLowerCase())
|
||||
const lang = translateLanguageOptions.find((lang) => lang.langCode?.toLowerCase() === language.toLowerCase())
|
||||
if (lang) {
|
||||
targetLang = lang.value
|
||||
targetLang = lang
|
||||
} else {
|
||||
targetLang = 'chinese'
|
||||
targetLang = LanguagesEnum.zhCN
|
||||
}
|
||||
} else {
|
||||
targetLang = biDirectionLangPair.value[0]
|
||||
targetLang = getLanguageByLangcode(biDirectionLangPair.value[0])
|
||||
}
|
||||
|
||||
if (!biDirectionLangPair || !biDirectionLangPair.value[1]) {
|
||||
alterLang = 'english'
|
||||
alterLang = LanguagesEnum.enUS
|
||||
} else {
|
||||
alterLang = biDirectionLangPair.value[1]
|
||||
alterLang = getLanguageByLangcode(biDirectionLangPair.value[1])
|
||||
}
|
||||
|
||||
setTargetLanguage(targetLang)
|
||||
@ -120,8 +120,8 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
|
||||
const sourceLanguage = await detectLanguage(action.selectedText)
|
||||
|
||||
let translateLang = ''
|
||||
if (sourceLanguage === targetLanguage) {
|
||||
let translateLang: Language
|
||||
if (sourceLanguage.langCode === targetLanguage.langCode) {
|
||||
translateLang = alterLanguage
|
||||
} else {
|
||||
translateLang = targetLanguage
|
||||
@ -129,7 +129,7 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
|
||||
// Initialize prompt content
|
||||
const userContent = translateModelPrompt
|
||||
.replaceAll('{{target_language}}', translateLang)
|
||||
.replaceAll('{{target_language}}', translateLang.value)
|
||||
.replaceAll('{{text}}', action.selectedText)
|
||||
|
||||
processMessages(assistantRef.current, topicRef.current, userContent, setAskId, onStream, onFinish, onError)
|
||||
@ -147,11 +147,11 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
return lastAssistantMessage ? <MessageContent key={lastAssistantMessage.id} message={lastAssistantMessage} /> : null
|
||||
}, [allMessages])
|
||||
|
||||
const handleChangeLanguage = (targetLanguage: string, alterLanguage: string) => {
|
||||
const handleChangeLanguage = (targetLanguage: Language, alterLanguage: Language) => {
|
||||
setTargetLanguage(targetLanguage)
|
||||
setAlterLanguage(alterLanguage)
|
||||
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage, alterLanguage] })
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: [targetLanguage.langCode, alterLanguage.langCode] })
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
@ -177,46 +177,46 @@ const ActionTranslate: FC<Props> = ({ action, scrollToBottom }) => {
|
||||
<ArrowRightToLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
|
||||
<Tooltip placement="bottom" title={t('translate.target_language')} arrow>
|
||||
<Select
|
||||
value={targetLanguage}
|
||||
value={targetLanguage.langCode}
|
||||
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
|
||||
listHeight={160}
|
||||
title={t('translate.target_language')}
|
||||
optionFilterProp="label"
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
options={translateLanguageOptions.map((lang) => ({
|
||||
value: lang.langCode,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
<Space.Compact block>{lang.label()}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
onChange={(value) => handleChangeLanguage(value, alterLanguage)}
|
||||
onChange={(value) => handleChangeLanguage(getLanguageByLangcode(value), alterLanguage)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ArrowRightFromLine size={16} color="var(--color-text-3)" style={{ margin: '0 2px' }} />
|
||||
<Tooltip placement="bottom" title={t('translate.alter_language')} arrow>
|
||||
<Select
|
||||
value={alterLanguage}
|
||||
value={alterLanguage.langCode}
|
||||
style={{ minWidth: 80, maxWidth: 200, flex: 'auto' }}
|
||||
listHeight={160}
|
||||
title={t('translate.alter_language')}
|
||||
optionFilterProp="label"
|
||||
options={translateLanguageOptions().map((lang) => ({
|
||||
value: lang.value,
|
||||
options={translateLanguageOptions.map((lang) => ({
|
||||
value: lang.langCode,
|
||||
label: (
|
||||
<Space.Compact direction="horizontal" block>
|
||||
<span role="img" aria-label={lang.emoji} style={{ marginRight: 8 }}>
|
||||
{lang.emoji}
|
||||
</span>
|
||||
<Space.Compact block>{lang.label}</Space.Compact>
|
||||
<Space.Compact block>{lang.label()}</Space.Compact>
|
||||
</Space.Compact>
|
||||
)
|
||||
}))}
|
||||
onChange={(value) => handleChangeLanguage(targetLanguage, value)}
|
||||
onChange={(value) => handleChangeLanguage(targetLanguage, getLanguageByLangcode(value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user