Merge branch 'main' into node-store

This commit is contained in:
温州程序员劝退师 2025-03-21 13:28:23 +08:00
commit a97c3d9695
42 changed files with 592 additions and 270 deletions

View File

@ -52,6 +52,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
- 🔤 AI-powered Translation - 🔤 AI-powered Translation
- 🎯 Drag-and-drop Sorting - 🎯 Drag-and-drop Sorting
- 🔌 Mini Program Support - 🔌 Mini Program Support
- ⚙️ MCP(Model Context Protocol) Server
5. **Enhanced User Experience**: 5. **Enhanced User Experience**:

View File

@ -53,6 +53,7 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
- 🔤 AI による翻訳機能 - 🔤 AI による翻訳機能
- 🎯 ドラッグ&ドロップによる整理 - 🎯 ドラッグ&ドロップによる整理
- 🔌 ミニプログラム対応 - 🔌 ミニプログラム対応
- ⚙️ MCPモデルコンテキストプロトコル サービス
5. **優れたユーザー体験** 5. **優れたユーザー体験**

View File

@ -52,6 +52,7 @@ Cherry Studio 是一款支持多个大语言模型LLM服务商的桌面客
- 🔤 AI 驱动的翻译功能 - 🔤 AI 驱动的翻译功能
- 🎯 拖拽排序 - 🎯 拖拽排序
- 🔌 小程序支持 - 🔌 小程序支持
- ⚙️ MCP(模型上下文协议) 服务
5. **优质使用体验** 5. **优质使用体验**

View File

@ -53,6 +53,6 @@ export default defineConfig([
} }
], ],
{ {
ignores: ['node_modules/**', 'dist/**', 'out/**', '.gitignore', 'scripts/cloudflare-worker.js'] ignores: ['node_modules/**', 'dist/**', 'out/**', 'local/**', '.gitignore', 'scripts/cloudflare-worker.js']
} }
]) ])

View File

@ -85,6 +85,7 @@
"npx-scope-finder": "^1.2.0", "npx-scope-finder": "^1.2.0",
"officeparser": "^4.1.1", "officeparser": "^4.1.1",
"p-queue": "^8.1.0", "p-queue": "^8.1.0",
"socks-proxy-agent": "^8.0.3",
"tar": "^7.4.3", "tar": "^7.4.3",
"tokenx": "^0.4.1", "tokenx": "^0.4.1",
"undici": "^7.4.0", "undici": "^7.4.0",

View File

@ -103,7 +103,10 @@ export const textExts = [
'.cxx', // C++ 源文件 '.cxx', // C++ 源文件
'.cppm', // C++20 模块接口文件 '.cppm', // C++20 模块接口文件
'.ipp', // 模板实现文件 '.ipp', // 模板实现文件
'.ixx' // C++20 模块实现文件 '.ixx', // C++20 模块实现文件
'.f90', // Fortran 90 源文件
'.f', // Fortran 固定格式源代码文件
'.f03' // Fortran 2003+ 源代码文件
] ]
export const ZOOM_SHORTCUTS = [ export const ZOOM_SHORTCUTS = [

View File

@ -0,0 +1,52 @@
const { ProxyAgent } = require('undici')
const { SocksProxyAgent } = require('socks-proxy-agent')
const https = require('https')
const fs = require('fs')
const { pipeline } = require('stream/promises')
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>} Promise that resolves when download is complete
*/
async function downloadWithRedirects(url, destinationPath) {
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY
if (proxyUrl.startsWith('socks')) {
const proxyAgent = new SocksProxyAgent(proxyUrl)
return new Promise((resolve, reject) => {
const request = (url) => {
https
.get(url, { agent: proxyAgent }, (response) => {
if (response.statusCode == 301 || response.statusCode == 302) {
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`))
return
}
const file = fs.createWriteStream(destinationPath)
response.pipe(file)
file.on('finish', () => resolve())
})
.on('error', (err) => {
reject(err)
})
}
request(url)
})
} else {
const proxyAgent = new ProxyAgent(proxyUrl)
const response = await fetch(url, {
dispatcher: proxyAgent
})
if (!response.ok) {
throw new Error(`Download failed: ${response.status} ${response.statusText}`)
}
const file = fs.createWriteStream(destinationPath)
await pipeline(response.body, file)
}
}
module.exports = { downloadWithRedirects }

View File

@ -2,8 +2,8 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const os = require('os') const os = require('os')
const { execSync } = require('child_process') const { execSync } = require('child_process')
const https = require('https')
const AdmZip = require('adm-zip') const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading bun binaries // Base URL for downloading bun binaries
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download' const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download'
@ -24,41 +24,6 @@ const BUN_PACKAGES = {
'linux-musl-arm64': 'bun-linux-aarch64-musl.zip' 'linux-musl-arm64': 'bun-linux-aarch64-musl.zip'
} }
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>}
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destinationPath)
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode}`))
return
}
response.pipe(file)
file.on('finish', () => {
file.close(resolve)
})
})
.on('error', reject)
}
request(url)
})
}
/** /**
* Downloads and extracts the bun binary for the specified platform and architecture * Downloads and extracts the bun binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux') * @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')

View File

@ -2,9 +2,9 @@ const fs = require('fs')
const path = require('path') const path = require('path')
const os = require('os') const os = require('os')
const { execSync } = require('child_process') const { execSync } = require('child_process')
const https = require('https')
const tar = require('tar') const tar = require('tar')
const AdmZip = require('adm-zip') const AdmZip = require('adm-zip')
const { downloadWithRedirects } = require('./download')
// Base URL for downloading uv binaries // Base URL for downloading uv binaries
const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download' const UV_RELEASE_BASE_URL = 'https://github.com/astral-sh/uv/releases/download'
@ -32,41 +32,6 @@ const UV_PACKAGES = {
'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz' 'linux-musl-armv7l': 'uv-armv7-unknown-linux-musleabihf.tar.gz'
} }
/**
* Downloads a file from a URL with redirect handling
* @param {string} url The URL to download from
* @param {string} destinationPath The path to save the file to
* @returns {Promise<void>}
*/
async function downloadWithRedirects(url, destinationPath) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destinationPath)
const request = (url) => {
https
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
request(response.headers.location)
return
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download: ${response.statusCode}`))
return
}
response.pipe(file)
file.on('finish', () => {
file.close(resolve)
})
})
.on('error', reject)
}
request(url)
})
}
/** /**
* Downloads and extracts the uv binary for the specified platform and architecture * Downloads and extracts the uv binary for the specified platform and architecture
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux') * @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')

View File

@ -1,6 +1,6 @@
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { replaceDevtoolsFont } from '@main/utils/windowUtil' import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app } from 'electron' import { app, ipcMain } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
@ -44,6 +44,9 @@ if (!app.requestSingleInstanceLock()) {
.then((name) => console.log(`Added Extension: ${name}`)) .then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err)) .catch((err) => console.log('An error occurred: ', err))
} }
ipcMain.handle('system:getDeviceType', () => {
return process.platform === 'darwin' ? 'mac' : process.platform === 'win32' ? 'windows' : 'linux'
})
}) })
// Listen for second instance // Listen for second instance

View File

@ -85,8 +85,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
}) })
// theme // theme
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => { ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
if (theme === configManager.getTheme()) return
configManager.setTheme(theme) configManager.setTheme(theme)
// should sync theme change to all windows
const senderWindowId = event.sender.id
const windows = BrowserWindow.getAllWindows()
// 向其他窗口广播主题变化
windows.forEach((win) => {
if (win.webContents.id !== senderWindowId) {
win.webContents.send('theme:change', theme)
}
})
mainWindow?.setTitleBarOverlay && mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight) mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
}) })
@ -131,6 +144,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('backup:restore', backupManager.restore) ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav) ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav) ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
// file // file
ipcMain.handle('file:open', fileManager.open) ipcMain.handle('file:open', fileManager.open)

View File

@ -13,7 +13,7 @@ export default abstract class BaseReranker {
public defaultHeaders() { public defaultHeaders() {
return { return {
Authorization: `Bearer ${this.base.apiKey}`, Authorization: `Bearer ${this.base.rerankApiKey}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
} }

