From f35198df01b170c61606f92188201544d9975691 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 01:43:21 +0800 Subject: [PATCH] feat: add desktop notification toggle to general settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a default-on persisted desktop notification switch that takes effect immediately for background task results while leaving in-app notifications unchanged. Group General settings into shortcut, conversation and notification, and workspace and download cards. Validation: 756 related tests passed, 1 skipped; typecheck and lint passed. Full suite timed out with three supervision preview failures; native OS notification delivery was not manually verified. Release note: 通用设置新增默认开启的桌面通知开关,可随时关闭系统任务提醒并保留应用内提示;设置项按用途分组,方便查找。 --- UI-DESIGN.md | 1 + .../model-download-source-design.md | 6 +- src/main/application-settings-store.test.ts | 28 ++ src/main/application-settings-store.ts | 5 + src/main/ipc.test.ts | 60 +++- src/main/ipc.ts | 18 +- src/renderer/src/App.test.tsx | 15 + src/renderer/src/MagicNotesWorkspace.test.tsx | 1 + .../src/PlatformFeaturesSettingsSection.tsx | 270 ++++++++++-------- src/renderer/src/SettingsPanel.test.tsx | 101 ++++++- .../src/UpdateSettingsSection.test.tsx | 4 + .../i18n/locales/en-US/settingsSections.ts | 11 + .../i18n/locales/zh-CN/settingsSections.ts | 10 + src/shared/application-settings-contracts.ts | 2 + 14 files changed, 411 insertions(+), 121 deletions(-) diff --git a/UI-DESIGN.md b/UI-DESIGN.md index e8dbdc12..e796f79a 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -829,6 +829,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须 明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即 更新,保存失败时保留最后确认状态。 +- 通用设置按快捷唤起、会话与通知、工作目录与下载分组,复用现有卡片。“桌面通知”为默认开启的持久 Switch,保存后立即控制窗口未聚焦时的任务完成、失败和取消系统通知,关闭不影响应用内通知。旧配置缺少该字段时默认开启,明确关闭后重启仍保持关闭;保存失败保留最后确认状态并显示应用内错误通知。 - “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 - “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 diff --git a/docs/architecture/model-download-source-design.md b/docs/architecture/model-download-source-design.md index 00cc92d2..5fb73542 100644 --- a/docs/architecture/model-download-source-design.md +++ b/docs/architecture/model-download-source-design.md @@ -225,6 +225,10 @@ ### 7.1 布局 +通用设置按快捷唤起、会话与通知、工作目录与下载分成三张卡片。模型下载源位于 +“工作目录与下载”卡片内的工作目录输入之后;以下仅展示下载源控件,完整分组及通知 +开关行为见 [UI 设计规范](../../UI-DESIGN.md#138-设置中心)。 + ```text 平台功能 管理通用平台行为与可选工作区能力 @@ -244,7 +248,7 @@ └────────────────────────────────────────────────────────┘ ``` -页面只保留一个视觉上最突出的任务。模型下载源保存属于即时设置,不增加与来源选项竞争的 +模型下载源保存属于即时设置,不增加与来源选项竞争的 大型“保存全部”按钮。 ### 7.2 控件 diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index 1b2c517a..709f4f50 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -44,6 +44,34 @@ afterEach(async () => { }) describe('ApplicationSettingsStore', () => { + it.each([11, 12])('defaults missing desktop notifications to on in version %s', async (version) => { + const { filePath, store } = await createStore() + const legacy: Record = { ...defaultApplicationSettings, version, lastSeenReleaseNotesVersion: null, checkUpdatesOnStartup: false } + delete legacy.desktopNotificationsEnabled + await writeFile(filePath, JSON.stringify(legacy)) + expect(await store.get()).toEqual({ ...defaultApplicationSettings, checkUpdatesOnStartup: false }) + expect(applicationSettingsSchema.parse({ ...defaultApplicationSettings, desktopNotificationsEnabled: undefined }).desktopNotificationsEnabled).toBe(true) + }) + + it('persists and publishes desktop notification choices without resetting them on unrelated updates', async () => { + const { filePath, store } = await createStore() + expect((await store.get()).desktopNotificationsEnabled).toBe(true) + const changed = vi.fn() + store.onChanged(changed) + for (const desktopNotificationsEnabled of [false, true]) { + const saved = await store.update({ desktopNotificationsEnabled }) + expect(saved.desktopNotificationsEnabled).toBe(desktopNotificationsEnabled) + expect(changed).toHaveBeenLastCalledWith(saved) + await store.update({ checkUpdatesOnStartup: false }) + expect((await createApplicationSettingsStore(filePath).get()).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled) + expect(JSON.parse(await readFile(filePath, 'utf8')).desktopNotificationsEnabled).toBe(desktopNotificationsEnabled) + } + expect(applicationSettingsUpdateSchema.parse({ checkUpdatesOnStartup: false })).toEqual({ checkUpdatesOnStartup: false }) + for (const desktopNotificationsEnabled of ['false', 0, null]) { + await expect(store.update({ desktopNotificationsEnabled })).rejects.toThrow() + } + }) + it('persists review pagination and batch controls without enabling supervision', async () => { const { filePath, store } = await createStore() const supervisionReview = { pageSize: 1, batchCharacters: 16000, batchMessages: 50, executionSeconds: 3600 } diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index 71d67b81..9cd15476 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -250,6 +250,7 @@ export const resolveLegacyLocalToolEnvironmentPaths: LegacyLocalToolEnvironmentR export const defaultApplicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -289,6 +290,7 @@ export class ApplicationSettingsStore { settings: Omit< StoredApplicationSettings, | 'conversationHtmlRenderingEnabled' + | 'desktopNotificationsEnabled' | 'localToolEnvironment' | 'version' | 'applicationNavigation' @@ -297,6 +299,7 @@ export class ApplicationSettingsStore { | 'magicNoteCanvasPageCount' > & { conversationHtmlRenderingEnabled?: boolean + desktopNotificationsEnabled?: boolean magicNoteCanvasPageCount?: number version: number } @@ -522,6 +525,7 @@ export class ApplicationSettingsStore { const stored = await this.loadStored() return { checkUpdatesOnStartup: stored.checkUpdatesOnStartup, + desktopNotificationsEnabled: stored.desktopNotificationsEnabled, updateSource: stored.updateSource, modelDownloadSource: stored.modelDownloadSource, localToolEnvironment: stored.localToolEnvironment, @@ -567,6 +571,7 @@ export class ApplicationSettingsStore { this.warnings = [] const settings: ApplicationSettings = { checkUpdatesOnStartup: next.checkUpdatesOnStartup, + desktopNotificationsEnabled: next.desktopNotificationsEnabled, updateSource: next.updateSource, modelDownloadSource: next.modelDownloadSource, localToolEnvironment: next.localToolEnvironment, diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 43cedcb5..278e8e15 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -34,6 +34,8 @@ import type { import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' import { ImageGenerationService } from './agent/image-generation-service' +import { ApplicationSettingsStore } from './application-settings-store' +import * as desktopNotification from './desktop-notification' import type { AgentExecutionRequest } from './agent/runtime' import { KnowledgeService } from './knowledge/knowledge-service' import { KnowledgeDatabase } from './knowledge/knowledge-database' @@ -5201,7 +5203,8 @@ describe('registerIpcHandlers agent terminal state', () => { heartbeatEnabled?: boolean, obsidianService?: ObsidianService, nativeClientCoordinator?: Parameters[45], - nativeTerminalManager?: Parameters[41] + nativeTerminalManager?: Parameters[41], + applicationSettingsStore?: ApplicationSettingsStore ) { const assistantDatabase = { createTask: vi.fn(), @@ -5438,6 +5441,7 @@ describe('registerIpcHandlers agent terminal state', () => { args[44] = obsidianService args[45] = nativeClientCoordinator args[41] = nativeTerminalManager + if (applicationSettingsStore) args[15] = applicationSettingsStore const dispose = registerIpcHandlers(...args) return { approvalBroker, @@ -5483,6 +5487,60 @@ describe('registerIpcHandlers agent terminal state', () => { senderFrame: webContents.mainFrame }) + it.each([ + ['conversation', 'completed'], ['conversation', 'failed'], + ['channel', 'completed'], ['channel', 'failed'] + ] as const)('uses the latest saved desktop notification preference for %s %s results', async (origin, status) => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-notification-ipc-')) + const store = new ApplicationSettingsStore(join(directory, 'application.json')) + await store.update({ magicNotesEnabled: false }) + const notify = vi.spyOn(desktopNotification, 'showDesktopNotificationWhenUnfocused').mockReturnValue(true) + let preference: 'default' | boolean | 'unreadable' = 'default' + const get = vi.spyOn(store, 'get') + const harness = createHarness({ + capability: 'chat', + async *run(request: { requestId: string }) { + // Change the persisted choice after admission to detect stale startup/task snapshots. + if (typeof preference === 'boolean') await store.update({ desktopNotificationsEnabled: preference }) + if (preference === 'unreadable') get.mockRejectedValueOnce(new Error('Settings unavailable')) + if (status === 'failed') throw new Error('Runtime failed') + yield { requestId: request.requestId, type: 'done' } + } + }, undefined, 'always', undefined, false, undefined, undefined, undefined, false, + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, store) + try { + for (preference of ['default', false, true, 'unreadable'] as const) { + notify.mockClear() + harness.webContents.send.mockClear() + harness.assistantDatabase.updateTaskStatus.mockClear() + if (origin === 'channel') { + await expect(channelMocks.executor!({ + channel: 'wecom', eventId: crypto.randomUUID(), senderId: 'user-1', + conversationId: 'conversation-1', conversationType: 'direct', + text: 'Read only', mentioned: false, workMode: 'ask' + }, new AbortController().signal)).resolves.toMatchObject({ status }) + } else { + const requestId = crypto.randomUUID() + await harness.handler!(trustedEvent(harness.webContents), { + requestId, conversationId: crypto.randomUUID(), prompt: 'Read only', workMode: 'ask' + }) + await vi.waitFor(() => expect(harness.webContents.send).toHaveBeenCalledWith( + ipcChannels.agentEvent, expect.objectContaining({ requestId, type: status === 'completed' ? 'done' : 'error' }) + )) + } + expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( + expect.any(String), status, ...(status === 'failed' ? ['Runtime failed'] : []) + ) + expect(notify).toHaveBeenCalledTimes(preference === 'default' || preference === true ? 1 : 0) + } + } finally { + await harness.dispose() + notify.mockRestore() + get.mockRestore() + await rm(directory, { recursive: true, force: true }) + } + }) + function createManagedSshHarness( selectedRuntime: Record ) { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 388a6846..653b3c8a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,6 +11,7 @@ import { dialog, ipcMain, shell, + type NotificationConstructorOptions, type IpcMainInvokeEvent } from 'electron' import { @@ -1043,6 +1044,15 @@ export function registerIpcHandlers( obsidianService?: ObsidianService, nativeClientCoordinator?: Pick ): () => Promise { + const notifyDesktop = async (options: NotificationConstructorOptions): Promise => { + try { + if ((await applicationSettingsStore?.get())?.desktopNotificationsEnabled === false) return + } catch { + // A notification preference read must not change the task's terminal result. + return + } + showDesktopNotificationWhenUnfocused(window, options) + } type ActiveRequestLease = { controller: AbortController isReply: boolean @@ -2601,7 +2611,7 @@ export function registerIpcHandlers( }) } assistantDatabase.updateTaskStatus(taskId, 'completed') - showDesktopNotificationWhenUnfocused(window, { + await notifyDesktop({ title: origin === 'channel' ? `${remoteContext?.channelLabel ?? '远程通道'}请求已完成` @@ -2631,7 +2641,7 @@ export function registerIpcHandlers( cancelled ? 'cancelled' : 'failed', message ) - showDesktopNotificationWhenUnfocused(window, { + await notifyDesktop({ title: origin === 'channel' ? `${remoteContext?.channelLabel ?? '远程通道'}请求失败` @@ -4496,7 +4506,7 @@ export function registerIpcHandlers( 'completed' ) } - showDesktopNotificationWhenUnfocused(window, { + await notifyDesktop({ title: 'GoodBuddy 任务已完成', body: '回复已生成,可返回会话查看。' }) @@ -4573,7 +4583,7 @@ export function registerIpcHandlers( ) } } - showDesktopNotificationWhenUnfocused(window, { + await notifyDesktop({ title: cancelled ? 'GoodBuddy 任务已取消' : 'GoodBuddy 任务失败', diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 80dd5dbf..6e39214e 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1130,6 +1130,7 @@ function installRemoteProjectsSetting(enabled: boolean): { } { let settings: ApplicationSettings = { checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github", modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -3144,6 +3145,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -3247,6 +3249,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -3262,6 +3265,7 @@ describe("App", () => { })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -3363,6 +3367,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -3378,6 +3383,7 @@ describe("App", () => { })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -5653,6 +5659,7 @@ describe("App", () => { it("updates and removes the composer shortcut hint immediately after saving Settings", async () => { let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github", modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13541,6 +13548,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13556,6 +13564,7 @@ describe("App", () => { })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13608,6 +13617,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13667,6 +13677,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13703,6 +13714,7 @@ describe("App", () => { api.updates = { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13718,6 +13730,7 @@ describe("App", () => { })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github" as const, modelDownloadSource: "modelscope" as const, localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13779,6 +13792,7 @@ describe("App", () => { ]); let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github", modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -13837,6 +13851,7 @@ describe("App", () => { it("omits global notes settings while retaining application center settings without navigating the workspace", async () => { let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: "github", modelDownloadSource: "modelscope", localToolEnvironment: defaultLocalToolEnvironmentSettings, diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index d8b2c57f..ef74ab78 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -73,6 +73,7 @@ const todo: MagicTodoItem = { } const otherTodo: MagicTodoItem = { ...todo, id: '00000000-0000-4000-8000-000000000604', noteId: secondNoteId, noteTitle: second.title, title: '准备演示' } const settings: ApplicationSettings = { + desktopNotificationsEnabled: true, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, applicationNavigation: defaultApplicationNavigation, localInferenceEnabled: true, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false, diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index fee522f3..d4d1daa2 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -493,6 +493,29 @@ export function PlatformFeaturesSettingsSection({ } } + const changeDesktopNotifications = async ( + enabled: boolean + ): Promise => { + const updates = window.goodbuddy.updates + if (!updates || !settings || saving) { + return + } + setSaving(true) + try { + setSettings(await updates.updateSettings({ + desktopNotificationsEnabled: enabled + })) + } catch { + onNotify?.({ + tone: 'error', + message: t('platformFeatures.errors.saveDesktopNotificationsFailed'), + dedupeKey: 'desktop-notifications-save' + }) + } finally { + setSaving(false) + } + } + const changeRemoteProjects = async ( enabled: boolean ): Promise => { @@ -556,53 +579,6 @@ export function PlatformFeaturesSettingsSection({ id="platform-features-panel-general" role="tabpanel" > -
-
- - -
- -
-
@@ -710,98 +686,164 @@ export function PlatformFeaturesSettingsSection({ )}
{settings && ( -
+
- - - {t('platformFeatures.conversationHtml.title')} - - - {t('platformFeatures.conversationHtml.description')} - - + {t('platformFeatures.groups.conversation')}
+

+ {t('platformFeatures.desktopNotifications.description')} +

+
+ + + {t('platformFeatures.conversationHtml.description')} + +
)} - {settings ? ( -
+
- - {t('platformFeatures.modelDownloadSource.title')} - - - {t( - 'platformFeatures.modelDownloadSource.description' - )} +
-
- - {t('platformFeatures.modelDownloadSource.title')} - - {( - ['modelscope', 'hugging-face'] as const - ).map((source) => ( - + {settings ? ( + <> +
+ + + {t('platformFeatures.modelDownloadSource.title')} + + {t( - `platformFeatures.modelDownloadSource.options.${source}` + 'platformFeatures.modelDownloadSource.description' )} - + - - ))} -
- {sourceError && ( -

- {sourceError} -

+ +
+ + {t('platformFeatures.modelDownloadSource.title')} + + {( + ['modelscope', 'hugging-face'] as const + ).map((source) => ( + + ))} +
+ {sourceError && ( +

+ {sourceError} +

+ )} + + ) : ( + !error && ( +

+ {t('platformFeatures.loading')} +

+ ) )}
- ) : ( - !error && ( -

- {t('platformFeatures.loading')} -

- ) - )} )} diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 918e58dd..36d4584a 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -546,6 +546,7 @@ const removeEmbeddingModel = vi.fn( ) let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -839,6 +840,7 @@ describe('SettingsPanel runtime files', () => { await changeUiLocale('zh-CN') applicationSettings = { checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -1426,7 +1428,7 @@ describe('SettingsPanel runtime files', () => { fireEvent.click( screen.getByRole('tab', { name: /Platform Features/i }) ) - expect(screen.getByText('Default workspace')).toBeInTheDocument() + expect(screen.getByText('Workspace & downloads')).toBeInTheDocument() expect( screen.getByLabelText('Default workspace folder') ).toBeInTheDocument() @@ -1574,6 +1576,100 @@ describe('SettingsPanel runtime files', () => { ).toBeInTheDocument() }) + it.each([ + ['zh-CN', '平台功能', '桌面通知'], + ['en-US', 'Platform features', 'Desktop notifications'] + ] as const)('saves desktop notifications immediately in %s', async (locale, category, label) => { + await changeUiLocale(locale) + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: category })) + const toggle = await screen.findByRole('switch', { name: label }) + expect(toggle).toBeChecked() + expect(toggle).toHaveAccessibleDescription() + fireEvent.click(toggle) + await waitFor(() => expect(toggle).not.toBeChecked()) + expect(updateApplicationSettings).toHaveBeenLastCalledWith({ + desktopNotificationsEnabled: false + }) + fireEvent.click(toggle) + await waitFor(() => expect(toggle).toBeChecked()) + expect(updateApplicationSettings).toHaveBeenLastCalledWith({ + desktopNotificationsEnabled: true + }) + expect(updateApplicationSettings).toHaveBeenCalledTimes(2) + expect(updateRuntime).not.toHaveBeenCalled() + }) + + it('loads disabled desktop notifications and groups related General settings', async () => { + applicationSettings.desktopNotificationsEnabled = false + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) + const conversation = await screen.findByRole('article', { name: '会话与通知' }) + expect(within(conversation).getByRole('switch', { name: '桌面通知' })).not.toBeChecked() + expect(within(conversation).getByRole('switch', { name: '在会话中渲染 HTML' })).toBeChecked() + const workspace = screen.getByRole('article', { name: '工作目录与下载' }) + expect(within(workspace).getByRole('textbox')).toHaveValue(runtimeSettings.workspacePath) + expect(within(workspace).getByRole('radio', { name: /ModelScope/u })).toBeChecked() + expect(updateApplicationSettings).not.toHaveBeenCalled() + }) + + it('keeps desktop notifications confirmed while saving and reports failure through onNotify', async () => { + const onNotify = vi.fn() + let rejectSave!: (reason: Error) => void + updateApplicationSettings.mockImplementationOnce(() => new Promise((_, reject) => { + rejectSave = reject + })) + render( + {})} + onClose={vi.fn()} + onSaved={vi.fn()} + /> + ) + + fireEvent.click(screen.getByRole('tab', { name: '平台功能' })) + const toggle = await screen.findByRole('switch', { name: '桌面通知' }) + fireEvent.click(toggle) + expect(toggle).toBeDisabled() + expect(toggle).toBeChecked() + fireEvent.click(toggle) + expect(updateApplicationSettings).toHaveBeenCalledTimes(1) + await act(async () => rejectSave(new Error('save failed'))) + expect(toggle).toBeEnabled() + expect(toggle).toBeChecked() + expect(onNotify).toHaveBeenCalledExactlyOnceWith({ + tone: 'error', + message: '保存桌面通知设置失败,请重试', + dedupeKey: 'desktop-notifications-save' + }) + expect(screen.queryByText('保存桌面通知设置失败,请重试')).not.toBeInTheDocument() + + fireEvent.click(toggle) + await waitFor(() => expect(toggle).not.toBeChecked()) + expect(updateApplicationSettings).toHaveBeenCalledTimes(2) + }) + it('disables conversation HTML rendering from General settings', async () => { const onConversationHtmlRenderingEnabledChange = vi.fn() render( @@ -1629,6 +1725,9 @@ describe('SettingsPanel runtime files', () => { expect( screen.queryByRole('radio', { name: /ModelScope/u }) ).not.toBeInTheDocument() + expect( + screen.queryByRole('switch', { name: '桌面通知' }) + ).not.toBeInTheDocument() expect( screen.queryByText('当前选择:ModelScope') ).not.toBeInTheDocument() diff --git a/src/renderer/src/UpdateSettingsSection.test.tsx b/src/renderer/src/UpdateSettingsSection.test.tsx index 550dc103..945dd90d 100644 --- a/src/renderer/src/UpdateSettingsSection.test.tsx +++ b/src/renderer/src/UpdateSettingsSection.test.tsx @@ -23,6 +23,7 @@ describe('UpdateSettingsSection', () => { it('checks the official release manifest and updates the startup preference', async () => { let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', modelDownloadSource: 'modelscope', localToolEnvironment: defaultLocalToolEnvironmentSettings, @@ -186,6 +187,7 @@ describe('UpdateSettingsSection', () => { updates: { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', magicNotesEnabled: true, magicNoteCommentMode: 'immediate', @@ -193,6 +195,7 @@ describe('UpdateSettingsSection', () => { })), updateSettings: vi.fn(async () => ({ checkUpdatesOnStartup: true, + desktopNotificationsEnabled: true, updateSource: 'github', magicNotesEnabled: true, magicNoteCommentMode: 'immediate', @@ -239,6 +242,7 @@ describe('UpdateSettingsSection', () => { updates: { getSettings: vi.fn(async () => ({ checkUpdatesOnStartup: false, + desktopNotificationsEnabled: true, updateSource: 'mirror', magicNotesEnabled: true, magicNoteCommentMode: 'immediate', diff --git a/src/renderer/src/i18n/locales/en-US/settingsSections.ts b/src/renderer/src/i18n/locales/en-US/settingsSections.ts index 9d76a703..8631440f 100644 --- a/src/renderer/src/i18n/locales/en-US/settingsSections.ts +++ b/src/renderer/src/i18n/locales/en-US/settingsSections.ts @@ -541,6 +541,8 @@ export const settingsSections = { serviceUnavailable: 'Application settings are not available in this version', readFailed: 'Could not load platform feature settings', + saveDesktopNotificationsFailed: + 'Could not save the desktop notification setting. Try again.', saveConversationHtmlRenderingFailed: 'Could not save the conversation HTML rendering setting. Try again.', saveRemoteProjectsFailed: @@ -560,6 +562,15 @@ export const settingsSections = { ariaLabel: 'Platform feature settings', general: 'General' }, + groups: { + conversation: 'Conversations & notifications', + workspace: 'Workspace & downloads' + }, + desktopNotifications: { + enabled: 'Desktop notifications', + description: + 'Show system notifications when background work needs your attention. Turning this off does not affect in-app notifications.' + }, conversationHtml: { title: 'Conversation HTML rendering', description: diff --git a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts index bc4bf336..4550e302 100644 --- a/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts +++ b/src/renderer/src/i18n/locales/zh-CN/settingsSections.ts @@ -516,6 +516,7 @@ export const settingsSections = { errors: { serviceUnavailable: '当前版本未提供应用设置服务', readFailed: '读取平台功能设置失败', + saveDesktopNotificationsFailed: '保存桌面通知设置失败,请重试', saveConversationHtmlRenderingFailed: '保存会话 HTML 渲染设置失败,请重试', saveRemoteProjectsFailed: '保存远程项目设置失败,请重试', @@ -531,6 +532,15 @@ export const settingsSections = { ariaLabel: '平台功能设置', general: '通用设置' }, + groups: { + conversation: '会话与通知', + workspace: '工作目录与下载' + }, + desktopNotifications: { + enabled: '桌面通知', + description: + '后台工作需要关注时显示系统通知。关闭后不影响应用内通知。' + }, conversationHtml: { title: '会话 HTML 渲染', description: diff --git a/src/shared/application-settings-contracts.ts b/src/shared/application-settings-contracts.ts index b9f9ddfd..a66dce65 100644 --- a/src/shared/application-settings-contracts.ts +++ b/src/shared/application-settings-contracts.ts @@ -58,6 +58,7 @@ export const applicationNavigationSchema = z.object({ const applicationPreferencesSchema = z .object({ checkUpdatesOnStartup: z.boolean(), + desktopNotificationsEnabled: z.boolean().default(true), updateSource: updateSourceSchema, modelDownloadSource: modelDownloadSourceSchema, localToolEnvironment: localToolEnvironmentSettingsSchema, @@ -88,6 +89,7 @@ export const applicationSettingsUpdateSchema = applicationPreferencesSchema .partial() .extend({ applicationNavigation: applicationNavigationSchema.optional(), + desktopNotificationsEnabled: z.boolean().optional(), localInferenceEnabled: z.boolean().optional(), magicNotesEnabled: z.boolean().optional(), heartbeatEnabled: z.boolean().optional(),