feat/notification (#6144)

* WIP

* feat: integrate notification system using Electron's Notification API

- Replaced the previous notification implementation with Electron's Notification API for better integration.
- Updated notification types and structures to support new features.
- Added translations for notification messages in multiple languages.
- Cleaned up unused dependencies related to notifications.

* refactor: remove unused node-notifier dependency from Electron config

* clean: remove node-notifier from asarUnpack in Electron config

* fix: update notification type in preload script to align with new structure

* feat: Integrate NotificationService for user notifications across various components

- Implemented NotificationService in useUpdateHandler to notify users of available updates.
- Enhanced KnowledgeQueue to send success and error notifications during item processing.
- Updated BackupService to notify users upon successful restoration and backup completion.
- Added error notifications in message handling to improve user feedback on assistant responses.

This update improves user experience by providing timely notifications for important actions and events.

* feat: Refactor notification handling and integrate new notification settings

- Moved SYSTEM_MODELS to a new file for better organization.
- Enhanced notification handling across various components, including BackupService and KnowledgeQueue, to include a source attribute for better context.
- Introduced notification settings in the GeneralSettings component, allowing users to toggle notifications for assistant messages, backups, and knowledge embedding.
- Updated the Redux store to manage notification settings and added migration logic for the new settings structure.
- Improved user feedback by ensuring notifications are sent based on user preferences.

This update enhances the user experience by providing customizable notification options and clearer context for notifications.

* feat: Add 'update' source to NotificationSource type for enhanced notification context

* feat: Integrate electron-notification-state for improved notification handling

- Added electron-notification-state package to manage Do Not Disturb settings.
- Updated NotificationService to respect user DND preferences when sending notifications.
- Adjusted notification settings in various components (BackupService, KnowledgeQueue, useUpdateHandler) to ensure notifications are sent based on user preferences.
- Enhanced user feedback by allowing notifications to be silenced based on DND status.

* feat: Add notification icon to Electron notifications

* fix: import SYSTEM_MODELS in EditModelsPopup for improved model handling

* feat(i18n): add knowledge base notifications in multiple languages

- Added success and error messages for knowledge base operations in English, Japanese, Russian, Chinese (Simplified and Traditional).
- Updated the KnowledgeQueue to utilize the new notification messages for better user feedback.

* feat(notification): introduce NotificationProvider and integrate into App component

- Added NotificationProvider to manage notifications within the application.
- Updated App component to include NotificationProvider, enhancing user feedback through notifications.
- Refactored NotificationQueue to support multiple listeners for notification handling.
This commit is contained in:
SuYao 2025-05-21 12:11:52 +08:00 committed by GitHub
parent 374e97b9ab
commit 8e5f2a523d
26 changed files with 581 additions and 31 deletions

View File

@ -80,6 +80,7 @@
"diff": "^7.0.0",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-notification-state": "^1.0.4",
"electron-store": "^8.2.0",
"electron-updater": "6.6.4",
"electron-window-state": "^5.0.3",

View File

@ -21,6 +21,9 @@ export enum IpcChannel {
App_InstallUvBinary = 'app:install-uv-binary',
App_InstallBunBinary = 'app:install-bun-binary',
App_Notification = 'app:notification',
App_OnNotificationClick = 'app:on-notification-click',
Webview_SetOpenLinkExternal = 'webview:set-open-link-external',
// Open

View File

@ -8,6 +8,7 @@ import { IpcChannel } from '@shared/IpcChannel'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, nativeTheme, session, shell } from 'electron'
import log from 'electron-log'
import { Notification } from 'src/renderer/src/types/notification'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
@ -20,6 +21,7 @@ import FileStorage from './services/FileStorage'
import { GeminiService } from './services/GeminiService'
import KnowledgeService from './services/KnowledgeService'
import { getMcpInstance } from './services/MCPService'
import NotificationService from './services/NotificationService'
import * as NutstoreService from './services/NutstoreService'
import ObsidianVaultService from './services/ObsidianVaultService'
import { ProxyConfig, proxyManager } from './services/ProxyManager'
@ -41,6 +43,7 @@ const obsidianVaultService = new ObsidianVaultService()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const appUpdater = new AppUpdater(mainWindow)
const notificationService = new NotificationService(mainWindow)
ipcMain.handle(IpcChannel.App_Info, () => ({
version: app.getVersion(),
@ -200,6 +203,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await appUpdater.checkForUpdates()
})
// notification
ipcMain.handle(IpcChannel.App_Notification, async (_, notification: Notification) => {
await notificationService.sendNotification(notification)
})
ipcMain.handle(IpcChannel.App_OnNotificationClick, (_, notification: Notification) => {
mainWindow.webContents.send('notification-click', notification)
})
// zip
ipcMain.handle(IpcChannel.Zip_Compress, (_, text: string) => compress(text))
ipcMain.handle(IpcChannel.Zip_Decompress, (_, text: Buffer) => decompress(text))

View File

@ -0,0 +1,35 @@
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
import { getDoNotDisturb } from 'electron-notification-state'
import { Notification } from 'src/renderer/src/types/notification'
import icon from '../../../build/icon.png?asset'
class NotificationService {
private window: BrowserWindow
constructor(window: BrowserWindow) {
// Initialize the service
this.window = window
}
public async sendNotification(notification: Notification) {
const shouldSilent = getDoNotDisturb() ?? !!notification.silent
// 使用 Electron Notification API
const electronNotification = new ElectronNotification({
title: notification.title,
body: notification.message,
silent: shouldSilent,
icon: icon
})
electronNotification.on('click', () => {
this.window.show()
this.window.webContents.send('notification-click', notification)
})
electronNotification.show()
}
}
export default NotificationService

View File

@ -3,6 +3,7 @@ import { electronAPI } from '@electron-toolkit/preload'
import { IpcChannel } from '@shared/IpcChannel'
import { FileType, KnowledgeBaseParams, KnowledgeItem, MCPServer, Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import { CreateDirectoryOptions } from 'webdav'
// Custom APIs for renderer
@ -25,6 +26,18 @@ const api = {
openWebsite: (url: string) => ipcRenderer.invoke(IpcChannel.Open_Website, url),
getCacheSize: () => ipcRenderer.invoke(IpcChannel.App_GetCacheSize),
clearCache: () => ipcRenderer.invoke(IpcChannel.App_ClearCache),
notification: {
send: (notification: Notification) => ipcRenderer.invoke(IpcChannel.App_Notification, notification),
onClick: (callback: () => void) => {
const listener = () => {
callback()
}
ipcRenderer.on(IpcChannel.App_OnNotificationClick, listener)
return () => {
ipcRenderer.off(IpcChannel.App_OnNotificationClick, listener)
}
}
},
system: {
getDeviceType: () => ipcRenderer.invoke(IpcChannel.System_GetDeviceType),
getHostname: () => ipcRenderer.invoke(IpcChannel.System_GetHostname)

View File

@ -9,6 +9,7 @@ import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import { NotificationProvider } from './context/NotificationProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
@ -27,26 +28,28 @@ function App(): React.ReactElement {
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
<NotificationProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<NavigationHandler />
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings/*" element={<PaintingsRoutePage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</CodeStyleProvider>
</NotificationProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>

View File

@ -441,7 +441,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
],
o3: [

View File

@ -0,0 +1,75 @@
import { NotificationQueue } from '@renderer/queue/NotificationQueue'
import { Notification } from '@renderer/types/notification'
import { isFocused } from '@renderer/utils/window'
import { notification } from 'antd'
import React, { createContext, use, useEffect, useMemo } from 'react'
type NotificationContextType = {
open: typeof notification.open
destroy: typeof notification.destroy
}
const typeMap: Record<string, 'info' | 'success' | 'warning' | 'error'> = {
error: 'error',
success: 'success',
warning: 'warning',
info: 'info',
progress: 'info',
action: 'info'
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined)
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [api, contextHolder] = notification.useNotification({
stack: {
threshold: 3
},
showProgress: true
})
useEffect(() => {
const queue = NotificationQueue.getInstance()
const listener = async (notification: Notification) => {
// 判断是否需要系统通知
if (notification.channel === 'system' || !isFocused()) {
window.api.notification.send(notification)
return
}
return new Promise<void>((resolve) => {
api.open({
message: notification.title,
description: notification.message,
duration: 3,
placement: 'topRight',
type: typeMap[notification.type] || 'info',
key: notification.id,
onClose: resolve
})
})
}
queue.subscribe(listener)
return () => queue.unsubscribe(listener)
}, [api])
const value = useMemo(
() => ({
open: api.open,
destroy: api.destroy
}),
[api]
)
return (
<NotificationContext value={value}>
{contextHolder}
{children}
</NotificationContext>
)
}
export const useNotification = () => {
const ctx = use(NotificationContext)
if (!ctx) throw new Error('useNotification must be used within a NotificationProvider')
return ctx
}

View File

@ -1,5 +1,7 @@
import { NotificationService } from '@renderer/services/NotificationService'
import { useAppDispatch } from '@renderer/store'
import { setUpdateState } from '@renderer/store/runtime'
import { uuid } from '@renderer/utils'
import { IpcChannel } from '@shared/IpcChannel'
import type { ProgressInfo, UpdateInfo } from 'builder-util-runtime'
import { useEffect } from 'react'
@ -8,6 +10,7 @@ import { useTranslation } from 'react-i18next'
export default function useUpdateHandler() {
const dispatch = useAppDispatch()
const { t } = useTranslation()
const notificationService = NotificationService.getInstance()
useEffect(() => {
if (!window.electron) return
@ -22,6 +25,14 @@ export default function useUpdateHandler() {
}
}),
ipcRenderer.on(IpcChannel.UpdateAvailable, (_, releaseInfo: UpdateInfo) => {
notificationService.send({
id: uuid(),
type: 'info',
title: t('button.update_available'),
message: t('button.update_available', { version: releaseInfo.version }),
timestamp: Date.now(),
source: 'update'
})
dispatch(
setUpdateState({
checking: false,

View File

@ -780,6 +780,11 @@
"hide_sidebar": "Hide Sidebar",
"show_sidebar": "Show Sidebar"
},
"notification": {
"assistant": "Assistant Response",
"knowledge.success": "Successfully added {{type}} to the knowledge base",
"knowledge.error": "Failed to add {{type}} to knowledge base: {{error}}"
},
"ollama": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
"keep_alive_time.placeholder": "Minutes",
@ -1494,6 +1499,12 @@
"moresetting.check.confirm": "Confirm Selection",
"moresetting.check.warn": "Please be cautious when selecting this option. Incorrect selection may cause the model to malfunction!",
"moresetting.warn": "Risk Warning",
"notification": {
"title": "Notification Settings",
"assistant": "Assistant Message",
"backup": "Backup Message",
"knowledge_embed": "KnowledgeBase Message"
},
"provider": {
"add.name": "Provider Name",
"add.name.placeholder": "Example: OpenAI",

View File

@ -781,6 +781,11 @@
"hide_sidebar": "サイドバーを非表示",
"show_sidebar": "サイドバーを表示"
},
"notification": {
"assistant": "助手回應",
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
"knowledge.error": "ナレッジベースへの{{type}}の追加に失敗しました: {{error}}"
},
"ollama": {
"keep_alive_time.description": "モデルがメモリに保持される時間デフォルト5分",
"keep_alive_time.placeholder": "分",
@ -1707,6 +1712,12 @@
"service_tier.auto": "自動",
"service_tier.default": "デフォルト",
"service_tier.flex": "フレックス"
},
"notification": {
"title": "通知設定",
"assistant": "アシスタントメッセージ",
"backup": "バックアップメッセージ",
"knowledge_embed": "ナレッジベースメッセージ"
}
},
"translate": {

View File

@ -780,6 +780,11 @@
"hide_sidebar": "Скрыть боковую панель",
"show_sidebar": "Показать боковую панель"
},
"notification": {
"assistant": "Ответ ассистента",
"knowledge.success": "Успешно добавлено {{type}} в базу знаний",
"knowledge.error": "Не удалось добавить {{type}} в базу знаний: {{error}}"
},
"ollama": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
"keep_alive_time.placeholder": "Минуты",
@ -1707,7 +1712,13 @@
"service_tier.flex": "Гибкий"
},
"about.debug.title": "Отладка",
"about.debug.open": "Открыть"
"about.debug.open": "Открыть",
"notification": {
"title": "Настройки уведомлений",
"assistant": "Сообщение ассистента",
"backup": "Резервное сообщение",
"knowledge_embed": "Сообщение базы знаний"
}
},
"translate": {
"any.language": "Любой язык",

View File

@ -780,6 +780,11 @@
"hide_sidebar": "隐藏侧边栏",
"show_sidebar": "显示侧边栏"
},
"notification": {
"assistant": "助手响应",
"knowledge.success": "成功添加 {{type}} 到知识库",
"knowledge.error": "添加 {{type}} 到知识库失败: {{error}}"
},
"ollama": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
"keep_alive_time.placeholder": "分钟",
@ -1494,6 +1499,12 @@
"moresetting.check.confirm": "确认勾选",
"moresetting.check.warn": "请慎重勾选此选项,勾选错误会导致模型无法正常使用!!!",
"moresetting.warn": "风险警告",
"notification": {
"title": "通知设置",
"assistant": "助手消息",
"backup": "备份",
"knowledge_embed": "知识嵌入"
},
"provider": {
"add.name": "提供商名称",
"add.name.placeholder": "例如 OpenAI",

View File

@ -781,6 +781,11 @@
"hide_sidebar": "隱藏側邊欄",
"show_sidebar": "顯示側邊欄"
},
"notification": {
"assistant": "助手回應",
"knowledge.success": "成功將{{type}}新增至知識庫",
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}"
},
"ollama": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。",
"keep_alive_time.placeholder": "分鐘",
@ -1707,6 +1712,12 @@
"service_tier.auto": "自動",
"service_tier.default": "預設",
"service_tier.flex": "彈性"
},
"notification": {
"title": "通知設定",
"assistant": "助手訊息",
"backup": "備份訊息",
"knowledge_embed": "知識庫訊息"
}
},
"translate": {

View File

@ -1,15 +1,17 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n'
import { useAppDispatch } from '@renderer/store'
import { setEnableDataCollection, setLanguage } from '@renderer/store/settings'
import { RootState, useAppDispatch } from '@renderer/store'
import { setEnableDataCollection, setLanguage, setNotificationSettings } from '@renderer/store/settings'
import { setProxyMode, setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
import { LanguageVarious } from '@renderer/types'
import { NotificationSource } from '@renderer/types/notification'
import { isValidProxyUrl } from '@renderer/utils'
import { defaultLanguage } from '@shared/config/constant'
import { Input, Select, Space, Switch } from 'antd'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { SettingContainer, SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '.'
@ -107,6 +109,12 @@ const GeneralSettings: FC = () => {
{ value: 'pt-PT', label: 'Português', flag: '🇵🇹' }
]
const notificationSettings = useSelector((state: RootState) => state.settings.notification)
const handleNotificationChange = (type: NotificationSource, value: boolean) => {
dispatch(setNotificationSettings({ ...notificationSettings, [type]: value }))
}
return (
<SettingContainer theme={themeMode}>
<SettingGroup theme={theme}>
@ -154,6 +162,27 @@ const GeneralSettings: FC = () => {
</>
)}
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.notification.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.assistant')}</SettingRowTitle>
<Switch checked={notificationSettings.assistant} onChange={(v) => handleNotificationChange('assistant', v)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.backup')}</SettingRowTitle>
<Switch checked={notificationSettings.backup} onChange={(v) => handleNotificationChange('backup', v)} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('settings.notification.knowledge_embed')}</SettingRowTitle>
<Switch
checked={notificationSettings.knowledgeEmbed}
onChange={(v) => handleNotificationChange('knowledgeEmbed', v)}
/>
</SettingRow>
</SettingGroup>
<SettingGroup theme={theme}>
<SettingTitle>{t('settings.launch.title')}</SettingTitle>
<SettingDivider />

View File

@ -1,10 +1,13 @@
import Logger from '@renderer/config/logger'
import db from '@renderer/databases'
import { getKnowledgeBaseParams } from '@renderer/services/KnowledgeService'
import { NotificationService } from '@renderer/services/NotificationService'
import store from '@renderer/store'
import { clearCompletedProcessing, updateBaseItemUniqueId, updateItemProcessingStatus } from '@renderer/store/knowledge'
import { KnowledgeItem } from '@renderer/types'
import { uuid } from '@renderer/utils'
import type { LoaderReturn } from '@shared/config/types'
import { t } from 'i18next'
class KnowledgeQueue {
private processing: Map<string, boolean> = new Map()
@ -84,6 +87,7 @@ class KnowledgeQueue {
}
private async processItem(baseId: string, item: KnowledgeItem): Promise<void> {
const notificationService = NotificationService.getInstance()
try {
if (item.retryCount && item.retryCount >= this.MAX_RETRIES) {
Logger.log(`[KnowledgeQueue] Item ${item.id} has reached max retries, skipping`)
@ -134,6 +138,16 @@ class KnowledgeQueue {
Logger.log(`[KnowledgeQueue] Successfully completed processing item ${item.id}`)
notificationService.send({
id: uuid(),
type: 'success',
title: t('knowledge.status_completed"'),
message: t('notification.knowledge.success', { type: item.type }),
silent: false,
timestamp: Date.now(),
source: 'knowledgeEmbed'
})
store.dispatch(
updateItemProcessingStatus({
baseId,
@ -157,6 +171,19 @@ class KnowledgeQueue {
store.dispatch(clearCompletedProcessing({ baseId }))
} catch (error) {
Logger.error(`[KnowledgeQueue] Error processing item ${item.id}: `, error)
notificationService.send({
id: uuid(),
type: 'error',
title: t('common.knowledge'),
message: t('notification.knowledge.error', {
type: item.type,
error: error instanceof Error ? error.message : 'Unkown error'
}),
silent: false,
timestamp: Date.now(),
source: 'knowledgeEmbed'
})
store.dispatch(
updateItemProcessingStatus({
baseId,

View File

@ -0,0 +1,53 @@
import type { Notification } from '@renderer/types/notification'
import PQueue from 'p-queue'
type NotificationListener = (notification: Notification) => Promise<void> | void
export class NotificationQueue {
private static instance: NotificationQueue
private queue = new PQueue({ concurrency: 1 })
private listeners: NotificationListener[] = []
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
public static getInstance(): NotificationQueue {
if (!NotificationQueue.instance) {
NotificationQueue.instance = new NotificationQueue()
}
return NotificationQueue.instance
}
public subscribe(listener: NotificationListener) {
this.listeners.push(listener)
}
public unsubscribe(listener: NotificationListener) {
this.listeners = this.listeners.filter((l) => l !== listener)
}
public async add(notification: Notification): Promise<void> {
await this.queue.add(() => Promise.all(this.listeners.map((listener) => listener(notification))))
}
/**
*
*/
public clear(): void {
this.queue.clear()
}
/**
*
*/
public get pending(): number {
return this.queue.pending
}
/**
*
*/
public get size(): number {
return this.queue.size
}
}

View File

@ -1,12 +1,14 @@
/* eslint-disable simple-import-sort/imports */
import Logger from '@renderer/config/logger'
import db from '@renderer/databases'
import { upgradeToV7 } from '@renderer/databases/upgrades'
import i18n from '@renderer/i18n'
import store from '@renderer/store'
import { setWebDAVSyncState } from '@renderer/store/backup'
import { uuid } from '@renderer/utils'
import dayjs from 'dayjs'
import { NotificationService } from './NotificationService'
export async function backup(skipBackupFile: boolean) {
const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip`
const fileContnet = await getBackupData()
@ -18,6 +20,7 @@ export async function backup(skipBackupFile: boolean) {
}
export async function restore() {
const notificationService = NotificationService.getInstance()
const file = await window.api.file.open({ filters: [{ name: '备份文件', extensions: ['bak', 'zip'] }] })
if (file) {
@ -33,6 +36,15 @@ export async function restore() {
}
await handleData(data)
notificationService.send({
id: uuid(),
type: 'success',
title: i18n.t('common.success'),
message: i18n.t('message.restore.success'),
silent: false,
timestamp: Date.now(),
source: 'backup'
})
} catch (error) {
Logger.error('[Backup] restore: Error restoring backup file:', error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
@ -71,6 +83,7 @@ export async function backupToWebdav({
customFileName = '',
autoBackupProcess = false
}: { showMessage?: boolean; customFileName?: string; autoBackupProcess?: boolean } = {}) {
const notificationService = NotificationService.getInstance()
if (isManualBackupRunning) {
Logger.log('[Backup] Manual backup already in progress')
return
@ -115,6 +128,16 @@ export async function backupToWebdav({
lastSyncError: null
})
)
notificationService.send({
id: uuid(),
type: 'success',
title: i18n.t('common.success'),
message: i18n.t('message.backup.success'),
silent: false,
timestamp: Date.now(),
source: 'backup'
})
if (showMessage && !autoBackupProcess) {
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
}
@ -173,7 +196,15 @@ export async function backupToWebdav({
if (autoBackupProcess) {
throw error
}
notificationService.send({
id: uuid(),
type: 'error',
title: i18n.t('common.error'),
message: i18n.t('message.backup.failed'),
silent: false,
timestamp: Date.now(),
source: 'backup'
})
store.dispatch(setWebDAVSyncState({ lastSyncError: error.message }))
console.error('[Backup] backupToWebdav: Error uploading file to WebDAV:', error)
showMessage &&

View File

@ -0,0 +1,55 @@
import type { Notification } from '@renderer/types/notification'
import { NotificationQueue } from '../queue/NotificationQueue'
export class NotificationService {
private static instance: NotificationService
private queue: NotificationQueue
private constructor() {
this.queue = NotificationQueue.getInstance()
this.setupNotificationClickHandler()
}
public static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService()
}
return NotificationService.instance
}
/**
*
* @param notification
*/
public async send(notification: Notification): Promise<void> {
await this.queue.add(notification)
}
/**
*
*/
private setupNotificationClickHandler(): void {
// Register an event listener for notification clicks
window.electron.ipcRenderer.on('notification-click', (_event, notification: Notification) => {
// 根据通知类型处理点击事件
if (notification.type === 'action' && notification.onClick) {
notification.onClick()
}
})
}
/**
*
*/
public clear(): void {
this.queue.clear()
}
/**
*
*/
public get pendingCount(): number {
return this.queue.pending
}
}

View File

@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 104,
version: 105,
blacklist: ['runtime', 'messages', 'messageBlocks'],
migrate
},

View File

@ -1421,6 +1421,14 @@ const migrateConfig = {
} catch (error) {
return state
}
},
'105': (state: RootState) => {
try {
state.settings.notification = settingsInitialState.notification
return state
} catch (error) {
return state
}
}
}

View File

@ -160,6 +160,12 @@ export interface SettingsState {
summaryText: OpenAISummaryText
serviceTier: OpenAIServiceTier
}
// Notification
notification: {
assistant: boolean
backup: boolean
knowledgeEmbed: boolean
}
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
@ -285,6 +291,11 @@ export const initialState: SettingsState = {
openAI: {
summaryText: 'off',
serviceTier: 'auto'
},
notification: {
assistant: true,
backup: true,
knowledgeEmbed: true
}
}
@ -613,6 +624,9 @@ const settingsSlice = createSlice({
},
setOpenAIServiceTier: (state, action: PayloadAction<OpenAIServiceTier>) => {
state.openAI.serviceTier = action.payload
},
setNotificationSettings: (state, action: PayloadAction<SettingsState['notification']>) => {
state.notification = action.payload
}
}
})
@ -709,7 +723,8 @@ export const {
setExportMenuOptions,
setEnableBackspaceDeleteModel,
setOpenAISummaryText,
setOpenAIServiceTier
setOpenAIServiceTier,
setNotificationSettings
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@ -2,6 +2,7 @@ import db from '@renderer/databases'
import { autoRenameTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { NotificationService } from '@renderer/services/NotificationService'
import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService'
import { estimateMessagesUsage } from '@renderer/services/TokenService'
import store from '@renderer/store'
@ -18,6 +19,7 @@ import type {
} from '@renderer/types/newMessage'
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { Response } from '@renderer/types/newMessage'
import { uuid } from '@renderer/utils'
import { isAbortError } from '@renderer/utils/error'
import { extractUrlsFromMarkdown } from '@renderer/utils/linkConverter'
import {
@ -32,9 +34,10 @@ import {
createTranslationBlock,
resetAssistantMessage
} from '@renderer/utils/messageUtils/create'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue'
import { t } from 'i18next'
import { throttle } from 'lodash'
import { v4 as uuidv4 } from 'uuid'
import type { AppDispatch, RootState } from '../index'
import { removeManyBlocks, updateOneBlock, upsertManyBlocks, upsertOneBlock } from '../messageBlock'
@ -254,6 +257,7 @@ const fetchAndProcessAssistantResponseImpl = async (
let citationBlockId: string | null = null
let mainTextBlockId: string | null = null
const toolCallIdToBlockIdMap = new Map<string, string>()
const notificationService = NotificationService.getInstance()
const handleBlockTransition = async (newBlock: MessageBlock, newBlockType: MessageBlockType) => {
lastBlockId = newBlock.id
@ -585,6 +589,16 @@ const fetchAndProcessAssistantResponseImpl = async (
status: error.status || error.code,
requestId: error.request_id
}
await notificationService.send({
id: uuid(),
type: 'error',
title: t('notification.assistant'),
message: serializableError.message,
silent: false,
timestamp: Date.now(),
source: 'assistant'
})
if (lastBlockId) {
// 更改上一个block的状态为ERROR
const changes: Partial<MessageBlock> = {
@ -631,6 +645,17 @@ const fetchAndProcessAssistantResponseImpl = async (
saveUpdatedBlockToDB(lastBlockId, assistantMsgId, topicId, getState)
}
const content = getMainTextContent(finalAssistantMsg)
await notificationService.send({
id: uuid(),
type: 'success',
title: t('notification.assistant'),
message: content.length > 50 ? content.slice(0, 47) + '...' : content,
silent: false,
timestamp: Date.now(),
source: 'assistant'
})
// 更新topic的name
autoRenameTopic(assistant, topicId)
@ -1287,7 +1312,7 @@ export const cloneMessagesToNewTopicThunk =
// 3. Clone Messages and Blocks with New IDs
for (const oldMessage of messagesToClone) {
const newMsgId = uuidv4()
const newMsgId = uuid()
originalToNewMsgIdMap.set(oldMessage.id, newMsgId) // Store mapping for all cloned messages
let newAskId: string | undefined = undefined // Initialize newAskId
@ -1313,7 +1338,7 @@ export const cloneMessagesToNewTopicThunk =
for (const oldBlockId of oldMessage.blocks) {
const oldBlock = state.messageBlocks.entities[oldBlockId]
if (oldBlock) {
const newBlockId = uuidv4()
const newBlockId = uuid()
const newBlock: MessageBlock = {
...oldBlock,
id: newBlockId,

View File

@ -0,0 +1,29 @@
export type NotificationType = 'progress' | 'success' | 'error' | 'warning' | 'info' | 'action'
export type NotificationSource = 'assistant' | 'backup' | 'knowledgeEmbed' | 'update'
export interface Notification<T = any> {
/** 通知唯一标识 */
id: string
/** 通知分类 */
type: NotificationType
/** 简要标题,用于列表或弹框的主文案 */
title: string
/** 详细描述,可包含执行上下文、结果摘要等 */
message: string
/** 时间戳,便于排序与去重 */
timestamp: number
/** 可选的进度值01针对长任务反馈 */
progress?: number
/** 附加元数据T 可定制各种业务字段 */
meta?: T
/** 点击或操作回调标识,前端可根据此字段触发路由或函数 */
actionKey?: string
/** 声音/声音开关标识,结合用户偏好决定是否播放 */
silent?: boolean
/** 渠道系统级OS 通知应用内UI 通知) */
channel?: 'system' | 'in-app'
/** 点击回调函数,仅在 type 为 'action' 时有效 */
onClick?: () => void
/** 通知源 */
source: NotificationSource
}

View File

@ -0,0 +1,3 @@
export const isFocused = () => {
return document.hasFocus()
}

View File

@ -5807,6 +5807,7 @@ __metadata:
electron-devtools-installer: "npm:^3.2.0"
electron-icon-builder: "npm:^2.0.1"
electron-log: "npm:^5.1.5"
electron-notification-state: "npm:^1.0.4"
electron-store: "npm:^8.2.0"
electron-updater: "npm:6.6.4"
electron-vite: "npm:^3.1.0"
@ -6534,6 +6535,15 @@ __metadata:
languageName: node
linkType: hard
"bindings@npm:^1.3.0, bindings@npm:^1.5.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
dependencies:
file-uri-to-path: "npm:1.0.0"
checksum: 10c0/3dab2491b4bb24124252a91e656803eac24292473e56554e35bbfe3cc1875332cfa77600c3bac7564049dc95075bf6fcc63a4609920ff2d64d0fe405fcf0d4ba
languageName: node
linkType: hard
"birecord@npm:^0.1.1":
version: 0.1.1
resolution: "birecord@npm:0.1.1"
@ -8845,6 +8855,17 @@ __metadata:
languageName: node
linkType: hard
"electron-notification-state@npm:^1.0.4":
version: 1.0.4
resolution: "electron-notification-state@npm:1.0.4"
dependencies:
macos-notification-state: "npm:^1.1.0"
windows-notification-state: "npm:^1.3.0"
windows-quiet-hours: "npm:^1.2.2"
checksum: 10c0/040af9b2d6966ce962712e54fe325e5863cf895d41e78dd31caf3da5fa2708c851321ec5c4ce84f192c25c72050d7aaa8d443d2c057712fe02f93f86d8d865b4
languageName: node
linkType: hard
"electron-publish@npm:26.0.13":
version: 26.0.13
resolution: "electron-publish@npm:26.0.13"
@ -10169,6 +10190,13 @@ __metadata:
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
checksum: 10c0/3b545e3a341d322d368e880e1c204ef55f1d45cdea65f7efc6c6ce9e0c4d22d802d5629320eb779d006fe59624ac17b0e848d83cc5af7cd101f206cb704f5519
languageName: node
linkType: hard
"file-url@npm:^2.0.0":
version: 2.0.2
resolution: "file-url@npm:2.0.2"
@ -12919,6 +12947,16 @@ __metadata:
languageName: node
linkType: hard
"macos-notification-state@npm:^1.1.0":
version: 1.3.6
resolution: "macos-notification-state@npm:1.3.6"
dependencies:
bindings: "npm:^1.5.0"
node-gyp: "npm:latest"
checksum: 10c0/2b9c2d358e7e0d331506ef97a808c6e16b26de32c4240ba2c0aee2e826007e8aa393f3389883975aa68734a4bb38466662f084523710ce4688b728515161d7f9
languageName: node
linkType: hard
"magic-string@npm:^0.30.17":
version: 0.30.17
resolution: "magic-string@npm:0.30.17"
@ -14388,6 +14426,15 @@ __metadata:
languageName: node
linkType: hard
"nan@npm:^2.14.0, nan@npm:^2.7.0":
version: 2.22.2
resolution: "nan@npm:2.22.2"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/971f963b8120631880fa47a389c71b00cadc1c1b00ef8f147782a3f4387d4fc8195d0695911272d57438c11562fb27b24c4ae5f8c05d5e4eeb4478ba51bb73c5
languageName: node
linkType: hard
"nanoid@npm:^3.3.7, nanoid@npm:^3.3.8":
version: 3.3.11
resolution: "nanoid@npm:3.3.11"
@ -19793,6 +19840,28 @@ __metadata:
languageName: node
linkType: hard
"windows-notification-state@npm:^1.3.0":
version: 1.3.4
resolution: "windows-notification-state@npm:1.3.4"
dependencies:
bindings: "npm:^1.5.0"
nan: "npm:^2.14.0"
node-gyp: "npm:latest"
checksum: 10c0/d92e59acb21369204b5ff3d4633b216fe6c374f27eed5a62ad25b859b4201023a2903b64d1d11e8ded266da6109b6a68fb95753c75e873cc173dd80e91e75c5a
languageName: node
linkType: hard
"windows-quiet-hours@npm:^1.2.2":
version: 1.2.7
resolution: "windows-quiet-hours@npm:1.2.7"
dependencies:
bindings: "npm:^1.3.0"
nan: "npm:^2.7.0"
node-gyp: "npm:latest"
checksum: 10c0/1a1076f40f0a66ebe6cc39e7f52a199ae59f6e94e733e363a6e1e168c8b38435020404f66f35268174a11015104e8ee8265f2e190a664d7923a70822d327a887
languageName: node
linkType: hard
"windows-system-proxy@npm:^1.0.0":
version: 1.0.0
resolution: "windows-system-proxy@npm:1.0.0"