diff --git a/package.json b/package.json
index 23da4289a0..c7f5831e38 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts
index e96fa919b5..4da92a9fd2 100644
--- a/packages/shared/IpcChannel.ts
+++ b/packages/shared/IpcChannel.ts
@@ -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
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 27d4fe4175..4ee9c1da17 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -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))
diff --git a/src/main/services/NotificationService.ts b/src/main/services/NotificationService.ts
new file mode 100644
index 0000000000..cde4d5204a
--- /dev/null
+++ b/src/main/services/NotificationService.ts
@@ -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
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 37d1dd6ac5..6201bb9c2c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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)
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 24024374ec..b46910cd65 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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 {
-
-
-
-
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
-
-
+
+
+
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+
+
diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts
index cea0c8d521..2ade2c1a74 100644
--- a/src/renderer/src/config/models.ts
+++ b/src/renderer/src/config/models.ts
@@ -441,7 +441,6 @@ export const SYSTEM_MODELS: Record = {
{ id: 'deepseek-r1', name: 'DeepSeek-R1', provider: 'burncloud', group: 'deepseek-ai' },
{ id: 'deepseek-v3', name: 'DeepSeek-V3', provider: 'burncloud', group: 'deepseek-ai' }
-
],
o3: [
diff --git a/src/renderer/src/context/NotificationProvider.tsx b/src/renderer/src/context/NotificationProvider.tsx
new file mode 100644
index 0000000000..fca0ef818e
--- /dev/null
+++ b/src/renderer/src/context/NotificationProvider.tsx
@@ -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 = {
+ error: 'error',
+ success: 'success',
+ warning: 'warning',
+ info: 'info',
+ progress: 'info',
+ action: 'info'
+}
+
+const NotificationContext = createContext(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((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 (
+
+ {contextHolder}
+ {children}
+
+ )
+}
+
+export const useNotification = () => {
+ const ctx = use(NotificationContext)
+ if (!ctx) throw new Error('useNotification must be used within a NotificationProvider')
+ return ctx
+}
diff --git a/src/renderer/src/hooks/useUpdateHandler.ts b/src/renderer/src/hooks/useUpdateHandler.ts
index 576da468f1..8b2f2800c6 100644
--- a/src/renderer/src/hooks/useUpdateHandler.ts
+++ b/src/renderer/src/hooks/useUpdateHandler.ts
@@ -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,
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index 325e5b74ea..84d17aeaaf 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index 48b99908e5..6adbf75d39 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -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": {
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index bf8c03d812..7903f23620 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -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": "Любой язык",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index b757c85c9a..bdb0ee1cc8 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -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",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 815c61748d..f5da40ff4a 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -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": {
diff --git a/src/renderer/src/pages/settings/GeneralSettings.tsx b/src/renderer/src/pages/settings/GeneralSettings.tsx
index f86f2bde75..8435e542ea 100644
--- a/src/renderer/src/pages/settings/GeneralSettings.tsx
+++ b/src/renderer/src/pages/settings/GeneralSettings.tsx
@@ -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 (
@@ -154,6 +162,27 @@ const GeneralSettings: FC = () => {
>
)}
+
+ {t('settings.notification.title')}
+
+
+ {t('settings.notification.assistant')}
+ handleNotificationChange('assistant', v)} />
+
+
+
+ {t('settings.notification.backup')}
+ handleNotificationChange('backup', v)} />
+
+
+
+ {t('settings.notification.knowledge_embed')}
+ handleNotificationChange('knowledgeEmbed', v)}
+ />
+
+
{t('settings.launch.title')}
diff --git a/src/renderer/src/queue/KnowledgeQueue.ts b/src/renderer/src/queue/KnowledgeQueue.ts
index 80d20a080a..0757fd280a 100644
--- a/src/renderer/src/queue/KnowledgeQueue.ts
+++ b/src/renderer/src/queue/KnowledgeQueue.ts
@@ -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 = new Map()
@@ -84,6 +87,7 @@ class KnowledgeQueue {
}
private async processItem(baseId: string, item: KnowledgeItem): Promise {
+ 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,
diff --git a/src/renderer/src/queue/NotificationQueue.ts b/src/renderer/src/queue/NotificationQueue.ts
new file mode 100644
index 0000000000..f256212837
--- /dev/null
+++ b/src/renderer/src/queue/NotificationQueue.ts
@@ -0,0 +1,53 @@
+import type { Notification } from '@renderer/types/notification'
+import PQueue from 'p-queue'
+
+type NotificationListener = (notification: Notification) => Promise | 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 {
+ 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
+ }
+}
diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts
index 662268764e..b6457412f2 100644
--- a/src/renderer/src/services/BackupService.ts
+++ b/src/renderer/src/services/BackupService.ts
@@ -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 &&
diff --git a/src/renderer/src/services/NotificationService.ts b/src/renderer/src/services/NotificationService.ts
new file mode 100644
index 0000000000..2f001f524c
--- /dev/null
+++ b/src/renderer/src/services/NotificationService.ts
@@ -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 {
+ 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
+ }
+}
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index e7e3e2fac7..851341143a 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
- version: 104,
+ version: 105,
blacklist: ['runtime', 'messages', 'messageBlocks'],
migrate
},
diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts
index 2570b1bbc3..7b1c47f429 100644
--- a/src/renderer/src/store/migrate.ts
+++ b/src/renderer/src/store/migrate.ts
@@ -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
+ }
}
}
diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts
index c6157e85b1..11a0e19d33 100644
--- a/src/renderer/src/store/settings.ts
+++ b/src/renderer/src/store/settings.ts
@@ -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) => {
state.openAI.serviceTier = action.payload
+ },
+ setNotificationSettings: (state, action: PayloadAction) => {
+ state.notification = action.payload
}
}
})
@@ -709,7 +723,8 @@ export const {
setExportMenuOptions,
setEnableBackspaceDeleteModel,
setOpenAISummaryText,
- setOpenAIServiceTier
+ setOpenAIServiceTier,
+ setNotificationSettings
} = settingsSlice.actions
export default settingsSlice.reducer
diff --git a/src/renderer/src/store/thunk/messageThunk.ts b/src/renderer/src/store/thunk/messageThunk.ts
index 685707afe4..1e81d123e2 100644
--- a/src/renderer/src/store/thunk/messageThunk.ts
+++ b/src/renderer/src/store/thunk/messageThunk.ts
@@ -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()
+ 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 = {
@@ -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,
diff --git a/src/renderer/src/types/notification.ts b/src/renderer/src/types/notification.ts
new file mode 100644
index 0000000000..c312b493b3
--- /dev/null
+++ b/src/renderer/src/types/notification.ts
@@ -0,0 +1,29 @@
+export type NotificationType = 'progress' | 'success' | 'error' | 'warning' | 'info' | 'action'
+export type NotificationSource = 'assistant' | 'backup' | 'knowledgeEmbed' | 'update'
+
+export interface Notification {
+ /** 通知唯一标识 */
+ id: string
+ /** 通知分类 */
+ type: NotificationType
+ /** 简要标题,用于列表或弹框的主文案 */
+ title: string
+ /** 详细描述,可包含执行上下文、结果摘要等 */
+ message: string
+ /** 时间戳,便于排序与去重 */
+ timestamp: number
+ /** 可选的进度值(0~1),针对长任务反馈 */
+ progress?: number
+ /** 附加元数据,T 可定制各种业务字段 */
+ meta?: T
+ /** 点击或操作回调标识,前端可根据此字段触发路由或函数 */
+ actionKey?: string
+ /** 声音/声音开关标识,结合用户偏好决定是否播放 */
+ silent?: boolean
+ /** 渠道:系统级(OS 通知)|应用内(UI 通知) */
+ channel?: 'system' | 'in-app'
+ /** 点击回调函数,仅在 type 为 'action' 时有效 */
+ onClick?: () => void
+ /** 通知源 */
+ source: NotificationSource
+}
diff --git a/src/renderer/src/utils/window.ts b/src/renderer/src/utils/window.ts
new file mode 100644
index 0000000000..3f67aca4b3
--- /dev/null
+++ b/src/renderer/src/utils/window.ts
@@ -0,0 +1,3 @@
+export const isFocused = () => {
+ return document.hasFocus()
+}
diff --git a/yarn.lock b/yarn.lock
index a05e585083..a80241ac83 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"