View File

@ -0,0 +1,48 @@
import { ExtractChunkData } from '@llm-tools/embedjs-interfaces'
import { KnowledgeBaseParams } from '@types'
import axios from 'axios'
import BaseReranker from './BaseReranker'
export default class JinaReranker extends BaseReranker {
constructor(base: KnowledgeBaseParams) {
super(base)
}
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
const url = `${baseURL}/rerank`
const requestBody = {
model: this.base.rerankModel,
query,
documents: searchResults.map((doc) => doc.pageContent),
top_n: this.base.topN
}
try {
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
const rerankResults = data.results
console.log(rerankResults)
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
return searchResults
.map((doc: ExtractChunkData, index: number) => {
const score = resultMap.get(index)
if (score === undefined) return undefined
return {
...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error) {
console.error('Jina Reranker API 错误:', error)
throw error
}
}
}

View File

@ -2,12 +2,15 @@ import { KnowledgeBaseParams } from '@types'
import BaseReranker from './BaseReranker' import BaseReranker from './BaseReranker'
import DefaultReranker from './DefaultReranker' import DefaultReranker from './DefaultReranker'
import JinaReranker from './JinaReranker'
import SiliconFlowReranker from './SiliconFlowReranker' import SiliconFlowReranker from './SiliconFlowReranker'
export default class RerankerFactory { export default class RerankerFactory {
static create(base: KnowledgeBaseParams): BaseReranker { static create(base: KnowledgeBaseParams): BaseReranker {
if (base.rerankModelProvider === 'silicon') { if (base.rerankModelProvider === 'silicon') {
return new SiliconFlowReranker(base) return new SiliconFlowReranker(base)
} else if (base.rerankModelProvider === 'jina') {
return new JinaReranker(base)
} }
return new DefaultReranker(base) return new DefaultReranker(base)
} }

View File

@ -10,37 +10,41 @@ export default class SiliconFlowReranker extends BaseReranker {
} }
public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => { public rerank = async (query: string, searchResults: ExtractChunkData[]): Promise<ExtractChunkData[]> => {
const url = `${this.base.baseURL}/rerank` const baseURL = this.base?.rerankBaseURL?.endsWith('/')
? this.base.rerankBaseURL.slice(0, -1)
: this.base.rerankBaseURL
const url = `${baseURL}/rerank`
const { data } = await axios.post( const requestBody = {
url, model: this.base.rerankModel,
{ query,
model: this.base.rerankModel, documents: searchResults.map((doc) => doc.pageContent),
query, top_n: this.base.topN,
documents: searchResults.map((doc) => doc.pageContent), max_chunks_per_doc: this.base.chunkSize,
top_n: this.base.topN, overlap_tokens: this.base.chunkOverlap
max_chunks_per_doc: this.base.chunkSize, }
overlap_tokens: this.base.chunkOverlap
},
{
headers: this.defaultHeaders()
}
)
const rerankResults = data.results try {
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0])) const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
return searchResults const rerankResults = data.results
.map((doc: ExtractChunkData, index: number) => { const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
const score = resultMap.get(index)
if (score === undefined) return undefined
return { return searchResults
...doc, .map((doc: ExtractChunkData, index: number) => {
score const score = resultMap.get(index)
} if (score === undefined) return undefined
})
.filter((doc): doc is ExtractChunkData => doc !== undefined) return {
.sort((a, b) => b.score - a.score) ...doc,
score
}
})
.filter((doc): doc is ExtractChunkData => doc !== undefined)
.sort((a, b) => b.score - a.score)
} catch (error) {
console.error('SiliconFlow Reranker API 错误:', error)
throw error
}
} }
} }

View File

