feat: make webdav state persistent, improve webdav autosync (#3690)

* feat: persist webdav state

* feat: schedule autosync by taking the last autosync time

* fix: correct scheduling behaviour with last error, improve messages

* refactor: delay setting lastSyncTime

---------

Co-authored-by: 亢奋猫 <kangfenmao@qq.com>
This commit is contained in:
one 2025-03-21 11:18:38 +08:00 committed by GitHub
parent 994ffa224e
commit 28c5231741
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 92 additions and 43 deletions

View File

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

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,
@ -56,7 +55,7 @@ const WebDavSettings: FC = () => {
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
@ -82,16 +81,16 @@ 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>
) )
} }

View File

@ -1,7 +1,7 @@
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' import Logger from 'electron-log'
@ -95,25 +95,28 @@ export async function backupToWebdav({
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
} }
} }
@ -126,7 +129,7 @@ export async function restoreFromWebdav(fileName?: string) {
try { try {
data = await window.api.backup.restoreFromWebdav({ webdavHost, webdavUser, webdavPass, webdavPath, fileName }) 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
@ -136,7 +139,7 @@ export async function restoreFromWebdav(fileName?: string) {
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' })
} }
} }
@ -171,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')
@ -178,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() {
@ -192,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

@ -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'
@ -21,6 +22,7 @@ import websearch from './websearch'
const rootReducer = combineReducers({ const rootReducer = combineReducers({
assistants, assistants,
agents, agents,
backup,
paintings, paintings,
llm, llm,
settings, settings,
@ -38,7 +40,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

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