mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 15:59:09 +08:00
Merge branch 'main' into feat/sidebar-ui
This commit is contained in:
commit
5d9b47198b
88
.github/dependabot.yml
vendored
88
.github/dependabot.yml
vendored
@ -4,38 +4,26 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
open-pull-requests-limit: 7
|
||||
open-pull-requests-limit: 5
|
||||
target-branch: "main"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "@google/genai"
|
||||
- dependency-name: "antd"
|
||||
- dependency-name: "epub"
|
||||
- dependency-name: "openai"
|
||||
groups:
|
||||
# 核心框架
|
||||
core-framework:
|
||||
# CherryStudio 自定义包
|
||||
cherrystudio-packages:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "electron"
|
||||
- "typescript"
|
||||
- "@types/react*"
|
||||
- "@types/node"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Electron 生态和构建工具
|
||||
electron-build:
|
||||
patterns:
|
||||
- "electron-*"
|
||||
- "@electron*"
|
||||
- "vite"
|
||||
- "@vitejs/*"
|
||||
- "dotenv-cli"
|
||||
- "rollup-plugin-*"
|
||||
- "@swc/*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
- "@cherrystudio/*"
|
||||
- "@kangfenmao/*"
|
||||
- "selection-hook"
|
||||
|
||||
# 测试工具
|
||||
testing-tools:
|
||||
@ -44,30 +32,40 @@ updates:
|
||||
- "@vitest/*"
|
||||
- "playwright"
|
||||
- "@playwright/*"
|
||||
- "eslint*"
|
||||
- "@eslint*"
|
||||
- "testing-library/*"
|
||||
- "jest-styled-components"
|
||||
|
||||
# Lint 工具
|
||||
lint-tools:
|
||||
patterns:
|
||||
- "eslint"
|
||||
- "eslint-plugin-*"
|
||||
- "@eslint/*"
|
||||
- "@eslint-react/*"
|
||||
- "@electron-toolkit/eslint-config-*"
|
||||
- "prettier"
|
||||
- "husky"
|
||||
- "lint-staged"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# CherryStudio 自定义包
|
||||
cherrystudio-packages:
|
||||
# Markdown
|
||||
markdown:
|
||||
patterns:
|
||||
- "@cherrystudio/*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# 兜底其他 dependencies
|
||||
other-dependencies:
|
||||
dependency-type: "production"
|
||||
|
||||
# 兜底其他 devDependencies
|
||||
other-dev-dependencies:
|
||||
dependency-type: "development"
|
||||
- "react-markdown"
|
||||
- "rehype-katex"
|
||||
- "rehype-mathjax"
|
||||
- "rehype-raw"
|
||||
- "remark-cjk-friendly"
|
||||
- "remark-gfm"
|
||||
- "remark-math"
|
||||
- "remove-markdown"
|
||||
- "markdown-it"
|
||||
- "@shikijs/markdown-it"
|
||||
- "shiki"
|
||||
- "@uiw/codemirror-extensions-langs"
|
||||
- "@uiw/codemirror-themes-all"
|
||||
- "@uiw/react-codemirror"
|
||||
- "fast-diff"
|
||||
- "mermaid"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -2,7 +2,7 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/dist/**": true,
|
||||
|
||||
@ -11,6 +11,11 @@ electronLanguages:
|
||||
- en # for macOS
|
||||
directories:
|
||||
buildResources: build
|
||||
|
||||
protocols:
|
||||
- name: Cherry Studio
|
||||
schemes:
|
||||
- cherrystudio
|
||||
files:
|
||||
- '**/*'
|
||||
- '!**/{.vscode,.yarn,.yarn-lock,.github,.cursorrules,.prettierrc}'
|
||||
|
||||
16
package.json
16
package.json
@ -62,9 +62,9 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"jsdom": "26.1.0",
|
||||
"macos-release": "^3.4.0",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"notion-helper": "^1.3.22",
|
||||
"opendal": "0.47.11",
|
||||
"os-proxy-config": "^1.1.2",
|
||||
"selection-hook": "^0.9.23",
|
||||
"turndown": "7.2.0"
|
||||
@ -106,7 +106,7 @@
|
||||
"@notionhq/client": "^2.2.15",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"@shikijs/markdown-it": "^3.4.2",
|
||||
"@shikijs/markdown-it": "^3.7.0",
|
||||
"@swc/plugin-styled-components": "^7.1.5",
|
||||
"@tanstack/react-query": "^5.27.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@ -126,9 +126,9 @@
|
||||
"@types/react-window": "^1",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@types/word-extractor": "^1",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.12",
|
||||
"@uiw/codemirror-themes-all": "^4.23.12",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@uiw/codemirror-extensions-langs": "^4.23.14",
|
||||
"@uiw/codemirror-themes-all": "^4.23.14",
|
||||
"@uiw/react-codemirror": "^4.23.14",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitest/browser": "^3.1.4",
|
||||
"@vitest/coverage-v8": "^3.1.4",
|
||||
@ -148,7 +148,7 @@
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "35.4.0",
|
||||
"electron": "35.6.0",
|
||||
"electron-builder": "26.0.15",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-log": "^5.1.5",
|
||||
@ -178,7 +178,7 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-react": "^0.487.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"mermaid": "^11.6.0",
|
||||
"mermaid": "^11.7.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.10.5",
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
@ -211,7 +211,7 @@
|
||||
"remove-markdown": "^0.6.2",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.88.0",
|
||||
"shiki": "^3.4.2",
|
||||
"shiki": "^3.7.0",
|
||||
"string-width": "^7.2.0",
|
||||
"styled-components": "^6.1.11",
|
||||
"tar": "^7.4.3",
|
||||
|
||||
@ -153,11 +153,6 @@ 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',
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
/**
|
||||
* Paratera_API_KEY=sk-abcxxxxxxxxxxxxxxxxxxxxxxx123 ts-node scripts/update-i18n.ts
|
||||
* 使用 OpenAI 兼容的模型生成 i18n 文本,并更新到 translate 目录
|
||||
*
|
||||
* API_KEY=sk-xxxx BASE_URL=xxxx MODEL=xxxx ts-node scripts/update-i18n.ts
|
||||
*/
|
||||
|
||||
// OCOOL API KEY
|
||||
const Paratera_API_KEY = process.env.Paratera_API_KEY
|
||||
const API_KEY = process.env.API_KEY
|
||||
const BASE_URL = process.env.BASE_URL || 'https://llmapi.paratera.com/v1'
|
||||
const MODEL = process.env.MODEL || 'Qwen3-235B-A22B'
|
||||
|
||||
const INDEX = [
|
||||
// 语言的名称 代码 用来翻译的模型
|
||||
{ name: 'France', code: 'fr-fr', model: 'Qwen3-235B-A22B' },
|
||||
{ name: 'Spanish', code: 'es-es', model: 'Qwen3-235B-A22B' },
|
||||
{ name: 'Portuguese', code: 'pt-pt', model: 'Qwen3-235B-A22B' },
|
||||
{ name: 'Greek', code: 'el-gr', model: 'Qwen3-235B-A22B' }
|
||||
// 语言的名称代码用来翻译的模型
|
||||
{ name: 'France', code: 'fr-fr', model: MODEL },
|
||||
{ name: 'Spanish', code: 'es-es', model: MODEL },
|
||||
{ name: 'Portuguese', code: 'pt-pt', model: MODEL },
|
||||
{ name: 'Greek', code: 'el-gr', model: MODEL }
|
||||
]
|
||||
|
||||
const fs = require('fs')
|
||||
@ -19,8 +22,8 @@ import OpenAI from 'openai'
|
||||
const zh = JSON.parse(fs.readFileSync('src/renderer/src/i18n/locales/zh-cn.json', 'utf8')) as object
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: Paratera_API_KEY,
|
||||
baseURL: 'https://llmapi.paratera.com/v1'
|
||||
apiKey: API_KEY,
|
||||
baseURL: BASE_URL
|
||||
})
|
||||
|
||||
// 递归遍历翻译
|
||||
|
||||
@ -124,19 +124,27 @@ if (!app.requestSingleInstanceLock()) {
|
||||
registerProtocolClient(app)
|
||||
|
||||
// macOS specific: handle protocol when app is already running
|
||||
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault()
|
||||
handleProtocolUrl(url)
|
||||
})
|
||||
|
||||
const handleOpenUrl = (args: string[]) => {
|
||||
const url = args.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
|
||||
if (url) handleProtocolUrl(url)
|
||||
}
|
||||
|
||||
// for windows to start with url
|
||||
handleOpenUrl(process.argv)
|
||||
|
||||
// Listen for second instance
|
||||
app.on('second-instance', (_event, argv) => {
|
||||
windowService.showMainWindow()
|
||||
|
||||
// Protocol handler for Windows/Linux
|
||||
// The commandLine is an array of strings where the last item might be the URL
|
||||
const url = argv.find((arg) => arg.startsWith(CHERRY_STUDIO_PROTOCOL + '://'))
|
||||
if (url) handleProtocolUrl(url)
|
||||
handleOpenUrl(argv)
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
|
||||
@ -345,11 +345,6 @@ 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 { isWin } from '@main/constant'
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { generateUserAgent } from '@main/utils/systemInfo'
|
||||
import { FeedUrl, UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { CancellationToken, UpdateInfo } from 'builder-util-runtime'
|
||||
@ -24,6 +25,10 @@ export default class AppUpdater {
|
||||
autoUpdater.forceDevUpdateConfig = !app.isPackaged
|
||||
autoUpdater.autoDownload = configManager.getAutoUpdate()
|
||||
autoUpdater.autoInstallOnAppQuit = configManager.getAutoUpdate()
|
||||
autoUpdater.requestHeaders = {
|
||||
...autoUpdater.requestHeaders,
|
||||
'User-Agent': generateUserAgent()
|
||||
}
|
||||
|
||||
autoUpdater.on('error', (error) => {
|
||||
// 简单记录错误信息和时间戳
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
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'
|
||||
@ -11,7 +10,6 @@ import * as path from 'path'
|
||||
import { CreateDirectoryOptions, FileStat } from 'webdav'
|
||||
|
||||
import { getDataPath } from '../utils'
|
||||
import S3Storage from './RemoteStorage'
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
@ -27,11 +25,6 @@ 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> {
|
||||
@ -92,11 +85,7 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.BackupProgress, processData)
|
||||
// 只在关键阶段记录日志:开始、结束和主要阶段转换点
|
||||
const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
Logger.log('[BackupManager] backup progress', processData)
|
||||
}
|
||||
|
||||
try {
|
||||
@ -158,23 +147,18 @@ class BackupManager {
|
||||
let totalBytes = 0
|
||||
let processedBytes = 0
|
||||
|
||||
// 首先计算总文件数和总大小,但不记录详细日志
|
||||
// 首先计算总文件数和总大小
|
||||
const calculateTotals = async (dirPath: string) => {
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,11 +230,7 @@ class BackupManager {
|
||||
|
||||
const onProgress = (processData: { stage: string; progress: number; total: number }) => {
|
||||
mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData)
|
||||
// 只在关键阶段记录日志
|
||||
const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed']
|
||||
if (logStages.includes(processData.stage) || processData.progress === 100) {
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
Logger.log('[BackupManager] restore progress', processData)
|
||||
}
|
||||
|
||||
try {
|
||||
@ -402,54 +382,21 @@ class BackupManager {
|
||||
destination: string,
|
||||
onProgress: (size: number) => void
|
||||
): Promise<void> {
|
||||
// 先统计总文件数
|
||||
let totalFiles = 0
|
||||
let processedFiles = 0
|
||||
let lastProgressReported = 0
|
||||
const items = await fs.readdir(source, { withFileTypes: true })
|
||||
|
||||
// 计算总文件数
|
||||
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
|
||||
}
|
||||
for (const item of items) {
|
||||
const sourcePath = path.join(source, item.name)
|
||||
const destPath = path.join(destination, item.name)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
await copyDir(source, destination)
|
||||
}
|
||||
|
||||
async checkConnection(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
|
||||
@ -476,141 +423,6 @@ 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('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
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('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
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('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
const entries = await s3Client.instance?.list('/')
|
||||
const files: Array<{ fileName: string; modifiedTime: string; size: number }> = []
|
||||
if (entries) {
|
||||
for await (const entry of entries) {
|
||||
const path = entry.path()
|
||||
if (path.endsWith('.zip')) {
|
||||
const meta = await s3Client.instance!.stat(path)
|
||||
if (meta.isFile()) {
|
||||
files.push({
|
||||
fileName: path.replace(/^\/+/, ''),
|
||||
modifiedTime: meta.lastModified || '',
|
||||
size: Number(meta.contentLength || 0n)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
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('s3', {
|
||||
endpoint: s3Config.endpoint,
|
||||
region: s3Config.region,
|
||||
bucket: s3Config.bucket,
|
||||
access_key_id: s3Config.access_key_id,
|
||||
secret_access_key: s3Config.secret_access_key,
|
||||
root: s3Config.root || ''
|
||||
})
|
||||
return await s3Client.checkConnection()
|
||||
}
|
||||
}
|
||||
|
||||
export default BackupManager
|
||||
|
||||
@ -21,10 +21,10 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
|
||||
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/loader'
|
||||
import { NoteLoader } from '@main/loader/noteLoader'
|
||||
import Reranker from '@main/reranker/Reranker'
|
||||
import Embeddings from '@main/knowledage/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledage/loader'
|
||||
import { NoteLoader } from '@main/knowledage/loader/noteLoader'
|
||||
import Reranker from '@main/knowledage/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
|
||||
@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) {
|
||||
}
|
||||
}
|
||||
|
||||
app.setAsDefaultProtocolClient('cherrystudio')
|
||||
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
|
||||
}
|
||||
|
||||
export function handleProtocolUrl(url: string) {
|
||||
|
||||
@ -1,83 +1,57 @@
|
||||
import Logger from 'electron-log'
|
||||
import type { Operator as OperatorType } from 'opendal'
|
||||
const { Operator } = require('opendal')
|
||||
// import Logger from 'electron-log'
|
||||
// import { Operator } from 'opendal'
|
||||
|
||||
export default class S3Storage {
|
||||
public instance: OperatorType | undefined
|
||||
// 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 S3Storage('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)
|
||||
// /**
|
||||
// *
|
||||
// * @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)
|
||||
}
|
||||
// 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')
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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')
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
public deleteFile = async (filename: string) => {
|
||||
if (!this.instance) {
|
||||
throw new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
try {
|
||||
return await this.instance.delete(filename)
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error deleting file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public checkConnection = async () => {
|
||||
if (!this.instance) {
|
||||
throw new Error('RemoteStorage client not initialized')
|
||||
}
|
||||
try {
|
||||
// 检查根目录是否可访问
|
||||
return await this.instance.stat('/')
|
||||
} catch (error) {
|
||||
Logger.error('[RemoteStorage] Error checking connection:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// try {
|
||||
// return await this.instance.read(filename)
|
||||
// } catch (error) {
|
||||
// Logger.error('[RemoteStorage] Error getting file contents:', error)
|
||||
// throw error
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
@ -72,7 +72,8 @@ export class WindowService {
|
||||
webSecurity: false,
|
||||
webviewTag: true,
|
||||
allowRunningInsecureContent: true,
|
||||
zoomFactor: configManager.getZoomFactor()
|
||||
zoomFactor: configManager.getZoomFactor(),
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -1,37 +1,47 @@
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
|
||||
export function handleProvidersProtocolUrl(url: URL) {
|
||||
const params = new URLSearchParams(url.search)
|
||||
export async function handleProvidersProtocolUrl(url: URL) {
|
||||
switch (url.pathname) {
|
||||
case '/api-keys': {
|
||||
// jsonConfig example:
|
||||
// {
|
||||
// "id": "tokenflux",
|
||||
// "baseUrl": "https://tokenflux.ai/v1",
|
||||
// "apiKey": "sk-xxxx"
|
||||
// "apiKey": "sk-xxxx",
|
||||
// "name": "TokenFlux", // optional
|
||||
// "type": "openai" // optional
|
||||
// }
|
||||
// cherrystudio://providers/api-keys?data={base64Encode(JSON.stringify(jsonConfig))}
|
||||
// cherrystudio://providers/api-keys?v=1&data={base64Encode(JSON.stringify(jsonConfig))}
|
||||
|
||||
// replace + and / to _ and - because + and / are processed by URLSearchParams
|
||||
const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-')
|
||||
const params = new URLSearchParams(processedSearch)
|
||||
const data = params.get('data')
|
||||
if (data) {
|
||||
const stringify = Buffer.from(data, 'base64').toString('utf8')
|
||||
Logger.info('get api keys from urlschema: ', stringify)
|
||||
const jsonConfig = JSON.parse(stringify)
|
||||
Logger.info('get api keys from urlschema: ', jsonConfig)
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(IpcChannel.Provider_AddKey, jsonConfig)
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?id=${jsonConfig.id}')`)
|
||||
}
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
const version = params.get('v')
|
||||
if (version == '1') {
|
||||
// TODO: handle different version
|
||||
Logger.info('handleProvidersProtocolUrl', { data, version })
|
||||
}
|
||||
|
||||
// add check there is window.navigate function in mainWindow
|
||||
if (
|
||||
mainWindow &&
|
||||
!mainWindow.isDestroyed() &&
|
||||
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
|
||||
) {
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
|
||||
} else {
|
||||
Logger.error('No data found in URL')
|
||||
setTimeout(() => {
|
||||
handleProvidersProtocolUrl(url)
|
||||
}, 1000)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
console.error(`Unknown MCP protocol URL: ${url}`)
|
||||
Logger.error(`Unknown MCP protocol URL: ${url}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
92
src/main/utils/systemInfo.ts
Normal file
92
src/main/utils/systemInfo.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { app } from 'electron'
|
||||
import macosRelease from 'macos-release'
|
||||
import os from 'os'
|
||||
|
||||
/**
|
||||
* System information interface
|
||||
*/
|
||||
export interface SystemInfo {
|
||||
platform: NodeJS.Platform
|
||||
arch: string
|
||||
osRelease: string
|
||||
appVersion: string
|
||||
osString: string
|
||||
archString: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic system constants for quick access
|
||||
* @returns {Object} Basic system constants
|
||||
*/
|
||||
export function getSystemConstants() {
|
||||
return {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osRelease: os.release(),
|
||||
appVersion: app.getVersion()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system information
|
||||
* @returns {SystemInfo} Complete system information object
|
||||
*/
|
||||
export function getSystemInfo(): SystemInfo {
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
const osRelease = os.release()
|
||||
const appVersion = app.getVersion()
|
||||
|
||||
let osString = ''
|
||||
|
||||
switch (platform) {
|
||||
case 'win32': {
|
||||
// Get Windows version
|
||||
const parts = osRelease.split('.')
|
||||
const buildNumber = parseInt(parts[2], 10)
|
||||
osString = buildNumber >= 22000 ? 'Windows 11' : 'Windows 10'
|
||||
break
|
||||
}
|
||||
case 'darwin': {
|
||||
// macOS version handling using macos-release for better accuracy
|
||||
try {
|
||||
const macVersionInfo = macosRelease()
|
||||
const versionString = macVersionInfo.version.replace(/\./g, '_') // 15.0.0 -> 15_0_0
|
||||
osString = arch === 'arm64' ? `Mac OS X ${versionString}` : `Intel Mac OS X ${versionString}` // Mac OS X 15_0_0
|
||||
} catch (error) {
|
||||
// Fallback to original logic if macos-release fails
|
||||
const macVersion = osRelease.split('.').slice(0, 2).join('_')
|
||||
osString = arch === 'arm64' ? `Mac OS X ${macVersion}` : `Intel Mac OS X ${macVersion}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'linux': {
|
||||
osString = `Linux ${arch}`
|
||||
break
|
||||
}
|
||||
default: {
|
||||
osString = `${platform} ${arch}`
|
||||
}
|
||||
}
|
||||
|
||||
const archString = arch === 'x64' ? 'x86_64' : arch === 'arm64' ? 'arm64' : arch
|
||||
|
||||
return {
|
||||
platform,
|
||||
arch,
|
||||
osRelease,
|
||||
appVersion,
|
||||
osString,
|
||||
archString
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate User-Agent string based on user system data
|
||||
* @returns {string} Dynamically generated User-Agent string
|
||||
*/
|
||||
export function generateUserAgent(): string {
|
||||
const systemInfo = getSystemInfo()
|
||||
|
||||
return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36`
|
||||
}
|
||||
@ -2,16 +2,7 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import {
|
||||
FileType,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
ThemeMode,
|
||||
WebDavConfig
|
||||
} from '@types'
|
||||
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, ThemeMode, WebDavConfig } from '@types'
|
||||
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
import { CreateDirectoryOptions } from 'webdav'
|
||||
@ -80,13 +71,7 @@ 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),
|
||||
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)
|
||||
ipcRenderer.invoke(IpcChannel.Backup_DeleteWebdavFile, fileName, webdavConfig)
|
||||
},
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => ipcRenderer.invoke(IpcChannel.File_Select, options),
|
||||
|
||||
@ -210,6 +210,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
public async getMessageContent(message: Message): Promise<string> {
|
||||
const content = getContentWithTools(message)
|
||||
|
||||
if (isEmpty(content)) {
|
||||
return ''
|
||||
}
|
||||
@ -273,6 +274,7 @@ export abstract class BaseApiClient<
|
||||
const webSearch: WebSearchResponse = window.keyv.get(`web-search-${message.id}`)
|
||||
|
||||
if (webSearch) {
|
||||
window.keyv.remove(`web-search-${message.id}`)
|
||||
return (webSearch.results as WebSearchProviderResponse).results.map(
|
||||
(result, index) =>
|
||||
({
|
||||
@ -298,6 +300,7 @@ export abstract class BaseApiClient<
|
||||
const knowledgeReferences: KnowledgeReference[] = window.keyv.get(`knowledge-search-${message.id}`)
|
||||
|
||||
if (!isEmpty(knowledgeReferences)) {
|
||||
window.keyv.remove(`knowledge-search-${message.id}`)
|
||||
// Logger.log(`Found ${knowledgeReferences.length} knowledge base references in cache for ID: ${message.id}`)
|
||||
return knowledgeReferences
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
import { smartLinkConverter } from '@renderer/utils/linkConverter'
|
||||
import { flushLinkConverterBuffer, smartLinkConverter } from '@renderer/utils/linkConverter'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@ -42,20 +42,46 @@ export const WebSearchMiddleware: CompletionsMiddleware =
|
||||
const providerType = model.provider || 'openai'
|
||||
// 使用当前可用的Web搜索结果进行链接转换
|
||||
const text = chunk.text
|
||||
const processedText = smartLinkConverter(text, providerType, isFirstChunk)
|
||||
const result = smartLinkConverter(text, providerType, isFirstChunk)
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
}
|
||||
controller.enqueue({
|
||||
...chunk,
|
||||
text: processedText
|
||||
})
|
||||
|
||||
// - 如果有内容被缓冲,说明convertLinks正在等待后续chunk,不使用原文本避免重复
|
||||
// - 如果没有内容被缓冲且结果为空,可能是其他处理问题,使用原文本作为安全回退
|
||||
let finalText: string
|
||||
if (result.hasBufferedContent) {
|
||||
// 有内容被缓冲,使用处理后的结果(可能为空,等待后续chunk)
|
||||
finalText = result.text
|
||||
} else {
|
||||
// 没有内容被缓冲,可以安全使用回退逻辑
|
||||
finalText = result.text || text
|
||||
}
|
||||
|
||||
// 只有当finalText不为空时才发送chunk
|
||||
if (finalText) {
|
||||
controller.enqueue({
|
||||
...chunk,
|
||||
text: finalText
|
||||
})
|
||||
}
|
||||
} else if (chunk.type === ChunkType.LLM_WEB_SEARCH_COMPLETE) {
|
||||
// 暂存Web搜索结果用于链接完善
|
||||
ctx._internal.webSearchState!.results = chunk.llm_web_search
|
||||
|
||||
// 将Web搜索完成事件继续传递下去
|
||||
controller.enqueue(chunk)
|
||||
} else if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
// 流结束时,清空链接转换器的buffer并处理剩余内容
|
||||
const remainingText = flushLinkConverterBuffer()
|
||||
if (remainingText) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: remainingText
|
||||
})
|
||||
}
|
||||
// 继续传递LLM_RESPONSE_COMPLETE事件
|
||||
controller.enqueue(chunk)
|
||||
} else {
|
||||
controller.enqueue(chunk)
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Provider } from '@renderer/types'
|
||||
import { oauthWithAihubmix, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth'
|
||||
import { oauthWithAihubmix, oauthWithPPIO, oauthWithSiliconFlow, oauthWithTokenFlux } from '@renderer/utils/oauth'
|
||||
import { Button, ButtonProps } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -28,6 +28,10 @@ const OAuthButton: FC<Props> = ({ provider, onSuccess, ...buttonProps }) => {
|
||||
oauthWithAihubmix(handleSuccess)
|
||||
}
|
||||
|
||||
if (provider.id === 'ppio') {
|
||||
oauthWithPPIO(handleSuccess)
|
||||
}
|
||||
|
||||
if (provider.id === 'tokenflux') {
|
||||
oauthWithTokenFlux()
|
||||
}
|
||||
|
||||
@ -1,298 +0,0 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { restoreFromS3 } from '@renderer/services/BackupService'
|
||||
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 S3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
root?: string
|
||||
}
|
||||
|
||||
interface S3BackupManagerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
s3Config: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
access_key_id?: string
|
||||
secret_access_key?: string
|
||||
root?: string
|
||||
}
|
||||
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, access_key_id, secret_access_key, root } = s3Config
|
||||
|
||||
const fetchBackupFiles = useCallback(async () => {
|
||||
if (!endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
window.message.error(t('settings.data.s3.manager.config.incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const files = await window.api.backup.listS3Files({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
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, access_key_id, secret_access_key, root, t])
|
||||
|
||||
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 || !access_key_id || !secret_access_key) {
|
||||
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(), {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
}
|
||||
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 || !access_key_id || !secret_access_key) {
|
||||
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, {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
} as S3Config)
|
||||
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 || !access_key_id || !secret_access_key) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,258 +0,0 @@
|
||||
import { backupToS3, handleData } 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
|
||||
access_key_id: string | undefined
|
||||
secret_access_key: string | undefined
|
||||
root?: string | undefined
|
||||
}
|
||||
|
||||
export function useS3RestoreModal({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
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 || !access_key_id || !secret_access_key) {
|
||||
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,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root
|
||||
})
|
||||
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, access_key_id, secret_access_key, root, t])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!selectedFile || !endpoint || !region || !bucket || !access_key_id || !secret_access_key) {
|
||||
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'),
|
||||
okText: t('settings.data.s3.restore.confirm.ok'),
|
||||
cancelText: t('settings.data.s3.restore.confirm.cancel'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
const data = await window.api.backup.restoreFromS3({
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
root,
|
||||
fileName: selectedFile
|
||||
})
|
||||
await handleData(JSON.parse(data))
|
||||
window.message.success(t('settings.data.s3.restore.success'))
|
||||
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, access_key_id, secret_access_key, 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
|
||||
}
|
||||
}
|
||||
164
src/renderer/src/components/__tests__/CopyButton.test.tsx
Normal file
164
src/renderer/src/components/__tests__/CopyButton.test.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CopyButton from '../CopyButton'
|
||||
|
||||
// Mock navigator.clipboard
|
||||
const mockWriteText = vi.fn()
|
||||
const mockClipboard = {
|
||||
writeText: mockWriteText
|
||||
}
|
||||
|
||||
// Mock window.message
|
||||
const mockMessage = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
|
||||
// Mock useTranslation
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'message.copy.success': '复制成功',
|
||||
'message.copy.failed': '复制失败'
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('CopyButton', () => {
|
||||
beforeEach(() => {
|
||||
// Setup mocks
|
||||
Object.assign(navigator, { clipboard: mockClipboard })
|
||||
Object.assign(window, { message: mockMessage })
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render with basic structure and copy icon', () => {
|
||||
render(<CopyButton textToCopy="test text" />)
|
||||
|
||||
// Should have basic clickable container
|
||||
const container = document.querySelector('div')
|
||||
expect(container).toBeInTheDocument()
|
||||
|
||||
// Should render copy icon
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
expect(copyIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label when provided', () => {
|
||||
const labelText = 'Copy to clipboard'
|
||||
render(<CopyButton textToCopy="test text" label={labelText} />)
|
||||
|
||||
expect(screen.getByText(labelText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip when provided', async () => {
|
||||
const tooltipText = 'Click to copy'
|
||||
render(<CopyButton textToCopy="test text" tooltip={tooltipText} />)
|
||||
|
||||
// Check that the component structure includes tooltip
|
||||
const container = document.querySelector('div')
|
||||
expect(container).toBeInTheDocument()
|
||||
|
||||
// The tooltip should be rendered when hovered
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
expect(copyIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<CopyButton textToCopy="test text" />)
|
||||
|
||||
// Should not have tooltip wrapper
|
||||
expect(document.querySelector('.ant-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should copy text to clipboard on click', async () => {
|
||||
const textToCopy = 'Hello World'
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
render(<CopyButton textToCopy={textToCopy} />)
|
||||
|
||||
// Find the clickable element by using the copy icon as reference
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
const clickableElement = copyIcon?.parentElement
|
||||
expect(clickableElement).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(clickableElement!)
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(textToCopy)
|
||||
})
|
||||
|
||||
it('should show success message when copy succeeds', async () => {
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
render(<CopyButton textToCopy="test text" />)
|
||||
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
const clickableElement = copyIcon?.parentElement
|
||||
await userEvent.click(clickableElement!)
|
||||
|
||||
expect(mockMessage.success).toHaveBeenCalledWith('复制成功')
|
||||
expect(mockMessage.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error message when copy fails', async () => {
|
||||
mockWriteText.mockRejectedValue(new Error('Clipboard access denied'))
|
||||
|
||||
render(<CopyButton textToCopy="test text" />)
|
||||
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
const clickableElement = copyIcon?.parentElement
|
||||
await userEvent.click(clickableElement!)
|
||||
|
||||
expect(mockMessage.error).toHaveBeenCalledWith('复制失败')
|
||||
expect(mockMessage.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply custom size to icon and label', () => {
|
||||
const customSize = 20
|
||||
const labelText = 'Copy'
|
||||
|
||||
render(<CopyButton textToCopy="test text" size={customSize} label={labelText} />)
|
||||
|
||||
// Should apply custom size to icon
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
expect(copyIcon).toHaveAttribute('width', customSize.toString())
|
||||
expect(copyIcon).toHaveAttribute('height', customSize.toString())
|
||||
|
||||
// Should apply custom size to label
|
||||
const label = screen.getByText(labelText)
|
||||
expect(label).toHaveStyle({ fontSize: `${customSize}px` })
|
||||
})
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
const emptyText = ''
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
render(<CopyButton textToCopy={emptyText} />)
|
||||
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
const clickableElement = copyIcon?.parentElement
|
||||
await userEvent.click(clickableElement!)
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(emptyText)
|
||||
})
|
||||
|
||||
it('should handle special characters', async () => {
|
||||
const specialText = '特殊字符 🎉 @#$%^&*()'
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
render(<CopyButton textToCopy={specialText} />)
|
||||
|
||||
const copyIcon = document.querySelector('.copy-icon')
|
||||
const clickableElement = copyIcon?.parentElement
|
||||
await userEvent.click(clickableElement!)
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(specialText)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,62 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import DividerWithText from '../DividerWithText'
|
||||
|
||||
describe('DividerWithText', () => {
|
||||
it('should render with correct structure and text', () => {
|
||||
const text = 'Section Title'
|
||||
render(<DividerWithText text={text} />)
|
||||
|
||||
// Verify text is rendered
|
||||
const textElement = screen.getByText(text)
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement.tagName).toBe('SPAN')
|
||||
|
||||
// Verify structure
|
||||
const dividerContainer = textElement.parentElement as HTMLElement
|
||||
expect(dividerContainer).toBeTruthy()
|
||||
expect(dividerContainer.tagName).toBe('DIV')
|
||||
expect(dividerContainer.children.length).toBe(2)
|
||||
|
||||
// Verify line element exists
|
||||
const lineElement = dividerContainer.children[1] as HTMLElement
|
||||
expect(lineElement.tagName).toBe('DIV')
|
||||
})
|
||||
|
||||
it('should apply custom styles', () => {
|
||||
const customStyle = {
|
||||
marginTop: '20px',
|
||||
marginBottom: '30px',
|
||||
padding: '10px'
|
||||
}
|
||||
|
||||
const { container } = render(<DividerWithText text="Styled" style={customStyle} />)
|
||||
const dividerContainer = container.firstChild as HTMLElement
|
||||
|
||||
expect(dividerContainer).toHaveStyle(customStyle)
|
||||
})
|
||||
|
||||
it('should handle edge cases for text prop', () => {
|
||||
// Empty string
|
||||
const { container, rerender } = render(<DividerWithText text="" />)
|
||||
const emptySpan = container.querySelector('span')
|
||||
expect(emptySpan).toBeTruthy()
|
||||
expect(emptySpan?.textContent).toBe('')
|
||||
|
||||
// Long text
|
||||
const longText = 'This is a very long section title that might wrap or cause layout issues'
|
||||
rerender(<DividerWithText text={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
|
||||
// Special characters
|
||||
const specialText = '特殊字符 & Symbols: <>&"\'@#$%'
|
||||
rerender(<DividerWithText text={specialText} />)
|
||||
expect(screen.getByText(specialText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<DividerWithText text="Test Divider" />)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
70
src/renderer/src/components/__tests__/EmojiIcon.test.tsx
Normal file
70
src/renderer/src/components/__tests__/EmojiIcon.test.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import EmojiIcon from '../EmojiIcon'
|
||||
|
||||
describe('EmojiIcon', () => {
|
||||
it('should render with provided emoji', () => {
|
||||
const { container } = render(<EmojiIcon emoji="🚀" />)
|
||||
|
||||
// Should render the emoji
|
||||
expect(container.textContent).toContain('🚀')
|
||||
|
||||
// Should also render emoji in background
|
||||
const background = container.querySelector('div > div')
|
||||
expect(background?.textContent).toContain('🚀')
|
||||
})
|
||||
|
||||
it('should render default emoji when no emoji provided', () => {
|
||||
const { container } = render(<EmojiIcon emoji="" />)
|
||||
|
||||
// Background should have default star emoji
|
||||
const background = container.querySelector('div > div')
|
||||
expect(background?.textContent).toContain('⭐️')
|
||||
|
||||
// Foreground should be empty (the actual emoji prop value)
|
||||
const emojiContainer = container.firstChild as HTMLElement
|
||||
// Remove background text to get only foreground text
|
||||
const foregroundText = emojiContainer.textContent?.replace(background?.textContent || '', '')
|
||||
expect(foregroundText).toBe('')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const customClass = 'custom-emoji-class'
|
||||
const { container } = render(<EmojiIcon emoji="😊" className={customClass} />)
|
||||
|
||||
const emojiContainer = container.firstChild as HTMLElement
|
||||
expect(emojiContainer).toHaveClass(customClass)
|
||||
})
|
||||
|
||||
it('should match snapshot', () => {
|
||||
const { container } = render(<EmojiIcon emoji="🎉" />)
|
||||
expect(container.firstChild).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('should handle special emojis correctly', () => {
|
||||
const specialEmojis = ['👨💻', '🏃♀️', '👨👩👧👦', '🇨🇳']
|
||||
|
||||
specialEmojis.forEach((emoji) => {
|
||||
const { container } = render(<EmojiIcon emoji={emoji} />)
|
||||
expect(container.textContent).toContain(emoji)
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply custom size and fontSize props', () => {
|
||||
const { container } = render(<EmojiIcon emoji="🌟" size={40} fontSize={24} />)
|
||||
const emojiContainer = container.firstChild as HTMLElement
|
||||
|
||||
// Verify that the component renders with custom props
|
||||
expect(emojiContainer).toHaveStyle({ width: '40px', height: '40px' })
|
||||
expect(emojiContainer).toHaveStyle({ fontSize: '24px' })
|
||||
})
|
||||
|
||||
it('should handle empty string emoji', () => {
|
||||
const { container } = render(<EmojiIcon emoji="" />)
|
||||
const backgroundElement = container.querySelector('div > div')
|
||||
|
||||
// Should show default emoji in background when emoji is empty
|
||||
expect(backgroundElement?.textContent).toContain('⭐️')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DividerWithText > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0px 0;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-2);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.c2 {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<span
|
||||
class="c1"
|
||||
>
|
||||
Test Divider
|
||||
</span>
|
||||
<div
|
||||
class="c2"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
@ -0,0 +1,42 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`EmojiIcon > should match snapshot 1`] = `
|
||||
.c0 {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 200%;
|
||||
transform: scale(1.5);
|
||||
filter: blur(5px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="c1"
|
||||
>
|
||||
🎉
|
||||
</div>
|
||||
🎉
|
||||
</div>
|
||||
`;
|
||||
@ -11,6 +11,8 @@ export const isWin = platform === 'win32' || platform === 'win64'
|
||||
export const isLinux = platform === 'linux'
|
||||
|
||||
export const SILICON_CLIENT_ID = 'SFaJLLq0y6CAMoyDm81aMu'
|
||||
export const PPIO_CLIENT_ID = '37d0828c96b34936a600b62c'
|
||||
export const PPIO_APP_SECRET = import.meta.env.RENDERER_VITE_PPIO_APP_SECRET || ''
|
||||
export const TOKENFLUX_HOST = 'https://tokenflux.ai'
|
||||
|
||||
// Messages loading configuration
|
||||
|
||||
@ -844,18 +844,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
|
||||
provider: 'ppio',
|
||||
name: 'Qwen3 Reranker 8B',
|
||||
group: 'qwen'
|
||||
},
|
||||
{
|
||||
id: 'thudm/glm-z1-32b-0414',
|
||||
provider: 'ppio',
|
||||
name: 'GLM-Z1 32B',
|
||||
group: 'thudm'
|
||||
},
|
||||
{
|
||||
id: 'thudm/glm-z1-9b-0414',
|
||||
provider: 'ppio',
|
||||
name: 'GLM-Z1 9B',
|
||||
group: 'thudm'
|
||||
}
|
||||
],
|
||||
alayanew: [],
|
||||
|
||||
@ -163,8 +163,9 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
apiKey: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/',
|
||||
apiKey:
|
||||
'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/settings/key-management',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { createSelector } from '@reduxjs/toolkit'
|
||||
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
addModel,
|
||||
addProvider,
|
||||
@ -10,7 +10,6 @@ import {
|
||||
updateProviders
|
||||
} from '@renderer/store/llm'
|
||||
import { Assistant, Model, Provider } from '@renderer/types'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
|
||||
@ -64,17 +63,3 @@ export function useProviderByAssistant(assistant: Assistant) {
|
||||
const { provider } = useProvider(model.provider)
|
||||
return provider
|
||||
}
|
||||
|
||||
// Listen for server changes from main process
|
||||
window.electron.ipcRenderer.on(IpcChannel.Provider_AddKey, (_, data) => {
|
||||
console.log('Received provider key data:', data)
|
||||
const { id, apiKey } = data
|
||||
// for now only suppor tokenflux, but in the future we can support more
|
||||
if (id === 'tokenflux') {
|
||||
if (apiKey) {
|
||||
store.dispatch(updateProvider({ id, apiKey } as Provider))
|
||||
window.message.success('Provider API key updated')
|
||||
console.log('Provider API key updated:', apiKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -1264,70 +1264,6 @@
|
||||
"maxBackups": "Maximum Backups",
|
||||
"maxBackups.unlimited": "Unlimited"
|
||||
},
|
||||
"s3": {
|
||||
"title": "S3 Compatible Storage",
|
||||
"title.help": "Object storage services compatible with AWS S3 API, such as AWS S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, etc.",
|
||||
"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",
|
||||
@ -1730,6 +1666,15 @@
|
||||
"models.quick_assistant_default_tag": "Default",
|
||||
"models.use_model": "Default Model",
|
||||
"models.use_assistant": "Use Assistant",
|
||||
"models.provider_key_confirm_title": "Add Provider API Key",
|
||||
"models.provider_name": "Provider Name",
|
||||
"models.provider_id": "Provider ID",
|
||||
"models.base_url": "Base URL",
|
||||
"models.api_key": "API Key",
|
||||
"models.provider_key_add_confirm": "Do you want to add the API key for {{provider}}?",
|
||||
"models.provider_key_override_confirm": "{{provider}} already has an API key ({{existingKey}}). Do you want to override it with the new key ({{newKey}})?",
|
||||
"models.provider_key_added": "Successfully added API key for {{provider}}",
|
||||
"models.provider_key_overridden": "Successfully updated API key for {{provider}}",
|
||||
"moresetting": "More Settings",
|
||||
"moresetting.check.confirm": "Confirm Selection",
|
||||
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
|
||||
@ -1928,6 +1873,10 @@
|
||||
"subscribe_name.placeholder": "Alternative name used when the downloaded subscription feed has no name.",
|
||||
"subscribe_add_success": "Subscription feed added successfully!",
|
||||
"subscribe_delete": "Delete",
|
||||
"subscribe_add_failed": "Failed to add blacklist subscription",
|
||||
"subscribe_update_success": "Blacklist subscription updated successfully",
|
||||
"subscribe_update_failed": "Failed to update blacklist subscription",
|
||||
"subscribe_source_update_failed": "Failed to update blacklist subscription source",
|
||||
"overwrite": "Override search service",
|
||||
"overwrite_tooltip": "Force use search service instead of LLM",
|
||||
"apikey": "API key",
|
||||
|
||||
@ -1244,70 +1244,6 @@
|
||||
"maxBackups": "最大バックアップ数",
|
||||
"maxBackups.unlimited": "無制限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "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": "接続確認",
|
||||
@ -1718,6 +1654,18 @@
|
||||
"models.quick_assistant_default_tag": "デフォルト",
|
||||
"models.use_model": "デフォルトモデル",
|
||||
"models.use_assistant": "アシスタントの活用",
|
||||
"models.provider_key_confirm_title": "{{provider}} の API キーを追加",
|
||||
"models.provider_name": "プロバイダー名",
|
||||
"models.provider_id": "プロバイダー ID",
|
||||
"models.base_url": "ベース URL",
|
||||
"models.api_key": "API キー",
|
||||
"models.provider_key_add_confirm": "{{provider}} の API キーを追加しますか?",
|
||||
"models.provider_key_already_exists": "{{provider}} には同じ API キーがすでに存在します。追加しません。",
|
||||
"models.provider_key_added": "{{provider}} の API キーを追加しました",
|
||||
"models.provider_key_overridden": "{{provider}} の API キーを更新しました",
|
||||
"models.provider_key_no_change": "{{provider}} の API キーは変更されませんでした",
|
||||
"models.provider_key_add_failed_by_empty_data": "{{provider}} の API キーを追加できませんでした。データが空です。",
|
||||
"models.provider_key_add_failed_by_invalid_data": "{{provider}} の API キーを追加できませんでした。データ形式が無効です。",
|
||||
"moresetting": "詳細設定",
|
||||
"moresetting.check.confirm": "選択を確認",
|
||||
"moresetting.check.warn": "このオプションを選択する際は慎重に行ってください。誤った選択はモデルの誤動作を引き起こす可能性があります!",
|
||||
@ -1940,7 +1888,11 @@
|
||||
"provider_not_found": "プロバイダーが見つかりません",
|
||||
"rag_failed": "RAG に失敗しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe_add_failed": "ブラックリスト購読の追加に失敗しました",
|
||||
"subscribe_update_success": "ブラックリスト購読が正常に更新されました",
|
||||
"subscribe_update_failed": "ブラックリスト購読の更新に失敗しました",
|
||||
"subscribe_source_update_failed": "ブラックリスト購読ソースの更新に失敗しました"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.test_plan.title": "テストプラン",
|
||||
|
||||
@ -1000,7 +1000,7 @@
|
||||
"per_images": "за изображения",
|
||||
"required_field": "Обязательное поле",
|
||||
"uploaded_input": "Загруженный ввод",
|
||||
"prompt_placeholder_en": "[to be translated]:Enter your image description, currently Imagen only supports English prompts"
|
||||
"prompt_placeholder_en": "Введите описание изображения, в настоящее время Imagen поддерживает только английские подсказки"
|
||||
},
|
||||
"prompts": {
|
||||
"explanation": "Объясните мне этот концепт",
|
||||
@ -1262,70 +1262,6 @@
|
||||
"maxBackups": "Максимальное количество резервных копий",
|
||||
"maxBackups.unlimited": "Без ограничений"
|
||||
},
|
||||
"s3": {
|
||||
"title": "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": "Проверить",
|
||||
@ -1718,6 +1654,18 @@
|
||||
"models.quick_assistant_default_tag": "умолчанию",
|
||||
"models.use_model": "модель по умолчанию",
|
||||
"models.use_assistant": "Использование ассистентов",
|
||||
"models.provider_key_confirm_title": "Добавить API ключ для {{provider}}",
|
||||
"models.provider_name": "Имя провайдера",
|
||||
"models.provider_id": "ID провайдера",
|
||||
"models.base_url": "Базовый URL",
|
||||
"models.api_key": "API ключ",
|
||||
"models.provider_key_add_confirm": "Добавить API ключ для {{provider}}?",
|
||||
"models.provider_key_already_exists": "{{provider}} уже существует один и тот же API ключ, не будет добавлен",
|
||||
"models.provider_key_added": "API ключ для {{provider}} успешно добавлен",
|
||||
"models.provider_key_overridden": "API ключ для {{provider}} успешно обновлен",
|
||||
"models.provider_key_no_change": "API ключ для {{provider}} не изменился",
|
||||
"models.provider_key_add_failed_by_empty_data": "Не удалось добавить API ключ для {{provider}}, данные пусты",
|
||||
"models.provider_key_add_failed_by_invalid_data": "Не удалось добавить API ключ для {{provider}}, данные имеют неверный формат",
|
||||
"moresetting": "Дополнительные настройки",
|
||||
"moresetting.check.confirm": "Подтвердить выбор",
|
||||
"moresetting.check.warn": "Пожалуйста, будьте осторожны при выборе этой опции. Неправильный выбор может привести к сбою в работе модели!",
|
||||
@ -1940,7 +1888,11 @@
|
||||
"provider_not_found": "Поставщик не найден",
|
||||
"rag_failed": "RAG не удалось"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe_add_failed": "Не удалось добавить подписку на черный список",
|
||||
"subscribe_update_success": "Подписка на черный список успешно обновлена",
|
||||
"subscribe_update_failed": "Не удалось обновить подписку на черный список",
|
||||
"subscribe_source_update_failed": "Не удалось обновить источник подписки на черный список"
|
||||
},
|
||||
"general.auto_check_update.title": "Автоматическое обновление",
|
||||
"general.test_plan.title": "Тестовый план",
|
||||
|
||||
@ -1264,71 +1264,7 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 用户名",
|
||||
"maxBackups": "最大备份数",
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "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": "请选择要恢复的备份文件"
|
||||
"maxBackups.unlimited": "无限制"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@ -1730,6 +1666,18 @@
|
||||
"models.quick_assistant_default_tag": "默认",
|
||||
"models.use_model": "默认模型",
|
||||
"models.use_assistant": "使用助手",
|
||||
"models.provider_key_confirm_title": "为{{provider}}添加 API 密钥",
|
||||
"models.provider_name": "服务商名称",
|
||||
"models.provider_id": "服务商 ID",
|
||||
"models.base_url": "基础 URL",
|
||||
"models.api_key": "API 密钥",
|
||||
"models.provider_key_add_confirm": "是否要为 {{provider}} 添加 API 密钥?",
|
||||
"models.provider_key_already_exists": "{{provider}} 已存在相同API 密钥, 不会重复添加",
|
||||
"models.provider_key_added": "成功为 {{provider}} 添加 API 密钥",
|
||||
"models.provider_key_overridden": "成功更新 {{provider}} 的 API 密钥",
|
||||
"models.provider_key_no_change": "{{provider}} 的 API 密钥没有变化",
|
||||
"models.provider_key_add_failed_by_empty_data": "添加服务商 API 密钥失败,数据为空",
|
||||
"models.provider_key_add_failed_by_invalid_data": "添加服务商 API 密钥失败,数据格式错误",
|
||||
"moresetting": "更多设置",
|
||||
"moresetting.check.confirm": "确认勾选",
|
||||
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
|
||||
@ -1958,7 +1906,11 @@
|
||||
"provider_not_found": "未找到服务商",
|
||||
"rag_failed": "RAG 失败"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe_add_failed": "添加黑名单订阅失败",
|
||||
"subscribe_update_success": "黑名单订阅更新成功",
|
||||
"subscribe_update_failed": "更新黑名单订阅失败",
|
||||
"subscribe_source_update_failed": "更新黑名单订阅源失败"
|
||||
},
|
||||
"quickPhrase": {
|
||||
"title": "快捷短语",
|
||||
|
||||
@ -1262,71 +1262,7 @@
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 使用者名稱",
|
||||
"maxBackups": "最大備份數量",
|
||||
"maxBackups.unlimited": "不限"
|
||||
},
|
||||
"s3": {
|
||||
"title": "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": "請選擇要恢復的備份檔案"
|
||||
"maxBackups.unlimited": "無限制"
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@ -1721,6 +1657,18 @@
|
||||
"models.quick_assistant_default_tag": "預設",
|
||||
"models.use_model": "預設模型",
|
||||
"models.use_assistant": "使用助手",
|
||||
"models.provider_key_confirm_title": "為{{provider}}添加 API 密鑰",
|
||||
"models.provider_name": "提供者名稱",
|
||||
"models.provider_id": "提供者 ID",
|
||||
"models.base_url": "基礎 URL",
|
||||
"models.api_key": "API 密鑰",
|
||||
"models.provider_key_add_confirm": "是否要為 {{provider}} 添加 API 密鑰?",
|
||||
"models.provider_key_already_exists": "{{provider}} 已存在相同API 密鑰, 不會重複添加",
|
||||
"models.provider_key_added": "成功為 {{provider}} 添加 API 密鑰",
|
||||
"models.provider_key_overridden": "成功更新 {{provider}} 的 API 密鑰",
|
||||
"models.provider_key_no_change": "{{provider}} 的 API 密鑰沒有變化",
|
||||
"models.provider_key_add_failed_by_empty_data": "添加提供者 API 密鑰失敗,數據為空",
|
||||
"models.provider_key_add_failed_by_invalid_data": "添加提供者 API 密鑰失敗,數據格式錯誤",
|
||||
"moresetting": "更多設定",
|
||||
"moresetting.check.confirm": "確認勾選",
|
||||
"moresetting.check.warn": "請謹慎勾選此選項,勾選錯誤會導致模型無法正常使用!!!",
|
||||
@ -1942,7 +1890,11 @@
|
||||
"provider_not_found": "未找到服務商",
|
||||
"rag_failed": "RAG 失敗"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe_add_failed": "加入黑名單訂閱失敗",
|
||||
"subscribe_update_success": "黑名單訂閱更新成功",
|
||||
"subscribe_update_failed": "更新黑名單訂閱失敗",
|
||||
"subscribe_source_update_failed": "更新黑名單訂閱來源失敗"
|
||||
},
|
||||
"general.auto_check_update.title": "自動更新",
|
||||
"general.test_plan.title": "測試計畫",
|
||||
|
||||
@ -12,9 +12,9 @@ function initKeyv() {
|
||||
|
||||
function initAutoSync() {
|
||||
setTimeout(() => {
|
||||
const { webdavAutoSync, s3 } = store.getState().settings
|
||||
const { webdavAutoSync } = store.getState().settings
|
||||
const { nutstoreAutoSync } = store.getState().nutstore
|
||||
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||
if (webdavAutoSync) {
|
||||
startAutoSync()
|
||||
}
|
||||
if (nutstoreAutoSync) {
|
||||
|
||||
@ -87,7 +87,7 @@ const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
trigger="hover"
|
||||
trigger="click"
|
||||
styles={{
|
||||
body: {
|
||||
padding: '0 0 8px 0'
|
||||
@ -182,7 +182,6 @@ const KnowledgeCitation: React.FC<{ citation: Citation }> = ({ citation }) => {
|
||||
<CitationLink className="text-nowrap" href={citation.url} onClick={(e) => handleLinkClick(citation.url, e)}>
|
||||
{citation.title}
|
||||
</CitationLink>
|
||||
|
||||
<CitationIndex>{citation.number}</CitationIndex>
|
||||
{citation.content && <CopyButton content={citation.content} />}
|
||||
</WebSearchCardHeader>
|
||||
|
||||
@ -352,7 +352,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
return () => true
|
||||
}
|
||||
const state = store.getState()
|
||||
const topicMessages = selectMessagesForTopic(state, topic.id)
|
||||
const topicMessages: Message[] = selectMessagesForTopic(state, topic.id)
|
||||
// 理论上助手消息只会关联一条用户消息
|
||||
const relatedUserMessage = topicMessages.find((msg) => {
|
||||
return msg.role === 'user' && message.askId === msg.id
|
||||
@ -366,7 +366,11 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
messageBlocksSelectors.selectById(store.getState(), msgBlockId)
|
||||
)
|
||||
|
||||
if (relatedUserMessageBlocks.some((block) => block.type === MessageBlockType.IMAGE)) {
|
||||
if (!relatedUserMessageBlocks) {
|
||||
return () => true
|
||||
}
|
||||
|
||||
if (relatedUserMessageBlocks.some((block) => block && block.type === MessageBlockType.IMAGE)) {
|
||||
return (m: Model) => isVisionModel(m)
|
||||
} else {
|
||||
return () => true
|
||||
|
||||
@ -1,233 +1,90 @@
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, RedoOutlined } from '@ant-design/icons'
|
||||
import { RedoOutlined } from '@ant-design/icons'
|
||||
import CustomTag from '@renderer/components/CustomTag'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { NavbarIcon } from '@renderer/pages/home/ChatNavbar'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Alert, Button, Dropdown, Empty, message, Tag, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronsDown, ChevronsUp, Plus, Search, Settings2 } from 'lucide-react'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { KnowledgeBase } from '@renderer/types'
|
||||
import { Button, Empty, Tabs, Tag, Tooltip } from 'antd'
|
||||
import { Book, Folder, Globe, Link, Notebook, Search, Settings } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CustomCollapse from '../../components/CustomCollapse'
|
||||
import FileItem from '../files/FileItem'
|
||||
import { NavbarIcon } from '../home/ChatNavbar'
|
||||
import KnowledgeSearchPopup from './components/KnowledgeSearchPopup'
|
||||
import KnowledgeSettingsPopup from './components/KnowledgeSettingsPopup'
|
||||
import StatusIcon from './components/StatusIcon'
|
||||
|
||||
const { Dragger } = Upload
|
||||
import KnowledgeDirectories from './items/KnowledgeDirectories'
|
||||
import KnowledgeFiles from './items/KnowledgeFiles'
|
||||
import KnowledgeNotes from './items/KnowledgeNotes'
|
||||
import KnowledgeSitemaps from './items/KnowledgeSitemaps'
|
||||
import KnowledgeUrls from './items/KnowledgeUrls'
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
const [expandAll, setExpandAll] = useState(false)
|
||||
|
||||
const {
|
||||
base,
|
||||
noteItems,
|
||||
fileItems,
|
||||
urlItems,
|
||||
sitemapItems,
|
||||
directoryItems,
|
||||
addFiles,
|
||||
updateNoteContent,
|
||||
refreshItem,
|
||||
addUrl,
|
||||
addSitemap,
|
||||
removeItem,
|
||||
getProcessingStatus,
|
||||
getDirectoryProcessingPercent,
|
||||
addNote,
|
||||
addDirectory,
|
||||
updateItem
|
||||
} = useKnowledge(selectedBase.id || '')
|
||||
const { base, urlItems, fileItems, directoryItems, noteItems, sitemapItems } = useKnowledge(selectedBase.id || '')
|
||||
const [activeKey, setActiveKey] = useState('files')
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
const knowledgeItems = [
|
||||
{
|
||||
key: 'files',
|
||||
title: t('files.title'),
|
||||
icon: activeKey === 'files' ? <Book size={16} color="var(--color-primary)" /> : <Book size={16} />,
|
||||
items: fileItems,
|
||||
content: <KnowledgeFiles selectedBase={selectedBase} />
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
title: t('knowledge.notes'),
|
||||
icon: activeKey === 'notes' ? <Notebook size={16} color="var(--color-primary)" /> : <Notebook size={16} />,
|
||||
items: noteItems,
|
||||
content: <KnowledgeNotes selectedBase={selectedBase} />
|
||||
},
|
||||
{
|
||||
key: 'directories',
|
||||
title: t('knowledge.directories'),
|
||||
icon: activeKey === 'directories' ? <Folder size={16} color="var(--color-primary)" /> : <Folder size={16} />,
|
||||
items: directoryItems,
|
||||
content: <KnowledgeDirectories selectedBase={selectedBase} />
|
||||
},
|
||||
{
|
||||
key: 'urls',
|
||||
title: t('knowledge.urls'),
|
||||
icon: activeKey === 'urls' ? <Link size={16} color="var(--color-primary)" /> : <Link size={16} />,
|
||||
items: urlItems,
|
||||
content: <KnowledgeUrls selectedBase={selectedBase} />
|
||||
},
|
||||
{
|
||||
key: 'sitemaps',
|
||||
title: t('knowledge.sitemaps'),
|
||||
icon: activeKey === 'sitemaps' ? <Globe size={16} color="var(--color-primary)" /> : <Globe size={16} />,
|
||||
items: sitemapItems,
|
||||
content: <KnowledgeSitemaps selectedBase={selectedBase} />
|
||||
}
|
||||
]
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getProgressingPercentForItem = (itemId: string) => getDirectoryProcessingPercent(itemId)
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = fileTypes.join(',')
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
files && handleDrop(Array.from(files))
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handleDrop = async (files: File[]) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (files) {
|
||||
const _files: FileType[] = files
|
||||
.map((file) => ({
|
||||
id: file.name,
|
||||
name: file.name,
|
||||
path: window.api.file.getPathForFile(file),
|
||||
size: file.size,
|
||||
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
|
||||
count: 1,
|
||||
origin_name: file.name,
|
||||
type: file.type as FileTypes,
|
||||
created_at: new Date().toISOString()
|
||||
}))
|
||||
.filter(({ ext }) => fileTypes.includes(ext))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
addFiles(uploadedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const urlInput = await PromptPopup.show({
|
||||
title: t('knowledge.add_url'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.url_placeholder'),
|
||||
inputProps: {
|
||||
rows: 10,
|
||||
onPressEnter: () => {}
|
||||
}
|
||||
})
|
||||
|
||||
if (urlInput) {
|
||||
// Split input by newlines and filter out empty lines
|
||||
const urls = urlInput.split('\n').filter((url) => url.trim())
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url.trim())
|
||||
if (!urlItems.find((item) => item.content === url.trim())) {
|
||||
addUrl(url.trim())
|
||||
} else {
|
||||
message.success(t('knowledge.url_added'))
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid URLs silently
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSitemap = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = await PromptPopup.show({
|
||||
title: t('knowledge.add_sitemap'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.sitemap_placeholder'),
|
||||
inputProps: {
|
||||
maxLength: 1000,
|
||||
rows: 1
|
||||
}
|
||||
})
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
new URL(url)
|
||||
if (sitemapItems.find((item) => item.content === url)) {
|
||||
message.success(t('knowledge.sitemap_added'))
|
||||
return
|
||||
}
|
||||
addSitemap(url)
|
||||
} catch (e) {
|
||||
console.error('Invalid Sitemap URL:', url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } })
|
||||
note && addNote(note)
|
||||
}
|
||||
|
||||
const handleEditNote = async (note: any) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } })
|
||||
editedText && updateNoteContent(note.id, editedText)
|
||||
}
|
||||
|
||||
const handleAddDirectory = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = await window.api.file.selectFolder()
|
||||
Logger.log('[KnowledgeContent] Selected directory:', path)
|
||||
path && addDirectory(path)
|
||||
}
|
||||
|
||||
const handleEditRemark = async (item: KnowledgeItem) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editedRemark: string | undefined = await PromptPopup.show({
|
||||
title: t('knowledge.edit_remark'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.edit_remark_placeholder'),
|
||||
defaultValue: item.remark || '',
|
||||
inputProps: {
|
||||
maxLength: 100,
|
||||
rows: 1
|
||||
}
|
||||
})
|
||||
|
||||
if (editedRemark !== undefined && editedRemark !== null) {
|
||||
updateItem({
|
||||
...item,
|
||||
remark: editedRemark,
|
||||
updated_at: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
const tabItems = knowledgeItems.map((item) => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<TabLabel>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
<CustomTag size={10} color={item.items.length > 0 ? '#00b96b' : '#cccccc'}>
|
||||
{item.items.length}
|
||||
</CustomTag>
|
||||
</TabLabel>
|
||||
),
|
||||
children: <TabContent>{item.content}</TabContent>
|
||||
}))
|
||||
|
||||
return (
|
||||
<MainContainer>
|
||||
@ -235,7 +92,7 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<ModelInfo>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Settings2 size={18} color="var(--color-icon)" />}
|
||||
icon={<Settings size={18} color="var(--color-icon)" />}
|
||||
onClick={() => KnowledgeSettingsPopup.show({ base })}
|
||||
size="small"
|
||||
/>
|
||||
@ -245,16 +102,10 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
</div>
|
||||
<Tooltip title={providerName} placement="bottom">
|
||||
<div className="tag-column">
|
||||
<Tag color="green" style={{ borderRadius: 20, margin: 0 }}>
|
||||
{base.model.name}
|
||||
</Tag>
|
||||
<Tag style={{ borderRadius: 20, margin: 0 }}>{base.model.name}</Tag>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{base.rerankModel && (
|
||||
<Tag color="cyan" style={{ borderRadius: 20, margin: 0 }}>
|
||||
{base.rerankModel.name}
|
||||
</Tag>
|
||||
)}
|
||||
{base.rerankModel && <Tag style={{ borderRadius: 20, margin: 0 }}>{base.rerankModel.name}</Tag>}
|
||||
</div>
|
||||
</ModelInfo>
|
||||
<HStack gap={8} alignItems="center">
|
||||
@ -262,353 +113,19 @@ const KnowledgeContent: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
<NarrowIcon onClick={() => base && KnowledgeSearchPopup.show({ base: base })}>
|
||||
<Search size={18} />
|
||||
</NarrowIcon>
|
||||
<Tooltip title={expandAll ? t('common.collapse') : t('common.expand')}>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
onClick={() => setExpandAll(!expandAll)}
|
||||
icon={expandAll ? <ChevronsUp size={14} /> : <ChevronsDown size={14} />}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</HeaderContainer>
|
||||
<MainContent>
|
||||
{!base?.version && (
|
||||
<Alert message={t('knowledge.not_support')} type="error" style={{ marginBottom: 20 }} showIcon />
|
||||
)}
|
||||
{!providerName && (
|
||||
<Alert message={t('knowledge.no_provider')} type="error" style={{ marginBottom: 20 }} showIcon />
|
||||
)}
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('files.title')} count={fileItems.length} />}
|
||||
defaultActiveKey={['1']}
|
||||
activeKey={expandAll ? ['1'] : undefined}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_file')}
|
||||
</Button>
|
||||
}>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}
|
||||
style={{ marginTop: 10, background: 'transparent' }}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
<FlexColumn>
|
||||
{fileItems.length === 0 ? (
|
||||
<EmptyView />
|
||||
) : (
|
||||
<VirtualList
|
||||
data={fileItems.reverse()}
|
||||
height={fileItems.length > 5 ? 400 : fileItems.length * 75}
|
||||
itemHeight={75}
|
||||
itemKey="id"
|
||||
styles={{
|
||||
verticalScrollBar: {
|
||||
width: 6
|
||||
},
|
||||
verticalScrollBarThumb: {
|
||||
background: 'var(--color-scrollbar-thumb)'
|
||||
}
|
||||
}}>
|
||||
{(item) => {
|
||||
const file = item.content as FileType
|
||||
return (
|
||||
<div style={{ height: '75px', paddingTop: '12px' }}>
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: file.ext,
|
||||
extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && (
|
||||
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
|
||||
)}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="file"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</VirtualList>
|
||||
)}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.directories')} count={directoryItems.length} />}
|
||||
defaultActiveKey={[]}
|
||||
activeKey={expandAll ? ['1'] : undefined}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddDirectory()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_directory')}
|
||||
</Button>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{directoryItems.length === 0 && <EmptyView />}
|
||||
{directoryItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.folder',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
getProcessingPercent={getProgressingPercentForItem}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.urls')} count={urlItems.length} />}
|
||||
defaultActiveKey={[]}
|
||||
activeKey={expandAll ? ['1'] : undefined}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddUrl()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_url')}
|
||||
</Button>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{urlItems.length === 0 && <EmptyView />}
|
||||
{urlItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: t('knowledge.edit_remark'),
|
||||
onClick: () => handleEditRemark(item)
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <CopyOutlined />,
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(item.content as string)
|
||||
message.success(t('message.copied'))
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.remark || (item.content as string)}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
),
|
||||
ext: '.url',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="url"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.sitemaps')} count={sitemapItems.length} />}
|
||||
defaultActiveKey={[]}
|
||||
activeKey={expandAll ? ['1'] : undefined}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddSitemap()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_sitemap')}
|
||||
</Button>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{sitemapItems.length === 0 && <EmptyView />}
|
||||
{sitemapItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.sitemap',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
|
||||
<CustomCollapse
|
||||
label={<CollapseLabel label={t('knowledge.notes')} count={noteItems.length} />}
|
||||
defaultActiveKey={[]}
|
||||
activeKey={expandAll ? ['1'] : undefined}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_note')}
|
||||
</Button>
|
||||
}>
|
||||
<FlexColumn>
|
||||
{noteItems.length === 0 && <EmptyView />}
|
||||
{noteItems.reverse().map((note) => (
|
||||
<FileItem
|
||||
key={note.id}
|
||||
fileInfo={{
|
||||
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||
ext: '.txt',
|
||||
extra: getDisplayTime(note),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={note.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="note"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FlexColumn>
|
||||
</CustomCollapse>
|
||||
</MainContent>
|
||||
<StyledTabs activeKey={activeKey} onChange={setActiveKey} items={tabItems} type="line" size="small" />
|
||||
</MainContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const EmptyView = () => <Empty style={{ margin: 0 }} styles={{ image: { display: 'none' } }} />
|
||||
export const KnowledgeEmptyView = () => <Empty style={{ margin: 20 }} styles={{ image: { display: 'none' } }} />
|
||||
|
||||
const CollapseLabel = ({ label, count }: { label: string; count: number }) => {
|
||||
export const ItemHeaderLabel = ({ label }: { label: string }) => {
|
||||
return (
|
||||
<HStack alignItems="center" gap={10}>
|
||||
<label style={{ fontWeight: 600 }}>{label}</label>
|
||||
<CustomTag size={12} color={count ? '#008001' : '#cccccc'}>
|
||||
{count}
|
||||
</CustomTag>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
@ -620,14 +137,55 @@ const MainContainer = styled.div`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const MainContent = styled(Scrollbar)`
|
||||
padding: 15px 20px;
|
||||
const TabLabel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
`
|
||||
|
||||
const TabContent = styled.div``
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
flex: 1;
|
||||
gap: 20px;
|
||||
padding-bottom: 50px;
|
||||
padding-right: 12px;
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 12px 12px;
|
||||
margin-right: 0;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
position: initial !important;
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
height: 2px;
|
||||
}
|
||||
`
|
||||
|
||||
const HeaderContainer = styled.div`
|
||||
@ -645,7 +203,7 @@ const ModelInfo = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 50px;
|
||||
height: 45px;
|
||||
|
||||
.model-header {
|
||||
display: flex;
|
||||
@ -675,26 +233,32 @@ const ModelInfo = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const FlexColumn = styled.div`
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export const ItemContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
`
|
||||
|
||||
const FlexAlignCenter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const ClickableSpan = styled.span`
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
`
|
||||
|
||||
const StatusIconWrapper = styled.div`
|
||||
export const ItemHeader = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
top: calc(var(--navbar-height) + 14px);
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
`
|
||||
|
||||
export const StatusIconWrapper = styled.div`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
@ -703,15 +267,21 @@ const StatusIconWrapper = styled.div`
|
||||
padding-top: 2px;
|
||||
`
|
||||
|
||||
const RefreshIcon = styled(RedoOutlined)`
|
||||
export const RefreshIcon = styled(RedoOutlined)`
|
||||
font-size: 15px !important;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
const NarrowIcon = styled(NavbarIcon)`
|
||||
@media (max-width: 1000px) {
|
||||
display: none;
|
||||
}
|
||||
export const ClickableSpan = styled.span`
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
`
|
||||
|
||||
export const FlexAlignCenter = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
export default KnowledgeContent
|
||||
|
||||
@ -96,14 +96,7 @@ const KnowledgePage: FC = () => {
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('knowledge.title')}</NavbarCenter>
|
||||
</NavbarMain>
|
||||
<ContentContainer id="content-container">
|
||||
{bases.length === 0 ? (
|
||||
<MainContent>
|
||||
<Empty description={t('knowledge.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</MainContent>
|
||||
) : selectedBase ? (
|
||||
<KnowledgeContent selectedBase={selectedBase} />
|
||||
) : null}
|
||||
<SideNav>
|
||||
<KnowledgeSideNav>
|
||||
<ScrollContainer>
|
||||
<DragableList
|
||||
list={bases}
|
||||
@ -134,7 +127,14 @@ const KnowledgePage: FC = () => {
|
||||
)}
|
||||
<div style={{ minHeight: '10px' }}></div>
|
||||
</ScrollContainer>
|
||||
</SideNav>
|
||||
</KnowledgeSideNav>
|
||||
{bases.length === 0 ? (
|
||||
<MainContent>
|
||||
<Empty description={t('knowledge.empty')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</MainContent>
|
||||
) : selectedBase ? (
|
||||
<KnowledgeContent selectedBase={selectedBase} />
|
||||
) : null}
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
)
|
||||
@ -162,7 +162,7 @@ const MainContent = styled(Scrollbar)`
|
||||
padding-bottom: 50px;
|
||||
`
|
||||
|
||||
const SideNav = styled.div`
|
||||
export const KnowledgeSideNav = styled.div`
|
||||
min-width: var(--settings-width);
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
padding: 12px 10px;
|
||||
|
||||
128
src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx
Normal file
128
src/renderer/src/pages/knowledge/items/KnowledgeDirectories.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import StatusIcon from '../components/StatusIcon'
|
||||
import {
|
||||
ClickableSpan,
|
||||
FlexAlignCenter,
|
||||
ItemContainer,
|
||||
ItemHeader,
|
||||
KnowledgeEmptyView,
|
||||
RefreshIcon,
|
||||
StatusIconWrapper
|
||||
} from '../KnowledgeContent'
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeDirectories: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
base,
|
||||
directoryItems,
|
||||
refreshItem,
|
||||
removeItem,
|
||||
getProcessingStatus,
|
||||
getDirectoryProcessingPercent,
|
||||
addDirectory
|
||||
} = useKnowledge(selectedBase.id || '')
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getProgressingPercentForItem = (itemId: string) => getDirectoryProcessingPercent(itemId)
|
||||
|
||||
const handleAddDirectory = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = await window.api.file.selectFolder()
|
||||
Logger.log('[KnowledgeContent] Selected directory:', path)
|
||||
path && addDirectory(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemContainer>
|
||||
<ItemHeader>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddDirectory()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_directory')}
|
||||
</Button>
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{directoryItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{directoryItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(item.content as string)}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={item.content as string}>{item.content as string}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.folder',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
getProcessingPercent={getProgressingPercentForItem}
|
||||
type="directory"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
export default KnowledgeDirectories
|
||||
194
src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx
Normal file
194
src/renderer/src/pages/knowledge/items/KnowledgeFiles.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import StatusIcon from '@renderer/pages/knowledge/components/StatusIcon'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { FileType, FileTypes, KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { bookExts, documentExts, textExts, thirdPartyApplicationExts } from '@shared/config/constant'
|
||||
import { Button, Tooltip, Upload } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import VirtualList from 'rc-virtual-list'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import {
|
||||
ClickableSpan,
|
||||
FlexAlignCenter,
|
||||
ItemContainer,
|
||||
ItemHeader,
|
||||
KnowledgeEmptyView,
|
||||
RefreshIcon,
|
||||
StatusIconWrapper
|
||||
} from '../KnowledgeContent'
|
||||
|
||||
const { Dragger } = Upload
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const fileTypes = [...bookExts, ...thirdPartyApplicationExts, ...documentExts, ...textExts]
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeFiles: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight)
|
||||
|
||||
const { base, fileItems, addFiles, refreshItem, removeItem, getProcessingStatus } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowHeight(window.innerHeight)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddFile = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = fileTypes.join(',')
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files
|
||||
files && handleDrop(Array.from(files))
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
const handleDrop = async (files: File[]) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (files) {
|
||||
const _files: FileType[] = files
|
||||
.map((file) => ({
|
||||
id: file.name,
|
||||
name: file.name,
|
||||
path: window.api.file.getPathForFile(file),
|
||||
size: file.size,
|
||||
ext: `.${file.name.split('.').pop()}`.toLowerCase(),
|
||||
count: 1,
|
||||
origin_name: file.name,
|
||||
type: file.type as FileTypes,
|
||||
created_at: new Date().toISOString()
|
||||
}))
|
||||
.filter(({ ext }) => fileTypes.includes(ext))
|
||||
const uploadedFiles = await FileManager.uploadFiles(_files)
|
||||
addFiles(uploadedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemContainer>
|
||||
<ItemHeader>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddFile()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_file')}
|
||||
</Button>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemFlexColumn>
|
||||
<Dragger
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleDrop([file as File])}
|
||||
multiple={true}
|
||||
accept={fileTypes.join(',')}>
|
||||
<p className="ant-upload-text">{t('knowledge.drag_file')}</p>
|
||||
<p className="ant-upload-hint">
|
||||
{t('knowledge.file_hint', { file_types: 'TXT, MD, HTML, PDF, DOCX, PPTX, XLSX, EPUB...' })}
|
||||
</p>
|
||||
</Dragger>
|
||||
{fileItems.length === 0 ? (
|
||||
<KnowledgeEmptyView />
|
||||
) : (
|
||||
<VirtualList
|
||||
data={fileItems.reverse()}
|
||||
height={windowHeight - 270}
|
||||
itemHeight={75}
|
||||
itemKey="id"
|
||||
styles={{
|
||||
verticalScrollBar: { width: 6 },
|
||||
verticalScrollBarThumb: { background: 'var(--color-scrollbar-thumb)' }
|
||||
}}>
|
||||
{(item) => {
|
||||
const file = item.content as FileType
|
||||
return (
|
||||
<div style={{ height: '75px', paddingTop: '12px' }}>
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan onClick={() => window.api.file.openPath(FileManager.getFilePath(file))}>
|
||||
<Ellipsis>
|
||||
<Tooltip title={file.origin_name}>{file.origin_name}</Tooltip>
|
||||
</Ellipsis>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: file.ext,
|
||||
extra: `${getDisplayTime(item)} · ${formatFileSize(file.size)}`,
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && (
|
||||
<Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />
|
||||
)}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="file"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</VirtualList>
|
||||
)}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 16px;
|
||||
gap: 10px;
|
||||
`
|
||||
|
||||
export default KnowledgeFiles
|
||||
107
src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx
Normal file
107
src/renderer/src/pages/knowledge/items/KnowledgeNotes.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import TextEditPopup from '@renderer/components/Popups/TextEditPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import StatusIcon from '../components/StatusIcon'
|
||||
import { FlexAlignCenter, ItemContainer, ItemHeader, KnowledgeEmptyView, StatusIconWrapper } from '../KnowledgeContent'
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeNotes: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { base, noteItems, updateNoteContent, removeItem, getProcessingStatus, addNote } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
)
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const note = await TextEditPopup.show({ text: '', textareaProps: { rows: 20 } })
|
||||
note && addNote(note)
|
||||
}
|
||||
|
||||
const handleEditNote = async (note: any) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editedText = await TextEditPopup.show({ text: note.content as string, textareaProps: { rows: 20 } })
|
||||
editedText && updateNoteContent(note.id, editedText)
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemContainer>
|
||||
<ItemHeader>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddNote()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_note')}
|
||||
</Button>
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{noteItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{noteItems.reverse().map((note) => (
|
||||
<FileItem
|
||||
key={note.id}
|
||||
fileInfo={{
|
||||
name: <span onClick={() => handleEditNote(note)}>{(note.content as string).slice(0, 50)}...</span>,
|
||||
ext: '.txt',
|
||||
extra: getDisplayTime(note),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
<Button type="text" onClick={() => handleEditNote(note)} icon={<EditOutlined />} />
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={note.id} base={base} getProcessingStatus={getProcessingStatus} type="note" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(note)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
export default KnowledgeNotes
|
||||
142
src/renderer/src/pages/knowledge/items/KnowledgeSitemaps.tsx
Normal file
142
src/renderer/src/pages/knowledge/items/KnowledgeSitemaps.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, message, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import StatusIcon from '../components/StatusIcon'
|
||||
import {
|
||||
ClickableSpan,
|
||||
FlexAlignCenter,
|
||||
ItemContainer,
|
||||
ItemHeader,
|
||||
KnowledgeEmptyView,
|
||||
RefreshIcon,
|
||||
StatusIconWrapper
|
||||
} from '../KnowledgeContent'
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeSitemaps: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { base, sitemapItems, refreshItem, addSitemap, removeItem, getProcessingStatus } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
)
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddSitemap = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = await PromptPopup.show({
|
||||
title: t('knowledge.add_sitemap'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.sitemap_placeholder'),
|
||||
inputProps: {
|
||||
maxLength: 1000,
|
||||
rows: 1
|
||||
}
|
||||
})
|
||||
|
||||
if (url) {
|
||||
try {
|
||||
new URL(url)
|
||||
if (sitemapItems.find((item) => item.content === url)) {
|
||||
message.success(t('knowledge.sitemap_added'))
|
||||
return
|
||||
}
|
||||
addSitemap(url)
|
||||
} catch (e) {
|
||||
console.error('Invalid Sitemap URL:', url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemContainer>
|
||||
<ItemHeader>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddSitemap()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_sitemap')}
|
||||
</Button>
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{sitemapItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{sitemapItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.content as string}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
),
|
||||
ext: '.sitemap',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon
|
||||
sourceId={item.id}
|
||||
base={base}
|
||||
getProcessingStatus={getProcessingStatus}
|
||||
type="sitemap"
|
||||
/>
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
export default KnowledgeSitemaps
|
||||
190
src/renderer/src/pages/knowledge/items/KnowledgeUrls.tsx
Normal file
190
src/renderer/src/pages/knowledge/items/KnowledgeUrls.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import Ellipsis from '@renderer/components/Ellipsis'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useKnowledge } from '@renderer/hooks/useKnowledge'
|
||||
import FileItem from '@renderer/pages/files/FileItem'
|
||||
import { getProviderName } from '@renderer/services/ProviderService'
|
||||
import { KnowledgeBase, KnowledgeItem } from '@renderer/types'
|
||||
import { Button, Dropdown, message, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import StatusIcon from '../components/StatusIcon'
|
||||
import {
|
||||
ClickableSpan,
|
||||
FlexAlignCenter,
|
||||
ItemContainer,
|
||||
ItemHeader,
|
||||
KnowledgeEmptyView,
|
||||
RefreshIcon,
|
||||
StatusIconWrapper
|
||||
} from '../KnowledgeContent'
|
||||
|
||||
interface KnowledgeContentProps {
|
||||
selectedBase: KnowledgeBase
|
||||
}
|
||||
|
||||
const getDisplayTime = (item: KnowledgeItem) => {
|
||||
const timestamp = item.updated_at && item.updated_at > item.created_at ? item.updated_at : item.created_at
|
||||
return dayjs(timestamp).format('MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const KnowledgeUrls: FC<KnowledgeContentProps> = ({ selectedBase }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { base, urlItems, refreshItem, addUrl, removeItem, getProcessingStatus, updateItem } = useKnowledge(
|
||||
selectedBase.id || ''
|
||||
)
|
||||
|
||||
const providerName = getProviderName(base?.model.provider || '')
|
||||
const disabled = !base?.version || !providerName
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const urlInput = await PromptPopup.show({
|
||||
title: t('knowledge.add_url'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.url_placeholder'),
|
||||
inputProps: {
|
||||
rows: 10,
|
||||
onPressEnter: () => {}
|
||||
}
|
||||
})
|
||||
|
||||
if (urlInput) {
|
||||
// Split input by newlines and filter out empty lines
|
||||
const urls = urlInput.split('\n').filter((url) => url.trim())
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
new URL(url.trim())
|
||||
if (!urlItems.find((item) => item.content === url.trim())) {
|
||||
addUrl(url.trim())
|
||||
} else {
|
||||
message.success(t('knowledge.url_added'))
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid URLs silently
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditRemark = async (item: KnowledgeItem) => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editedRemark: string | undefined = await PromptPopup.show({
|
||||
title: t('knowledge.edit_remark'),
|
||||
message: '',
|
||||
inputPlaceholder: t('knowledge.edit_remark_placeholder'),
|
||||
defaultValue: item.remark || '',
|
||||
inputProps: {
|
||||
maxLength: 100,
|
||||
rows: 1
|
||||
}
|
||||
})
|
||||
|
||||
if (editedRemark !== undefined && editedRemark !== null) {
|
||||
updateItem({
|
||||
...item,
|
||||
remark: editedRemark,
|
||||
updated_at: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ItemContainer>
|
||||
<ItemHeader>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={16} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddUrl()
|
||||
}}
|
||||
disabled={disabled}>
|
||||
{t('knowledge.add_url')}
|
||||
</Button>
|
||||
</ItemHeader>
|
||||
<ItemFlexColumn>
|
||||
{urlItems.length === 0 && <KnowledgeEmptyView />}
|
||||
{urlItems.reverse().map((item) => (
|
||||
<FileItem
|
||||
key={item.id}
|
||||
fileInfo={{
|
||||
name: (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: t('knowledge.edit_remark'),
|
||||
onClick: () => handleEditRemark(item)
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
icon: <CopyOutlined />,
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard.writeText(item.content as string)
|
||||
message.success(t('message.copied'))
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
trigger={['contextMenu']}>
|
||||
<ClickableSpan>
|
||||
<Tooltip title={item.content as string}>
|
||||
<Ellipsis>
|
||||
<a href={item.content as string} target="_blank" rel="noopener noreferrer">
|
||||
{item.remark || (item.content as string)}
|
||||
</a>
|
||||
</Ellipsis>
|
||||
</Tooltip>
|
||||
</ClickableSpan>
|
||||
</Dropdown>
|
||||
),
|
||||
ext: '.url',
|
||||
extra: getDisplayTime(item),
|
||||
actions: (
|
||||
<FlexAlignCenter>
|
||||
{item.uniqueId && <Button type="text" icon={<RefreshIcon />} onClick={() => refreshItem(item)} />}
|
||||
<StatusIconWrapper>
|
||||
<StatusIcon sourceId={item.id} base={base} getProcessingStatus={getProcessingStatus} type="url" />
|
||||
</StatusIconWrapper>
|
||||
<Button type="text" danger onClick={() => removeItem(item)} icon={<DeleteOutlined />} />
|
||||
</FlexAlignCenter>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ItemFlexColumn>
|
||||
</ItemContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemFlexColumn = styled(Scrollbar)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
height: calc(100vh - 135px);
|
||||
`
|
||||
|
||||
export default KnowledgeUrls
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CloudSyncOutlined,
|
||||
FileSearchOutlined,
|
||||
FolderOpenOutlined,
|
||||
@ -43,7 +42,6 @@ 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'
|
||||
@ -90,7 +88,6 @@ 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',
|
||||
@ -656,7 +653,6 @@ const DataSettings: FC = () => {
|
||||
)}
|
||||
{menu === 'webdav' && <WebDavSettings />}
|
||||
{menu === 'nutstore' && <NutstoreSettings />}
|
||||
{menu === 's3' && <S3Settings />}
|
||||
{menu === 'export_menu' && <ExportMenuOptions />}
|
||||
{menu === 'markdown_export' && <MarkdownExportSettings />}
|
||||
{menu === 'notion' && <NotionSettings />}
|
||||
@ -690,12 +686,8 @@ const MenuList = styled.div`
|
||||
gap: 5px;
|
||||
width: var(--settings-width);
|
||||
padding: 12px;
|
||||
padding-bottom: 48px;
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
.iconfont {
|
||||
color: var(--color-text-2);
|
||||
line-height: 16px;
|
||||
|
||||
@ -1,276 +0,0 @@
|
||||
import { FolderOpenOutlined, 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 { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { S3Config, setS3 } from '@renderer/store/settings'
|
||||
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 { s3Sync } = useAppSelector((state) => state.backup)
|
||||
|
||||
const onSyncIntervalChange = (value: number) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(setS3({ ...s3, syncInterval: value, autoSync: value !== 0 }))
|
||||
if (value === 0) {
|
||||
stopAutoSync()
|
||||
} else {
|
||||
startAutoSync()
|
||||
}
|
||||
}
|
||||
|
||||
const onMaxBackupsChange = (value: number) => {
|
||||
setMaxBackups(value)
|
||||
dispatch(setS3({ ...s3, maxBackups: value }))
|
||||
}
|
||||
|
||||
const onSkipBackupFilesChange = (value: boolean) => {
|
||||
setSkipBackupFile(value)
|
||||
dispatch(setS3({ ...s3, 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>{t('settings.data.s3.title')}</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(setS3({ ...s3, 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(setS3({ ...s3, 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(setS3({ ...s3, 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(setS3({ ...s3, 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(setS3({ ...s3, 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(setS3({ ...s3, 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={!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,
|
||||
access_key_id: accessKeyId,
|
||||
secret_access_key: secretAccessKey,
|
||||
root
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</SettingGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default S3Settings
|
||||
@ -3,7 +3,7 @@ import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import CustomSelect from '@renderer/components/CustomSelect'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { isEmbeddingModel } from '@renderer/config/models'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { TRANSLATE_PROMPT } from '@renderer/config/prompts'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useAssistants, useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
@ -46,7 +46,7 @@ const ModelSettings: FC = () => {
|
||||
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
|
||||
title: p.name,
|
||||
options: sortBy(p.models, 'name')
|
||||
.filter((m) => !isEmbeddingModel(m))
|
||||
.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
|
||||
.map((m) => ({
|
||||
label: `${m.name} | ${p.isSystem ? t(`provider.${p.id}`) : p.name}`,
|
||||
value: getModelUniqId(m)
|
||||
@ -238,7 +238,7 @@ const StyledButton = styled(Button)<{ selected: boolean }>`
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right-width: 0px; // No right border for the first button when not selected
|
||||
border-right-width: 0; // No right border for the first button when not selected
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
@ -248,6 +248,7 @@ const StyledButton = styled(Button)<{ selected: boolean }>`
|
||||
}
|
||||
|
||||
// Override Ant Design's default hover and focus styles for a cleaner look
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import AiHubMixProviderLogo from '@renderer/assets/images/providers/aihubmix.webp'
|
||||
import PPIOProviderLogo from '@renderer/assets/images/providers/ppio.png'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png'
|
||||
import TokenFluxProviderLogo from '@renderer/assets/images/providers/tokenflux.png'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
@ -21,14 +22,18 @@ interface Props {
|
||||
const PROVIDER_LOGO_MAP = {
|
||||
silicon: SiliconFlowProviderLogo,
|
||||
aihubmix: AiHubMixProviderLogo,
|
||||
ppio: PPIOProviderLogo,
|
||||
tokenflux: TokenFluxProviderLogo
|
||||
}
|
||||
|
||||
const ProviderOAuth: FC<Props> = ({ provider, setApiKey }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const providerWebsite =
|
||||
let providerWebsite =
|
||||
PROVIDER_CONFIG[provider.id]?.api?.url.replace('https://', '').replace('api.', '') || provider.name
|
||||
if (provider.id === 'ppio') {
|
||||
providerWebsite = 'ppio.cn'
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@ -5,10 +5,10 @@ import { getProviderLogo } from '@renderer/config/providers'
|
||||
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
|
||||
import ImageStorage from '@renderer/services/ImageStorage'
|
||||
import { INITIAL_PROVIDERS } from '@renderer/store/llm'
|
||||
import { Provider } from '@renderer/types'
|
||||
import { Provider, ProviderType } from '@renderer/types'
|
||||
import { droppableReorder, generateColorFromChar, getFirstCharacter, uuid } from '@renderer/utils'
|
||||
import { Avatar, Button, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { Search, UserPen } from 'lucide-react'
|
||||
import { Avatar, Button, Card, Dropdown, Input, MenuProps, Tag } from 'antd'
|
||||
import { Eye, EyeOff, Search, UserPen } from 'lucide-react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@ -61,6 +61,206 @@ const ProvidersList: FC = () => {
|
||||
}
|
||||
}, [providers, searchParams])
|
||||
|
||||
// Handle provider add key from URL schema
|
||||
useEffect(() => {
|
||||
const handleProviderAddKey = (data: {
|
||||
id: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
type?: ProviderType
|
||||
name?: string
|
||||
}) => {
|
||||
const { id, apiKey: newApiKey, baseUrl, type, name } = data
|
||||
|
||||
// 查找匹配的 provider
|
||||
let existingProvider = providers.find((p) => p.id === id)
|
||||
const isNewProvider = !existingProvider
|
||||
|
||||
if (!existingProvider) {
|
||||
existingProvider = {
|
||||
id,
|
||||
name: name || id,
|
||||
type: type || 'openai',
|
||||
apiKey: '',
|
||||
apiHost: baseUrl || '',
|
||||
models: [],
|
||||
enabled: true,
|
||||
isSystem: false
|
||||
}
|
||||
}
|
||||
|
||||
const providerDisplayName = existingProvider.isSystem
|
||||
? t(`provider.${existingProvider.id}`)
|
||||
: existingProvider.name
|
||||
|
||||
// 检查是否已有 API Key
|
||||
const hasExistingKey = existingProvider.apiKey && existingProvider.apiKey.trim() !== ''
|
||||
|
||||
// 检查新的 API Key 是否已经存在
|
||||
const existingKeys = hasExistingKey ? existingProvider.apiKey.split(',').map((k) => k.trim()) : []
|
||||
const keyAlreadyExists = existingKeys.includes(newApiKey.trim())
|
||||
|
||||
const confirmMessage = keyAlreadyExists
|
||||
? t('settings.models.provider_key_already_exists', {
|
||||
provider: providerDisplayName,
|
||||
key: '*********'
|
||||
})
|
||||
: t('settings.models.provider_key_add_confirm', {
|
||||
provider: providerDisplayName,
|
||||
newKey: '*********'
|
||||
})
|
||||
|
||||
const createModalContent = () => {
|
||||
let showApiKey = false
|
||||
|
||||
const toggleApiKey = () => {
|
||||
showApiKey = !showApiKey
|
||||
// 重新渲染模态框内容
|
||||
updateModalContent()
|
||||
}
|
||||
|
||||
const updateModalContent = () => {
|
||||
const content = (
|
||||
<ProviderInfoContainer>
|
||||
<ProviderInfoCard size="small">
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_name')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{providerDisplayName}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_id')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{id}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
{baseUrl && (
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.base_url')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{baseUrl}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
)}
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.api_key')}:</ProviderInfoLabel>
|
||||
<ApiKeyContainer>
|
||||
<ApiKeyValue>{showApiKey ? newApiKey : '*********'}</ApiKeyValue>
|
||||
<EyeButton onClick={toggleApiKey}>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</EyeButton>
|
||||
</ApiKeyContainer>
|
||||
</ProviderInfoRow>
|
||||
</ProviderInfoCard>
|
||||
<ConfirmMessage>{confirmMessage}</ConfirmMessage>
|
||||
</ProviderInfoContainer>
|
||||
)
|
||||
|
||||
// 更新模态框内容
|
||||
if (modalInstance) {
|
||||
modalInstance.update({
|
||||
content: content
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const modalInstance = window.modal.confirm({
|
||||
title: t('settings.models.provider_key_confirm_title', { provider: providerDisplayName }),
|
||||
content: (
|
||||
<ProviderInfoContainer>
|
||||
<ProviderInfoCard size="small">
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_name')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{providerDisplayName}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.provider_id')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{id}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
{baseUrl && (
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.base_url')}:</ProviderInfoLabel>
|
||||
<ProviderInfoValue>{baseUrl}</ProviderInfoValue>
|
||||
</ProviderInfoRow>
|
||||
)}
|
||||
<ProviderInfoRow>
|
||||
<ProviderInfoLabel>{t('settings.models.api_key')}:</ProviderInfoLabel>
|
||||
<ApiKeyContainer>
|
||||
<ApiKeyValue>{showApiKey ? newApiKey : '*********'}</ApiKeyValue>
|
||||
<EyeButton onClick={toggleApiKey}>
|
||||
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</EyeButton>
|
||||
</ApiKeyContainer>
|
||||
</ProviderInfoRow>
|
||||
</ProviderInfoCard>
|
||||
<ConfirmMessage>{confirmMessage}</ConfirmMessage>
|
||||
</ProviderInfoContainer>
|
||||
),
|
||||
okText: keyAlreadyExists ? t('common.confirm') : t('common.add'),
|
||||
cancelText: t('common.cancel'),
|
||||
centered: true,
|
||||
onCancel() {
|
||||
window.navigate(`/settings/provider?id=${id}`)
|
||||
},
|
||||
onOk() {
|
||||
window.navigate(`/settings/provider?id=${id}`)
|
||||
if (keyAlreadyExists) {
|
||||
// 如果 key 已经存在,只显示消息,不做任何更改
|
||||
window.message.info(t('settings.models.provider_key_no_change', { provider: providerDisplayName }))
|
||||
return
|
||||
}
|
||||
|
||||
// 如果 key 不存在,添加到现有 keys 的末尾
|
||||
const finalApiKey = hasExistingKey ? `${existingProvider.apiKey},${newApiKey.trim()}` : newApiKey.trim()
|
||||
|
||||
const updatedProvider = {
|
||||
...existingProvider,
|
||||
apiKey: finalApiKey,
|
||||
...(baseUrl && { apiHost: baseUrl })
|
||||
}
|
||||
|
||||
if (isNewProvider) {
|
||||
addProvider(updatedProvider)
|
||||
} else {
|
||||
updateProvider(updatedProvider)
|
||||
}
|
||||
|
||||
setSelectedProvider(updatedProvider)
|
||||
window.message.success(t('settings.models.provider_key_added', { provider: providerDisplayName }))
|
||||
}
|
||||
})
|
||||
|
||||
return modalInstance
|
||||
}
|
||||
|
||||
createModalContent()
|
||||
}
|
||||
|
||||
// 检查 URL 参数
|
||||
const addProviderData = searchParams.get('addProviderData')
|
||||
if (!addProviderData) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Decode = (base64EncodedString: string) =>
|
||||
new TextDecoder().decode(Uint8Array.from(atob(base64EncodedString), (m) => m.charCodeAt(0)))
|
||||
const {
|
||||
id,
|
||||
apiKey: newApiKey,
|
||||
baseUrl,
|
||||
type,
|
||||
name
|
||||
} = JSON.parse(base64Decode(addProviderData.replaceAll('_', '+').replaceAll('-', '/')))
|
||||
|
||||
if (!id || !newApiKey || !baseUrl) {
|
||||
window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data'))
|
||||
window.navigate('/settings/provider')
|
||||
return
|
||||
}
|
||||
|
||||
handleProviderAddKey({ id, apiKey: newApiKey, baseUrl, type, name })
|
||||
} catch (error) {
|
||||
window.message.error(t('settings.models.provider_key_add_failed_by_invalid_data'))
|
||||
window.navigate('/settings/provider')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
setDragging(false)
|
||||
if (result.destination) {
|
||||
@ -380,4 +580,97 @@ const AddButtonWrapper = styled.div`
|
||||
align-items: center;
|
||||
padding: 10px 8px;
|
||||
`
|
||||
|
||||
const ProviderInfoContainer = styled.div`
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const ProviderInfoCard = styled(Card)`
|
||||
margin-bottom: 16px;
|
||||
background-color: var(--color-background-soft);
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderInfoRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const ProviderInfoLabel = styled.span`
|
||||
font-weight: 600;
|
||||
color: var(--color-text-2);
|
||||
min-width: 80px;
|
||||
`
|
||||
|
||||
const ProviderInfoValue = styled.span`
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
`
|
||||
|
||||
const ConfirmMessage = styled.div`
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
`
|
||||
|
||||
const ApiKeyContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
margin-left: 8px;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const ApiKeyValue = styled.span`
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
background-color: var(--color-background-soft);
|
||||
padding: 2px 32px 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const EyeButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-primary-outline);
|
||||
}
|
||||
`
|
||||
|
||||
export default ProvidersList
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
import { CheckOutlined, DeleteOutlined, HistoryOutlined, SendOutlined } from '@ant-design/icons'
|
||||
import { CheckOutlined, DeleteOutlined, HistoryOutlined, RedoOutlined, SendOutlined } from '@ant-design/icons'
|
||||
import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar'
|
||||
import CustomSelect from '@renderer/components/CustomSelect'
|
||||
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 { useCodeStyle } from '@renderer/context/CodeStyleProvider'
|
||||
import db from '@renderer/databases'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { useProviders } from '@renderer/hooks/useProvider'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { fetchTranslate } from '@renderer/services/ApiService'
|
||||
import { getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
|
||||
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 { runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import {
|
||||
@ -66,7 +70,11 @@ const TranslateSettings: FC<{
|
||||
selectOptions
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { translateModelPrompt } = useSettings()
|
||||
const dispatch = useAppDispatch()
|
||||
const [localPair, setLocalPair] = useState<[string, string]>(bidirectionalPair)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [localPrompt, setLocalPrompt] = useState(translateModelPrompt)
|
||||
|
||||
const defaultTranslateModel = useMemo(
|
||||
() => (hasModel(translateModel) ? getModelUniqId(translateModel) : undefined),
|
||||
@ -75,7 +83,8 @@ const TranslateSettings: FC<{
|
||||
|
||||
useEffect(() => {
|
||||
setLocalPair(bidirectionalPair)
|
||||
}, [bidirectionalPair, visible])
|
||||
setLocalPrompt(translateModelPrompt)
|
||||
}, [bidirectionalPair, translateModelPrompt, visible])
|
||||
|
||||
const handleSave = () => {
|
||||
if (localPair[0] === localPair[1]) {
|
||||
@ -89,6 +98,8 @@ const TranslateSettings: FC<{
|
||||
db.settings.put({ id: 'translate:bidirectional:pair', value: localPair })
|
||||
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 })
|
||||
dispatch(setTranslateModelPrompt(localPrompt))
|
||||
window.message.success({
|
||||
content: t('message.save.success.title'),
|
||||
key: 'translate-settings-save'
|
||||
@ -113,7 +124,14 @@ const TranslateSettings: FC<{
|
||||
width={420}>
|
||||
<Flex vertical gap={16} style={{ marginTop: 16 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>{t('translate.settings.model')}</div>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, display: 'flex', alignItems: 'center' }}>
|
||||
{t('translate.settings.model')}
|
||||
<Tooltip title={t('translate.settings.model_desc')}>
|
||||
<span style={{ marginLeft: 4, display: 'flex', alignItems: 'center' }}>
|
||||
<HelpCircle size={14} style={{ color: 'var(--color-text-3)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
<CustomSelect
|
||||
style={{ width: '100%' }}
|
||||
@ -138,9 +156,6 @@ const TranslateSettings: FC<{
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-3)' }}>
|
||||
{t('translate.settings.model_desc')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -158,7 +173,7 @@ const TranslateSettings: FC<{
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between" style={{ marginBottom: 8 }}>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
{t('translate.settings.bidirectional')}
|
||||
@ -171,8 +186,8 @@ const TranslateSettings: FC<{
|
||||
</div>
|
||||
<Switch checked={isBidirectional} onChange={setIsBidirectional} />
|
||||
</Flex>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{isBidirectional && (
|
||||
{isBidirectional && (
|
||||
<Space direction="vertical" style={{ width: '100%', marginTop: 8 }}>
|
||||
<Flex align="center" justify="space-between" gap={10}>
|
||||
<Select
|
||||
showSearch
|
||||
@ -210,8 +225,51 @@ const TranslateSettings: FC<{
|
||||
suffixIcon={<ChevronDown strokeWidth={1.5} size={16} color="var(--color-text-3)" />}
|
||||
/>
|
||||
</Flex>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Flex align="center" justify="space-between">
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setShowPrompt(!showPrompt)}>
|
||||
{t('settings.models.translate_model_prompt_title')}
|
||||
<ChevronDown
|
||||
size={16}
|
||||
style={{
|
||||
transform: showPrompt ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.3s',
|
||||
marginLeft: 5
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{localPrompt !== TRANSLATE_PROMPT && (
|
||||
<Tooltip title={t('common.reset')}>
|
||||
<Button
|
||||
icon={<RedoOutlined />}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={() => setLocalPrompt(TRANSLATE_PROMPT)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div style={{ display: showPrompt ? 'block' : 'none' }}>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={localPrompt}
|
||||
onChange={(e) => setLocalPrompt(e.target.value)}
|
||||
placeholder={t('settings.models.translate_model_prompt_message')}
|
||||
style={{ borderRadius: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
@ -4,62 +4,11 @@ import { upgradeToV7 } from '@renderer/databases/upgrades'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { setWebDAVSyncState } from '@renderer/store/backup'
|
||||
import { setS3SyncState } from '@renderer/store/backup'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { NotificationService } from './NotificationService'
|
||||
|
||||
// 重试删除S3文件的辅助函数
|
||||
async function deleteS3FileWithRetry(fileName: string, s3Config: any, 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: any, 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()
|
||||
@ -212,21 +161,17 @@ export async function backupToWebdav({
|
||||
// 文件已按修改时间降序排序,所以最旧的文件在末尾
|
||||
const filesToDelete = currentDeviceFiles.slice(webdavMaxBackups)
|
||||
|
||||
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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -297,201 +242,6 @@ export async function restoreFromWebdav(fileName?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 备份到 S3
|
||||
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
|
||||
}
|
||||
|
||||
// force set showMessage to false when auto backup process
|
||||
if (autoBackupProcess) {
|
||||
showMessage = false
|
||||
}
|
||||
|
||||
isManualBackupRunning = true
|
||||
|
||||
store.dispatch(setS3SyncState({ syncing: true, lastSyncError: null }))
|
||||
|
||||
const {
|
||||
s3: {
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
accessKeyId: s3AccessKeyId,
|
||||
secretAccessKey: s3SecretAccessKey,
|
||||
root: s3Root,
|
||||
maxBackups: s3MaxBackups,
|
||||
skipBackupFile: s3SkipBackupFile
|
||||
}
|
||||
} = store.getState().settings
|
||||
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 {
|
||||
await window.api.backup.backupToS3(backupData, {
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
access_key_id: s3AccessKeyId,
|
||||
secret_access_key: s3SecretAccessKey,
|
||||
root: s3Root,
|
||||
fileName: finalFileName,
|
||||
skipBackupFile: s3SkipBackupFile
|
||||
})
|
||||
|
||||
// S3上传成功
|
||||
store.dispatch(
|
||||
setS3SyncState({
|
||||
lastSyncError: null
|
||||
})
|
||||
)
|
||||
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 (s3MaxBackups > 0) {
|
||||
try {
|
||||
// 获取所有备份文件
|
||||
const files = await window.api.backup.listS3Files({
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
access_key_id: s3AccessKeyId,
|
||||
secret_access_key: s3SecretAccessKey,
|
||||
root: s3Root
|
||||
})
|
||||
|
||||
// 筛选当前设备的备份文件
|
||||
const currentDeviceFiles = files.filter((file) => {
|
||||
// 检查文件名是否包含当前设备的标识信息
|
||||
return file.fileName.includes(deviceType) && file.fileName.includes(hostname)
|
||||
})
|
||||
|
||||
// 如果当前设备的备份文件数量超过最大保留数量,删除最旧的文件
|
||||
if (currentDeviceFiles.length > s3MaxBackups) {
|
||||
// 文件已按修改时间降序排序,所以最旧的文件在末尾
|
||||
const filesToDelete = currentDeviceFiles.slice(s3MaxBackups)
|
||||
|
||||
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, {
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
access_key_id: s3AccessKeyId,
|
||||
secret_access_key: s3SecretAccessKey,
|
||||
root: s3Root
|
||||
})
|
||||
|
||||
// 在删除操作之间添加短暂延迟,避免请求过于频繁
|
||||
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)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// if auto backup process, throw error
|
||||
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 {
|
||||
s3: {
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
accessKeyId: s3AccessKeyId,
|
||||
secretAccessKey: s3SecretAccessKey,
|
||||
root: s3Root
|
||||
}
|
||||
} = store.getState().settings
|
||||
let data = ''
|
||||
|
||||
try {
|
||||
data = await window.api.backup.restoreFromS3({
|
||||
endpoint: s3Endpoint,
|
||||
region: s3Region,
|
||||
bucket: s3Bucket,
|
||||
access_key_id: s3AccessKeyId,
|
||||
secret_access_key: s3SecretAccessKey,
|
||||
root: s3Root,
|
||||
fileName
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[Backup] restoreFromS3: Error downloading file from S3:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.restore.failed'),
|
||||
content: error.message
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await handleData(JSON.parse(data))
|
||||
} catch (error) {
|
||||
console.error('[Backup] Error downloading file from S3:', error)
|
||||
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
|
||||
}
|
||||
}
|
||||
|
||||
let autoSyncStarted = false
|
||||
let syncTimeout: NodeJS.Timeout | null = null
|
||||
let isAutoBackupRunning = false
|
||||
@ -502,17 +252,9 @@ export function startAutoSync(immediate = false) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
webdavAutoSync,
|
||||
webdavHost,
|
||||
s3: { autoSync: s3AutoSync, endpoint: s3Endpoint }
|
||||
} = store.getState().settings
|
||||
const { webdavAutoSync, webdavHost } = store.getState().settings
|
||||
|
||||
// 检查WebDAV或S3自动同步配置
|
||||
const hasWebdavConfig = webdavAutoSync && webdavHost
|
||||
const hasS3Config = s3AutoSync && s3Endpoint
|
||||
|
||||
if (!hasWebdavConfig && !hasS3Config) {
|
||||
if (!webdavAutoSync || !webdavHost) {
|
||||
Logger.log('[AutoSync] Invalid sync settings, auto sync disabled')
|
||||
return
|
||||
}
|
||||
@ -535,29 +277,22 @@ export function startAutoSync(immediate = false) {
|
||||
syncTimeout = null
|
||||
}
|
||||
|
||||
const {
|
||||
webdavSyncInterval: _webdavSyncInterval,
|
||||
s3: { syncInterval: _s3SyncInterval }
|
||||
} = store.getState().settings
|
||||
const { webdavSync, s3Sync } = store.getState().backup
|
||||
const { webdavSyncInterval } = store.getState().settings
|
||||
const { webdavSync } = store.getState().backup
|
||||
|
||||
// 使用当前激活的同步配置
|
||||
const syncInterval = hasWebdavConfig ? _webdavSyncInterval : _s3SyncInterval
|
||||
const lastSyncTime = hasWebdavConfig ? webdavSync?.lastSyncTime : s3Sync?.lastSyncTime
|
||||
|
||||
if (syncInterval <= 0) {
|
||||
if (webdavSyncInterval <= 0) {
|
||||
Logger.log('[AutoSync] Invalid sync interval, auto sync disabled')
|
||||
stopAutoSync()
|
||||
return
|
||||
}
|
||||
|
||||
// 用户指定的自动备份时间间隔(毫秒)
|
||||
const requiredInterval = syncInterval * 60 * 1000
|
||||
const requiredInterval = webdavSyncInterval * 60 * 1000
|
||||
|
||||
let timeUntilNextSync = 1000 //also immediate
|
||||
switch (type) {
|
||||
case 'fromLastSyncTime': // 如果存在最后一次同步的时间,以它为参考计算下一次同步的时间
|
||||
timeUntilNextSync = Math.max(1000, (lastSyncTime || 0) + requiredInterval - Date.now())
|
||||
case 'fromLastSyncTime': // 如果存在最后一次同步WebDAV的时间,以它为参考计算下一次同步的时间
|
||||
timeUntilNextSync = Math.max(1000, (webdavSync?.lastSyncTime || 0) + requiredInterval - Date.now())
|
||||
break
|
||||
case 'fromNow':
|
||||
timeUntilNextSync = requiredInterval
|
||||
@ -566,9 +301,8 @@ export function startAutoSync(immediate = false) {
|
||||
|
||||
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
|
||||
|
||||
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||
Logger.log(
|
||||
`[AutoSync] Next ${backupType} sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
||||
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
|
||||
(timeUntilNextSync / 1000) % 60
|
||||
)} seconds`
|
||||
)
|
||||
@ -587,28 +321,17 @@ export function startAutoSync(immediate = false) {
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||
Logger.log(`[AutoSync] Starting auto ${backupType} backup... (attempt ${retryCount + 1}/${maxRetries})`)
|
||||
Logger.log(`[AutoSync] Starting auto backup... (attempt ${retryCount + 1}/${maxRetries})`)
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
await backupToWebdav({ autoBackupProcess: true })
|
||||
|
||||
store.dispatch(
|
||||
setWebDAVSyncState({
|
||||
lastSyncError: null,
|
||||
lastSyncTime: Date.now(),
|
||||
syncing: false
|
||||
})
|
||||
)
|
||||
|
||||
isAutoBackupRunning = false
|
||||
scheduleNextBackup()
|
||||
@ -617,31 +340,20 @@ export function startAutoSync(immediate = false) {
|
||||
} catch (error: any) {
|
||||
retryCount++
|
||||
if (retryCount === maxRetries) {
|
||||
const backupType = hasWebdavConfig ? 'WebDAV' : 'S3'
|
||||
Logger.error(`[AutoSync] Auto ${backupType} backup failed after all retries:`, error)
|
||||
Logger.error('[AutoSync] Auto backup failed after all retries:', error)
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
store.dispatch(
|
||||
setWebDAVSyncState({
|
||||
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: `[${backupType} Auto Backup] ${new Date().toLocaleString()} ` + error.message
|
||||
content: `[WebDAV Auto Backup] ${new Date().toLocaleString()} ` + error.message
|
||||
})
|
||||
|
||||
scheduleNextBackup('fromNow')
|
||||
|
||||
@ -16,11 +16,11 @@ export function getProviderName(id: string) {
|
||||
}
|
||||
|
||||
export function isProviderSupportAuth(provider: Provider) {
|
||||
const supportProviders = ['silicon', 'aihubmix', 'tokenflux']
|
||||
const supportProviders = ['silicon', 'aihubmix', 'ppio', 'tokenflux']
|
||||
return supportProviders.includes(provider.id)
|
||||
}
|
||||
|
||||
export function isProviderSupportCharge(provider: Provider) {
|
||||
const supportProviders = ['silicon', 'aihubmix']
|
||||
const supportProviders = ['silicon', 'aihubmix', 'ppio']
|
||||
return supportProviders.includes(provider.id)
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ export interface WebDAVSyncState {
|
||||
|
||||
export interface BackupState {
|
||||
webdavSync: WebDAVSyncState
|
||||
s3Sync: WebDAVSyncState
|
||||
}
|
||||
|
||||
const initialState: BackupState = {
|
||||
@ -16,11 +15,6 @@ const initialState: BackupState = {
|
||||
lastSyncTime: null,
|
||||
syncing: false,
|
||||
lastSyncError: null
|
||||
},
|
||||
s3Sync: {
|
||||
lastSyncTime: null,
|
||||
syncing: false,
|
||||
lastSyncError: null
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,12 +24,9 @@ const backupSlice = createSlice({
|
||||
reducers: {
|
||||
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
||||
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
||||
},
|
||||
setS3SyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
||||
state.s3Sync = { ...state.s3Sync, ...action.payload }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setWebDAVSyncState, setS3SyncState } = backupSlice.actions
|
||||
export const { setWebDAVSyncState } = backupSlice.actions
|
||||
export default backupSlice.reducer
|
||||
|
||||
@ -1663,10 +1663,28 @@ const migrateConfig = {
|
||||
},
|
||||
'117': (state: RootState) => {
|
||||
try {
|
||||
updateProvider(state, 'ppio', {
|
||||
models: SYSTEM_MODELS.ppio,
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/'
|
||||
})
|
||||
const ppioProvider = state.llm.providers.find((provider) => provider.id === 'ppio')
|
||||
const modelsToRemove = [
|
||||
'qwen/qwen-2.5-72b-instruct',
|
||||
'qwen/qwen2.5-32b-instruct',
|
||||
'meta-llama/llama-3.1-70b-instruct',
|
||||
'meta-llama/llama-3.1-8b-instruct',
|
||||
'01-ai/yi-1.5-34b-chat',
|
||||
'01-ai/yi-1.5-9b-chat',
|
||||
'thudm/glm-z1-32b-0414',
|
||||
'thudm/glm-z1-9b-0414'
|
||||
]
|
||||
if (ppioProvider) {
|
||||
updateProvider(state, 'ppio', {
|
||||
models: [
|
||||
...ppioProvider.models.filter((model) => !modelsToRemove.includes(model.id)),
|
||||
...SYSTEM_MODELS.ppio.filter(
|
||||
(systemModel) => !ppioProvider.models.some((existingModel) => existingModel.id === systemModel.id)
|
||||
)
|
||||
],
|
||||
apiHost: 'https://api.ppinfra.com/v3/openai/'
|
||||
})
|
||||
}
|
||||
return state
|
||||
} catch (error) {
|
||||
return state
|
||||
|
||||
@ -37,19 +37,6 @@ export type UserTheme = {
|
||||
colorPrimary: string
|
||||
}
|
||||
|
||||
export interface S3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
root: string
|
||||
autoSync: boolean
|
||||
syncInterval: number
|
||||
maxBackups: number
|
||||
skipBackupFile: boolean
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
showAssistants: boolean
|
||||
showTopics: boolean
|
||||
@ -196,7 +183,6 @@ export interface SettingsState {
|
||||
knowledgeEmbed: boolean
|
||||
}
|
||||
defaultPaintingProvider: PaintingProvider
|
||||
s3: S3Config
|
||||
transparentWindow: boolean
|
||||
}
|
||||
|
||||
@ -341,19 +327,7 @@ export const initialState: SettingsState = {
|
||||
knowledgeEmbed: false
|
||||
},
|
||||
defaultPaintingProvider: 'aihubmix',
|
||||
transparentWindow: true,
|
||||
s3: {
|
||||
endpoint: '',
|
||||
region: '',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
root: '',
|
||||
autoSync: false,
|
||||
syncInterval: 0,
|
||||
maxBackups: 0,
|
||||
skipBackupFile: false
|
||||
}
|
||||
transparentWindow: true
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
@ -712,9 +686,6 @@ const settingsSlice = createSlice({
|
||||
setDefaultPaintingProvider: (state, action: PayloadAction<PaintingProvider>) => {
|
||||
state.defaultPaintingProvider = action.payload
|
||||
},
|
||||
setS3: (state, action: PayloadAction<S3Config>) => {
|
||||
state.s3 = action.payload
|
||||
},
|
||||
setTransparentWindow: (state, action: PayloadAction<boolean>) => {
|
||||
state.transparentWindow = action.payload
|
||||
}
|
||||
@ -824,7 +795,6 @@ export const {
|
||||
setOpenAIServiceTier,
|
||||
setNotificationSettings,
|
||||
setDefaultPaintingProvider,
|
||||
setS3,
|
||||
setTransparentWindow
|
||||
} = settingsSlice.actions
|
||||
|
||||
|
||||
@ -736,16 +736,4 @@ 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
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
root?: string
|
||||
fileName?: string
|
||||
skipBackupFile?: boolean
|
||||
}
|
||||
|
||||
export type { Message } from './newMessage'
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
convertLinksToHunyuan,
|
||||
convertLinksToOpenRouter,
|
||||
convertLinksToZhipu,
|
||||
extractUrlsFromMarkdown
|
||||
extractUrlsFromMarkdown,
|
||||
flushLinkConverterBuffer
|
||||
} from '../linkConverter'
|
||||
|
||||
describe('linkConverter', () => {
|
||||
@ -90,22 +91,197 @@ describe('linkConverter', () => {
|
||||
it('should convert links with domain-like text to numbered links', () => {
|
||||
const input = '查看这个网站 [example.com](https://example.com)'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result).toBe('查看这个网站 [<sup>1</sup>](https://example.com)')
|
||||
expect(result.text).toBe('查看这个网站 [<sup>1</sup>](https://example.com)')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle parenthesized link format ([host](url))', () => {
|
||||
const input = '这里有链接 ([example.com](https://example.com))'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result).toBe('这里有链接 [<sup>1</sup>](https://example.com)')
|
||||
expect(result.text).toBe('这里有链接 [<sup>1</sup>](https://example.com)')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should use the same counter for duplicate URLs', () => {
|
||||
const input =
|
||||
'第一个链接 [example.com](https://example.com) 和第二个相同链接 [subdomain.example.com](https://example.com)'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result).toBe(
|
||||
expect(result.text).toBe(
|
||||
'第一个链接 [<sup>1</sup>](https://example.com) 和第二个相同链接 [<sup>1</sup>](https://example.com)'
|
||||
)
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should not misinterpret code placeholders as incomplete links', () => {
|
||||
const input =
|
||||
'The most common reason for a `404` error is that the repository specified in the `owner` and `repo`'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe(
|
||||
'The most common reason for a `404` error is that the repository specified in the `owner` and `repo`'
|
||||
)
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle text with square brackets that are not links', () => {
|
||||
const input = 'Use [owner] and [repo] placeholders in your configuration [file]'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('Use [owner] and [repo] placeholders in your configuration [file]')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle markdown code blocks with square brackets', () => {
|
||||
const input = 'In the code: `const config = { [key]: value }` you can see [brackets]'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('In the code: `const config = { [key]: value }` you can see [brackets]')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should properly handle partial markdown link patterns', () => {
|
||||
// 这种情况下,[text] 后面没有紧跟 (,所以不应该被当作潜在链接
|
||||
const input = 'Check the [documentation] for more details'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('Check the [documentation] for more details')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should correctly identify and handle real incomplete links', () => {
|
||||
// 第一个块包含真正的不完整链接模式
|
||||
const chunk1 = 'Visit [example.com]('
|
||||
const result1 = convertLinks(chunk1, true)
|
||||
expect(result1.text).toBe('Visit ')
|
||||
expect(result1.hasBufferedContent).toBe(true)
|
||||
|
||||
// 第二个块完成该链接
|
||||
const chunk2 = 'https://example.com) for more info'
|
||||
const result2 = convertLinks(chunk2, false)
|
||||
expect(result2.text).toBe('[<sup>1</sup>](https://example.com) for more info')
|
||||
expect(result2.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle mixed content with real links and placeholders', () => {
|
||||
const input = 'Configure [owner] and [repo] in [GitHub](https://github.com) settings'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('Configure [owner] and [repo] in GitHub [<sup>1</sup>](https://github.com) settings')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty text', () => {
|
||||
const input = ''
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle text with only square brackets', () => {
|
||||
const input = '[][][]'
|
||||
const result = convertLinks(input, true)
|
||||
expect(result.text).toBe('[][][]')
|
||||
expect(result.hasBufferedContent).toBe(false)
|
||||
})
|
||||
|
||||
describe('streaming small chunks simulation', () => {
|
||||
it('should handle non-link placeholders in small chunks without buffering', () => {
|
||||
// 模拟用户遇到的问题:包含方括号占位符的文本被分成小chunks
|
||||
const chunks = [
|
||||
'The most common reason for a `404` error is that the repository specified in the `',
|
||||
'owner` and `',
|
||||
'repo` parameters are incorrect.'
|
||||
]
|
||||
|
||||
let accumulatedText = ''
|
||||
|
||||
// 第一个chunk
|
||||
const result1 = convertLinks(chunks[0], true)
|
||||
expect(result1.text).toBe(chunks[0]) // 应该立即返回,不缓冲
|
||||
expect(result1.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result1.text
|
||||
|
||||
// 第二个chunk
|
||||
const result2 = convertLinks(chunks[1], false)
|
||||
expect(result2.text).toBe(chunks[1]) // 应该立即返回,不缓冲
|
||||
expect(result2.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result2.text
|
||||
|
||||
// 第三个chunk
|
||||
const result3 = convertLinks(chunks[2], false)
|
||||
expect(result3.text).toBe(chunks[2]) // 应该立即返回,不缓冲
|
||||
expect(result3.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result3.text
|
||||
|
||||
// 验证最终结果
|
||||
expect(accumulatedText).toBe(chunks.join(''))
|
||||
expect(accumulatedText).toBe(
|
||||
'The most common reason for a `404` error is that the repository specified in the `owner` and `repo` parameters are incorrect.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle real links split across small chunks with proper buffering', () => {
|
||||
// 模拟真实链接被分割成小chunks的情况 - 更现实的分割方式
|
||||
const chunks = [
|
||||
'Please visit [example.com](', // 不完整链接
|
||||
'https://example.com) for details' // 完成链接
|
||||
]
|
||||
|
||||
let accumulatedText = ''
|
||||
|
||||
// 第一个chunk:包含不完整链接 [text](
|
||||
const result1 = convertLinks(chunks[0], true)
|
||||
expect(result1.text).toBe('Please visit ') // 只返回安全部分
|
||||
expect(result1.hasBufferedContent).toBe(true) // [example.com]( 被缓冲
|
||||
accumulatedText += result1.text
|
||||
|
||||
// 第二个chunk:完成链接
|
||||
const result2 = convertLinks(chunks[1], false)
|
||||
expect(result2.text).toBe('[<sup>1</sup>](https://example.com) for details') // 完整链接 + 剩余文本
|
||||
expect(result2.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result2.text
|
||||
|
||||
// 验证最终结果
|
||||
expect(accumulatedText).toBe('Please visit [<sup>1</sup>](https://example.com) for details')
|
||||
})
|
||||
|
||||
it('should handle mixed content with placeholders and real links in small chunks', () => {
|
||||
// 混合内容:既有占位符又有真实链接 - 更现实的分割方式
|
||||
const chunks = [
|
||||
'Configure [owner] and [repo] in [GitHub](', // 占位符 + 不完整链接
|
||||
'https://github.com) settings page.' // 完成链接
|
||||
]
|
||||
|
||||
let accumulatedText = ''
|
||||
|
||||
// 第一个chunk:包含占位符和不完整链接
|
||||
const result1 = convertLinks(chunks[0], true)
|
||||
expect(result1.text).toBe('Configure [owner] and [repo] in ') // 占位符保留,链接部分被缓冲
|
||||
expect(result1.hasBufferedContent).toBe(true) // [GitHub]( 被缓冲
|
||||
accumulatedText += result1.text
|
||||
|
||||
// 第二个chunk:完成链接
|
||||
const result2 = convertLinks(chunks[1], false)
|
||||
expect(result2.text).toBe('GitHub [<sup>1</sup>](https://github.com) settings page.') // 完整链接 + 剩余文本
|
||||
expect(result2.hasBufferedContent).toBe(false)
|
||||
accumulatedText += result2.text
|
||||
|
||||
// 验证最终结果
|
||||
expect(accumulatedText).toBe(
|
||||
'Configure [owner] and [repo] in GitHub [<sup>1</sup>](https://github.com) settings page.'
|
||||
)
|
||||
expect(accumulatedText).toContain('[owner] and [repo]') // 占位符保持原样
|
||||
expect(accumulatedText).toContain('[<sup>1</sup>](https://github.com)') // 链接被转换
|
||||
})
|
||||
|
||||
it('should properly handle buffer flush at stream end', () => {
|
||||
// 测试流结束时的buffer清理
|
||||
const incompleteChunk = 'Check the documentation at [GitHub]('
|
||||
const result = convertLinks(incompleteChunk, true)
|
||||
|
||||
// 应该有内容被缓冲
|
||||
expect(result.hasBufferedContent).toBe(true)
|
||||
expect(result.text).toBe('Check the documentation at ') // 只返回安全部分
|
||||
|
||||
// 模拟流结束,强制清空buffer
|
||||
const remainingText = flushLinkConverterBuffer()
|
||||
expect(remainingText).toBe('[GitHub](') // buffer中的剩余内容
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -126,9 +126,12 @@ export function convertLinksToHunyuan(text: string, webSearch: any[], resetCount
|
||||
*
|
||||
* @param {string} text The current chunk of text to process
|
||||
* @param {boolean} resetCounter Whether to reset the counter and buffer
|
||||
* @returns {string} Processed text with complete links converted
|
||||
* @returns {{text: string, hasBufferedContent: boolean}} Processed text and whether content was buffered
|
||||
*/
|
||||
export function convertLinks(text: string, resetCounter: boolean = false): string {
|
||||
export function convertLinks(
|
||||
text: string,
|
||||
resetCounter: boolean = false
|
||||
): { text: string; hasBufferedContent: boolean } {
|
||||
if (resetCounter) {
|
||||
linkCounter = 1
|
||||
buffer = ''
|
||||
@ -158,12 +161,22 @@ export function convertLinks(text: string, resetCounter: boolean = false): strin
|
||||
} else if (buffer[i] === '[') {
|
||||
// Check if this could be the start of a regular link
|
||||
const substring = buffer.substring(i)
|
||||
const match = /^\[([^\]]+)\]\(([^)]+)\)/.exec(substring)
|
||||
|
||||
if (!match) {
|
||||
// 检查是否是真正的不完整链接:[text]( 但没有完整的 url)
|
||||
const incompleteLink = /^\[([^\]]+)\]\s*\([^)]*$/.test(substring)
|
||||
if (incompleteLink) {
|
||||
safePoint = i
|
||||
break
|
||||
}
|
||||
|
||||
// 检查是否是完整的链接但需要验证
|
||||
const completeLink = /^\[([^\]]+)\]\(([^)]+)\)/.test(substring)
|
||||
if (completeLink) {
|
||||
// 如果是完整链接,继续处理,不设置safePoint
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果不是潜在的链接格式,继续检查
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,6 +184,9 @@ export function convertLinks(text: string, resetCounter: boolean = false): strin
|
||||
const safeBuffer = buffer.substring(0, safePoint)
|
||||
buffer = buffer.substring(safePoint)
|
||||
|
||||
// 检查是否有内容被保留在buffer中
|
||||
const hasBufferedContent = buffer.length > 0
|
||||
|
||||
// Process the safe buffer to handle complete links
|
||||
let result = ''
|
||||
let position = 0
|
||||
@ -237,7 +253,10 @@ export function convertLinks(text: string, resetCounter: boolean = false): strin
|
||||
position++
|
||||
}
|
||||
|
||||
return result
|
||||
return {
|
||||
text: result,
|
||||
hasBufferedContent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -439,13 +458,13 @@ export function extractWebSearchReferences(text: string): Array<{
|
||||
* @param {any[]} webSearchResults Web搜索结果数组
|
||||
* @param {string} providerType Provider类型 ('openai', 'zhipu', 'hunyuan', 'openrouter', etc.)
|
||||
* @param {boolean} resetCounter 是否重置计数器
|
||||
* @returns {string} 转换后的文本
|
||||
* @returns {{text: string, hasBufferedContent: boolean}} 转换后的文本和是否有内容被缓冲
|
||||
*/
|
||||
export function smartLinkConverter(
|
||||
text: string,
|
||||
providerType: string = 'openai',
|
||||
resetCounter: boolean = false
|
||||
): string {
|
||||
): { text: string; hasBufferedContent: boolean } {
|
||||
// 检测文本中的引用模式
|
||||
const references = extractWebSearchReferences(text)
|
||||
|
||||
@ -458,10 +477,26 @@ export function smartLinkConverter(
|
||||
const hasZhipuPattern = references.some((ref) => ref.placeholder.includes('ref_'))
|
||||
|
||||
if (hasZhipuPattern) {
|
||||
return convertLinksToZhipu(text, resetCounter)
|
||||
return {
|
||||
text: convertLinksToZhipu(text, resetCounter),
|
||||
hasBufferedContent: false
|
||||
}
|
||||
} else if (providerType === 'openrouter') {
|
||||
return convertLinksToOpenRouter(text, resetCounter)
|
||||
return {
|
||||
text: convertLinksToOpenRouter(text, resetCounter),
|
||||
hasBufferedContent: false
|
||||
}
|
||||
} else {
|
||||
return convertLinks(text, resetCounter)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制返回buffer中的所有内容,用于流结束时清空缓冲区
|
||||
* @returns {string} buffer中剩余的所有内容
|
||||
*/
|
||||
export function flushLinkConverterBuffer(): string {
|
||||
const remainingBuffer = buffer
|
||||
buffer = ''
|
||||
return remainingBuffer
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SILICON_CLIENT_ID, TOKENFLUX_HOST } from '@renderer/config/constant'
|
||||
import { PPIO_APP_SECRET, PPIO_CLIENT_ID, SILICON_CLIENT_ID, TOKENFLUX_HOST } from '@renderer/config/constant'
|
||||
import i18n, { getLanguageCode } from '@renderer/i18n'
|
||||
|
||||
export const oauthWithSiliconFlow = async (setKey) => {
|
||||
@ -58,6 +58,81 @@ export const oauthWithAihubmix = async (setKey) => {
|
||||
window.addEventListener('message', messageHandler)
|
||||
}
|
||||
|
||||
export const oauthWithPPIO = async (setKey) => {
|
||||
const redirectUri = 'cherrystudio://'
|
||||
const authUrl = `https://ppio.cn/oauth/authorize?client_id=${PPIO_CLIENT_ID}&scope=api%20openid&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}`
|
||||
|
||||
window.open(
|
||||
authUrl,
|
||||
'oauth',
|
||||
'width=720,height=720,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,alwaysOnTop=yes,alwaysRaised=yes'
|
||||
)
|
||||
|
||||
if (!setKey) {
|
||||
console.log('[PPIO OAuth] No setKey callback provided, returning early')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[PPIO OAuth] Setting up protocol listener')
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const removeListener = window.api.protocol.onReceiveData(async (data) => {
|
||||
try {
|
||||
const url = new URL(data.url)
|
||||
const params = new URLSearchParams(url.search)
|
||||
const code = params.get('code')
|
||||
|
||||
if (!code) {
|
||||
reject(new Error('No authorization code received'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!PPIO_APP_SECRET) {
|
||||
reject(
|
||||
new Error('PPIO_APP_SECRET not configured. Please set RENDERER_VITE_PPIO_APP_SECRET environment variable.')
|
||||
)
|
||||
return
|
||||
}
|
||||
const formData = new URLSearchParams({
|
||||
client_id: PPIO_CLIENT_ID,
|
||||
client_secret: PPIO_APP_SECRET,
|
||||
code: code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
const tokenResponse = await fetch('https://ppio.cn/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: formData.toString()
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
console.error('[PPIO OAuth] Token exchange failed:', tokenResponse.status, errorText)
|
||||
throw new Error(`Failed to exchange code for token: ${tokenResponse.status} ${errorText}`)
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
const accessToken = tokenData.access_token
|
||||
|
||||
if (accessToken) {
|
||||
setKey(accessToken)
|
||||
resolve(accessToken)
|
||||
} else {
|
||||
reject(new Error('No access token received'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PPIO OAuth] Error processing callback:', error)
|
||||
reject(error)
|
||||
} finally {
|
||||
removeListener()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const oauthWithTokenFlux = async () => {
|
||||
const callbackUrl = `${TOKENFLUX_HOST}/auth/callback?redirect_to=/dashboard/api-keys`
|
||||
const resp = await fetch(`${TOKENFLUX_HOST}/api/auth/auth-url?type=login&callback=${callbackUrl}`, {})
|
||||
@ -90,6 +165,11 @@ export const providerCharge = async (provider: string) => {
|
||||
url: `https://tokenflux.ai/dashboard/billing`,
|
||||
width: 900,
|
||||
height: 700
|
||||
},
|
||||
ppio: {
|
||||
url: 'https://ppio.cn/billing?utm_source=github_cherry-studio',
|
||||
width: 900,
|
||||
height: 700
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,6 +198,11 @@ export const providerBills = async (provider: string) => {
|
||||
url: `https://tokenflux.ai/dashboard/billing`,
|
||||
width: 900,
|
||||
height: 700
|
||||
},
|
||||
ppio: {
|
||||
url: 'https://ppio.cn/billing/billing-details?utm_source=github_cherry-studio',
|
||||
width: 900,
|
||||
height: 700
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user