mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
Merge branch 'main' into node-store
This commit is contained in:
commit
a97c3d9695
@ -52,6 +52,7 @@ Cherry Studio is a desktop client that supports for multiple LLM providers, avai
|
||||
- 🔤 AI-powered Translation
|
||||
- 🎯 Drag-and-drop Sorting
|
||||
- 🔌 Mini Program Support
|
||||
- ⚙️ MCP(Model Context Protocol) Server
|
||||
|
||||
5. **Enhanced User Experience**:
|
||||
|
||||
|
||||
@ -53,6 +53,7 @@ Cherry Studio は、複数の LLM プロバイダーをサポートするデス
|
||||
- 🔤 AI による翻訳機能
|
||||
- 🎯 ドラッグ&ドロップによる整理
|
||||
- 🔌 ミニプログラム対応
|
||||
- ⚙️ MCP(モデルコンテキストプロトコル) サービス
|
||||
|
||||
5. **優れたユーザー体験**:
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ Cherry Studio 是一款支持多个大语言模型(LLM)服务商的桌面客
|
||||
- 🔤 AI 驱动的翻译功能
|
||||
- 🎯 拖拽排序
|
||||
- 🔌 小程序支持
|
||||
- ⚙️ MCP(模型上下文协议) 服务
|
||||
|
||||
5. **优质使用体验**:
|
||||
|
||||
|
||||
@ -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']
|
||||
}
|
||||
])
|
||||
|
||||
@ -85,6 +85,7 @@
|
||||
"npx-scope-finder": "^1.2.0",
|
||||
"officeparser": "^4.1.1",
|
||||
"p-queue": "^8.1.0",
|
||||
"socks-proxy-agent": "^8.0.3",
|
||||
"tar": "^7.4.3",
|
||||
"tokenx": "^0.4.1",
|
||||
"undici": "^7.4.0",
|
||||
|
||||
@ -103,7 +103,10 @@ export const textExts = [
|
||||
'.cxx', // C++ 源文件
|
||||
'.cppm', // C++20 模块接口文件
|
||||
'.ipp', // 模板实现文件
|
||||
'.ixx' // C++20 模块实现文件
|
||||
'.ixx', // C++20 模块实现文件
|
||||
'.f90', // Fortran 90 源文件
|
||||
'.f', // Fortran 固定格式源代码文件
|
||||
'.f03' // Fortran 2003+ 源代码文件
|
||||
]
|
||||
|
||||
export const ZOOM_SHORTCUTS = [
|
||||
|
||||
52
resources/scripts/download.js
Normal file
52
resources/scripts/download.js
Normal 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 }
|
||||
@ -2,8 +2,8 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const https = require('https')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading bun binaries
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')
|
||||
|
||||
@ -2,9 +2,9 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const os = require('os')
|
||||
const { execSync } = require('child_process')
|
||||
const https = require('https')
|
||||
const tar = require('tar')
|
||||
const AdmZip = require('adm-zip')
|
||||
const { downloadWithRedirects } = require('./download')
|
||||
|
||||
// Base URL for downloading uv binaries
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string} platform Platform to download for (e.g., 'darwin', 'win32', 'linux')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
|
||||
import { app } from 'electron'
|
||||
import { app, ipcMain } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
|
||||
import { registerIpc } from './ipc'
|
||||
@ -44,6 +44,9 @@ if (!app.requestSingleInstanceLock()) {
|
||||
.then((name) => console.log(`Added Extension: ${name}`))
|
||||
.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
|
||||
|
||||
@ -85,8 +85,21 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
})
|
||||
|
||||
// theme
|
||||
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
|
||||
ipcMain.handle('app:set-theme', (event, theme: ThemeMode) => {
|
||||
if (theme === configManager.getTheme()) return
|
||||
|
||||
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(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:backupToWebdav', backupManager.backupToWebdav)
|
||||
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
|
||||
ipcMain.handle('backup:listWebdavFiles', backupManager.listWebdavFiles)
|
||||
|
||||
// file
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
|
||||
@ -13,7 +13,7 @@ export default abstract class BaseReranker {
|
||||
|
||||
public defaultHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.base.apiKey}`,
|
||||
Authorization: `Bearer ${this.base.rerankApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
48
src/main/reranker/JinaReranker.ts
Normal file
48
src/main/reranker/JinaReranker.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,12 +2,15 @@ import { KnowledgeBaseParams } from '@types'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
import DefaultReranker from './DefaultReranker'
|
||||
import JinaReranker from './JinaReranker'
|
||||
import SiliconFlowReranker from './SiliconFlowReranker'
|
||||
|
||||
export default class RerankerFactory {
|
||||
static create(base: KnowledgeBaseParams): BaseReranker {
|
||||
if (base.rerankModelProvider === 'silicon') {
|
||||
return new SiliconFlowReranker(base)
|
||||
} else if (base.rerankModelProvider === 'jina') {
|
||||
return new JinaReranker(base)
|
||||
}
|
||||
return new DefaultReranker(base)
|
||||
}
|
||||
|
||||
@ -10,37 +10,41 @@ export default class SiliconFlowReranker extends BaseReranker {
|
||||
}
|
||||
|
||||
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(
|
||||
url,
|
||||
{
|
||||
model: this.base.rerankModel,
|
||||
query,
|
||||
documents: searchResults.map((doc) => doc.pageContent),
|
||||
top_n: this.base.topN,
|
||||
max_chunks_per_doc: this.base.chunkSize,
|
||||
overlap_tokens: this.base.chunkOverlap
|
||||
},
|
||||
{
|
||||
headers: this.defaultHeaders()
|
||||
}
|
||||
)
|
||||
const requestBody = {
|
||||
model: this.base.rerankModel,
|
||||
query,
|
||||
documents: searchResults.map((doc) => doc.pageContent),
|
||||
top_n: this.base.topN,
|
||||
max_chunks_per_doc: this.base.chunkSize,
|
||||
overlap_tokens: this.base.chunkOverlap
|
||||
}
|
||||
|
||||
const rerankResults = data.results
|
||||
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
|
||||
try {
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
return searchResults
|
||||
.map((doc: ExtractChunkData, index: number) => {
|
||||
const score = resultMap.get(index)
|
||||
if (score === undefined) return undefined
|
||||
const rerankResults = data.results
|
||||
const resultMap = new Map(rerankResults.map((result: any) => [result.index, result.relevance_score || 0]))
|
||||
|
||||
return {
|
||||
...doc,
|
||||
score
|
||||
}
|
||||
})
|
||||
.filter((doc): doc is ExtractChunkData => doc !== undefined)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
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('SiliconFlow Reranker API 错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import * as fs from 'fs-extra'
|
||||
import * as path from 'path'
|
||||
import { createClient, FileStat } from 'webdav'
|
||||
|
||||
import WebDav from './WebDav'
|
||||
import { windowService } from './WindowService'
|
||||
@ -18,6 +19,7 @@ class BackupManager {
|
||||
this.restore = this.restore.bind(this)
|
||||
this.backupToWebdav = this.backupToWebdav.bind(this)
|
||||
this.restoreFromWebdav = this.restoreFromWebdav.bind(this)
|
||||
this.listWebdavFiles = this.listWebdavFiles.bind(this)
|
||||
}
|
||||
|
||||
private async setWritableRecursive(dirPath: string): Promise<void> {
|
||||
@ -117,10 +119,10 @@ class BackupManager {
|
||||
await fs.remove(this.tempDir)
|
||||
onProgress({ stage: 'completed', progress: 100, total: 100 })
|
||||
|
||||
Logger.log('Backup completed successfully')
|
||||
Logger.log('[BackupManager] Backup completed successfully')
|
||||
return backupedFilePath
|
||||
} catch (error) {
|
||||
Logger.error('Backup failed:', error)
|
||||
Logger.error('[BackupManager] Backup failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@ -186,7 +188,7 @@ class BackupManager {
|
||||
}
|
||||
|
||||
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 webdavClient = new WebDav(webdavConfig)
|
||||
return await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
@ -195,18 +197,48 @@ class BackupManager {
|
||||
}
|
||||
|
||||
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 retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
try {
|
||||
const retrievedFile = await webdavClient.getFileContents(filename)
|
||||
const backupedFilePath = path.join(this.backupDir, filename)
|
||||
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
fs.mkdirSync(this.backupDir, { recursive: true })
|
||||
if (!fs.existsSync(this.backupDir)) {
|
||||
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> {
|
||||
|
||||
15
src/preload/index.d.ts
vendored
15
src/preload/index.d.ts
vendored
@ -6,7 +6,12 @@ import { AppInfo, FileType, KnowledgeBaseParams, KnowledgeItem, LanguageVarious,
|
||||
import type { LoaderReturn } from '@shared/config/types'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
import type { UpdateInfo } from 'electron-updater'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -24,6 +29,9 @@ declare global {
|
||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||
reload: () => void
|
||||
clearCache: () => Promise<{ success: boolean; error?: string }>
|
||||
system: {
|
||||
getDeviceType: () => Promise<'mac' | 'windows' | 'linux'>
|
||||
}
|
||||
zip: {
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
@ -33,6 +41,7 @@ declare global {
|
||||
restore: (backupPath: string) => Promise<string>
|
||||
backupToWebdav: (data: string, webdavConfig: WebDavConfig) => Promise<boolean>
|
||||
restoreFromWebdav: (webdavConfig: WebDavConfig) => Promise<string>
|
||||
listWebdavFiles: (webdavConfig: WebDavConfig) => Promise<BackupFile[]>
|
||||
}
|
||||
file: {
|
||||
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
|
||||
@ -68,8 +77,8 @@ declare global {
|
||||
update: (shortcuts: Shortcut[]) => Promise<void>
|
||||
}
|
||||
knowledgeBase: {
|
||||
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) => Promise<void>
|
||||
reset: ({ base }: { base: KnowledgeBaseParams }) => Promise<void>
|
||||
create: (base: KnowledgeBaseParams) => Promise<void>
|
||||
reset: (base: KnowledgeBaseParams) => Promise<void>
|
||||
delete: (id: string) => Promise<void>
|
||||
add: ({
|
||||
base,
|
||||
|
||||
@ -17,6 +17,9 @@ const api = {
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
clearCache: () => ipcRenderer.invoke('app:clear-cache'),
|
||||
system: {
|
||||
getDeviceType: () => ipcRenderer.invoke('system:getDeviceType')
|
||||
},
|
||||
zip: {
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
|
||||
@ -27,7 +30,8 @@ const api = {
|
||||
restore: (backupPath: string) => ipcRenderer.invoke('backup:restore', backupPath),
|
||||
backupToWebdav: (data: string, webdavConfig: 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: {
|
||||
list: () => ipcRenderer.invoke('nodeapp:list'),
|
||||
@ -92,9 +96,8 @@ const api = {
|
||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
|
||||
},
|
||||
knowledgeBase: {
|
||||
create: ({ id, model, apiKey, baseURL }: KnowledgeBaseParams) =>
|
||||
ipcRenderer.invoke('knowledge-base:create', { id, model, apiKey, baseURL }),
|
||||
reset: ({ base }: { base: KnowledgeBaseParams }) => ipcRenderer.invoke('knowledge-base:reset', { base }),
|
||||
create: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:create', base),
|
||||
reset: (base: KnowledgeBaseParams) => ipcRenderer.invoke('knowledge-base:reset', base),
|
||||
delete: (id: string) => ipcRenderer.invoke('knowledge-base:delete', id),
|
||||
add: ({
|
||||
base,
|
||||
|
||||
@ -45,7 +45,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, defaultT
|
||||
|
||||
useEffect(() => {
|
||||
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>
|
||||
}
|
||||
|
||||
@ -237,7 +237,8 @@
|
||||
"copied": "Copied",
|
||||
"confirm": "Confirm",
|
||||
"more": "More",
|
||||
"advanced_settings": "Advanced Settings"
|
||||
"advanced_settings": "Advanced Settings",
|
||||
"expand": "Expand"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Docs"
|
||||
@ -484,6 +485,10 @@
|
||||
"upgrade.success.title": "Upgrade successfully",
|
||||
"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.",
|
||||
"tools": {
|
||||
"invoking": "Invoking",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"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."
|
||||
},
|
||||
@ -766,6 +771,12 @@
|
||||
"password": "WebDAV Password",
|
||||
"path": "WebDAV Path",
|
||||
"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.content": "Restore from WebDAV will overwrite the current data, continue?",
|
||||
"restore.title": "Restore from WebDAV",
|
||||
|
||||
@ -237,7 +237,8 @@
|
||||
"copied": "コピーされました",
|
||||
"confirm": "確認",
|
||||
"more": "もっと",
|
||||
"advanced_settings": "詳細設定"
|
||||
"advanced_settings": "詳細設定",
|
||||
"expand": "展開"
|
||||
},
|
||||
"docs": {
|
||||
"title": "ドキュメント"
|
||||
@ -483,7 +484,11 @@
|
||||
"upgrade.success.content": "アップグレードを完了するためにアプリケーションを再起動してください",
|
||||
"upgrade.success.title": "アップグレードに成功しました",
|
||||
"warn.notion.exporting": "Notionにエクスポート中です。重複してエクスポートしないでください! ",
|
||||
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。"
|
||||
"warning.rate.limit": "送信が頻繁すぎます。{{seconds}} 秒待ってから再試行してください。",
|
||||
"tools": {
|
||||
"invoking": "呼び出し中",
|
||||
"completed": "完了"
|
||||
}
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "サイドバーに追加",
|
||||
@ -763,7 +768,13 @@
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
"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": {
|
||||
"check": {
|
||||
|
||||
@ -237,7 +237,8 @@
|
||||
"confirm": "Подтверждение",
|
||||
"copied": "Скопировано",
|
||||
"more": "Ещё",
|
||||
"advanced_settings": "Дополнительные настройки"
|
||||
"advanced_settings": "Дополнительные настройки",
|
||||
"expand": "Развернуть"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Документация"
|
||||
@ -489,7 +490,11 @@
|
||||
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
|
||||
"upgrade.success.title": "Обновление успешно",
|
||||
"warn.notion.exporting": "Экспортируется в Notion, пожалуйста, не отправляйте повторные запросы!",
|
||||
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова."
|
||||
"warning.rate.limit": "Отправка слишком частая, пожалуйста, подождите {{seconds}} секунд, прежде чем попробовать снова.",
|
||||
"tools": {
|
||||
"invoking": "Вызов",
|
||||
"completed": "Завершено"
|
||||
}
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "Добавить в боковую панель",
|
||||
@ -763,7 +768,13 @@
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
"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": {
|
||||
"check": {
|
||||
|
||||
@ -237,7 +237,8 @@
|
||||
"warning": "警告",
|
||||
"you": "用户",
|
||||
"more": "更多",
|
||||
"advanced_settings": "高级设置"
|
||||
"advanced_settings": "高级设置",
|
||||
"expand": "展开"
|
||||
},
|
||||
"docs": {
|
||||
"title": "帮助文档"
|
||||
@ -483,7 +484,11 @@
|
||||
"upgrade.success.content": "重启用以完成升级",
|
||||
"upgrade.success.title": "升级成功",
|
||||
"warn.notion.exporting": "正在导出到 Notion, 请勿重复请求导出!",
|
||||
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试"
|
||||
"warning.rate.limit": "发送过于频繁,请等待 {{seconds}} 秒后再尝试",
|
||||
"tools": {
|
||||
"invoking": "调用中",
|
||||
"completed": "已完成"
|
||||
}
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "添加到侧边栏",
|
||||
@ -757,6 +762,12 @@
|
||||
"password": "WebDAV 密码",
|
||||
"path": "WebDAV 路径",
|
||||
"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.content": "从 WebDAV 恢复将覆盖当前数据,是否继续?",
|
||||
"restore.title": "从 WebDAV 恢复",
|
||||
|
||||
@ -237,7 +237,8 @@
|
||||
"copied": "已複製",
|
||||
"confirm": "確認",
|
||||
"more": "更多",
|
||||
"advanced_settings": "進階設定"
|
||||
"advanced_settings": "進階設定",
|
||||
"expand": "展開"
|
||||
},
|
||||
"docs": {
|
||||
"title": "說明文件"
|
||||
@ -483,7 +484,11 @@
|
||||
"upgrade.success.content": "請重新啟動程式以完成升級",
|
||||
"upgrade.success.title": "升級成功",
|
||||
"warn.notion.exporting": "正在匯出到 Notion,請勿重複請求匯出!",
|
||||
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試"
|
||||
"warning.rate.limit": "發送過於頻繁,請在 {{seconds}} 秒後再嘗試",
|
||||
"tools": {
|
||||
"invoking": "調用中",
|
||||
"completed": "已完成"
|
||||
}
|
||||
},
|
||||
"minapp": {
|
||||
"sidebar.add.title": "新增到側邊欄",
|
||||
@ -763,7 +768,13 @@
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
"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": {
|
||||
"check": {
|
||||
|
||||
@ -70,7 +70,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
const onCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
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' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
|
||||
@ -60,7 +60,7 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
<TitleContent>
|
||||
<ToolName>{tool.name}</ToolName>
|
||||
<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 }} />}
|
||||
{isDone && <CheckOutlined style={{ marginLeft: 6 }} />}
|
||||
</StatusIndicator>
|
||||
|
||||
@ -142,10 +142,10 @@ const PopupContainer: React.FC<Props> = ({ base: _base, resolve }) => {
|
||||
name="rerankModel"
|
||||
label={t('models.rerank_model')}
|
||||
tooltip={{ title: t('models.rerank_model_tooltip'), placement: 'right' }}
|
||||
initialValue={getModelUniqId(base.rerankModel) || undefined}
|
||||
rules={[{ required: false, message: t('message.error.enter.model') }]}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
defaultValue={getModelUniqId(base.rerankModel) || undefined}
|
||||
options={rerankSelectOptions}
|
||||
placeholder={t('settings.models.empty')}
|
||||
allowClear
|
||||
|
||||
@ -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 { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { backupToWebdav, restoreFromWebdav, startAutoSync, stopAutoSync } from '@renderer/services/BackupService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import {
|
||||
setWebdavAutoSync,
|
||||
setWebdavHost as _setWebdavHost,
|
||||
@ -13,13 +12,19 @@ import {
|
||||
setWebdavSyncInterval as _setWebdavSyncInterval,
|
||||
setWebdavUser as _setWebdavUser
|
||||
} from '@renderer/store/settings'
|
||||
import { Button, Input, Select } from 'antd'
|
||||
import { Button, Input, Modal, Select, Spin, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
interface BackupFile {
|
||||
fileName: string
|
||||
modifiedTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
const WebDavSettings: FC = () => {
|
||||
const {
|
||||
webdavHost: webDAVHost,
|
||||
@ -38,45 +43,22 @@ const WebDavSettings: FC = () => {
|
||||
|
||||
const [backuping, setBackuping] = 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 { theme } = useTheme()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { webdavSync } = useRuntime()
|
||||
const { webdavSync } = useAppSelector((state) => state.backup)
|
||||
|
||||
// 把之前备份的文件定时上传到 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) => {
|
||||
setSyncInterval(value)
|
||||
dispatch(_setWebdavSyncInterval(value))
|
||||
@ -99,20 +81,102 @@ const WebDavSettings: FC = () => {
|
||||
return (
|
||||
<HStack gap="5px" alignItems="center">
|
||||
{webdavSync.syncing && <SyncOutlined spin />}
|
||||
{!webdavSync.syncing && webdavSync.lastSyncError && (
|
||||
<Tooltip title={`${t('settings.data.webdav.syncError')}: ${webdavSync.lastSyncError}`}>
|
||||
<WarningOutlined style={{ color: 'red' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{webdavSync.lastSyncTime && (
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('settings.data.webdav.lastSync')}: {dayjs(webdavSync.lastSyncTime).format('HH:mm:ss')}
|
||||
</span>
|
||||
)}
|
||||
{webdavSync.lastSyncError && (
|
||||
<span style={{ color: 'var(--error-color)' }}>
|
||||
{t('settings.data.webdav.syncError')}: {webdavSync.lastSyncError}
|
||||
</span>
|
||||
)}
|
||||
</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 (
|
||||
<SettingGroup theme={theme}>
|
||||
<SettingTitle>{t('settings.data.webdav.title')}</SettingTitle>
|
||||
@ -165,10 +229,10 @@ const WebDavSettings: FC = () => {
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<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')}
|
||||
</Button>
|
||||
<Button onClick={onPressRestore} icon={<FolderOpenOutlined />} loading={restoring}>
|
||||
<Button onClick={showRestoreModal} icon={<FolderOpenOutlined />} loading={restoring}>
|
||||
{t('settings.data.webdav.restore.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
@ -198,6 +262,46 @@ const WebDavSettings: FC = () => {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -306,7 +306,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</Space.Compact>
|
||||
{apiKeyWebsite && (
|
||||
<SettingHelpTextRow style={{ justifyContent: 'space-between' }}>
|
||||
<HStack gap={5}>
|
||||
<HStack>
|
||||
<SettingHelpLink target="_blank" href={apiKeyWebsite}>
|
||||
{t('settings.provider.get_api_key')}
|
||||
</SettingHelpLink>
|
||||
|
||||
@ -112,8 +112,8 @@ const TranslatePage: FC = () => {
|
||||
message,
|
||||
assistant,
|
||||
onResponse: (text) => {
|
||||
translatedText = text
|
||||
setResult(text)
|
||||
translatedText = text.replace(/^\s*\n+/g, '')
|
||||
setResult(translatedText)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@ -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)
|
||||
console.log('start invoke tools', toolCalls)
|
||||
if (this.isZhipuTool(model)) {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import { setWebDAVSyncState } from '@renderer/store/runtime'
|
||||
import { setWebDAVSyncState } from '@renderer/store/backup'
|
||||
import dayjs from 'dayjs'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
export async function backup() {
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
|
||||
@ -59,16 +60,27 @@ export async function reset() {
|
||||
}
|
||||
|
||||
// 备份到 webdav
|
||||
export async function backupToWebdav({ showMessage = false }: { showMessage?: boolean } = {}) {
|
||||
export async function backupToWebdav({
|
||||
showMessage = false,
|
||||
customFileName = ''
|
||||
}: { showMessage?: boolean; customFileName?: string } = {}) {
|
||||
if (isManualBackupRunning) {
|
||||
console.log('[Backup] Manual backup already in progress')
|
||||
Logger.log('[Backup] Manual backup already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
store.dispatch(setWebDAVSyncState({ syncing: true, lastSyncError: null }))
|
||||
|
||||
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()
|
||||
|
||||
// 上传文件
|
||||
@ -77,43 +89,47 @@ export async function backupToWebdav({ showMessage = false }: { showMessage?: bo
|
||||
webdavHost,
|
||||
webdavUser,
|
||||
webdavPass,
|
||||
webdavPath
|
||||
webdavPath,
|
||||
fileName: finalFileName
|
||||
})
|
||||
if (success) {
|
||||
store.dispatch(
|
||||
setWebDAVSyncState({
|
||||
lastSyncTime: Date.now(),
|
||||
lastSyncError: null
|
||||
})
|
||||
)
|
||||
showMessage && window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||
} else {
|
||||
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) {
|
||||
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
|
||||
console.error('[backup] backupToWebdav: Error uploading file to WebDAV:', error)
|
||||
showMessage &&
|
||||
window.modal.error({
|
||||
title: i18n.t('message.backup.failed'),
|
||||
content: error.message
|
||||
})
|
||||
console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.backup.failed'),
|
||||
content: error.message
|
||||
})
|
||||
} finally {
|
||||
store.dispatch(setWebDAVSyncState({ syncing: false }))
|
||||
store.dispatch(
|
||||
setWebDAVSyncState({
|
||||
lastSyncTime: Date.now(),
|
||||
syncing: false
|
||||
})
|
||||
)
|
||||
isManualBackupRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
// 从 webdav 恢复
|
||||
export async function restoreFromWebdav() {
|
||||
export async function restoreFromWebdav(fileName?: string) {
|
||||
const { webdavHost, webdavUser, webdavPass, webdavPath } = store.getState().settings
|
||||
let data = ''
|
||||
|
||||
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) {
|
||||
console.error('[backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
|
||||
console.error('[Backup] restoreFromWebdav: Error downloading file from WebDAV:', error)
|
||||
window.modal.error({
|
||||
title: i18n.t('message.restore.failed'),
|
||||
content: error.message
|
||||
@ -123,7 +139,7 @@ export async function restoreFromWebdav() {
|
||||
try {
|
||||
await handleData(JSON.parse(data))
|
||||
} 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' })
|
||||
}
|
||||
}
|
||||
@ -158,6 +174,7 @@ export function startAutoSync() {
|
||||
}
|
||||
|
||||
const { webdavSyncInterval } = store.getState().settings
|
||||
const { webdavSync } = store.getState().backup
|
||||
|
||||
if (webdavSyncInterval <= 0) {
|
||||
console.log('[AutoSync] Invalid sync interval, auto sync disabled')
|
||||
@ -165,9 +182,21 @@ export function startAutoSync() {
|
||||
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() {
|
||||
@ -179,7 +208,7 @@ export function startAutoSync() {
|
||||
|
||||
isAutoBackupRunning = true
|
||||
try {
|
||||
console.log('[AutoSync] Performing auto backup...')
|
||||
console.log('[AutoSync] Starting auto backup...')
|
||||
await backupToWebdav({ showMessage: false })
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Auto backup failed:', error)
|
||||
|
||||
@ -11,10 +11,12 @@ import FileManager from './FileManager'
|
||||
|
||||
export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams => {
|
||||
const provider = getProviderByModel(base.model)
|
||||
const rerankProvider = getProviderByModel(base.rerankModel)
|
||||
const aiProvider = new AiProvider(provider)
|
||||
const rerankAiProvider = new AiProvider(rerankProvider)
|
||||
|
||||
let host = aiProvider.getBaseURL()
|
||||
|
||||
const rerankHost = rerankAiProvider.getBaseURL()
|
||||
if (provider.type === 'gemini') {
|
||||
host = host + '/v1beta/openai/'
|
||||
}
|
||||
@ -40,6 +42,8 @@ export const getKnowledgeBaseParams = (base: KnowledgeBase): KnowledgeBaseParams
|
||||
baseURL: host,
|
||||
chunkSize,
|
||||
chunkOverlap: base.chunkOverlap,
|
||||
rerankBaseURL: rerankHost,
|
||||
rerankApiKey: rerankAiProvider.getApiKey() || 'secret',
|
||||
rerankModel: base.rerankModel?.id,
|
||||
rerankModelProvider: base.rerankModel?.provider,
|
||||
topN: base.topN
|
||||
|
||||
32
src/renderer/src/store/backup.ts
Normal file
32
src/renderer/src/store/backup.ts
Normal 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
|
||||
@ -5,6 +5,7 @@ import storage from 'redux-persist/lib/storage'
|
||||
|
||||
import agents from './agents'
|
||||
import assistants from './assistants'
|
||||
import backup from './backup'
|
||||
import copilot from './copilot'
|
||||
import knowledge from './knowledge'
|
||||
import llm from './llm'
|
||||
@ -22,6 +23,7 @@ import websearch from './websearch'
|
||||
const rootReducer = combineReducers({
|
||||
assistants,
|
||||
agents,
|
||||
backup,
|
||||
paintings,
|
||||
llm,
|
||||
settings,
|
||||
@ -40,7 +42,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 81,
|
||||
version: 82,
|
||||
blacklist: ['runtime', 'messages'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -28,31 +28,6 @@ const initialState: MessagesState = {
|
||||
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(
|
||||
'messages/prepareTopic',
|
||||
@ -144,7 +119,7 @@ const messagesSlice = createSlice({
|
||||
if (message) {
|
||||
Object.assign(message, updates)
|
||||
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 = (
|
||||
|
||||
@ -772,6 +772,22 @@ const migrateConfig = {
|
||||
'81': (state: RootState) => {
|
||||
addProvider(state, 'copilot')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,12 +11,6 @@ export interface UpdateState {
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export interface WebDAVSyncState {
|
||||
lastSyncTime: number | null
|
||||
syncing: boolean
|
||||
lastSyncError: string | null
|
||||
}
|
||||
|
||||
export interface RuntimeState {
|
||||
avatar: string
|
||||
generating: boolean
|
||||
@ -25,7 +19,6 @@ export interface RuntimeState {
|
||||
filesPath: string
|
||||
resourcesPath: string
|
||||
update: UpdateState
|
||||
webdavSync: WebDAVSyncState
|
||||
export: ExportState
|
||||
}
|
||||
|
||||
@ -48,11 +41,6 @@ const initialState: RuntimeState = {
|
||||
downloadProgress: 0,
|
||||
available: false
|
||||
},
|
||||
webdavSync: {
|
||||
lastSyncTime: null,
|
||||
syncing: false,
|
||||
lastSyncError: null
|
||||
},
|
||||
export: {
|
||||
isExporting: false
|
||||
}
|
||||
@ -83,9 +71,6 @@ const runtimeSlice = createSlice({
|
||||
setUpdateState: (state, action: PayloadAction<Partial<UpdateState>>) => {
|
||||
state.update = { ...state.update, ...action.payload }
|
||||
},
|
||||
setWebDAVSyncState: (state, action: PayloadAction<Partial<WebDAVSyncState>>) => {
|
||||
state.webdavSync = { ...state.webdavSync, ...action.payload }
|
||||
},
|
||||
setExportState: (state, action: PayloadAction<Partial<ExportState>>) => {
|
||||
state.export = { ...state.export, ...action.payload }
|
||||
}
|
||||
@ -100,7 +85,6 @@ export const {
|
||||
setFilesPath,
|
||||
setResourcesPath,
|
||||
setUpdateState,
|
||||
setWebDAVSyncState,
|
||||
setExportState
|
||||
} = runtimeSlice.actions
|
||||
|
||||
|
||||
@ -218,6 +218,7 @@ export type WebDavConfig = {
|
||||
webdavUser: string
|
||||
webdavPass: string
|
||||
webdavPath: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export type AppInfo = {
|
||||
@ -285,6 +286,8 @@ export type KnowledgeBaseParams = {
|
||||
baseURL: string
|
||||
chunkSize?: number
|
||||
chunkOverlap?: number
|
||||
rerankApiKey?: string
|
||||
rerankBaseURL?: string
|
||||
rerankModel?: string
|
||||
rerankModelProvider?: string
|
||||
topN?: number
|
||||
|
||||
@ -18,7 +18,10 @@ const supportedAttributes = [
|
||||
]
|
||||
|
||||
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 filtered = Object.fromEntries(Object.entries(obj).filter(([key]) => keys.includes(key)))
|
||||
|
||||
@ -46,10 +49,10 @@ function filterPropertieAttributes(tool: MCPTool, filterNestedObj = false) {
|
||||
return filtered
|
||||
}
|
||||
|
||||
for (const [key, val] of Object.entries(roperties)) {
|
||||
roperties[key] = getSubMap(val, supportedAttributes)
|
||||
for (const [key, val] of Object.entries(properties)) {
|
||||
properties[key] = getSubMap(val, supportedAttributes)
|
||||
}
|
||||
return roperties
|
||||
return properties
|
||||
}
|
||||
|
||||
export function mcpToolsToOpenAITools(mcpTools: MCPTool[]): Array<ChatCompletionTool> {
|
||||
|
||||
@ -29,7 +29,7 @@ const HomeWindow: FC = () => {
|
||||
const textChange = useState(() => {})[1]
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { defaultModel: model } = useDefaultModel()
|
||||
const { language, readClipboardAtStartup } = useSettings()
|
||||
const { language, readClipboardAtStartup, windowStyle, theme } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||
@ -201,9 +201,24 @@ const HomeWindow: FC = () => {
|
||||
}
|
||||
}, [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)) {
|
||||
return (
|
||||
<Container>
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
{route === 'chat' && (
|
||||
<>
|
||||
<InputBar
|
||||
@ -232,7 +247,7 @@ const HomeWindow: FC = () => {
|
||||
|
||||
if (route === 'translate') {
|
||||
return (
|
||||
<Container>
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<TranslateWindow text={referenceText} />
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Footer route={route} onExit={() => setRoute('home')} />
|
||||
@ -241,7 +256,7 @@ const HomeWindow: FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container style={{ backgroundColor: backgroundColor() }}>
|
||||
<InputBar
|
||||
text={text}
|
||||
model={model}
|
||||
@ -280,7 +295,6 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
-webkit-app-region: drag;
|
||||
padding: 8px 10px;
|
||||
background-color: ${isMac ? 'transparent' : 'var(--color-background)'};
|
||||
`
|
||||
|
||||
const Main = styled.main`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user