Merge branch 'main' into feat/sidebar-ui

This commit is contained in:
suyao 2025-07-03 05:21:20 +08:00
commit 5d9b47198b
No known key found for this signature in database
73 changed files with 2776 additions and 3001 deletions

View File

@ -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: "/"

View File

@ -2,7 +2,7 @@
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
"source.organizeImports": "never"
},
"search.exclude": {
"**/dist/**": true,

View File

@ -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}'

View File

@ -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",

View File

@ -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',

View File

@ -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
})
// 递归遍历翻译

View File

@ -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) => {

View File

@ -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)

View File

@ -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) => {
// 简单记录错误信息和时间戳

View File

@ -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

View File

@ -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'

View File

@ -19,7 +19,7 @@ export function registerProtocolClient(app: Electron.App) {
}
}
app.setAsDefaultProtocolClient('cherrystudio')
app.setAsDefaultProtocolClient(CHERRY_STUDIO_PROTOCOL)
}
export function handleProtocolUrl(url: string) {

View File

@ -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
// }
// }
// }

View File

@ -72,7 +72,8 @@ export class WindowService {
webSecurity: false,
webviewTag: true,
allowRunningInsecureContent: true,
zoomFactor: configManager.getZoomFactor()
zoomFactor: configManager.getZoomFactor(),
backgroundThrottling: false
}
})

View File

@ -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
}
}

View 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`
}

View File

@ -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),

View File

@ -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
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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>
)
}

View File

@ -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
}
}

View 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)
})
})

View File

@ -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()
})
})

View 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('⭐️')
})
})

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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

View File

@ -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: [],

View File

@ -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'
}

View File

@ -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)
}
}
})

View File

@ -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",

View File

@ -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": "テストプラン",

View File

@ -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": "Тестовый план",

View File

@ -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": "快捷短语",

View File

@ -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": "測試計畫",

View File

@ -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) {

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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;

View 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

View 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

View 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

View 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

View 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

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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')

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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中的剩余内容
})
})
})

View File

@ -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
}

View File

@ -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
}
}

690
yarn.lock

File diff suppressed because it is too large Load Diff