diff --git a/FEATURES.md b/FEATURES.md index 6180e352..6feb51c6 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -51,7 +51,7 @@ - [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。 - [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。 - [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。 -- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。 +- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。 - [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。 - [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 3ba08d9e..0e7a134a 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -613,6 +613,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。 - 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。 +- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。 - 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。 - 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 @@ -633,7 +634,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。 - “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。 - 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 -- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 +- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 - Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。 diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index a29733f2..a5c6329f 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -58,13 +58,15 @@ describe('ApplicationSettingsStore', () => { await expect( store.update({ checkUpdatesOnStartup: false, - magicNotesEnabled: false + magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: false }) ).resolves.toEqual({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) @@ -73,15 +75,17 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 7, + version: 8, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null @@ -107,6 +111,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) @@ -130,6 +135,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) @@ -153,6 +159,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) @@ -176,6 +183,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'combined' }) @@ -201,11 +209,12 @@ describe('ApplicationSettingsStore', () => { new ApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion() ).resolves.toBe('0.8.18') expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 7, + version: 8, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'narrative', lastSeenReleaseNotesVersion: '0.8.18' @@ -232,6 +241,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }) @@ -258,6 +268,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'mirror', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'after-save-auto', magicNoteCommentFormat: 'structured' }) @@ -268,11 +279,40 @@ describe('ApplicationSettingsStore', () => { await store.update({ modelDownloadSource: 'hugging-face' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ ...versionSix, - version: 7, - modelDownloadSource: 'hugging-face' + version: 8, + modelDownloadSource: 'hugging-face', + magicNotesShowIncompleteTodoCount: true }) }) + it('migrates version 7 to show incomplete todo counts', async () => { + const { filePath, store } = await createStore() + const versionSeven = { + version: 7, + checkUpdatesOnStartup: false, + updateSource: 'github', + modelDownloadSource: 'modelscope', + magicNotesEnabled: true, + magicNoteCommentMode: 'immediate', + magicNoteCommentFormat: 'combined', + lastSeenReleaseNotesVersion: null + } + await writeFile(filePath, JSON.stringify(versionSeven), 'utf8') + + await expect(store.get()).resolves.toEqual({ + checkUpdatesOnStartup: false, + updateSource: 'github', + modelDownloadSource: 'modelscope', + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, + magicNoteCommentMode: 'immediate', + magicNoteCommentFormat: 'combined' + }) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual( + versionSeven + ) + }) + it('strictly rejects incomplete full settings', () => { for (const input of [ {}, @@ -319,6 +359,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) @@ -433,15 +474,17 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 7, + version: 8, checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null @@ -467,6 +510,7 @@ describe('ApplicationSettingsStore', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' }) diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index 2cdfdc12..c124cceb 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -20,7 +20,7 @@ export { } from '../shared/application-settings-contracts' export type { ApplicationSettings } from '../shared/application-settings-contracts' -const CURRENT_SETTINGS_VERSION = 7 +const CURRENT_SETTINGS_VERSION = 8 const legacyStoredApplicationSettingsSchema = z .object({ @@ -47,14 +47,22 @@ const versionThreeStoredApplicationSettingsSchema = z .strict() const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema - .omit({ updateSource: true, modelDownloadSource: true }) + .omit({ + updateSource: true, + modelDownloadSource: true, + magicNotesShowIncompleteTodoCount: true + }) .extend({ version: z.literal(4) }) .strict() const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema - .omit({ updateSource: true, modelDownloadSource: true }) + .omit({ + updateSource: true, + modelDownloadSource: true, + magicNotesShowIncompleteTodoCount: true + }) .extend({ version: z.literal(5), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() @@ -62,13 +70,25 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema .strict() const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema - .omit({ modelDownloadSource: true }) + .omit({ + modelDownloadSource: true, + magicNotesShowIncompleteTodoCount: true + }) .extend({ version: z.literal(6), lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() }) .strict() +const versionSevenStoredApplicationSettingsSchema = + applicationSettingsSchema + .omit({ magicNotesShowIncompleteTodoCount: true }) + .extend({ + version: z.literal(7), + lastSeenReleaseNotesVersion: releaseVersionSchema.nullable() + }) + .strict() + const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ version: z.literal(CURRENT_SETTINGS_VERSION), @@ -85,6 +105,7 @@ export const defaultApplicationSettings: ApplicationSettings = { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' } @@ -140,13 +161,24 @@ export class ApplicationSettingsStore { ) const result = storedApplicationSettingsSchema.safeParse(parsed) if (!result.success) { + const versionSevenResult = + versionSevenStoredApplicationSettingsSchema.safeParse(parsed) + if (versionSevenResult.success) { + this.settings = { + ...versionSevenResult.data, + version: CURRENT_SETTINGS_VERSION, + magicNotesShowIncompleteTodoCount: true + } + return this.settings + } const versionSixResult = versionSixStoredApplicationSettingsSchema.safeParse(parsed) if (versionSixResult.success) { this.settings = { ...versionSixResult.data, version: CURRENT_SETTINGS_VERSION, - modelDownloadSource: 'modelscope' + modelDownloadSource: 'modelscope', + magicNotesShowIncompleteTodoCount: true } return this.settings } @@ -157,7 +189,8 @@ export class ApplicationSettingsStore { ...versionFiveResult.data, version: CURRENT_SETTINGS_VERSION, updateSource: 'github', - modelDownloadSource: 'modelscope' + modelDownloadSource: 'modelscope', + magicNotesShowIncompleteTodoCount: true } return this.settings } @@ -169,6 +202,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + magicNotesShowIncompleteTodoCount: true, lastSeenReleaseNotesVersion: null } return this.settings @@ -181,6 +215,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + magicNotesShowIncompleteTodoCount: true, magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null } @@ -194,6 +229,7 @@ export class ApplicationSettingsStore { version: CURRENT_SETTINGS_VERSION, updateSource: 'github', modelDownloadSource: 'modelscope', + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null @@ -210,6 +246,7 @@ export class ApplicationSettingsStore { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined', lastSeenReleaseNotesVersion: null @@ -251,6 +288,8 @@ export class ApplicationSettingsStore { updateSource: stored.updateSource, modelDownloadSource: stored.modelDownloadSource, magicNotesEnabled: stored.magicNotesEnabled, + magicNotesShowIncompleteTodoCount: + stored.magicNotesShowIncompleteTodoCount, magicNoteCommentMode: stored.magicNoteCommentMode, magicNoteCommentFormat: stored.magicNoteCommentFormat, ...(this.warnings.length > 0 @@ -284,6 +323,8 @@ export class ApplicationSettingsStore { updateSource: next.updateSource, modelDownloadSource: next.modelDownloadSource, magicNotesEnabled: next.magicNotesEnabled, + magicNotesShowIncompleteTodoCount: + next.magicNotesShowIncompleteTodoCount, magicNoteCommentMode: next.magicNoteCommentMode, magicNoteCommentFormat: next.magicNoteCommentFormat } diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 4122c5fb..a41cb7dd 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -24,10 +24,17 @@ afterEach(async () => { ) }) -async function createDatabase(): Promise { +async function createDatabase( + options: { + onMagicTodosChanged?: () => void + } = {} +): Promise { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-assistant-')) temporaryDirectories.push(directory) - const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) + const database = new AssistantDatabase( + join(directory, 'assistant.sqlite'), + options + ) database.initialize('C:\\Workspace') return database } @@ -4064,6 +4071,60 @@ describe('AssistantDatabase', () => { database.close() }) + it('counts incomplete todos and publishes todo-changing writes', async () => { + const onMagicTodosChanged = vi.fn() + const database = await createDatabase({ onMagicTodosChanged }) + const note = database.createMagicNote({ title: '发布笔记' }) + + expect(database.getMagicTodoStatus()).toEqual({ + incompleteCount: 0 + }) + expect(onMagicTodosChanged).not.toHaveBeenCalled() + + database.createMagicNoteEntry({ + noteId: note.id, + content: { + version: 1, + ops: [ + { insert: '核对发布材料' }, + { insert: '\n', attributes: { list: 'unchecked' } }, + { insert: '上传构建产物' }, + { insert: '\n', attributes: { list: 'unchecked' } }, + { insert: '通知负责人' }, + { insert: '\n', attributes: { list: 'checked' } } + ] + }, + plainText: '核对发布材料\n上传构建产物\n通知负责人' + }) + + expect(database.getMagicTodoStatus()).toEqual({ + incompleteCount: 2 + }) + expect(onMagicTodosChanged).toHaveBeenCalledTimes(1) + + const todo = database + .listMagicTodos() + .find((candidate) => !candidate.completed)! + database.updateMagicTodo({ + todoId: todo.id, + completed: true, + expectedRevision: todo.revision + }) + + expect(database.getMagicTodoStatus()).toEqual({ + incompleteCount: 1 + }) + expect(onMagicTodosChanged).toHaveBeenCalledTimes(2) + + database.deleteMagicNote(note.id) + expect(database.getMagicTodoStatus()).toEqual({ + incompleteCount: 0 + }) + expect(onMagicTodosChanged).toHaveBeenCalledTimes(3) + + database.close() + }) + it('protects magic note records from stale revisions', async () => { const database = await createDatabase() const note = database.createMagicNote({ title: '并发笔记' }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 95ca112d..48db5d3e 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -63,6 +63,7 @@ import { type MagicNoteSearchResult, type MagicNoteSummary, type MagicTodoItem, + type MagicTodoStatus, type MagicTodoUpdateInput } from '../../shared/magic-notes-contracts' import type { ComputerControlAuditEvent } from '../computer-control/audit' @@ -967,7 +968,16 @@ export class AssistantDatabase { private channelEventWrites = 0 private channelOutboxWrites = 0 - constructor(private readonly databasePath: string) {} + constructor( + private readonly databasePath: string, + private readonly options: { + onMagicTodosChanged?: () => void + } = {} + ) {} + + private notifyMagicTodosChanged(): void { + this.options.onMagicTodosChanged?.() + } initialize(defaultRootPath: string): void { if (this.database) { @@ -2605,7 +2615,11 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } - return this.getMagicNote(id) + const detail = this.getMagicNote(id) + if (input.content) { + this.notifyMagicTodosChanged() + } + return detail } updateMagicNote(input: { @@ -2644,6 +2658,7 @@ export class AssistantDatabase { if (result.changes !== 1) { throw new Error('笔记不存在') } + this.notifyMagicTodosChanged() } createMagicNoteEntry(input: { @@ -2696,7 +2711,9 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } - return this.getMagicNote(input.noteId) + const detail = this.getMagicNote(input.noteId) + this.notifyMagicTodosChanged() + return detail } updateMagicNoteEntry(input: { @@ -2758,7 +2775,9 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } - return this.getMagicNote(existing.note_id) + const detail = this.getMagicNote(existing.note_id) + this.notifyMagicTodosChanged() + return detail } deleteMagicNoteEntry(entryId: string): MagicNoteDetail { @@ -2787,7 +2806,9 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } - return this.getMagicNote(existing.note_id) + const detail = this.getMagicNote(existing.note_id) + this.notifyMagicTodosChanged() + return detail } getMagicNoteEntry(entryId: string): MagicNoteEntry { @@ -2861,6 +2882,17 @@ export class AssistantDatabase { ).map(toMagicTodo) } + getMagicTodoStatus(): MagicTodoStatus { + const row = this.requireDatabase() + .prepare( + `SELECT COUNT(*) AS incomplete_count + FROM magic_todos + WHERE source = 'note' AND completed = 0` + ) + .get() as { incomplete_count: number } + return { incompleteCount: row.incomplete_count } + } + searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] { const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%` return ( @@ -2985,7 +3017,9 @@ export class AssistantDatabase { database.exec('ROLLBACK') throw error } - return this.getMagicTodo(input.todoId) + const todo = this.getMagicTodo(input.todoId) + this.notifyMagicTodosChanged() + return todo } saveMagicTodoAnalysis(input: { diff --git a/src/main/index.ts b/src/main/index.ts index d392bef1..46e7758e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -511,7 +511,22 @@ if (hasSingleInstanceLock) { | ReturnType | undefined const startupAssistantDatabase = new AssistantDatabase( - join(app.getPath('userData'), 'assistant.sqlite') + join(app.getPath('userData'), 'assistant.sqlite'), + { + onMagicTodosChanged: () => { + queueMicrotask(() => { + if ( + mainWindow && + !mainWindow.isDestroyed() && + !mainWindow.webContents.isDestroyed() + ) { + mainWindow.webContents.send( + ipcChannels.magicTodosStatusChanged + ) + } + }) + } + } ) assistantDatabase = startupAssistantDatabase const goodbuddyConfigService = new GoodBuddyConfigService( diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 58728341..ed508d64 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -5682,6 +5682,14 @@ export function registerIpcHandlers( } ) + registerHandler( + ipcChannels.magicTodosStatus, + (event) => { + assertTrustedSender(event, window) + return assistantDatabase.getMagicTodoStatus() + } + ) + registerHandler( ipcChannels.magicTodosUpdate, (event, input: unknown) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 6284b655..8d3b28e3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -107,6 +107,7 @@ import type { MagicNoteDetail, MagicNotesSnapshot, MagicTodoItem, + MagicTodoStatus, MagicTodoUpdateResult, MagicTodosSnapshot } from '../shared/magic-notes-contracts' @@ -1110,6 +1111,10 @@ const desktopApi: DesktopApi = { ipcRenderer.invoke( ipcChannels.magicTodosList ) as Promise, + getTodoStatus: () => + ipcRenderer.invoke( + ipcChannels.magicTodosStatus + ) as Promise, updateTodo: (input) => ipcRenderer.invoke( ipcChannels.magicTodosUpdate, @@ -1131,6 +1136,15 @@ const desktopApi: DesktopApi = { ipcChannels.magicNotesAnalysisEvent, handler ) + }, + onTodoStatusChanged: (listener) => { + const handler = (): void => listener() + ipcRenderer.on(ipcChannels.magicTodosStatusChanged, handler) + return () => + ipcRenderer.removeListener( + ipcChannels.magicTodosStatusChanged, + handler + ) } }, knowledge: { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 5722258c..114fe603 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -107,6 +107,7 @@ let fileSelectionProgressListener: >[0] | undefined let newConversationListener: (() => void) | undefined +let magicTodoStatusChangedListener: (() => void) | undefined let maximizedChangedListener: ((maximized: boolean) => void) | undefined let beforeQuitListener: (() => Promise) | undefined let conversationQueueDispatchListener: @@ -682,13 +683,20 @@ const api: DesktopApi = { throw new Error('not used') }), listTodos: vi.fn(async () => ({ todos: [] })), + getTodoStatus: vi.fn(async () => ({ incompleteCount: 0 })), updateTodo: vi.fn(async () => { throw new Error('not used') }), analyzeTodo: vi.fn(async () => { throw new Error('not used') }), - onAnalysisEvent: vi.fn(() => vi.fn()) + onAnalysisEvent: vi.fn(() => vi.fn()), + onTodoStatusChanged: vi.fn((listener) => { + magicTodoStatusChangedListener = listener + return () => { + magicTodoStatusChangedListener = undefined + } + }) }, knowledge: { getSnapshot: vi.fn(async () => ({ @@ -870,6 +878,18 @@ describe('App', () => { delete document.documentElement.dataset.theme document.documentElement.style.colorScheme = '' vi.clearAllMocks() + magicTodoStatusChangedListener = undefined + vi.mocked(api.magicNotes.getTodoStatus) + .mockReset() + .mockResolvedValue({ incompleteCount: 0 }) + vi.mocked(api.magicNotes.onTodoStatusChanged) + .mockReset() + .mockImplementation((listener) => { + magicTodoStatusChangedListener = listener + return () => { + magicTodoStatusChangedListener = undefined + } + }) vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]) vi.mocked(api.conversations.replace) .mockReset() @@ -1849,6 +1869,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -1942,6 +1963,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -1950,6 +1972,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -2044,6 +2067,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -2052,6 +2076,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -3887,6 +3912,7 @@ describe('App', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' } @@ -8617,6 +8643,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -8625,6 +8652,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -8659,6 +8687,91 @@ describe('App', () => { } }) + it('shows and refreshes the incomplete Magic Notes todo count', async () => { + vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ + incompleteCount: 4 + }) + api.updates = { + getSettings: vi.fn(async () => ({ + checkUpdatesOnStartup: false, + updateSource: 'github' as const, + modelDownloadSource: 'modelscope' as const, + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: true, + magicNoteCommentMode: 'immediate' as const, + magicNoteCommentFormat: 'combined' as const + })), + updateSettings: vi.fn(), + check: vi.fn(), + openReleasePage: vi.fn(async () => {}), + onResult: vi.fn(() => () => {}) + } + try { + render() + + expect( + await screen.findByLabelText('4 个未完成待办') + ).toHaveTextContent('4') + + vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ + incompleteCount: 0 + }) + await act(async () => { + magicTodoStatusChangedListener?.() + }) + await waitFor(() => + expect( + screen.queryByLabelText('4 个未完成待办') + ).not.toBeInTheDocument() + ) + + vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValueOnce({ + incompleteCount: 125 + }) + await act(async () => { + magicTodoStatusChangedListener?.() + }) + expect( + await screen.findByLabelText('125 个未完成待办') + ).toHaveTextContent('99+') + } finally { + delete api.updates + } + }) + + it('hides the todo count when its Magic Notes setting is off', async () => { + vi.mocked(api.magicNotes.getTodoStatus).mockResolvedValue({ + incompleteCount: 4 + }) + api.updates = { + getSettings: vi.fn(async () => ({ + checkUpdatesOnStartup: false, + updateSource: 'github' as const, + modelDownloadSource: 'modelscope' as const, + magicNotesEnabled: true, + magicNotesShowIncompleteTodoCount: false, + magicNoteCommentMode: 'immediate' as const, + magicNoteCommentFormat: 'combined' as const + })), + updateSettings: vi.fn(), + check: vi.fn(), + openReleasePage: vi.fn(async () => {}), + onResult: vi.fn(() => () => {}) + } + try { + render() + + expect( + await screen.findByRole('button', { name: '魔法笔记' }) + ).toBeInTheDocument() + expect( + screen.queryByLabelText('4 个未完成待办') + ).not.toBeInTheDocument() + expect(api.magicNotes.getTodoStatus).not.toHaveBeenCalled() + } finally { + delete api.updates + } + }) it('hides Magic Notes when the platform feature is disabled', async () => { api.updates = { getSettings: vi.fn(async () => ({ @@ -8666,6 +8779,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -8674,6 +8788,7 @@ describe('App', () => { updateSource: 'github' as const, modelDownloadSource: 'modelscope' as const, magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate' as const, magicNoteCommentFormat: 'combined' as const })), @@ -8707,6 +8822,7 @@ describe('App', () => { updateSource: 'github', modelDownloadSource: 'modelscope', magicNotesEnabled: false, + magicNotesShowIncompleteTodoCount: true, magicNoteCommentMode: 'immediate', magicNoteCommentFormat: 'combined' } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ab790e0d..67d3b9b9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2313,6 +2313,12 @@ function App(): React.JSX.Element { const [settingsInitialChannel, setSettingsInitialChannel] = useState() const [magicNotesEnabled, setMagicNotesEnabled] = useState(false) + const [ + magicNotesShowIncompleteTodoCount, + setMagicNotesShowIncompleteTodoCount + ] = useState(true) + const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = + useState(0) const [searchQuery, setSearchQuery] = useState('') const deferredSearchQuery = useDeferredValue(searchQuery) const [searchConversationSnapshot, setSearchConversationSnapshot] = @@ -2713,6 +2719,9 @@ function App(): React.JSX.Element { .getSettings() .then(async (settings) => { setMagicNotesEnabled(settings.magicNotesEnabled) + setMagicNotesShowIncompleteTodoCount( + settings.magicNotesShowIncompleteTodoCount + ) if (!settings.magicNotesEnabled) { setView((current) => current === 'magic-notes' ? 'chat' : current @@ -2736,6 +2745,42 @@ function App(): React.JSX.Element { .catch(() => undefined) }, [i18n, setView]) + useEffect(() => { + const magicNotes = window.goodbuddy.magicNotes + if ( + !magicNotesEnabled || + !magicNotesShowIncompleteTodoCount + ) { + return + } + let active = true + let requestId = 0 + const refresh = async (): Promise => { + const currentRequestId = ++requestId + try { + const status = await magicNotes.getTodoStatus() + if (active && requestId === currentRequestId) { + setIncompleteMagicTodoCount(status.incompleteCount) + } + } catch { + if (active && requestId === currentRequestId) { + setIncompleteMagicTodoCount(0) + } + } + } + const removeListener = magicNotes.onTodoStatusChanged(() => { + void refresh() + }) + void refresh() + return () => { + active = false + removeListener() + } + }, [ + magicNotesEnabled, + magicNotesShowIncompleteTodoCount + ]) + useEffect(() => { const releaseNotesApi = window.goodbuddy.releaseNotes if (!releaseNotesApi || startupReleaseNotesStartedRef.current) { @@ -7489,6 +7534,19 @@ function App(): React.JSX.Element { >