feat: improve desktop reliability and customization
Address reliability and consistency gaps across Agent Runtimes, persistence, settings, Knowledge, Magic Notes, Smart Heartbeat, and the download site. Runtime processes now have bounded lifecycle cleanup and atomic configuration rollback, while model packages and persisted mutations recover safely. Add a configurable global shortcut, protect unsaved work, improve modal and keyboard behavior, localize the built-in project without rewriting stored data, and lazy-load heavy renderer routes under enforced bundle budgets. Align project forms and disabled controls with shared typography and interaction states, and strengthen website release metadata validation and navigation accessibility. Release note: 修复 Runtime、设置、知识库、魔法笔记与智能心跳中的可靠性和交互一致性问题;新增可配置全局快捷键,改进无障碍与加载性能,并强化官网下载校验。
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AssistantProject } from './assistant-contracts'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
projectCreateSchema,
|
||||
projectUpdateSchema
|
||||
} from './assistant-contracts'
|
||||
|
||||
const untouchedProject: AssistantProject = {
|
||||
id: '00000000-0000-4000-8000-000000000101',
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'C:\\Workspace',
|
||||
defaultWorkMode: 'ask',
|
||||
kind: 'user',
|
||||
builtInDefault: true,
|
||||
status: 'active',
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('isUntouchedBuiltInDefaultProject', () => {
|
||||
it('requires the persistent marker and exact seed text', () => {
|
||||
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
|
||||
|
||||
const changedProjects: AssistantProject[] = [
|
||||
{ ...untouchedProject, name: '默认项目 ' },
|
||||
{ ...untouchedProject, description: 'GoodBuddy 默认工作区 ' },
|
||||
{ ...untouchedProject, builtInDefault: false },
|
||||
{ ...untouchedProject, builtInDefault: undefined }
|
||||
]
|
||||
|
||||
expect(
|
||||
changedProjects.every(
|
||||
(project) => !isUntouchedBuiltInDefaultProject(project)
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not infer identity for an independently created identical project', () => {
|
||||
const independentProject = { ...untouchedProject }
|
||||
delete independentProject.builtInDefault
|
||||
expect(
|
||||
isUntouchedBuiltInDefaultProject(independentProject)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps identity across unrelated project setting changes', () => {
|
||||
expect(
|
||||
isUntouchedBuiltInDefaultProject({
|
||||
...untouchedProject,
|
||||
rootPath: 'D:\\Moved',
|
||||
defaultWorkMode: 'execute',
|
||||
runtimeSelection: { provider: 'continue' },
|
||||
status: 'archived',
|
||||
updatedAt: '2026-08-01T00:00:01.000Z'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not accept the read-only marker in project inputs', () => {
|
||||
const input = {
|
||||
name: builtInDefaultProjectSeedName,
|
||||
description: builtInDefaultProjectSeedDescription,
|
||||
rootPath: 'C:\\Workspace',
|
||||
defaultWorkMode: 'ask',
|
||||
builtInDefault: true
|
||||
}
|
||||
expect(projectCreateSchema.safeParse(input).success).toBe(false)
|
||||
expect(projectUpdateSchema.safeParse(input).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -411,11 +411,26 @@ export type AssistantProject = ProjectCreateInput & {
|
||||
id: string
|
||||
kind: ProjectKind
|
||||
channel?: ProjectChannel
|
||||
readonly builtInDefault?: boolean
|
||||
status: 'active' | 'archived'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const builtInDefaultProjectSeedName = '默认项目'
|
||||
export const builtInDefaultProjectSeedDescription =
|
||||
'GoodBuddy 默认工作区'
|
||||
|
||||
export function isUntouchedBuiltInDefaultProject(
|
||||
project: AssistantProject
|
||||
): boolean {
|
||||
return (
|
||||
project.builtInDefault === true &&
|
||||
project.name === builtInDefaultProjectSeedName &&
|
||||
project.description === builtInDefaultProjectSeedDescription
|
||||
)
|
||||
}
|
||||
|
||||
export type WorkspaceChanges = {
|
||||
rootPath: string
|
||||
available: boolean
|
||||
|
||||
@@ -79,6 +79,12 @@ import type {
|
||||
ModelDownloadSource,
|
||||
VersionCheckResult
|
||||
} from './application-settings-contracts'
|
||||
import type {
|
||||
GlobalShortcutRegistrationStatus,
|
||||
GlobalShortcutSettings,
|
||||
GlobalShortcutSettingsSnapshot,
|
||||
GlobalShortcutSettingsUpdateResult
|
||||
} from './shortcut'
|
||||
import type { ReleaseNotesSnapshot } from './release-notes-contracts'
|
||||
import type {
|
||||
SpeechModelSnapshot,
|
||||
@@ -93,6 +99,7 @@ import type {
|
||||
import type {
|
||||
DocumentOcrAssets,
|
||||
DocumentOcrFailure,
|
||||
DocumentOcrModelProgressSnapshot,
|
||||
DocumentOcrRequest,
|
||||
DocumentOcrResult,
|
||||
DocumentParsingDiagnostic,
|
||||
@@ -1128,6 +1135,7 @@ export type AppInfo = {
|
||||
platform: string
|
||||
arch: string
|
||||
shortcut: string
|
||||
shortcutStatus?: GlobalShortcutRegistrationStatus
|
||||
}
|
||||
|
||||
export const browserLiveStateSchema = z
|
||||
@@ -1401,6 +1409,12 @@ export type DesktopApi = {
|
||||
listener: (result: VersionCheckResult) => void
|
||||
) => () => void
|
||||
}
|
||||
shortcuts?: {
|
||||
getSettings: () => Promise<GlobalShortcutSettingsSnapshot>
|
||||
updateSettings: (
|
||||
input: GlobalShortcutSettings
|
||||
) => Promise<GlobalShortcutSettingsUpdateResult>
|
||||
}
|
||||
releaseNotes?: {
|
||||
getPending: () => Promise<ReleaseNotesSnapshot>
|
||||
acknowledge: (version: string) => Promise<void>
|
||||
@@ -1435,6 +1449,7 @@ export type DesktopApi = {
|
||||
}
|
||||
documentParsing?: {
|
||||
getSnapshot: () => Promise<DocumentParsingSnapshot>
|
||||
getOcrModelProgress: () => Promise<DocumentOcrModelProgressSnapshot>
|
||||
update: (
|
||||
input: DocumentParsingSettings
|
||||
) => Promise<DocumentParsingSnapshot>
|
||||
|
||||
@@ -228,6 +228,12 @@ export const documentOcrModelSnapshotSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentOcrModelProgressSnapshotSchema = z
|
||||
.object({
|
||||
operations: z.array(documentOcrModelOperationSchema).max(8)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const documentOcrModelActionInputSchema = z
|
||||
.object({
|
||||
modelId: localOcrModelIdSchema
|
||||
@@ -445,6 +451,9 @@ export type DocumentOcrModelOperation = z.infer<
|
||||
export type DocumentOcrModelSnapshot = z.infer<
|
||||
typeof documentOcrModelSnapshotSchema
|
||||
>
|
||||
export type DocumentOcrModelProgressSnapshot = z.infer<
|
||||
typeof documentOcrModelProgressSnapshotSchema
|
||||
>
|
||||
export type DocumentParsingDiagnostic = z.infer<
|
||||
typeof documentParsingDiagnosticSchema
|
||||
>
|
||||
|
||||
@@ -47,6 +47,8 @@ export const ipcChannels = {
|
||||
remoteChannelActivity: 'channels:remote-activity',
|
||||
applicationSettingsGet: 'settings:application:get',
|
||||
applicationSettingsUpdate: 'settings:application:update',
|
||||
shortcutSettingsGet: 'settings:shortcut:get',
|
||||
shortcutSettingsUpdate: 'settings:shortcut:update',
|
||||
versionCheck: 'application:update:check',
|
||||
versionOpenReleasePage: 'application:update:open-release-page',
|
||||
versionCheckResult: 'application:update:result',
|
||||
@@ -66,6 +68,8 @@ export const ipcChannels = {
|
||||
embeddingSettingsGet: 'settings:embedding:get',
|
||||
embeddingDiagnose: 'settings:embedding:diagnose',
|
||||
documentParsingGet: 'settings:document-parsing:get',
|
||||
documentOcrModelsProgress:
|
||||
'settings:document-ocr-models:progress',
|
||||
documentParsingUpdate: 'settings:document-parsing:update',
|
||||
documentParsingTest: 'settings:document-parsing:test',
|
||||
documentOcrModelsInstall: 'settings:document-ocr-models:install',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatShortcutForDisplay } from './shortcut'
|
||||
import {
|
||||
areShortcutAcceleratorsEquivalent,
|
||||
canonicalizeShortcutAccelerator,
|
||||
formatShortcutForDisplay,
|
||||
globalShortcutSettingsSchema
|
||||
} from './shortcut'
|
||||
|
||||
describe('formatShortcutForDisplay', () => {
|
||||
it.each([
|
||||
@@ -14,4 +19,42 @@ describe('formatShortcutForDisplay', () => {
|
||||
)
|
||||
).toBe(expected)
|
||||
})
|
||||
|
||||
it('canonicalizes recorder and typed accelerator aliases', () => {
|
||||
expect(canonicalizeShortcutAccelerator('ctrl + shift + space')).toBe(
|
||||
'Control+Shift+Space'
|
||||
)
|
||||
expect(
|
||||
globalShortcutSettingsSchema.parse({
|
||||
enabled: true,
|
||||
accelerator: 'cmdorctrl+alt+k'
|
||||
})
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
accelerator: 'CommandOrControl+Alt+K'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['Space', 'Control+Shift', 'Control+A+B', 'Control+Nope'])(
|
||||
'rejects invalid accelerator %s',
|
||||
(accelerator) => {
|
||||
expect(() =>
|
||||
canonicalizeShortcutAccelerator(accelerator)
|
||||
).toThrow()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['win32', 'CmdOrCtrl+Shift+K', 'Control+Shift+K', true],
|
||||
['linux', 'Control+Shift+K', 'CommandOrControl+Shift+K', true],
|
||||
['darwin', 'CmdOrCtrl+Shift+K', 'Command+Shift+K', true],
|
||||
['darwin', 'Control+Shift+K', 'Command+Shift+K', false]
|
||||
])(
|
||||
'compares physical accelerator aliases on %s',
|
||||
(platform, left, right, expected) => {
|
||||
expect(
|
||||
areShortcutAcceleratorsEquivalent(left, right, platform)
|
||||
).toBe(expected)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,178 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const defaultGlobalShortcutSettings = {
|
||||
enabled: true,
|
||||
accelerator: 'CommandOrControl+Shift+Space'
|
||||
} as const
|
||||
|
||||
const modifierAliases = new Map<string, string>([
|
||||
['cmdorctrl', 'CommandOrControl'],
|
||||
['commandorcontrol', 'CommandOrControl'],
|
||||
['cmd', 'Command'],
|
||||
['command', 'Command'],
|
||||
['control', 'Control'],
|
||||
['ctrl', 'Control'],
|
||||
['option', 'Alt'],
|
||||
['alt', 'Alt'],
|
||||
['altgr', 'AltGr'],
|
||||
['shift', 'Shift'],
|
||||
['super', 'Super'],
|
||||
['meta', 'Super']
|
||||
])
|
||||
|
||||
const keyAliases = new Map<string, string>([
|
||||
['space', 'Space'],
|
||||
['spacebar', 'Space'],
|
||||
['esc', 'Escape'],
|
||||
['escape', 'Escape'],
|
||||
['return', 'Enter'],
|
||||
['enter', 'Enter'],
|
||||
['backspace', 'Backspace'],
|
||||
['delete', 'Delete'],
|
||||
['del', 'Delete'],
|
||||
['insert', 'Insert'],
|
||||
['home', 'Home'],
|
||||
['end', 'End'],
|
||||
['pageup', 'PageUp'],
|
||||
['pagedown', 'PageDown'],
|
||||
['tab', 'Tab'],
|
||||
['up', 'Up'],
|
||||
['arrowup', 'Up'],
|
||||
['down', 'Down'],
|
||||
['arrowdown', 'Down'],
|
||||
['left', 'Left'],
|
||||
['arrowleft', 'Left'],
|
||||
['right', 'Right'],
|
||||
['arrowright', 'Right']
|
||||
])
|
||||
|
||||
export function canonicalizeShortcutAccelerator(
|
||||
accelerator: string
|
||||
): string {
|
||||
const parts = accelerator
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
if (parts.length < 2 || parts.length > 5) {
|
||||
throw new Error('Shortcut must contain modifiers and one key')
|
||||
}
|
||||
const modifiers: string[] = []
|
||||
let key: string | undefined
|
||||
for (const part of parts) {
|
||||
const normalized = part.toLowerCase()
|
||||
const modifier = modifierAliases.get(normalized)
|
||||
if (modifier) {
|
||||
if (modifiers.includes(modifier)) {
|
||||
throw new Error('Shortcut contains a duplicate modifier')
|
||||
}
|
||||
modifiers.push(modifier)
|
||||
continue
|
||||
}
|
||||
if (key) {
|
||||
throw new Error('Shortcut must contain exactly one non-modifier key')
|
||||
}
|
||||
const aliasedKey = keyAliases.get(normalized)
|
||||
if (aliasedKey) {
|
||||
key = aliasedKey
|
||||
continue
|
||||
}
|
||||
if (/^[a-z0-9]$/iu.test(part)) {
|
||||
key = part.toUpperCase()
|
||||
continue
|
||||
}
|
||||
const functionKey = /^f([1-9]|1\d|2[0-4])$/iu.exec(part)
|
||||
if (functionKey) {
|
||||
key = `F${functionKey[1]}`
|
||||
continue
|
||||
}
|
||||
throw new Error('Shortcut contains an unsupported key')
|
||||
}
|
||||
if (modifiers.length === 0 || !key) {
|
||||
throw new Error('Shortcut must contain modifiers and one key')
|
||||
}
|
||||
return [...modifiers, key].join('+')
|
||||
}
|
||||
|
||||
export const shortcutAcceleratorSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.transform(canonicalizeShortcutAccelerator)
|
||||
|
||||
export const globalShortcutSettingsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
accelerator: shortcutAcceleratorSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const globalShortcutSettingsUpdateSchema =
|
||||
globalShortcutSettingsSchema
|
||||
|
||||
export type GlobalShortcutSettings = z.infer<
|
||||
typeof globalShortcutSettingsSchema
|
||||
>
|
||||
|
||||
export type GlobalShortcutRegistrationStatus =
|
||||
| 'registered'
|
||||
| 'disabled'
|
||||
| 'conflict'
|
||||
| 'failed'
|
||||
|
||||
export type GlobalShortcutSettingsSnapshot = {
|
||||
settings: GlobalShortcutSettings
|
||||
defaultSettings: GlobalShortcutSettings
|
||||
platform: string
|
||||
displayAccelerator: string
|
||||
registered: boolean
|
||||
registeredAccelerator?: string
|
||||
status: GlobalShortcutRegistrationStatus
|
||||
}
|
||||
|
||||
export function areShortcutAcceleratorsEquivalent(
|
||||
left: string,
|
||||
right: string,
|
||||
platform: string
|
||||
): boolean {
|
||||
const normalize = (accelerator: string): string => {
|
||||
const parts = canonicalizeShortcutAccelerator(accelerator).split('+')
|
||||
const key = parts.pop()
|
||||
const modifiers = parts
|
||||
.map((modifier) => {
|
||||
if (platform === 'darwin') {
|
||||
return modifier === 'CommandOrControl' ||
|
||||
modifier === 'Command'
|
||||
? 'Command'
|
||||
: modifier
|
||||
}
|
||||
return modifier === 'CommandOrControl' ||
|
||||
modifier === 'Control'
|
||||
? 'Control'
|
||||
: modifier
|
||||
})
|
||||
.sort()
|
||||
return [...modifiers, key].join('+')
|
||||
}
|
||||
return normalize(left) === normalize(right)
|
||||
}
|
||||
|
||||
export type GlobalShortcutUpdateErrorCode =
|
||||
| 'conflict'
|
||||
| 'registration-failed'
|
||||
| 'save-failed'
|
||||
|
||||
export type GlobalShortcutSettingsUpdateResult =
|
||||
| {
|
||||
ok: true
|
||||
snapshot: GlobalShortcutSettingsSnapshot
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: GlobalShortcutUpdateErrorCode
|
||||
snapshot: GlobalShortcutSettingsSnapshot
|
||||
}
|
||||
|
||||
export function formatShortcutForDisplay(
|
||||
accelerator: string,
|
||||
platform: string
|
||||
|
||||
Reference in New Issue
Block a user