@ -5,6 +5,7 @@ import { app } from 'electron'
import Logger from 'electron-log' import Logger from 'electron-log'
import * as fs from 'fs-extra' import * as fs from 'fs-extra'
import * as path from 'path' import * as path from 'path'
import { createClient, FileStat } from 'webdav'
import WebDav from './WebDav' import WebDav from './WebDav'
import { windowService } from './WindowService' import { windowService } from './WindowService'
@ -18,6 +19,7 @@ class BackupManager {
this.restore = this.restore.bind(this) this.restore = this.restore.bind(this)
this.backupToWebdav = this.backupToWebdav.bind(this) this.backupToWebdav = this.backupToWebdav.bind(this)
this.restoreFromWebdav = this.restoreFromWebdav.bind(this) this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
this.listWebdavFiles = this.listWebdavFiles.bind(this)
} }
private async setWritableRecursive(dirPath: string): Promise<void> { private async setWritableRecursive(dirPath: string): Promise<void> {
@ -117,10 +119,10 @@ class BackupManager {
await fs.remove(this.tempDir) await fs.remove(this.tempDir)
onProgress({ stage: 'completed', progress: 100, total: 100 }) onProgress({ stage: 'completed', progress: 100, total: 100 })
Logger.log('Backup completed successfully') Logger.log('[BackupManager] Backup completed successfully')
return backupedFilePath return backupedFilePath
} catch (error) { } catch (error) {
Logger.error('Backup failed:', error) Logger.error('[BackupManager] Backup failed:', error)
throw error throw error
} }
} }
@ -186,7 +188,7 @@ class BackupManager {
} }
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) { async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
const filename = 'cherry-studio.backup.zip' const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const backupedFilePath = await this.backup(_, filename, data) const backupedFilePath = await this.backup(_, filename, data)
const webdavClient = new WebDav(webdavConfig) const webdavClient = new WebDav(webdavConfig)
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), { return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
@ -195,18 +197,48 @@ class BackupManager {
} }
async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) { async restoreFromWebdav(_: Electron.IpcMainInvokeEvent, webdavConfig: WebDavConfig) {
const filename = 'cherry-studio.backup.zip' const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
const webdavClient = new WebDav(webdavConfig) const webdavClient = new WebDav(webdavConfig)
const retrievedFile = await webdavClient.getFileContents(filename) try {
const backupedFilePath = path.join(this.backupDir, filename) const retrievedFile = await webdavClient.getFileContents(filename)
const backupedFilePath = path.join(this.backupDir, filename)
if (!fs.existsSync(this.backupDir)) { if (!fs.existsSync(this.backupDir)) {
fs.mkdirSync(this.backupDir, { recursive: true }) fs.mkdirSync(this.backupDir, { recursive: true })
}
// sync为同步写无须await
fs.writeFileSync(backupedFilePath, retrievedFile as Buffer)
return await this.restore(_, backupedFilePath)
} catch (error: any) {
Logger.error('[backup] Failed to restore from WebDAV:', error)
throw new Error(error.message || 'Failed to restore backup file')
} }
}
await fs.writeFileSync(backupedFilePath, retrievedFile as Buffer) listWebdavFiles = async (_: Electron.IpcMainInvokeEvent, config: WebDavConfig) => {
try {
const client = createClient(config.webdavHost, {
username: config.webdavUser,
password: config.webdavPass
})
return await this.restore(_, backupedFilePath) const response = await client.getDirectoryContents(config.webdavPath)
const files = Array.isArray(response) ? response : response.data
return files
.filter((file: FileStat) => file.type === 'file' && file.basename.endsWith('.zip'))
.map((file: FileStat) => ({
fileName: file.basename,
modifiedTime: file.lastmod,
size: file.size
}))
.sort((a, b) => new Date(b.modifiedTime).getTime() - new Date(a.modifiedTime).getTime())
} catch (error: any) {
Logger.error('Failed to list WebDAV files:', error)
throw new Error(error.message || 'Failed to list backup files')
}
} }
private async getDirSize(dirPath: string): Promise<number> { private async getDirSize(dirPath: string): Promise<number> {

View File

@ -6,7 +6,12 @@ import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious,
import type { LoaderReturn } from '@shared/config/types' import type { LoaderReturn } from '@shared/config/types'
import type { OpenDialogOptions } from 'electron' import type { OpenDialogOptions } from 'electron'
import type { UpdateInfo } from 'electron-updater' import type { UpdateInfo } from 'electron-updater'
import { Readable } from 'stream'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
declare global { declare global {
interface Window { interface Window {
@ -24,6 +29,9 @@ declare global {
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }> clearCache: () => Promise<{ success: boolean; error?: string }>
system: {
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
}
zip: { zip: {
compress: (text: string) => Promise<Buffer> compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string> decompress: (text: Buffer) => Promise<string>
@ -33,6 +41,7 @@ declare global {
restore: (backupPath: string) => Promise<string> restore: (backupPath: string) => Promise<string>
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean> backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string> restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
} }
file: { file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null> select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
@ -68,8 +77,8 @@ declare global {
update: (shortcuts: Shortcut[]) => Promise<void> update: (shortcuts: Shortcut[]) => Promise<void>
} }
knowledgeBase: { knowledgeBase: {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void> create: (base: KnowledgeBaseParams) => Promise<void>
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void> reset: (base: KnowledgeBaseParams) => Promise<void>
delete: (id: string) => Promise<void> delete: (id: string) => Promise<void>
add: ({ add: ({
base, base,

View File

@ -17,6 +17,9 @@ const api = {
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url), openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url), minApp: (url: string) => ipcRenderer.invoke('minapp', url),
clearCache: () => ipcRenderer.invoke('app:clear-cache'), clearCache: () => ipcRenderer.invoke('app:clear-cache'),
system: {
getDeviceType: () => ipcRenderer.invoke('system:getDeviceType')
},
zip: { zip: {
compress: (text: string) => ipcRenderer.invoke('zip:compress', text), compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text) decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
@ -27,7 +30,8 @@ const api = {
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath), restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => backupToWebdav: (data: string, webdavConfig: WebDavConfig) =>
ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig), ipcRenderer.invoke('backup:backupToWebdav', data, webdavConfig),
restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig) restoreFromWebdav: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:restoreFromWebdav', webdavConfig),
listWebdavFiles: (webdavConfig: WebDavConfig) => ipcRenderer.invoke('backup:listWebdavFiles', webdavConfig)
}, },
nodeapp: { nodeapp: {
list: () => ipcRenderer.invoke('nodeapp:list'), list: () => ipcRenderer.invoke('nodeapp:list'),
@ -92,9 +96,8 @@ const api = {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts) update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
}, },
knowledgeBase: { knowledgeBase: {
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => create: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:create', base),
ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }), reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:reset', base),
reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }),
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id), delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
add: ({ add: ({
base, base,

View File

@ -45,7 +45,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
useEffect(() => { useEffect(() => {
document.body.setAttribute('os', isMac ? 'mac' : 'windows') document.body.setAttribute('os', isMac ? 'mac' : 'windows')
}, [])
// listen theme change from main process from other windows
const themeChangeListenerRemover = window.electron.ipcRenderer.on('theme:change', (_, newTheme) => {
setTheme(newTheme)
})
return () => {
themeChangeListenerRemover()
}
})
return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider> return <ThemeContext.Provider value={{ theme: _theme, toggleTheme }}>{children}</ThemeContext.Provider>
} }

View File

@ -237,7 +237,8 @@
"copied": "Copied", "copied": "Copied",
"confirm": "Confirm", "confirm": "Confirm",
"more": "More", "more": "More",
"advanced_settings": "Advanced Settings" "advanced_settings": "Advanced Settings",
"expand": "Expand"
}, },
"docs": { "docs": {
"title": "Docs" "title": "Docs"
@ -484,6 +485,10 @@
"upgrade.success.title": "Upgrade successfully", "upgrade.success.title": "Upgrade successfully",
"warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!", "warn.notion.exporting": "Exporting to Notion, please do not request export repeatedly!",
"warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.", "warning.rate.limit": "Too many requests. Please wait {{seconds}} seconds before trying again.",
"tools": {
"invoking": "Invoking",
"completed": "Completed"
},
"nextJsInfo": "Next.js Application Note", "nextJsInfo": "Next.js Application Note",
"nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command." "nextJsDescription": "Next.js applications require a build step before they can be started. Make sure to check 'This is a Next.js application' or manually add 'npm run build' as the build command."
}, },
@ -766,6 +771,12 @@
"password": "WebDAV Password", "password": "WebDAV Password",
"path": "WebDAV Path", "path": "WebDAV Path",
"path.placeholder": "/backup", "path.placeholder": "/backup",
"backup.modal.title": "Backup to WebDAV",
"backup.modal.filename.placeholder": "Please enter backup filename",
"restore.modal.title": "Restore from WebDAV",
"restore.modal.select.placeholder": "Please select a backup file to restore",
"restore.confirm.title": "Confirm Restore",
"restore.confirm.content": "Restoring from WebDAV will overwrite current data. Do you want to continue?",
"restore.button": "Restore from WebDAV", "restore.button": "Restore from WebDAV",
"restore.content": "Restore from WebDAV will overwrite the current data, continue?", "restore.content": "Restore from WebDAV will overwrite the current data, continue?",
"restore.title": "Restore from WebDAV", "restore.title": "Restore from WebDAV",

View File

@ -237,7 +237,8 @@
"copied": "コピーされました", "copied": "コピーされました",
"confirm": "確認", "confirm": "確認",
"more": "もっと", "more": "もっと",
"advanced_settings": "詳細設定" "advanced_settings": "詳細設定",
"expand": "展開"
}, },
"docs": { "docs": {
"title": "ドキュメント" "title": "ドキュメント"
@ -483,7 +484,11 @@
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください", "upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
"upgrade.success.title": "アップグレードに成功しました", "upgrade.success.title": "アップグレードに成功しました",
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ", "warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。" "warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。",
"tools": {
"invoking": "呼び出し中",
"completed": "完了"
}
}, },
"minapp": { "minapp": {
"sidebar.add.title": "サイドバーに追加", "sidebar.add.title": "サイドバーに追加",
@ -763,7 +768,13 @@
"syncError": "バックアップエラー", "syncError": "バックアップエラー",
"syncStatus": "バックアップ状態", "syncStatus": "バックアップ状態",
"title": "WebDAV", "title": "WebDAV",
"user": "WebDAVユーザー" "user": "WebDAVユーザー",
"backup.modal.title": "WebDAV にバックアップ",
"backup.modal.filename.placeholder": "バックアップファイル名を入力してください",
"restore.modal.title": "WebDAV から復元",
"restore.modal.select.placeholder": "復元するバックアップファイルを選択してください",
"restore.confirm.title": "復元を確認",
"restore.confirm.content": "WebDAV から復元すると現在のデータが上書きされます。続行しますか?"
}, },
"yuque": { "yuque": {
"check": { "check": {

View File

@ -237,7 +237,8 @@
"confirm": "Подтверждение", "confirm": "Подтверждение",
"copied": "Скопировано", "copied": "Скопировано",
"more": "Ещё", "more": "Ещё",
"advanced_settings": "Дополнительные настройки" "advanced_settings": "Дополнительные настройки",
"expand": "Развернуть"
}, },
"docs": { "docs": {
"title": "Документация" "title": "Документация"
@ -489,7 +490,11 @@
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления", "upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
"upgrade.success.title": "Обновление успешно", "upgrade.success.title": "Обновление успешно",
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!", "warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова." "warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.",
"tools": {
"invoking": "Вызов",
"completed": "Завершено"
}
}, },
"minapp": { "minapp": {
"sidebar.add.title": "Добавить в боковую панель", "sidebar.add.title": "Добавить в боковую панель",
@ -763,7 +768,13 @@
"syncError": "Ошибка резервного копирования", "syncError": "Ошибка резервного копирования",
"syncStatus": "Статус резервного копирования", "syncStatus": "Статус резервного копирования",
"title": "WebDAV", "title": "WebDAV",
"user": "Пользователь WebDAV" "user": "Пользователь WebDAV",
"backup.modal.title": "Резервное копирование на WebDAV",
"backup.modal.filename.placeholder": "Введите имя файла резервной копии",
"restore.modal.title": "Восстановление с WebDAV",
"restore.modal.select.placeholder": "Выберите файл резервной копии для восстановления",
"restore.confirm.title": "Подтверждение восстановления",
"restore.confirm.content": "Восстановление с WebDAV перезапишет текущие данные, продолжить?"
}, },
"yuque": { "yuque": {
"check": { "check": {

View File

@ -237,7 +237,8 @@
"warning": "警告", "warning": "警告",
"you": "用户", "you": "用户",
"more": "更多", "more": "更多",
"advanced_settings": "高级设置" "advanced_settings": "高级设置",
"expand": "展开"
}, },
"docs": { "docs": {
"title": "帮助文档" "title": "帮助文档"
@ -483,7 +484,11 @@
"upgrade.success.content": "重启用以完成升级", "upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功", "upgrade.success.title": "升级成功",
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!", "warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试" "warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
"tools": {
"invoking": "调用中",
"completed": "已完成"
}
}, },
"minapp": { "minapp": {
"sidebar.add.title": "添加到侧边栏", "sidebar.add.title": "添加到侧边栏",
@ -757,6 +762,12 @@
"password": "WebDAV 密码", "password": "WebDAV 密码",
"path": "WebDAV 路径", "path": "WebDAV 路径",
"path.placeholder": "/backup", "path.placeholder": "/backup",
"backup.modal.title": "备份到 WebDAV",
"backup.modal.filename.placeholder": "请输入备份文件名",
"restore.modal.title": "从 WebDAV 恢复",
"restore.modal.select.placeholder": "请选择要恢复的备份文件",
"restore.confirm.title": "确认恢复",
"restore.confirm.content": "从 WebDAV 恢复将会覆盖当前数据,是否继续?",
"restore.button": "从 WebDAV 恢复", "restore.button": "从 WebDAV 恢复",
"restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?", "restore.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
"restore.title": "从 WebDAV 恢复", "restore.title": "从 WebDAV 恢复",

View File

@ -237,7 +237,8 @@
"copied": "已複製", "copied": "已複製",
"confirm": "確認", "confirm": "確認",
"more": "更多", "more": "更多",
"advanced_settings": "進階設定" "advanced_settings": "進階設定",
"expand": "展開"
}, },
"docs": { "docs": {
"title": "說明文件" "title": "說明文件"
@ -483,7 +484,11 @@
"upgrade.success.content": "請重新啟動程式以完成升級", "upgrade.success.content": "請重新啟動程式以完成升級",
"upgrade.success.title": "升級成功", "upgrade.success.title": "升級成功",
"warn.notion.exporting": "正在匯出到 Notion請勿重複請求匯出", "warn.notion.exporting": "正在匯出到 Notion請勿重複請求匯出",
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試" "warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
"tools": {
"invoking": "調用中",
"completed": "已完成"
}
}, },
"minapp": { "minapp": {
"sidebar.add.title": "新增到側邊欄", "sidebar.add.title": "新增到側邊欄",
@ -763,7 +768,13 @@
"syncError": "備份錯誤", "syncError": "備份錯誤",
"syncStatus": "備份狀態", "syncStatus": "備份狀態",
"title": "WebDAV", "title": "WebDAV",
"user": "WebDAV 使用者名稱" "user": "WebDAV 使用者名稱",
"backup.modal.title": "備份到 WebDAV",
"backup.modal.filename.placeholder": "請輸入備份文件名",
"restore.modal.title": "從 WebDAV 恢復",
"restore.modal.select.placeholder": "請選擇要恢復的備份文件",
"restore.confirm.title": "復元確認",
"restore.confirm.content": "從 WebDAV 恢復將覆蓋目前資料,是否繼續?"
}, },
"yuque": { "yuque": {
"check": { "check": {

View File

@ -70,7 +70,7 @@ const MessageMenubar: FC<Props> = (props) => {
const onCopy = useCallback( const onCopy = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content)) navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content.trimStart()))
window.message.success({ content: t('message.copied'), key: 'copy-message' }) window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)

View File

@ -60,7 +60,7 @@ const MessageTools: FC<Props> = ({ message }) => {
<TitleContent> <TitleContent>
<ToolName>{tool.name}</ToolName> <ToolName>{tool.name}</ToolName>
<StatusIndicator $isInvoking={isInvoking}> <StatusIndicator $isInvoking={isInvoking}>
{isInvoking ? t('tools.invoking') : t('tools.completed')} {isInvoking ? t('message.tools.invoking') : t('message.tools.completed')}
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />} {isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
{isDone && <CheckOutlined style={{ marginLeft: 6 }} />} {isDone && <CheckOutlined style={{ marginLeft: 6 }} />}
</StatusIndicator> </StatusIndicator>

View File

@ -142,10 +142,10 @@ const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
name="rerankModel" name="rerankModel"
label={t('models.rerank_model')} label={t('models.rerank_model')}
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }} tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
initialValue={getModelUniqId(base.rerankModel) || undefined}
rules={[{ required: false, message: t('message.error.enter.model') }]}> rules={[{ required: false, message: t('message.error.enter.model') }]}>
<Select <Select
style={{ width: '100%' }} style={{ width: '100%' }}
defaultValue={getModelUniqId(base.rerankModel) || undefined}
options={rerankSelectOptions} options={rerankSelectOptions}
placeholder={t('settings.models.empty')} placeholder={t('settings.models.empty')}
allowClear allowClear

View File

@ -1,10 +1,9 @@
import { FolderOpenOutlined, SaveOutlined, SyncOutlined } from '@ant-design/icons' import { FolderOpenOutlined, SaveOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService' import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
setWebdavAutoSync, setWebdavAutoSync,
setWebdavHost as _setWebdavHost, setWebdavHost as _setWebdavHost,
@ -13,13 +12,19 @@ import {
setWebdavSyncInterval as _setWebdavSyncInterval, setWebdavSyncInterval as _setWebdavSyncInterval,
setWebdavUser as _setWebdavUser setWebdavUser as _setWebdavUser
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { Button, Input, Select } from 'antd' import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..' import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
interface BackupFile {
fileName: string
modifiedTime: string
size: number
}
const WebDavSettings: FC = () => { const WebDavSettings: FC = () => {
const { const {
webdavHost: webDAVHost, webdavHost: webDAVHost,
@ -38,45 +43,22 @@ const WebDavSettings: FC = () => {
const [backuping, setBackuping] = useState(false) const [backuping, setBackuping] = useState(false)
const [restoring, setRestoring] = useState(false) const [restoring, setRestoring] = useState(false)
const [isModalVisible, setIsModalVisible] = useState(false)
const [customFileName, setCustomFileName] = useState('')
const [isRestoreModalVisible, setIsRestoreModalVisible] = useState(false)
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([])
const [selectedFile, setSelectedFile] = useState<string>('')
const [loadingFiles, setLoadingFiles] = useState(false)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
const { webdavSync } = useRuntime() const { webdavSync } = useAppSelector((state) => state.backup)
// 把之前备份的文件定时上传到 webdav首先先配置 webdav 的 host, port, user, pass, path // 把之前备份的文件定时上传到 webdav首先先配置 webdav 的 host, port, user, pass, path
const onBackup = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setBackuping(true)
await backupToWebdav({ showMessage: true })
setBackuping(false)
}
const onRestore = async () => {
if (!webdavHost) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setRestoring(true)
await restoreFromWebdav()
setRestoring(false)
}
const onPressRestore = () => {
window.modal.confirm({
title: t('settings.data.webdav.restore.title'),
content: t('settings.data.webdav.restore.content'),
centered: true,
onOk: onRestore
})
}
const onSyncIntervalChange = (value: number) => { const onSyncIntervalChange = (value: number) => {
setSyncInterval(value) setSyncInterval(value)
dispatch(_setWebdavSyncInterval(value)) dispatch(_setWebdavSyncInterval(value))
@ -99,20 +81,102 @@ const WebDavSettings: FC = () => {
return ( return (
<HStack gap="5px" alignItems="center"> <HStack gap="5px" alignItems="center">
{webdavSync.syncing && <SyncOutlined spin />} {webdavSync.syncing && <SyncOutlined spin />}
{!webdavSync.syncing && webdavSync.lastSyncError && (
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${webdavSync.lastSyncError}`}>
<WarningOutlined style={{ color: 'red' }} />
</Tooltip>
)}
{webdavSync.lastSyncTime && ( {webdavSync.lastSyncTime && (
<span style={{ color: 'var(--text-secondary)' }}> <span style={{ color: 'var(--text-secondary)' }}>
{t('settings.data.webdav.lastSync')}: {dayjs(webdavSync.lastSyncTime).format('HH:mm:ss')} {t('settings.data.webdav.lastSync')}: {dayjs(webdavSync.lastSyncTime).format('HH:mm:ss')}
</span> </span>
)} )}
{webdavSync.lastSyncError && (
<span style={{ color: 'var(--error-color)' }}>
{t('settings.data.webdav.syncError')}: {webdavSync.lastSyncError}
</span>
)}
</HStack> </HStack>
) )
} }
const showBackupModal = async () => {
// 获取默认文件名
const deviceType = await window.api.system.getDeviceType()
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const defaultFileName = `cherry-studio.${timestamp}.${deviceType}.zip`
setCustomFileName(defaultFileName)
setIsModalVisible(true)
}
const handleBackup = async () => {
setBackuping(true)
try {
await backupToWebdav({ showMessage: true, customFileName })
} finally {
setBackuping(false)
setIsModalVisible(false)
}
}
const handleCancel = () => {
setIsModalVisible(false)
}
const showRestoreModal = async () => {
if (!webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({ content: t('message.error.invalid.webdav'), key: 'webdav-error' })
return
}
setIsRestoreModalVisible(true)
setLoadingFiles(true)
try {
const files = await window.api.backup.listWebdavFiles({
webdavHost,
webdavUser,
webdavPass,
webdavPath
})
setBackupFiles(files)
} catch (error: any) {
window.message.error({ content: error.message, key: 'list-files-error' })
} finally {
setLoadingFiles(false)
}
}
const handleRestore = async () => {
if (!selectedFile || !webdavHost || !webdavUser || !webdavPass || !webdavPath) {
window.message.error({
content: !selectedFile ? t('message.error.no.file.selected') : t('message.error.invalid.webdav'),
key: 'restore-error'
})
return
}
window.modal.confirm({
title: t('settings.data.webdav.restore.confirm.title'),
content: t('settings.data.webdav.restore.confirm.content'),
centered: true,
onOk: async () => {
setRestoring(true)
try {
await restoreFromWebdav(selectedFile)
setIsRestoreModalVisible(false)
} catch (error: any) {
window.message.error({ content: error.message, key: 'restore-error' })
} finally {
setRestoring(false)
}
}
})
}
const formatFileOption = (file: BackupFile) => {
const date = dayjs(file.modifiedTime).format('YYYY-MM-DD HH:mm:ss')
const size = `${(file.size / 1024).toFixed(2)} KB`
return {
label: `${file.fileName} (${date}, ${size})`,
value: file.fileName
}
}
return ( return (
<SettingGroup theme={theme}> <SettingGroup theme={theme}>
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle> <SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
@ -165,10 +229,10 @@ const WebDavSettings: FC = () => {
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle> <SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
<HStack gap="5px" justifyContent="space-between"> <HStack gap="5px" justifyContent="space-between">
<Button onClick={onBackup} icon={<SaveOutlined />} loading={backuping}> <Button onClick={showBackupModal} icon={<SaveOutlined />} loading={backuping}>
{t('settings.data.webdav.backup.button')} {t('settings.data.webdav.backup.button')}
</Button> </Button>
<Button onClick={onPressRestore} icon={<FolderOpenOutlined />} loading={restoring}> <Button onClick={showRestoreModal} icon={<FolderOpenOutlined />} loading={restoring}>
{t('settings.data.webdav.restore.button')} {t('settings.data.webdav.restore.button')}
</Button> </Button>
</HStack> </HStack>
@ -198,6 +262,46 @@ const WebDavSettings: FC = () => {
</SettingRow> </SettingRow>
</> </>
)} )}
<>
<Modal
title={t('settings.data.webdav.backup.modal.title')}
open={isModalVisible}
onOk={handleBackup}
onCancel={handleCancel}
okButtonProps={{ loading: backuping }}>
<Input
value={customFileName}
onChange={(e) => setCustomFileName(e.target.value)}
placeholder={t('settings.data.webdav.backup.modal.filename.placeholder')}
/>
</Modal>
<Modal
title={t('settings.data.webdav.restore.modal.title')}
open={isRestoreModalVisible}
onOk={handleRestore}
onCancel={() => setIsRestoreModalVisible(false)}
okButtonProps={{ loading: restoring }}
width={600}>
<div style={{ position: 'relative' }}>
<Select
style={{ width: '100%' }}
placeholder={t('settings.data.webdav.restore.modal.select.placeholder')}
value={selectedFile}
onChange={setSelectedFile}
options={backupFiles.map(formatFileOption)}
loading={loadingFiles}
showSearch
filterOption={(input, option) => (option?.label ?? '').toLowerCase().includes(input.toLowerCase())}
/>
{loadingFiles && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}>
<Spin />
</div>
)}
</div>
</Modal>
</>
</SettingGroup> </SettingGroup>
) )
} }

View File

@ -306,7 +306,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</Space.Compact> </Space.Compact>
{apiKeyWebsite && ( {apiKeyWebsite && (
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}> <SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
<HStack gap={5}> <HStack>
<SettingHelpLink target="_blank" href={apiKeyWebsite}> <SettingHelpLink target="_blank" href={apiKeyWebsite}>
{t('settings.provider.get_api_key')} {t('settings.provider.get_api_key')}
</SettingHelpLink> </SettingHelpLink>

View File

@ -112,8 +112,8 @@ const TranslatePage: FC = () => {
message, message,
assistant, assistant,
onResponse: (text) => { onResponse: (text) => {
translatedText = text translatedText = text.replace(/^\s*\n+/g, '')
setResult(text) setResult(translatedText)
} }
}) })
} catch (error) { } catch (error) {

View File

@ -497,7 +497,7 @@ export default class OpenAIProvider extends BaseProvider {
} }
} }
if (finishReason === 'tool_calls') { if (finishReason === 'tool_calls' || (finishReason === 'stop' && Object.keys(final_tool_calls).length > 0)) {
const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs) const toolCalls = Object.values(final_tool_calls).map(this.cleanToolCallArgs)
console.log('start invoke tools', toolCalls) console.log('start invoke tools', toolCalls)
if (this.isZhipuTool(model)) { if (this.isZhipuTool(model)) {

View File

@ -1,8 +1,9 @@
import db from '@renderer/databases' import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import store from '@renderer/store' import store from '@renderer/store'
import { setWebDAVSyncState } from '@renderer/store/runtime' import { setWebDAVSyncState } from '@renderer/store/backup'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Logger from 'electron-log'
export async function backup() { export async function backup() {
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
@ -59,16 +60,27 @@ export async function reset() {
} }
// 备份到 webdav // 备份到 webdav
export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) { export async function backupToWebdav({
showMessage = false,
customFileName = ''
}: { showMessage?: boolean; customFileName?: string } = {}) {
if (isManualBackupRunning) { if (isManualBackupRunning) {
console.log('[Backup] Manual backup already in progress') Logger.log('[Backup] Manual backup already in progress')
return return
} }
store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null })) store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null }))
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let deviceType = 'unknown'
try {
deviceType = (await window.api.system.getDeviceType()) || 'unknown'
} catch (error) {
Logger.error('[Backup] Failed to get device type:', error)
}
const timestamp = dayjs().format('YYYYMMDDHHmmss')
const backupFileName = customFileName || `cherry-studio.${timestamp}.${deviceType}.zip`
const finalFileName = backupFileName.endsWith('.zip') ? backupFileName : `${backupFileName}.zip`
const backupData = await getBackupData() const backupData = await getBackupData()
// 上传文件 // 上传文件
@ -77,43 +89,47 @@ export async function backupToWebdav({ showMessage = false }: { showMessage?: bo
webdavHost, webdavHost,
webdavUser, webdavUser,
webdavPass, webdavPass,
webdavPath webdavPath,
fileName: finalFileName
}) })
if (success) { if (success) {
store.dispatch( store.dispatch(
setWebDAVSyncState({ setWebDAVSyncState({
lastSyncTime: Date.now(),
lastSyncError: null lastSyncError: null
}) })
) )
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' }) showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} else { } else {
store.dispatch(setWebDAVSyncState({ lastSyncError: 'Backup failed' })) store.dispatch(setWebDAVSyncState({ lastSyncError: 'Backup failed' }))
showMessage && window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' }) window.message.error({ content: i18n.t('message.backup.failed'), key: 'backup' })
} }
} catch (error: any) { } catch (error: any) {
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message })) store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error) console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
showMessage && window.modal.error({
window.modal.error({ title: i18n.t('message.backup.failed'),
title: i18n.t('message.backup.failed'), content: error.message
content: error.message })
})
} finally { } finally {
store.dispatch(setWebDAVSyncState({ syncing: false })) store.dispatch(
setWebDAVSyncState({
lastSyncTime: Date.now(),
syncing: false
})
)
isManualBackupRunning = false isManualBackupRunning = false
} }
} }
// 从 webdav 恢复 // 从 webdav 恢复
export async function restoreFromWebdav() { export async function restoreFromWebdav(fileName?: string) {
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
let data = '' let data = ''
try { try {
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath }) data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath, fileName })
} catch (error: any) { } catch (error: any) {
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error) console.error('[Backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
window.modal.error({ window.modal.error({
title: i18n.t('message.restore.failed'), title: i18n.t('message.restore.failed'),
content: error.message content: error.message
@ -123,7 +139,7 @@ export async function restoreFromWebdav() {
try { try {
await handleData(JSON.parse(data)) await handleData(JSON.parse(data))
} catch (error) { } catch (error) {
console.error('[backup] Error downloading file from WebDAV:', error) console.error('[Backup] Error downloading file from WebDAV:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} }
} }
@ -158,6 +174,7 @@ export function startAutoSync() {
} }
const { webdavSyncInterval } = store.getState().settings const { webdavSyncInterval } = store.getState().settings
const { webdavSync } = store.getState().backup
if (webdavSyncInterval <= 0) { if (webdavSyncInterval <= 0) {
console.log('[AutoSync] Invalid sync interval, auto sync disabled') console.log('[AutoSync] Invalid sync interval, auto sync disabled')
@ -165,9 +182,21 @@ export function startAutoSync() {
return return
} }
syncTimeout = setTimeout(performAutoBackup, webdavSyncInterval * 60 * 1000) // 用户指定的自动备份时间间隔(毫秒)
const requiredInterval = webdavSyncInterval * 60 * 1000
console.log(`[AutoSync] Next sync scheduled in ${webdavSyncInterval} minutes`) // 如果存在最后一次同步WebDAV的时间以它为参考计算下一次同步的时间
const timeUntilNextSync = webdavSync?.lastSyncTime
? Math.max(1000, webdavSync.lastSyncTime + requiredInterval - Date.now())
: requiredInterval
syncTimeout = setTimeout(performAutoBackup, timeUntilNextSync)
console.log(
`[AutoSync] Next sync scheduled in ${Math.floor(timeUntilNextSync / 1000 / 60)} minutes ${Math.floor(
(timeUntilNextSync / 1000) % 60
)} seconds`
)
} }
async function performAutoBackup() { async function performAutoBackup() {
@ -179,7 +208,7 @@ export function startAutoSync() {
isAutoBackupRunning = true isAutoBackupRunning = true
try { try {
console.log('[AutoSync] Performing auto backup...') console.log('[AutoSync] Starting auto backup...')
await backupToWebdav({ showMessage: false }) await backupToWebdav({ showMessage: false })
} catch (error) { } catch (error) {
console.error('[AutoSync] Auto backup failed:', error) console.error('[AutoSync] Auto backup failed:', error)

View File

@ -11,10 +11,12 @@ import FileManager from './FileManager'
export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams => { export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams => {
const provider = getProviderByModel(base.model) const provider = getProviderByModel(base.model)
const rerankProvider = getProviderByModel(base.rerankModel)
const aiProvider = new AiProvider(provider) const aiProvider = new AiProvider(provider)
const rerankAiProvider = new AiProvider(rerankProvider)
let host = aiProvider.getBaseURL() let host = aiProvider.getBaseURL()
const rerankHost = rerankAiProvider.getBaseURL()
if (provider.type === 'gemini') { if (provider.type === 'gemini') {
host = host + '/v1beta/openai/' host = host + '/v1beta/openai/'
} }
@ -40,6 +42,8 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
baseURL: host, baseURL: host,
chunkSize, chunkSize,
chunkOverlap: base.chunkOverlap, chunkOverlap: base.chunkOverlap,
rerankBaseURL: rerankHost,
rerankApiKey: rerankAiProvider.getApiKey() || 'secret',
rerankModel: base.rerankModel?.id, rerankModel: base.rerankModel?.id,
rerankModelProvider: base.rerankModel?.provider, rerankModelProvider: base.rerankModel?.provider,
topN: base.topN topN: base.topN

View File

@ -0,0 +1,32 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface BackupState {
webdavSync: WebDAVSyncState
}
const initialState: BackupState = {
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
}
}
const backupSlice = createSlice({
name: 'backup',
initialState,
reducers: {
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
}
}
})
export const { setWebDAVSyncState } = backupSlice.actions
export default backupSlice.reducer

View File

@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
import agents from './agents' import agents from './agents'
import assistants from './assistants' import assistants from './assistants'
import backup from './backup'
import copilot from './copilot' import copilot from './copilot'
import knowledge from './knowledge' import knowledge from './knowledge'
import llm from './llm' import llm from './llm'
@ -22,6 +23,7 @@ import websearch from './websearch'
const rootReducer = combineReducers({ const rootReducer = combineReducers({
assistants, assistants,
agents, agents,
backup,
paintings, paintings,
llm, llm,
settings, settings,
@ -40,7 +42,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 81, version: 82,
blacklist: ['runtime', 'messages'], blacklist: ['runtime', 'messages'],
migrate migrate
}, },

View File

@ -28,31 +28,6 @@ const initialState: MessagesState = {
error: null error: null
} }
// const MAX_RECENT_TOPICS = 10
// // 只初始化最近的会话消息
// export const initializeMessagesState = createAsyncThunk('messages/initialize', async () => {
// try {
// // 获取所有会话的基本信息
// const recentTopics = await TopicManager.getTopicLimit(MAX_RECENT_TOPICS)
// console.log('recentTopics', recentTopics)
// const messagesByTopic: Record<string, Message[]> = {}
// // 只加载最近会话的消息
// for (const topic of recentTopics) {
// if (topic.messages && topic.messages.length > 0) {
// const messages = topic.messages.map((msg) => ({ ...msg }))
// messagesByTopic[topic.id] = messages
// }
// }
// return messagesByTopic
// } catch (error) {
// console.error('Failed to initialize recent messages:', error)
// return {}
// }
// })
// 新增准备会话消息的函数,实现懒加载机制 // 新增准备会话消息的函数,实现懒加载机制
export const prepareTopicMessages = createAsyncThunk( export const prepareTopicMessages = createAsyncThunk(
'messages/prepareTopic', 'messages/prepareTopic',
@ -144,7 +119,7 @@ const messagesSlice = createSlice({
if (message) { if (message) {
Object.assign(message, updates) Object.assign(message, updates)
db.topics.update(topicId, { db.topics.update(topicId, {
messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : m)) messages: topicMessages.map((m) => (m.id === message.id ? cloneDeep(message) : cloneDeep(m)))
}) })
} }
} }
@ -210,19 +185,6 @@ const messagesSlice = createSlice({
} }
} }
} }
// extraReducers: (builder) => {
// builder
// .addCase(initializeMessagesState.pending, (state) => {
// state.error = null
// })
// .addCase(initializeMessagesState.fulfilled, (state, action) => {
// console.log('initializeMessagesState.fulfilled', action.payload)
// state.messagesByTopic = action.payload
// })
// .addCase(initializeMessagesState.rejected, (state, action) => {
// state.error = action.error.message || 'Failed to load messages'
// })
// }
}) })
const handleResponseMessageUpdate = ( const handleResponseMessageUpdate = (

View File

@ -772,6 +772,22 @@ const migrateConfig = {
'81': (state: RootState) => { '81': (state: RootState) => {
addProvider(state, 'copilot') addProvider(state, 'copilot')
return state return state
},
'82': (state: RootState) => {
const runtimeState = state.runtime as any
if (runtimeState?.webdavSync) {
state.backup = state.backup || {}
state.backup = {
...state.backup,
webdavSync: {
lastSyncTime: runtimeState.webdavSync.lastSyncTime || null,
syncing: runtimeState.webdavSync.syncing || false,
lastSyncError: runtimeState.webdavSync.lastSyncError || null
}
}
delete runtimeState.webdavSync
}
return state
} }
} }

View File

@ -11,12 +11,6 @@ export interface UpdateState {
available: boolean available: boolean
} }
export interface WebDAVSyncState {
lastSyncTime: number | null
syncing: boolean
lastSyncError: string | null
}
export interface RuntimeState { export interface RuntimeState {
avatar: string avatar: string
generating: boolean generating: boolean
@ -25,7 +19,6 @@ export interface RuntimeState {
filesPath: string filesPath: string
resourcesPath: string resourcesPath: string
update: UpdateState update: UpdateState
webdavSync: WebDAVSyncState
export: ExportState export: ExportState
} }
@ -48,11 +41,6 @@ const initialState: RuntimeState = {
downloadProgress: 0, downloadProgress: 0,
available: false available: false
}, },
webdavSync: {
lastSyncTime: null,
syncing: false,
lastSyncError: null
},
export: { export: {
isExporting: false isExporting: false
} }
@ -83,9 +71,6 @@ const runtimeSlice = createSlice({
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => { setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
state.update = { ...state.update, ...action.payload } state.update = { ...state.update, ...action.payload }
}, },
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
state.webdavSync = { ...state.webdavSync, ...action.payload }
},
setExportState: (state, action: PayloadAction<Partial<ExportState>>) => { setExportState: (state, action: PayloadAction<Partial<ExportState>>) => {
state.export = { ...state.export, ...action.payload } state.export = { ...state.export, ...action.payload }
} }
@ -100,7 +85,6 @@ export const {
setFilesPath, setFilesPath,
setResourcesPath, setResourcesPath,
setUpdateState, setUpdateState,
setWebDAVSyncState,
setExportState setExportState
} = runtimeSlice.actions } = runtimeSlice.actions

View File

@ -218,6 +218,7 @@ export type WebDavConfig = {
webdavUser: string webdavUser: string
webdavPass: string webdavPass: string
webdavPath: string webdavPath: string
fileName?: string
} }
export type AppInfo = { export type AppInfo = {
@ -285,6 +286,8 @@ export type KnowledgeBaseParams = {
baseURL: string baseURL: string
chunkSize?: number chunkSize?: number
chunkOverlap?: number chunkOverlap?: number
rerankApiKey?: string
rerankBaseURL?: string
rerankModel?: string rerankModel?: string
rerankModelProvider?: string rerankModelProvider?: string
topN?: number topN?: number

View File

@ -18,7 +18,10 @@ const supportedAttributes = [
] ]
function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) { function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
const roperties = tool.inputSchema.properties const properties = tool.inputSchema.properties
if (!properties) {
return {}
}
const getSubMap = (obj: Record<string, any>, keys: string[]) => { const getSubMap = (obj: Record<string, any>, keys: string[]) => {
const filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key))) const filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
@ -46,10 +49,10 @@ function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
return filtered return filtered
} }
for (const [key, val] of Object.entries(roperties)) { for (const [key, val] of Object.entries(properties)) {
roperties[key] = getSubMap(val, supportedAttributes) properties[key] = getSubMap(val, supportedAttributes)
} }
return roperties return properties
} }
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> { export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {

View File

@ -29,7 +29,7 @@ const HomeWindow: FC = () => {
const textChange = useState(() => {})[1] const textChange = useState(() => {})[1]
const { defaultAssistant } = useDefaultAssistant() const { defaultAssistant } = useDefaultAssistant()
const { defaultModel: model } = useDefaultModel() const { defaultModel: model } = useDefaultModel()
const { language, readClipboardAtStartup } = useSettings() const { language, readClipboardAtStartup, windowStyle, theme } = useSettings()
const { t } = useTranslation() const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null) const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null) const featureMenusRef = useRef<FeatureMenusRef>(null)
@ -201,9 +201,24 @@ const HomeWindow: FC = () => {
} }
}, [route]) }, [route])
const backgroundColor = () => {
// ONLY MAC: when transparent style + light theme: use vibrancy effect
// because the dark style under mac's vibrancy effect has not been implemented
if (
isMac &&
windowStyle === 'transparent' &&
theme === 'light' &&
!window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return 'transparent'
}
return 'var(--color-background)'
}
if (['chat', 'summary', 'explanation'].includes(route)) { if (['chat', 'summary', 'explanation'].includes(route)) {
return ( return (
<Container> <Container style={{ backgroundColor: backgroundColor() }}>
{route === 'chat' && ( {route === 'chat' && (
<> <>
<InputBar <InputBar
@ -232,7 +247,7 @@ const HomeWindow: FC = () => {
if (route === 'translate') { if (route === 'translate') {
return ( return (
<Container> <Container style={{ backgroundColor: backgroundColor() }}>
<TranslateWindow text={referenceText} /> <TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} /> <Footer route={route} onExit={() => setRoute('home')} />
@ -241,7 +256,7 @@ const HomeWindow: FC = () => {
} }
return ( return (
<Container> <Container style={{ backgroundColor: backgroundColor() }}>
<InputBar <InputBar
text={text} text={text}
model={model} model={model}
@ -280,7 +295,6 @@ const Container = styled.div`
flex-direction: column; flex-direction: column;
-webkit-app-region: drag; -webkit-app-region: drag;
padding: 8px 10px; padding: 8px 10px;
background-color: ${isMac ? 'transparent' : 'var(--color-background)'};
` `
const Main = styled.main` const Main = styled.main`

View File

@ -3411,6 +3411,7 @@ __metadata:
rollup-plugin-visualizer: "npm:^5.12.0" rollup-plugin-visualizer: "npm:^5.12.0"
sass: "npm:^1.77.2" sass: "npm:^1.77.2"
shiki: "npm:^1.22.2" shiki: "npm:^1.22.2"
socks-proxy-agent: "npm:^8.0.3"
string-width: "npm:^7.2.0" string-width: "npm:^7.2.0"
styled-components: "npm:^6.1.11" styled-components: "npm:^6.1.11"
tar: "npm:^7.4.3" tar: "npm:^7.4.3"