diff --git a/package-lock.json b/package-lock.json index 66b8e03..ce6292b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.8.6", + "version": "0.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.8.6", + "version": "0.8.8", "license": "UNLICENSED", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 2daacc3..c41f33e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.8.6", + "version": "0.8.8", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index 08b4b1a..6746919 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { ApplicationSettingsStore, applicationSettingsSchema, + applicationSettingsUpdateSchema, defaultApplicationSettings } from './application-settings-store' @@ -51,18 +52,26 @@ describe('ApplicationSettingsStore', () => { await expect(readdir(directory)).resolves.toEqual([]) }) - it('persists only the versioned startup update preference', async () => { + it('persists versioned application preferences', async () => { const { directory, filePath, store } = await createStore() await expect( - store.update({ checkUpdatesOnStartup: false }) - ).resolves.toEqual({ checkUpdatesOnStartup: false }) + store.update({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) + ).resolves.toEqual({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) await expect(store.get()).resolves.toEqual({ - checkUpdatesOnStartup: false + checkUpdatesOnStartup: false, + magicNotesEnabled: false }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 1, - checkUpdatesOnStartup: false + version: 2, + checkUpdatesOnStartup: false, + magicNotesEnabled: false }) expect( (await readdir(directory)).filter((name) => name.endsWith('.tmp')) @@ -73,38 +82,103 @@ describe('ApplicationSettingsStore', () => { const { directory } = await createStore() const filePath = join(directory, 'nested', 'application-settings.json') const store = new ApplicationSettingsStore(filePath) - await store.update({ checkUpdatesOnStartup: false }) + await store.update({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) await expect( new ApplicationSettingsStore(filePath).get() - ).resolves.toEqual({ checkUpdatesOnStartup: false }) + ).resolves.toEqual({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) }) - it('strictly rejects unknown, missing, and mistyped input', async () => { - const { directory, store } = await createStore() + it.each([1, 2])( + 'loads version %s settings missing the field with Magic Notes disabled', + async (version) => { + const { filePath, store } = await createStore() + await writeFile( + filePath, + JSON.stringify({ + version, + checkUpdatesOnStartup: false + }), + 'utf8' + ) + + await expect(store.get()).resolves.toEqual({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) + } + ) + + it('strictly rejects incomplete full settings', () => { for (const input of [ {}, { checkUpdatesOnStartup: 'true' }, - { checkUpdatesOnStartup: true, anotherSetting: true }, + { + checkUpdatesOnStartup: true, + magicNotesEnabled: true, + anotherSetting: true + }, + { checkUpdatesOnStartup: true }, null ]) { expect(applicationSettingsSchema.safeParse(input).success).toBe( false ) + } + }) + + it('strictly rejects empty, unknown, and mistyped updates', async () => { + const { directory, store } = await createStore() + for (const input of [ + {}, + { checkUpdatesOnStartup: 'true' }, + { anotherSetting: true }, + null + ]) { + expect( + applicationSettingsUpdateSchema.safeParse(input).success + ).toBe(false) await expect(store.update(input)).rejects.toThrow() } await expect(readdir(directory)).resolves.toEqual([]) }) + it('merges partial updates without overwriting other settings', async () => { + const { store } = await createStore() + + await store.update({ magicNotesEnabled: true }) + await expect( + store.update({ checkUpdatesOnStartup: false }) + ).resolves.toEqual({ + checkUpdatesOnStartup: false, + magicNotesEnabled: true + }) + }) + it.each([ '{not-json', - JSON.stringify({ version: 2, checkUpdatesOnStartup: false }), JSON.stringify({ - version: 1, + version: 3, checkUpdatesOnStartup: false, + magicNotesEnabled: false + }), + JSON.stringify({ + version: 2, + checkUpdatesOnStartup: false, + magicNotesEnabled: true, injected: true }), - JSON.stringify({ version: 1, checkUpdatesOnStartup: 'false' }) + JSON.stringify({ + version: 2, + checkUpdatesOnStartup: 'false', + magicNotesEnabled: true + }) ])('isolates corrupt persisted data and restores defaults', async (data) => { const { directory, filePath, store } = await createStore() await writeFile(filePath, data, 'utf8') @@ -142,28 +216,48 @@ describe('ApplicationSettingsStore', () => { const { filePath, store } = await createStore() await Promise.all([ - store.update({ checkUpdatesOnStartup: false }), - store.update({ checkUpdatesOnStartup: true }), - store.update({ checkUpdatesOnStartup: false }) + store.update({ + checkUpdatesOnStartup: false, + magicNotesEnabled: true + }), + store.update({ + checkUpdatesOnStartup: true, + magicNotesEnabled: false + }), + store.update({ + checkUpdatesOnStartup: false, + magicNotesEnabled: false + }) ]) await expect(store.get()).resolves.toEqual({ - checkUpdatesOnStartup: false + checkUpdatesOnStartup: false, + magicNotesEnabled: false }) expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ - version: 1, - checkUpdatesOnStartup: false + version: 2, + checkUpdatesOnStartup: false, + magicNotesEnabled: false }) }) it('continues accepting updates after a validation failure', async () => { const { store } = await createStore() await expect( - store.update({ checkUpdatesOnStartup: 'invalid' }) + store.update({ + checkUpdatesOnStartup: 'invalid', + magicNotesEnabled: true + }) ).rejects.toThrow() await expect( - store.update({ checkUpdatesOnStartup: false }) - ).resolves.toEqual({ checkUpdatesOnStartup: false }) + store.update({ + checkUpdatesOnStartup: false, + magicNotesEnabled: true + }) + ).resolves.toEqual({ + checkUpdatesOnStartup: false, + magicNotesEnabled: true + }) }) }) diff --git a/src/main/application-settings-store.ts b/src/main/application-settings-store.ts index 1f97c30..928079c 100644 --- a/src/main/application-settings-store.ts +++ b/src/main/application-settings-store.ts @@ -10,12 +10,23 @@ import { dirname } from 'node:path' import { z } from 'zod' import { applicationSettingsSchema, + applicationSettingsUpdateSchema, type ApplicationSettings } from '../shared/application-settings-contracts' -export { applicationSettingsSchema } from '../shared/application-settings-contracts' +export { + applicationSettingsSchema, + applicationSettingsUpdateSchema +} from '../shared/application-settings-contracts' export type { ApplicationSettings } from '../shared/application-settings-contracts' -const CURRENT_SETTINGS_VERSION = 1 +const CURRENT_SETTINGS_VERSION = 2 + +const legacyStoredApplicationSettingsSchema = z + .object({ + version: z.union([z.literal(1), z.literal(2)]), + checkUpdatesOnStartup: z.boolean() + }) + .strict() const storedApplicationSettingsSchema = applicationSettingsSchema .extend({ @@ -28,7 +39,8 @@ type StoredApplicationSettings = z.infer< > export const defaultApplicationSettings: ApplicationSettings = { - checkUpdatesOnStartup: true + checkUpdatesOnStartup: true, + magicNotesEnabled: false } function isMissingFile(error: unknown): boolean { @@ -81,6 +93,17 @@ export class ApplicationSettingsStore { } const result = storedApplicationSettingsSchema.safeParse(parsed) if (!result.success) { + const legacyResult = + legacyStoredApplicationSettingsSchema.safeParse(parsed) + if (legacyResult.success) { + this.settings = { + version: CURRENT_SETTINGS_VERSION, + checkUpdatesOnStartup: + legacyResult.data.checkUpdatesOnStartup, + magicNotesEnabled: false + } + return this.settings + } await this.isolateCorruptFile() this.settings = { version: CURRENT_SETTINGS_VERSION, @@ -106,16 +129,19 @@ export class ApplicationSettingsStore { async get(): Promise { const stored = await this.loadStored() return { - checkUpdatesOnStartup: stored.checkUpdatesOnStartup + checkUpdatesOnStartup: stored.checkUpdatesOnStartup, + magicNotesEnabled: stored.magicNotesEnabled } } update(input: unknown): Promise { const operation = this.updateQueue.then(async () => { - const settings = applicationSettingsSchema.parse(input) + const updates = applicationSettingsUpdateSchema.parse(input) + const current = await this.loadStored() const next: StoredApplicationSettings = { - version: CURRENT_SETTINGS_VERSION, - ...settings + ...current, + ...updates, + version: CURRENT_SETTINGS_VERSION } await mkdir(dirname(this.filePath), { recursive: true }) const temporaryPath = @@ -137,7 +163,8 @@ export class ApplicationSettingsStore { } this.settings = next return { - checkUpdatesOnStartup: next.checkUpdatesOnStartup + checkUpdatesOnStartup: next.checkUpdatesOnStartup, + magicNotesEnabled: next.magicNotesEnabled } }) this.updateQueue = operation.then( diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5244579..c907f95 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -63,7 +63,7 @@ import { dingTalkChannelSettingsInputSchema, weComChannelSettingsInputSchema } from '../shared/channel-settings-contracts' -import { applicationSettingsSchema } from '../shared/application-settings-contracts' +import { applicationSettingsUpdateSchema } from '../shared/application-settings-contracts' import { speechModelActionInputSchema, speechModelSelectionInputSchema @@ -834,6 +834,7 @@ export function registerIpcHandlers( channel: keyof typeof projectChannelLabels channelLabel: string senderDisplay: string + projectId: string projectName: string rootPath: string conversationId: string @@ -1027,6 +1028,8 @@ export function registerIpcHandlers( publishRemoteActivity({ requestId, conversationId: remoteContext.conversationId, + projectId: remoteContext.projectId, + projectName: remoteContext.projectName, channel: remoteContext.channel, kind: 'tool', callId: taskEvent.callId, @@ -1450,6 +1453,8 @@ export function registerIpcHandlers( publishRemoteActivity({ requestId: remoteTaskId, conversationId: remoteConversation.id, + projectId: project.id, + projectName: project.name, channel, kind: 'request', title: `${channelLabel} · ${senderDisplay}`, @@ -1490,6 +1495,8 @@ export function registerIpcHandlers( publishRemoteActivity({ requestId: remoteTaskId, conversationId: remoteConversation.id, + projectId: project.id, + projectName: project.name, channel, kind: 'result', title: `${channelLabel}远程执行不可用`, @@ -1522,6 +1529,8 @@ export function registerIpcHandlers( publishRemoteActivity({ requestId: remoteTaskId, conversationId: remoteConversation.id, + projectId: project.id, + projectName: project.name, channel, kind: 'result', title: `${channelLabel}远程执行不可用`, @@ -1554,6 +1563,7 @@ export function registerIpcHandlers( channel, channelLabel, senderDisplay, + projectId: project.id, projectName: project.name, rootPath: project.rootPath, conversationId: remoteConversation.id, @@ -1604,6 +1614,8 @@ export function registerIpcHandlers( publishRemoteActivity({ requestId: remoteTaskId, conversationId: remoteConversation.id, + projectId: project.id, + projectName: project.name, channel, kind: 'result', title: @@ -2409,7 +2421,7 @@ export function registerIpcHandlers( throw new Error('应用设置服务不可用') } return applicationSettingsStore.update( - applicationSettingsSchema.parse(input) + applicationSettingsUpdateSchema.parse(input) ) } ) diff --git a/src/main/window.ts b/src/main/window.ts index 58058b2..6a77485 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -60,8 +60,8 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow { const window = new BrowserWindow({ width: 1180, height: 760, - minWidth: 920, - minHeight: 620, + minWidth: 680, + minHeight: 560, show: false, frame: false, ...(usableIcon ? { icon: usableIcon } : {}), diff --git a/src/preload/index.ts b/src/preload/index.ts index eb936a2..8da1379 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -61,6 +61,7 @@ import type { } from '../shared/channel-settings-contracts' import type { ApplicationSettings, + ApplicationSettingsUpdate, VersionCheckResult } from '../shared/application-settings-contracts' import type { @@ -294,7 +295,7 @@ const desktopApi: DesktopApi = { ipcRenderer.invoke( ipcChannels.applicationSettingsGet ) as Promise, - updateSettings: (input: ApplicationSettings) => + updateSettings: (input: ApplicationSettingsUpdate) => ipcRenderer.invoke( ipcChannels.applicationSettingsUpdate, input diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index 624e9c7..14fd9b8 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -21,6 +21,7 @@ function makeRecord( id: `activity-${index}`, conversationId: `conversation-${index}`, requestId: `request-${index}`, + scope: { kind: 'global' }, kind: 'tool', title: `活动 ${index}`, detail: `详情 ${index}`, @@ -150,6 +151,26 @@ describe('ActivityPanel', () => { ).toBeDisabled() }) + it('clears a filter that has no matching activity', () => { + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: '进行中' })) + expect(screen.getByText('没有匹配的活动')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '清除筛选' })) + + expect( + screen.getByRole('button', { name: '全部' }) + ).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByText('对话:活动 1')).toBeInTheDocument() + }) + it('labels Subagent activity as child expert work', () => { render( { expect(groups[0]!.querySelectorAll('article')).toHaveLength(2) }) + it('shows immutable scope snapshots on groups and records', () => { + const projectRecord: ActivityRecord = { + ...makeRecord(1), + scope: { + kind: 'project', + projectId: 'project-1', + projectName: '项目甲' + } + } + const unavailableRecord: ActivityRecord = { + ...makeRecord(2), + scope: { kind: 'unavailable' } + } + render( + + ) + + expect(screen.getAllByText('项目:项目甲')).toHaveLength(2) + expect(screen.getAllByText('范围不可用')).toHaveLength(2) + }) + it('uses the shared page hierarchy and explicit global scope', () => { render( record.createdAt)), status } @@ -361,6 +380,17 @@ export function ActivityPanel({ {filteredRecords.length === 0 ? ( setFilter('all')} + type="button" + > + 清除筛选 + + ) + } description={emptyMessage(filter)} icon={} level="section" @@ -379,6 +409,9 @@ export function ActivityPanel({ 对话:{group.title} {group.records.length} 条活动 + +

{record.title}

{record.detail.length > 0 &&

{record.detail}

} + {magicNotesEnabled && ( + + )} - @@ -4157,6 +4370,7 @@ function App(): React.JSX.Element { className="conversation-more" onClick={() => { setRenamingConversationId('') + setConfirmingConversationId('') setConversationActionsId((current) => current === conversation.id ? '' : conversation.id ) @@ -4177,16 +4391,6 @@ function App(): React.JSX.Element { > - {!conversation.remote && ( - - )} {conversationActionsId === conversation.id && (
导出 Markdown + {!conversation.remote && ( +
)} {!conversation.remote && @@ -4304,16 +4532,29 @@ function App(): React.JSX.Element { + {sidebarOpen && ( + {view === 'chat' && ( <> @@ -5496,7 +5737,7 @@ function App(): React.JSX.Element { )} - ) : view === 'magic-notes' ? ( + ) : view === 'magic-notes' && magicNotesEnabled ? ( { @@ -5629,8 +5871,11 @@ function App(): React.JSX.Element { window.goodbuddy.knowledge.retrySource(sourceId) ) } + onRetryLoad={retryKnowledgeLoad} onSelectLibrary={(libraryId) => { - void refreshKnowledge(libraryId) + void refreshKnowledge(libraryId).catch(() => { + // KnowledgeWorkspace renders the recoverable load error. + }) }} onSyncSource={(sourceId) => runKnowledgeSourceAction(() => @@ -5660,9 +5905,12 @@ function App(): React.JSX.Element { configs={assistantHeartbeats} currentProjectName={activeProject?.name} entries={heartbeatEntries} + loadError={heartbeatLoadError} + loading={heartbeatLoading} memories={assistantMemories} onCreate={createHeartbeat} - onRefresh={refreshHeartbeatCenter} + onRefresh={retryHeartbeatLoad} + onRetryLoad={retryHeartbeatLoad} onRemove={removeHeartbeat} onRunNow={runHeartbeat} onSetMemoryStatus={setMemoryStatus} @@ -5694,6 +5942,9 @@ function App(): React.JSX.Element { setSelectedExpertId('') } }} + onMagicNotesEnabledChange={(enabled) => { + setMagicNotesEnabled(enabled) + }} onRemoveHeartbeat={removeHeartbeat} onRunHeartbeat={runHeartbeat} onNotify={notify} diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 3d47f5d..60068be 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -110,6 +110,7 @@ function createProps( onRemove: vi.fn(async () => {}), onRunNow: vi.fn(async () => {}), onRefresh: vi.fn(async () => {}), + onRetryLoad: vi.fn(async () => {}), onSetMemoryStatus: vi.fn(async () => {}), onSetTaskStatus: vi.fn(async () => {}), onUseFollowUpTask: vi.fn(), @@ -264,4 +265,56 @@ describe('HeartbeatCenter', () => { screen.getByRole('button', { name: '启用智能心跳' }) ).toBeInTheDocument() }) + + it('keeps loading and load failure distinct from first-time empty state', () => { + const emptyProps = { + configs: [], + runs: [], + entries: [], + memories: [], + tasks: [] + } + const { rerender } = render( + + ) + + expect(screen.getByText('正在加载智能心跳')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '配置智能心跳' }) + ).not.toBeInTheDocument() + + rerender( + + ) + expect(screen.getByText('智能心跳加载失败')).toBeInTheDocument() + expect(screen.getByText('数据库暂时不可用')).toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '配置智能心跳' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument() + }) + + it('keeps existing heartbeat data visible when refresh fails', () => { + render( + + ) + + expect(screen.getByText('智能心跳刷新失败')).toBeInTheDocument() + expect(screen.getByText(config.name)).toBeInTheDocument() + expect( + screen.getByText(entry.summary) + ).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/HeartbeatCenter.tsx b/src/renderer/src/HeartbeatCenter.tsx index 374397e..b200a51 100644 --- a/src/renderer/src/HeartbeatCenter.tsx +++ b/src/renderer/src/HeartbeatCenter.tsx @@ -54,6 +54,9 @@ export type HeartbeatCenterProps = { ) => Promise onUseFollowUpTask: (task: AssistantTask) => void currentProjectName?: string + loading?: boolean + loadError?: string + onRetryLoad: () => void | Promise } const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', { @@ -143,7 +146,10 @@ export function HeartbeatCenter({ onSetMemoryStatus, onSetTaskStatus, onUseFollowUpTask, - currentProjectName = '当前项目' + currentProjectName = '当前项目', + loading = false, + loadError, + onRetryLoad }: HeartbeatCenterProps): React.JSX.Element { const [tab, setTab] = useState('overview') const [pendingAction, setPendingAction] = useState() @@ -226,6 +232,10 @@ export function HeartbeatCenter({ entry.followUpTaskIds.length ) ) + const hasHeartbeatData = + configs.length > 0 || runs.length > 0 || entries.length > 0 + const initialLoadBlocked = + !hasHeartbeatData && (loading || loadError !== undefined) const runAction = async ( actionId: string, @@ -269,11 +279,12 @@ export function HeartbeatCenter({ > + initialLoadBlocked ? undefined : ( + <> )} - + + ) } description="定期回顾经历、沉淀记忆、发现问题,并把每次变化转化为可处理的成长建议。" eyebrow="SMART HEARTBEAT" @@ -321,6 +334,49 @@ export function HeartbeatCenter({

)} + {loading && !hasHeartbeatData ? ( + } + level="page" + title="正在加载智能心跳" + /> + ) : loadError && !hasHeartbeatData ? ( + void onRetryLoad()} + type="button" + > +