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"